edgekit

edgekit.engine

Two backtest engines, both obeying the no-look-ahead contract: a strategy sees bars up to and including the closed bar i, and fills happen at the OPEN of the next bar (or gap-aware at bar i). One engine is the research workhorse; the other is the fixed-RR prop-firm simulator.

What's inside. The research path is run_bar_loop, driven by a Strategy (a runtime-checkable Protocol) that emits an EntryIntent; it prices trades in R and returns the canonical trade frame. The two fill helpers it uses — fill_entry and fill_stop — are exposed for reuse. The prop-firm path is run_backtest, configured by an EngineConfig and returning a BacktestResult: fixed reward:risk brackets, next-open fills, an EOD flatten, a daily-loss cap, and an overall equity-floor circuit breaker.

Two engines, two jobs

run_bar_loop — one position at a time, gap-aware fills, pessimistic stops, trailing-channel exits, cost charged in R. This is what the ORB / Keltner / breakout research uses and what the gauntlet consumes.

run_backtest — fixed reward:risk brackets under FTMO / BrightFunded account rules (EOD flatten, daily-loss cap, overall circuit breaker). Priced in pips and dollars for a real account.

The research engine#

EntryIntent#

A strategy's entry decision on bar i. direction is +1 / −1; level is the trigger price; stop_dist is the stop distance in price units — the R denominator. The engine fills gap-aware at max(level, open) for longs, so a gap-through fills at the open, never the stale level.

EntryIntent(direction: int, level: float, stop_dist: float)
FieldTypeMeaning
directionint+1 long, −1 short.
levelfloatThe trigger price (e.g. the breakout level).
stop_distfloatStop distance in price units — becomes the R denominator.

Strategy (Protocol)#

The research-strategy interface consumed by run_bar_loop — a runtime-checkable Protocol. prepare precomputes indicator arrays once (the P dict); entry returns an EntryIntent or None when flat; exit returns an exit price or None while in a position. Concrete implementations live in edgekit.strategy.

class Strategy(Protocol):
    name: str
    def prepare(self, bars) -> dict: ...
    def entry(self, bars, P: dict, i: int) -> EntryIntent | None: ...
    def exit(self, bars, P: dict, pos: dict, i: int) -> float | None: ...
  • name — a label carried onto every trade's tag.
  • prepare(bars) — precompute causal (lagged) indicator arrays once; return them as the P dict.
  • entry(bars, P, i) — called on a flat bar i; return an EntryIntent or None.
  • exit(bars, P, pos, i) — called while in position pos; return an exit price or None.
!Lag inside prepare, not entry/exit
Because entry/exit run on bar i, any indicator they read must already have been lagged in prepare (via edgekit.core.lag). If P holds unlagged arrays, bar i reads its own completed value and the backtest peeks at the future.

run_bar_loop#

The research workhorse. Run a Strategy bar-by-bar, one position at a time, with gap-aware fills, pessimistic stops, and cost charged in R. bars_per_day converts hold-in-bars to days for the swap cost (H4 = 6/day).

run_bar_loop(bars, strategy: Strategy, cost: CostModel | None = None,
             warmup: int = 210, bars_per_day: float = 6.0) -> pd.DataFrame
ParamTypeDefaultMeaning
barspd.DataFrameThe OHLC frame (validated via as_ohlc internally).
strategyStrategyAnything satisfying the Strategy protocol.
costCostModel | NoneNoneFraction-of-price cost model; None → the default CostModel.
warmupint210Bars skipped before trading (indicator warmup).
bars_per_dayfloat6.0Bars per day, converting holds to days for swap cost (H4 = 6).

Returns: the canonical trade DataFrame (from trades_to_frame) — one row per closed trade with net R in r, exit date in date, plus dir and bars_held.

run_bar_loop.py
from edgekit.engine import run_bar_loop
from edgekit.strategy import ORB

trades = run_bar_loop(rth, ORB(or_bars=30, target_r=2.0), warmup=5, bars_per_day=390)
print(trades["r"].sum(), len(trades))   # total R (net-negative), trade count

fill_entry, fill_stop#

The two fill helpers the loop uses internally, exposed for reuse. fill_entry is the gap-aware entry fill (a long gapping up fills at the open, not the stale level); fill_stop is the pessimistic stop fill (a gap-through fills at the worse of stop / open).

fill_entry(level: float, open_price: float, direction: int) -> float
fill_stop(stop: float, open_price: float, direction: int) -> float
  • level / stop — the intended entry trigger / stop price.
  • open_price — the actual bar open being filled against.
  • direction — +1 / −1.

