Skip to content

Backtest core: one engine for timed sports and prediction exchanges

The plan for backtesting (and tuning) any timed or racing sport against any prediction exchange with one set of code. It is the backtest counterpart of the multi-sport live design: one shared core, and per sport only data (sports/<code>.toml) and a data source. Since 2026-10-11 there is one pricing model for every modelled sport, the one engine, whose inputs and defaults each sport's schema declares.

Status: step 1 done (market kinds, sweep calibration, search report); steps 2–6 proposed. Every step ships off by default, with today's F1 and downhill outputs byte-identical.

What the params-4h search taught us

The first long settings search (2026-09-27; Cloud sweeps): 291 F1 season sweeps, 2026 tuned and 2025 held out.

About F1 on Polymarket

  1. The edge is in the less-watched markets. Winner markets were about as sharp as the model (the best taker lost -379 on them in 2025); head-to-heads were the only kind that earned in both seasons.
  2. The bar to act depends on the market kind. A 10-point edge threshold was the largest robust gain, but head-to-heads still paid at 5 points (hence min_edge_h2h).
  3. Timing is a strategy setting. Pre-weekend entries lost in both seasons; after-quali was mixed.
  4. Takers and makers want different models. Takers a reactive, grid-aware one; the only maker that earned in both seasons ran on gbm and stayed out of markets where it disagreed with the price.
  5. Takers and makers are separate sleeves: their weekly P&L was nearly uncorrelated.

About backtesting (every sport)

  1. A held-out season decides. Many 2026 winners lost in 2025 (the top 2026 combo: +4,442 / -742).
  2. One event can carry a season: the British GP added +900 to +1,600 to every 2026 taker. Report the P&L without the best event.
  3. Monte Carlo noise is as large as many effects (±67 to ±149 across simulation counts). Measure it with seed replicates.
  4. Execution realism can fake an edge. Dropping the volume filter "made" +3k to +7k from stale prices; doubling stakes doubled the losses too. Fill rules, costs and liquidity filters are guards, not tuning knobs.
  5. Identity, not position. A re-analysis renumbered a candidate into a different combo; an analysis run before the last job finished read partial data.
  6. Pricing is the expensive part. Stage prices are cached by model_key, so strategy settings that share prices cost almost nothing.

The design

Both model families already simulate the same thing: a matrix of finishing ranks. That is the seam.

Contract What it is Where Status
Outcome simulations OutcomeSims: entrants, (n_sims × n) classification ranks, finished flags, earlier rounds' ranks, rounds reached, points, groups racinglines/models/outcomes.py done: adapters for the laps core (from_position_sim) and the run-times core (from_timed_runs)
Market kinds One definition per kind gives its fair value from OutcomeSims and its settlement from the official result racinglines/markets/kinds.py done: win, podium, top 10, pole, makes the Final, head-to-head, top constructor; private_book.outcome_for settles through it
Pricing model load, events(data, settings, seasons) (each with its cutoff), history(data, settings), price(hist, event, settings, rng) -> OutcomeSims, results(data, event) for settlement, and the model's own Settings racinglines/models/race_model.py (contract), racinglines/models/model_global.py (the engine) done: one engine, GlobalModel, for every modelled sport; its cores are the former PositionSim (F1), TimedRuns (downhill) and results model, the first two kept runnable as challengers
Walk-forward engine Prices every event from data before it, settles every kind the simulations support, scores them; model-only until a venue is given racinglines/core/walk_forward.py done: racinglines model walk-forward mtb_dh
Stages When new information arrives: a [stages] table per sport (first stage, data lag, the session never traded, early closes) turned into [(label, cutoff)] sports/<code>.toml, racinglines/core/stages.py done: the F1 sweep's stages and the pole market's close come from sports/f1.toml; downhill prices once, "pre-event"
Venues markets(), view(market, t) (public data up to t only), costs, fill model, resolution racinglines/markets/venue_replay.py done: Polymarket (the F1 sweep reads through it) and PrivateBook (a live run's book replayed); model-only is no venue. Kalshi reads what markets/kalshi/ stores in the same tables, per market ticker, with Kalshi's taker fee; the maker replay reads Kalshi through load_event(exchange="kalshi") with its maker fee (Kalshi history)
Search One queue for every sport, with the held-out rule, seed replicates, confirmation and stable ids built in racinglines/pipelines/search*.py done: sport = "mtb_dh" jobs, replicates = N, candidate ids carried into the Lab

Settings. Shared groups (timing, taker, maker, markets, guards: SHARED_SETTINGS) plus the model's own group (MODEL_SETTINGS; downhill's is models/inputs/run_times/settings.py), whose per-sport defaults are the schema's [model.defaults]. Per-kind overrides generalise min_edge_h2h: min_edge_by_kind = "race_h2h=0.05,race_podium=0.08" gives each listed kind its own entry threshold (over min_edge and min_edge_h2h), the params-4h lesson that the edge sits in some kinds and not others. settings_key / model_key keep today's hashing (settings at an unset default are left out), so saved keys never change: every saved sweep's and stage run's key recomputes to what it was.

