SX BetSX BetBlog
Intermediatepython

How to Build a Sports Betting Bot in Python: Step-by-Step Guide

Build a sports betting bot in Python with SX Bet's free exchange API. Full code: fetch odds, calculate EV, sign and place orders, manage risk. Step-by-step.

20 minutes · 12 min read · April 14, 2026 · by Declan

How to Build a Sports Betting Bot in Python: Step-by-Step Guide

TL;DR This guide walks you through building a working sports betting bot in Python using the SX Bet exchange API. The read-only API needs no key — you can fetch live odds immediately. Write access (placing bets) requires EIP-712 wallet signing. The full loop: fetch markets → calculate expected value → place taker bet → manage risk. Use the testnet at api.toronto.sx.bet before going live. Read the full API reference at docs.sx.bet.


Every sports bettor eventually wonders whether a systematic, automated approach can outperform manual betting. The answer depends on edge — but the tools to build one have never been more accessible. The SX Bet betting exchange API in Python gives you a fully open read endpoint, a well-documented REST interface for order placement, and a testnet so you can rehearse execution before touching real funds.

This guide builds a functional betting bot skeleton end-to-end. By the end you'll have code that fetches active markets, reads live odds, calculates a simple EV-based signal, and places a taker bet with proper wallet signing and risk controls.


Architecture: How a Betting Bot Works

Before writing a line of code, understand the loop your bot runs:

1. FETCH — pull active markets and live odds from the exchange
2. SIGNAL — run your model or rule to identify edge
3. SIZE — calculate stake based on bankroll and edge
4. EXECUTE — place the order via API
5. MONITOR — track open positions, cancel stale orders, update state

Each step maps to one or more API calls. On SX Bet:

Step Endpoint
Fetch markets GET /markets/active?sportIds={id}
Fetch best odds GET /orders/odds/best
Read orderbook WebSocket (Centrifugo) or REST
Place taker bet POST /orders/fill/v2
Cancel orders DELETE /orders
Heartbeat Auto-cancel mechanism (prevents orphaned orders)

SX Bet is a peer-to-peer exchange — your bet fills against other users' limit orders, not against a house position. That matters for bot design: you need liquidity at your desired odds, not just a market line.


Prerequisites

Python packages:

pip install requests eth-account python-dotenv
  • requests — REST calls to the API
  • eth-account — EIP-712 signing for order placement
  • python-dotenv — keep your private key out of source code

What you need:

  • A crypto wallet (MetaMask or any EVM wallet)
  • USDC in that wallet for live trading (or use testnet with no real funds)
  • Your wallet's private key (treat this like a password — never commit to git)

Create a .env file:

PRIVATE_KEY=0xyour_private_key_here
WALLET_ADDRESS=0xyour_wallet_address

Step 1: Fetch Active Markets

The SX Bet read API requires no API key. You can call it right now from any machine.

import requests

BASE_URL = "https://api.sx.bet"
# Use testnet during development:
# BASE_URL = "https://api.toronto.sx.bet"

def get_active_markets(sport_id: int) -> list[dict]:
    """Fetch all active markets for a given sport."""
    response = requests.get(
        f"{BASE_URL}/markets/active",
        params={"sportIds": sport_id}
    )
    response.raise_for_status()
    data = response.json()
    return data.get("data", [])

# Sport IDs: 1=NFL, 2=NBA, 3=MLB, 4=NHL, 5=Soccer, 7=Tennis
# Check docs.sx.bet for the full sport ID list
markets = get_active_markets(sport_id=2)  # NBA

for market in markets[:3]:
    print(market["marketHash"], market["label"])

