Skip to main content
Build an Arbitrage Bot in Python
Back to Blog

Build an Arbitrage Bot in Python

James Whitfield

James Whitfield

4 min read

Arbitrage betting means covering every outcome of an event at different bookmakers whose odds disagree enough that you profit whichever way it ends. Finding those windows by hand is hopeless. They appear and vanish in minutes. So we're going to build a bot that finds them, sizes the stakes, and alerts you, in about 100 lines of Python.

If you want the fundamentals first, read our arbitrage scanner tutorial. That post covers the theory and a scan-and-print script in depth. This one is the automation angle: a long-running bot with deduplication, stake splitting, and alerting.

What You Need

  • A free Odds-API.io API key (100 requests/hour, no credit card)
  • Python 3.10+ and the requests library
  • Accounts at two or more bookmakers if you plan to actually place bets
pip install requests

The Two-Way Arbitrage Math

For a two-outcome market (tennis match winner, Asian handicap, totals), sum the inverse of the best available odds on each side:

margin = 1/odds_a + 1/odds_b

If the sum is below 1.0, an arb exists. Say Bookmaker A has the home side at 2.15 and Bookmaker B has the away side at 2.10:

1/2.15 + 1/2.10 = 0.4651 + 0.4762 = 0.9413

That's a 5.87% edge before limits and fees. To lock it in you split your total stake in proportion to each side's implied probability:

def split_stakes(total, odds_a, odds_b):
    inv_a, inv_b = 1 / odds_a, 1 / odds_b
    s = inv_a + inv_b
    stake_a = total * inv_a / s
    stake_b = total * inv_b / s
    payout = stake_a * odds_a  # equal on both sides
    return round(stake_a, 2), round(stake_b, 2), round(payout - total, 2)

print(split_stakes(100, 2.15, 2.10))
# (49.41, 50.59, 6.24)  -> bet 49.41 on A, 50.59 on B, ~6.24 profit either way

The Easy Way: the /arbitrage-bets Endpoint

You could pull raw odds for every event and run that math across every bookmaker pair yourself. Odds-API.io also does it server-side. The /arbitrage-bets endpoint scans the books you specify and returns live opportunities with the optimal stakes already computed:

import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://api.odds-api.io/v3"

resp = requests.get(f"{BASE}/arbitrage-bets", params={
    "apiKey": API_KEY,
    "bookmakers": "Bet365,SingBet,Unibet",
    "includeEventDetails": "true",
    "limit": 50,
})
resp.raise_for_status()

for arb in resp.json():
    ev = arb.get("event", {})
    print(f"{ev.get('home')} vs {ev.get('away')} | {arb['market']['name']} "
          f"| margin {arb['profitMargin']:.2%}")
    for leg, stake in zip(arb["legs"], arb["optimalStakes"]):
        print(f"  {leg['side']:>4} @ {leg['odds']} on {leg['bookmaker']} "
              f"-> stake {stake['stake']}")

Each opportunity includes the legs, the bookmaker for each side, the profit margin, optimal stakes for a 100 unit bankroll (scale them linearly for your own), and direct links to the exact market at each bookmaker.

Verifying with Raw Odds

Before betting real money, re-check the prices yourself. Odds move, and the seconds between the scan and your bet matter. Pull the event's current odds with /odds:

def verify(event_id, bookmakers):
    resp = requests.get(f"{BASE}/odds", params={
        "apiKey": API_KEY,
        "eventId": event_id,
        "bookmakers": ",".join(bookmakers),
    })
    resp.raise_for_status()
    data = resp.json()
    for book, markets in data["bookmakers"].items():
        for market in markets:
            if market["name"] == "ML":
                print(book, market["odds"], "updated", market["updatedAt"])

verify(123456, ["Bet365", "SingBet"])

Recompute the margin from what you see. If it's gone above 1.0, the window closed. Walk away, the bot will find the next one.

Putting the Bot Together

Here's the full loop: poll, dedupe so you don't get alerted twice for the same opportunity, filter by minimum margin, and notify via Telegram.

import time
import requests

API_KEY = "YOUR_API_KEY"
BASE = "https://api.odds-api.io/v3"
BOOKMAKERS = "Bet365,SingBet,Unibet"
MIN_MARGIN = 0.01          # ignore arbs under 1%
BANKROLL = 200.0
POLL_SECONDS = 120         # 30 polls/hour, well inside the free tier

TG_TOKEN = "YOUR_TELEGRAM_BOT_TOKEN"
TG_CHAT = "YOUR_CHAT_ID"

seen = {}

def notify(text):
    requests.post(
        f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
        json={"chat_id": TG_CHAT, "text": text},
    )

def fetch_arbs():
    resp = requests.get(f"{BASE}/arbitrage-bets", params={
        "apiKey": API_KEY,
        "bookmakers": BOOKMAKERS,
        "includeEventDetails": "true",
    })
    if resp.status_code == 429:
        reset = resp.headers.get("x-ratelimit-reset", "")
        print("rate limited until", reset)
        time.sleep(300)
        return []
    resp.raise_for_status()
    return resp.json()

def run():
    while True:
        for arb in fetch_arbs():
            if arb["profitMargin"] < MIN_MARGIN or arb["id"] in seen:
                continue
            seen[arb["id"]] = time.time()
            ev = arb.get("event", {})
            scale = BANKROLL / arb["totalStake"]
            lines = [
                f"ARB {arb['profitMargin']:.2%} | "
                f"{ev.get('home')} vs {ev.get('away')} | {arb['market']['name']}"
            ]
            for leg, stake in zip(arb["legs"], arb["optimalStakes"]):
                lines.append(
                    f"{leg['side']}: {stake['stake'] * scale:.2f} "
                    f"@ {leg['odds']} ({leg['bookmaker']})"
                )
            notify("\n".join(lines))
        # drop dedupe entries older than an hour
        cutoff = time.time() - 3600
        for key in [k for k, t in seen.items() if t < cutoff]:
            del seen[key]
        time.sleep(POLL_SECONDS)

if __name__ == "__main__":
    run()

Run it with:

python arb_bot.py

Polling vs WebSocket

Polling every two minutes is fine for pre-match arbs, which often live for several minutes. For in-play arbitrage the windows close in seconds, and polling loses the race. Paid plans include the WebSocket feed (wss://api.odds-api.io/v3/ws), which pushes odds changes to you in roughly one second. The bot logic stays the same, you just recompute margins on each pushed update instead of on a timer.

Realistic Caveats

  • Rate limits. The free tier is 100 requests/hour, paid plans are 5,000/hour. Watch the x-ratelimit-remaining header and back off on a 429.
  • Latency kills marginal arbs. A 1% arb that took you 90 seconds to place is often a 0% arb. Verify prices immediately before betting.
  • Account restrictions are the real limiter. Soft bookmakers limit or close accounts that consistently take arb prices. Sharp books and exchanges tolerate winners; factor that into which books you scan.
  • Stake rounding. Betting 49.41 and 50.59 looks exactly like arbing. Many arbers round to natural amounts and accept slightly uneven profit.
  • One leg can be voided or rejected, leaving you exposed on the other. Have a plan (hedge at an exchange) before it happens.

Where to Go Next

Get a free API key, run the bot against real odds, and read the scanner tutorial for the deeper theory, more market types, and the full source of the scan-focused version. The two scripts share the same math and complement each other well.