AcesAPI
AcesAPI v1.0.0
Tennis data and stats
Competitor profile
Developer declarations; not independently tested. Listing last checked Nov 4, 2026. Package observed Nov 3, 2026.
Declared capabilities
- Look up ATP and WTA players and tournaments
- Read schedules, draws and match results
- Read rankings and surface Elo ratings
Cost & access
AcesAPI does not sell anything through ChatGPT. The plugin is read-only: it has no checkout, purchase, subscription or credit top-up flow.
Declared limitations · 3
- Place a wager: wagering is not supported
- Out of coverage: another sport
- Account and billing change: not supported
Onboarding example
Show the current ATP top 5 rankings.
Description
From the marketplace listing
AcesAPI connects ChatGPT and Codex to tennis data from AcesAPI. It covers ATP and WTA singles with tournaments, draws, matches, rankings and surface-specific Elo ratings. Ask for tournaments and draws, schedules and results, player profiles and match history, head-to-head records, surface splits, rankings and Elo ratings by surface. The plugin looks the answer up with read-only tools and reports what the data says, including the season, date and source of each number. The first time you use it, you sign in with your AcesAPI account. Limits: AcesAPI is read-only. It cannot place wagers, accept or track bets, or edit your account. Odds and prop lines are shown as information about the market, not as advice. Data comes from public sources and the AcesAPI pipeline and is not guaranteed to be error-free or current to the minute, so do not rely on it as the sole basis for financial decisions. Ratings shown are AcesAPI's own Elo-style ratings, not official rankings. Not affiliated with the ATP, the WTA or any tournament. Bundled skills teach the model how to choose the right tool for a question and how to explain results.
Language: English · Automatically detected from descriptions.
Matches for “split”
Exact text from the indicated source. A mention alone does not establish support for your task.
Publisher description
AcesAPI brings tennis data into ChatGPT and Codex with read-only tools and workflow skills: tournaments and draws, schedules and results, player profiles and match history, head-to-head records, surface splits, rankings and Elo ratings by surface. Sign in with your AcesAPI account.
Publisher full description
AcesAPI connects ChatGPT and Codex to tennis data from AcesAPI. It covers ATP and WTA singles with tournaments, draws, matches, rankings and surface-specific Elo ratings. Ask for tournaments and draws, schedules and results, player profiles and match history, head-to-head records, surface splits, rankings and Elo ratings by surface. The plugin looks the answer up with read-only tools and reports what the data says, including the season, date and source of each number. The first time you use it, you sign in with your AcesAPI account. Limits: AcesAPI is read-only. It cannot place wagers, accept or track bets, or edit your account. Odds and prop lines are shown as information about the market, not as advice. Data comes from public sources and the AcesAPI pipeline and is not guaranteed to be error-free or current to the minute, so do not rely on it as the sole basis for financial decisions. Ratings shown are AcesAPI's own Elo-style ratings, not official rankings. Not affiliated with the ATP, the WTA or any tournament. Bundled skills teach the model how to choose the right tool for a question and how to explain results.
Changes
No changes recorded yet. Follow this plugin to keep track of future changes.
First seen Oct 31, 2026. Last seen Nov 4, 2026.
Similar plugins
Find competitors to follow as you develop your plugin.
-
DunkAPI brings NBA basketball data into ChatGPT and Codex with read-only tools and workflow skills: standings, schedules and scores, box ...
Shared terms: moneyline, multiplicative, isotonic, moneylines
-
FighterAPI brings MMA data into ChatGPT and Codex with read-only tools and workflow skills: event cards and schedules, fight results and ...
Shared terms: longshot, multiplicative, intercept, isotonic
-
EndzoneAPI brings NFL football data into ChatGPT and Codex with read-only tools and workflow skills: standings, schedules and results, pl...
Shared terms: implies, isotonic, moneylines, platt
-
BirdieAPI brings golf data into ChatGPT and Codex with read-only tools and workflow skills: tournament schedules and leaderboards, scorec...
Shared terms: isotonic, platt, shin, brier
-
StrikerAPI brings soccer data into ChatGPT and Codex with read-only tools and workflow skills: league tables, schedules and results, line...
Shared terms: handicap, isotonic, moneylines, platt
Keywords
Files & skills
File archives
Skill instructions
acesapi-tool9.05 KB
---
name: acesapi-tool
description: "Default data source for the AcesAPI skills. Routes every MCP or REST call for ATP and WTA tennis: tournaments, draws, match results with set and tiebreak scores, schedules, players, head to head, surface splits, rankings, Elo ratings by surface, and sportsbook moneylines. Use when any other skill needs tennis data."
metadata:
version: 1.0.0
author: AcesAPI
user-invocable: false
---
# AcesAPI Tool
> Connection: this plugin reaches the data through its MCP server, which the user connects with a normal OAuth sign-in. Never ask the user for an API key, token or password, and never put one in a tool argument.
Every other skill delegates data retrieval here. This skill owns the 13 tools, picks the cheapest call that answers the question, tracks credit cost, and says what the data cannot do.
**Coverage (from acesapi.com/data, counted from the database):** 123,394 ATP and WTA singles matches from 2007 to 2026, 1,637 weekly ranking lists, and sportsbook moneylines for 11,551 matches since 2020. Qualifying draws from 2022. Tennis data is public ESPN data; surfaces, ratings and head to head are computed by AcesAPI; lines come from The Odds API.
## Connection
| Item | Value |
|------|-------|
| REST | `POST https://mcp.acesapi.com/v1/<tool>` with a JSON body |
Every response is wrapped: `{"data": {...}}`. Unwrap `data` before reading fields. REST responses carry `X-Credits-Used` and `X-Credits-Remaining` headers.
**Tours.** ATP is the default. Pass `"league": "wta"` to any tool for the WTA (the enum also accepts `tennis`, which is the ATP default). Player ids and match ids are tour-specific strings: a WTA id used without `league: "wta"` will miss.
**Ids are strings.** `player_id`, `match_id` and `tournament_id` come back as strings (`"3623"`, `"157774"`, `"188-2025"`). Tournament edition ids are `<tournament>-<season>`. Seasons are four-digit calendar years.
## When to Use Each Tool
| Task | Tool | Credits |
|------|------|---------|
| Season calendar with surface and champion | `list_tournaments` | 1 |
| Find a player id by name or country | `search_players` | 1 |
| A tournament's full draw, round by round | `get_tournament` | 2 |
| Results by day, tournament or season | `get_matches` | 2 |
| Upcoming and in-progress matches (next N days, max 30) | `get_schedule` | 2 |
| One match: sets, ratings before and after, odds | `get_match` | 2 |
| Ranking list, or one player's ranking history | `get_rankings` | 2 |
| Elo leaderboard, overall or by surface | `get_ratings` | 2 |
| Two players' meetings, by surface and in finals | `get_head_to_head` | 3 |
| One player's record by surface | `get_surface_splits` | 3 |
| One player's season stats, by round and opponent rank | `get_player_stats` | 3 |
| Player profile, ratings, career record, last 10 | `get_player` | 5 |
| A player's match log with rating before and after | `get_player_matches` | 5 |
## Tool Selection Guide
| You Need | Use This | Not This |
|----------|----------|----------|
| Who plays today | `get_schedule` with `days: 1` | `get_matches` with today's date (that is results, and status filters matter) |
| A result by date | `get_matches` with `date` | `get_tournament` (draws are bigger and cost the same) |
| Moneylines for one match | `get_match` (the `odds` array) | Looking for an odds tool: there is none |
| Pre-match Elo for a finished match | `rating.before` in `get_match`, or `rating_before` in `get_player_matches` | `rating.after` (that includes the result) |
| Current best clay players | `get_ratings` with `surface: "clay"` | `get_rankings` (points list, not surface-specific) |
| A player's trajectory in the rankings | `get_rankings` with `player_id` | Looping weekly lists |
| Bulk match history for modeling | `get_matches` by `season` with `limit` and `offset` | `get_player_matches` (5 credits per page of 50) |
## Response Shapes (real, trimmed)
`get_match` (2 credits). Ratings are `null` until a match is played; `odds` is an array of one row per book:
```json
{"data": {
"tour": "ATP",
"match": {"match_id": "157774", "tournament": "Wimbledon", "season": 2025, "surface": "grass",
"round": "Final", "player1": "Jannik Sinner", "player2": "Carlos Alcaraz",
"winner_id": "3623", "outcome": "completed", "score": "4-6 6-4 6-4 6-4",
"sets": [{"set_no": 1, "games1": 4, "games2": 6, "tiebreak1": null, "tiebreak2": null, "winner": 2}]},
"players": [{"player_id": "3623", "rating": {"before": 2325, "after": 2335, "surface_before": 1995, "surface_after": 2037}}],
"stats": {"sets_played": 4, "total_games": 40, "tiebreaks": 0, "went_the_distance": false,
"winner_lost_first_set": true, "serve_stats": null},
"odds": [{"source": "the_odds_api", "book": "Pinnacle", "p1_open": 102, "p1_close": -116,
"p2_open": -115, "p2_close": 105, "games_spread1": -0.5, "games_total": 41.5}]
}}
```
`get_ratings` returns `ratings[]` with `rank`, `rating`, `player_id`, `name`, `surface_matches`, `last_match`, plus a `method` string describing the Elo.
`get_head_to_head` returns `summary` (`matches`, `wins` keyed by player id, `sets_won`, `by_surface`, `in_finals`, `walkovers`) and `matches[]`.
Paginated tools (`list_tournaments`, `get_matches`, `get_player_matches`) return `count` and `next_offset`; pass it back as `offset`.
## What the Data Does Not Contain
- **No serve or return statistics.** No aces, double faults, break points or first-serve percentages. `get_match` returns `serve_stats: null` with a note that ESPN does not publish them. Anything that needs serve percentage must be built from score proxies (see `set-score-modeling`) or from the user's own data.
- **No odds tool.** Moneylines exist only inside `get_match`. Grand Slam matches from mid-2020 and ATP Masters and WTA 1000 matches from August 2024; the array is empty elsewhere. Lines are pre-match, open and close per book, refreshed hourly. No in-play prices and no props.
- **Rankings are the top 100 to 150** per week. `current_rank` in `search_players` is `null` outside that list.
- **No injuries, lineups, weather, court speed or betting percentages.**
- Walkovers are listed but not counted as wins in head to head. Walkovers, defaults and retirements are not rated by the Elo.
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_odds` | `get_match` and read its `odds` array |
| `get_live_scores` | `get_schedule` (status `live`) or `get_matches` with `status: "live"` |
| `get_serve_stats` | Not available: no serve data exists in this dataset |
| `get_player_by_name` | `search_players` with `name` |
| `get_h2h` | `get_head_to_head` |
| `get_elo` | `get_ratings` (leaderboard) or `get_player` (one player) |
| `get_standings` | `get_rankings` |
| `get_injuries` | Not available |
## Season and Surface Resolution
- The tour year is the calendar year. "This season" is the current calendar year; the tour runs from early January to November.
- Surfaces: `hard`, `clay`, `grass`, `carpet`. Carpet is nearly empty in modern years. `indoor` is a separate flag on matches and tournaments, so indoor hard is `surface: "hard"` with `indoor: true`.
- Typical swing: indoor hard and Australia (January to March), clay (April to early June), grass (June to July), North American hard (August to September), Asian and indoor hard (September to November).
## Credit Planning
| Operation | Credits | Notes |
|-----------|---------|-------|
| Season of results, one tour | about 90 | 2026 had 45 `get_matches` pages at the time of writing (published credit rates) |
| Odds for every covered match in a season | about 7,148 | 3,574 `get_match` calls for 2026 (published credit rates) |
| Preview of one match | 15 to 20 | see `match-preview` |
| Player scouting report | 8 to 16 | see `player-scouting` |
Warn before any loop of `get_match` or `get_player_matches` calls. State the count and the credits first.
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Guess a player id from a name | Ids are exact strings, and the WTA has its own | `search_players` first, with `league` if WTA |
| Read `rating.after` as the pre-match rating | It contains the match result (leakage) | Use `rating.before` or `rating_before` |
| Subtract a surface rating from an overall rating | They are on different scales (fewer matches, different K) | Compare like with like: surface to surface |
| Treat a `null` rank as rank 0 | It means outside the top 100 to 150 | Handle as unranked |
| Count a `walkover` as a played match | No sets were played | Filter `outcome == "completed"` |
| Loop `get_player_matches` for a dataset | 5 credits per 50 matches | `get_matches` by season, 2 credits per 100 |
## What to Do Next
| What You Retrieved | Next Action | Skill |
|--------------------|-------------|-------|
| A day of results | Summarize, find upsets | `match-lookup` |
| A player profile | Scouting report | `player-scouting` |
| Two player ids | Matchup brief | `match-preview` |
| Surface ratings | Win probability | `surface-elo` |
| `odds` array | Remove the margin | `odds-analysis` |
| A season of matches | Build features | `feature-engineering` |
dispatch6.26 KB
--- name: dispatch description: "Routes tennis analytics requests to the right 2 or 3 skills. Load this FIRST when an ATP or WTA data or analytics request comes in and you are unsure which skills to activate. Prevents loading all 16 skills when only a few are needed. Also sets expectations on what the data cannot answer (serve statistics, in-play, props)." metadata: version: 1.0.0 author: AcesAPI --- # Skill Dispatch Guide Match the request to a pattern below. Load those skills only. **Default data tool:** `acesapi-tool`. It covers 13 tools for ATP and WTA singles. Load it alongside the strategy skill unless the request needs no data (user-supplied numbers). **Tour check first.** ATP is the default. For WTA questions, every call needs `"league": "wta"`. Ask which tour only if the player or event is ambiguous. ## First-Use Persona Detection | Signal | Persona | Start With | |--------|---------|-----------| | "What matches are on today?" / exploring | **Explorer** | `match-lookup` + `acesapi-tool` | | "Tell me about this player" | **Scout** | `player-scouting` + `ranking-history` | | "Who wins tomorrow?" | **Previewer** | `match-preview` + `surface-elo` | | "I want to build a prediction model" | **Builder** | `feature-engineering` + `surface-elo` | | "Is my model any good?" | **Validator** | `walk-forward-validation` + `probability-calibration` | | "I am new to odds" | **Odds reader** | `odds-analysis` + `odds-explorer` | ## Request Patterns -> Skills to Load ### "Who plays today?" / "Who won?" / "Show me the draw" / "Every final this season" 1. `match-lookup`: results, schedules, draws 2. `acesapi-tool`: `get_matches`, `get_schedule`, `get_tournament`, `list_tournaments` ### "Tell me about [player]" / "How is she doing?" / "Compare these two players" 1. `player-scouting`: profile, surface fit, opposition quality, form 2. `acesapi-tool`: `search_players`, `get_player`, `get_surface_splits`, `get_player_stats` 3. Two players with a direct record: also `head-to-head-analysis` ### "[A] vs [B] head to head" / "Who leads the rivalry?" / "How do they split on clay?" 1. `head-to-head-analysis`: record, surface split, finals, weighting 2. `surface-elo` if a probability is wanted ### "Who is number one?" / "Ranking on a date" / "How did the ranking change?" 1. `ranking-history`: lists, trajectories, points versus rank 2. `surface-elo` if the question is really about strength ### "Who is best on clay?" / "Win probability for A vs B" / "How does the rating work?" 1. `surface-elo`: ratings, probability formula, blending 2. `head-to-head-analysis` for context if a rivalry exists ### "Preview the match" / "Who is favored tomorrow and why?" 1. `match-preview`: assembles ratings, form, record, route, price 2. `surface-elo` for the probability 3. `odds-explorer` if the event is a Slam, Masters or WTA 1000 ### "What are the odds?" / "Best price" / "How did the line move?" 1. `odds-explorer`: prices, margins, open to close 2. `odds-analysis` for devigging 3. Remember: odds exist only for Grand Slams (mid-2020 on) and Masters and WTA 1000 (August 2024 on) ### "What probability does +450 imply?" / "Remove the vig" / "Shin vs power" 1. `odds-analysis`: no data needed unless prices must be fetched ### "How many games will the match go?" / "Straight sets chance" / "Tiebreak frequency" 1. `set-score-modeling`: set distributions, totals, tiebreaks 2. `surface-elo` for the match probability that feeds it 3. State that serve data does not exist ### "Build features" / "What should I feed the model?" / "Is there leakage?" 1. `feature-engineering`: pre-match ratings, form, rest, symmetric layout 2. `walk-forward-validation` when evaluation comes next ### "Cross-validation okay?" / "Compare two models" / "How many matches do I need?" 1. `walk-forward-validation`: time-ordered evaluation and significance 2. `probability-calibration` for reliability ### "Are my probabilities calibrated?" / "Overconfident on favorites" 1. `probability-calibration`: reliability tables, Platt, isotonic 2. `walk-forward-validation` for the split ## Things the Data Cannot Answer Say so immediately and offer the closest proxy. | Request | Reality | Offer | |---------|---------|-------| | Aces, double faults, first-serve percentage, break points | No serve statistics exist | Games-won share, tiebreak record, total games per match (`player-scouting`, `set-score-modeling`) | | Live or in-play prices | Pre-match open and close only | Open to close movement (`odds-explorer`) | | Player props (aces, games won, sets) | No props data | Not supported: do not build a prop model | | Injury or fitness reports | None | Rest days and recent load from scores (`feature-engineering`) | | Prices for tour 250 and 500 events | Not covered | Ratings-only analysis | | Rankings outside the top 100 to 150 | Not in the lists | Ratings via `get_ratings` for active players with 10 or more rated matches | | Doubles | Singles only | State the limit | ## Multi-Skill Workflows **Research workflow:** `ranking-history` and `surface-elo` to find an anomaly (a rank far from rating) -> `player-scouting` to explain it -> `head-to-head-analysis` for the direct record. ## Credit Awareness Before any request that loops over many matches or players, state the number of calls and the credits (see `acesapi-tool`). Cheap by default: one preview is about 20 credits; a day of results is 2; odds for a Slam round of 64 matches is 128. A typical monthly allowance is 500 credits. ## Rules for Every Answer - Cite the tool results used, and name the tour. - Use pre-match ratings only (`rating.before`), never `rating.after`. - Probabilities and edges are estimates. Mention variance and sample size, and prefer closing-line value over short-run results. - If a user asks for a lock, a guarantee or a pick to bet the house on, decline that framing and give the analysis. ## Anti-patterns | What the Model Might Do | Why It's Wrong | Do This Instead | |---------------------|---------------|-----------------| | Load all 16 skills | Wastes context | 2 or 3 per request | | Reach for a nonexistent odds tool | Odds are inside `get_match` | `odds-explorer` | | Start odds analysis without checking coverage | Tour 250 events have no prices | Check the tournament tier | | Skip the tour | WTA ids and ATP ids differ | Pass `league` | | Answer serve questions | No data | Offer proxies |
feature-engineering9.49 KB
---
name: feature-engineering
description: "Building leak-free features for tennis match prediction from AcesAPI data: pre-match Elo (overall and surface), rating gaps, rolling form, days of rest and recent load, head-to-head with a time guard, surface and format flags, and a symmetric pair layout. Use when the user is building a model, asks what features to use, or suspects leakage. Do not use for model fitting or tuning: see walk-forward-validation. Do not use for calibration: see probability-calibration. Do not use for quick lookups: see match-lookup."
metadata:
version: 1.0.0
author: AcesAPI
---
# Feature Engineering
> **Default data tool:** AcesAPI (`acesapi-tool`).
> Bulk results: `get_matches` by `season` (2 credits per 100 matches, use `offset`). Per-match pre-match ratings: `get_match` (2 each) or `get_player_matches` (5 per 50). Ages, hand, height: `get_player` (5). Ranking on a date: `get_rankings` with `date` (2).
> The cheapest design: pull seasons of results with `get_matches` and compute your own Elo and rolling features locally. About 90 credits per tour-season.
You turn match records into a table where each row is one match and every feature was knowable before the first ball. The single biggest risk is leakage, so every feature below states what it is allowed to see.
## When to Use
- "What features should I use to predict tennis matches?"
- "How do I build a rating feature without leakage?"
- "My model is 80 percent accurate, why?" (it is leaking)
- "Build a dataset from 2015 to 2025"
## When NOT to Use
- Evaluating the model: see `walk-forward-validation`
- Probability quality: see `probability-calibration`
- Using a price as a feature: see `odds-analysis` first, and treat it separately (see below)
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_matches` | Season of results with sets, surface, round, seeds, winner; paginate with `limit`, `offset` | 2 |
| `get_player_matches` | One player's matches with `rating_before`, `rating_after` | 5 |
| `get_match` | `players[].rating.before` and `surface_before`, `stats`, `odds` | 2 |
| `get_player` | `date_of_birth`, `height_in`, `plays`, country, debut year via the profile | 5 |
| `get_rankings` | List in force on a `date`, for rank and points as of before the match | 2 |
| `get_head_to_head` | Prior meetings (only ever use meetings before the match date) | 3 |
| `get_tournament` | Draw structure | 2 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_features` | Build locally from the calls above |
| `get_serve_stats` | Not available: no serve or break-point features |
| `get_pre_match_ratings` | `rating.before` in `get_match`, `rating_before` in `get_player_matches` |
| `get_injuries` | Not available: proxy with long gaps and retirements |
## Row Design: One Match, Symmetric Pair
Each match record has `player1` and `player2`. Do not treat position as meaningful. Two valid layouts:
1. **Difference layout.** Pick the focal player at random per row (or alternate), compute `feature_focal - feature_opponent`, and the label is "focal won". Mirror the dataset (add the swapped row) so the model sees both orientations; evaluate on one orientation only.
2. **Paired layout.** Keep both players' features and enforce symmetry: `P(A beats B) + P(B beats A) = 1`, by averaging the model's output across the swap.
A model that learns "player1 wins more often" has learned data ordering, not tennis.
## Feature Families
**Strength (pre-match only)**
| Feature | Source | Rule |
|---------|--------|------|
| `elo_gap` | `rating.before` for each player | Never `after` |
| `surf_elo_gap` | `surface_before` | Same surface only; thin early in a career |
| `elo_blend_gap` | shrink surface toward overall (see `surface-elo`) | Fit shrinkage by walk-forward |
| `rank_gap_log` | `log(rank2) - log(rank1)` from the list in force before the match | Rank is null outside the top 100 to 150; use an indicator plus a cap (e.g. 200) |
| `points_gap` | list points before the match | Same cap logic |
**Form and load**
| Feature | Definition | Rule |
|---------|------------|------|
| `form_10` | sum of `rating_after - rating_before` over the last 10 completed matches | Shift by one: exclude the current match |
| `win_rate_surface_20` | wins in last 20 matches on this surface | Min 5, else missing |
| `games_share_10` | games won divided by games played, last 10 | A serve and return proxy |
| `days_rest` | days since the previous match | Cap at 60; flag returns after long gaps |
| `sets_last_7d`, `games_last_7d` | sets or games played in the previous 7 days | Fatigue proxy |
| `deciding_sets_14d` | three or five set matches in 14 days | Same |
**Context**
| Feature | Source |
|---------|--------|
| `surface`, `indoor` | match record (`indoor` may be null; encode as unknown) |
| `best_of_5` | men's Grand Slam main draw; known in advance |
| `round_order` | match record |
| `seed_gap` | `seed1`, `seed2`; null for unseeded, encode as 33 or a missing flag |
| `age_gap`, `height_gap`, `hand_matchup` | `get_player` bio (compute age at match date) |
| `tourney_tier` | `major` flag from `list_tournaments`, or Masters/1000 by name |
**Head to head with a time guard**
Compute from `get_head_to_head` only meetings with `match_date` before the current match. Use `wins_a - wins_b` shrunk by `n / (n + 10)`; set to zero when `n < 3`. Do not call `get_head_to_head` per row of a large dataset (3 credits each). Build the history from your own results table instead.
## Rebuilding Elo Locally
If you compute ratings yourself from `get_matches`, you can reproduce the published method: start 1500, update both players with `K = 250 / (matches + 5)^0.4` where `matches` is the player's own count of rated matches so far (separately for each surface rating), skip walkovers, defaults and retirements, and expected score `1 / (1 + 10^(-(Ra - Rb)/400))`. Your numbers will differ from `rating.before` where your match pool differs from AcesAPI's (qualifying is included in theirs, from 2022). Validate on a sample: compare your pre-match value with `rating_before` from `get_player_matches` for one player, and expect agreement to within a few points when pools match.
```python
def update(ra, rb, na, nb, a_won):
ea = 1 / (1 + 10 ** (-(ra - rb) / 400))
ka = 250 / (na + 5) ** 0.4
kb = 250 / (nb + 5) ** 0.4
s = 1.0 if a_won else 0.0
return ra + ka * (s - ea), rb + kb * ((1 - s) - (1 - ea))
```
Process matches strictly in date order (`starts_at`), and write the pre-match rating into the row before applying the update.
## Leakage Checklist
| Leak | Why | Fix |
|------|-----|-----|
| `rating.after`, `surface_after` | Contains this match's result | Use `before` |
| Score, `sets`, `games1`, `stats.total_games` of the target match | It is the outcome | Drop from features |
| Rank from the list dated after the match | Includes the result | Take `get_rankings` with `date` set to the day before |
| Rolling means that include the current row | Self-reference | Shift by 1 before windowing |
| Career record from `get_player` pulled today | Includes later matches | Rebuild from results as of the match date |
| Random train/test split | Future matches train the past | Time split, see `walk-forward-validation` |
| Odds close as a feature, then reporting accuracy | The market already knows | Report it as a market-informed model; compare against the market baseline |
| Round and draw knowledge | Fine, known in advance | Keep |
| Final-round survivors | Winners reach late rounds | Do not condition on round reached by outcome |
Sanity test: a pre-match tennis model that beats the closing line by a wide margin is leaking. Rating-only models usually land in the mid-60s for accuracy on tour-level matches (run the Elo baseline on your own data to confirm); treat 75 percent as a bug to explain.
## Missing Data
- Walkovers, retirements: drop from training and from rating updates, keep as a "retired recently" flag for the next match.
- Qualifying draw matches: available from 2022; the rating updates include them, so models trained before and after 2022 see different coverage.
- Rank null: indicator plus cap. Never fill with 0.
- First matches of a career: few prior matches means the rating is unsettled; include `matches_played_log` as a feature so the model can discount.
## Credit Usage
| Dataset | Calls | Credits |
|---------|-------|---------|
| One tour, one season of results | about 45 `get_matches` pages | about 90 |
| Ten seasons, one tour | about 450 pages | about 900 |
| Ratings from `get_match` per row | 1 per match | 2 per match: avoid, recompute locally |
| Player bios for 300 players | 300 `get_player` | 1,500: sample or only fetch finalists |
State the credit estimate before pulling more than one season.
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Use `rating.after` as a feature | Leak | `before` |
| Let `player1` mean something | It is record order | Symmetrize |
| Fill missing rank with 0 | 0 would be better than #1 | Indicator and cap |
| Add career win rate from today's profile | Includes the future | Rebuild as-of |
| Evaluate on a shuffled split | Leak | Walk forward |
| Add 80 features for 20,000 rows | Overfit | Start with Elo gap, surface Elo gap, rest, form |
## What to Do Next
| What You Built | Next Action | Skill |
|----------------|-------------|-------|
| A feature table | Fit and evaluate in time order | `walk-forward-validation` |
| Predictions | Check probabilities | `probability-calibration` |
head-to-head-analysis7.1 KB
---
name: head-to-head-analysis
description: "Head-to-head analysis for two tennis players: wins each, sets won, record by surface and in finals, every meeting with its score, and how much weight a small head-to-head deserves. Use when the user asks who leads a rivalry, how two players match up on a surface, or whether a past result predicts the next one. Do not use for a player's overall profile: see player-scouting. Do not use for win probabilities from ratings: see surface-elo. Do not use for the pre-match brief that combines everything: see match-preview."
metadata:
version: 1.0.0
author: AcesAPI
---
# Head-to-Head Analysis
> **Default data tool:** AcesAPI (`acesapi-tool`).
> `get_head_to_head` (3 credits) returns the whole record: summary and every meeting. `search_players` (1) resolves the two ids. `get_player_matches` with `opponent_id` (5) adds ratings at the time of each meeting.
You read a rivalry honestly. A head-to-head is a tiny sample, often confounded by surface, age and form at the time. Report the record, then say how much it should move a forecast, which is usually very little once ratings are known.
## When to Use
- "Who leads Sinner against Alcaraz?"
- "How do they split on clay?"
- "Has she ever beaten her in a final?"
- "Does the head-to-head matter for tomorrow?"
## When NOT to Use
- One player only: see `player-scouting`
- The probability from ratings: see `surface-elo`
- A full pre-match brief with prices: see `match-preview`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `search_players` | Resolve each name to a `player_id` | 1 |
| `get_head_to_head` | `summary` (matches, wins, sets, `by_surface`, `in_finals`, walkovers) and `matches[]` | 3 |
| `get_player_matches` | Use `opponent_id` to get one player's meetings with `rating_before` for each | 5 |
| `get_match` | Detail on one meeting: sets, tiebreak points, ratings | 2 |
| `get_ratings` | Current ratings for context | 2 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_h2h` | `get_head_to_head` |
| `get_rivalry` | `get_head_to_head` plus the method below |
| `get_head_to_head_odds` | Not available: pull `get_match` for the upcoming match |
## How It Works
**Step 1: Resolve both ids.** `search_players {"name": "sinner"}` returns `"player_id": "3623"`; `search_players {"name": "alcaraz"}` returns `"3782"`. Add `league: "wta"` for the WTA. Both players must be on the same tour.
**Step 2: One call.** `get_head_to_head {"player_id": "3623", "opponent_id": "3782"}`.
Real output (trimmed), `summary` as returned:
```json
{"matches": 17,
"wins": {"3623": 7, "3782": 10},
"sets_won": {"3623": 25, "3782": 26},
"by_surface": {"clay": {"3623": 2, "3782": 3},
"hard": {"3623": 3, "3782": 7},
"grass": {"3623": 2, "3782": 0}},
"in_finals": {"3623": 4, "3782": 5},
"walkovers": 0}
```
Keys of `wins` and `by_surface` are `player_id` strings. Map them to names before writing the answer.
**Step 3: Read the matches.** `matches[]` is newest first. In the live response, the latest meeting was the 2026 Monte-Carlo Masters final on clay, `7-6(5) 6-3` for Sinner, and the one before it the 2025 Nitto ATP Finals final (indoor hard), `7-6(4) 7-5` for Sinner. Report recency: a record of 10-7 where the leader lost the last two is a different story from 10-7 the other way.
**Step 4: Weigh it.** Compute the rivalry as a sample, not a fact.
## Method: How Much Should a Head-to-Head Count?
A rivalry record of `a` wins to `b` wins is `n = a + b` observations of a process the ratings already summarize. Use it as a small adjustment:
```
P_h2h_shrunk = (a + m * P_rating) / (n + m) # m = prior strength, try 10
```
where `P_rating` is the pre-match probability from `surface-elo` and `m` expresses how many matches' worth of weight the rating gets. At 10-7 and `P_rating = 0.50` with `m = 10`, the shrunk estimate is `(10 + 5) / 27 = 0.56` for the leader, not 10/17 = 0.59. With `n = 3` it barely moves. Pick `m` by out-of-sample log loss; if you cannot test it, state that `m` is a judgment call.
Better still, check whether the head-to-head adds information beyond ratings: regress the result of each meeting on the pre-match rating gap and a prior-meeting-record feature, in time order (see `walk-forward-validation`). In most datasets the second feature adds little.
## What Makes a Rivalry Misleading
- **Surface mix.** The 17 meetings above are spread over hard (10), clay (5) and grass (2). A hard-court lead says little about a clay match.
- **Time.** Meetings years ago involved younger versions of both players. Weight recent meetings, or use `rating_before` per meeting to see how mismatched each one was.
- **Format.** Slam meetings are best of five for men. Finals are a small subset that often involve two players in form.
- **Retirements and walkovers.** The tool lists walkovers but does not count them as wins. A retirement is counted if sets were recorded; read the `outcome` of the meeting.
- **Sample size.** With under about 8 meetings, any split is within noise. The Wilson interval for 10 of 17 is roughly 0.36 to 0.78.
## Worked Example
User: "How do Sinner and Alcaraz compare on clay?"
```
1. get_head_to_head {"player_id": "3623", "opponent_id": "3782"} -> 3 credits
2. get_ratings {"surface": "clay", "limit": 10} -> 2 credits
```
Clay record: Alcaraz 3, Sinner 2. Clay ratings from the live call: Alcaraz 2247 (121 clay matches), Sinner 2209 (106). Rating gap 38 points gives Alcaraz about 55 percent on clay. The record, five matches, agrees in direction and adds almost nothing. The most recent clay meeting went to Sinner, in straight sets. The honest summary: a coin flip with a small lean to Alcaraz, and the head-to-head is too small to change it. Total cost: 5 credits.
## Credit Usage
| Operation | Credits |
|-----------|---------|
| Resolve two ids | 2 |
| Head to head | 3 |
| Ratings context | 2 to 10 |
| Per-meeting ratings via `get_player_matches` | 5 |
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Say "he owns her" off a 3-1 record | n = 4 | Report the record and say it is a small sample |
| Mix tours | Ids differ and the players never meet | Same `league` for both |
| Ignore surface | A grass record does not transfer to clay | Use `by_surface` |
| Count walkovers as wins | They are not counted by the tool | Use the summary |
| Use the record as the probability | Ignores everything else | Shrink toward the rating probability |
## Output Format
```
[A] vs [B]: [a]-[b] overall ([n] matches)
By surface: hard [x]-[y], clay [x]-[y], grass [x]-[y]
In finals: [x]-[y]
Last 3: [date] [tournament] [winner] [score] ...
Weight: [low / moderate], because [sample, surface mix, recency]
```
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| A clear surface split | Check surface ratings | `surface-elo` |
| A decision on an upcoming match | Build the brief | `match-preview` |
| A head-to-head feature idea | Add it with a time guard | `feature-engineering` |
match-lookup7.86 KB
---
name: match-lookup
description: "Finding ATP and WTA matches, draws, schedules and tournaments: results by day, a tournament's draw round by round, today's and this week's schedule, finals of a season, set and tiebreak scores. Use when the user asks who won, who plays today, what the score was, how a tournament draw looked, or which tournaments are on a surface. Do not use for a player's career profile: see player-scouting. Do not use for two-player matchups: see head-to-head-analysis. Do not use for moneylines: see odds-explorer."
metadata:
version: 1.0.0
author: AcesAPI
---
# Match Lookup
> **Default data tool:** AcesAPI (`acesapi-tool`).
> Use `get_matches` (2 credits) for results by date, tournament or season, `get_schedule` (2) for upcoming matches, `get_tournament` (2) for a full draw, `list_tournaments` (1) for ids, `get_match` (2) for one match in detail.
You find tennis matches and read them correctly. Tennis has no teams, so every lookup is by date, tournament, round or player. Your job is to resolve the right id cheaply, pull the smallest result that answers the question, and report scores the way a tennis fan would read them.
## When to Use
- "Who won the Wimbledon final?" "What was the score?"
- "Who plays today?" "What is on the schedule this week?"
- "Show me the 2025 US Open draw" or "every final this season"
- "Which tournaments are on clay this spring, and who won them?"
- "Did the match go five sets?" "Were there tiebreaks?"
## When NOT to Use
- A player's career, ratings or form: see `player-scouting`
- Two players against each other: see `head-to-head-analysis`
- Lines and prices for a match: see `odds-explorer`
- Building a full pre-match brief: see `match-preview`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `list_tournaments` | A season's tournaments: dates, city, surface, Grand Slam flag, status, champion | 1 |
| `get_tournament` | Every singles match of one edition, grouped by round, with sets and seeds | 2 |
| `get_matches` | Matches by `date`, `tournament_id` or `season`; filter `round`, `status` | 2 |
| `get_schedule` | Scheduled and in-progress matches for the next `days` (default 7, max 30) | 2 |
| `get_match` | One match: sets, tiebreak points, ratings before and after, stats, odds | 2 |
| `search_players` | Resolve a name to a `player_id` when the question names a player | 1 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_live_scores` | `get_schedule`, or `get_matches` with `status: "live"`; scores are set by set, not point by point |
| `search_matches` | `get_matches` with filters, or `get_player_matches` for one player |
| `get_draw` | `get_tournament` |
| `get_team_schedule` | There are no teams: use `get_schedule` or `get_player_matches` |
## How It Works
**Step 1: Resolve the target.**
| User says | Resolve with |
|-----------|--------------|
| A date ("yesterday", "last Sunday") | Convert to `YYYY-MM-DD`. Dates are UTC, so a late match in the Americas can land on the next date |
| A tournament name | `list_tournaments` for the season, match on `name`, take `tournament_id` (format `188-2025`) |
| A player name | `search_players`, then `get_player_matches` (5 credits) if their log is needed |
| "This week" | `get_schedule` with `days: 7` for upcoming, `get_matches` with `date` for each past day |
| WTA | Add `"league": "wta"` to every call |
**Step 2: Pull the narrowest result.**
- One day of results: `get_matches {"date": "2025-07-13"}`
- One tournament's final: `get_matches {"tournament_id": "188-2025", "round": "Final"}`
- All finals of a season: `get_matches {"season": 2025, "round": "Final", "limit": 100}`
- The whole draw: `get_tournament {"tournament_id": "188-2025"}` (`draw: "all"` adds qualifying, available from 2022)
**Step 3: Read the score.**
`score` is a string such as `4-6 6-4 6-4 6-4` or `7-6(5) 6-3`. In a set decided by a tiebreak the number in brackets is the loser's tiebreak points. `sets[]` has the same information as numbers: `games1`, `games2`, `tiebreak1`, `tiebreak2`, `winner` (1 or 2). Player 1 and player 2 are positions in the record, not seeds or favorites.
**Step 4: Check `outcome` before counting anything.** A real response for a walkover looked like this (trimmed): `"outcome": "walkover", "score": "w/o", "sets": []`. Retirements, defaults and walkovers have partial or empty set data. Count a match as played only when `outcome` is `completed`, and say so when you drop rows.
**Step 5: Report.** Lead with the answer, then the context: tournament, surface, round, score, and one line on what the score says (straight sets, five sets, tiebreaks, a comeback). `get_match` gives `stats.went_the_distance` and `stats.winner_lost_first_set` directly.
## Worked Example
User: "Who won the 2025 Wimbledon final and how?"
```
1. list_tournaments {"season": 2025, "surface": "grass"} -> 1 credit, find "188-2025"
2. get_matches {"tournament_id": "188-2025", "round": "Final"} -> 2 credits
```
Real record (trimmed): `Jannik Sinner` (seed 1) beat `Carlos Alcaraz` (seed 2) `4-6 6-4 6-4 6-4`, Centre Court, grass. Answer: "Sinner won in four sets after dropping the first (22 games to 18, no tiebreaks). It was the Wimbledon final of 2025 on grass." Total cost: 3 credits.
## Season Logic
- "Last season" is the previous calendar year. A tournament edition belongs to the year it starts.
- Two events can share a week. A date query returns all of them; group by `tournament` before summarizing.
- `status` values are `scheduled`, `live`, `final`. A match with `status: "final"` and `outcome: "walkover"` is final but unplayed.
- Rounds are named as the data lists them (seen in live responses: `Round 1` to `Round 4`, `Group Stage`, `Semifinal`, `Final`). Pass the name exactly; if a filter returns nothing, read `round` from a `get_tournament` response. Use `round_order` to sort them (the Final is 22).
- Draw sizes vary by event (`main_draw_matches` in `list_tournaments` shows 27 to 31 for tour events). Do not assume a fixed number of rounds.
## Credit Usage
| Operation | Credits |
|-----------|---------|
| Resolve tournament id | 1 |
| One day of results (up to 100 matches) | 2 |
| One full draw | 2 |
| One season of results, one tour | about 90 (45 pages of 100 at the time of writing) |
| Detail on one match | 2 |
Do not call `get_match` on every row of a day just to read a score. `get_matches` already returns score and sets. Use `get_match` only for ratings, `stats` or `odds`.
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Treat `player1` as the favorite or the winner | It is only a record position | Read `winner_id` |
| Report a walkover as a straight-set win | No sets were played | Check `outcome`, say "walkover" |
| Pass "Quarter-finals" or "R1" as `round` | The filter needs the listed name | Use the names from the data, or pull the draw and read `round` |
| Query "today" without UTC in mind | A match that ends after midnight UTC is on the next date | Query the adjacent date when a result is missing |
| Assume five sets | Best of five is for men at Grand Slams only; everything else is best of three | Read the actual `sets[]` |
## Output Format
```
[Tournament] [Year], [Surface], [Round]
[Winner] (seed) d. [Loser] (seed) [score]
[One line: duration of the match in sets, tiebreaks, comeback, upset by seed or rating]
```
For a day of results, group by tournament and list matches by `round_order`, newest first.
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| An upset | Check ratings and prices before the match | `surface-elo`, `odds-explorer` |
| Tomorrow's match worth a brief | Build the matchup page | `match-preview` |
| A player who keeps appearing | Profile them | `player-scouting` |
| Two players who met again | Pull the full history | `head-to-head-analysis` |
match-preview6.85 KB
---
name: match-preview
description: "One-page pre-match brief for an upcoming ATP or WTA match: tournament and surface context, both players' ratings overall and on the surface, recent form, head to head, route through the draw, and the market price if the event is covered. Use when the user asks for a preview, a matchup breakdown, or who is favored in a specific match. Do not use for results: see match-lookup. Do not use for deep single-player work: see player-scouting."
metadata:
version: 1.0.0
author: AcesAPI
---
# Match Preview
> **Default data tool:** AcesAPI (`acesapi-tool`).
> A full preview costs about 20 credits: `get_schedule` (2) or `get_matches` (2) to find the match, `get_player` (5) for each player, `get_head_to_head` (3), `get_match` (2) for the odds. Add `get_surface_splits` (3 each) when the surface matters.
You assemble a decision-ready brief. Lead with who the evidence favors and by how much, then the reasons. Keep every number sourced from a call you made. Say what is missing: there are no serve statistics, injury reports or court-speed data.
## When to Use
- "Preview tomorrow's Alcaraz match"
- "Who is favored in the Shanghai third round, and why?"
- "Break down the final for me"
## When NOT to Use
- A finished match: see `match-lookup`
- A single player's whole profile: see `player-scouting`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_schedule` | Find the match, `match_id`, start time, round, court | 2 |
| `get_match` | Match record, ratings (null before play), and the `odds` array | 2 |
| `get_player` | Bio, ratings by surface, career record, last 10 | 5 |
| `get_head_to_head` | Meetings, by surface, in finals | 3 |
| `get_surface_splits` | Record on today's surface | 3 |
| `get_player_stats` | Record against top-10 and top-50 opponents | 3 |
| `get_tournament` | The draw: who each player has beaten to get here | 2 |
| `get_rankings` | Ranking and points, if the question is about seeding | 2 |
| `search_players` | Ids from names | 1 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_preview` | This skill builds it from the calls above |
| `get_odds` | `get_match` and read `odds` |
| `get_serve_stats` | Not available |
| `get_court_speed` | Not available: use surface and the `indoor` flag only |
| `get_injury_report` | Not available: say so, and look at recent retirements in `get_player_matches` |
## How It Works
**Step 1: Find the match.** `get_schedule {"days": 2}`. Real fields: `match_id`, `tournament`, `surface`, `indoor`, `round`, `court`, `starts_at`, `player1`, `player2`, `seed1`, `seed2`, `status`. `indoor` can be `null` when unknown; do not assume.
**Step 2: Pull the match record and price.** `get_match {"match_id": "..."}`. Before play, `players[].rating` is `null` (real response for a scheduled match), so take ratings from `get_player` or `get_ratings`. `odds` has one row per book if the event is a Grand Slam, a Masters or a WTA 1000; otherwise it is empty.
**Step 3: Ratings and form.** `get_player` for each player: overall rating, surface rating and match count on today's surface, career record, last 10. Compute the rating change over the last 10 completed matches as the form signal (see `player-scouting`).
**Step 4: Head to head.** `get_head_to_head`. Weigh by sample size (see `head-to-head-analysis`).
**Step 5: Route.** `get_tournament` for the event: list each player's wins so far, and total games or sets dropped. A player who has played three three-setters carries more load than one who has won in straight sets. This is a signal from scores, not from physical data, so say "load proxy".
**Step 6: Model probability.** Elo probability from `surface-elo`. If the event is covered, add the no-vig close from `odds-analysis`. Do not force them to agree; show both.
**Step 7: Write the brief.**
## Worked Example (structure with real fields)
Match: Alexander Zverev vs Quentin Halys, Shanghai Masters 2026, Round 3, hard, scheduled 2026-10-11. From `get_match`:
- `rating` was `null` for both (not played yet), so ratings come from `get_player`
- `odds` had six books. Pinnacle close: Zverev -673, Halys +537, margin 2.8%, no-vig Zverev about 84.7%
- `games_spread1` -3.5 and `games_total` 22.5 at Pinnacle (a two-set match line)
Brief:
```
Zverev (-673) vs Halys (+537), Shanghai R3, hard
Market: Zverev ~85% no-vig (Pinnacle), range across books [x]% to [y]%
Rating: Zverev [overall / hard], Halys [overall / hard] -> model [z]%
H2H: [a]-[b] ([n] matches), last meeting [date]
Load: games and sets dropped so far this event
Games line: Zverev -3.5 games, total 22.5
Missing: serve data, injuries, court speed
```
Cost: 2 (schedule) + 2 (match) + 10 (two players) + 3 (head to head) = 17 credits.
## Reading Tennis Matchups from Scores
| Signal | Source | Use |
|--------|--------|-----|
| Surface fit | `get_surface_splits` games-won share on the surface, with match count | Is the surface rating real? |
| Big-match record | `get_player_stats` vs top 10 and top 50 | Quality of wins |
| Tiebreak record | `tiebreaks_won`, `tiebreaks_lost` | Close-set tendency (small sample) |
| Deciding sets | `deciding_sets_won` of `deciding_sets` | Noisy, do not lean on it |
| Time since last match | `last_match` in `search_players` | Rust or fatigue (days off) |
| Format | Best of five at men's Slams, otherwise best of three | Favorites win more in best of five |
## Credit Usage
| Brief depth | Calls | Credits |
|-------------|-------|---------|
| Quick: match, price, ratings | `get_schedule`, `get_match`, `get_ratings` | about 6 |
| Standard | + `get_player` x2, `get_head_to_head` | about 20 |
| Full with route and splits | + `get_tournament`, `get_surface_splits` x2 | about 28 |
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Invent serve numbers | Not in the data | Name the gap |
| Use `rating.after` from `get_match` | Contains the result | Pre-match ratings only |
| Declare a favorite from rank alone | Rank is a points total and can lag form | Use Elo plus market |
| Skip the tour check | WTA needs `league: "wta"` | Pass it everywhere |
| Quote odds for an uncovered event | The array is empty | Say the event is outside coverage |
| Rely on a 2-meeting head to head | Too small | Down-weight it |
## Output Format
```
[P1] vs [P2] | [Tournament], [Round], [Surface], [start time UTC]
Lean: [P] at [x]% (model), market [y]% (no-vig close, Pinnacle)
Why: 1) ... 2) ... 3) ...
Risks and gaps: ...
```
Always include "Estimates, not guarantees" when probabilities are shown, and never present a lean as a bet recommendation.
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| Totals or set markets of interest | Model them | `set-score-modeling` |
| Thin surface sample | Check the rating blend | `surface-elo` |
odds-analysis6.97 KB
--- name: odds-analysis description: "Odds math for two-way tennis markets: American to implied probability, margin, devigging (multiplicative, power, Shin), no-vig fair odds, consensus across books, favorite-longshot bias, and expected value of a price. Use when the user asks what a price implies, how to remove the vig, what the true probability is, or how to compare books. Do not use for fetching or comparing live prices: see odds-explorer." metadata: version: 1.0.0 author: AcesAPI --- # Odds Analysis > **Default data tool:** AcesAPI (`acesapi-tool`). > Prices come from the `odds` array of `get_match` (2 credits per match). The math here needs no calls. With the user's own prices, it costs nothing. You are an expert in converting prices to probabilities. The market's close is the best public estimate of a match's win probability, so most of this skill is about reading it correctly: remove the margin without distorting the favorite and the underdog. ## When to Use - "What probability does -450 imply?" - "Remove the vig from this line" - "What is the fair price if the book charges 4 percent?" - "Which book is sharpest?" (lowest margin, closest to consensus) - "Why does the underdog look like value after I devig?" (usually a method problem) ## When NOT to Use - Fetching and displaying prices: see `odds-explorer` ## Commands Available | Command | What It Does | Credits | |---------|-------------|---------| | `get_match` | Source of `odds`: `p1_open`, `p1_close`, `p2_open`, `p2_close` per book | 2 | | `get_schedule` | Find `match_id` for upcoming matches | 2 | | `get_matches` | Find `match_id` for past matches | 2 | ## Commands That Do NOT Exist | Not Available | Use Instead | |--------------|-------------| | `get_odds` | `get_match` | | `get_implied_probability` | Compute from the formulas below | | `get_consensus_line` | Average no-vig probabilities across the rows of `odds` | ## Conversion ``` American a > 0: decimal = 1 + a/100; implied = 100/(a+100) American a < 0: decimal = 1 + 100/(-a); implied = (-a)/(-a+100) fair American from probability p: p >= 0.5 -> -100p/(1-p); p < 0.5 -> 100(1-p)/p margin (overround) = implied1 + implied2 - 1 ``` A two-way tennis market has exactly two outcomes (no draw), which makes devigging a one-parameter problem. ## Devigging Methods Let `q1`, `q2` be the raw implied probabilities with `q1 + q2 = 1 + m`. **1. Multiplicative (proportional).** `p_i = q_i / (q1 + q2)`. Fast, standard. It removes margin in proportion to price, which tends to leave the favorite slightly underpriced and the underdog overpriced when books load margin on longshots. **2. Additive.** `p_i = q_i - m/2`. Equal subtraction per side. Can push a longshot's probability below zero on lopsided matches. Do not use when either price is beyond about +1000. **3. Power.** Find `k` such that `q1^k + q2^k = 1`, then `p_i = q_i^k`. Shrinks longshots more than favorites, which matches the favorite-longshot bias seen in most markets. Solve with bisection on `k` in [1, 2]. **4. Shin.** Models the margin as protection against informed bettors. Solve numerically for `z` (the insider proportion): ``` with S = q1 + q2: p_i(z) = (sqrt(z^2 + 4(1-z) q_i^2 / S) - z) / (2(1-z)) find z in [0, 0.4] such that p_1(z) + p_2(z) = 1 (bisection) ``` Treat Shin and power as the better choices for lopsided matches and multiplicative for near-even matches. When methods differ by less than 0.5 percentage points, the choice does not matter. ## Worked Example (real data: Pinnacle, 2025 Wimbledon final, close) `p1_close = -116`, `p2_close = 105`. ``` q1 = 116/216 = 0.5370 q2 = 100/205 = 0.4878 margin = 2.48% multiplicative: p1 = 0.5370 / 1.0248 = 0.5240 fair American for p1: -100*0.5240/0.4760 = -110 ``` So the market's fair price on Sinner was about -110 against -116 on offer. Lopsided case (real data: Pinnacle close, 2026 Shanghai Masters third round, scheduled at time of the call): `-673` and `+537`. ``` q1 = 673/773 = 0.8706 q2 = 100/637 = 0.1570 margin = 2.76% multiplicative: favorite 0.8472, underdog 0.1528 ``` Power (k = 1.070): favorite 0.8622, underdog 0.1378. Shin (z = 0.028): favorite 0.8568, underdog 0.1432. The underdog's fair probability ranges from 13.8% to 15.3% depending on method, a gap larger than most claimed edges. Run two methods before arguing about value on a +537 price. ## Consensus and Sharpness 1. Devig each book at close with the same method. 2. Weight books by inverse margin (or give Pinnacle 2x weight if present). 3. Consensus close = weighted mean of no-vig probabilities. On the Wimbledon final, the six books' no-vig close for Sinner ranged from 0.506 to 0.531 (multiplicative). A spread under 3 points is normal; wider than 5 means one row is stale or a quirk of an exchange back price. The lowest-margin book is not automatically the sharpest, but it is a good reference. ## Expected Value of a Price For a stake of 1 and decimal odds `d` with win probability `p`: ``` EV = p * (d - 1) - (1 - p) = p * d - 1 break-even p = 1 / d ``` EV is only as good as `p`. If `p` comes from the no-vig market, the EV of the best price at any book equals minus the margin gap between the best price and the fair price. That is the benchmark: any claimed edge should be measured against the closing no-vig number, not against zero. ## Pitfalls - Using `p1_open` from one row and `p2_close` from another. - Treating `p1` as the favorite. - Averaging American odds. Average probabilities instead. - Devigging an exchange back price as if it were a two-sided quote: Betfair's margin of 0.7% on the final reflects that it is a tight market, not a free lunch. - Applying a margin that was measured on a favorite to an underdog price. Margin is not evenly split. - Concluding value from a single match. One outcome cannot confirm a probability. ## Credit Usage | Operation | Credits | |-----------|---------| | Devig prices the user pasted | 0 | | Devig one match from AcesAPI | 2 (plus 2 to find the id) | | Consensus for a day of 20 priced matches | about 40 | ## Anti-patterns | What the Model Might Do | Why It's Wrong | Do This Instead | |---------------------|---------------|-----------------| | Treat implied probability as true probability | It includes margin | Devig first | | Use additive devig on +1000 or longer | Can go negative or distort | Power or Shin | | Average American odds across books | Not linear in probability | Average no-vig probabilities | | Say the underdog is "+EV" after one devig method | The method choice can create the edge | Show two methods; if they disagree, say so | | Use open prices as the fair line | Close is more informed | Use close for fair probability, open for movement | ## Output Format ``` Price: [p1 price] / [p2 price] Margin: X.X% Raw implied: P1 xx.x% P2 xx.x% No-vig (multiplicative): P1 xx.x% (power: xx.x%) Fair American: P1 [price] ``` ## What to Do Next | What You Found | Next Action | Skill | |----------------|-------------|-------| | A model whose probabilities look off | Check calibration | `probability-calibration` |
odds-explorer7.02 KB
---
name: odds-explorer
description: "Multi-book tennis moneyline comparison, best price, margin (vig) per book, and open-to-close line movement for Grand Slam, ATP Masters and WTA 1000 matches. Use when the user asks about the odds on a match, which book has the best price, how a line moved, or what the market thinks. Do not use for devigging math or true probabilities: see odds-analysis. Do not use for results: see match-lookup."
metadata:
version: 1.0.0
author: AcesAPI
---
# Odds Explorer
> **Default data tool:** AcesAPI (`acesapi-tool`).
> There is no odds tool. Moneylines live in the `odds` array of `get_match` (2 credits per match). Get the `match_id` first from `get_schedule` (2) or `get_matches` (2).
You are an expert in reading a two-way tennis market. Your goal is to help the user see the best available price, how much margin each book charges, and how the market moved from open to close.
## Coverage, Stated Plainly
- Grand Slam singles matches from mid-2020, and ATP Masters and WTA 1000 matches from August 2024. Other tournaments return an empty `odds` array.
- Pre-match only: one open price and one close price per book. No in-play prices, no props, no set betting.
- Source: The Odds API, refreshed hourly. Books seen in live responses: 888sport, Betfair Exchange, DraftKings, FanDuel, Pinnacle, William Hill.
- Prices are American odds. `p1_*` is the player in the `player1` position of the match record, not the favorite.
## When to Use
- "What are the odds on tomorrow's Masters match?"
- "Which book has the best price on the underdog?"
- "How much did the line move before the final?"
- "What does Pinnacle think compared with DraftKings?"
## When NOT to Use
- Removing the margin or computing fair probabilities: see `odds-analysis`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_match` | One match with its `odds` array: per book `p1_open`, `p1_close`, `p2_open`, `p2_close`, `games_spread1`, `games_total` | 2 |
| `get_schedule` | Upcoming matches with `match_id`, to find what is priced | 2 |
| `get_matches` | Past matches with `match_id`, for historical lines | 2 |
| `search_players` | Resolve a name before searching the schedule | 1 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_odds` | `get_match` and read `odds` |
| `get_line_movement` | Compare `p1_open` with `p1_close` from `get_match`; only two points exist per book |
| `get_live_odds` | Not available: pre-match only |
| `get_player_props` | Not available |
| `get_set_betting` | Not available |
## Fields in the odds Array
| Field | Meaning |
|-------|---------|
| `book` | Sportsbook name |
| `p1_open`, `p2_open` | American odds at the first snapshot |
| `p1_close`, `p2_close` | American odds at the last pre-match snapshot |
| `games_spread1` | Handicap in games for player 1, present for a few books only (Pinnacle in the examples), else `null` |
| `games_total` | Total games line, present for a few books only, else `null` |
For a match that has not started, "close" is the latest price, not yet final.
## How It Works
**Step 1: Find the match.** `get_schedule {"days": 2}` lists matches with `match_id`. If the user names players, filter by `player1` and `player2`. Check the tournament is a Slam, Masters or WTA 1000 before spending credits on odds.
**Step 2: Pull odds.** `get_match {"match_id": "184930"}`. Read `odds`.
**Step 3: Price table.** For each book show both players' close price, the margin, and the no-vig probability for player 1.
```
implied(a) = 100 / (a + 100) if a > 0
= -a / (-a + 100) if a < 0
margin = implied(p1) + implied(p2) - 1
no_vig_p1 = implied(p1) / (implied(p1) + implied(p2))
```
**Step 4: Best price.** Per side, the best price is the highest number (+250 beats +230, -110 beats -120). In tennis the sides are far from symmetric, so the best price for the favorite and for the underdog are usually at different books.
**Step 5: Movement.** `p1_close` against `p1_open` gives direction. Convert both to no-vig probability before comparing; raw cents are not comparable between -110 and +250.
## Worked Example (real data, 2025 Wimbledon final, Sinner as player 1)
Pinnacle row: `p1_open 102`, `p2_open -115`, `p1_close -116`, `p2_close 105`.
```
open : implied(+102) = 0.4950, implied(-115) = 0.5349, margin 2.99%, no-vig p1 = 0.4807
close: implied(-116) = 0.5370, implied(+105) = 0.4878, margin 2.48%, no-vig p1 = 0.5240
```
The market moved about 4.3 percentage points toward Sinner between open and close. The other books at close, by margin: Betfair Exchange 0.7%, 888sport and William Hill 3.6%, FanDuel 4.6%, DraftKings 5.6%. Sinner won. One result says nothing about whether the close was right.
Cost: 2 credits for `get_match`.
## Credit Usage
| Operation | Credits |
|-----------|---------|
| Odds for one match | 2 |
| Find ids for a day | 2 |
| Odds for a full Slam round (64 matches) | 128 plus 2 to list them |
| Odds for every covered match in a season | roughly 7,000 (3,574 calls in 2026 per the published credit rates) |
State the count first for anything above a handful of matches.
## Reading the Market
- **Margin is lower at sharp books.** Betfair Exchange and Pinnacle showed 0.7% and 2.5% on the final; DraftKings 5.6%. Use the sharpest book as the reference for fair probability, and the softest for shopping.
- **Lopsided prices hide a larger margin.** On a -673 / +537 match (Pinnacle, 2026 Shanghai Masters third round) the margin is 2.8%, but the favorite's and underdog's implied probabilities are 87.1% and 15.7%. See `odds-analysis` for why the longshot side carries more of the margin.
- **Exchange prices can be inconsistent.** A back price on an exchange is not a two-sided quote. Use it as a signal, not a fillable price.
- **Open and close are snapshots, not a time series.** Do not describe steam moves or timing.
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Look for a separate odds tool | There is none | `get_match` |
| Assume `p1` is the favorite | It is the record position | Compute the favorite from the prices |
| Compare movement in cents | -110 to -125 and +250 to +275 are different probability moves | Convert to no-vig probability |
| Quote odds for a tour 250 | Empty array, not an error | Check the tournament tier first |
| Say a book "moved sharply" | Only open and close exist | Describe the net move |
| Mix `p1_open` from one book and `p2_open` from another | The pair must come from one quote | Always use the two sides of one row |
## Output Format
```
[Player 1] vs [Player 2] [Tournament], [Round], [Surface]
Close prices:
Book P1 P2 Margin No-vig P1
Pinnacle -116 +105 2.5% 52.4%
...
Best price: P1 [price] @ [book]; P2 [price] @ [book]
Move (Pinnacle, no-vig): 48.1% -> 52.4% for P1 (+4.3 pts)
```
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| Prices in hand | Compute fair probability | `odds-analysis` |
player-scouting8.61 KB
---
name: player-scouting
description: "Scouting and comparing ATP and WTA players: profile, ranking, Elo overall and by surface, career record, titles, form, record by surface, round and opponent ranking, and ranking trajectory. Use when the user asks about a player, who is better on a surface, recent form, how a player does against top-10 opponents, or to compare two players side by side. Do not use for a single match result: see match-lookup. Do not use for two players' direct meetings: see head-to-head-analysis. Do not use for win probabilities: see surface-elo."
metadata:
version: 1.0.0
author: AcesAPI
---
# Player Scouting
> **Default data tool:** AcesAPI (`acesapi-tool`).
> Start with `search_players` (1 credit) for the id. `get_player` (5) is the one-call profile. Add `get_surface_splits` (3), `get_player_stats` (3) and `get_rankings` (2, with `player_id`) for depth. `get_player_matches` (5 per page) is the match log.
You build scouting reports from results data. Be honest about what the data is: scores, surfaces, rankings and Elo. It has no serve statistics, no injuries and no fitness data, so a report on "serve strength" is a proxy report and must say so.
## When to Use
- "Tell me about Jannik Sinner" or "profile Iga Swiatek"
- "Who is better on clay, A or B?" (see also `head-to-head-analysis`)
- "How does she do against top-10 players?"
- "Is he in form?" "Has the ranking moved?"
- "Compare these two players across everything"
## When NOT to Use
- One match's score or schedule: see `match-lookup`
- Two players' direct meetings only: see `head-to-head-analysis`
- A win probability for a specific match: see `surface-elo`
- Betting prices: see `odds-explorer`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `search_players` | Find ids by `name` and or `country` (3-letter code); most-played first | 1 |
| `get_player` | Bio, latest ranking, overall and per-surface rating with peak, career record, titles, Grand Slam titles, last 10 matches | 5 |
| `get_surface_splits` | Record by surface: wins, losses, win rate, sets, share of games won, tiebreaks, deciding sets, 6-0 sets; `season`, `by_season` | 3 |
| `get_player_stats` | Season stats counted from scores; record by round and by opponent ranking (top 10, 11-50, 51-100) | 3 |
| `get_rankings` | With `player_id`: ranking history with `previous_rank` and points | 2 |
| `get_player_matches` | Match log newest first with `rating_before` and `rating_after`; filters `season`, `surface`, `opponent_id`, `tournament_id`, `draw` | 5 |
| `get_ratings` | Elo leaderboard, to place the player among peers | 2 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_serve_stats` | Not available. Use proxies from `get_surface_splits` (tiebreaks, share of games won) and say they are proxies |
| `get_player_injuries` | Not available |
| `get_player_form` | Build it from `get_player_matches` (see below) |
| `compare_players` | Run the calls for each player and tabulate |
## How It Works
**Step 1: Resolve the id.** `search_players {"name": "alcaraz"}`. Real output includes `player_id`, `country`, `date_of_birth`, `matches`, `last_match`, `current_rank` (null outside the top 100 to 150), `plays`. If two players match, ask or use `country`. For the WTA add `"league": "wta"`.
**Step 2: Profile.** `get_player {"player_id": "3782"}` for bio, rating and last 10 matches. This is the single best 5-credit call.
**Step 3: Surface fit.** `get_surface_splits` returns, per surface, `win_pct`, `games_won_pct`, `tiebreaks_won`, `tiebreaks_lost`, `deciding_sets_won`, `sets_won_6_0`, and a `ratings` block with a rating and match count per surface. Real example, trimmed (Alcaraz, career at the time of the call):
```json
{"overall": {"wins": 307, "losses": 69, "win_pct": 0.816, "games_won_pct": 0.58},
"splits": [
{"surface": "hard", "wins": 168, "losses": 46, "win_pct": 0.785, "games_won_pct": 0.579},
{"surface": "clay", "wins": 104, "losses": 19, "win_pct": 0.846, "games_won_pct": 0.588},
{"surface": "grass", "wins": 35, "losses": 4, "win_pct": 0.897, "games_won_pct": 0.569}],
"ratings": {"clay": {"rating": 2247, "matches": 121}, "grass": {"rating": 2135, "matches": 39}, "hard": {"rating": 2216, "matches": 204}}}
```
Read it with sample size in view: 39 grass matches is a small sample, so a 0.897 win rate carries far more uncertainty than 0.846 over 123 clay matches. A Wilson interval is the right way to say so (see Method).
**Step 4: Quality of opposition.** `get_player_stats` returns record by opponent ranking band. A player with a high win rate built against opponents outside the top 50 is a different player from one who is 60 percent against the top 10. Report both.
**Step 5: Trajectory.** `get_rankings {"player_id": "3623"}` returns `best_rank` and `history[]` (`season`, `week`, `ranking_date`, `rank`, `previous_rank`, `points`). Points can fall while the rank holds (the real history for the world number one fell from 13,450 to 11,000 points over a few months with the rank unchanged at 1, because old results dropped off). Use `get_rankings` for the shape, not for form.
**Step 6: Form.** Use `get_player_matches {"player_id": "...", "limit": 20}` or the `last 10` in `get_player`. Form is the last N completed matches, weighted by opponent rating. Net rating change over the last 10 (`rating_after` of the newest minus `rating_before` of the oldest) is the cleanest one-number form signal here: it already adjusts for who the opponents were.
## Method: Honest Intervals on Small Samples
Wilson 95 percent interval for a win rate with `w` wins in `n` matches:
```
p = w / n; z = 1.96
center = (p + z^2/(2n)) / (1 + z^2/n)
half = z * sqrt(p(1-p)/n + z^2/(4n^2)) / (1 + z^2/n)
interval = center +/- half
```
For 35 wins in 39 matches this gives roughly 0.76 to 0.96. Do not rank two players' surface win rates when their intervals overlap heavily. Say "similar".
## Serve and Return Without Serve Data
The data has no serve statistics, so do not invent them. What the scores do support:
| Proxy | Source | What it says |
|-------|--------|--------------|
| Share of games won | `games_won_pct` | Overall dominance, serve and return combined |
| Tiebreak record | `tiebreaks_won` and `tiebreaks_lost` | Holds under pressure; tiebreaks occur when both players hold |
| Deciding-set record | `deciding_sets_won` of `deciding_sets` | Closing ability and fitness, noisy |
| 6-0 sets won and lost | `sets_won_6_0`, `sets_lost_0_6` | Blowout tendency |
| Total games per match | `stats.total_games` in `get_match` | Lots of games and tiebreaks suggests serve-dominant play; few suggests breaks |
Label every one of these "proxy".
## Comparing Two Players
Build one table and name the winner of each row only when the gap is meaningful:
```
Player A Player B
Rank / points 1 / 11000 3 / 5560
Elo overall 2407 ...
Elo clay 2209 2247
Win pct clay .xxx (n) .xxx (n)
vs top 10 W-L W-L
Tiebreaks W-L ... ...
Last 10 rating chg +xx -xx
```
Then one paragraph of judgment. For the direct record use `head-to-head-analysis`.
## Credit Usage
| Report | Calls | Credits |
|--------|-------|---------|
| Quick profile | `search_players` + `get_player` | 6 |
| Surface and opposition depth | + `get_surface_splits` + `get_player_stats` | 12 |
| Full report with ranking history | + `get_rankings` | 14 |
| Two-player comparison | double the above | 12 to 28 |
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Quote "serve rating" or "first-serve percentage" | Not in the data | Say it is unavailable and show proxies |
| Rank players by raw win rate | Ignores opponent strength and sample size | Use Elo, win rate against top-10, and intervals |
| Use `current_rank` as strength | Rank is a 52-week points total, and null outside the top 100 to 150 | Use Elo for strength, rank for seeding context |
| Call a 4-match win streak "form" | Four matches is noise | Use 10 to 20 matches and the rating change |
| Compare a hard-court Elo to a clay Elo on one scale | Different match counts and update speeds | Compare the same surface across players |
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| Two players with similar Elo | Look at direct history | `head-to-head-analysis` |
| A surface specialist | Convert ratings to a probability | `surface-elo` |
| A decision needed on a specific match | Build the brief | `match-preview` |
| A ranking slide | Study the trajectory | `ranking-history` |
probability-calibration7.08 KB
--- name: probability-calibration description: "Checking and fixing tennis win probabilities: reliability tables, calibration slope and intercept, Brier decomposition, Platt scaling, isotonic regression, temperature scaling, and calibrating against the market close. Use when the user asks whether a model's 70 percent means 70 percent, why a model is overconfident on favorites, or how to correct probabilities. Do not use for training the model: see feature-engineering. Do not use for choosing validation splits: see walk-forward-validation." metadata: version: 1.0.0 author: AcesAPI --- # Probability Calibration > **Default data tool:** AcesAPI (`acesapi-tool`). > Out-of-sample predictions with outcomes are the input. Results and `winner_id` come from `get_matches` (2 credits per 100). Market closes for comparison come from `get_match` (2 each). You test whether predicted probabilities match reality. In tennis this is where models usually fail: a rating gap turns into a probability through a formula with assumptions (a fixed 400-point scale, one format, constant surface effect) and those assumptions break at the extremes and in best-of-five. ## When to Use - "When my model says 75 percent, how often does the player win?" - "My Elo probabilities seem too extreme for heavy favorites" - "Should I calibrate?" - "Is the market better calibrated than my model?" ## When NOT to Use - Building features or fitting models: see `feature-engineering` - Deciding how to split data: see `walk-forward-validation` ## Commands Available | Command | What It Does | Credits | |---------|-------------|---------| | `get_matches` | Outcomes (`winner_id`) and format context for scoring predictions | 2 | | `get_match` | Pre-match ratings and market closes for comparison | 2 | | `get_ratings` | Current ratings for forward predictions | 2 | | `get_player_matches` | `rating_before` for many matches of one player | 5 | ## Commands That Do NOT Exist | Not Available | Use Instead | |--------------|-------------| | `get_calibration` | Compute from your prediction log | | `get_model_probability` | Your model; the API serves data only | | `get_historical_predictions` | Not available | ## Step 1: Orient the Predictions Calibration needs one probability per match for one named side and a 0 or 1 label. Make it unambiguous: `p` = probability that the focal player wins, `y` = 1 if `winner_id` equals the focal player's id. If you keep both orientations of each match, score one orientation, or the table duplicates every row and halves your apparent uncertainty. ## Step 2: Reliability Table Bin predictions, then compare predicted and realized. ``` bins: [0.0, 0.1) ... [0.9, 1.0] # use equal-count bins when n is small for each bin: n, mean(p), mean(y), mean(y) - mean(p) ``` For tennis, put extra bins above 0.8, because heavy favorites are common (the best players are favored at 85 to 95 percent in early rounds). A model that says 92 percent and wins 88 percent costs real money when a price is -1000. Report `n` per bin. A bin with 12 matches has a standard error near 0.14 for a 50 percent rate. Show intervals (Wilson) for bins under 100. ## Step 3: Summary Numbers | Measure | Definition | Reading | |---------|------------|---------| | Brier | `mean((p - y)^2)` | 0.25 for a coin flip | | Log loss | `-mean(y ln p + (1-y) ln(1-p))` | 0.693 for a coin flip | | Calibration intercept and slope | Fit `logit(P(y=1)) = a + b * logit(p)` | Perfect: a = 0, b = 1. b < 1 means overconfident; b > 1 means underconfident | | Expected calibration error | `sum(n_k / n * abs(mean y_k - mean p_k))` | Sensitive to binning; report with the bin scheme | Slope is the clearest single number. Elo-based tennis probabilities tend to show a slope below 1 in best-of-three matches among close players and above 1 in best-of-five, because best of five amplifies the better player's edge. Test it on your data; do not assume. ## Step 4: Fix It Fit the calibrator on a validation block earlier than the test block (see `walk-forward-validation`). Never fit and score on the same matches. | Method | Use When | Notes | |--------|----------|-------| | Platt (logistic on logit(p)) | Under about 2,000 matches; slope off but shape monotone | Two parameters, stable | | Temperature scaling | Same, simpler | One parameter `T` in `sigmoid(logit(p)/T)` | | Isotonic | Over 3,000 matches and non-sigmoid miscalibration | Flat steps; overfits small samples | | Per-format correction | Best-of-five vs best-of-three differ | Add a format term in the logistic | | Blend with the market | Covered events | `logit(p_final) = a + b logit(p_model) + c logit(p_market)`, fit walk-forward | Format and surface often matter more than the method: fit separate intercepts or slopes by format first. ## Step 5: Calibration Against the Market ## Worked Example (method, with the credit cost) You score 1,200 Masters and Slam matches from 2025 with an Elo model. 1. `get_matches` pages for the season to collect outcomes: about 90 credits per tour. 2. Predictions from your own Elo (see `feature-engineering`), pre-match only. 3. Reliability table in ten bins, plus extra bins at 0.8, 0.85, 0.9, 0.95. 4. Fit `logit(P(y)) = a + b logit(p)` on 2023 and 2024, apply to 2025. 5. Report slope, Brier and log loss before and after, per format. 6. For the 2025 matches that have odds, add the market column. This costs 2 credits per match. Expected deliverable: a table of bins, the fitted `a` and `b`, the before and after scores, and one sentence stating whether the correction survives the next season. ## Pitfalls - Calibrating on the same data you evaluate on. - Pooling best-of-three and best-of-five. - Judging calibration with accuracy. - Over-binning small samples; use equal-count bins. - Treating a calibrated model as a profitable one. Calibration is necessary for betting, not sufficient. The market is also calibrated. - Using walkovers and retirements in the evaluation set inconsistently. - Rating drift: Elo ratings stretch over time as players cluster; recalibrate every season. ## Credit Usage | Task | Credits | |------|---------| | Outcomes for a tour season | about 90 | | Market column for covered matches | 2 per match | | Calibration on user data | 0 | ## Anti-patterns | What the Model Might Do | Why It's Wrong | Do This Instead | |---------------------|---------------|-----------------| | Report accuracy as calibration | Different property | Reliability table and slope | | Fit isotonic on 400 matches | Overfits | Platt or temperature | | Calibrate on the test season | Leak | Calibrate on earlier seasons | | Merge formats | Hides the best-of-five effect | Split or add a format term | | Say "well calibrated" from the Brier score | Brier mixes sharpness and calibration | Show the table | ## Output Format ``` n = [matches], seasons [a-b], format [bo3 / bo5 / both] Calibration intercept [a], slope [b] Brier [x] (coin flip 0.250), log loss [y] (coin flip 0.693) Bin n mean p mean y gap 0.9+ .. 0.93 0.89 -0.04 ``` ## What to Do Next | What You Found | Next Action | Skill | |----------------|-------------|-------| | Persistent bias by surface | Revisit features | `feature-engineering` |
ranking-history6.72 KB
---
name: ranking-history
description: "ATP and WTA ranking lists and trajectories: the current top list, the list in force on a past date, a player's ranking history, points movement, ranking versus Elo, and seeding context. Use when the user asks who is number one, how a ranking changed, what the ranking was on a date, or whether the ranking understates a player. Do not use for strength estimates: see surface-elo. Do not use for a full player profile: see player-scouting."
metadata:
version: 1.0.0
author: AcesAPI
---
# Ranking History
> **Default data tool:** AcesAPI (`acesapi-tool`).
> `get_rankings` (2 credits) returns a weekly list or, with `player_id`, one player's history. `get_ratings` (2) is the Elo leaderboard for comparison.
You answer ranking questions precisely and explain what a ranking is. A ranking is a rolling points total, not a strength estimate, so use it for seeding and bracket questions and use ratings for strength.
## Coverage
ESPN's weekly ATP and WTA lists since 2007, top 100 to 150 a week. The data page counts 1,637 weekly lists covered from 2007 to 2026. Players outside the top 100 to 150 are not in a given week. Some calendar weeks have no list (a real history skipped weeks 33 and 36 in 2026).
## When to Use
- "Who is number one?" "Show me the top 10."
- "What was the ranking list on 1 January 2020?"
- "How did Alcaraz's ranking move this season?"
- "Is the number two ranked player really the second best?"
## When NOT to Use
- Win probability or strength: see `surface-elo`
- A scouting report: see `player-scouting`
- Results: see `match-lookup`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_rankings` | Latest list (default), or by `season` and `week`, or the list in force on `date`; with `player_id`, that player's history and `best_rank`; `limit` caps list rows | 2 |
| `get_ratings` | Elo leaderboard, overall or by surface | 2 |
| `search_players` | Ids, and `current_rank` (null outside the list) | 1 |
| `get_player` | `ranking` block with latest rank and points, plus ratings | 5 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_standings` | `get_rankings` |
| `get_race_rankings` | Not available: only the official weekly list |
| `get_ranking_points_breakdown` | Not available: only total points per week |
| `get_live_rankings` | Not available: lists are weekly |
## How It Works
**Step 1: Pick the query.**
| Question | Call |
|----------|------|
| Current top N | `get_rankings {"limit": 10}` |
| List on a past date | `get_rankings {"date": "2020-01-06"}` |
| A specific week | `get_rankings {"season": 2025, "week": 20}` |
| One player's path | `get_rankings {"player_id": "3623"}` |
| WTA | add `"league": "wta"` |
**Step 2: Read the shape.** A list row: `rank`, `previous_rank`, `points`, `player_id`, `name`, `country`; the response has `season`, `week` and `ranking_date`. The latest list at the time of the call was week 40 of 2026, dated 2026-10-01, with the top three as Sinner (11,000 points), Zverev (9,720) and Alcaraz (5,560). A history row: `season`, `week`, `ranking_date`, `rank`, `previous_rank`, `points`, newest first.
**Step 3: Compute what the user wants.** Peak, time at number one, net change over a window, biggest weekly drop. Always show the date range.
## Points Versus Rank
Points tell the story that rank hides. In the real history above, the world number one's points fell from 13,450 (week 31) to 11,000 (week 40) with rank 1 held throughout. That is a drop of 2,450 points, almost 18 percent, while the headline did not move. Read `points` for momentum and the gap to the next player for how safe the position is: 11,000 against 9,720 is a 1,280-point cushion.
Points move for two reasons: new results added, and last year's results dropped off after 52 weeks. A fall is therefore not always bad form. Compare with the player's results from the same weeks a year earlier (`get_player_matches` with `season`).
## Ranking Versus Rating
Compare `get_rankings` with `get_ratings` (overall) for the same player set:
```
rank_gap = rank_by_points - rank_by_elo
```
Large positive gaps (ranked far lower than Elo says) occur for players returning from injury, players who play fewer events and players who peaked recently. Large negative gaps occur for players riding points from one big result that Elo discounts. In the live data, Alcaraz was 3rd on points (5,560) and first on clay Elo. That is a comparison of two different measures, not a contradiction.
Both are summaries of results. Neither is verified by a serve-based model because that data does not exist here.
## Seeding Context
Seeds in a draw follow the ranking at entry. A top-8 seed cannot meet another top-8 seed before the quarterfinal in a standard draw, which makes the ranking relevant for route analysis: use `get_tournament` to see the draw, and `seed1`, `seed2` in matches. A player who is under-ranked relative to Elo is dangerous as an unseeded opponent in an early round.
## Pitfalls
- `current_rank` of `null` means outside the list, not unranked in the sense of zero points.
- Ranking dates are weekly. A question about a specific day needs the `date` parameter, which returns the list in force.
- Gaps in weeks are normal. Do not interpolate silently.
- Comparing rank across tours is meaningless.
- Points systems and the 52-week window mean historical points cannot be compared directly across eras unless the scale is the same. Use rank and the gap to others.
## Credit Usage
| Operation | Credits |
|-----------|---------|
| Current top 10 | 2 |
| A player's full history | 2 |
| List on a date | 2 |
| Elo comparison | + 2 |
| Season of weekly lists | 2 x about 40 = about 80 |
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Use rank as skill | It is a points total with a rolling window | Pair with Elo |
| Say a player "dropped" when only points fell | Rank may be unchanged | Report both |
| Fill a missing week with the neighbor | Invented data | Show the gap |
| Pass a week number without a season | Ambiguous | Always pass both |
| Quote `previous_rank` as last year's | It is the previous list | Label it "previous week's list" |
## Output Format
```
[Tour] ranking, week [w] of [season] ([date])
1. [Name] ([country]) [points] pts (prev [n])
...
[Player]: peak [n], now [n], change over [window]: [+/-] points, rank [moved / held]
```
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| Rank far from Elo | Check ratings and surfaces | `surface-elo` |
| A ranking slide | Look at recent results | `player-scouting` |
| A seeded favorite in the next round | Brief the match | `match-preview` |
set-score-modeling9.29 KB
---
name: set-score-modeling
description: "Modeling tennis scores without serve data: match win probability from set and game win probabilities, best-of-three versus best-of-five, set-score distributions (2-0, 2-1, 3-0), tiebreak rates, total games and games handicap lines, and the games_total and games_spread1 fields in match odds. Use when the user asks about straight sets odds, how many games a match will run, tiebreak frequency, or how a match probability maps to set scores. Do not use for rating-based win probability: see surface-elo. Do not use for player props: not supported."
metadata:
version: 1.0.0
author: AcesAPI
---
# Set Score Modeling
> **Default data tool:** AcesAPI (`acesapi-tool`).
> `get_matches` (2 credits per 100 matches) gives every set and tiebreak score for building distributions. `get_surface_splits` (3) and `get_player_stats` (3) give tiebreak and games-won rates. `get_match` (2) has the `games_total` and `games_spread1` lines inside `odds` for the few books that post them.
You model the shape of a match: how likely a straight-sets win is, how many games to expect, how often tiebreaks occur. The standard tool for this, a point-by-point serve and return model, needs first-serve and break-point data that this dataset does not contain. So this skill builds from what scores give: games won share, hold-rate proxies and empirical set distributions. Say that out loud when presenting results.
## When to Use
- "How likely is a straight-sets win?"
- "What is a reasonable total games line for this match?"
- "How often do ATP matches on grass go to a tiebreak?"
- "Convert a 70 percent match probability into set probabilities"
- "Is the market's games handicap consistent with its moneyline?"
## When NOT to Use
- A pure win probability from ratings: see `surface-elo`
- Serve speed, aces, break points, player props: not in the data
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_matches` | Matches with `sets[]` (`games1`, `games2`, `tiebreak1`, `tiebreak2`, `winner`) for building distributions | 2 |
| `get_match` | `stats.total_games`, `stats.tiebreaks`, `stats.went_the_distance`; `odds[].games_total`, `odds[].games_spread1` | 2 |
| `get_surface_splits` | Per-surface sets, games, tiebreaks, deciding sets, 6-0 sets | 3 |
| `get_player_stats` | Season record with tiebreaks and deciding sets, by round | 3 |
| `get_head_to_head` | Scores of prior meetings | 3 |
| `get_tournament` | All scores of an event (for event-level game totals) | 2 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_serve_stats` | Not available. ESPN publishes no serve statistics; `get_match` returns `serve_stats: null` |
| `get_break_points` | Not available |
| `get_point_by_point` | Not available |
| `get_set_odds` | Not available: only moneylines plus `games_total` and `games_spread1` on some books |
| `get_total_games_line` | Read `games_total` from `get_match` |
## From Match Probability to Set Probability
Let `q` be a player's probability of winning any one set (assumed constant and independent, which is an approximation). Then:
```
Best of three: P(match) = q^2 (3 - 2q)
Best of five: P(match) = q^3 (10 - 15q + 6q^2)
```
To go from a match probability `P` (from `surface-elo` or a no-vig close) back to `q`, solve by bisection. Real example: a no-vig match probability of 0.524 (Pinnacle close for Sinner in the 2025 Wimbledon final, best of five) corresponds to `q = 0.5128`; in best of three the same match probability needs `q = 0.516`. The format matters: the same set strength gives a more extreme match probability in best of five.
**Set-score distribution, best of three, for player A with set probability q:**
```
P(A wins 2-0) = q^2
P(A wins 2-1) = 2 q^2 (1 - q)
P(B wins 2-1) = 2 (1-q)^2 q
P(B wins 2-0) = (1-q)^2
```
At `q = 0.6`: 36 percent for 2-0, 28.8 percent for 2-1, and 64.8 percent match win. These are model outputs; compare them with the empirical shares from `get_matches`.
## From Game Win Rate to Set Probability (without serve data)
Games-won share is available per player (`games_won_pct` in `get_surface_splits`, 0.58 for one top player over a career). Map two players' shares to a matchup game probability with a log5-style formula, then to a set probability with a hold model:
```python
from functools import lru_cache
def set_win(hA, hB, first='A'):
"""P(A wins a set). hA, hB = probability each holds serve. Alternating serve."""
@lru_cache(None)
def go(a, b, srv):
if a >= 6 and a - b >= 2: return 1.0
if b >= 6 and b - a >= 2: return 0.0
if a == 7: return 1.0
if b == 7: return 0.0
if a == 6 and b == 6:
return 0.5 + 0.5 * (hA - hB) # crude tiebreak: tilts with the hold gap
nxt = 'B' if srv == 'A' else 'A'
if srv == 'A':
return hA * go(a + 1, b, nxt) + (1 - hA) * go(a, b + 1, nxt)
return hB * go(a, b + 1, nxt) + (1 - hB) * go(a + 1, b, nxt)
return go(0, 0, first)
```
Equal holds of 0.80 give exactly 0.5; holds of 0.85 and 0.80 give `q = 0.591`. The problem: **hold rates are not in the data.** You can only calibrate them: choose `hA`, `hB` so that the model reproduces the match win probability (from ratings or the market) and the total games the market expects. That is a fitted model, not an observation. Present it as "consistent with a hold gap of about X points", never as measured serve strength.
## Total Games
Observed in `get_match` for a real final: `stats.total_games` 40 over four sets, no tiebreaks. The Pinnacle row carried `games_total` 41.5 and `games_spread1` -0.5.
Build the expectation from data, not memory:
1. `get_matches` for a season, filtered to completed matches of one tour, one surface, one format.
2. Per match, total games = sum over `sets[]` of `games1 + games2`.
3. Take mean and standard deviation by surface and by format. Total games are right-skewed: a few five-set matches run long.
4. Adjust for the matchup. Two evenly matched players generate more games and more sets than a mismatch. The relation is roughly monotone in the rating gap, so regress total games on the absolute rating gap, surface and format on past matches (time-ordered, see `walk-forward-validation`).
Use the market's `games_total` as a benchmark where it exists, not as a fact: it is one book's line.
## Tiebreak Frequency
Tiebreak share of sets is computable from `get_surface_splits`:
```
tiebreak_share = (tiebreaks_won + tiebreaks_lost) / (sets_won + sets_lost)
```
From a real call (one player's whole career): hard 92 of 545 sets (16.9 percent), clay 52 of 322 (16.1 percent), grass 21 of 128 (16.4 percent). The differences are inside sampling noise, which is a useful finding in itself. Do not claim grass produces more tiebreaks from one player's record; build the tour-level rate from `get_matches`.
Tiebreak outcomes are close to coin flips: keep the tiebreak model near 50 percent, tilted at most slightly by the hold gap or the rating gap, and shrink toward 0.5 when data is thin.
## Pitfalls
- **Independence.** Sets are not independent: momentum, fatigue and tactics link them. The formulas above are a baseline. Check them against empirical 2-0 and 2-1 frequencies in `get_matches` and correct with a fitted adjustment.
- **Format mixing.** Men's Grand Slams are best of five; everything else, and all WTA matches, are best of three. Never pool them for totals or set distributions.
- **Retirements.** A retired match has an incomplete score. Exclude `outcome != "completed"` from distributions, or you bias total games down.
- **Walkovers** have empty `sets`.
- **Survivorship in event totals.** Early rounds have more mismatches than late rounds. Condition on round or on rating gap.
- **Over-reading the line.** `games_total` appears for only a few books (Pinnacle in the examples), and `games_spread1` has the same limit. Do not call it "the market" without saying which book.
## Credit Usage
| Task | Calls | Credits |
|------|-------|---------|
| Distribution for one tour, one season | about 45 pages of `get_matches` | about 90 |
| Tiebreak rate for one player | `get_surface_splits` | 3 |
| One match: the lines | `get_match` | 2 |
| Event totals | `get_tournament` | 2 |
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Quote first-serve percentages | Do not exist here | Use games-won share and label as a proxy |
| Pool best-of-three and best-of-five | Different distributions | Split by format |
| Treat a hold model as measured | Holds were fitted, not observed | Say "fitted to the match probability" |
| Include retired matches in totals | Biases totals down | Filter `outcome == "completed"` |
| Use a single book's total as the market | One line | Say which book |
## Output Format
```
[A] vs [B], best of [3/5], [surface]
Match win: A [xx]% (from [source])
Set win: A [xx]%
Score distribution: 2-0 A [xx]%, 2-1 A [xx]%, 2-1 B [xx]%, 2-0 B [xx]%
Expected total games: [x] (empirical, [n] matches); market [y] at [book]
Model: set-level, no serve data; holds fitted, not observed.
```
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| A model whose probabilities need testing | Calibrate | `probability-calibration` |
| A feature set for totals | Engineer it | `feature-engineering` |
surface-elo8.17 KB
--- name: surface-elo description: "Tennis Elo by surface: how AcesAPI ratings are built, reading overall versus hard, clay and grass ratings, converting a rating gap to a win probability, tracking rating change over time, and building your own surface-weighted blend. Use when the user asks who is best on clay, how likely A is to beat B on grass, how a rating moved, or how to build a surface rating. Do not use for direct meetings: see head-to-head-analysis. Do not use for set or game-count markets: see set-score-modeling." metadata: version: 1.0.0 author: AcesAPI --- # Surface Elo > **Default data tool:** AcesAPI (`acesapi-tool`). > `get_ratings` (2 credits) is the leaderboard overall or by surface. `get_player` (5) gives one player's overall and surface ratings with peak. `get_match` (2) gives both players' ratings before and after a match. `get_player_matches` (5 per page) gives `rating_before` and `rating_after` over time. You turn ratings into probabilities. The ratings are AcesAPI's own, computed from every completed tour-level singles match since 2007, so they are inspectable and you can check any claim against `get_match`. ## How the Rating Is Built From the `method` field returned by `get_ratings`: - Elo from every completed singles match on the tour since 2007 (main draw and qualifying), start 1500 - `K = 250 / (matches + 5)^0.4`, so new players move fast and veterans move slowly - The surface rating counts only matches on that surface, so it has its own match count and its own K - Walkovers, defaults and retirements are not rated - A player is listed once they have at least 10 rated matches and a rated match since `active_since` (default one year ago) **Consistency check on the 2025 Wimbledon final** (real `get_match` output): Sinner overall 2325 to 2335 (+10), Alcaraz 2278 to 2267 (-11). With the standard 400-point logistic, Sinner's expected score was 0.567, so a +10 change implies K of about 23. The formula gives about 23 for a player with roughly 400 rated matches. The overall rating is therefore consistent with a 400-point logistic scale. ## When to Use - "Who is the best clay-court player right now?" - "What is Sinner's chance against Alcaraz on grass?" - "How much did that loss cost him?" - "Is a player's grass rating real or based on 12 matches?" - "Build a better rating than the overall one" ## When NOT to Use - The direct record between the two: see `head-to-head-analysis` - Total games, set scores, tiebreak odds: see `set-score-modeling` ## Commands Available | Command | What It Does | Credits | |---------|-------------|---------| | `get_ratings` | Leaderboard: `rank`, `rating`, `surface_matches`, `last_match`; filter `surface`, `active_since`, `limit` | 2 | | `get_player` | One player: overall rating, per-surface ratings, peak | 5 | | `get_match` | `players[].rating`: `before`, `after`, `surface_before`, `surface_after` | 2 | | `get_player_matches` | `rating_before`, `rating_after` per match, filter by `surface` | 5 | | `get_surface_splits` | `ratings` block with a rating and match count per surface | 3 | ## Commands That Do NOT Exist | Not Available | Use Instead | |--------------|-------------| | `get_elo` | `get_ratings` | | `get_win_probability` | Compute from the formula below | | `get_rating_history` | `get_player_matches` and read `rating_after` in date order | | `get_serve_rating` | Not available: no serve data | ## Win Probability For ratings `Ra` and `Rb` on the same scale: ``` P(A beats B) = 1 / (1 + 10^(-(Ra - Rb) / 400)) ``` This is a single-match probability for the whole match, regardless of format. A 100-point gap is 64 percent, 200 is 76 percent, 300 is 85 percent. **Worked example (real ratings before the 2025 Wimbledon final, grass):** Sinner surface 1995, Alcaraz surface 2176. Gap -181, so P(Sinner) = 1 / (1 + 10^(0.4525)) = 0.26. The overall ratings (2325 against 2278) said 0.57. The two numbers disagree by 31 points of probability because the grass ratings had few matches behind them. The market's no-vig close was 0.524 for Sinner (Pinnacle, see `odds-explorer`), closer to the overall number. Neither rating alone is the answer: blend them. ## The Scale Pitfall Overall and surface ratings are on different scales. A surface rating sees fewer matches, so it is less settled, and it starts at the same 1500 baseline with a different number of updates. In the example, Sinner's overall was 2325 and his grass was 1995. Never subtract a surface rating from an overall rating, and never compare two players' ratings from different surfaces. Compare overall to overall, or the same surface to the same surface. ## Blending Overall and Surface A practical blend that respects small samples: ``` w = n_surface / (n_surface + c) # c = shrinkage, try 30 R_blend = w * (R_surface - R_surface_baseline) + (1 - w) * 0 + R_overall ``` where `R_surface_baseline` is the tour average of that surface rating among active players (take it from `get_ratings` with a large `limit`). The idea: start from the overall rating, add the player's surface edge, and trust the edge in proportion to its match count. Fit `c` by walk-forward log loss (see `walk-forward-validation`), not by feel. Report it as a model, because it is. ## Rating Change and Form `rating_after - rating_before` per match is the update. Over a window it measures results against expectation: - Sum over the last 10 completed matches = rating form - A player who wins a lot at low-rated opponents barely moves; a single loss to a much lower-rated opponent costs a lot - Retirements and walkovers have `null` rating fields (real example: a walkover row had `rating_before: null`), so drop them ## Pitfalls - **Leakage.** Using `rating.after` or `surface_after` to predict that same match. Always use `before`. - **Stale ratings.** A player who has not played for months keeps the old rating. `last_match` in `get_ratings` tells you. Returns from injury are the largest errors. - **Small surface samples.** `surface_matches` below about 30 means the surface rating is mostly noise. Grass is usually the thinnest because the grass season is short (Alcaraz had 39 grass matches against 121 on clay). - **Best-of-five.** Men's Grand Slam matches are best of five, which favors the better player more than best of three. The rating does not know the format, so a gap understates favorites at Slams. Check this with a calibration table (see `probability-calibration`) before relying on it. - **Inactive players are filtered.** Players with no rated match since `active_since` and fewer than 10 rated matches are missing from `get_ratings`. - **Tour boundaries.** ATP and WTA ratings are separate pools. Never compare a WTA rating to an ATP rating. ## Credit Usage | Task | Calls | Credits | |------|-------|---------| | Top 20 on a surface | `get_ratings` | 2 | | One match probability, both players | 2 x `get_player` or `get_ratings` for both | 4 to 10 | | Rating history for one player, last 50 | `get_player_matches` | 5 | | Pre-match ratings for one player's season | `get_player_matches` with `season` | 5 per 50 matches | For a model, take pre-match ratings from `get_player_matches` (5 credits per 50 matches) rather than calling `get_match` per row. ## Anti-patterns | What the Model Might Do | Why It's Wrong | Do This Instead | |---------------------|---------------|-----------------| | Predict with `rating.after` | Contains the outcome | Use `before` | | Compare a clay rating to a hard rating | Different scales | Same surface only | | Trust a 12-match surface rating | Noise | Blend with overall, or say it is thin | | Quote 76 percent as "the model says" without a source | Needs the method and the ratings | Show both ratings and the formula | | Mix ATP and WTA ratings | Separate pools | Keep tours apart | ## Output Format ``` [A] vs [B], [surface] Overall: A 2325 B 2278 -> A 57% Surface: A 1995 B 2176 -> A 26% (grass sample thin) Blend: A xx% Market (no-vig close): xx% ``` ## What to Do Next | What You Found | Next Action | Skill | |----------------|-------------|-------| | A rating gap with a thin sample | Look at the direct record | `head-to-head-analysis` | | A model-ready rating column | Build features | `feature-engineering` | | Probabilities to be trusted | Check calibration | `probability-calibration` |
walk-forward-validation7.97 KB
---
name: walk-forward-validation
description: "Time-ordered validation of tennis models: expanding or rolling windows, season-by-season walk-forward, purging, log loss, Brier score and accuracy, baselines (Elo, ranking, market close), and significance tests. Use when the user asks if a model is any good, how to split data, whether cross-validation is okay, or how to compare two models. Refuses random k-fold on matches. Do not use for building features: see feature-engineering. Do not use for probability reliability: see probability-calibration."
metadata:
version: 1.0.0
author: AcesAPI
---
# Walk-Forward Validation
> **Default data tool:** AcesAPI (`acesapi-tool`).
> Results come from `get_matches` (2 credits per 100 matches). Use `get_ratings` or `get_player_matches` (`rating_before`) for baseline ratings. For the market baseline, `get_match` (2 each) on Slam and Masters matches.
You make sure a tennis model is judged only on matches that happened after the data it learned from. Tennis models fail in a specific way: a player's rating, form and context drift week to week, so shuffled validation looks excellent and forecasts look average.
## When to Use
- "Is my model any good?"
- "Can I use k-fold cross-validation?" (no)
- "How do I split a multi-season dataset?"
- "Is model B really better than model A?"
## When NOT to Use
- Constructing features: see `feature-engineering`
- Judging probability calibration alone: see `probability-calibration`
## Commands Available
| Command | What It Does | Credits |
|---------|-------------|---------|
| `get_matches` | Source of labeled matches by season | 2 |
| `get_player_matches` | `rating_before` for baselines | 5 |
| `get_match` | Pre-match ratings and market close for a baseline | 2 |
| `get_ratings` | Current leaderboard, only for current-season sanity checks | 2 |
| `list_tournaments` | Season boundaries and tiers | 1 |
## Commands That Do NOT Exist
| Not Available | Use Instead |
|--------------|-------------|
| `get_train_test_split` | Split by `match_date` yourself |
| `get_model_score` | Compute locally |
| `get_historical_predictions` | Not available: keep your own prediction log |
## The Rule
Every prediction for a match on date `t` uses only information with timestamps before `t`: model weights trained on matches before `t` and features built from the past. No exceptions for "just a few features" or "the same tournament".
## Split Schemes
**Season walk-forward (default).** Tour data runs by calendar year, so evaluate each season in turn:
```
train: 2007..2018 test: 2019
train: 2007..2019 test: 2020
train: 2007..2020 test: 2021
...
```
Expanding window keeps all history. Rolling window (for example the last 8 seasons) adapts to change at the cost of data. Compare both; with Elo-style features, expanding usually wins.
**Month or tournament-level folds** for more test points: refit monthly and predict the next month. Costly but gives a finer drift picture.
**Purging.** Matches on the same day, and consecutive rounds of one tournament, share players and context. Leave a one-week gap between train and test when the model uses short-window form, so a player's recent matches do not appear on both sides.
**Embargo for ratings.** If your rating is updated daily, evaluate with the rating as of the morning of the match, built from earlier dates only.
## Metrics
| Metric | Formula | Notes |
|--------|---------|-------|
| Log loss | `-mean(y ln p + (1-y) ln(1-p))` | The primary score. Punishes confident errors |
| Brier score | `mean((p - y)^2)` | Easier to read, 0.25 is a coin flip |
| Accuracy | `mean((p > 0.5) == y)` | Context only; ignores confidence |
| Calibration error | see `probability-calibration` | Separate check |
Reference points to compute on your own data, not to quote as constants: a coin flip scores log loss 0.693 and Brier 0.25. Elo from `get_match` ratings is the baseline to beat. The market no-vig close (see `odds-analysis`) is the stronger baseline where it exists; beating Elo and losing to the close is the normal result.
## Baselines (always report)
1. **Coin flip:** p = 0.5.
2. **Higher-ranked player wins:** the `current_rank` or list rank baseline.
3. **Pre-match Elo:** `rating.before` gap through the logistic (see `surface-elo`).
4. **Surface Elo:** same with `surface_before`.
5. **Market close:** only on covered events (Slams from mid-2020; Masters and WTA 1000 from August 2024). Compare on the subset where odds exist, not on the full set.
A model that cannot beat baseline 3 is not a model.
## Significance
Differences between models are small, and match counts decide whether they are real.
**Paired bootstrap on log loss.** Per match, compute `d_i = loss_A_i - loss_B_i`. Resample matches (block by tournament to respect dependence) 2,000 times, and report the mean difference and its 95 percent interval. If the interval contains 0, call it a tie.
Rough scale: the standard error of a mean log loss over `n` matches is about `0.01 * sqrt(5000 / n)`, so with 500 matches a 0.01 difference is not distinguishable from noise. A Slam main draw has 127 singles matches per tour; a whole tour season has several thousand (about 45 pages of 100 at the time of writing, qualifying included), enough to see differences of about 0.005 to 0.01.
## Pitfalls
- **Random k-fold.** Puts a player's 2025 matches in train and their 2024 matches in test. Refuse it.
- **Tuning on the test seasons.** Pick hyperparameters on a validation block inside the training window, then report on seasons that were never used for choices. Count how many variants you tried.
- **Survivor bias in players.** Evaluating only top-100 players inflates accuracy: mismatches are easier. Report by tier of matchup (rating gap buckets).
- **Retirements and walkovers.** Decide in advance and apply to every model identically.
- **Covariate shift.** Qualifying data appears from 2022, which changes the match pool. Check results before and after.
- **Format.** Best-of-five Slams behave differently. Report them separately.
- **Peeking via rank.** Use the list in force before the match.
## Worked Pattern
```python
import numpy as np
import pandas as pd
def walk_forward(df, seasons, fit, predict):
rows = []
for s in seasons:
train = df[df.season < s]
test = df[df.season == s]
model = fit(train)
p = predict(model, test) # pre-match features only
rows.append(test.assign(p=p))
out = pd.concat(rows)
out["ll"] = -(out.y * np.log(out.p) + (1 - out.y) * np.log(1 - out.p))
return out.groupby("season").ll.mean(), out
```
Run it for each baseline and the candidate, then compare per-season. A model that wins five of six seasons by 0.01 or more is more convincing than one that wins by 0.02 on the pooled mean thanks to one season.
## Credit Usage
| Task | Credits |
|------|---------|
| Ten seasons of results, one tour | about 900 |
| Market baseline for one Slam (127 matches) | 254 |
| Market baseline for all covered matches in a recent season | about 7,000 |
| User-provided data | 0 |
Plan the baseline sample first; the market baseline is the expensive part, and a Slam or two is enough to learn something.
## Anti-patterns
| What the Model Might Do | Why It's Wrong | Do This Instead |
|---------------------|---------------|-----------------|
| Report in-sample accuracy | Not a forecast | Report out-of-sample by season |
| Compare models on different match sets | Not like for like | Same matches, paired differences |
| Call 0.003 log loss an improvement | Inside noise | Bootstrap interval |
| Skip the Elo baseline | Cannot tell whether work added anything | Always include it |
| Report accuracy alone | Hides overconfidence | Log loss plus calibration |
| Test on the same season used to choose hyperparameters | Optimistic | Hold out untouched seasons |
## What to Do Next
| What You Found | Next Action | Skill |
|----------------|-------------|-------|
| Model beats Elo out of sample | Check calibration | `probability-calibration` |
| Model matches Elo | Improve features | `feature-engineering` |
Release notes
Initial release. Read-only tennis data tools with OAuth sign-in and 14 workflow skills.
Declared in the saved package. Remote tools may change independently.
Package details
Publisher declarations from the archived package. These are separate from our research and the live service's terms.
- Package license
- MIT
- Package author
- AcesAPI
- Keywords
- See keywords
- Commerce declaration
- Does not support commerceThis does not establish whether access is free or paid.
- Review scenarios
- 5 positive · 3 negativeScenario counts, not test results.
Declared capabilities
- Look up ATP and WTA players and tournaments
- Read schedules, draws and match results
- Read rankings and surface Elo ratings
- Compare players head to head and by surface
- Read player match history and stats
Package observed Nov 3, 2026.
Technical details
- First seen
- Oct 31, 2026 · 14:53 UTC
- Last seen
- Nov 4, 2026 · 00:00 UTC
- Collection status
- Collected
plugin_asdk_app_6ad1467bd8f88191bead2eb0a9b87e49
Download plugin data (JSON)