tennis-model / PROJECT_DETAILS.md
chetanmitra12's picture
Organize sportsbook data and remove obsolete copies
fbb33c8 verified
|
Raw History Blame Contribute Delete
9.21 kB

ATP Tennis Match Prediction Model

ATP match prediction using point-in-time Elo ratings, serve-return matchup features, recent form, and calibrated probability models.

Current Model

The current feature rebuild contains 42 features and 81,547 chronological feature rows (through 2026-06-28). The saved artifact is trained and calibrated only before 2026-01-01; 2026 is the held-out test year. It contains calibrated XGBoost and regularized logistic candidates. Calibrated logistic is the current default because it was the strongest 2026 holdout candidate; set MODEL_KIND=xgboost to run the tree challenger.

Baseline Feature Reference

# Feature Weight Description
1 elo_diff_x_expected 28.18% elo_diff Γ— sigmoid β€” auto-downweights close matchups
2 surface_elo_weighted 13.51% surface_elo_diff Γ— sigmoid Γ— min(surf_matches,30)/30
3 elo_volatility_diff 3.44% Std dev of recent Elo changes
4 diff_win_rate_weighted 5.62% (P1_wr - P2_wr) Γ— min(min_matches,200)/200
5 diff_form_5 4.65% Last 5 match win rate diff
6 diff_form_10 3.35% Last 10 match win rate diff
7 diff_ace_rate 3.56% EMA ace rate diff
8 diff_bpSave 3.35% EMA break point save diff
9 diff_bpConv 3.42% EMA break point conversion diff
10 ret_vs_1stServe 4.61% P1_retWonΓ—(1-P2_1stWon) - reverse (5Γ— feature_weight)
11 ret_vs_2ndServe 3.86% P1_retWonΓ—(1-P2_2ndWon) - reverse (5Γ— feature_weight)
12 h2h_close 3.82% (h2h_wr-0.5)Γ—min(total,10)/10 Γ— closeness
13 surface_wr_close 4.50% diff_surface_wr Γ— closeness
14 ret_matchup_close 4.05% (ret_vs_1st+ret_vs_2nd) Γ— closeness
15 best_of 6.39% 3 or 5 sets
16 indoor 3.69% Indoor/outdoor flag

Added Serve/Return Features

The five direct serve/return features added in the earlier rebuild are diff_first_serve_in, diff_first_serve_points_won, diff_second_serve_points_won, diff_service_hold, and diff_return_points_won. The current 42-feature rebuild adds 21 more leakage-safe features: 20-match, surface, and 52-week form; rest and 14/30-day workload; a bounded fatigue score; point-weighted Bayesian first-serve, second-serve, return, and total-point rates; opponent-adjusted serve and return residuals; surface Bayesian rates; log ranking context; serve sample size; stat coverage; and handedness matchup. Tennis Abstract match statistics, when available, update those estimates only after the match.

Betting Strategy

  • Live selection: pre-match higher-Elo favorite, probability >= 0.60, net edge >= 0.01
  • Price: executable YES ask, less any configured per-contract fee
  • Live sizing: favorite-ask price only, multiplied by the configured sizing fraction and capped at 1% of bankroll
  • Live matches: always skipped

The historical backtest is a separate favorite-only evaluation: every matched settled row is a bet on the lower-priced sportsbook favorite. The displayed test is 2026 only; all feature rows before 2026 are training/calibration history and are never included as 2026 test rows. It does not emit BET/SKIP rows or use model edge to decide inclusion. Sizing starts with $100 and compounds after every bet using the current bankroll. The frozen sizing method is min(0.5% of bankroll, 0.5% / favorite decimal odds), selected before the 2026 test period. The current 2026 result is 717W-316L, -2.79% ROI, $89.89 final bankroll, and 17.19% maximum drawdown across 1,033 matched bets. This is historical sportsbook diagnostics, not proof of executable positive edge.

Evaluation

The previous sportsbook ROI backtest was disabled because it used final tracker state for historical matches and had no trustworthy pre-match execution timestamps. Use the leakage-safe walk-forward evaluator for model metrics.

New odds polls are appended to odds_snapshots.csv with event and market tickers, observation time, bid/ask, and pre-match status. ROI should only be computed from that immutable snapshot history.

Tennis Abstract Enrichment

prod_core/tennisabstract_import.py downloads the embedded matchmx arrays from Tennis Abstract player-classic pages, parses match-level serve/return counts and duration, and writes data/tennisabstract_match_stats.csv. It can also create data/tml-data/all_matches_ta_enriched.csv; core_model.py uses that derived file automatically when present and never modifies the raw CSV.

