# Tennis Elo Ratings Scraper - ATP & WTA Surface Elo, Win Odds (`neverempty/tennis-elo-ratings-scraper`) Actor

For tennis bettors and model builders: Tennis Abstract Elo for every ATP and WTA player - overall, hard, clay and grass Elo with ranks, peak Elo, official rank - plus matchup win probability. 1,083 players in the 14 Sep 2026 update. Price matches on surface Elo without your own model.

- **URL**: https://apify.com/neverempty/tennis-elo-ratings-scraper.md
- **Developed by:** [NeverEmpty](https://apify.com/neverempty) (community)
- **Categories:** Sports, Developer tools, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.95 / 1,000 player or matchup returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Tennis Elo Ratings Scraper - ATP & WTA Surface Elo, Win Odds

For tennis bettors and model builders: **Elo ratings for every ATP and WTA player** from Tennis Abstract - overall, hard-court, clay-court and grass-court Elo with their ranks, peak Elo and the month it was reached, official ranking - plus the **win probability and fair odds for any matchup** on the surface you choose. The 14 Sep 2026 update lists **1,083 players (553 ATP, 530 WTA)**, and both lists come from two page loads. Price a match on surface Elo in seconds instead of scraping tables by hand or maintaining your own rating model.

Export as JSON, CSV or Excel.

The ratings come from [Tennis Abstract](https://tennisabstract.com/)'s public Elo reports (`/reports/atp_elo_ratings.html` and `/reports/wta_elo_ratings.html`), which Tennis Abstract updates about once a week. This Actor is unofficial and not affiliated with Tennis Abstract.

### Why this Actor

- **Surface Elo, not just one number.** Every player row carries overall Elo plus hard, clay and grass Elo and the rank on each surface, the peak Elo and its month, the official ATP or WTA rank, and Tennis Abstract's "log diff" (how far the Elo rank is from the official rank).
- **Win probability built in.** Enter `Jannik Sinner vs Carlos Alcaraz | clay` and get both players' clay Elo, the win probability for each, the fair decimal odds (1 / probability) and, for comparison, the probability on overall Elo.
- **Find players the way you type them.** Full name, a unique last name, the Tennis Abstract id (`JannikSinner`) or the player page URL. Accents and case do not matter (`iga świątek` finds `Iga Swiatek`). A name that matches several players is not guessed: you get a free row that lists the candidates.
- **Optional player profiles.** Tick `includeProfile` to add current and best official rank, the dates the best rank was first and last held, date of birth, height, playing hand, backhand, country and an active flag, read from each player's Tennis Abstract page. The name on the page is checked against the name in the Elo table before the profile is attached.
- **Honest failures.** A player not in the lists, an unreadable page, a matchup across ATP and WTA, and a run that hits your spending limit each come back as a separate free row that says what happened. An unreadable table is never reported as "0 players".

### How the win probability is calculated

The standard Elo expected-score formula:

```
P(A beats B) = 1 / (1 + 10^((EloB - EloA) / 400))
```

`EloA` and `EloB` are the two players' Tennis Abstract ratings on the chosen surface (`overall` uses the overall Elo, `hard`/`clay`/`grass` use the surface Elo). Fair decimal odds are `1 / P`, with no bookmaker margin. `player1WinProbabilityOverallElo` gives the same formula on overall Elo, because some modellers blend overall and surface Elo; the blend is up to you. Probabilities are rounded to 4 decimals and odds to 3.

Example (14 Sep 2026 update): Jannik Sinner clay Elo 2211.8, Carlos Alcaraz clay Elo 2083.8, difference 128 → Sinner 0.6763, Alcaraz 0.3237, fair odds 1.479 / 3.089. On overall Elo (2321.9 vs 2141.2) Sinner's probability is 0.7389.

This is a rating-based estimate for a single match. It does not know about injuries, fatigue, best-of-three versus best-of-five, or anything that happened after the last Tennis Abstract update.

### Input

| Field | Type | Default | What it does |
|---|---|---|---|
| `tours` | list | both (prefilled: ATP, WTA) | `atp` and/or `wta`. Empty = both. |
| `maxPlayersPerTour` | integer | 50 | Players from the top of each Elo list. `0` = every player. Ignored when `players` is filled. |
| `players` | list | (none) | Only these players: full name, unique last name, Tennis Abstract id or player page URL. |
| `includeProfile` | boolean | false | Add the player profile from each player's page. Slower (see below). |
| `matchups` | list | (none) | `Player A vs Player B`, optionally followed by a bar and a surface (clay, hard, grass or overall). With matchups, only matchup rows are returned unless `alsoReturnRankings` is on. |
| `surface` | select | overall | Surface for matchup lines that do not name one. |
| `alsoReturnRankings` | boolean | false | With matchups, also return the ranking rows chosen above. |

Examples:

```json
{ "tours": ["atp", "wta"], "maxPlayersPerTour": 0 }
```

```json
{ "players": ["Jannik Sinner", "Carlos Alcaraz", "Iga Swiatek"], "includeProfile": true }
```

```json
{ "matchups": ["Jannik Sinner vs Carlos Alcaraz | clay", "Aryna Sabalenka vs Coco Gauff | hard"] }
```

### Output

Player row (ATP, 14 Sep 2026 update, with `includeProfile`):

```json
{
  "source": "tennisabstract.com",
  "status": "ok",
  "rowType": "player",
  "tour": "ATP",
  "eloRank": 1,
  "playerName": "Jannik Sinner",
  "playerId": "JannikSinner",
  "playerUrl": "https://www.tennisabstract.com/cgi-bin/player.cgi?p=JannikSinner",
  "age": 24.8,
  "elo": 2321.9,
  "hardEloRank": 1,
  "hardElo": 2259.3,
  "clayEloRank": 1,
  "clayElo": 2211.8,
  "grassEloRank": 1,
  "grassElo": 2125.5,
  "peakElo": 2339.8,
  "peakEloMonth": "2026-05",
  "officialRank": 1,
  "rankLogDiff": 0,
  "ratingsDate": "2026-09-14",
  "currentOfficialRank": 1,
  "bestOfficialRank": 1,
  "bestRankFirstDate": "2024-06-10",
  "bestRankLastDate": "2026-09-14",
  "dateOfBirth": "2001-08-16",
  "heightCm": 191,
  "plays": "right",
  "backhand": "two-handed",
  "country": "ITA",
  "active": true,
  "profileStatus": "ok"
}
```

Matchup row:

```json
{
  "source": "tennisabstract.com",
  "status": "ok",
  "rowType": "matchup",
  "matchup": "Jannik Sinner vs Carlos Alcaraz | clay",
  "tour": "ATP",
  "surface": "clay",
  "player1Name": "Jannik Sinner",
  "player1Id": "JannikSinner",
  "player1Elo": 2211.8,
  "player1EloRank": 1,
  "player2Name": "Carlos Alcaraz",
  "player2Id": "CarlosAlcaraz",
  "player2Elo": 2083.8,
  "player2EloRank": 2,
  "eloDifference": 128,
  "player1WinProbability": 0.6763,
  "player2WinProbability": 0.3237,
  "player1FairDecimalOdds": 1.479,
  "player2FairDecimalOdds": 3.089,
  "player1WinProbabilityOverallElo": 0.7389,
  "formula": "P(A beats B) = 1 / (1 + 10^((EloB - EloA) / 400))",
  "ratingsDate": "2026-09-14"
}
```

Notes on the columns:

- `ratingsDate` is Tennis Abstract's "Last update" date for the list, not the time of the run.
- `officialRank` is the ATP or WTA rank shown in the Elo table (as of `ratingsDate`); `currentOfficialRank` comes from the player page and can be newer. Either can be `null` (5 of 553 ATP players had no official rank in the 14 Sep 2026 update).
- `age` is in years with one decimal, as Tennis Abstract shows it (`null` for 3 ATP players and 1 WTA player).
- `profileStatus`: `ok`; `not-found` (the player page has no profile); `name-mismatch` (the page names a different player, so no profile is attached); `unreadable` (the page could not be read after 3 tries; the Elo columns are still filled).
- Values are never filled with 0 or guessed: a missing value is `null`.

**Not included:** match-by-match results and histories. Tennis Abstract's robots.txt asks crawlers not to read the files that hold them (`/jsfrags/`, `/jsmatches/`, `/jsplayers/`), and this Actor respects that. Only the Elo reports and the player pages are read.

#### Free note rows

These rows have `rowType: "note"`, a `status` and a plain-English `note`, and are never charged:

| `status` | Meaning |
|---|---|
| `player-not-found` | The name is not in the Elo lists (they list players with recent tour-level matches). |
| `ambiguous-player` | The name matches several players; the row lists them. Use the full name. |
| `duplicate` | The same player, or the same two players on the same surface (in either order), was asked for twice; it is returned once. |
| `different-tours` | A matchup across the ATP and WTA lists. The two Elo scales are separate and cannot be compared. |
| `invalid-matchup` | A matchup line that is not "Player A vs Player B", names an unknown surface, or names the same player twice. |
| `no-surface-rating` | One player has no rating on the chosen surface. |
| `unreadable` | A Tennis Abstract page could not be read (after waiting and retrying). Nothing from it is guessed. |
| `budget-reached` | The run reached the maximum total charge you set; the row says how many rows were not returned. |
| `invalid-input` | The input could not be used (for example an unknown tour); nothing was requested. |

### Speed and politeness

Tennis Abstract is an independent site, so this Actor reads one page at a time, waits at least 3 seconds between pages, follows the site's `Retry-After` when it answers HTTP 429 (too many requests) and then waits one second longer between pages (up to 8 seconds). It identifies itself with its own User-Agent and connects through Apify Proxy (datacenter), because plain cloud addresses get a browser check page.

- Rankings and matchups need only the two Elo pages: a run takes seconds, whatever the number of players.
- With `includeProfile`, each player needs one more page: about 5 seconds per player (60 ATP players took 4 min 45 s on 21 Sep 2026), so 100 players take about 8 minutes and all 1,083 players about 1.5 hours or more. The default run timeout is 3 hours; if a run hits its timeout, the rows returned so far are kept and only they are charged, but no note row is written, so raise the timeout for very long runs.
- If player pages stop answering (3 failures in a row), the run stops and a free row says how many players were left out.

### Pricing

Pay per result: each player row and each matchup row is one result. Note rows are free. With `includeProfile`, a player row whose profile could not be attached (`profileStatus` other than `ok`) is still one result, because all its Elo columns are filled; after 3 unreadable player pages in a row the run stops. If a run reaches the maximum total charge you set, it stops and tells you in a free row how many rows were not returned.

### FAQ

**How often do the ratings change?** Tennis Abstract updates the Elo lists about once a week (the `ratingsDate` column shows the update). Running more often than that returns the same numbers.

**Why is a well-known player missing?** The Elo lists contain players with recent tour-level results. Retired or long-inactive players drop off the list.

**Can I compare an ATP and a WTA player?** No. The two lists are separate rating pools, so the Actor returns a free `different-tours` row instead of a meaningless probability.

**Is this official?** No. It reads Tennis Abstract's public pages; it is not affiliated with or endorsed by Tennis Abstract, the ATP or the WTA.

# Actor input Schema

## `tours` (type: `array`):

Which Elo lists to return: ATP (men) and/or WTA (women). Leave empty for both.

## `maxPlayersPerTour` (type: `integer`):

How many players to return from the top of each Elo list. 0 returns every player (553 ATP and 530 WTA in the 14 Sep 2026 update). Ignored when you list players by name, and not used when you enter matchups unless 'Also return rankings' is ticked.

## `players` (type: `array`):

Return only these players, one per line: full name (Jannik Sinner), last name when it is unique (Sinner), Tennis Abstract id (JannikSinner) or player page URL. Accents and case do not matter (iga świątek finds Iga Swiatek). A name that matches several players or none comes back as a free row that says so.

## `includeProfile` (type: `boolean`):

Also open each player's Tennis Abstract page and add current and best official rank, date the best rank was first and last held, date of birth, height, playing hand, backhand, country and active flag. Slower: Tennis Abstract is an independent site, so pages are read one at a time at least 3 seconds apart (about 5 seconds per player; 100 players take about 8 minutes). Match histories are not included.

## `matchups` (type: `array`):

Pairs of players to compare, one per line, written as "Player A vs Player B". Add "| clay", "| hard", "| grass" or "| overall" at the end to choose the surface for that line. Each line returns Player A's and Player B's Elo, win probability from the standard Elo formula 1 / (1 + 10^((EloB - EloA) / 400)), and fair decimal odds. Both players must be on the same tour. When you enter matchups, only matchup rows are returned unless you tick 'Also return rankings'.

## `surface` (type: `string`):

Surface used for matchups that do not name one: overall Elo, or the hard, clay or grass Elo.

## `alsoReturnRankings` (type: `boolean`):

With matchups entered, also return the ranking rows chosen above (tours, players per tour, only these players).

## Actor input object example

```json
{
  "tours": [
    "atp",
    "wta"
  ],
  "maxPlayersPerTour": 50,
  "includeProfile": false,
  "surface": "overall",
  "alsoReturnRankings": false
}
```

# Actor output Schema

## `results` (type: `string`):

One row per player (ATP or WTA): Elo rank, overall, hard, clay and grass Elo with their ranks, peak Elo and month, official rank, and optionally the player profile; or one row per matchup with each player's Elo, win probability and fair decimal odds. Names not found, unreadable pages and limits come back as free rows that say why.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "tours": [
        "atp",
        "wta"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/tennis-elo-ratings-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "tours": [
        "atp",
        "wta",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("neverempty/tennis-elo-ratings-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "tours": [
    "atp",
    "wta"
  ]
}' |
apify call neverempty/tennis-elo-ratings-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,neverempty/tennis-elo-ratings-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/6m9ypgh6u1RNaRdRo/builds/SjL2MKEvkRExSOdJF/openapi.json
