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
- If
book_sum >= 1, the bookmakers' margin eats you alive — no arb. - If
book_sum < 1, there's an arb. Your guaranteed margin is1 - book_sum.
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.
- Free key + docs: https://puntersedge.online/api-platform
- Also on RapidAPI: https://rapidapi.com/Propertyscout001/api/puntersedge-au-odds
- Endpoints used:
/v1/best-odds/{sport},/v1/arb/sports,/v1/demo/best-odds
Questions or want an endpoint that isn't there yet? Drop a comment.