DEV Community

Punters Edge
Punters Edge

Posted on

Your arbitrage calculator is probably wrong, and the bug is rounding

Every arbitrage tutorial gives you the same formula. Two outcomes, two books, back both, split your stake so you win the same amount either way. Three lines of arithmetic, and it is correct.

It is also unimplementable, because it assumes you can stake $17.67635. You cannot. Bookmakers take whole dollars.

I maintain a small Australian odds API and a Python client for it, and I found this bug in my own sizing code. Two things before the arithmetic, because you will find them out anyway:

  • Australian bookmakers restrict accounts that arb, usually within weeks. That sentence is in the package's own CLI help text. Nothing below changes it, and I would not build a business on retail arbing.
  • This is a data tool. It never places a bet, never holds a bookmaker login, and makes no claim about outcomes. What follows is about doing arithmetic correctly.

The textbook formula

The implied probability of a decimal price o is 1/o. If the implied probabilities across the best price for each outcome sum to less than 1, there is an arb. For a total stake T, the equal-profit split is s_i = T * (1/o_i) / sum(1/o_j).

Book A has the outsider at 3.40. Book B has the favourite at 1.42.

1/3.40 = 0.294118
1/1.42 = 0.704225
          --------
          0.998343   ->  margin 0.166%
Enter fullscreen mode Exit fullscreen mode

A real arb, and a thin one. Thin ones are most of them. Cap the total at $60:

s_A = 60 * 0.294118 / 0.998343 = 17.67635
s_B = 60 * 0.704225 / 0.998343 = 42.32365
Enter fullscreen mode Exit fullscreen mode

Check it. 17.67635 * 3.40 = 60.0996, 42.32365 * 1.42 = 60.0996. Same return either way, profit $0.10. The formula did its job.

Now stake it. Round to whole dollars: $18 and $42.

A wins:  18 * 3.40 = 61.20   staked 60   ->  +1.20
B wins:  42 * 1.42 = 59.64   staked 60   ->  -0.36
Enter fullscreen mode Exit fullscreen mode

You have not made $0.10. You have taken a position that loses 36 cents if the favourite wins, which it will most of the time. You did not misprice anything and you did not get a stale quote. You rounded, the way every calculator rounds.

Be precise about what was lost

My own README called this outcome a guaranteed loss until 0.2.2, and the library said it back to you in the refusal message. Both are fixed, but it is worth spelling out why, because the obvious reading of that phrase is provably impossible.

For any genuine arb, S = sum(1/o_i) < 1. Whatever integer stakes you choose, the 1/o_i-weighted sum of the branch profits equals T(1 - S) > 0, where T is what you actually staked. A weighted sum of negatives cannot be positive, so at least one branch is always in front. My example proves it three lines up: +$1.20 if the outsider gets up. I ran 100,000 random two-leg cases looking for a rounded split that was negative on every branch and found none, which is what the algebra says.

What rounding destroys is not the money, it is the guarantee. Before rounding you held a position with no losing branch. After rounding you hold a directional bet on the branch that wins most often — one you did not choose, did not price, and did not intend. That is the defect, and calling it anything stronger is a claim I cannot support.

Why the rounding eats the edge

Write the worst branch out. If B wins, profit is 0.42*s_B - s_A. Rounding moved s_B down by $0.324 and s_A up by $0.324, so that branch pays:

0.324 * 0.42  = 0.136   lost on the short leg
0.324 * 1.00  = 0.324   lost to the extra dollar on the other leg
                -----
                0.460   total cost of rounding
Enter fullscreen mode Exit fullscreen mode

The edge was $0.0996. That is the whole bug in one line: the rounding step is a dollar, and the edge is ten cents. The coefficient on the other leg is exactly 1.00, because every dollar on a leg that loses comes straight off the branch where it loses.

This gets worse as arbs get thinner, and thinner arbs are the ones that survive long enough for you to see them.

The fix is not better rounding

The instinct is to round smarter. Floor instead of round. Round one leg and derive the other. None of it works reliably, because the optimum is often not near the continuous answer at all. Search the integer grid for the same market, still capped at $60:

$13 and $31  (total $44, not $60)

A wins:  13 * 3.40 = 44.20   staked 44   ->  +0.20
B wins:  31 * 1.42 = 44.02   staked 44   ->  +0.02
Enter fullscreen mode Exit fullscreen mode