Each market has a marketHash (the unique identifier you'll use in all subsequent calls), a label describing the fixture and bet type, and metadata about the event.

Sport IDs to know:

  • NFL: 1 / NBA: 2 / MLB: 3 / NHL: 4 / Soccer: 5 / Tennis: 7

Step 2: Get Best Odds for a Market

Once you have a marketHash, fetch the best available odds:

def get_best_odds(market_hash: str) -> dict:
    """Get the best available odds for a market."""
    response = requests.get(
        f"{BASE_URL}/orders/odds/best",
        params={"marketHash": market_hash}
    )
    response.raise_for_status()
    return response.json().get("data", {})

odds_data = get_best_odds(markets[0]["marketHash"])
print(odds_data)

The response includes percentageOdds — SX Bet's internal odds format — alongside the American and decimal equivalents. The percentageOdds represents the implied probability as an integer (e.g., 5263 = 52.63% implied probability = roughly -111 American odds).

Converting percentageOdds to American odds:

def percentage_odds_to_american(pct_odds: int) -> float:
    """Convert SX Bet percentageOdds to American odds."""
    implied_prob = pct_odds / 10000  # e.g. 5263 → 0.5263
    if implied_prob >= 0.5:
        return round(-(implied_prob / (1 - implied_prob)) * 100, 1)
    else:
        return round(((1 - implied_prob) / implied_prob) * 100, 1)

# Example:
american = percentage_odds_to_american(5263)  # → -111.1

Step 3: Implement a Signal — Expected Value Calculation

This is where your edge lives. A simple EV-based signal compares your model's estimated probability against the implied probability in the market odds.

def calculate_ev(your_prob: float, pct_odds: int) -> float:
    """
    Calculate expected value per unit wagered.
    Returns positive number if bet has positive EV.

    your_prob: your model's estimated win probability (0-1)
    pct_odds: SX Bet's percentageOdds for the outcome you're backing
    """
    market_implied_prob = pct_odds / 10000
    # At 0% commission, the payout is exactly 1/market_implied_prob
    # (minus your stake)
    win_payout = (1 / market_implied_prob) - 1
    ev = (your_prob * win_payout) - ((1 - your_prob) * 1)
    return ev

# Example: you estimate Lakers win probability at 55%
# Market shows 52.63% implied (American -111)
ev = calculate_ev(your_prob=0.55, pct_odds=5263)
print(f"EV per unit: {ev:.4f}")  # Positive = bet has edge

Key advantage: SX Bet charges 0% commission on straight bets. That means the full payout accrues to your EV calculation — no vig drag eating into edge the way traditional sportsbooks do.

Your "model" can be as simple or complex as you build. A basic version might use:

  • Your own team ratings or Elo scores
  • A regression model trained on historical data
  • Sharp line movement as a signal

For this tutorial, we'll use a threshold rule: bet if EV > 0.03 (3%).


Step 4: Size Your Bet — Kelly Criterion

Never bet a fixed amount regardless of edge. The Kelly Criterion calculates the optimal fraction of your bankroll:

def kelly_stake(bankroll: float, your_prob: float, pct_odds: int,
                fraction: float = 0.25) -> float:
    """
    Calculate Kelly-optimal stake.
    Uses fractional Kelly (default: 25%) to reduce variance.

    Returns stake in USDC.
    """
    market_implied_prob = pct_odds / 10000
    decimal_odds = 1 / market_implied_prob  # gross odds
    b = decimal_odds - 1  # net odds (profit per unit staked)
    p = your_prob
    q = 1 - p

    kelly = (b * p - q) / b
    stake = bankroll * kelly * fraction
    return max(0.0, stake)

# Example: $1000 bankroll, 55% estimated prob, -111 market
stake = kelly_stake(
    bankroll=1000.0,
    your_prob=0.55,
    pct_odds=5263,
    fraction=0.25  # quarter-Kelly for safety
)
print(f"Recommended stake: ${stake:.2f} USDC")

The fraction=0.25 (quarter-Kelly) is a common choice to reduce variance. Full Kelly is mathematically optimal but practically aggressive.


Step 5: Place a Taker Bet with EIP-712 Signing

This is the step that separates reading data from executing trades. SX Bet uses EIP-712 typed data signing to authorise orders — your private key signs the bet parameters, proving you own the wallet without sending it to the server.

import os
import time
import random
import json
from eth_account import Account
from eth_account._utils.structured_data.hashing import hash_domain, hash_message
from dotenv import load_dotenv

load_dotenv()

PRIVATE_KEY = os.getenv("PRIVATE_KEY")
WALLET_ADDRESS = os.getenv("WALLET_ADDRESS")

def place_taker_bet(
    market_hash: str,
    bet_size_usdc: float,
    pct_odds: int,
    betting_outcome_one: bool  # True = back outcome 1
) -> dict:
    """
    Place a taker (market) bet on SX Bet.
    Fills against existing maker orders at or better than pct_odds.
    """

    # Convert USDC amount to smallest unit (6 decimals)
    # 1 USDC = 1_000_000 units
    bet_size_units = int(bet_size_usdc * 1_000_000)

    # EIP-712 domain for SX Bet (mainnet)
    domain = {
        "name": "SX Bet",
        "version": "1.0",
        "chainId": 137,  # Polygon mainnet
        "verifyingContract": "0x..."  # Check docs.sx.bet for current contract address
    }

    # Order parameters
    order_params = {
        "marketHash": market_hash,
        "baseToken": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",  # USDC on Polygon
        "totalBetSize": str(bet_size_units),
        "percentageOdds": str(pct_odds),
        "expiry": int(time.time()) + 3600,  # 1 hour from now
        "salt": str(random.randint(1, 2**256 - 1)),
        "maker": WALLET_ADDRESS,
        "executor": "0x...",  # SX Bet executor address — see docs.sx.bet
        "isMakerBettingOutcomeOne": betting_outcome_one
    }

    # Sign with EIP-712 — see docs.sx.bet for full typed data structure
    # eth_account handles the encoding and signing
    account = Account.from_key(PRIVATE_KEY)

    # Build the signed order payload (see docs.sx.bet/api-reference for full schema)
    payload = {
        **order_params,
        "signature": "0x...",  # Your EIP-712 signature
        "apiKey": None  # Not required for read; check docs for write endpoint
    }

    response = requests.post(
        f"{BASE_URL}/orders/fill/v2",
        json=payload,
        headers={"Content-Type": "application/json"}
    )
    response.raise_for_status()
    return response.json()

Important: The exact EIP-712 type definitions, contract addresses, and executor addresses are documented at docs.sx.bet/api-reference. Always use the testnet (api.toronto.sx.bet, chainId 80001) to validate your signing code before going live.


Step 6: Risk Controls

A betting bot without risk controls will blow up your bankroll. Implement these before going live:

Maximum single-bet exposure

MAX_BET_USDC = 50.0   # Never bet more than this on a single market
MIN_BET_USDC = 1.0    # Ignore tiny Kelly allocations
MAX_OPEN_BETS = 10    # Limit simultaneous open positions

def apply_limits(stake: float) -> float:
    """Clip stake to acceptable range."""
    return min(MAX_BET_USDC, max(MIN_BET_USDC, stake))

Order heartbeat (auto-cancel on disconnect)

SX Bet's API includes a heartbeat mechanism that automatically cancels your maker orders if your bot loses connectivity — preventing stale orders from filling against your wallet when you're offline. This is critical for market-making bots (Article 1.3) but good practice for any automated system.

def send_heartbeat(session: requests.Session) -> None:
    """Keep the bot's session alive and orders protected."""
    response = session.post(f"{BASE_URL}/orders/heartbeat")
    response.raise_for_status()

Call this every 30–60 seconds in your main loop.

Cancel all orders on shutdown

import signal
import sys

def cancel_all_orders():
    """Emergency cancel — run on keyboard interrupt or error."""
    response = requests.delete(
        f"{BASE_URL}/orders",
        headers={"Content-Type": "application/json"},
        json={"maker": WALLET_ADDRESS}
    )
    print(f"Cancellation status: {response.status_code}")

def handle_shutdown(sig, frame):
    print("Shutting down — cancelling all orders...")
    cancel_all_orders()
    sys.exit(0)

signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)

