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:
```bash
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:
```bash
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:
```bash
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
```bash
# 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