F1 model¶
F1 is the second sport, and the first one with liquid markets. It uses the same
database, web app and private book as downhill, and the one engine
(racinglines/models/model_global.py) with F1's lap-level inputs (racinglines/models/inputs/), which
uses track and sector properties in both training and prediction.
Data source¶
The official Formula 1 live-timing archive, read with FastF1 (the Python client for it). For every qualifying, sprint and race session since 2018 it has:
- Classification: driver (Ergast-compatible
driverId), team, grid, finishing position, classified status, official points, Q1/Q2/Q3 times. - Every lap: lap and sector times, four speed traps (sector 1, sector 2, finish line, main straight), tyre compound and age, stint, pit in/out, track status (green / yellow / safety car / VSC / red), position, and whether FastF1 rates the timing as accurate.
- Weather through the session: air and track temperature, rain, humidity, wind.
No other single source has lap- and sector-level timing going back to 2020. FastF1 limits itself to 500 API calls per hour (about 65 sessions), so a full 2020–2026 backfill takes several hours. The fetcher waits out the limit rather than failing, and fetches the newest seasons first.
racinglines data fetch f1 # newest first: 2026, 2025, …, 2020; skips sessions already on disk
racinglines data fetch f1 --years 2026 # e.g. after a race weekend
racinglines data ingest f1 --years 2020-2026 # into PostgreSQL (idempotent)
Raw files go to data/raw/f1/fastf1/<year>/<round>_<session>.{results,laps}.parquet plus a
.meta.json (event, circuit, date, format, weather summary). FastF1's own HTTP
cache lives in data/cache/fastf1. Sprints are fetched only from --sprints-from
(default 2026), because the model trains on qualifying and races and only needs
the current season's sprints for championship points.
In the database¶
| Level | F1 value |
|---|---|
| Sport / league / competition | f1 / fia_f1 / f1_wdc (Drivers' Championship) |
| Category | DRV |
| Event | one Grand Prix: source = "f1timing", source_key = "2026-15", venue = circuit location slug |
| Race | event × DRV; format = {"kind": "f1", "sprint": …, "total_laps": …} |
| Rounds | qual, sprint, race; rounds.extra holds weather, total laps and session date |
| Results | position and status (OK if classified; DNF, DSQ, DNS), race time, bib = car number, extra = grid, points, laps, status text, Q1–Q3, team id |
| Athletes | matched on athlete_identifiers(scheme="f1", value=<driverId>), e.g. max_verstappen |
laps |
one row per lap (added for F1; usable by any lap-based sport) |
track_profiles |
one row per event: the track features below |
Migration f3e017071b76 adds laps, track_profiles and rounds.extra.
Track profile (per event)¶
These come from each driver's fastest clean qualifying lap (median across the field) and from the race classification:
| Feature | Meaning |
|---|---|
lap_s |
Typical qualifying lap time |
s1_share, s2_share, s3_share |
Share of the lap spent in each sector |
v_i1, v_i2, v_fl, v_st |
Median speed-trap speeds: sector 1, sector 2, finish line, main straight (km/h) |
speed_index |
Time-weighted trap speed: Σ sector share × that sector's trap speed |
mean_places_gained |
Mean abs(grid − finish) of classified drivers, a proxy for how easy overtaking is |
grid_finish_rank_corr |
Rank correlation between grid and finish |
street |
Street circuit (registry.STREET_CIRCUITS) |
qual_rain_share, race_rain_share, *_track_temp |
Weather |
Model (racinglines/models/inputs/laps.py)¶
Everything is built in date order, so each race's features use only data from before it.
- Measurements per past event
- Qualifying: each driver's best lap as a % deficit to pole, and each team's best time in each sector as a % deficit to the fastest.
- Race pace: each driver's median clean race lap as a % deficit. Clean means green flag, accurate timing, no pit in/out laps, not lap 1, within 107% of the field median, and at least 8 such laps.
- Sector-type car model (the track/sector part)
- For each team, fit
sector deficit = a + b·x, wherex = (sector trap speed − 250) / 50, over its recent qualifying sectors. - The fit is weighted by recency (half-life 120 days) and ridge-shrunk:
atoward the field,btoward 0. - A positive
bmeans the car loses more in fast sectors; a negativebmeans it's relatively strong there. - At a target track, the predicted deficit is
a + b·x_track, wherex_track = Σ sector share × (sector trap speed − 250) / 50. It comes from that circuit's earlier dry profiles, or the field average for a new venue. - The same form is fitted for race pace, against each event's
x. - Driver offsets relative to the teammate, for qualifying and race pace: recency-weighted and shrunk toward 0.
The car is shared by both drivers. A team's qualifying sector deficit at
an event is the average of its drivers' deficits (CAR_PACE = "mean").
Before 2026-09-26 it was the team's best sector times, which credited the
faster driver's pace to the car and then again through that driver's offset.
Measured over 2021–2026 (as-of predictions vs actual):
| Car share | Driver share | |
|---|---|---|
| Qualifying-deficit spread | 61% | 39% |
| Race-pace spread | 86% | 14% |
| Teammates' surprises (actual − predicted) | Correlation |
|---|---|
| Qualifying | +0.49 |
| Race pace | +0.63 |
| Finishing position | +0.19 |
| DNF | +0.11 |
Saved car gaps. Every run that prices a race (event diagnostics, the live stage runs and forecasts) saves
each car's term at that track, without its drivers' offsets, as metrics.car_gaps:
{event: {team: {race_pct, qual_pct}}}, the % gap to the fastest car (0.0), ordered by race pace. Over MCP:
get_model_run(run_id, path='metrics.car_gaps'). A team with two strong drivers still flatters its car,
because teammate offsets can't tell a strong pair from a strong car.
4. Finishing model: a ridge regression, fit on earlier races, of normalized
finishing position on:
- g: normalized grid;
- rp_rel: predicted race-pace gap;
- qp_rel: predicted qualifying gap;
- interactions with the track's overtaking ease
(mean_places_gained / 2.8): g_ease and rp_ease;
- street_g: grid × street circuit.
5. Simulation (10,000 runs). Teammates share part of the luck: the
qualifying and finishing noise is split into a team part (one draw per car
per simulated race) and a driver part. The teammate correlation is measured
on races before the cutoff, about 0.5 for qualifying and 0.2 for finishing.
Each driver's own spread is unchanged. Teammate head-to-heads become more
decisive and team one-twos more likely (TEAMMATE_CORR).
- Grid: the real grid if qualifying has happened, otherwise simulated from
qualifying pace plus noise (σ estimated from history).
- Grid penalties: confirmed penalties are listed per event and session in
sports/f1/grid_penalties.toml (event YYYY-RR, driver surname or full name matched exactly after
lowercasing and dropping accents, session race or sprint, a source, an optional announced date).
Three kinds: back (back of the grid), pit_lane (priced as back of the grid) and places with
places = N (N places back, the drivers behind closing up). penalties.apply moves the known or simulated
grid after its draws (simulate_race(grid_adjust=...)), so no random draw is added or moved and a weekend
without a penalty prices byte-identically. Pole is still priced from the qualifying order, and the
applied penalties are recorded in the run's audit.grid_penalties. A driver name that matches no entrant
is an error, not a no-op.
- Finish: a finishing score plus noise (σ from the regression residuals).
- DNFs: each team's recency-weighted DNF rate.
- Points: official points (race 25-18-15-…, sprint 8-7-…, 2021 sprint 3-2-1).
- Championship: current official points plus the simulated remaining races
and sprints.
6. Sprint stage (sprint weekends; U13, provisional). Each race-like session is a row of
[sessions.sim] in sports/f1.toml: race = {grid_from = "qual", points = "race"} and
sprint = {grid_from = "sprint_qual", points = "sprint", dnf_scale = 0.5}. On a weekend that has a sprint,
pricing.price_stages runs the same simulate_race a second time with the Grand Prix's paces and finishing
model, the sprint points table (2021: 3-2-1) and each driver's DNF rate × dnf_scale. No sprint-specific grid
weight or noise yet.
- Grid: Sprint Qualifying's laps ranked by each driver's best valid lap once SQ has run (FastF1 stores
SQ without positions, so the classification can't be used; segment-aware SQ1/SQ2/SQ3 ordering is still
open). Before SQ, the grid is simulated from qualifying pace plus noise, as for the Grand Prix.
- Per-year grid source: grid_from_by_year (model.grid_source(stage, year)) overrides grid_from for a
season: in 2021 Friday's qualifying set the sprint grid (there was no Sprint Qualifying), so a 2021 sprint
takes its grid from qual's classification once qualifying has run (qualifying laps aren't practice rows).
- After the sprint: its actual classification and points, the way the Grand Prix uses the actual
qualifying order.
- As-of: the stage reads SQ and the sprint only from the as-of view, so a stage before them never sees
their results (tests/test_sprint_stage.py).
- Nothing else moves: the draws come from a side stream seeded from the main one without advancing it.
So every Grand Prix price, the constructor tie-break and the caller's next draws are byte-identical with
or without the stage, and a weekend without a sprint prices exactly as before.
- Outputs: the summary gains sprint_win_prob and sprint_pole_prob (stored in
race_predictions.extra). The sims gain stage_rank["sprint"] and ["sprint_qual"] plus the sprint's
finished and points; OutcomeSims.at("sprint") is the sprint as a race of its own.
- Walk-forward: racinglines model sprint-check f1 (models/sprint_check.py) prices every
stored sprint a minute before SQ and a minute before the sprint. It scores the sprint winner (Brier, log
loss) against the Grand Prix win stand-in and counts sprint and GP retirements per weekend, the evidence
for dnf_scale. It has not been run on real data yet.
Team lineage is kept across renames: Racing Point → Aston Martin, Renault → Alpine, Toro Rosso / AlphaTauri → RB, Alfa Romeo / Kick Sauber / Audi → Sauber.
Using it¶
racinglines model backtest f1 --save # historical: every race before qualifying and before the race
racinglines model diagnostic f1 --event 2026-15 --cutoff 2026-09-25T13:30 --save # one past event as of a cutoff
racinglines model forecast f1 --year 2026 --save # live: remaining races + championship
All three price through inputs.laps_pricing.price_race with a hard as-of cutoff. See
Market making.
Backtest results are on F1 evaluation; the current live
forecast is on F1 forecast.
A saved forecast drives the web app's Markets page, race pages and quotes. Upcoming Grands
Prix become scheduled events with per-driver win, podium, top-10 (points) and
pole probabilities; championship standings are stored too.
The private book works for F1 as well. Markets use Grand Prix wording, and
there's a race_top10 (points finish) market in place of "makes the Final".
Auto-settlement reads the race round and requires a classified finish.
Polymarket alignment¶
Polymarket lists many F1 markets. The ones this model prices, and how:
| Polymarket event | Our prediction kind | Model source |
|---|---|---|
<GP>: Driver Winner (multi-outcome) |
race_win |
race win probability |
<GP>: Driver Podium Finish |
race_podium |
podium probability |
<GP>: Head-to-Head ("who finishes higher") |
race_h2h (both outcome tokens) |
P(A ahead of B) from the race simulation, with DNFs placed last |
<GP>: Which Constructor Scores 1st? |
race_constructor_top |
team with the most race points in each simulation |
<GP>: Driver Pole Position |
race_pole |
pole probability (extra.pole_prob) |
<GP>: Driver Fastest Lap |
race_fastest_lap |
extra.fl_prob from runs priced with the fastlap variant (off by default, uncalibrated), else no price |
<GP>: Sprint Winner |
race_sprint_win |
extra.sprint_win_prob (the sprint stage), else the race win probability |
<GP>: Sprint Qualifying Pole Winner |
race_sprint_pole |
extra.sprint_pole_prob (the sprint stage), else the pole probability |
F1 Drivers' Champion |
champion |
season simulation |
F1 Constructors' Champion |
constructors_champion |
season simulation, team points = current + both drivers' simulated points |
Will X win N+ Grands Prix in YYYY? |
season_wins_ge |
current wins + simulated wins ≥ N |
Will A finish ahead of B in the YYYY Drivers' Championship? |
standings_h2h |
pairwise final-standings rank |
The safety car, red flag and rain questions ("Will there be a safety car during the 2026 F1 Bahrain Grand
Prix?", "Rain during the Bahrain Grand Prix?") are linked to their race as race_safety_car, race_red_flag
and race_rain with no model price (the live props book prices them from history, models/props.py).
Any other <GP>: ... event (constructor fastest lap, ...) is linked to its race but stays unmodeled, and other
props are synced with their prices only.
Weather-aware (-WX) variants¶
A -WX variant is its base model or method priced with a probability of rain for the race (p_wet). Which markets
the weather moves is set by the kinds schema, not by code: each kind has a wx (racinglines/markets/kinds.py,
wx = "..." in markets/kinds.toml). wx = "rate" (the red flag) is priced with props.rate_wx, the circuit's
wet-race rate times p_wet plus its dry-race rate times 1 - p_wet. wx = "dnf" (every classification and
retirement kind: classified, retired, last classified, n-th retirement, driver and team) is priced from the run's
dnf_prob multiplied by props.wx_scale: the circuit's retirement rate at that p_wet over the same at its usual
wet share, so a forecast that says no more than the history leaves the price unchanged. The safety car has no wx
(conditioning it was worse in the walk-forward); rain is not conditioned but priced at p_wet itself. Without a
p_wet every price is the plain one. The live book passes one only for its props (rain and red flag) and only when a
wet vote is saved for the race (Weather: wet-race forecast); its retirement and
classification markets keep the run's dnf_prob.
The backtest takes the forecasts issued days before each past race, the leads CSV of racinglines weather leads
(committed: tests/fixtures/weather/open_meteo-leads-f1.csv, one row per race, lead and model), turned into a p_wet
per race by weather/wet.py's p_wet_series: each model's vote at that lead, shrunk to the circuit's wet share as of
the race, the same vote racinglines weather backtest scores. racinglines model props f1 --check --history <csv> --forecast
<csv> --lead 5 adds climatology-WX (the red flag and safety car with rate_wx at the forecast's p_wet instead of
the circuit's wet share) and --dnf-check <csv> --forecast <csv> --history <csv> --lead 5 adds model-WX (the
retirement twin of the live simulation variant, gridq+pretrain+reset-WX), each with a paired Brier difference against
its base (climatology-WX - climatology, model-WX - model). The forecast's own skill by lead is
racinglines weather backtest. Results go in the decision log before any of it is
treated as final.
Placeholders. Some race events carry unnamed slots, "Driver A" to "Driver E" and "any other driver" (or
"constructor"), so Polymarket can add outcomes later without a new event. They name no driver, so we can't price
them: they stay unmodeled, linked to the race, with params.placeholder set to the kind they stand in for
(race_win, race_pole, ...). The race page leaves them out of each market's table and says how many there are
under it; the linked-markets admin page shows them as "placeholder". Rows synced before 2026-10-04 get the
tag on the next Polymarket sync.
Sync (racinglines/markets/polymarket/sync.py, the Refresh button on /markets/polymarket, or
POST /pm/sync):
- Events: reads every active event tagged
f1/formula1from Polymarket's public Gamma API. - Classification: each market is classified by its title.
- Drivers: matched by full name, unique surname or last two names, plus
aliases such as
"Jr."→ Carlos Sainz. - Teams: matched by name, including sponsor labels such as "Mclaren Mastercard" and "Tgr Haas".
- Races: matched by Grand Prix name within the season (FastF1's short event
name is stored in
races.format.event_name). - Storage: one
market_linksrow per outcome token, with live bid/ask/mid, volume, end date, and resolution once the market closes.
Makers use the Polymarket page (/markets/polymarket, linked from Markets):
- Per event: each outcome's Polymarket bid/ask/mid, our fair value, the edge (our fair − Polymarket mid), and suggested quotes at fair ± spread/2.
- Mirror into my book creates or reprices a YES/NO market in the maker's book for every priced outcome, linked to the Polymarket token.
- Takers then see those markets on their Markets board (
/markets), with the live Polymarket price and a link next to each maker quote. - Season-long markets (championships, props) appear under "Season-long markets".
- Admin refreshes also settle mirrored markets from Polymarket's resolution.
Kalshi alignment¶
Kalshi lists more F1 markets per weekend than Polymarket. The ones this model prices
(racinglines/markets/kalshi/sync.py, series ticker → kind):
| Kalshi series (event title) | Our prediction kind | Model source |
|---|---|---|
KXF1RACE (<GP> Winner) |
race_win |
race win probability |
KXF1RACEPODIUM (Main Race: Podium Finishers) |
race_podium |
podium probability |
KXF1TOP10 (Main Race: Top 10 Finishers) |
race_top10 |
points-finish probability (not in the sweep's kinds) |
KXF1TOP5 (Main Race: Top 5 Finishers) |
race_top5 |
classified in the top 5 (extra.top5_prob, runs saved since 2026-10-04). Not in the sweep's kinds |
KXF1BIGGESTMOVER (Main Race: Biggest Mover) |
race_biggest_mover |
the classified driver with the largest gain from grid to finish, ties all YES (extra.mover_prob, runs saved since 2026-10-04). Settled from the stored starting grid (results.extra.grid of the race, FastF1's GridPosition after penalties; payoffs.biggest_mover); undecided when a classified driver has no stored slot, or when a pit-lane starter (grid 0) could be the mover under some reading of its slot. Not in the sweep's kinds |
KXF1POLE (Qualifying Session (Q3): Pole Position) |
race_pole |
pole probability (extra.pole_prob) |
KXF1FASTLAP (Main Race: Fastest Lap) |
race_fastest_lap |
extra.fl_prob from runs priced with the fastlap variant (off by default, uncalibrated), else no price; the live props book prices it from finishing odds × history (models/props.py). Not in the sweep's kinds |
KXF1TOPCONSTRUCTOR (Main Race: Top Constructor) |
race_constructor_top |
team with the most race points |
KXF1H2H (F1 Matchup: A vs B) |
race_h2h (params.opponent_id) |
P(A ahead of B); the race is read from the ticker's code (BRIGP26) |
KXF1 (F1 Drivers Champion) |
champion |
season simulation |
KXF1CONSTRUCTORS (F1 Constructors Champion) |
constructors_champion |
season simulation |
KXF1RACESPRINT (<GP>: Sprint Race Winner) — on unless RACINGLINES_KALSHI_SPRINTS=0 |
race_sprint_win |
extra.sprint_win_prob from the sprint stage (Model, step 6), else the race win probability |
KXF1SPRINTPOLE (Sprint Qualifying: Pole Position) — on unless RACINGLINES_KALSHI_SPRINTS=0 |
race_sprint_pole |
extra.sprint_pole_prob from the sprint stage, else the pole probability |
Other sprint markets, retirements and other props are synced with their prices but have no model price. Kalshi's markets differ from Polymarket's in shape: one market per driver with a YES contract (NO is its mirror), so a head-to-head is one market, not two tokens. Each link keeps Kalshi's resolution rules.
Sprint weekends (roadmap U5, on by default since 2026-10-06 by the owner's call; RACINGLINES_KALSHI_SPRINTS=0
in the sync's environment turns them off): the sprint winner and sprint pole markets take the sprint kinds above
(racinglines/markets/kinds.py: race_sprint_pole settles from the sprint qualifying order, which the results
hold only as the sprint's starting grid, so it settles after the Sprint; race_sprint_win from the sprint
classification, after the Sprint; sports/f1.toml closes them when that session starts), and price like any
other link (db.reads.model_prob): from the sprint stage's extra.sprint_win_prob / sprint_pole_prob on runs
that simulate it (every run of a sprint weekend since the sprint stage), else the race-odds stand-in. With the
flag at 0 every Kalshi sprint market stays unmodeled, and a sync writes exactly the rows it did before. The sprint's
fastest lap, top 5, top 10 and top constructor stay unmodeled either way. The sprint stage also prices
race_sprint_podium, race_sprint_top8, race_sprint_h2h and race_sprint_constructor_top (all
default=False, kept out of every live and private book list until their settlement is proven, owner DEC-12);
no venue link carries them yet. Proven on the archived 2026 Dutch GP links (round 12,
tests/test_kalshi.py); Singapore (round 17, 11 Oct) is the first sprint weekend it can run on. See
Kalshi history.
Sync (racinglines/markets/kalshi/sync.py, racinglines markets --exchange kalshi sync [--year] [--closed];
no web button yet):
- Series: every Sports series whose ticker starts
KXF1or whose title says Formula 1, F1 or Grand Prix ("Qualify in Pole Position" doesn't say F1, so the ticker is what finds it). - Classification, drivers, teams, races: the same classifier and name matching (
Resolver) as Polymarket. Kalshi names the driver or team inyes_sub_title. Head-to-head titles don't name the Grand Prix, so the race comes from the ticker's code (KXF1H2H-BRIGP26VERHAM→BRIGP26, which the weekend's other events name). - Storage: one
market_linksrow per market (exchange = 'kalshi',token_id= the market ticker,condition_id= the event ticker), with live bid/ask/mid, volume, close time, resolution and Kalshi's resolution rules (params.rules). - Settled markets: with
--closed, settled events too. Those past Kalshi's cutoff (about two months) come from its/historicalendpoints.
2025 markets use older titles and series: F1 Australian Grand Prix Winner?, Abu Dhabi Grand Prix 2025
Podium Finishers?, Las Vegas GP: Qualify in Pole Position, Gran Premio de Mexico Winner?, and the series
KXF1POLEPOSITION and KXF1FASTESTLAP (their titles say pole or fastest lap, so they classify the same). Each
event is matched against its own season's races and drivers (the ticker's 25 / 26, else its close date),
whatever --year the sync ran with.
What's stored (2026-09-28, Data changes): 3,793 links, 2,890 modeled: all 24 Grands Prix
of 2025 (winner and podium; pole and fastest lap for the last three only) and the 15 of 2026 so far (Australia to
Azerbaijan; Azerbaijan has no pole market, and Australia no top-constructor market: KXF1TOPCONSTRUCTOR-AUSGP26 is
really a second Austria market), plus both championships of both years.
Differences from Polymarket's feed (trades without wallets, candlesticks, the taker fee, /historical):
Data. Kalshi isn't on the Polymarket page or in "Mirror into
my book" yet. The backtest venue (venue_replay.Kalshi) and the maker replay read its rows, and the demo maker has
a Kalshi record (Kalshi history).
Results¶
Over 129 races (2021 to Azerbaijan 2026), each priced before practice, before
qualifying and after qualifying, the model beats a uniform guess at every stage
and a grid-only guess on top 10 after qualifying. With the gridq+pretrain+reset
variant it also beats the grid-only guess on win and podium. Against Polymarket
it is competitive in 2026 (better mid-weekend on race winners) but the market is
sharper in 2025 and after qualifying. Full tables: F1 evaluation.
Model variants (F1 roadmap F1-2, F1-3)¶
The variants (grid, gridq, pretrain, gbm, tail, reset, rookie) and their
backtest comparisons are in
F1 evaluation → Model variants.
fastlap (provisional, uncalibrated) changes no price: it adds the race's fastest lap to the simulation
(extra.fl_prob, the race_fastest_lap kind), so it combines with any of them, e.g. gridq+pretrain+reset+fastlap. flpos (provisional, 2026-10-07) does the same
with the fastest lap drawn from the simulated finishing order and race pace (sports/f1/fastest_lap.toml), so the
winner sets it about as often as in real races; combo pricing (markets/combos.py, the f1_combo Lab job) uses it.
gridnoise (provisional, 2026-10-20, off unless named) fits the finishing noise per starting-grid band (P1, P2, P3-4,
P5-6, P7-10, P11 and back) from earlier races' residuals, each band's variance shrunk toward the field's by 20 weighted
rows, instead of one noise level for every car (GRID_NOISE in racinglines/models/inputs/laps.py). Leave it out of
the variant to get the old prices back, e.g. gridq+pretrain+reset vs gridq+pretrain+reset+gridnoise.