18+ Only  |  Gambling can be addictive — please gamble responsibly  |  Gambling Help: 1800 858 858  |  GambleAware

Build an Australian sports arbitrage scanner in Python

Arbitrage betting — "arbing" — is backing every outcome of a market across different bookmakers at prices that guarantee a profit no matter who wins. It exists because bookmakers disagree on prices, and in Australia you have a lot of books that disagree: Sportsbet, TAB, Neds, Ladbrokes, Unibet, PointsBet and the Betfair exchange.

In this tutorial we'll build a working arb scanner in Python: pull live best odds across Australian bookmakers, do the maths to spot a guaranteed-margin opportunity, and compute the exact stake split. We'll use the free PuntersEdge AU Odds API so you don't have to scrape a single bookmaker yourself.

⚠️ 18+ only. This is an engineering tutorial about market data and probability. Odds move fast and a leg can move or fail to match before you place it — an "arb" is only locked once *both* sides are actually on. Bet only what you can afford to lose. Not financial advice. Gambling Help: 1800 858 858.

The maths in 30 seconds

Convert each price to an implied probability (1 / decimal_odds). For a two-way market, add up the best available implied probability for each side:

book_sum = 1/best_odds_A + 1/best_odds_B

Stake split for a total bankroll T:

stake_i = T * (1/odds_i) / book_sum

That makes your return identical whichever side wins: T / book_sum. Profit = return - T.

Step 1 — get a free key

Grab a free key at puntersedge.online/api-platform (1,500 credits/month, no credit card). You pass it in the X-API-Key header. Want to poke around first? The /v1/demo/* endpoints need no key at all.

import requests

BASE = "https://api.puntersedge.online/v1"
HEADERS = {"X-API-Key": "YOUR_FREE_KEY"}

# No-key sanity check first:
demo = requests.get(f"{BASE}/demo/best-odds", timeout=15).json()
print(demo["sport"], "—", len(demo["events"]), "events")

Step 2 — pull best odds for a sport

/v1/best-odds/{sport} returns each event with the best price per selection across all books:

def best_odds(sport):
    r = requests.get(f"{BASE}/best-odds/{sport}", headers=HEADERS, timeout=15)
    r.raise_for_status()
    return r.json()

events = best_odds("afl")
e = events[0]
print(e["home_team"], "vs", e["away_team"])
for s in e["selections"]:
    print(f"  {s['name']:<24} {s['best_price']}  @ {s['best_bookmaker']}")

Each selection also carries all_prices (every bookmaker's price), so you can see exactly where the edge is coming from.

Step 3 — detect the arb and size the stakes

def find_arb(event, bankroll=100.0):
    """Return arb details for a clean 2-way market, or None."""
    sels = event["selections"]
    if len(sels) != 2:                     # skip messy/duplicate markets here
        return None
    book_sum = sum(1.0 / s["best_price"] for s in sels)
    if book_sum >= 1.0:
        return None                        # no arb
    payout = bankroll / book_sum
    legs = [{
        "bet": s["name"],
        "book": s["best_bookmaker"],
        "odds": s["best_price"],
        "stake": round(bankroll * (1.0 / s["best_price"]) / book_sum, 2),
    } for s in sels]
    return {
        "match": f"{event['home_team']} v {event['away_team']}",
        "margin_pct": round((1 - book_sum) * 100, 2),
        "profit": round(payout - bankroll, 2),
        "legs": legs,
    }

for ev in best_odds("afl"):
    arb = find_arb(ev)
    if arb:
        print(f"\n{arb['match']}  →  {arb['margin_pct']}% margin "
              f"(${arb['profit']} on ${100} staked)")
        for leg in arb["legs"]:
            print(f"  ${leg['stake']:<6} on {leg['bet']} @ {leg['odds']} ({leg['book']})")

Run it across a few sports and you've got a scanner:

for sport in ("afl", "nrl", "nba", "soccer_apl"):
    for ev in best_odds(sport):
        arb = find_arb(ev)
        if arb:
            print(sport, arb["match"], f"{arb['margin_pct']}%")

Step 4 — or let the API do the maths

Two-way is easy; three-way markets and inconsistent team naming across books are where DIY gets painful. /v1/arb/sports does the normalisation and optimisation for you — it returns only events where an arb exists, with the optimal stakes already computed:

arbs = requests.get(f"{BASE}/arb/sports", headers=HEADERS, timeout=15).json()
for a in arbs:
    if a["is_arb"]:
        print(f"{a['home_team']} v {a['away_team']}  {a['arb_pct']}% margin")
        for leg in a["optimal_stakes"]:
            print(f"  ${leg['stake']} on {leg['name']} @ {leg['bookmaker']} "
                  f"→ +${leg['profit_if_wins']} if it wins")

Each entry gives you arb_pct, optimal_stakes (name / stake / bookmaker / profit_if_wins) and the full per-book selections, so you can render a board or fire alerts directly. There's a matching /v1/arb/racing for next-to-go racing.

A note on actually placing them

A scanner finds the opportunity; locking it is the hard part. Prices move in seconds, books limit winning accounts, and a partially-matched leg turns a "sure thing" into exposure. Treat the margin as a target, not a promise — and always confirm both legs live before committing real money. (And, again: 18+, gamble responsibly, Gambling Help 1800 858 858.)

Wrap up

With ~40 lines of Python and a free key you've got a live Australian arbitrage scanner. From here you might add a polling loop, push alerts when arb_pct crosses a threshold, or pull all_prices to track line movement.

Questions or want an endpoint that isn't there yet? Drop a comment.

Get a free API key →

This site contains wagering-related analysis and is intended for Australian users aged 18+. Gambling involves risk. Please gamble responsibly.