One engine, schema-declared inputs

Owner, 2026-10-11: "Single global model ... For ALL sports, we will want to be able to handle high-level and/or granular data." There are no sport-specific models, only schemas that declare the model's inputs and defaults (decision log 2026-10-11 "One engine", provisional, staging only). Every modelled sport's [sport] pricing_model is racinglines.models.model_global:GlobalModel (tests/test_one_engine.py guards it):

[model]
inputs = ["results", "qualifying", "grid", "practice", "laps", "sectors"]    # F1
name = "f1_sector_sim"          # model_runs.model of its runs (optional; default: the core's name)
[model.defaults]                # the per-sport defaults of the engine's settings
half_life_days = 120.0
Sport [model] inputs Core Stored as (model_runs.model)
F1 results, qualifying, grid, practice, laps, sectors laps f1_sector_sim
Downhill run_times, practice run_times timed_runs
NASCAR results, qualifying, fastest_lap results global_results
MotoGP results, qualifying results global_results

How the schema drives it, with no if sport == ... anywhere (racinglines/models/inputs/__init__.py):

  • Inputs are layers. Each input names a module: inputs/results.py (finishing positions), inputs/laps.py (lap pace, the sector fit, the finishing model and the Monte Carlo), inputs/practice.py, inputs/grid.py (grid penalties), inputs/laps_pricing.py (the as-of view and price_race), inputs/run_times/ (timed runs).
  • The inputs pick the core. core_for(inputs) takes the core whose required inputs are all declared, the most specific one when several fit: results only → the results core (inputs/results.py); results, qualifying, grid and laps → the laps core (inputs/laps_core.py); run_times → the run-times core (inputs/run_times/core.py). A declared input the core doesn't read is an error, so nothing declared is silently ignored. High-level data (results) and granular data (laps, sectors, run times) both work: a sport declares what it has.
  • Optional inputs gate their settings. An undeclared sectors turns track_features off by default, an undeclared practice turns practice_prior off (laps core) or leaves the timed training runs out (run-times core).
  • Defaults are schema. [model.defaults] holds the sport's settings; F1's and downhill's equal the code's, so every settings key and saved sweep key is unchanged.
  • Lower levels stay runnable. GlobalModel.for_sport(code, core="results") is the engine at the results level (backtest walk-forward f1 --model global), with [model.results.defaults] if any. The engines F1 and downhill had before (PositionSim, TimedRuns) were the same cores with the code's settings; they were retired on 2026-10-19 (decision log). --model rank_logit (F1, NASCAR, MotoGP, downhill) is the one remaining challenger: the results core with a Davidson-Luce Plackett-Luce strength model, off by default (F1-6).

Price-preserving. The cores keep the algorithms they had as separate models, so with the same seed and data the engine reproduces every price: F1 equals the laps core with its code settings and laps_pricing.price_race, downhill the run-times core with its code settings, NASCAR and MotoGP equal the counts origin/staging's GlobalModel gave (tests/test_one_engine.py, synthetic data). Blending the cores (laps into NASCAR and MotoGP, one strength model) is phase 2, after validation.

Limits. The laps, grid and run-times layers read their constants (points, sessions, regulation resets, formats, the penalty file) from the one schema that declares them, found by sports.declaring(input); a second sport declaring laps raises until those constants are bound per sport. The F1 commands that use sweep_settings.Settings directly (racinglines model ... f1, the live book, the signal engine) keep the code's defaults, which the guard test pins to the schema's. The old import paths racinglines.models.position_sim and racinglines.models.timed_runs were removed on 2026-10-19; import racinglines.models.inputs.*.

The walk-forward engine

