Adverse selection eating away my Polymarket bot arbitrage profits.
Last post I left a question hanging - the directional bets lost $3,184 when every one was supposed to have at least 7% edge. Analytics scripts I built gave me a clear picture but there is not a single simple answer that explains why my ROI decayed. The main reason was that I was using stale odds (up to 30 minutes fresh) which resulted in adverse selection fills a lot more often than I thought would happen.
- polymarket
- arbitrage
- analytics
- build-in-public
At the end of the last post about my Polymarket market-making bot I said the forced directional bets lost $3,184. If you haven't read that part, start there first. The problem with those bets was that they were each supposed to carry a minimum 7% edge, and yet they consistently lost me money. The whole point of this post is figuring out why. So I built an analytics stack and went digging.
There's no single villain. The ROI didn't fall off a cliff for one clean reason - it got eaten by a handful of separate things at once. Some based on assumptions I made, some structural. But if I had to point at the one that did the most damage, that's being exposed to adverse selection - my prices were too slow, and I was getting picked off.
Again, you don't have to take my word for any of it as my wallet is public - polymarket.com/@b00k13 - all the bets I've taken for my esports bot are here. By the way, you may notice that the bot is now live and trading again. I'm currently doing a lot of improvements like rewriting the scrapers and the bot itself. I will touch on that in some of my next posts as I'm doing all of this in my free time and I haven't had much of that recently.
Anyway, I'd like to mention one honest caveat first before going all-in into the analysis - I didn't record orders from day one. I only started logging properly once I was already bleeding and couldn't see why, so some stats here are mostly the back half of the run. Flying blind until the losses forced me to create the dashboard is one of the first mistakes I made while working on this.
Adverse selection: the edge was real when I quoted it and gone when I got filled
The whole strategy (which I described in detail in the previous post) depends on one number - the fair probability I compute from the bookmaker odds. Put simply, I place bids a cent above the best bid and at a minimum 7% edge, wait for a fill, and then try to hedge. The weak point is that my fair value is only as fresh as my odds - and my odds were, at worst, 30 minutes stale.
At the time, I assumed that didn't matter much for prematch markets. Well, it most certainly does. Prematch lines move more than you'd think, which is especially the case with esports games - a 10-15% swing before going live is normal, on news, lineups, late money. And when the line moves, my bid doesn't - and that's exactly what cost me on the games and moments where the line actually moved.
Here's the exact way it works. Say I've got a resting bid on Team X at 60¢, because my fair value says X is 67% to win - a clean 7% edge, exactly what I'm fishing for. Then the real line moves. Some key player at team X sprains his wrist and is called off the game, and the true fair drops to 50%. My 60¢ bid is now a gift sitting on the book. Whoever has the up-to-date odds lifts it and pockets a ~10% edge against the true price - and I get filled at what my stale number still calls +7% and in reality I'm at a -10% loss.
That's adverse selection, and it's the structural reason a book of "+EV" bets can quietly bleed. This may be obvious to you if you've got experience in market making. Unfortunately, it wasn't obvious to me, and I had to learn it the hard way. Thankfully, a decent chunk of my good orders were getting filled at the time (including the arbitrages), and that was making up for all the losses I was taking from adverse selection. As I said, I had to find out what adverse selection is from personal experience, so how did I pick it up?
Long story short, in one of my analysis scripts I noticed that I had positions at a negative edge compared to the current odds. I did a deep dive to figure out why this happened. I could not attribute the negative edge to any bug, so I started recording the odds over time. Then I created a script that measures the odds swings, which is what you see in the table below.
| Game | Matches | Median jump | Big jump (5pp+) |
|---|---|---|---|
| CoD | 98 | 10.9pp | 60% |
| LoL | 299 | 4.9pp | 49% |
| Dota 2 | 415 | 3.1pp | 37% |
| CS2 | 1,301 | 1.9pp | 31% |
| MLBB | 29 | 0.0pp | 28% |
| SC2 | 112 | 0.0pp | 17% |
| Valorant | 266 | 0.1pp | 12% |
| R6 | 35 | 0.0pp | 0% |
These are the results from historical odds I've recorded for the March 6 - 20 period. Across all 2,555 matches, the line jumped 5pp or more in nearly a third of them while my bid was just sitting on the book. But it's wildly uneven, and it's the game that decides it. A typical Valorant or R6 line barely twitches. CoD is a different animal - it moves 10.9pp in the median match, and 60% of them throw a 5pp+ jump. LoL and Dota 2 are the other real movers, and in the tail it gets worse - a LoL or Dota line can lurch 30pp before going live. That's the part that stings - LoL was my single biggest losing game, and its lines move about more than almost anything I traded.
One honest note on this data - the odds were sampled every ~7-8 minutes, so it tells you where the lines move, not the second-by-second texture. The results you see in this table cannot give you a complete picture of what's going on. Nevertheless, for me it was really useful to have some numbers back what I was starting to realise was going on. The key metric to look at here is the percentage of games that experienced a big jump of over 5pp, and as you can see, that happens with a significant chunk of them.
Still, there's one very important thing these stats don't tell us, and that is - how do we solve our problem with adverse selection? How fresh do we want our odds to be? 5 min? 1 min? 10 seconds? Even when you establish that, how are you going to get odds at such frequency? Are you going to pay for an API? Can you find one with good coverage for these high spread games? Scraping from sportsbooks is free (apart from compute) but quite slow and is exactly what got me here.
There's no shortcut here - you really need to come up with a solution for these questions if you want to be profitable. The competition for "easy money" in any crypto field is fierce and nobody's going to hand you a free lunch if you haven't earned it. And that means making the least costly mistakes possible, which takes me to the next section.
I made things worse by expanding into markets I had no business in
Put simply, when things were going well I got greedy and tried to find other markets to expand to. I initially started only with esports markets - CS2, Dota, LoL, etc. At the beginning I only had about $1,000 in capital, and for the first 1-2 months the esports games were doing decent volume. I had no reason to even think about expanding as my cash was getting utilised ~100%. As I grew my capital in Jan and Feb, that was not enough for me, of course.
In sports markets the competition is much more fierce as the volume is much bigger. That didn't bother me a great deal as I was still able to find sports markets where the spread is decent - rugby, smaller basketball and football leagues, etc. In theory, the conditions looked the same to me as the esports markets I was already trading, so the same strategy should've been profitable, right?
In practice, things didn't work out exactly as expected. Even though at the time I made some improvements to my odds freshness, simply because of the way I was scraping those, my odds were refetched roughly every 10 minutes. Additionally, even though the markets had a decent spread that didn't mean there was no competition. So, I ended up being even more exposed to the adverse selection above, and there wasn't enough volume overall to complete the arbs that were supposed to make it worthwhile.
When I split the P&L out by market, every single non-esports thing I touched was net-negative:
| Market | Arb P&L | Directional P&L | Net P&L |
|---|---|---|---|
| Basketball | +$76 | -$304 | -$228 |
| Football | +$24 | -$189 | -$165 |
| Hockey | +$39 | -$100 | -$61 |
| Rugby | +$95 | -$150 | -$55 |
| Core esports | +$8,059 | -$2,442 | +$5,617 |
The reason is the whole strategy in one line - the risk-free arb has to cover the directional residual that keeps getting picked off. In esports it did: the arb pulled in +$8,059 and covered the -$2,442 directional bleed more than three times over. In these new markets there was barely any arb to be had, and the directional bleed was far worse. Put as a ratio, the arb covered the directional loss 3.3x in esports and less than a third of it in football and basketball.
The honest size of this is -$509 net - against the all-time numbers that looks fairly insignificant. But it wasn't spread out. Almost all of it landed in March, the month my core edge was already fading, and in March these markets were about $580 of the month's ~$810 directional loss - roughly three-quarters of it - which cut that month's profit nearly in half. So it wasn't a rounding error. It was me taking my worst month and digging the hole deeper, in markets I wasn't yet prepared to trade.
There's a lesson here, but I don't regret expanding into those markets. There's always a risk of losing money on endeavours like building a bot like this, and I've learned to be comfortable with that. Five hundred bucks was the price I paid to learn I need to be much better prepared the next time I go into those markets. Making costly mistakes usually helps you avoid repeating them in the future. Hopefully.
The bugs I shipped and only found in the P&L
As I said earlier, I built this project entirely using AI in my free time. Although I tried to be somewhat careful, quite a lot of bugs went under the radar. The analytics suite mostly existed to surface exactly this kind of silent error. The three that cost me real money:
- Swapped fair probabilities. Sometimes the bot had Team A's probability sitting on Team B and vice versa - so it was confidently betting the wrong team, at full size, certain it had an edge.
- Odds from an old game. Before I added a staleness check, the bot would happily reuse the probabilities from a previous match between the same two teams as if they were current. Same names, completely different game.
- Odds from the wrong game entirely. Before I added a "is this actually a CS2 game?" check, it could match odds from a different game - sometimes a different sport - onto a market, because the names lined up closely enough to fool the matcher.
None of these are clever. They're the unglamorous reality of pointing an AI-built bot at a live market and trusting it. I haven't worked out exactly how much these (and other bugs) cost me, but it's surely several hundred bucks again. Not much of a moral here apart from the obvious need to understand how your bot works when you throw money at it. It's always better to find a bug in your code before it's cost you something.
Competition: I couldn't keep my quotes on the book
The edge wasn't going to last forever. I knew that back in Jan/Feb when I started making decent profits from this bot. Truth be told, the overall idea of the bot isn't very sophisticated, and neither is implementing it. I could see there were far better bots than mine competing in the sports markets - ones that had, for some reason, been overlooking esports. So the most natural thing happened - competitors started coming in and the edge began to thin.
Faster market-making bots showed up, and one of the core problems with my bot was speed - my bids didn't update quickly enough. Others would outbid me by a cent and sit on top of the book, so I'd miss the fill on the good prices and only get hit on the stale ones - which is the adverse selection mentioned earlier, made worse.
| Month | Outcomes I bid on | Got filled on | Fill rate |
|---|---|---|---|
| Jan | 2,034 | 760 | 37.4% |
| Feb | 53,283 | 7,971 | 15.0% |
| Mar | 45,795 | 2,309 | 5.0% |
| Apr | 22,467 | 222 | 1.0% |
My fill rate fell off a cliff - from getting onto 37% of the markets I bid on in January to 1% by April, even while I was bidding on ten times more of them. I'll be honest that this is two things tangled together - faster bots sitting a cent ahead of me so my quote never got hit, and me expanding into the sports markets - I can't cleanly separate the two from this data. But the direction is unmistakable - my bids weren't getting filled for the most part.
And that's what actually stalled the engine. The arbs were the part that made the money, and an arb only happens when I get filled on both sides. The profit held up through February only because the sheer volume I was bidding made up for the falling hit rate - but once the fill rate dropped to 5% and then 1%, no amount of volume could compensate. Locked arb profit went $2,865 in January and $4,158 in February, then collapsed to $1,254 in March and $17 in April. The edge didn't gently erode - the moment I stopped getting filled, the whole thing stopped.
Competition didn't shrink the edge so much as hand it to the people who beat me to it.
The assumption I never put to the test
There's a quieter lesson in all of this, and it's less about a bug than about how I built the thing in the first place. When you put a model like this together - especially one that's largely AI-assisted - you make a pile of small assumptions that feel right, and you almost never go back and check whether they actually were.
Mine was the devig. To strip the bookmaker's margin out of the odds I didn't use plain proportional normalisation - I used Shin's method, which models the idea that some of the money in a book is insider money rather than noise. The AI suggested it, it sounded more sophisticated, and it felt like I was reverse-engineering how the book was really built. So I went with it, and never actually checked whether it helped.
It took the analytics to find out. I happened to run Shin's in January and dropped it from February on, so the month-by-month split reads almost like a natural experiment:
| Jan (Shin's) | Feb | Mar | Apr | |
|---|---|---|---|---|
| Total P&L | $1,823 | $2,659 | $445 | $180 |
| Favourites win rate | 72.0% | 74.1% | 65.4% | 60.0% |
| Favourites ROI | 1.5% | 11.7% | -1.1% | 45.3% |
| Underdogs win rate | 35.4% | 31.9% | 34.8% | 33.3% |
| Underdogs ROI | 19.7% | -0.4% | 4.4% | 96.4% |
The tell is that the win rates barely move - I was picking winners about as well with Shin's as without it - but the ROIs flip completely. In January, with Shin's on, my favourites won 72% of the time and returned almost nothing (1.5%), while my underdogs returned a fat 19.7%. Drop Shin's in February and it flips - favourites jump to 11.7% and the underdog bargain disappears.
That's the fingerprint of a pricing mistake, not a prediction one. Shin's wasn't making me worse at calling games - it was making me overpay for favourites and underpay for underdogs. It nudges the favourite's implied probability up a touch - on the FaZe vs NAVI example from the last post, a 59% favourite becomes about 60% - always in the same direction, so the bot bid a little high on favourites and a little low on underdogs. With Shin's on, my profit was quietly coming from underdogs I underpriced, while the favourites I was overpaying for barely returned a thing.
I don't want to oversell it - it's one month with Shin's against three without, and January was also my earliest and least competitive month, so some of that fat underdog ROI is probably just early easy money rather than the devig. But the direction is clean, and the point holds either way - an assumption I'd never questioned was quietly deciding which side of every market my edge sat on. I only found out because I built something to look - and that's the real reason for all this analysis. Not to admire the wins, but to put the assumptions you didn't even know you were making to the test.
What I'm working on to fix it
Let me get back to some of the questions from earlier, especially the ones about adverse selection. So, what am I doing about it? How do I avoid the issues I ran into before? I started working on improving the bot at the start of June, and since the middle of the month it's live again.

What have I changed? First and most importantly, the scrapers. I found 9 different sources where I can get odds with API calls, not from scraping the pages of sportsbooks, which is very slow and error-prone. Currently I'm fetching odds every 5 minutes, which costs me ~$3/day; I run the compute on Cloud Run jobs. I might get a dedicated VM when I move to fetching odds at a higher rate, but that time hasn't come yet - there are other things I need to do first.
One of the big changes I've decided to make is rewriting the code from Python to Rust. Honestly, I'm not entirely certain whether that's the right thing for me, but I went with it anyway. My motivation is that rewriting it in Rust makes execution faster and memory properly managed. I'm hoping for improved correctness, not just speed. On the other hand, my understanding of the code drops drastically, and as I've mentioned, that's cost me already.
I'm still finding out bugs from the big rewrite and I'm working to fix those whenever I have the time. I'm only placing 10 share orders right now and I started with a ~$300 balance. I'm getting filled so rarely that this balance is more than enough right now. When I fix my bugs and ensure that the bot is working properly, I will improve the odds freshness and I will start trading with size.
Too slow, not unlucky
None of this is a story about a clever edge that mysteriously ran out. The edge was real. I was just too slow to defend it - getting picked off on stale prices, shipping a few dumb bugs, and then pouring it all into markets I wasn't ready for. The fixes are mostly speed and discipline, which is unglamorous, and probably why it took me an analytics stack and a few thousand dollars to admit it.
The edge was real. I was just too slow to keep it.
So the next job for me is to complete the Rust rewrite, fix all my current bugs, improve my odds freshness, test things live, and then turn the bot back on in public, with the numbers live on this page. You'll be able to watch whether faster, fresher quotes actually close the leak, or just check the wallet and call me on it.
I'm also planning to open-source the Python version once I've cleaned it up - the retired codebase, not the live Rust one, but a complete, working starting point if you want to pull the strategy apart or try it yourself.
If you want to catch the restart and the code drop the moment they land, subscribe. Until then, the whole track record is on the public wallet if you'd like to pick any of this apart.