Step 7: The Main Loop

Putting it all together:

import time

def run_bot(
    bankroll: float,
    sport_id: int,
    ev_threshold: float = 0.03,
    poll_interval_seconds: int = 30
):
    """
    Main betting bot loop.

    bankroll: current USDC balance available for betting
    sport_id: which sport to monitor
    ev_threshold: minimum EV per unit to place a bet
    poll_interval_seconds: how often to refresh markets and odds
    """
    print(f"Starting bot. Bankroll: {bankroll} USDC. Sport: {sport_id}")
    print("Using TESTNET — no real funds at risk" if "toronto" in BASE_URL else "LIVE MODE")

    while True:
        try:
            # 1. Fetch markets
            markets = get_active_markets(sport_id)

            for market in markets:
                market_hash = market["marketHash"]

                # 2. Get best odds
                odds_data = get_best_odds(market_hash)
                if not odds_data:
                    continue

                pct_odds = odds_data.get("percentageOddsOutcomeOne")
                if not pct_odds:
                    continue

                # 3. Run your signal — replace with your actual model
                your_prob = your_model_estimate(market)  # implement this!
                ev = calculate_ev(your_prob, pct_odds)

                # 4. Place bet if edge found
                if ev > ev_threshold:
                    raw_stake = kelly_stake(bankroll, your_prob, pct_odds)
                    stake = apply_limits(raw_stake)

                    print(f"Edge found: {market['label']}")
                    print(f"  EV: {ev:.4f}, Stake: {stake:.2f} USDC")

                    result = place_taker_bet(
                        market_hash=market_hash,
                        bet_size_usdc=stake,
                        pct_odds=pct_odds,
                        betting_outcome_one=True
                    )
                    print(f"  Order result: {result}")

            # Wait before next poll
            time.sleep(poll_interval_seconds)

        except KeyboardInterrupt:
            handle_shutdown(None, None)
        except Exception as e:
            print(f"Error: {e}")
            time.sleep(5)  # Brief pause before retrying