racinglines backtest walk-forward mtb_dh [--seasons 2025 2026] [--seed N] [--save] (or racinglines model walk-forward mtb_dh --db, which also reads a tidy CSV with --data) runs a sport's pricing model through racinglines/core/walk_forward.py: every completed event is priced from the runs before it with its real start list and format, every market kind is settled from the official result, and the fair values are scored (Brier, log loss, ECE, reliability bins) per kind and per season. The model's settings (racinglines/models/inputs/run_times/settings.py: category, sims, half-life, pace shrinkage, junior weight, training scope, t noise, seed) use the same schema, keys and flags as the F1 sweep's, so the search queue treats a downhill job like an F1 one. --save stores a walk_forward model run whose per-event scores (<kind>_score = −1000 × log loss, higher is better) are the search report's curves.

Check: over the 43 rounds of 2021–2026 the engine's per-rider fair values and outcomes equal walk_forward_season's exactly (5,171 riders × win, podium, top 10, makes the Final), and the tuning table in the downhill model comes out the same through it (makes the Final −0.035 ± 0.006, 6 of 6 seasons; top 10 −0.006 ± 0.006, 5 of 6; podium −0.003 ± 0.002, 6 of 6; win −0.001 ± 0.001, 3 of 6).

Prediction records

With RACINGLINES_PREDICTION_RECORDS=1 (default off: nothing is written and every existing output is unchanged), every saved run also writes long-format records beside race_predictions (engine roadmap, phase E4a): data/runs/records/<model_run_id>/predictions.parquet (root moved by RACINGLINES_RECORDS_DIR; git-ignored like all of data/). One row per run x event x stage x kind x subject:

column meaning
run_id, sport, model_id the model_runs.id, the sport code, the model (f1_sector_sim, timed_runs)
season, event_id, event, stage, cutoff what was priced and as of when (pre_race for a walk-forward, the sweep's stage label for a stage run, the sport's pre label for a forecast)
kind a code from racinglines/markets/kinds.py, the one registry (db/reads.PREDICTION_KINDS is derived from it; the season-long standings kinds have payoff="standings" and are not priced from simulations)
subject, params the athlete id (the group key for a top team) and JSON: {}, {"opponent_id": b} for a head-to-head (each pair once), {"team": g}
fair, se, n_sims the fair value, its Monte Carlo standard error sqrt(p(1-p)/n_sims), the simulations behind it

Writers: run_walk_forward(save=True), save_forecast (forecast and scenario runs) and save_diagnostic (the sweep's stage runs). The simulations archive, sims.npz beside the records (sims-<event>.npz when a run covers several events), is written for walk-forwards and forecasts only, never for stage runs: it lets a kind added later be priced on an old run (records.load_sims(run_id) then markets.kinds.fair). The downhill forecast (mtb_dh forecast --save) does not write records yet: its weekend simulations do not leave forecast_season.

racinglines eval compare RUN_A RUN_B [--kinds k1,k2] [--stage LABEL] [--out DIR] (engine roadmap E5; the MCP tool compare_runs) scores two saved runs on these records, paired by event. It inner-joins the runs on (event, stage, kind, subject, params as canonical JSON) and reports the rows only one run has (never dropped silently); settles each paired row with the walk-forward's own call (markets.kinds.settle on the sport's pricing model's official results; a row that can't settle is counted and left out); scores log loss (the clip of core/calibration.py) and Brier per row; takes each run's mean per event; and per kind, per kind x season and pooled over seasons gives the mean of the per-event differences B - A, its standard error over events (the standard deviation of the differences, one degree of freedom off, over the square root of the number of events) and whether 0 lies outside ±2 SE. Lower is better, so a negative difference favours B; one event has no standard error and is never marked. It prints a Markdown table and writes compare.csv (per event) and compare.md to data/runs/eval/<A>-vs-<B>/. The Lab has no page for it yet.

