Adding a sport (the real steps)¶
What adding NASCAR Cup and MotoGP (results-backed, modeled) and IndyCar, road cycling, Le Mans and SailGP (tape only)
actually took, in the order it has to happen. Each step links to the section that holds the detail; this page is the
checklist, not a second copy. Written 2026-09-30 from the code on main.
The short version: a sport is mostly data: a schema file, plus exchange schemas for any new venue. Code is needed for
one thing only, a results adapter, and not for a tape-only sport: the model is the one engine, configured by the
schema's [model] inputs and [model.defaults] (step 6). A few
places still name the sports in code (listed in step 9).
1. Decide: tape only, or results-backed¶
| Tape only | Results-backed | |
|---|---|---|
[sport] model_family |
"none" |
"global": the one engine, for every modelled sport |
| What you get | exchange links (all unmodeled) and the recorded tape: prices, trades, books |
the same, plus results, a model, replays, forecasts, the demo paper record |
| Needs | a sport schema with [markets.*] |
a cleared results source, a results adapter, [model] inputs in the schema |
| Examples | IndyCar, road cycling, Le Mans, SailGP | NASCAR Cup, MotoGP |
A sport starts tape only and moves to results-backed only when a results source is cleared: its terms of use allow
the use, and it has been probed from the owner's Mac (CLAUDE.md, "Probe a data source from the owner's machine before
any full run"). IndyCar is the counter-example: www.indycar.com's terms forbid automated scraping, and its live timing
feed is INDYCAR's content too, so sports/indycar.toml deliberately has no [results] table (adding one would read as
"cleared"). MotoGP's API terms are restrictive; the owner reviewed them on 2026-09-29 for the private,
non-commercial use, to be reviewed again before any soft launch (the terms string in sports/motogp.toml).
2. Write the sport schema (sports/<code>.toml)¶
Every section the code reads, as data (Database: adding a sport or league is data, not schema):
[sport]:code,name,result_kind,model_family,display_order, and for a modeled sportpricing_model = "racinglines.models.model_global:GlobalModel"(every modelled sport's,tests/test_one_engine.py) with[model] inputsand[model.defaults](step 6).[league]and[competition](code,name,display_name,categories).tests/test_sports.pychecks the required keys for every schema. Optionalaudience = "admin": the web app shows the sport to admins only, and its pages answer 404 for everyone else; recording, pricing, settlement and the CLI are unchanged (Web app: Sports shown to admins only).[markets]:venuesandstandard_kinds(the board's columns;[]for a tape-only sport).[markets.kalshi] series: the Kalshi series ticker prefixes to sync (e.g.KXNASCAR,KXMOTOGP).[markets.polymarket] tags: the Gammatag_slugvalues to page. These slugs are unverified until checked against the live API from the Mac (every schema says so);sync --tags SLUG …overrides them.[identity] resolver: the package underracinglines/sources/whoselinks.pysays which driver and race a market is about (step 4).[results]: the source's host,termsandlimitsfrom the probe (step 3), and the fallback rule.[replay]: the taker replay'ssource(theevents.sourceits model reads),linker,kinds,race_day_offset,stagesandgroup_target(CLI). All a priori; tuning any of them needs a decision-log entry.[data] framesand[live]only when the sport has input frames or a live simulation (Input frames, Live events); NASCAR and MotoGP have neither.
Then add the code to SPORT_CODES in racinglines/sports.py (at the end: the order is the seeding order and keeps
database ids stable) and run racinglines db seed (syncs and ingests also create a sport's reference rows).
A new exchange is exchanges/<code>.toml read by the one generic driver, not new code
(Exchanges); its [sports.<code>] blocks say which sports it lists
(exchanges/og.toml lists F1, NASCAR and SailGP). The exchange's caps go in limits and fail at load, not on the
first live call. Polymarket and Kalshi predate schemas and keep their own packages.
3. Probe the results source from the Mac, then write the adapter¶
A Remote Control session on the Mac makes a small read-only probe: the endpoint answers, the shape matches, paging,
caps and history depth, the terms of use. The responses become test fixtures (tests/fixtures/market/<sport>_*, with
a <sport>_README.txt), the findings go into the schema (limits, terms), and only then does a full run happen on
the VM. Never guess at a live API from the cloud. Worked examples: Data
(NASCAR) and Data (MotoGP).
The adapter is racinglines/sources/<sport>/ with fetch.py (raw files to data/raw/<sport>/, byte for byte, polite
rate) and ingest.py (events, races, rounds, results, athletes), plus a CLI group racinglines/cli/<sport>.py
registered in GROUPS (racinglines/cli/__init__.py). Lessons from the two sports:
- Ingest scheduled events too. A forecast needs upcoming races. NASCAR's ingest stores races still to run as
scheduled; MotoGP's stores finished events only, somotogp forecasthas nothing to price until its calendar is ingested (step 7). - Newest seasons first, and don't let old seasons block new ones. MotoGP's ingest commits per event and stops at the first error; the overnight run now ingests 2025-2026 before 2016-2024 (#100).
- Run ingest where the replays run. The first overnight run lost its NASCAR phase because
nascar ingestandnascar link --applyhad only ever run on the Mac: the VM had 13,599 NASCAR Kalshi links and no results.
4. Link identity: which driver and race each market is about¶
A market link is only usable for a replay, a forecast or the board when it carries a race (market_links.race_id)
and, for a driver contract, an athlete. The replay groups markets by race_id; a link without one is recorded tape and
nothing more.
- The resolver is
racinglines/sources/<name>/links.pywith aLinker(conn)whosefill(rows)setsathlete_id,race_idandparams(kind, season, …) and never raises (racinglines/markets/identity.pyloads it from[identity] resolver). The Kalshi, Polymarket and OG.com syncs call it on every new link of the sport, so the same outcome gets the same identity on every exchange.predictionstaysunmodeledoutside F1. - Links stored before the resolver existed are fixed by a database pass (
racinglines data link nascar [--apply --backup FILE] [--undo FILE], a dry run by default) or identified in memory by the replay (MotoGP). - How races are matched differs by sport. NASCAR matches the race's name within the season (a date alone would
pick the O'Reilly race the day before at the same track); MotoGP maps the Kalshi series (
KXMOTOGPRACEis the Grand Prix winner) and picks the event whose Sunday falls just before the market's close.KXMOTOGP(the championship) gets no race kind;motogp season-replayclassifies it in memory only (Championship markets). - Kalshi discovers a sport's series by the schema's ticker prefixes;
sync --series TICKER …names exact tickers when discovery finds the wrong ones (CLI).
5. Preflight: is the sport ready, before any run¶
Counts are not readiness. Before any long run, check per sport: events stored, races stored, and Kalshi links with a
race (not just links). The overnight run's preflight (scripts/vm/overnight.sh, sport_ready) prints
preflight <sport>: N events, M Kalshi links with a race for every sport in every mode; dry flags a sport with 0 of
either as NOT READY; replay loads the sport's results and identifies its links after its backup, and skips the sport
with a STOP line only if it is still not ready. A failed load is logged, not fatal, so one sport's data problem never
stops the rest (Overnight VM run). Adding a sport there means adding it to
SPORTS and to code_of (sport code to competition code) in that script.
The replay checks readiness per race too: it counts races with markets, markets priced and markets tradeable, flags a
race with no stored price as NO TAPE, and prints NOT TRADED with the reason for a venue with nothing tradeable
(--require-tradeable exits 1). --tape probe asks each exchange about 3 markets before any pull.
6. Model inputs, replay and backtest¶
-
The model is the one engine (owner, 2026-10-11: no sport-specific models, only schemas that declare the model's inputs; Backtest core). The schema declares the data the sport has, high-level or granular, and the engine prices at that level:
[model] inputs = ["results", "qualifying"] # MotoGP; F1: + "grid", "practice", "laps", "sectors"; downhill: "run_times", "practice" source = "motogp_api" # the results core's hooks: events.source, group, prior, sessions, best_lap [model.defaults] # per-sport defaults of the engine's settings (decision-log entries)The inputs pick the engine's core (
racinglines/models/inputs/__init__.py: results, laps or run_times), never the sport's name, and an input the core doesn't read is an error. Data no input reads yet means a new input layer underracinglines/models/inputs/, not a sport model. Thenracinglines backtest walk-forward <code>and the search work without engine changes; an older model can stay runnable as a[sport] challengersentry. - The taker replay (racinglines strategy replay <sport>, CLI) needs only the[replay]section.--tape pull --backup FILEpulls the selected races' tape;--save --backup FILEstores one as-of model run per race, which the board's recent results and the status table read. - The settings grid is the overnight run's read-only grid (scripts/vm/replay_grid.py, runsdata/runs/replay-grid/<sport>/<year>-e<edge>-v<volume>/), ranked by the worse season. A season with no markets at all (MotoGP 2025 on Kalshi) must not crash the ranking or count as a $0 season (#102,sport_paper.pick). - Champion markets (optional):season-replaybehindRACINGLINES_SEASON_REPLAY=1, read-only (Championship markets).
7. Put it in the app: status row, demo paper record, forecast¶
- Status row. With
RACINGLINES_SPORT_STATUS=1(off by default) Markets opens with one row per sport schema, modeled or tape only: race data, exchange markets (links, with a race, open), model, backtests and the demo paper record, each green, amber, red or grey (racinglines/web/sport_status.py, Web app). A new schema gets its row with no code. It is the quickest check that the steps above landed. - Board section. A modeled sport with replay saves but no live forecast gets a board section (recent results with
the model's pre-race price; next races with the exchanges' prices). The race-card queries in
racinglines/web/board.pyfilter on the category codesDRV,MEandRDR, so a sport with another category code needs adding there. - Demo paper record.
racinglines strategy demo-history <sport> --grid data/runs/replay-grid/<sport> --backup FILEstores the replay's trades as the demo accounts' paper positions (in-sample, labelled so; on the VM,vm.sh demo) (Paper trading). The sport has to be insport_paper.SPORTSand in the loops ofscripts/vm/demo_setup.sh. - Forecast.
racinglines model forecast <sport> --save --backup FILEprices the next scheduled races and stores aforecastrun, which the board, race pages and exchange markets read as live prices;--undo RUN_IDremoves it. It needs an upcoming calendar: with no scheduled race stored (MotoGP today) it says so and stores nothing.
8. Standing rules for every step that writes or runs long¶
- Back up before any database write. Every writing command refuses without
--backup FILE, a dump under 24 hours old (link --apply,replay --saveand--tape pull,demo-history,forecast --save), and records adata_changesrow naming it. Add the matching line to Data changes in the same change. The VM scripts back up first and check the dump's trailer. - Progress lines. Owner rule (2026-09-30): any long job, on the cloud, the Mac or the VM, prints a flushed progress
line at least every 5 minutes (run Python with
PYTHONUNBUFFERED=1or print withflush=True: the overnight run's 12:59Z-13:44Z silence was partly Python buffering its output). The overnight runner has per-step lines and a heartbeat (Overnight VM run); a shared helper for searches, replays and demo-history is still open (Roadmap). - Label honestly. Say which numbers are in-sample (a setting picked on the seasons it is then replayed on: the
demo paper record, the settings grid) and which are held out; say whether a price comes from a simple
baseline (NASCAR's and MotoGP's result-only models, uncalibrated season sims) or a validated model; and never
report the debug
buy_allmode as a result. The app shows demo replay, in-sample on every such row, and the status table marks non-F1 grids "indicative, in-sample". Sports and exchanges keeps the per-sport model status. - Tuned settings need a decision-log entry before they are treated as final (F1 roadmap format).
9. The places that still name the sports¶
Schemas carry most of it, but these still list sports in code or scripts, checked on main 2026-09-30. A new sport
that should reach them needs an edit there:
| Where | What |
|---|---|
racinglines/sports.py SPORT_CODES |
every sport with a schema (append at the end) |
racinglines/cli/__init__.py GROUPS, racinglines/cli/<sport>.py |
the sport's CLI group (fetch, ingest, and the shared replay, season-replay, demo-history, forecast from cli/replay_cmd.py) |
racinglines/pipelines/sport_paper.py SPORTS, pipelines/season_replay.py SPORTS |
("nascar", "motogp") |
scripts/vm/overnight.sh SPORTS, code_of, prep |
the overnight run's sports, their competition codes and how their results load |
scripts/vm/demo_setup.sh |
for S in nascar motogp |
racinglines/web/board.py recent_results, next_races |
category codes DRV, ME, RDR |
racinglines/models/inputs/laps.py, grid.py, run_times/model.py |
read their constants from the one schema declaring laps, grid or run_times (sports.declaring): a second sport with those inputs needs them bound per sport first |
racinglines/markets/kalshi/sync.py |
only F1 is classified into model kinds (modeled = sport == "f1"); every other sport's links are unmodeled and get their identity from the resolver |
Related¶
- Database: the multi-sport tables.
- Input frames: a sport's L1 frames.
- Live events: a sport's live simulation.
- Backtest core: a sport in the walk-forward and search.
- Cloud build-out: the schemas-first design.
- Sports and exchanges (status): where each sport stands today.