Case studySolo projectApr to Sep 2026
Tick to Trade
A Dota 2 model that bets real money on Polymarket.
I built a bot that watches pro Dota 2 live and bets on who wins.
- Role
- Solo, end to end
- Timeline
- April to September 2026
- Team
- Just me, plus AI helpers
- Status
- Traded live, then I stopped it on purpose. Findings kept.
- Python 3.13
- asyncio
- FastAPI
- XGBoost
- pandas
- Socket.IO
- OpenCV + ffmpeg
- Polymarket trading API
- Next.js 16
- React 19
- TypeScript
- pytest
- file-locked JSON stores
Overview
It watches a match and trades the odds
Up to eight live data streams go in. Every minute one win chance comes out, and real money follows it. Just me, six months.
Watch
every match
Guess
every minute
Trade
real money
The problem
Eight data sources, and none were both fast and right
The best data is slow. It refreshes every 25 to 30 seconds, and at big tournaments it ran 15 minutes behind the game.
One blank data point once flipped my win chance from 36% to 61%.
The build
102 code updates, all mine
I built every layer. AI helpers wrote code, then others tried to break it.
Code updates per month. April was notes only.
- APR0 code updates
- Notes beginNotes start on 21 April. The first model aims for fair odds above all.
- MAY3 code updates
- Code starts 18 MayThe code project starts on 18 May. Training and live data get matched up, and the hero list shrinks from 42 to 29.
- JUN23 code updates
- Stuck-sell bug fixedA sale went through but my records missed it, so every later sale failed. Now the exchange's own balance is the truth.
- JUL0 code updates
- 1 of 10 ideas passed reviewA review found 1 of 10 experiments worth keeping. A 100-clue model was pulled after two clues used the wrong units live.
- AUG75 code updates
- Training stops peeking aheadI stopped the model peeking one minute ahead, and now test it only on newer matches.
- Wrong-side sell fixedA sale that timed out once bet on the wrong team. Fixed. The Twitch video reader also joins, as an optional extra.
- SEP1 code updates
- 15-min delay foundValve's 900-second delay is found to be behind the bad September data. A price-posting idea is dropped: none of 480 variants made money.
The system
One program does everything
Data in, order out. Every step is logged so I can replay it.
- Live feeds8 sourcesUp to eight live sources, some fast and some slow.
- Mergeone clean rowAll sources are combined into one clean snapshot, with checks against bad data.
- Modelwin chanceThe model turns that row into a win chance.
- Bet sizehow muchHow much to bet. It only ever shrinks when the data looks shaky.
- Orderwait or takeWait in line for a better price, or take the price on offer now.
- Polymarketthe exchangeThe prediction market where the orders go.
- Trade confirmedexchange says soA trade only counts once the exchange confirms it.
- Saved recordprotected filesConfirmed trades go into protected files so two parts cannot record the same one.
sizes the next bet
Every step is loggedDashboardEvery prediction, decision and trade is appended to a log. The dashboard reads those same logs live.
The feeds
Fast feeds fix the slow one
Faster sources patch the slow data, each with safety checks. If the main data looks wrong, a backup source and a smaller backup model take over.
- CyberScore · game clock
- hawk.live · towers
- esport.vision · kills
- Twitch video · kills
- Steam · towers
The model
Training and live must see the same data
I taught the model with 8,450 pro matches, one snapshot per game minute. Live snapshots are built the exact same way.
Training
- 8,450 matchespro gamesTop pro games from OpenDota, with full match history from Stratz.
- One row a minute334,090 rowsA snapshot for every game minute, each one copied with the teams swapped.
- Train model300 small trees300 tiny yes-or-no flowcharts, each only 3 questions deep.
- Clue listexact input orderThe exact order of the model's clues. Live snapshots are lined up to it.
Live
- Live feeds8 sourcesLive data from DLTV, 1xBet and the quick feeds.
- Build rowsame recipeBuilds the row the same way training did.
- Startup checkmain model onlyRefuses to start if any clue is missing. It covers the main model only.
- Win chanceevery game minuteOne new win chance for each game minute.
The backup model has no such check.
The old model peeked one minute ahead. Fixing that made my scores look worse. I kept the fix anyway.
Mistakes, lower is better
Over-confidence, lower is better
Backup model share: 94 to 97% in September.
on the backup model
The orders
From win chance to filled order
I compare my win chance with the real price. Bets shrink on shaky data.
- Model's chancewho winsWhat my model thinks each team's chance is.Market pricewhat I can getThe price I could really buy or sell at: the seller's price to buy, the buyer's price to sell.
- The gapchance vs priceThe difference between the two. A trade needs a big enough gap.
- Bet sizeshrinks if shakyScaled down for early game, the backup model and shaky data.
- Wait in linecheap, no feeWaits in the queue at a better price and pays no fee.Take nowfast, pays a feeTrades right away for a fee. Used for big late-game gaps.Sell out15 s, then sells anywayTries a patient sale first, then sells whatever is left after 15 seconds.
- PolymarketPolymarket, where the orders go.
- Trade confirmedexchange says soNothing counts until the exchange confirms the fill.
- Saved recordprotected filesThe only place holdings change, and only once a trade is confirmed.
September limit on oversized extra buys is built, not switched on
The control room
My dashboard
Ten pages let me watch it, pause it or stop it. New rules run quietly first and go live only when the records show they help.