# Start the bot
# run_bot(bankroll=500.0, sport_id=2)  # Uncomment to run

Running on Testnet First

Before betting real USDC, run every piece of your bot against the testnet:

# Switch base URL to testnet
BASE_URL = "https://api.toronto.sx.bet"

# Testnet runs on Polygon Mumbai (chainId: 80001)
# No real funds required — you can get testnet USDC for free
# See docs.sx.bet/developers/quickstart for testnet setup

Run your bot for at least several hours on testnet, checking:

  • Does order signing produce valid signatures?
  • Do fills execute at the expected odds?
  • Does the heartbeat keep orders alive correctly?
  • Does the emergency cancel clean up properly on shutdown?

What to Build Next

This bot skeleton handles the core loop. From here you can extend in several directions:

Extension Article
Market making — post limit orders and earn Maker Rewards Betting Exchange Market Making Bot
Real-time data — WebSocket instead of REST polling Real-Time Sports Odds via WebSocket
Arbitrage — guaranteed profit between books Sports Betting Arbitrage Bot in Python
Full API reference Sports Betting Exchange API: Developer Guide

Frequently Asked Questions

Q: Do I need an API key to fetch sports odds from SX Bet? A: No. The SX Bet read API — markets, odds, orderbook — is fully public and requires no key or registration. Write access (placing orders) requires EIP-712 signing with your wallet's private key.

Q: Is it legal to run a betting bot? A: On betting exchanges like SX Bet, automated trading is explicitly supported — the API exists precisely for this use case. SX Bet does not limit or ban winning accounts, including systematic/algorithmic ones. For general legal questions about your jurisdiction, consult local regulations.

Q: What are the fees on SX Bet for bot trading? A: 0% commission on straight bets. 5% commission on parlay winnings only. This makes SX Bet structurally better for bot trading than sportsbooks that build vig into every line.

Q: What's the difference between a taker bet and a maker order? A: A taker bet (POST /orders/fill/v2) fills immediately against existing orders at the best available price. A maker order (POST /orders/new) posts a limit order to the orderbook, waiting for another user to fill it. Makers can also earn USDC rewards through SX Bet's Maker Rewards program.

Q: How does EIP-712 signing work? A: EIP-712 is an Ethereum standard for signing structured data. Your bot signs the order parameters (market, odds, size, expiry) with your private key locally — the signature proves you authorised the bet without exposing your key to the server. The eth-account Python library handles the encoding. Full type definitions are at docs.sx.bet.


Ready to Start Building?

The full API reference, quickstart guide, and schema definitions are at docs.sx.bet.

For Claude Code and Cursor users, add the SX Bet MCP server to get AI-assisted API development:

claude mcp add --transport http sx-bet https://docs.sx.bet/mcp

Or install the skill:

npx skills add https://docs.sx.bet --yes

Explore the full SX Bet API reference →

Build on SX Bet's Open API

No API key required. Fetch live odds, markets, and orderbook data with a single HTTP call.