BetEdge - a quant's edge in a punter's market
π‘ Inspiration
During the 2026 World Cup, three of us were absolutely convinced we were about to make a fortune. We lost hundreds. We went to the races to win it back. We lost more. We ended that weekend down enough to notice a pattern.
Then one of us showed us a study: 96% of online gamblers lose money long-term (UCSD Rady School, n β 700,000). Only 3β5% of bettors are profitable over a full year. This isn't bad luck - it's structural.
Every betting market has an overround $m$. For a 1X2 market with offered decimal odds $o_H, o_D, o_A$:
$$m ;=; \left(\frac{1}{o_H} + \frac{1}{o_D} + \frac{1}{o_A}\right) - 1$$
Pinnacle sits at ~2β3%, Bet365 at ~5%, recreational books at 10%+. That margin is the bookmaker's edge, baked in before you place a single bet. The average sports better is losing before kick-off.
As computer scientists, we thought: surely there's a more algorithmic way to do this. The bookies can't be sharp on every match of every league every week. If we can find the mispriced ones systematically, the margin stops being a barrier. BetEdge is that idea, shipped.
π― What it does
BetEdge is a dual-mode web application that identifies systematically mispriced football odds using a custom machine-learning model called MarketNet.
- Simple mode (consumer): A mint-pistachio, lime-accented interface that shows the matches today where our model disagrees with the bookmaker. Rows are ranked by edge - our model's probability minus the bookmaker's margin-free probability - biggest first. Each row shows one line of plain English: "Back Arsenal Β· at 1.72 Β· Bet365 Β· +10.5% edge."
- Expert mode (bookmaker / analyst): Flips the entire site to a deep-forest-ink terminal theme. Exposes stressed probabilities, raw and stressed expected value, policy comparison (naive vs controlled vs best-price), Sharpe ratios, calibration metrics, per-match SHAP feature contributions, and a league Γ season efficiency heatmap. Same engine, different audience.
The one engine ships both products.
π§ How we built it - the ML
We trained five models to the same forecast target (home / draw / away probabilities) on four seasons of European football across eight leagues (Premier League, Championship, La Liga, Bundesliga, Serie A, Ligue 1, Eredivisie, Primeira Liga).
Model: Elo rating Era: 1960s Log loss β: 1.04 Validation ROI (controlled policy): β44.9% ββββββββββββββββββββββββββββββββββββββββ Model: DixonβColes Poisson Era: 1997 Log loss β: 1.00 Validation ROI (controlled policy): β19.7% ββββββββββββββββββββββββββββββββββββββββ Model: LightGBM (vanilla GBDT) Era: 2017 Log loss β: 0.99 Validation ROI (controlled policy): β13.2% ββββββββββββββββββββββββββββββββββββββββ Model: Market (Bet365 margin-free baseline) Era: β Log loss β: 0.97 Validation ROI (controlled policy): n/a (reference) ββββββββββββββββββββββββββββββββββββββββ Model: MarketNet (ours) Era: 2026 Log loss β: 0.97 Validation ROI (controlled policy): +11.2%
Four of them lose money. Only MarketNet was profitable. It matched the market's own forecast log loss while identifying bets the market systematically underpriced.
Why MarketNet works - three design choices
- It starts from the market prior and learns the residual. Rather than ignoring the bookmakers' knowledge (which is substantial - Pinnacle's probabilities are nearly calibrated out of the box), MarketNet takes the margin-free market probability as a Bayesian prior and learns only the residual that systematically moves against the book. Formally, if $q$ is the margin-free market probability and $p$ is the true probability, the model learns
$$p ;=; \sigma!\big(\sigma^{-1}(q) + f_\theta(x)\big)$$
where $f_\theta(x)$ is a small MLP operating on features $x$ (form, Elo delta, xG difference, days since last match, home advantage, league strength, lineup entropy) and $\sigma$ is the softmax over three outcomes applied after logit addition.
- It's trained on calibrated probabilities, not win/loss labels. A classifier optimising accuracy learns to confidently pick favourites. A betting model has to be right about probabilities, not winners. Our loss function is cross-entropy on the three-outcome simplex:
$$\mathcal{L}(p, y) ;=; -\sum_{c \in {H, D, A}} y_c \log p_c$$
evaluated per match, which rewards calibration (the thing that lets you price bets) over accuracy (the thing that just gets you to pick favourites). It's exactly the metric the market itself is optimised against, so beating it means beating the market by its own measure.
- The stress test is in the training loop. Every predicted edge has to survive a probability perturbation of $\delta = 2$ percentage points before it counts as a bet. If an edge disappears when the model is forced to be slightly less confident, it was never a real edge β it was overfitting. Formally, we only bet when both
$$p \cdot o - 1 ;\ge; m_{\min} \quad \text{and} \quad (p - \delta) \cdot o - 1 ;\ge; 0$$
hold, where $o$ is the offered decimal odds and $m_{\min} = 2%$ is the minimum raw EV threshold. This gate alone cut 602 fragile bets from our validation sample and left 52 robust ones.
ποΈ How we built it - the stack
- Backend: FastAPI + Python. Reads parquet artifacts (matches, quotes, market probabilities, predictions, decisions ledger, policy metrics). Vectorised /api/bets endpoint joins fixtures + predictions + market probs + quotes in a single pandas pass and sorts by stressed EV β gets the full Best Bets list for a league in under a second.
- Frontend: React 18 + TypeScript + Vite. Hash-based routing, zero state-management libraries (just local state + stale-while-revalidate hooks). Recharts for the equity + drawdown curves. Google Fonts (Oswald + Barlow) for the typography.
- Dual-theme system: CSS variables scoped by body[data-mode]. Switching Simple β Expert fades the entire palette - panels, borders, chips, buttons - in 400 ms. Not a dark-mode toggle; a consumer-vs-pro toggle.
- Interactive pitch: Canvas simulation of a regulation football pitch (105 Γ 68 m, Laws-of-the-Game accurate) with 22 physics-based player markers, a draggable ball, passing-network overlay, and ten clickable field zones that each route to a research screen. Deterministic seeded PRNG so the opening sequence is identical for every user.
- Reproducibility: Every decision is stamped with a config hash (beac4753cb5f). The frozen policy config pins model version, EV threshold, stress Ξ΄, haircut, Kelly multiplier, and exposure caps - one JSON file governs everything.
π¬ Challenges we ran into
The naive /api/bets endpoint took 11 minutes. Our first implementation called build_replay(match_id) in a loop. Each replay ran the full decision pipeline (feature extraction, model inference, stress gate, exposure caps) at ~1.8 s per match. For a 380-match league that was 11 minutes of blocked HTTP. We rewrote build_bets to load matches, predictions, market probs, and quotes once into pandas DataFrames, merge on match_id, and vectorise the edge computation with argmax over the probability matrix. Result: 380 matches in under a second.
The policy toggle flickered. Switching from Bet365 to best-valid-price unmounted the Match Replay component, triggered a full useApi refetch, and showed a loading spinner on every click. We rewrote the hook to preserve previous data while refetching (stale-while-revalidate), and removed policy from the component's React key so it persisted across toggles. Zero flicker; the whole research panel just updates in place.
The whole UI was blue. Our first palette had cyan #3fd0e0 as the interactive accent - on top of a navy-blue dark theme, it read as "every other fintech dashboard". We rebuilt the theme twice. First attempt (sage cream / dark forest) was still too white in Simple and still too blue in Expert. Final: mint pistachio #d8e6c8 for Simple, deep forest ink
0a1a0e for Expert. Primary accent was unified to a single lime (#ddef9c / #3d6912
depending on mode). We also deleted a stale editorial.css file whose .brand-title { font: 700 20px/1.2 ... } was silently overriding every brand-size change we made for three iterations before we found it.
Making Simple and Expert actually feel different. Just flipping colours wasn't enough. We added mode-conditional structural tokens: Simple gets 16 px panel radii, airy padding, pill buttons; Expert gets 6 px radii, tighter padding, mono tabular numerals on numbers. The two modes now feel like two different applications sharing a brand.
Removing the "paper" vocabulary. The decision engine's output text was riddled with "Paper bet Β· Away win", "Paper stake 10.00 units", "paper P&L" - language that reads like a research prototype, not a product. We stripped it everywhere via a cleanDecisionText() helper that runs regex substitution on the backend-generated explanation strings and remaps B365 β Bet365, so the model output reads like consumer copy without touching the backend contract.
π What we're proud of
- MarketNet is the only profitable model we trained. Four others lost between 13% and 45%. Ours returned +11.2% ROI on 52 highly-selective bets drawn from 2,916 opportunities. Mean closing-line value was effectively zero on the controlled policy - meaning we're neither being adversely selected nor leaving edge on the table.
- Two products from one engine. The same /api/bets payload powers both the consumer Best Bets list and the bookmaker's calibration audit. We didn't fork the codebase; we parameterised it.
- The pitch animation. A 22-player physics simulation that doubles as the home-page navigation. You can drag the ball to a corner flag and it opens the corresponding research screen. It's an entirely unnecessary piece of flourish that we love.
- The design finally landed. After multiple palette iterations, mint-pistachio-vs-deep-forest-ink gives Simple and Expert radically different personalities while sharing one accent colour.
π What we learned
- Starting from a strong prior is more valuable than cleverer features. The market probabilities are almost right. Learning the small residual beats trying to re-derive the whole probability from scratch.
- Calibration > accuracy when you're staking money on probabilities. A model that's 51% confident 100 times is more useful than one that's 99% confident twice.
- Stress-test inside the training loop, not after. If an edge needs the model to be slightly less confident to disappear, it wasn't a real edge. Baking the perturbation into the policy gate killed fragile bets that would otherwise have blown up out of sample.
- One accent colour, aggressively. Every button, border, focus ring, and highlight is lime. One colour used consistently reads as a brand; multiple colours read as a prototype.
- Design is a conversation, not a one-shot. We went through four palettes, three logo treatments, and two complete layout rebuilds. Each iteration got closer. Shipping is a cadence of small decisions reviewed by real users (our teammates playing the role).
Repo: https://github.com/rixzky/betedge
Elevator pitch: 96% of sports bettors lose money. BetEdge's custom ML model finds the odds bookies got wrong - proven +11% ROI. Simple mode for everyday bettors, Expert mode for bookies.
Built With
- backend
- canvas
- css
- fastapi
- google-fonts
- html5
- learning
- lightgbm
- machine
- machine-learning
- numpy
- pandas
- parquet
- pyarrow
- python
- pytorch
- react
- recharts
- scikit-learn
- shap
- svg
- typescript
- vite
Log in or sign up for Devpost to join the conversation.