Returns: the actual (pessimistic, gap-aware) fill price as a float.

!Pessimism is the point
These helpers always resolve ambiguity against you: a gap through a long entry fills at the higher open, a gap through a stop fills at the worse of stop/open. An optimistic fill is how a backtest quietly manufactures edge that evaporates live.

The prop-firm engine#

EngineConfig#

Config for the fixed-RR prop-firm engine. rr sets TP distance = rr * stop distance; the loss caps and EOD hour encode the account rules. The property pip_usd equals cost.pip_value_per_lot * lots.

EngineConfig(cost: PipCostModel = PipCostModel(), lots: float = 1.0, rr: float = 3.0,
             min_stop_pips: float = 3.0, max_stop_pips: float = 40.0,
             account_size: float = 100_000.0, daily_loss_cap_pct: float = 4.0,
             overall_loss_cap_pct: float = 8.0, eod_hour_utc: int = 21,
             warmup_bars: int = 100, pip: float = 1e-4)
FieldTypeDefaultMeaning
costPipCostModelPipCostModel()The pips-based cost model (spread/slippage/commission).
lotsfloat1.0Position size in lots.
rrfloat3.0Reward:risk — TP distance = rr × stop distance.
min_stop_pipsfloat3.0Stop is clipped to at least this many pips.
max_stop_pipsfloat40.0Stop is clipped to at most this many pips.
account_sizefloat100_000.0Starting account in dollars.
daily_loss_cap_pctfloat4.0No new entries once the day is down this % of account.
overall_loss_cap_pctfloat8.0Equity-floor circuit breaker: halt below account × (1 − this%).
eod_hour_utcint21UTC hour at which open positions are flattened (EOD).
warmup_barsint100Bars skipped before the strategy is polled.
pipfloat1e-4Pip size in price units.

BacktestResult#

The result bundle returned by run_backtest: the trades frame, the equity curve, the config used, and the safety-rail telemetry.

BacktestResult(trades: pd.DataFrame, equity: pd.Series, config: EngineConfig,
               ambiguous_bars: int, halted_days: list = [], overall_halt_at: str | None = None)
FieldTypeMeaning
tradespd.DataFrameOne row per trade — see columns below.
equitypd.SeriesAccount equity curve (account + realized + floating), NaNs dropped.
configEngineConfigThe config the run used.
ambiguous_barsintCount of bars that touched both SL and TP (resolved as SL).
halted_dayslistDates on which the daily-loss cap blocked new entries.
overall_halt_atstr | NoneTimestamp the equity-floor circuit breaker fired, or None.

The trades frame columns are: entry_time, exit_time, direction, entry, exit, stop_pips, pips, usd, r, bars_held, tag, exit_reason.

run_backtest#

The event-driven fixed-RR prop-firm engine with safety rails. It fills a signal at the next bar's open, sets TP = rr * stop, and if a bar touches both SL and TP it counts as SL (pessimistic). It enforces an EOD flatten, a per-day loss cap (no new entries once breached), and an overall equity-floor circuit breaker. The strategy here is a BracketStrategy — a Callable[[pd.DataFrame, int], Signal | None] polled on the closed bar i when flat.

run_backtest(df, strategy: BracketStrategy, cfg: EngineConfig | None = None) -> BacktestResult
ParamTypeDefaultMeaning
dfpd.DataFrameThe OHLC frame (validated via as_ohlc internally).
strategyBracketStrategyCallable(df, i) → Signal | None, polled on closed bar i when flat.
cfgEngineConfig | NoneNoneEngine config; None → a default EngineConfig().

Returns: a BacktestResult (trades, equity, config, and safety-rail telemetry).

run_backtest.py
from edgekit.engine import run_backtest, EngineConfig
from edgekit.core import Signal

def my_strat(df, i):
    if breakout(df, i):
        return Signal(direction=1, stop_dist=20.0, tag="brk")
    return None

res = run_backtest(df, my_strat, EngineConfig(rr=3.0, account_size=100_000))
print(res.equity.iloc[-1], res.overall_halt_at)
print(res.trades[["exit_reason", "r", "usd"]].tail())
Which engine to reach for
Use run_bar_loop for research and the validation gauntlet — it is priced in R and trailing-exit driven. Use run_backtest only when you need the fixed-RR bracket account simulation (EOD flatten + loss caps) that mirrors a prop-firm evaluation. The R-stream from either can be sized with edgekit.sizing.

See also#