Worst case +$0.02, in this snapshot — both prices have to still be there when you get there, and if one leg is refused you are holding a directional bet you never intended. Note the total: $44, not the $60 you allowed. No continuous rounding rule gets you from 17.68 / 42.32 to 13 / 31, because the answer is not a perturbation of the textbook one.

So change the objective. Stop equalising the theoretical profit. Maximise the worst case over the discrete grid.

import math

def worst_case(odds, stakes):
    total = sum(stakes)
    return min(o * s - total for o, s in zip(odds, stakes))

def best_split(odds, cap, min_stake=1):
    """Two legs, whole dollars. Returns None if nothing clears zero."""
    o1, o2 = odds
    denom = 1 / o1 + 1 / o2
    best = None
    for total in range(2 * min_stake, cap + 1):
        anchor = total * (1 / o1) / denom      # the textbook split, real-valued
        for s1 in (math.floor(anchor), math.ceil(anchor)):
            s2 = total - s1
            if s1 < min_stake or s2 < min_stake:
                continue
            w = worst_case(odds, (s1, s2))
            if best is None or w > best[1]:
                best = ((s1, s2), w)
    if best is None or best[1] <= 0:
        return None
    return best

print(best_split((3.40, 1.42), 60))   # ((13, 31), 0.01999999999999602)
print(best_split((3.40, 1.42), 40))   # None
Enter fullscreen mode Exit fullscreen mode

The textbook formula is still in there, but it is the anchor, not the answer. For a fixed total, one leg's profit rises with s1 while the other's falls, so the max-min is unimodal and the discrete optimum for that total is one of the two integers either side of the continuous crossing. Sweep the total and you have an O(cap) search that is exact for two legs. Three or more legs is a grid search, not a proof — I have not shown unimodality there and do not claim it.

Two details matter more than the search. It returns None — at a $40 cap nothing clears zero, and the correct output is a refusal, not a least-bad plan. And cap is a cap, not a target: it stakes less when less pays better.

How often does this bite? It depends on your stake cap

My README reported a "guaranteed loss" in 6.9% of thin arbs tested, and worse than optimal in 56%. I went to retract both. Only one of them deserved it, and finding out which taught me more than the original result did.

The 6.9% is correct. The module docstring had recorded the generator — 3,000 randomly generated two- and three-leg arbs, inv_sum between 0.94 and 0.999, whole-dollar stakes — but not the stake cap. And the cap is what decides the answer, because the entire effect is the size of the rounding step measured against the size of the edge. Sweeping the cap on that same generator:

stake cap $10 $20 $40 $60 $100 $200
non-positive worst case 84.8% 55.4% 31.3% 18.6% 11.1% 4.5%

6.9% sits at about $150. So the number was never invented — it was published without the one parameter that made it mean anything, which quietly turned a property of my stake size into an apparent property of arbitrage. That is the actual defect in the claim, and it is a more useful thing to know than any single percentage.

The companion figure, "worse than optimal in 56%", does not reproduce at any cap — the same sweep gives 68–91%. That one is withdrawn with no replacement, because a quantity that moves this much does not have a headline number.

There is a lesson in the retraction that nearly did not happen: a measured value that disagrees with yours usually means you are measuring something slightly different. Reproduce a number under its own stated parameters before withdrawing it, and check what those parameters leave out. The omission is often the finding.

Here is a harness on a different distribution, so you can watch the same effect move. Same file, after the code above:

import random

def naive(odds, cap):
    o1, o2 = odds
    s1 = round(cap * (1 / o1) / (1 / o1 + 1 / o2))
    return (s1, cap - s1)

random.seed(1)
n = losing = beaten = allneg = 0
while n < 20000:
    o1 = round(random.uniform(1.30, 9.00), 2)
    rest = random.uniform(0.98, 0.9995) - 1 / o1
    if rest <= 0.02:
        continue
    o2 = round(1 / rest, 2)
    if 1 / o1 + 1 / o2 >= 1.0:
        continue
    cap = 200
    s = naive((o1, o2), cap)
    if min(s) < 1:
        continue
    n += 1
    wn = worst_case((o1, o2), s)
    best = best_split((o1, o2), cap)
    if wn < 0:
        losing += 1
    if o1 * s[0] - cap < 0 and o2 * s[1] - cap < 0:
        allneg += 1
    if best is not None and best[1] > wn + 1e-9:
        beaten += 1

print(n, losing / n, beaten / n, allneg)
# 20000 0.16505 0.8465 0
Enter fullscreen mode Exit fullscreen mode

