DEV Community

Cover image for My win rate was 33%, 57% and 67% at the same time
Mark Veenema
Mark Veenema

Posted on

My win rate was 33%, 57% and 67% at the same time

My site showed one win rate for the Premier League on Tuesday morning. Three numbers, actually. The competition block on the tips page said 33.3%. The archive, filtered to Premier League, said 67%. And after I fixed both, they agreed on 57.1%, until I found 33.3% again on a different page an hour later.

Same database. Same table. Same four tips. Nobody had touched the data.

How I found it

Not from a test. I was looking at the Premier League tips page, read "33.3% of our published tips won", and then opened the archive the way a visitor would to check it. The archive said 67%.

That is the worst kind of bug on a site whose whole point is a public track record. Every pick is published before kickoff and settled afterwards, losses included, so that anyone can check the numbers. If two pages disagree, the checking is broken, even when nothing is technically wrong.

So I counted by hand. The Premier League main tips since our public archive starts:

SELECT result, COUNT(*) AS n
FROM betting_tips
WHERE league_id = 39
  AND is_extra = 0
  AND tip_date >= '2026-05-11'
GROUP BY result;
Enter fullscreen mode Exit fullscreen mode
+-----------+---+
| result    | n |
+-----------+---+
| win       | 2 |
| loss      | 1 |
| half_loss | 1 |
+-----------+---+
Enter fullscreen mode Exit fullscreen mode

Four tips. Getting from four tips to three different percentages took four separate mistakes.

Layer one: two Premier Leagues

The archive filter was built from the league name:

if (!empty($row['league'])) $leagues[$row['league']] = $row['league'];
Enter fullscreen mode Exit fullscreen mode
<tr class="dsp-row" data-league="<?= esc_attr($row['league']) ?>">
Enter fullscreen mode Exit fullscreen mode

"Premier League" is England. It is also Ukraine. Both arrive from the fixtures API with exactly that name, and both had tips in the table. Picking "Premier League" in the dropdown quietly merged two countries into one row of the filter. The table also had a third spelling, Premier League England, from an older import, which showed up as its own entry.

The fix keys the filter on the ID and only uses the name as a label:

function dsp_arch_league_key($row) {
    $id = isset($row['league_id']) ? intval($row['league_id']) : 0;
    return $id > 0 ? 'id' . $id : (string)($row['league'] ?? '');
}
Enter fullscreen mode Exit fullscreen mode

That was a real bug, but it didn't explain the gap. With Ukraine filtered out, the archive still said 67% and the block still said 33.3%.

Layer two: what a half win is worth

Most of our tips are on Asian handicap lines, and quarter lines settle as half wins and half losses. A bet on -0.25 that ends in a draw loses half the stake and refunds the other half. That is not a win, and it is not a full loss either.

Three pieces of code had three opinions about it.

The archive summary, in JavaScript, ignored half results completely:

if (row.dataset.result === 'win')  wins++;
if (row.dataset.result === 'loss') losses++;

var winrate = Math.round(wins / (wins + losses) * 100);
Enter fullscreen mode Exit fullscreen mode

The competition block, in SQL, counted a half win as half a win, but a half loss as a full loss, because it was in the denominator at full weight:

ROUND((SUM(result='win') + 0.5*SUM(result='half_win'))
      / NULLIF(SUM(result IN ('win','loss','half_win','half_loss')), 0) * 100, 1)
Enter fullscreen mode Exit fullscreen mode

And the analysis script I used to write the articles on the site counted both halves as halves:

W = wins + 0.5 * half_wins
L = losses + 0.5 * half_losses
rate = W / (W + L)
Enter fullscreen mode Exit fullscreen mode

Run the same four tips through each:

formula result
ignore halves 2 / 3 = 66.7%
half win counts half, half loss counts full 2 / 4 = 50.0%
both halves count half 2 / 3.5 = 57.1%

None of these is wrong in isolation. They are three different questions. The problem was that all three were labelled "win rate".

I picked the third: it is the only one where a half loss and a half win cancel out, which is what they do to your stake. Then I put the formula in the text next to the number, so a reader can redo it.

Layer three: since when?

The block didn't say 50%, though. It said 33.3%. The last difference was the window.

The archive starts on 11 May 2026, the day the current model setup went live, and that date already lived in one constant:

define('DSP_TRACK_SINCE', '2026-05-11');
Enter fullscreen mode Exit fullscreen mode

The archive query used it. The competition block didn't:

FROM betting_tips
WHERE $where AND is_extra = 0
Enter fullscreen mode Exit fullscreen mode

