Backtest (in-sample)
Run the strategy over history under a deliberately pessimistic execution model, measure it from a reconstructed equity curve — not the engine's cash analyzer — and hold it to a hard set of criteria. A halted run is not a result.
In plain terms
Play the strategy against the past, but pretend you're a realistic, slightly unlucky trader: your orders fill on the next bar, always a tick worse, and you pay fees every time.
Then grade the result on real portfolio value — cash plus what your holdings are worth — and only pass strategies that clear every bar of the report card.
Technically
backtest.py builds a BacktestEngine on an XNAS venue
(CASH account, NETTING OMS, $1M start). A LatencyModel defers
fills one bar; a FillModel charges one adverse tick. Performance comes from
reconstruct_equity_curve() (NLV), never analyzer.returns().
Assembling the backtest
A strategy entry in the STRATEGIES registry resolves to bar types + a config.
Both the high-level (build_run_config) and low-level (run_engine_backtest)
paths wire the same venue, data, and cost models via shared constants.
The execution model (no look-ahead)
A decision made on bar t's close cannot know its own fill price — because the order fills on bar t+1's close, one adverse tick worse. This is what makes the backtest honest and keeps backtest and live identical.
EXEC_LATENCY_NANOS = 3_600_000_000_000 # 1 hour -> fill defers to next bar close FILL_PROB_SLIPPAGE = 1.0 # one adverse price-increment per fill FILL_RANDOM_SEED = 42 # deterministic backtests # Venue: XNAS · AccountType.CASH · OmsType.NETTING · starting_balances=["1_000_000 USD"]
Honest measurement (NLV)
On a CASH account, analyzer.returns() is the percent change of the cash balance —
buying the book reads as a −54% day, liquidating as +115%. And positions_closed()
undercounts round trips ~80× under NETTING. Both silently fabricated results before 2026-07-23.
Instead, reconstruct_equity_curve() rebuilds net liquidation value from the account and
fill streams, and every statistic — Sharpe, Sortino, drawdown, trade count — is computed from it.
# Net liquidation value at each day: NLV(t) = cash(t) + sum( qty_i(t) * close_i(t) ) # Sharpe / Sortino / max_drawdown / trade counts all derive from this series.
Gate 1 criteria
A strategy must satisfy all of these to advance. The "run valid" check is evaluated first: if the drawdown kill-switch latched mid-window, the run halted and its tidy stats are meaningless.
| Criterion | Threshold | Guards against |
|---|---|---|
| Run valid (kill-switch not latched) | required, checked first | scoring a halted run |
| Sharpe ratio | ≥ 1.0 | weak risk-adjusted return |
| Sortino ratio | ≥ 1.2 | downside volatility |
| Max drawdown | ≤ 20% | unacceptable peak-to-trough loss |
| Trade count | ≥ 100 | statistically thin samples |
| Fees + slippage modeled | required | costless fantasy fills |
| No look-ahead in signals | required | peeking at the future |
The kill-switch fires at 0.25 drawdown — above the 20% Gate 1 limit, below anything
catastrophic. Set below the standard it would just be the drawdown gate applied twice, killing
strategies that were still performing acceptably.
$ python -m src.runners.backtest --strategy cross_sectional_momentum_etf $ python -m src.runners.promote_check --strategy cross_sectional_momentum_etf
References
- src/runners/backtest.py —
STRATEGIES,build_run_config,run_engine_backtest, execution-model constants. - src/runners/equity_curve.py —
reconstruct_equity_curve,sharpe_ratio,max_drawdown,match_round_trips,EquityReport. - src/runners/promote_check.py —
check_gate1,check_auto_rejections,run_promote_check. - docs/gates.md — the full Gate 1 checklist and the "a halted run is not a result" rule.