At a $200 cap over margins of 0.05% to 2%, naive rounding left a negative worst case 16.5% of the time and was beaten by the search 84.7% of the time. Widen the margin range to 10% and the first number falls to 3.1% and the second to 69.4%. Zero cases were negative on every branch, in this run and every other one.

Note that this is a different 4.5%-vs-16.5% than the table above: same $200 cap, but a distribution that concentrates on much thinner arbs. Both knobs move it, and they move it a lot.

Three caveats before someone else supplies them. This is a synthetic distribution — uniform prices, uniform margin — so the rate is a property of my generator, not of real arbitrage markets, and I do not have a defensible sample of the latter. It is one seed; the numbers move in the third digit across seeds. And it is two legs only. The swing across caps and margins is the actual result here: any single percentage for this, including the one I published, is worth less than the curve it came from.

What is invariant is the direction, and it is a ratio: compare your quantisation step to the effect you are optimising for. When the step is comparable to the edge, optimising in the continuous domain and projecting onto the grid afterwards is not an approximation. It is a different problem with a different answer.

That generalises well past betting. If you have a solver, an allocator, a rebalancer or a bin packer that computes a real-valued optimum and then quantises it, go and check your step against your edge. If the step is bigger, your formula is decoration. Search the grid, maximise the worst case, and return nothing when nothing clears zero.

The toolkit this came out of

The rest of this is the library the finding came from. Skip it if you only wanted the maths — it is all above.

Look at real data first, no key and no signup:

curl -s 'https://api.puntersedge.online/v1/demo/racing/next-to-go' | jq '.races[0]'
Enter fullscreen mode Exit fullscreen mode
{ "venue": "Muswellbrook", "race_number": 1, "category": "horse",
  "start_time": "2026-10-05T02:39:00Z", "country": "AU",
  "runners": [ { "name": "I'm Flexible", "number": 2,
                 "bookmakers": [ {"key": "pointsbetau", "win_price": 20.0},
                                 {"key": "betright", "win_price": 19.0},
                                 {"key": "ladbrokes_au", "win_price": 18.0} ] } ] }
Enter fullscreen mode Exit fullscreen mode

/v1/demo/best-odds and /v1/demo/book-sport?book=sportsbet&sport=afl are open too, and there is an interactive playground or a no-key sandbox if you would rather click than curl. Coverage is fourteen Australian bookmakers on racing — Sportsbet, TAB, TABtouch, Neds, Ladbrokes, Unibet, PointsBet, Betr, BetRight, Palmerbet, PlayUp, BetDeluxe, BetGold, BoostBet — across thoroughbred, harness and greyhound, measured live at puntersedge.online/coverage-report. Six of those cover sports (AFL, NRL, NBA, WNBA, tennis, cricket), so sports coverage is thinner per sport. Do not assume parity.

pip install puntersedge     # 0.3.2, MIT, 205 tests, Python 3.8-3.13
Enter fullscreen mode Exit fullscreen mode

Most of the toolkit is a machine for refusing things and telling you why. This runs offline, with no key. The book names and prices are placeholders, so nobody reads it as a real arb I am claiming to have seen:

from puntersedge.arb import from_sports_payload, evaluate, GateConfig

payload = [{
    "event_id": "e1",
    "sport_key": "aussierules_afl",
    "is_arb": True,
    "arb_pct": 4.8,
    "selections": [
        {"name": "Home", "best_price": 2.10, "best_bookmaker": "book_a"},
        {"name": "Away", "best_price": 2.10, "best_bookmaker": "book_b"},
    ],
}]

opp = from_sports_payload(payload)[0]
print(evaluate(opp))
# Verdict(ok=False, reasons=['unknown_age'], verdict_class='data')

opp = from_sports_payload(payload, ages={"e1": 42.0})[0]
print(evaluate(opp, GateConfig(bettable_books={"book_a"})))
# Verdict(ok=False, reasons=['book_not_bettable'], verdict_class='policy')

print(evaluate(opp, GateConfig(bettable_books={"book_a", "book_b"})))
# Verdict(ok=True, reasons=[], verdict_class='clear')
Enter fullscreen mode Exit fullscreen mode

Every refusal has a reason and every reason has a class. Policy reasons (book_not_bettable, below_min_edge, wrong_kind) mean you configured it that way. Data reasons (no_cross, stale_quote, unknown_age, malformed) mean the market or the feed. Same "0 passed", completely different thing to go fix. passes(opp, cfg) and refusal_reasons(opp, cfg) take the opportunity, not the verdict.

