Gate 1

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.

flowchart TD CFG["strategy config<br/>(STRATEGIES registry)"] --> RB["build_run_config<br/>or run_engine_backtest"] RB --> VEN["BacktestVenueConfig<br/>XNAS / CASH / NETTING / $1M"] RB --> DAT["BacktestDataConfig<br/>ParquetDataCatalog bars"] RB --> FM["FillModel + LatencyModel<br/>(shared constants)"] VEN --> ENG["BacktestEngine"] DAT --> ENG FM --> ENG ENG --> RUN["run()"] RUN --> REC["reconstruct_equity_curve<br/>NLV = cash + sum(qty x close)"] REC --> REP["EquityReport to stats"]
Backtest assembly. The registry drives which instruments load from the catalog; the venue is a CASH/NETTING account; the fill and latency models are shared so both backtest paths, and live, price execution identically.

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.

sequenceDiagram autonumber participant S as Strategy participant L as LatencyModel participant F as FillModel participant V as Venue Note over S: decision uses close[t] S->>V: submit MARKET order (bar t close) V->>L: apply latency (EXEC_LATENCY_NANOS = 1h) L-->>V: eligible at bar t+1 V->>F: fill at bar t+1 close F-->>V: + one adverse tick (prob_slippage = 1.0) V-->>S: OrderFilled at close[t+1] + slippage Note over S: fill price unknown at decision time
The sub-bar latency model pushes every fill to the next bar; the fill model applies a deterministic one-tick adverse move on top of the taker fee. Together they are the slippage model.
src/runners/backtest.py — execution-model constants
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)

Why not the built-in analyzer?

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.

src/runners/equity_curve.py — the source of truth
# 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.

CriterionThresholdGuards against
Run valid (kill-switch not latched)required, checked firstscoring a halted run
Sharpe ratio≥ 1.0weak risk-adjusted return
Sortino ratio≥ 1.2downside volatility
Max drawdown≤ 20%unacceptable peak-to-trough loss
Trade count≥ 100statistically thin samples
Fees + slippage modeledrequiredcostless fantasy fills
No look-ahead in signalsrequiredpeeking at the future
The tripwire sits above the standard

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.

zsh — run Gate 1
$ python -m src.runners.backtest --strategy cross_sectional_momentum_etf
$ python -m src.runners.promote_check --strategy cross_sectional_momentum_etf

References