DEV Community

Matvey
Matvey

Posted on

Sum the Prize Money Column on 50 WTA Match Rows and Sabalenka Made $36.3M. She Made $6.5M

I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads the women's tennis tour: you type a player's name, you get one flat row per match she played, with the tournament tier, the surface, the round, both players' ranks and seeds, the score, the ranking points and the prize money. The tour publishes no API, and the free aggregators drop exactly the two columns that make a tour row worth having — points and money. So the columns are there. The trap is what they are the total of.

100 rows, two players, 5.5 seconds

Here is a real run from 21 October 2026, straight off the scheduled check I keep on this Actor:

{
  "mode": "player-matches",
  "playerNames": ["Aryna Sabalenka", "Iga Swiatek"],
  "maxItems": 100,
  "newestFirst": true
}
Enter fullscreen mode Exit fullscreen mode

5.5 seconds end to end, 100 match rows — the 50 most recent for each player. Sabalenka's rows run from 4 March to 12 October 2026, Swiatek's from 17 March to 12 October. Twelve tournaments each.

Now the obvious thing to do with a prize money column: add it up.

                     Sum of prizeWon    Sum of rankingPoints
Aryna Sabalenka          $36,323,958                  24,435
Iga Swiatek              $17,486,601                  17,556
Enter fullscreen mode Exit fullscreen mode

$53.8 million between two players over seven months. The real figures in that same payload are $6,520,946 and $3,274,064 — 5.57x and 5.34x smaller. The points are off by 5.13x and 5.52x.

Nothing in the data is wrong. I added up the wrong grain.

One number, seven rows

prizeWon is not the money from that match. It is the money the player took from that tournament, and it is stamped onto every match row from that event, identically. Same for rankingPoints. Here is Sabalenka's US Open, seven rows, exactly as they come back:

round score won rankingPoints prizeWon
R128 6-2 6-4 true 1300 2800000
R64 6-1 6-1 true 1300 2800000
R32 6-3 6-4 true 1300 2800000
R16 6-4 6-3 true 1300 2800000
Q 7-6(1) 3-6 7-6(7) true 1300 2800000
S 7-5 6-2 true 1300 2800000
F 6-4 5-7 6-2 false 1300 2800000

She reached the final and lost it: 1300 points, $2.8m, one time. Summing the column books it seven times — $19.6m from one fortnight. And the multiplier is not a constant you can divide out, because it is just the number of matches she played at that event. Her Wuhan was two rows and her Miami was seven, so the inflation factor swings between 2x and 7x depending on how deep each run went. An average correction is worse than none: wrong in both directions, and plausible.

The fix is a deduplication, not a division. The key is the player and the tournament edition — playerId plus tournamentId plus year, because tournament IDs are stable across seasons and Rome 2025 and Rome 2026 are the same ID:

def season_totals(rows):
    seen = {}
    for r in rows:
        if r.get("type") != "match":
            continue
        key = (r["playerId"], r["tournamentId"], r["year"])
        seen[key] = (r.get("prizeWon") or 0, r.get("rankingPoints") or 0)
    money = sum(v[0] for v in seen.values())
    points = sum(v[1] for v in seen.values())
    return money, points
Enter fullscreen mode Exit fullscreen mode

One quiet benefit: it survives truncation. My run cut Sabalenka off at 50 rows, mid–Indian Wells, so that event arrives as 2 rows instead of 7 — and still reads $1,085,220 and 1000 points, because the value was never per-row. A sum of a truncated run is wrong in a way you cannot detect; a dedupe of one is simply missing earlier events.

tournamentPrizeMoney is a third grain hiding in the same row — the whole prize pool for the event, not anybody's earnings. Three money-shaped numbers, three different denominators, no visible difference in a spreadsheet.

Every scoreline reads like a win

Second field with a grain problem, and this one bit me harder because it is text. Here is the top row of the run, Sabalenka at Wuhan:

