# Tennis Match Model - formula spec

Data: `tennis_model.json` (same folder). Reference implementation: `model.js` (ES module, function `predict(data, input)`).
`test_vectors.json` holds inputs and expected outputs from the Python model; an implementation is correct when every
probability is within 0.5 percentage points (lines/totals within 0.1 games, fair lines equal).

## 1. Match-winner probability (player 1 = A, player 2 = B)
p = 1 / (1 + exp(-z)), z = sum over weights[tour] of weight x feature. No intercept. Every feature is (A value - B value).
Unknown player: Elo 1500, no history (all other terms use the defaults below).

| feature | formula |
|---|---|
| elo | ln(10)/400 x ((1-w)(eA - eB) + w(sA - sB)); e = players[k].e; s = players[k].s[surface][0] (1500 if missing); w = elo[tour].w_surface (ATP 0.5, WTA 0.25) |
| ranking | ln(min(rankB, 2000)) - ln(min(rankA, 2000)); rank = user override, else players[k].r, else 2000 |
| best_of_5_x_elo | elo feature x (best_of == 5 ? 1 : 0) (ATP only) |
| deciding_set_record | dsr(A) - dsr(B); dsr = (ds[0] + 5) / (ds[1] + 10) |
| fatigue_sets7d | sets7(A) - sets7(B); sets7 = sum of h[i][2] (sets) over history entries with 0 < (match date - h[i][0]) days <= 7 |
| rest_days | rest(A) - rest(B); rest = min(days since last history entry, 30); 30 if no history |
| breakback_proxy_return_games_won | g(A) - g(B); 0 if either is null |
| h2h | keys sorted alphabetically -> h2h[tour]["k0|k1"] = [sum, n, {surface: [sum, n]}]; sum is from k0's side; sgn = +1 if A is k0 else -1; feature = sgn x sum / (n + 4); 0 if no entry |
| h2h_surface | sgn x surface_sum / (surface_n + 3) |
| form | form(A) - form(B); form = sum of h[i][1] over the last 10 entries / (count + 5) |
| tournament_history | rate(A) - rate(B); ow = (w[0] + 5) / (w[1] + 10); over t[td_name] entries [year, wins, played] with year < match year: (tw + 6 ow) / (tn + 6) - ow (WTA only) |

### 1b. Checklist columns (Nov 2026; kept per tour by walk-forward selection, see `weights`)
Per player: `x` = recent events at all levels (tour, challenger, ITF, tour-event qualifying), oldest first, each
[date, minutes, sets, games, is_qualifying (0/1), surface, location or null]; `xn` = all matches on record; `bd` = birth date;
`eh` (ATP) = [[date, Elo before that match], ...] about a year back; `cp` (ATP) = serve/return component ratings
{fi, w1, w2, ace, df, brk: [S, R, {surface: [Ss, Rs]}]}; `ap` = aggregate serve-point ratings [S, R, {surface: [Ss, Rs]}].
Tournaments add `loc` (tennis-data Location) and `indoor` (tennis-data Court; unknown = outdoor). `geo[loc]` = [lat, lon, IANA tz].

