From Notebook to Production: Shipping Your First Sports Trading Bot
You have a Jupyter notebook that produces calibrated win probabilities. You have backtested a strategy that looks profitable. The next step is the hard part: turning the notebook into a service that runs unattended, places real orders against a real market, recovers from crashes, and surfaces the right alerts when something breaks. This post is the practical playbook for that transition.
The model in your notebook is maybe 20% of the work. The other 80% is what we cover here.
What changes between notebook and production
A notebook lives inside one Python process. State is in memory. If something goes wrong, you re-run the cell. The model is loaded once and runs against a static dataset. There are no concurrent users, no failures, no clocks.
A production trading bot lives in a long-running process under a supervisor. State must persist across restarts. Failures must be handled gracefully. Concurrent events arrive on the network and must be processed in the right order. A clock advances continuously and the bot must keep up.
Each of those differences requires explicit engineering work that the notebook lets you skip.
Step 1: Extract from the notebook
Move the modeling code out of the notebook into a real Python module. Save the trained model to a pickle file. Replace the notebook's data loading with a function that pulls live data instead of static CSVs.
# notebook/research_v3.ipynb → bot/model.py
import pickle
from sklearn.isotonic import IsotonicRegression
from xgboost import XGBClassifier
class ProductionModel:
def __init__(self, model_path: str):
with open(model_path, "rb") as f:
self.clf, self.iso = pickle.load(f)
def predict(self, features) -> float:
raw = self.clf.predict_proba([features])[:, 1][0]
return float(self.iso.transform([raw])[0])
# Save once after training:
# with open("model.pkl", "wb") as f:
# pickle.dump((clf, iso), f)
The pickle file becomes a versioned artifact. Always backup before retraining (we use a timestamped backups/ directory). One corrupted pickle from an interrupted training job can take you offline for hours.
Step 2: Build the live data ingestion
Notebooks read CSVs. Production reads live feeds. For sports, that usually means polling ESPN's JSON endpoints (or an official league API) on a sport-appropriate cadence and maintaining an in-memory cache.
import asyncio, time
from typing import Optional
class LiveStateCache:
def __init__(self):
self._state = {} # game_id -> dict
self._ts = {} # game_id -> epoch
def update(self, game_id: str, state: dict):
self._state[game_id] = state
self._ts[game_id] = time.time()
def get(self, game_id: str, max_age_s: float = 30.0) -> Optional[dict]:
if game_id not in self._state:
return None
if time.time() - self._ts[game_id] > max_age_s:
return None # Stale
return self._state[game_id]
CACHE = LiveStateCache()
async def poll_loop(sport: str, cadence: float):
while True:
try:
data = fetch_scoreboard(sport)
for ev in data.get("events", []):
CACHE.update(ev["id"], parse_event(ev))
except Exception as e:
logger.exception(f"Poll failed for {sport}: {e}")
await asyncio.sleep(cadence)
The poll loop runs as a background task. The bot's main loop reads from the cache. The two are decoupled so a slow upstream does not block the bot.
Step 3: Persist your trade log
A bot that loses its trade history on restart is dangerous. It might re-buy positions it already holds. It might miss settlements it should have processed. The fix is an append-only trade log that survives restarts.
import json
from pathlib import Path
from datetime import datetime, timezone
TRADE_LOG = Path("trades.jsonl")
def log_trade(game_id: str, side: str, size: float, price_c: float, fair_prob: float):
rec = {
"ts": datetime.now(timezone.utc).isoformat(),
"game_id": game_id,
"side": side,
"size": size,
"price_c": price_c,
"fair_prob": fair_prob,
"resolved": None,
}
TRADE_LOG.open("a").write(json.dumps(rec) + "\n")
def load_open_positions() -> list[dict]:
if not TRADE_LOG.exists():
return []
open_pos = []
for line in TRADE_LOG.open("r"):
rec = json.loads(line)
if rec.get("resolved") is None:
open_pos.append(rec)
return open_pos
JSONL is the right format. Each trade is one line. Append is atomic on most filesystems. You can tail -f the file to watch live activity. You can read it with pandas for analysis.
Step 4: Reconstruct state on startup
When the bot restarts, it reads the trade log and rebuilds its in-memory position map. Without this step, the bot wakes up thinking it has zero positions, even when there are several open trades on Polymarket.
def restore_positions():
positions = {}
for trade in load_open_positions():
gid = trade["game_id"]
positions.setdefault(gid, []).append(trade)
return positions
# In bot startup
positions = restore_positions()
logger.info(f"Restored {sum(len(v) for v in positions.values())} open positions across {len(positions)} games")
Step 5: Add a supervisor
A bot that crashes and stays down is worse than no bot. Run it under systemd (Linux) or launchd (macOS) with auto-restart. Systemd unit:
[Unit]
Description=My Sports Trading Bot
After=network-online.target
[Service]
Type=simple
User=mybot
WorkingDirectory=/opt/mybot
ExecStart=/opt/mybot/venv/bin/python -m bot.main
Restart=always
RestartSec=15
StandardOutput=append:/var/log/mybot/bot.log
StandardError=append:/var/log/mybot/bot.log
[Install]
WantedBy=multi-user.target
Restart=always with RestartSec=15 prevents tight crash loops while ensuring the bot recovers from transient errors. Logs go to a file you can tail -f.
Step 6: Build monitoring
The most common production failure mode is silent. The bot is "running" but not actually trading. Three things to monitor:
- Heartbeat: the bot writes a timestamp to a file every minute. An external uptime check (Healthchecks.io, BetterStack) pings the file's mtime and alerts if it's stale.
- Trade rate: count trades per hour. If it drops to zero on a busy game night, something is broken.
- Closing Line Value: compute daily. If it trends below your threshold, the model is degrading.
import time, json
from pathlib import Path
HEARTBEAT = Path("heartbeat.json")
def write_heartbeat():
HEARTBEAT.write_text(json.dumps({
"ts": time.time(),
"iso": datetime.now(timezone.utc).isoformat(),
}))
# In bot main loop, every minute
write_heartbeat()
Step 7: Deployment workflow
You will iterate on the bot. Define a deploy workflow that is fast and safe:
- Edit code locally.
- Run unit tests.
- rsync changes to the server (excluding stateful files like trade logs).
- Run a remote import smoke test (catches syntax errors).
- systemctl restart the service.
- curl the health endpoint to verify it came back up.
Wrap this in a shell script. Run it 50 times a day during early development. Once you have confidence in the workflow, the friction of deploying drops to near-zero.
Step 8: Risk controls
Before live trading, add hard limits:
- Max position size per trade. Prevents a buggy quarter-Kelly calculation from sizing 50% of bankroll.
- Max total exposure. Prevents the bot from holding more positions than your bankroll supports.
- Daily loss limit. Hard stop if session P&L drops below threshold.
- Per-sport circuit breaker. Disable a sport when its rolling 30-day ROI drops below -5%.
These controls feel paranoid until the day they save you. Add them all before going live, not after the first incident.
What separates the bots that survive
Three patterns across the bots that have run in production for many months without intervention:
They restart cleanly. State persists. The bot can be killed and brought back at any moment without losing track of positions or producing duplicate trades.
They monitor themselves. Heartbeats, trade-rate alerts, and CLV monitoring catch the silent failure modes that watching P&L would miss.
They fail loud, not silent. Every exception logs. Every uncovered edge case raises an alert. The cost of fixing one bug today is much lower than the cost of debugging six months of compounded weird behavior.
The bottom line
Shipping a sports trading bot is mostly engineering discipline, not modeling. The modeling work in your notebook is necessary. The persistence, monitoring, supervision, and deployment work is what makes it actually run unattended for months. Build the eight steps above before going live. The compound returns start when the entire stack is in place — not when any single piece is perfect.
The full Polymarket Bot Course
Six Jupyter modules covering everything from ESPN scraping to live deployment, including the production patterns in this post. $49 standalone or included with every ZenHodl API plan.
Get the course