{
  "date": "2026-10-12",
  "tournamentName": "WUHAN",
  "level": "WTA 1000",
  "surface": "HARD",
  "round": "R32",
  "playerName": "Aryna Sabalenka",
  "playerRank": 2,
  "playerSeed": 1,
  "opponentName": "Ann Li",
  "opponentRank": 31,
  "score": "3-6 6-4 7-6(5)",
  "won": false,
  "winnerName": "Ann Li",
  "loserName": "Aryna Sabalenka",
  "outcome": "completed",
  "rankingPoints": 10,
  "prizeWon": 26000
}
Enter fullscreen mode Exit fullscreen mode

Read the score on its own and Sabalenka won two sets to one. She lost. The tour publishes every scoreline from the winner's side, and the row is assembled from the requested player's side, so on a loss the two disagree.

This is not an edge case. Of the 100 rows, 83 are matches actually played, and I checked all 83: every single one has the first-listed side leading — 62 wins and 21 losses, zero exceptions. The score string carries no information at all about whether the player you asked for won. There is nothing to parse your way out of; you read won, or winnerName, and you leave the score as a display string.

Which means the one line of code everyone writes first —

sets_won = score.split()[0].split("-")[0]   # wrong on 21 of 83 rows
Enter fullscreen mode Exit fullscreen mode

— is wrong on a quarter of the rows, and wrong only on the losses. A form guide built that way shows a player who never loses.

Seventeen of a hundred rows are not matches

Third one, and it is the cheapest to get right. A top seed's first round at a 1000-level event is often a bye, and a bye is a line in the draw, not a match. It arrives as a row with outcome: "bye", no score, no opponent — and won: true, because formally she advanced.

Seventeen of my 100 rows are byes. Count wins over all rows and you get 79.0%. Drop the byes and the two of them went 62–21, which is 74.7%. Four and a bit points of free win rate, concentrated entirely on the players you are most likely to be modelling, since byes only go to high seeds.

played = [r for r in rows if r.get("outcome") != "bye"]
Enter fullscreen mode Exit fullscreen mode

Byes are also free: the Actor bills per match and skips them, so that 100-row run charged for 83. At $0.004 a match on the free tier it came to 33 cents. Error rows are free too, and they are rows rather than a failed run — if a name does not resolve against the tour's player index you get a type: "error" row naming the query, not a silent gap in a 100-row file.

The rankings table is a different shape

Before you reuse the same parser: in rankings mode the numbers mean what they look like. One row per player, points is her live ranking total, movement is places gained or lost since the previous publication. Top of the doubles table, 19 October:

{
  "type": "ranking",
  "playerName": "Katerina Siniakova",
  "countryCode": "CZE",
  "discipline": "doubles",
  "rank": 1,
  "points": 11260,
  "tournamentsPlayed": 16,
  "movement": 0,
  "rankedAt": "2026-10-19"
}
Enter fullscreen mode Exit fullscreen mode

rankedAt is the field to keep if you snapshot this on a schedule. The table is republished weekly, so two runs three days apart carry the same rankedAt and are the same table — dedupe your history on that date, not on the day you ran the scraper.

Calling any of this from Python is one request:

import requests

rows = requests.post(
    "https://api.apify.com/v2/acts/lergassy~wta-tennis-scraper/run-sync-get-dataset-items",
    params={"token": TOKEN},
    json={"mode": "player-matches",
          "playerNames": ["Coco Gauff"],
          "dateFrom": "2026-01-01",
          "maxItems": 100},
).json()

money, points = season_totals(rows)
print(f"${money:,} and {points:,} points")
Enter fullscreen mode Exit fullscreen mode

When this is the wrong tool

  • You need men's tennis. This reads the women's tour only. Cross-tour results and live scores are a different feed — I keep a separate scores Actor for ATP, Challenger and ITF days.
  • You need live, in-progress scores. These are published results. A match in its second set is not here.
  • You need point-by-point or serve statistics. Aces, first-serve percentage and rally length are licensed products; the public tour data stops at the scoreline.
  • You need a match-level money column. It does not exist upstream. The tour pays by round reached, not by match, so per-match prize money is something you would be inventing.

The Actor is WTA Tennis Scraper — match histories, ranking tables, player profiles and the tournament calendar, searched by name, one flat row per record.

If you keep one thing from this: in a tennis feed, a row is a match but half the numbers on it are not. Ask what each column is the total of before you reach for SUM.

Top comments (0)