The importer is cached and resumable. The model-targeted crawl uses the players and match keys in data/core_meta.csv (the 81k rows actually used by the model), checkpoints the canonical sidecar, and keeps player failures in a progress file so it can be continued safely:

python scripts/tennis_model/prod_core/tennisabstract_import.py --target-meta data/core_meta.csv --replace --merge --delay 2.0 --retries 3 --retry-failures

The crawl is intentionally serial and rate-limited. It may take a long time; rerunning the same command without --replace resumes from data/tennisabstract_match_stats.progress.json and reuses data/tennisabstract_cache/. TA player pages return career history, so the fetcher may read more rows than the target, but the sidecar retains only the year-aware keys from core_meta.csv. The raw match file remains unchanged and continues to supply non-target history needed for rolling pre-match state. The sidecar also retains target rows for which TA reports no serve stats (stat_count=0), letting the audit distinguish match coverage from complete stat coverage.

Run the coverage audit after a crawl, before rebuilding the model:

python scripts/tennis_model/prod_core/audit_tennisabstract_coverage.py

It writes output/tennisabstract_coverage.json and output/tennisabstract_coverage_by_year.csv. Tennis Abstract reports tournament start dates for many historical rows, so the importer uses a tournament/surface/round/player join when an exact row date is unavailable. These statistics are used only to update rolling state after a completed match; they are never features for that same match. The source is rate-limited, so failed players are recorded in .failures.csv and can be retried later with --retry-failures. A TA page may still legitimately lack match-level stats; those rows remain visible in the audit rather than being misrepresented as fully enhanced.

The backtest dashboard is available locally with:

node web/backtest/server.js

It uses walk_forward_bookmaker_predictions.csv, walk_forward_sized_trades.csv, and train_test_split_audit.json; it does not deploy to Fly.

Project Structure

tennis-model/
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ prod_core/core_model.py  # Builds chronological features from raw data
β”‚   └── prod_core/core_backtest.py # Trains calibrated model candidates
β”œβ”€β”€ models/
β”‚   β”œβ”€β”€ models_elo.pkl           # Trained XGBoost model (requires EloSystem class)
β”‚   └── tracker_state_elo.pkl    # Player stats, Elo ratings, name lookup
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ clean_data.pkl           # Preprocessed 195,614 matches
β”‚   β”œβ”€β”€ X_elo.npy                # Feature matrix (73,405 Γ— 16)
β”‚   β”œβ”€β”€ y_elo.npy                # Labels
β”‚   β”œβ”€β”€ dates_elo.npy            # Match dates
β”‚   β”œβ”€β”€ sportsbook/2025.xlsx     # Sportsbook odds 2025
β”‚   β”œβ”€β”€ sportsbook/2026.xlsx     # Sportsbook odds 2026
β”‚   └── tml-data/                # Raw yearly CSVs + ATP_Database.csv
β”œβ”€β”€ output/
β”‚   β”œβ”€β”€ all_baseline_bets.csv    # All conf>0.50 bets with Kelly
β”‚   └── conf60_bets.csv          # Filtered conf>=0.60 bets
└── README.md

Running

# Step 1: Feature engineering (builds X.npy, y.npy, dates.npy, tracker)
python scripts/tennis_model/prod_core/core_model.py

# Step 2: Train calibrated candidates
python scripts/tennis_model/prod_core/core_backtest.py

# Step 3: Leakage-safe walk-forward metrics
python scripts/tennis_model/prod_core/walk_forward_eval.py

# Step 4: Favorite-only historical price backtest and odds-only sizing
python scripts/tennis_model/prod_core/walk_forward_bookmaker_backtest.py
python scripts/tennis_model/prod_core/walk_forward_sizing_eval.py

# Step 5: Independent train/test boundary audit
python scripts/tennis_model/prod_core/verify_train_test_split.py

Key Technical Notes

  • EloSystem class must be defined before unpickling models/tracker (pickle dependency)
  • Feature order in every make_fv() must exactly match prod_core/core_model.py
  • Challengers update player stats but don't generate training rows
  • P1 is always the higher-Elo player during training and prediction
  • Walk-forward training rows are strictly before each calendar-year evaluation window; calibration is a later slice inside training history
  • feature_weights param: 5Γ— for ret_vs features, 3Γ— for ret_matchup_close
  • Closeness factor: 1.0 - abs(elo_expected - 0.5) * 2.0
  • Elo time-weighting is anchored to the data cutoff, not the machine clock
  • Tennis Abstract enrichment is a post-match sidecar; it cannot provide reliable event-time execution timestamps