Two defaults worth arguing with. max_edge_pct=8.0, because a very large apparent edge is usually a stale or wrong price. unknown_age=REJECT, because "I could not determine how old this quote is" must never silently mean "fresh".

Sizing, on the 3.40 / 1.42 market from the top of this post:

from puntersedge.arb import (
    from_sports_payload, size, theoretical_split, minimum_viable_total,
)

market = [{
    "event_id": "e2",
    "sport_key": "aussierules_afl",
    "is_arb": True,
    "arb_pct": 0.166,
    "selections": [
        {"name": "Outsider", "best_price": 3.40, "best_bookmaker": "book_a"},
        {"name": "Favourite", "best_price": 1.42, "best_bookmaker": "book_b"},
    ],
}]
thin = from_sports_payload(market, ages={"e2": 30.0})[0]

plan = size(thin, total=60, step=1.0)
print([leg.stake for leg in plan.legs])   # [13.0, 31.0]
print(plan.total_staked, plan.profit)     # 44.0 0.02
print(plan.theoretical_profit)            # 0.09958506224066355

print(size(thin, total=40, step=1.0).viable)   # False
print(size(thin, total=60).step)               # 0.01
print(theoretical_split([3.40, 1.42], 60))
# [17.676348547717843, 42.323651452282164]
print(minimum_viable_total([3.40, 1.42], [1.0, 1.0]))
# 3.3943661971830985
Enter fullscreen mode Exit fullscreen mode

step defaults to one cent, not one dollar — the whole-dollar case above is the worst case of the general problem, and the finer the step your book accepts, the smaller the effect. plan.profit is the worst case after rounding, never the textbook figure. theoretical_split(odds, total) shows the naive number next to the real one, and minimum_viable_total(odds, minimums) gives the smallest total that still respects each book's minimum stake.

Limitations

Stated up front rather than discovered later.

  • Book-vs-book only. Exchange prices are not part of what this API serves, and there is no racing arb endpoint. racing_best_odds() is the cross-book racing view; /v1/arb/sports is the sports scanner.
  • The free tier cannot run a live scanner. A poll costs 3 credits per sport in your list, plus 1 more for each sport needing a freshness check — so the two-sport scan below is 3×2 + 1 = 7. The number is available before you spend anything, and it needs no network:
from puntersedge import PuntersEdge
from puntersedge.arb import Scanner

scan = Scanner(PuntersEdge(api_key="unused-for-this-call"),
               sports=["aussierules_afl", "rugbyleague_nrl"])
print(scan.estimate_poll_cost())                          # 7
print(scan.fits_budget(900, monthly_credits=3000))        # False
print(scan.budget_advice(900, monthly_credits=3000))
# ~20,160 credits/month at 900s intervals (~7 credits/poll) — that is
# 7x a 3,000-credit plan. Slow the interval to ~101 min, narrow
# `sports=`, or move to a larger plan.
Enter fullscreen mode Exit fullscreen mode

That is 20,160 credits a month for two sports at a 15-minute interval, against a free allowance of 3,000, and it scales with the length of your sport list. That is arithmetic, not a limitation I chose. There is also no point polling faster than 15 minutes, which is the upstream sports feed refresh rate. A free key needs no credit card; the pricing and credit tiers are what a real scanner needs.

  • Australian bookmakers restrict accounts that arb, usually within weeks. No tool changes that.
  • It is small. One maintainer, zero stars, at 0.3.2 as I write this. 0.1.0 is yanked and its method names differ, so ignore any snippet older than 0.2.x — read the API documentation rather than trusting a signature in a blog post, including this one.
  • It never places a bet and never holds a bookmaker login. There is nowhere in the config to put one, and trying to add one is a startup error.

One more rule from the same family of bugs: a plan is not a bet. Recording an intention as a placement overstated the conversion of a private betting system of mine by 3.6x, so plans contribute nothing to P&L until a real placement is recorded per leg at the price actually got.

Source: github.com/Propertyscout001/puntersedge-python. If a number in this post is wrong, I would rather know — the cap table above exists precisely because someone going to check my figure would have found it underspecified, and I would rather be the one who found that.


Informational and analytical use only. No profit is claimed or implied and nothing here is financial advice. Odds move — verify every leg at the book before staking. 18+. Gamble responsibly. Gambling Help Online 1800 858 858, gamblinghelponline.org.au

Top comments (0)