--diagnostics (the MCP tool's diagnostics=true) adds the pass test's three tables on the same paired, settled rows, and writes buckets.csv, favourites.csv and pole.csv beside compare.csv: reliability in six fixed price buckets (edges 0.05, 0.15, 0.30, 0.50, 0.70; n, mean priced, mean happened, the gap, flagged when it exceeds 5 points on at least 30 rows) per kind, season and pooled for A and B side by side, with a top-bucket pass/fail line per kind and season (pass_top2 in buckets.csv: the two highest-priced buckets with at least 30 rows both within 5 points); the favourite (highest fair per event, for the subject kinds race_win, race_podium and race_top10 only) priced against how often it happened; and, for race_win, how often the pole-sitter won against each run's mean win price for it. The pole-sitter is the entrant with qual_position == 1 in the same results frame the settlement reads (an event with no qualifying order is skipped and counted), and the Lab has no page for these tables.

Venues in a backtest

racinglines/markets/venue_replay.py holds the venues a backtest trades against, each read only as of a moment:

  • Polymarket: the recorded 5-minute prices and trade tape of one weekend. view(market, t) gives the last price within 6 hours, the 24-hour traded volume and whether the venue would take an order (priced strictly inside 0–1, liquid enough); coherent(kind, t) is the multi-outcome sanity check; resolve settles through the market-kind catalogue. The F1 sweep's weekend_markets now reads Polymarket only through it.
  • OG: OG.com's recorded markets (one market per instrument), every stored price counting except an empty book's 0.50, and a flat fee per contract (per_share_fee(), added to a taker's cost per share).
  • Kalshi: the same for Kalshi's recorded markets (one market per ticker). Each exchange class declares its fee schedule (TAKER_FEE, MAKER_FEE: rate × contracts × P × (1 − P), rounded up to the cent), and venue_replay.EXCHANGES maps a venue code to its class; the sweep's maker, the signal engine and the disagreement log read the fees from there. A sweep's venue is its venue setting.
  • PrivateBook.from_run(run_dir): a live run's private book (downhill polls or F1 windows) replayed from its logged quotes and seeds. With the recorded quotes it re-derives the live book exactly; with spread_quoter(half_spread) (or any quoter(quote, inventory)) it replays another quoting rule against the same crowd draws, which is how a maker setting is backtested on a past event.

Check: full 2025 and 2026 F1 sweeps are identical before and after the change (weekends and trades), a synthetic downhill final recorded by live_dh.update replays to the same book (every market's inventory and cash, every taker's budget, the late window), and so does an F1-style run folder. The real Whistler book is checked by tests/test_venue_replay.py where the data bucket's run folder is present.

Adding a sport

This is the backtest step only; the whole sequence (results source, link identity, preflight, app) is Adding a sport.

A sport joins the backtest core with its schema and its data, and no change to the engine, the market kinds, the search or the report:

  1. sports/<code>.toml: its schema, with [sport] pricing_model = "racinglines.models.model_global:GlobalModel" (the one engine), [model] inputs (the data it has, which pick the engine's core) and [model.defaults], its [competition] (where saved runs are filed) and, when it trades through a weekend, its [stages].
  2. Its data, in the shape the declared inputs read (results rows for results, the tidy runs frame for run_times, Measurements for laps). A kind of data no input reads yet is a new input layer and, if no core prices it, a new core in racinglines/models/inputs/ with the pricing-model contract (racinglines/models/race_model.py: load, seasons, events with cutoffs, history, price → OutcomeSims, results, data_through, Settings, plus bind), chosen by inputs, never by sport. A test can still plug in a class of its own as pricing_model (tests/toy_backtest.py).

  3. A season sweep (optional): [sweep] stages = "weekend" with a [replay] table (stages as hours from 00:00 UTC on race day, kinds, group targets) makes the F1 sweep's taker modes and maker variants run on it (racinglines/pipelines/season_sweep.py, racinglines <code> sweep, search jobs kind = "sweep" once [search] jobs lists it), on every exchange of [markets] venues plus OG.com where exchanges/og.toml lists the sport. Its sweep settings are the model's own plus the shared taker, maker and markets settings, with the schema's stage labels, kinds and venues as choices. F1's engine is "sessions" (the session schedule, pipelines/weekend_sweep.py), unchanged. [sweep] stages is only the default stage mode: a sweep's stages setting (--stages, or stages = "weekend" in a search job) picks any mode the schema supports ("sessions" needs [sessions.schedule], "weekend" needs [replay] stages), so the earlier weekend-based results stay reproducible beside a session-aware path; unset, the setting stays out of the settings keys. A sport whose sessions are stored rounds (NASCAR, MotoGP) adds [sessions] time_key, the rounds.extra key holding a session's UTC start, a [sessions.schedule] keyed by round kind, and [stages] with until = "race" and until_fallback_hours (the race start, as hours from 00:00 UTC on race day, where the race round stores none). Its "sessions" mode builds each event's stages from its stored sessions through core/stages.py (a stage before any running, then one after each session), asks the pricing model at every stage with the start list and the sessions run so far (Event.info field and sessions), and trades an event that stores no session time on its [replay] stages instead. GlobalModel reads race results before the event only, so its fair is the same at every stage; only the market and the stages move. Both modes price the start list: the race's entrants less DNS.

Then racinglines backtest walk-forward <code> [--seasons ...] [--save] [its settings' flags] runs it, a queue job with sport = "<code>" searches it (with replicates), and search-report labels it. tests/toy_backtest.py is a complete example: a synthetic running race (data source and a form model in one module) that tests/test_backtest_toy.py takes through all of that, including the command line.

The results level: a sport that is only a schema

A sport whose history is a table of finishing positions declares inputs = ["results"] (plus qualifying / fastest_lap for its side classifications) and the engine prices it with the results core (racinglines/models/inputs/results.py, which model_global.py was before the one engine). Nothing names a sport; race_model.model_class(code) binds the engine to the schema's [model] block.

[sport]
pricing_model = "racinglines.models.model_global:GlobalModel"
[model]
inputs = ["results"]
source = "motogp_api"     # events.source of the results (or the [replay] source)
group = "team"            # optional: results column / results.extra key naming the team (group prior)
prior = "uci_points"      # optional: results.extra key, a rating the entrant had before the race
[model.defaults]          # optional: per-sport defaults for its settings (each needs a decision-log entry)

It is the "fuzzy-data" model: every finish becomes a field-size-free strength (second of 150 is not second of 5), an entrant's strength is their recency-weighted mean shrunk toward a prior by how few starts they have, and a race is a strength-plus-noise draw with a shrunk retirement rate. It needs no laps, no fixed schedule and no full participation, so every sport with price tapes and a results history can be backtested with it, whatever else it has (--model global: the engine at the results level). Settings, hooks and the leakage rule (days strictly before the event) are in the module's docstring. Side by side with the sport's own level:

racinglines backtest walk-forward motogp --model global --seasons 2026
python scripts/validate_global_model.py motogp --seasons 2016 2026 --own-field --own-fill   # own vs global vs a learn-nothing control

A parameter search over it is an ordinary search queue with model = "global" on each job (racinglines/pipelines/search.py docstring): the job's settings are the global model's (with the sport's [model.defaults]), its command is the walk-forward above with --save, it gets its own default-settings baseline per sport and season, and search-report writes one folder per sport and model (<sport>-global/) with the labels below, scored per market kind. python scripts/make_global_sweep.py writes sweeps/global-multi-sport.toml, the multi-sport grid.

python scripts/make_global_sweep.py --confirm <search folder> writes stage 2, sweeps/global-multi-sport-confirm.toml: the finished search's candidates (every sport's candidates.toml) and the default settings, re-run in every season at the report's confirm_sims (8,000, four times the grid's 2,000). It carries the same search name, so one search-report reads both stages and fills the report's confirmed column.

Model against the exchange

racinglines backtest walk-forward <code> --model global --venue polymarket kalshi --out-dir DIR also reads each venue's price for the same market, through the same venue_replay classes the sweeps use (a price counts only if it is under 6 hours old), and scores model and market on the markets that have both. When it reads matters: a sport with a session schedule (F1) is read at the first stage of its [stages], 1 h before any running, when the market knows what the model knows (earlier results); --market-stage 'after Quali' reads a later stage, which shows what the model lacks. A sport without a schedule is read at the event's day (00:00 UTC), which on a race weekend is after qualifying, so it favours the market. Prices pair on the exact market_links keys (race_id, kind, athlete) and never guess: a sport whose links carry no race_id reports "nothing to pair" with the link counts. Output: a per-kind and per-season table, walk_forward_vs_<venue>.csv (one row per pair, with the read time at) and walk_forward_vs_<venue>_scores.csv. Read-only on the database (no --save). It needs the price history the venue replays read, so on the VM run it where the market archive is (data/archive/markets/). The code is racinglines/pipelines/model_vs_market.py; the field is the event's actual starters, so the comparison has a mild lookahead on who started (the same for model and market, which price a field that includes them).

Calibration in every sweep

racinglines strategy sweep f1 --reliability scores our fair value and the exchange's price side by side at every tradeable stage: Brier, log loss and ECE per market kind and stage, and reliability bins with a z per bin (racinglines/core/calibration.py, shared by every sport). Pooled rows count a market once per stage it was tradeable, so they are correlated; compare model with market on the same rows rather than reading the absolute numbers.

Closing-line value in every sweep

Every season sweep, of any sport and venue, also scores its trades against the close: each taker mode's trades and each maker variant's fills go through racinglines/pipelines/clv.py (the racinglines backtest clv rule: the venue's price when the market's deciding session starts, the YES price signed for the side held), and the saved run keeps the aggregate in metrics.clv (by_strategy, by_stage, by_event, undecided). It is an addition, not a setting: no key changes, and every other metric of a sweep is what it was. The owner's go/no-go line for real money is CLV above +2 % over 100 live bets; a sweep's CLV is the backtest's view of the same number.

The search report

racinglines strategy search-report f1 QUEUE.toml turns a finished search into labelled results (racinglines/pipelines/search_report.py):

  • Labels against the default-settings baseline, beyond a noise floor: robust (every season), target only, held-out-led (held-out seasons, target within noise), not better, or no held-out run. A held-out season may run the combo's twin: the same model with switches that are inert that season dropped (variants.for_season, e.g. reset outside a new-regulations year).
  • Noise floor: the largest spread of seed replicates (jobs that differ only in seed, three or more) per strategy family; the configured floor where there are none. Replicates of a combo are averaged.
  • Same-fidelity baselines: a run is compared with the baseline at its own simulation count, seed and rounds.
  • Confirmation at confirm_sims (default 16,000) in every season the label rests on, when run.
  • Concentration: P&L without the best event, and which event it was.
  • Better is not profitable: a combo that beats a losing baseline but still loses money is flagged (loses_money_in), as the params-4h hold strategies were.
  • Stable ids: a candidate is <strategy>-<settings_key> (file candidates/<id>.json); its rank is a field, never its name.
  • Finished jobs only: running or failed jobs are never read.

A downhill job is scored per market kind instead of per strategy: its per-event score is −1000 × log loss (higher is better), the noise floor family is model (±25 unless replicates measure it), and combos are ranked by their gain over the baseline (a score is never positive, so "loses money" doesn't apply). Its report goes to data/runs/search/<name>/mtb_dh/, configured by [report.mtb_dh].

Check: a queue with a short F1 sweep and downhill jobs (the pre-tuning settings in 2026 at three seeds, and in 2025) ran end to end: 10 jobs, both leaderboards, and a downhill report whose noise floor came from the seed replicates (±4 per kind) and which labels the old settings "not better" on the Final, podium and top 10 and "target only" on win and fastest qualifier.

[[job]]
sport = "mtb_dh"               # the downhill model's settings (racinglines/models/inputs/run_times/settings.py)
year = 2026
prior_n = 1.5
replicates = 3                 # this job and its season's baseline at 3 seeds each

[[job]]
venue = "kalshi"               # the maker strategies on Kalshi's recorded tape (`f1 sweep --venue kalshi`);
variant = "gbm"                # judged against a baseline on the same venue (sweeps/kalshi-maker-k.toml)

[report]                       # optional, in the queue file
target = 2026
holdout = [2025]
confirm_sims = 16000
top = 25
noise = { taker = 150, maker = 350, model = 25 }   # used where the search ran no seed replicates

[report.mtb_dh]                # another sport's jobs: the same keys
target = 2026
holdout = [2025]

Plan

Step What Check
1 ✓ Market-kind catalogue; calibration in the F1 sweep; the search report F1 sweeps byte-identical; the params-4h A and C numbers reproduced
2 ✓ PricingModel for both families; the downhill walk-forward through the engine in model-only mode Downhill tuning numbers reproduced
3 ✓ Sport-aware search queue (downhill jobs), replicates = N, stable candidate ids in the Lab A mixed F1 + downhill queue runs end to end
4 ✓ Venue interface: Polymarket behind it, Kalshi mock, private-book venue with the simulated crowd F1 sweep identical; the Whistler book replays as a backtest (synthetic finals here; Whistler where the bucket is)
5 ✓ Stages from the schema; per-kind strategy overrides; settings split Saved F1 settings_keys unchanged (every saved sweep and 1,245 stage runs here); full F1 sweeps identical
6 ✓ "Adding a sport": schema + data source + model wrapper, nothing else A synthetic third sport passes the suite (tests/test_backtest_toy.py)
7 (staging) One engine: GlobalModel the only pricing_model, inputs and defaults in the schema Every price equal to the old engines' (tests/test_one_engine.py); prod waits for the owner

The live engine keeps working throughout; once step 4 lands it can use the same venues and kinds.