# racinglines
> racinglines turns official race timing into win, podium, head-to-head and championship odds for race-sport prediction markets (Formula 1 on Polymarket and Kalshi, UCI downhill mountain biking, NASCAR Cup and MotoGP), priced strictly as of any moment and tested on real market tapes. These docs cover the data pipelines, the pricing models and their evaluation, market making and paper trading, the sportsbook slip schema, the web app and MCP server, the CLI (with a synthetic kart demo pack that runs offline), and how the production VM is run.
---
# racinglines
> Fair prices and market-making tools for race-sport prediction markets, priced strictly as of any moment and tested on real market tapes.
**Tools for an edge in race-sport prediction markets.** racinglines turns
official timing into win, podium, head-to-head and championship odds, priced
strictly as of any moment and tested on real market tapes, and gives makers and
takers the tools to find and size an edge inside the existing markets. Two
sports mark the ends of the range we build for:
- **Formula 1**, the big end: priced against Polymarket, with live paper trading
from October 2026;
- **UCI downhill** mountain biking, the small end: modelled and forecast, waiting
for an exchange to list it.
New sports qualify when their audience and expected market liquidity sit between
the two. **NASCAR Cup** and **MotoGP** are the first: results, market links on
Kalshi, Polymarket and OG.com, and a simple result-only model in replay, with no
edge yet that held up in a second season. Where every sport stands on every
exchange: [Sports and exchanges](coverage.md).
## Start here
- :material-rocket-launch: **Quickstart**
---
Install, build a synthetic demo store and run the whole pipeline in about 5 minutes. Every command is shown
with the output it really printed.
[:octicons-arrow-right-24: Quickstart](quickstart.md)
- :material-web: **The app**
---
[racinglines.bet](https://racinglines.bet): Markets, Trade, History and Lab. Click **Try the demo** to look
around, or sign up for the fantasy league alpha.
[:octicons-arrow-right-24: Web app](webapp.md)
- :material-console: **The CLI**
---
One command, task first: `racinglines data | model | strategy | markets | book | live | db ...`, with
`--demo` on any of them.
[:octicons-arrow-right-24: CLI reference](cli.md)
- :material-robot: **MCP: ask Claude**
---
The same data and simulations as typed tools, local over stdio or hosted at `mcp.racinglines.bet`, for
Claude Code, Claude Desktop or the CLI.
[:octicons-arrow-right-24: MCP server](mcp.md)
## At a glance
| | |
|---|---|
| ๐๏ธ **129 F1 races backtested** | 2021 to Azerbaijan 2026. Each race is priced before practice, before qualifying and after qualifying. After qualifying, top-10 Brier is **0.149** against 0.163 for a grid-only guess; the best variant also beats it on win and podium. |
| ๐น **Every Polymarket F1 race weekend, replayed** | 38 weekends (2025 and 2026) on the real prices and trade tape. The default maker made **+$751** on $8,969 filled in 2026. |
| โ๏ธ **1,161 strategy combinations searched** | A 4-hour cloud search found two setups that held up in both seasons: **A** (a taker, +$1,232 in 2026 / +$1,237 in 2025) and **C** (a maker, +$653 / +$835, the best Sharpe of any combination). |
| ๐ก **Live paper trading** | A and C run on every F1 weekend Polymarket lists, through 2027, stage by stage, with heat ratings, alerts and paper positions, through the same code as the backtest (checked trade for trade). Polymarket lists F1 race markets again: production's database holds them for every 2026 round from Italy (6 Sep) to Brazil (8 Nov), checked 2026-10-14. |
| ๐ด **Live events** | The Whistler downhill final (27 Sep 2026) followed live from UCI timing: rank probabilities after every update, a maker re-quoting every rider, 1,000 simulated private-book takers and a hype-picking demo taker, P&L by venue. Live events and private books are admin-only while the fantasy alpha runs ([Alpha layout](alpha-layout.md)). |
| ๐ **Three exchanges, eight sports** | Polymarket, Kalshi and OG.com (OG.com read-only, from one schema file). F1 is the only sport with a real model and a backtest that held in a held-out season (on Polymarket). NASCAR on Kalshi: 2026 **+$4.2k to +$5.5k** in the taker replay but **โ$242 to โ$390** in 2025 in every setting, on spiky last-trade prices, so not robust. MotoGP: race win only, no held-out season. Both show to every signed-in user (MotoGP again since 2026-10-14). Road cycling is priced by the same engine, for admins only. IndyCar, Le Mans and SailGP: recorded tapes, no model. Of the 3 ร 3 grid (F1, NASCAR, MotoGP ร Polymarket, Kalshi, OG.com), 8 cells hold data; only MotoGP on OG.com is empty (OG.com doesn't list it). [Status matrix](coverage.md#the-status-matrix). |
| ๐ฎ **Fantasy league alpha, open** | Every signed-in account gets F$1,000 of fantasy money: trade F1 markets by hand on the recorded order books, or follow one of five strategy styles, and climb the leaderboard. No real money, nothing reaches an exchange. [Alpha layout](alpha-layout.md), [Fantasy trading](fantasy-trading.md). |
| ๐ต **43 downhill World Cup rounds** | 2021โ2026, walk-forward. Error on who makes the Final is **38% lower** than a uniform guess (0.127 vs 0.204); the actual winner got **7.1%** on average, against ~1%. |
| โ
**Checks in seconds** | `racinglines check` runs 21 checks (code, every data source, database) in ~10 s; the regression suite runs 1,552 tests (2026-10-14) on pinned public fixtures and golden outputs. |
The goal isn't to out-predict the market everywhere: on race-winner odds
Polymarket is about as sharp as the model (sharper after qualifying, and in 2025
at every stage). The tools act only where the gap is large, and quote where it's
safe. Nothing is traded with real money until paper trading has validated the
backtests. The pages below have the details.
## Try it
1. **Open [racinglines.bet](https://racinglines.bet)** and click **Try the demo** (it signs in as the demo
maker), or sign up for the fantasy league alpha. **What is this?** on the same page opens
[Racinglines 101](https://racinglines.bet/racinglines101), a plain-language introduction. Signed in, the app has
four tabs:
- **Markets:** the prediction markets now, with our fair price against each venue for every sport you can see
(F1, NASCAR, MotoGP);
- **Trade:** fantasy trading in F$ on the recorded order books, one immediate-or-cancel order at a time (F1 only);
- **History:** your F$, every trade and order, your strategy's trades and the leaderboard;
- **Lab:** follow a strategy style and size it, its track record and positions, and the backtests, Edge Finder
and replays.
The demo accounts can look but not trade (sign up to trade). Their track records are replays of real weekends,
and nothing a visitor changes in a demo session is kept. The
[quickstart replay](https://racinglines.bet/docs/quickstart/term) plays the command line in the browser, no
sign-in needed.
2. **Run it locally**, then check the setup (about 10 seconds, nothing
downloaded):
```
python3.14 -m venv .venv && source .venv/bin/activate # Python 3.11+
pip install -r requirements.txt && pip install -e . # dependencies + the `racinglines` command
racinglines check # code, data endpoints, database (~10 s)
```
The demo ([Quickstart](quickstart.md), each command with its real output): the real commands on a synthetic Mario
Kart season, a separate database built on first use (needs Postgres: `docker compose up -d`):
```
racinglines model backtest kart --demo # every past race priced as of before it, scored
racinglines model forecast kart --demo --save # the rest of the season and the championships
racinglines live run kart/demo-2026-09 --demo --simulate --no-fetch # a live private book, 100 simulated takers
```
Then the full pipeline:
```
docker compose up -d # PostgreSQL on localhost:5433 (or a conda Postgres: see Database)
racinglines db init # tables + reference data
racinglines data fetch f1 && racinglines data ingest f1 # F1 sessions (FastF1) -> database
racinglines model forecast f1 --save # live F1 prices: remaining races + championships
racinglines markets sync # Polymarket's F1 markets
racinglines data ingest mtb_dh # downhill event files -> database
racinglines model forecast mtb_dh --db --save # downhill season forecast
racinglines strategy profiles f1 --assign-demo # strategy profiles A and C for the demo accounts
racinglines strategy signals f1 # live paper signals (outside a race weekend: a no-op)
ADMIN_PASSWORD=... racinglines web # the app on http://127.0.0.1:8000
```
## What it does
```
official timing โโ> PostgreSQL โโ> as-of model โโ> simulated weekends โโ> fair prices โโ> markets
(FastF1, (one multi- (only sessions & seasons (win, podium, (Polymarket;
ChronoRace) sport schema) ended before (thousands of runs) h2h, pole, paper trading,
the cutoff) titles) replays, Lab)
```
- **Collects** official timing into one multi-sport **PostgreSQL** schema:
- F1 via FastF1: every practice, qualifying, sprint and race session since
2020;
- downhill split timing from ChronoRace, 2021โ2026.
Exchange history (prices, trades, order books) goes to a **Parquet archive**.
- **Models** each sport:
- **F1:** a sector-aware car model shared by both team drivers, driver
offsets, a practice-pace prior, and a grid/overtaking finishing model, with
challenger variants behind switches;
- **Downhill:** a log-time model of each rider's pace, consistency and
crash/DNF rate.
- **Simulates** race weekends and seasons thousands of times: win, podium,
top 10, pole, head-to-head and team markets, plus championships.
- **Prices strictly as of a moment:** only sessions that have ended before the
cutoff are used, and a leakage guard enforces it. Backtests, diagnostics, the
live forecast and the signal engine all call the same function.
- **Tests strategies on real market data:** maker replays, taker strategies that
re-trade after every session, and a season-long championship strategy, all on
Polymarket's recorded prices and trades; settings searches run locally or in
the cloud.
- **Paper trades live:** strategy profiles (A, a taker; C, a maker) produce
signals at every stage of a race weekend, with heat ratings for takers,
alerts, paper fills and positions. No real orders are placed.
- **Web app** ([racinglines.bet](https://racinglines.bet)), laid out for the fantasy league alpha:
- **Markets:** our fair price against each venue for each sport's next races;
- **Trade** and **History:** fantasy trading in F$ on the recorded books, every trade, and the leaderboard;
- **Lab:** follow a strategy style, its track record and positions, and the Edge Finder, backtests, scenario
forecasts, diagnostics and strategy replays with your own settings.
- **Reaches the hosted server from the command line:** `--remote` runs the book commands and Lab jobs on
racinglines.bet, `racinglines remote` calls any hosted tool, and `racinglines data pull SPORT` copies one sport's
shared data into your own database ([MCP server](mcp.md#more-commands-on-the-hosted-server-remote-racinglines-remote-data-pull)).
Remote access is in testing (#aintcare mode): the review of venue and data-feed terms is pending, so what the
server returns is for testing only, and every remote command prints that warning.
## Results
| Question | Answer | Details |
|---|---|---|
| How accurate is the F1 model? | Beats uniform everywhere and a grid-only guess on top 10; the best variant also beats it on win and podium after qualifying. | [F1 evaluation](f1-evaluation.md) |
| How does it compare with Polymarket? | Close, which is the point: in 2026 better mid-weekend on race winners, worse after qualifying; in 2025 the market is sharper at every stage. The edge comes from the few large gaps. | [F1 evaluation โ Against Polymarket](f1-evaluation.md#against-polymarket) |
| Can it make money on Polymarket? | In backtests, yes: the default maker +$751 over 2026; profiles A and C made money in both 2025 and 2026. Live paper trading is the test. | [Market making](market-making.md), [Paper trading](paper-trading.md) |
| Which model, traded which way? | A: the update taker on `gridq+pretrain+reset`, 10-point edges (5 on head-to-head), no pre-weekend stage. C: the conservative maker on `gbm`, 5-point filter, 25-share quotes. | [Market making โ Cloud settings search](market-making.md) |
| What does F1 look like now? | The next race's favourites and the championship odds. | [F1 forecast](f1-forecast.md) |
| How accurate is the downhill model? | Better than uniform on podium and make-Final in every season; win odds too flat. | [Downhill evaluation](evaluation.md) |
| Who wins the 2026 downhill title? | [Downhill forecast](forecast.md) | |
| What about NASCAR, MotoGP, Kalshi and OG.com? | Data and a simple result-only model for NASCAR and MotoGP; no strategy outside F1 on Polymarket has passed a held-out season. OG.com is readable and replayable, not tradable. | [Sports and exchanges](coverage.md) |
## Explore, one layer at a time
Each layer builds on the one before, so start at the top and stop when you
have what you need. F1 and downhill each have a model, an evaluation and a
forecast page, and F1 also has market pages. NASCAR, MotoGP and the tape-only
sports are summarised on one status page.
| Layer | Page | What's there |
|---|---|---|
| 0. **Status** | [Sports and exchanges](coverage.md) | Every sport ร exchange: data, model, backtest and its caveats, live or paper, and the gaps |
| 1. **Data** | [Data](data.md) | Sources, what was downloaded, the on-disk layout, and what's in git |
| | [Parser](parser.md) | Downhill results formats and the tidy schema |
| | [Database](database.md) | The multi-sport schema, ingest, stored runs, the snapshot, and what stays in Postgres vs Parquet |
| | [Exchanges as schemas](exchanges.md) | OG.com read from one TOML file: sync, tape, fair-price indicator, buy-all check |
| 2. **Formula 1** | [F1 model](f1.md) | Car and driver pace, practice prior, finishing model, as-of pricing |
| | [F1 evaluation](f1-evaluation.md) | 129-race backtest, model variants, against Polymarket |
| | [F1 forecast](f1-forecast.md) | The next race and the championships |
| | [Market making](market-making.md) | Maker replay, season sweeps, the model ร strategy matrix, the cloud settings search, season strategy |
| | [Paper trading](paper-trading.md) | Profiles A and C, the signal engine, heat, alerts, the demo accounts |
| 3. **Downhill** | [Downhill model](model.md) | Log-time rider model, weekend formats, season simulation |
| | [Downhill evaluation](evaluation.md) | 43-round walk-forward, per season and per event |
| | [Downhill forecast](forecast.md) | The live 2026 title projection |
| 4. **Use it** | [Quickstart](quickstart.md) | The whole pipeline on a synthetic demo store, every command with its real output |
| | [Web app](webapp.md) | Pages and routes, roles, demo accounts and sessions, the Lab, the private book, Polymarket orders |
| | [CLI reference](cli.md) | Every group and command, its `-h` output and options |
| | [MCP server](mcp.md) | The data and simulations as typed tools for Claude Code, Claude Desktop or the CLI |
| | [Live events](live-events.md) | A race followed live: the live model, the maker's quotes, the private book's crowd, replay logs |
| 5. **Operate** | [VM deploy](vm-deploy.md) | How racinglines.bet is run: deploys, timers, staging, backups |
| | [Cloud sweeps](cloud-sweep.md) | Running settings searches on a cloud machine |
| | [Testing](testing.md) | Quick check, regression suite, fixtures, golden outputs |
| 6. **Roadmap** | [Roadmap](todo.md) ยท [F1 live test](f1-live-roadmap.md) ยท [F1 roadmap](f1-roadmap.md) | What's next, in priority order; this week's F1 live test; the phased F1 plan with its ground rules |
| | [Project history](history.md) ยท [F1 reference](f1-reference.md) | How it got here, and the model and market-making ideas behind the F1 plan |
## Collaborators and beta testers
We're looking for beta testers and collaborators! Try the app at
[racinglines.bet](https://racinglines.bet), and if you'd like to help, test, or just say hi, drop us a line at
[hello@racinglines.bet](mailto:hello@racinglines.bet?subject=racinglines%20beta%20tester%20%2F%20collaborator).
We'd love to hear from you. The [contributor guide](contributing.md) says how to get set up.
## Building these docs
The top-level `README.md` is the short version. Parts of it are generated
from sections of these pages (see [Docs & README](cli.md#scriptsbuild_readmepy)). The command outputs on these
pages are captured by `scripts/docs/capture_examples.py` on the kart demo store (`scripts/docs/examples.toml`
lists them) and embedded as snippets, never typed by hand.
```
pip install -r requirements-docs.txt
mkdocs serve # live preview at http://127.0.0.1:8000
mkdocs build # static HTML in site/
python scripts/build_readme.py # refresh README.md from sections tagged in docs/
```
A pre-push hook blocks a push unless the docs build with `--strict`, the README
matches the docs, and no data outside the allow-list is tracked. Enable it once
per clone:
```
git config core.hooksPath scripts/hooks # skip once with: git push --no-verify
```
On very new Pythons (e.g. 3.14), `watchdog` may have no prebuilt package and fail
to build. It's only needed for `mkdocs serve`'s live reload. Either use
Python โค3.13, or install with `pip install --no-deps mkdocs mkdocs-get-deps`
plus mkdocs's other dependencies, then run `mkdocs build` and open
`site/index.html`.
## Repo layout
| Path | |
|---|---|
| `racinglines/cli/` | The `racinglines` command, task first: `data`, `model`, `strategy`, `markets`, `book`, `live`, `db`, `web`, `mcp`, `remote`, `check` and more ([CLI reference](cli.md)) |
| `racinglines/sources/` | Data sources: `fastf1/` (fetch, ingest), `chronorace/` (download, parse, ingest), `http.py` (paced, retried requests for every source) |
| `racinglines/models/` | The one engine, `model_global.py` (every modelled sport's pricing model, its inputs declared in `sports/.toml` [model]), and its input layers in `inputs/`: `laps.py`, `laps_pricing.py`, `practice.py`, `grid.py` (F1: car/driver pace, sectors, practice prior, race and season pricing), `run_times/` (downhill: log-time model, weekend and season simulation), `results.py` (finishing positions); `variants.py`, `props.py`, `evaluate.py` beside them |
| `racinglines/markets/` | Exchanges and books: Polymarket `sync`/`trade`/`links`, Kalshi `client`/`sync`/`trade`, the Parquet `store`, `venues`, the `private_book`, new-market and signal `alerts`, and `strategies/` (maker replay, weekend taker, season) |
| `racinglines/pipelines/` | Multi-stage runs: `weekend_sweep`, `sweep_settings`, `search` (settings searches), `season_strategy`, `season_checkpoints`, `profiles`, `signals` (live paper trading), `story` and `demo_history` (the demo accounts) |
| `racinglines/db/` | Database: models, reads, queries, shared ingest helpers, the `snapshot`, registry of sports and venues |
| `racinglines/web/` | The web app (`racinglines web`): Markets, Trade, History, Lab and admin under the alpha layout (`layout.py`; the older pages are archived behind it); demo sessions (`demo.py`); old-URL redirects (`legacy.py`) |
| `racinglines/testing/` | Synthetic data and the checks behind `racinglines check` |
| `sports/*.toml`, `racinglines/sports.py` | Sport schemas: what differs between sports (sessions, points, categories, venues, markets), read by the code |
| `racinglines/paths.py` | Where data lives (`data/raw`, `data/archive`, `data/runs`, `data/cache`) |
| `migrations/`, `alembic.ini` | Alembic schema migrations |
| `tests/`, `scripts/fetch_test_fixtures.py` | Regression suite on fixtures built from public sources, plus golden outputs (see [Testing](testing.md)) |
| `scripts/` | `build_readme.py`, `signals_parity.py`, `cloud/` (prepare and start a cloud run), launchd agents for the recorder and the signal engine, `hooks/pre-push` |
| `sweeps/` | Settings-search queues (TOML) |
| `docker-compose.yml` | Local PostgreSQL 17 (port 5433) |
| `pyproject.toml`, `requirements.txt`, `requirements-docs.txt` | Package (`pip install -e .` for the `racinglines` command); pinned dependencies (pipeline / docs site) |
| `data/` | Downloads, the market archive, run outputs and caches; only an allow-listed minimal set is in git (see [Data](data.md)) |
| `docs/`, `mkdocs.yml` | Documentation site (MkDocs) |
| `pitch.html` | The pitch deck, served at `/pitch` (screenshots in `racinglines/web/static/pitch/`) |
## Internal book
Day-in-the-life (DITL) reports, the golden rules for building and deploying taker strategies, house rules, the
project's history and the full report index live in an internal book at `/docs/internal/` (admins only). Access can be
requested via hello@racinglines.bet.
!!! warning "Downhill points are placeholders"
Downhill championship points use approximate tables, not the official UCI
scale. Every downhill number measured in points (expected points,
standings, champion odds) is approximate until
[points validation](todo.md#points-validation) is done. Win, podium, top-10
and make-Final probabilities don't depend on points.
---
# Quickstart
> The whole pipeline on synthetic data, with the real commands: install, build a demo store, backtest, forecast, run a live private book and price a sportsbook's lines. About 5 minutes once Postgres is up.
Every command below is a normal `racinglines` command with `--demo` added. `--demo` switches the data source to a
**demo store** (a separate database and data folder filled from a [data pack](demo.md)) and changes nothing else.
The default pack, `kart`, is a synthetic Formula 1 world with a Mario Kart grid: every pace, result and price is
invented; play money, nothing is traded anywhere.
!!! note "What you need"
Python 3.11 or newer and a PostgreSQL 16+ server. No API key, no account and no network access are needed for
the demo. The hosted way in needs only Python and a racinglines.bet account with MCP access (in testing).
!!! tip "Installing from PyPI?"
`pip install "racinglines[db]"` is the shortest way in: the
[package README](https://pypi.org/project/racinglines/) walks through the same demo from the installed package, with
the database in one `docker run`. The package is F1, the downhill sim, the kart demo and the web app;
`[db]` runs every command, `pip install "racinglines[mcp]"` adds the MCP server and the hosted way in (`--remote`,
`racinglines remote`, `data pull`), and `pip install "racinglines[web]"` adds the web app. The NASCAR, MotoGP, road cycling and weather commands on this page are not in it.
Prefer to watch first? The same commands, recorded, play in the browser at
[racinglines.bet/docs/quickstart/term](https://racinglines.bet/docs/quickstart/term), no sign-in needed.
## Which way in?
=== "Local (demo store), recommended"
Everything runs on your machine. The demo store is built on first use (about a minute) and every command works
on it.
### Setup (5 min)
#### Step 1: Install
```bash
# LOCAL (Mac), or any machine with Python 3.11+ and Docker
python3 -m venv ~/rl && source ~/rl/bin/activate
pip install "racinglines[db]" # or, in a clone of racinglines-public: pip install -e ".[web]"
```
#### Step 2: Start Postgres
```bash
# LOCAL (Mac): PostgreSQL 17 on localhost:5433, data in the Docker volume racinglines-pgdata
docker run -d --name racinglines-db -p 5433:5432 -e POSTGRES_USER=racinglines -e POSTGRES_PASSWORD=racinglines \
-e POSTGRES_DB=racinglines -v racinglines-pgdata:/var/lib/postgresql/data postgres:17
```
In a clone of the full development repository, `docker compose up -d` starts the same server
(`docker-compose.yml`).
Without Docker, any Postgres 16+ works: point `DATABASE_URL` at it ([Database](database.md#setup)). The demo
database is created next to the configured one (`racinglines_demo_kart` on the same server), so the user in the
URL needs the right to create databases (the container's user has it).
#### Step 3: Check the install
`racinglines check` runs the pipelines on synthetic data, probes every data source and checks the database
(about 10 seconds). `--offline` skips the endpoint probes; `--no-db` skips the database.
=== "Command"
```bash title="Command"
racinglines check --offline
```
=== "Output"
```text title="Output"
code (synthetic data)
โ f1 code load + measurements 0.3s 6 events, 7,485 laps, 360 practice rows
โ f1 code as-of leakage guard 0.0s sessions only usable after they end
โ f1 code training history 0.6s 120 training rows
โ f1 code race pricing 1.2s favourite Driver 01 36%
โ f1 code backtest 0.6s 6 race x mode rows, mean win Brier 0.032
โ f1 code season forecast 0.3s leader Driver 01 71%
โ markets code maker replay 0.0s 172 fills, P&L +72.56
โ markets code weekend taker 0.0s 46 trades, P&L +300.15
โ markets code season strategy 0.2s 39 trades, P&L -93.55
โ mtb_dh code parse results tables 0.1s 1920 rows, 60 riders, rounds ['final', 'practice', 'qual1']
โ mtb_dh code season model 0.2s sigma 0.0096, 60 riders
โ mtb_dh code season forecast 0.2s leader 69%
โ mtb_dh code backtest 0.2s 1 held-out round(s) scored
database
โ database Postgres 0.2s localhost:5433/racinglines, schema up to date
14/14 checks passed
```
#### Step 4: Choose where the data comes from
The first command that needs a database, when none is configured (no `DATABASE_URL`, no `db` or `source` in
`~/.config/racinglines/cli.toml`), asks once and saves the answer as `source` in that file. Answer `1`:
```text
racinglines needs a database, and none is configured (no DATABASE_URL, no `db` or `source` in ~/.config/racinglines/cli.toml).
1) local: the Postgres of docker-compose.yml on localhost:5433; start it with one command, `docker compose up -d`
(then every command works; add --demo for the synthetic demo store, docs/quickstart.md)
2) remote: sign in to racinglines.bet (the browser opens on first use) and run `racinglines book map|price|settle`
on the hosted server, like `racinglines remote ...` and `--remote` on a Lab-job command (in testing);
other commands still need a local database
Choose 1 or 2 [1]:
```
With no terminal to ask on (a script, CI), it prints the same two options and exits with code 2: set
`DATABASE_URL`, or run `racinglines book config source local` (or `remote`). The same command changes the answer
later. One resolver makes this choice and the `--demo` one (`racinglines/cli/source.py`).
Now [run the quickstart](#run-the-quickstart-the-kart-pack).
=== "Hosted (racinglines.bet, in testing)"
!!! warning "#aintcare mode"
racinglines.bet remote access is in testing (#aintcare mode). A review of venue and data-feed terms is
pending, so data served here is for testing only. Every command that connects to the hosted server prints
this warning on stderr.
!!! info "Sign up on racinglines.bet first"
Every `--remote` command needs a racinglines.bet token, which comes from your sign-in on the site:
[sign up](https://racinglines.bet/signup) before Step 2. The hosted demo store (`--demo --remote`) may let you
try a few calls without a token first, then asks you to sign up. The local tab needs no account.
From PyPI, these commands need the MCP extra: `pip install "racinglines[mcp]"`.
Nothing to install but the CLI itself, no Postgres: `--remote` sends the commands the hosted server runs to
racinglines.bet, and `--demo --remote` sends them to the pack's **hosted demo store** (`racinglines mcp --http
--demo` on the VM, over its own database built from the pack: synthetic data only, never production's). The
outputs below are not shown because these commands need the hosted server, which the docs build cannot reach.
### Setup (2 min)
#### Step 1: Install the CLI
```bash
# LOCAL (Mac), or any machine with Python 3.11+: no Postgres needed
git clone https://github.com/racinglines/racinglines.git && cd racinglines # the development repository
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt && pip install -e .
```
#### Step 2: Sign in
The first `--remote` command opens the browser to sign in with your racinglines.bet account (one with MCP
access: Pro or admin, [MCP: Hosted](mcp.md#hosted-the-vm)) and waits for **Allow**. The login is kept in
`~/.config/racinglines/mcp-login.json` and refreshed on its own; `racinglines book logout` forgets it. On a
machine with no browser, an `rl_` token from Settings > Connect Claude (MCP) in `RACINGLINES_MCP_TOKEN` skips
the sign-in.
```bash
# LOCAL (Mac): the pack's book, priced on the hosted demo store (the browser opens once)
racinglines book price --demo --remote
```
#### Step 3: Run the model as a hosted job
On the hosted store the model commands run as the server's Lab jobs (`run_job`, followed with `get_job`), the
log printed as it arrives, and the run is saved there. They take the job's options only (`--races`, `--sims`,
`--track`, `--half-life`, `--scenario`; `--half-life-days`, `--junior-weight` for `mtb_dh`).
```bash
# LOCAL (Mac)
racinglines model backtest kart --demo --remote --races 10 # the backtest as a hosted job
racinglines model forecast kart --demo --remote # a forecast scenario on the hosted demo store
```
`db stats`, `live run` and `live report` need a local store. The pack names its server (`[remote] mcp_url` in
`pack.toml`; `RACINGLINES_DEMO_MCP_URL` overrides it); it answers once the owner has built it on the VM
(`scripts/vm/demo_store.sh`, [Demo data packs](demo.md#the-hosted-demo-store)). With `source = "remote"` saved
in `~/.config/racinglines/cli.toml`, `--demo` alone goes to the hosted store too.
#### Step 4: Pull real data into your own database (optional)
The same sign-in reaches more of the hosted server ([MCP: More commands on the hosted
server](mcp.md#more-commands-on-the-hosted-server-remote-racinglines-remote-data-pull)): `--remote` on any command that has a Lab job,
`racinglines remote tools | call | job`, and `racinglines data pull SPORT`, which copies one sport's shared data
(race data, model runs, exchange prices, trades and books; never accounts or positions) into a local database:
```bash
# LOCAL (Mac), or any machine with Postgres: a fresh local database, then F1's data from racinglines.bet
racinglines db init
racinglines data pull f1
racinglines data pull f1 --since last # later: a top-up
```
## Run the quickstart (the kart pack)
The steps come from the pack's manifest (`racinglines/demo/packs/kart/pack.toml`, `[[quickstart]]`, rendered by
`python3 scripts/build_quickstart.py`), and every **Output** tab is what the command printed on the pack, captured
by `scripts/docs/capture_examples.py` (never typed by hand). The [browser replay](https://racinglines.bet/docs/quickstart/term)
records the same list. Run them in order.
### Step 1: ๐ฆ Install racinglines
In a virtualenv with Python 3.11+: the package with its database extra (from a clone of racinglines-public, `pip install -e ".[web]"`).
```bash
# LOCAL (Mac), or any machine with Postgres
pip install "racinglines[db]"
```
### Step 2: ๐ Build and look at the demo store
The first --demo command builds the store (a few minutes, with a progress bar): the database, the synthetic files, the real ingest and the exchange links. Later commands reuse it.
=== "Command"
```bash title="Command"
racinglines db stats --demo
```
=== "Output"
```text title="Output"
demo: building the kart pack's store (no database): database racinglines_demo_kart, folder
/home/user/racinglines-public/data/demo/kart. Synthetic data, made once (a few minutes).
๐ง demo store 0/7 creating the database
๐ง demo store 1/7 schema and reference data (alembic upgrade head, seed)
๐ง demo store 2/7 the pack's data files
๐ง demo store generating: 2024 R01 Mario Kart Stadium
๐ง demo store generating: 2025 R01 Mario Kart Stadium
๐ง demo store generating: 2026 R01 Mario Kart Stadium
๐ง demo store generating: files written
๐ง demo store generating: exchange odds 2026-09
๐ง demo store generating: exchange odds 2026-10
๐ง demo store generating: exchange odds 2026-11
๐ง demo store 3/7 racinglines data ingest f1 --years 2024-2026
๐ง demo store 4/7 racinglines model forecast f1 --save --sims 2000
๐ง demo store 5/7 racinglines data pm-links-import f1
๐ง demo store 6/7 racinglines data pm-links-import f1 --exchange kalshi
๐ง demo store 7/7 ready
demo: the kart store is ready (f1 data; `racinglines db stats --demo kart`) Let's-a go! ๐
โญโ ๐ฆ database racinglines_demo_kart โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ ๐
calendar ๐ results ๐ฎ model โ
โ sports 8 athletes 20 model runs 1 โ
โ leagues 8 athlete identifiers 20 race predictions 60 โ
โ competitions 8 results 3,300 standings predictions 20 โ
โ categories 11 splits 0 โ
โ venues 27 source files 33 โ
โ events 36 โ
โ races 36 โ
โ rounds 165 โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
coverage
competition year category events rounds results
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
f1_wdc 2024 DRV 12 60 1,200
f1_wdc 2025 DRV 12 60 1,200
f1_wdc 2026 DRV 9 45 900
```
### Step 3: ๐ Backtest the model
Every past race priced as of before the weekend, before qualifying and before the race, with track features on and off, scored against the results and against uniform and grid baselines.
=== "Command"
```bash title="Command"
racinglines model backtest kart --demo
```
=== "Output"
```text title="Output"
progress 0/1 building history (track features on)
๐ backtest, track features on
progress 10/33 2024 R10 rainbow-road
progress 20/33 2025 R08 mount-wario
progress 30/33 2026 R06 thwomp-ruins
progress 33/33 2026 R09 bowser-s-castle
๐ Track features ON: mean over 32 races
โญโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฎ
โ market โ stage โ model Brier โ uniform โ grid โ skill vs uniform โ log loss โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ win โ pre weekend โ 0.0431 โ 0.0475 โ โ โ โ +9% โ 0.1518 โ
โ โ pre quali โ 0.0405 โ 0.0475 โ โ โ โโ +15% โ 0.1388 โ
โ โ pre race โ 0.0381 โ 0.0475 โ 0.0410 โ โโ +20% โ 0.1266 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ podium โ pre weekend โ 0.0988 โ 0.1275 โ โ โ โโโ +23% โ 0.3155 โ
โ โ pre quali โ 0.0913 โ 0.1275 โ โ โ โโโ +28% โ 0.3058 โ
โ โ pre race โ 0.0772 โ 0.1275 โ 0.0824 โ โโโโ +39% โ 0.2699 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ top10 โ pre weekend โ 0.1662 โ 0.2500 โ โ โ โโโโ +34% โ 0.5105 โ
โ โ pre quali โ 0.1543 โ 0.2500 โ โ โ โโโโ +38% โ 0.4897 โ
โ โ pre race โ 0.1295 โ 0.2500 โ 0.1345 โ โโโโโ +48% โ 0.4353 โ
โฐโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโฏ
๐ favourite won 34% of 32 races ยท rank correlation 0.83 ยท mean winner probability 22.7%
best call 2024 R04 Royal Raceway: Donkey Kong won at 48.6%
upset 2024 R08 Mount Wario: Donkey Kong won at 1.7% (favourite Peach)
progress 0/1 building history (track features off)
๐ backtest, track features off
progress 10/33 2024 R10 rainbow-road
progress 20/33 2025 R08 mount-wario
progress 30/33 2026 R06 thwomp-ruins
progress 33/33 2026 R09 bowser-s-castle
๐ Track features OFF: mean over 32 races
โญโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฎ
โ market โ stage โ model Brier โ uniform โ grid โ skill vs uniform โ log loss โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ win โ pre weekend โ 0.0437 โ 0.0475 โ โ โ โ +8% โ 0.1629 โ
โ โ pre quali โ 0.0407 โ 0.0475 โ โ โ โโ +14% โ 0.1391 โ
โ โ pre race โ 0.0377 โ 0.0475 โ 0.0410 โ โโ +21% โ 0.1240 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ podium โ pre weekend โ 0.1033 โ 0.1275 โ โ โ โโ +19% โ 0.3356 โ
โ โ pre quali โ 0.0915 โ 0.1275 โ โ โ โโโ +28% โ 0.3104 โ
โ โ pre race โ 0.0768 โ 0.1275 โ 0.0824 โ โโโโ +40% โ 0.2650 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ top10 โ pre weekend โ 0.1879 โ 0.2500 โ โ โ โโโ +25% โ 0.5659 โ
โ โ pre quali โ 0.1573 โ 0.2500 โ โ โ โโโโ +37% โ 0.4944 โ
โ โ pre race โ 0.1296 โ 0.2500 โ 0.1345 โ โโโโโ +48% โ 0.4326 โ
โฐโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโฏ
๐ favourite won 38% of 32 races ยท rank correlation 0.83 ยท mean winner probability 23.1%
best call 2024 R04 Royal Raceway: Donkey Kong won at 52.3%
upset 2024 R08 Mount Wario: Donkey Kong won at 1.6% (favourite Peach)
Per-race rows -> /home/user/racinglines-public/data/demo/kart/runs/f1/backtests/backtest_20261014_0506.csv
```
### Step 4: ๐ฎ Forecast the rest of the season
The next races (Rainbow Road is a sprint weekend) and both championships; --save stores the run the book and the web app read.
=== "Command"
```bash title="Command"
racinglines model forecast kart --demo --save
```
=== "Output"
```text title="Output"
๐ฎ forecast 2026 ยท cutoff 2026-10-14 05:06 UTC ยท latest results 2026-10-04 14:00
๐ R10 Rainbow Road Grand Prix (18 Oct)
โญโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโฎ
โ driver โ team โ win โ โ podium โ โ top 10 โ DNF โ exp pts โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโผโโโโโโโโโโค
โ Mario โ mushroom โ 26.4% โ โโโโ โ 58.7% โ โโโโโโโโโ โ 95.0% โ 3.2% โ 15.1 โ
โ Luigi โ mushroom โ 18.8% โ โโโ โ 49.7% โ โโโโโโโ โ 93.8% โ 3.1% โ 13.4 โ
โ Bowser โ koopa โ 14.4% โ โโ โ 40.2% โ โโโโโโ โ 82.2% โ 13.9% โ 11.2 โ
โ Peach โ royal โ 9.9% โ โโ โ 33.1% โ โโโโโ โ 86.7% โ 5.5% โ 10.2 โ
โ Daisy โ royal โ 8.3% โ โโ โ 29.6% โ โโโโโ โ 84.4% โ 5.9% โ 9.5 โ
โ Donkey Kong โ kong โ 8.0% โ โ โ 27.4% โ โโโโ โ 85.8% โ 3.5% โ 9.3 โ
โ Bowser Jr. โ koopa โ 6.7% โ โ โ 23.6% โ โโโโ โ 75.2% โ 13.7% โ 8.1 โ
โ Diddy Kong โ kong โ 2.8% โ โ โ 12.3% โ โโ โ 72.9% โ 3.3% โ 5.8 โ
โ Yoshi โ yoshi โ 1.6% โ โ โ 7.7% โ โ โ 60.1% โ 7.3% โ 4.2 โ
โ Rosalina โ comet โ 1.2% โ โ โ 5.9% โ โ โ 55.5% โ 5.6% โ 3.6 โ
โฐโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโดโโโโโโโโโโฏ
๐ R11 Wario Stadium Grand Prix (01 Nov)
โญโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโฎ
โ driver โ team โ win โ โ podium โ โ top 10 โ DNF โ exp pts โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโผโโโโโโโโโโค
โ Mario โ mushroom โ 24.4% โ โโโโ โ 59.3% โ โโโโโโโโโ โ 95.4% โ 3.0% โ 15.0 โ
โ Luigi โ mushroom โ 20.7% โ โโโ โ 52.9% โ โโโโโโโโ โ 94.8% โ 3.0% โ 14.0 โ
โ Peach โ royal โ 15.6% โ โโโ โ 44.2% โ โโโโโโโ โ 90.8% โ 5.9% โ 12.4 โ
โ Bowser โ koopa โ 12.9% โ โโ โ 37.3% โ โโโโโโ โ 82.2% โ 14.0% โ 10.8 โ
โ Daisy โ royal โ 12.1% โ โโ โ 36.9% โ โโโโโโ โ 89.1% โ 5.7% โ 11.2 โ
โ Bowser Jr. โ koopa โ 4.6% โ โ โ 19.1% โ โโโ โ 75.1% โ 13.7% โ 7.3 โ
โ Donkey Kong โ kong โ 4.2% โ โ โ 18.4% โ โโโ โ 82.0% โ 3.1% โ 7.6 โ
โ Yoshi โ yoshi โ 2.1% โ โ โ 11.0% โ โโ โ 69.3% โ 7.4% โ 5.4 โ
โ Diddy Kong โ kong โ 1.0% โ โ โ 6.1% โ โ โ 61.1% โ 3.6% โ 4.0 โ
โ Birdo โ yoshi โ 0.9% โ โ โ 5.3% โ โ โ 56.3% โ 7.3% โ 3.6 โ
โฐโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโดโโโโโโโโโโฏ
๐ 2026 drivers' championship
โญโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโฌโโโโโโโโโฎ
โ driver โ points โ expected โ champion โ โ top 3 โ
โโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโโโโโโผโโโโโโโโโค
โ Luigi โ 191 โ 234 โ 99.8% โ โโโโโโโโโโโโโโ โ 100.0% โ
โ Mario โ 134 โ 182 โ 0.2% โ โ 99.1% โ
โ Peach โ 106 โ 141 โ 0.0% โ โ 60.5% โ
โ Bowser โ 99 โ 135 โ 0.0% โ โ 39.9% โ
โ Daisy โ 62 โ 95 โ 0.0% โ โ 0.2% โ
โ Donkey Kong โ 57 โ 85 โ 0.0% โ โ 0.2% โ
โ Bowser Jr. โ 60 โ 85 โ 0.0% โ โ 0.1% โ
โ Koopa Troopa โ 55 โ 58 โ 0.0% โ โ 0.0% โ
โ Birdo โ 36 โ 48 โ 0.0% โ โ 0.0% โ
โ Diddy Kong โ 28 โ 46 โ 0.0% โ โ 0.0% โ
โฐโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโโโโดโโโโโโโโโฏ
constructors
team points expected champion
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Mushroom Racing 325 416 100.0% โโโโโโโโโโโโโโ
Royal Racing 168 236 0.0%
Koopa Kart Works 159 220 0.0%
Kong Jungle Racing 85 132 0.0%
Yoshi Island GP 62 91 0.0%
finishing model coef {'g': 0.527, 'g_ease': -0.149, 'rp_rel': 0.041, 'rp_ease': 0.051, 'qp_rel':
0.08, 'street_g': 0.0} sigma 0.155; season drift (as-of) {'team_sd': 0.002652025069557153,
'driver_sd': 0.0005339299639313828}
Saved forecast run 2.
```
### Step 5: ๐ Run a live private book
Bowser's Castle (round 9) replayed on a simulated clock: the maker's book opens before practice, reprices after each session from the model's stage runs, 100 simulated takers trade against it, the book closes at lights out and settles from the results.
=== "Command"
```bash title="Command"
racinglines live run kart/demo-2026-09 --demo --simulate --no-fetch
```
=== "Output"
```text title="Output"
pre-weekend: priced (run #3)
2026-10-02T10:30:00+00:00 2026-09 pre-weekend: 80 markets, 54 quoted, 0 crowd fills, maker P&L +24.73
โญโ ๐ฆ Bowser's Castle Grand Prix 2026 (synthetic) ยท pre-weekend (1 of 7 ยท 2026-10-02T10:30 UTC) โโฎ
โ โ
โ driver fair move bid ask Polymarket Kalshi inventory โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Mario 27.6% โโโโ 24.0% 31.0% 32.5% 36.0% -81 โ
โ Luigi 21.4% โโโ 18.0% 25.0% 11.1% 8.0% โ โ
โ Daisy 20.5% โโโ 17.0% 24.0% 6.1% 5.7% โ โ
โ Peach 18.2% โโโ 15.0% 22.0% 10.4% 11.2% โ โ
โ Bowser 3.6% โ 1.0% 8.0% 10.2% 10.5% -357 โ
โ Bowser Jr. 2.1% โ โ 6.0% 1.8% 2.1% โ โ
โ Yoshi 1.6% โ โ 5.0% 1.6% 1.9% โ โ
โ Koopa Troopa 1.2% โ โ 5.0% 7.2% 11.2% โ โ
โ โ
โ ๐ฅ crowd: 0 fills this update ยท 0 in all ยท $0 traded ยท 0 of 100 takers still betting โ
โ ๐ฐ maker P&L +24.73 (crowd +0.00, picks +24.73) โ
โ next: after FP1 at 2026-10-02T13:00 UTC โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
after FP1: priced (run #4)
2026-10-02T13:00:00+00:00 2026-09 after FP1: 80 markets, 44 quoted, 4 crowd fills, maker P&L -75.09
โญโ ๐ Bowser's Castle Grand Prix 2026 (synthetic) ยท after FP1 (2 of 7 ยท 2026-10-02T13:00 UTC) โโโโโโฎ
โ โ
โ driver fair move bid ask Polymarket Kalshi inventory โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Mario 29.1% โโโโ โฒ 1.5 26.0% 32.0% 32.5% 36.0% -66 โ
โ Bowser 20.1% โโโ โฒ 16.4 17.0% 23.0% 10.2% 10.5% -357 โ
โ Luigi 19.3% โโโ โผ 2.1 16.0% 22.0% 11.1% 8.0% โ โ
โ Peach 16.0% โโ โผ 2.2 13.0% 19.0% 10.4% 11.2% โ โ
โ Daisy 11.1% โโ โผ 9.5 8.0% 14.0% 6.1% 5.7% โ โ
โ Koopa Troopa 1.9% โ โฒ 0.6 โ 5.0% 7.2% 11.2% โ โ
โ Yoshi 0.7% โผ 0.9 โ 4.0% 1.6% 1.9% โ โ
โ King Boo 0.5% โผ 0.6 โ 4.0% 2.3% 1.8% โ โ
โ โ
โ ๐ฅ crowd: 4 fills this update ยท 4 in all ยท $294 traded ยท 4 of 100 takers still betting โ
โ ๐ฐ maker P&L -75.09 (crowd -51.91, picks -23.17) โ
โ ๐ fp1: P1 Mario ยท P2 Bowser ยท P3 Luigi โ
โ next: after FP2 at 2026-10-02T16:30 UTC โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
โฆ 79 lines cut (showing the first 39 and last 28 of 146) โฆ
2026-10-04T17:00:00+00:00 2026-09 results: 80 markets, 0 quoted, 0 crowd fills, maker P&L -222.25
โญโ ๐ Bowser's Castle Grand Prix 2026 (synthetic) ยท results (7 of 7 ยท 2026-10-04T17:00 UTC) โโโโโโโฎ
โ โ
โ driver fair move bid ask Polymarket Kalshi inventory โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ Luigi โ
31.4% โโโโ โฒ 4.5 โ โ 11.1% 8.0% -13 โ
โ Daisy โ 20.5% โโโ โฒ 8.5 โ โ 6.1% 5.7% -66 โ
โ Mario โ 20.4% โโโ โผ 2.2 โ โ 32.5% 36.0% -66 โ
โ Bowser โ 20.0% โโโ โฒ 5.3 โ โ 10.2% 10.5% -1,058 โ
โ Peach โ 6.5% โ โผ 12.8 โ โ 10.4% 11.2% -132 โ
โ King Boo โ 0.5% โผ 0.3 โ โ 2.3% 1.8% -2,338 โ
โ Koopa Troopa โ 0.4% โผ 1.4 โ โ 7.2% 11.2% โ โ
โ Donkey Kong โ 0.2% = โ โ 0.8% โ โ โ
โ โ
โ ๐ฅ crowd: 0 fills this update ยท 176 in all ยท $6,048 traded ยท 52 of 100 takers still betting โ
โ ๐ฐ maker P&L -222.25 (crowd -72.25, picks -150.00) โ
โ ๐ race: P1 Luigi ยท P2 Daisy ยท P3 Donkey Kong โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
simulated 2026-09 to 2026-10-04 17:00:00: 7 updates
โญโ ๐ Bowser's Castle Grand Prix 2026 (synthetic) โโโโโโโโโโโโโโโโโโโโโโฎ
โ opened 2026-10-02T10:30 UTC โ
โ settled 2026-10-04T17:00 UTC โ
โ ๐ฐ maker P&L -222.25 โ
โ from the crowd -72.25 โ
โ from the picks -150.00 โ
โ ๐ฅ fills 176 ($6,048 traded) โ
โ bettors 83: 31 up, 52 down, best +1,297.32, worst -184.03 โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
```
!!! tip "Rich output, or plain"
`db stats`, `model backtest`, `model forecast`, `live run`, `live report` and `book map|price|settle` print Rich
output (colour, tables, probability bars), also when piped. `--plain` on any command gives the plain text;
`NO_COLOR=1` keeps the layout and drops the colour codes, which is how these outputs were captured
([CLI: Output](cli.md#output-rich-plain-json)).
Measured on a cloud container (one CPU, 2026-10-14, the capture's own timings): the store build 56 s, the backtest
45 s, the forecast 10 s, the live run 36 s, the book 2 s.
## After the live run
The rehearsal left a settled event in the store. `live status` lists it, and `live report` writes the event report
(Markdown, charts and, with `--pdf`, a PDF, which needs weasyprint or a headless Chrome) under
`data/demo/kart/runs/live/demo-2026-09/report/`.
=== "Command"
```bash title="Command"
racinglines live status --demo
```
=== "Output"
```text title="Output"
demo-2026-09 f1 settled last 2026-10-04T17:00 (0.1 h ago) Bowser's Castle Grand Prix 2026 (synthetic)
```
The report, with its own summary of the event and the files it wrote:
=== "Command"
```bash title="Command"
racinglines live report kart/demo-2026-09 --demo
```
=== "Output"
```text title="Output"
โญโ ๐ Bowser's Castle Grand Prix 2026 (synthetic) โโโโโโโโโโโโโโโโโโโโโโฎ
โ opened 2026-10-02T10:30 UTC โ
โ settled 2026-10-04T17:00 UTC โ
โ ๐ฐ maker P&L -222.25 โ
โ from the crowd -72.25 โ
โ from the picks -150.00 โ
โ ๐ฅ fills 176 ($6,048 traded) โ
โ bettors 83: 31 up, 52 down, best +1,297.32, worst -184.03 โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
๐ report files
what file
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
md runs/live/demo-2026-09/report/report.md
html runs/live/demo-2026-09/report/report.html
pnl.svg runs/live/demo-2026-09/report/pnl.svg
scorecard.svg runs/live/demo-2026-09/report/scorecard.svg
```
## Price a sportsbook's lines
The pack carries a generic sportsbook's lines on rounds 10 and 11 (`racinglines/demo/packs/kart/book.toml`), one
per F1 pricing feature. `book map` resolves every leg to a race, athletes and a market kind by exact keys; `book
price` adds the model's fair, the linked exchange price, the book's implied probability and the EV against each;
`book settle` adds each leg's result and payout once the race is in. `--json` on any of them prints the same result
as JSON ([Sportsbook slips](sportsbook/slips.md)).
=== "Command"
```bash title="Command"
racinglines book map --demo
```
=== "Output"
```text title="Output"
demo: the demo store's synthetic data (made-up names, results and prices); nothing is traded
book map
id selection kind status race reason
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
win Super Mario race_win mapped 34
win-no Luigi not to win race_win mapped 34
podium Peach race_podium mapped 34
top5 Daisy race_top5 mapped 34
top10 Bowser race_top10 mapped 34
p2 Luigi race_p2 mapped 34
pole Mario race_pole mapped 34
fl Luigi race_fastest_lap mapped 34
h2h Daisy v Peach race_h2h mapped 34
mover Yoshi race_biggest_mover mapped 34
spr-win Mario race_sprint_win mapped 34
spr-pole Luigi race_sprint_pole mapped 34
red No race_red_flag mapped 34
sgp-joint Mario win + Luigi combo mapped 34
podium
โ 14.1 race_win Mario (f1 mapped 34
2026-10)
โ 14.2 race_podium Luigi mapped 34
(f1 2026-10)
sgp-product Peach podium + combo mapped 34
Daisy top 10
โ 15.1 race_podium Peach mapped 34
(f1 2026-10)
โ 15.2 race_top10 Daisy mapped 34
(f1 2026-10)
parlay Mario wins Rainbow combo mapped 34, 35
Road and Wario
Stadium
โ 16.1 race_win Mario (f1 mapped 34
2026-10)
โ 16.2 race_win Mario (f1 mapped 35
2026-11)
typo Maryo race_win unmapped 34 leg 17: driver
'Maryo' is not an
exact name in race
34's field
โ 17 race_win (f1 unmapped 34 driver 'Maryo' is
2026-10) not an exact name
in race 34's field
book_demo ยท event 2026-10 ยท f1 ยท captured 2026-10-15T09:00Z ยท odds decimal ยท USD
โญโโโโโโโโโโโโโโโโโโ unmapped lines โโโโโโโโโโโโโโโโโโโโฎ
โ typo โ
โ exact keys only: fix the book file or its [aliases] โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
```
Every leg mapped by its exact keys. Now the prices:
=== "Command"
```bash title="Command"
racinglines book price --demo
```
=== "Output"
```text title="Output"
demo: the demo store's synthetic data (made-up names, results and prices); nothing is traded
book price
model
id selection odds book model market EV model EV market method
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
win Super 3.20 31.2% 26.4% 65.4% -15.6% +109.3% single
Mario
win-no Luigi not 1.30 76.9% 81.2% 87.2% +5.6% +13.4% single
to win
podium Peach 2.40 41.7% 33.1% 11.6% -20.5% -72.2% single
top5 Daisy 1.90 52.6% 50.2% โ -4.6% โ single
top10 Bowser 1.25 80.0% 82.2% โ +2.7% โ single
p2 Luigi 4.50 22.2% โ 32.8% โ +47.6% single
pole Mario 2.75 36.4% 35.0% 66.3% -3.9% +82.3% single
fl Luigi 5.00 20.0% โ โ โ โ single
h2h Daisy v 2.10 47.6% 47.0% 41.5% -1.4% -12.8% single
Peach
mover Yoshi 6.00 16.7% 8.6% โ -48.5% โ single
spr-win Mario 2.90 34.5% 24.8% โ -28.0% โ single
spr-pole Luigi 4.00 25.0% 16.1% โ -35.6% โ single
red No 1.45 69.0% โ โ โ โ single
sgp-joint Mario win 4.20 23.8% 13.1% 40.0% -44.9% +68.1% product
+ Luigi
podium
โ 14.1 race_win โ 26.4% 65.4%
Mario (f1 polymarket
2026-10)
โ 14.2 race_podiโฆ โ 49.7% 61.2%
Luigi (f1 polymarket
2026-10)
sgp-product Peach 2.50 40.0% 28.0% โ -30.1% โ product
podium +
Daisy top
10
โ 15.1 race_podiโฆ โ 33.1% 11.6%
Peach (f1 polymarket
2026-10)
โ 15.2 race_top10 โ 84.4% โ
Daisy (f1
2026-10)
parlay Mario wins 9.60 10.4% 6.4% 35.9% -38.2% +244.7% single x
Rainbow single
Road and
Wario
Stadium
โ 16.1 race_win 3.2 31.2% 26.4% 65.4%
Mario (f1 polymarket
2026-10)
โ 16.2 race_win 3.0 33.3% 24.4% 54.9%
Mario (f1 polymarket
2026-11)
typo Maryo โ โ โ โ โ โ
book_demo ยท event 2026-10 ยท f1 ยท captured 2026-10-15T09:00Z ยท odds decimal ยท USD
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ flags โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ sgp-joint: race 34: 2 legs on one race, correlated: EV approximate โ
โ sgp-joint: market: product of leg prices on one race, correlated: EV approximate โ
โ sgp-product: race 34: 2 legs on one race, correlated: EV approximate โ
โ parlay: legs on 2 races: independent across races (product) โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
โญโโโโโโโโโโโโโโโโโโ unmapped lines โโโโโโโโโโโโโโโโโโโโฎ
โ typo โ
โ exact keys only: fix the book file or its [aliases] โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ assumptions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ โข EV is per unit staked: probability x decimal payout - 1; decimal payout = 1 / the book's โ
โ implied probability. โ
โ โข Legs on different races are independent: the slip probability is the product of their โ
โ probabilities. โ
โ โข Legs on the same race are priced jointly from simulations when they are available (passed in, โ
โ or a stored combo run with exactly these legs); otherwise the product of the marginals, flagged โ
โ 'correlated: EV approximate'. โ
โ โข The market's slip probability is the product of its legs' prices: no exchange lists the slip โ
โ itself. โ
โ โข Model and market are shown side by side, never blended (owner, 2026-10-07). โ
โ โข The market price is the freshest linked exchange's mid (bid/ask midpoint, else its last price, โ
โ else the tape's). โ
โ blend: none (owner, 2026-10-07; docs/sportsbook/slips.md) โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
```
And the settlement:
=== "Command"
```bash title="Command"
racinglines book settle --demo
```
=== "Output"
```text title="Output"
demo: the demo store's synthetic data (made-up names, results and prices); nothing is traded
book settle
id selection odds result payout profit
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
win Super Mario 3.20 pending โ โ
win-no Luigi not to win 1.30 pending โ โ
podium Peach 2.40 pending โ โ
top5 Daisy 1.90 pending โ โ
top10 Bowser 1.25 pending โ โ
p2 Luigi 4.50 pending โ โ
pole Mario 2.75 pending โ โ
fl Luigi 5.00 manual โ โ
h2h Daisy v Peach 2.10 pending โ โ
mover Yoshi 6.00 pending โ โ
spr-win Mario 2.90 pending โ โ
spr-pole Luigi 4.00 pending โ โ
red No 1.45 manual โ โ
sgp-joint Mario win + Luigi podium 4.20 pending โ โ
โ 14.1 race_win Mario (f1 2026-10) pending
โ 14.2 race_podium Luigi (f1 2026-10) pending
sgp-product Peach podium + Daisy top 10 2.50 pending โ โ
โ 15.1 race_podium Peach (f1 2026-10) pending
โ 15.2 race_top10 Daisy (f1 2026-10) pending
parlay Mario wins Rainbow Road and Wario Stadium 9.60 pending โ โ
โ 16.1 race_win Mario (f1 2026-10) pending
โ 16.2 race_win Mario (f1 2026-11) pending
typo Maryo โ โ โ โ
book_demo ยท event 2026-10 ยท f1 ยท captured 2026-10-15T09:00Z ยท odds decimal ยท USD
โญโโโโโโโโโโโโโโโโโโ unmapped lines โโโโโโโโโโโโโโโโโโโโฎ
โ typo โ
โ exact keys only: fix the book file or its [aliases] โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
```
Rounds 10 and 11 have not been raced in the pack, so every line settles as `pending`.
## Where things go
| What | Where | Notes |
|---|---|---|
| The demo database | `racinglines_demo_kart`, on the same server as `DATABASE_URL` | `RACINGLINES_DEMO_DATABASE_URL` moves it |
| The demo data folder | `data/demo/kart/` (gitignored) | the generated FastF1 files under `raw/`, the exchange links under `archive/`, run outputs under `runs/`, the live spec under `live/`; `RACINGLINES_DEMO_DATA` moves it |
| The build marker | `data/demo/kart/demo-store.json` | a fingerprint of the pack and its generator: both the database and the folder are rebuilt when the pack changes; delete the marker (or drop the database) to rebuild by hand |
Not in the quickstart yet: `strategy replay f1` (the maker replay needs a recorded exchange tape of price history and
trades per market; the kart pack carries quotes only, not a tape).
## Rebuild the data yourself
The demo is synthetic. To rebuild the real history locally, every source the code ingests is fetched with your own
machine's network access, and with your own key where one is needed. Keys are never typed on the command line or
committed: put them in your environment, or in a gitignored env file you fill with a hidden prompt, e.g.
```bash
# LOCAL (Mac): store a key without echoing it or leaving it in the shell history
read -rs -p "WUNDERGROUND_API_KEY: " k && printf 'export WUNDERGROUND_API_KEY=%s\n' "$k" >> .env.local && unset k
chmod 600 .env.local && source .env.local # .env.local: add it to .git/info/exclude if it isn't ignored
```
| Source | What | Your own key? | Commands | Code |
|---|---|---|---|---|
| F1 live-timing archive (via FastF1) | F1 sessions: results, laps, schedule | No | `racinglines data fetch f1 --years 2020-2026`, then `racinglines data ingest f1` | `racinglines/sources/fastf1/fetch.py`, `ingest.py` |
| ChronoRace | UCI downhill results | No (the results key comes from ChronoRace's public page data) | `racinglines data download mtb_dh`, then `racinglines data ingest mtb_dh` | `racinglines/sources/chronorace/download.py`, `ingest.py` |
| NASCAR content feeds (cf.nascar.com) | NASCAR Cup results and laps | No | `racinglines data fetch nascar`, then `racinglines data ingest nascar` | `racinglines/sources/nascar/fetch.py` |
| MotoGP results API (api.motogp.pulselive.com) | MotoGP classifications | No | `racinglines data fetch motogp`, then `racinglines data ingest motogp` | `racinglines/sources/motogp/fetch.py` |
| Polymarket (Gamma API) | market links, prices, trades | No (public market data) | `racinglines markets sync`, `racinglines markets history`, `racinglines markets trades` | `racinglines/markets/polymarket/sync.py` |
| Kalshi (Trade API v2, market data) | market links, prices, trades | No for market data; a key (`KALSHI_API_KEY_ID`, `KALSHI_PRIVATE_KEY_PATH`, from your Kalshi account's API keys page) only for the portfolio and order endpoints, which a rebuild doesn't use | `racinglines markets --exchange kalshi sync` (then `history`, `trades`) | `racinglines/markets/kalshi/client.py`, `trade.py` |
| Open-Meteo | weather forecasts per event | No | `racinglines weather fetch` | `racinglines/weather/open_meteo.py` |
| Weather Underground | weather forecasts (optional source) | Yes: `WUNDERGROUND_API_KEY`, the key a Weather Underground personal weather station owner gets; untested (no key has been probed) | `racinglines weather probe`, `racinglines weather fetch` | `racinglines/weather/wunderground.py` |
| IndyCar, WEC, SailGP | results (sources present, not wired into an ingest) | unknown | none yet | `racinglines/sources/indycar/`, `wec/`, `sailgp/` |
Recorded exchange history older than what the exchanges still serve exists only on the VM: a rebuild has whatever
the exchanges return today. Respect each source's terms of use (e.g. `sports/motogp.toml` `[results].terms`) and its
rate limits; the fetchers pace themselves and are safe to re-run.
## Next steps
- :material-console: **CLI reference**
---
Every group and command, with its `-h` output and a real run where one is captured.
[:octicons-arrow-right-24: CLI reference](cli.md)
- :material-package-variant: **Demo data packs**
---
What `--demo` does, what a pack is, the kart pack, and how to add one of your own.
[:octicons-arrow-right-24: Demo data packs](demo.md)
- :material-robot: **Ask Claude instead**
---
The MCP server: the same data and simulations as typed tools for Claude Code, Claude Desktop or the CLI.
[:octicons-arrow-right-24: MCP server](mcp.md)
- :material-web: **The web app**
---
Markets, Trade, History and Lab at racinglines.bet, and how to run the app yourself.
[:octicons-arrow-right-24: Web app](webapp.md)
---
# Demo data packs
> `--demo [PACK]` on any command runs that command, unchanged, on a demo store: a separate Postgres database and data folder filled from a data pack by the real pipeline.
The commands to run are in the [Quickstart](quickstart.md). This page is how a demo store works, what the bundled
`kart` pack holds, how the hosted demo store serves it, and how to add a pack of your own.
!!! note "No demo branch anywhere"
There is no demo code path in any command, model or pipeline: the backtest is `pricing.backtest`, the forecast
`pricing.forecast`, the live book `pipelines/live_f1.py` with the real crowd and quoting, the book
`books/slips.py`. `--demo` only changes where the data comes from.
## What `--demo` does
One resolver (`racinglines/cli/source.py`, called by `racinglines/cli/__init__.py` before anything else) takes
`--demo [PACK]` out of the arguments and points the process at the pack's store:
| Setting | Demo value | Default location |
|---|---|---|
| `DATABASE_URL` | the pack's database, on the same server as the configured one | `racinglines_demo_` (`RACINGLINES_DEMO_DATABASE_URL` overrides) |
| `RACINGLINES_DATA` | the pack's data folder: raw files, archives, run outputs, live specs | `data/demo//` (gitignored; `RACINGLINES_DEMO_DATA` overrides) |
| `RACINGLINES_OFFLINE=1` | FastF1's servers are never called: the store's files are the record | (a general switch, also for machines without FastF1 access) |
Then the command runs as it always does. `PACK` defaults to `kart`; `--demo=PACK` works too. The first `--demo`
command builds the store when it is missing or when the pack has changed (a Rich progress bar; about a minute). The
store build is logged in the store's own data change log, as any ingest is:
=== "Command"
```bash title="Command"
racinglines db changes --demo
```
=== "Output"
```text title="Output"
2026-10-14 05:06 f1 forecast f1 forecast run 2: 2026-10, 2026-11, 2026-12, baseline, 10000 sims (root@vm)
2026-10-14 05:05 f1 demo demo store built from pack kart (synthetic f1 data) (root@vm)
2026-10-14 05:05 f1 forecast f1 forecast run 1: 2026-10, 2026-11, 2026-12, baseline, 2000 sims (root@vm)
```
A pack can name its sport differently from the schema it is stored under, so its commands read naturally: the kart
pack's `sport_name = "kart"` is why `racinglines model forecast kart --demo` is the F1 forecast, with F1's options:
=== "Command"
```bash title="Command"
racinglines model forecast kart --demo -h
```
=== "Output"
```text title="Output"
usage: racinglines model forecast f1 [-h] [--year YEAR] [--sims SIMS] [--save] [--top TOP]
[--no-track] [--scenario LABEL]
options:
-h, --help show this help message and exit
--year YEAR
--sims SIMS
--save
--top TOP
--no-track
--scenario LABEL Save as kind='scenario' (not used for live prices until promoted in the web
app).
```
## What a pack is
A folder with a `pack.toml` manifest, bundled under `racinglines/demo/packs//` or in a folder named by
`RACINGLINES_DEMO_PACKS` (several, separated by `:`). The manifest holds data and the names of real commands only:
| Key | What |
|---|---|
| `name`, `title`, `about` | the pack's name (what `--demo NAME` takes) and description |
| `sport` | an **existing** sport schema (`sports/.toml`) the data is stored under: no new sport, no new schema |
| `sport_name` | optional: what the quickstart's commands call the sport (`kart`, read as `f1`) |
| `files` | a folder copied as is into the data folder (e.g. `raw/...` files a real ingest reads, live specs under `live/`) |
| `[generator]` | optional: `entry = "module:function"` writing more data files into the data folder, and its `input` |
| `[load] commands` | the real `racinglines ...` commands that load the data into the database, in order |
| `[book] file` | the sportsbook book `racinglines book price --demo` prices when no FILE is given |
| `[remote] mcp_url` | the pack's hosted demo server, for `--demo --remote` ([below](#the-hosted-demo-store)) |
| `tagline` | optional: a line printed once the store is ready (a pack's flavour lives here, never in a command) |
| `[[quickstart]]` | the pack's quickstart: `title`, `run` (the command), `about`, optional `section` (steps group under one heading per section, in first-seen order; none: one list) and `record = false` for a step that is shown but never run; rendered into the [Quickstart](quickstart.md) by `scripts/build_quickstart.py` (`--commands PACK [--section NAME]` lists them) and replayed by the web app's `/docs/quickstart/term` page (`Pack.sections()` in `racinglines/demo/store.py`) |
The store build (`racinglines/demo/store.py`), in order:
1. create the database (its name must contain `demo`), Alembic upgrade head and the seed (`racinglines db init`);
2. copy the files;
3. run the generator;
4. run each load command as a subprocess on the store;
5. record a `data_changes` line and write a marker (`demo-store.json`, a fingerprint of the pack and its generator).
Adding a pack is adding a folder: no command, flag, schema, sport file or code path changes.
`tests/test_demo_store.py` builds a second, tiny pack under `mtb_dh` (synthetic ChronoRace result files loaded by
`racinglines data ingest mtb_dh`) and runs its backtest through the same code.
## The kart pack
`racinglines/demo/packs/kart/`: a synthetic Formula 1 world with a Mario Kart grid, stored under the F1 schema.
- `world.toml`, the generator's input: three seasons (2024-2026, twelve rounds each, 2026's rounds 1-9 raced, sprints
at rounds 3, 6 and 10), 20 drivers in 10 teams (Mario, Luigi, Peach, Bowser, ...), twelve tracks (Mario Kart 8
Deluxe's Mario Kart Stadium, Rainbow Road, Bowser's Castle, ...), each team's car pace and the driver offsets, the
tracks' lap times (5 % off each course's fastest 150cc world-record lap, from mkwrs.com, so no generated lap beats a
record), sector shares, trap speeds, how hard passing is and how often a safety car comes out. Sector speeds and
passing come from course profiles where there is one, and how much each team gains on fast or tight tracks from its
drivers' in-game stats. These are the synthetic world's hidden truth; the real model never reads them, it fits the
generated laps. `--demo kart-classic` is the first kart world, with made-up F1-length lap times.
- `racinglines/demo/mk_demo/generate.py` writes FastF1-shaped files for every raced session (practice, sprint qualifying,
sprint, qualifying, race: results, laps with sectors and speed traps, meta with weather) and each season's
schedule, the shapes `sources/fastf1/fetch.py` saves; and quotes from a synthetic Polymarket (win, podium, pole,
teammates' head-to-heads) and Kalshi (win, podium, 2nd place) on rounds 9-11, in the file
`racinglines data pm-links-export` writes. Seeded: the same input gives the same files.
- The load: `data ingest f1` (the real FastF1 ingest: events, results, laps, track profiles), `model forecast f1
--save` (files the unraced rounds as scheduled races), `data pm-links-import f1` for each exchange.
- `files/live/f1/demo-2026-09.toml`: the live rehearsal's launch spec (round 9, already raced, so the book settles):
`[live.crowd] takers = 100`.
- `book.toml`: a generic sportsbook's lines on rounds 10 and 11, one per F1 pricing feature of `book price`.
The synthetic exchanges are data like the rest: prices drawn around the generator's own odds with an exchange's
noise and spread. No real exchange data is in any pack. What the store holds once built, by table:
=== "Command"
```bash title="Command"
racinglines db stats --demo
```
=== "Output"
```text title="Output"
demo: building the kart pack's store (no database): database racinglines_demo_kart, folder
/home/user/racinglines-public/data/demo/kart. Synthetic data, made once (a few minutes).
๐ง demo store 0/7 creating the database
๐ง demo store 1/7 schema and reference data (alembic upgrade head, seed)
๐ง demo store 2/7 the pack's data files
๐ง demo store generating: 2024 R01 Mario Kart Stadium
๐ง demo store generating: 2025 R01 Mario Kart Stadium
๐ง demo store generating: 2026 R01 Mario Kart Stadium
๐ง demo store generating: files written
๐ง demo store generating: exchange odds 2026-09
๐ง demo store generating: exchange odds 2026-10
๐ง demo store generating: exchange odds 2026-11
๐ง demo store 3/7 racinglines data ingest f1 --years 2024-2026
๐ง demo store 4/7 racinglines model forecast f1 --save --sims 2000
๐ง demo store 5/7 racinglines data pm-links-import f1
๐ง demo store 6/7 racinglines data pm-links-import f1 --exchange kalshi
๐ง demo store 7/7 ready
demo: the kart store is ready (f1 data; `racinglines db stats --demo kart`) Let's-a go! ๐
โญโ ๐ฆ database racinglines_demo_kart โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ ๐
calendar ๐ results ๐ฎ model โ
โ sports 8 athletes 20 model runs 1 โ
โ leagues 8 athlete identifiers 20 race predictions 60 โ
โ competitions 8 results 3,300 standings predictions 20 โ
โ categories 11 splits 0 โ
โ venues 27 source files 33 โ
โ events 36 โ
โ races 36 โ
โ rounds 165 โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
coverage
competition year category events rounds results
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
f1_wdc 2024 DRV 12 60 1,200
f1_wdc 2025 DRV 12 60 1,200
f1_wdc 2026 DRV 9 45 900
```
## The hosted demo store
!!! warning "Remote access is in testing"
racinglines.bet remote access is in testing (#aintcare mode). A review of venue and data-feed terms is pending,
so data served here is for testing only. Every command that connects to the hosted server prints this warning on
stderr ([Quickstart: Hosted](quickstart.md#which-way-in)).
`--demo [PACK] --remote`, or `--demo` with `source = "remote"` in `~/.config/racinglines/cli.toml`, uses the pack's
hosted store instead of a local one. The resolver builds nothing locally: it points the command's own `--remote`
path at the pack's demo MCP server (`[remote] mcp_url` in `pack.toml`, `RACINGLINES_DEMO_MCP_URL` overrides it) and
adds `--remote`. Only the commands the hosted server runs take it:
| Command | How it runs on the hosted store |
|---|---|
| `book map \| price \| settle` | the pack's book sent to the `map_book` / `price_book` / `settle_book` tools |
| `model backtest \| forecast f1 \| mtb_dh` | the command's Lab job, queued with `run_job` and followed with `get_job` (`racinglines/cli/remote.py`); the job's options only |
| `db stats`, `live run`, `live report` | not available: these need a local store |
`model backtest f1 --remote` without `--demo` runs the same way on whichever server `--remote` names.
The server is the normal one with `--demo`: `racinglines mcp --http --port 8766 --demo kart` on the VM serves the
kart store (its own database `racinglines_demo_kart` and `data/demo/kart/`, built by the same `store.build`), and its
job worker runs the jobs on it. Accounts, `rl_` tokens and OAuth sign-ins are read from production's database
(`RACINGLINES_MCP_ACCOUNTS_URL`), so the same Pro and admin accounts sign in, and the store has no account rows of its
own: jobs are filed without an owner, so every signed-in caller sees the store's jobs and can cancel a queued one
(acceptable on a synthetic store). Account-scoped reads (`overview`'s users, `track_record`, `list_positions`,
`list_signals`, our maker's quotes in `list_markets`) show nothing to a non-admin caller there, because a caller's id
belongs to production's users table, not the store's. `scripts/vm/demo_store.sh` builds it (`build`, backup first)
and starts the server (`serve`); the owner runs both and adds the `mcp-demo` hostname to the tunnel.
## Add a pack of your own
### Step 1: Make the folder
A folder named after the pack, bundled under `racinglines/demo/packs//` or anywhere on
`RACINGLINES_DEMO_PACKS`, with a `pack.toml` naming an existing sport schema.
### Step 2: Bring the data
Either files a real ingest reads (`files`), a generator that writes them (`[generator]`), or both. The kart pack's
`world.toml` and `generate.py` are the worked example; the tiny `mtb_dh` pack in `tests/test_demo_store.py` is the
smallest one.
### Step 3: Name the load commands
`[load] commands`, in order: the same `racinglines data ingest ...` and `racinglines model forecast ... --save` a real
database gets.
### Step 4: Write the quickstart
`[[quickstart]]` entries, one per command. Then `python3 scripts/build_quickstart.py` renders them into the docs, and
`python scripts/docs/capture_examples.py` (after adding them to `scripts/docs/examples.toml`) captures their real
output for the page.
### Step 5: Run it
```bash
# LOCAL (Mac), or any machine with Postgres
racinglines db stats --demo # builds the store, then prints what it holds
```
## Next steps
- :material-rocket-launch: **Quickstart**
---
The kart pack's commands with their real outputs, local or hosted.
[:octicons-arrow-right-24: Quickstart](quickstart.md)
- :material-robot: **MCP server**
---
The hosted server the `--remote` commands reach, and how to connect a chat client to it.
[:octicons-arrow-right-24: MCP server](mcp.md)
- :material-book-open-variant: **Sportsbook slips**
---
The book file `book map | price | settle` reads, and what the EV columns mean.
[:octicons-arrow-right-24: Sportsbook slips](sportsbook/slips.md)
---
# Contributing
Thanks for wanting to help! racinglines is in beta, and we're looking for beta testers and collaborators.
This page is a starting point: **more detail is coming.** Questions, ideas or "I'd like to help with X":
[hello@racinglines.bet](mailto:hello@racinglines.bet?subject=racinglines%20contributor).
## Data in this repository
This repository (`racinglines/racinglines`, private since 2026-10-19; the package source is exported to `racinglines/racinglines-public`) holds **code, docs, schemas and golden test outputs
only**. The data it was developed with (raw FastF1 sessions, the Polymarket and Kalshi price and trade archives,
database snapshots, the pinned test fixtures) was removed from this repository and its history when it went public
on 2026-10-01: some of it isn't ours to republish, and none of it belongs in git. It lives on the production
server and in a private bucket.
**Test fixtures are available to beta testers and contributors.** Two ways to get them:
- **Ask us** at [hello@racinglines.bet](mailto:hello@racinglines.bet?subject=racinglines%20test%20fixtures)
for the pinned fixture bundle (about 2 MB). Unpacked into `tests/fixtures/`, the regression suite matches the
golden outputs exactly.
- **With access to the private repository** (the owner's machines and cloud sessions):
`bash scripts/restore_test_fixtures.sh` copies the pinned set from its git into `tests/fixtures/`, untracked.
- **Build them yourself** from the original public sources with `python scripts/fetch_test_fixtures.py --refresh`
(needs Postgres; the F1 part can take up to an hour because FastF1 is rate limited). Today's data differs a
little from the pinned set, so expect some golden tests to differ: that's fine for development.
Without fixtures the fixture-based tests are skipped, not failed, and everything else runs.
## Getting set up
1. Python 3.12 and Docker (for Postgres).
2. Clone, then install the pinned environment:
```
git clone https://github.com/racinglines/racinglines racinglines && cd racinglines
python -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt && pip install -e .
```
3. Start the database and check the install: `docker compose up -d && racinglines check`.
4. Run the tests: `python -m pytest -m "not live"` ([Testing](testing.md)). With the fixtures (above), this is
the full regression suite.
5. Run the web app locally: `racinglines web`, then open .
## Making a change
- Branch off `main`; one topic per pull request. `main` deploys to racinglines.bet through CI on every merge, so
only the owner merges.
- **New behaviour ships behind a switch that is off by default** (an `RACINGLINES_*` environment variable), and
existing outputs stay byte-identical unless the change is meant to move them (then re-baseline with
`UPDATE_GOLDEN=1` and explain the diff).
- Before opening a PR: `racinglines check`, `python -m pytest -m "not live"` and `mkdocs build --strict`.
- Never commit data: `tests/test_no_data_in_git.py` fails the build if you do.
- Docs live in `docs/`; parts of `README.md` are generated from them (`python scripts/build_readme.py`).
## Where to start
- [Sports and exchanges (status)](coverage.md): what's covered and what isn't yet.
- [Adding a sport](add-a-sport.md) and [Exchanges as schemas](exchanges.md): most new venues are a config file.
- [Priorities and open items](todo.md): the current list.
*More coming: code layout, how the models and backtests fit together, and good first issues.*
---
# Web app & trading
`racinglines/web/` is the maker/taker web app (FastAPI, server-rendered pages, no
JavaScript framework) on top of the [database](database.md). Partial updates (the Live
tab's refresh and replay, the Lab's sections and Edge Finder) are [htmx](https://htmx.org)
attributes on the templates (`static/htmx.min.js`, vendored); the little browser-side state
(open Lab sections, the replay player) is in `static/app.js`. The look is one stylesheet,
`static/style.css`, in sections (tokens, base, layout, components, pages); shared pieces
(the page head, flashes, KPIs, signed money) are macros in `templates/_macros.html`.
A page shows one thing at a time: big pages (Live, Positions, Strategy, a race, a diagnostic,
Quotes) split into tabs (`` with `[data-panel]` sections; the
choice is remembered per browser, `#key` in the URL opens a tab), secondary sections are
`` with the number that matters in the summary, and a long
table gets a filter box and a row cap (`.scroll[data-rows][data-filter]`, or `table(โฆ, cap=,
filter=)`). All of it is in `app.js`; the server renders every row regardless.
```
ADMIN_PASSWORD=choose-one racinglines web # http://127.0.0.1:8000, user "admin"
```
If `ADMIN_PASSWORD` isn't set, a random password is generated and printed at
startup.
| Variable | Default | Meaning |
|---|---|---|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | `admin` / random | Required on every page. Browsers get a sign-in page (`/signin`, posting to `/login`; 12-hour session cookie); scripts can use HTTP Basic (`curl -u admin:PASSWORD`). |
| `APP_SECRET` | random per start | Signs the session cookie and the anti-forgery token on every form. Set it to keep sessions across restarts. |
| `WEB_HOST` / `WEB_PORT` | `127.0.0.1` / `8000` | Listen address. Keep it on localhost or a private network. |
| `DATABASE_URL` | docker-compose DB | See [Database](database.md). |
## Reaching it from other devices (Cloudflare tunnel)
```
ADMIN_PASSWORD=... racinglines web # still listens on 127.0.0.1
cloudflared tunnel --no-autoupdate --url http://127.0.0.1:8000 # prints https://.trycloudflare.com
```
- **The app stays on localhost.** `cloudflared` forwards HTTPS traffic to it, so
there are no open ports and no router changes.
- **Use a real staging host as the default branch gate.** The recommended pre-deploy path is a single script:
```sh
bash scripts/deploy/predeploy.sh --staging main
bash scripts/deploy/predeploy.sh --prod main
```
or the full flow in one command:
```sh
bash scripts/deploy/predeploy.sh --all main
```
The staging step checks `https://staging.racinglines.bet` only. If that hostname is down or the route is
misconfigured, fix the Cloudflare tunnel first; do not silently switch to a temporary tunnel for the
release path. Do not leave a Cloudflare tunnel running from the owner Mac in the normal flow.
For an ad hoc local-only check while debugging a branch, the one-off helper is:
```sh
bash scripts/deploy/staging.sh smoke 8010
```
The helper script uses `cloudflared tunnel --no-autoupdate --url http://127.0.0.1:8010`, captures the
temporary trycloudflare URL, runs `scripts/deploy/smoke.sh`, and exits cleanly. For a stable public
route, use the VM tunnel and Cloudflare DNS (`staging.racinglines.bet`), not a long-running tunnel on the Mac.
- **The address is public: anyone with it reaches the sign-in page.** Protections:
- a strong `ADMIN_PASSWORD`;
- failed logins limited to 8 per client IP per 15 minutes (using Cloudflare's
`CF-Connecting-IP` header);
- session cookies are `Secure` over HTTPS.
- For stronger protection, put **Cloudflare Access** (email one-time PIN or SSO) in
front of a named tunnel, especially before setting
`POLYMARKET_TRADING_ENABLED=true`.
## One shape for every sport, race and venue
Every view reads the same structure (`racinglines/markets/venues.py`):
```
event: a race, or a competition's season
โโ outcome: kind + subject (e.g. race_win ยท George Russell, race_h2h ยท A ahead of B)
โโ our fair value:
โ upcoming: the live forecast
โ past: the last as-of price made before the start (diagnostic, else an earlier forecast)
โโ venue quotes: Polymarket ยท Kalshi (off with RACINGLINES_KALSHI_VENUE=0) ยท private book (our own markets)
โโ result, once the race has run
```
- **Past races:** the exchange column shows the price **at the time of our as-of
run**, not the resolved 0/1. So "we had 30%, Polymarket 64.5%" compares like
with like.
- **Adding a venue:** add a `VENUES` entry and rows in `market_links` with
`exchange = `. Every page then shows its column.
## Sports shown to admins only
One schema key decides which sports users see: `[sport] audience` in `sports/.toml`, `"all"` by default.
With `audience = "admin"`, the sport is left out of every page for everyone but admins. Its race, event, season,
run, diagnostic and athlete pages, and its JSON API rows, answer 404. That covers the Markets board and calendar,
the exchange lists, the Lab (Edge Finder, run forms, jobs, backtests, scenarios, diagnostics, runs), Positions,
Strategy and its track record, Live, Settings and the landing page's sports line. One gate decides it,
`racinglines.sports.is_public(code, user)` (the web app's side is `racinglines/web/audience.py`; the MCP tools use the
same gate), and only user-facing readers ask: the sport is still recorded, priced and settled, the CLI shows it, and nothing is
deleted. Setting the key back to `"all"` shows it again; it is code, so the change is a deploy.
MotoGP is shown to every signed-in user again (owner, 2026-10-14, data-terms review v0.2: MotoGP results and prices may
reach Alpha users). It was admin-only from 2026-10-11 ("Hide for Alpha"). The terms-pending warning stays. Fantasy
trading stays F1-only.
## Pages
**Landing page** (`/`, public; owner, 2026-10-08, reworked and moved to the index 2026-10-12; a signed-in browser
sees it too, with "Open the app" in place of "Sign in", since 2026-10-13). A compact,
centered page with no same-page navigation: a one-line **fantasy league alpha** strip above the hero (sign-up, the two
one **Try the demo** button that signs in as the pro demo account `maker`, and the 101 link; `/#account`); the hero, whose eyebrow reads "Sportsbook pricing for all racing
disciplines", with its **Talk to us** button; what the pricing does for sportsbooks that set their own lines, books on
a platform provider's feed, and the providers themselves; the business infographic; and a dated **News** feed, newest
first, with the PyPI release marked upcoming, each sport and exchange added on the earliest date the project history
states, and the X and Substack links under it. The sign-in form has its own page, `/signin`, linked top right; `/login`
shows the same page (old links, `smoke.sh`, `vm.sh status` and the uptime monitor keep their 200), and the form posts
to `/login`. Pages behind sign-in keep their own paths (`/markets`, `/lab`, ...); a signed-out browser asking for one is
sent to `/signin?next=...`. The infographic has no "How it works" heading. The page names no sportsbook, operator or provider.
The landing page has no popup, and the maintenance popup is off (`MAINTENANCE_NOTICE = ""`). The demo accounts (maker, taker) still get the sign-up popup, once per browser session, and a **Sign up** link in the top bar, while sign-up is on (owner, 2026-10-11). Registered accounts get neither. Under the alpha layout, a registered account that follows no strategy gets the follow popup instead (owner, 2026-10-21; it replaced the add-email popup), once per browser session, on every app page but the strategy and settings pages, and not from lights out until a day after the race (a pick waits then). It is a compact Lab โ Follow a strategy: the five styles with their 2026 backtest, one click to follow at standard sizing, and the exchange checkboxes (Polymarket runs; Kalshi and ProphetX are opt-ins saved in `users.prefs.follow_venues` for when they open). Kelly and the tweak drawer stay on the full page.
**Quickstart replay** (`/docs/quickstart/term`, under the docs' Quickstart since 2026-10-12, public like `/pitch` while the rest of `/docs` needs a sign-in; `/term` redirects there; owner, 2026-10-11). A terminal-styled replay of the
quickstart (`docs/quickstart.md`): its commands, each with `--demo` (the kart demo pack, a synthetic F1 world: no real names
or model prices), typed at a prompt one by one, each followed by its recorded output (shown at once without JavaScript or
with reduced motion). Steps with a pack `section` group into parts; with more than one part, a part nav switches between them (large tabs across the top on a phone, a sidebar from 900 px wide; owner, 2026-10-12), one part at a time, kept in the URL hash (`#part-2`). Nothing runs on the server, so there is no shell to sandbox. The commands are not written in the
web app: they are the pack's `[[quickstart]]` list (`racinglines/demo/packs/kart/pack.toml`), the same list
`docs/quickstart.md` is built from. `python scripts/demo/record_term.py --run` snapshots that list into
`racinglines/web/term/quickstart.json`, runs each command in colour with Rich's animations off (the final frame of each
progress bar or live table), saves what it printed as `racinglines/web/term/.ansi` with absolute paths cut, and
renders `.html`; without `--run` it re-renders the saved output only. Drop the demo store first for a true first
run, and update `recorded` in `racinglines/web/term/demos.toml`. `tests/test_term.py` fails if a committed render no
longer matches its output, or if the recorded list differs from the pack's quickstart. Runs on the hosted server may
come later (owner, 2026-10-11). The pack's steps take `section` from `demos.toml` (Kart quickstart) when they have none.
Extra parts follow them (`demos.toml` `parts`), each a steps file next to it: `f1-singapore.toml` is the 2026
Singapore GP on the real data and Kalshi and Polymarket tapes (owner, 2026-10-11: staging only), Part 2 the pre-race
picks of profile A on each exchange written to a book (`strategy signals f1 --asof ... --book`) and priced, Part 3 the
same books repriced after each session (`book price --asof`). That data is on the VM, so those parts are recorded there,
with staging's code, by `scripts/vm/record_term_f1.sh` (by default against production's database in read-only sessions (`PGOPTIONS` `default_transaction_read_only=on`) and its data folder, so nothing is written; `TARGET=staging` takes a backup first; then `record_term.py --run --part f1-singapore --out
DIR`, which leaves the checkout alone and packs DIR.tar.gz); unpack it into `racinglines/web/term/` and render. A part
shows once it is recorded.
**Beta sign-up** (`/signup`, `RACINGLINES_SIGNUP=1`, off by default; `racinglines/web/accounts.py`). A public page,
linked from the landing page's "Fantasy trade with us" section: username, email (required since 2026-10-08, one
account per address, stored as `users.prefs.email` like the settings page's), password (twice, 10+ characters, stored
only as a scrypt hash) and an 18+ / fantasy-money tick. Accounts made before 2026-10-08 have no email and keep working. Every beta account starts as
**pro** (owner, 2026-10-01; fantasy tiers come later, and an admin can change a role at `/admin/users`). One transaction
creates the account and credits it **1,000 fantasy bucks**, then signs the person in; the demo maker's starting strategy is assigned when the Lab candidates exist. Ten attempts per IP per hour
and a hidden honeypot field; passwords are never logged or echoed back.
The fantasy bucks live in their own Postgres schema, `accounts.fantasy_ledger` (one row per credit or debit, never
updated; a balance is a sum), apart from the market data in `public`. It is not an alembic migration, because deploys
never change the VM's database: `bash scripts/deploy/vm.sh accounts` backs the database up, runs `racinglines users
setup` (idempotent; every existing active account also gets its 1,000) and switches sign-up on. Until then `/signup`
says sign-up isn't open yet. Rollback: `vm.sh accounts off`.
No reset email is sent automatically. **Forgot your password?** (`/forgot`, linked from the sign-in form
when sign-up is on) writes a reset request to hello@racinglines.bet for the person to send (an
"Open in my email app" link and a Copy button); an admin resets the password and replies with the one-time password.
Admins manage accounts at `/admin/users`: add, change role, deactivate, **Reset password** (a random one-time password
shown once on that page, stored only as a hash, never logged) and **Remove** (only accounts with no markets or bets;
otherwise untick Active). The same from a shell: `racinglines users list | add NAME --role basic | reset-password NAME
| remove NAME | deactivate NAME | activate NAME`.
The nav follows the role:
| Role | Nav |
|---|---|
| **basic** | Markets ยท Strategy ยท Positions |
| **pro** | Markets ยท Strategy ยท Positions ยท My Book ยท Lab ยท Pitch ยท Docs |
| **admin** | Markets ยท Strategy ยท Positions ยท Books (the admin's name for My Book) ยท Lab ยท Admin ยท Pitch ยท Docs |
Strategy and Positions appear for accounts with a strategy profile, and for admins. The **Strategy** link
carries a badge with the number of unread signals. A paper-account banner (profile, bankroll, return) on
the Markets, My Book and Positions pages links to Strategy and Positions.
**Trading (every role with a strategy profile)**
| Page | What it shows |
|---|---|
| **Markets** (`/markets`), **takers** | Every open Polymarket F1 market (upcoming races first, listed or not yet, then season markets and everything else) with the account's strategy profile's current call on each: side, shares, the most to pay, and heat (`signals.call`; see [Paper trading](paper-trading.md#the-current-call-on-any-market)). Never a fair value or edge. |
| **Strategy** (`/strategy`) | The account's story: paper bankroll (start, now, return), worst drawdown, most capital in use, weekends up, a bankroll curve, and how it has used racinglines. For a maker, every strategy decision with the Edge Finder evidence it had at the time and what the chosen setup then made; for a taker, what it was offered and took by heat, against taking every recommendation. Polymarket only: private-book results are on Positions. Then the track record (every weekend: strategy, trades or fills, P&L, bankroll; backtest replay or live paper) and any weekend's signals by stage, with a link to its positions. Takers never see fair values or edges, only the heat. Admins can pick any user. |
| **Pick a strategy** (`/strategy/pick`) | The account picks what it paper-trades; a new account runs nothing until it does (owner, 2026-10-11). Formula 1 on Polymarket is the one slot open today; admins also see every other sport and exchange as *coming soon* (the library's strategies carry F1's stages and kinds, and only F1 on Polymarket has a signal timer). Basic accounts pick **Your picks** (their own draw of three from the taker shortlist) or nothing. Pro accounts pick from the taker shortlist (ranked) or the recommended blend and maker C (open since 2026-10-12), and can move a single strategy's few sliders (minimum edge, head-to-head edge, volume floor; ranges capped to what has been backtested, `racinglines/web/ranges.py`) and the pre-weekend toggle, which saves it as their own "adjusted" version; the dice roll a strategy or its sliders. Saves stay open right up to lights out and reopen a day after the race (owner, 2026-10-21; it was the whole signal window, from four days before the race). A pick made during a weekend trades only the stages priced after it (`signals.after_pick`, from the profile's `since`), so it never bets on a session that already happened. Strategy and Positions point here while an account has no strategy. The demo accounts keep theirs. The same pick works from MCP and the CLI (`fantasy_follow`, [MCP](mcp.md)). |
| **Live** (`/live`, every signed-in user; green dot while a final runs, grey once it is over and the page is a replay with a timeline, step and play controls) | A race followed live: leaderboard, riders on course and their split positions, who's up next. Makers also see every rider's rank probabilities, their auto-updating quotes, the private book (crowd fills, positions, event P&L, the crowd's results) and a win-probability chart; takers see their picks' P&L. Refreshes itself every 15 s. See [Live events](live-events.md). |
| **Positions** (`/positions`) | The account's ledger. Three highlight cells: **Polymarket P&L** (paper history), **Private book P&L** (live, play money) and **Total P&L**; click one to show only that venue below (`?venue=polymarket|private`). Open and settled counts and realised P&L for the venues shown; a **P&L history** chart with a Polymarket / Private book switch (the two are never plotted together): the Polymarket bankroll curve with its worst drawdown and Sharpe ratio (mean / s.d. of weekend P&L ร โ24), or the private book's P&L at every poll of the live event (from its saved snapshots) with its worst drawdown. **Explore saved strategy P&L** loads a separate curve from a complete, saved Edge Finder sweep; select its season, sport, venue, settings and strategy. It is a historical simulation, never added to the account's actual paper P&L. The rest of the page shows **Coming up** (the next races' calls for a taker, where it would quote for a maker, from `signals.call` / `maker_call`); totals by market type; every position (open, settled, closed), sorted by weekend or by P&L (`?sort=pnl` best first, `-pnl` worst first; the P&L column header toggles it), and with a weekend picked the executions behind it (trades taken or paper fills). A taker's in-app bets, if any, below. With `RACINGLINES_SPORT_PAPER` on (the default), the demo accounts' NASCAR and MotoGP rows are listed too, with race names and dates and a *demo replay, in-sample* pill ([Paper trading](paper-trading.md#nascar-and-motogp-demo-in-sample-off-by-default)). Positions never shows a position made by the debug `buy_all` mode. |
**Makers and admin**
Markets initially loads sport headers without reading tape-history statistics. Opening a sport loads only that
competition's race cards and exchange breakdown; Recent results loads separately on open. Positions counts each
ledger row once, even when its event has multiple race categories. The race lookup supplies a single display name,
not additional positions; private-book chart totals and ledger totals can still differ while fair marks are unsettled.
| Page | What it shows |
|---|---|
| **Markets** (`/markets`; `/` redirects here), makers and admin | **Headline numbers:** exchange outcomes we price and their volume, order books recording, my open markets and worst case, F1 model vs grid-only baseline, running jobs. **Per sport** (F1 first): โข next three races as cards: countdown, venue badges (live / pending / soon / mine), a **new** badge for markets first listed in the last 48 hours, our top-3 win probabilities with the exchange price as a tick; โข the season card; โข later races; โข recent results: winner, our pre-race price, Polymarket at the same time; โข **Exchange data**: per exchange, the markets linked for the sport and the trades, price points and book snapshots stored for them, counted over the Parquet archive and the Postgres buffer (the archive is counted in a background thread, never in the request; a zero shows as n/a), with each exchange's historical coverage under the table ([Data](data.md#what-the-exchange-data-counts-show)). With `RACINGLINES_SPORT_STATUS=1` the page opens with **Every sport: status** ([below](#accounts-demo-users-vs-polymarkets-takers)). |
| **Race** (`/races/{race_id}`) | The core page (takers are sent to Markets): โข **Header:** where our fair values come from (run, as-of time, and **update cadence**), the favourite, the result, venue badges, my book here. The cadence shows how often prices are refreshed: "updated every 5 min during sessions" for F1 live stage runs, "updated live during sessions" for F1 forecasts, "updated daily" for other sports like NASCAR. โข **Chart:** Polymarket price history for the top outcomes, with our fair as dotted lines and markers for qualifying, the race and our as-of time. โข **One table per market kind** (win, podium, top 10, make the Final, head-to-head, top constructor): fair bar with market tick, a column per venue, gap, my YES/NO quote (or settled P&L), bets, and the result. โข **Quote this race** (upcoming): generate private markets from our fair ยฑ spread, or mirror the Polymarket event. โข **Classification** (past): with our win and podium prices, plus links to diagnostics. |
| **Season** (`/seasons/{code}`) | The same layout for season-long markets: champion, constructors' champion, season wins, championship head-to-heads. |
| **Polymarket** (`/markets/polymarket`, linked from Markets) | Every listed F1 event, including outcomes we don't price, with **new** badges (48 hours), **Mirror into my book** and refresh. |
| **My Book** (`/book`; **Books** for admin) | Positions across venues. Totals: open markets, bets against me, stakes, EV at fair and worst case (open markets only), settled P&L. One row per event, linking to the race page and to Quotes. Admin can filter by maker. |
| **Quotes** (`/book/quotes`, `/book/markets/{id}`, `/book/quotes/sheet`) | The private book per race, behind My Book: create and reprice, close, record offline bets, settle, quote sheet. See [Private book](#private-book-markets-we-quote). The quote sheet is open to every role. |
| **Lab** (`/lab`) | **Leads with the Edge Finder**, the headline feature for a maker account, per season (**2026**: every weekend raced so far; **2025**: as if live from its first race). A pinned **benchmark** (the conservative maker on the baseline at default settings) that nothing replaces; a card per chosen combo (a saved configuration ร a strategy) with its P&L, its closing-line value (from the run's `metrics.clv`, when it has one), its P&L vs the baseline priced from the same data and vs the benchmark; a **full-season recap** (P&L, volume, return on volume, weekends up, average and s.d. per weekend, best and worst weekend, max drawdown, consistency, fills and markout for makers); and P&L per weekend with season totals. Nothing is simulated on a visit. **Explore:** choose any saved sport, season, venue, setup and taker or maker strategy. The Edge Finder combines settings already present in complete saved sweeps; it does not invent a curve when per-weekend results are missing. A separate curve is also available under Positions and is kept apart from account P&L. For F1 Polymarket, **Demo strategy history and pools** lists the entire fixed maker and taker decision pools, the M1โM3/C and TW1/TW2 phases, saved results by season and direct links to each saved run. It also recomputes the historical decision rule from current saved sweeps and flags when those results no longer reproduce the recorded phase. T1/A is listed separately because it is a recommended profile, not part of the taker demo's decision pool; its matching saved sweep uses the sweep's sizing, not the flat-size resweep score. **Strategy sweeps:** Run supports full-season simulations for Formula 1, NASCAR and MotoGP. Change the sport's model/settings, season and venue; the sweep trades every supported strategy against recorded exchange prices and saves per-weekend simulated P&L for Edge Finder. Jobs shows progress and the saved run. A walk-forward backtest is a different job: it scores model predictions against outcomes (and optionally exchange prices), not a trading strategy's P&L. Downhill has a model-only walk-forward but no strategy sweep; road cycling and tape-only sports are not sweep-enabled in this app. **Candidates:** โ
on an F1 card saves that configuration ร strategy as a named candidate (searches add theirs on import; strategy profiles are candidates too). **Load in Lab** opens the F1 **Edge Finder sweep** form (Run) with every setting filled in. **F1 sweep form:** every setting of `racinglines/pipelines/sweep_settings.py`, grouped (model, entry timing, taker, maker, markets), with **Start from** (defaults, your last run, or a candidate), the season and the maker venue. NASCAR and MotoGP use their sport-specific settings and exchanges. **Sections, toggled on demand** (each loads from `/lab/section/{key}` the first time it's opened; `/lab#diagnostics` opens one): **Run**; **Jobs** (yours, or everyone's); **Model variants** (F1 only; click a cell to add that combo); **Scenarios** with **Promote to live**; **Backtests** (+ Edge Finder per model); **Event diagnostics**; **All runs**. **Alpha Fantasy Launch:** makers are open again (owner, 2026-10-12; maker sliders capped at 10โ100 shares per quote and a 0.05โ0.15 disagreement cut-off). **Run** shows one form at a time (pick a sport, then a job) with its main inputs first and the rest under **More settings**; numeric inputs are capped to sensible ranges (`racinglines/web/ranges.py`, inside the engine's own bounds, widened only to hold a saved value). **What's stored where:** Edge Finder combos, season and job settings in `users.prefs`; candidates as `model_runs` (`kind='candidate'`); open sections and the jobs filter in the browser; jobs, runs and bets in their own tables. |
| **Event diagnostic** (`/lab/diagnostics/{id}`) | One past event as of a cutoff: our prices vs Polymarket, scoring, the paper taking strategy, and the **maker replay with every strategy knob**. Knobs: fill rule, half-spread, shares per quote, max shares per market, max worst-case loss, inventory skew, disagreement filter, minimum 24 h volume, pull time. Replays take a few seconds. **Live replay:** *Start the live replay* downloads the event's tape once (`/lab/diagnostics/{id}/tape.json`, about 2 MB gzipped) and the replay compiled to WebAssembly (`/static/replay.wasm`), then re-runs the whole event in the browser on every slider move (tens of milliseconds), with the server's rules and numbers; touch and through fills only (queue stays on the server). See [Market making](market-making.md#the-replay-in-the-browser). `/lab/diagnostics` opens the Lab's diagnostics section. |
| **Run detail** (`/lab/runs/{id}`) | One stored run: parameters, metrics, predictions. A sweep's page opens with its strategies side by side: P&L, amount bought or filled, weekends up, and closing-line value (bets, decided, mean CLV in points and percent, share positive), then `metrics.clv`'s tables. `/lab/runs` redirects to the Lab. |
| **Events / Athletes** (`/events/{id}`, `/athletes/{id}`; linked from Markets and race pages) | Results and prediction history for one event or athlete. No index/search page (removed). Every role can open them; takers don't see predictions. |
**Admin only**
| Page | What it shows |
|---|---|
| **Linked markets** (`/markets/linked`, `/markets/linked/lookup`, `/markets/linked/{id}`) | Polymarket markets linked to our predictions, the lookup to link one, and a market's page with the order book and suggested quotes. See [Linking a market](#linking-a-market). |
| **Orders** (`/orders`) | The Polymarket order log. |
| **Admin** (`/admin/โฆ`) | See [Admin pages](#admin-pages) below. |
**Every visitor**
| Page | What it shows |
|---|---|
| **Racinglines 101** (`/racinglines101`, public: no sign-in) | A plain-language intro in three parts, one section at a time with the /term section nav: how we trade (makers, takers, the demo strategies), how the prediction engine works (pace, the simulation, prices, what's real), and using the app. Linked from the landing page as **What is this?** `/racinglines102` redirects to its engine part. |
| **Sign in** (`/signin`, also `/login`; `/logout` returns to `/`) | The standard username/password form, which the admin uses; it posts to `/login`. One **Try the demo** button, here and on the landing page (`/#account`), signs in as the pro demo account `maker` (owner, 2026-10-12: one demo, no pro/basic choice). The basic demo account `taker` still signs in through the form (password `password`). |
| **Pitch** (`/pitch`) | Serves `pitch.html`. Public: no sign-in, like the login and sign-up pages. |
| **Docs** (`/docs/`) | Serves these docs as built in `site/` (the pre-push hook builds them; or `python -m mkdocs build -d site`), behind the same login. |
### Launching runs from the Lab
Each run type is a CLI command with typed, range-checked knobs
(`racinglines/web/jobs.py`). Jobs run one at a time in a background thread, as a
subprocess with no shell, and stream their progress into the `jobs` table.
| Job | Knobs | Typical time |
|---|---|---|
| F1 backtest | last N races, recency half-life, simulations, track features (on / off / both) | ~6 s for 5 races, a few minutes for all |
| F1 forward forecast (scenario) | name, half-life, simulations, track features | ~10 s |
| F1 event diagnostic | event, as-of time, half-life, simulations, track features | ~10 s |
| Downhill forward forecast (scenario) | name, half-life, junior weight, simulations | ~1 min |
| Downhill backtest | half-life, junior weight, simulations | a few min |
**Guard rails:**
- **Forecasts from the Lab are saved as `kind='scenario'`.** Live fair prices
come from the most recently **promoted** forecast (`params.promoted_at`),
falling back to the newest forecast. Promoting is logged in the activity log.
- **No leakage.** Backtests and diagnostics use the same as-of pricing as the
CLI, whatever the knobs.
- **Interrupted jobs.** A job running when the app restarts is marked failed
("interrupted").
**Routes match page names.** Old URLs (`/`, `/bet`, `/me`, `/signals`, `/pm`, `/houseโฆ`, `/race/โฆ`,
`/season/โฆ`, `/diagโฆ`, `/runsโฆ`, and the admin's `/markets/{id}`) redirect to the current ones
(`racinglines/web/legacy.py`).
### Roles
The roles are **admin**, **pro** and **basic** (`racinglines/web/roles.py`). Until 2026-09-30 pro was called `maker`
and basic `taker`; accounts created before then still carry the old value in `users.role` and are read as the new
role everywhere (`roles.canonical`), so no migration was needed. To retire the aliases run, after a backup,
`UPDATE users SET role = 'pro' WHERE role = 'maker'` and `UPDATE users SET role = 'basic' WHERE role = 'taker'`.
The words *maker* and *taker* below (and everywhere else in these docs) mean the two kinds of strategy, which keep
their names.
**Tiers.** A pro account may run any strategy profile, maker or taker, including a blend, and has the Lab (Edge
Finder, sweeps, backtests, diagnostics, replays). A basic account runs only **Your picks** (below) and has no Lab,
book or fair values. `/admin/users/{id}` refuses to assign a basic account anything else, and refuses to make an
account basic while it runs something else. A basic account already running **A** (the rule before 2026-09-30)
keeps working, role edits included, until it is re-assigned (`roles.LEGACY_BASIC_PROFILES`).
**The taker shortlist and blends.** `profiles.TAKER_TOP` ranks ten taker profiles, **T1** to **T10**, from the taker
re-sweep (Lab candidates with source `taker-resweep`, created by `racinglines strategy profiles f1` like A and C). A *blend*
(`profiles.COMBOS`; today **TB**, half T1 and half T8) is a profile with `members` `[(code, weight)]` instead of one
strategy: the signal engine and the demo backfill run each member as itself with its stakes scaled by its weight
(`TakerParams.scale`; the $2 minimum rebalance is not scaled), store its rows under its own candidate and the blend's
name, and tag each signal `detail.member`. A blend isn't a candidate; assign it on `/admin/users/{id}` by its code.
Pages that read one strategy (the Markets page's calls, the "every recommendation" line) use the first member's.
**Basic picks.** A basic account's profile is the blend of `roles.BASIC_PICKS` (3) codes drawn from `TAKER_TOP` by
`roles.basic_members(user_id)`, at 1/3 each, named **Your picks**. The draw is seeded with `roles.BASIC_SEED` and the
account id: the same three every time, never re-rolled at login, nothing written until an admin assigns it (the
**Your picks** option on `/admin/users/{id}`, which shows the draw). Change `BASIC_SEED` to re-draw every account
(each then needs re-assigning). A basic account never sees which strategy made a pick: on Strategy, Positions and
Markets, and in the MCP tools (`track_record`, `list_positions`, `list_signals`, for a basic caller its own account
only), profile, strategy and member names and codes read **Your picks** / taker, and there's no fair value or edge.
Instead each entry has a 1 to 3 star rating, computed on the server from the edge over the minimum edge of the
strategy that made it (`roles.pick_stars`: 2x or more, 3 stars; 1.5x, 2; else 1), next to the heat. Pro and admin
pages are unchanged.
| Role | Can do |
|---|---|
| **basic** | Nav: **Markets**, **Strategy**, **Positions**. **Markets** lists every open Polymarket F1 market with the account's strategy profile's current call on each (side, shares, the most to pay, heat), never a fair value or edge. There is no maker on the other side: takers trade on Polymarket, on paper (see [Paper trading](paper-trading.md)). Race, season, book, Polymarket and Lab pages are closed to takers (a race link sends them to Markets); event and athlete pages show results without predictions. (The in-app private book still exists for makers; a taker's in-app bet is placed at the quoted price, refused if the maker has repriced since the page loaded, with a per-bet cap `MAX_STAKE`, default $100.) |
| **pro** | Nav: **Markets**, **Strategy**, **Positions**, **My Book**, **Lab**, **Pitch**, **Docs** (Strategy and Positions with a strategy profile). Any strategy profile, maker or taker. **Own** markets only: generate from the live forecast (fair ยฑ spread), reprice, close, see the bets against them. Can launch Lab jobs and promote scenarios. Can't bet, can't touch other makers' markets, and can't settle. |
| **admin** | Everything: the pro nav (My Book is called **Books**) plus **Admin**, which links to activity, users, database, SQL, linked Polymarket markets and the order log. Plus the private book across all makers, settlement (auto and manual), offline bets, Polymarket, any user's Strategy page, strategy profiles (on `/admin/users/{id}`), and the admin pages below. |
Every route declares the roles allowed (`allow(...)` in `app.py`); anything else gets 403.
### Accounts: demo users vs Polymarket's takers
Three accounts are easy to mix up. They are separate entities:
| Account | What it is | Its trades |
|---|---|---|
| `maker` (demo, **Try the demo**) | Our maker, $10,000 paper bankroll from 2025-01-01. 2025: M1 (the defaults: conservative maker, baseline model), then M2 (grid-aware maker, 10-pt filter) after round 8, then M3 (gbm maker, 7-pt filter) after round 16; profile C since the start of 2026. Each switch follows a fixed rule on the Edge Finder's walk-forward evidence (`pipelines/story.py`) | Its own quotes and paper fills (**Strategy**, **Positions**); the in-app markets it opens |
| `taker` (demo, signs in by name; no button since 2026-10-12) | Our taker, $1,000 paper bankroll from 2025-01-01: TW1 (the defaults) for 2025 rounds 1-8, then TW2 (grid-aware model with the reset, 10-pt edge) by the same walk-forward rule as the maker ([Paper trading](paper-trading.md#the-takers-history)), following about a third of its recommendations (`follow_rate` 0.33, hotter entries more often). Bankrolls and the follow rate are set in code (`profiles.DEMO_BANKROLL`, `DEMO_FOLLOW`, per account and kind of strategy: the basic demo takes 0.33 of its taker picks, the pro demo fills every pick of a taker strategy when it runs one), not in the UI. See [Paper trading](paper-trading.md#the-demo-accounts) | Its own paper trades (**Strategy**, **Positions**) and any bets placed in the app |
| `polymarket-takers` (system, no login) | The Polymarket traders whose real trades filled a replayed maker | Replay fills recorded from a diagnostic page (*Record the replay's fills*); its P&L is that maker's P&L reversed |
Both demo accounts' weekends before live paper trading began are **backtest replays**
(`racinglines strategy demo-history f1`, `pipelines/demo_history.py`): real Polymarket prices and trades, the
strategy the account ran then, flagged in the database (`detail.backfill`) and labelled in the app.
The maker can also have a Kalshi record (`f1 demo-history --venue kalshi`, [Kalshi history](kalshi-history.md)).
Positions shows it beside the Polymarket record and Strategy under its Polymarket / Kalshi switch
(`?venue=kalshi`); `RACINGLINES_KALSHI_VENUE=0` hides it.
**OG.com and recorded tapes** (both off by default). With `RACINGLINES_OG_VENUE=1`, OG.com shows beside Polymarket and
Kalshi on the board, race and season pages, and `/markets/og` lists its markets with the fair-price indicator per side
(edge YES = fair โ ask โ fee, edge NO = bid โ fair โ fee, and the call), refresh and mirror, read-only. Every schema
exchange in `exchanges/` gets its own `/markets/` page this way ([Exchanges](exchanges.md)). With
`RACINGLINES_TAPES=1`, `/markets/tapes` lists the tape-only sports' markets (IndyCar, road cycling, Le Mans, SailGP: the schemas with `model_family = "none"`; NASCAR and MotoGP show on the board instead) per exchange
event: what is linked, the volume and favourite, and what has been recorded (trades, price points, book snapshots and
their last timestamps). It is market data only: no model, positions or P&L. With both switches off every page renders as before.
**Every sport's status** (off by default, `RACINGLINES_SPORT_STATUS=1`; `racinglines/web/sport_status.py`). Markets
opens with one row per sport schema, modeled or tape only, for both roles: race data (races with results, the latest
result, the next event), exchange markets (links per exchange, open, tied to a race, last sync), model (the schema's
model family, the latest live forecast run, the stored as-of replay prices, season forecasts), backtests (backtest runs
in the database, the replay settings grid under `data/runs/replay-grid/`) and, for Pro and admin, the demo
accounts' paper record per sport (settled P&L, never a buy_all row). Each cell is green (there), amber (partial), red
(missing) or grey (not applicable: a tape-only sport has no model by design), so a sport with no model or no data
says so rather than disappearing. With the switch on, every sport with exchange data opens on the board. A modeled sport
with no live forecast run but with replay saves (NASCAR, MotoGP: ` replay --save`, as-of runs made before each
race) gets its board section too: the next races with the exchanges' prices (no fair price until a forecast is stored)
and the recent races with the model's pre-race price on the winner. ` forecast --save` (in `vm.sh demo`) stores a live forecast for
the next scheduled races, and the section then reads like F1's: live prices from that run. It is the replay's model and
settings on every result so far, the field taken from the latest race; refresh it after each race. The Pro
account's Strategy page also splits its record by sport and strategy (*Where the P&L came from*: the F1 maker, the
NASCAR / MotoGP taker demo). `bash scripts/deploy/vm.sh demo` turns this and `RACINGLINES_SPORT_PAPER` on and stores
the NASCAR / MotoGP demo rows for both demo accounts ([Paper trading](paper-trading.md#nascar-and-motogp-demo-in-sample-off-by-default)).
`RACINGLINES_SPORT_PAPER` (the NASCAR / MotoGP demo paper rows in the nav P&L, Positions and Strategy) is **on by
default** since 2026-09-30 (`pipelines/sport_paper.py` `enabled`): unset means on, `1`/`true`/`yes`/`on` means on, and
any other value (`0`, an empty string) turns it off, so every page reads as before. `RACINGLINES_SPORT_STATUS` stays
**off by default**. Every one of these rows is labelled *demo replay, in-sample*: the settings were picked on the same
seasons they are replayed on, so the P&L shows what the strategy would have done, not that it has an edge.
**NASCAR and MotoGP on the board, today.** NASCAR has a live forecast once `nascar forecast --save` has run (the
next scheduled Cup races, the field taken from the latest race). MotoGP has no upcoming races stored (its ingest
loads finished events only), so `motogp forecast` has nothing to price and its section shows recent results
(the RDR category) with the replay-save prices, and no next races, until its calendar is ingested.
**Demo sessions are disposable** (`racinglines/web/demo.py`; the demo accounts are `RACINGLINES_DEMO_USERS`,
default `maker,taker`). Every sign-in gets a fresh session id. View settings (Edge Finder combos and season,
job settings, which signals were seen) live in a per-session overlay on top of the account's saved baseline
(`profiles.assign_demo` sets it), so a new sign-in starts clean and two visitors never see each other's
changes; the Lab's browser storage is namespaced by the session. Anything that would create or change data
(Lab jobs, promotion, candidates, the in-app book, bets, Polymarket sync or mirror, recorded replays) is
refused with a message. Every request of a demo session is logged to `activity_log` (`demo_view`,
`demo_post`, `demo_blocked`, with the session id, method, path and query): Admin > Activity.
**Demo-only text.** Explanations that exist only for the demo accounts (their decision rules and habits,
backtest-replay notes) render as info bubbles from the `demo_context` macro (`_macros.html`), tagged
invisibly with `data-tag="demo-context"`. `RACINGLINES_DEMO_CONTEXT=0` hides them all; before real users,
delete every `demo_context` call (`grep -rn demo_context racinglines/web/templates`). The Racinglines 101
page is the demo's own explainer and uses none. Everything else describes the product.
The demo taker is **not** the maker's counterparty. It trades its own strategy against
Polymarket's prices, so if it ever ends up on the opposite side of the demo maker, that's a
coincidence, and the app doesn't pair or net them.
### Admin pages
| Page | Purpose |
|---|---|
| `/admin` | Totals (markets, taker bets, stakes, takers' P&L, makers' worst case), users with activity counts, recent activity. |
| `/admin/activity` | Full activity log, filterable by user and action. Records logins (and failures), market generation, reprices, status changes, bets placed and rejected, settlements, Polymarket links and orders, user changes, database edits and SQL. |
| `/admin/users` | Create users (any role), change role, display name or password, deactivate. You can't demote or deactivate yourself. |
| `/admin/users/{id}` | One user's trading: markets made (exposure, EV, settled P&L), bets placed (P&L), activity; and the user's **strategy profile** (assign any Lab candidate, or clear it). |
| `/admin/locks` | **Pick locks** (owner, 2026-10-21): every coming F1 weekend's strategy-pick lock, by default from lights out until a day after the race. Per weekend, move when it locks or reopens (UTC), turn the lock off, or reset to the default. Saved in `data/fantasy/pick_locks.json` (`fantasy/follow.lock_overrides`), so no migration and deploys never touch it. Read by the picker, the follow popup and MCP `fantasy_follow` alike. A lock can't reopen later than the default, because the next weekend takes over a day after the race. Each save is in the activity log (`pick_lock`). |
| `/admin/db` | Database explorer: every table with row counts. Browse with a column filter and paging, edit or delete a row (confirmation needed; logged with before/after values). Password hashes are never shown. |
| `/admin/sql` | SQL console. **Read** mode runs in a `READ ONLY` transaction, so Postgres rejects any write, including data-modifying `WITH`. **Write** mode needs the toggle and a confirm box. 15 s timeout, 500 rows max. Every statement is logged in full. |
Accounts are in the `users` table, with scrypt password hashes from the standard
library. On startup the admin account is created or updated from
`ADMIN_USERNAME` / `ADMIN_PASSWORD`, or a password is generated and printed if
none is set. Other users are created on `/admin/users`.
### Authentication
- **Accounts:** users come from the `users` table. The session cookie carries the
user id, and the user and role are re-read on every request, so a deactivation
or role change takes effect immediately.
- **Browsers:** sent to `/signin`. A correct username and password set an HttpOnly,
SameSite=Lax cookie signed with `APP_SECRET` that lasts 12 hours. It's marked
`Secure` when the request came over HTTPS (e.g. through the tunnel).
- **Scripts:** HTTP Basic (`curl -u admin:PASSWORD โฆ`) is accepted on every route.
Non-browser requests without credentials get 401.
- **Throttling:** after 8 failed logins from one client IP within 15 minutes, that
IP gets 429 until the window passes. This covers both the form and Basic auth.
Behind Cloudflare, the client IP comes from `CF-Connecting-IP`. The counter is in
memory and resets on restart.
- **Forms:** every form POST also carries an anti-forgery (CSRF) token.
### JSON API
Off by default: every route answers 404 unless `RACINGLINES_JSON_API=1` is set where the app
runs (`racinglines/web/api.py`). Read-only, behind the same login as the pages (cookie or HTTP
Basic), and only what the events and athletes pages show every signed-in user:
| Route | Returns |
|---|---|
| `GET /api/v1/events?season=&competition=` | events, newest first |
| `GET /api/v1/events/{id}` | one event and its classification, every round |
| `GET /api/v1/athletes?q=&limit=200` | athletes with result counts (limit at most 1,000) |
| `GET /api/v1/athletes/{id}` | one athlete, identifiers and results (practice left out) |
```
RACINGLINES_JSON_API=1 racinglines web
curl -u taker:PASSWORD 'http://localhost:8000/api/v1/events?competition=uci_dhi_wc&season=2026'
```
No model prices, quotes, positions or market data. Open questions for the owner before it goes
further:
- **What's public:** results only (today), or model prices too (the pages hide them from takers)?
- **Who can use it:** any account (today), a new API role or keys, or no login for results?
- **Data terms:** F1 timing data (FastF1 / F1's terms), OpenF1's non-commercial terms and UCI /
ChronoRace results may not allow redistribution; check before anything leaves the private demo.
- **Limits:** a per-client rate limit, and paging for the larger lists.
## Getting predictions for an event weekend
```
racinglines data download mtb_dh --events 20260925_mtb --discipline DH --category "Elite Men" --out-dir data/raw/mtb_dh/chronorace/
racinglines data download mtb_dh --events 20260925_mtb --discipline DH --category "Junior Men" --out-dir data/raw/mtb_dh/chronorace/
racinglines data ingest mtb_dh data/raw/mtb_dh/chronorace
racinglines model forecast mtb_dh --db --save --backtest 0
```
An event that's in the data but has no Final results yet (Timed Training done, Q1
start list published) is treated as **in progress**:
- the field is its real Q1 start list;
- the model is fit only on data from before the weekend;
- each rider's weekend pace is adjusted from this weekend's Timed Training, using
`weekend_prior` with training noise inflated 1.5ร.
There's a safety check on that adjustment: if the Timed Training times are far
more spread out than normal, the session is ignored with a warning. That happened
at **Whistler 2026**, where riders were held on track and times jumped by minutes.
Re-run after each session (e.g. after Q1) to refresh the predictions. **The model
doesn't yet use completed rounds within the weekend.** For example, once Q1 is run
it doesn't lock in who has already qualified.
## Private book (markets we quote)
For events with no exchange market, the app can quote its own YES/NO markets for
**private bets**. Money is handled outside the app; it records quotes, bets,
exposure and settlement. If you offer quotes to the public, that's bookmaking and
needs a licence where you operate.
**Pricing:** prices are what the counterparty pays per $1 of payout.
- YES costs fair + spread/2, and NO costs (1 โ fair) + spread/2.
- Prices are rounded up to the cent, so rounding only widens your margin.
- A side isn't offered above 0.97. With a 6-point spread that hides the NO side of
long shots under about 6%; narrow the spread to offer it.
- A market can be repriced manually (fair value and spread). Manually priced
markets aren't touched when the rest are repriced.
**Workflow (`/book/quotes`):**
1. Pick the race and **Create / reprice**: choose market types, top N riders and
spread. Win, podium and make-the-Final riders are the top N by win probability;
rank markets are the top N in the current standings.
2. Show the **Quote sheet** (phone-friendly) to counterparties.
3. On a market's page, **record each bet** (counterparty, side, stake; the price
defaults to the current quote and is locked on the bet). The page and book show
P&L if YES and if NO, EV at fair value, and the worst case.
4. Close a market to stop taking bets. Re-run the forecast (e.g. after Q1) and
**reprice** open markets; bets already taken keep their price.
5. **Settle:**
- **Auto-settle from results** covers win, podium and make the Final, once the
Final start list or results are ingested.
- **Rank up/down:** settle by hand from the **official UCI standings**. The
model prices these with placeholder points tables.
- You can void a market to return all stakes.
| Market | YES means | Priced from | Settled by |
|---|---|---|---|
| `race_win` | Wins the Final | `win_prob` | Final results (auto) |
| `race_podium` | Top 3 in the Final | `podium_prob` | Final results (auto) |
| `race_make_final` | On the Final start list | `make_final_prob` | Final start list (auto) |
| `rank_up` | Championship rank better than now after this race | `rank_up_prob` (simulated standings after this weekend) | Official standings (manual) |
| `rank_down` | Championship rank worse than now after this race | `rank_down_prob` | Official standings (manual) |
Tables: `house_markets` (one per race ร athlete ร kind) and `house_bets`.
## Polymarket
### Linking a market
1. **Markets โ Link a Polymarket market.** Paste a market or event URL/slug, or
search active events.
2. **For each outcome token**, choose:
- the athlete (by ID; find it on the Athletes page);
- the prediction: `race_win`, `race_podium`, `race_top10`, `race_make_final`,
`champion` or `standings_top3`;
- for race predictions, the race (a scheduled or in-progress event; blank means
"the next unknown round");
- whether the token pays when the prediction **fails** (e.g. the "No" side). The
fair price is then 1 โ p.
3. **The market page** shows the model's fair price (from the latest forecast run),
the live order book, and suggested maker quotes: 2 ticks inside fair value and
never crossing the book.
### Placing orders
Every order goes through **Preview โ Confirm โ Submit**. It's a **post-only GTC
limit order** (maker side only). Before signing, the app checks all of these:
- the price is within (0, 1) and on the market's tick size;
- the size is at least the market minimum;
- the notional (price ร size) is โค `POLYMARKET_MAX_ORDER_USD`;
- a BUY is below the best ask, and a SELL is above the best bid. Polymarket also
rejects any post-only order that would cross.
The preview shows the edge against the model and warns when the price is on the
wrong side of fair value. Every attempt is written to the `orders` table with the
model probability, model run, book at the time, and the exchange's response.
| Variable | Default | Meaning |
|---|---|---|
| `POLYMARKET_PRIVATE_KEY` | โ | Signer key. Without it the app only does lookups and order books. |
| `POLYMARKET_FUNDER` | โ | Address holding funds (Polymarket proxy wallet), if different from the signer. |
| `POLYMARKET_SIGNATURE_TYPE` | `0` | 0 = EOA, 1 = email/Magic proxy, 2 = browser-wallet proxy. |
| `POLYMARKET_API_KEY` / `_SECRET` / `_PASSPHRASE` | โ | API credentials. Derived from the key if unset. |
| `POLYMARKET_TRADING_ENABLED` | off | **Must be `true` to send orders.** Otherwise orders are signed locally as a dry run and nothing is sent. |
| `POLYMARKET_MAX_ORDER_USD` | `25` | Per-order notional cap. |
| `POLYMARKET_CLOB_HOST` / `POLYMARKET_GAMMA_HOST` / `POLYMARKET_CHAIN_ID` | Polymarket production / 137 | Endpoints. |
| `POLYMARKET_ORDER_VERSION` | `2` | Exchange order version for dry runs. Live orders use the version the CLOB reports. |
The banner on every page shows the mode: **no credentials**, **dry run**, or **LIVE**
(in red).
!!! note "CLOB V2 signing: dry-run tested only"
`racinglines/markets/polymarket/trade.py` signs with Polymarket's V2 client
(`py-clob-client-v2`), since Polymarket rejects V1-signed orders from 2026-04-28.
A dry run builds the V2 order and signs it locally (EIP-712 domain version 2) with
no network call. `tests/test_polymarket_trade.py` recovers the signer from that
signature. **No order has been sent with it.** Posting, open orders and cancels are
untested against the live CLOB. Check them with the owner's approval and a small
size before relying on them.
!!! warning
Check that your Polymarket account and jurisdiction are eligible before setting
`POLYMARKET_TRADING_ENABLED=true`. Start with the dry run, then small sizes.
Point-in-time model probabilities for downhill have limited calibration (see
[Evaluation](evaluation.md)), especially for winners.
### Market availability
As of 2026-09-26, searching Polymarket for "Whistler", "downhill", "Crankworx" and
"UCI downhill" finds **no downhill markets**. The linking flow works with any
Polymarket market, so markets can be linked as soon as they're listed.
## Kalshi
Kalshi's F1 markets are synced and their history stored (links, trades, hourly prices, in Postgres and
`data/archive/markets/kalshi/`), and the web app shows them at parity with Polymarket (on by default; with
`RACINGLINES_KALSHI_VENUE=0` the venue column says "Kalshi (soon)", the book page "Kalshi: coming soon", and no Kalshi
row appears anywhere): its quotes on the board, race and season pages, a `/markets/kalshi` list with mirror and refresh, the maker's Kalshi
record on Positions and Strategy, and the Kalshi replay in the Lab's event diagnostics (the page-by-page list is in
[Kalshi history](kalshi-history.md#in-the-app)). There's no linking flow to build: `markets --exchange kalshi sync`
links every F1 market it can classify ([F1](f1.md#kalshi-alignment)). Orders are dry runs unless
`KALSHI_TRADING_ENABLED=true` ([CLI](cli.md#kalshi-exchange-kalshi)). How Kalshi's feed differs from
Polymarket's: [Data](data.md#exchange-history-kalshi-and-polymarket).
**Cross-venue disagreement panel** (`RACINGLINES_DISAGREE=1`, off by default; the Markets page is byte-identical
without it): a collapsible card at the foot of the Markets page with the latest tick of the disagreement log
(`market_disagreements`, written by `racinglines markets disagree` and by the recorder's passes,
[CLI](cli.md#racinglines-markets)) for every outcome listed on both Polymarket and Kalshi: each venue's mid and top
of book, our fair, each venue's fee-adjusted taker edge, the gap (Kalshi mid โ Polymarket mid) and the gap net of both
taker fees, widest first, with the last seven days' count of liquid rows above fees. Rows where a venue's stored tape
shows nothing traded in the prior 24 h are greyed: that gap is on paper. Nothing renders while the log is empty.
The log itself and what its columns mean: [Kalshi history](kalshi-history.md#cross-venue-disagreement-log).
## Tested
Using FastAPI's test client against the Docker database:
- **Login:** requests without a login or with a wrong password get 401.
- **Forms:** a bad anti-forgery token gets 403.
- **Pages:** every page renders.
- **Market flow:** a live market lookup, link and book work.
- **Blocked orders:** preview blocks crossing BUYs and SELLs, over-cap orders and
off-tick prices. Submitting without confirmation gets 400.
- **Dry run:** a dry-run order is signed with a throwaway key and recorded.
- **Private book:**
- generate 75 markets;
- quote sheet;
- YES and NO bets with correct exposure and EV (e.g. $10 YES at 0.10 plus $50
NO at 0.97 โ โ$40 if YES, +$8.45 if NO);
- auto-settle waits for the Final;
- manual settlement.
- **Sign-in:**
- a browser without a session is redirected to `/signin`;
- wrong password โ error;
- right password โ cookie โ 200;
- Basic auth โ 200;
- forged cookie โ 401;
- the 9th failure from one IP โ 429;
- all of it checked through the Cloudflare tunnel too.
No real order has been sent.
---
# Alpha layout
The web app's layout for the fantasy alpha (owner, 2026-10-12): four tabs and nothing else. It is a switch, not a
rewrite. The pages it hides are archived, so their routes, templates and tests stay in the code and come back with
one environment variable. The code is in `racinglines/web/layout.py`.
The last staging commit before the layout is **8c3c05d** ("[app] Fantasy first cut: My F$ balance page, manual IOC
trading on recorded books, leaderboard (staging only)", #151). Check it out to see the app as it was.
## What it shows
| Tab | Address | What is on it | Code |
|---|---|---|---|
| **Markets** | `/markets` | Unchanged: the prediction markets now, and what the model says about the busiest prices | `web/views.py`, `templates/board.html` |
| **Trade** | `/trade` | Fantasy trading on the recorded order books: pick a market, see its book, place an IOC order in F$. An order never goes past the recorded book (depth 10) | `web/fantasy_routes.py`, `templates/fantasy_trade.html`, `fantasy/exchange.py` |
| **History** | `/history` | Your F$ (total, cash, by hand, strategy), F$ by hand through the run, open trades with Sell links, every order by hand (refused ones with the reason), your strategy's trades, and the leaderboard (`/history#leaderboard`) | `web/fantasy_routes.py` `history_page`, `templates/history.html`, `fantasy/ledger.py` |
| **Lab** | `/strategy/pick` | Opens on Follow a strategy (owner, 2026-10-13). The advanced tools: follow a strategy (`/strategy/pick`), its performance (`/strategy`), its positions (`/positions`), and for Pro accounts backtests and research (`/lab`). An account without Pro that opens `/lab` goes to `/strategy`. The unread-signals badge sits on the tab | `templates/_lab_nav.html` |
Admins also get **Admin**. The right side of the top bar keeps only the GitHub badge and the account links (name,
Settings or Sign up, Log out).
### The leaderboard's score
Score = ROI ร (trades รท 10)^0.3 (owner, 2026-10-25 21:03Z-21:04Z: "ROI is primary figure of merit, trade volume
adds a gradual boost. 1000 trades significantly better (2x?) than 100 trades and same ROI"). The boost is ร1 at 10
trades, ร1.4 at 30, ร2 at 100 and ร4 at 1,000: ten times the trades doubles it, so a player must trade ten times as
much to make up for half the ROI. It replaces 2026-10-12's ROI ร โtrades, which let volume outweigh ROI (100 trades
at +5% beat 9 at +15%). **This is the fantasy alpha's scoring rule and is likely to stay for the alpha unless major
issues turn up** (owner, 21:04Z). ROI is the P&L (by hand plus strategy) over the F$ staked: what an
account's buys paid, fees included, plus what its strategy paid in, net of what it sold back. Trades counts the
filled orders by hand plus the markets the strategy traded. An account needs `MIN_TRADES` (5) trades to be
ranked, so one lucky trade can't top the board. Accounts with fewer are listed after the ranked ones, by total
F$, with "โ" for rank. The demo accounts aren't listed. Open trades count at what they would sell for now (the best
recorded bid). The code is `fantasy/ledger.py` (`MIN_TRADES`, `VOLUME_BASE`, `VOLUME_POWER`, `TRADE_STATS`, `score()`, `ranked()`).
### Old addresses
| Old | Now |
|---|---|
| `/fantasy` (My F$) | 303 to `/history` |
| `/fantasy/trade?...` | 303 to `/trade?...` (the query string is kept) |
| `/leaderboard` | 303 to `/history#leaderboard` |
`POST /fantasy/orders` keeps its address and sends you back to `/trade?link=...`.
### Picking a style (`/strategy/pick`)
Under the alpha layout the picker is five **style cards** instead of the full list (owner, 2026-10-12 19:23-19:55Z,
asking for variety over best performance). Each card has a name, one sentence for a first-timer, its 2026 and 2025
backtests as a season return on F$1,000 ("backtest, season return on F$1,000 at our standard sizing": the profile's `why` P&L over the grant; no dollars, owner 2026-10-13) and a Follow button.
| Style | Profile | Note |
|---|---|---|
| Steady | T1 | |
| Head start | T2 | |
| Early bird | T8 | labelled in-sample: tuned on the same seasons it is scored on |
| Big markets only | T6 | |
| Hair trigger | T5 | |
- The names, sentences, numbers and order are one data structure: `ALPHA_STYLES` and `ALPHA_ORDER` in
`pipelines/profiles.py`. Users never see the codes or the `why` text.
- **Sizing:** Standard (the backtests' fixed stakes) or Kelly, with a fraction slider from 10% to 100% (default 25%),
quick picks for Quarter, Half and Full, and a tier label (Nitty, Shark, Spicy, Degen). Kelly is labelled untested.
The choice is saved on the profile as `sizing` and `kelly`, and the live signal pass sizes to it (`fantasy/sizing.py`).
Hand trades and the followed strategy share one F$1,000: each can use only what the other hasn't tied up.
- **Tweak drawer** (closed by default, everyone): two sliders (edge needed, market volume) and the dice. A
moved slider is labelled "untested: scored live from the next race weekend".
- **One user level** (owner, 21:04Z): under the alpha layout a basic account runs as pro (`layout.role`), so everyone
gets the Lab, the full picker and the tweaks. `users.role` is not rewritten; the classic layout brings the basic tier
back. An account already running a "Your picks" draw keeps it until it picks a style.
- Makers, the TB blend and the other codes aren't offered. `POST /strategy/pick` refuses them for everyone but admins
(the Edge Finder still shows makers).
- Saves still pause during a race weekend's signal window.
- **Fixed 2026-10-14:** Head start (T2) and Hair trigger (T5), whose stages include pre-weekend, saved as
"ยท adjusted" with the pre-weekend stage off when followed with no tweak (the drawer sends no pre-weekend field and it
read as off). They now save as tuned. A player who saved one before keeps the "ยท adjusted" profile until saving again.
- **From MCP and the CLI** (2026-10-14): `fantasy_strategy` reads the same cards, the profile followed and the lock;
`fantasy_follow` saves through the same function as the page (`fantasy/follow.follow`), so a pick from either writes
the same profile row, with the same lock, refusals and words. It offers the five styles (admins also the other
codes) and the drawer's two tweaks, on the caller's own account only. `racinglines remote call fantasy_follow` reaches
it from a terminal ([MCP](mcp.md), [CLI: racinglines remote](cli.md#racinglines-remote)).
- `/strategy` drops the venue tabs, the sport select and the "How it chooses" tab. Admins keep the user select.
- The switch is `strategy_pick.alpha_picker()`. With the classic layout the page is unchanged.
## What it hides
- **Live events:** `/live` and everything under it. The bar's Live link goes too.
- **Private books:** `/book` and everything under it (Books / My book). On Markets the "My open markets" KPI goes; on
Positions the private book's venue option, its P&L plot button, its mention in "About these positions", its rows
and the in-app bets go, and the "In-app markets you quote are on My book" line goes. `?venue=private` shows all
venues instead. History's strategy trades leave the private book out.
- **Pitch and Docs links** in the top bar's right side. The pages themselves still answer.
- The old bar's Strategy, Positions, My F$ and Leaderboard links. Their pages live under Lab and History.
Hidden routes answer **404 to everyone but admins**: `layout.guard()` runs inside `authenticate` in
`web/app.py`, for every signed-in page, and checks `HIDDEN_PREFIXES` (`/live`, `/book`). Admins can still open
them to check on them.
## The switch
| `RACINGLINES_LAYOUT` | Layout |
|---|---|
| `alpha` | The alpha layout |
| `classic` | The old bar and every page, as before (the fantasy pages, if on, are linked as Trade and My F$) |
| unset | Follows the fantasy switch, `fantasy/ledger.on()`: alpha where `RACINGLINES_FANTASY=1`, or on staging (`RACINGLINES_ENV=staging`) with that switch unset; classic otherwise, as on production today |
Templates read it through the `alpha_layout()` global (`web/app.py`). `positions_page` in `web/views.py` passes
`hide_private` to its template.
## Bringing the archived pages back
1. **For one deploy:** set `RACINGLINES_LAYOUT=classic` in the app's environment (staging: its env file on the VM)
and restart the web service. Everything comes back as it was, and nothing in the database changes.
2. **For good, page by page:** remove a prefix from `HIDDEN_PREFIXES` in `web/layout.py` to stop the 404, then add
its link to the alpha branch of the bar in `templates/base.html` (the `{% if user and alpha_layout() %}` block)
and drop the `alpha_layout()` / `hide_private` conditions in `board.html` and `positions.html` that hide it.
That is a code change, so it is a deploy.
3. **The whole alpha:** delete the alpha branch of the bar in `base.html` and make `alpha()` return False. The
old bar is still there below it, unchanged.
Tests: `tests/test_fantasy_trading.py` (the pages, the redirects, the nav under both layouts, the 404s, the
Positions page) and `tests/test_fantasy_exchange.py` (the score and the switch, without a database).
---
# Fantasy soft launch: sprint roadmap (29 Sep - 11 Oct 2026)
!!! note "Historical (2026-10-14)"
This is the plan as written on 29 and 30 Sep. What shipped is the **fantasy league alpha** on production
(2026-10-12): four tabs, F$1,000 per account, hand trading on F1's recorded Kalshi and Polymarket books, five
strategy styles, a leaderboard, no makers on exchange markets. The current description is
[Alpha layout](alpha-layout.md); the open items are on the [Roadmap](todo.md#priorities).
racinglines already prices F1 races, paper-trades them on Kalshi and Polymarket, and ran a live private book at
Whistler against a simulated crowd. The owner asked for a sprint through next weekend: a soft launch of fantasy
trading across every supported market, with players signing up as makers or takers and keeping that role for the
first fantasy season. That needs unique logins, per-user stats in the database, and a VM that stays up and fresh.
This page is the index: the verdict, the phases, the owner's dates and the decisions, with links. The detail
pages below are the reference.
**Status:** draft for the owner, Tue 2026-09-29; checked again Wed 2026-09-30 evening. **Built:** PR #71 (deploys
pause the timers, merged), the VM cutover (racinglines.bet runs on the GCE VM, `n2-standard-2`, which the owner keeps),
and the data the launch shows (F1, plus NASCAR and MotoGP status and demo rows on Markets). **Not built:** sign-up,
mail, the fantasy tables, the health, backup and Kalshi sync timers, and the staging host. Copilot builds SEC-1,
MAIL-1, ACC-1 and ACC-3 locally on Fri 2 โ Sat 3 Oct, behind `RACINGLINES_SIGNUP=off`. **CI and a staging host
(STG-1 to STG-4, [Roadmap](todo.md#priorities)) are now the top open item**: STAGE-1 below rehearses on that host
once it exists, instead of on production with the dry-run season. Every decision is open until the owner signs it.
| Page | What it holds |
|---|---|
| [Fantasy launch runbook](fantasy-runbook.md) | The day-by-day calendar, every task with its role, model and money flag, the merge sequence, the full decision table, the staging rehearsal, the go/no-go checklist, the launch, off-switch and rollback blocks, risks, the report outline |
| [Fantasy accounts](fantasy-accounts.md) | Sign-up, email verification, sessions, seasons and the role lock, the F$ ledger, stats and leaderboards, admin tooling, privacy, and the `fantasy_schema_v1` migration |
| [Fantasy trading](fantasy-trading.md) | Exchange paper trades for takers, the private book with human makers and takers, settlement, anti-abuse |
| [VM reliability](vm-reliability.md) | Targets, monitoring and alerts, data freshness, backups and restore, deploys after PR #71 and the deploy windows, capacity, incident runbooks |
| [Email setup](email-setup.md) | Proton Mail on racinglines.bet, every DNS record in Cloudflare, what (not) to do at Squarespace, and app mail through a Proton SMTP token |
---
## Summary
The full scope (open sign-up, every supported market, makers quoting on exchange markets, tape-only sports) cannot
ship safely by Singapore. What can ship is an **invite-gated F1 soft launch**. Go/no-go is Thu 8 Oct 12:00 PDT
(19:00 UTC) and sign-up opens **Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC)**, six and a half hours before the
Singapore book opens. Takers make exchange paper trades on Kalshi and Polymarket F1 markets at live venue prices,
and bet the in-app private book. Makers quote the private book only. The earliest safe date for open sign-up is
Thu 22 Oct. Makers on exchange markets and the tape-only sports come no earlier than R19. It is paper money (F$)
only: `POLYMARKET_TRADING_ENABLED` and `KALSHI_TRADING_ENABLED` are never touched. If the go/no-go fails, R17
becomes closed dry run #2 and the soft launch moves to Thu 22 Oct; with the fantasy code first on production on
Tue 6 and Wed 7 Oct, that fallback is the expected outcome if the staging rehearsal finds a P0.
| | At the soft launch (Thu 8 Oct 18:00 PDT) | Later |
|---|---|---|
| Who can join | Invited players only: `RACINGLINES_SIGNUP=invite`, `RACINGLINES_SIGNUP_CAP=50` (DEC-2) | Open sign-up (`RACINGLINES_SIGNUP=open`) not before Thu 22 Oct, decided at the retro (OWNER-10) |
| Takers | Exchange paper trades on Kalshi F1 (R17 race kinds; sprint per DEC-12) and Kalshi/Polymarket F1 season futures; bets on the private book | Tape-only sports and OG.com (NEXT-2), R19 or later |
| Makers | Quote the private book only (DEC-6) | Exchange markets (NEXT-1), R19 or later |
| Sports | F1 only | NASCAR, MotoGP, IndyCar stay tape-only this sprint; downhill has no event until 2027 |
| Money | F$ only: takers F$1,000, makers F$10,000, F$100 per bet or order (DEC-5, recommended); no prizes, no fees (DEC-1) | Any prize is a new decision taken with counsel |
| Season | `2026-s1`, trading from R17, `ends_at` left NULL (TBD) until the owner sets it (DEC-4) | Recommended end once the 2026 championship markets resolve (7-8 Dec) |
| Public surface | https://racinglines.bet through the Cloudflare tunnel only; `vm.sh public on` is never run (DEC-8) | Same |
| Off switch | `RACINGLINES_SIGNUP=off` plus `RACINGLINES_FANTASY=0`: sign-up closes and the pages hide, while the fantasy tick keeps settling open positions (it gates on season status) | Same |
| Fallback | R17 = closed dry run #2 with 25 or fewer invited testers; soft launch Thu 22 Oct (DEC-23) | |
The market-by-market scope (which Kalshi, Polymarket, OG.com and private-book markets are tradable, by whom) is in
[Fantasy trading](fantasy-trading.md). The final list is signed with DEC-20 on Wed 7 Oct.
## Assumptions
- **Sprint window:** Tue 29 Sep to Sun 11 Oct 2026 (13 days), plus Mon 12 Oct for the retro and report.
"Next weekend" means **F1 R17, Singapore** (sprint format), 9-11 Oct. R16 (2-4 Oct, the Bahrain GP run at
Sepang) is this weekend and serves as a closed reliability dry run.
- **No live-event freeze.** PR #71 (branch `work/deploy-pause`, pending the owner's merge) makes `vm.sh deploy`
pause the VM's timers, deploy, run a catch-up step and resume them. A failed deploy is recorded in
`/var/lib/racinglines/deploy-paused` and resumed by the next deploy. There is no `--force`. Deploys may run
during R16 and R17; between sessions is kindest, because the web app restarts for a few seconds. The same PR
rewrites the freeze text in `CLAUDE.md` and [VM deploy](vm-deploy.md). Merging it is the first owner step
(OWNER-1).
- **The owner runs every VM write and every merge.** Claude sessions produce branches, worker footers and
paste-ready, environment-labelled blocks that tee to logs. Running `vm.sh public on` and any DB migration
against the VM need the owner's explicit sign-off in the same turn.
- **"Fantasy" means paper money only** (F$, no cash value).
- **Times** are PDT with UTC in brackets. systemd `OnCalendar` values are UTC.
- **The VM cutover happens Wed 30 Sep**, as planned in [VM deploy](vm-deploy.md#cutover). If it slips, the P1 ops
items move with it, and the R16 dry run runs on whichever host serves racinglines.bet.
- **Kalshi listing lead time:** Kalshi listed R16 about 5.4 days out, so R17 is expected about Mon 5 - Tue 6 Oct.
If it has not listed by Thu 8 Oct, the exchange side of the launch is F1 season futures only.
- **Audience:** 50 or fewer invited players, F1 only.
- **Email:** Proton Mail Plus (1 domain, 10 addresses) is enough. Cloudflare stays the authoritative DNS
(`kipp.ns.cloudflare.com`, `zara.ns.cloudflare.com`); Squarespace stays the registrar only.
- **Alembic head** is `c8e3f6a2d4b1` on 29 Sep. ACC-1 re-checks `alembic heads` when it branches.
- **"More info coming":** the market details arrive by Wed 7 Oct 12:00 PDT. Until then workers build to the
recommended decisions, and anything undecided stays switched off.
- PR #66 (`docs/engine-roadmap.md`) is unrelated to this sprint.
## Phases
| Phase | Dates | Goal | Owner steps | Exit criterion | Detail |
|---|---|---|---|---|---|
| P0 Unblock, decide, cut over | Tue 29 - Wed 30 Sep | PR #71 merged, build-scope decisions signed, VM cutover with the public port closed, the sprint docs merged | OWNER-1 PR #71 by Wed 12:00 PDT; OWNER-4 cutover; OWNER-2 batch A by Wed 18:00 PDT | `vm.sh status` shows PR #71's sha; `vm.sh public off`; batch A signed | [Runbook P0](fantasy-runbook.md#p0-unblock-decide-cut-over-tue-29-sep-wed-30-sep) |
| P1 Reliability build, R16 closed dry run | Tue 29 Sep - Sun 4 Oct | Health, backup and Kalshi sync timers, snapshots and ops alerts on before the R16 book; features built on branches behind switches | LEGAL-2a and the early decisions Thu 1 Oct; ops batch deployed by Thu 12:00 PDT; OPS-7 hardening; DRY-1 with 5 or fewer staff testers; OWNER-3 Proton in the R16 weekend | Timers active and a test alert on the phone before Thu 20:30 PDT; the R16 incident log | [Runbook P1](fantasy-runbook.md#p1-reliability-build-and-r16-closed-dry-run-tue-29-sep-sun-4-oct), [VM reliability](vm-reliability.md) |
| P2 Schema, features, rehearsal | Mon 5 - Wed 7 Oct | OPS-6 and OPS-8 Monday; `fantasy_schema_v1` on the VM Tue 09:00 PDT; accounts batch Tue, trading batch Wed, each after a Fable read; two-part staging rehearsal; R16 settlement replay | OWNER-5 R16 routine; batch B; OWNER-8 migration with sign-off; MAIL-2; LEGAL-2; batch C; STAGE-1 parts A and B | Every P2 PR merged with switches off, golden diff empty, rehearsal and replay passed | [Runbook P2](fantasy-runbook.md#p2-schema-on-the-vm-fantasy-features-staging-rehearsal-mon-5-oct-wed-7-oct), [Fantasy accounts](fantasy-accounts.md), [Fantasy trading](fantasy-trading.md) |
| P3 Go/no-go and soft launch | Thu 8 Oct | Decide on evidence at 12:00 PDT; open invite-gated sign-up at 18:00 PDT | GO-1 sign-off; LAUNCH-1 (~45 min) | Every go/no-go line signed; the first invited player verified and granted F$ | [Go / no-go](fantasy-runbook.md#go-no-go), [Launch day](fantasy-runbook.md#launch-day) |
| P4 Singapore weekend | Fri 9 - Sun 11 Oct | On call, deploys only in the windows, every position settled by Sunday evening | OPS-10 on call and the support@ inbox; SETTLE-2 Sun from ~08:00 PDT | No outage over 15 min; everything settled or queued with a reason by Sun 18:00 PDT | [Runbook P4](fantasy-runbook.md#p4-singapore-soft-launch-weekend-f1-r17-fri-9-oct-sun-11-oct), [deploy windows](vm-reliability.md#deploy-windows) |
| P5 Retro and report | Mon 12 Oct, then to Thu 22 Oct | The soft-launch report at the project's bar; the open sign-up call | OWNER-10 retro decisions | `reports/2026-10-11-singapore-fantasy/report.md` with a verdict | [Runbook P5](fantasy-runbook.md#p5-retro-report-and-the-road-to-open-sign-up-mon-12-oct-follow-on-to-thu-22-oct) |
## The owner's dates
The full calendar, with race sessions and the build column, is in the [runbook](fantasy-runbook.md#calendar).
| When (PDT) | Owner step |
|---|---|
| Wed 30 Sep 12:00 | OWNER-1: merge and deploy PR #71; then OWNER-4 cutover |
| Wed 30 Sep 18:00 | Decision batch A |
| Thu 1 Oct 12:00 | LEGAL-2a (Kalshi and Polymarket data terms); DEC-18, DEC-19; the ops batch merged and deployed |
| Thu 1 Oct 18:00 | Early decisions DEC-1, 5, 7, 10, 11, 13, 14; OPS-7 hardening done; DRY-1 starts (testers created after a backup) |
| Fri 2 - Sun 4 Oct | OWNER-3 Proton Mail and DNS (steps in [Email setup](email-setup.md)); on call for R16 |
| Mon 5 Oct | OWNER-5 R16 routine; DEC-17 by 12:00 and resize if yes; Proton green by 12:00; OPS-6 + OPS-8 deployed; batch B by 18:00 |
| Tue 6 Oct 09:00 | OWNER-8 `fantasy_schema_v1` on the VM, with sign-off; then SEC-1 + MAIL-1, MAIL-2, LEGAL-2 by 12:00, the accounts batch by 16:00 |
| Tue 6 Oct 18:00 | STAGE-1 part A (accounts, mail, the role lock) |
| Wed 7 Oct 12:00 | Batch C; OWNER-9 R17 listings; the trading batch by 16:00; REPLAY-1 on the Mac |
| Wed 7 Oct 17:00 | STAGE-1 part B (book, exchange, settlement, admin) |
| Thu 8 Oct 12:00 | GO-1 go/no-go |
| Thu 8 Oct 18:00 | LAUNCH-1: sign-up opens (Fri 9 Oct 01:00 UTC); R17 live timer on before Fri 00:30 |
| Sun 11 Oct 08:00 | SETTLE-2; manual queue clear by 18:00 |
| Mon 12 Oct | OWNER-10 retro; REPORT-1 |
## Decisions
The full table (options, recommendation, reasons) is in the [runbook](fantasy-runbook.md#decisions). Until a
decision is signed, workers build to its recommendation behind a switch that is off. None of this is legal advice.
| ID | Question | Recommendation (short) | Needed by |
|---|---|---|---|
| DEC-1 | Prizes? | None: F$ with no cash value, no fees | Thu 1 Oct 18:00 |
| DEC-2 | Invite-only or open, and who tests R16? | Invite codes, cap 50; R16 5 or fewer staff testers | Wed 30 Sep 18:00 (A) |
| DEC-3 | App mail provider? | Proton SMTP now, a transactional provider before open sign-up if needed | Wed 30 Sep 18:00 (A) |
| DEC-4 | Season `2026-s1` dates? | From the soft launch; `ends_at` TBD, recommended after the championship markets resolve | Wed 7 Oct 12:00 (C) |
| DEC-5 | Bankrolls and limits? | Takers F$1,000, makers F$10,000, F$100 per bet or order, F$1,000 per maker market | Thu 1 Oct 18:00 |
| DEC-6 | Makers on exchange markets at launch? | No; private book only; NEXT-1 from R19 | Wed 30 Sep 18:00 (A) |
| DEC-7 | May players see venue prices? | Signed-in only, attributed; drop a venue whose terms forbid it | Thu 1 Oct 18:00 |
| DEC-8 | `vm.sh public on`? | Never | Thu 8 Oct 12:00 (Go) |
| DEC-9 | Which private book? | The in-app DB book; the crowd stays with the demo | Wed 30 Sep 18:00 (A) |
| DEC-10 | Role meaning and lock? | Hard lock with a reasoned admin override; Lab, promotion and syncs staff-only | Thu 1 Oct 18:00 |
| DEC-11 | Settlement? | Hybrid: the tick from results and venue resolution, admin queue for the rest; runs while a season is open | Thu 1 Oct 18:00 |
| DEC-12 | R17 sprint markets? | Exchange takers only, if Kalshi lists them | Wed 7 Oct 12:00 (C) |
| DEC-13 | Scoring? | Separate maker and taker boards, login-only, handles only | Thu 1 Oct 18:00 |
| DEC-14 | In-play trading? | Closed at the start of the deciding session | Thu 1 Oct 18:00 |
| DEC-15 | Demo accounts after launch? | Keep, separated from players | Mon 5 Oct 18:00 (B) |
| DEC-16 | Bot protection? | Turnstile plus a Cloudflare rate-limiting rule | Mon 5 Oct 18:00 (B) |
| DEC-17 | Resize the VM? | e2-medium on Mon 5 Oct unless R16 memory stays under 60% | Mon 5 Oct 12:00 (B) |
| DEC-18 | Recovery targets? | RPO 24 h, RTO 4 h, 99% availability | Thu 1 Oct 12:00 (A) |
| DEC-19 | Backup bucket? | The new bucket in project racinglines | Thu 1 Oct 12:00 (A) |
| DEC-20 | Tradable markets? | Kalshi F1 R17 and season futures, Polymarket season futures, private-book F1 | Build scope Wed 30 Sep; final Wed 7 Oct 12:00 |
| DEC-21 | DMARC? | `p=none` to Cloudflare DMARC Management on Fri 2 Oct; `p=quarantine` once tests pass at two providers (target Tue 6 Oct, before STAGE-1, no later than the go/no-go); `p=reject` after the season | Wed 30 Sep 18:00 (A) |
| DEC-22 | Terms, privacy and age wording? | 18+ attestation, one account per person, deletion = anonymise | Tue 6 Oct 12:00 (LEGAL-2) |
| DEC-23 | Launch date and fallback? | Thu 8 Oct only if every go/no-go line is signed; else Thu 22 Oct | Thu 8 Oct 12:00 (Go) |
## Off switch
`RACINGLINES_SIGNUP=off`, `RACINGLINES_FANTASY=0`, `RACINGLINES_FANTASY_BOOK=0` and
`RACINGLINES_FANTASY_EXCHANGE=0`, then restart web: sign-up closes, the pages hide and new orders and bets are
refused within about 10 seconds, and nothing is deleted. `racinglines fantasy tick` ignores `RACINGLINES_FANTASY`
and runs while a fantasy season is open or has anything unsettled, so open positions keep settling and the health
check keeps watching the tick. Stopping the tick is a separate step, only when settlement itself is wrong. The
paste-ready blocks are in the [runbook](fantasy-runbook.md#off-switch).
## Names used across these docs
**Master copy.** The four detail docs repeat the subset they use, word for word; the runbook links here. Change a name here first.
```text
NAMES EVERY DOC USES EXACTLY (docs/fantasy-launch.md holds the master copy; the other docs repeat the subset they use, word for word)
TERMS
- fantasy season = one row in fantasy_seasons. This is never the racing `seasons` table. First season slug: 2026-s1. Dry-run/staging season slug: 2026-dryrun. It is closed before launch and left out of stats.
- app role = users.role (admin|maker|taker); it controls permissions. season role = season_members.role (maker|taker); it is the choice made at sign-up, locked by season_members.locked_at until the season's status becomes closed.
- account type = users.account_type: player (signed up through /signup), staff (owner, admin, collaborators, R16 testers), demo (maker, taker), system (polymarket-takers). Only players appear in leaderboards and fantasy stats.
- handle = users.username. Lowercase, regex ^[a-z0-9][a-z0-9_-]{2,19}$. It is the only public identity; email is never shown to another user. Reserved: admin, root, staff, support, noreply, system, demo, maker, taker, polymarket-takers, racinglines, plus the value of ADMIN_USERNAME.
- F$ = fantasy dollars: paper money, no cash value, not transferable. Player-facing copy never uses a bare "$".
- private book = the in-app DB book (house_markets / house_bets). live book = the file-based demo book under data/runs/live// (demo maker plus simulated crowd). Players never trade the live book, and the crowd never fills a player's quote.
- exchange paper trade = a player's order filled against a venue's live order book (Kalshi, Polymarket) and stored only in fantasy_orders / fantasy_fills / fantasy_positions. It never goes into paper_positions or strategy_signals, and never through racinglines/markets/polymarket/trade.py or racinglines/markets/kalshi/trade.py.
- equity: taker = balance + open private-book stakes at cost + open exchange positions at liquidation value (YES shares x best YES bid; NO shares x (1 - best YES ask)), marked from market_links last_bid/last_ask. maker = balance (settled only; open worst case shown, not ranked). ROI = (equity - starting_bankroll) / starting_bankroll.
- closed dry run = R16, Thu 1 Oct to Sun 4 Oct, 5 or fewer admin-created staff tester accounts, no sign-up. staging rehearsal = Wed 7 Oct on the VM with RACINGLINES_SIGNUP=invite and tester codes in season 2026-dryrun. soft launch = RACINGLINES_SIGNUP=invite from Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC). open sign-up = RACINGLINES_SIGNUP=open (not before Thu 22 Oct).
- Times: owner-facing times in PDT with UTC in brackets; systemd OnCalendar in UTC.
TABLES: one additive Alembic revision fantasy_schema_v1, file migrations/versions/2026MMDD__fantasy_schema_v1.py, down_revision = the single head at branch time (c8e3f6a2d4b1 on 29 Sep)
- users (+): email String(254) NULL; email_verified_at; terms_version String(20); terms_accepted_at; age_confirmed_at; account_type String(10) NOT NULL DEFAULT 'staff' CHECK IN (player,staff,demo,system); session_epoch Integer NOT NULL DEFAULT 0; last_login_at; deleted_at. Indexes: unique ix_users_username_lower ON lower(username), unique ix_users_email_lower ON lower(email). CHECK users.role IN (admin,maker,taker). Backfill: maker, taker -> demo; polymarket-takers -> system; everything else -> staff.
- email_tokens: id, user_id FK CASCADE, purpose (verify|reset|email_change), token_hash String(64) UNIQUE (sha256 hex), email, created_at, expires_at, used_at. TTL: verify 24 h, reset 1 h, email_change 24 h.
- invite_codes: id, code_hash String(64) UNIQUE, label, season_id FK, created_by FK SET NULL, max_uses DEFAULT 1, uses DEFAULT 0, expires_at, revoked_at, created_at.
- fantasy_seasons: id, slug UNIQUE, name, status (draft|open|closed), starts_at, ends_at NULL (NULL = TBD), signup_opens_at, signup_closes_at NULL, bankroll_taker Numeric(12,2), bankroll_maker Numeric(12,2), max_stake Numeric(12,2), max_market_liability Numeric(12,2), notes JSONB, created_at.
- season_members: id, season_id FK, user_id FK, role (maker|taker), locked_at, joined_at, starting_bankroll Numeric(12,2), status (active|suspended|left). UNIQUE(season_id, user_id).
- bankroll_entries: id, season_id, user_id, ts, kind (grant|stake|payout|refund|trade|fee|settle|book_pnl|adjust), amount Numeric(12,2) signed, ref_table, ref_id, note, created_by. UNIQUE(ref_table, ref_id, kind). balance = SUM(amount).
- fantasy_orders: id, season_id, user_id, market_link_id FK, exchange (kalshi|polymarket), token_id, side (yes|no), action (buy|sell), shares_req, limit_price, status (filled|partial|rejected), reject_reason, book JSONB (levels used, fetched_at), created_at.
- fantasy_fills: id, order_id FK, season_id, user_id, market_link_id, exchange, side, action, shares, price, fee, ts.
- fantasy_positions: id, season_id, user_id, market_link_id, exchange, yes_shares, no_shares, cost, fees, status (open|settled|void), outcome Boolean NULL, payout, settled_at. UNIQUE(season_id, user_id, market_link_id).
- fantasy_event_results: season_id, user_id, event_key, venue (kalshi|polymarket|book), role, pnl, turnover, bets, won, settled_at. PK(season_id, user_id, event_key, venue).
- fantasy_stats: season_id, user_id, venue (all|kalshi|polymarket|book), role, starting_bankroll, balance, equity, pnl, roi, turnover, bets, settled, won, hit_rate, max_drawdown (positive number), sharpe (x sqrt(24); NULL under 4 events), events, rank, computed_at. PK(season_id, user_id, venue).
- house_markets (+): season_id FK NULL, closes_at, max_liability Numeric(12,2). house_bets (+): season_id FK NULL, fair_prob_at_bet, settled_at.
ENV (in /etc/racinglines.env on the VM; every new one is off or safe by default)
RACINGLINES_FANTASY=0|1 (master switch: nav, /fantasy, /leaderboard); RACINGLINES_SIGNUP=off|invite|open (default off); RACINGLINES_SIGNUP_CAP=50; RACINGLINES_FANTASY_BOOK=0|1; RACINGLINES_FANTASY_EXCHANGE=0|1; RACINGLINES_FANTASY_EXCHANGE_MAKERS=0 (stays 0 this sprint); RACINGLINES_MAIL_BACKEND=log|smtp (default log); RACINGLINES_SMTP_HOST=smtp.protonmail.ch; RACINGLINES_SMTP_PORT=587; RACINGLINES_SMTP_USER=noreply@racinglines.bet; RACINGLINES_SMTP_TOKEN (secret, never in git); RACINGLINES_MAIL_FROM=racinglines ; RACINGLINES_MAIL_REPLY_TO=support@racinglines.bet; RACINGLINES_MAIL_DAILY_CAP=200; RACINGLINES_TURNSTILE_SITEKEY and RACINGLINES_TURNSTILE_SECRET (empty = widget off); RACINGLINES_OPS_NTFY_TOPIC (ops alerts, separate from RACINGLINES_NTFY_TOPIC for market alerts); RACINGLINES_HEALTHCHECK_PING_URL (dead-man ping). Existing: RACINGLINES_URL=https://racinglines.bet (base for mail links); RACINGLINES_GCS_BUCKET; APP_SECRET (must be set); MAX_STAKE (per-bet cap, F$100); RACINGLINES_KALSHI_SPRINTS; RACINGLINES_CANCELLED_RACE_RULES (not changed by fantasy); RACINGLINES_DEMO_USERS; RACINGLINES_DEMO_CONTEXT. Never touched: POLYMARKET_TRADING_ENABLED, KALSHI_TRADING_ENABLED.
COOKIES: rl_session = uid|expires|sid|epoch|HMAC (epoch = users.session_epoch; bumping it ends every session). rl_csrf = signed double-submit token for unauthenticated forms.
ROUTES
Public (add to PUBLIC_PATHS): /signup, /verify/{token}, /verify/resend, /password/forgot, /password/reset/{token}, /terms, /privacy, /rules, /healthz.
Signed in: /account (GET/POST), /account/delete (POST), /fantasy, /fantasy/markets, /fantasy/orders (POST), /fantasy/book, /book/markets/{id}/take (existing POST), /leaderboard, POST /logout (GET /logout shows a confirm form).
Admin: /admin/fantasy, /admin/fantasy/seasons/{id}, /admin/fantasy/invites, /admin/fantasy/members/{user_id}, /admin/fantasy/settle.
CLI: racinglines fantasy season create|set|close|list; racinglines fantasy invite create|revoke|list; racinglines fantasy tick [--full]; racinglines fantasy stats [--full]; racinglines mail test --to ; racinglines ops health [--alert]; racinglines ops backup [--label ].
CODE: racinglines/fantasy/{seasons,ledger,book,exchange,settle,stats}.py; racinglines/markets/books.py (read-only live order books; does not import polymarket/trade.py); racinglines/web/{accounts,mailer,health}.py; racinglines/ops/{health,backup}.py; racinglines/cli/{fantasy,ops,mail}.py; TERMS_VERSION = '2026-10-08' in racinglines/web/accounts.py. Templates: signup, verify, forgot, reset, account, terms, privacy, rules, fantasy, fantasy_markets, fantasy_book, leaderboard, admin_fantasy (.html); mail/verify.txt, mail/reset.txt, mail/welcome.txt, mail/email_changed.txt. Tests: tests/test_auth.py, test_signup.py, test_mailer.py, test_fantasy_schema.py, test_fantasy_seasons.py, test_ledger.py, test_private_book.py, test_fantasy_book.py, test_fantasy_exchange.py, test_fantasy_settle.py, test_fantasy_stats.py, test_health.py, test_ops_health.py, test_ops_backup.py; fixtures in tests/fixtures/fantasy/.
activity_log actions (new): signup, signup_rejected, email_verify, verify_resend, password_reset_request, password_reset, password_change, email_change, account_delete, role_override, bankroll_adjust, member_suspend, invite_create, invite_revoke, season_update, fantasy_order, fantasy_order_rejected, fantasy_settle, fantasy_resettle, mail_sent, mail_failed.
SYSTEMD (deploy/vm/systemd/): racinglines-health.service + .timer (every 5 min); racinglines-backup.service + .timer (daily 10:00 UTC); racinglines-kalshi-sync.service + .timer (every 30 min, daily closed sync; this is U2); racinglines-fantasy.service + .timer (every 10 min: close, settle, stats). Existing and unchanged in name: racinglines-web, racinglines-recorder, racinglines-signals(.timer), racinglines-live-f1@(.timer), racinglines-live-dh@, racinglines-mcp, cloudflared. GCE: snapshot resource policy racinglines-daily (14-day retention) on disk racinglines-vm.
BACKUPS: pre-change data/backups/db/racinglines-before--.sql.gz (CLAUDE.md); nightly data/backups/db/racinglines-nightly-.sql.gz (7 kept on disk) and gs://$RACINGLINES_GCS_BUCKET/db/nightly/ (35-day lifecycle); owner-run logs /opt/racinglines/data/backups/-.log.
EMAIL (Proton Mail Plus, 10-address cap): admin@racinglines.bet (owner mailbox), support@racinglines.bet (catch-all target and reply-to), noreply@racinglines.bet (app sender, holds the SMTP token). DMARC reports go to the rua address from Cloudflare DMARC Management.
REPORT: reports/2026-10-11-singapore-fantasy/report.md (+ img/), rendered with racinglines/reporting/markdown_html.render.
```
## What this doesn't cover
- **The implementation detail.** It lives in the pages listed at the top; this page sequences them.
- **Legal advice.** DEC-1, DEC-7 and DEC-22 are the owner's calls, with counsel if wanted.
- **Real money.** No deposits, withdrawals, prizes or fees, and no change to the trading flags or the order path.
- **The R16 and R17 live-test plans themselves** (tiers, the live book, the crowd, the demo taker): see
[F1 live test](f1-live-roadmap.md) and the [weekend routine](todo.md#weekend-routine).
- **Tape-only sports, OG.com and exchange makers** beyond the NEXT-1 and NEXT-2 briefs in the runbook.
- **More than one VM,** a second tunnel connector, or a move to Cloud Run ([Google Cloud proposal](google-cloud.md)).
- **The engine roadmap** (PR #66), which is unrelated.
- **Work in flight in another thread (2026-09-29), not planned here:** the maker `/markets` calendar fix (PR #70,
merged), the NASCAR data fetch on the VM (`racinglines data fetch nascar`, 2017-2026) and its ingest, which comes next
and needs its own `data_changes` entry. The fantasy work touches different code (auth, users, the private book,
paper positions, new tables), but the VM is shared: check with the owner before a deploy lands during a long
fetch or ingest.
- **The VM as of 29 Sep:** `vm.sh public on` was active (plain HTTP on :8000, with scanner traffic in the web log).
Go/no-go line 5 ([runbook](fantasy-runbook.md#go-no-go)) requires `vm.sh public off` before any player signs up.
---
# Fantasy accounts: sign-up, seasons, bankrolls and stats
Today only an admin can create a racinglines account. There is one admin, two demo logins and one system account.
A session is a stateless 12-hour cookie, and one CSRF token serves the whole process. The soft launch opens sign-up
on Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC), with invite codes and at most 50 players. For it, people need to make
their own accounts: a unique handle, an email address they have proven they own, and a role (maker or taker; since
2026-09-30 the app calls these **pro** and **basic**, `racinglines/web/roles.py`, and this plan's role names read as those) that is
locked for the fantasy season. Each account then gets an F$ bankroll and a line on a leaderboard. This page is the
implementation spec for that. It covers what exists, the tables, the `fantasy_schema_v1` migration, the sign-up and
mail flows, session hardening, seasons and the role lock, the F$ ledger, stats and leaderboards, admin tooling,
privacy, and the tasks that build it all. How players trade is in [Fantasy trading](fantasy-trading.md). The
sprint plan is indexed in [Fantasy soft launch](fantasy-launch.md); the calendar, decisions, go/no-go and command
blocks are in the [runbook](fantasy-runbook.md). Proton Mail and DNS are in
[Email setup](email-setup.md), and the VM is in [VM reliability](vm-reliability.md).
**Status:** draft for the owner, Tue 2026-09-29. None of this is built yet. Every new table, route, module, flag and
switch below is marked **new**. Everything else is cited by file and line at `70eec70`. `main` (`1c417b2`) has not
changed `racinglines/web`, `racinglines/db` or `migrations` since then.
**Assumptions.** The sprint runs Tue 29 Sep to Sun 11 Oct 2026. "Next weekend" means F1 round 17, Singapore, 9-11
Oct. PR #71 (`work/deploy-pause`) merges first (OWNER-1). After that, `vm.sh deploy` pauses the VM's timers,
deploys, runs a catch-up step and resumes them. So there is no live-event freeze and no `--force`. A deploy
restarts web for a few seconds, so it is kindest between sessions. "Fantasy" means paper money only:
`POLYMARKET_TRADING_ENABLED` and `KALSHI_TRADING_ENABLED` are never touched. The owner runs every merge, every VM
write and every migration. Nothing here is legal advice.
---
## 1. What exists today
| # | Area | Today (file:line at `70eec70`) | What it means for the launch |
|---|---|---|---|
| 1 | Accounts table | `users` (`racinglines/db/models.py:424-434`) has `username` String(60) under a plain unique constraint, so `Max` and `max` are two accounts. It also has `display_name`, `role` String(10) with no CHECK, `password_hash`, `active`, `created_at` and `prefs` JSONB (which also holds the MCP token hash, `racinglines/mcp/auth.py`). There is no email, no account type and no last-login time | Add email, verification, account type and a session epoch, and make uniqueness case-insensitive |
| 2 | Passwords | scrypt with n=2^14, r=8, p=1 and a 16-byte salt (`racinglines/web/users.py:23-38`) | Keep as is |
| 3 | Creating accounts | Only through the admin's POST `/admin/users` (`racinglines/web/admin.py:83-95`), which calls `create_user` (`users.py:67-76`). `create_user` checks the role and an exact-match duplicate, strips spaces only on insert, and has no password policy. The 8-character minimum lives in the routes (`admin.py:87`, and `admin.py:121` for resets) | Add sign-up; move the policy into `create_user` |
| 4 | Session cookie | `rl_session` holds the user id, the expiry, a session id and an HMAC-SHA256 made with `APP_SECRET` (`racinglines/web/app.py:78-96`). It lasts 12 hours (`SESSION_HOURS`, `app.py:52`) and is HttpOnly, SameSite=Lax, and Secure behind https (`app.py:924-927`). It is stateless: GET `/logout` (`app.py:931-940`) only deletes the browser's copy, so a copied cookie keeps working until it expires. `authenticate` re-reads the user on every request (`app.py:113-118`), so setting `active = false` is the only way to end a session early | Add a way to sign someone out everywhere |
| 5 | `APP_SECRET` | Falls back to a new random value on each process start (`app.py:47`). Every restart then signs everyone out and changes the CSRF token. The VM's setup fills it in (`deploy/vm/setup.sh:62`) | Make it required once players can exist |
| 6 | CSRF | One token per process: HMAC(`APP_SECRET`, "csrf") (`app.py:48`), shared by every user and every session. It is a template global (`app.py:230`), read by the `csrf()` macro (`templates/_macros.html:9`) and by `static/app.js:14-18`, and checked by `check_csrf` (`app.py:150-152`). POST `/login` has no check (`app.py:906-907`), and logout is a GET | Use a token per session, plus a double-submit cookie for signed-out forms |
| 7 | Failed-login throttle | Kept in memory, per IP: 8 failures in 15 minutes gives a 429 (`app.py:55-75`). There is no per-username limit. An IP's entries are trimmed only when that IP comes back, and a restart clears everything. The IP comes from `cf-connecting-ip` (`app.py:61-65`), which anyone could forge if tcp:8000 were public (DEC-8) | Add pruning and a per-username counter (SEC-1). Sign-up and mail limits are counted in the database |
| 8 | HTTP Basic | Accepted for every account on every route (`app.py:107-134`). `scripts/deploy/smoke.sh:22-30` uses it to sign in as the demo accounts | Keep it for staff and demo accounts; refuse it for players |
| 9 | `next` | Goes into redirects without URL-encoding (`app.py:132`, `app.py:917`). The target must start with `/` and not `//` (`app.py:922`) | URL-encode it (SEC-1) |
| 10 | Demo accounts | Identified by username through `RACINGLINES_DEMO_USERS`, default `maker,taker` (`racinglines/web/demo.py:25`; `is_demo` at `demo.py:34`). Their password, `password`, is printed on the login page (`templates/login.html:9-13`). `demo.guard` refuses their writes and logs every request (`demo.py:62-83`). `/logout` is already an allowed POST for them (`demo.py:26`) | Give them account type `demo`; behaviour stays the same |
| 11 | System account | `polymarket-takers`: inactive, password `!no-login` (`users.py:53-64`) | Give it account type `system` |
| 12 | `ensure_admin` | Runs at every start (`app.py:159-162`, `users.py:79-94`). If the `ADMIN_USERNAME` account is missing, it creates it, and prints a generated password when `ADMIN_PASSWORD` is empty. If `ADMIN_PASSWORD` is set and differs from the stored hash, it resets the hash and forces `role = admin`, `active = true` | Whoever holds the `ADMIN_USERNAME` handle would become admin at the next restart. Reserve the name, and never touch a non-staff account |
| 13 | Audit | `U.log` (`users.py:97-105`) appends to `activity_log` (`models.py:437-448`) with the IP and path. `login_failed` keeps the typed username (`app.py:916`, `app.py:130`), which is sometimes a password typed into the wrong box | This is PII: prune it after 90 days |
| 14 | MCP | `SQL_HIDDEN = ("users", "orders")` (`racinglines/mcp/tools.py:24`). The `sql` tool can read `activity_log` (IPs, typed usernames) | Hide `activity_log` and the new token tables (ADM-1) |
| 15 | Private-book bets | `house_bets.counterparty` String(120) stores the username at bet time (`models.py:375`; `place_bet`, `app.py:867-894`), alongside `taker_id` (`models.py:382`). Stake, price and payout are Float (`models.py:377-379`) | Anonymising must rewrite `counterparty`, and the ledger rounds to cents |
| 16 | Stats code | `curve_stats` (`racinglines/reporting/metrics.py:6-19`) reports drawdown as a positive number, with the peak starting at 0. Sharpe is mean / sd (ddof 1) x sqrt(24), or 0.0 when sd is 0 | Reuse it as is |
| 17 | Alembic head | `c8e3f6a2d4b1` (`migrations/versions/20260929_c8e3f6a2d4b1_market_disagreements.py`) | It becomes the `down_revision` of the new revision |
---
## 2. Account types and roles
### Account types (new column `users.account_type`)
| `account_type` | Who | Created by | In fantasy stats and leaderboards | Backfill in `fantasy_schema_v1` |
|---|---|---|---|---|
| `player` | Everyone who signs up through `/signup` | ACC-3 | Yes, once verified, while their membership is `active` and the account is not deleted | None exist yet |
| `staff` | The owner, the admin, collaborators, the R16 testers | `/admin/users`, `ensure_admin` | No | Every other existing account (the column default) |
| `demo` | `maker`, `taker` | Already exist | No | `username IN ('maker', 'taker')` |
| `system` | `polymarket-takers` | `ensure_replay_taker` | No | `username = 'polymarket-takers'` |
The backfill uses the literal default demo usernames. If the VM's `RACINGLINES_DEMO_USERS` is set to anything
else, ACC-2's backfill counts will show it (expected: 2 demo and 1 system). `is_demo()` (`demo.py:34`) keeps
reading `RACINGLINES_DEMO_USERS` this sprint. A test asserts that every account it names has `account_type =
'demo'`.
### App role and season role
| | App role | Season role |
|---|---|---|
| Column | `users.role`: admin, maker or taker | `season_members.role`: maker or taker (new) |
| Controls | Permissions: `allow()` (`app.py:137-145`) and the staff test below | Which bankroll and which board the player gets in that season, and what they may do there |
| Set by | Sign-up (the chosen role), the admin | Verification, which copies `users.role` and sets `locked_at` |
| Changes | For a player, only with the season role (override, or joining a later season) | Only through an admin override with a reason while the season's `status` is not `closed` (ยง8) |
For a player, `users.role` always equals the `season_members.role` of the open season. The existing role form on
`/admin/users/{id}` (`admin.py:98-129`) will refuse to change a player who has a locked membership, and will point
to `/admin/fantasy/members/{user_id}` instead (ACC-4).
**Staff test (new, `racinglines/web/users.py`):** `is_staff(user)` is true when `user["role"] == "admin"` or
`user["account_type"] == "staff"`. `_user_dict` (`app.py:103-104`) gains `account_type`, `email_verified` (a bool)
and `epoch`.
### Handles
- Normalise first: strip spaces, then lowercase. Then apply the regex `^[a-z0-9][a-z0-9_-]{2,19}$` (3 to 20
characters).
- Reserved, and refused at sign-up and by `create_user`: admin, root, staff, support, noreply, system, demo, maker,
taker, polymarket-takers, racinglines, plus the value of `ADMIN_USERNAME` (lowercased).
- Also refused (new rule): any handle that starts with `deleted-`. That prefix is kept for anonymised accounts
(ยง12).
- Uniqueness is on `lower(username)`, through `ix_users_username_lower`.
- The handle is the only public identity. Email is never shown to another user. Players can't change their handle
this sprint.
- Existing staff and demo usernames don't have to match the regex, because only new accounts are checked.
`ensure_admin` and `ensure_replay_taker` are the only code paths allowed to create a reserved name.
### What each role can do after ROLE-1
"Staff maker" means role `maker` with `account_type = 'staff'`. Demo accounts can look at everything their role
can see, and `demo.guard` refuses all their writes, as today.
| Action (route) | Admin | Staff maker | Player maker | Player taker |
|---|---|---|---|---|
| Race, event, athlete and market pages | yes | yes | yes | yes, prices only |
| Launch a Lab job (POST `/lab/run`, `racinglines/web/views.py:434`) | yes | yes | **no** (DEC-10) | no |
| Promote a forecast (POST `/lab/promote/{run_id}`, `views.py:450`) | yes | yes | **no**: promoting changes prices for everyone | no |
| Exchange syncs (POST `/markets/polymarket/sync` `app.py:1016`, `/markets/kalshi/sync` `app.py:1058`, `/markets/{code}/sync` `app.py:1096`) | yes | yes | **no** | no |
| Generate, mirror, reprice, open or close private-book markets (`app.py:672`, `1005`, `1047`, `1086`, `734`, `744`) | yes | yes | yes, within the BOOK-2 bounds and BOOK-3 collateral | no |
| Settle a private-book market (`app.py:757`) | yes | no | no | no |
| Bet on the private book (POST `/book/markets/{id}/take`, `app.py:867`) | no | no | no | yes |
| Exchange paper trades (POST `/fantasy/orders`, new) | no | no | no (DEC-6) | yes |
| See fair values and edge | yes | yes | **only the model fair on private-book markets they generate** (DEC-10). No exchange edges: no edge columns on `/markets` pages or race pages, no `/strategy`, `/positions` or `/orders` (the house's paper trades), no `/live` picks | **never** |
| `/fantasy`, `/leaderboard` (new) | yes | yes | yes, once verified | yes, once verified |
| `/admin/fantasy` (new) | yes | no | no | no |
Player makers are invited outsiders, so the model's fair values and edges are not theirs to relay to a taker. They
see the fair value only where they need it to quote, as in [Fantasy trading](fantasy-trading.md). ROLE-1 tests that
a player maker gets no edge from the `/markets` pages, `/strategy`, `/positions`, `/orders`, the `/live` picks or a race page.
Player roles in this plan must not inherit Pro MCP research access. Current real Pro accounts have account-scoped
MCP access (default `RACINGLINES_MCP_ROLES=admin,pro`; demos excluded); player-role restrictions remain a separate
requirement when those roles are implemented.
---
## 3. Schema
Everything below ships in one Alembic revision (ยง4). Money columns are Numeric(12,2) in F$. Timestamps are
`DateTime(timezone=True)`. Seasons are closed, never deleted, and users are anonymised, never deleted. So foreign
keys to `fantasy_seasons` use RESTRICT, and so do the membership and ledger keys to `users`. The derived tables
cascade. Constraint and index names not listed in the conventions are new and are proposals for ACC-1.
### `users` (new columns)
| Column | Type | Null / default | Notes |
|---|---|---|---|
| `email` | String(254) | NULL | Stored as typed, compared with `lower()` |
| `email_verified_at` | timestamptz | NULL | NULL means unverified |
| `terms_version` | String(20) | NULL | `TERMS_VERSION` at acceptance: `2026-10-08` |
| `terms_accepted_at` | timestamptz | NULL | |
| `age_confirmed_at` | timestamptz | NULL | The 18+ attestation. No date of birth is stored (DEC-22) |
| `account_type` | String(10) | NOT NULL DEFAULT `'staff'` | CHECK `ck_users_account_type`: IN (player, staff, demo, system) |
| `session_epoch` | Integer | NOT NULL DEFAULT 0 | Part of `rl_session`. Bumping it ends every session of that user |
| `last_login_at` | timestamptz | NULL | Set by `/login` |
| `deleted_at` | timestamptz | NULL | Set when the account is anonymised |
- **Indexes and checks:**
- Unique `ix_users_username_lower` ON `(lower(username))`.
- Unique `ix_users_email_lower` ON `(lower(email))`. NULLs never collide, so staff with no email are fine.
- CHECK `ck_users_role`: `role IN ('admin', 'maker', 'taker')`.
- The existing unique constraint on `username` stays.
- **Model:** new mapped columns on `User` (`models.py:424`). `__table_args__` gets the two functional unique
indexes (`Index(..., func.lower(...), unique=True)`) and the two CHECKs.
### `email_tokens` (new)
| Column | Type | Constraints |
|---|---|---|
| `id` | Integer | PK |
| `user_id` | Integer | NOT NULL, FK `users.id` ON DELETE CASCADE, indexed |
| `purpose` | String(12) | NOT NULL, CHECK IN (verify, reset, email_change) |
| `token_hash` | String(64) | NOT NULL, UNIQUE: the sha256 hex of the raw token |
| `email` | String(254) | NOT NULL: the address the link was sent to (for `email_change`, the new address) |
| `created_at` | timestamptz | NOT NULL DEFAULT now() |
| `expires_at` | timestamptz | NOT NULL: `created_at` + 24 h (verify), 1 h (reset) or 24 h (email_change) |
| `used_at` | timestamptz | NULL. Set when the link is used, or when a newer token of the same purpose replaces it |
Index `ix_email_tokens_user_purpose` on `(user_id, purpose, created_at)`, for the resend limits.
### `invite_codes` (new)
| Column | Type | Constraints |
|---|---|---|
| `id` | Integer | PK |
| `code_hash` | String(64) | NOT NULL, UNIQUE: the sha256 hex of the normalised code |
| `label` | String(80) | NULL: the batch or person, e.g. `soft-launch-wave-1` |
| `season_id` | Integer | NOT NULL, FK `fantasy_seasons.id` ON DELETE RESTRICT. A code works only for its season |
| `created_by` | Integer | NULL, FK `users.id` ON DELETE SET NULL (NULL when made from the CLI) |
| `max_uses` | Integer | NOT NULL DEFAULT 1 |
| `uses` | Integer | NOT NULL DEFAULT 0, CHECK `uses >= 0 AND uses <= max_uses` |
| `expires_at` | timestamptz | NULL |
| `revoked_at` | timestamptz | NULL |
| `created_at` | timestamptz | NOT NULL DEFAULT now() |
A code is 12 characters drawn from `ABCDEFGHJKMNPQRSTUVWXYZ23456789`. That alphabet has 31 characters and leaves out
0, O, 1, I and L, which gives about 2^59 possible codes. Codes print as `XXXX-XXXX-XXXX`. Before hashing, a code is
normalised: uppercased, with dashes and spaces removed. A code is shown once, when it is created, and is never
stored in plain text.
### `fantasy_seasons` (new)
| Column | Type | Constraints |
|---|---|---|
| `id` | Integer | PK |
| `slug` | String(20) | NOT NULL, UNIQUE: `2026-s1`, `2026-dryrun` |
| `name` | String(80) | NOT NULL |
| `status` | String(10) | NOT NULL DEFAULT `'draft'`, CHECK IN (draft, open, closed) |
| `starts_at` | timestamptz | NOT NULL |
| `ends_at` | timestamptz | NULL. NULL means TBD. CHECK `ends_at IS NULL OR ends_at > starts_at` |
| `signup_opens_at` | timestamptz | NOT NULL |
| `signup_closes_at` | timestamptz | NULL. NULL means sign-up stays open while the season is open |
| `bankroll_taker` | Numeric(12,2) | NOT NULL |
| `bankroll_maker` | Numeric(12,2) | NOT NULL |
| `max_stake` | Numeric(12,2) | NOT NULL: the cap per book bet and per exchange order |
| `max_market_liability` | Numeric(12,2) | NOT NULL: the default for `house_markets.max_liability` |
| `notes` | JSONB | NULL. `{"dryrun": true}` marks a dry-run season; owner notes can go here too |
| `created_at` | timestamptz | NOT NULL DEFAULT now() |
There is a unique partial index `ix_fantasy_seasons_one_open` ON `(status) WHERE status = 'open'`, so at most one
season can be open. The launch order depends on this: close `2026-dryrun` first, then open `2026-s1`.
### `season_members` (new)
| Column | Type | Constraints |
|---|---|---|
| `id` | Integer | PK |
| `season_id` | Integer | NOT NULL, FK `fantasy_seasons.id` RESTRICT |
| `user_id` | Integer | NOT NULL, FK `users.id` RESTRICT, indexed |
| `role` | String(5) | NOT NULL, CHECK IN (maker, taker) |
| `locked_at` | timestamptz | NOT NULL: the verification time. The lock holds until the season's status is `closed` |
| `joined_at` | timestamptz | NOT NULL DEFAULT now() |
| `starting_bankroll` | Numeric(12,2) | NOT NULL: copied from the season when the member joins |
| `status` | String(10) | NOT NULL DEFAULT `'active'`, CHECK IN (active, suspended, left) |
UNIQUE `uq_season_members_season_user` on `(season_id, user_id)`.
### `bankroll_entries` (new)
| Column | Type | Constraints |
|---|---|---|
| `id` | BigInteger | PK |
| `season_id` | Integer | NOT NULL, FK `fantasy_seasons.id` RESTRICT |
| `user_id` | Integer | NOT NULL, FK `users.id` RESTRICT |
| `ts` | timestamptz | NOT NULL DEFAULT now() |
| `kind` | String(10) | NOT NULL, CHECK IN (grant, stake, payout, refund, trade, fee, settle, book_pnl, adjust) |
| `amount` | Numeric(12,2) | NOT NULL, signed: + is a credit, - is a debit |
| `ref_table` | String(40) | NULL: the table of the row that caused the entry |
| `ref_id` | BigInteger | NULL |
| `note` | Text | NULL |
| `created_by` | Integer | NULL, FK `users.id` SET NULL (NULL for the tick and for sign-up) |
UNIQUE `uq_bankroll_entries_ref` on `(ref_table, ref_id, kind)`. Index `ix_bankroll_entries_member` on
`(season_id, user_id, ts)`. `balance = SUM(amount)`. ยง9 has the rules.
### `fantasy_event_results` (new, derived)
| Column | Type | Notes |
|---|---|---|
| `season_id` | Integer | FK `fantasy_seasons.id` ON DELETE CASCADE |
| `user_id` | Integer | FK `users.id` ON DELETE CASCADE |
| `event_key` | String(40) | `events.source_key` for race markets (as `private_book._event_key`, `racinglines/markets/private_book.py:360-361`). For season-level markets, `--season`, e.g. `f1_wdc-2026-season` |
| `venue` | String(10) | CHECK IN (kalshi, polymarket, book) |
| `role` | String(5) | maker or taker |
| `pnl` | Numeric(12,2) | Settled P&L for this event and venue |
| `turnover` | Numeric(12,2) | |
| `bets` | Integer | |
| `won` | Integer | |
| `settled_at` | timestamptz | The latest settlement that fed this row |
PK `(season_id, user_id, event_key, venue)`. It is rebuilt from the ledger by `racinglines fantasy stats --full`.
### `fantasy_stats` (new, derived)
| Column | Type | Notes |
|---|---|---|
| `season_id`, `user_id` | Integer | FKs, ON DELETE CASCADE |
| `venue` | String(10) | CHECK IN (all, kalshi, polymarket, book) |
| `role` | String(5) | |
| `starting_bankroll` | Numeric(12,2) | |
| `balance`, `equity` | Numeric(12,2) | Only on the `all` row. NULL on the venue rows |
| `pnl` | Numeric(12,2) | |
| `roi` | Float | |
| `turnover` | Numeric(12,2) | |
| `bets`, `settled`, `won` | Integer | |
| `hit_rate` | Float | NULL when `settled = 0` |
| `max_drawdown` | Numeric(12,2) | A positive number |
| `sharpe` | Float | x sqrt(24). NULL under 4 events |
| `events` | Integer | |
| `rank` | Integer | NULL when the player is not eligible or is hidden |
| `computed_at` | timestamptz | |
PK `(season_id, user_id, venue)`. ยง10 defines the numbers.
### The trading tables (same revision)
`fantasy_orders`, `fantasy_fills`, `fantasy_positions`, and the new columns on `house_markets` (`season_id`,
`closes_at`, `max_liability`) and `house_bets` (`season_id`, `fair_prob_at_bet`, `settled_at`) are defined in
[Fantasy trading](fantasy-trading.md). ACC-1 builds them in the same revision.
---
## 4. Migration `fantasy_schema_v1` (ACC-1, ACC-2, OWNER-8)
### Rules
- **One additive revision.** The file is `migrations/versions/2026MMDD__fantasy_schema_v1.py`, where `MMDD` is
the day ACC-1 creates it and `` comes from `alembic revision`.
- **`down_revision`** is the single head at branch time: `c8e3f6a2d4b1` on 29 Sep. ACC-1 runs `alembic heads` when
it branches and again before the owner merges. The output must be exactly one line. If `main` has moved, rebase
and repoint `down_revision`. Never merge two heads.
- **Every new column is nullable or has a server default.** `update.sh` migrates before web restarts
(`deploy/vm/update.sh:24-26`), so the old process briefly runs against the new schema. Additive changes keep that
safe.
- **Guards first.** Before changing anything, `upgrade()` looks for usernames that differ only by case and for
roles outside admin, maker and taker. If it finds any, it raises with the list. Postgres runs the revision in one
transaction, so nothing changes. The owner then fixes those rows by hand, backup first, and runs it again.
- **Order inside `upgrade()`:**
1. The guards.
2. The `users` columns.
3. The `account_type` backfill.
4. The two CHECKs.
5. The two `lower()` unique indexes.
6. The new tables in foreign-key order: `fantasy_seasons`, `invite_codes`, `email_tokens`, `season_members`,
`bankroll_entries`, `fantasy_event_results`, `fantasy_stats`, then `fantasy_orders`, `fantasy_fills`,
`fantasy_positions`.
7. The `house_markets` and `house_bets` columns.
- **No seed rows.** Seasons and invites come from the CLI (ยง8) at STAGE-1 and LAUNCH-1.
- **A real `downgrade()`.** It is the exact reverse: drop the `house_*` columns, drop the ten tables in reverse
order, drop the two indexes and the two CHECKs, then drop the nine `users` columns. A downgrade loses every row in
the new tables. That is why rolling back on the VM means restoring the pre-deploy dump, not running `alembic
downgrade`.
- **Locks.** `ALTER TABLE users` takes a brief exclusive lock, and `users` has fewer than 10 rows. Adding nullable
columns to `house_markets` and `house_bets` only changes the catalogue.
- **Same PR:** the models in `racinglines/db/models.py`, and `docs/database.md` (table rows plus a line in the
migrations table).
```python
# migrations/versions/2026MMDD__fantasy_schema_v1.py: outline for ACC-1, not final code
revision = ""
down_revision = "c8e3f6a2d4b1" # re-check with `alembic heads` at branch time and before merge
def upgrade():
conn = op.get_bind()
dupes = conn.execute(sa.text(
"SELECT lower(username), array_agg(username) FROM users GROUP BY 1 HAVING count(*) > 1")).all()
roles = conn.execute(sa.text(
"SELECT DISTINCT role FROM users WHERE role NOT IN ('admin', 'maker', 'taker')")).all()
if dupes or roles:
raise RuntimeError(f"fantasy_schema_v1: fix these rows first: case duplicates {dupes}, roles {roles}")
op.add_column("users", sa.Column("email", sa.String(254), nullable=True))
op.add_column("users", sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("terms_version", sa.String(20), nullable=True))
op.add_column("users", sa.Column("terms_accepted_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("age_confirmed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("account_type", sa.String(10), nullable=False, server_default="staff"))
op.add_column("users", sa.Column("session_epoch", sa.Integer(), nullable=False, server_default="0"))
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
op.execute("UPDATE users SET account_type = 'demo' WHERE username IN ('maker', 'taker')")
op.execute("UPDATE users SET account_type = 'system' WHERE username = 'polymarket-takers'")
op.create_check_constraint("ck_users_role", "users", "role IN ('admin', 'maker', 'taker')")
op.create_check_constraint("ck_users_account_type", "users",
"account_type IN ('player', 'staff', 'demo', 'system')")
op.create_index("ix_users_username_lower", "users", [sa.text("lower(username)")], unique=True)
op.create_index("ix_users_email_lower", "users", [sa.text("lower(email)")], unique=True)
op.create_table("fantasy_seasons", ...) # columns as in section 3
op.create_index("ix_fantasy_seasons_one_open", "fantasy_seasons", ["status"], unique=True,
postgresql_where=sa.text("status = 'open'"))
... # invite_codes, email_tokens, season_members, bankroll_entries, fantasy_event_results, fantasy_stats
... # fantasy_orders, fantasy_fills, fantasy_positions, house_markets / house_bets columns (fantasy-trading.md)
def downgrade():
... # the exact reverse of upgrade(); data in the new tables is lost
```
### Run order
The steps are: ACC-2's trial on a restored copy (Mon 5 Oct), then the data-change record, then the owner's merge
and VM deploy (OWNER-8, Tue 6 Oct 09:00 PDT (16:00 UTC)), then the check. Rollback is last. OPS-8 must already be
in `main`: it adds `--migrate` to `vm.sh deploy`, which dumps the database before migrating. OPS-4 provides
`scripts/ops/restore_scratch.sh`. Both are specified in [VM reliability](vm-reliability.md). Run the LOCAL
commands below from a checkout of `work/acc-1`; a worktree is fine.
```sh
# LOCAL (Mac) โ ACC-2 step 1 (Mon 5 Oct): the newest VM dump -> racinglines_scratch, then alembic upgrade head.
# OPS-4's script. It writes the scratch database only and never touches `racinglines`. Keep this shell open for steps 2-3.
UTC=$(date -u +%Y%m%dT%H%M%SZ); LOG=data/backups/acc2-trial-$UTC.log
bash scripts/ops/restore_scratch.sh 2>&1 | tee "$LOG"
```
```sh
# LOCAL (Mac) โ ACC-2 step 2: downgrade and upgrade again on the scratch copy, timed (the downgrade's only rehearsal)
export DATABASE_URL=postgresql+psycopg://racinglines:racinglines@localhost:5433/racinglines_scratch
{ time .venv/bin/alembic downgrade -1; time .venv/bin/alembic upgrade head; .venv/bin/alembic current; } 2>&1 | tee -a "$LOG"
unset DATABASE_URL
```
```sh
# LOCAL (Mac) โ ACC-2 step 3 (read-only): backfill counts, case duplicates, new tables on the scratch copy.
# Expected: demo 2, system 1, staff = the rest; no duplicate rows; new_tables = 10.
PG_BIN=${PG_BIN:-$HOME/miniconda3-arm64/envs/racinglines-db/bin}
"$PG_BIN/psql" postgresql://racinglines:racinglines@localhost:5433/racinglines_scratch \
-c "SELECT account_type, role, count(*) FROM users GROUP BY 1, 2 ORDER BY 1, 2" \
-c "SELECT lower(username) AS handle, count(*) FROM users GROUP BY 1 HAVING count(*) > 1" \
-c "SELECT count(*) AS new_tables FROM information_schema.tables WHERE table_name IN ('email_tokens', 'invite_codes', 'fantasy_seasons', 'season_members', 'bankroll_entries', 'fantasy_event_results', 'fantasy_stats', 'fantasy_orders', 'fantasy_fills', 'fantasy_positions')" \
2>&1 | tee -a "$LOG"
```
**ACC-2 step 4, the record (in the ACC-1 PR).** Add a section to [Data changes](data-changes.md), headed
`2026-10-06 ยท VM: fantasy_schema_v1`. It gives the reason, the trial log path and the timings from step 2, and the
backfill counts from step 3. It names the backup as
`data/backups/db/racinglines-before-deploy--.sql.gz` on the VM; the owner fills in the exact name that
`vm.sh` prints, and DOC-2 commits it. The undo is to restore that dump. The `data_changes` row on the VM is written
by `vm.sh deploy --migrate` (OPS-8), and step 3 below checks for it.
```sh
# LOCAL (Mac) โ OWNER-8 step 1, Tue 6 Oct 09:00 PDT (16:00 UTC): merge, then the pre-merge checks (.venv active)
git checkout main && git pull
git merge --no-ff work/acc-1 # โ ๏ธ needs special attention: DB migration (users columns and CHECKs, 10 new tables, house_* columns); the VM step next needs the owner's sign-off
racinglines check
python -m pytest -m "not live"
mkdocs build --strict
git diff --stat HEAD^1 HEAD -- tests/golden # must print nothing
git push
```
```sh
# VM (production), run from the Mac with vm.sh โ OWNER-8 step 2
# โ ๏ธ needs special attention: migrates the production database. Needs the owner's explicit sign-off in the same turn.
# vm.sh --migrate (OPS-8) dumps first and prints the dump's name: copy it into the data-changes line.
UTC=$(date -u +%Y%m%dT%H%M%SZ); LOG=data/backups/owner8-deploy-$UTC.log
bash scripts/deploy/vm.sh status 2>&1 | tee "$LOG" # note the running sha: the rollback target
bash scripts/deploy/vm.sh deploy main --migrate 2>&1 | tee -a "$LOG"
bash scripts/deploy/smoke.sh https://racinglines.bet 2>&1 | tee -a "$LOG"
```
```sh
# LOCAL (Mac) โ open a shell on the VM for step 3
bash scripts/deploy/vm.sh ssh
```
```sh
# VM (production) โ OWNER-8 step 3 (read-only): the revision, the dump, the data_changes row, the backfill
# (the rl / rlsql helpers are the same as in fantasy-runbook.md)
rl() { sudo -u racinglines -H bash -c 'cd /opt/racinglines && set -a && . /etc/racinglines.env && set +a && exec .venv/bin/racinglines "$@"' rl "$@"; }
rlsql() { sudo -u racinglines -H bash -c 'cd /opt/racinglines && docker compose exec -T db psql -U racinglines -d racinglines -v ON_ERROR_STOP=1 -P pager=off'; }
LOG=/opt/racinglines/data/backups/owner8-check-$(date -u +%Y%m%dT%H%M%SZ).log
sudo -u racinglines -H bash -c 'cd /opt/racinglines && set -a && . /etc/racinglines.env && set +a && .venv/bin/alembic current' 2>&1 | sudo tee "$LOG"
rl db changes --limit 3 2>&1 | sudo tee -a "$LOG"
sudo ls -lt /opt/racinglines/data/backups/db | head -3 | sudo tee -a "$LOG"
rlsql <<'SQL' 2>&1 | sudo tee -a "$LOG"
SELECT account_type, role, count(*) FROM users GROUP BY 1, 2 ORDER BY 1, 2;
SQL
```
**Rollback, only if the deploy or the smoke check failed.** There is one runbook for this, and this page doesn't
keep a second copy: [VM reliability, Bad migration](vm-reliability.md#bad-migration). Its order is: deploy the sha
before the migration first (after OPS-8 that deploy skips `alembic` with a warning), then stop the writers, dump
what is there, load the pre-deploy dump, start everything again, smoke through Cloudflare, and record both dumps in
`data_changes`. For OWNER-8 the dump to load is the `racinglines-before-deploy--.sql.gz` that `vm.sh
--migrate` printed in step 2, and the sha to deploy is the one before the ACC-1 merge. Every step that writes the
VM database needs the owner's explicit sign-off in the same turn (โ ๏ธ needs special attention: it replaces the
production database). Rows written between the dump and the restore are lost from the database, so act quickly.
Book snapshots from that window are also in the recorder's Parquet archive. If any account was anonymised in that
window, re-apply the deletions after the restore (ยง12, "Backups and restores").
The Mac's own dev database is upgraded only after the merge. Before that, a checkout of `main` would find a
revision it doesn't know.
```sh
# LOCAL (Mac) โ after OWNER-8 only: back up the Mac's dev database, then upgrade it (CLAUDE.md: back up first)
UTC=$(date -u +%Y%m%dT%H%M%SZ); PG_BIN=${PG_BIN:-$HOME/miniconda3-arm64/envs/racinglines-db/bin}
"$PG_BIN/pg_dump" --no-owner --no-privileges postgresql://racinglines:racinglines@localhost:5433/racinglines | gzip -6 > data/backups/db/racinglines-before-fantasy-schema-v1-$UTC.sql.gz
.venv/bin/alembic upgrade head
.venv/bin/racinglines db changes --add "fantasy_schema_v1 on the Mac dev database; backup data/backups/db/racinglines-before-fantasy-schema-v1-$UTC.sql.gz"
```
To roll the Mac back, restore that file the same way the VM rollback does, into the local `racinglines` database.
---
## 5. Sign-up (ACC-3)
### States
| `RACINGLINES_SIGNUP` | GET `/signup` | POST `/signup` | Login page |
|---|---|---|---|
| `off` (default) | 404 | 404 | No sign-up link |
| `invite` (the soft launch) | Form, and an invite code is required | The code must be valid for the open season | "Have an invite? Sign up" |
| `open` (not before Thu 22 Oct) | Form, and a code is optional (used if given) | | "Sign up" |
The form also needs an open fantasy season, with `signup_opens_at <= now` and `signup_closes_at` either NULL or
still in the future. Otherwise the page shows "Sign-up is closed" (200) and no form.
`RACINGLINES_SIGNUP_CAP` (new, default 50) counts accounts where `account_type = 'player'` and `deleted_at IS
NULL`. Unverified sign-ups count too, until they expire at 72 hours (ยง6). At the cap the page shows "Sign-up is
full for now", and POST writes `signup_rejected` with reason `cap`.
### The form (`templates/signup.html`, new)
| Field | Rule | Stored as | Message on error |
|---|---|---|---|
| Invite code | Required under `invite`. Normalised, then matched by sha256. The code must not be revoked or expired, must have `uses < max_uses`, and must belong to the open season | `invite_codes.uses + 1`, through a conditional `UPDATE ... SET uses = uses + 1 WHERE id = :id AND uses < max_uses AND revoked_at IS NULL` in the same transaction as the insert. If no row changes, the sign-up is refused | "That invite code isn't valid." (the same text for every reason) |
| Handle | The rules in ยง2 | `users.username` and `display_name` | "Handles are 3-20 characters: a-z, 0-9, _ and -, starting with a letter or digit." or "That handle is taken." (also shown for reserved names) |
| Email | Trimmed, at most 254 characters, one `@`, and a dot in the domain. Refused if the domain is `racinglines.bet` (the catch-all would deliver it to support@). Unique by `lower(email)` in the index, and also by a normalised form checked in the same transaction: the `+tag` is dropped, and for `gmail.com` and `googlemail.com` the dots are dropped and the domain becomes `gmail.com` | `users.email` (as typed) | "That doesn't look like an email address." (also for `racinglines.bet`) or "That email already has an account: sign in or reset your password." (this reveals that the address is registered; see Open items) |
| Password | 10-128 characters, and must not contain the handle | `users.password_hash` (scrypt) | "Passwords are 10 to 128 characters and can't contain your handle." |
| Role | Radio buttons, maker or taker, required, neither preselected | `users.role`. The lock is set at verification (ยง6) | "Pick maker or taker." |
| 18+ | Required checkbox: "I am 18 or older" | `users.age_confirmed_at` | "You must be 18 or older to play." |
| Terms | Required checkbox: "I agree to the Terms, the Privacy notice and the Rules", with a link to each | `users.terms_version = TERMS_VERSION` (`2026-10-08`) and `users.terms_accepted_at` | "Please accept the terms." |
| Turnstile | When both keys are set (DEC-16) | nothing | "Please complete the check." |
| `csrf` | Must match the `rl_csrf` cookie (ยง7) | nothing | 403 |
After an error the form comes back with a 400 and the fields kept, except the password. The message sits next to
its field.
The copy above the form reads "Paper money only. F$ have no cash value, there are no prizes, and nothing here is
investment advice." This line is on the go/no-go list. The copy next to the role radio reads:
> **Taker**: trade F1 markets at live Kalshi and Polymarket prices, and bet on makers' private-book quotes.
> Starting bankroll F$1,000.
>
> **Maker**: quote your own prices on private-book markets for takers to bet on. Starting bankroll F$10,000.
>
> Your role is locked until the season closes.
The amounts are read from `bankroll_taker` and `bankroll_maker` on the open season, never hard-coded.
!!! note "Owner input: DEC-1, DEC-4, DEC-5, DEC-22"
The copy assumes no prizes (DEC-1), bankrolls of F$1,000 for takers and F$10,000 for makers (DEC-5), an 18+
attestation with no date of birth (DEC-22), and a season end still TBD (DEC-4). If any decision lands
differently, change the season row and the template text. The flow stays the same.
### What a successful POST `/signup` does
The whole thing is one database transaction:
1. Load the open season, then check the sign-up window and the cap.
2. Redeem the invite code with the conditional `UPDATE`.
3. Insert the `users` row: `username = display_name = handle`, `email`, `role`, `account_type = 'player'`,
`password_hash`, `terms_version`, `terms_accepted_at = now()`, `age_confirmed_at = now()`, `active = true`,
`email_verified_at = NULL`.
4. Insert an `email_tokens` row: purpose `verify`, expiring in 24 h.
5. Commit.
6. Queue `mail/verify.txt` through the mailer (a background send, MAIL-1) and write the `signup` log row.
7. Set `rl_session` for the new account and redirect 303 to `/account`, which says "We sent a link to" followed
by the address.
If two sign-ups race, the unique indexes decide: the loser gets the "taken" message.
### Rate limit and Turnstile
- **Counting IPs:** every per-IP limit on this page (sign-up, resend, forgot) counts an IPv6 address by its /64,
so one host can't step around it by rotating addresses inside its own prefix. IPv4 addresses count as they are.
- **Sign-up limit:** 5 attempts per hour per IP. The count is taken from `activity_log` rows (`signup`, plus
`signup_rejected` except those with reason `rate`) from that IP in the last hour. Over the limit, the page returns
429 and a `signup_rejected` row with reason `rate`. Because the count lives in the database, a deploy doesn't
reset it.
- **Turnstile (DEC-16):** active when `RACINGLINES_TURNSTILE_SITEKEY` and `RACINGLINES_TURNSTILE_SECRET` are both
set.
- `signup.html` and `forgot.html` then load `https://challenges.cloudflare.com/turnstile/v0/api.js` and show the
widget.
- Before doing anything else, the server posts `secret`, `response` and `remoteip` to
`https://challenges.cloudflare.com/turnstile/v0/siteverify`, with a 5 s timeout.
- A failure or a timeout refuses the sign-up (fail closed) and writes `signup_rejected` with reason `turnstile`.
- It covers every signed-out form that can send mail: `/signup`, `/password/forgot` and the signed-out
`/verify/resend` form. `/login` sends no mail and relies on the throttle (ยง7 row 7).
- Empty keys turn the widget off, which is what local dev and the tests use.
- The owner creates the widget in Cloudflare and puts both keys only in `/etc/racinglines.env`.
- Turnstile stays an owner decision (DEC-16), but these forms send mail to any address typed in, so without it
the only guard is the per-IP and per-address limits, which an attacker rotating IPs can spread out. So the app
logs a warning at every start while `RACINGLINES_SIGNUP` is not `off` or `RACINGLINES_MAIL_BACKEND=smtp` and the
keys are empty, and this doc's go/no-go line asks for either the keys set and a working widget, or DEC-16
recorded as "no". The recommendation is yes, with the Cloudflare rate-limiting rule on `/signup` and
`/password` from DEC-16.
### `PUBLIC_PATHS`
The tuple at `app.py:53` becomes `("/login", "/static", "/racinglines101", "/signup", "/verify", "/password",
"/terms", "/privacy", "/rules", "/healthz")`. It is a prefix match. So `/verify` covers `/verify/{token}` and
`/verify/resend`, and `/password` covers `/password/forgot` and `/password/reset/{token}`. On public paths,
`authenticate()` still reads the cookie, without ever raising, so these pages can show who is signed in. `/healthz`
belongs to OPS-1.
### `activity_log` rows (ACC-3)
No row ever holds a password, a token, a link or a full email address.
**The request path carries the token.** `U.log` (`users.py:97-105`) stores `request.url.path`, and `demo.guard`
logs every path too, so a GET or POST on `/verify/{token}` or `/password/reset/{token}` would put the raw token into
`activity_log.path`. uvicorn's access log (`racinglines web` runs `uvicorn.run` in `racinglines/cli/web.py:13`,
access log on) would put it in the journal as well. ACC-3 closes both:
- `U.log` stores the matched route template (`request.scope["route"].path`, for example `/verify/{token}`) when
there is one, and otherwise the path with the last segment replaced by `{token}` under `/verify/` and
`/password/reset/`. `demo.guard` uses the same helper.
- A `logging.Filter` on the `uvicorn.access` logger applies the same redaction before a line reaches the journal.
- The token pages send `Referrer-Policy: no-referrer` (ยง7 row 15), so the token can't leave in a Referer header.
- Test: `test_no_raw_token_in_activity_log_or_access_log` walks verify, resend and reset and asserts that no
`activity_log` row and no captured `uvicorn.access` line contains the raw token.
Cloudflare's own request logs are outside the app. A token that reaches them is still single-use and
short-lived (24 h, 1 h for resets), and a used or superseded token opens nothing.
| `action` | When | `user_id` / `username` | `detail` |
|---|---|---|---|
| `signup` | Account created | The new user | season, role, `invite_code_id` (shown on the admin member page, so each account traces to the code that let it in), email domain, turnstile (bool) |
| `signup_rejected` | Any refusal | None; the typed handle goes in `username` | reason: code, handle, handle_taken, email, email_taken, password, role, consent, turnstile, cap, closed or rate |
| `email_verify` | A verify or email_change link confirmed, or the admin marks the email verified | The user | purpose, `by` (link or admin), season, grant amount |
| `verify_resend` | Resend requested | The user if signed in | sent (bool) |
| `password_reset_request` | Forgot form submitted | None | matched (bool), email domain |
| `password_reset` | New password set from a link | The user | new epoch |
| `password_change` | Changed on `/account` | The user | new epoch |
| `email_change` | New address confirmed | The user | old and new email domains |
| `account_delete` | Account anonymised | The user | `by` (self, admin or system), reason (admin; `unverified` for the 72-hour expiry) |
| `login`, `login_failed`, `logout` | Already written today (`app.py:916-920`, `app.py:934`) | | Adds `via` and, for players trying HTTP Basic, reason `player` |
---
## 6. Email verification, reset and email change
### Tokens
- **Generation:** `raw = secrets.token_urlsafe(32)`, i.e. 32 random bytes, which make 43 characters in the link.
The database stores only `sha256(raw).hexdigest()` in `email_tokens.token_hash`, so the raw token exists only in
the mail.
- **Use:** a single statement that also checks the account is still the one the link was sent for:
```sql
UPDATE email_tokens t SET used_at = now()
FROM users u
WHERE t.token_hash = :h AND t.used_at IS NULL AND t.expires_at > now()
AND u.id = t.user_id AND u.active AND u.deleted_at IS NULL
AND (t.purpose = 'email_change' OR lower(u.email) = lower(t.email))
RETURNING t.user_id, t.purpose, t.email
```
A `verify` or `reset` link only works while the account's address is still the one it was sent to (an
`email_change` token carries the new address, so it is exempt). If it returns no row, the link is invalid, expired,
already used or no longer valid for the account, and the page shows one message, "This link has expired or was
already used", with a resend form.
- **Newest wins:** issuing a token marks older unused tokens of the same user and purpose as used, so only the
newest link works.
- **Account changes end every link:** a password change on `/account`, a password reset, a confirmed email change,
a suspension and an anonymisation each mark all of that user's unused `email_tokens` as used, in the same
transaction. A reset link sent before the password or the address changed stops working at once, not an hour
later. (Anonymising deletes the rows, ยง12.)
- **GET shows, POST acts:**
- GET `/verify/{token}` shows a "Confirm" button. GET `/password/reset/{token}` shows the new-password form.
- The token is used only by the POST, which also carries the `rl_csrf` check.
- Mail scanners and link previews fetch links on their own. A GET that used the token would verify or burn it
before the person ever clicked.
| Purpose | Lifetime | Page | Mail |
|---|---|---|---|
| `verify` | 24 h | `/verify/{token}` | `mail/verify.txt` |
| `reset` | 1 h | `/password/reset/{token}` | `mail/reset.txt` |
| `email_change` | 24 h | `/verify/{token}` | `mail/email_changed.txt`: to the new address with the link, and to the old address as a notice with no link |
### Limits
All limits are counted in the database, so restarts don't reset them. A request over a limit gets the same
"we've sent a link" answer, and nothing is sent.
| Flow | Limit |
|---|---|
| Resend verification (POST `/verify/resend`) | 3 per hour per user (from `email_tokens`), 10 per hour per IP (from `verify_resend` rows) |
| Forgot password (POST `/password/forgot`) | 3 per hour per email address (from `email_tokens`), 10 per hour per IP (from `password_reset_request` rows) |
| Email change (POST `/account`) | 3 per hour per user |
| Any one address | 6 mails per UTC day, whatever the flow (from `email_tokens.email`) |
**The daily mail cap is shared, so part of it is kept for verification.** `RACINGLINES_MAIL_DAILY_CAP` (200) counts
every mail. Forgot, signed-out resend and email-change mail together may use at most half of it (100 a day); past
that they answer as usual and send nothing, so a flood of reset requests can't use up the mail that new invitees
need to verify. At 80% of either count (160 in all, or 80 for forgot, resend and email change) the mailer sends one
alert a day to `RACINGLINES_OPS_NTFY_TOPIC`. MAIL-1 keeps the counts; [Email setup](email-setup.md) describes the
cap itself.
### Flows
- **Verify** (POST `/verify/{token}`, purpose `verify`) runs in one transaction:
1. Set `email_verified_at`.
2. Call `seasons.join(open season, user, users.role)` (new). It writes the `season_members` row, with
`locked_at = now()` and `starting_bankroll` from the season, and posts the `grant` entry (ยง9).
3. Commit.
4. Queue `mail/welcome.txt` and log `email_verify`.
If no season is open at that moment, verification still succeeds. There is no membership and no grant, and
`/fantasy` says the next season hasn't opened yet.
- **Resend** (POST `/verify/resend`): a signed-in unverified user gets a button. A signed-out visitor types an
address. Either way the answer is "If that address has an unverified account, we've sent a new link".
- **Forgot** (POST `/password/forgot`): the answer is always "If an account uses that address, we've sent a link.
It works for 1 hour." It doesn't reveal whether the address exists. Turnstile applies when it is on.
- **Reset** (POST `/password/reset/{token}`):
- The password policy applies, and the new hash is saved.
- `session_epoch + 1` ends every other session, the token is marked used, and this browser gets a fresh
`rl_session`.
- Log `password_reset`.
- A reset does not verify an unverified email: verification is the only path that creates the membership and the
grant.
- **Email change** (POST `/account`, action `email`) requires the current password. The new address follows the
sign-up rules in ยง5 (no `racinglines.bet`, unique by the normalised form).
- An unverified account (fixing a typo) just replaces `users.email` and gets a fresh verify token. No notice is
sent.
- A verified account gets an `email_change` token that carries the new address. `mail/email_changed.txt` goes to
the new address with the link. The same template, without the link, goes to the old address: "If this wasn't
you, reset your password and write to support@racinglines.bet".
- When the link is confirmed: `users.email = new` and `email_verified_at = now()`. It is refused if the new address
was taken in the meantime. Log `email_change`.
### Unverified players
Right after sign-up an unverified player is signed in. They can reach `/account`, `/verify/resend`,
`/verify/{token}`, POST `/logout` and the public pages. Any other path redirects 303 to `/account` with "Verify
your email to start". They have no membership, no bankroll and no stats, and they appear on no board. The check
lives in `authenticate()`, so no route can forget it.
**Unverified accounts expire after 72 hours.** The daily `--full` pass of `racinglines fantasy tick` anonymises
every `account_type = 'player'` account with `email_verified_at IS NULL` created more than 72 hours earlier, the same
way as ยง12 (no membership or ledger exists yet, so nothing else changes), and logs `account_delete` with `by=system`
and reason `unverified`. That frees the handle, the address and the cap slot, so unverified sign-ups can't squat an
address or fill the cap for long. `/admin/fantasy` lists unverified accounts with their age.
### Mail
- **Code:** MAIL-1 builds the sender (`racinglines/web/mailer.py`). ACC-3 writes the wording in the plain-text
templates under `racinglines/web/templates/mail/`.
- **Sender:** every mail comes from `RACINGLINES_MAIL_FROM`, with Reply-To `RACINGLINES_MAIL_REPLY_TO`
(support@racinglines.bet). It says "Paper money only (F$)" and never uses a bare "$".
- **The four templates:**
- `verify.txt`: subject "Confirm your email for racinglines". The link, "works for 24 hours", and "If you didn't
sign up, ignore this mail."
- `reset.txt`: subject "Reset your racinglines password". The link, "works for 1 hour", and "If you didn't ask,
ignore this mail; your password hasn't changed."
- `welcome.txt`: the season name, the role, the starting bankroll in F$, "your role is locked until the season
closes", and links to `/fantasy` and `/rules`.
- `email_changed.txt`: the two variants described under Flows.
- **Links:** every link is built from `RACINGLINES_URL` (`https://racinglines.bet` on the VM,
`deploy/vm/racinglines.env.example:5`), never from the request's Host header. A forged Host header would
otherwise put someone else's domain into a reset link. Local dev sets `RACINGLINES_URL=http://127.0.0.1:8000`.
- **Backends:** under `RACINGLINES_MAIL_BACKEND=log` (the default), the message goes to the journal and only
metadata goes to `activity_log`. Under either backend that metadata names the recipient by `user_id` and email
domain only, never the full address, to match the rule in ยง5. The rows use the actions `mail_sent` and `mail_failed`
from the master names list; [Email setup](email-setup.md) logs the same fields. While mail is down, the admin can read the link in the journal or mark the email
verified on `/admin/fantasy/members/{user_id}` (the admin-verify fallback). SMTP goes through the Proton token for
noreply@racinglines.bet (DEC-3). Its setup is OWNER-3 and MAIL-2 in [Email setup](email-setup.md).
---
## 7. Sessions and security (SEC-1, ACC-3)
| # | Change | Task | Where and how |
|---|---|---|---|
| 1 | Cookie format | SEC-1 sets it up; ACC-3 starts checking the epoch | `rl_session` becomes five fields: user id, expiry, session id, epoch, HMAC. It is signed with a key derived from `APP_SECRET` (HMAC(`APP_SECRET`, "rl_session")) and kept apart from the CSRF key. SEC-1 ships the five-field format with epoch always 0 and doesn't check it. ACC-3 then compares the epoch with `users.session_epoch`, which is 0 for everyone after the migration. So only SEC-1's deploy signs everyone out, and only once. The old three- and four-field cookies (`app.py:85-96`) are rejected from SEC-1 on |
| 2 | `session_epoch` | ACC-3, ADM-1 | Bumped on a password change, a password reset, a suspension, the admin's "Revoke sessions", "Sign out everywhere" on `/account`, and anonymising. A cookie whose epoch doesn't match is treated as signed out |
| 3 | Per-session CSRF | SEC-1 | token = HMAC(csrf key, session id), checked by `check_csrf` on every signed-in POST. It has to reach the `csrf()` macro (`_macros.html:9`) and `app.js` (`app.js:14-18`), which read it from the page. SEC-1 picks how, for example by importing `_macros.html` `with context` and reading it from `request.state`, and tests it. A POST with no cookie session (HTTP Basic) is refused, since Basic is for GETs (scripts, `smoke.sh`). The `CSRF_TOKEN` constant goes away, and `tests/test_views.py:158`, `:165`, `:301`, `:306` and `:308` read the token from a page instead |
| 4 | `rl_csrf` double-submit | SEC-1 | For signed-out forms: `/login` (including the two demo buttons), `/signup`, `/password/forgot`, `/password/reset/{token}`, `/verify/{token}` and `/verify/resend`. The GET sets `rl_csrf` (a nonce plus an HMAC; HttpOnly, SameSite=Lax, Secure behind https, 2 h) and puts the nonce in a hidden `csrf` field. The POST needs a valid signature and a field equal to the nonce, or it gets a 403 |
| 5 | POST `/logout` | SEC-1 | GET `/logout` shows a one-button form, so old links still work. The POST signs this browser out and resets the demo overlay, as today (`app.py:935-937`). It doesn't bump the epoch |
| 6 | `next` | SEC-1 | URL-encoded with `urllib.parse.quote` in both redirects (`app.py:132`, `app.py:917`). The same-site check at `app.py:922` stays |
| 7 | Throttle | SEC-1 | Per IP: 8 failures in 15 minutes, as today. Per username (new): 10 failures in 15 minutes on one `lower(username)` refuses that name for 15 minutes from any IP. Pruning: every check drops keys with no failure inside the window, and the dict is capped at 10,000 keys. Demo accounts are exempt from the per-username lock: their password is public, and otherwise anyone could lock the one-click demo for everyone. Staff accounts (the admin included) are locked per (username, IP) instead, so a stranger can lock the admin handle only from their own IP and can't shut the owner out of the abuse tools during an incident. Guessing a staff password from many IPs is still bounded by the per-IP limit, and `/admin` has a second gate (row 16). It stays in memory, so a restart resets it (see Open items) |
| 8 | Rules inside `create_user` | SEC-1 | Normalise the handle, then apply the regex and the reserved names. Password policy (new `U.check_password(password, username)`): 10-128 characters, and must not contain the handle. `admin.py:87` and `admin.py:121` call the same check. `create_user` gains an `account_type` argument, default `staff`, used from ACC-3 on. The demo accounts' existing password is untouched, because the policy applies when a password is set, not at login |
| 9 | HTTP Basic refused for players | ACC-3 | The Basic branch of `authenticate()` (`app.py:120-130`) returns 401 for `account_type = 'player'`, even with the right password. It looks exactly like a wrong password and logs `login_failed` with `via=basic, reason=player`. Staff and demo accounts keep Basic, so `smoke.sh` is unchanged |
| 10 | `APP_SECRET` required | ACC-3 | The app refuses to start if `RACINGLINES_SIGNUP` is not `off`, or `RACINGLINES_FANTASY=1`, while `APP_SECRET` is unset or shorter than 32 characters. Local dev with both off keeps the random fallback |
| 11 | `ensure_admin` only for staff | ACC-3 | If the `ADMIN_USERNAME` account exists with an `account_type` other than `staff`, `ensure_admin` prints a warning and changes nothing |
| 12 | Login | SEC-1 (timing), ACC-3 (email) | The form's `username` field (label: "Handle or email") looks up `lower(username)`, or `lower(email)` when the input contains `@`. Deleted or inactive accounts fail exactly like a wrong password, in the same time: today `U.authenticate` (`users.py:45-47`) skips scrypt when the user is missing or inactive, so an unknown handle or address answers in microseconds and a real one takes a full scrypt, which would reveal which addresses are registered. SEC-1 makes it always run one `verify_password` against a fixed dummy scrypt hash when the user is missing, inactive, deleted, or has an unparseable hash (`!deleted`), and tests the timing ratio. `last_login_at` is set on success |
| 13 | "Sign out everywhere" | ACC-3 | A button on `/account` that bumps `session_epoch` and issues this browser a fresh cookie |
| 14 | No token in logs | ACC-3 | `U.log`, `demo.guard` and the `uvicorn.access` logger record the route template (`/verify/{token}`), never the raw token (ยง5, "`activity_log` rows") |
| 15 | Security headers | SEC-1 | One middleware on every response: `X-Frame-Options: DENY` and CSP `frame-ancestors 'none'` (no clickjacking of the bet, order, settle or SQL buttons), `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin` (`no-referrer` on the token pages). The script and style parts of the CSP start as `Content-Security-Policy-Report-Only`, allowing `'self'` and `https://challenges.cloudflare.com` for script and frame: several templates use inline `style=` attributes today (`_macros.html`, `board.html`, `live.html`, `race.html`), so an enforced policy would break pages until they are cleaned up. HSTS is turned on by the owner in Cloudflare (SSL/TLS, Edge Certificates) once the tunnel serves only https, which DEC-8 (a) keeps true |
| 16 | The admin surface | SEC-1 (app), owner (Cloudflare) | `/admin*` (including `/admin/sql` with its write toggle and the `/admin/db` explorer) sits on the same public origin as sign-up, behind one password. Two changes before `RACINGLINES_SIGNUP` leaves `off`: (a) the owner puts a Cloudflare Access application (free tier, one-time PIN to the owner's own address) in front of `racinglines.bet/admin*`, which gives the admin a second factor; (b) the app refuses the `/admin/sql` write toggle and `/admin/db` row edits while `RACINGLINES_SIGNUP` is not `off`, pointing to `/admin/fantasy` or a Mac-side fix with a backup. `mcp.racinglines.bet` keeps its bearer token, as [VM reliability](vm-reliability.md) decides |
**Deploy note.** SEC-1 signs everyone out once, and demo overlays reset as they do on any restart. Deploy it in a
between-session window; [VM reliability](vm-reliability.md#deploy-windows) lists them.
**What stays:** the 12-hour lifetime, HttpOnly, SameSite=Lax, Secure behind https, scrypt, and re-reading the user
and role on every request.
---
## 8. Seasons and the role lock (ACC-4)
### Lifecycle
| `status` | Players see | Sign-up | Trading | Stats | How a season gets here |
|---|---|---|---|---|---|
| `draft` | Nothing | No | No | No | `season create` |
| `open` | `/fantasy`, `/leaderboard` | When `RACINGLINES_SIGNUP` is not `off` and the sign-up window is open | Yes, per [Fantasy trading](fantasy-trading.md) | Every tick | `season create --status open` or `season set --status open`. Refused while another season is open |
| `closed` | The final leaderboard under "Past seasons" | No | No new bets, orders or quotes. The tick settles what is left | A final `--full` pass, then frozen | `season close` |
Seasons only move forward: draft, then open, then closed. A closed season never reopens.
**Closing.** `season close ` refuses while bets, maker markets or exchange positions of that season are
unsettled, and lists them. With `--void-open --reason "..."` it voids them first: refunds go through the ledger,
with `fantasy_settle` rows that carry the reason. A dry-run season (`notes.dryrun`) voids whatever is still open
without being asked, with the reason "dry-run season closed". That is why launch step 2 in the
[runbook](fantasy-runbook.md#launch-day) needs no flag. Closing unlocks the roles, since the lock holds only until
`status = closed`, and runs one last `stats --full`.
**`ends_at` NULL means TBD.** A season can be created with no end date. The sign-up page and `/rules` then say:
"Season 1 runs from Thu 8 Oct until a date the owner will announce, expected after the 2026 championship markets
settle (about 7-8 Dec)." The owner sets the date later with `season set 2026-s1 --ends-at ...` or on
`/admin/fantasy/seasons/{id}`. `ends_at` is for display only: a season is closed only by an explicit `close`. The
tick sends one ops alert a day while `now > ends_at` and the season is still open.
!!! note "Owner input: DEC-4, the dates of 2026-s1"
Recommended: `signup_opens_at` Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC), trading from R17, and `ends_at`
left NULL until the 2026 championship markets resolve (expected 7-8 Dec, after Abu Dhabi on 6 Dec; earlier if
Qatar or Abu Dhabi is cancelled). Due Wed 7 Oct 12:00 PDT, for the sign-up page copy.
### The lock
- **When it starts:** `season_members.locked_at` is set at verification. After that, `season_members.role` can't
change while the season's `status` is not `closed`.
- **Where it is enforced:** in `seasons.change_role` (new), and in the existing role form (`admin.py:98-129`), which
refuses locked players.
- **Override:** on `/admin/fantasy/members/{user_id}`, the admin picks the new role and writes a reason of at least
10 characters. It is allowed only while the member has no ledger entries other than the grant. It:
- writes `role_override` to `activity_log` with the old role, the new role and the reason;
- updates `season_members.role`, `users.role` and `starting_bankroll`;
- posts an `adjust` entry for the bankroll difference, whose ref is that `activity_log` row;
- bumps `session_epoch`.
The form says it is meant for mistakes within 48 h of sign-up (DEC-10). A member who has already traded waits for
the next season.
- **Late joiners** get the full starting bankroll whatever the date, so comparisons across join dates should use
ROI. The owner can set `signup_closes_at` to stop late joins.
- **The next season:** when a later season opens, an existing verified player joins it with a new role choice
(`seasons.join`), and `users.role` follows the new season role. The join page isn't built this sprint, since
there is one season. Verification is the only way to join for now.
### `2026-dryrun`
- **What it is for:** the staging rehearsal (STAGE-1, Wed 7 Oct 17:00-21:00 PDT (Thu 8 Oct 00:00-04:00 UTC)).
- **Created by:** `season create 2026-dryrun ... --status open`. A slug ending in `-dryrun`, or the `--dryrun` flag,
sets `notes = {"dryrun": true}`.
- **Testers:** they sign up as players with 5 tester codes. The owner anonymises them at the end of the rehearsal,
which frees their handles and emails for real sign-ups.
- **Stats:** computed, so the rehearsal can check the leaderboard, but never shown to players. The leaderboard's
season list hides dry-run seasons from everyone except staff.
- **End:** closed at launch step 2, before `2026-s1` opens.
### CLI (new: `racinglines/cli/fantasy.py`, and `"fantasy"` added to `GROUPS` at `racinglines/cli/__init__.py:20`)
| Command | What it does | Writes |
|---|---|---|
| `season create --name --starts-at --signup-opens-at [--ends-at] [--signup-closes-at] --bankroll-taker --bankroll-maker --max-stake --max-market-liability [--status open] [--dryrun]` | A new season, `draft` unless `--status open` | `fantasy_seasons`; `season_update` |
| `season set [--name] [--ends-at] [--signup-opens-at] [--signup-closes-at] [--status open]` | Edits a season. The bankroll and limit fields are refused once the season has a member | `season_update`, with each change as old and new |
| `season close [--void-open --reason TEXT]` | Closes a season (see Closing) | `fantasy_seasons.status`; `season_update` |
| `season list` | Slug, status, dates (PDT and UTC), members by role, verified and unverified | nothing |
| `invite create --season --count N --label TEXT [--max-uses 1] [--expires-at]` | Prints the codes once. Don't tee them into a log | `invite_codes`; `invite_create` |
| `invite revoke ` or `invite revoke --season --all` | Sets `revoked_at` | `invite_revoke` |
| `invite list --season ` | id, label, uses and max, expiry, revoked. Never the codes | nothing |
- **Times:** ISO 8601 with `Z` or an offset. The CLI prints PDT with UTC in brackets.
- **Audit:** the CLI writes `activity_log` rows with no user and `detail.by = "cli:@"`.
- **On the VM:** these commands run as the `racinglines` user with `/etc/racinglines.env` loaded (the `rl` helper in
ยง4). Each write there follows a `racinglines ops backup`. The launch-day and STAGE-1 blocks in the
[runbook](fantasy-runbook.md) do that.
**Module (new: `racinglines/fantasy/seasons.py`).** Functions:
- `open_season(conn)`, `get(conn, slug)`
- `create(...)`, `update(...)`, `close(...)`
- `join(session, season, user, role)`, `change_role(session, member, role, reason, admin)`
- `create_invites(session, season, n, label, max_uses=1, expires_at=None, created_by=None)`, which returns the
plain codes once
- `redeem(session, code, season)`, `revoke(session, invite_id=None, season=None, all_=False, by=None)`
---
## 9. Bankrolls and the F$ ledger (ACC-4)
### Rules
- **One bankroll per member per season,** across all venues (DEC-5). Amounts come from the season row: F$1,000 for
takers and F$10,000 for makers. The cap per book bet and per exchange order is F$100 (`max_stake`, equal to
`MAX_STAKE`, `app.py:619`). The cap per maker market is F$1,000 (`max_market_liability`, the default for
`house_markets.max_liability`).
- **The grant** is posted at verification: kind `grant`, `+starting_bankroll`, with ref `season_members` and the
member's id.
- **`balance = SUM(amount)`** over the member's entries in that season. There is no stored balance column: with 50
players or fewer the sum is cheap, and it can never drift.
- **Append-only.** Code never updates or deletes an entry. A correction is a new `adjust` entry.
- **Idempotent.** `UNIQUE(ref_table, ref_id, kind)` plus `INSERT ... ON CONFLICT DO NOTHING` mean that posting the
same thing twice changes nothing: a retried request, or a tick run twice.
- **Cents.** `house_bets` stakes and payouts are Float, so `ledger.post` rounds to the cent with
`Decimal(str(round(x, 2)))`.
- **No top-ups** other than an admin adjust with a reason (DEC-5).
### Entry kinds
The trading entries are written by the tasks in [Fantasy trading](fantasy-trading.md), which owns the amounts.
This table is the summary the ledger module is built to.
| `kind` | Who | Sign | `ref_table` / `ref_id` | Written by |
|---|---|---|---|---|
| `grant` | Every member | + starting bankroll | `season_members` / member id | ACC-4, at verification |
| `stake` | Taker | - stake | `house_bets` / bet id | BOOK-3, when the bet is placed |
| `payout` | Taker | + payout if won, 0.00 if lost | `house_bets` / bet id | SETTLE-1 |
| `refund` | Taker | + stake (voided bet), or + cost basis (voided exchange position) | `house_bets` or `fantasy_positions` / id | SETTLE-1, ADM-1 |
| `trade` | Taker | - shares x price on a buy, + on a sell | `fantasy_fills` / fill id | EXCH-1 |
| `fee` | Taker | - fee, once per order (Kalshi) | `fantasy_orders` / order id | EXCH-1 |
| `settle` | Taker | + payout of an exchange position, 0.00 on a loss | `fantasy_positions` / id | SETTLE-1 |
| `book_pnl` | Maker | ยฑ the maker's P&L on one market, 0.00 if void | `house_markets` / market id | SETTLE-1 |
| `adjust` | Any member | ยฑ | A re-settle uses the original ref (one per ref). An admin F$ adjust or a role override uses the `activity_log` row that holds the reason | ADM-1, ACC-4 |
Every settled bet, position and maker market gets exactly one closing entry, even when it is F$0.00. That makes
"settled" a join rather than a guess, and the stats (ยง10) rely on it.
### Free balance
- **Taker:** free balance = balance. `stake` and `trade` entries are written when the bet or fill happens, so the
balance is already net of open stakes and open exchange cost. A bet or buy is refused if its cost plus fee is more
than the free balance.
- **Maker:** free balance = balance - ฮฃ worst case over the maker's unsettled season markets. A market's worst case
is `-min(pnl_if_yes, pnl_if_no, 0)`: the negative of the `worst` column that `private_book.book()` computes
(`private_book.py:218-222`). A taker's bet is refused if it would push the maker's free balance below 0 or the
market's worst case above `max_liability` (BOOK-3).
- `/fantasy` shows balance, free balance and equity.
**Concurrency.** `ledger.lock_member(session, season_id, user_id)` (new) runs `SELECT ... FOR UPDATE` on the
`season_members` row. BOOK-3 and EXCH-1 lock the payer, and the maker too, in ascending `user_id` order, before they
check free balance and post. The concurrency test belongs to BOOK-3.
**Module (new: `racinglines/fantasy/ledger.py`).** Functions:
- `post(session, season_id, user_id, kind, amount, ref_table, ref_id, note=None, created_by=None)`, which returns
the entry and whether it was new
- `balance(conn, season_id, user_id)`, `free_balance(conn, season_id, user_id)`,
`open_liability(conn, season_id, maker_id)`
- `lock_member(...)`
- `adjust(session, season_id, user_id, amount, reason, admin)`. It writes the `bankroll_adjust` `activity_log` row
and the `adjust` entry in one transaction. The amount must be non-zero and at most F$10,000 either way, and the
reason at least 10 characters.
- `entries(conn, season_id, user_id, limit, offset)`, for the history on `/fantasy`
---
## 10. Stats and leaderboards (STAT-1, STAT-2)
### Definitions
| Metric | Definition |
|---|---|
| `starting_bankroll` | `season_members.starting_bankroll` |
| `balance` | `SUM(bankroll_entries.amount)`, on the `all` row only |
| `equity`, taker | balance + open private-book stakes at cost + open exchange positions at liquidation value (YES shares x best YES bid; NO shares x (1 - best YES ask)), marked from `market_links` `last_bid` / `last_ask`. A missing bid marks at 0 |
| `equity`, maker | balance (settled only). The open worst case is shown on `/fantasy` but not ranked |
| `pnl` | On `all`: equity - starting_bankroll. On a venue row: settled P&L on that venue, plus, for takers, the open mark minus cost |
| `roi` | (equity - starting_bankroll) / starting_bankroll. On venue rows: pnl / starting_bankroll |
| `turnover` | Taker: private-book stakes + exchange fill notional (shares x price, buys and sells). Maker: stakes taken on their markets. Fees go into pnl, not turnover |
| `bets` | Taker: private-book bets + exchange orders with at least one fill. Maker: bets taken on their markets |
| `settled`, `won` | Taker: bets won or lost, plus positions settled (won = a positive P&L). Maker: bets taken on markets that settled (won = bets the taker lost). Voids are left out |
| `hit_rate` | won / settled. NULL when settled = 0 |
| `events`, `max_drawdown`, `sharpe` | Taken from the per-event P&L series: `fantasy_event_results` for that venue (for `all`, summed across venues per `event_key`), ordered by `settled_at` and passed to `reporting.metrics.curve_stats`. Drawdown is positive, with the peak starting at 0. Sharpe = mean / sd (ddof 1) x sqrt(24), stored as NULL under 4 events (`curve_stats` itself returns 0.0 there) |
| `rank` | See Eligibility |
`stats.py` calls `curve_stats`; it never copies it. The test `test_drawdown_and_sharpe_match_curve_stats` pins
this. The sqrt(24) scale matches the paper record's roughly 24 race weekends a year, not the length of the fantasy
season. The leaderboard says so in a footnote.
### Eligibility and exclusions
- **Who gets rows:** only members with `account_type = 'player'`. Demo, staff and system accounts never do.
- **Ranked:** the membership is `active`, `users.deleted_at IS NULL`, `users.active` is true, and the player has 5
or more settled bets or fills (DEC-13).
- Takers: settled private-book bets + exchange fills >= 5.
- Makers: settled bets taken on their markets >= 5.
- **Not ranked yet:** everyone else who qualifies for a row. They are listed under "Not ranked yet", with how many
more they need.
- **Hidden:** suspended and deleted members keep their rows, so a restore is exact, but get `rank = NULL` and never
appear on a board.
- **Order:** takers by equity and makers by balance on the `all` row, and by pnl on venue rows. Ties share a rank.
### Boards (DEC-13)
- There are two boards, one for takers and one for makers, and they are never mixed, because the private book is
zero-sum between the two.
- **Venue filter:** all, kalshi, polymarket or book. Makers only have book this sprint, since they don't quote
exchange markets (DEC-6).
- **Season list:** open and closed seasons. Dry-run seasons are shown to staff only.
### Recompute
- **`racinglines fantasy stats`** (incremental) recomputes members of the open season who have a ledger entry newer
than their `computed_at`, plus every taker with an open exchange position, because marks move. It runs inside
every `racinglines fantasy tick` (`racinglines-fantasy.timer`, every 10 minutes, SETTLE-1).
- **`racinglines fantasy stats --full`** covers every member of the open season, and a closed season once, when it
closes. It rebuilds `fantasy_event_results` from the ledger, then `fantasy_stats`. This is the nightly `--full`:
the tick runs it when the oldest `computed_at` in the open season is more than 24 hours old. That gives one full
pass a day with no extra timer. Run it by hand after an adjust or a re-settle. With 50 players or fewer it takes
about a second.
- **Invariant, tested:** an incremental run followed by `--full` gives identical rows.
- **Spot check (go/no-go):** for 3 players, the `balance` in `fantasy_stats` equals the sum of their
`bankroll_entries`.
### Pages (STAT-2)
- **`/fantasy` (new)** shows the signed-in member's season, role and lock line, balance, free balance, equity, ROI
and board rank. It lists their open private-book bets and exchange positions (marked), their maker markets with
worst cases (makers), and their ledger history, 50 per page. Takers see prices, never fair values or edges.
- **`/leaderboard` (new)** is for signed-in, verified accounts only.
- It shows handles only: no email and no display names.
- Columns: rank, handle, equity (takers) or balance (makers), ROI, P&L, settled, hit rate, max drawdown, Sharpe,
events.
- The signed-in player's row is highlighted. 50 rows per page. "Computed at" is shown in PDT with UTC in
brackets.
- Both pages return 404 when `RACINGLINES_FANTASY=0`. The nav links in `base.html` appear only when it is on.
- **No fair value for takers:** no page a taker can reach renders `fair_prob`, fair, edge or model columns. The test
follows `test_signals_page_hides_fair_from_takers` (`tests/test_views.py:201`).
### MCP tool (new)
`leaderboard(conn, season=None, role="taker", venue="all", limit=None, offset=0)` lives in
`racinglines/mcp/tools.py` and is registered in `racinglines/mcp/server.py`.
- `season=None` means the open season.
- It returns rank, handle, equity or balance, roi, pnl, settled, hit_rate, max_drawdown, sharpe, events and
computed_at, paged with `P.page`, ranked rows first.
- Handles only.
- Only accounts in `RACINGLINES_MCP_ROLES` (default `admin,pro`, `mcp/auth.py`; demos excluded) can reach it. `docs/mcp.md` gets a line
for it.
---
## 11. Admin tooling (ADM-1)
Every page here is admin-only (`allow("admin")`). Every POST carries the per-session CSRF token. Every action writes
an `activity_log` row with its reason.
| Page (new) | Shows | Actions and their `activity_log` rows |
|---|---|---|
| `/admin/fantasy` | The open season: status, dates in PDT and UTC, members by role, verified and unverified, unverified accounts with their age (they expire at 72 h, ยง6), open bets and positions, the size of the unsettled queue. The switch values (read-only). Member search: a handle prefix or an exact email, `?q=` and `&page=`, 50 per page | Read-only |
| `/admin/fantasy/seasons/{id}` | One season | Edit the name, dates and sign-up window. draft to open, open to closed (the close rules in ยง8) โ `season_update`, with each change as old and new |
| `/admin/fantasy/invites` | The codes per season: id, label, uses and max, expiry, revoked. Never the codes | Create (the codes are shown once) โ `invite_create`. Revoke โ `invite_revoke` |
| `/admin/fantasy/members/{user_id}` | Handle, email, verified, account type, memberships, balance, free balance, equity, recent ledger and activity | Suspend or unsuspend (with a reason; bumps the epoch) โ `member_suspend`. Role override (with a reason) โ `role_override`. F$ adjust (amount and reason) โ `bankroll_adjust`. Revoke sessions (bumps the epoch) โ `user_update` with `changes.session_epoch`, the action `admin.py:128` already writes. Mark email verified (the fallback when mail fails) โ `email_verify` with `by=admin`. Anonymise (a reason, plus the handle typed again) โ `account_delete` with `by=admin` |
| `/admin/fantasy/settle` | The manual settle queue ([Fantasy trading](fantasy-trading.md)) | Settle, void, or re-settle with a reason โ `fantasy_settle`, `fantasy_resettle` |
**Suspension.** It sets `season_members.status = 'suspended'` and bumps `session_epoch`. The maker's open markets
are closed. The member can sign in again, sees "suspended" on `/fantasy`, and can't bet, order or quote. Their open
bets and positions settle normally, and they vanish from the boards. For a full block, the admin also unticks
Active on `/admin/users` (existing).
**`/admin/users` (existing, `admin.py:78-129`).** It shows `account_type`. Its totals split by account type (ROLE-1
changes `USERS_SQL`, `admin.py:33-43`). Role changes for locked players are refused (ACC-4).
**Cautions for the SQL console and the database explorer.**
- `/admin/sql` (`admin.py:297-330`), with its write toggle, and `/admin/db` (`admin.py:214-292`) can change any
row. That bypasses the ledger rules, the required reasons and the audit trail of `/admin/fantasy`.
- **Rule:** never change the fantasy tables, `users.email`, `users.role`, `users.account_type` or
`users.session_epoch` by hand. Use `/admin/fantasy`. If a hand fix is unavoidable: back up first (CLAUDE.md), add
a `data_changes` note, and post a compensating `adjust` rather than editing `bankroll_entries`.
- **ADM-1 makes the database explorer read-only** for `bankroll_entries`, `season_members`, `fantasy_seasons`,
`invite_codes`, `email_tokens`, `activity_log` and the `fantasy_*` tables. The row form refuses with a pointer to
`/admin/fantasy`. `activity_log` is on the list because it is the audit trail for every admin power (adjust,
role override, verify, anonymise, settle): an edited row would rewrite history.
- **While sign-up is on** (`RACINGLINES_SIGNUP` not `off`), the console's write toggle and every explorer row edit
are refused outright (ยง7 row 16). A hand fix then happens on the VM with `psql`, after `racinglines ops backup`,
with a `data_changes` note.
- **The ledger explains itself.** Every admin action that posts to `bankroll_entries` (adjust, role-override
regrant, settle, void) copies its reason into `bankroll_entries.note` as well as the `activity_log` row, so the
ledger still says why even if the log is ever lost.
- **Pruning only blanks.** The 90-day step in ยง12 sets `ip` and `username` to NULL. Nothing ever deletes an
`activity_log` row or edits its `action` or `detail`.
- **ADM-1 also hides columns:** `("email_tokens", "token_hash")` and `("invite_codes", "code_hash")` join
`HIDDEN_COLUMNS` (`admin.py:24`).
- **The console's default query** reads `activity_log` (`admin.py:299`), which holds IPs. That is acceptable
because the console is admin-only and logs every query it runs (`sql_read` / `sql_write`, `admin.py:328`).
---
## 12. Privacy and data handling (ACC-3, ADM-1, LEGAL-1)
### What personal data exists
| Data | Where | Why | Kept | Who sees it |
|---|---|---|---|---|
| Email address | `users.email`, `email_tokens.email` | Sign-in, verification, reset, support | Until deletion, when it is set to NULL and the tokens are deleted | The player and the admin. Never other players, never a board, never MCP (`users` is hidden) |
| Password | `users.password_hash` (scrypt) | Sign-in | Until deletion (`!deleted`) | Nobody (hidden in the database explorer, `admin.py:24`) |
| Handle | `users.username`, `house_bets.counterparty`, `activity_log.username` | Public identity | Replaced by `deleted-` at deletion | Every signed-in player |
| IP address | `activity_log.ip` | Abuse checks, rate limits | 90 days, then NULL | The admin |
| Usernames typed on failed logins | `activity_log.username` on `login_failed` and `signup_rejected` rows | Abuse checks | 90 days, then NULL | The admin |
| 18+ and terms acceptance | `users.age_confirmed_at`, `terms_version`, `terms_accepted_at` | Proof of consent | As long as the account row exists | The admin |
| Game history | `bankroll_entries`, `house_bets`, `fantasy_orders` / `fills` / `positions`, `fantasy_stats` | The game itself | Kept after deletion under `deleted-`, hidden from boards | The player and the admin. Counterparties see a handle |
| Bot check | Cloudflare Turnstile (a third party) | Bot protection on the public forms | Cloudflare's policy | |
| Mail | Proton (a third party) | Sending verification and reset mail | Proton's policy | |
| Backups | Nightly dumps on the VM and in the bucket, pre-change dumps, disk snapshots ([VM reliability](vm-reliability.md)) | Recovery | 7 dumps on the VM disk; 35 days in `gs://$RACINGLINES_GCS_BUCKET/db/nightly/`, plus 30 days of old object versions; 14 days of snapshots (`racinglines-daily`) | The owner. Never a cloud session or the Mac dev database in raw form (below) |
**Pruning.** ADM-1 adds a step to the daily `--full` pass of `racinglines fantasy tick` in
`racinglines/cli/fantasy.py`:
```sql
UPDATE activity_log SET ip = NULL WHERE ts < now() - interval '90 days' AND ip IS NOT NULL;
UPDATE activity_log SET username = NULL
WHERE ts < now() - interval '90 days' AND action IN ('login_failed', 'signup_rejected') AND username IS NOT NULL;
```
This is a routine write, like the recorder's and the tick's own, and the nightly dump covers it
([VM reliability](vm-reliability.md)).
**MCP.** `SQL_HIDDEN` (`mcp/tools.py:24`) becomes `("users", "orders", "activity_log", "email_tokens",
"invite_codes")`. The check at `mcp/tools.py:641-643` and the row counts at `mcp/tools.py:118` pick this up. The
fantasy tables hold only user ids and no PII, and handles come only through the `leaderboard` tool.
### Deletion = anonymise
A player deletes their own account with POST `/account/delete`, giving the current password and typing their handle
again. The admin can do it from the member page. Either way, one transaction:
- `users`:
- `username` and `display_name` become `deleted-`
- `email` and `email_verified_at` are set to NULL
- `password_hash` becomes `!deleted`, which can never verify, because `verify_password` needs six `$`-separated
parts (`users.py:32-38`)
- `prefs` is set to NULL, `active = false`, `deleted_at = now()`, and `session_epoch + 1`
- `house_bets.counterparty = 'deleted-'` where `taker_id` is the user.
- `activity_log.username = 'deleted-'` where `user_id` is the user.
- The user's `email_tokens` rows are deleted.
- `season_members.status = 'left'`. The maker's open markets are set to `closed`, so no new bets can arrive. Open
bets and positions stay and settle normally, which keeps counterparties' ledgers right.
- The ledger, `fantasy_event_results` and `fantasy_stats` are kept, hidden from the boards.
- Log `account_delete`.
The handle and the email address become free for a new sign-up. Anonymising can't be undone.
### Backups and restores
Whole-database dumps carry everything in the table above, including emails, password hashes, IPs and
`email_tokens`, and anonymising doesn't reach a dump that was already taken. Three rules follow. They belong to the
backup tasks in [VM reliability](vm-reliability.md) (OPS-3, OPS-4), and the DOC-1 review carries them there:
1. **No player PII outside the owner's copies.** The bucket's "latest" copy that `scripts/cloud/bucket.sh restore`
and cloud sessions read, and every restore onto the Mac dev or scratch database, must not carry player PII.
Today `bucket.sh` dumps the whole database with no exclusions (`scripts/cloud/bucket.sh:31`, copied to `db/`
at `:38-39`). Before `RACINGLINES_SIGNUP` leaves `off`, that copy is made with `pg_dump --exclude-table-data` for `email_tokens`,
`invite_codes` and `activity_log`, and with the player rows' PII blanked (email, password hash, the consent
timestamps) on a restored scratch copy before it is dumped for sharing. The full nightly dumps stay owner-only.
The owner also confirms uniform bucket-level access and public access prevention on the bucket.
2. **The privacy page says how long backups keep data** (the row above), and that a deleted account's data can
survive in them for up to 35 days plus 30 days of object versions, and 14 days in snapshots.
3. **A restore re-applies deletions.** Anonymising also appends one line, the user id and the UTC time (no other
data), to `/var/lib/racinglines/deletions.log` on the VM, outside the database. After any restore (the
[Bad migration](vm-reliability.md#bad-migration) runbook and the other restore paths), the owner re-runs
Anonymise from `/admin/fantasy/members/{user_id}` for every id in that file newer than the dump, before sign-up
is switched back on.
### Export on request
A player writes to support@racinglines.bet from their registered address. The owner checks that it is the same
address, then runs the read-only export below and replies from Proton with the file. The response time is stated on
the privacy page (owner input, DEC-22). The file contains PII: never commit it, and delete both copies once it is
sent.
```sh
# LOCAL (Mac) โ open a shell on the VM
bash scripts/deploy/vm.sh ssh
```
```sh
# VM (production) โ read-only: one player's data as JSON in your home directory (set USER_ID first)
USER_ID=123; UTC=$(date -u +%Y%m%dT%H%M%SZ)
sudo -u racinglines -H bash -c "cd /opt/racinglines && docker compose exec -T db psql -U racinglines -d racinglines -At -c \"SELECT json_build_object(
'user', (SELECT row_to_json(u) FROM (SELECT id, username, email, role, account_type, created_at, email_verified_at, terms_version, terms_accepted_at, age_confirmed_at, last_login_at FROM users WHERE id = $USER_ID) u),
'memberships', (SELECT json_agg(s) FROM season_members s WHERE user_id = $USER_ID),
'ledger', (SELECT json_agg(b ORDER BY b.id) FROM bankroll_entries b WHERE user_id = $USER_ID),
'book_bets', (SELECT json_agg(h ORDER BY h.id) FROM house_bets h WHERE taker_id = $USER_ID),
'exchange_orders', (SELECT json_agg(o ORDER BY o.id) FROM fantasy_orders o WHERE user_id = $USER_ID),
'exchange_positions', (SELECT json_agg(p ORDER BY p.id) FROM fantasy_positions p WHERE user_id = $USER_ID),
'stats', (SELECT json_agg(f) FROM fantasy_stats f WHERE user_id = $USER_ID),
'activity', (SELECT json_agg(a ORDER BY a.id) FROM activity_log a WHERE user_id = $USER_ID))\"" > ~/export-user-$USER_ID-$UTC.json
ls -l ~/export-user-$USER_ID-$UTC.json
```
```sh
# LOCAL (Mac) โ copy the export off the VM (use the file name the ls above printed)
gcloud compute scp racinglines-vm:~/export-user--.json . --project racinglines --zone us-west1-b --tunnel-through-iap
```
```sh
# VM (production) โ once the export is sent, remove the VM copy
rm ~/export-user--.json
```
### The privacy, terms and rules pages (LEGAL-1, DEC-22)
LEGAL-1 writes a plain-language draft for the owner, with counsel if the owner wants. It is not legal advice.
Owner placeholders go wherever a decision is still open. All three pages are public. Each shows
"Version 2026-10-08" (`TERMS_VERSION`) and is linked from the sign-up form and the footer.
- **`/terms`:**
- who runs racinglines (owner placeholder)
- F$ are paper money with no cash value and can't be transferred; there are no prizes and no fees (DEC-1)
- 18 or older; one account per person; not available where prohibited, with no geo-blocking at the soft launch
- not investment advice
- no promise that the service is always available
- data sources and attribution (Kalshi, Polymarket, F1 data; DEC-7 placeholder)
- suspension and termination
- how the terms change (a new `TERMS_VERSION`)
- contact: support@racinglines.bet
- **`/privacy`:**
- the table above, in plain words
- the processors: Proton for mail, Cloudflare for the tunnel and Turnstile, Google Cloud (us-west1) for hosting
- the cookies: `rl_session`, `rl_csrf`, and Turnstile's
- IPs kept 90 days
- deletion means anonymising, and backups keep a deleted account's data for up to 35 days (plus 30 days of old
versions and 14 days of disk snapshots); a restore re-applies deletions
- export on request, with the response time (placeholder)
- no sale of data
- contact
- **`/rules`:**
- roles and the lock
- bankrolls, the F$100 cap per bet and per order, and the F$1,000 cap per maker market
- markets close at the start of the session that decides them (DEC-14)
- settlement from results or venue resolution, and the void rules
- the Kalshi fee on exchange paper trades
- how the boards rank (DEC-13)
- fair play: one account each (addresses are compared without `+tags`, and Gmail addresses without dots), no
collusion between maker and taker accounts; collusion voids the results involved and can lead to suspension
`TERMS_VERSION = '2026-10-08'` lives in `racinglines/web/accounts.py`. Bumping it should ask players to accept the
new terms at their next sign-in. That flow isn't built this sprint (see Open items).
---
## 13. Demo accounts after launch (DEC-15, ROLE-1)
| What changes | What stays |
|---|---|
| `account_type = 'demo'` (the backfill) | One-click **Try as maker** and **Try as taker**, password `password` (`login.html:9-13`) |
| The `demo_context` bubbles (`_macros.html:52`, `DEMO_CONTEXT` at `app.py:228-229`) show only to demo viewers, and only while `RACINGLINES_DEMO_CONTEXT` isn't 0. They are hidden for everyone else, not deleted, which replaces the "delete every call" note at `app.py:225-227` | `demo.guard`: read-only, disposable sessions, every request logged (`demo.py`) |
| `/live` labels the demo taker's picks "demo taker" for viewers who aren't demo (`live_f1.html`, `live_picks.html`) | Never members of a fantasy season, so never in stats, boards or player totals. Their paper record (`paper_positions`, the story) is untouched |
| Admin totals split by account type | HTTP Basic for demo accounts, so `scripts/deploy/smoke.sh` passes unchanged. OPS-9 adds GET checks for `/healthz`, `/terms`, `/privacy`, `/rules` and `/signup` |
| With `RACINGLINES_FANTASY=1`, players' `/markets` redirects to `/fantasy/markets`, or to `/fantasy` while `RACINGLINES_FANTASY_EXCHANGE=0`. Demo and staff accounts keep `/markets` | The demo tests in `tests/test_views.py` pass once SEC-1 adds a login helper that knows about CSRF. Today the `clients` fixture posts `/login` with no token (`test_views.py:71-90`) |
| The login page: "Handle or email" label, a sign-up link when sign-up is on, and the `rl_csrf` field on the demo buttons | The per-username lock never applies to demo accounts |
---
## 14. Switches, tests and tasks
### Switches used here
Every new switch lives in `/etc/racinglines.env` on the VM and is off or safe by default. Secrets (the Turnstile
secret, `APP_SECRET`, the SMTP token) live only there.
| Switch | Default | Used for |
|---|---|---|
| `RACINGLINES_FANTASY` (new) | `0` | The nav, `/fantasy`, `/leaderboard`. When it is on, `APP_SECRET` is required |
| `RACINGLINES_SIGNUP` (new) | `off` | `off`, `invite` or `open` (ยง5). When it isn't off, `APP_SECRET` is required |
| `RACINGLINES_SIGNUP_CAP` (new) | `50` | The limit on player accounts |
| `RACINGLINES_FANTASY_BOOK`, `RACINGLINES_FANTASY_EXCHANGE` (new) | `0` | Trading ([Fantasy trading](fantasy-trading.md)). `/fantasy` shows only what is on |
| `RACINGLINES_MAIL_BACKEND` (new) | `log` | Mail ([Email setup](email-setup.md)) |
| `RACINGLINES_TURNSTILE_SITEKEY`, `RACINGLINES_TURNSTILE_SECRET` (new) | empty | The Turnstile widget on `/signup`, `/password/forgot` and the signed-out `/verify/resend` form |
| `RACINGLINES_URL` (existing) | `https://racinglines.bet` on the VM | The base of every mail link |
| `APP_SECRET` (existing) | A random value per start | The cookie and CSRF keys |
| `MAX_STAKE` (existing) | `100` | The per-bet cap (F$100), alongside the season's `max_stake` |
| `RACINGLINES_DEMO_USERS`, `RACINGLINES_DEMO_CONTEXT` (existing) | `maker,taker`, `1` | The demo accounts and the bubbles |
| `ADMIN_USERNAME`, `ADMIN_PASSWORD` (existing) | `admin`, empty | `ensure_admin`. `ADMIN_USERNAME` is reserved |
| `RACINGLINES_MCP_ROLES` (existing) | `admin,pro` | Who can call the `leaderboard` tool; demos excluded |
Never touched: `POLYMARKET_TRADING_ENABLED`, `KALSHI_TRADING_ENABLED`.
### Tests
- **Which database, today:** `test_engine` (`tests/conftest.py:77-98`) rebuilds `racinglines_test` from the
migrations each session, but only code handed that engine uses it. The app and the CLI call `get_engine()`
(`racinglines/db/config.py:22-23`), which is `lru_cache`d and reads `DATABASE_URL`, falling back to the dev
database. So today's app-level tests run against the **dev database**: the `clients` fixture
(`tests/test_views.py:71-90`) logs in through `TestClient(app)` and writes `login` rows to the dev
`activity_log`. Sign-up, ledger and settle tests written that way would create users and F$ rows there.
- **New fixture (SEC-1):** `app_test_db` in `tests/conftest.py`. It depends on `test_engine`, sets `DATABASE_URL`
to the test database with `monkeypatch.setenv` before `racinglines.web.app` is imported, calls
`get_engine.cache_clear()` before and after, and yields a `TestClient`. Every new test in the table below that
touches a database or the app uses `test_engine` or `app_test_db`, never the dev database, so they need no backup.
Existing tests keep their current fixtures; moving them over is not in this sprint.
- **Fixtures** go in `tests/fixtures/fantasy/`.
- **Golden tests** (`tests/golden`) don't change.
| File | Task | Tests (names are proposals) |
|---|---|---|
| `tests/test_auth.py` (new) | SEC-1, ACC-3 | `test_login_requires_csrf`, `test_csrf_token_is_per_session`, `test_logout_is_post_and_get_shows_a_form`, `test_next_is_url_encoded_and_stays_on_site`, `test_throttle_prunes_idle_ips`, `test_throttle_per_username_spares_demo`, `test_create_user_normalises_and_refuses_reserved_handles`, `test_create_user_password_policy`, `test_old_cookie_format_is_rejected`, `test_demo_one_click_login_still_works`, `test_session_epoch_bump_ends_sessions`, `test_basic_auth_refused_for_players`, `test_ensure_admin_never_touches_a_player`, `test_app_secret_required_when_signup_on`, `test_login_by_handle_or_email_case_insensitive`, `test_login_unknown_user_takes_scrypt_time` (timing ratio), `test_throttle_staff_lock_is_per_ip`, `test_security_headers_on_every_page`, `test_admin_sql_write_refused_while_signup_on` |
| `tests/test_signup.py` (new) | ACC-3 | `test_signup_off_is_404`, `test_signup_invite_needs_a_valid_code`, `test_invite_code_single_use_under_race`, `test_signup_cap`, `test_handle_rules_and_reserved`, `test_email_case_duplicate_refused`, `test_password_length_bounds`, `test_role_and_consents_required`, `test_turnstile_checked_when_keys_set` (siteverify mocked), `test_signup_rate_limit_per_ip`, `test_unverified_user_reaches_only_account_and_resend`, `test_verify_get_does_not_use_the_token`, `test_verify_token_single_use_and_expiry`, `test_verify_creates_locked_membership_and_grant`, `test_resend_limit`, `test_forgot_same_answer_for_unknown_email`, `test_reset_bumps_epoch_and_expires_in_1h`, `test_email_change_confirms_new_address_and_notifies_old`, `test_account_delete_anonymises`, `test_mail_links_use_racinglines_url_not_host_header`, `test_signup_writes_activity_log_rows_without_secrets`, `test_no_raw_token_in_activity_log_or_access_log`, `test_account_change_ends_open_links`, `test_reset_refused_after_email_change`, `test_email_normalised_for_uniqueness`, `test_own_domain_refused`, `test_ipv6_counted_by_64`, `test_reset_share_of_daily_cap`, `test_unverified_expire_after_72h` |
| `tests/test_fantasy_schema.py` (new) | ACC-1 | `test_upgrade_downgrade_upgrade`, `test_backfill_account_types`, `test_case_duplicate_usernames_stop_the_migration`, `test_role_and_account_type_checks`, `test_one_open_season_index`, `test_models_match_migration` (functional indexes compared by name) |
| `tests/test_fantasy_seasons.py` (new) | ACC-4 | `test_season_lifecycle_moves_forward_only`, `test_ends_at_null_is_tbd`, `test_role_lock_refuses_change_without_reason`, `test_role_override_with_reason_regrants_and_logs`, `test_late_joiner_gets_full_bankroll`, `test_invite_codes_hashed_used_revoked_expired`, `test_close_refuses_open_items_unless_void`, `test_dryrun_close_voids_open_items`, `test_cli_season_and_invite` |
| `tests/test_ledger.py` (new) | ACC-4 | `test_balance_is_sum`, `test_post_is_idempotent_per_ref_and_kind`, `test_taker_free_balance_is_net_of_stakes`, `test_maker_free_balance_minus_worst_case`, `test_adjust_needs_reason_and_refs_activity_log`, `test_entries_are_append_only`, `test_amounts_round_to_cents` |
| `tests/test_fantasy_stats.py` (new) | STAT-1 | `test_drawdown_and_sharpe_match_curve_stats`, `test_sharpe_null_under_4_events`, `test_equity_marks_at_bid`, `test_roi_and_hit_rate`, `test_eligibility_minimum_5`, `test_only_players_get_rows`, `test_suspended_and_deleted_hidden`, `test_incremental_equals_full`, `test_venue_pnl_sums_to_all` |
| `tests/test_views.py` | ROLE-1, STAT-2, LEGAL-1 | `test_player_maker_cannot_launch_lab_promote_or_sync`, `test_player_maker_sees_no_edges`, `test_staff_maker_keeps_lab`, `test_demo_bubbles_only_for_demo_viewers`, `test_fantasy_off_is_404`, `test_leaderboard_login_only_handles_only`, `test_leaderboard_hides_fair_from_takers`, `test_terms_privacy_rules_are_public` |
| `tests/test_mcp.py` | STAT-2, ADM-1 | `test_leaderboard_tool_handles_only`, `test_sql_hides_activity_log_and_token_tables` |
| `tests/test_admin_fantasy.py` (new) | ADM-1 | `test_admin_only`, `test_suspend_bumps_epoch_and_closes_markets`, `test_adjust_writes_entry_and_log`, `test_role_override_needs_reason`, `test_admin_verify_fallback`, `test_anonymise_from_admin`, `test_member_search_pages`, `test_ip_prune_after_90_days`, `test_db_explorer_refuses_ledger_edits`, `test_db_explorer_refuses_activity_log_edits`, `test_adjust_reason_in_ledger_note` |
### Tasks
The tasks come from the plan in the [runbook](fantasy-runbook.md). The P2 branches stack on
`work/acc-1` until OWNER-8 merges it, then each one rebases onto `main`. The merge order (FABLE-1)
is: OPS-8 โ ACC-1 (OWNER-8) โ SEC-1 โ MAIL-1 โ ACC-3 โ ACC-4 โ ROLE-1 โ BOOK-3 โ EXCH-1 โ STAT-1 โ SETTLE-1 โ
STAT-2 โ ADM-1. SEC-1 has no schema change, so it may merge in P1. If it hasn't, it keeps its place in that order.
| ID | Task | Role ยท model | Size | Depends on | Money flag | When |
|---|---|---|---|---|---|---|
| SEC-1 | Auth hardening, no schema change (ยง7 rows 1, 3-8, 12 timing, 15, 16(b); the `app_test_db` fixture) | own ยท Opus | L (~35 calls, 9 files) | none | none. Signs everyone out once: deploy between sessions | P1, Tue 29 Sep - Sun 4 Oct |
| ACC-1 | `fantasy_schema_v1` plus the models (ยง3, ยง4) | own ยท Opus, Fable reviews | L (~30 calls, 4 files) | OPS-8 (to merge) | โ ๏ธ DB migration | On a branch by Fri 2 Oct. Merged only at OWNER-8 |
| ACC-2 | Trial on a restored copy, and the data-change record (ยง4) | own ยท Opus | S (~10 calls) | ACC-1, OPS-4 | โ ๏ธ writes the scratch database only | Mon 5 Oct |
| OWNER-8 | Apply the migration on the VM (ยง4) | owner | S (~30 min) | ACC-2, OPS-8 | โ ๏ธ needs special attention: a migration against the VM, with the owner's sign-off in the same turn | Tue 6 Oct 09:00 PDT (16:00 UTC) |
| ACC-3 | Sign-up, verify, reset, account, deletion, epoch (ยง5-ยง7) | own ยท Opus | XL (~45 calls, 12 files) | ACC-1, SEC-1, MAIL-1 | none | P2, by Wed 7 Oct |
| ACC-4 | Seasons, membership, ledger, CLI (ยง8-ยง9) | implement ยท Sonnet, Fable reviews the ledger rules | L (~28 calls, 7 files) | ACC-1 | โ ๏ธ ledger | P2 |
| ROLE-1 | Player makers vs staff, demo separation (ยง2, ยง13) | implement ยท Sonnet | M (~22 calls, 9 files) | ACC-1 | none | P2 |
| STAT-1 | `fantasy/stats.py` and `racinglines fantasy stats [--full]` (ยง10) | implement ยท Sonnet | M (~18 calls, 3 files) | ACC-4 | none | P2 |
| STAT-2 | `/fantasy`, `/leaderboard`, the MCP `leaderboard` tool (ยง10) | implement ยท Sonnet | M (~20 calls, 8 files) | STAT-1 | none | P2 |
| ADM-1 | `/admin/fantasy`, `SQL_HIDDEN`, IP pruning (ยง11-ยง12) | implement ยท Sonnet, Fable reviews settle and adjust | L (~28 calls, 5 files) | ACC-4, SETTLE-1 | โ ๏ธ manual settlement and F$ adjustments | P2 |
| LEGAL-1 | Drafts of `/terms`, `/privacy`, `/rules` (ยง12) | implement ยท Sonnet | S (~10 calls, 5 files) | OWNER-2 | none | P1, after decision batch A |
**Dispatch briefs** (name the model on every spawn, per CLAUDE.md):
```text
# SEC-1 โ Opus (own). EST: ~35 calls, 9 files
Scope: racinglines/web/app.py, racinglines/web/users.py, racinglines/web/admin.py, racinglines/web/demo.py,
racinglines/web/templates/login.html, racinglines/web/templates/base.html, scripts/deploy/smoke.sh,
tests/conftest.py (the app_test_db fixture), tests/test_auth.py (new), tests/test_views.py
Done when: section 7 rows 1, 3-8, the timing part of 12, 15 and 16(b) hold; app_test_db keeps every new test off
the dev database; the one-click demo logins and smoke.sh pass; no schema change
Verify: python -m pytest tests/test_auth.py tests/test_views.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# ACC-1 โ Opus (own), Fable reviews. EST: ~30 calls, 4 files
Scope: racinglines/db/models.py, migrations/versions/2026MMDD__fantasy_schema_v1.py (new),
tests/test_fantasy_schema.py (new), docs/database.md
Done when: every table and column in section 3 and fantasy-trading.md exists; guards, backfill and a real
downgrade; `alembic heads` prints one line
Verify: upgrade, downgrade -1, upgrade on a scratch database; python -m pytest tests/test_fantasy_schema.py -m "not live";
python -m pytest -m "not live"
Report: files changed, the verify commands and their last lines, the alembic heads output, what you did not check
```
```text
# ACC-2 โ Opus (own). EST: ~10 calls
Scope: scripts/ops/restore_scratch.sh (OPS-4's script, used as is), docs/data-changes.md
Done when: section 4 LOCAL steps 1-3 ran on racinglines_scratch with timings, backfill counts (demo 2, system 1)
and no duplicates; the data-changes section is in the ACC-1 PR
Verify: the log data/backups/acc2-trial-.log exists and its last lines show `alembic current` = the new revision
Report: the log path, the timings, the counts, what you did not check
```
```text
# ACC-3 โ Opus (own). EST: ~45 calls, 12 files
Scope: racinglines/web/accounts.py (new), racinglines/web/users.py, racinglines/web/app.py, racinglines/web/demo.py,
racinglines/cli/web.py (the uvicorn.access filter), templates signup, verify, forgot, reset, account (new),
login, base; tests/test_signup.py (new), tests/test_auth.py
Done when: sections 5-7 hold behind RACINGLINES_SIGNUP=off by default; sign-up 5/h per IP, resend 3/h per user,
forgot 3/h per email and 10/h per IP (IPv6 by /64), 6 mails a day per address; no raw token in
activity_log or the access log; account changes end every open link; unverified users reach only
/account and /verify/resend, and expire at 72 h
Verify: python -m pytest tests/test_signup.py tests/test_auth.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# ACC-4 โ Sonnet (implement), Fable reviews the ledger rules. EST: ~28 calls, 7 files
Scope: racinglines/fantasy/__init__.py, seasons.py, ledger.py (new), racinglines/cli/fantasy.py (new; plus "fantasy"
in GROUPS at racinglines/cli/__init__.py:20), racinglines/web/admin.py, tests/test_fantasy_seasons.py,
tests/test_ledger.py (new)
Done when: sections 8-9 hold; the CLI flags match the STAGE-1 and launch blocks in fantasy-runbook.md
Verify: python -m pytest tests/test_fantasy_seasons.py tests/test_ledger.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# ROLE-1 โ Sonnet (implement). EST: ~22 calls, 9 files
Scope: racinglines/web/app.py, views.py, jobs.py, admin.py; templates _macros.html, live_f1.html, live_picks.html,
login.html; tests/test_views.py
Done when: the section 2 role table and the section 13 table hold; demo accounts and smoke.sh behave as today
Verify: python -m pytest tests/test_views.py -m "not live"; bash scripts/deploy/smoke.sh http://127.0.0.1:8000
Report: files changed, the verify commands and their last lines, what you did not check
```
```text
# STAT-1 โ Sonnet (implement). EST: ~18 calls, 3 files
Scope: racinglines/fantasy/stats.py (new), racinglines/cli/fantasy.py, tests/test_fantasy_stats.py (new)
Done when: section 10 definitions hold; curve_stats is called, not copied; incremental equals --full
Verify: python -m pytest tests/test_fantasy_stats.py tests/test_reporting_metrics.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# STAT-2 โ Sonnet (implement). EST: ~20 calls, 8 files
Scope: racinglines/web/app.py, templates fantasy.html, leaderboard.html (new), base.html; racinglines/mcp/tools.py,
racinglines/mcp/server.py; tests/test_views.py, tests/test_mcp.py
Done when: /fantasy and /leaderboard as in section 10, 404 while RACINGLINES_FANTASY=0; no fair value reaches a
taker; the MCP leaderboard tool returns handles only
Verify: python -m pytest tests/test_views.py tests/test_mcp.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# ADM-1 โ Sonnet (implement), Fable reviews settle and adjust. EST: ~28 calls, 5 files
Scope: racinglines/web/admin.py, racinglines/web/templates/admin_fantasy.html (new), racinglines/mcp/tools.py,
racinglines/fantasy/ledger.py, tests/test_admin_fantasy.py (new); plus the prune step in racinglines/cli/fantasy.py
Done when: section 11 pages and actions, each writing its activity_log row with the reason; SQL_HIDDEN extended;
IPs pruned after 90 days; the database explorer refuses ledger and activity_log edits; admin reasons also
land in bankroll_entries.note; the member page shows the invite code that created the account
Verify: python -m pytest tests/test_admin_fantasy.py tests/test_mcp.py -m "not live"
Report: files changed, the verify command and its last line, what you did not check
```
```text
# LEGAL-1 โ Sonnet (implement). EST: ~10 calls, 5 files
Scope: racinglines/web/templates/terms.html, privacy.html, rules.html (new), racinglines/web/app.py, tests/test_views.py
Done when: the three public pages carry the section 12 outline, "Version 2026-10-08", and an owner placeholder
wherever DEC-1, DEC-4, DEC-7 or DEC-22 is open; each says it is a draft and not legal advice
Verify: python -m pytest tests/test_views.py -m "not live"
Report: files changed, the verify command and its last line, the placeholders left for the owner
```
**Docs obligations.**
- ACC-1 updates `docs/database.md`.
- STAT-2 adds the `leaderboard` tool to `docs/mcp.md`.
- DOC-2 updates `docs/webapp.md` (roles, accounts, auth) and `docs/cli.md` (the `fantasy` group) after the merges.
- Every change passes `mkdocs build --strict`.
### What this doc's tasks put on the go/no-go
| Go/no-go line (in the [runbook](fantasy-runbook.md#go-no-go)) | Evidence from these tasks |
|---|---|
| `fantasy_schema_v1` applied on the VM after a dump | OWNER-8 step 3 log: `alembic current`, `db changes`, the dump's name; the ACC-2 trial log |
| `APP_SECRET` set | A web restart keeps an existing session signed in |
| Sign-up rehearsal passed | STAGE-1 lines for sign-up, verify, grant, lock, reset, deletion, and the cap |
| Terms live with `TERMS_VERSION` 2026-10-08 | `/terms`, `/privacy` and `/rules` return 200. The sign-up page says "paper money, no prizes, no cash value" |
| Stats: players only, handles only, no fair value for takers | `/leaderboard` screenshot; 3 spot checks of `fantasy_stats` balance against the `bankroll_entries` sum |
| Admin: suspend, adjust with a reason, void, revoke sessions | `activity_log` rows `member_suspend`, `bankroll_adjust`, `fantasy_settle`, `user_update` |
| Demo unaffected | One-click demo logins work; `smoke.sh` passes against https://racinglines.bet |
| Admin behind Cloudflare Access (ยง7 row 16) | A signed-out browser on `https://racinglines.bet/admin` gets the Cloudflare Access PIN page; `/admin/sql` refuses writes while `RACINGLINES_SIGNUP` is not `off` |
| Bot check decided (DEC-16) | Either the Turnstile keys are set and the widget shows on `/signup`, `/password/forgot` and `/verify/resend`, or DEC-16 is recorded as "no" |
| No player PII in shared dumps (ยง12) | The bucket's "latest" copy has no rows in `email_tokens`, `invite_codes` or `activity_log`, and no player email |
The last three lines are go/no-go lines 20, 22 and 23 in the [runbook](fantasy-runbook.md#go-no-go) (line 20
also accepts SEC-1's fallback when Cloudflare Access is not on).
---
## 15. Names used across these docs
This is the subset of the conventions block this page uses, copied word for word. The master copy is in
[Fantasy soft launch](fantasy-launch.md).
```text
TERMS
- fantasy season = one row in fantasy_seasons. This is never the racing `seasons` table. First season slug: 2026-s1. Dry-run/staging season slug: 2026-dryrun. It is closed before launch and left out of stats.
- app role = users.role (admin|maker|taker); it controls permissions. season role = season_members.role (maker|taker); it is the choice made at sign-up, locked by season_members.locked_at until the season's status becomes closed.
- account type = users.account_type: player (signed up through /signup), staff (owner, admin, collaborators, R16 testers), demo (maker, taker), system (polymarket-takers). Only players appear in leaderboards and fantasy stats.
- handle = users.username. Lowercase, regex ^[a-z0-9][a-z0-9_-]{2,19}$. It is the only public identity; email is never shown to another user. Reserved: admin, root, staff, support, noreply, system, demo, maker, taker, polymarket-takers, racinglines, plus the value of ADMIN_USERNAME.
- F$ = fantasy dollars: paper money, no cash value, not transferable. Player-facing copy never uses a bare "$".
- private book = the in-app DB book (house_markets / house_bets). live book = the file-based demo book under data/runs/live// (demo maker plus simulated crowd). Players never trade the live book, and the crowd never fills a player's quote.
- exchange paper trade = a player's order filled against a venue's live order book (Kalshi, Polymarket) and stored only in fantasy_orders / fantasy_fills / fantasy_positions. It never goes into paper_positions or strategy_signals, and never through racinglines/markets/polymarket/trade.py or racinglines/markets/kalshi/trade.py.
- equity: taker = balance + open private-book stakes at cost + open exchange positions at liquidation value (YES shares x best YES bid; NO shares x (1 - best YES ask)), marked from market_links last_bid/last_ask. maker = balance (settled only; open worst case shown, not ranked). ROI = (equity - starting_bankroll) / starting_bankroll.
- closed dry run = R16, Thu 1 Oct to Sun 4 Oct, 5 or fewer admin-created staff tester accounts, no sign-up. staging rehearsal = Wed 7 Oct on the VM with RACINGLINES_SIGNUP=invite and tester codes in season 2026-dryrun. soft launch = RACINGLINES_SIGNUP=invite from Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC). open sign-up = RACINGLINES_SIGNUP=open (not before Thu 22 Oct).
- Times: owner-facing times in PDT with UTC in brackets; systemd OnCalendar in UTC.
TABLES: one additive Alembic revision fantasy_schema_v1, file migrations/versions/2026MMDD__fantasy_schema_v1.py, down_revision = the single head at branch time (c8e3f6a2d4b1 on 29 Sep)
- users (+): email String(254) NULL; email_verified_at; terms_version String(20); terms_accepted_at; age_confirmed_at; account_type String(10) NOT NULL DEFAULT 'staff' CHECK IN (player,staff,demo,system); session_epoch Integer NOT NULL DEFAULT 0; last_login_at; deleted_at. Indexes: unique ix_users_username_lower ON lower(username), unique ix_users_email_lower ON lower(email). CHECK users.role IN (admin,maker,taker). Backfill: maker, taker -> demo; polymarket-takers -> system; everything else -> staff.
- email_tokens: id, user_id FK CASCADE, purpose (verify|reset|email_change), token_hash String(64) UNIQUE (sha256 hex), email, created_at, expires_at, used_at. TTL: verify 24 h, reset 1 h, email_change 24 h.
- invite_codes: id, code_hash String(64) UNIQUE, label, season_id FK, created_by FK SET NULL, max_uses DEFAULT 1, uses DEFAULT 0, expires_at, revoked_at, created_at.
- fantasy_seasons: id, slug UNIQUE, name, status (draft|open|closed), starts_at, ends_at NULL (NULL = TBD), signup_opens_at, signup_closes_at NULL, bankroll_taker Numeric(12,2), bankroll_maker Numeric(12,2), max_stake Numeric(12,2), max_market_liability Numeric(12,2), notes JSONB, created_at.
- season_members: id, season_id FK, user_id FK, role (maker|taker), locked_at, joined_at, starting_bankroll Numeric(12,2), status (active|suspended|left). UNIQUE(season_id, user_id).
- bankroll_entries: id, season_id, user_id, ts, kind (grant|stake|payout|refund|trade|fee|settle|book_pnl|adjust), amount Numeric(12,2) signed, ref_table, ref_id, note, created_by. UNIQUE(ref_table, ref_id, kind). balance = SUM(amount).
- fantasy_orders: id, season_id, user_id, market_link_id FK, exchange (kalshi|polymarket), token_id, side (yes|no), action (buy|sell), shares_req, limit_price, status (filled|partial|rejected), reject_reason, book JSONB (levels used, fetched_at), created_at.
- fantasy_fills: id, order_id FK, season_id, user_id, market_link_id, exchange, side, action, shares, price, fee, ts.
- fantasy_positions: id, season_id, user_id, market_link_id, exchange, yes_shares, no_shares, cost, fees, status (open|settled|void), outcome Boolean NULL, payout, settled_at. UNIQUE(season_id, user_id, market_link_id).
- fantasy_event_results: season_id, user_id, event_key, venue (kalshi|polymarket|book), role, pnl, turnover, bets, won, settled_at. PK(season_id, user_id, event_key, venue).
- fantasy_stats: season_id, user_id, venue (all|kalshi|polymarket|book), role, starting_bankroll, balance, equity, pnl, roi, turnover, bets, settled, won, hit_rate, max_drawdown (positive number), sharpe (x sqrt(24); NULL under 4 events), events, rank, computed_at. PK(season_id, user_id, venue).
- house_markets (+): season_id FK NULL, closes_at, max_liability Numeric(12,2). house_bets (+): season_id FK NULL, fair_prob_at_bet, settled_at.
ENV (in /etc/racinglines.env on the VM; every new one is off or safe by default)
RACINGLINES_FANTASY=0|1 (master switch: nav, /fantasy, /leaderboard); RACINGLINES_SIGNUP=off|invite|open (default off); RACINGLINES_SIGNUP_CAP=50; RACINGLINES_FANTASY_BOOK=0|1; RACINGLINES_FANTASY_EXCHANGE=0|1; RACINGLINES_FANTASY_EXCHANGE_MAKERS=0 (stays 0 this sprint); RACINGLINES_MAIL_BACKEND=log|smtp (default log); RACINGLINES_SMTP_HOST=smtp.protonmail.ch; RACINGLINES_SMTP_PORT=587; RACINGLINES_SMTP_USER=noreply@racinglines.bet; RACINGLINES_SMTP_TOKEN (secret, never in git); RACINGLINES_MAIL_FROM=racinglines ; RACINGLINES_MAIL_REPLY_TO=support@racinglines.bet; RACINGLINES_MAIL_DAILY_CAP=200; RACINGLINES_TURNSTILE_SITEKEY and RACINGLINES_TURNSTILE_SECRET (empty = widget off); RACINGLINES_OPS_NTFY_TOPIC (ops alerts, separate from RACINGLINES_NTFY_TOPIC for market alerts); RACINGLINES_HEALTHCHECK_PING_URL (dead-man ping). Existing: RACINGLINES_URL=https://racinglines.bet (base for mail links); RACINGLINES_GCS_BUCKET; APP_SECRET (must be set); MAX_STAKE (per-bet cap, F$100); RACINGLINES_KALSHI_SPRINTS; RACINGLINES_CANCELLED_RACE_RULES (not changed by fantasy); RACINGLINES_DEMO_USERS; RACINGLINES_DEMO_CONTEXT. Never touched: POLYMARKET_TRADING_ENABLED, KALSHI_TRADING_ENABLED.
COOKIES: rl_session = uid|expires|sid|epoch|HMAC (epoch = users.session_epoch; bumping it ends every session). rl_csrf = signed double-submit token for unauthenticated forms.
ROUTES
Public (add to PUBLIC_PATHS): /signup, /verify/{token}, /verify/resend, /password/forgot, /password/reset/{token}, /terms, /privacy, /rules, /healthz.
Signed in: /account (GET/POST), /account/delete (POST), /fantasy, /fantasy/markets, /fantasy/orders (POST), /fantasy/book, /book/markets/{id}/take (existing POST), /leaderboard, POST /logout (GET /logout shows a confirm form).
Admin: /admin/fantasy, /admin/fantasy/seasons/{id}, /admin/fantasy/invites, /admin/fantasy/members/{user_id}, /admin/fantasy/settle.
CLI: racinglines fantasy season create|set|close|list; racinglines fantasy invite create|revoke|list; racinglines fantasy tick [--full]; racinglines fantasy stats [--full]; racinglines mail test --to ; racinglines ops health [--alert]; racinglines ops backup [--label ].
CODE: racinglines/fantasy/{seasons,ledger,book,exchange,settle,stats}.py; racinglines/markets/books.py (read-only live order books; does not import polymarket/trade.py); racinglines/web/{accounts,mailer,health}.py; racinglines/ops/{health,backup}.py; racinglines/cli/{fantasy,ops,mail}.py; TERMS_VERSION = '2026-10-08' in racinglines/web/accounts.py. Templates: signup, verify, forgot, reset, account, terms, privacy, rules, fantasy, fantasy_markets, fantasy_book, leaderboard, admin_fantasy (.html); mail/verify.txt, mail/reset.txt, mail/welcome.txt, mail/email_changed.txt. Tests: tests/test_auth.py, test_signup.py, test_mailer.py, test_fantasy_schema.py, test_fantasy_seasons.py, test_ledger.py, test_private_book.py, test_fantasy_book.py, test_fantasy_exchange.py, test_fantasy_settle.py, test_fantasy_stats.py, test_health.py, test_ops_health.py, test_ops_backup.py; fixtures in tests/fixtures/fantasy/.
activity_log actions (new): signup, signup_rejected, email_verify, verify_resend, password_reset_request, password_reset, password_change, email_change, account_delete, role_override, bankroll_adjust, member_suspend, invite_create, invite_revoke, season_update, fantasy_order, fantasy_order_rejected, fantasy_settle, fantasy_resettle, mail_sent, mail_failed.
SYSTEMD (deploy/vm/systemd/): racinglines-health.service + .timer (every 5 min); racinglines-backup.service + .timer (daily 10:00 UTC); racinglines-kalshi-sync.service + .timer (every 30 min, daily closed sync; this is U2); racinglines-fantasy.service + .timer (every 10 min: close, settle, stats). Existing and unchanged in name: racinglines-web, racinglines-recorder, racinglines-signals(.timer), racinglines-live-f1@(.timer), racinglines-live-dh@, racinglines-mcp, cloudflared. GCE: snapshot resource policy racinglines-daily (14-day retention) on disk racinglines-vm.
BACKUPS: pre-change data/backups/db/racinglines-before--.sql.gz (CLAUDE.md); nightly data/backups/db/racinglines-nightly-.sql.gz (7 kept on disk) and gs://$RACINGLINES_GCS_BUCKET/db/nightly/ (35-day lifecycle); owner-run logs /opt/racinglines/data/backups/-.log.
EMAIL (Proton Mail Plus, 10-address cap): admin@racinglines.bet (owner mailbox), support@racinglines.bet (catch-all target and reply-to), noreply@racinglines.bet (app sender, holds the SMTP token). DMARC reports go to the rua address from Cloudflare DMARC Management.
```
---
## What this doesn't cover
- How players trade: fills, fees, closing times, private-book collateral, settlement and the settle queue. See
[Fantasy trading](fantasy-trading.md).
- VM monitoring, backups, the `vm.sh --migrate` guard (OPS-8), `restore_scratch.sh` (OPS-4) and the incident
runbooks. See [VM reliability](vm-reliability.md).
- Proton Mail, DNS, the SMTP token and how the mailer works inside. See [Email setup](email-setup.md).
- The calendar, the decisions table, the go/no-go checklist, and the STAGE-1 and launch-day blocks. See
the [runbook](fantasy-runbook.md).
- Prizes, payments, identity checks and geo-blocking. None are offered (DEC-1, DEC-22). Nothing here is legal
advice.
- Two-factor sign-in for players (the admin gets a second factor from Cloudflare Access, ยง7 row 16), passkeys,
social sign-in, handle changes, public profiles, a self-service data export, a join page for later seasons, a
public leaderboard, and any mail beyond the four templates.
## Open items
1. **Email enumeration at sign-up.** "That email already has an account" reveals which addresses are registered.
That is accepted for the invite-only launch (limited to 5 per hour per IP). The alternative, which reveals
nothing, needs a fifth mail template. Decide before open sign-up (Thu 22 Oct at the earliest).
2. **A throttle kept in the database.** The login throttle lives in memory and resets on every deploy. Sign-up,
resend and forgot limits are already counted in the database. The login throttle could move there too after the
soft launch.
3. **Anyone can lock a player's handle.** The per-username lock (10 failures in 15 minutes) lets anyone lock a
player handle for 15 minutes. Accepted for the soft launch. Staff handles, the admin's included, are locked per
(username, IP) instead, and `/admin` sits behind Cloudflare Access before sign-up opens (ยง7 rows 7 and 16).
4. **Public leaderboard later.** DEC-7 (data terms) and DEC-13 keep boards login-only for now.
5. **SEC-1 scope.** `tests/test_mcp.py:218` and `:249` create users with password `"pw"`, which the new policy in
`create_user` refuses. Recommendation: add `tests/test_mcp.py` to SEC-1's scope and use passwords of 10 or more
characters. FABLE-1 decides; the worker doesn't bend the test.
6. **Files outside the plan's lists.** Adding `"fantasy"` to `GROUPS` in `racinglines/cli/__init__.py` (ACC-4); the
prune step in `racinglines/cli/fantasy.py` (ADM-1); `docs/mcp.md` (STAT-2). If LEGAL-1 merges before ACC-3, it
creates `racinglines/web/accounts.py` holding only `TERMS_VERSION`, and ACC-3 extends it.
7. **Admin F$ adjust ref differs from the trading doc.** [Fantasy trading](fantasy-trading.md) describes a later
admin F$ adjust as having "no ref". This page gives it the `activity_log` row that holds the reason, which keeps
it traceable and makes a double-submit harmless. The columns stay nullable, so both readings fit the schema. The
DOC-1 review should pick one and align the two pages.
8. **The demo backfill assumes default usernames.** It assumes the VM runs the default `RACINGLINES_DEMO_USERS`.
ACC-2's counts (2 demo, 1 system) confirm it on a copy of the VM's data.
9. **Unverified accounts** count toward the cap until they expire at 72 hours (ยง6). Whether they should count at
all is for open sign-up to decide.
10. **Re-accepting the terms** when `TERMS_VERSION` changes isn't built. The terms must not change during 2026-s1
unless that flow is added.
11. **12-hour sessions** mean players sign in about twice a day. A longer "remember me" is a later decision.
12. **Eligibility wording.** ยง10 reads DEC-13's "5 or more settled bets or fills" as settled book bets plus exchange
fills for takers, and settled bets taken for makers. The owner should confirm this with batch B (Mon 5 Oct).
13. **One open season at a time** is enforced by an index. That suits 2026. Seasons that overlap would need the
index dropped and a season picked explicitly at sign-up.
14. **Harder multi-account checks.** Email normalisation (ยง5) stops one Gmail inbox from making many accounts, but
not a second mailbox. Disposable-domain blocking needs a list to maintain and is left out. The maker-taker pair
report (over 50% of volume, the same IP) is in [Fantasy trading](fantasy-trading.md); with no prizes (DEC-1)
collusion only bends the board, and `/rules` says it voids results. Revisit if prizes are ever considered.
15. **A database-level guard on `activity_log`.** The app uses one database role, so revoking UPDATE and DELETE on
`activity_log` would also block the 90-day prune. A trigger that allows only the prune's column changes would
work; it would be a second migration, so it is left for after the soft launch. Until then the guard is the app:
the explorer's read-only list and the write toggle refused while sign-up is on (ยง11).
16. **Cross-doc follow-ups for DOC-1:** the backup rules in ยง12 go into [VM reliability](vm-reliability.md)
(OPS-3, OPS-4); the reserved share of the daily cap goes into [Email setup](email-setup.md); the three new
go/no-go lines (ยง14) are lines 20, 22 and 23 in the [runbook](fantasy-runbook.md#go-no-go).
17. **Owner placeholders still open:** DEC-4 (the end date), DEC-5 (amounts), DEC-16 (Turnstile yes or no), DEC-22
(terms wording and the export response time), DEC-7 (the attribution text).
---
# Fantasy trading: exchange markets and private books
**Status:** draft for the owner, Tue 2026-09-29. Every feature is behind a switch that is off by default. A first cut
of exchange paper trading for takers is built (2026-10-12, staging only): see [First cut](#first-cut-2026-10-12).
## First cut (2026-10-12)
The owner asked for "first-cut manual trading, staging only" and a balance players can see ("NO REAL TRADES OR
MONEY"). What is built, and where it differs from the design below:
| | First cut | Design (below) |
|---|---|---|
| Pages | `/trade` (the markets and one market's book with the order form), `POST /fantasy/orders`, `/history` (your F$, every trade by hand and by your strategy, F$ through the run, and the leaderboard ranked by ROI ร (trades รท 10)^0.3, see [alpha layout](alpha-layout.md)) (`racinglines/web/fantasy_routes.py`). The first cut's `/fantasy`, `/fantasy/trade` and `/leaderboard` redirect there ([Alpha layout](alpha-layout.md)) | `/fantasy/markets` |
| Switch | `RACINGLINES_FANTASY=1`, or unset on staging (`RACINGLINES_ENV=staging`); off on production | `RACINGLINES_FANTASY` + `RACINGLINES_FANTASY_EXCHANGE` |
| Prices | The recorder's latest `market_book_snapshots` row for the link's token, refused past 15 minutes (`RACINGLINES_FANTASY_BOOK_MAX_AGE_MIN`). Staging has no recorder, so `RACINGLINES_FANTASY_BOOKS_URL` points it at production's database; that engine is always read-only (`default_transaction_read_only`) | A live fetch per order through `markets/books.py` (needs EXCH-0) |
| Who trades | Every active account with a ledger grant; demo accounts look only | Season members with the taker role |
| Ledger | `accounts.fantasy_ledger` (the 1,000 grant) gains `ref_table`/`ref_id`: one `trade` row per fill, one `fee` row per order | `bankroll_entries`, season-scoped |
| Tables | `accounts.fantasy_orders`, `fantasy_fills`, `fantasy_positions`, created by `racinglines users setup` (no Alembic migration; a deploy writes nothing) | Alembic `fantasy_schema_v1` |
| Closing | The deciding round's stored start (`pipelines/clv.closes`); a race link without one is refused | The FastF1 schedule parquet; season futures paused during sessions |
| Settlement | `fantasy/exchange.settle`, run when Trade or History loads (staging has no timers): a position whose link's token has resolved (`market_links.resolved_yes`, read from the books database by exact token id) pays F$1 per winning share as one `settle` ledger row and becomes `settled`; a settled position can't be traded. No void or admin price settlement yet | SETTLE-1 |
| Total F$ | The 1,000 grant + manual P&L + the followed strategy's paper P&L (`paper_positions`) | `fantasy_stats` |
The fill model, NO-side derivation, fee rules, order cap, rate limit and rejection codes are the design's, in
`racinglines/fantasy/exchange.py`; tests in `tests/test_fantasy_exchange.py` and `tests/test_fantasy_trading.py`.
From the soft launch (Thu 8 Oct 18:00 PDT [Fri 9 Oct 01:00 UTC]), invited players trade F1 with F$, which is paper
money. There are two ways to trade. A **taker** makes exchange paper trades: orders fill against Kalshi's or
Polymarket's live order book at the real prices, and the result is stored only in our own tables. A **maker** quotes
the in-app private book, and human takers bet against those quotes. This page covers both paths: the fill and fee rules,
the tables, settlement, anti-abuse, the pages and the tests. It also covers what stays untouched: the order path
(`racinglines/markets/polymarket/trade.py`, `racinglines/markets/kalshi/trade.py`), the frozen profiles A, C and K, the
demo records and the goldens. The sprint plan is indexed in
[Fantasy soft launch](fantasy-launch.md); every decision (DEC-n), STAGE-1 and the launch blocks are in the
[runbook](fantasy-runbook.md). Accounts, seasons, the ledger and stats are in
[Fantasy accounts](fantasy-accounts.md). The timers and backups this relies on are in
[VM reliability](vm-reliability.md). Mail is in [Email setup](email-setup.md).
## Ground rules
| Rule | How it is held |
|---|---|
| Paper money only (F$). No cash value, no prizes (DEC-1). | Copy says F$ everywhere. `POLYMARKET_TRADING_ENABLED` and `KALSHI_TRADING_ENABLED` are never read, set or proposed by anything here. |
| Fantasy code never reaches the order path. | `racinglines/fantasy/*.py` and `racinglines/markets/books.py` (new) never import `polymarket/trade.py` or `kalshi/trade.py`, and never call `post_order`. A new guard test sits next to `test_signal_code_never_touches_the_order_path` in `tests/test_signals.py`. A second, runtime check is in `tests/test_fantasy_exchange.py`. |
| Takers never see fair value or edge. | Taker pages show venue prices, maker quotes and the maker's handle only. They show no model probability, no edge, and no strategy-profile call (a call is the model's edge in another form). |
| Human ledgers are separate. | Players' trades live in `fantasy_orders`, `fantasy_fills`, `fantasy_positions`, `house_bets` and `bankroll_entries`. They never touch `paper_positions` or `strategy_signals`, which the engine deletes and rewrites on every run (`pipelines/signals.py:532`, `pipelines/season_strategy.py:331`, `markets/crowd.py:240`, `pipelines/demo_history.py:37-39`). |
| Goldens stay byte-identical. | `git diff --stat main -- tests/golden` is empty on every PR on this page. `tests/golden/f1_maker_replay.json` is the one that NEXT-1 must keep. |
| Everything is switched off by default. | Book features: `RACINGLINES_FANTASY_BOOK`. Exchange features: `RACINGLINES_FANTASY_EXCHANGE`. Both sit under the master switch `RACINGLINES_FANTASY`. `RACINGLINES_FANTASY_EXCHANGE_MAKERS` stays 0 this sprint. |
| The global cancelled-race switch is not changed. | `RACINGLINES_CANCELLED_RACE_RULES` keeps its value. Fantasy settlement passes `rules=False` and applies the private book's void rule itself (see [Settlement](#settlement-settle-1-dec-11)). |
## What is tradable at the soft launch (DEC-20)
This is the build scope (DEC-20, batch A, due Wed 30 Sep 18:00 PDT). The final list is due Wed 7 Oct 12:00 PDT.
Kind codes are the `market_links.prediction` / `house_markets.kind` values. The race kinds (`race_*`) are defined in
`racinglines/markets/kinds.py`; the season kinds (`champion`, `constructors_champion`, `season_wins_ge`,
`standings_h2h`) are listed in `PREDICTION_KINDS` in `racinglines/db/reads.py`.
| Venue and market | Kinds | Taker at launch | Maker at launch | Later |
|---|---|---|---|---|
| Kalshi F1 race, R17 Singapore | `race_win`, `race_podium`, `race_top10`, `race_constructor_top`; `race_pole` and `race_h2h` if listed | Exchange paper trade (EXCH-1) | No (DEC-6) | Makers: NEXT-1, R19 at the earliest |
| Kalshi F1 sprint, R17 | `race_sprint_win`, `race_sprint_pole` | Only if Kalshi lists them; sprint classification is on by default since 2026-10-06 (DEC-12) | No | Same as above |
| Kalshi F1 season futures | `champion`, `constructors_champion` | Exchange paper trade | No | Same as above |
| Polymarket F1 season futures | `champion`, `constructors_champion` | Exchange paper trade | No | `season_wins_ge` and `standings_h2h`: owner input |
| Polymarket F1 race | none listed since 28 Aug | Same rules as Kalshi race, if a market is listed | No | |
| Private book, F1 R17 | Generated: `race_win`, `race_podium`, `race_top10`. Mirrored from Kalshi or Polymarket: `race_pole`, `race_h2h`, `race_constructor_top` | Bet (BOOK-1, BOOK-3) | Quote (BOOK-3) | Private-book season markets: owner input |
| Private book, sprint kinds | `race_sprint_win`, `race_sprint_pole` | Excluded | Excluded | Once `private_book.race_outcomes` reads the sprint rounds (it reads `race`/`final` and `qual` only today) |
| Live book (demo) | the file-based book under `data/runs/live//` | Never (DEC-9) | Never | |
| OG.com F1 | | Coming soon | Coming soon | NEXT-2, once its fee is confirmed ([Coverage](coverage.md#ogcom)) |
| NASCAR, MotoGP, IndyCar (Kalshi tapes) | `unmodeled` | Coming soon | Coming soon | NEXT-2 |
| UCI downhill | | Coming soon | Coming soon | No event until 2027 |
The tradable list is a code default in `racinglines/fantasy/exchange.py` (new). When the key is present, it is
overridden by `fantasy_seasons.notes["tradable"]` (`{"kalshi": [kinds], "polymarket": [kinds], "book": [kinds]}`).
The owner can therefore drop a venue (the DEC-7 fallback) or add a kind with `racinglines fantasy season set`
(ACC-4), with no deploy.
!!! note "Owner input: the final market list (DEC-20, DEC-12, DEC-7)"
"More info coming." Until Wed 7 Oct 12:00 PDT this table is the build scope, not the launch list. To fill in:
(1) Polymarket `season_wins_ge` and `standings_h2h`: in or out. (2) R17 sprint kinds: in only if Kalshi lists
them by Wed 7 Oct. (3) Private-book season markets: in or out. (4) Kalshi: in, unless its market-data terms
forbid showing prices to third parties (DEC-7). Then the launch is the private book plus Polymarket season
futures.
!!! note "Owner input: season dates (DEC-4)"
`fantasy_seasons.ends_at` stays NULL (TBD). Season futures resolve after Abu Dhabi (6 Dec), expected 7-8 Dec, so
exchange positions on them stay open until then and count in equity at their liquidation value.
### R17 closing times (DEC-14)
A market closes at the start of the session that decides it. This applies to exchange trades and book bets alike.
Times come from the same rule as `pipelines/weekend_sweep.schedule(2026)`, which builds `closes` and `race_start`
through `core/stages.build` from `sports/f1.toml` `[stages]`. The web order path never calls `schedule()` itself:
each call imports `fastf1`, asks FastF1's servers for the schedule (falling back to the saved copy only on an error)
and rewrites `data/raw/f1/fastf1//schedule.parquet`. That is a heavy import, an unbounded network wait and a
disk write on a 2 GB box. Instead:
- EXCH-1 splits the per-row loop of `weekend_sweep.schedule` into a pure `schedule_from_frame(sch, year)` that
`schedule()` then calls. This is a refactor with identical output: the existing weekend-sweep tests and every
golden stay byte-identical.
- `fantasy/exchange.load_schedule(year)` (new) reads the saved `data/raw/f1/fastf1//schedule.parquet` with
pandas and passes it to `schedule_from_frame`. There is no `fastf1` import, no network call and no write. The
result, `{round: dict}`, is cached in-process for an hour and keyed on the file's mtime. The cache wrapper is new
code.
- The fantasy tick (SETTLE-1) keeps the parquet fresh: once an hour it calls `weekend_sweep.schedule(2026)` inside
its own oneshot process, with a 60 s timeout. A failure there is logged and the saved copy stays in use.
| Kinds | Close at | R17 (PDT [UTC]) |
|---|---|---|
| `race_sprint_pole` | Sprint Qualifying start (`[stages.closes]`) | Fri 9 Oct 05:30 [12:30] |
| `race_sprint_win` | Sprint start (`[stages.closes]`) | Sat 10 Oct 02:00 [09:00] |
| `race_pole` | Qualifying start (`[stages.closes]`) | Sat 10 Oct 06:00 [13:00] |
| `race_win`, `race_podium`, `race_top10`, `race_h2h`, `race_constructor_top` | Race start (`until = "Race"`) | Sun 11 Oct 05:00 [12:00] |
| `champion`, `constructors_champion` (proposed, part of DEC-14) | Paused while a Race or Sprint session runs: from its start to start + its `[sessions.schedule]` minutes + 30 | Sprint Sat 10 Oct 02:00-03:15 [09:00-10:15]; Race Sun 11 Oct 05:00-08:00 [12:00-15:00] |
`core/stages.is_open(kind, t, closes)` returns True for any kind that is missing from `closes`. The race kinds are
not listed there, because the race itself is `until`. The new helper `fantasy/exchange.closes_at(link, sched)`
therefore returns `closes.get(kind) or race_start` for every `race_*` kind. It fails closed: a race link with no
`params.event_key`, a round missing from the schedule, or a missing or unreadable parquet is refused with
`no_schedule`.
## Exchange paper trading for takers (EXCH-1)
### Price source: `racinglines/markets/books.py` (new)
This is a read-only fetcher for live order books. It never imports `polymarket/trade.py` or `kalshi/trade.py`.
| Venue | Request | Parsing |
|---|---|---|
| Kalshi | `GET /trade-api/v2/markets/{ticker}/orderbook?depth=10`, the same path as `kalshi/client.Client.orderbook`, through the Kalshi `Client`'s httpx client (its `transport` is mocked in tests) | `kalshi/sync.book_row(ticker, ob, ts, depth=10)` (existing): YES bids, and NO bids converted to YES asks at 1 - p, best first |
| Polymarket | `GET {POLYMARKET_CLOB_HOST}/book?token_id=` (default `https://clob.polymarket.com`), read from the environment directly | Its own parser, written to the same shape as `polymarket/trade.book` but never importing it: bids descending, asks ascending, `tick_size`, `min_order_size`, and the book's `timestamp` if present |
- `Book` (new dataclass): `exchange`, `token_id`, `bids` and `asks` as `[(price, size)]` best first (YES side),
`fetched_at` (UTC), `venue_ts` (Polymarket's `timestamp`, else None) and `tick_size`.
- `get(exchange, token_id, now=None) -> Book` (new) keeps a process-local cache keyed by `(exchange, token_id)`,
behind a lock, with at most 2,000 entries. `CACHE_S = 10`: a cached book up to 10 s old is reused. When it is older,
`get` fetches again. If that fetch fails, a cached book up to `MAX_AGE_S = 30` s old is still served (and flagged).
Past 30 s, `get` raises `StaleBook`. With no cache at all, it raises `BookUnavailable`.
- Timeouts: `sources/http.request` retries 5 times with waits of up to 120 s (`MAX_TRIES`, `MAX_WAIT`), which is too long for a
web request. `books.py` calls `http.request(client, "GET", path, tries=2, params=...)` on an httpx client with a
5 s timeout, so a player's order waits at most about 12 s. The per-host pacing in `sources/http.py`
(`HOST_INTERVAL`) still applies.
- The list on `/fantasy/markets` shows `market_links.last_bid` / `last_ask` from the sync, with its age. It makes
no venue call. A live book is fetched only for the market panel (`/fantasy/markets?link=`) and at order time.
### Order flow: `POST /fantasy/orders`
Form fields: `market_link_id`, `side` (yes, no), `action` (buy, sell), `shares` (whole shares), `limit_price`
(0.01-0.99) and `csrf_token`. The panel prefills the limit at the best ask for a buy and the best bid for a sell.
Every step below is in `fantasy/exchange.place(...)` (new), called from the route in `racinglines/web/app.py`.
1. Gate. `RACINGLINES_FANTASY` and `RACINGLINES_FANTASY_EXCHANGE` must both be on. A request refused here writes no
row and returns 404.
2. Per-user rate limit (in memory, one uvicorn process; see [Limits](#limits)). A refused request writes no
order row. At most one `fantasy_order_rejected` audit row is written per user per minute.
3. Validate the input, the membership (a season role of `taker`, status `active`), the kind allow-list, the link
(`NOT closed`, `active`, `resolved_yes IS NULL`), the closing time and the order cap.
4. Fetch the book with `books.get`. **No database lock is held during the HTTP call.**
5. In one transaction: `SELECT ... FOR UPDATE` on the player's `season_members` row, then check the balance, walk
the book, insert `fantasy_orders` and `fantasy_fills`, upsert `fantasy_positions` and write the ledger entries.
6. Write an `activity_log` row: `fantasy_order`, with the order id, fills and fee, or `fantasy_order_rejected`, with
the reason.
7. Redirect (303) to `/fantasy/markets?link=&msg=...` with the fills, the fee and the new position.
Every refusal after step 2 still writes a `fantasy_orders` row with `status = 'rejected'` and a `reject_reason`, so
the admin pages and the report can count refusals.
### Fill model
An order fills immediately or not at all, like immediate-or-cancel. Takers only: nothing rests on a book.
- **Buy:** walk the ask levels from the best one up. At each level, take `min(level size, shares still wanted)` while the
level price is at or below `limit_price`. Stop at the limit, at the requested shares, or at the end of the
visible depth (10 levels).
- **Sell (close):** walk the bid levels from the best one down, at or above the limit. Selling more than the position
holds is refused (`sell_exceeds_position`). There is no short selling.
- **One side per position:** buying NO while holding YES on the same link (or the other way round) is refused
(`opposite_side_held`). The player sells first. This keeps positions simple, and it is what a CHECK on
`fantasy_positions` enforces.
- **Status:** `filled` (every requested share filled), `partial` (at least one share, fewer than requested; the rest is
cancelled), `rejected` (none).
- **Cost basis:** `fantasy_positions.cost` is the average-cost basis of the shares still held. A buy adds
`shares x price`. A sell removes `shares x average cost`, and its realized P&L shows up only in the ledger, as the
sell's `trade` entry minus that basis.
- **No shared depletion:** two players who buy the same level within one 10 s cache window both fill against the
full visible size. This is a documented limitation. It is bounded by the F$100 order cap and the 50-player invite
cap. [Open questions](#open-questions) covers it.
Worked example (Kalshi, YES asks 0.41 x 120, 0.42 x 300, 0.44 x 500; buy 200 YES, limit 0.42):
| | Shares | Price | Notional (F$) | Fee term 0.07 x C x P x (1 - P) |
|---|---|---|---|---|
| Level 1 | 120 | 0.41 | 49.20 | 2.03196 |
| Level 2 | 80 | 0.42 | 33.60 | 1.36416 |
| Order | 200 | | 82.80 | 3.39612, charged as **F$3.40** |
The ledger gets `trade` -49.20, `trade` -33.60 and `fee` -3.40. The position ends with `yes_shares` 200,
`cost` 82.80 and `fees` 3.40. If YES happens, the position settles at +200.00 (P&L +113.80); if NO, at 0.00
(P&L -86.20).
### The NO side and Polymarket's outcome-0 links
- A book is always read on the link's own token, as the YES side. The NO prices are derived from it, as
`maker_replay.to_yes` does for trades. The NO ask levels are `1 - YES bid` (walked from the highest YES bid down).
The NO bid levels are `1 - YES ask`. Example: YES bids 0.39 x 150 and 0.38 x 400 make NO asks 0.61 x 150 and
0.62 x 400.
- Kalshi: one link per market ticker, and the token is the YES contract (`kalshi/sync.link_rows`), so this is exact.
- Polymarket: `polymarket/sync.py` links outcome 0 only, except `race_h2h`, which links both tokens (index 0 and 1).
`/fantasy/markets` lists one row per `condition_id` for `race_h2h` (the outcome-0 link), the same pairing
`private_book.mirror_event` uses. A player who wants the other driver buys NO. Positions are stored on the
outcome-0 link, so the same bet can't be held twice through the two tokens. POST refuses the outcome-1 link
(`kind_not_tradable`).
- Settlement reads `market_links.resolved_yes` of that same link. YES shares pay F$1 when it is true, and NO shares
pay F$1 when it is false.
### Fees (coded assumptions for the owner to confirm)
| Venue | Rule | Source in code |
|---|---|---|
| Kalshi | `ceil_cent(0.07 x sum over fills of C x P x (1 - P))`, charged **once per order**, on buys and sells alike. For a single-level fill this equals `venue_replay.Kalshi.taker_fee(price, contracts)` exactly, and a test asserts it. | `venue_replay.Kalshi.TAKER_FEE = 0.07` |
| Polymarket | 0 | `venue_replay.Polymarket.TAKER_FEE = 0.0` (the class has no `taker_fee` method, so `fantasy/exchange.fee(exchange, fills)` (new) applies the same formula with the class's rate) |
Two examples: 10 contracts at 0.55 cost F$0.18 (0.17325 rounded up); 100 NO at 0.61 costs F$1.67. The fee is
written as one `fee` ledger entry per order. It is stored on the order's first `fantasy_fills` row, with 0 on the
others, so `sum(fantasy_fills.fee)` equals the order's fee.
!!! note "Owner input: fees"
Confirm (a) that Kalshi's F1 markets use the 0.07 taker schedule, rounded per order and not per fill, and
(b) that Polymarket charges no taker fee on F1 season markets. Until then, both are coded assumptions and are
labelled that way on `/rules`.
### Limits
| Limit | Value | Where |
|---|---|---|
| Order cap | Refused when `shares x limit_price` is over `min(MAX_STAKE, fantasy_seasons.max_stake)`: F$100 (DEC-5). Fills never exceed the limit, so notional never exceeds the cap. | `fantasy/exchange.py` |
| Balance | A buy needs `shares x limit + worst-case fee` to be at most the balance, checked under the row lock | same |
| Shares | Whole shares, at least `market_links.min_size` (Kalshi 1, Polymarket usually 5) | same |
| Limit price | 0.01-0.99 (a UI confirm appears when the limit is more than 5 cents through the best price) | form and server |
| Rate | 10 orders a minute and 200 a day per user (coded defaults; tune after the staging rehearsal) | in memory, `fantasy/exchange.py` |
| Closing | [R17 closing times](#r17-closing-times-dec-14) | `fantasy/exchange.closes_at` |
| Staleness | Refused when the book is over 30 s old | `books.get` |
### Rejection reasons
`fantasy_orders.reject_reason` is String(40). These are the codes:
| Code | Meaning |
|---|---|
| `bad_input` | Side, action, shares or limit out of range |
| `not_a_member` | No `season_members` row in the open season (demo, system and most staff accounts) |
| `not_a_taker` | Season role is `maker` (exchange makers are deferred, DEC-6) |
| `member_suspended` | `season_members.status = 'suspended'` |
| `kind_not_tradable` | Kind or venue not in the tradable list, or an outcome-1 Polymarket link |
| `market_closed` | Link closed, inactive or already resolved |
| `session_started` | Past `closes_at`, or a season future during a paused session (DEC-14) |
| `no_schedule` | A race link whose round has no schedule (fails closed) |
| `book_unavailable` | The fetch failed and there is no cached book |
| `stale_book` | The cached book is over 30 s old |
| `no_liquidity` | The side is empty, or no level is within the limit |
| `over_order_cap` | `shares x limit_price` is over F$100 |
| `insufficient_balance` | The balance can't cover the worst case |
| `opposite_side_held` | Holds the other side of this link |
| `sell_exceeds_position` | Sells more shares than it holds |
`rate_limited` is only an audit reason. No order row is written for it.
### Module outline: `racinglines/fantasy/exchange.py` (new)
| Name | Does |
|---|---|
| `DEFAULT_TRADABLE` | The kind lists per venue from the matrix above. Sprint kinds only when `kalshi/sync.sprints_enabled()` |
| `tradable(season)` | `season.notes["tradable"]` if set, else `DEFAULT_TRADABLE` |
| `load_schedule(year)` | Reads the saved schedule parquet through `weekend_sweep.schedule_from_frame`; cached an hour, keyed on the file's mtime; never imports `fastf1` |
| `closes_at(link, sched)` | The closing rule above. `sched` is `load_schedule(2026)` |
| `walk(levels, shares, limit)` | A pure function: `[(price, shares)]` taken from best-first levels |
| `yes_to_no(book)` | Derives NO bids and asks from the YES book |
| `fee(exchange, fills)` | The fee rules above |
| `place(session, conn, user, season, link_id, side, action, shares, limit, now=None, get_book=books.get)` | The order flow. `get_book` is injectable for tests |
| `liquidation_value(position, link)` | YES x `last_bid`, NO x (1 - `last_ask`), the equity mark STAT-1 uses |
| `Rejected(reason)` | An exception carrying one of the codes above |
## Tables owned here
All of these ship in the single additive revision `fantasy_schema_v1` (ACC-1). The revision, the backup, the
restored-copy trial and the owner's `vm.sh deploy --migrate` are in [Fantasy accounts](fantasy-accounts.md).
Nothing on this page runs a migration.
### `fantasy_orders`, `fantasy_fills`, `fantasy_positions`
| Table | Column | Type and constraint |
|---|---|---|
| `fantasy_orders` | `id` | BigInteger PK |
| | `season_id` | FK `fantasy_seasons.id` NOT NULL, ON DELETE RESTRICT |
| | `user_id` | FK `users.id` NOT NULL, RESTRICT (deletion anonymises and never deletes rows) |
| | `market_link_id` | FK `market_links.id` NOT NULL, RESTRICT |
| | `exchange` | String(20), CHECK IN (kalshi, polymarket) |
| | `token_id` | String(100); kept so the row can still be read if a link is rebuilt |
| | `side` | String(3), CHECK IN (yes, no) |
| | `action` | String(4), CHECK IN (buy, sell) |
| | `shares_req` | Numeric(12,2), CHECK > 0 |
| | `limit_price` | Numeric(6,4), CHECK 0.01-0.99 |
| | `status` | String(10), CHECK IN (filled, partial, rejected) |
| | `reject_reason` | String(40) NULL; CHECK `(status = 'rejected') = (reject_reason IS NOT NULL)` |
| | `book` | JSONB: `{"fetched_at", "age_s", "venue_ts", "side_walked", "levels": [[p, s], ...], "best_bid", "best_ask"}` |
| | `created_at` | timestamptz, server default now(). Index `(season_id, user_id, created_at)` |
| `fantasy_fills` | `id` | BigInteger PK |
| | `order_id` | FK `fantasy_orders.id` NOT NULL, RESTRICT; indexed |
| | `season_id`, `user_id`, `market_link_id` | As on the order (FKs, RESTRICT) |
| | `exchange`, `side`, `action` | As on the order |
| | `shares` | Numeric(12,2), CHECK > 0 |
| | `price` | Numeric(6,4), CHECK > 0 AND < 1 |
| | `fee` | Numeric(12,2) NOT NULL DEFAULT 0, CHECK >= 0 (the order's fee on its first fill) |
| | `ts` | timestamptz |
| `fantasy_positions` | `id` | BigInteger PK |
| | `season_id`, `user_id`, `market_link_id` | FKs, RESTRICT. UNIQUE(season_id, user_id, market_link_id) |
| | `exchange` | As above |
| | `yes_shares`, `no_shares` | Numeric(12,2) NOT NULL DEFAULT 0, CHECK >= 0; CHECK NOT (yes_shares > 0 AND no_shares > 0) |
| | `cost` | Numeric(12,2) NOT NULL DEFAULT 0 (average-cost basis of the shares held) |
| | `fees` | Numeric(12,2) NOT NULL DEFAULT 0 |
| | `status` | String(10), CHECK IN (open, settled, void) |
| | `outcome` | Boolean NULL (NULL when an admin settles at a price) |
| | `payout` | Numeric(12,2) NULL |
| | `settled_at` | timestamptz NULL. Partial index on `(market_link_id) WHERE status = 'open'` for the tick |
### `house_markets` and `house_bets` additions
| Table | New column | Type | Use |
|---|---|---|---|
| `house_markets` | `season_id` | FK `fantasy_seasons.id` NULL | Set on markets that season makers create. NULL means demo, legacy or admin markets, which the fantasy tick never touches |
| | `closes_at` | timestamptz NULL | From the closing rule, set at generate or mirror time. The bet path checks it directly and does not wait for the tick |
| | `max_liability` | Numeric(12,2) NULL | Per-market worst-case cap. NULL means `fantasy_seasons.max_market_liability` (F$1,000, DEC-5) |
| `house_bets` | `season_id` | FK `fantasy_seasons.id` NULL | Always the market's season (enforced in `fantasy/book.py`, tested) |
| | `fair_prob_at_bet` | Float NULL | The market's `fair_prob` when the bet was taken, for stats and abuse review. Never shown to takers |
| | `settled_at` | timestamptz NULL | Set when the bet's ledger entry is written |
`house_bets.stake` and `payout` stay Float, as today. Ledger amounts are rounded to cents when they are written
(`Numeric(12,2)`).
### Migration outline (this page's part of `fantasy_schema_v1`)
```python
# migrations/versions/2026MMDD__fantasy_schema_v1.py (ACC-1), the trading part only; upgrade order after
# fantasy_seasons / season_members / bankroll_entries, which fantasy-accounts.md defines
op.create_table("fantasy_orders", ...) # columns and CHECKs as above
op.create_table("fantasy_fills", ...)
op.create_table("fantasy_positions", ..., sa.UniqueConstraint("season_id", "user_id", "market_link_id"))
op.add_column("house_markets", sa.Column("season_id", sa.Integer, sa.ForeignKey("fantasy_seasons.id"), nullable=True))
op.add_column("house_markets", sa.Column("closes_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("house_markets", sa.Column("max_liability", sa.Numeric(12, 2), nullable=True))
op.add_column("house_bets", sa.Column("season_id", sa.Integer, sa.ForeignKey("fantasy_seasons.id"), nullable=True))
op.add_column("house_bets", sa.Column("fair_prob_at_bet", sa.Float, nullable=True))
op.add_column("house_bets", sa.Column("settled_at", sa.DateTime(timezone=True), nullable=True))
# downgrade: drop the six columns, then fantasy_positions, fantasy_fills, fantasy_orders (reverse order)
```
Every new column is nullable, so existing `house_markets` and `house_bets` rows are unchanged. There is no backfill.
### Ledger entries written (`bankroll_entries`)
Idempotency comes from `UNIQUE(ref_table, ref_id, kind)`. `balance = SUM(amount)`.
| Event | `kind` | `amount` | `ref_table` / `ref_id` | Task |
|---|---|---|---|---|
| Exchange buy, per fill | `trade` | -shares x price | `fantasy_fills` / fill id | EXCH-1 |
| Exchange sell, per fill | `trade` | +shares x price | `fantasy_fills` / fill id | EXCH-1 |
| Exchange fee, per order | `fee` | -fee | `fantasy_orders` / order id | EXCH-1 |
| Exchange position settles | `settle` | +payout (0.00 on a loss; one per position) | `fantasy_positions` / id | SETTLE-1 |
| Exchange position voided | `refund` | +cost (basis of the held shares; fees stay paid) | `fantasy_positions` / id | SETTLE-1, ADM-1 |
| Book bet placed (taker) | `stake` | -stake | `house_bets` / bet id | BOOK-3 |
| Book bet won or lost (taker) | `payout` | +payout if won, 0.00 if lost | `house_bets` / bet id | SETTLE-1 |
| Book bet voided (taker) | `refund` | +stake | `house_bets` / bet id | SETTLE-1 |
| Book market settled (maker) | `book_pnl` | `pnl_if_yes` or `pnl_if_no` over season bets that aren't void (0.00 if void) | `house_markets` / market id | SETTLE-1 |
| Re-settle correction | `adjust` | new amount - old amount, note = the admin's reason | the original ref (one re-settle per ref; later fixes are an admin F$ adjust with no ref) | ADM-1 |
Invariants the tests assert: every settled season bet has exactly one `payout` or `refund` entry. Every settled season
market has one `book_pnl` entry. For each market, the takers' entries plus the maker's `book_pnl` sum to zero.
### EXCH-0: probe fixtures and the Kalshi read budget
This is a read-only probe from the Mac, as CLAUDE.md requires before any full run. The cloud sessions can't reach the
venues. Kalshi has listed R16 as `KXF1RACE-BAH26`.
!!! warning "The fixture folder needs an allow-list entry (decided)"
`tests/test_no_data_in_git.py` fails on any file under `tests/fixtures/` outside `ALLOWED_DATA`, and
`tests/fixtures/fantasy/` is not on that list today. Decision: the fixtures stay in `tests/fixtures/fantasy/`
(the plan's name). EXCH-0 adds `"tests/fixtures/fantasy/"` to `ALLOWED_DATA` in the same change, a one-line
edit to test infrastructure that does not change any assertion, with a `tests/fixtures/fantasy/README.txt`
giving the source, the date and the trimming (as `tests/fixtures/market/og_README.txt` does). Because EXCH-0
edits a test file, it is an implement task (Sonnet), not a read-only scout task. `work/exch-0` merges in P0,
before EXCH-1 and OPS-5, which depend on it. The [runbook](fantasy-runbook.md) lists it in the same
place.
```bash
# LOCAL (Mac) โ EXCH-0 step 1: the R16 Kalshi race event's market tickers (public GET, no credentials)
cd ~/py_dev/racinglines # the EXCH-0 branch's checkout
curl -s "https://api.elections.kalshi.com/trade-api/v2/events/KXF1RACE-BAH26?with_nested_markets=true" \
| .venv/bin/python -c 'import json,sys; b=json.load(sys.stdin); ms=(b.get("event") or {}).get("markets") or b.get("markets") or []; [print(m["ticker"], m.get("yes_bid_dollars"), m.get("yes_ask_dollars"), m.get("volume_fp") or m.get("volume")) for m in ms]'
```
```bash
# LOCAL (Mac) โ EXCH-0 step 2: one Kalshi order book with its headers and latency (the most-traded ticker from step 1)
T=
mkdir -p tests/fixtures/fantasy
curl -s -D "$TMPDIR/kalshi_headers.txt" -w 'latency %{time_total}s\n' \
"https://api.elections.kalshi.com/trade-api/v2/markets/$T/orderbook?depth=10" -o tests/fixtures/fantasy/kalshi_orderbook_bah26.json
grep -i -E 'ratelimit|retry-after|date' "$TMPDIR/kalshi_headers.txt"; wc -c tests/fixtures/fantasy/kalshi_orderbook_bah26.json
```
```bash
# LOCAL (Mac) โ EXCH-0 step 3: an open Polymarket F1 champion token from the local database (read-only SELECT)
.venv/bin/python -c "from sqlalchemy import text; from racinglines.db.config import get_engine; print(get_engine().connect().execute(text(\"SELECT token_id, question FROM market_links WHERE exchange = 'polymarket' AND prediction = 'champion' AND NOT closed ORDER BY volume DESC NULLS LAST LIMIT 1\")).first())"
```
```bash
# LOCAL (Mac) โ EXCH-0 step 4: that token's CLOB book with headers and latency
TOK=
curl -s -D "$TMPDIR/pm_headers.txt" -w 'latency %{time_total}s\n' \
"https://clob.polymarket.com/book?token_id=$TOK" -o tests/fixtures/fantasy/polymarket_book_f1_champion.json
grep -i -E 'ratelimit|retry-after|date' "$TMPDIR/pm_headers.txt"; wc -c tests/fixtures/fantasy/polymarket_book_f1_champion.json
```
```bash
# LOCAL (Mac) โ EXCH-0 step 5: keep the fixtures small (trim to 10 levels a side if over 20 KB), then the guard test
python -m pytest tests/test_no_data_in_git.py
```
The probe fills in this table (in this doc, in the EXCH-0 PR):
| Finding | Kalshi | Polymarket |
|---|---|---|
| Levels returned at depth 10 | | |
| Price format (`*_dollars` strings or cents) | | |
| Book timestamp field | none expected (we use `fetched_at`) | `timestamp` expected |
| Rate-limit headers | | |
| Latency (3 calls) | | |
**Read budget.** The 10 s cache limits the web process to 6 book GETs a minute per ticker, however many players
trade. `sources/http.HOST_INTERVAL` limits one process to 4 GETs a second on `api.elections.kalshi.com` (0.25 s) and
about 6.7 on `clob.polymarket.com` (0.15 s). The Kalshi sync timer (OPS-5) is a separate process with its own
pacing, so the two can together reach about 8 Kalshi GETs a second. R17 is roughly 100 Kalshi race tickers
(22 win, 22 podium, 22 top 10, 11 constructor, plus pole and head-to-heads if listed). Only tickers with an open
panel or an order are fetched, and 40 of them busy at once would fill the 240 a minute. Kalshi's published read
limit is not verified here. If a 429 shows up at R16 or in staging, `CACHE_S` rises to 20 s.
## Makers on exchange markets: deferred (DEC-6, NEXT-1)
Player makers quote the private book only this season. The reasons, from the code:
- **The fill loop is inline.** `maker_replay.replay(data, p)` quotes, walks the tape and caps fills in one loop over
stages and markets. There is no function a human's standing quote could call.
- **Golden parity.** Profiles A, C and K are scored with this replay, and `tests/golden/f1_maker_replay.json` must stay
byte-identical. Refactoring the loop during a launch sprint risks the frozen records.
- **The tape arrives only in the signals window.** Trades are fetched by the signal engine's weekend window, so a
human quote would fill hours late, or not at all, between windows.
- **No cancel or lifetime concept.** The replay re-posts every `step_min` and pulls quotes before each session. A
human quote needs a lifetime, a cancel and a queue position that persist between requests.
Design sketch for NEXT-1 (Fable designs, Opus builds, R19 at the earliest, behind
`RACINGLINES_FANTASY_EXCHANGE_MAKERS`):
1. Factor the per-trade fill decision out of `replay()` into a pure function (`fill_against(quote, trade, state, p)`,
new) that `replay()` calls. The exit test: the golden file and `tests/test_replay.py` are unchanged.
2. Add a `fantasy_quotes` table (new, a later revision) holding a player's resting bid and ask per link, with
`placed_at`, `expires_at`, `cancelled_at` and a queue position from the latest `market_book_snapshots` row.
3. The Kalshi sync timer fetches trades for links that have live player quotes every 30 min, outside the signals
window. Each new trade after `placed_at` goes through `fill_against`, and the fills land in `fantasy_fills` as the
maker's side.
4. Settlement is the same as for taker positions (`resolved_yes`). The maker board ranks on settled balance.
## Private book with human makers and takers (BOOK-1, BOOK-2, BOOK-3; DEC-9)
### Which book
Players use the **private book**: the database book in `house_markets` / `house_bets`, served by
`racinglines/markets/private_book.py` and the `/book/...` routes. The **live book** (the demo maker and the simulated
crowd, `markets/crowd.py`, `data/runs/live//`) is untouched. Players never trade it, the crowd never fills a
player's quote, and `racinglines-live-f1@` runs as before ([Live events](live-events.md#the-private-book-and-its-takers)).
### Maker actions and bounds
| Action | Existing code | Change | Task |
|---|---|---|---|
| Generate R17 markets from the forecast | `private_book.generate(...)`, `POST /book/quotes/generate` | One "latest forecast" rule. `latest_race_predictions` picks the forecast run by `coalesce((params->>'promoted_at')::timestamptz, created_at) DESC, id DESC`, the ordering of `db/reads.latest_forecast_run`, in place of `max(model_run_id)`. A transaction advisory lock per (maker, race) stops concurrent generates duplicating markets whose `market_link_id` is NULL, which `uq_house_market_maker` can't catch because NULLs are distinct. Player makers: kinds `race_win`, `race_podium`, `race_top10`, R17 only | BOOK-2, BOOK-3 |
| Mirror an exchange event | `private_book.mirror_event(...)`, `POST /markets/{polymarket,kalshi}/mirror` | Player mirrors skip sprint kinds and any kind outside `tradable(season)["book"]`. `season_id` and `closes_at` are set on creation | BOOK-3 |
| Reprice | `private_book.set_price(...)`, `POST /book/markets/{id}/price` | Bounds: fair 0.01-0.99, spread 0.02-0.30, and after rounding `yes_price + no_price >= 1.00`. A side over `MAX_PRICE` (0.99) stays unoffered, as today. Refused after `closes_at` | BOOK-2 |
| Open or close | `POST /book/markets/{id}/status` | Reopening after `closes_at` is refused | BOOK-3 |
| Settle | admin only, `POST /book/markets/{id}/settle` | Only `open` or `closed` markets. A re-settle needs an admin reason and writes `fantasy_resettle` | BOOK-2 |
Makers can't bet, on their own markets (`record_bet` already refuses `taker_id == maker_id`) or on anyone's, because
the season role is locked.
### Maker collateral (DEC-5)
- Worst case of a market = `-min(pnl_if_yes, pnl_if_no, 0)` over its bets that aren't void (the `worst` column of
`private_book.book`).
- Free balance of a maker = balance - the sum of worst cases over the maker's season markets that are `open` or `closed`
and not yet settled. Balance moves only at settlement (`book_pnl`), so collateral is a reservation, not a ledger entry.
- A bet is refused (`maker_over_capacity`) if, after it, the market's worst case would exceed its `max_liability`
(F$1,000 by default) or the maker's free balance would go below 0. With F$10,000 a maker can carry 10 markets at the
full cap.
### Taker bet flow
`/fantasy/book` (BOOK-1) lists open season markets with the maker's handle, the YES and NO prices and the close time.
Each has a bet form that posts to the existing `POST /book/markets/{id}/take` with `side`, `stake`, `quoted` and
`next=/fantasy/book`. After BOOK-3 the route calls `fantasy/book.take(...)` (new):
1. The gates: `RACINGLINES_FANTASY_BOOK`, a season role of `taker`, status `active`, the market's `season_id` equal to the
open season, `status = 'open'` and `now < closes_at`.
2. `0 < stake <= min(MAX_STAKE, fantasy_seasons.max_stake)`, which is F$100.
3. `SELECT ... FOR UPDATE` on the `house_markets` row, then on both `season_members` rows (taker and maker) in
ascending `user_id` order, so two bets can't deadlock.
4. The price-moved refusal (existing): the current `yes_price` / `no_price` must equal `quoted`.
5. The taker's balance must cover the stake. The stake is debited when the bet is placed, so balance is already net of
open stakes. Then the maker-capacity check.
6. `private_book.record_bet(...)` with `taker_id`, then `season_id` and `fair_prob_at_bet` on the new `house_bets`
row, and a `stake` ledger entry, all in one transaction.
7. Audit (the existing `bet_place` / `bet_rejected`). Redirect to `next`, which must start with `/fantasy/` or
`/markets`. Today it always goes to `/markets`.
Before BOOK-3 (the R16 closed dry run, if BOOK-1 and BOOK-2 are deployed by Thu 1 Oct 12:00 PDT), bets are recorded
as today, with no F$ ledger.
### Closing, counterparties and sprint kinds
- `closes_at` uses the [closing rule](#r17-closing-times-dec-14). The tick sets `status = 'closed'` at or after it,
but the bet and reprice paths check `closes_at` themselves, so a 10-minute tick can't leave a gap.
- The counterparty is shown by handle only: the taker sees the maker's `users.username`, and the maker sees the
taker's (`private_book.bets` already uses `coalesce(u.username, hb.counterparty)`). Never an email.
- Sprint kinds are excluded from player generate and mirror: `private_book.race_outcomes` reads the `race`/`final`
and `qual` rounds only, so a sprint market could never settle.
### Changes by file
| File | Change | Task |
|---|---|---|
| `racinglines/web/templates/fantasy_book.html` (new) | Taker list and bet forms. No fair, spread, EV or worst case | BOOK-1 |
| `racinglines/web/views.py`, `racinglines/web/app.py` | `GET /fantasy/book` (404 unless `RACINGLINES_FANTASY_BOOK`); `place_bet` honours `next` | BOOK-1 |
| `racinglines/markets/private_book.py` | Quote bounds in `set_price` and `generate`; `settle()` refuses statuses other than open or closed unless given `resettle_reason`; `with_for_update=True` in `record_bet` and `settle`; one latest-forecast rule; advisory lock in `generate`; `settle_from_exchange` notes name `ml.exchange` (today it always says "Polymarket") and take an optional `market_ids` (new, default None = today's behaviour) | BOOK-2 |
| `racinglines/fantasy/book.py` (new) | `take`, `capacity(maker, season)`, `closes_at_for(market)`, player generate and mirror wrappers | BOOK-3 |
| `racinglines/web/templates/house.html`, `house_market.html` | For player makers: the collateral panel (balance, open worst case, free balance, per-market cap), `closes_at`, taker handles | BOOK-3 |
## Settlement (SETTLE-1, DEC-11)
### `racinglines fantasy tick`, step by step
`racinglines/fantasy/settle.py` (new) runs from `racinglines/cli/fantasy.py`. The whole tick takes
`pg_try_advisory_lock` on a fixed key. If a previous tick is still running, it logs a line and exits 0. It only
touches rows with `season_id IS NOT NULL`, plus `fantasy_positions`.
1. **Close.** `house_markets` that are `open` with `closes_at <= now` become `closed`.
2. **Settle book markets from results.** For each race with open or closed season markets, check the race status
first: `settlement_rules.race_status("f1", event_key, settlement_rules.db_status(conn, race_id))`.
- `cancelled`: the private book's U8 rule, applied directly: `private_book.settle(session, id, None, "auto: race
cancelled, private book rule: void")` for every season market of the race, generated or mirrored alike.
- Otherwise: `private_book.settle_from_results(session, conn, race_id, market_ids=, rules=False)`.
`rules=False` means the environment is never read (`settlement_rules.enabled(False)` is False), so
`RACINGLINES_CANCELLED_RACE_RULES` changes nothing here, and a relocated race (R16, the Bahrain GP at Sepang,
`RACE_STATUS`) settles on its result. The function waits for at least 5 classified finishers.
3. **Settle mirrored markets from any exchange.** `private_book.settle_from_exchange(session, conn,
market_ids=)` settles those whose link has `resolved_yes`, which covers what results can't
decide.
4. **Settle exchange positions.** For open `fantasy_positions` on links that have `resolved_yes`: payout = YES shares
x 1 when true, NO shares x 1 when false. The status becomes `settled`, with `outcome`, `payout` and `settled_at`
set. The position's link is refreshed first, by an on-demand closed sync when an event has ended and any of its
links with open positions has `resolved_yes IS NULL` and `synced_at` older than 1 h:
`kalshi/sync.sync(session, conn, 2026, include_closed=True)` or `polymarket/sync.sync(...)` with
`include_closed=True`. That is at most once an hour per ended event. OPS-5's daily closed sync covers the rest.
5. **Ledger payouts, as a reconciliation.** Write the `payout` / `refund` / `book_pnl` / `settle` entries for
**every** season bet, market or position that is settled or void and has no entry yet, and set
`house_bets.settled_at`. Because this step reconciles rather than following steps 2-4, a market settled by
another path still gets paid: the admin's `POST /book/markets/{id}/settle`, or `settle_from_exchange` through
`pm_sync`.
6. **Stats refresh.** `fantasy/stats.py` (STAT-1) runs for the members touched in steps 4-5. The nightly `--full`
is described in [Fantasy accounts](fantasy-accounts.md).
7. **Alerts.** Anything still unsettled 24 h after its race's scheduled start raises one ops alert per item per day
to `RACINGLINES_OPS_NTFY_TOPIC` (OPS-2's sender).
**Backup before settling (CLAUDE.md).** Before the first settlement write for an event (steps 2-5), the tick runs
`racinglines.ops.backup` with the label `pre-settle-` (for R17, `pre-settle-2026-17`), which writes
`data/backups/db/racinglines-before-pre-settle-2026-17-.sql.gz`, its `data_changes` row and the bucket copy
(OPS-3). It does this once per event, and the `data_changes` row is the marker. If the dump fails, the tick settles
nothing for that event, raises one ops alert and retries at the next tick: it fails closed. The dump takes about 30 MB
and under a minute (the 29 Sep hand dump was 14.5 MB), inside the unit's 15 min timeout. Season futures use the label
`pre-settle--futures`.
The tick prints one line: `closed=N book_settled=N mirrored_settled=N positions_settled=N ledger_entries=N queued=N`.
The race classification reaches `results` through `signals.refresh_fastf1` (`racinglines data fetch f1` then
`racinglines data ingest f1`), which the live-f1 unit's results update and the signals timer both run.
### Idempotency and the re-settle guard
- Running the tick twice changes nothing: statuses gate steps 1-4, and `UNIQUE(ref_table, ref_id, kind)` plus the
reconciliation query gate step 5. A test asserts this.
- `private_book.settle` refuses a market that is already `settled` or `void` unless `resettle_reason` is given
(BOOK-2). Only `/admin/fantasy/settle` (ADM-1) passes one. A re-settle writes `fantasy_resettle` to `activity_log`,
plus one `adjust` entry per affected bet, position or market (new amount minus old). After that, corrections go
through an admin F$ adjust with a reason.
- A `fantasy_positions` row is settled only once. Moving it from `settled` back to `open` is not possible.
### The admin queue
`/admin/fantasy/settle` (ADM-1) lists, oldest first:
| Case | Example | Admin actions |
|---|---|---|
| Exchange link closed, `resolved_yes` NULL | Kalshi "fair price" on a race cancelled or not started within 48 h (`settlement_rules`, `FAIR`); Polymarket's 50-50 on a head-to-head | YES, NO, at a price p (payout = YES x p + NO x (1 - p)), or void (refund the cost basis) |
| Book market that results can't decide 24 h after the race | `outcome_for` returns None | YES, NO or void |
| Anything unsettled 24 h after the race | Venue resolution lag | Wait, or settle by hand with a reason |
Every action needs a reason and writes an `activity_log` row: `fantasy_settle`, or `fantasy_resettle` for a re-settle.
### Timing
| Step | R17 (PDT [UTC]) |
|---|---|
| Race start: race kinds close | Sun 11 Oct 05:00 [12:00] |
| Race classification in `results` | about 08:00 [about 15:00], about 3 h after the start |
| Pre-settle dump (`pre-settle-2026-17`) | at the first tick that has something to settle, just before it |
| Book markets settled | at the next tick, by about 08:10 [15:10] |
| Kalshi positions settled | after Kalshi resolves and the hourly closed sync picks it up. The lag is unmeasured, so R16 records it |
| Manual queue cleared (SETTLE-2) | Sun 11 Oct 18:00 [Mon 12 Oct 01:00] |
| Unsettled alert | Mon 12 Oct 05:00 [12:00], 24 h after the start |
| Season futures | after Abu Dhabi (6 Dec): expected 7-8 Dec |
### The unit (`deploy/vm/systemd/`, new)
```ini
# racinglines-fantasy.service โ one fantasy tick: close, settle, stats (SETTLE-1). Started by the .timer.
[Unit]
Description=racinglines fantasy tick
After=network-online.target docker.service
[Service]
Type=oneshot
User=racinglines
WorkingDirectory=/opt/racinglines
EnvironmentFile=/etc/racinglines.env
ExecStart=/opt/racinglines/.venv/bin/racinglines fantasy tick
TimeoutStartSec=15min
```
```ini
# racinglines-fantasy.timer
[Unit]
Description=racinglines fantasy tick every 10 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
[Install]
WantedBy=timers.target
```
The owner installs and enables it. The blocks are in [VM reliability](vm-reliability.md).
**The tick ignores `RACINGLINES_FANTASY` (decided here, for SETTLE-1).** The switch only hides the pages and refuses
new orders and bets. The tick gates on the data instead: it runs whenever any `fantasy_seasons` row has status `open`,
or any season has open `fantasy_positions` or unsettled season `house_bets`, and it exits 0 with nothing to do
otherwise. The [off switch](fantasy-runbook.md#off-switch) (`RACINGLINES_SIGNUP=off` plus `RACINGLINES_FANTASY=0`)
therefore stops sign-up and trading but never settlement. The only way to stop settlement is to stop
`racinglines-fantasy.timer`. The tick touches `data/runs/heartbeat/fantasy-tick` on every run, even with nothing to do,
so the health check's "fantasy tick under 30 min" alarm fires if the timer stops while positions are open.
### Launch-weekend backups (every 6 h)
The nightly dump runs at 10:00 UTC, two hours before the R17 race closes its markets, so on its own it would miss the
busiest orders. The extra dumps are a drop-in on `racinglines-backup.timer` (04:00, 16:00 and 22:00 UTC), installed
by the owner by Thu 8 Oct 16:00 UTC and removed Tue 13 Oct: the blocks are in
[VM reliability, launch-weekend backup drop-in](vm-reliability.md#launch-weekend-backup-drop-in).
### Settle day, owner-run (SETTLE-2)
โ ๏ธ These steps write settlement to the production database and touch a live-event unit. Each block runs in the VM
shell opened in step 1. Each block appends to the same log file.
```bash
# LOCAL (Mac) โ SETTLE-2 step 1: open a shell on the VM
bash scripts/deploy/vm.sh ssh
```
```bash
# VM (production) โ step 2: start the log, then check the tick has been running (read-only)
export LOG=/opt/racinglines/data/backups/r17-settle-$(date -u +%Y%m%dT%H%MZ).log
systemctl list-timers racinglines-fantasy.timer --no-pager 2>&1 | sudo tee -a "$LOG"
journalctl -u racinglines-fantasy --since "3 hours ago" --no-pager | tail -20 | sudo tee -a "$LOG"
```
```bash
# VM (production) โ step 3: โ ๏ธ backup before clearing the manual queue (a DB write follows). The tick has already
# taken pre-settle-2026-17 before its own settlement writes; this one covers the manual queue
sudo -u racinglines bash -c 'set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && .venv/bin/racinglines ops backup --label before-r17-settle' 2>&1 | sudo tee -a "$LOG"
```
Step 4, in the browser: `https://racinglines.bet/admin/fantasy/settle`. Clear each queued item with a reason.
```bash
# VM (production) โ step 5: one tick by hand, then a second that must report all zeros (idempotency check)
sudo -u racinglines bash -c 'set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && .venv/bin/racinglines fantasy tick && .venv/bin/racinglines fantasy tick' 2>&1 | sudo tee -a "$LOG"
```
```bash
# VM (production) โ step 6: backup after settlement, then save the leaderboard into the log
sudo -u racinglines bash -c 'set -a; . /etc/racinglines.env; set +a; cd /opt/racinglines && .venv/bin/racinglines ops backup --label after-r17-settle && .venv/bin/racinglines fantasy stats' 2>&1 | sudo tee -a "$LOG"
```
```bash
# VM (production) โ step 7: โ ๏ธ live-event unit: disable the R17 live timer once every market is settled
sudo systemctl disable --now racinglines-live-f1@2026-17.timer 2>&1 | sudo tee -a "$LOG"
```
```bash
# VM (production) โ rollback, only if settlement is wrong: stop the tick, then follow the "fantasy tick stuck or
# settlement wrong" runbook in vm-reliability.md. Compare against a restore of the tick's pre-settle-2026-17 dump on
# scratch (it holds every order up to the first automatic settlement), not the nightly dump or before-r17-settle
sudo systemctl stop racinglines-fantasy.timer 2>&1 | sudo tee -a "$LOG"
```
`racinglines ops backup` is new (OPS-3). Until it merges, use the `pg_dump` block in
[VM reliability](vm-reliability.md), which writes `data/backups/db/racinglines-before--.sql.gz`. Either
way, the backup gets a `data_changes` row and a [Data changes](data-changes.md) line.
## Anti-abuse
| Threat | Rule | Where enforced | Task |
|---|---|---|---|
| Self-dealing: one person runs a maker and a taker and moves F$ between them through a mispriced quote | (1) One account per verified email, with the email normalised for uniqueness (lowercase; `+tag` stripped; dots stripped for `gmail.com` and `googlemail.com`; `@racinglines.bet` and known disposable domains refused), single-use invite codes each issued to one named recipient, and a season role locked for the season. `/admin/fantasy/members/{user_id}` shows which invite created the account. (2) Caps: F$100 a bet, F$1,000 worst case per market. (3) Admin review of maker-taker pairs on `/admin/fantasy`: pairs where one taker is over 50% of a maker's settled volume, or one maker over 50% of a taker's; the same IP within 90 days (`activity_log`, which a second network defeats, so it is a hint only); bets where `abs(fair_prob_at_bet - model fair) > 0.25`. The pair report is part of the ADM-1 go/no-go before the soft launch and is read again before any leaderboard is published. (4) `/rules` says that collusion or multiple accounts void the results of every account involved. There are no prizes (DEC-1), so this protects the leaderboard's fairness; any later prize needs stronger identity checks first | `fantasy/book.py`, the email normaliser in `racinglines/web/accounts.py` ([Fantasy accounts](fantasy-accounts.md)), ADM-1 report, `rules.html` | BOOK-3, ACC-3, ADM-1, LEGAL-1 |
| Latency games on a live session | Markets close at the start of the session that decides them (DEC-14). Season futures pause during Race and Sprint (proposed). Books over 30 s old are refused | `fantasy/exchange.closes_at`, `books.get`, `fantasy/book.take` | EXCH-1, BOOK-3 |
| Latency games on a repriced book quote | The price-moved refusal (existing): the `quoted` price must equal the current price | `place_bet` | BOOK-1 |
| Fat fingers | Taker: limit 0.01-0.99, the F$100 cap, a confirm when the limit is more than 5 cents through the touch. Maker: fair 0.01-0.99, spread 0.02-0.30, yes + no >= 1.00 | forms and server | EXCH-1, BOOK-2 |
| Order spam and venue read load | 10 orders a minute and 200 a day per user; the 10 s book cache; host pacing | `fantasy/exchange.py`, `sources/http.py` | EXCH-1 |
| A bad actor | `season_members.status = 'suspended'`: orders and bets refused (`member_suspended`); the maker's open markets closed; sessions ended through `session_epoch` ([Fantasy accounts](fantasy-accounts.md)). Open positions still settle, or an admin voids them | ADM-1 | ADM-1 |
| Informed flow into the crowd | Not possible: the crowd never fills a player's quote (DEC-9) | by design | |
## Pages and UI
| Route | Serves | Task |
|---|---|---|
| `/fantasy` | Balance, equity, open exchange positions at their liquidation value, open book bets, order history (with reject reasons) and settled history | STAT-2 |
| `/fantasy/markets` | Exchange markets by R17 kind, then season futures, per venue. Each row: subject, venue, bid, ask (from `market_links`, with the sync age), close time (PDT [UTC]) and the player's position. `?link=` opens the live panel: 5 levels a side from `books.get`, "Prices: Kalshi, fetched 12:34:56 UTC" (DEC-7 attribution) and the order form | EXCH-1 |
| `POST /fantasy/orders` | The order flow above | EXCH-1 |
| `/fantasy/book` | Open private-book markets for the open season, maker handle, YES and NO price, close time, bet form | BOOK-1, BOOK-3 |
| `POST /book/markets/{id}/take` | Existing. Through `fantasy/book.take` after BOOK-3 | BOOK-3 |
| `/book`, `/book/quotes`, `/book/markets/{id}` | Existing maker pages, plus the collateral panel and close times for player makers | BOOK-3 |
| `/markets` | For players, it redirects to `/fantasy/markets` (ROLE-1). Demo and staff takers keep today's page | ROLE-1 |
What each role and account type sees:
| | Player taker | Player maker | Staff (admin, testers) | Demo (`maker`, `taker`) |
|---|---|---|---|---|
| `/fantasy/markets` | Trade | Read-only, with a note that makers quote the private book this season | Trade in `2026-dryrun` when a member; admin sees everything | Prices read-only, "sign up to trade", no form |
| `/fantasy/book` | Bet | Read-only | As members | Read-only |
| `/book` pages | No | Own markets | Admin: every maker | As today (writes refused in demo sessions) |
| Fair value or edge | Never | Own generated markets' fair only (they quote from it). No edge columns on race pages, and no `/markets`, `/signals` or `/live` picks (ROLE-1) | Yes | As today |
| Strategy-profile calls | Never on fantasy pages | No | Yes | As today on `/markets` |
## Switches
| Switch | Default | Effect here |
|---|---|---|
| `RACINGLINES_FANTASY` | 0 | Master switch. When 0, the `/fantasy*` routes return 404 and new orders and bets are refused. The tick ignores it and keeps closing and settling (see [The unit](#the-unit-deployvmsystemd-new)) |
| `RACINGLINES_FANTASY_BOOK` | 0 | `/fantasy/book`, and the player gates on `/take` |
| `RACINGLINES_FANTASY_EXCHANGE` | 0 | `/fantasy/markets` trading and `POST /fantasy/orders` |
| `RACINGLINES_FANTASY_EXCHANGE_MAKERS` | 0 | Stays 0 this sprint (NEXT-1) |
| `RACINGLINES_KALSHI_SPRINTS` | on (default since 2026-10-06, owner) | Sprint kinds are linked, and so tradable, unless set to 0 (DEC-12) |
| `MAX_STAKE` | 100 (existing) | Capped further by `fantasy_seasons.max_stake` |
| `RACINGLINES_CANCELLED_RACE_RULES` | existing | Not changed by fantasy. The tick passes `rules=False` |
| `POLYMARKET_CLOB_HOST`, `KALSHI_API_HOST` | existing | Read by `books.py` for its base URLs |
## Tests
| File | Tests (new unless marked) | Task |
|---|---|---|
| `tests/test_fantasy_exchange.py` | `test_buy_walks_ask_levels_to_the_limit`; `test_depth_cap_makes_a_partial_fill`; `test_no_side_maps_through_the_yes_book`; `test_sell_closes_at_the_bid_and_never_goes_short`; `test_opposite_side_refused`; `test_kalshi_fee_rounds_up_per_order` (0.17325 becomes 0.18, and the worked example gives 3.40); `test_single_level_fee_equals_venue_replay_taker_fee`; `test_polymarket_fee_is_zero`; `test_order_cap`; `test_cache_reused_within_10s`; `test_stale_book_refused_after_30s`; `test_closed_at_session_start` (pole at Qualifying, win at Race, sprint kinds, R17 times); `test_no_schedule_fails_closed` (including a missing parquet); `test_order_path_never_imports_fastf1`; `test_rate_limit_per_user`; `test_rejections_write_an_order_row`; `test_outcome_1_polymarket_link_refused`; `test_parses_exch0_fixtures`; `test_takers_never_see_fair_or_calls` (renders `/fantasy/markets`); `test_import_graph_has_no_order_path` (a subprocess imports `racinglines.fantasy.exchange` and `racinglines.markets.books`, then asserts neither `trade` module is in `sys.modules`); `test_paper_tables_untouched` (row counts of `paper_positions` and `strategy_signals` before and after) | EXCH-1 |
| `tests/test_signals.py` | One new test, `test_fantasy_code_never_touches_the_order_path`, next to the existing `test_signal_code_never_touches_the_order_path`. It checks the source text of every `racinglines/fantasy/*.py` and `racinglines/markets/books.py` for `polymarket.trade`, `polymarket import trade`, `kalshi.trade`, `kalshi import trade` and `post_order`. Every existing test in the file stays unchanged | EXCH-1 |
| `tests/test_private_book.py` | The first committed tests for generate, bet and settle: `test_generate_uses_the_promoted_forecast`; `test_generate_does_not_duplicate_null_link_markets`; `test_quote_bounds`; `test_take_refused_when_the_price_moved`; `test_take_refused_over_max_stake`; `test_settle_only_open_or_closed`; `test_resettle_needs_a_reason`; `test_settle_from_exchange_note_names_the_exchange`; `test_fantasy_book_hides_fair_value`; `test_fantasy_book_off_by_default` | BOOK-1, BOOK-2 |
| `tests/test_fantasy_book.py` | `test_taker_stake_debited_and_balance_checked`; `test_maker_collateral_within_free_balance`; `test_max_liability_per_market`; `test_two_bets_against_a_maker_at_capacity_one_refused` (two threads, separate sessions, Postgres); `test_closes_at_blocks_bets_before_the_tick`; `test_sprint_kinds_excluded_from_player_mirrors`; `test_bet_season_matches_market_season`; `test_counterparty_shown_by_handle`; `test_book_is_zero_sum` | BOOK-3 |
| `tests/test_fantasy_settle.py` | `test_tick_twice_changes_nothing`; `test_cancelled_race_voids_season_markets_with_the_env_switch_on_or_off`; `test_relocated_r16_settles_on_the_result`; `test_mirrored_market_settles_from_exchange_resolution`; `test_exchange_position_settles_from_resolved_yes`; `test_closed_link_without_resolution_goes_to_the_queue`; `test_market_settled_elsewhere_is_still_paid`; `test_resettle_writes_an_adjust_delta`; `test_demo_and_legacy_markets_untouched` (`season_id` NULL); `test_auto_close_at_closes_at`; `test_unsettled_after_24h_alerts_once`; `test_closed_sync_at_most_hourly`; `test_every_settled_bet_has_one_ledger_entry`; `test_tick_settles_with_the_fantasy_switch_off`; `test_tick_writes_heartbeat_when_idle`; `test_pre_settle_backup_once_per_event`; `test_backup_failure_skips_settlement_and_alerts` | SETTLE-1 |
| Unchanged | `tests/test_settlement_rules.py`, the existing tests in `tests/test_signals.py`, `tests/test_replay.py`, `tests/test_venue_replay.py` and every file in `tests/golden/` ([Testing](testing.md#golden-baselines)) | all |
Postgres tests use the `test_engine` fixture (`tests/conftest.py`: a fresh `racinglines_test` database built from
the migrations, and a skip when there is no Postgres). That fixture only covers code that is handed the engine.
**Today's app-level tests do not use it:** `tests/test_views.py` logs in through `TestClient(app)`, and the app and
CLI use `racinglines.db.config.get_engine()`, which is `lru_cache`d on `DATABASE_URL` and defaults to the dev
database, so those tests write `activity_log` rows to the dev database. Written the same way, the tests on this page
would create users, bets and F$ ledger rows there. So SEC-1 (the first P1 task to add app-level tests; see
[Fantasy accounts](fantasy-accounts.md)) adds a new fixture, `app_test_db`, in `tests/conftest.py`: it depends on
`test_engine`, sets `DATABASE_URL` to the test database before `racinglines.web.app` is imported, calls
`get_engine.cache_clear()` (and again on teardown), and yields a `TestClient`. Every new app-level or CLI test on this
page (BOOK-1, BOOK-3, EXCH-1, SETTLE-1) uses `app_test_db`, and none uses the existing `clients` fixture. Until SEC-1
merges, BOOK-1 carries the same fixture in its own branch. Venue calls are mocked, with the Kalshi `Client`'s
`transport` and an httpx mock transport for the CLOB. No test touches the network.
## Task list
The role and model for every task come from the plan. The owner merges every PR. Each task runs on its own branch
off `main` (`work/`).
| Task | Title | Role / model | Files | Size | Depends on | Money-adjacent |
|---|---|---|---|---|---|---|
| EXCH-0 | Read-only Mac probe; save the Kalshi and Polymarket book fixtures; note depth, timestamps and rate-limit headers; add the `ALLOWED_DATA` entry | implement / Sonnet | `tests/fixtures/fantasy/kalshi_orderbook_bah26.json`, `tests/fixtures/fantasy/polymarket_book_f1_champion.json`, `tests/fixtures/fantasy/README.txt`, `tests/test_no_data_in_git.py` (one `ALLOWED_DATA` entry) | S (~10 calls) | none | No |
| BOOK-1 | `/fantasy/book` taker page and bet form, behind `RACINGLINES_FANTASY_BOOK`; first committed tests | implement / Sonnet | `racinglines/web/app.py`, `racinglines/web/views.py`, `racinglines/web/templates/fantasy_book.html`, `racinglines/web/templates/base.html`, `tests/test_private_book.py` | M (~18 calls) | none | โ ๏ธ bet path |
| BOOK-2 | Private-book safety fixes (bounds, settle guard, `FOR UPDATE`, latest-forecast rule, de-dup, exchange note) | implement / Sonnet (Fable reviews) | `racinglines/markets/private_book.py`, `racinglines/web/app.py`, `tests/test_private_book.py` | M (~20 calls) | BOOK-1 | โ ๏ธ settlement code |
| BOOK-3 | Private book for players: balances, collateral, `max_liability`, `closes_at`, `season_id`, `fair_prob_at_bet`, handles, no sprint kinds | own / Opus (Fable reviews) | `racinglines/fantasy/book.py`, `racinglines/markets/private_book.py`, `racinglines/web/app.py`, `racinglines/web/views.py`, `racinglines/web/templates/house.html`, `racinglines/web/templates/house_market.html`, `racinglines/web/templates/fantasy_book.html`, `tests/test_fantasy_book.py` | L (~35 calls) | ACC-4, BOOK-2 | โ ๏ธ bet and collateral rules |
| EXCH-1 | Exchange paper trading for takers | own / Opus (Fable reviews fill and fee rules) | `racinglines/fantasy/exchange.py`, `racinglines/markets/books.py`, `racinglines/pipelines/weekend_sweep.py` (the `schedule_from_frame` split only), `racinglines/web/app.py`, `racinglines/web/templates/fantasy_markets.html`, `tests/test_fantasy_exchange.py`, `tests/test_signals.py` | XL (~40 calls) | ACC-4, EXCH-0 | โ ๏ธ paper fill and fee logic |
| SETTLE-1 | `racinglines fantasy tick` and `racinglines-fantasy.service`/`.timer` | own / Opus (Fable reviews) | `racinglines/fantasy/settle.py`, `racinglines/cli/fantasy.py`, `deploy/vm/systemd/racinglines-fantasy.service`, `deploy/vm/systemd/racinglines-fantasy.timer`, `tests/test_fantasy_settle.py` | L (~32 calls) | BOOK-3, EXCH-1, STAT-1, OPS-5 | โ ๏ธ settlement plus a new VM unit |
| SETTLE-2 | Sun 11 Oct settle day: queue cleared by 18:00 PDT, backups, leaderboard snapshot, live timer off | owner / owner + Haiku | `/admin/fantasy/settle` | S | OPS-10 | โ ๏ธ settlement and a live-event unit |
| NEXT-1 | Makers on exchange markets | decide then own / Fable (design), Opus (build) | `racinglines/markets/strategies/maker_replay.py`, `racinglines/fantasy/exchange.py`, `tests/golden/f1_maker_replay.json` (unchanged) | XL | OWNER-10 | โ ๏ธ touches the replay that scores the frozen profiles |
| NEXT-2 | Tape-only sports and OG.com for takers | own / Opus | `deploy/vm/systemd/`, `racinglines/fantasy/exchange.py`, `exchanges/og.toml` | L | OWNER-10 | โ ๏ธ new VM units and settlement sources |
Verify, per task (LOCAL (Mac)):
| Task | Command | Done when |
|---|---|---|
| EXCH-0 | `python -m pytest tests/test_no_data_in_git.py` | Both fixtures and the README committed, each fixture under 20 KB, `tests/fixtures/fantasy/` in `ALLOWED_DATA`, and the findings table filled in |
| BOOK-1 | `python -m pytest tests/test_private_book.py tests/test_views.py -m "not live"` | `/fantasy/book` returns 404 with the switch off; a bet posts and returns to `/fantasy/book` |
| BOOK-2 | `python -m pytest tests/test_private_book.py tests/test_settlement_rules.py -m "not live"` | Bounds and guards tested; goldens unchanged |
| BOOK-3 | `python -m pytest tests/test_fantasy_book.py tests/test_private_book.py -m "not live"` | The concurrency test passes against Postgres |
| EXCH-1 | `python -m pytest tests/test_fantasy_exchange.py tests/test_signals.py -m "not live"` | Fills and fees match the EXCH-0 fixtures; the guard tests pass |
| SETTLE-1 | `python -m pytest tests/test_fantasy_settle.py tests/test_settlement_rules.py -m "not live"` | Two ticks in a row give the same database; a tick with `RACINGLINES_FANTASY=0` still settles; each event's first settlement write is preceded by its `pre-settle-` dump |
| All | `racinglines check`, `python -m pytest -m "not live"`, `mkdocs build --strict`, `git diff --stat main -- tests/golden` (empty) | Before any merge is proposed |
Merge order for this page's tasks, inside the plan's sequence. EXCH-0 goes in P0. BOOK-1 and BOOK-2 form the P1 book
batch. In P2 the order is OPS-8 -> ACC-1 (OWNER-8) -> SEC-1 -> MAIL-1 -> ACC-3 -> ACC-4 -> ROLE-1 -> BOOK-3 ->
EXCH-1 -> STAT-1 -> SETTLE-1 -> STAT-2 -> ADM-1.
```bash
# LOCAL (Mac) โ P0/P1: EXCH-0 fixtures, then the P1 book batch (each branch is a PR the owner merges)
git checkout main && git pull
git merge --no-ff work/exch-0 # fixtures + ALLOWED_DATA entry; test_no_data_in_git.py must pass
git merge --no-ff work/book-1 # โ ๏ธ needs special attention: bet path (new taker page posting to /take)
git merge --no-ff work/book-2 # โ ๏ธ needs special attention: settlement code (settle guard, FOR UPDATE); Fable reads the diff first
python -m pytest -m "not live" && git diff --stat main@{1} -- tests/golden
git push
```
```bash
# LOCAL (Mac) โ P2: this page's branches, in the plan's slots (ACC-1, SEC-1, MAIL-1, ACC-3, ACC-4, ROLE-1 merged before; STAT-1 between)
git checkout main && git pull
git merge --no-ff work/book-3 # โ ๏ธ needs special attention: bet and collateral rules, ledger writes; after ACC-4 and ROLE-1
git merge --no-ff work/exch-1 # โ ๏ธ needs special attention: paper fill and fee logic; the order-path guard must pass
git merge --no-ff work/stat-1 # (fantasy-accounts.md) needed by SETTLE-1
git merge --no-ff work/settle-1 # โ ๏ธ needs special attention: settlement + new VM unit racinglines-fantasy.timer
racinglines check && python -m pytest -m "not live" && mkdocs build --strict
git diff --stat main@{1} -- tests/golden # must print nothing
git push
```
## Open questions
| Question | Why it matters | Recommendation |
|---|---|---|
| Kalshi's read rate limit for unauthenticated market data | It sets `CACHE_S` and how many tickers can be busy at once | EXCH-0 records the headers; watch for 429s at R16 and in staging; raise the cache to 20 s if needed |
| Fee confirmation: Kalshi 0.07 rounded per order (not per fill); Polymarket 0 on F1 season markets | Every taker's P&L | Owner confirms against each venue's fee page by Wed 7 Oct. `/rules` labels them as assumptions until then |
| Informed takers in the crowd. `markets/crowd.py` is uninformed: random sides and budgets, no view on fair | Player makers may see little flow with 50 or fewer players, and human takers are their first informed counterparties | DEC-9 keeps the crowd away from players for 2026-s1. Revisit at the retro whether a later season lets a simulated crowd, including informed takers, fill player quotes |
| Should player makers see fair values? Today the maker role sees the full race page (fair, edges against every venue) | A player maker could pass fair values or edges to a taker friend | ROLE-1/DEC-10: player makers keep the model fair only on markets they generate (they need it to quote). Race pages hide fair values and edges from player makers, and `/markets`, `/signals` and `/live` picks are closed to them. ROLE-1 adds a test that a player maker cannot reach any of them. The MCP server serves the caller's own account only (2026-10-14): a hosted player reads its own F$, trades through `fantasy_order` and follows a style through `fantasy_follow` as the pages do, and can't reach another account. [Fantasy accounts](fantasy-accounts.md) section 2 must say the same; the owner confirms with DEC-10 |
| Season futures during a race (DEC-14 extension) | A 10 s-old book around a crash is a latency game on `champion` | Pause during Race and Sprint, as proposed above; the owner confirms with DEC-14 |
| Shared depletion of visible depth | Two players can fill the same level | Accept for the soft launch; if the report shows it, add a per-level, per-window fill ledger |
## Names used across these docs
The subset of the conventions block this page uses, copied word for word. The master copy is in
[Fantasy soft launch](fantasy-launch.md).
```text
NAMES EVERY DOC USES EXACTLY (docs/fantasy-launch.md holds the master copy; the other docs repeat the subset they use, word for word)
TERMS
- fantasy season = one row in fantasy_seasons. This is never the racing `seasons` table. First season slug: 2026-s1. Dry-run/staging season slug: 2026-dryrun. It is closed before launch and left out of stats.
- app role = users.role (admin|maker|taker); it controls permissions. season role = season_members.role (maker|taker); it is the choice made at sign-up, locked by season_members.locked_at until the season's status becomes closed.
- account type = users.account_type: player (signed up through /signup), staff (owner, admin, collaborators, R16 testers), demo (maker, taker), system (polymarket-takers). Only players appear in leaderboards and fantasy stats.
- handle = users.username. Lowercase, regex ^[a-z0-9][a-z0-9_-]{2,19}$. It is the only public identity; email is never shown to another user. Reserved: admin, root, staff, support, noreply, system, demo, maker, taker, polymarket-takers, racinglines, plus the value of ADMIN_USERNAME.
- F$ = fantasy dollars: paper money, no cash value, not transferable. Player-facing copy never uses a bare "$".
- private book = the in-app DB book (house_markets / house_bets). live book = the file-based demo book under data/runs/live// (demo maker plus simulated crowd). Players never trade the live book, and the crowd never fills a player's quote.
- exchange paper trade = a player's order filled against a venue's live order book (Kalshi, Polymarket) and stored only in fantasy_orders / fantasy_fills / fantasy_positions. It never goes into paper_positions or strategy_signals, and never through racinglines/markets/polymarket/trade.py or racinglines/markets/kalshi/trade.py.
- equity: taker = balance + open private-book stakes at cost + open exchange positions at liquidation value (YES shares x best YES bid; NO shares x (1 - best YES ask)), marked from market_links last_bid/last_ask. maker = balance (settled only; open worst case shown, not ranked). ROI = (equity - starting_bankroll) / starting_bankroll.
- closed dry run = R16, Thu 1 Oct to Sun 4 Oct, 5 or fewer admin-created staff tester accounts, no sign-up. staging rehearsal = Wed 7 Oct on the VM with RACINGLINES_SIGNUP=invite and tester codes in season 2026-dryrun. soft launch = RACINGLINES_SIGNUP=invite from Thu 8 Oct 18:00 PDT (Fri 9 Oct 01:00 UTC). open sign-up = RACINGLINES_SIGNUP=open (not before Thu 22 Oct).
- Times: owner-facing times in PDT with UTC in brackets; systemd OnCalendar in UTC.
TABLES: one additive Alembic revision fantasy_schema_v1, file migrations/versions/2026MMDD__fantasy_schema_v1.py, down_revision = the single head at branch time (c8e3f6a2d4b1 on 29 Sep)
- fantasy_seasons: id, slug UNIQUE, name, status (draft|open|closed), starts_at, ends_at NULL (NULL = TBD), signup_opens_at, signup_closes_at NULL, bankroll_taker Numeric(12,2), bankroll_maker Numeric(12,2), max_stake Numeric(12,2), max_market_liability Numeric(12,2), notes JSONB, created_at.
- season_members: id, season_id FK, user_id FK, role (maker|taker), locked_at, joined_at, starting_bankroll Numeric(12,2), status (active|suspended|left). UNIQUE(season_id, user_id).
- bankroll_entries: id, season_id, user_id, ts, kind (grant|stake|payout|refund|trade|fee|settle|book_pnl|adjust), amount Numeric(12,2) signed, ref_table, ref_id, note, created_by. UNIQUE(ref_table, ref_id, kind). balance = SUM(amount).
- fantasy_orders: id, season_id, user_id, market_link_id FK, exchange (kalshi|polymarket), token_id, side (yes|no), action (buy|sell), shares_req, limit_price, status (filled|partial|rejected), reject_reason, book JSONB (levels used, fetched_at), created_at.
- fantasy_fills: id, order_id FK, season_id, user_id, market_link_id, exchange, side, action, shares, price, fee, ts.
- fantasy_positions: id, season_id, user_id, market_link_id, exchange, yes_shares, no_shares, cost, fees, status (open|settled|void), outcome Boolean NULL, payout, settled_at. UNIQUE(season_id, user_id, market_link_id).
- fantasy_event_results: season_id, user_id, event_key, venue (kalshi|polymarket|book), role, pnl, turnover, bets, won, settled_at. PK(season_id, user_id, event_key, venue).
- fantasy_stats: season_id, user_id, venue (all|kalshi|polymarket|book), role, starting_bankroll, balance, equity, pnl, roi, turnover, bets, settled, won, hit_rate, max_drawdown (positive number), sharpe (x sqrt(24); NULL under 4 events), events, rank, computed_at. PK(season_id, user_id, venue).
- house_markets (+): season_id FK NULL, closes_at, max_liability Numeric(12,2). house_bets (+): season_id FK NULL, fair_prob_at_bet, settled_at.
ENV (in /etc/racinglines.env on the VM; every new one is off or safe by default)
RACINGLINES_FANTASY=0|1 (master switch: nav, /fantasy, /leaderboard); RACINGLINES_SIGNUP=off|invite|open (default off); RACINGLINES_SIGNUP_CAP=50; RACINGLINES_FANTASY_BOOK=0|1; RACINGLINES_FANTASY_EXCHANGE=0|1; RACINGLINES_FANTASY_EXCHANGE_MAKERS=0 (stays 0 this sprint); RACINGLINES_MAIL_BACKEND=log|smtp (default log); RACINGLINES_SMTP_HOST=smtp.protonmail.ch; RACINGLINES_SMTP_PORT=587; RACINGLINES_SMTP_USER=noreply@racinglines.bet; RACINGLINES_SMTP_TOKEN (secret, never in git); RACINGLINES_MAIL_FROM=racinglines ; RACINGLINES_MAIL_REPLY_TO=support@racinglines.bet; RACINGLINES_MAIL_DAILY_CAP=200; RACINGLINES_TURNSTILE_SITEKEY and RACINGLINES_TURNSTILE_SECRET (empty = widget off); RACINGLINES_OPS_NTFY_TOPIC (ops alerts, separate from RACINGLINES_NTFY_TOPIC for market alerts); RACINGLINES_HEALTHCHECK_PING_URL (dead-man ping). Existing: RACINGLINES_URL=https://racinglines.bet (base for mail links); RACINGLINES_GCS_BUCKET; APP_SECRET (must be set); MAX_STAKE (per-bet cap, F$100); RACINGLINES_KALSHI_SPRINTS; RACINGLINES_CANCELLED_RACE_RULES (not changed by fantasy); RACINGLINES_DEMO_USERS; RACINGLINES_DEMO_CONTEXT. Never touched: POLYMARKET_TRADING_ENABLED, KALSHI_TRADING_ENABLED.
ROUTES
Signed in: /account (GET/POST), /account/delete (POST), /fantasy, /fantasy/markets, /fantasy/orders (POST), /fantasy/book, /book/markets/{id}/take (existing POST), /leaderboard, POST /logout (GET /logout shows a confirm form).
Admin: /admin/fantasy, /admin/fantasy/seasons/{id}, /admin/fantasy/invites, /admin/fantasy/members/{user_id}, /admin/fantasy/settle.
CLI: racinglines fantasy season create|set|close|list; racinglines fantasy invite create|revoke|list; racinglines fantasy tick [--full]; racinglines fantasy stats [--full]; racinglines mail test --to ; racinglines ops health [--alert]; racinglines ops backup [--label ].
CODE: racinglines/fantasy/{seasons,ledger,book,exchange,settle,stats}.py; racinglines/markets/books.py (read-only live order books; does not import polymarket/trade.py); racinglines/web/{accounts,mailer,health}.py; racinglines/ops/{health,backup}.py; racinglines/cli/{fantasy,ops,mail}.py; TERMS_VERSION = '2026-10-08' in racinglines/web/accounts.py. Templates: signup, verify, forgot, reset, account, terms, privacy, rules, fantasy, fantasy_markets, fantasy_book, leaderboard, admin_fantasy (.html); mail/verify.txt, mail/reset.txt, mail/welcome.txt, mail/email_changed.txt. Tests: tests/test_auth.py, test_signup.py, test_mailer.py, test_fantasy_schema.py, test_fantasy_seasons.py, test_ledger.py, test_private_book.py, test_fantasy_book.py, test_fantasy_exchange.py, test_fantasy_settle.py, test_fantasy_stats.py, test_health.py, test_ops_health.py, test_ops_backup.py; fixtures in tests/fixtures/fantasy/.
activity_log actions (new): signup, signup_rejected, email_verify, verify_resend, password_reset_request, password_reset, password_change, email_change, account_delete, role_override, bankroll_adjust, member_suspend, invite_create, invite_revoke, season_update, fantasy_order, fantasy_order_rejected, fantasy_settle, fantasy_resettle, mail_sent, mail_failed.
SYSTEMD (deploy/vm/systemd/): racinglines-health.service + .timer (every 5 min); racinglines-backup.service + .timer (daily 10:00 UTC); racinglines-kalshi-sync.service + .timer (every 30 min, daily closed sync; this is U2); racinglines-fantasy.service + .timer (every 10 min: close, settle, stats). Existing and unchanged in name: racinglines-web, racinglines-recorder, racinglines-signals(.timer), racinglines-live-f1@(.timer), racinglines-live-dh@, racinglines-mcp, cloudflared. GCE: snapshot resource policy racinglines-daily (14-day retention) on disk racinglines-vm.
BACKUPS: pre-change data/backups/db/racinglines-before--.sql.gz (CLAUDE.md); nightly data/backups/db/racinglines-nightly-.sql.gz (7 kept on disk) and gs://$RACINGLINES_GCS_BUCKET/db/nightly/ (35-day lifecycle); owner-run logs /opt/racinglines/data/backups/-.log.
```
## What this doesn't cover
- Accounts, sign-up, sessions, seasons, the grant, the ledger module itself, stats and the leaderboard. See
[Fantasy accounts](fantasy-accounts.md).
- The alembic revision as a whole, its backup, the restored-copy trial and the owner's migration on the VM. See
[Fantasy accounts](fantasy-accounts.md).
- Installing the timer, health checks, backups and the incident runbooks. See [VM reliability](vm-reliability.md).
- Real-money trading of any kind. The order path, the trading flags and `vm.sh public on` are out of scope, and no
step here proposes them.
- The demo maker's live book and its crowd, the signal engine, and the A, C and K records. They are unchanged and
are covered in [Paper trading](paper-trading.md), [Live events](live-events.md) and
[Market making](market-making.md).
- Makers on exchange markets (NEXT-1) and tape-only sports and OG.com (NEXT-2) beyond the sketch above.
- Kalshi's and Polymarket's data-display terms (DEC-7). The owner reads them (LEGAL-2). This page only attributes
prices and keeps them behind a login.
## Open items
- [x] **Fixture allow-list (EXCH-0).** Decided: the fixtures stay in `tests/fixtures/fantasy/`, and EXCH-0 (now
implement / Sonnet) adds the `ALLOWED_DATA` entry and merges in P0 as `work/exch-0`.
- [x] **The tick under the off switch.** Decided: the tick ignores `RACINGLINES_FANTASY` and gates on season data
(see [The unit](#the-unit-deployvmsystemd-new)); `test_tick_settles_with_the_fantasy_switch_off` covers it.
- [ ] **Link rebuilds.** `racinglines data pm-links-import f1` deletes and re-inserts `market_links` rows
(`markets/polymarket/links.py:103`). With `ON DELETE RESTRICT` from the fantasy tables it will refuse any token
that has fantasy orders. That is intended on the VM (the import is for cloud sessions). ACC-1's reviewer
confirms RESTRICT over SET NULL.
- [ ] **Duplicate generated markets.** A partial unique index on `house_markets (race_id, athlete_id, kind, maker_id)
WHERE market_link_id IS NULL` would enforce the BOOK-2 de-dup in the schema. It is not in the conventions, so
BOOK-2 uses an advisory lock instead. Decide at FABLE-1 whether ACC-1 adds it, and check the production data
for existing duplicates in the ACC-2 trial.
- [ ] **Second settlement path.** `pm_sync` (admin) calls `settle_from_exchange` over every mirrored market. The
tick's reconciliation step pays out whatever it settles, but BOOK-2 should pass
`market_ids` there too so that season markets settle in one place.
- [ ] **Resolution lag.** R16 records Kalshi's resolution lag and whether its closed-sync result field is `yes`/`no`
for every F1 kind. These are the numbers for the [timing table](#timing).
- [ ] **Owner inputs.** The final market list (DEC-20, DEC-12, DEC-7) by Wed 7 Oct 12:00 PDT; the fee
confirmation; the season-futures pause (DEC-14); whether player makers see fair values (DEC-10).
These go in [Owner decisions](todo.md#owner-decisions) with the rest of the DEC batch.
---
# CLI reference
> One command, task first: `racinglines [SPORT] [options]`. Every group, every command, its `-h` and a real run where one is captured.
Everything runs through one command (after `pip install "racinglines[db]"`, or `pip install -e ".[web]"` in a
clone). The task comes first and the sport is an argument. `racinglines -h` is the map:
=== "Command"
```bash title="Command"
racinglines -h
```
=== "Output"
```text title="Output"
racinglines [SPORT] [options]
data fetch | ingest | link | download | parse | record | pull | ... SPORT (results, sessions, feeds)
model forecast | backtest | compare | diagnostic | props | ... SPORT (pricing)
strategy replay | sweep | season-replay | signals | scorecard | ... SPORT (trading against exchange tapes)
backtest walk-forward SPORT | coverage | clv (the backtest core, any sport with a pricing model)
weather probe | fetch | show (weather forecasts per event: Weather Underground, docs/weather.md)
markets sync | history | trades | record | archive
book map | price | settle | view | render | new (generic sportsbook lines and slips: model and market EV, docs/sportsbook/slips.md)
db init | seed | stats | export
live new | step | run | agent | status | report (a live private-book event, any sport)
web serve the web app
users setup | list | add | reset-password | remove | deactivate | activate (accounts, fantasy bucks)
mcp the MCP server: the data and simulations for a chat client (docs/mcp.md)
remote tools | call | job | logout (the hosted server's tools from the command line, docs/mcp.md)
check quick validation: code on synthetic data, data endpoints, database
SPORT is f1, mtb_dh, nascar, motogp or cycling, e.g. `racinglines model forecast f1 --save`.
--demo on any command: the same command on the demo store, a separate database and data folder of synthetic data
(built on first use; racinglines/cli/source.py, docs/quickstart.md), e.g. `racinglines model forecast kart --demo --save`.
`racinglines -h` lists a group's commands and the sports that offer each.
```
!!! tip "Add `--demo` to any command"
`--demo` runs the same command on a synthetic demo store, a separate database and data folder built on first use,
so every example on this page can be tried without data of your own ([Quickstart](quickstart.md)).
!!! note "The PyPI package covers part of this page"
`pip install "racinglines[db]"` has the F1 and downhill (`mtb_dh`) commands, the kart demo, `book`, `db`,
`live`, `users` and `check`. `pip install "racinglines[mcp]"` adds `mcp`, `remote` and `data pull`, and
`pip install "racinglines[web]"` adds `web`. The
`nascar`, `motogp` and `cycling` sports and the `weather` and `tui` commands are in the development repository
only: in the package they stop with an error.
## Conventions
SPORT is `f1`, `mtb_dh`, `nascar`, `motogp` or `cycling` (`road_cycling` too). `racinglines -h` lists the
group's commands with the sports that offer each, and `racinglines SPORT -h` gives a command's
options. Examples:
```bash
racinglines data ingest nascar --years 2026
racinglines model forecast f1 --save
racinglines model backtest f1 --variant grid --save # the sport's own options (f1's --variant, --db) go anywhere after it
racinglines strategy replay f1 --runs 1298,1299,1300 --sweep
```
The old sport-first form (the sport before the command) is deprecated: for one version it still runs and prints a
one-line note on stderr naming the new form, then it is removed. Nothing in the repo calls it
(`tests/test_no_old_cli_forms.py` checks code, scripts, systemd units and docs). The mapping lives in
`racinglines/cli/verbs.py`; which sports offer which command is read from each sport's parser
(`racinglines/cli/.py`), so a new command only needs a group there.
`python -m racinglines โฆ` works too.
The database connection comes from `$DATABASE_URL`, or `--db URL` (`racinglines model forecast f1 --db URL โฆ`,
`racinglines db --db URL โฆ`). The default is the local Postgres on port 5433 (see [Database](database.md#setup)).
## Output: Rich, plain, JSON
`db stats`, `model backtest f1`, `model forecast f1`, `live run`, `live report` and `book map|price|settle`
print Rich output by default (colour, tables, probability bars, a few emojis for steps and events), also when piped or
recorded; redrawing progress bars only on a terminal (off one, or with `TTY_INTERACTIVE=0`, each line prints once).
`--plain` on any command, or `RACINGLINES_PLAIN=1`, prints the plain text instead; `--json` is always pure JSON.
`NO_COLOR` drops every escape code and keeps the layout and emojis; `FORCE_COLOR` keeps colour. Under systemd
(`JOURNAL_STREAM` set) the output carries no escape codes, so journalctl never shows `[blob data]`. The lines scripts
and the Lab read stay plain text in every mode: `progress i/n ...`, `Saved ... run N.`, `Per-race rows -> FILE`
(`racinglines/cli/tty.py`).
| Switch | Effect |
|---|---|
| (default) | Rich: colour, tables, bars |
| `--plain`, `RACINGLINES_PLAIN=1` | the plain text |
| `--json` | pure JSON (where a command offers it) |
| `NO_COLOR=1` | the Rich layout without escape codes (how the outputs on these pages were captured) |
| `FORCE_COLOR=1` | colour even when piped |
## Where the data comes from: `--demo`, `--remote`, `--local`
Three modes on `book map|price|settle|export`, and `--demo` and `--remote` on more commands:
| Mode | What it runs on |
|---|---|
| `--local` (the default) | your own database (`--db`, else `DATABASE_URL`, else the defaults file's `db`) |
| `--demo [PACK]` | a demo store of synthetic data from a data pack (default `kart`), built on first use ([Demo data packs](demo.md)); on every command |
| `--remote` | the hosted MCP server, in testing: it prints the #aintcare warning ([MCP server](mcp.md#hosted-the-vm)) |
`--demo [PACK]` works on every command, not only `book`: it switches the data source to a demo store and nothing else
([Quickstart](quickstart.md), [Demo data packs](demo.md)); `--demo --remote` uses the pack's hosted demo store for the
commands the hosted server runs. `model backtest|forecast f1|mtb_dh --remote` runs the command as the hosted server's
Lab job (`run_job`, then `get_job` until it finishes, its log printed as it arrives; the job's options only), and so
do `strategy sweep f1|nascar|motogp`, `strategy season-strategy f1`, `model diagnostic f1` and `model combo f1`, with
the job's knobs as flags (`--year 2025`, `--set NAME=VALUE`). `racinglines remote tools | call TOOL --ARG VALUE |
job JOB_TYPE` reaches any hosted tool, and `racinglines data pull SPORT [--since last]` copies one sport's shared data
into the local database ([MCP: More commands on the hosted server](mcp.md#more-commands-on-the-hosted-server-remote-racinglines-remote-data-pull)).
When no database is configured, the first command that needs one asks once, local or remote, and saves the answer
as `source` ([Quickstart: First run](quickstart.md#step-4-choose-where-the-data-comes-from)).
## Command groups
In the order of `racinglines -h`. The sport-specific detail (every option of every F1, NASCAR and downhill command)
is in the sections after them: [F1 commands](#f1-commands), [NASCAR commands](#nascar-commands),
[Downhill commands](#downhill-commands), [Road cycling commands](#road-cycling-commands).
### racinglines data
Results, sessions, feeds and market links into the database.
??? example "racinglines data -h"
```text title="Output"
racinglines data SPORT [options] (results, sessions, feeds and market links into the database)
fetch f1 nascar motogp cycling Download the sport's source data (results, sessions or feeds) to data/raw/.
download mtb_dh Download Chronorace result files (see: racinglines data download mtb_dh -h).
parse mtb_dh Parse result files into a tidy CSV (see: racinglines data parse mtb_dh -h).
ingest f1 mtb_dh nascar motogp cycling Load the downloaded data into the database.
schedule f1 nascar cycling Store the start times of sessions still to run as scheduled rounds (db/schedule.py), so markets on upcoming sessions have a close.
link nascar Identify the driver, race and contract of stored market links.
events cycling list the event files
startlist cycling fetch an event's start list (Mac only)
probe cycling fetch one results page, save it and show what parses (Mac only)
points mtb_dh Championship points tables: import, show, check against official standings.
record f1 Live F1 session recorder: fetch current session via FastF1 and store snapshot to database.
inrace-backfill f1 After a race: the Live tab's in-race win chart from the race's laps, one point per lap, as if the live-timing relay had run all race.
pm-sync f1
pm-links-export f1 Write market links to data/archive/markets//links/ (stable keys).
pm-links-import f1 Load that file into this database (no exchange access needed).
pm-history f1
pm-trades f1
pm-record f1
pm-archive f1
pull any sport One sport's shared data from the hosted server into the local database (racinglines data pull -h).
`racinglines data SPORT -h` for a command's options.
```
| Sport | Commands | Detail |
|---|---|---|
| F1 | `fetch`, `ingest`, `schedule`, `record`, `inrace-backfill`, `pm-*` (the Polymarket commands, also as `racinglines markets`) | [F1 commands: Data](#data), [racinglines markets](#racinglines-markets) |
| Downhill | `download`, `parse`, `ingest`, `points` | [Downhill commands](#downhill-commands) |
| NASCAR | `fetch`, `ingest`, `schedule`, `link` | [NASCAR commands](#nascar-commands) |
| MotoGP | `fetch`, `ingest` | [NASCAR commands](#nascar-commands) (the same options) |
| Road cycling | `fetch`, `events`, `startlist`, `probe` | [Road cycling commands](#road-cycling-commands) |
| any | `pull` | below |
`racinglines data pull SPORT` copies one sport's shared data from the hosted server into the local database
([MCP: More commands on the hosted server](mcp.md#more-commands-on-the-hosted-server-remote-racinglines-remote-data-pull)).
It needs racinglines.bet, so only its help is shown:
=== "Command"
```bash title="Command"
racinglines data pull f1 -h
```
=== "Output"
```text title="Output"
usage: racinglines data pull [-h] [--since SINCE] [--no-markets] [--keep] [--force] [--db DB]
[--mcp-url MCP_URL]
sport
`racinglines data pull SPORT`: one sport's shared data from the hosted server into the local database and data folder
(racinglines/db/pull.py says what a pull holds). Sign-in is `--remote`'s (cli/remote.py: the browser once, or
RACINGLINES_MCP_TOKEN), and the remote-access warning is printed first.
racinglines data pull f1 everything for F1 (the first pull wants a fresh database: db init)
racinglines data pull f1 --since last a top-up from where this database's last F1 pull stopped
racinglines data pull f1 --since 2026-09-01 --no-markets
positional arguments:
sport a sport code (sports/.toml), e.g. f1, nascar, motogp
options:
-h, --help show this help message and exit
--since SINCE 'last' (where this database's last pull stopped) or a UTC date: a top-up
--no-markets race data and model runs only (no exchange history)
--keep keep the downloaded files (data/pulls//)
--force a first pull into a database that already has model runs: replace its race
data and the rows that point at it
--db DB the local database (default: DATABASE_URL or the CLI defaults)
--mcp-url MCP_URL the hosted server (default RACINGLINES_MCP_URL or racinglines.bet)
```
### racinglines model
Pricing: forecasts, backtests, model comparisons, one-event diagnostics.
??? example "racinglines model -h"
```text title="Output"
racinglines model SPORT [options] (pricing: forecasts, backtests, model comparisons, one-event diagnostics)
forecast f1 mtb_dh nascar motogp cycling Price the upcoming races with the sport's model.
backtest f1 mtb_dh Price past races as of before each one and score them against the results.
walk-forward mtb_dh Price every event through the shared engine (model-only): calibration per market kind.
compare f1 motogp A challenger model against the baseline, race by race.
matrix f1 Every model variant x every trading strategy: accuracy (latest saved backtest per variant, paired vs baseline) and 2026 P&L (latest sweep + season strategy per variant).
diagnostic f1 ONE PAST EVENT at a chosen cutoff (e.g. yesterday); saved as kind='diagnostic'.
combo f1 Combo (same-game parlay) prices for one event (combo_job.py).
props f1 Race props: safety car, red flag, rain, fastest lap (props.py).
sprint-check f1 Walk-forward check of the sprint stage (models/sprint_check.py).
season nascar Cup season forecast (champion odds) from the points standings; read-only.
price cycling price an event's book
reliability cycling calibration check: favourites vs long shots, walk-forward
search motogp Sweep a small set of MotoGP recent-form settings and rank by mean log loss.
No data yet? Add --demo to any command, e.g. `racinglines model backtest kart --demo`: the same command on a synthetic demo store (docs/quickstart.md).
`racinglines model SPORT -h` for a command's options.
```
A backtest prices every past race as of before it and scores it against the results, uniform and grid baselines;
on the kart demo store:
=== "Command"
```bash title="Command"
racinglines model backtest kart --demo
```
=== "Output"
```text title="Output"
progress 0/1 building history (track features on)
๐ backtest, track features on
progress 10/33 2024 R10 rainbow-road
progress 20/33 2025 R08 mount-wario
progress 30/33 2026 R06 thwomp-ruins
progress 33/33 2026 R09 bowser-s-castle
๐ Track features ON: mean over 32 races
โญโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฎ
โ market โ stage โ model Brier โ uniform โ grid โ skill vs uniform โ log loss โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ win โ pre weekend โ 0.0431 โ 0.0475 โ โ โ โ +9% โ 0.1518 โ
โ โ pre quali โ 0.0405 โ 0.0475 โ โ โ โโ +15% โ 0.1388 โ
โ โ pre race โ 0.0381 โ 0.0475 โ 0.0410 โ โโ +20% โ 0.1266 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ podium โ pre weekend โ 0.0988 โ 0.1275 โ โ โ โโโ +23% โ 0.3155 โ
โ โ pre quali โ 0.0913 โ 0.1275 โ โ โ โโโ +28% โ 0.3058 โ
โ โ pre race โ 0.0772 โ 0.1275 โ 0.0824 โ โโโโ +39% โ 0.2699 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ top10 โ pre weekend โ 0.1662 โ 0.2500 โ โ โ โโโโ +34% โ 0.5105 โ
โ โ pre quali โ 0.1543 โ 0.2500 โ โ โ โโโโ +38% โ 0.4897 โ
โ โ pre race โ 0.1295 โ 0.2500 โ 0.1345 โ โโโโโ +48% โ 0.4353 โ
โฐโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโฏ
๐ favourite won 34% of 32 races ยท rank correlation 0.83 ยท mean winner probability 22.7%
best call 2024 R04 Royal Raceway: Donkey Kong won at 48.6%
upset 2024 R08 Mount Wario: Donkey Kong won at 1.7% (favourite Peach)
progress 0/1 building history (track features off)
๐ backtest, track features off
progress 10/33 2024 R10 rainbow-road
progress 20/33 2025 R08 mount-wario
progress 30/33 2026 R06 thwomp-ruins
progress 33/33 2026 R09 bowser-s-castle
๐ Track features OFF: mean over 32 races
โญโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโฎ
โ market โ stage โ model Brier โ uniform โ grid โ skill vs uniform โ log loss โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ win โ pre weekend โ 0.0437 โ 0.0475 โ โ โ โ +8% โ 0.1629 โ
โ โ pre quali โ 0.0407 โ 0.0475 โ โ โ โโ +14% โ 0.1391 โ
โ โ pre race โ 0.0377 โ 0.0475 โ 0.0410 โ โโ +21% โ 0.1240 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ podium โ pre weekend โ 0.1033 โ 0.1275 โ โ โ โโ +19% โ 0.3356 โ
โ โ pre quali โ 0.0915 โ 0.1275 โ โ โ โโโ +28% โ 0.3104 โ
โ โ pre race โ 0.0768 โ 0.1275 โ 0.0824 โ โโโโ +40% โ 0.2650 โ
โโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโค
โ top10 โ pre weekend โ 0.1879 โ 0.2500 โ โ โ โโโ +25% โ 0.5659 โ
โ โ pre quali โ 0.1573 โ 0.2500 โ โ โ โโโโ +37% โ 0.4944 โ
โ โ pre race โ 0.1296 โ 0.2500 โ 0.1345 โ โโโโโ +48% โ 0.4326 โ
โฐโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโฏ
๐ favourite won 38% of 32 races ยท rank correlation 0.83 ยท mean winner probability 23.1%
best call 2024 R04 Royal Raceway: Donkey Kong won at 52.3%
upset 2024 R08 Mount Wario: Donkey Kong won at 1.6% (favourite Peach)
Per-race rows -> /home/user/racinglines-public/data/demo/kart/runs/f1/backtests/backtest_20261014_0506.csv
```
A forecast prices the upcoming races and the championships; `--save` stores the run the web app and the book read:
=== "Command"
```bash title="Command"
racinglines model forecast kart --demo --save
```
=== "Output"
```text title="Output"
๐ฎ forecast 2026 ยท cutoff 2026-10-14 05:06 UTC ยท latest results 2026-10-04 14:00
๐ R10 Rainbow Road Grand Prix (18 Oct)
โญโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโฎ
โ driver โ team โ win โ โ podium โ โ top 10 โ DNF โ exp pts โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโผโโโโโโโโโโค
โ Mario โ mushroom โ 26.4% โ โโโโ โ 58.7% โ โโโโโโโโโ โ 95.0% โ 3.2% โ 15.1 โ
โ Luigi โ mushroom โ 18.8% โ โโโ โ 49.7% โ โโโโโโโ โ 93.8% โ 3.1% โ 13.4 โ
โ Bowser โ koopa โ 14.4% โ โโ โ 40.2% โ โโโโโโ โ 82.2% โ 13.9% โ 11.2 โ
โ Peach โ royal โ 9.9% โ โโ โ 33.1% โ โโโโโ โ 86.7% โ 5.5% โ 10.2 โ
โ Daisy โ royal โ 8.3% โ โโ โ 29.6% โ โโโโโ โ 84.4% โ 5.9% โ 9.5 โ
โ Donkey Kong โ kong โ 8.0% โ โ โ 27.4% โ โโโโ โ 85.8% โ 3.5% โ 9.3 โ
โ Bowser Jr. โ koopa โ 6.7% โ โ โ 23.6% โ โโโโ โ 75.2% โ 13.7% โ 8.1 โ
โ Diddy Kong โ kong โ 2.8% โ โ โ 12.3% โ โโ โ 72.9% โ 3.3% โ 5.8 โ
โ Yoshi โ yoshi โ 1.6% โ โ โ 7.7% โ โ โ 60.1% โ 7.3% โ 4.2 โ
โ Rosalina โ comet โ 1.2% โ โ โ 5.9% โ โ โ 55.5% โ 5.6% โ 3.6 โ
โฐโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโดโโโโโโโโโโฏ
๐ R11 Wario Stadium Grand Prix (01 Nov)
โญโโโโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโฌโโโโโโโโโโฎ
โ driver โ team โ win โ โ podium โ โ top 10 โ DNF โ exp pts โ
โโโโโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโผโโโโโโโโโโค
โ Mario โ mushroom โ 24.4% โ โโโโ โ 59.3% โ โโโโโโโโโ โ 95.4% โ 3.0% โ 15.0 โ
โ Luigi โ mushroom โ 20.7% โ โโโ โ 52.9% โ โโโโโโโโ โ 94.8% โ 3.0% โ 14.0 โ
โ Peach โ royal โ 15.6% โ โโโ โ 44.2% โ โโโโโโโ โ 90.8% โ 5.9% โ 12.4 โ
โ Bowser โ koopa โ 12.9% โ โโ โ 37.3% โ โโโโโโ โ 82.2% โ 14.0% โ 10.8 โ
โ Daisy โ royal โ 12.1% โ โโ โ 36.9% โ โโโโโโ โ 89.1% โ 5.7% โ 11.2 โ
โ Bowser Jr. โ koopa โ 4.6% โ โ โ 19.1% โ โโโ โ 75.1% โ 13.7% โ 7.3 โ
โ Donkey Kong โ kong โ 4.2% โ โ โ 18.4% โ โโโ โ 82.0% โ 3.1% โ 7.6 โ
โ Yoshi โ yoshi โ 2.1% โ โ โ 11.0% โ โโ โ 69.3% โ 7.4% โ 5.4 โ
โ Diddy Kong โ kong โ 1.0% โ โ โ 6.1% โ โ โ 61.1% โ 3.6% โ 4.0 โ
โ Birdo โ yoshi โ 0.9% โ โ โ 5.3% โ โ โ 56.3% โ 7.3% โ 3.6 โ
โฐโโโโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโดโโโโโโโโโโฏ
๐ 2026 drivers' championship
โญโโโโโโโโโโโโโโโฌโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโฌโโโโโโโโโฎ
โ driver โ points โ expected โ champion โ โ top 3 โ
โโโโโโโโโโโโโโโโผโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโผโโโโโโโโโโโโโโโโโผโโโโโโโโโค
โ Luigi โ 191 โ 234 โ 99.8% โ โโโโโโโโโโโโโโ โ 100.0% โ
โ Mario โ 134 โ 182 โ 0.2% โ โ 99.1% โ
โ Peach โ 106 โ 141 โ 0.0% โ โ 60.5% โ
โ Bowser โ 99 โ 135 โ 0.0% โ โ 39.9% โ
โ Daisy โ 62 โ 95 โ 0.0% โ โ 0.2% โ
โ Donkey Kong โ 57 โ 85 โ 0.0% โ โ 0.2% โ
โ Bowser Jr. โ 60 โ 85 โ 0.0% โ โ 0.1% โ
โ Koopa Troopa โ 55 โ 58 โ 0.0% โ โ 0.0% โ
โ Birdo โ 36 โ 48 โ 0.0% โ โ 0.0% โ
โ Diddy Kong โ 28 โ 46 โ 0.0% โ โ 0.0% โ
โฐโโโโโโโโโโโโโโโดโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโดโโโโโโโโโโโโโโโโโดโโโโโโโโโฏ
constructors
team points expected champion
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Mushroom Racing 325 416 100.0% โโโโโโโโโโโโโโ
Royal Racing 168 236 0.0%
Koopa Kart Works 159 220 0.0%
Kong Jungle Racing 85 132 0.0%
Yoshi Island GP 62 91 0.0%
finishing model coef {'g': 0.527, 'g_ease': -0.149, 'rp_rel': 0.041, 'rp_ease': 0.051, 'qp_rel':
0.08, 'street_g': 0.0} sigma 0.155; season drift (as-of) {'team_sd': 0.002652025069557153,
'driver_sd': 0.0005339299639313828}
Saved forecast run 2.
```
Every option: [F1 commands: Pricing and evaluation](#pricing-and-evaluation),
[Downhill commands](#downhill-commands) (`forecast`, `backtest`, `walk-forward`), [NASCAR commands](#nascar-commands)
(`forecast`, `season`).
#### racinglines model props f1: wet or dry and the DNF check
`racinglines model props f1 --check --history ` runs the walk-forward calibration of the yes/no props on a history CSV
(`props.history()`'s columns) with no database, scoring the red flag and safety car given wet or dry as well
(`climatology`, `wet_oracle`); `--dnf-check ` scores the position simulation's `dnf_prob` against the
classification (`models/dnf_check.py`). Both are read-only; see the decision log of 2026-10-06 in
[F1 roadmap](f1-roadmap.md#decision-log).
`--forecast [--lead N]` (the leads CSV of `racinglines weather leads`, e.g.
`tests/fixtures/weather/open_meteo-leads-f1.csv`, turned into a walk-forward `p_wet` per race by
`weather/wet.py`'s `p_wet_series`; lead 5 by default) adds the weather-aware variants to both: `climatology-WX` to
`--check` and `model-WX` to `--dnf-check`, each with its paired Brier difference against its base. Both read the
race history from `--history `, else the database. The forecast's own skill by lead is
`racinglines weather backtest`. See [F1: weather-aware (-WX) variants](f1.md#weather-aware-wx-variants).
### racinglines strategy
Trading: replays against exchange tapes, season sweeps, paper signals, strategy searches.
??? example "racinglines strategy -h"
```text title="Output"
racinglines strategy SPORT [options] (trading: replays against exchange tapes, season sweeps, paper signals, strategy searches)
replay f1 nascar motogp Replay a strategy against an exchange's recorded prices.
sweep f1 nascar motogp The season sweep: the taker modes and maker variants through each race's weekend.
season-replay nascar motogp Champion markets replayed through the season.
season-strategy f1 Default championship-market strategy through the season: as-of season forecasts pre-season and after every race, trades at Polymarket's recorded prices (+ spread and slippage), settles eliminated markets, marks the rest.
season-checkpoints f1 Championship markets entered at fixed points (pre-season, after 3 and after 6 grands prix) and held, per model variant:
scorecard f1 Pricing scorecard of an exchange weekend (pipelines/scorecard.py): the stored stage runs' fair values vs the result and vs the venue's mid at each stage.
signals f1 nascar motogp Live paper signals of users' strategy profiles (recommendations only).
demo-history f1 nascar motogp Backfill the demo accounts' track record from backtest replays.
reconcile f1 One account's live paper weekend against the backtest's replay of it on the recorded tape (pipelines/reconcile.py; docs/paper-trading.md, rule 1).
profiles f1 List strategy profiles; create A / C / K as Lab candidates if missing.
search f1
search-report f1
search-import f1
search-analyze f1 Analyze saved F1 sweep-search runs and write report artifacts (curves, rankings, charts, noise, HTML; beside search-report).
live mtb_dh Follow a final live from UCI timing: rank probabilities, the maker's quotes, the private book's crowd (pipelines/live_dh.py).
`racinglines strategy SPORT -h` for a command's options.
```
`profiles` lists the strategy profiles (A the core taker, C the maker sleeve, K the Kalshi maker, and the alpha's
styles), creating them as Lab candidates when missing:
=== "Command"
```bash title="Command"
racinglines strategy profiles f1 --demo
```
=== "Output"
```text title="Output"
A candidate #8 A ยท core taker (update)
C candidate #9 C ยท maker sleeve (gbm)
K candidate #10 K ยท Kalshi maker [kalshi]
T1 candidate #11 T1 ยท update, core (A's settings)
T2 candidate #12 T2 ยท update, every stage
T3 candidate #13 T3 ยท update, gridq+pretrain
T4 candidate #14 T4 ยท update, gbm
T5 candidate #15 T5 ยท update, 8-pt edges
T6 candidate #16 T6 ยท update, $200 volume floor
T7 candidate #17 T7 ยท update, loose podium coherence
T8 candidate #18 T8 ยท stage-aware (early, $25 volume floor)
T9 candidate #19 T9 ยท stage-aware, loose podium coherence
T10 candidate #20 T10 ยท update, baseline model
```
Every option: [F1 commands: Trading research](#trading-research) (`sweep`, `season-strategy`, `season-checkpoints`,
`replay`, `search*`) and [Paper signals and strategy profiles](#paper-signals-and-strategy-profiles) (`profiles`,
`signals`, `demo-history`, `reconcile`); [NASCAR commands](#nascar-commands) (`replay`, `sweep`, `season-replay`,
`demo-history`, `signals`); [racinglines strategy live mtb_dh](#racinglines-strategy-live-mtb_dh).
### racinglines backtest
The backtest core for any sport with a pricing model ([Backtest core](backtest-core.md)).
??? example "racinglines backtest -h"
```text title="Output"
usage: racinglines backtest [-h] {walk-forward,coverage,clv} ...
racinglines backtest : the backtest core for any sport with a pricing model (docs/backtest-core.md).
walk-forward SPORT price every event from data before it (racinglines/core/walk_forward.py), model-only:
calibration per market kind; --save stores it as a walk_forward model run (what a
search job runs). The sport's pricing model is named in sports/.toml
([sport] pricing_model), and its settings are its flags.
racinglines backtest walk-forward mtb_dh --seasons 2025 --half-life-days 120 [--save]
racinglines backtest walk-forward motogp --model global --seasons 2026 # the one engine at the results level
racinglines backtest walk-forward nascar --model nascar_recent_form # a challenger ([sport] challengers)
racinglines backtest walk-forward f1 --model global --seasons 2025 2026 --venue polymarket kalshi
# also scores each venue's price beside the model's (model_vs_market): for F1 at the first
# stage, 1 h before any running (--market-stage 'after Quali' for another)
coverage read-only: per sport x exchange x market kind x season, the races linked, settled and taped
(prices, trades, books in Postgres and the Parquet archive) and the parity tier that gives;
season futures; per sport, the race results in the database and the declared results
sources (racinglines/pipelines/coverage.py, docs/parity-rebuild.md "C0")
racinglines backtest coverage [--seasons 2025 2026] [--out DIR]
clv read-only: closing-line value of every paper bet (strategy_signals: the taker's followed
trades, the maker's fills) on any sport and venue, per bet and per sport x venue x kind x
strategy x profile: bets, mean CLV, share positive (racinglines/pipelines/clv.py, "C4")
racinglines backtest clv [--user NAME ...] [--sport nascar] [--venue kalshi] [--event KEY] [--asof UTC] [--summary] [--json]
[--out DIR]
positional arguments:
{walk-forward,coverage,clv}
walk-forward Price every event through the shared engine (model-only).
coverage Read-only: linked, settled and taped races per sport, exchange, kind and
season.
clv Read-only: closing-line value per paper bet, and per sport, venue, kind
and profile.
options:
-h, --help show this help message and exit
```
#### racinglines backtest walk-forward
The backtest core for any sport with a pricing model ([Backtest core](backtest-core.md#adding-a-sport)):
every event priced from data before it, model-only, calibration per market kind. The sport's settings are
its flags (`racinglines backtest walk-forward SPORT -h` lists them). What a search job for a sport other
than F1 runs.
```
racinglines backtest walk-forward SPORT [--db URL] [--seasons 2025 2026] [--kinds K1,K2] [--save]
[--out-dir data/runs//walk_forward] [the sport's settings, e.g. --seed N --sims N]
```
#### racinglines backtest coverage
The coverage counter (parity rebuild package C0), read-only: it writes nothing to the database. It prints four
things. The first is what it counted: the database, the archive trees, and the Postgres and Parquet rows per
store. A header line says when there's no market tape at all, which is how you check a copy such as staging.
The second is one row per sport ร exchange ร market kind ร season of race markets: links, races linked,
races settled, races with a price, trade or book tape (Postgres or the Parquet archive, through
`markets/store.py`), the parity tier, the maker tier, whether the kind is modeled, and links still `unmodeled`.
The third is the season futures. The fourth is, per sport, the race results in the database (seasons, events,
events with results) and the results sources its schema declares (`[results]`, `[replay]` or `[model]` source,
allowed fallbacks, `[sport] results_modules`, cleared or not).
```
racinglines backtest coverage [--db URL] [--seasons 2025 2026] [--out DIR]
```
A tier counts, per season, the races with both a settlement and a price tape. **parity-2** is 8 or more in two
seasons, **parity-1** is 8 or more in one, **thin** is 1 to 7, and **missing** is none. The maker tier counts
book tape in place of price tape. A kind is **modeled** when `markets/kinds.py` defines it, the sport's schema
names a `pricing_model`, and one of the schema's `kinds` / `*_kinds` lists names it. A link's kind is its
`prediction`, or `params.kind` while it's `unmodeled`. Its season is the year of the race's event, or of the
link's `end_date` for a futures market. `--out` writes `coverage_combos.csv`, `coverage_futures.csv` and
`coverage_results.csv`.
On the kart demo store (which has links and results but no market tape, so every tape count is 0):
=== "Command"
```bash title="Command"
racinglines backtest coverage --demo
```
=== "Output"
```text title="Output"
database racinglines_demo_kart ยท data /home/user/racinglines-public/data/demo/kart ยท archive trees: /home/user/racinglines-public/data/demo/kart/archive/markets/kalshi, /home/user/racinglines-public/data/demo/kart/archive/markets/polymarket
prices postgres 0 rows ยท parquet 0 rows in 0 files
trades postgres 0 rows ยท parquet 0 rows in 0 files
books postgres 0 rows ยท parquet 0 rows in 0 files
no market tape here: every price/trade/book count below is 0
=== Race markets (tier at 8+ races a season with both a settlement and a price tape; maker_tier needs a book tape) ===
sport exchange kind season links races settled_races price_races trade_races book_races tier maker_tier modeled unmodeled_links
f1 kalshi race_p2 2026 33 3 0 0 0 0 missing missing no 0
f1 kalshi race_podium 2026 49 3 0 0 0 0 missing missing yes 0
f1 kalshi race_win 2026 43 3 0 0 0 0 missing missing yes 0
f1 polymarket race_h2h 2026 30 3 0 0 0 0 missing missing yes 0
f1 polymarket race_podium 2026 49 3 0 0 0 0 missing missing yes 0
f1 polymarket race_pole 2026 17 3 0 0 0 0 missing missing yes 0
f1 polymarket race_win 2026 60 3 0 0 0 0 missing missing yes 0
combos per tier: parity-2 0, parity-1 0, thin 0, missing 7
=== Season futures (no race) ===
none linked
=== Results depth and sources (per sport) ===
sport first_season last_season events events_with_results results_source fallbacks source_modules cleared
mtb_dh 0 0 chronorace yes
f1 2024 2026 36 33 f1timing fastf1 yes
nascar 0 0 nascar_cf wikipedia (summary_only) nascar yes
motogp 0 0 motogp_api motogp yes
indycar 0 0 indycar no
road_cycling 0 0 yes
le_mans 0 0 wec no
sailgp 0 0 sailgp no
More results deepen the models' training (walk-forward burn-in); P&L backtests also need market tape, which starts when recording began.
```
#### racinglines backtest clv
Closing-line value (CLV) per paper bet (parity rebuild package C4; the owner's go/no-go metric for real money is CLV
above +2 % over 100 live bets). Read-only: it computes CLV on read and writes nothing to the database
(`racinglines/pipelines/clv.py`). A bet is a paper trade in `strategy_signals`: a taker's buy or sell it followed, or
a maker's paper fill, on any sport (F1, NASCAR, MotoGP) and venue (Polymarket, Kalshi, OG.com). It prints one row
per sport ร venue ร kind ร strategy ร profile (bets, decided, undecided, mean CLV in points and percent, share of
decided bets with a positive CLV, mean move against the mid at entry) with an `all` row, then every bet.
```
racinglines backtest clv [--db URL] [--user NAME ...] [--sport nascar] [--venue kalshi] [--event KEY] [--asof UTC] [--summary] [--json] [--out DIR]
```
CLV is the close minus the entry, both as the YES token's price, signed for the side held: positive when the market
closed on our side of the price we paid. In percent it's divided by the price paid for that side. The entry is the
fill price the signal stored. The close is the start of the round that decides the market (`markets/kinds.toml`:
the kind's `session`, else a `stage_top_n` or `stage_top` stage, such as qualifying for pole; else the race), read
from `rounds.extra.session_date` or the sport's `[sessions] time_key` (NASCAR's `run_date_utc`: the race's start is the
feed's local race time plus the weekend's own UTC offset, from its practice and qualifying runs). A race with no
stored start (a NASCAR weekend whose runs carry no UTC time) closes on race day from
the schema: the event's date plus `[replay] race_day_offset` days, at `[sweep] quote_until_hours` UTC (default
00:00). An earlier round with no stored start gets no close. The closing price is the venue's own reading at the close
(`markets/venue_replay.py`): the book's mid where the venue keeps a quote (Kalshi), else the last tape price within
6 hours. A bet stays **undecided**, with its reason, when its close is still ahead, it was made after the close, it
has no race (season futures) or no link, or the venue has no price in the 6 hours before the close. `--asof` judges which markets have closed as of that time (default now). `--out` writes
`clv_bets.csv` and `clv_summary.csv`. A season sweep computes the same CLV for its own trades and saves it with the
run (`metrics.clv`, below); its in-memory taker trades go through `clv.from_trades` and its maker fills through
`clv.from_fills`.
### racinglines markets
??? example "racinglines markets -h"
```text title="Output"
racinglines markets : exchange data (Polymarket and Kalshi; F1 plus the tape-only sports).
sync Sync the exchange's events into market links (live prices, resolutions)
history Store price history for events (--fidelity 1 = minute-level)
trades Store every taker trade for events (the tape a maker replay fills against)
record Record order-book snapshots every --interval seconds (archives to Parquet hourly)
archive Move stale prices / trades / books from Postgres to Parquet (--stats: sizes)
disagree Cross-venue disagreement log: Polymarket vs Kalshi on every outcome listed on both, per tick
(--event 2026-15, a round number, or season [--year]; --start/--end UTC, --step minutes, --no-save)
settle-check Read-only, any --exchange and --sport: our outcome from the results (markets/kinds.settle) beside
the exchange's resolved_yes on every resolved link; lists disagreements, exit 1 if any [--out FILE.csv]
Options: --exchange polymarket (default) --sport f1 (default), then the command's own options.
Polymarket for a tape-only sport (--sport nascar|motogp|indycar; sports/.toml [markets.polymarket] tags,
unverified against the live Gamma API; markets/polymarket/sync.py). Additive: rows are upserted, never deleted:
sync [--year 2026] [--closed] [--tags SLUG โฆ] the sport's Polymarket events into market links
trades [--events SLUG โฆ] the tape of those events' markets
history [--events SLUG โฆ] --start --end [--fidelity 60] price history (minutes per point)
books [--events SLUG โฆ] one order-book snapshot per open market
(--sport f1 keeps the commands above, except `trades` and `history` without --events, which take every F1 market
the same way; `record` is F1 only.) `trades --open --since-hours H` on any exchange: only markets not closed, only
trades from H hours ago on (the recorder's pass, scripts/vm/record_venues.sh and pm_sync.sh; the one-off backfill,
scripts/vm/backfill_tape.sh, takes --since-hours alone).
Kalshi (--exchange kalshi; markets/kalshi/, built on mocked responses, unverified against the live API):
sync [--year 2026] [--closed] [--series TICKER โฆ] the sport's Kalshi markets into market links
trades [--events TICKER โฆ] the tape of those events' markets
history [--events TICKER โฆ] --start --end [--period 60] candlesticks (minutes: 1, 60, 1440): price, bid, ask
[--save-raw DIR | --from-raw DIR] also keep Kalshi's raw responses / re-import them offline
books [--events TICKER โฆ] one order-book snapshot per open market
coverage [--events TICKER โฆ] stored prices with the candle's bid/ask (Parquet + Postgres)
Exchanges defined as schemas (--exchange og; exchanges/.toml, markets/exchange_driver.py), read-only:
sync [--year 2026] the sport's markets and quotes into market links
settle [--since UTC] [--max-pages N] outcomes of closed links from the settlement feed
(resolved_yes; a void is recorded in params and left empty); a schema with no feed does nothing
--check: no scan, read-only: compare our settlement with resolved_yes (= settle-check)
trades [--events SYMBOL โฆ] the tape the exchange still serves (about a month)
history [--events SYMBOL โฆ] --start [--end] minute prices (clipped to what the exchange keeps)
books [--events SYMBOL โฆ] one order-book snapshot per open market
fair the model's fair price beside the quote, net of the taker fee: a simple indicator, no trades
buy-all debug, read-only: one YES and one NO of every market (modeled or not) at the first price stored
(minute price, trade, book side or the sync's quote, whatever the spread or depth), held; P&L settled
when resolved, else marked at the last price stored [--cost 0.01] [--fee F] [--out FILE.csv]
--sport names the sport (default f1): the tape-only sports (nascar, motogp, indycar; sports/.toml
[markets.kalshi] / [markets.polymarket]) are synced only when named, every link unmodeled, under their own
competition. Without --events, trades / history / books take every event of --sport's competition on the
exchange (books: open markets).
```
Exchange data (Polymarket; F1 is the only sport listed). `racinglines markets CMD`
is the same as `racinglines data pm-CMD f1`; group options are `--exchange polymarket`
and `--sport f1` (the defaults) and `--db URL`.
```
racinglines markets sync [--year 2026] [--closed] [--alert]
racinglines markets history --events 'f1-azerbaijan-grand-prix%' --start 2026-09-22T00:00 --end 2026-09-26T12:00 [--fidelity 60]
racinglines markets trades --events 'f1-azerbaijan-grand-prix%'
racinglines markets record [--events โฆ] [--interval 60] [--minutes 0] [--sync-every 30] [--no-alerts] [--year 2026]
racinglines markets archive [--stats] [--vacuum-full] [--compact] [--hours H]
racinglines markets disagree --event 2026-15 | --event season [--year 2026] [--start โฆ --end โฆ] [--step 60] [--no-save]
racinglines data pm-links-export f1 [--exchange kalshi]
racinglines data pm-links-import f1 [--exchange kalshi]
```
| Command | What it does |
|---|---|
| `sync` (`f1 pm-sync`) | Sync Polymarket's F1 events of `--year` into `market_links` (live prices, resolutions). `--closed` adds every closed event of that year (e.g. a past season). `--alert` notifies about markets seen for the first time (`market_links.first_seen_at`). |
| `history` (`f1 pm-history`) | Store price history for events (slugs, or a prefix ending in `%`) between `--start` and `--end` (UTC). `--fidelity` is minutes per point (default 60; 1 = minute-level). |
| `trades` (`f1 pm-trades`) | Store every taker trade for events (the tape the maker replay fills against). |
| `record` (`f1 pm-record`) | Record order-book snapshots every `--interval` seconds (default 60) of the given events, or of the open markets of races not yet run. Runs until stopped (`--minutes N` stops after N). Re-syncs Polymarket's F1 events every `--sync-every` minutes (default 30; 0 = never) so new race markets get recorded, and alerts about new markets unless `--no-alerts`. Archives to Parquet hourly. Run it through race weekends. |
| `archive` (`f1 pm-archive`) | Move stale prices, trades and books from Postgres to Parquet under the retention policy, or every row older than `--hours H`. `--vacuum-full` returns freed space to the OS, `--compact` merges each month into one file, `--stats` only shows where the rows are. See [Database](database.md#storage-postgres-for-the-app-parquet-for-heavy-history). |
| `disagree` | The cross-venue disagreement log ([Kalshi history](kalshi-history.md#cross-venue-disagreement-log)): for every outcome of a race weekend (`--event 2026-15`, or a round number of `--year`) or of the season's drivers' and constructors' champion markets (`--event season`) that is linked on both Polymarket and Kalshi, one row per `--step` minutes (default 60) with both mids and tops of book, our fair from the run in force, each venue's fee-adjusted taker edge and the cross-venue gap net of both taker fees, upserted into `market_disagreements` (`--no-save`: report only) and printed day by day. The window defaults to where both venues have stored prices, and for a race ends at its start. Reads the archived prices, books and tapes, so it needs no exchange access. |
| `f1 pm-links-export` | Write `market_links` to `data/archive/markets/polymarket/links/market_links.parquet`, with database ids swapped for stable keys (event key and category, FastF1 driver id, competition and category codes). |
| `f1 pm-links-import` | Load that file into this database (no Polymarket access needed); replaces each token's row and reports rows whose keys don't resolve. `--exchange kalshi` does the same for Kalshi's links (`data/archive/markets/kalshi/links/`). |
#### Kalshi (`--exchange kalshi`)
Built from Kalshi's public API docs, then run against the live read-only API on 2026-09-28 (sync with
`--closed`, trades, history and books; no order has been sent). Nothing runs unless you call it.
Markets settled before Kalshi's historical cutoff (about two months back) are read from its `/historical`
endpoints: settled events' markets, their trades and their candlesticks.
```
racinglines markets --exchange kalshi sync [--year 2026] [--closed] [--series TICKER โฆ]
racinglines markets --exchange kalshi trades --events KXF1RACE-AZEGP26
racinglines markets --exchange kalshi history --events KXF1RACE-AZEGP26 --start 2026-09-24T00:00 --end 2026-09-27T00:00 [--period 60]
racinglines markets --exchange kalshi history --start 2025-01-01 --end 2026-10-05 --save-raw data/raw/kalshi/candles-2025-2026 # + raw responses
racinglines markets --exchange kalshi history --start 2025-01-01 --end 2026-10-05 --from-raw data/raw/kalshi/candles-2025-2026 # re-import, no network
racinglines markets --exchange kalshi books --events KXF1-26
racinglines markets --exchange kalshi --sport nascar sync [--closed] # NASCAR Cup: KXNASCAR* series, links unmodeled
racinglines markets --exchange kalshi --sport nascar trades # every NASCAR event's tape (no --events needed)
racinglines markets --exchange kalshi --sport motogp books # one snapshot per open MotoGP market
racinglines markets --exchange kalshi --sport road_cycling sync --closed # UCI road: KXCYCLING* series, incl. settled grand tours
racinglines markets --sport nascar sync [--tags nascar] # the same sport's Polymarket markets (tape only)
racinglines markets --sport nascar trades # trades / history / books work the same way
```
| Command | What it does |
|---|---|
| `sync` | Find Kalshi's F1 series (Sports series whose ticker starts `KXF1` or whose title says Formula 1, F1 or Grand Prix, or `kalshi.sync.SERIES`; `--series TICKER โฆ` names them instead), and upsert one `market_links` row per market (`exchange='kalshi'`, `token_id` = the market ticker's YES contract, `condition_id` = the event ticker), classified into the model's kinds: win, podium, top 10, pole, top constructor, fastest lap, head-to-head (the race read from the ticker's code, `BRIGP26`), champions. The sprint winner and sprint pole (`KXF1RACESPRINT`, `KXF1SPRINTPOLE`) classify as `race_sprint_win` / `race_sprint_pole` and get a model price ([F1](f1.md#kalshi-alignment)); `RACINGLINES_KALSHI_SPRINTS=0` lists them as unmodeled instead. The sprint's top 5, top 10, fastest lap and top constructor stay unmodeled. Each row keeps Kalshi's resolution rules (`params.rules`). `--closed` adds settled events. Races resolve within `--year` (2026: 15 weekends, 1,847 modeled links on 2026-09-28). |
| `trades` | Store every trade on those events' markets in `market_trades` (the taker's side of YES, at the YES price, in contracts). |
| `history` | Store candlesticks (`--period` 1, 60 or 1440 minutes) in `market_price_history`. |
| `books` | One order-book snapshot per open market in `market_book_snapshots` (a NO bid at p is a YES ask at 1 โ p). |
`--sport nascar` / `motogp` / `indycar` / `road_cycling` / `le_mans` / `sailgp` (before the command) switches the sync to a **tape-only sport**: Kalshi's
NASCAR Cup (`KXNASCAR*`: race winners and the champion), MotoGP (`KXMOTOGP*`) or IndyCar (`KXINDYCAR*`) series,
filed under the sport's own competition (`sports/.toml`, no model) with every link `unmodeled`. Off by
default: without `--sport`, `sync` is the F1 sync above and touches nothing else. For any sport, `trades`,
`history` and `books` without `--events` take every Kalshi event of that sport's competition (books: its open
markets), so recording a series from listing to settlement is `sync --closed`, `trades` and `books` per pass.
On Polymarket (`markets --sport nascar|motogp|indycar sync`, no `--exchange`) the same sports are recorded from the Gamma tags in `[markets.polymarket] tags`; the slugs are unverified against the live API and `--tags SLUG โฆ` overrides them. The Polymarket path is additive: it upserts by token id and has no reset or delete. The series prefixes come from `[markets.kalshi] series` in the schema and were not checked against the live
listing (the cloud can't reach Kalshi): `sync --series TICKER โฆ` syncs exact tickers.
[Data](data.md#other-series-tapes-nascar-motogp-indycar-road-cycling-le-mans-sailgp).
**The recorder unit is not changed.** `racinglines-recorder` (`deploy/vm/systemd/racinglines-recorder.service`,
`scripts/deploy/vm.sh`) still runs `markets record --interval 60`: Polymarket's F1 books, plus the hourly archive
pass that also files Kalshi's rows. There is no Kalshi `record`. To record NASCAR or MotoGP on the VM, add a
timer of your own next to `racinglines-signals.timer`, e.g. `racinglines-tapes.service` with
`ExecStart=/bin/sh -c 'for s in nascar motogp; do racinglines markets --exchange kalshi --sport $s sync --closed
&& racinglines markets --exchange kalshi --sport $s trades && racinglines markets --exchange kalshi --sport $s books; done'`
(same `User`, `WorkingDirectory` and `EnvironmentFile` as the recorder) and a `racinglines-tapes.timer` with
`OnCalendar=*:0/5` (books every 5 min; trades are deduplicated, so re-pulling them is safe), then
`vm.sh deploy` installs whatever is in `deploy/vm/systemd/`. `history --period 60` once after settlement fills
the candle series.
With `RACINGLINES_DISAGREE=1`, `markets record` also writes one tick of the disagreement log on every pass
(`disagree.record`), from the latest stored prices and books of every open race listed on both venues and of the
season's champion markets; the Markets page then shows the log's latest tick ([Web app](webapp.md#kalshi)).
`markets archive` (and the recorder's hourly pass) archives Kalshi's rows too, into `data/archive/markets/kalshi/`
([Data](data.md#exchange-history-kalshi-and-polymarket)). No `record` or links export for Kalshi yet. Pulling a season: `sync --year Y --closed`,
then `trades` and `history --period 60` per modeled event ticker, from each market's `open_time` to its `close_time`.
Orders (`markets/kalshi/trade.py`): post-only limit orders on YES, checked against the book, the 1-cent grid
and `KALSHI_MAX_ORDER_USD` (default 25), signed with RSA-PSS (`KALSHI_API_KEY_ID`, `KALSHI_PRIVATE_KEY_PATH`),
and sent only when `KALSHI_TRADING_ENABLED=true`; otherwise a dry run that returns the request. No CLI.
#### OG.com and other schema exchanges (`--exchange og`)
An exchange defined by a file in `exchanges/` goes through one generic driver
(`markets/exchange_driver.py`); the commands and the fair-price indicator are in [Exchanges](exchanges.md).
```
racinglines markets --exchange og [--sport f1|nascar|sailgp] sync | trades | history --start UTC [--end UTC] | books | fair
racinglines markets --exchange og [--sport f1|nascar|sailgp] settle [--since UTC] [--max-pages N]
racinglines markets --exchange og [--sport f1|nascar|sailgp] settle --check # read-only: = settle-check
racinglines markets --exchange og --sport f1|nascar|sailgp buy-all [--cost 0.01] [--fee USD] [--out FILE.csv] # debug
```
`settle` writes the outcomes of the sport's closed, unresolved links from the exchange's settlement feed
(`[endpoints.settlements]` in its schema; OG.com's `get-expired-settlement-price`, since its listing drops a settled
instrument): `resolved_yes`, plus `params.settled_at`; a void (a 0.50 settlement) is noted in `params.settlement` and
left unresolved. Bounded (`--max-pages`, default the schema's 500) and resumed where the last pass stopped; a schema
with no feed does nothing. The VM recorder runs it hourly, every day
([Exchanges](exchanges.md#outcomes-the-settlement-feed)).
`settle --check`, or `racinglines markets --exchange polymarket|kalshi|og [--sport S] settle-check [--out FILE.csv]` for
any exchange, is read-only: for every closed link of the sport that the exchange resolved, it settles the link from our
stored results (`markets/kinds.settle`, the one settlement the walk-forward, the replays and the private book use) and
compares it with `resolved_yes`. It prints counts of agree / disagree / undecided (with the reason: no result yet, a kind
the results don't decide, a cancelled race, a biggest mover without a stored grid) / unmodeled / void, per kind, then
every disagreement, and exits 1 when there is one ([`markets/settle_check.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/markets/settle_check.py)).
OG.com is also a **replay venue**: `racinglines strategy replay nascar --venue og` (and `--venue all`, where `exchanges/og.toml`
lists the sport) and `f1 season-strategy --venue og` read its stored minute prices, trades and book quotes whatever their
spread or depth, dropping a stored 0.50 as an empty book's midpoint, at the schema's flat fee per contract
($0.02, unverified).
`buy-all` (read-only) buys one YES and one NO of every market the exchange lists for the sport, modeled or not, at the
first price stored, holds the pair and settles or marks it ([Exchanges](exchanges.md#debug-buy-one-of-everything)).
**It is an operator's plumbing check, not a result:** a pair loses exactly its costs and fees, so the P&L only shows
that each market was found, priced and valued. It is never shown in the web app or in reporting, and it never counts as a
backtest. The same holds for the race replays' `--buy-all` / `RACINGLINES_BUY_ALL=1` mode above.
**Alert channels** (`racinglines/markets/alerts.py`, used by new-market alerts and by
`signals`): a macOS notification, one JSON line per event in
`data/runs/alerts/new_markets.jsonl`, plus these when set:
| Variable | Meaning |
|---|---|
| `RACINGLINES_NTFY_TOPIC` | Phone push via ntfy.sh on this topic (sends market titles to ntfy's public server: pick an unguessable name). |
| `ALERT_WEBHOOK_URL` | JSON POST (Slack and Discord incoming webhooks work). |
| `RACINGLINES_URL` | Link in notifications (default `https://racinglines.bet`). |
#### Running the recorder persistently
Polymarket has no historical order-book API, so `markets record` must run
continuously, and `f1 signals` runs every few minutes through race weekends. On
macOS both run as LaunchAgents; the plists are in `scripts/` (paths inside are
this machine's repo path: edit them for another checkout).
| Agent | Runs | Schedule | Log |
|---|---|---|---|
| `scripts/bet.racinglines.recorder.plist` (`bet.racinglines.recorder`) | `racinglines markets record --interval 60` | at login, restarted if it exits (`KeepAlive`) | `data/runs/logs/record.log` |
| `scripts/bet.racinglines.signals.plist` (`bet.racinglines.signals`) | `racinglines strategy signals f1` | at login and every 300 s (`StartInterval`); outside a race weekend each run only notes when signals start | `data/runs/logs/signals.log` |
```
cp scripts/bet.racinglines.recorder.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/bet.racinglines.recorder.plist # install + start
launchctl print gui/$(id -u)/bet.racinglines.recorder | grep -E 'state|pid' # status
launchctl bootout gui/$(id -u)/bet.racinglines.recorder # stop + remove
tail -f data/runs/logs/record.log
```
The same with `bet.racinglines.signals`.
### racinglines book
??? example "racinglines book -h"
```text title="Output"
usage: racinglines book [-h] [--demo | --remote | --local] [--mcp-url TOP_MCP_URL]
{render,new,map,price,settle,export,view,logout,config} ...
racinglines book FILE [options]: generic sportsbook lines and slips against our model and the prediction
markets (racinglines/books/slips.py, docs/sportsbook/slips.md). CLI only, outside the web app; read-only on the
database.
map resolve every line's legs to a race, athletes and a kind by exact keys; list what stays unmapped
price + the model's fair, the linked exchange price, the book's implied probability, EV against each
settle + each leg's and line's result from the stored classification, and the payout per unit staked
render one HTML page (and --png) of the priced board: hud (on the book's screenshot), card, or agnostic (model
and exchanges only), with stakes from markets/books.toml (docs/sportsbook/render.md)
new a book file draft from loose text ("A over B 1.57", "A vs B 1.57 2.25", "Name 4.35"): --from-text
export the book's staked lines (a line's `stake`, `placed_utc`) priced and settled, as ledger rows:
`--ledger OUT.csv` (racinglines/books/ledger.py, docs/sportsbook/slips.md#ledger-export); refuses an
existing OUT unless --force, never appends
view render a saved --json result of map, price or settle with Rich (no database)
logout forget the browser sign-in that --remote keeps
config show or set the CLI's defaults (racinglines/cli/config.py): `config`, `config KEY VALUE`, `config KEY --unset`
Where the data comes from (map, price, settle, export), three modes; the default is the defaults file's `source`, else local:
--local a local database (--db, else DATABASE_URL, else the defaults file's `db`): you rebuild the
data or run the app and its MCP server yourself
--demo [PACK] a demo store (racinglines/cli/source.py): a separate local database of synthetic data from a
data pack (default kart), built on first use, priced by the same code as --local. FILE
defaults to the pack's book (racinglines/demo/packs/kart/book.toml); `racinglines model
forecast kart --demo --save` first gives it model prices. Rich by default.
`--demo [PACK] --remote` (or the defaults file's source = remote) prices it on the pack's
hosted demo store instead (its [remote] mcp_url; synthetic data only)
--remote the VM's hosted MCP server (racinglines/cli/remote.py): live model runs and exchange prices.
Signs in through the browser the first time (kept in ~/.config/racinglines/mcp-login.json),
or uses RACINGLINES_MCP_TOKEN (an rl_ token) when set. --mcp-url URL, RACINGLINES_MCP_URL or
the defaults file's `mcp_url` point it at your own server (default
https://mcp.racinglines.bet/mcp)
Options (map, price, settle, export):
--json the whole result as JSON (stable keys: docs/sportsbook/slips.md#json-output)
--rich a coloured table (Rich; racinglines/cli/rich_view.py): the default; --plain (any command) or
RACINGLINES_PLAIN=1 prints the plain text
--db URL the database (default DATABASE_URL)
price and export:
--run ID price legs of that run's competition from it (repeatable; default: the app's own choice
per race, markets/venues.pricing_run)
--sims RACE_ID=FILE an OutcomeSims archive (.npz, models/outcomes.save_sims) for a race: legs on that race are
priced jointly from it (repeatable)
price:
--asof UTC price as it stood then (follow a book through a weekend): the newest model run cut off by
then and each exchange's last tape price by then, no bid/ask (local only)
render (FILE = a book file, priced like `price`; or a cycling price output folder or CSV; or leave it out with
--json-in):
--style hud|card|agnostic --image PNG --out PATH.html --png --chrome PATH --json-in PRICED.json
--bank --kelly --cap --min-ev --scale --drop-under override markets/books.toml [stake]
new:
--from-text TXT --venue CODE --event KEY --odds FORMAT --out FILE.toml [--sport CODE] [--currency USD]
positional arguments:
{render,new,map,price,settle,export,view,logout,config}
render An HTML page (and PNG) of the priced board: hud, card or agnostic.
new A book file draft from loose text lines.
map Resolve each line's legs by exact keys.
price Model fair, market price, book implied probability and EV per leg and per
slip.
settle Results and payouts from the stored classification.
export The staked lines, priced and settled, as ledger rows (--ledger OUT.csv).
view Render a saved --json result with Rich (no database).
logout Forget the browser sign-in --remote keeps.
config Show or set the CLI's defaults (source, mcp_url, db).
options:
-h, --help show this help message and exit
--demo as `--demo` after the command
--remote as `--remote` after the command
--local as `--local` after the command
--mcp-url TOP_MCP_URL
as `--mcp-url` after the command
```
Generic sportsbook lines and slips (singles and parlays across F1, NASCAR and MotoGP) priced against the model and the
linked prediction markets, read-only and CLI only: `map FILE` (exact-key mapping of every leg to race, athletes and
kind; unmapped legs listed), `price FILE [--run ID] [--sims RACE_ID=FILE]` (model fair, market price, book implied
probability, EV against model and against market; same-race legs jointly from simulations or flagged
`correlated: EV approximate`), `settle FILE` (results and payouts from the stored classification); `--json` on each. `export FILE --ledger
OUT.csv [--force]` writes the staked lines (a line's `stake`, `placed_utc`), priced and settled, as ledger rows
([Ledger export](sportsbook/slips.md#ledger-export)).
Three modes: `--demo [PACK]` (a local demo store of synthetic data), `--remote` (the hosted MCP server; in testing, it prints
the #aintcare warning), `--local` (the default);
`book config` sets the default, `book logout` forgets the `--remote` sign-in. See [Sportsbook slips](sportsbook/slips.md).
On the kart demo store (`racinglines book map|price|settle --demo`, the pack's book): [Quickstart: Price a
sportsbook's lines](quickstart.md#price-a-sportsbooks-lines) shows all three with their output.
### racinglines weather
??? example "racinglines weather -h"
```text title="Output"
usage: racinglines weather [-h] {probe,fetch,show,leads,backtest} ...
racinglines weather : weather forecasts for an event, in the provider-agnostic forecast frame
(racinglines/weather/; docs/weather.md). --provider picks the source: open_meteo (the default, no key) or
wunderground (optional, unverified, a PWS owner's key in $WUNDERGROUND_API_KEY).
probe Fetch the daily and hourly forecasts for --lat/--lon and write the responses untouched (any key left out)
to --out (default data/raw/weather//probe-.json); lists every declared field the
response doesn't have (exit 1 when any is missing).
fetch Fetch, parse and save one issue for an event key (data/weather//), the raw response next to
the probe's; print the summary per session. --session race=START/END (UTC, repeatable) labels the
hourly rows inside that window; with a race window (open_meteo) it also saves the race's wet vote
(weather/wet.py: seven global models, wet-.json), which the live book's race_rain and
race_red_flag props read.
show Load the event's latest saved issue and print the summary per session, the daily rows and the wet vote.
leads For each race in --races (a CSV: race_id, event_key, venue_slug, race_start_utc; default every F1 race in
the local database), the forecasts Open-Meteo issued 0 to 7 days before its window, per global model, to
--out (one row per race, lead and model; open_meteo.LEAD_COLUMNS). Raw responses under
data/raw/weather/open_meteo/leads/. Leads 1-7 exist from about February 2024; lead 0 earlier.
backtest The 5-day (--lead) wet-race forecast against every race since February 2024, walk-forward: rain,
red flag, safety car and DNF count per method, the lead-time table, wet vs dry correlations. Reads the
local database (or --history CSV: weather.wet.races()'s columns) and the leads CSV (--leads; default the
committed fixture tests/fixtures/weather/open_meteo-leads-f1.csv). --out writes the per-race CSV.
Nothing here runs by default, writes to the database, or touches the VM. The event file has no location yet, so
--lat/--lon are given by hand.
positional arguments:
{probe,fetch,show,leads,backtest}
probe fetch once and write the raw response (the Mac, before anything else)
fetch fetch, parse and save one forecast issue for an event
show print the latest saved issue's summary per session
leads past races: the forecasts issued 0-7 days before each (Open-Meteo archive)
backtest the wet-race forecast's walk-forward backtest (rain, red flag, SC, DNF)
options:
-h, --help show this help message and exit
```
Weather forecasts per event in one provider-agnostic frame: `probe --lat --lon [--out]`, `fetch --lat
--lon [--session race=START/END]`, `show `, `leads --out [--races ]` (the forecasts issued 0 to 7
days before past races), `backtest [--lead 5] [--history ] [--leads ]` (the wet-race forecast against rain,
red flag, safety car and DNFs). `--provider open_meteo` (the default, no key, verified by
the probe of 2026-10-06) or `wunderground` (optional, unverified, a PWS owner's key). See
[Weather forecasts](weather.md).
| Variable | Default | Meaning |
|---|---|---|
| `WUNDERGROUND_API_KEY` | โ | Weather Underground (The Weather Company API) key, a PWS owner's key; read by `weather probe` and `weather fetch` with `--provider wunderground` only. |
### racinglines db
??? example "racinglines db -h"
```text title="Output"
usage: racinglines db [-h] [--db DB]
{init,seed,stats,export,snapshot-export,snapshot-import,merge-athletes,changes} ...
racinglines db
init Create or upgrade the schema to the latest migration (Alembic), then seed reference data.
seed Upsert sports / leagues / competitions / categories / venues from registry.py.
stats Row counts and coverage per season and category.
export Write the tidy frame (same columns as `racinglines data parse mtb_dh`'s CSV) for a competition to CSV.
snapshot-export The tables the models read, with ids, to data/archive/db/ (Parquet).
snapshot-import Load that snapshot into a fresh database: an exact replica (same ids, same prices).
merge-athletes Merge two athletes that are the same person (e.g. a name change found by UCI ID).
changes The data change log: ingests that changed anything, merges, notes (--add "why" for a note).
Connection: $DATABASE_URL, or --db URL (default: docker-compose.yml's database).
positional arguments:
{init,seed,stats,export,snapshot-export,snapshot-import,merge-athletes,changes}
init Migrate schema to latest and seed reference data.
seed Upsert reference data from registry.py.
stats Row counts and coverage.
export Write the tidy frame for a competition to CSV.
snapshot-export Model tables with ids -> data/archive/db/.
snapshot-import Load data/archive/db/ into a fresh database.
merge-athletes Merge athlete DROP into KEEP (same person), then delete DROP.
changes The data change log, newest first; --add "why" adds a note.
options:
-h, --help show this help message and exit
--db DB Database URL (default: $DATABASE_URL or the docker-compose database).
```
Database commands. See [Database](database.md).
```
racinglines db [--db URL] init # alembic upgrade head + seed reference data
racinglines db [--db URL] seed # re-apply racinglines/db/registry.py
racinglines db [--db URL] stats # table counts + coverage per season and category
racinglines db [--db URL] export [--competition uci_dhi_wc] [--out splits.csv]
racinglines db [--db URL] snapshot-export # model tables with ids -> data/archive/db/
racinglines db [--db URL] snapshot-import [--force]
racinglines db [--db URL] merge-athletes KEEP DROP [--dry-run]
racinglines db [--db URL] changes [--add TEXT] [--sport mtb_dh] [--limit 20]
```
| Command | What it does |
|---|---|
| `init` | Create or upgrade the schema to the latest migration (Alembic), then seed reference data. |
| `seed` | Upsert sports, leagues, competitions, categories and venues from `registry.py`. |
| `stats` | Row counts and coverage per season and category. |
| `export` | The tidy frame for a competition (same columns as `mtb_dh parse`'s CSV) to CSV. |
| `snapshot-export` | The tables the models read (race data and market links, with their ids) to `data/archive/db/.parquet` plus `manifest.json`. Never web-app tables or model runs. |
| `snapshot-import` | Load that snapshot into a fresh database (schema already at head): an exact replica, same ids, so seeded prices are identical. Refuses a database that already holds model runs unless `--force`. See [Database](database.md#snapshot-an-exact-replica). |
| `merge-athletes` | Merge athlete `DROP` into `KEEP` (the same person, e.g. a name change that downhill ingest reports once files carry UCI IDs): every table referring to athletes moves to `KEEP`, then `DROP` is deleted. Refuses, changing nothing, if a row would collide (both in the same round). `--dry-run` only reports. It changes the downhill history the model sees, so it's the owner's call. Logged in the data change log. |
| `changes` | The [data change log](data-changes.md), newest first: every ingest that changed data, every merge, and notes (`--add "why"`). |
`db stats` on the kart demo store (the first `--demo` command, so it builds the store first):
=== "Command"
```bash title="Command"
racinglines db stats --demo
```
=== "Output"
```text title="Output"
demo: building the kart pack's store (no database): database racinglines_demo_kart, folder
/home/user/racinglines-public/data/demo/kart. Synthetic data, made once (a few minutes).
๐ง demo store 0/7 creating the database
๐ง demo store 1/7 schema and reference data (alembic upgrade head, seed)
๐ง demo store 2/7 the pack's data files
๐ง demo store generating: 2024 R01 Mario Kart Stadium
๐ง demo store generating: 2025 R01 Mario Kart Stadium
๐ง demo store generating: 2026 R01 Mario Kart Stadium
๐ง demo store generating: files written
๐ง demo store generating: exchange odds 2026-09
๐ง demo store generating: exchange odds 2026-10
๐ง demo store generating: exchange odds 2026-11
๐ง demo store 3/7 racinglines data ingest f1 --years 2024-2026
๐ง demo store 4/7 racinglines model forecast f1 --save --sims 2000
๐ง demo store 5/7 racinglines data pm-links-import f1
๐ง demo store 6/7 racinglines data pm-links-import f1 --exchange kalshi
๐ง demo store 7/7 ready
demo: the kart store is ready (f1 data; `racinglines db stats --demo kart`) Let's-a go! ๐
โญโ ๐ฆ database racinglines_demo_kart โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
โ ๐
calendar ๐ results ๐ฎ model โ
โ sports 8 athletes 20 model runs 1 โ
โ leagues 8 athlete identifiers 20 race predictions 60 โ
โ competitions 8 results 3,300 standings predictions 20 โ
โ categories 11 splits 0 โ
โ venues 27 source files 33 โ
โ events 36 โ
โ races 36 โ
โ rounds 165 โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
coverage
competition year category events rounds results
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
f1_wdc 2024 DRV 12 60 1,200
f1_wdc 2025 DRV 12 60 1,200
f1_wdc 2026 DRV 9 45 900
```
### racinglines live
??? example "racinglines live -h"
```text title="Output"
usage: racinglines live [-h] {new,step,run,auto,agent,status,report,reprice,settle} ...
racinglines live: launch and run a live private-book event, for any sport (pipelines/live.py).
racinglines live new f1 2026-16 # write live/f1/2026-16.toml from the schedule
racinglines live new mtb_dh 20260925_mtb --final 3 --quali 2,91
racinglines live step live/f1/2026-16.toml # one idempotent update (F1: acts only when an update is due)
racinglines live run live/f1/2026-16.toml # step at the sport's cadence until the event is settled
racinglines live run live/mtb_dh/20260925_mtb.toml # downhill: the poll loop, locked (one loop per final)
racinglines live run live/f1/2026-15.toml --simulate --no-fetch # a rehearsal on a simulated clock
racinglines live run kart/demo-2026-09 --demo --simulate --no-fetch # the kart demo pack's rehearsal (docs/quickstart.md)
racinglines live agent live/f1/2026-16.toml --install # a macOS LaunchAgent: one step every 5 minutes, locked
racinglines live auto # F1: step every event whose window is open (a 5-min timer)
racinglines live status # every event: live / replay / settled, last update, lateness
racinglines live report live/f1/2026-16.toml # the event report (Markdown + charts + PDF)
racinglines live settle live/mtb_dh/20260925_mtb.toml # record a settled event in the database (live_events)
racinglines live reprice live/mtb_dh/20260925_mtb.toml --every 10 # re-price from the logged raw feed
A spec is a path or sport/event (live//.toml). A demo experiment: play money, nothing is
traded anywhere.
positional arguments:
{new,step,run,auto,agent,status,report,reprice,settle}
new Write a launch spec from the schedule, with the sport's defaults.
step One idempotent update.
run Step at the sport's cadence until the event is settled (downhill: one
locked poll loop per final).
auto F1: one step of every committed spec whose window is open (from its
opening to a day after its results), so no event has to be started by
hand.
agent Write (and load) a macOS LaunchAgent for a spec: a step every 5 min,
locked, logged.
status Every event: live / replay / settled, last update, next update, lateness.
report The event report: Markdown, charts and a PDF, in the run folder.
reprice Re-price an event from its logged raw feed with this code (downhill);
scores it against what was quoted live.
settle Record a settled event in the database (live_events: dates, the book's
P&L).
options:
-h, --help show this help message and exit
```
Launch and run a live private-book event for any sport, from a launch spec (`live//.toml`):
`new`, `step`, `run`, `agent`, `status` and `report`. The commands, options and the F1 engine are in
[Live events](live-events.md#running-an-event-racinglines-live).
```
racinglines live new f1 2026-16
racinglines live step live/f1/2026-16.toml
racinglines live agent live/f1/2026-16.toml --install
racinglines live status
```
The kart pack's rehearsal (`racinglines live run kart/demo-2026-09 --demo --simulate --no-fetch`) and its report are
run step by step in the [Quickstart](quickstart.md#run-the-quickstart-the-kart-pack); afterwards `live status` lists
the settled event:
=== "Command"
```bash title="Command"
racinglines live status --demo
```
=== "Output"
```text title="Output"
demo-2026-09 f1 settled last 2026-10-04T17:00 (0.1 h ago) Bowser's Castle Grand Prix 2026 (synthetic)
```
### racinglines web
The web app. See [Web app & trading](webapp.md).
```
ADMIN_USERNAME=admin ADMIN_PASSWORD=... racinglines web # http://127.0.0.1:8000
cloudflared tunnel --no-autoupdate --url http://127.0.0.1:8000 # optional public HTTPS URL
```
| Variable | Default | Meaning |
|---|---|---|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | `admin` / random, printed at start | Login for every page (sign-in page or HTTP Basic). |
| `APP_SECRET` | random per start | Signs session cookies and CSRF tokens. Set it to keep sessions across restarts. |
| `WEB_HOST` / `WEB_PORT` | `127.0.0.1` / `8000` | Listen address. |
| `WEB_RELOAD` | off | `1` = auto-reload on code changes (development). |
| `DATABASE_URL` | local DB on port 5433 | Database connection. |
| `POLYMARKET_*` | โ | Exchange credentials and limits; see [Web app & trading](webapp.md#polymarket). |
### racinglines users
Web-app accounts and the fantasy-bucks ledger from the command line (`racinglines/web/accounts.py`). Passwords are
never read back: they are stored as scrypt hashes, and `add` and `reset-password` print a one-time password exactly
once.
=== "Command"
```bash title="Command"
racinglines users -h
```
=== "Output"
```text title="Output"
usage: racinglines users [-h]
{setup,list,add,reset-fantasy,season-demo,reset-password,remove,deactivate,activate} ...
racinglines users -> web-app accounts and the fantasy-bucks ledger from the command line (web/accounts.py).
racinglines users setup the accounts schema, ledger and manual-trading tables
(idempotent; fantasy/ledger.py); every active account
without its 1,000 signup grant gets it
racinglines users list accounts, role, active, fantasy bucks
racinglines users add NAME --role basic|pro|admin a new account; prints a one-time password
racinglines users reset-password NAME a new one-time password, printed once
racinglines users remove NAME delete an account with no markets or bets (else deactivate)
racinglines users deactivate NAME | activate NAME
racinglines users reset-fantasy [--keep maker,taker] [--apply]
every active account but --keep back to one 1,000 grant and no
trading history (paper positions, signals, house bets) and no
strategy profile; counts
only without --apply (on the VM: scripts/vm/reset_fantasy.sh)
racinglines users season-demo [--seed N] [--year 2026] [--apply]
every fantasy player follows a random alpha style at a random
weekend share and follow rate through the season so far, replayed
on the real tapes (fantasy/season_demo.py); a database whose name
contains "demo" only; the draw only without --apply (on the VM:
scripts/vm/season_demo.sh)
Passwords are never read back: they are stored as scrypt hashes, and a reset prints a fresh one exactly once.
positional arguments:
{setup,list,add,reset-fantasy,season-demo,reset-password,remove,deactivate,activate}
options:
-h, --help show this help message and exit
```
### racinglines mcp
The MCP server: the app's data and simulations for a chat client (Claude Desktop, Claude Code, any MCP
client). See [MCP server](mcp.md).
```
racinglines mcp # stdio: the client launches it as a subprocess
racinglines mcp --http # streamable HTTP on 127.0.0.1:8100/mcp; each request needs an account's token
racinglines mcp token admin [--revoke] # issue (printed once) or revoke an account's token; `token` alone lists holders
```
| Option | Meaning |
|---|---|
| `--db URL` | Database URL (default `$DATABASE_URL`, else the local one). |
| `--http` | Serve streamable HTTP instead of stdio. Every request must carry a bearer token of an active, non-demo account whose role is in `RACINGLINES_MCP_ROLES` (default `admin,pro`): one from OAuth sign-in (signed with `APP_SECRET`; [MCP server](mcp.md#hosted-the-vm)) or an `rl_` token. Pro account records and jobs are scoped to the caller; SQL and audit tools are admin-only. |
| `--host` / `--port` | Listen address in `--http` mode (default `127.0.0.1` / `8100`). |
| `--no-jobs` | Don't run the Lab's job worker in this process: queued jobs wait for the web app's worker. |
=== "Command"
```bash title="Command"
racinglines mcp -h
```
=== "Output"
```text title="Output"
usage: racinglines mcp [-h] [--db DB] [--http] [--host HOST] [--port PORT] [--no-jobs]
racinglines mcp the MCP server over stdio (docs/mcp.md)
racinglines mcp --http [--port 8100] streamable HTTP on 127.0.0.1:8100/mcp; each request needs an account's token
racinglines mcp token ACCOUNT [--revoke] issue (print once) or revoke an account's bearer token; `token` alone lists holders
options:
-h, --help show this help message and exit
--db DB database URL (default $DATABASE_URL, else the local one)
--http serve streamable HTTP (per-account bearer tokens) instead of stdio
--host HOST
--port PORT
--no-jobs don't run the Lab's job worker here (leave queued jobs to the web app)
```
### racinglines remote
Any tool on the hosted server's MCP server from the command line, signed in as for `--remote` (in testing: every
call prints the #aintcare warning). `tools` lists what the account may call, `call TOOL --ARG VALUE` calls one and
prints its JSON (`--csv FILE` writes its rows), `job JOB_TYPE --KNOB VALUE` queues a Lab job and follows its log
([MCP: More commands on the hosted server](mcp.md#more-commands-on-the-hosted-server-remote-racinglines-remote-data-pull)).
It needs racinglines.bet, so only its help is shown:
=== "Command"
```bash title="Command"
racinglines remote -h
```
=== "Output"
```text title="Output"
racinglines remote (the hosted server's MCP tools, signed in as for --remote)
tools the tools this account may call, with their arguments
call TOOL [--ARG VALUE ...] call one tool and print its JSON; --csv FILE writes its rows instead
e.g. racinglines remote call get_market_history --race-id 812 --series trades
racinglines remote call list_live_events
racinglines remote call replay_maker --run-id 10833 --half-spread 0.03
racinglines remote call fantasy_strategy
racinglines remote call fantasy_follow --code T2 --sizing kelly --kelly 0.5
job JOB_TYPE [--KNOB VALUE] queue a Lab job and follow its log (list_job_types names them; --set NAME=VALUE
for a sweep setting), e.g. racinglines remote job f1_sweep --year 2025
logout forget the stored sign-in
--mcp-url URL the server (default RACINGLINES_MCP_URL, else https://mcp.racinglines.bet/mcp)
```
**Follow a strategy from the terminal.** There is no separate verb: `remote call` reaches the two MCP tools behind
`/strategy/pick` ([MCP](mcp.md), `fantasy/follow.py`), on your own account, with the page's gates (closed from lights
out until a day after the race, demo accounts refused). Arguments are typed from the server's schema, so `--kelly 0.5`
and `--min-edge 0.03` arrive as numbers and `--rolled true` as a boolean.
```bash
racinglines remote call fantasy_strategy # what you follow, the five styles, the lock
racinglines remote call fantasy_follow --code T2 --sizing kelly --kelly 0.5 # follow Head start at half Kelly
racinglines remote call fantasy_follow --code T1 --min-edge 0.12 # Steady, tweaked (untested, scored live)
racinglines remote call fantasy_follow --code none # stop: nothing trades until you pick again
```
A refusal prints `"ok": false` with its `reason` and the page's message.
### racinglines tui
The web app's tabs in the terminal over one MCP session: the hosted server, signed in as for `--remote` (in testing:
the #aintcare warning), or `--local` for this machine's database through `racinglines mcp` over stdio. See
[Terminal app](tui.md).
=== "Command"
```bash title="Command"
racinglines tui -h
```
=== "Output"
```text title="Output"
usage: racinglines tui [-h] [--local] [--db DB] [--no-jobs] [--mcp-url MCP_URL] [--user USER]
[--timeout TIMEOUT]
racinglines tui the web app's screens in the terminal, on the hosted server (docs/tui.md)
racinglines tui --local [--db URL] the same on this machine's database: `racinglines mcp` over stdio
Hosted sign-in is `--remote`'s (cli/remote.py): RACINGLINES_MCP_TOKEN, else the browser once, kept in
~/.config/racinglines/mcp-login.json. The sign-in happens here, before the screen opens, so its URL is readable.
RACINGLINES_MCP_URL the hosted server (default https://mcp.racinglines.bet/mcp); --mcp-url overrides it
RACINGLINES_USER the account preselected on the Paper tab; --user overrides it
options:
-h, --help show this help message and exit
--local this machine's database through `racinglines mcp` (stdio)
--db DB with --local: the database URL (default $DATABASE_URL, else the local one)
--no-jobs with --local: don't run the Lab's job worker here (leave queued jobs to the
web app)
--mcp-url MCP_URL the hosted server (default RACINGLINES_MCP_URL or racinglines.bet)
--user USER account preselected on the Paper tab
--timeout TIMEOUT seconds to wait for one tool call (default 90)
```
### racinglines check
Quick validation for new setups: the pipelines on synthetic data, one tiny request
to each data source, and the database. `--sport f1|mtb_dh` (default both),
`--offline` skips the endpoints, `--no-db` the database. See [Testing](testing.md).
??? example "racinglines check -h"
```text title="Output"
usage: racinglines check [-h] [--sport {f1,mtb_dh}] [--offline] [--no-db]
racinglines check: quick validation for new setups (about a minute, no data downloaded).
code the pipelines on small synthetic data (no network, no database)
endpoints one tiny request to each data source (FastF1, ChronoRace, Polymarket)
database Postgres reachable and migrated (skipped with --no-db)
Options: --sport f1|mtb_dh (default both) --offline (skip endpoints) --no-db
The full regression suite on real data: docs/testing.md.
options:
-h, --help show this help message and exit
--sport {f1,mtb_dh} Default: both.
--offline Skip the data-endpoint checks.
--no-db Skip the database check.
```
Offline, against the local database:
=== "Command"
```bash title="Command"
racinglines check --offline
```
=== "Output"
```text title="Output"
code (synthetic data)
โ f1 code load + measurements 0.3s 6 events, 7,485 laps, 360 practice rows
โ f1 code as-of leakage guard 0.0s sessions only usable after they end
โ f1 code training history 0.6s 120 training rows
โ f1 code race pricing 1.2s favourite Driver 01 36%
โ f1 code backtest 0.6s 6 race x mode rows, mean win Brier 0.032
โ f1 code season forecast 0.3s leader Driver 01 71%
โ markets code maker replay 0.0s 172 fills, P&L +72.56
โ markets code weekend taker 0.0s 46 trades, P&L +300.15
โ markets code season strategy 0.2s 39 trades, P&L -93.55
โ mtb_dh code parse results tables 0.1s 1920 rows, 60 riders, rounds ['final', 'practice', 'qual1']
โ mtb_dh code season model 0.2s sigma 0.0096, 60 riders
โ mtb_dh code season forecast 0.2s leader 69%
โ mtb_dh code backtest 0.2s 1 held-out round(s) scored
database
โ database Postgres 0.2s localhost:5433/racinglines, schema up to date
14/14 checks passed
```
## F1 commands
Formula 1. See [Formula 1](f1.md).
Options every F1 command takes (anywhere after `f1`):
| Option | Meaning |
|---|---|
| `--db URL` | Database URL (default: `$DATABASE_URL`, else the local one). |
| `--variant NAME` | Model variant from `racinglines/models/variants.py`: `baseline` (default), `grid`, `gridq`, `pretrain`, `gbm`, `tail`, `reset`, or a combination such as `gridq+pretrain+reset`. Saved runs record it; the sweep and season strategy reuse only stored pricing from the same variant. The web app's live prices use baseline runs. |
| `--half-life DAYS` | Recency half-life of the pace models (default `inputs.laps.HALF_LIFE_DAYS`). |
### Data
```
racinglines data fetch f1 [--years 2026,2025,โฆ] [--sessions Q,S,R] [--rounds 6-15] [--sprints-from 2026] [--force]
racinglines data ingest f1 [--years 2020-2026] [--force]
racinglines data schedule f1 [--through 2026-11-30] [--apply --backup FILE] [--undo --backup FILE]
```
| Command | What it does |
|---|---|
| `fetch` | Download sessions from the F1 live-timing archive (FastF1) into `data/raw/f1/fastf1//`, newest seasons first (default `--years` 2026 back to 2020). `--sessions` takes `Q,S,R` (default) and/or practice `FP1,FP2,FP3,SQ`; `--rounds` limits to some rounds. `--sprints-from YEAR` fetches sprint races from that season on (default 2026). Sessions already on disk are skipped (`--force` re-downloads). Waits out FastF1's 500 calls/hour limit. Saves each season's schedule (`schedule.parquet`). |
| `ingest` | Load `data/raw/f1/fastf1/` into the database: events, results, laps, track profiles. Unchanged events are skipped (`--force` reloads). |
| `schedule` | Store the UTC starts of sessions still to run (FastF1's event schedule; NASCAR: the stored race list's `schedule` entries with `start_time_utc`) as scheduled rounds with no results (`racinglines/db/schedule.py`), so a market on an upcoming session has a close (`pipelines/clv.closes`). Events up to `--through` (default 30 Nov) that are stored and not completed; a round the ingest wrote is kept, a moved session is updated, nothing is guessed. A dry run unless `--apply --backup FILE`; `--undo` removes the scheduled rounds. The F1 ingest rebuilds a weekend's rounds as each session ends but keeps the scheduled ones still to come (`db/schedule.clear_for_ingest`), clearing them once the race is in; on the VM `vm.sh schedule` reruns it every 6 hours and writes only when a session is added or moved. |
### Pricing and evaluation
```
racinglines model backtest f1 [--variant NAME] [--half-life DAYS] [--start-year 2021] [--races N] [--track both|on|off] [--no-track] [--sims 4000] [--out CSV] [--save]
racinglines model compare f1 BASELINE_RUN CHALLENGER_RUN [--reliability]
racinglines model matrix f1 [--variants baseline,grid,gridq,pretrain,gbm,tail,gridq+pretrain] [--year 2026] [--out MD]
racinglines model diagnostic f1 [--variant NAME] [--half-life DAYS] --event 2026-15 --cutoff 2026-09-25T13:30 [--sims 10000] [--no-track] [--calib-power P] [--ridge-team 4 ...] [--save]
racinglines model forecast f1 [--half-life DAYS] [--year 2026] [--sims 10000] [--no-track] [--save [--scenario LABEL]] [--top 10]
racinglines model props f1 EVENT [--run STAGE_RUN] [--prior-n 16] | racinglines model props f1 --check [--from 2022]
racinglines strategy scorecard f1 [--variant NAME] --event 2026-15 [--venue polymarket|kalshi|both] [--model-key KEY] [--out DIR]
racinglines strategy scorecard f1 [--variant NAME] --all --year 2025 [--rounds 1-12] [--venue polymarket|kalshi|both]
```
| Command | What it does |
|---|---|
| `backtest` | Walk-forward over races from `--start-year`, both before qualifying (grid simulated) and after (real grid), with track features on and off (`--track on` / `--track off`, or `--no-track`, for one). Writes one row per race and mode to `data/runs/f1/backtests/` (`--out`); `--save` stores `kind='backtest'`. `--races N` runs only the last N races. |
| `compare` | Pair two saved backtest runs by race: challenger โ baseline ยฑ 2 standard errors per metric and pricing stage. `--reliability` adds calibration bins and the calibration error (runs saved since 2026-09-27 keep each driver's probabilities). |
| `matrix` | The model ร strategy matrix: for each variant, the latest saved backtest (accuracy, marked where it differs from baseline beyond 2 SE) and the latest saved sweep and season strategy (P&L). Writes `data/runs/f1/matrix.md`. See [Model ร strategy matrix](market-making.md#model-strategy-matrix). |
| `diagnostic` | Price one past event as of `--cutoff` (UTC), with a leakage audit. `--save` stores `kind='diagnostic'`. See [Market making](market-making.md). It also takes the model settings of a sweep (`--ridge-team`, `--ridge-slope`, `--driver-prior-n`, `--half-life-days`, `--track-features`, `--practice-prior`, `--teammate-corr`, `--finish-rho-scale`, `--reset-weight`, `--seed`, and the global `--variant`), applied while it prices; a saved run records them as `params.model_settings`. The Lab job `f1_diagnostic` takes them in its `settings` knob: the Lab's Run form (under "Model settings"), `run_job` and the TUI's Model tab send them. |
| `forecast` | Live: cutoff = now; upcoming races and the championships. `--save` creates scheduled events for upcoming rounds and stores `kind='forecast'` (the only kind the web app uses for live fair prices). `--scenario LABEL` saves `kind='scenario'` instead, ignored by live prices until promoted in the web app's Lab. A save logs one `data_changes` row (`forecast`: the events, run id, `--variant`, sims, code version and data key), as ` forecast --save` does. |
| `scorecard` | The pricing scorecard of an exchange weekend, traded or not ([Paper trading](paper-trading.md#validation-plan), rule 2): at every stage cutoff (before any running, after each session), the stored stage runs' fair values for every linked market kind scored against the result and against the venue's mid at that cutoff (Brier and log loss; `n` markets, and `paired` where the venue also priced it). `--venue both` runs Polymarket and Kalshi; `--all --year` every raced weekend of a season, per weekend and pooled per kind and stage. Nothing is priced or stored: the stages must have been priced by `f1 sweep` or `f1 signals` with the same model settings (`--variant`, or `--model-key`). Writes `data/runs/f1/scorecard/_.md` / `.csv` / `_markets.csv` (or `_โฆ`). See [F1 evaluation](f1-evaluation.md#the-weekend-scorecard). |
| `props` | Race props for one event from the race history before it: safety car, red flag, rain (per-circuit rates shrunk to the field rate); `--run` adds fastest-lap prices from a stored stage run's finishing odds. `--check` scores the yes/no props walk-forward from `--from` against the field rate and a coin flip. Nothing is stored. See [F1 live test](f1-live-roadmap.md#props-opt-in). |
### Trading research
```
racinglines strategy sweep f1 [--variant NAME] [--year 2026] [--rounds 1-15] [--no-fetch | --fetch-only] [--reprice] [--reliability] [--save] [SETTINGS โฆ]
racinglines strategy season-strategy f1 [--venue {polymarket,kalshi,og}] [--year 2026] [--sims 5000] [--no-fetch] [--reforecast] [--min-edge 0.03] [--stake-per-edge 500] [--max-stake 150] [--capital 1500] [--save]
racinglines strategy season-strategy f1 --paper --venue {polymarket,kalshi} [--after-round N] [--user maker] [--no-fetch] [--save] [SIZING โฆ]
racinglines strategy season-checkpoints f1 [--variants V1,V2] [--year 2026] [--entries 0,3,6] [--window 3] [--sims 5000] [--out MD] [--save]
racinglines strategy replay f1 --runs 11,12,9 [--sweep]
racinglines strategy search f1 QUEUE.toml [--leaderboard]
racinglines strategy search-plan f1 INPUT.json [--budget 0] [--stop-after 0] [--best-score X] [--margin 0] [--dry-run]
racinglines strategy search-report f1 QUEUE.toml
racinglines strategy search-import f1 RESULTS.json
```
| Command | What it does |
|---|---|
| `sweep` | Trade every raced weekend of a season: price before any running and after each session, trade Polymarket, settle. Runs four taker modes (`update`, `hold`, `last`, `early`) and five maker settings side by side. See [Market making](market-making.md#season-sweep). Downloads the season's missing Polymarket history and trades first unless `--no-fetch`; `--fetch-only` only downloads (e.g. a past season). Stages are reused only when priced with the same model settings from the same data; `--reprice` prices them again. `--reliability` also scores calibration: our fair values and Polymarket's prices at every tradeable stage, per market kind and stage (Brier, log loss, ECE) and in reliability bins, written to `sweep__calibration.csv` / `_reliability.csv` (and saved with the run). `--save` stores `kind='sweep'`. `--venue kalshi` (the `venue` setting) trades Kalshi's markets instead: its `exchange='kalshi'` links, one market per ticker, its tape per market from `data/archive/markets/kalshi/` and the shared tables (nothing is downloaded), and Kalshi's maker fee ([Kalshi history](kalshi-history.md#in-the-signal-engine)). `--grid FILE.json` sweeps several settings in one process (a JSON list of `{"job": id, "settings": {...}}`; the search uses it), sharing what doesn't depend on the strategy; each result and saved run is the same as its own `sweep`. **Closing-line value:** after the season is traded, every taker mode's trades and every maker variant's fills get their CLV against the venue's price when each market closed, by the `backtest clv` rule (read from the database, nothing written); the totals print it next to P&L (`CLV +1.20% (+0.0042) ยท 61% positive of 812 decided`), the per-bet rows go to `sweep__clv_bets.csv`, and `--save` keeps the aggregate as `metrics.clv`: `by_strategy` (bets, decided, undecided, mean CLV in points and percent, share of decided bets with a positive CLV, mean move against the mid at entry), `by_stage` (per strategy, a row per stage then its `all` row), `by_event` (per strategy and event) and `undecided` (bets per reason). It adds to the run without changing it: no setting, nothing in the settings or model key, every other metric as before. A close that can't be read prints `CLV not computed` and saves `metrics.clv.error`; the sweep's own results stand. |
| `season-strategy` | The championship-market strategy through the season: as-of season forecasts pre-season and after every race, trades at Polymarket's recorded prices (plus spread and slippage), settles eliminated markets, marks the rest. `--reforecast` recomputes the cached forecasts. `--venue kalshi` or `--venue og` replays the same strategy on that exchange's stored tape instead (no fetch; Kalshi costed at its taker fee, OG.com at its flat $0.02 per contract with no volume floor and prices read as its replay venue reads them, an empty book's 0.50 dropped); Polymarket stays the default and its output is unchanged, and `--save` stores another venue's replay as kind `season_venue_replay` so no page reads it as Polymarket's. **`--paper`** is the championship sleeve (paper only, off unless given): replay through the decision after `--after-round` (default: the last raced round) on `--venue` (Polymarket's championship markets, or Kalshi's `KXF1` / `KXF1CONSTRUCTORS` champion markets by ticker, costed at the taker fee) and store that rebalance as the demo maker's (`--user`) paper positions under venue `season:`, event `-season`, replacing the previous store on that venue; the weekend records of A, C and K never read that venue. `--save` also stores the rebalance as a run of kind `season_sleeve`. See [Paper trading](paper-trading.md#validation-plan). |
| `season-checkpoints` | Championship markets entered at fixed points (pre-season, after 3 and after 6 grands prix; `--entries`) and held, one $500 book each, per model variant (`--variants`). Scores each entry over the next 3 GPs (`--window`) and to date: P&L, how far the market moved toward our fair value, and the share of our edge it closed. Writes `data/runs/f1/season_checkpoints.md`; `--save` stores kind `season_checkpoints`. See [Checkpoint entries](market-making.md#checkpoint-entries). |
| `replay` | Replay a maker quoting Polymarket through an event's diagnostic runs (ids in time order), with both fill rules, against the real trade tape. `--sweep` adds the half-spread and fill-rule sensitivity table. |
| `search QUEUE.toml` | Run a queue of season sweeps in parallel (`[search] parallel`, `hours`), each saved as a sweep run. The queue file (e.g. `sweeps/poc.toml`, `sweeps/params-4h.toml`) is re-read whenever a slot frees, so pending `[[job]]` entries can be edited while it runs. A job with `venue = "kalshi"` (or `"og"`) runs `sweep --venue kalshi` (the strategies on Kalshi's tape) and gets its own baseline on that venue; a job with `sport = "nascar"` (or `"motogp"`) and `kind = "sweep"` runs `racinglines strategy sweep ` the same way (any sport whose schema has a `[sweep]` table and lists `"sweep"` in `[search] jobs`); its id and title carry the venue, and a `[[candidate]]` may carry it too (e.g. `sweeps/kalshi-maker-k.toml`). `[search] grid = N` runs up to N sweeps of the same season and model in one process (`sweep --grid`: the measurements, stage pricings, markets and maker tape are read once; every saved run is what its own process would save), and `history_cache = true` keeps rating histories in `data/cache/history/`; `parallel` defaults to every core. Writes `data/runs/search//` (`leaderboard.md`, `results.json`, `state.json`, logs). `--leaderboard` only rewrites the leaderboard from `state.json`. See [Cloud sweeps](cloud-sweep.md). |
| `search-report QUEUE.toml` | Analyse a search's finished sweeps with the checks in [Backtest core](backtest-core.md#the-search-report): labels against the baseline in the target and held-out seasons beyond a noise floor (from seed replicates when the search ran them), same-fidelity baselines, confirmation at more simulations, P&L without the best event and without the best two, a shape label describing the record, not the risk (steady: still up without its best two events in every season; concentrated: up in every season but not without its best two in one; mixed: up in one season, down in another; losing: down in every season), and candidates with stable ids (`-`). An optional `[report]` table in the queue sets `target`, `holdout`, `confirm_sims`, `rank_sims` (the simulation count combos are ranked at; default the settings' 4,000), `top`, `noise`. Writes `stats.csv`, `ranking.csv`, `pnl_curves.json`, `candidates/`, `candidates.toml` and `report.md` next to `state.json`. |
| `search-import RESULTS.json` | Load a search's sweep runs (e.g. from a cloud session) into this database, marked with `params.source`. |
| `search-plan INPUT.json` | Code-only F1 next-run plan from a year-labelled snapshot of stored keys and candidate settings. Deduplicate by the unchanged `Settings.key`, preserve input order and cap at `--budget` (default 0). Optional completed-score history enables `--stop-after K` with `--best-score X` and `--margin M`. Prints JSON only; no sweep, database connection, file write or Jev call, with or without `--dry-run`. See [Planner input and stop semantics](cloud-sweep.md#code-only-next-run-planner-local). |
**Local auto-sweeper research CLI:** `.venv/bin/python scripts/research/auto_sweeper.py POOL.json`
is dry-run by default. `--execute --budget N --jev-budget N --backup PATH` opts into serial,
Jev-chosen sweeps with persistent cumulative budgets and an atomic resume journal. The supplied
finite pool uses exact settings identities; missing same-fidelity baselines and confirmations
also count against budget. See [Local Jev-guided auto-sweeper](cloud-sweep.md#local-jev-guided-auto-sweeper-research-mvp)
for the score, interruption/restart, retry and backup/rollback rules. This standalone MVP is not a
remote/MCP job or Lab control.
**Sweep settings.** Every setting of the schema in
`racinglines/pipelines/sweep_settings.py` is a `sweep` flag; the same schema drives the
Lab's Edge Finder form, search queues and each saved sweep's params. Defaults are the
baseline. The model variant is the group's `--variant`.
| Group | Flag | Default | Meaning |
|---|---|---|---|
| Model | `--sims` | 4000 | Simulations per stage. |
| | `--half-life-days` | 120 | Recency half-life of the pace models. |
| | `--track-features` | true | Match each car's fast-vs-slow sector profile to the track. |
| | `--practice-prior` | true | Use this weekend's practice laps once they exist. |
| | `--ridge-team` | 2.0 | Team pace shrinkage (events). |
| | `--ridge-slope` | 6.0 | Track-slope shrinkage (events). |
| | `--driver-prior-n` | 3.0 | Races before a driver's gap to the teammate is trusted. |
| | `--teammate-corr` | true | Teammates share noise. |
| | `--finish-rho-scale` | 1.0 | Teammate finish correlation scale. |
| | `--reset-weight` | 0.25 | With the `reset` variant: weight of earlier seasons' car data. |
| | `--seed` | not set | Monte Carlo seed. Unset = today's fixed seed (42), the same prices and cache keys. Different seeds give independent noise draws, e.g. to check a search's winner isn't one lucky draw. |
| Timing | `--taker-stages` | every stage (`pre-weekend` โฆ `after Quali`) | Stages takers may trade (every taker mode). |
| | `--late-stages` | `after FP3,after Quali` | Stages the stage-aware taker skips. |
| Taker | `--min-edge` | 0.05 | Min edge to act (probability). |
| | `--min-edge-h2h` | not set | Min edge for head-to-head markets; unset = `--min-edge`. |
| | `--min-edge-by-kind` | not set | Per market kind, e.g. `race_h2h=0.05,race_podium=0.08`; each overrides `--min-edge` (and `--min-edge-h2h`) for its kind. |
| | `--stake-per-edge` | 250 | Target cost = this ร edge ($). |
| | `--max-stake` | 50 | Max stake per market ($). |
| | `--cost` | 0.01 | Cost per share per trade ($). |
| | `--bankroll` | not set | Bankroll-aware sizing: each taker mode starts with this bankroll, and its stakes scale with its balance after earlier weekends (`balance / bankroll`, 0 once it's gone). Unset = fixed sizing. |
| | `--root-unit` | not set | The root unit system (Dan Abrams on Circles Off ep. 181): a taker's stake in a market is this unit over the square root of the net odds, `unit ร โ(c / (1 โ c))` for a contract costing `c` (price plus cost), instead of `--stake-per-edge` ร edge. One unit at evens, half a unit at 4/1 (`c` = 0.20), two units at 1/4 (`c` = 0.80). The edge only decides whether to bet (`--min-edge`). Still capped at `--max-stake`, and scales with `--bankroll` like the linear rule. Not with `--kelly`. Unset = off. |
| | `--bankroll-growth` | linear | With `--bankroll`: how stakes follow the balance. `linear` = `balance / bankroll`; `sqrt` = its square root (square-root staking: stake = C ร โbalance ร edge, with C = `--stake-per-edge` / โbankroll), so a bankroll that quadruples doubles the stakes and one down 75% halves them. With `--kelly`, Kelly sizes on โ(balance ร bankroll). Unset = linear. |
| | `--kelly` | not set | Kelly sizing (needs `--bankroll`): a taker's stake in a market is this fraction ร the Kelly fraction ร its current balance, where the Kelly fraction is `(q โ c) / (1 โ c)` for a contract costing `c` (price plus cost, at the touch where there's a quote) that wins with probability `q`. Still capped at `--max-stake` ร the bankroll scale per market and at `--max-deployed` per weekend. 0.5 = half Kelly. Unset = linear sizing. |
| | `--thin-edge-mult` | not set | Trade a market under the volume floor when the edge is at least this many times the minimum edge and a recorded order book (at most 10 min old) shows size at the touch: buys only, stake capped at that size. Needs recorded books (`markets record`); without them nothing changes. Unset = thin markets are skipped. |
| | `--max-deployed` | not set | Cap on the capital deployed across a weekend's markets ($). Markets are traded in time order; a buy over the cap is cut to fit. Unset = no cap. |
| Maker | `--half-spread` | 0.02 | Quote half-spread ($). |
| | `--size` | 50 | Shares per quote. |
| | `--max-pos` | 250 | Max inventory per market (shares). |
| | `--skew` | 1.0 | Inventory skew. |
| | `--max-disagree` | 0.15 | Don't quote beyond \|fair โ market\|. |
| | `--maker-min-volume-24h` | not set | The maker's own per-market filter: min $ traded in the prior 24 h before quoting a market. Unset = the replay's $100 (`--min-volume-24h` is the takers' filter). |
| | `--fill` | `through` | `through`: a trade must cross our price; `touch`: at our price; `queue`: at our price once the recorded book's queue ahead of us is served ([queue rule](market-making.md#the-queue-rule)). |
| | `--info-skew` | 2.0 | Info-timed skew (`maker_skew`, `maker_all`). |
| | `--widen` | 1.5 | Widen factor on bad markouts (`maker_widen`, `maker_all`). |
| Markets | `--market-kinds` | `race_win,race_podium,race_h2h,race_constructor_top,race_pole` | Market kinds traded. `race_top10` (Kalshi's top-10 finishers, a group of ten for the coherence check) can be added; it is not in the default set and only the takers trade it. |
| | `--coherence-tol-by-kind` | not set | `kind=tolerance` pairs, e.g. `race_podium=0.35`: how far a multi-outcome group's prices may sum from its target (as a share of it) before its markets are skipped. Unset = 0.25 for every kind. |
| | `--min-volume-24h` | 50 | Min $ traded in the prior 24 h. |
| | `--venue` | not set | `polymarket` (the default) or `kalshi`: whose links and recorded tape the strategies trade. Unset = Polymarket, and unset or `polymarket` leaves every settings key as it was. A profile with this setting paper-trades Kalshi in the signal engine. |
### Paper signals and strategy profiles
```
racinglines strategy profiles f1 [--assign-demo] [--venue kalshi]
racinglines strategy signals f1 [--profile A|C|ID|NAME] [--user NAME ...] [--event next|ROUND|YEAR-ROUND] [--asof UTC] [--no-fetch] [--no-alert] [--venue polymarket|kalshi|og] [--book OUT.toml]
racinglines strategy demo-history f1 [--reset] [--user maker taker] [--venue kalshi]
racinglines strategy reconcile f1 --event YEAR-ROUND --profile A|C|ID|NAME [--venue polymarket|kalshi] [--user NAME] [--replicates N] [--no-price] [--asof UTC] [--markdown]
```
| Command | What it does |
|---|---|
| `profiles` | List the strategy profiles, creating A (core taker), C (maker sleeve) and K (Kalshi maker, [Kalshi history](kalshi-history.md#profile-k)) as Lab candidates if missing, and show which users run which. `--assign-demo` assigns the demo taker A and the demo maker C (`users.prefs["strategy_profile"]`, with the demo bankrolls). `--assign-demo --venue kalshi` assigns the demo maker K as its Kalshi profile only (`users.prefs["strategy_profile_kalshi"]`, with `venue = "kalshi"`), leaving the Polymarket profiles alone; nothing reads that key until the signal engine runs Kalshi, so it changes nothing live. `racinglines/pipelines/profiles.py`. |
| `signals` | Live paper signals of every user with a profile, for the weekend in progress (`racinglines/pipelines/signals.py`): refresh FastF1 and Polymarket data (skip with `--no-fetch`), price each stage whose session is in, run the profile's strategy with the backtest's code, store `strategy_signals` and `paper_positions`, and alert (skip with `--no-alert`). Recommendations only: never places an order. `--profile` runs one profile instead of each user's own; `--user` limits the users; `--event` picks the weekend (default the next). `--asof UTC` replays at that time and only prints (markets read at each stage's cutoff, like the sweep); with `--profile`, `--venue` is the exchange it replays on, and `--book OUT.toml` writes its entries as a book file for `book price|settle|export` (`racinglines/books/picks.py`). A profile whose settings say `venue = kalshi` trades Kalshi's markets (its links, tape and maker fee; signals and positions tagged `kalshi`); there is no flag for it, the profile setting is the switch. `--venue kalshi|og` runs the users' profiles stored for that exchange (`users.prefs["strategy_profile_"]`) instead of their Polymarket ones; the default, `polymarket`, is the 5-minute timer's pass, unchanged. A stage priced live, and an as-of-now pricing between weekends, is logged once in `data_changes` as `live-price` (only when a new run is stored). See [Paper trading](paper-trading.md#the-signal-engine). |
| `reconcile` | One account's live paper weekend against the backtest's replay of it ([Paper trading](paper-trading.md#validation-plan), rule 1; `racinglines/pipelines/reconcile.py`): the `strategy_signals` and `paper_positions` the signal engine stored for the profile's account (`--user`, default its demo account) on that weekend and venue, against `signals.compute` replayed as of a day after the race on the recorded tape with the maker's "through" fill rule. Prints fills, notional, 60-minute markouts (from the same tape, for both sides) and P&L to resolution, live and replay, per stage, with the verdict: fills and markouts within ยฑ25% of the replay's, P&L inside the replay's noise band, the range of the weekend's P&L over seed replicates (the profile's seed plus `--replicates` more, default 2: seeds 43 and 44, whose stage pricings are cached as diagnostic runs exactly like a sweep's), at least ยฑ31 (taker) / ยฑ71 (maker) wide. `--no-price` is read-only on the database (cached seeds only). The replay's signals are compared as `signals.store` keeps them (one row per market, timestamp, action and side: maker fills sharing a second on Kalshi's tape collapse into one, and the output says how many). A weekend whose rows are the backfilled replay is a self-check and must match exactly. `--markdown` adds the block for the weekend report. Exit 1 when flagged, 2 when the account has no rows for the weekend. Never writes a signal or a position. |
| `demo-history` | Backfill the demo accounts' track record: every past weekend with Polymarket race markets, replayed with the profile each account ran then, stored as paper signals and positions flagged as backtest replays (`racinglines/pipelines/demo_history.py`). Idempotent; `--reset` deletes the backfill first; `--user` limits it to `maker` or `taker`. `--venue kalshi` replays the maker's profiles on Kalshi's recorded tape instead, with Kalshi's maker fee, stored apart as venue `kalshi` ([Kalshi history](kalshi-history.md)); off by default. |
## NASCAR commands
NASCAR Cup, from the content feeds at `cf.nascar.com` (no key). See [Data](data.md#nascar-content-feeds-verified-2026-09-29).
```
racinglines data fetch nascar [--years 2026] [--series 1] [--feeds a,b] [--races 5624,5628] [--force] [--dry-run]
racinglines data ingest nascar [--years 2026] [--series 1] [--force] [--no-laps] (--db URL anywhere after the sport)
racinglines data schedule nascar [--through 2026-11-30] [--series 1] [--apply --backup FILE] [--undo --backup FILE]
racinglines data link nascar [--exchange kalshi] [--apply --backup FILE] [--undo FILE]
racinglines strategy replay nascar [--years 2025-2026] [--venue all|kalshi|polymarket|og] [--kinds a,b] [--min-edge X] [--min-volume USD]
[--events KEY,...|latest] [--tape probe|pull] [--require-tradeable] [--buy-all]
[--save --backup FILE] [--undo BATCH]
racinglines strategy replay motogp (the same options)
racinglines strategy sweep nascar [--year 2026] [--rounds 1-8] [--venue polymarket|kalshi|og] [--reliability] [--save] [SETTINGS โฆ]
racinglines strategy sweep motogp (the same options; venues polymarket | kalshi)
RACINGLINES_NASCAR_SEASON=1 racinglines model season nascar [--year 2026] [--sims 4000] [--seed 7] [--stage-noise 5] [--race-noise auto|N|model] [--standings feed|results] [--quotes] [--csv FILE]
RACINGLINES_SEASON_REPLAY=1 racinglines strategy season-replay nascar [--year 2026] [--venue all|kalshi|polymarket|og] [--sims 2000] [--min-edge 0.03] [--stake-per-edge 500] [--max-stake 150] [--capital 1500] [--out DIR]
RACINGLINES_SEASON_REPLAY=1 racinglines strategy season-replay motogp (the same options)
racinglines strategy demo-history nascar --pick data/runs/replay-grid/nascar (print the selection, write nothing)
racinglines strategy demo-history nascar --grid data/runs/replay-grid/nascar --backup FILE [--book kinds|blend|best]
[--venue kalshi|polymarket] [--grid-venue kalshi|polymarket] [--users taker] [--events KEY,...|latest]
racinglines strategy demo-history nascar --reset --backup FILE [--venue kalshi|polymarket] [--users taker] [--grid FOLDER]
racinglines model forecast nascar [--races 3] [--save --backup FILE] [--undo RUN_ID]
racinglines strategy signals nascar [--venue all|polymarket|kalshi|og] [--profile ID|NAME] [--user NAME ...] [--event next|KEY] [--asof UTC] [--no-fetch] [--no-alert]
racinglines strategy demo-history motogp | forecast | signals (the same options)
```
| Command | What it does |
|---|---|
| `fetch` | Download the feeds to `data/raw/nascar/cf///`, byte for byte, one request per second, for every race already run. Files on disk are skipped and a feed the server says is missing (HTTP 403) is remembered in a `.missing` marker (not for races from the last three days, whose feeds may not be published yet), so a re-run after each race weekend asks only for what is new. `--feeds` picks from `race_list_basic, points-feed, weekend-feed, pit-data, loopstats, lap-times, lap-notes`; `--races` limits to some race ids; `--force` asks again; `--dry-run` requests nothing and prints how many requests a run would make. |
| `ingest` | Load the stored feeds into the database: events (races still to run as `scheduled`), rounds (practice, qualifying, race), results, laps, and one athlete per NASCAR `driver_id`. A race whose files have not changed is skipped (`--force` rebuilds it); `--no-laps` leaves out the lap table. Only Cup (`--series 1`) has a competition. |
| `link` | Say which driver, race and contract each stored NASCAR market link is about (Kalshi, Polymarket, OG.com), the same answer the syncs now give new links ([Data](data.md#nascar-content-feeds-verified-2026-09-29)). A **dry run** by default: totals, one line per exchange, series and kind with how many links got a driver and a race, and the names it could not place. `--apply` writes `athlete_id`, `race_id` and `params` (`kind`, `nascar_series`, `season`) of the links that change and refuses without `--backup FILE`, a database dump under 24 hours old; it records a `data_changes` entry and an undo file (`data/backups/db/nascar-links-undo-.json`) that `--undo FILE` replays. `--exchange` limits it to one exchange. Run it after `ingest`, since it matches against the drivers and races in the database. |
| `replay` | Taker replay of the season's Cup races against the recorded prices of every exchange the sport lists (`--venue all`, the default: Kalshi, then Polymarket, one report each; then OG.com where `exchanges/og.toml` lists the sport (NASCAR, not MotoGP); `--venue kalshi`, `polymarket` or `og` for one. OG.com's stored minute prices, trades and book quotes all count, whatever their spread, side or depth, except a stored 0.50 from an empty book; its fee is the schema's flat $0.02 per contract, unverified) ([`pipelines/position_replay.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/pipelines/position_replay.py)). Per race: the field is the race's start list, the model in `[sport] pricing_model` prices it from earlier races only, and the F1 taker (`update` / `hold` / `last`) trades at the stages of `sports/.toml` `[replay]` (hours from 00:00 UTC on race day: T-3d, T-1d, race eve) wherever the market is priced, has $50 of 24 h volume, is still open and its group is coherent; the result settles each market through `markets/kinds.settle`, the walk-forward's settlement (a top-n market: classified by the sport's `[results] classified` rule and in the top n; NASCAR `placed`: a retirement counts by its place by laps; MotoGP `status_ok`). Prints P&L, Kalshi's taker fees and P&L after fees per mode, P&L by kind and model-vs-exchange calibration, and writes `data/runs/replay/--/`. Kinds: NASCAR race win, top 3/5/10/20 and head-to-head; MotoGP race win. Links are read as `link --apply` (NASCAR) or the sync (MotoGP, whose `[identity]` resolver `sources/motogp/links.py` is on by default since 2026-09-30) left them, and any link not yet identified is identified in memory; nothing is written to `market_links`. Read-only unless `--save`, which also stores one as-of model run per race (`model_runs` kind `diagnostic` + `race_predictions` + prediction records and sims, unless `RACINGLINES_PREDICTION_RECORDS=0`; with the first venue's pass only), refuses without `--backup FILE` (a dump under 24 hours old), logs a `data_changes` entry with its batch id, and is undone with `--undo BATCH`. Each report first counts races with markets, markets priced and markets tradeable; a race whose markets have no stored price at any stage is flagged `NO TAPE`, and a venue where nothing was tradeable prints `NOT TRADED` with the reason instead of P&L lines (`--require-tradeable` then exits 1). The tape (prices and trades) is pulled by `--tape pull --backup FILE`: for the selected races only, their markets' trades and hourly prices from 2 days before the first stage to 2 days after the last (idempotent upserts, logged in `data_changes`), then the replay runs; `--tape probe` asks each exchange about 3 markets of the first selected race, prints the counts, writes nothing and stops. `--buy-all` (debug, off by default; or `RACINGLINES_BUY_ALL=1`, which the F1 weekend sweep also reads) adds the mode `buy_all` ([`markets/strategies/buy_everything.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/markets/strategies/buy_everything.py)): one YES and one NO share of every market open and priced strictly inside (0, 1) at its first such stage, with no edge, volume, price-band or coherence filter, costs and Kalshi's fee still applied, so a pair loses exactly its costs and fees and the cell shows that the market reached the replay and settled; its report adds P&L by side and kind and `buy_all_trades.csv`, and the taker's modes are unchanged. **`buy_all` is a plumbing check, not a result:** it shows that the tape reached the replay and settled, it is never shown in the web app or in any report, no page or status cell counts it as a backtest, and `demo-history` never stores it. Otherwise the F1 sweep is not involved. |
| `sweep` | The season sweep ([`pipelines/season_sweep.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/pipelines/season_sweep.py); also `motogp sweep`): the F1 sweep's four taker modes (`update`, `hold`, `last`, `early`) and five maker variants (`maker`, `maker_flat`, `maker_skew`, `maker_widen`, `maker_all`) through each race's `[replay]` stages on one exchange's recorded tape (`--venue`, default Polymarket; Kalshi's taker fee schedule and maker fee, OG.com's flat fee per contract on the taker's cost), settled through `markets/kinds.settle` as the replay, with the F1 sweep's closing-line value per strategy (printed next to P&L, saved as `metrics.clv`). The schema says everything sport-specific (`sports/.toml` `[sweep]`: `stages = "weekend"`, the default stage mode, which the `--stages` setting can override with any mode the schema supports, so the weekend-based results stay reproducible beside a session-aware one; the stages the stage-aware taker skips, the hour of race day the maker stops quoting; `[replay]` stages, kinds and group targets; `[sport] pricing_model`). The field is the race's start list (its entrants less DNS) and the model prices it once from earlier races only (the fair is the same at every stage; only the market moves). `--stages sessions` trades each event after each of its stored sessions instead (`[sessions]` and `[stages]` in the schema: NASCAR practice and qualifying, MotoGP FP, Practice, Q1/Q2, sprint and warm-up), asking the model at every stage; an event with no stored session time keeps its `[replay]` stages. `--stages race_day` (NASCAR) trades the `[replay]` stages plus `[replay] race_day_stages` (a 12:00 UTC "race morning" stage, where Polymarket's NASCAR volume is), the maker quoting until `[sweep] race_day_quote_until_hours`; opt-in, the weekend default is unchanged. `nascar replay` and `nascar demo-history` take the same `--stages weekend|race_day`. The settings are the model's own plus the shared taker, maker and markets ones (every setting is a flag, e.g. `--min-edge 0.08 --market-kinds race_win`; `taker_stages` / `late_stages` take the stage labels). Read-only unless `--save`, which stores `kind='sweep'` under the sport's competition with `sport` and `venue` in its params; the search queues it as `kind = "sweep"` with `sport = ""` ([Cloud sweep](cloud-sweep.md)). |
| `demo-history` | The replay's trades as the demo accounts' paper portfolio, shown in the app and written only while `RACINGLINES_SPORT_PAPER` is on, **which is the default** since 2026-09-30 (unset = on; `0` or any value other than `1`/`true`/`yes`/`on` turns it off, and then the command refuses to write; `--pick` works either way) (also `motogp demo-history`; [Paper trading](paper-trading.md#nascar-and-motogp-demo-in-sample-off-by-default)). Every row is in-sample and labelled so. `--grid data/runs/replay-grid/ --backup FILE` (the grid is the overnight run's read-only settings grid, runs `-e-v//summary.json`): picks each market kind's best grid setting (best worse-season net after fees; in-sample), replays the grid's seasons and stores each race's `update` trades as backfilled signals and Kalshi paper positions for `--users` (default `taker`), replacing that race's earlier rows; `--book blend` trades every kind at the one best setting; `--book best` is the best-effort book (best total per kind, else every kind at the best total, win or lose); `--events latest` for a spot check; `--pick FOLDER` prints the selection and writes nothing; `--reset --backup FILE` deletes this sport's demo rows on `--venue` for `--users` (with `--grid` too, it then rebuilds). `--venue` is `kalshi` (the default) or `polymarket`; `--grid-venue` picks the settings from another venue's grid (Polymarket has none, so `--venue polymarket --grid-venue kalshi` trades Polymarket's tape with the Kalshi grid's selection). `--backup` must be a dump under 24 hours old. Writes the selection to `demo-selection-.md` in the grid folder and prints the undo command. Never the debug `buy_all` mode. Logs a `data_changes` entry. On the VM, `vm.sh demo` runs it for both sports and both demo accounts ([VM deploy](vm-deploy.md#whats-in-the-repo)). |
| `forecast` | The next scheduled races priced by the sport's model (`[sport] pricing_model`, the replay's settings), field from the latest race's entrants (also `motogp forecast`; [Web app](webapp.md#accounts-demo-users-vs-polymarkets-takers)). Read-only; `--save --backup FILE` stores one `forecast` run (model_runs + race_predictions) that the board, race pages and exchange markets read as live prices; `--undo RUN_ID` removes it. Logs a `data_changes` entry. `--races N` (default 3) is how many scheduled races. A sport with no scheduled race stored (or no completed race to take a field from) says so and stores nothing: that is MotoGP today, whose ingest loads finished events only, so it has no forecast until its calendar (the upcoming events) is ingested. Nothing is tuned: the model settings are the replay's (`[replay]`, seed 7). |
| `signals` | Live paper signals of every user with a profile for this sport (also `motogp signals`; [`pipelines/sport_signals.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/pipelines/sport_signals.py)), on each exchange the sport's schema lists (`--venue all`, the default) or one: a user's profile for a sport and venue is `users.prefs["strategy_profile__"]`. Per race, the schema's `[replay]` stages: each stage due is priced once by the sport's pricing model on the race's start list and stored (`model_runs` kind `diagnostic`, params `mode = "live signals"`, `sweep_stage`, `live`), logged once in `data_changes` as `live-price`, and reused by every later pass; the venue's tape is refreshed for the last 26 h first (skip with `--no-fetch`); the profile's taker or maker runs with the sweep's parameters; `strategy_signals` (`detail.sport`, `detail.venue`) and `paper_positions` (the venue, the sport's event key) are stored and alerted as `f1 signals` does. `--asof UTC` replays and only prints (the race priced in memory on its classified field, as the sweep). Recommendations only: never places an order. No timer runs it yet. |
| `season-replay` | The champion-market replay, **read-only and off by default** (`RACINGLINES_SEASON_REPLAY=1`; also `motogp season-replay`) ([Championship markets](championship-markets.md#championship-replays-backtests)): the F1 championship sleeve's default strategy traded against each exchange's recorded champion prices (`--venue all`, the default: every exchange the sport lists, one report each; or `kalshi`, `polymarket`, `og`), with an as-of season forecast after every points race from the results before it only (NASCAR: the Chase model of `season`, standings summed from results; MotoGP: `models/motogp_season.py`, Grand Prix and sprint points, Grand Prix only and flagged when no sprint is stored). MotoGP champion contracts are classified in memory; nothing is written to the database. Writes `decisions.csv`, `trades.csv`, `positions.csv`, `equity.csv` and `summary.json` to `data/runs/season-replay/--/` (or `--out DIR`). Not yet run on the VM as of 2026-09-30 ([Roadmap](todo.md)). |
| `season` | The Cup season forecast, **read-only and off by default** (`RACINGLINES_NASCAR_SEASON=1`) ([`models/nascar_season.py`](https://github.com/racinglines/racinglines/blob/main/racinglines/models/nascar_season.py)). The standings today are NASCAR's own `points-feed.json` when it is on disk (points with penalties, the official seeds; matched by NASCAR driver id), else summed from the season's stored results (the feed's points per race, stage and bonus points included; seeds from regular-season points, ties by wins, then top 5s, then top 10s; `--standings results` forces this); the output lists every driver whose results-summed points or seed differ from the feed; every points race still to run is priced by `[sport] pricing_model` for the field of the last race run, turned into points (55 for a win, 35 for 2nd down to 1; 10..1 to each paid stage's top 10, stages simulated as the race order re-drawn with `--stage-noise` places; the one bonus point a race), each race's finishing order drawn with `--race-noise` places of spread (`auto`, the default: the spread measured on the last two seasons, about 7 places, not the race model's own 2.0, which puts most simulated wins on one favourite), and the season runs through the 2026 Chase (top 16 on points, reset 2100 / 2075 / 2065 / 2060 / 2055 then 5 fewer a place to 2000, no eliminations, most points after 10 races). Prints each driver's seed, points today, expected points and the odds of making the Chase, the title, the top 3 and the top 10, and cross-checks the points against NASCAR's own `points-feed.json` when it is on disk (penalties show up there). `--quotes` puts the champion price beside every Cup champion contract on file (OG.com's 17, Kalshi's, Polymarket's), with the edge net of the schema fee (OG.com only; Kalshi and Polymarket before fees). Nothing is saved; `exchanges/og.toml` keeps NASCAR `modeled = false`, so the board and the syncs do not change. Races are simulated independently (no form drift, no track types), which understates the spread, so the output says NOT CALIBRATED. |
Nothing here runs by default. **Back up the database before the first `ingest` on a real database, and before `link --apply`** ([Data changes](data-changes.md)).
## Downhill commands
UCI downhill (`mtb_dh`): ChronoRace result files in, the log-time rider model out ([Downhill model](model.md)).
### racinglines data download mtb_dh
`racinglines/sources/chronorace/download.py`.
```
racinglines data download mtb_dh (--year YEAR | --events SLUG [SLUG ...] | --probe START END)
--discipline {DH,XCO,XCC,EDR}
--category "Elite Men" | "Junior Men" | "Elite Women" | "Junior Women" | ...
[--probe-suffixes SUFFIX ...] [--pdf-results] [--out-dir DIR] [--list-only]
```
| Option | Meaning |
|---|---|
| `--year` | Find events from the Wikipedia season page (only slugs dated that year). |
| `--events` | Explicit ChronoRace slugs; skips Wikipedia. |
| `--probe` | Also find events by trying `YYYYMMDD_{dh,dhi,mtb,xco}` for every date from START to END (`YYYY-MM-DD`) against ChronoRace, for rounds missing from Wikipedia. About 2 s per day. Combines with `--year`/`--events`. |
| `--probe-suffixes` | The suffixes `--probe` tries (default `dh dhi mtb xco`). |
| `--pdf-results` | For rounds without live timing, read the result PDF into the round's table (the 2021 Leogang and Les Gets rounds, several Timed Training rounds). Needs poppler's `pdftotext` (`brew install poppler`). Off by default: the round is written as its PDF links. |
| `--discipline` | DH is the tested one; XC/EDR are best-effort. |
| `--category` | Friendly name; mapped to `ME`/`MJ`/`WE`/`WJ`/`MU`/`WU`. |
| `--out-dir` | Default `data/raw/mtb_dh/chronorace/`. |
| `--list-only` | Print the discovered slugs and stop. |
Output: `/__.md`. Existing files are overwritten.
Typical full refresh:
```
for y in 2021 2022 2023 2024 2025 2026; do
for c in "Elite Men" "Junior Men"; do
racinglines data download mtb_dh --year $y --discipline DH --category "$c" --out-dir data/raw/mtb_dh/chronorace/
done
done
# 2021 rounds missing from Wikipedia (found by --probe 2021-04-01 2021-10-31):
racinglines data download mtb_dh --events 20210914_dh 20210918_dh --discipline DH --category "Elite Men" --out-dir data/raw/mtb_dh/chronorace/
racinglines data download mtb_dh --events 20210914_dh 20210918_dh --discipline DH --category "Junior Men" --out-dir data/raw/mtb_dh/chronorace/
```
Add `--pdf-results` to fill PDF-only rounds from their result PDFs. The 2026 elite files were renamed by
hand (`2026-08_les-gets_men-elite.md`); a re-download writes `_dhi_elite-men.md` beside them, so
rename or remove one of each pair before parsing a directory into a CSV (ingest keys races by event, so
the database isn't affected).
Find unlisted events:
```
racinglines data download mtb_dh --probe 2021-04-01 2021-10-31 --discipline DH --category "Elite Men" --list-only
```
### racinglines data parse mtb_dh
```
racinglines data parse mtb_dh --inspect FILE
racinglines data parse mtb_dh [--input-dir DIR] [--input-file FILE ...] [--out splits.csv]
[--round LABEL] [--conditions-file CSV] [--canonical-venues]
```
| Option | Meaning |
|---|---|
| `--inspect` | Print the detected structure of one file and exit. |
| `--input-dir` | Parse every `.html/.htm/.csv/.json/.md/.txt` in the directory (not recursive). |
| `--input-file` | One file; can be repeated. |
| `--out` | Output CSV (default `splits.csv`). |
| `--round` | Force a round label on every row (rarely needed). |
| `--conditions-file` | CSV `event_id,round,track_condition` to fill in `track_condition`. |
| `--canonical-venues` | Write venues under their canonical slugs (`sports/mtb_dh.toml` `[venue_aliases]`, as the database stores them: `vallnord` โ `pal-arinsal`). Default: as spelled in the source. |
### racinglines data ingest mtb_dh
```
racinglines data ingest mtb_dh [PATH ...] [--db URL] [--competition uci_dhi_wc] [--force] [--note WHY]
```
Loads downloaded files into the database. `PATH` is files or directories
(non-recursive `*.md`; default `data/raw/mtb_dh/chronorace`). Unchanged files are
skipped by content hash, and `--force` re-ingests them. Each ingested file replaces
its race's rounds, results and splits. See [Database](database.md#ingest). A run that ingests anything
is logged in the [data change log](data-changes.md), with `--note` as the reason.
### racinglines data points mtb_dh
Championship points tables by season, and a rider-by-rider check against official standings
([Roadmap โ Points validation](todo.md#points-validation)).
```
racinglines data points mtb_dh import sports/points/uci_dhi_wc.toml --db # file -> points_schemes
racinglines data points mtb_dh show --db # the tables each season uses
racinglines data points mtb_dh check --db --season 2026 --through-round 7 --standings STANDINGS.toml
```
| Command | What it does |
|---|---|
| `import FILE` | Loads one table per era and round kind (`final`, `qual`, `qual1`, `semi`) into `points_schemes`, replacing rows with the same era start and round kind. Refuses tables that aren't non-negative and non-increasing. |
| `show` | Per season: where its tables come from (`db` or `schema`), official or placeholder, and their sizes. |
| `check` | Each rider's cumulative points, from the results and the season's tables, against the official standings (CSV with `rider`, `points`, or TOML as in `sports/points/standings/`). Riders match by name, ignoring accents, case and word order. Exits 1 if any rider differs. `--points schema` checks the placeholders instead. |
`sports/points/uci_dhi_wc.toml` is the entry template: every table in it is still a **placeholder**
(the schema's scales copied into each era). The owner replaces them with the official UCI scales,
sets `official = true` and cites the source.
### racinglines model forecast mtb_dh
Predict and backtest one target season.
```
racinglines model forecast mtb_dh (--db [URL] | --data splits.csv) [--competition uci_dhi_wc] [--save [--scenario LABEL]]
[--out-dir data/runs/mtb_dh/forecast]
[--season YEAR] [--category ME]
[--train-scope {all,season}] [--half-life-days 240] [--junior-weight 0.25]
[--prior-n 0.5] [--eps-df DF]
[--walk-forward] [--backtest 2] [--remaining 2] [--unraced-format {default,last}]
[--sims 10000] [--seed 42] [--top 15]
```
| Option | Default | Meaning |
|---|---|---|
| `--db [URL]` | โ | Read from the database (`$DATABASE_URL` or the default if no URL is given). |
| `--data` | โ | Read a tidy CSV from `racinglines data parse mtb_dh` instead. |
| `--competition` | `uci_dhi_wc` | Competition code in the database. |
| `--save` | off | Store the model run, its metrics and predictions in the database (needs `--db`). |
| `--points` | `schema` | Points tables: `schema` = the placeholders in `sports/mtb_dh.toml` for every season (today's behaviour); `db` = each season's tables from `points_schemes` ([`mtb_dh points`](#racinglines-data-points-mtb_dh)), falling back to the placeholders. |
| `--scenario LABEL` | โ | With `--save`: store as `kind='scenario'`, ignored by live prices until promoted in the web app's Lab. |
| `--season` | latest in data | Target season. |
| `--category` | `ME` | Target category. |
| `--train-scope` | `all` | `all` = every season and category in `--data`; `season` = target only. |
| `--half-life-days` | 240 | Recency half-life for training runs (120 before 2026-09-28). |
| `--junior-weight` | 0.25 | Training weight of `MJ` runs relative to elite (0.5 before 2026-09-28). |
| `--prior-n` | 0.5 | Shrinkage of each rider's pace toward the field (1.5 before 2026-09-28; [Calibration](model.md#calibration)). |
| `--eps-df` | normal | Student-t run noise with these degrees of freedom (above 2), same sd. Tried at 4 and 6: no better. |
| `--walk-forward` | off | Predict and score every target round from everything before it. |
| `--backtest N` | 2 | Hold out the last N raced rounds; score them and the standings after them. `0` skips. |
| `--remaining N` | 2 | Rounds left in the season, **including** in-progress events already in the data (forecast with their start lists). The rest are simulated as unknown rounds. `0` skips the forecast. |
| `--unraced-format` | `default` | The format those rounds are simulated in: `default` = the 2026 elite men's (`[rounds] default_format`, a 30-rider final) for every category; `last` = the category's latest completed event's own format (women, juniors), else `[rounds.category_format]`. |
| `--sims` | 10000 | Monte Carlo simulations (walk-forward uses at most 5000). |
| `--seed` | 42 | Random seed. |
| `--top` | 15 | Rows printed per table. |
Files written: `walk_forward.csv`, `backtest_.csv`, `backtest_standings.csv`,
`forecast_per_round.csv`, `forecast_standings.csv`.
### racinglines model backtest mtb_dh
Walk-forward plus a standings holdout, for several seasons.
```
racinglines model backtest mtb_dh (--db [URL] | --data splits.csv) [--competition uci_dhi_wc] [--save]
[--out-dir data/runs/mtb_dh/backtests]
[--seasons 2021 2022 ...] [--category ME]
[--train-scope all] [--half-life-days 240] [--junior-weight 0.25]
[--prior-n 0.5] [--eps-df DF] [--reliability]
[--sims 4000] [--seed 42]
```
`--prior-n` and `--eps-df` work as for `forecast`. `--reliability` also prints and saves
(`backtest_reliability.csv`) reliability curves over every walk-forward round: win, podium,
top 10 and making the Final, per probability bin ([Calibration](model.md#calibration)).
`--db`, `--data`, `--competition`, `--save` and `--points` work as for `forecast` (with `--points db`, each season uses its own tables). With `--save`, the
per-season and per-event metrics are stored in `model_runs.metrics`. Seasons with
fewer than 3 events that have data are skipped. Files written:
`backtest_events.csv` (one row per predicted round) and `backtest_seasons.csv` (one
row per season). The printed per-season table includes the actual and predicted
champion.
### racinglines model walk-forward mtb_dh
Every completed event priced through the shared engine, model-only
([Backtest core](backtest-core.md#the-walk-forward-engine)): calibration per market kind (win, podium,
top 10, fastest qualifier, makes the Final; `--kinds race_h2h` adds head-to-heads), overall and per season.
```
racinglines model walk-forward mtb_dh (--db [URL] | --data splits.csv) [--seasons 2025 2026] [--kinds K1,K2]
[--category ME] [--sims 5000] [--half-life-days 240] [--prior-n 0.5] [--junior-weight 0.25]
[--train-scope all] [--eps-df DF] [--seed N] [--save] [--out-dir data/runs/mtb_dh/walk_forward]
```
Writes `walk_forward_events.csv`, `walk_forward_calibration.csv` and `walk_forward_reliability.csv`.
With `--save`, stores a `walk_forward` model run (settings, per-event scores, calibration), which is
what a downhill search job runs.
### racinglines strategy live mtb_dh
Follow a downhill final live from UCI timing (ChronoRace): rank probabilities after every update, the demo
maker's quotes, the private book's anonymous crowd, and the demo accounts' private-book positions. A demo
experiment; see [Live events](live-events.md). The loop holds a lock in the final's run folder (as
`racinglines live run` does): a second loop for the same final says so and exits instead of polling alongside it.
```
racinglines strategy live mtb_dh --slug 20260925_mtb --final 3 --quali 2,91 --conditions "clear, rutted"
```
| Option | Default | |
|---|---|---|
| `--slug` | (required) | ChronoRace event slug |
| `--final` | (required) | Live-timing key of the final (Whistler 2026: `3` men, `6` women) |
| `--quali` | `2,91` | Live-timing keys of the qualifying sessions, comma-separated |
| `--conditions` | | Free text; "rutted" and "wet" widen the spread and raise the crash risk |
| `--interval` | 20 | Seconds between polls |
| `--minutes` | 0 | Stop after this many minutes (0: when the final is over) |
| `--once` | | One update, then exit |
## Road cycling commands
Road cycling sportsbook lines (winner, head-to-head) for time trials and road races on the timed-runs engine:
`events`, `fetch itt|road`, `startlist `, `price [--calibrate]`. Admin-only and not in the PyPI package.
## Scripts
| Script | What it does |
|---|---|
| `scripts/fetch_test_fixtures.py [--f1] [--mtb]` | Build the regression-test fixtures from the public sources. See [Testing](testing.md#fixtures-built-locally-from-the-public-sources). |
| `scripts/signals_parity.py [--profile A] [--year 2026] [--round 15] [--reprice] [--venue kalshi]` | Parity check: `f1 signals` replayed at a past race's start must give the same taker trades (market, stage, side, shares, price) as `f1 sweep --rounds N --no-fetch` with the profile's settings; a maker profile (replayed settled, a day after the race) must match the sweep's maker on fills and settled P&L, and `demo-history`'s stored record for the weekend when there is one. `--venue kalshi` gives the profile the `venue = kalshi` setting on both sides. Exit 1 on a difference. |
| `scripts/cloud/prepare.sh` | Before a cloud sweep: move every market row from Postgres to the Parquet archive, `db snapshot-export`, `f1 pm-links-export`, and run the no-data-in-git guard. Then commit and push. |
| `scripts/cloud/start.sh` | Bring up racinglines on a fresh Linux machine (a cloud session): `.venv` with Python 3.14, Postgres, `db init`, then `db snapshot-import` (or, without a snapshot, `f1 ingest` and `pm-links-import` / `pm-sync`), then `check --offline`. `SKIP_SYSTEM=1` runs only the database steps. See [Cloud sweeps](cloud-sweep.md). |
| `scripts/hooks/pre-push` | Git hook: README in sync, `mkdocs build --strict`, no data in git. See [Testing](testing.md#pre-push-hook). |
| `scripts/build_readme.py` | See below. |
## Docs
```
pip install -r requirements-docs.txt
mkdocs serve # http://127.0.0.1:8000, rebuilds live while you edit
mkdocs build # static site in site/
```
See `requirements-docs.txt` for a workaround if `watchdog` fails to install on Python 3.14.
## scripts/build_readme.py
Parts of `README.md` are generated from sections of the docs, so the two can't drift
apart. The docs are the source of truth.
**1. Tag a section in any `docs/*.md` page.** HTML comments are invisible in both
MkDocs and GitHub:
```
...any markdown...
```
Tag names must be unique across all docs pages.
**2. Include it in `README.md`:**
```
```
Everything between the include markers is replaced on each build, so edit the docs,
not the README. Text outside include blocks (title, section headings, anything
README-only) is left alone. Options go after the name: `shift=N` demotes headings
inside the section by N levels (negative promotes).
**3. Build:**
```
python scripts/build_readme.py # rewrite README.md
python scripts/build_readme.py --check # exit 1 if README.md is stale (the pre-push hook runs it)
python scripts/build_readme.py --list # tagged sections, and whether the README uses each
```
Conversions while copying:
| In docs | In README |
|---|---|
| `[x](todo.md#points-validation)` | `[x](docs/todo.md#points-validation)` |
| `[x](#some-heading)` (same page) | `[x](docs/.md#some-heading)` |
| `!!! warning "Title"` + indented body | `> โ ๏ธ **Title**` blockquote |
Sections currently tagged (`--list`):
| Tag | Page |
|---|---|
| `tagline`, `glance`, `quickstart`, `overview`, `results-map`, `layers`, `docs-build`, `repo-layout`, `points-warning` | index.md |
| `validation` | testing.md |
| `f1-accuracy` | f1.md |
| `f1-trading`, `f1-matrix`, `f1-checkpoints` | market-making.md |
| `headline` | evaluation.md |
| `forecast-summary` | forecast.md |
| `todo-summary` | todo.md |
---
# Terminal app (racinglines tui)
`racinglines tui` is the web app's tabs in a terminal, built with [Textual](https://textual.textualize.io/) and Rich. It
holds one MCP session and calls the same tools a chat client does ([MCP server](mcp.md)): every screen is a view of a
tool's JSON, so it shows what the server serves, nothing computed on the side.
```
racinglines tui # the hosted server, signed in as for --remote (in testing: the #aintcare warning)
racinglines tui --local [--db URL] # this machine's database: `racinglines mcp` over stdio, owner mode
```
```text title="Output"
usage: racinglines tui [-h] [--local] [--db DB] [--no-jobs] [--mcp-url MCP_URL] [--user USER]
[--timeout TIMEOUT]
racinglines tui the web app's screens in the terminal, on the hosted server (docs/tui.md)
racinglines tui --local [--db URL] the same on this machine's database: `racinglines mcp` over stdio
Hosted sign-in is `--remote`'s (cli/remote.py): RACINGLINES_MCP_TOKEN, else the browser once, kept in
~/.config/racinglines/mcp-login.json. The sign-in happens here, before the screen opens, so its URL is readable.
RACINGLINES_MCP_URL the hosted server (default https://mcp.racinglines.bet/mcp); --mcp-url overrides it
RACINGLINES_USER the account preselected on the Paper tab; --user overrides it
options:
-h, --help show this help message and exit
--local this machine's database through `racinglines mcp` (stdio)
--db DB with --local: the database URL (default $DATABASE_URL, else the local one)
--no-jobs with --local: don't run the Lab's job worker here (leave queued jobs to the
web app)
--mcp-url MCP_URL the hosted server (default RACINGLINES_MCP_URL or racinglines.bet)
--user USER account preselected on the Paper tab
--timeout TIMEOUT seconds to wait for one tool call (default 90)
```
## Sign-in
Hosted is `--remote`'s sign-in ([CLI reference](cli.md#where-the-data-comes-from-demo-remote-local)):
`RACINGLINES_MCP_TOKEN` (an `rl_` token from `racinglines mcp token ACCOUNT`), else the browser once, stored in
`~/.config/racinglines/mcp-login.json`. The sign-in runs before the screen opens, so its URL and any 401 print in the
terminal; `racinglines book logout` forgets the login. The remote-access warning prints there too and shows again in
the app on connect.
`--local` starts `racinglines mcp` with the same Python, so the TUI and the server share one environment and one
database (`--db`, else `DATABASE_URL`). Stdio has no tokens: as with any stdio client, whoever runs it is the owner.
The server's stderr goes to `~/.cache/racinglines/tui/server.log`, never to the screen. `--no-jobs` leaves queued
Lab jobs to the web app's worker.
## Tabs
| Key | Tab | Tools |
|---|---|---|
| 1 | Home: row counts, next races, live forecasts, seasons, run kinds, linked markets, venues | `overview` |
| 2 | Forecast: the next races' win / podium / top-10 and the championship, per competition and category | `get_forecast` |
| 3 | Markets: fair value against each venue, the gap drawn as a bar, freshness per venue (stale after `RACINGLINES_STALE_HOURS`); an outcome opens its price history | `list_events`, `list_markets`, `get_market_history` |
| 4 | Events: by competition, season, status; a weekend opens on results, predictions and its market matrix | `list_events`, `get_event` |
| 5 | Athletes: search; results and prediction history | `search_athletes`, `get_athlete` |
| 6 | Trade: F$ balance, tradable markets, the recorded book, the expected fill, IOC orders, positions, orders, leaderboard | `fantasy_*` |
| 7 | Paper: an account's track record with its cumulative P&L, positions with totals per venue, signals | `track_record`, `list_positions`, `list_signals` |
| 8 | Lab: jobs (refreshed every 5 s, log tail, cancel, the saved run), new jobs from `list_job_types` with every sweep setting, Edge Finder, candidates, diagnostics with maker replay, model runs with each metrics part one key away | `list_jobs`, `get_job`, `cancel_job`, `list_job_types`, `run_job`, `edge_finder`, `list_candidates`, `list_diagnostics`, `get_diagnostic`, `replay_maker`, `list_model_runs`, `get_model_run`, `get_predictions` |
| 9 | Sportsbook: a book as TOML; map, price, settle ([Sportsbook slips](sportsbook/slips.md)) | `map_book`, `price_book`, `settle_book`, `list_kinds` |
| 0 | Live: the private book's events and whether one is live now, refreshed every 30 s | `list_live_events` |
| | Data: the change log, the schema, a read-only SQL console, and `racinglines data pull` (run as a subprocess) | `data_changes`, `describe_schema`, `sql` |
| `ctrl+n` | Notepad: the standing strategy note, today's and this week's ([Claude Code](#claude-code)) | none: files on this machine |
| `ctrl+o` | Model (experimental, `--experimental`): the Lab from scratch, every tunable step of the pipeline with the model's output before and after, ten sheets ([Model workbook](#model-workbook-experimental)) | `list_job_types`, `list_diagnostics`, `get_diagnostic`, `run_job`, `get_job`, `replay_maker`, `edge_finder`, `list_events`, `get_event`, `get_athlete`, `list_markets`, `list_signals`, `list_positions`, `fantasy_positions`, `fantasy_orders`, `track_record` |
Everywhere: `r` refreshes, `enter` opens a row, `[` and `]` page, `esc` goes back, `ctrl+r` reconnects, `ctrl+l` turns
[layout mode](#layout-mode) on and off, `?` lists the keys. A row that names a job, a model run (`run_id`, `result_run_id`, `last_run`, a change's `detail.run`), an event
(`event_id`, `event_key`) or an athlete opens it. Lists the server pages (signals, positions, Edge Finder, diagnostics,
the change log, SQL) page through it rather than loading everything.
Colour has one meaning each: green in our favour (fair above market, profit), red against, purple the best on the
board, amber stale. Settings (`min_edge`, `max_stake`, a sweep's knobs) print as written, never coloured as data.
## Layout mode
`ctrl+l` turns layout mode on: a bar under the header names the layout and the splits on this tab, and a divider
shows between the two sides of each split. The other keys work as usual until `esc` or `ctrl+l` again.
| In layout mode | |
|---|---|
| `โ` `โ` | move this tab's side-by-side split 5% (`shift` for 1%) |
| `โ` `โ` | move this tab's stacked split 5% (`shift` for 1%) |
| drag a divider | move that split with the mouse |
| `s` | save into the current layout (on `default`, asks for a name) |
| `a` | save as a new layout; an existing name is replaced |
| `n` | switch to the next saved layout |
| `x` | put this tab's splits back to the built-in sizes |
| Split | Tab | Sides |
|---|---|---|
| `trade.h` | Trade | markets โ book and order form |
| `trade.v` | Trade | markets and book โ positions, orders, leaderboard |
| `lab.diag` | Lab > Diagnostics | diagnostic runs โ maker replay |
| `book.h` | Sportsbook | the book's TOML โ the result |
| `model.strength` | Model > Strength | the grid โ adjustments |
| `model.strategy` | Model > Strategy | taker and Edge Finder โ the maker replay |
| `model.quicklook` | Model > Quick look | predictors โ the fit and the latest round |
| `model.results` | Model > Results | breakdown โ the Lab's strategies |
| `model.dash` | Model > Dashboard | P&L curve and metrics โ the charts |
A side gets 10% to 90%. Layouts are kept in `~/.config/racinglines/tui-layouts.json` (`RACINGLINES_TUI_LAYOUTS`
overrides the path) with the one last used, which the next start opens in. `default` is built in and can't be
overwritten. Leaving layout mode without saving keeps the sizes until you quit. `racinglines tui --layout NAME` opens
in a saved or built-in layout.
Three more built-ins come with the [Model workbook](#model-workbook-experimental), and each one turns
`--experimental` on: `robp` opens on the Model tab and labels each sheet with Rob's own tab name ("Inputs
(rCorsi)"), with the whole key in the Model header; `stratdash` opens on the Model tab's Dashboard sheet; `cycling`
opens the Model tab on road cycling (`uci_road_wt`, [other sports](#other-sports-on-the-model-tab)), or, on a server
that doesn't list it, on the first competition with the notice "road cycling isn't on this server (0 events): run the
TUI with --local on the Mac". None can be overwritten, and they are listed only with the Model tab on; `n` cycles
through them in layout mode and the bridge's `tui_layout` switches to them.
## Claude Code
Claude Code (or any MCP client on the same machine) can see what the TUI shows, move it, and keep the notepad, through
`racinglines tui bridge`, a small MCP server over stdio:
```
claude mcp add racinglines-tui -- racinglines tui bridge
claude mcp add --transport http racinglines https://mcp.racinglines.bet/mcp # the data, as for any chat client
```
Use the `racinglines` from the environment the TUI runs in (`.venv/bin/racinglines` with a full path if Claude Code
starts elsewhere). Run the TUI in one terminal pane and Claude Code in another: "why is this market amber?" or "open
that run" then mean what is on the screen.
| Tool | Does |
|---|---|
| `tui_context` | the front tab, each visible grid with its columns, selected row and first rows, the text strips, an open dialog, the layout |
| `tui_tab`, `tui_open`, `tui_select`, `tui_trade_search`, `tui_back`, `tui_refresh`, `tui_layout`, `tui_notify` | move the app: a tab, an event / run / job / athlete, a row (optionally opened), a Trade search, close a dialog, reload, a saved layout, a message in the corner |
| `notepad_read`, `notepad_write`, `notepad_list` | the notes, also with the TUI closed; writing appends unless told to replace |
Nothing here places an order, runs a pull or answers a confirmation: orders stay in the Trade tab, by hand.
**How it connects.** The running TUI listens on a Unix socket, `~/.cache/racinglines/tui/control.sock`
(`RACINGLINES_TUI_SOCKET` overrides; mode 600, so only your user), one JSON line per request. `--no-control` turns it
off. A second TUI leaves the socket to the first and says so.
**Notepad.** Markdown files in `~/.local/share/racinglines/notepad/` (`RACINGLINES_NOTEPAD_DIR` overrides):
`strategy.md`, `daily/.md`, `weekly/-W.md`. The bridge tells Claude to read the strategy, week and
day notes at the start of a session and to append dated lines rather than rewrite yours. The tab saves with `ctrl+s`
and reloads a note Claude changed when it has no unsaved edits. A note is not a paper lock: an event's locked strategy
still goes in `paper/locks/-.toml` ([Paper trading](paper-trading.md)).
## Model workbook (experimental)
```
racinglines tui --experimental # or RACINGLINES_TUI_EXPERIMENTAL=1
racinglines tui --layout robp # the same, with Rob's tab names next to the sheets
racinglines tui --layout stratdash # the same, opening on the Dashboard sheet
racinglines tui --local --layout cycling # the same on road cycling, on the Mac's database (its data lives there)
racinglines tui --experimental --no-readme # skip the README at launch (or RACINGLINES_TUI_README=0)
```
The Model tab (`ctrl+o`, last in the tab bar) is the Lab from scratch: every tunable step of the pipeline, with the
model's own output before and after. Each sheet is one stage of the global model with that stage's knobs, and the
numbers on it are the model's, from an event-diagnostic run (`run_job("f1_diagnostic")` โ `get_diagnostic`), never a
regression fitted in the terminal. `<` and `>` move between the sheets. Without the flag the tab, its key and the two
layouts don't exist, and nothing else in the app changes.
At launch with the Model tab on, its README for Rob (`racinglines/tui/model/README.md`, shipped with the package)
opens in a dialog first; OK, enter or escape close it, and the command palette's `readme` (ctrl+p) opens it again.
**The header**, shared by every sheet:
- competition, season, the Quick look window (season to date or last N rounds, N, k) and the account (Wagers,
Results, Dashboard);
- the sizing: **one bankroll** (default F$1,000) and the **unit** as a % of it (default 1%), every money column also in
units, "units first / F$ first"; saved with the adjustments, so they survive restarts;
- **event** (the next race when it has a diagnostic, else the latest event that has one; from `list_diagnostics`),
**cutoff** (that event's last diagnostic cutoff, `YYYY-MM-DDTHH:MM` UTC), **baseline run** (that event's latest
diagnostic; type another run id and Enter), **tuned** (the run the last job here saved) and **Run**.
**Run** queues `f1_diagnostic` for the event and cutoff with every sheet's model knobs (only the ones changed from the
defaults travel; sims, half-life, track and calibration power are the job's own knobs, the rest go in its `settings`),
then polls `get_job` every 3 s and shows "running job #31 ยท 0:42 ยท the log's last line" in the header (no dialog).
When the job is done the tuned run loads and every sheet shows it next to the baseline. One job at a time. Knob
defaults and ranges come from the server's setting schema (`list_job_types().sweep_settings`, groups model, taker,
maker), so the sheets can't drift from the server; an out-of-range value is refused with the range. The knobs start
from the baseline run's own settings (`params.model_settings`). Only F1 has an event-diagnostic job; road cycling tunes through the walk-forward
backtest ([other sports](#other-sports-on-the-model-tab)); for another competition Run is off, the header says "tuning needs an event-diagnostic job for this sport", and the read-only
sheets (Race sheet live, Wagers, Results, Dashboard, Quick look) still work.
| Rob's tab | Sheet | Knobs | What it shows |
|---|---|---|---|
| rCorsi | Inputs | track_features, practice_prior | the model's input layers (results, qualifying, grid, practice, laps, sectors, from `sports/f1.toml`) with the event's rows per session (`get_event().rounds`; laps "โ", no laps tool yet); per layer whether a knob switches it ("per-run layer switch: not a job setting yet" for the others); the finish model's coefficients by feature (g, g_ease, rp_rel, rp_ease, qp_rel, street_g, plus g_log when the grid is known) tuned, baseline and change; sigma; Brier and log loss per kind, model against the exchange at the cutoff, tuned against baseline |
| 1617 stats + Rankings | Strength | half_life_days, ridge_team, ridge_slope, driver_prior_n, reset_weight, variant | per driver win / podium / top 10 / expected points, tuned, baseline and the change (green up, red down), sorted by win; vs mate (the driver's expected points minus the teammate's, in points; win probability for a run without expected points); a constructor roll-up (the sum of its drivers'); grid penalties and car changes as notes; the driver swap dialog |
| goalies | Simulation | sims, teammate_corr, finish_rho_scale, seed, track_features | the same grid, sigma, and the coherence check: each kind's probabilities summed against what they should sum to (1 winner, 3 on the podium, 1 top constructor), red when off |
| | Calibration | calib_power (walk_forward, a number from 1 to 10, or off) | the scores before and after; a reliability strip: the run's win probabilities binned (0-5%, 5-15%, 15-30%, 30%+) with the hit rate from the result ("one event: n is small") |
| Schedule | Race sheet | tiers | the tuned run's markets (else the baseline's): fair, the exchange at the cutoff (`pm_cut`), edge, side, units by tier, stake, โ
, the run's paper side and paper P&L; or any event's live `list_markets` as before (pick it in the event list); Enter searches Trade |
| | Strategy | taker: min_edge, stake_per_edge, max_stake, kelly, bankroll, max_deployed; maker: half_spread, size, max_pos, skew, max_disagree | the run's paper P&L; the taker on the run's markets at the cutoff price (computed here, no fee); the Edge Finder's season sweeps (`edge_finder`); **Replay maker** re-quotes the tuned run against the real tape (`replay_maker`, nothing stored). These knobs never queue a job |
| Wagers | Wagers | source, filters | every bet from paper fills, positions or hand trades: entry, fair, close, CLV in points and %, result, stake and P&L |
| Tracking | Results | breakdown | the track record, hit rate, % beat close, ROI by CLV sign, breakdowns by constructor, driver, kind, strategy, venue or event, the Lab's strategies |
| | Dashboard | | the account's paper trades: cumulative P&L, totals, a weekday ร hour heatmap, entry prices, kinds, sizes, daily volume |
| rSC | Quick look | target, predictors, N, k | a side-car, **not the model**: a least-squares fit of the next finish on simple stats from earlier rounds (grid, form, season, prior season, car, teammate delta, DNF rate, gains per lap, chase, sprint; laps led waits for a laps tool), Rยฒ each and together |
**Adjustments** (Rob's injuries): a grid penalty (driver, places or "back", reason) or a car change (constructor,
delta, note), added on Strength, `x` removes the selected one. They live in
`~/.local/share/racinglines/notepad/model/adjustments.json` (under `RACINGLINES_NOTEPAD_DIR`) and show as notes on
Strength and the Race sheet; the model doesn't read them yet, so the numbers stay the run's.
**Driver swap and reserves** (Rob's goalie swap, a dialog on Strength): a side-car estimate on Quick look's ratings,
not the model. Replacement level is the mean rating of the field's bottom quartile; a reserve with race rows in
`get_athlete` is shrunk hard to it (k = 20), one with none is replacement level ("replacement level, no data").
**CLV** follows `racinglines backtest clv`: direction ร (close โ entry), both as the YES price, direction +1 for YES
exposure and โ1 for NO; % is that over the price paid, matched by the exact `event_key` and `market_key`.
**Computed here, not by the server:** the before/after tables, the roll-ups, the reliability bins, units, stakes, the
taker on a run, CLV, every chart and the Quick look fit. The prices, coefficients, scores, coherence and P&L are the
server's, as served. **The one write is Run**: it queues a research job (a diagnostic run, never the live prices);
nothing here orders, and Enter on a Race-sheet row only fills Trade's search.
Limits: expected points come from `get_diagnostic`'s predictions (`exp_points`: the stored column, else the row's
`extra`); a run without them shows no such column and "vs mate" falls back to win probability. The live server's `f1_diagnostic` takes the model settings once the matching
server change is deployed; until then only its own knobs (sims, half-life, track, calibration power) can be tuned, and
the others are greyed out with that reason.
### Other sports on the Model tab
A sport with no event-diagnostic job tunes through the walk-forward backtest when it is a **results-only sport on the
one engine**: its schema's `[model] inputs` are just `results` and the server's `walk_forward` job lists it. Today
that is road cycling (`road_cycling`, competition `uci_road_wt`, `racinglines tui --local --layout cycling`); the
others (MotoGP, NASCAR, downhill) keep Run off with "tuning needs an event-diagnostic job for this sport". For such a
sport the sheets say Rider and Team instead of Driver and Constructor (headers, the roll-up, the swap and adjustment
dialogs; the ids and keys are unchanged), and:
- **Header**: the event list is the season's events from `list_events` (scheduled first, then completed); there is no
cutoff (the input is off: "no cutoff: the forecast is as of its data_through"); **baseline run** is the
competition's newest `forecast` run (`list_model_runs`, editable), and the header says what Run does: "Run queues
walk_forward (global model, 2026) ยท settings travel only on the Mac CLI".
- **Strength and Simulation**: the forecast run's win / podium / top 10 per rider for the picked event
(`get_predictions` by the event's exact target; an event the run doesn't price says so, nothing is filled in). The
knob bar shows the schema's `[model.defaults]` read-only (sims, half_life_days, prior_weight, noise, uncertainty,
dnf): a priori, untuned (decision log 2026-10-12); change them on the Mac by editing `sports/road_cycling.toml` and
running `racinglines model forecast cycling --save --backup FILE`. The forecast has no expected points and its
prediction rows carry no team, so there is no expected-points column and the team roll-up and "vs mate" are "โ".
- **Inputs**: the layers from the schema (results only) with the event's coverage (`get_event().rounds`), "the one
engine fits rider strengths, not feature coefficients" in place of the coefficients, and the walk-forward run's
mean score per kind (โ1000 ร log loss per event, higher is better), tuned against baseline.
- **Calibration**: the competition's newest `walk_forward` run (`get_model_run(path="metrics.calibration")` and
`"metrics.weekends"`): its calibration table as the server stores it (n, ece, kind, brier, season, source, log loss),
with the change in brier, log loss and ece against the baseline run when both have the row (matched by kind, season
and source), and each kind's mean score in the head. The reliability bins stay "โ": there is no result join for a
forecast run. `get_model_run` pages a list at 50 rows; a longer run's scores say they are over the first 50 events.
- **Run** queues `run_job("walk_forward", {sport, model: "global", venue: "none", seasons: ""})`, follows it in
the header as F1's does, and loads the run it saved as **tuned** (the run that was newest becomes the baseline);
Inputs and Calibration then show tuned against baseline.
- **Race sheet**: `list_markets` for the picked event as for any event; none linked shows "no linked markets for this
event (Kalshi lists only the grand tours)". Wagers, Results and Dashboard are unchanged; Strategy's maker replay needs
a diagnostic run, so it says so.
The production server has no road cycling events or runs yet: the data (results, start lists) and the
forecast runs are on the owner's Mac, so this runs with `--local` there.
## Trade
Orders are fantasy F$ and reach no exchange (`fantasy_order`). Before one is sent the TUI shows the fill walked from
the recorded book (at most 10 levels, at or better than the limit) and asks to confirm. It blocks an order on a book
older than the server's `max_age_min` (15), a buy over the `max_stake` cap (F$100) or beyond the cash, and gives each
order a fresh `client_key` so a retry can't fill twice. "Search Trade" on a market outcome fills the Trade search
with its subject: a search, not a link, since a fantasy market and a matrix outcome share no exact key. The same
rule holds in the market screen's price history: it is fetched by the venue's token, else by `athlete_id` (with the
row's `exchange` when it has one), and an outcome with neither shows "no exact key" rather than a name match. The
Trade book refreshes every 20 s only while the Trade tab is in front.
## Data > Pull writes your database
Pull runs `racinglines data pull SPORT` as a subprocess and **writes to the database it names**. The TUI asks first,
showing the command and the database URL (password hidden), and makes no backup itself: take the dump before you
confirm, as in [Data changes](data-changes.md) (`data/backups/db/racinglines-before--.sql.gz`). The pull
is terminated if its worker is cancelled or the app quits.
## Lab: a sweep from an Edge Finder row
An Edge Finder row opens its season recap and the settings that differ from the defaults (`changed_settings` for its
`settings_key`). "Queue a sweep with these settings" fills the F1 sweep form with them and the row's year and venue;
only changed fields are sent with `run_job`. Edge Finder rows don't name a sport: for a NASCAR or MotoGP configuration
pick `nascar_sweep` or `motogp_sweep` before queueing.
## Limits
- `track_record` has no offset: past about 68 weekends the server cuts the page, and the equity curve says how many
of the total it covers. `get_event` carries the newest 60 predictions; `search_athletes` returns one page.
- Web features with no MCP tool are not here: promoting a scenario to the live prices, following a strategy (#218
adds it to MCP), account settings, and the private book's house markets and bets (readable with `sql`).
- The `fantasy_order` result reads `status`, `filled`, `avg_price`, `fee` and `reason`; it was the one tool not
called against the live server while building this (it places a real F$ order).
## Troubleshooting
**Stacked headers, a traceback behind the app, the screen scrolled** (the owner's terminal on 2026-10-18, below):
something wrote to the terminal while the app owned it, and the terminal scrolled under Textual's drawing. **`esc`
repaints the screen**; `r` refreshes the tab as well. If the screen stays wrong, `q`, then `clear`, then relaunch.
Since that day the app sends its own stderr and every library's logging to `~/.cache/racinglines/tui/tui.log`
(`RACINGLINES_TUI_LOG_DIR` moves it) and says so on exit when anything was written, so a traceback can no longer
land on the screen; the log says what failed.

**`map_book` refuses the sample book.** The Sportsbook tab's sample is a book file ([Sportsbook](sportsbook/index.md)):
`[book]` needs `venue`, `event`, `captured_utc`, `source`, `odds` and `currency`, and every `[[lines]]` entry its
`title`, `selection`, `odds` and `market` key. The sample is captured at the time the tab opened; edit the rest.
**"No database event for '20261002_mtb'"** from a Live tab row: that run folder is a private-book live event that was
never ingested as a database event, so there is no event page; the screen shows the row instead. `esc` goes back.
## Tests
`python -m pytest tests/test_tui_*.py` (no network, no database): payload parsing and the book walk, a real MCP stdio
round trip (`tests/tui_stub_server.py`), the CLI wiring, and every tab driven headless against payloads captured
from the hosted server on 2026-10-16 (`tests/tui_fixtures.py`). `TUI_SHOTS=` saves an SVG of each screen. The
Model workbook's pure pieces are `tests/test_tui_model_stats.py` and `tests/test_tui_model_run.py`, and its sheets
`tests/test_tui_model.py` (`FakeBackend(model=True)` serves a small hand-written season, two diagnostic runs in the
live shape and a job that finishes on its second poll, by their arguments). Road cycling's are `tests/test_tui_model_cycling.py`
(the `cycling` layout, a forecast run, two walk-forward runs in the shape `get_model_run` serves, all made up, and
the walk_forward job).
---
# Sportsbook and special-event markets: the schema (draft, 2026-10-06)
Owner decisions (2026-10-06): sportsbook analysis stays separate from the web app, CLI tools only; events are defined
by exchange-agnostic schemas; the deployment target is the hosted MCP server (`docs/mcp.md`), not the web app. The
full roadmap (coverage of the three generic sportsbooks (A: decimal odds, B and C: American odds) boards, tiers per market, phases) lives in the HL project
library; this page is the part the repo needs: the schema, the vocabulary, the CLI and the MCP surface.
The owner's rule: sportsbook analysis is CLI only, outside the app and its database for now, and events are defined by
schemas that no venue owns. The repo already defines sports (`sports/.toml`), exchanges (`exchanges/.toml`,
read by `racinglines/exchanges.py`) and input frames (`racinglines/frames/schema.py`) as data with a validator. The
proposal adds three more files of the same kind. Draft examples, built from real board rows, are under
[`schemas/`](schemas/) and load through `racinglines/books/schema.py` (`tests/test_books_schema.py` loads every one).
| File | One per | Holds | Example |
|---|---|---|---|
| `events//--.toml` | event | sport, season, round, the launch-spec key (`live/f1/2026-17.toml` uses `event = "2026-17"`), `race_id` (the DB's exact key), format, session start times (UTC), the market kinds the event opens and their lines. **No venue, no prices, no entrants** (entrants come from the DB entry list for `race_id`) | [`events-f1-2026-17-singapore.toml`](schemas/events-f1-2026-17-singapore.toml) |
| `venues/.toml` | venue | code, name, `kind = "sportsbook" \| "exchange"`, odds format, currency, who settles, observed caps and voids as notes, and `[[rules]]`: regex on the venue's market title โ kind + subject, exactly the shape of `exchanges/og.toml` `[[sports.f1.rules]]`. Selections resolve to the entry list through an exact alias table ("Sainz Jr, Carlos" โ "Carlos Sainz"), never fuzzy; an unknown name is `unmapped` | [`venues-book_a.toml`](schemas/venues-book_a.toml) |
| `books//-.toml` | capture | venue, event key, capture time (UTC), source (screenshot / paste / api) and its reference, odds format, and `[[lines]]`: the venue's own `title` and `selection` untouched, the odds, a transcription `note`, and the `market` key the rules produced, written into the file so the mapping is reviewable before pricing; optionally `[book] image` (the screenshot) and a line's `box = [x, y, w, h]`, the pixels on the screenshot where `book render --style hud` puts that line's label ([render](render.md#hud-placement-box)) | [`books-book_a-2026-10-05-singapore.toml`](schemas/books-book_a-2026-10-05-singapore.toml) |
| (same) | capture | A second venue in the same shape, American odds, same event file untouched: sportsbook B's 22-driver winner board from the owner's screenshot (2026-10-05 22:59 PDT). Its overround is 23.3%; the model comparison is the "track sportsbook bets" thread's job | [`books-book_b-2026-10-05-singapore.toml`](schemas/books-book_b-2026-10-05-singapore.toml) |
**Odds presentations** (`venue.odds` and `book.odds`; `racinglines/books/schema.py` checks each, `to_prob` reads a quote
as an implied probability with the venue's margin still in it, `present` writes a fair value in the venue's own units).
The names are provisional: the owner checks them after the merge ([todo: owner decisions](../todo.md#owner-decisions)).
| Name | Looks like | Where | Means |
|---|---|---|---|
| `decimal` | 1.85 | European sportsbooks (sportsbook A) | payout per unit staked, stake included |
| `american` | -118 / +150 | US sportsbooks (sportsbooks B and C) | stake to win 100, or the win on a 100 stake |
| `fractional` | "7/4" | UK sportsbooks | the win per unit staked, as a string |
| `cents` | 37 | Kalshi | price of a $1 YES contract, in cents (1 to 99) |
| `dollars` | 0.37 | Polymarket, OG.com | price of a $1 share, in dollars (0 to 1) |
| `prob` | 0.37 | our fair values | a plain probability |
**What makes it exchange-agnostic.** The market key is `(kind, subject, params)` in the kinds registry's vocabulary
(`racinglines/markets/kinds.py`), the same key Kalshi's and Polymarket's classifiers already produce and OG.com's rules
file produces. A sportsbook A line, an sportsbook C American-odds line and a Kalshi contract for the same thing carry the same key,
so one fair value prices all three and one `settle()` decides them. A new venue is a `venues/.toml`; a new market
type is a kind (with `fair` and `settle`) and one rule line per venue that lists it. Exchanges' own API schemas stay in
`exchanges/`; a `venues/` file for an exchange would hold only its title rules, which for OG.com already live in
`exchanges/og.toml` (one of the two should own them: **decision**).
**The CLI around it** (all read-only against the app; nothing writes to `market_links` or any app table). `book map`, `book price` and `book settle` are built (2026-10-07), and `book export --ledger` writes a book's staked lines as ledger rows (2026-10-11, [Ledger export](slips.md#ledger-export)): [Sportsbook slips](slips.md) has their actual interface, the EV definitions and the JSON fields. `book render` (2026-10-09, [Bet-slip graphics](render.md)) draws a priced board as a HUD on the book's screenshot, a card or a book-agnostic graphic, with recommended stakes from the v2 rule in `markets/books.toml`; `book new --from-text` drafts a book file from pasted lines. The table below is the original proposal; shrink, picks CSV, writing back into the book file and updating a ledger in place are not built:
| Command | Does |
|---|---|
| `racinglines book map ` | applies the venue rules, writes `market` keys into the book file, lists every `unmapped` line and every unknown selection name |
| `racinglines book price --run ` | fair value per mapped line from a stored run's predictions (MCP `get_predictions` on the VM, or a local run id), no-vig, shrink, EV, Kelly at the configured fraction and bankroll, picks CSV; the output keeps the run id, commit and latest race in the data on every row |
| `racinglines book settle ` | `kinds.settle()` from the classification once the race is in the DB; writes result and payout per line, and updates `ledger.csv` rows that reference the book |
| `racinglines event new ` | writes the event file from the DB (race_id, sessions, format) so the exact key is never typed by hand |
Settings for the shrink (halfway to the book's no-vig price), the Kelly fraction (the owner uses half; the cycling CLI
defaults to a quarter, `racinglines/cli/cycling.py:145-146`), bankroll and the longshot filter live in one TOML with a
decision-log entry. Sportsbook lines stay out of `market_links`, the disagreement log and the app's CLV work until the
owner says otherwise; CLV for hand-placed bets is computed by the CLI from the book's own later captures (or an
exchange's close as a proxy, `docs/todo.md:279`) into the ledger's `closing_odds` column.
**Validation** (built, second commit). `racinglines/books/schema.py` in the style of `frames/schema.py`: `load_event`,
`load_venue`, `load_book` and `load` (picks the schema from the top-level table) return the parsed dict or raise
`BookError` listing every problem with its field: required sections and fields, odds that can be odds in the file's
format (decimal > 1, American an integer of at least 100 either way, prob in (0, 1)), UTC times (ISO 8601 ending in `Z`,
or `"unknown"` for a session), every `market.kind` in `kinds.KINDS` or `props.PROP_KINDS`, every rule title a compilable
regex, every line either `"unmapped"` or a market table carrying the driver / team / opponent / line its payoff needs.
`tests/test_books_schema.py` loads every example under `schemas/` (as `tests/test_exchange_schema.py` does for
`exchanges/`, `docs/exchanges.md:26`). The five classification kinds the vocabulary needed are in the registry with
`fair` and `settle` (`kinds.py`, `default=False`, so the app's summaries and the golden tests are unchanged).
**Adding a market type** (owner, 2026-10-06: "as few conditional code switches in the model, and more generalized
support functions who's inputs are set by the schemas"). The classification and retirement kinds are declarative: one
`[[kinds]]` table each in `markets/kinds.toml` with a payoff spec (`subject` driver / team / field, a `predicate` on one
car, an `aggregate` for a team or the field, `compare = "over"` for a line), priced and settled by the two generic
functions of `racinglines/markets/payoffs.py`. A new market type is a new table there plus a venue rule line; code
changes only when it needs a predicate the table doesn't have yet. The legacy kinds (win, podium, pole, head-to-head,
top constructor, standings, ...) keep their code in `kinds.py` until a later migration under the promotion rule, since
their fair values feed the golden tests and the live book.
**Combos (same-game parlays, 2026-10-07).** A slip like "Race Winner and Fastest Lap" is one line whose `market` is
`{ kind = "combo", legs = [ { kind = "race_win", driver = "..." }, { kind = "race_fastest_lap", driver = "..." } ],
void_leg = "void_all" }`: two or more legs, each a market table of a single kind (no combo inside a combo), and the
book's rule for a void leg (`"void_all"`, the default: a void leg voids the slip; `"drop_leg"`: the other legs decide).
`racinglines/markets/combos.py` prices it as the share of simulations in which every leg holds, from the same
simulations as the single markets (`combo_fair`; `price` adds each leg's marginal, their product and the lift), so legs
that move together price above the product. Every kind decided per simulation can be a leg: win / podium / top n, the
declarative kinds through their spec predicates, pole and the sprint stages, head-to-head, top constructor (a points tie
splits it), biggest mover, and the fastest lap when the run draws it (variant `fastlap` or `flpos`). A season standings
market or a history-rate prop (safety car, red flag, rain) can't be a leg: pricing raises an error naming it.
Settlement takes each leg's own rule; any losing leg settles the combo NO, and a fastest-lap leg otherwise returns
None (a manual settlement: the classification records no lap times). On the VM the Lab job `f1_combo` prices combos
for one event (MCP `run_job`, then `get_model_run`; [docs/mcp.md](../mcp.md)).
**What the exchanges list today, for the rules files.** Polymarket's safety car / red flag / rain questions are linked but
unpriced (`docs/f1.md:178-181`); Kalshi sprint markets price from the stand-in; Kalshi retirements and "race occurrence"
are `unmodeled` (`kalshi/sync.py:25-28`); OG.com lists only champions and Race Winner (`docs/exchanges.md:34-40`).
Polymarket has listed no F1 race since 28 Aug 2026 (`docs/coverage.md:10`), so near-term exchange coverage means Kalshi
and OG.com.
## MCP surface (the deployment target)
The CLI commands above are the same functions the hosted MCP server exposes, so a thread can price a board from the
VM's current run without a cloud rebuild (owner, 2026-10-06: "the eventual deployment target is to add these
sportsbook/special event analytics to the MCP tools"). Proposed tools, beside the existing `list_markets`,
`get_predictions` and `run_job` (`racinglines/mcp/`):
| Tool | Does |
|---|---|
| `price_book(book_toml, run_id=None)` | the `book price` command on a book file passed as text: mapped lines with fair, no-vig, EV and stake; unmapped lines listed |
| `map_book(venue, lines)` | applies a venue's rules to raw `(title, selection, odds)` rows and returns the market keys, so a thread can transcribe a screenshot and get a reviewable mapping back |
| `settle_book(book_toml)` | results per line from the classification once the race is in |
| `list_kinds()` | the vocabulary: every kind, its status, subjects and known venue wordings (`vocabulary.md`) |
The sportsbook files themselves are not stored in the app's database: a book is passed in and the priced table comes
back, and the owner's ledger stays a file outside the app.
## Vocabulary
[vocabulary.md](vocabulary.md), generated from [`schemas/kinds-vocabulary.toml`](schemas/kinds-vocabulary.toml):
49 kinds, 29 of them in the registry (which holds 30: 17 before this branch, 13 declared in `markets/kinds.toml` on it: classified, last classified, both cars classified, at least one car classified, both cars in the points, number classified, winning constructor, retire, number of retirements, first, second and third retirement, first retiring constructor), 3 in `props.py` only, 16 proposed, 1 with no data source; 17 seen on two or more
venues, 20 seen only on sportsbook A.
---
# Sportsbook slips: EV against the model and the market
`racinglines book map | price | settle` takes a generic sportsbook's lines and slips, written as a book file
([schema](index.md)), and puts three prices beside each bet: **the model's fair probability**, **the linked
prediction-market price** (Polymarket, Kalshi, OG.com) wherever a linked market exists, and **the book's implied
probability** from its odds. It reports the expected value (EV) against the model and against the market, side by
side. F1, NASCAR and MotoGP work the same way: kinds come from the kinds registry (`racinglines/markets/kinds.py`
`KINDS`), sports from `sports/.toml`, and there are no per-sport branches.
Owner decisions this follows: sportsbook analysis is CLI only and stays out of the web app (2026-10-06), and the
command never writes to the database. Every query runs in a `READ ONLY` transaction. The functions behind it
(`racinglines/books/slips.py`: `map_book`, `price_book`, `settle_book`) are what the MCP tools `map_book`,
`price_book` and `settle_book` wrap, with the book passed in as text (`slips.load_text`).
## Two ways to find edge
The model's fair probabilities are useful in two places, and the data behind both is the same:
1. **Prediction markets.** Compare the model with Polymarket and Kalshi directly and take the +EV side on the
exchange. That is the app's own work (the Markets board, paper trading, the maker and taker strategies) and the
MCP tool `list_markets`, not this command.
2. **Sportsbooks.** Compare a sportsbook's odds with the model *and* with the exchanges' prices for the same outcome.
`book price` puts both EVs on every line, `EV model` and `EV market`. A line that is +EV against both is the
strongest case. A line that is +EV only against the model is a bet on the model being right where the market
disagrees. A line that is +EV only against the market is a soft book line the model doesn't back.
## Three ways in
| Mode | Flag | Needs | For |
|---|---|---|---|
| **Demo** | `--demo [PACK]` | Postgres (`docker compose up -d`); no data, network or account | A first look. The kart pack's book (`racinglines/demo/packs/kart/book.toml`, a Mario Kart grid) priced by the real code on a local demo store of synthetic data, built on first use, showing each F1 pricing feature ([Quickstart](../quickstart.md)). `--demo --remote` prices it on the pack's hosted demo store instead, with no local database ([Demo data packs](../demo.md#the-hosted-demo-store)). |
| **Hosted** | `--remote` | A racinglines.bet account with MCP access ([MCP: Hosted](../mcp.md#hosted-the-vm)) | Your own book file priced on the VM's live data through the hosted MCP server. The first run opens your browser to sign in. |
| **Local** | `--local` (the default) | Your own database with the data rebuilt (or your own server, below) | Everything, including `--run` and `--sims` |
```
# LOCAL (Mac), or any machine with the package and Postgres: the demo
racinglines model forecast f1 --demo --save # the model run the book's fair values come from
racinglines book price --demo # Rich tables by default; --json for the raw result
racinglines book settle --demo
```
```
# LOCAL (Mac): your own book file on the VM's data (the first run opens the browser to sign in)
racinglines book price FILE --remote --rich
```
```
# LOCAL (Mac) or VM: your own database
racinglines book map FILE [--json] [--db URL]
racinglines book price FILE [--json] [--db URL] [--run ID ...] [--sims RACE_ID=FILE.npz ...] [--asof UTC]
racinglines book settle FILE [--json] [--db URL]
racinglines book render FILE --style hud|card|agnostic [--png] [--json-in PRICED.json] # render.md
racinglines book export FILE --ledger OUT.csv [--force] [--db URL] [--run ID ...]
racinglines book view RESULT.json
```
!!! warning "Remote access is in testing"
racinglines.bet remote access is in testing (#aintcare mode). A review of venue and data-feed terms is pending,
so data served here is for testing only. Every command that connects to the hosted server prints this warning on
stderr (`racinglines/cli/remote.py`).
**Signing in for `--remote`** (`racinglines/cli/remote.py`) uses the same OAuth flow Claude's connector uses. The
first `--remote` prints a racinglines.bet URL and opens it. You sign in if needed and press **Allow** once. The CLI
then keeps the login in `~/.config/racinglines/mcp-login.json` (mode 600, one entry per server;
`RACINGLINES_MCP_LOGIN` moves it). It refreshes the access token on its own and asks again after 30 days of disuse
or a **Disconnect all** in Settings. `racinglines book logout` forgets it. For scripts and machines with no browser,
an account's `rl_` token in `RACINGLINES_MCP_TOKEN` is used instead (Settings > Connect Claude (MCP) > Get a token),
read without echo:
```
# LOCAL (Mac): a token instead of the browser sign-in
read -rs -p "rl_ token: " RACINGLINES_MCP_TOKEN && echo && export RACINGLINES_MCP_TOKEN
racinglines book price FILE --remote
```
**Your own server as the default** (`racinglines/cli/config.py`). If you host the data and the MCP server yourself
([MCP: Once: host the server](../mcp.md#once-host-the-server)), or you only ever use one mode, set the defaults
once in `~/.config/racinglines/cli.toml` (`RACINGLINES_CLI_CONFIG` moves it; written mode 600):
```
# LOCAL (Mac): defaults for every `racinglines book` command
racinglines book config source remote # local (default) | remote | demo
racinglines book config mcp_url https://mcp.example.com/mcp # the server --remote uses
racinglines book config db postgresql+psycopg://... # the database local mode uses when DATABASE_URL is unset
racinglines book config # show them (the db URL is shown as )
racinglines book config mcp_url --unset # remove one
```
Flags win over environment variables, which win over the file: `--demo / --remote / --local` > `source`;
`--mcp-url` > `RACINGLINES_MCP_URL` > `mcp_url` > `https://mcp.racinglines.bet/mcp`; `--db` > `DATABASE_URL` > `db`.
| Command | Does |
|---|---|
| `map` | Resolves every line's legs to a race, athletes and a kind **by exact keys**, and lists every line that stays unmapped with the reason |
| `price` | `map`, plus per leg the model's fair, the market price and, when the leg carries its own odds, the book's implied probability and both EVs; per line (a single or a slip), the payout, the book's, model's and market's probability of the whole bet, how each was computed, flags and both EVs |
| `logout` | Forgets the `--remote` browser sign-in (`--mcp-url` for one server, else all) |
| `config` | Shows or sets the defaults file (above) |
| `view` | Renders a saved `--json` result of `map`, `price` or `settle` the way `--rich` does, with no database (`-` reads stdin), so a result made on the VM can be read anywhere |
| `settle` | `map`, plus each leg's result from the stored classification (`kinds.settle`, through `private_book.outcome_for`), and each line's result, payout and profit per unit staked |
| Option | Meaning |
|---|---|
| `--json` | The whole result as JSON (fields below) |
| `--rich` | A coloured terminal table ([Rich](https://github.com/Textualize/rich), `racinglines/cli/rich_view.py`) instead of plain text: one row per line with a slip's legs nested under it, EV and results green above zero and red below, flags, unmapped lines and assumptions in panels |
| `--db URL` | The database for local mode (default `DATABASE_URL`, else `db` in the defaults file) |
| `--demo [PACK]` | A demo store (`racinglines/cli/source.py`): a local database of synthetic data from a data pack (default kart), built on first use; FILE defaults to the pack's book. `--db` doesn't apply |
| `--remote` | Run on the hosted MCP server instead: the book file is sent to its `map_book` / `price_book` / `settle_book` tools ([MCP: Hosted](../mcp.md#hosted-the-vm)), which return the same result, so `--json`, `--rich` and plain text work unchanged. Read-only, like every MCP read. `--run` is passed on; `--db` and `--sims` are local only |
| `--local` | Your own database, even when the defaults file says otherwise |
| `--mcp-url URL` | The server for `--remote` (default `RACINGLINES_MCP_URL`, else `mcp_url` in the defaults file, else `https://mcp.racinglines.bet/mcp`, the production VM; staging runs no MCP server) |
| `--run ID` | Price the legs of that run's competition from this model run (repeatable, one per sport). Default: the app's own choice per race, `markets/venues.pricing_run` (the live stage or forecast run before the race, the latest as-of run made before the start once it has run) |
| `--asof UTC` | Price the book as it stood at that moment (local only): each race's newest stage or forecast run cut off by then (its `cutoff`, else when it was made) unless `--run` names one, and each exchange leg's last tape price by then (the stored price history and its Parquet archive), with no bid or ask. For following a book through a weekend after the fact, e.g. a replay's picks (`strategy signals f1 --asof T --book`, [Paper trading](../paper-trading.md#the-signal-engine)) |
| `--sims RACE_ID=FILE` | An `OutcomeSims` archive (`models/outcomes.save_sims`) for one race. Legs on that race are then priced jointly from it (repeatable) |
## The book file
The book file uses the [schema](index.md) as before, with these optional additions:
- `[book] sport`: the sport of the book's `event` key (`sports/.toml`). Without it, a leg must name its own
`sport` or `race_id`.
- `sport`, `event` and `race_id` on a market table or a leg: that leg's event, so one slip can hold legs on F1,
NASCAR and MotoGP.
- `driver_id` and `opponent_id`: athlete ids instead of names.
- `odds` on a combo's leg: a parlay with no quoted odds of its own pays the product of its legs' odds (the line's
`odds` may then be left out).
- `id` on a line: the name that output and errors use (default: its 1-based position).
- `stake` on a line: the bet placed, a number above 0 in `book.currency` (a Kalshi `cents` book: dollars). Read by
`book export`; a line without one is a quote, not a bet. It is not the recommended stake `book render` prints
([Bet-slip graphics](render.md#sizing-the-v2-rule-as-data)), which is never written into the file.
- `placed_utc` on a line: when the bet was placed (`2026-10-03T22:32Z`), else the book's `captured_utc`.
- `[aliases]`: an exact table from the venue's spelling to our display name (`"Alpha, Ann" = "Ann Alpha"`).
- `box = [x, y, w, h]` on a line and `[book] image`: where `book render --style hud` puts the line's label on the
book's screenshot ([Bet-slip graphics](render.md#hud-placement-box)). Pricing ignores them; `--json` carries `box`.
A slip is a line whose market is `kind = "combo"` with `legs = [...]` ([combos](index.md), `markets/combos.py`).
This synthetic example has a single and a cross-sport parlay:
```toml
[book]
venue = "book_x"
event = "2026-17"
sport = "f1"
captured_utc = "2026-10-07T09:00Z"
source = "paste"
odds = "decimal"
currency = "USD"
[[lines]]
id = "win"
title = "Race Winner"
selection = "Ann Alpha"
odds = 3.0
market = { kind = "race_win", driver = "Ann Alpha" }
[[lines]]
id = "parlay"
title = "Parlay"
selection = "Cora Cup win + Dan Duke win"
market = { kind = "combo", legs = [
{ kind = "race_win", driver = "Cora Cup", sport = "nascar", event = "2026-30", odds = 2.0 },
{ kind = "race_win", driver = "Dan Duke", sport = "motogp", event = "2026-18", odds = 4.0 },
] }
```
## Mapping: exact keys only
- **Race.** `race_id` (the database's `races.id`), or `(sport, season, round)`: the event with that `series_round` in
the sport's competition and season, in the competition's first category (`sports/.toml`
`[competition] categories`). No match, or more than one, leaves the leg unmapped.
- **Athlete.** An id in the race's field, or a name equal to a display name in it, after the book's exact
`[aliases]` table. The field is everyone in the race's predictions, results or linked markets. Case,
accents and surnames are not matched loosely. A near miss is unmapped and the reason names it.
- **Kind.** The book file's `market.kind`, checked against the registry when the file loads. A race prop (safety car,
red flag, rain) maps, but no stored run prices it.
- A line whose market is `"unmapped"`, or any of whose legs is unmapped, is listed under `unmapped` and is not
priced or settled.
## What EV is computed against
For a bet with decimal payout `d` (the stake included: `d = 1 / implied probability`, whatever the odds format) and
probability `p`, EV per unit staked is `p x d - 1`. `edge` is `p - 1/d`.
| Column | Probability |
|---|---|
| `book_prob` | The book's implied probability, `1 / d`, with the book's margin still in it |
| `model_prob` | The model's fair probability from a stored run (`db/reads.model_prob`, the same read the board uses for exchange links), with the leg's side applied (`no` and `under` are the complement) |
| `market_prob` | The linked exchange market's price for the same key (`market_links`: same race, kind, athlete and opponent / team / line; an h2h listed the other way round counts as its complement; an inverted link is flipped). The mid of bid and ask, else the link's last price, else the tape's last price (`markets/store.last_before`). When several exchanges list it, the most recently synced one is used and every quote is listed under `quotes` |
| `ev_model`, `ev_market` | EV against `model_prob` and against `market_prob` |
**Slips.** The payout is the line's quoted odds, else the product of the legs' odds. The model's probability of the
slip:
1. **Legs on different races are independent.** The slip's probability is the product of the races' probabilities.
This is an assumption, and the output says so (`assumptions`, and a flag on the line).
2. **Legs on the same race are correlated** (a winner is on the podium). They are priced jointly from simulations
when they are available: an `OutcomeSims` passed with `--sims` (`markets/combos.combo_fair`: the share of
simulations in which every leg holds), else the newest stored combo run (the Lab job `f1_combo`, `model_runs.kind
= 'combo'`) holding exactly these legs (its `calibrated: false` flags are passed through). Otherwise the product
of the legs' marginals is used, and the line is flagged **`correlated: EV approximate`**. No correlation model is
invented here.
3. The market's slip probability is always the product of its legs' prices. No exchange lists the slip itself. When
legs share a race, it carries the same flag.
## Model and market: no blend
Both prices and both EVs are shown side by side, and nothing combines them. The owner decided on no blend
(2026-10-07); a blend later would need a new row here in the [F1 roadmap decision log](../f1-roadmap.md) format.
| Date | Decision | Status |
|---|---|---|
| 2026-10-07 | Blend of model fair and prediction-market price for sportsbook slip EV: **none**. `book price` shows both prices and both EVs side by side | Decided (owner) |
## Settlement
Each leg follows the single market's own rule (`kinds.settle`, through `private_book.outcome_for`, the same rule
`markets/combos.settle` applies to a combo's legs), and its side is applied:
| Leg result | When |
|---|---|
| `won` / `lost` | The stored classification decides it |
| `void` | The classification is stored but cannot decide it (for example, an h2h opponent who did not start) |
| `manual` | The results do not record it (the fastest lap, a race prop) |
| `pending` | No classification is stored for the race yet |
For a line: any lost leg loses it (payout 0). Otherwise a pending leg leaves it pending, and a manual leg leaves it
manual. A void leg voids the line and returns the stake (payout 1) under `void_leg = "void_all"`, the default. Under
`"drop_leg"`, the other legs decide, and the payout is re-priced from their odds when the legs carry odds; otherwise
the payout is left to the book. A won line pays `d`. Payout and profit are per unit staked. Cancelled-race venue
rules (`markets/settlement_rules.py`) are not applied here.
## Ledger export
`racinglines book export FILE --ledger OUT.csv` (`racinglines/books/ledger.py`) prices and settles the book (local,
`--demo` or `--remote`, as the other commands) and writes its staked lines as ledger rows, one per bet, with the
columns `date_utc, sport, event, venue, market, selection, stake, currency, odds_taken, our_fair_prob, closing_odds,
result, payout, follower, notes`. It writes OUT only: it refuses an existing file unless `--force` is given and never
appends. Lines without a `stake` are skipped and counted on stderr.
| Column | From |
|---|---|
| `date_utc` | the line's `placed_utc`, else the book's `captured_utc`, to the minute |
| `sport`, `event` | the line's legs as mapped: `- ` (e.g. `2026-16 Malaysia GP`), joined with ` + ` across races; an unmapped line gets the book's event key, said in `notes` |
| `venue`, `currency` | the book's, as given (never converted) |
| `market`, `selection`, `stake` | the line's title, selection and stake |
| `odds_taken` | decimal odds, the payout per unit staked; the book's own quote goes in `notes` when it is not decimal |
| `our_fair_prob` | the model's probability of the whole bet (`book price`), blank without one |
| `closing_odds`, `follower` | blank |
| `result` | `won`, `lost` or `void` from our own results (`book settle`, noted `settled: ours`); otherwise `open`, with the reason (pending, manual, unmapped) in `notes` |
| `payout` | the total returned: won = stake ร the line's payout, lost = 0, void = the stake, open blank |
An exchange quote (`cents`, `dollars`) also notes the number of $1 contracts the stake buys. A `cents` book must have
`currency = "USD"`, since a Kalshi contract pays $1; anything else is refused, so a stake can't be written in cents.
The row builder (`ledger.rows`) returns dicts, so a database table can take the same rows.
## JSON output
`--json` prints one object. Its keys are stable, every key is always present, and a value that does not apply is
`null`.
- `book`: `venue`, `event`, `sport`, `captured_utc`, `odds`, `currency`.
- `lines[]`: `line` (1-based), `id`, `title`, `selection`, `kind` (`combo` for a slip), `status`
(`mapped` / `unmapped`), `reason`, `n_legs`, `races` (race ids), `odds`, `decimal_odds`, `book_prob`, `model_prob`,
`model_method` (`single`, `product`, `joint: simulations passed in`, `joint: stored combo run N`, joined by ` x `
across races), `market_prob`, `market_method`, `flags`, `edge_model`, `edge_market`, `ev_model`, `ev_market`,
`result`, `payout`, `profit`, `box` (the line's HUD box, or null), `legs`.
- `lines[].legs[]`: `line`, `leg` (`"3"` or `"3.2"`), `status`, `reason`, `sport`, `competition`, `season`, `round`,
`event_key`, `race_id`, `event`, `kind`, `side`, `athlete_id`, `athlete`, `opponent_id`, `opponent`, `team`,
`threshold` (an over/under line), `odds` (the leg's own). `price` adds `book_prob`, `run_id`, `run_source`,
`model_prob`, `model_note`, `market_prob`, `market_exchange`, `market_bid`, `market_ask`, `market_synced_utc`,
`ev_model`, `ev_market` and `quotes[]` (`exchange`, `link_id`, `token_id`, `prob`, `bid`, `ask`, `closed`,
`synced_utc`: when the price is from, the newest recorded book when it is newer than the link's last sync). A
market price more than 3 h old adds a line flag (`... may be out of sync`). `settle` adds `result`.
- `unmapped`: the ids of the lines that are not mapped.
- `price` only: `assumptions` (the list above) and `blend` (`"none: ..."`).
Probabilities are fractions, and EV and payout are per unit staked. Tests: `tests/test_book_slips.py`, a synthetic
book on F1, NASCAR and MotoGP races in the Postgres test database.
## Jev decision-chain demo (research, paper only)
`scripts/research/jev_book_demo.py` runs a layered [Jev](https://typesafe.ai) chain over a priced book as it stood
before an event (`book price --asof`), then scores it against `book settle`. Jev sees only our fair price, the
exchange price and the line (names withheld; Python does the EV arithmetic). Layers, each asked only if the one before
passed: `plus_ev` (Noul, gate 0.70), `trust` (fair, exchange or neither; confidence gate 0.60), `sizing` (one of
`flat_1u`, `kelly_eighth`, `kelly_quarter`, `kelly_half`, capped at 3 units of a 100-unit bankroll) and `final`
(place or pass). The report compares it with take-all, +EV-on-both and quarter-Kelly baselines.
```
# LOCAL (Mac): plan only, no calls, nothing written
.venv/bin/python scripts/research/jev_book_demo.py --priced PRICED.json --settled SETTLED.json --dry-run
# LOCAL (Mac): real run, needs TYPESAFE_API_KEY; at most --budget calls (default 200), cached so a rerun resumes
.venv/bin/python scripts/research/jev_book_demo.py --priced PRICED.json --settled SETTLED.json --out data/runs/jev_book_demo/run1
```
Gate values are untuned defaults, the sample is one event of hand-supplied lines, and nothing here is a trading claim.
---
# Bet-slip graphics: `racinglines book render`
`racinglines book render` turns a priced board into one self-contained HTML page, and a PNG when a headless Chromium
is around, in three styles. It reads the same prices as [`book price`](slips.md): the model's fair probability from
the app's pricing run, every linked exchange quote (Polymarket, Kalshi, OG.com) with its sync time, and the book's
odds. Sizing comes from a settings table (`markets/books.toml`), not from code. The command is CLI only and read-only
on the database, like the rest of `racinglines book`. Code: `racinglines/books/render.py` and `render.css`. Tests:
`tests/test_book_render.py` (no database).
| Style | Shows |
|---|---|
| `hud` | The book's own screenshot with our label on each line: the stake or PASS, the model's chance, the market price used and its exchange, and the EV against the model and against the market. Each label sits in the line's `box`. Lines without a box are listed in a side panel next to the screenshot, with the totals and the sizing rule |
| `card` | The same content per line in the racinglines dark style (1200 px wide), grouped by market kind, without the screenshot |
| `agnostic` | No book odds and no stakes. Per selection: a bar for the model's chance, a tick per exchange where its quote is tight, and the minimum decimal odds worth taking. The title is neutral ("Matchups and potential winners to watch") |
Every output ends with the same footer: the model run ids and their source, each exchange's latest sync time, the
board's capture time (not on `agnostic`), "calibration unvalidated" (the C48 calibration check is not done), "Not
betting advice", and racinglines.bet.
## Commands
```
# LOCAL (Mac) or VM โ price from the database and render
racinglines book render books/book_a/2026-10-10-singapore.toml --style hud --png
# LOCAL (Mac) โ render a saved `book price --json` result, no database needed
racinglines book price books/book_a/2026-10-10-singapore.toml --json > priced.json # on a machine with the DB
racinglines book render --json-in priced.json --style card --png
racinglines book render books/book_a/2026-10-10-singapore.toml --json-in priced.json --style hud --png # boxes and image from the file
# LOCAL (Mac) โ road cycling: the folder `racinglines model price cycling` wrote
racinglines book render reports/2026-10-10-il-lombardia --style agnostic --png
# LOCAL (Mac) โ a book file draft from pasted lines
racinglines book new --from-text board.txt --venue book_a --event 2026-17 --odds decimal --out books/book_a/2026-10-10-singapore.toml
```
| Option | Meaning |
|---|---|
| `FILE` | A book file (priced through `price_book`, as `book price` does), a `cycling price` output folder or one of its CSVs, or a `.json` priced result. It can be left out when `--json-in` is given |
| `--style hud\|card\|agnostic` | The style (default `card`) |
| `--image PNG` | The book's screenshot, for `hud`. Default: `[book] image` in the book file, relative to the file |
| `--out PATH.html` | Default: ` -