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.
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)| Field | Type | Meaning |
|---|---|---|
direction | int | +1 long, −1 short. |
level | float | The trigger price (e.g. the breakout level). |
stop_dist | float | Stop 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'stag.prepare(bars)— precompute causal (lagged) indicator arrays once; return them as thePdict.entry(bars, P, i)— called on a flat bari; return anEntryIntentorNone.exit(bars, P, pos, i)— called while in positionpos; return an exit price orNone.
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| Param | Type | Default | Meaning |
|---|---|---|---|
bars | pd.DataFrame | — | The OHLC frame (validated via as_ohlc internally). |
strategy | Strategy | — | Anything satisfying the Strategy protocol. |
cost | CostModel | None | None | Fraction-of-price cost model; None → the default CostModel. |
warmup | int | 210 | Bars skipped before trading (indicator warmup). |
bars_per_day | float | 6.0 | Bars 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.
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 countfill_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) -> floatlevel/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.
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)| Field | Type | Default | Meaning |
|---|---|---|---|
cost | PipCostModel | PipCostModel() | The pips-based cost model (spread/slippage/commission). |
lots | float | 1.0 | Position size in lots. |
rr | float | 3.0 | Reward:risk — TP distance = rr × stop distance. |
min_stop_pips | float | 3.0 | Stop is clipped to at least this many pips. |
max_stop_pips | float | 40.0 | Stop is clipped to at most this many pips. |
account_size | float | 100_000.0 | Starting account in dollars. |
daily_loss_cap_pct | float | 4.0 | No new entries once the day is down this % of account. |
overall_loss_cap_pct | float | 8.0 | Equity-floor circuit breaker: halt below account × (1 − this%). |
eod_hour_utc | int | 21 | UTC hour at which open positions are flattened (EOD). |
warmup_bars | int | 100 | Bars skipped before the strategy is polled. |
pip | float | 1e-4 | Pip 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)| Field | Type | Meaning |
|---|---|---|
trades | pd.DataFrame | One row per trade — see columns below. |
equity | pd.Series | Account equity curve (account + realized + floating), NaNs dropped. |
config | EngineConfig | The config the run used. |
ambiguous_bars | int | Count of bars that touched both SL and TP (resolved as SL). |
halted_days | list | Dates on which the daily-loss cap blocked new entries. |
overall_halt_at | str | None | Timestamp 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| Param | Type | Default | Meaning |
|---|---|---|---|
df | pd.DataFrame | — | The OHLC frame (validated via as_ohlc internally). |
strategy | BracketStrategy | — | Callable(df, i) → Signal | None, polled on closed bar i when flat. |
cfg | EngineConfig | None | None | Engine config; None → a default EngineConfig(). |
Returns: a BacktestResult (trades, equity, config, and safety-rail telemetry).
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())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#
- edgekit.core —
Signal,Trade, and the R-multiple convention. - edgekit.strategy — concrete
Strategyimplementations (ORB, Keltner…). - edgekit.costs — the
CostModel/PipCostModelthe engines charge in R. - edgekit.validation — the gauntlet that consumes the trade frame.