The Backtester
Not a separate simulator with its own opinions: a replayer of the daemon's own cycle over historical days, with the identical compiled strategy code in the middle and a deliberately hostile fill model at the end.
One brain, three bodies
Every strategy implements one interface, decide(MarketState) → TargetWeights, and the same compiled code runs in three hosts. Only the source of the world and the destination of the orders change.
| Host | MarketState source | Fills |
|---|---|---|
| Backtest | Historical snapshots, replayed | Simulated |
| Paper | Live snapshot | Simulated, same fill model |
| Live | Live snapshot | IBKR |
For each simulated day the backtest host assembles the same 06:45-style state the daemon would have built that morning, calls the same strategy, routes orders through the fill simulator, and advances. There is no "backtest mode" inside a strategy; it cannot tell which host it is in, by construction. That is what makes a backtest result evidence about live behavior rather than about a lookalike.

Point in time, by construction
The assembler is the single chokepoint. Strategies have no other data path, so lookahead requires deliberately breaking the architecture rather than merely being careless. For trading day T it slices bars dated strictly before T; the prior close is the last thing the morning cycle could have seen. The guarantee is in the slice, not in a check.
var prev = _calendar.PreviousTradingDay(tradingDay) ?? throw new InvalidOperationException(...); ... var end = UpperBound(bars, prev, symbol); // count of bars with Date <= prev var start = Math.Max(0, end - _options.HistoryBars); var history = new ArraySegment<DailyBar>((DailyBar[])bars, start, end - start); // strictly <= prev — the no-lookahead slice
Anything the assembler cannot build point-in-time is absent with a reason, never approximated. In Phase 2 that is macro, regime, probability and news — each stamped with why (“no regime source yet (Phase 1.5)”) — and absence is a value the strategy must handle. Macro releases will appear on their release date, not their reference date; the replay clock also honors the data-morning hold rule, so a morning the record says was stale does what the daemon would have done: nothing.
Built to kill strategies, not flatter them
Orders fill at the day's open, worse by a haircut; a commission per fill with a minimum; no more than a configured fraction of the day's volume, so a $10M paper account cannot pretend liquidity. The spec's phrase is that the simulator exists to kill strategies, and the first time it makes a result look worse than expected, that is the tool working.
var haircut = bar.Open * _cfg.SlippageBps / 10_000m; var price = order.Side == OrderSide.Buy ? bar.Open + haircut : bar.Open - haircut; var commission = ... Math.Max(_cfg.CommissionMinimum, _cfg.CommissionPerShare * qty);
The rebalancer in front of it turns weights into orders — only the difference, and only where it clears the no-trade band. Sells go first so buys have cash; turnover above the cap is scaled and finished next cycle; buys may not exceed the cash the sells will leave.
The day loop
One day at a time, in this order: dividends — entitlement on the ex-date if held before the open, cash on the pay date; assemble the world as of the prior close; apply the hold rule; call decide(); diff the weights into orders and fill them at today's open; mark to today's close and write the daily state row. The benchmark series starts at the close before the first trading day, the same instant the starting capital is worth exactly itself, so the comparison is not off by one day.
Overfitting discipline
Run enough backtests and something will look great by chance, so the backtester tracks its own multiplicity. Every run of a strategy family is taxed by the trial registry before it starts: the run takes the next trial number from BacktestRuns, and a strategy that "worked" on configuration attempt #37 wears that number on the tear sheet at promotion time.
A hypothesis cannot count its own origin window as evidence. The run request records where the hypothesis was born, and the engine refuses the overlap outright:
throw new InvalidOperationException(
$"REFUSED: the requested range {req.From:yyyy-MM-dd}…{req.To:yyyy-MM-dd} overlaps the hypothesis origin window ... " +
"A hypothesis cannot count its own origin window as evidence (backtester spec §8). Pass --allow-origin-overlap for an exploratory run.");
An exploratory run that overrides it is stamped ORIGIN-WINDOW-OVERLAP on its tear sheet. Two more rules from the spec: a holdout era (draft: the trailing 18 months) is unlocked for promotion evidence at most once per strategy version, and there is no parameter-sweep feature. The API runs one config at a time, on purpose.
The four artifacts
A run writes one versioned set under backtests/{runId}/ — blob first, then the row.
- tearsheet.json — full-period metrics, equity curve and drawdown series, gross versus net, and the caveat stamps:
FRICTIONLESS,SPLIT-ADJUSTED-PRICES,SURVIVOR-UNIVERSE. - trades.jsonl.gz — every simulated fill with its reason, in the schema the live journal will use, so the learning loop reads both with one codepath.
- daily-state.jsonl.gz — positions, exposures, the benchmark index, whether the day was held, which components were absent. The "why did it do that on 2022-06-16?" drill-down.
- manifest.json — config hashes, the fill model, the data snapshot, the engine version, the trial number, and the SHA-256 of the other three. Same manifest, bit-identical rerun: the fact the CI gate stands on.
As built, and the four deviations
The core of the spec exists and passed Gate 2 on 2026-09-03: the contract, the assembler, the bar store, the fill simulator and rebalancer, the day loop, the trial registry, the four artifacts, three strategies (spy-hold, coin-flip, dollar-mag7), and the gate itself as a command, Plutus.Backtester.Console verify. Where the code departs from the text, the spec now says so:
- Strategies see split-adjusted prices, and the simulator applies only dividends, positionally by pay date. Returns are identical either way, splits never need positional handling, and one class of bugs is gone. Every tear sheet is stamped
SPLIT-ADJUSTED-PRICES. - "Cash earns the BIL-proxy rate" is present as a config, defaulted to 0 until a rate series is wired. Gate 2 was frictionless on purpose, and every such run is stamped
FRICTIONLESS. - The latency penalty and collars are not built; nothing in Phase 2 is tactical.
- Regime conditioning and the tournament are not built; they need the GMI ingest and more than three strategies.
Twelve contract tests
Twelve tests in Plutus.Tests pin the contract rather than the results: the three strategies are deterministic across fifty calls; TargetWeights rejects weights over one and negative weights; config documents are strict and hash stably; the simulator applies the haircut, the minimum commission and the volume cap; a dividend lands on its pay date and the same request reproduces the same three SHA-256s; the engine refuses a run inside its origin window; the calendar knows 2001-09-11 was closed and refuses a year it does not cover.
One test is there to cheat. It asks the assembler for June 16, then March 2, then December 31 — deliberately out of order, to defeat the cursor that makes the daily march O(1) — and asserts on every call that no bar in the history is on or after the day requested. The assembler notices the non-monotonic call and falls back to a fresh scan. That fallback exists because of this test.
BacktestAssembler.cs— the point-in-time sliceFillSimulator.cs— fills and the rebalancerBacktestEngine.cs— the trial registry, the day loop, the artifactsTearSheetBuilder.cs— the metricsPlutus.Tests/ContractTests.cs— the twelve tests
- Regime-sliced tear sheet — needs the GMI ingest
- Tournament and correlation matrix — needs more strategies
- Latency penalty, collars — nothing tactical yet
- Cash yield — config exists, series does not