Features
Every clue, with its source
Every clue the model uses shows where it came from and how old it is.

Structures
A hand on the wheel
When the data goes dark, I can mark a tower as destroyed by hand.
Numbers
The numbers, with caveats
8
live feeds
Seven companies, one with two streams.
334,090
training snapshots
From 8,450 pro matches.
0.66 → 0.89
winner-picking accuracy score (0 to 1)
Early to late game. From older tests, so possibly too rosy.
14.8min
hidden delay found
Typical data delay across all 59 September matches.
3,118
self-checks that catch bugs
789 of them check the data sources.
86
days it ran
Late April to late September, some without real money.
Twitch clock reader, correct readings
Before and after the fix.
Model vs market
One match, me vs the market
Over 70 minutes the lines mostly agree. When they split, the market caught up fast.
LGD Gaming (Radiant) vs Team Resilience · 13 Aug 2026
00:00 to 12:00
The lines move together
Both start above half. From minute 5 they slide down together.
16:56
The lines split
My model says 22%. The market says 45%. A gap of 23 points.
25:56
A 33-point gap
My model says 24.3%, the market 57.5%. By minute 30 the lines meet again.
Minute 70
Radiant won
The model ends at 98%, the market at 100%. 10 of the 198 ticks came from the backup data.
Table: every tick of the match
| Minute | Model | Market | Gap (pts) | Source |
|---|---|---|---|---|
| 00:00 | 73% | 60% | +14 | main |
| 00:53 | 82% | 63% | +19 | main |
| 01:37 | 72% | 63% | +9 | main |
| 02:01 | 73% | 63% | +10 | main |
| 02:25 | 69% | 69% | 0 | main |
| 02:51 | 84% | 67% | +17 | main |
| 03:13 | 80% | 71% | +9 | main |
| 04:25 | 80% | 74% | +6 | main |
| 04:48 | 75% | 75% | +1 | main |
| 05:11 | 74% | 80% | −6 | main |
| 06:26 | 70% | 70% | 0 | main |
| 06:50 | 63% | 66% | −3 | main |
| 07:37 | 58% | 65% | −6 | main |
| 08:26 | 52% | 61% | −8 | main |
| 08:50 | 50% | 56% | −5 | main |
| 09:13 | 53% | 66% | −12 | main |
| 10:00 | 49% | 60% | −11 | main |
| 10:24 | 43% | 56% | −12 | main |
| 11:13 | 46% | 55% | −8 | main |
| 11:40 | 43% | 60% | −16 | main |
| 12:05 | 53% | 63% | −10 | main |
| 13:00 | 64% | 70% | −6 | main |
| 13:25 | 70% | 70% | +1 | main |
| 14:14 | 61% | 66% | −4 | main |
| 15:00 | 40% | 48% | −8 | main |
| 15:05 | 40% | 50% | −10 | main |
| 15:24 | 35% | 51% | −16 | main |
| 16:10 | 23% | 33% | −9 | main |
| 16:56 | 22% | 45% | −23 | main |
| 17:09 | 27% | 44% | −16 | main |
| 17:18 | 23% | 42% | −19 | main |
| 17:21 | 27% | 37% | −9 | main |
| 17:22 | 27% | 36% | −9 | main |
| 17:26 | 28% | 41% | −13 | main |
| 17:44 | 28% | 41% | −13 | main |
| 18:07 | 21% | 43% | −21 | main |
| 18:31 | 30% | 50% | −19 | main |
| 18:42 | 30% | 49% | −18 | main |
| 18:44 | 30% | 49% | −19 | main |
| 18:52 | 30% | 50% | −19 | main |
| 18:55 | 30% | 50% | −20 | main |
| 18:55 | 30% | 50% | −20 | main |
| 18:59 | 30% | 50% | −19 | main |
| 19:06 | 31% | 45% | −14 | main |
| 19:19 | 28% | 41% | −13 | main |
| 20:05 | 26% | 46% | −19 | main |
| 20:08 | 30% | 45% | −15 | main |
| 20:29 | 32% | 41% | −9 | main |
| 21:14 | 21% | 42% | −20 | main |
| 22:01 | 21% | 32% | −10 | main |
| 22:25 | 17% | 24% | −6 | main |
| 23:13 | 18% | 33% | −15 | main |
| 23:59 | 22% | 36% | −13 | main |
| 24:08 | 21% | 37% | −16 | main |
| 24:20 | 24% | 39% | −15 | main |
| 24:25 | 24% | 39% | −15 | main |
| 24:49 | 27% | 39% | −12 | main |
| 25:13 | 27% | 41% | −14 | main |
| 25:35 | 23% | 39% | −16 | main |
| 25:56 | 24% | 58% | −33 | main |
| 25:59 | 24% | 57% | −32 | main |
| 26:01 | 24% | 58% | −33 | main |
| 26:05 | 31% | 51% | −19 | main |
| 26:10 | 31% | 50% | −19 | main |
| 26:14 | 31% | 45% | −13 | main |
| 26:21 | 31% | 46% | −14 | main |
| 26:22 | 31% | 46% | −14 | main |
| 26:24 | 28% | 48% | −20 | main |
| 26:29 | 31% | 48% | −17 | main |
| 26:44 | 34% | 48% | −14 | main |
| 27:07 | 41% | 57% | −16 | main |
| 27:55 | 54% | 61% | −7 | main |
| 28:20 | 52% | 58% | −6 | main |
| 28:55 | 48% | 69% | −21 | main |
| 29:06 | 48% | 70% | −22 | main |
| 29:07 | 55% | 70% | −15 | main |
| 29:14 | 55% | 66% | −11 | main |
| 29:16 | 55% | 67% | −12 | main |
| 29:21 | 55% | 71% | −16 | main |
| 29:25 | 55% | 71% | −16 | main |
| 29:31 | 55% | 71% | −16 | main |
| 29:32 | 55% | 70% | −15 | main |
| 29:37 | 61% | 70% | −8 | main |
| 29:53 | 73% | 70% | +4 | main |
| 30:17 | 73% | 74% | −1 | main |
| 30:55 | 74% | 80% | −5 | main |
| 31:04 | 73% | 79% | −6 | main |
| 31:13 | 73% | 80% | −6 | main |
| 31:49 | 78% | 81% | −3 | main |
| 32:14 | 77% | 82% | −5 | main |
| 32:55 | 80% | 86% | −6 | main |
| 33:08 | 83% | 86% | −3 | main |
| 33:46 | 79% | 85% | −6 | backup |
| 34:10 | 83% | 84% | −1 | main |
| 34:53 | 81% | 89% | −7 | main |
| 34:59 | 81% | 89% | −8 | main |
| 35:20 | 90% | 92% | −2 | main |
| 35:32 | 91% | 93% | −1 | main |
| 35:35 | 93% | 93% | +1 | main |
| 35:40 | 96% | 93% | +3 | main |
| 36:06 | 96% | 93% | +3 | main |
| 36:53 | 97% | 94% | +3 | main |
| 36:53 | 91% | 94% | −3 | backup |
| 37:41 | 93% | 95% | −2 | backup |
| 38:07 | 92% | 97% | −4 | backup |
| 38:52 | 93% | 95% | −1 | backup |
| 39:15 | 98% | 95% | +3 | main |
| 39:53 | 97% | 94% | +3 | main |
| 40:18 | 97% | 87% | +10 | main |
| 40:23 | 96% | 86% | +10 | main |
| 40:26 | 96% | 82% | +15 | main |
| 40:28 | 96% | 86% | +10 | main |
| 40:33 | 96% | 84% | +12 | main |
| 40:38 | 96% | 84% | +13 | main |
| 40:43 | 91% | 82% | +9 | main |
| 40:47 | 96% | 82% | +15 | main |
| 40:49 | 96% | 82% | +15 | main |
| 40:52 | 91% | 80% | +11 | main |
| 41:12 | 81% | 63% | +19 | main |
| 41:59 | 70% | 69% | +2 | main |
| 42:48 | 70% | 66% | +5 | main |
| 43:11 | 67% | 66% | +1 | main |
| 43:34 | 67% | 66% | +1 | main |
| 43:58 | 66% | 62% | +5 | main |
| 44:21 | 66% | 64% | +3 | main |
| 44:44 | 66% | 63% | +4 | main |
| 45:07 | 63% | 64% | −1 | main |
| 45:54 | 61% | 66% | −4 | main |
| 46:39 | 67% | 66% | +2 | main |
| 47:04 | 71% | 68% | +3 | main |
| 47:53 | 77% | 68% | +9 | main |
| 48:17 | 75% | 71% | +4 | main |
| 49:05 | 70% | 68% | +2 | main |
| 49:53 | 64% | 70% | −6 | main |
| 50:41 | 63% | 67% | −4 | main |
| 51:04 | 62% | 65% | −3 | main |
| 51:23 | 63% | 67% | −4 | main |
| 51:29 | 61% | 68% | −7 | main |
| 51:32 | 61% | 68% | −7 | main |
| 51:37 | 61% | 64% | −2 | main |
| 51:46 | 62% | 67% | −5 | main |
| 51:49 | 62% | 67% | −5 | main |
| 52:13 | 79% | 73% | +6 | backup |
| 52:17 | 79% | 68% | +11 | backup |
| 52:21 | 79% | 74% | +5 | backup |
| 52:35 | 79% | 78% | +1 | main |
| 52:44 | 79% | 80% | −1 | main |
| 52:51 | 79% | 80% | −1 | main |
| 52:54 | 73% | 80% | −7 | backup |
| 52:59 | 79% | 79% | 0 | backup |
| 53:21 | 87% | 79% | +8 | main |
| 53:44 | 86% | 81% | +5 | main |
| 54:30 | 90% | 83% | +8 | main |
| 55:17 | 92% | 80% | +13 | main |
| 55:41 | 92% | 80% | +13 | main |
| 55:56 | 93% | 82% | +12 | main |
| 56:00 | 93% | 82% | +11 | main |
| 56:04 | 93% | 80% | +13 | main |
| 56:05 | 93% | 80% | +13 | main |
| 56:11 | 93% | 80% | +13 | main |
| 56:15 | 93% | 83% | +10 | main |
| 56:21 | 93% | 83% | +10 | main |
| 56:25 | 93% | 83% | +10 | main |
| 56:27 | 93% | 83% | +10 | main |
| 56:30 | 93% | 83% | +10 | main |
| 56:35 | 94% | 84% | +10 | main |
| 56:50 | 93% | 84% | +10 | main |
| 57:14 | 94% | 85% | +10 | main |
| 58:02 | 94% | 85% | +10 | main |
| 58:26 | 95% | 87% | +9 | main |
| 58:50 | 95% | 86% | +9 | main |
| 59:13 | 94% | 85% | +10 | main |
| 59:37 | 95% | 79% | +16 | main |
| 60:01 | 94% | 75% | +20 | main |
| 60:24 | 95% | 78% | +17 | main |
| 60:47 | 95% | 78% | +17 | main |
| 61:10 | 96% | 78% | +18 | main |
| 61:33 | 96% | 78% | +18 | main |
| 61:56 | 95% | 78% | +17 | main |
| 62:19 | 95% | 78% | +17 | main |
| 62:43 | 95% | 73% | +22 | main |
| 63:29 | 93% | 92% | +1 | main |
| 63:32 | 96% | 92% | +4 | main |
| 63:53 | 95% | 93% | +3 | main |
| 64:01 | 96% | 95% | +2 | main |
| 64:38 | 95% | 95% | 0 | main |
| 65:01 | 95% | 95% | 0 | main |
| 65:48 | 96% | 93% | +3 | main |
| 66:54 | 96% | 95% | +1 | main |
| 67:06 | 97% | 97% | 0 | main |
| 67:20 | 97% | 100% | −2 | main |
| 67:34 | 97% | 99% | −1 | main |
| 68:05 | 97% | 99% | −1 | main |
| 68:28 | 97% | 99% | −2 | main |
| 68:43 | 97% | 100% | −2 | main |
| 69:09 | 98% | 99% | −2 | main |
| 69:27 | 98% | 100% | −1 | main |
| 69:37 | 98% | 100% | −2 | main |
LGD Gaming (Radiant) vs Team Resilience, 13 August 2026. Radiant won. Hover or drag across the chart, or use the arrow keys, to scrub.
Lessons
What I learned
My test scores lied
A live test found the market's odds beat mine every time, mostly with older models.
Check the cause before fixing
September looked like unfamiliar teams. A raw recording showed Valve delaying the data by 15 minutes.
Some feeds froze but kept answering
Some sources kept answering on time while repeating frozen numbers.
Test new rules quietly
Most ideas looked great on old matches. A review passed 1 of 10.
One slow site froze everything
One dead mirror site froze the whole program, even its exchange connection.
The ending
Why I stopped
I sized up too big
Overall I made money. But a few times I bet far too large, and those days gave a lot of it back.
Some games looked fixed
I came to suspect certain matches were rigged. A few months later, some of the teams I had flagged were banned for good.
It never stopped needing me
Keeping the trader alive ate my time. Feeds broke, sources changed, and automated trading is hard to predict.
So I paused it and kept what I learned. This page is that.