Per player on match day d (events used: date <= d - 1 day; "within k" = d - date <= k days):
min_k / g_k / s_k = minutes/60, games/10, sets within k (k = 3, 7, 14); q7 / qmin7 = qualifying matches / qualifying minutes/60 within 7;
days_all = min(days since last event, 365) (999 -> 365 if none); surfchg = 1 if last event within 21 days was on another surface;
days_surf = min(days since last event on this surface, 365); km, tz = great-circle km/1000 and time-zone hours (min(|dz|, 24-|dz|),
UTC offsets on each event's date) between the last event's location and this tournament's, only if the last event is within 21 days,
at a different location, and both are in `geo` (else 0); ret = 1 if the player is within the first 3 events after a >= 60-day gap,
or the current gap is >= 60 days; nall = xn - events dated >= d; age = (d - bd)/365.25 (missing -> 26);
traj = current Elo - Elo of the latest `eh` record dated <= d - 365 (0 if none).

| column | formula (A - B) |
|---|---|
| agg_spw | p_agg(A serving vs B) - p_agg(B vs A); p_agg = sigmoid(agg_mu[tour][surface] + S_A + Ss_A - R_B - Rs_B) from `ap` |
| comp_spw | logit(fi w1 + (1-fi) w2)(A vs B) - same(B vs A); each component p_c = sigmoid(comp_mu[tour][c][surface] + S + Ss - R - Rs) from `cp` |
| first_in, first_won, second_won, ace_rate, df_rate | p_c(A vs B) - p_c(B vs A) for c = fi, w1, w2, ace, df |
| rgw_adj | p_brk(B serving vs A) - p_brk(A vs B) |
| age, age_sq | age_A - age_B; ((age_A - 26)^2 - (age_B - 26)^2) / 10 |
| surface_change, log_days_since_surface, log_days_since_any | surfchg diff; ln(1 + days_surf) diff; ln(1 + days_all) diff |
| elo_traj_365d | ln(10)/400 x (traj_A - traj_B) |
| qual_matches7, qual_hours7 | q7 diff; qmin7 diff |
| indoor_x_elo | elo feature x indoor |
| returning, long_layoff | ret diff; ln(1 + max(days_all - 30, 0)) diff |
| s3, s14, min3/7/14, g3/7/14 | workload diffs |
| travel_km, travel_tz | km diff; tz diff |
| elo_x_uncertainty | elo feature x (1/sqrt(1 + nall_A/30) + 1/sqrt(1 + nall_B/30)) |

Confidence label: n = players[k].c (tour-level matches), nall, days_all as above.
'limited evidence' if min n < 5 or min nall < 25 or max days_all >= 90; else 'moderate' if min nall < 75 or max days_all >= 30;
else 'good'. `confidence_tiers[tour][label]` = [model log loss, Pinnacle log loss, share of matches] in the 2024-26 test.

Fair odds = 1/p and 1/(1-p). Edge = p x decimal_odds - 1. No-vig market p = (1/o1) / (1/o1 + 1/o2).
American odds: +150 -> 2.50, -110 -> 1.909.

## 2. Game spread / total
Serve-point probability: p_serve(A vs B) = sigmoid(point_mu[tour][surface] + S_A + Ss_A[surface] - R_B - Rs_B[surface]),
with players[k].pt = [S, R, {surface: [Ss, Rs]}, matches_with_stats] (missing = 0).

Exact match model with i.i.d. points: hold(p) = p^4 (1 + 4q + 10q^2) + 20 p^3 q^3 p^2 / (1 - 2pq), q = 1 - p;
7-point tiebreak at 6-6 (final set per tournament `final`: tb7, tb10 = 10-point match tiebreak counted as one game, tb12 = tiebreak at 12-12, adv);
server of the first set is 50/50; next set's first server alternates by game parity. Totals pmf over 0..129 games, margin pmf A-B.

Form noise: delta ~ N(0, sigma^2) (sigma ATP 0.15, WTA 0.175) added to A's serve logit and subtracted from B's, integrated with the
7-node Gauss-Hermite rule in `gh` (x, w).

Calibration: find d in [-2, 2] (40 bisection steps) so the noise-mixture P(A wins match) with serve logits (lA + d, lB - d) equals
the target: market no-vig p when both moneyline odds are given (`games_calib: market`), otherwise the model p (`model`).
Then total/margin pmfs = mixture at the calibrated serve probabilities.

Outputs: expected total and margin; fair total = the x.5 line from 10.5..69.5 with P(over) closest to 0.5 (first on ties);
fair spread = A's x.5 handicap from -20.5..20.5 with P(cover) closest to 0.5.
P(cover A at line L) = P(margin + L > 0); P(over L) = P(total > L); pushes are possible only on whole-number lines.
Spread edge A = Pc x (o - 1) - (1 - Pc - Ppush); total edges likewise.

## 3. Court speed
tournaments[tour][i] = {name, td (tennis-data name, used for history and final-set rule), surface, speed_z (prior edition, z-score
within tour; higher = faster), speed_rank / speed_n (rank among that year's events, 1 = fastest), speed_year, best_of, final, aliases}.
No tournament: surface_speed[tour][surface]. Speed is informational; it is not a term in the selected model.

## 4. Data freshness
`elo_through` = last result date in the rating data; `stats_through` = last match date in the stats mirror; `comp_through` = last match
with serve/return detail (the mirror's detailed stats stop in early June 2026, so serve ratings are older than results); `generated` = export date.
Fatigue/rest/form use the match date you enter (default today) against the stored history.

Walk-forward backtest 2024-26: the model did NOT beat bookmaker closing lines. Paper tracking / second opinion only.