So the block counted every main tip since March, including two months of an older model, while the archive next to it counted from May. For the Premier League that was 18 tips at 33.3% against 4 tips at 57.1%. Both numbers were correctly computed and neither could be checked against the other, because a visitor can only see the archive.

That is the rule I ended up with: if a reader cannot reproduce a number from the public archive, the number shouldn't be on the site. Every block now counts from DSP_TRACK_SINCE, and says so in the label: "57.1% of our main tips won, 4 settled since 11 May 2026".

Layer four: the copies

I patched the block and the archive, checked both pages, and called it done.

An hour later the competitions overview showed 33.3% on the Premier League card. That card came from a different shortcode, with its own copy of the old query, which I hadn't touched because I didn't know it existed.

This time I searched for the formula instead of the function name:

grep -n "result='win'" wp-content/plugins/betting-tips/betting-tips.php
Enter fullscreen mode Exit fullscreen mode

Fifteen hits. Four of them computed a win rate that ended up on a public page:

  • the competition block on each tips page
  • the cards on the competitions overview
  • a second, older accuracy shortcode with the same SQL
  • the archive summary, in JavaScript, in the same file

Two more lived in shortcodes that no published page used any more. I checked that with a query rather than from memory:

SELECT COUNT(*) FROM wp_posts
WHERE post_status = 'publish'
  AND post_content LIKE '%[monthly_stats%';
Enter fullscreen mode Exit fullscreen mode

Zero. They stay dead, and they are next on the list to be deleted, so nobody revives them with the old formula.

The fix I haven't finished

Tuesday's patch brought all four copies in line. They now use the same window, the same formula and the same minimum of three settled tips. But they are still four copies. The next person to add a fifth block will copy whichever one is closest, and the drift starts again.

The real fix is one function that owns the definition:

function dsp_win_rate(mysqli $db, string $where): ?array {
    $since = DSP_TRACK_SINCE;
    $r = $db->query(
        "SELECT SUM(result='win') AS w,      SUM(result='loss') AS l,
                SUM(result='half_win') AS hw, SUM(result='half_loss') AS hl
         FROM betting_tips
         WHERE is_extra = 0 AND tip_date >= '$since' AND $where"
    )->fetch_assoc();

    $W = $r['w'] + 0.5 * $r['hw'];
    $L = $r['l'] + 0.5 * $r['hl'];
    if ($W + $L == 0) return null;

    return [
        'rate'    => round(100 * $W / ($W + $L), 1),
        'settled' => (int)($r['w'] + $r['l'] + $r['hw'] + $r['hl']),
    ];
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript side is the awkward one, because it counts rows in the browser after filtering. It has to implement the same formula a second time, so it gets a comment pointing at the PHP function, and a check that runs after every deploy:

import re, subprocess

def pct(url, pattern):
    html = subprocess.run(['curl', '-s', url], capture_output=True, text=True).stdout
    m = re.search(pattern, html)
    return m.group(1) if m else None

block = pct('https://dailysportpick.com/tips/premier-league-tips/',
            r'dsp-league-acc-pct[^>]*>\s*([\d.]+)%')
card  = pct('https://dailysportpick.com/competitions/',
            r'Premier League</span>.*?([\d.]+)% win rate')

assert block == card, f'block says {block}, card says {card}'
Enter fullscreen mode Exit fullscreen mode

It is crude. It would have caught the fourth layer an hour before I did.

What I took from it

A rate without a denominator and a window is not a number. "Win rate" meant three different things in three files. The label was identical, and the label was the only part anyone read.

Half outcomes are where definitions split. With only wins and losses, every reasonable formula agrees. The moment a result can be worth half, each developer makes a choice, usually without noticing it is a choice.

Grep for the formula, not the function. I searched for the shortcode I knew about and fixed it. Searching for result='win' found the three I didn't.

The public record is the source of truth, so everything else has to be derived from it. If the archive is what readers can check, any number that uses a different window or filter is unverifiable, and an unverifiable number on a track-record site does more damage than a low one.

Fixing the copies is not the same as removing them. I'm writing this partly so that the next commit actually happens.

The numbers in this post come from Daily Sport Pick, a football prediction site I run on my own. It publishes every pick before kickoff and keeps the losses in the archive, which is exactly why three numbers for one league was a problem worth a morning.

Top comments (1)

Collapse
 
rasinmuhammed profile image
Muhammed Rasin O M •

Nice write-up, and grepping for the formula rather than the function name is a good trick. One addition to the post-deploy check: two pages agreeing only proves they share the bug, so keep a small fixture of tips with half wins and half losses where you have worked the correct rate out by hand, and assert dsp_win_rate() returns exactly that. Then every page that calls it inherits a tested number.