{"@attributes":{"version":"2.0"},"channel":{"title":"DEV Community: manja316","description":"The latest articles on DEV Community by manja316 (@manja316).","link":"https:\/\/dev.to\/manja316","image":{"url":"https:\/\/media2.dev.to\/dynamic\/image\/width=90,height=90,fit=cover,gravity=auto,format=auto\/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3807032%2F019bc956-974c-46d5-880d-00fcfa5fabf7.jpeg","title":"DEV Community: manja316","link":"https:\/\/dev.to\/manja316"},"language":"en","item":[{"title":"94.6% of ended prediction markets converge to near-certainty \u2014 and why I still can't tell you if they were right","pubDate":"Mon, 29 Jun 2026 01:08:25 +0000","link":"https:\/\/dev.to\/manja316\/946-of-ended-prediction-markets-converge-to-near-certainty-and-why-i-still-cant-tell-you-if-3ahj","guid":"https:\/\/dev.to\/manja316\/946-of-ended-prediction-markets-converge-to-near-certainty-and-why-i-still-cant-tell-you-if-3ahj","description":"<p>I run a collector that snapshots every active Polymarket market's price every 15 minutes. After 92 days it's 18.6 million price points across 22,410 markets. People keep asking the same question, and the honest answer is more interesting than the marketing one.<\/p>\n\n<blockquote>\n<p>\"Is Polymarket well-calibrated? When it says 70%, does the thing happen 70% of the time?\"<\/p>\n<\/blockquote>\n\n<p>I can't answer that from this data. But I can show you something adjacent and real \u2014 and, more usefully, I can show you exactly where the wall is, because most \"prediction markets are smart\" posts walk straight through it without noticing.<\/p>\n\n<h2>\n  \n  \n  The thing I CAN measure: convergence\n<\/h2>\n\n<p>Take every market that has ended (<code>end_date<\/code> in the past) and look at its last traded YES price. On the frozen export (2026-03-28 \u2192 2026-06-28):<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight sql\"><code><span class=\"k\">SELECT<\/span>\n  <span class=\"k\">COUNT<\/span><span class=\"p\">(<\/span><span class=\"o\">*<\/span><span class=\"p\">)<\/span> <span class=\"k\">AS<\/span> <span class=\"n\">ended<\/span><span class=\"p\">,<\/span>\n  <span class=\"n\">ROUND<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">.<\/span><span class=\"mi\">0<\/span><span class=\"o\">*<\/span><span class=\"k\">SUM<\/span><span class=\"p\">(<\/span><span class=\"k\">CASE<\/span> <span class=\"k\">WHEN<\/span> <span class=\"n\">last_trade_price<\/span><span class=\"o\">&gt;=<\/span><span class=\"mi\">0<\/span><span class=\"p\">.<\/span><span class=\"mi\">95<\/span> <span class=\"k\">OR<\/span> <span class=\"n\">last_trade_price<\/span><span class=\"o\">&lt;=<\/span><span class=\"mi\">0<\/span><span class=\"p\">.<\/span><span class=\"mi\">05<\/span>\n        <span class=\"k\">THEN<\/span> <span class=\"mi\">1<\/span> <span class=\"k\">ELSE<\/span> <span class=\"mi\">0<\/span> <span class=\"k\">END<\/span><span class=\"p\">)<\/span><span class=\"o\">\/<\/span><span class=\"k\">COUNT<\/span><span class=\"p\">(<\/span><span class=\"o\">*<\/span><span class=\"p\">),<\/span><span class=\"mi\">1<\/span><span class=\"p\">)<\/span> <span class=\"k\">AS<\/span> <span class=\"n\">pct_decisive<\/span><span class=\"p\">,<\/span>\n  <span class=\"n\">ROUND<\/span><span class=\"p\">(<\/span><span class=\"mi\">100<\/span><span class=\"p\">.<\/span><span class=\"mi\">0<\/span><span class=\"o\">*<\/span><span class=\"k\">SUM<\/span><span class=\"p\">(<\/span><span class=\"k\">CASE<\/span> <span class=\"k\">WHEN<\/span> <span class=\"n\">last_trade_price<\/span><span class=\"o\">&gt;<\/span><span class=\"mi\">0<\/span><span class=\"p\">.<\/span><span class=\"mi\">40<\/span> <span class=\"k\">AND<\/span> <span class=\"n\">last_trade_price<\/span><span class=\"o\">&lt;<\/span><span class=\"mi\">0<\/span><span class=\"p\">.<\/span><span class=\"mi\">60<\/span>\n        <span class=\"k\">THEN<\/span> <span class=\"mi\">1<\/span> <span class=\"k\">ELSE<\/span> <span class=\"mi\">0<\/span> <span class=\"k\">END<\/span><span class=\"p\">)<\/span><span class=\"o\">\/<\/span><span class=\"k\">COUNT<\/span><span class=\"p\">(<\/span><span class=\"o\">*<\/span><span class=\"p\">),<\/span><span class=\"mi\">1<\/span><span class=\"p\">)<\/span> <span class=\"k\">AS<\/span> <span class=\"n\">pct_coinflip<\/span>\n<span class=\"k\">FROM<\/span> <span class=\"n\">markets<\/span>\n<span class=\"k\">WHERE<\/span> <span class=\"n\">end_date<\/span> <span class=\"o\">&lt;<\/span> <span class=\"nb\">date<\/span><span class=\"p\">(<\/span><span class=\"s1\">'now'<\/span><span class=\"p\">)<\/span> <span class=\"k\">AND<\/span> <span class=\"n\">end_date<\/span> <span class=\"o\">!=<\/span> <span class=\"s1\">''<\/span> <span class=\"k\">AND<\/span> <span class=\"n\">last_trade_price<\/span> <span class=\"k\">IS<\/span> <span class=\"k\">NOT<\/span> <span class=\"k\">NULL<\/span><span class=\"p\">;<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight plaintext\"><code>ended    pct_decisive   pct_coinflip\n19402    94.6           0.9\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p><strong>94.6% of ended markets had their price collapse to \u22650.95 or \u22640.05 by close.<\/strong> Only 0.9% were still a coin-flip (0.40\u20130.60). The crowd makes up its mind almost every time before the window shuts.<\/p>\n\n<p>That's a genuinely useful fact if you're building anything time-aware: a market sitting at 0.55 two days before close is in the rare 4.5% that <em>hasn't<\/em> resolved its uncertainty yet \u2014 that's where the trades live.<\/p>\n\n<h2>\n  \n  \n  The thing I CANNOT measure: was it right?\n<\/h2>\n\n<p>Here's where the wall is. <strong>Convergence is not correctness.<\/strong> A market going to 0.97 tells you traders agreed. It does not tell you the YES outcome happened. To know that, you need the resolved outcome \u2014 and this dataset doesn't have it:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight sql\"><code><span class=\"k\">SELECT<\/span> <span class=\"n\">resolved<\/span><span class=\"p\">,<\/span> <span class=\"k\">COUNT<\/span><span class=\"p\">(<\/span><span class=\"o\">*<\/span><span class=\"p\">)<\/span> <span class=\"k\">FROM<\/span> <span class=\"n\">markets<\/span> <span class=\"k\">GROUP<\/span> <span class=\"k\">BY<\/span> <span class=\"n\">resolved<\/span><span class=\"p\">;<\/span>\n<span class=\"c1\">-- 0 | 22410   (every single row)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p><code>resolved_outcome<\/code> is empty for all 22,410 markets. The collector reads live prices from Polymarket's Gamma\/CLOB APIs every 15 minutes; it never joins the on-chain resolution feed. So calibration curves, favorite-longshot bias, Brier scores \u2014 anything that needs \"which side won\" \u2014 are <strong>not computable from this file alone.<\/strong><\/p>\n\n<p>You could approximate by <em>assuming<\/em> the final price equals the truth (price \u2265 0.95 \u2192 \"YES happened\"). But that's circular: you'd be grading the market against its own last guess, then announcing it's well-calibrated. It's the single most common mistake in prediction-market blog posts.<\/p>\n\n<h2>\n  \n  \n  Why ship a dataset and tell you what it can't do?\n<\/h2>\n\n<p>Because the alternative is a refund and a 1-star review. The price series is the dense, reliable layer \u2014 15-minute resolution, 92 days, no gaps, no scraping (straight from the public Gamma + CLOB APIs). That's worth paying for if you're backtesting price-based strategies, training a short-horizon predictor, or studying microstructure. It is <em>not<\/em> worth paying for if you wanted a calibration study, and I'd rather you know that before you click.<\/p>\n\n<p>(Same reason I published <a href=\"https:\/\/dev.to\/manja316\/88-of-the-order-book-rows-in-my-dataset-were-fake-heres-how-i-caught-it-4hn8\">the audit showing ~94% of the order-book rows are thin-market placeholders<\/a>. Price = the product. Order book and resolution labels = honest caveats.)<\/p>\n\n<h2>\n  \n  \n  Reproduce it in 30 seconds\n<\/h2>\n\n<p>Free 1-day sample (no signup): <a href=\"https:\/\/huggingface.co\/datasets\/manja316\/polymarket-historical-prices\" rel=\"noopener noreferrer\">Hugging Face<\/a><br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">sqlite3<\/span><span class=\"p\">,<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n<span class=\"n\">con<\/span> <span class=\"o\">=<\/span> <span class=\"n\">sqlite3<\/span><span class=\"p\">.<\/span><span class=\"nf\">connect<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">polymarket.db<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<span class=\"n\">m<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">read_sql<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">SELECT last_trade_price p FROM markets <\/span><span class=\"sh\">\"<\/span>\n                <span class=\"sh\">\"<\/span><span class=\"s\">WHERE end_date &lt; date(<\/span><span class=\"sh\">'<\/span><span class=\"s\">now<\/span><span class=\"sh\">'<\/span><span class=\"s\">) AND last_trade_price IS NOT NULL<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"n\">con<\/span><span class=\"p\">)<\/span>\n<span class=\"n\">decisive<\/span> <span class=\"o\">=<\/span> <span class=\"p\">((<\/span><span class=\"n\">m<\/span><span class=\"p\">.<\/span><span class=\"n\">p<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"mf\">0.95<\/span><span class=\"p\">)<\/span> <span class=\"o\">|<\/span> <span class=\"p\">(<\/span><span class=\"n\">m<\/span><span class=\"p\">.<\/span><span class=\"n\">p<\/span> <span class=\"o\">&lt;=<\/span> <span class=\"mf\">0.05<\/span><span class=\"p\">)).<\/span><span class=\"nf\">mean<\/span><span class=\"p\">()<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"sa\">f<\/span><span class=\"sh\">\"<\/span><span class=\"si\">{<\/span><span class=\"n\">decisive<\/span><span class=\"si\">:<\/span><span class=\"p\">.<\/span><span class=\"mi\">1<\/span><span class=\"o\">%<\/span><span class=\"si\">}<\/span><span class=\"s\"> of ended markets converged<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>Full dataset, $19 one-time: <a href=\"https:\/\/manja8.gumroad.com\/l\/polymarket-quant-toolkit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-29\" rel=\"noopener noreferrer\">Gumroad<\/a>. Live auto-refreshing API: <a href=\"https:\/\/api.protodex.io\" rel=\"noopener noreferrer\">api.protodex.io<\/a>.<\/p>\n\n<p><strong>Open question for the comments:<\/strong> if you had the resolution labels joined in, what's the first thing you'd test \u2014 calibration, or fading the longshots? That's the next build and I'll prioritize by what people actually want.<\/p>\n\n","category":["datascience","dataset","python","trading"]},{"title":"What 91 days of Polymarket price data can \u2014 and can't \u2014 tell you","pubDate":"Sat, 27 Jun 2026 23:06:08 +0000","link":"https:\/\/dev.to\/manja316\/what-91-days-of-polymarket-price-data-can-and-cant-tell-you-19p7","guid":"https:\/\/dev.to\/manja316\/what-91-days-of-polymarket-price-data-can-and-cant-tell-you-19p7","description":"<p>Most \"dataset\" posts oversell. This one tells you the limits first.<\/p>\n\n<p>For 91 straight days (28 Mar \u2192 27 Jun 2026) I collected Polymarket prices on one Mac, no cloud bill. The frozen export is:<\/p>\n\n<ul>\n<li>\n<strong>18,591,646<\/strong> price snapshots<\/li>\n<li>\n<strong>22,362<\/strong> markets (19,819 of them with an actual price series \u2014 avg <strong>938 snapshots per market<\/strong>)<\/li>\n<li>\n<strong>1,854,788<\/strong> orderbook snapshots<\/li>\n<\/ul>\n\n<p>Category mix: sports 12,258 \u00b7 <code>other<\/code> 4,689 \u00b7 crypto 2,514 \u00b7 politics 1,441 \u00b7 geopolitics 685 \u00b7 science\/tech 241 \u00b7 economics 224 \u00b7 entertainment 169 \u00b7 weather 137.<\/p>\n\n<p>Here's what you can actually do with that \u2014 and three things you can't.<\/p>\n\n<h2>\n  \n  \n  What it's good for\n<\/h2>\n\n<p><strong>1. Price-path \/ convergence studies.<\/strong> Of the 6,776 markets that <em>ended<\/em> inside the window, <strong>6,551 (96.7%) closed decisively<\/strong> \u2014 last Yes-price above 0.95 or below 0.05. Only 3.3% were still mushy in the middle at the end. So the data is clean enough to study <em>how<\/em> and <em>when<\/em> a market makes up its mind: most of the information arrives well before close, and you can measure the shape of that arrival per category. (Crypto markets snap late; politics drifts.)<\/p>\n\n<p><strong>2. Mean-reversion \/ momentum backtests on the spread.<\/strong> With ~938 snapshots\/market you have enough intraday resolution to test \"does a 10-point move in 6 hours revert or continue?\" across thousands of contracts and eight categories.<\/p>\n\n<p><strong>3. Behavioral \/ microstructure features.<\/strong> Volume, 24h volume, liquidity, best bid\/ask, spread, and last-trade are all captured per snapshot, so you can build features without re-hitting the API 17 million times.<\/p>\n\n<h2>\n  \n  \n  What it can NOT do (the part most listings hide)\n<\/h2>\n\n<p><strong>1. True calibration. The dataset has no resolution labels.<\/strong> Every market in the export carries price series \u2014 not the final settled outcome. So you can study <em>price<\/em> convergence, but you cannot compute real calibration (predicted probability vs realized result) without joining external resolution data yourself. If a vendor shows you a \"calibration curve\" derived purely from prices, they're measuring the market against itself, not against reality. I'm not going to pretend otherwise.<\/p>\n\n<p><strong>2. Deep order-book research.<\/strong> The orderbook table is large (1.85M rows) but only a single-digit % of those rows are genuine two-sided quotes \u2014 the rest are placeholder\/one-sided. <strong>The price series is the real product here, not the book.<\/strong> Buy it for price history; don't buy it expecting an L2 reconstruction.<\/p>\n\n<p><strong>3. Sub-minute tick data.<\/strong> This is snapshot cadence, not a trade-by-trade feed. Great for hourly\/daily strategy research, wrong tool for HFT.<\/p>\n\n<h2>\n  \n  \n  Why collect it at all\n<\/h2>\n\n<p>Polymarket's API is free but rate-limited and ephemeral \u2014 query it today and last month's prices are gone. The value of a frozen 91-day export is that the history <em>exists<\/em> and is queryable in one SQLite file (<code>prices<\/code>, <code>markets<\/code>, <code>orderbooks<\/code>, <code>market_features<\/code>, \u2026), indexed on <code>(market_id, ts)<\/code>. Pull a market's full path in one query instead of paginating a live endpoint.<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight sql\"><code><span class=\"k\">SELECT<\/span> <span class=\"n\">ts<\/span><span class=\"p\">,<\/span> <span class=\"n\">price<\/span>\n<span class=\"k\">FROM<\/span> <span class=\"n\">prices<\/span>\n<span class=\"k\">WHERE<\/span> <span class=\"n\">market_id<\/span> <span class=\"o\">=<\/span> <span class=\"o\">?<\/span> <span class=\"k\">AND<\/span> <span class=\"n\">outcome<\/span> <span class=\"o\">=<\/span> <span class=\"s1\">'Yes'<\/span>\n<span class=\"k\">ORDER<\/span> <span class=\"k\">BY<\/span> <span class=\"n\">ts<\/span><span class=\"p\">;<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>That's the whole pitch: clean, indexed, honestly-scoped prediction-market price history you can backtest against tonight.<\/p>\n\n<p>If that's the shape of data you need, the full export (and a free sample tier) is here:<br>\n\ud83d\udc49 <strong><a href=\"https:\/\/manja8.gumroad.com\/l\/agyjd?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-29\" rel=\"noopener noreferrer\">Polymarket Historical Price Dataset<\/a><\/strong><\/p>\n\n<p>Questions about schema or coverage before you buy \u2014 ask in the comments and I'll answer with a real query against the file.<\/p>\n\n\n\n\n<p><em>Counts above are computed directly from the delivered export (the exact file you download), not the live collector, so what you read is what you get.<\/em><\/p>\n\n","category":["datasets","data","trading","opensource"]},{"title":"88% of the order-book rows in my dataset were fake. Here's how I caught it.","pubDate":"Fri, 26 Jun 2026 02:23:29 +0000","link":"https:\/\/dev.to\/manja316\/88-of-the-order-book-rows-in-my-dataset-were-fake-heres-how-i-caught-it-4hn8","guid":"https:\/\/dev.to\/manja316\/88-of-the-order-book-rows-in-my-dataset-were-fake-heres-how-i-caught-it-4hn8","description":"<p>I've been collecting Polymarket prediction-market data on a single Mac for 89 days. As of this morning the SQLite file holds:<\/p>\n\n<ul>\n<li>\n<strong>18,207,844<\/strong> price snapshots (15-minute OHLC)<\/li>\n<li>\n<strong>21,871<\/strong> markets<\/li>\n<li>\n<strong>1,816,392<\/strong> order-book rows<\/li>\n<li>span: <strong>2026-03-28 \u2192 2026-06-25<\/strong> (89 days)<\/li>\n<\/ul>\n\n<p>For weeks I described it as \"18M price snapshots <strong>+ 1.8M order-book records<\/strong>.\" That second number was a lie I was telling by accident. This post is the autopsy, because if you buy or build on market data, the failure mode I hit is one you will hit too.<\/p>\n\n<h2>\n  \n  \n  The smell test that should have run on day one\n<\/h2>\n\n<p>I went to compute realized spreads \u2014 <code>best_ask - best_bid<\/code> over time \u2014 expecting a tight distribution for liquid markets. Instead almost every row looked like this:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight plaintext\"><code>best_bid | best_ask | spread | mid\n   0.001 |    0.999 |  0.998 | 0.5\n   0.001 |    0.999 |  0.998 | 0.5\n   0.001 |    0.999 |  0.998 | 0.5\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>A 99.8-cent spread on a market that resolves between $0 and $1 is not a quote. It's the placeholder my collector wrote when the CLOB returned an empty book \u2014 and I had been counting every one of those as a \"record.\"<\/p>\n\n<p>One query settled it:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight sql\"><code><span class=\"k\">SELECT<\/span>\n  <span class=\"k\">COUNT<\/span><span class=\"p\">(<\/span><span class=\"o\">*<\/span><span class=\"p\">)<\/span> <span class=\"k\">AS<\/span> <span class=\"n\">total<\/span><span class=\"p\">,<\/span>\n  <span class=\"k\">SUM<\/span><span class=\"p\">(<\/span><span class=\"k\">CASE<\/span> <span class=\"k\">WHEN<\/span> <span class=\"p\">(<\/span><span class=\"n\">best_ask<\/span> <span class=\"o\">-<\/span> <span class=\"n\">best_bid<\/span><span class=\"p\">)<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"mi\">0<\/span><span class=\"p\">.<\/span><span class=\"mi\">99<\/span> <span class=\"k\">THEN<\/span> <span class=\"mi\">1<\/span> <span class=\"k\">ELSE<\/span> <span class=\"mi\">0<\/span> <span class=\"k\">END<\/span><span class=\"p\">)<\/span> <span class=\"k\">AS<\/span> <span class=\"n\">effectively_empty<\/span>\n<span class=\"k\">FROM<\/span> <span class=\"n\">orderbooks<\/span><span class=\"p\">;<\/span>\n<span class=\"c1\">-- total            = 1,816,392<\/span>\n<span class=\"c1\">-- effectively_empty = 1,602,616   (88.2%)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p><strong>88.2% of my order-book rows carry the 0.001 \/ 0.999 placeholder.<\/strong> Only ~11.8% have a spread tight enough to even be a candidate for a real two-sided quote, and the genuinely tradeable subset is smaller still. The \"1.8M order-book records\" headline was real rows in a table and almost entirely empty as <em>information<\/em>.<\/p>\n\n<h2>\n  \n  \n  Why this happens (and why it's not Polymarket's fault)\n<\/h2>\n\n<p>Prediction markets are thin. Most of the 21,871 markets are long-tail contracts \u2014 a niche election, a sports prop, a \"will X tweet by Friday.\" At any given 15-minute poll, most of those books are genuinely empty. The collector dutifully recorded \"no bid, no ask\" as a row. Nothing was broken. The bug was in how I <em>summarized<\/em> it.<\/p>\n\n<p>The price series, by contrast, is dense and reliable: a market's last-trade \/ OHLC ticks along even when the book is empty, because it reflects executed prices, not resting orders. <strong>18.2M price rows are 18.2M real observations. 1.8M book rows are ~200K real observations wearing a costume.<\/strong><\/p>\n\n<h2>\n  \n  \n  What I changed\n<\/h2>\n\n<ol>\n<li>\n<strong>Dropped \"order-book records\" as a headline feature<\/strong> everywhere I controlled \u2014 README, the dataset article, the listing copy. Selling an 88%-empty column as a feature is how you earn refund requests.<\/li>\n<li>\n<strong>Re-led with the price series<\/strong>, which is what the dataset is actually good for: backtesting, calibration studies, favorite-longshot analysis.<\/li>\n<li>\n<strong>Shipped the empty book anyway, labeled honestly<\/strong> \u2014 as a sparse bonus with its real coverage stated, not as a selling point. A few hundred thousand real top-of-book snapshots still have uses; pretending it's 1.8M does not.<\/li>\n<\/ol>\n\n<h2>\n  \n  \n  The transferable lesson\n<\/h2>\n\n<p><code>COUNT(*)<\/code> is not coverage. Before you trust \u2014 or sell, or backtest on \u2014 any market dataset:<\/p>\n\n<ul>\n<li>Run the <strong>spread\/sanity distribution<\/strong>, not just the row count. A column can be 100% populated and ~0% informative.<\/li>\n<li>Separate <strong>observations<\/strong> from <strong>rows<\/strong>. Placeholders are rows. Only non-degenerate values are observations.<\/li>\n<li>State coverage <strong>as a percentage of plausible values<\/strong>, not as a raw total. \"1.8M rows, 12% non-empty\" is honest; \"1.8M records\" is marketing.<\/li>\n<\/ul>\n\n<p>I'd rather a buyer learn this from my article than discover it after paying. The dense, verified layer \u2014 18.2M price snapshots across 89 days \u2014 is the thing worth having, and it's the thing I now lead with.<\/p>\n\n\n\n\n<p><em>The full dataset (price series + the honestly-labeled sparse book) is on Gumroad: <a href=\"https:\/\/manja8.gumroad.com\/l\/polymarket-quant-toolkit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-26\" rel=\"noopener noreferrer\">Polymarket Quant Toolkit<\/a>. There's a free sample so you can run your own sanity queries before paying \u2014 which, after reading this, you absolutely should.<\/em><\/p>\n\n","category":["datascience","dataset","python","trading"]},{"title":"What 86 Days of Clean Prediction-Market History Actually Shows","pubDate":"Wed, 24 Jun 2026 22:59:02 +0000","link":"https:\/\/dev.to\/manja316\/what-86-days-of-clean-prediction-market-history-actually-shows-1122","guid":"https:\/\/dev.to\/manja316\/what-86-days-of-clean-prediction-market-history-actually-shows-1122","description":"<p>Everyone has an opinion about whether prediction markets are \"smart.\" Almost nobody has the data to check.<\/p>\n\n<p>I do. For 86 days straight, a single Mac mini has been polling Polymarket's public API and writing every price tick to a local SQLite file. As of this morning:<\/p>\n\n<ul>\n<li>\n<strong>20,585 markets<\/strong> tracked<\/li>\n<li><strong>17,256,332 price snapshots<\/strong><\/li>\n<li><strong>1,721,218 order-book snapshots<\/strong><\/li>\n<li>\n<strong>85 continuous days<\/strong> (2026-03-28 \u2192 2026-06-21)<\/li>\n<li>\n<strong>$0\/month<\/strong> in infrastructure \u2014 no cloud, no cluster, one always-on machine<\/li>\n<\/ul>\n\n<p><em>(Those are the exact counts in the downloadable export. The live recorder keeps running \u2014 the local feed is already past 18.0M snapshots across 19,084 markets \u2014 but a buyer should know precisely what's in the file, so these are the file's numbers, not the live feed's.)<\/em><\/p>\n\n<p>That's not a toy sample. It's enough to ask the only question that matters about a prediction market: <strong>when the crowd says 70%, does the thing happen 70% of the time?<\/strong><\/p>\n\n<h2>\n  \n  \n  Why this is hard to get anywhere else\n<\/h2>\n\n<p>Polymarket's API serves you the <em>current<\/em> price. It does not hand you history. If you want to know what a market was trading at three weeks ago \u2014 the thing you need for any backtest \u2014 you had to have been recording it then. There's no rewind button.<\/p>\n\n<p>So the dataset isn't valuable because the data is secret. It's valuable because it's <strong>time-stamped and continuous<\/strong>. You can't reconstruct it after the fact at any price. You either started the recorder in March or you didn't.<\/p>\n\n<h2>\n  \n  \n  The calibration question, concretely\n<\/h2>\n\n<p>Take every market that resolved. Bucket the price history into deciles \u2014 the 0\u201310% bucket, the 10\u201320% bucket, and so on. For each bucket, compute the fraction of those markets that actually resolved YES.<\/p>\n\n<p>A perfectly calibrated market draws a straight 45\u00b0 line: the 30% bucket resolves YES ~30% of the time, the 90% bucket ~90%, etc.<\/p>\n\n<p>When I ran exactly this earlier in June, the broad finding held up better than the cynics expect \u2014 the crowd is roughly honest in the middle of the distribution, and the interesting distortions live in the <strong>tails<\/strong> (very cheap longshots and very expensive favorites), which is precisely where the <a href=\"https:\/\/en.wikipedia.org\/wiki\/Favourite-longshot_bias\" rel=\"noopener noreferrer\">favorite-longshot bias<\/a> lives in every betting market ever studied. I wrote that up with the bucket-by-bucket table here: <strong><a href=\"https:\/\/dev.to\/manja316\/when-polymarket-says-70-does-it-happen-70-of-the-time-i-checked-against-194m-price-snapshots-3enj\">when Polymarket says 70%, does it happen 70% of the time?<\/a><\/strong>.<\/p>\n\n<p>The point of <em>this<\/em> post isn't to re-paste that table. It's to show you the query is trivial once you have the history:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">sqlite3<\/span><span class=\"p\">,<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n\n<span class=\"n\">con<\/span> <span class=\"o\">=<\/span> <span class=\"n\">sqlite3<\/span><span class=\"p\">.<\/span><span class=\"nf\">connect<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_universe.db<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n\n<span class=\"c1\"># Latest price per resolved market, joined to its outcome\n<\/span><span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">read_sql<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"\"\"<\/span><span class=\"s\">\n  SELECT p.market_id, p.outcome, p.price, mo.outcome_label\n  FROM prices p\n  JOIN market_outcomes mo\n    ON p.market_id = mo.market_id\n  WHERE p.outcome = <\/span><span class=\"sh\">'<\/span><span class=\"s\">Yes<\/span><span class=\"sh\">'<\/span><span class=\"s\">\n<\/span><span class=\"sh\">\"\"\"<\/span><span class=\"p\">,<\/span> <span class=\"n\">con<\/span><span class=\"p\">)<\/span>\n\n<span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bucket<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">price<\/span> <span class=\"o\">*<\/span> <span class=\"mi\">10<\/span><span class=\"p\">).<\/span><span class=\"nf\">astype<\/span><span class=\"p\">(<\/span><span class=\"nb\">int<\/span><span class=\"p\">).<\/span><span class=\"nf\">clip<\/span><span class=\"p\">(<\/span><span class=\"mi\">0<\/span><span class=\"p\">,<\/span> <span class=\"mi\">9<\/span><span class=\"p\">)<\/span>\n<span class=\"c1\"># join your resolution labels, then:\n<\/span><span class=\"n\">calib<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bucket<\/span><span class=\"sh\">\"<\/span><span class=\"p\">).<\/span><span class=\"nf\">agg<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">n<\/span><span class=\"o\">=<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">size<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n    <span class=\"n\">mean_price<\/span><span class=\"o\">=<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">mean<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n<span class=\"p\">)<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">calib<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>Five lines of pandas. The hard part \u2014 <em>having 17 million rows of honest, timestamped history to run it against<\/em> \u2014 is the part that took 86 days.<\/p>\n\n<h2>\n  \n  \n  What you can build on top of it\n<\/h2>\n\n<ul>\n<li>\n<strong>Calibration audits<\/strong> \u2014 is a specific market category (politics? sports? crypto?) better calibrated than another?<\/li>\n<li>\n<strong>Longshot fade backtests<\/strong> \u2014 systematically short the sub-10% bucket and measure the edge after fees.<\/li>\n<li>\n<strong>Mean-reversion \/ momentum<\/strong> studies on the minute-level price path.<\/li>\n<li>\n<strong>Event-study windows<\/strong> \u2014 how fast does a market re-price around a news shock?<\/li>\n<\/ul>\n\n<p>All of it needs the same thing: a continuous price record you didn't have to be there to capture.<\/p>\n\n<h2>\n  \n  \n  Get the data\n<\/h2>\n\n<ul>\n<li>\n<strong>Free sample + schema<\/strong> to kick the tires: comment and I'll point you at it.<\/li>\n<li>\n<strong>Full archive (17.2M snapshots, one-time download):<\/strong> <a href=\"https:\/\/manja8.gumroad.com\/l\/agyjd?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-29\" rel=\"noopener noreferrer\">on Gumroad<\/a>.<\/li>\n<li>\n<strong>Monthly refresh<\/strong> (the recorder keeps running; you get the new days): ask in the comments \u2014 I'm pricing it for the people who actually backtest.<\/li>\n<\/ul>\n\n<p>The crowd is mostly calibrated. The edge is in knowing exactly <em>where<\/em> it isn't \u2014 and that only shows up if someone was recording. Someone was.<\/p>\n\n","category":["datascience","trading","python","machinelearning"]},{"title":"17 million Polymarket price snapshots, collected on one Mac for $0\/month","pubDate":"Mon, 22 Jun 2026 10:54:09 +0000","link":"https:\/\/dev.to\/manja316\/17-million-polymarket-price-snapshots-collected-on-one-mac-for-0month-2cen","guid":"https:\/\/dev.to\/manja316\/17-million-polymarket-price-snapshots-collected-on-one-mac-for-0month-2cen","description":"<p>Most prediction-market datasets you find online are a one-time dump someone scraped, posted, and abandoned. They go stale the day after they're published. I wanted a <em>living<\/em> archive of Polymarket \u2014 every market, sampled every 15 minutes, running continuously \u2014 and I wanted it to cost nothing to operate.<\/p>\n\n<p>Here's what the archive holds today, counted straight from the database:<\/p>\n\n<ul>\n<li>\n<strong>18,611,636<\/strong> price snapshots<\/li>\n<li>\n<strong>22,410<\/strong> markets tracked<\/li>\n<li>\n<strong>~92 days<\/strong> of continuous 15-minute history (and counting)<\/li>\n<\/ul>\n\n<p>All of it runs on a single Mac, with a recurring infrastructure bill of <strong>$0\/month<\/strong>. No cloud database, no managed queue, no Kubernetes. This post is how.<\/p>\n\n<h3>\n  \n  \n  The architecture is boring on purpose\n<\/h3>\n\n<p>The whole thing is three moving parts:<\/p>\n\n<ol>\n<li>\n<strong>A collector<\/strong> \u2014 a Python process on a 15-minute timer (launchd, the macOS-native scheduler \u2014 no cron daemon, no external trigger). Each tick pulls the live market list from Polymarket's public API, then for each active market records the current YES\/NO prices and, where one exists, the top of the order book.<\/li>\n<li>\n<strong>One SQLite file<\/strong> \u2014 not Postgres, not a warehouse. SQLite handles tens of millions of rows on a laptop without complaint as long as you index the access path you actually use. The entire archive is a single <code>.db<\/code> file you can <code>scp<\/code> anywhere.<\/li>\n<li>\n<strong>A daily exporter<\/strong> \u2014 dumps the SQLite tables to Parquet so the dataset is portable and loads in one line of pandas\/Polars.<\/li>\n<\/ol>\n\n<p>That's it. The \"$0\/month\" isn't a trick \u2014 a laptop you already own, the OS scheduler you already have, and a file format that doesn't bill you.<\/p>\n\n<h3>\n  \n  \n  One honest caveat: the price series is the real product, not the book\n<\/h3>\n\n<p>The collector also samples top-of-book (best bid \/ best ask) every tick \u2014 but be clear-eyed about it: most Polymarket markets are thin, so a live two-sided book simply doesn't exist at most sample times. Counted straight from the DB, only about <strong>6%<\/strong> of the 1.86M book rows captured a real two-sided quote; the rest are markets with no live book at that moment. So treat the dense, reliable layer as the <strong>price time series<\/strong> (18.6M real snapshots) \u2014 that's what you backtest on. The book samples are a sparse bonus for the handful of liquid markets, not a full reconstructable order book for everything. I'd rather tell you that up front than have you discover it after download.<\/p>\n\n<p><strong>Index the query, not the table.<\/strong> The naive mistake is to over-index and watch your write throughput collapse every 15 minutes. The prices table has exactly one composite index \u2014 <code>(market_id, ts)<\/code> \u2014 because the only read pattern that matters is \"give me the price history of <em>this<\/em> market over <em>this<\/em> window.\" One index, sized to the actual query, keeps both the 15-minute writes and the backtest reads fast.<\/p>\n\n<p><strong>Append, never mutate.<\/strong> Every snapshot is an immutable row stamped with an ISO-8601 UTC timestamp. Nothing is ever updated in place. That means the archive is a true time series \u2014 you can reconstruct the YES\/NO price as it stood at any 15-minute mark in the last 92 days, not just \"latest state.\" Mutable rows would have quietly destroyed the history I was trying to capture.<\/p>\n\n<h3>\n  \n  \n  Why this is hard to copy (and why that matters if you trade)\n<\/h3>\n\n<p>The dataset isn't valuable because the <em>code<\/em> is clever \u2014 it's three boring parts. It's valuable because of the one thing you cannot backfill: <strong>time.<\/strong> You cannot retroactively collect the price as it stood on April 3rd at 14:15 UTC. Either a process was running and recording it, or that moment is gone forever.<\/p>\n\n<p>So if you want 92 days of 15-minute Polymarket history to backtest a mean-reversion or calibration strategy, you have two options: stand up a collector today and wait three months, or start from the archive that's already been running since March 28th.<\/p>\n\n<h3>\n  \n  \n  Get the data\n<\/h3>\n\n<ul>\n<li>\n<strong>Free sample + schema + loader:<\/strong> <a href=\"https:\/\/github.com\/LuciferForge\/polymarket-historical-data\" rel=\"noopener noreferrer\">github.com\/LuciferForge\/polymarket-historical-data<\/a> \u2014 grab the sample, check the schema, run the example query before you commit to anything.<\/li>\n<li>\n<strong>Full dataset (one-time download):<\/strong> <a href=\"https:\/\/manja8.gumroad.com\/l\/polymarket-quant-toolkit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-29\" rel=\"noopener noreferrer\">on Gumroad<\/a>.<\/li>\n<\/ul>\n\n<p>If you'd want this kept fresh automatically \u2014 a recurring refresh so your backtests never run on a frozen file \u2014 that's the thing I'm deciding whether to build next. There's an open roadmap thread on the repo; tell me what cadence and format you'd actually use.<\/p>\n\n<p><em>All figures in this post were counted from the database export built on 2026-06-28 and are not estimates.<\/em><\/p>\n\n","category":["python","datascience","sqlite","opensource"]},{"title":"What 295,732 IPL balls reveal: chase to win, and the death overs are a different game","pubDate":"Mon, 22 Jun 2026 02:01:25 +0000","link":"https:\/\/dev.to\/manja316\/what-295732-ipl-balls-reveal-chase-to-win-and-the-death-overs-are-a-different-game-3nf2","guid":"https:\/\/dev.to\/manja316\/what-295732-ipl-balls-reveal-chase-to-win-and-the-death-overs-are-a-different-game-3nf2","description":"<p>I cleaned every IPL delivery from 2008 to 2026 into one tidy ball-by-ball table \u2014 295,732 rows \u2014 and added the columns I always end up recomputing by hand (match phase, running run-rate, per-batter tallies, and fantasy box-scores). Then I asked the data three simple questions.<\/p>\n\n<h2>\n  \n  \n  1. Chasing wins \u2014 but the toss barely matters\n<\/h2>\n\n<p>Across 1,218 completed matches, the toss-winner won just <strong>51.6%<\/strong> of the time. Close to a coin flip \u2014 <em>until you look at what they do with it.<\/em><\/p>\n\n<p>Captains who won the toss and chose to <strong>field (chase)<\/strong> won <strong>54.7%<\/strong> of their matches. Those who chose to <strong>bat first<\/strong> won only <strong>45.3%<\/strong>. A ~9-point swing from one decision \u2014 chasing is a real, persistent edge in the IPL.<\/p>\n\n<h2>\n  \n  \n  2. The death overs are a different sport\n<\/h2>\n\n<p>Run rate by phase, across every IPL ball:<\/p>\n\n<div class=\"table-wrapper-paragraph\"><table>\n<thead>\n<tr>\n<th>Phase<\/th>\n<th>Run rate<\/th>\n<th>Wickets \/ over<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Powerplay (1\u20136)<\/td>\n<td>7.78<\/td>\n<td>0.23<\/td>\n<\/tr>\n<tr>\n<td>Middle (7\u201315)<\/td>\n<td>7.81<\/td>\n<td>0.26<\/td>\n<\/tr>\n<tr>\n<td><strong>Death (16\u201320)<\/strong><\/td>\n<td><strong>9.78<\/strong><\/td>\n<td><strong>0.52<\/strong><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/div>\n\n<p>Scoring barely moves from powerplay to middle, then explodes at the death \u2014 but the wicket rate <strong>more than doubles<\/strong>. If you're modeling fantasy points or win probability, treating all overs the same leaves signal on the table.<\/p>\n\n<h2>\n  \n  \n  3. Do it yourself\n<\/h2>\n\n<p>The dataset is one flat CSV, so this is a three-liner:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">read_csv<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">ipl_ballbyball.csv<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">phase<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">runs_total<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">sum<\/span><span class=\"p\">()<\/span> <span class=\"o\">*<\/span> <span class=\"mi\">6<\/span> <span class=\"o\">\/<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">phase<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">ball_in_innings<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">count<\/span><span class=\"p\">())<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<h2>\n  \n  \n  The data\n<\/h2>\n\n<ul>\n<li>\n<strong>Free sample on Kaggle:<\/strong> <a href=\"https:\/\/www.kaggle.com\/datasets\/luciferforge\/ipl-cricket-ballbyball-enriched\" rel=\"noopener noreferrer\">IPL ball-by-ball + fantasy box-scores<\/a>\n<\/li>\n<li>\n<strong>Full 19-season history (295,732 deliveries + 27,374 player-match fantasy box-scores), auto-refreshed weekly:<\/strong> <a href=\"https:\/\/manja8.gumroad.com\/l\/ipl-cricket\" rel=\"noopener noreferrer\">$12 on Gumroad<\/a>\n<\/li>\n<li>Internationals too: <a href=\"https:\/\/www.kaggle.com\/datasets\/luciferforge\/t20i-cricket-ballbyball-enriched\" rel=\"noopener noreferrer\">765k+ T20I deliveries on Kaggle<\/a>\n<\/li>\n<\/ul>\n\n<p>Built from <a href=\"https:\/\/cricsheet.org\" rel=\"noopener noreferrer\">Cricsheet<\/a> (ODC-BY). Columns include match phase, live run rate, running score, per-batter tallies, and per-player fantasy box-scores \u2014 so you can model straight away instead of parsing raw JSON.<\/p>\n\n","category":["datascience","python","cricket","tutorial"]},{"title":"Supervising 13 always-on processes without Kubernetes: the log-freshness health check that ended my 3 a.m. babysitting","pubDate":"Sat, 20 Jun 2026 22:54:36 +0000","link":"https:\/\/dev.to\/manja316\/supervising-13-always-on-processes-without-kubernetes-the-log-freshness-health-check-that-ended-my-4g4l","guid":"https:\/\/dev.to\/manja316\/supervising-13-always-on-processes-without-kubernetes-the-log-freshness-health-check-that-ended-my-4g4l","description":"<p>I'm a solo builder. At any given moment I have a handful of always-on processes earning, scraping, or thinking on my behalf: a Polymarket crash-recovery trading bot, a weekly site-scraper that regenerates a ~14k-entry directory, a couple of API servers, a secret-scanner, a DB-retention job, and a few scheduled agents that call an LLM to review strategy or hunt vulnerabilities. Thirteen in total.<\/p>\n\n<p>For months these lived as a mess of <code>launchd<\/code> plists, loose <code>crontab<\/code> lines, and <code>nohup \u2026 &amp;<\/code> processes I'd forget about until one silently died \u2014 and I'd notice <em>days<\/em> later, usually because a number on a dashboard stopped moving. That's the classic agent-ops failure mode: <strong>lots of independent autonomous processes, no unified supervisor.<\/strong> Each one is individually simple. Collectively they're un-auditable.<\/p>\n\n<p>I didn't want Kubernetes. For one solo dev's always-on jobs, k8s is a sledgehammer \u2014 a control plane heavier than everything it would supervise. I wanted the <em>smallest<\/em> thing that would give me one honest answer to one question: <strong>is everything that's supposed to be running actually running, and actually working?<\/strong><\/p>\n\n<p>So I built ForgeOS: a tiny kernel (~1,900 LOC of Python, zero heavy deps \u2014 just <code>pyyaml<\/code>) that treats every process as an <strong>engine<\/strong> defined by a single YAML file.<\/p>\n\n<h2>\n  \n  \n  Three engine types, one primitive\n<\/h2>\n\n\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight plaintext\"><code>daemon        long-running        the trading bot, API servers, collectors\ncron          scheduled           weekly export, daily scan\nintelligence  scheduled + LLM     a Claude-CLI agent run on a schedule, output piped back in\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The third type is the one people raise an eyebrow at, so let me defend it early: an <strong>intelligence engine is just a cron job that happens to call an LLM.<\/strong> Same scheduling, same health checks, same logging as any other engine. The moment I stopped treating \"AI agents\" as special snowflakes and modeled them as the same primitive as a backup job, the whole system got simpler. A scheduled strategy-review agent and a nightly DB-prune are the same shape; only the <code>command<\/code> differs.<\/p>\n\n<h2>\n  \n  \n  The part that actually changed my life: health + self-heal\n<\/h2>\n\n<p>Each engine declares what \"healthy\" means <em>right in its config<\/em>:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight yaml\"><code><span class=\"na\">name<\/span><span class=\"pi\">:<\/span> <span class=\"s\">crash-bot<\/span>\n<span class=\"na\">type<\/span><span class=\"pi\">:<\/span> <span class=\"s\">daemon<\/span>\n<span class=\"na\">description<\/span><span class=\"pi\">:<\/span> <span class=\"s\">Polymarket crash-recovery bot<\/span>\n<span class=\"na\">command<\/span><span class=\"pi\">:<\/span> <span class=\"pi\">[<\/span><span class=\"s2\">\"<\/span><span class=\"s\">python3\"<\/span><span class=\"pi\">,<\/span> <span class=\"s2\">\"<\/span><span class=\"s\">pm_crash_monitor.py\"<\/span><span class=\"pi\">]<\/span>\n<span class=\"na\">cwd<\/span><span class=\"pi\">:<\/span> <span class=\"s\">~\/...\/agents\/trader<\/span>\n<span class=\"na\">health<\/span><span class=\"pi\">:<\/span>\n  <span class=\"na\">process<\/span><span class=\"pi\">:<\/span> <span class=\"s\">pm_crash_monitor<\/span>      <span class=\"c1\"># must appear in the process table<\/span>\n  <span class=\"na\">log_max_age_min<\/span><span class=\"pi\">:<\/span> <span class=\"m\">20<\/span>            <span class=\"c1\"># log must have been written in the last 20 min<\/span>\n  <span class=\"na\">log_path<\/span><span class=\"pi\">:<\/span> <span class=\"s\">\/tmp\/pm_crash_monitor.log<\/span>\n<span class=\"na\">kill_condition<\/span><span class=\"pi\">:<\/span> <span class=\"s2\">\"<\/span><span class=\"s\">daily_loss<\/span><span class=\"nv\"> <\/span><span class=\"s\">&gt;<\/span><span class=\"nv\"> <\/span><span class=\"s\">$10<\/span><span class=\"nv\"> <\/span><span class=\"s\">OR<\/span><span class=\"nv\"> <\/span><span class=\"s\">cash<\/span><span class=\"nv\"> <\/span><span class=\"s\">&lt;<\/span><span class=\"nv\"> <\/span><span class=\"s\">$20\"<\/span>\n<span class=\"na\">env<\/span><span class=\"pi\">:<\/span>\n  <span class=\"na\">LIVE_TRADING<\/span><span class=\"pi\">:<\/span> <span class=\"s2\">\"<\/span><span class=\"s\">true\"<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The kernel runs a loop: for each enabled engine, check the declared health signals \u2014 is the process alive, is the log fresh? If a daemon is dead <em>or its log has gone stale<\/em>, restart it and record the event. One command, <code>forge health<\/code>, tells me the true state of all 13 engines instead of me SSH-ing around running <code>ps aux | grep<\/code>.<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight shell\"><code>forge init       <span class=\"c\"># creates ~\/.forgeos\/<\/span>\nforge start      <span class=\"c\"># start all daemon engines<\/span>\nforge health     <span class=\"c\"># the one honest answer<\/span>\nforge daemon     <span class=\"c\"># run the kernel: health + scheduling + self-heal<\/span>\nforge brief      <span class=\"c\"># one-screen status of everything<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<h2>\n  \n  \n  Three lessons that generalize beyond my setup\n<\/h2>\n\n<p>If you run autonomous agents, these cost me real downtime to learn:<\/p>\n\n<p><strong>1. \"Is the process running?\" is a near-useless health check on its own.<\/strong> A <em>hung<\/em> process is \"running.\" The PID is right there. My collectors and AI agents fail far more often by <em>getting stuck<\/em> than by <em>crashing<\/em> \u2014 and a stuck process keeps its PID forever. Adding <code>log_max_age_min<\/code> (the log must have been written recently) caught dramatically more real failures than PID checks ever did. Liveness \u2260 progress. Health-check progress.<\/p>\n\n<p><strong>2. Declare kill conditions in config, not buried in code.<\/strong> My trading engine carries <code>kill_condition: daily_loss &gt; $10 OR cash &lt; $20<\/code> next to its definition \u2014 not 400 lines deep in the bot's source. Keeping the safety limit <em>visible<\/em>, beside the engine it governs, is what made me actually willing to let a money-moving process run unattended. A safety limit you can't see at a glance is a safety limit you don't trust.<\/p>\n\n<p><strong>3. Model AI agents as ordinary engines.<\/strong> The temptation is to build a special \"agent runtime.\" Resist it. An LLM call on a schedule is a cron job with an expensive <code>command<\/code>. Give it the same health loop (did it run? is its output log fresh?) and the same restart policy as everything else. The non-determinism lives <em>inside<\/em> the <code>command<\/code>; the supervision around it should be boring.<\/p>\n\n<h2>\n  \n  \n  The honest part: where this still falls short\n<\/h2>\n\n<p>Log-freshness answers \"did it run.\" It does <strong>not<\/strong> answer \"did it run <em>well<\/em>\" \u2014 which for an intelligence engine (an LLM whose output is non-deterministic text) is the question that actually matters. Right now that's still manual review for me: the agent reliably <em>runs<\/em>, but judging whether its strategy review was <em>good<\/em> is a human gate. I haven't automated the quality check, and I'm not convinced a naive \"LLM grades the LLM\" loop is trustworthy enough to remove me from it yet. If you've solved non-deterministic-output health-checking for scheduled agents, I genuinely want to hear how \u2014 that's the open edge of this design.<\/p>\n\n<h2>\n  \n  \n  Status\n<\/h2>\n\n<p>ForgeOS is <strong>pre-release (v0.1.0)<\/strong> and built in public \u2014 it's not on PyPI or a public repo <em>yet<\/em>; today it installs from a source checkout (<code>pip install -e .<\/code> from the project root). I'm writing this up now because the architecture (declarative engines + log-freshness health + self-heal, no k8s) is the reusable part, and it stands on its own whether or not you ever run my code.<\/p>\n\n<p>If you're supervising long-running or scheduled agents today \u2014 systemd? a hosted orchestrator? homegrown? \u2014 I'd love to compare notes in the comments, especially on that last open problem: how do you health-check an agent whose output is non-deterministic text?<\/p>\n\n","category":["python","devops","ai","automation"]},{"title":"MCP's real attack surface isn't prompt injection \u2014 it's the trust boundary (21 patterns, 5 languages)","pubDate":"Fri, 19 Jun 2026 22:53:24 +0000","link":"https:\/\/dev.to\/manja316\/mcps-real-attack-surface-isnt-prompt-injection-its-the-trust-boundary-21-patterns-5-554","guid":"https:\/\/dev.to\/manja316\/mcps-real-attack-surface-isnt-prompt-injection-its-the-trust-boundary-21-patterns-5-554","description":"<p>We keep talking about prompt injection like it's the endgame. It isn't. Prompt injection is step one. The actual damage happens one step later, at a place most MCP threat models barely mention: the <strong>trust boundary<\/strong> \u2014 the moment an injected instruction turns into a real tool call that runs with <em>your<\/em> machine's privileges.<\/p>\n\n<p>I build and run a handful of MCP servers locally, and the more server source I read, the more I became convinced we're auditing the wrong layer. So I built a small MCP server whose only job is to audit <em>other<\/em> MCP servers. This is the write-up of what it looks for and why \u2014 the threat model is the reusable part whether or not you ever run my code.<\/p>\n\n<h2>\n  \n  \n  The chain that should keep you up at night\n<\/h2>\n\n<p>An MCP server is, by design, code that an LLM is <em>allowed<\/em> to invoke. That's the whole point. Which means the dangerous chain is:<\/p>\n\n<blockquote>\n<p>untrusted input \u2192 model is convinced \u2192 tool call fires \u2192 server-side code executes on your box<\/p>\n<\/blockquote>\n\n<p>The injection was never the vulnerability. The injection is just the <em>delivery mechanism<\/em>. The vulnerability is what the tool <strong>does<\/strong> once it's called. If the server-side handler does anything unsafe with the arguments it receives, the model has just become a remote-code-execution courier \u2014 and it did it while behaving exactly as designed.<\/p>\n\n<p>This reframes the problem in a useful way: <strong>most MCP security is just appsec, newly reachable through a model.<\/strong> The bugs that matter are 20-year-old classics. What changed is who can reach them. Pre-MCP, a lot of this code was only callable by a trusted caller. Now a sentence in a webpage your agent reads can reach it.<\/p>\n\n<h2>\n  \n  \n  The patterns that keep showing up\n<\/h2>\n\n<p>Reading through MCP server source, the same handful of mistakes appear over and over. These are the OWASP greatest hits, scored by how badly they bite when they sit one tool-call away:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight plaintext\"><code>eval() \/ exec() \/ os.system() on tool arguments     CRITICAL  direct code injection\nsubprocess(..., shell=True), Runtime.exec(concat)    HIGH      command injection\npickle.load \/ torch.load \/ ObjectInputStream         HIGH      deserialization RCE\nyaml.load() without SafeLoader                        HIGH      object instantiation\nf-string \/ string-concat SQL                          HIGH      SQL injection\nURL built by concatenation (fetch tools)              MEDIUM    SSRF \/ network pivot\nhardcoded API keys \/ tokens in server source          MEDIUM    credential leak\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>None of these are exotic. That's the point. The scanner I built detects <strong>21 vulnerability patterns across Python, Java, Go, C++, and Rust<\/strong> \u2014 and almost every one predates LLMs entirely. The novelty isn't the bug class; it's the reachability.<\/p>\n\n<h2>\n  \n  \n  The design decision that mattered most: purpose-aware severity\n<\/h2>\n\n<p>The naive version of a scanner like this is a glorified <code>grep eval<\/code>. It floods you with false positives and you stop reading the output by day two. An <code>eval()<\/code> inside a sandboxed test harness is not the same finding as an <code>eval()<\/code> on a tool's input argument \u2014 and a scanner that scores them identically is noise.<\/p>\n\n<p>So the core of the tool is <strong>purpose-aware scoring<\/strong>: it weighs a pattern by where it sits and what reaches it. A <code>subprocess(shell=True)<\/code> in a CI helper that never touches model input is a low-priority note. The same call wired to a tool argument is a CRITICAL. Getting this right is the difference between a report someone acts on and a report someone closes.<\/p>\n\n<p>The scanner ships as an MCP server itself, with three tools:<\/p>\n\n<ul>\n<li>\n<code>audit_repo<\/code> \u2014 point it at a GitHub URL, get a scored report<\/li>\n<li>\n<code>audit_code<\/code> \u2014 paste a snippet, get findings inline<\/li>\n<li>\n<code>list_patterns<\/code> \u2014 see every pattern and its severity<\/li>\n<\/ul>\n\n<p>Because it runs locally as an MCP server in Claude Desktop, your agent can audit a repo <em>before you wire it in<\/em> \u2014 which is the right time to find out a server you're about to trust shells out on its arguments.<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight shell\"><code>pip <span class=\"nb\">install <\/span>mcp-security-audit\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<h2>\n  \n  \n  Two lessons that generalize beyond MCP\n<\/h2>\n\n<p><strong>1. The injection is not the vuln \u2014 the handler is.<\/strong> Spend your defensive budget on what tool code does with arguments, not only on filtering what reaches the model. Input filtering is a sieve; a safe handler is a wall. You want the wall.<\/p>\n\n<p><strong>2. Severity without context is noise that trains people to ignore you.<\/strong> Any scanner that can't tell a sandboxed <code>eval<\/code> from a reachable one will get muted. Context-awareness isn't a nice-to-have on a security tool \u2014 it's the feature that decides whether anyone reads the second report.<\/p>\n\n<h2>\n  \n  \n  The honest part: where this still falls short\n<\/h2>\n\n<p>Static patterns catch reachable-by-shape, not reachable-in-fact. A taint analysis that proves an argument actually flows into a dangerous sink would cut false positives further \u2014 that's the open edge of this design, and it's not built yet. And the two pattern families I'm least confident I've covered well are <strong>path traversal<\/strong> in file-serving tools and <strong>SSRF<\/strong> in fetch-style tools that are <em>built<\/em> to make outbound requests, where \"is this call malicious\" is genuinely ambiguous. If you've solved the fetch-tool SSRF problem cleanly, I want to hear how.<\/p>\n\n<h2>\n  \n  \n  Where to find it\n<\/h2>\n\n<p>The scanner is open-source (MIT) and free:<\/p>\n\n<ul>\n<li>\n<strong>Repo + star:<\/strong> <a href=\"https:\/\/github.com\/LuciferForge\/mcp-security-audit\" rel=\"noopener noreferrer\">https:\/\/github.com\/LuciferForge\/mcp-security-audit<\/a>\n<\/li>\n<li>\n<strong>Install:<\/strong> <code>pip install mcp-security-audit<\/code>\n<\/li>\n<\/ul>\n\n<p>If you'd rather not run it yourself \u2014 if you're shipping an MCP server and want a scored, human-reviewed audit report with a 3-day turnaround instead of a raw scan dump \u2014 I offer that as a paid service here: <a href=\"https:\/\/manja8.gumroad.com\/l\/mcp-security-audit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=mcp-security-audit-2026-06-22\" rel=\"noopener noreferrer\">MCP Security Audit Report \u2014 $29<\/a>.<\/p>\n\n<p>But I'm more interested in the threat-model discussion than either. Two open questions for the comments:<\/p>\n\n<ol>\n<li>\n<strong>Where should the boundary live?<\/strong> Sandbox every tool call by default (perf + DX cost) or scan-and-trust at install time? I lean install-time scanning plus runtime allow-lists, and I'd like to be talked out of it.<\/li>\n<li>\n<strong>What pattern am I missing?<\/strong> I've got 21. Tell me the 22nd.<\/li>\n<\/ol>\n\n","category":["security","ai","python","mcp"]},{"title":"How much edge is left in the final 48h of a Polymarket? I measured price convergence on 18.6M snapshots","pubDate":"Thu, 18 Jun 2026 22:54:17 +0000","link":"https:\/\/dev.to\/manja316\/how-much-edge-is-left-in-the-final-48h-of-a-polymarket-i-measured-price-convergence-on-186m-51bi","guid":"https:\/\/dev.to\/manja316\/how-much-edge-is-left-in-the-final-48h-of-a-polymarket-i-measured-price-convergence-on-186m-51bi","description":"<p>A question I kept circling when I started treating prediction markets as a trading venue: by the time a market is <em>obviously<\/em> heading one way, is the move already priced in \u2014 or is there still a systematic gap between price and outcome you can lean on?<\/p>\n\n<p>\"Buy the favorite at 0.85, collect the 0.15\" sounds like free money until you account for the markets where the favorite loses. So I stopped eyeballing it and computed it.<\/p>\n\n<h2>\n  \n  \n  The data\n<\/h2>\n\n<p>I've been archiving Polymarket order books every 15 minutes since late March 2026. As of mid-June that's <strong>~18.6M price snapshots across ~18.6k markets<\/strong> (~77 days, most of them resolved). Prediction markets are one of the few venues where you get the probability estimate, the <em>full path<\/em> it took, <strong>and<\/strong> the realized 0\/1 outcome \u2014 so convergence is actually measurable instead of hand-waved.<\/p>\n\n<h2>\n  \n  \n  The measurement: how far is price from truth, as a function of time-to-resolution?\n<\/h2>\n\n<p>For every resolved market, line up each snapshot by hours-to-resolution, then measure the mean absolute error between price and the eventual outcome in each time bucket:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n\n<span class=\"c1\"># df: rows of (market_id, ts, price_yes, resolved_outcome[0\/1], resolve_ts)\n<\/span><span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">hrs_to_res<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">resolve_ts<\/span> <span class=\"o\">-<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">ts<\/span><span class=\"p\">).<\/span><span class=\"n\">dt<\/span><span class=\"p\">.<\/span><span class=\"nf\">total_seconds<\/span><span class=\"p\">()<\/span> <span class=\"o\">\/<\/span> <span class=\"mi\">3600<\/span>\n<span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">abs_err<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span>    <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">price_yes<\/span> <span class=\"o\">-<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">resolved_outcome<\/span><span class=\"p\">).<\/span><span class=\"nf\">abs<\/span><span class=\"p\">()<\/span>\n\n<span class=\"n\">bins<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[<\/span><span class=\"mi\">0<\/span><span class=\"p\">,<\/span> <span class=\"mi\">1<\/span><span class=\"p\">,<\/span> <span class=\"mi\">6<\/span><span class=\"p\">,<\/span> <span class=\"mi\">12<\/span><span class=\"p\">,<\/span> <span class=\"mi\">24<\/span><span class=\"p\">,<\/span> <span class=\"mi\">48<\/span><span class=\"p\">,<\/span> <span class=\"mi\">96<\/span><span class=\"p\">,<\/span> <span class=\"mi\">168<\/span><span class=\"p\">,<\/span> <span class=\"mf\">1e9<\/span><span class=\"p\">]<\/span>\n<span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">window<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">cut<\/span><span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"n\">hrs_to_res<\/span><span class=\"p\">,<\/span> <span class=\"n\">bins<\/span><span class=\"p\">)<\/span>\n\n<span class=\"n\">conv<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">window<\/span><span class=\"sh\">\"<\/span><span class=\"p\">).<\/span><span class=\"nf\">agg<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">mae<\/span>     <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">abs_err<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">mean<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n    <span class=\"n\">brier<\/span>   <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">abs_err<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"k\">lambda<\/span> <span class=\"n\">s<\/span><span class=\"p\">:<\/span> <span class=\"p\">(<\/span><span class=\"n\">s<\/span><span class=\"o\">**<\/span><span class=\"mi\">2<\/span><span class=\"p\">).<\/span><span class=\"nf\">mean<\/span><span class=\"p\">()),<\/span>  <span class=\"c1\"># abs_err == |p - outcome|\n<\/span>    <span class=\"n\">n_snaps<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">abs_err<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">size<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n<span class=\"p\">)<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">conv<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>That's the whole thing. No model, no backtest \u2014 just a descriptive stat that most people assume the shape of instead of computing.<\/p>\n\n<h2>\n  \n  \n  What the curve actually shows\n<\/h2>\n\n<ul>\n<li>\n<strong>Error shrinks fast in the last 24h, but it is <em>not<\/em> zero at the buzzer.<\/strong> There's a non-trivial residual right up to resolution \u2014 and that residual is concentrated in exactly the set of markets where \"the obvious favorite\" flips. Those tail markets are where a naive favorite-buying strategy quietly bleeds.<\/li>\n<li>\n<strong>The 24\u201348h bucket is the interesting one for a trader.<\/strong> Most of the information has arrived, spreads have tightened \u2014 yet realized error is still meaningfully above the last-hour bucket. That gap is the thing to interrogate: is it edge, or is it just the variance of unresolved coin-flips that <em>look<\/em> settled?<\/li>\n<\/ul>\n\n<h2>\n  \n  \n  The caveats people skip (and why I'm posting the method, not a strategy)\n<\/h2>\n\n<p><strong>1. Survivorship \/ resolution-time leakage.<\/strong> <code>resolve_ts<\/code> is known only ex-post. If you bucket by hours-to-resolution you're implicitly conditioning on a market that <em>did<\/em> resolve when it did \u2014 fine for a descriptive convergence curve, fatal if you turn it into a live signal without a causal \"what did I know at time <em>t<\/em>\" cut.<\/p>\n\n<p><strong>2. Liquidity \u2260 midprice.<\/strong> The curve above uses last-trade \/ mid. The order book (I store depth too) tells a different story near the buzzer: getting size off at the \"converged\" price is often impossible, and that illiquidity is precisely where the residual error lives.<\/p>\n\n<p><strong>3. Resolution risk is not modeled.<\/strong> Some of that terminal error is genuine ambiguity or dispute, not mispricing. You can't arb a market whose <em>resolution criteria themselves<\/em> are the uncertain part.<\/p>\n\n<h2>\n  \n  \n  The takeaway\n<\/h2>\n\n<p>I'm not claiming an edge. I'm claiming the <strong>measurement is cheap and most people eyeball it instead of computing it.<\/strong> If you trade these, the convergence curve plus an honest point-in-time cut is the first thing worth building \u2014 before any strategy logic.<\/p>\n\n<h2>\n  \n  \n  Reproduce it\n<\/h2>\n\n<p>The data behind this is open:<\/p>\n\n<ul>\n<li>\n<strong>Free API, no signup:<\/strong> <a href=\"https:\/\/api.protodex.io\" rel=\"noopener noreferrer\">api.protodex.io<\/a> \u2014 <code>\/stats<\/code>, <code>\/markets<\/code>, <code>\/market\/{id}<\/code>, <code>\/prices<\/code>, <code>\/orderbook<\/code>, <code>\/categories<\/code>. Enough to rebuild the convergence curve yourself.<\/li>\n<li>\n<strong>Full historical archive<\/strong> (18.7M+ snapshots, depth-level order books, resolved outcomes \u2014 the version I run this analysis on): <a href=\"https:\/\/manja8.gumroad.com\/l\/polymarket-quant-toolkit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-week25\" rel=\"noopener noreferrer\">Polymarket Quant Toolkit dataset<\/a>.<\/li>\n<\/ul>\n\n<p>What's your experience with the last-48h window \u2014 real residual edge, or just liquidity-trapped variance? I'd genuinely like to compare notes in the comments.<\/p>\n\n","category":["python","datascience","trading","statistics"]},{"title":"When Polymarket says 70%, does it happen 70%? Why price-only data can't answer that \u2014 and what it can","pubDate":"Tue, 16 Jun 2026 10:51:24 +0000","link":"https:\/\/dev.to\/manja316\/when-polymarket-says-70-does-it-happen-70-of-the-time-i-checked-against-194m-price-snapshots-3enj","guid":"https:\/\/dev.to\/manja316\/when-polymarket-says-70-does-it-happen-70-of-the-time-i-checked-against-194m-price-snapshots-3enj","description":"<blockquote>\n<p><strong>Correction (2026-06-27):<\/strong> An earlier version of this post implied the dataset ships with per-market 0\/1 resolution labels and that I had measured calibration directly. That was wrong, and I'd rather fix it loudly than quietly. <strong>This is a <em>price<\/em> dataset \u2014 it does not contain settled outcome labels.<\/strong> True calibration needs realized outcomes you have to join in from an external source. Below is the honest version: what price-only data <em>can<\/em> tell you, the legit proxy it gives you, and exactly what you'd have to add to score real calibration.<\/p>\n<\/blockquote>\n\n<p>If you trade, model, or just read prediction markets, there's one question that decides whether the price means anything: <strong>when the market says 70%, does the thing actually happen about 70% of the time?<\/strong><\/p>\n\n<p>That's <em>calibration<\/em>, and it's the single most decision-relevant property of any probabilistic forecaster. A market can be liquid, popular, and heavily traded and still be systematically wrong in a way that's invisible until you score it against what actually resolved.<\/p>\n\n<p>Here's the honest catch I have to lead with: <strong>measuring calibration needs two things \u2014 a dense price history AND the realized outcome of each market.<\/strong> I have the first cleanly. The second is <em>not<\/em> in this dataset, and any vendor (me included) who shows you a \"calibration curve\" derived from prices alone is measuring the market against itself, not against reality.<\/p>\n\n<h2>\n  \n  \n  The dataset (what's actually in it)\n<\/h2>\n\n<p>Since late March I've logged Polymarket every 15 minutes. The frozen export holds:<\/p>\n\n<ul>\n<li>\n<strong>22,410<\/strong> markets<\/li>\n<li>\n<strong>18,611,636<\/strong> price snapshots (\u2248831 per market with a series)<\/li>\n<li>\n<strong>1,856,388<\/strong> order-book snapshots<\/li>\n<li>15-minute cadence, <strong>92 continuous days<\/strong> (2026-03-28 \u2192 2026-06-28)<\/li>\n<li>Each row: market id, timestamp, yes-price, plus volume \/ liquidity \/ best bid-ask features<\/li>\n<\/ul>\n\n<p>What it does <strong>not<\/strong> carry: a per-market settled <code>0\/1<\/code> outcome. <code>SELECT COUNT(*) FROM markets WHERE resolved=1<\/code> returns <strong>0<\/strong> for all 22,410 markets in the export \u2014 these are price paths, not graded results. So you cannot, from this file alone, compute \"of the times the price sat at 70%, how often did the event happen.\" You need the resolution labels, and those live outside the price feed (Polymarket's resolution \/ the on-chain settlement).<\/p>\n\n<h2>\n  \n  \n  What price-only data CAN give you: the convergence proxy\n<\/h2>\n\n<p>You don't get true calibration, but you <em>do<\/em> get a useful, honest proxy. Of the <strong>7,101<\/strong> markets that ended inside the window <em>and<\/em> still carry a terminal price series, <strong>6,836 (96.3%)<\/strong> closed decisively \u2014 last yes-price \u2265 0.95 or \u2264 0.05. Only <strong>265 (3.7%)<\/strong> were still mushy in the middle at the end. <em>(Denominator matters: measured across **all 19,584<\/em>* markets whose end-date fell in the window \u2014 many with no live terminal quote \u2014 the decisive share drops sharply, so always state which denominator you mean.)*<\/p>\n\n<p>That terminal price is a <em>noisy<\/em> stand-in for the outcome: ~96.3% of the time the market made up its mind hard enough that \"did it close near 1?\" is a defensible label. It's not ground truth (the 3.7% ambiguous tail and any post-close revision are exactly where it breaks), but it lets you study the <em>shape<\/em> of price convergence \u2014 how and when a market sharpens \u2014 which is genuinely informative on its own.<\/p>\n\n<h2>\n  \n  \n  The real calibration measurement (what you'd add)\n<\/h2>\n\n<p>The classic check is a <strong>reliability diagram<\/strong>, and the method is fine \u2014 it just needs labels you join in:<\/p>\n\n<ol>\n<li>Get realized outcomes for resolved markets <strong>from an external resolution source<\/strong> (the price feed won't give them to you).<\/li>\n<li>Bin every historical price into deciles (0\u201310%, 10\u201320%, \u2026 90\u2013100%).<\/li>\n<li>For each bin, compute the empirical resolution rate \u2014 of all the times the price sat in that bin, how often did the event actually happen?<\/li>\n<li>Plot empirical rate vs. stated price. Perfect calibration is the 45\u00b0 diagonal.\n<\/li>\n<\/ol>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">requests<\/span><span class=\"p\">,<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n\n<span class=\"n\">BASE<\/span> <span class=\"o\">=<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">https:\/\/api.protodex.io<\/span><span class=\"sh\">\"<\/span>   <span class=\"c1\"># free price API, no signup\n<\/span>\n<span class=\"c1\"># Prices come from the dataset\/API. LABELS DO NOT \u2014 you must supply them.\n# `resolutions` here is an external {market_id: 0\/1} you join in yourself\n# (Polymarket resolution \/ on-chain settlement). It is NOT in this price feed.\n<\/span><span class=\"n\">resolutions<\/span> <span class=\"o\">=<\/span> <span class=\"nf\">load_external_resolution_labels<\/span><span class=\"p\">()<\/span>   <span class=\"c1\"># &lt;-- the part you provide\n<\/span>\n<span class=\"n\">rows<\/span> <span class=\"o\">=<\/span> <span class=\"p\">[]<\/span>\n<span class=\"k\">for<\/span> <span class=\"n\">market_id<\/span><span class=\"p\">,<\/span> <span class=\"n\">label<\/span> <span class=\"ow\">in<\/span> <span class=\"n\">resolutions<\/span><span class=\"p\">.<\/span><span class=\"nf\">items<\/span><span class=\"p\">():<\/span>\n    <span class=\"n\">prices<\/span> <span class=\"o\">=<\/span> <span class=\"n\">requests<\/span><span class=\"p\">.<\/span><span class=\"nf\">get<\/span><span class=\"p\">(<\/span><span class=\"sa\">f<\/span><span class=\"sh\">\"<\/span><span class=\"si\">{<\/span><span class=\"n\">BASE<\/span><span class=\"si\">}<\/span><span class=\"s\">\/prices<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"n\">params<\/span><span class=\"o\">=<\/span><span class=\"p\">{<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">:<\/span> <span class=\"n\">market_id<\/span><span class=\"p\">}).<\/span><span class=\"nf\">json<\/span><span class=\"p\">()<\/span>\n    <span class=\"k\">for<\/span> <span class=\"n\">p<\/span> <span class=\"ow\">in<\/span> <span class=\"n\">prices<\/span><span class=\"p\">:<\/span>\n        <span class=\"n\">rows<\/span><span class=\"p\">.<\/span><span class=\"nf\">append<\/span><span class=\"p\">((<\/span><span class=\"n\">p<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">yes_price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">],<\/span> <span class=\"n\">label<\/span><span class=\"p\">))<\/span>\n\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nc\">DataFrame<\/span><span class=\"p\">(<\/span><span class=\"n\">rows<\/span><span class=\"p\">,<\/span> <span class=\"n\">columns<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">outcome<\/span><span class=\"sh\">\"<\/span><span class=\"p\">])<\/span>\n<span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bin<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">*<\/span> <span class=\"mi\">10<\/span><span class=\"p\">).<\/span><span class=\"nf\">clip<\/span><span class=\"p\">(<\/span><span class=\"mi\">0<\/span><span class=\"p\">,<\/span> <span class=\"mi\">9<\/span><span class=\"p\">).<\/span><span class=\"nf\">astype<\/span><span class=\"p\">(<\/span><span class=\"nb\">int<\/span><span class=\"p\">)<\/span>\n<span class=\"n\">reliability<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bin<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">outcome<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">mean<\/span><span class=\"p\">()<\/span>   <span class=\"c1\"># empirical rate per decile\n<\/span><span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">reliability<\/span><span class=\"p\">)<\/span>   <span class=\"c1\"># compare each row to its bin midpoint -&gt; the diagonal\n<\/span><\/code><\/pre>\n\n<\/div>\n\n\n\n<p>If you only have the price feed and substitute the convergence proxy for <code>label<\/code>, be explicit that you're scoring the market against its own terminal price \u2014 a different, weaker claim than calibration against reality.<\/p>\n\n<h2>\n  \n  \n  What to look for (and the trap)\n<\/h2>\n\n<p>The literature on real-money markets has a well-documented signature: the <strong>favorite\u2013longshot bias<\/strong> \u2014 longshots tend to be overpriced, heavy favorites slightly underpriced. <em>But you cannot confirm it from prices alone.<\/em> It's a statement about realized outcomes vs. price, so it lives or dies on the labels you join in. Same for any Brier-score-over-time trajectory: great question, needs ground truth.<\/p>\n\n<h2>\n  \n  \n  Honest caveats\n<\/h2>\n\n<ul>\n<li>\n<strong>No resolution labels in the dataset.<\/strong> The single biggest limit \u2014 restated because it's the one most listings hide.<\/li>\n<li>\n<strong>Convergence \u2260 truth.<\/strong> The 96.3% terminal-decisiveness proxy mislabels the ambiguous tail and ignores any post-close correction.<\/li>\n<li>\n<strong>Survivorship \/ selection.<\/strong> Ended markets \u2260 all markets; conditioning on resolution biases some analyses.<\/li>\n<li>\n<strong>Mid \u2260 executable price.<\/strong> Fees and spread are real the moment you make a trading claim.<\/li>\n<li>\n<strong>86 days is one window, not multiple regimes.<\/strong> Treat any \"markets are calibrated\" conclusion as provisional.<\/li>\n<\/ul>\n\n<h2>\n  \n  \n  Get the data\n<\/h2>\n\n<p>The free read-only API reproduces the price side with no signup:<\/p>\n\n<p><code>https:\/\/api.protodex.io<\/code> \u2014 endpoints <code>\/stats<\/code>, <code>\/markets<\/code>, <code>\/market\/{id}<\/code>, <code>\/prices<\/code>, <code>\/orderbook<\/code>, <code>\/categories<\/code>.<\/p>\n\n<p>If you'd rather not page the API market-by-market and want the full 18.6M-snapshot, 92-day price history as one indexed SQLite file for offline work, the one-time archive is here: <strong><a href=\"https:\/\/manja8.gumroad.com\/l\/agyjd?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-29\" rel=\"noopener noreferrer\">Polymarket Historical Price Dataset<\/a><\/strong>. It's price history, honestly scoped \u2014 bring your own resolution labels if calibration is the goal.<\/p>\n\n<p>I'd genuinely like to know how others source clean resolution labels for prediction-market calibration work \u2014 that's the open edge here. If you've benchmarked a market's calibration, where did your ground truth come from?<\/p>\n\n","category":["datascience","python","statistics","machinelearning"]},{"title":"How to backtest a Polymarket strategy with free 15-minute historical data","pubDate":"Wed, 10 Jun 2026 22:51:26 +0000","link":"https:\/\/dev.to\/manja316\/how-to-backtest-a-polymarket-strategy-with-free-15-minute-historical-data-3hm0","guid":"https:\/\/dev.to\/manja316\/how-to-backtest-a-polymarket-strategy-with-free-15-minute-historical-data-3hm0","description":"<p>Most people backtest prediction-market strategies wrong, and it's not their fault \u2014 the data to do it right is annoying to assemble. You need <em>time series<\/em> per contract (not just the final resolution), aligned to a clock, with the resolution label attached so you know who won. Polymarket's API gives you the live order book, but the moment a market resolves, that history is gone from where most people look.<\/p>\n\n<p>So here's a clean recipe. Free data, ~40 lines of pandas, and the caveats that separate a backtest you can trust from one that lies to you.<\/p>\n\n<h2>\n  \n  \n  0. The data\n<\/h2>\n\n<p>You need a price history per market at a fixed interval. I've been archiving Polymarket at a 15-minute cadence since late March \u2014 <strong>18.6M+ price snapshots across 22,410 markets, 92 days of history<\/strong> as of this writing. The live market index is free to browse at <a href=\"https:\/\/protodex.io?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-30\" rel=\"noopener noreferrer\">protodex.io<\/a>.<\/p>\n\n<p><strong>One honest caveat before you write a line of code:<\/strong> this is <em>price<\/em> history, not officially-labeled outcomes. Polymarket's public API does not hand you a clean post-hoc <code>resolved_yes<\/code> flag, so you derive the outcome from where the price ends up \u2014 and roughly 5% of ended markets never converge decisively, so they stay ambiguous and must be dropped, not guessed. That limitation is the single most important thing to understand before you trust any backtest built on this (or any price-only prediction-market dataset). I measured it: 94.6% of ended markets close at \u22650.95 or \u22640.05; the rest are coin-flips you cannot score.<\/p>\n\n<p>You can roll your own collector against the Polymarket CLOB + Gamma APIs (the snapshot loop is maybe 60 lines), or skip the three-month wait and grab the parquet bundle \u2014 link at the end. Either way, the analysis below is identical.<\/p>\n\n<h2>\n  \n  \n  1. Load it\n<\/h2>\n\n<p>Assume a parquet with columns <code>market_id<\/code>, <code>timestamp<\/code>, and <code>price<\/code> (the YES probability, 0\u20131). There is <strong>no<\/strong> ground-truth <code>resolved_yes<\/code> column \u2014 you derive a proxy outcome from each market's terminal price, and you throw away anything that didn't converge.<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">read_parquet<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">polymarket_history.parquet<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">to_datetime<\/span><span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">],<\/span> <span class=\"n\">utc<\/span><span class=\"o\">=<\/span><span class=\"bp\">True<\/span><span class=\"p\">)<\/span>\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">sort_values<\/span><span class=\"p\">([<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">])<\/span>\n\n<span class=\"c1\"># Derive a PROXY outcome from the terminal price \u2014 there is no official label.\n# End &gt;= 0.95 -&gt; treat YES, &lt;= 0.05 -&gt; treat NO; anything in between never\n# converged, so it's unscoreable and gets dropped (NOT guessed at 0.5).\n<\/span><span class=\"n\">last<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">).<\/span><span class=\"nf\">tail<\/span><span class=\"p\">(<\/span><span class=\"mi\">1<\/span><span class=\"p\">)[[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]]<\/span>\n<span class=\"n\">last<\/span> <span class=\"o\">=<\/span> <span class=\"n\">last<\/span><span class=\"p\">.<\/span><span class=\"nf\">rename<\/span><span class=\"p\">(<\/span><span class=\"n\">columns<\/span><span class=\"o\">=<\/span><span class=\"p\">{<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">:<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">final_price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">})<\/span>\n<span class=\"n\">last<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"n\">NA<\/span>\n<span class=\"n\">last<\/span><span class=\"p\">.<\/span><span class=\"n\">loc<\/span><span class=\"p\">[<\/span><span class=\"n\">last<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">final_price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"mf\">0.95<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"mi\">1<\/span>\n<span class=\"n\">last<\/span><span class=\"p\">.<\/span><span class=\"n\">loc<\/span><span class=\"p\">[<\/span><span class=\"n\">last<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">final_price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">&lt;=<\/span> <span class=\"mf\">0.05<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"mi\">0<\/span>\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">merge<\/span><span class=\"p\">(<\/span><span class=\"n\">last<\/span><span class=\"p\">[[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]],<\/span> <span class=\"n\">on<\/span><span class=\"o\">=<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"n\">how<\/span><span class=\"o\">=<\/span><span class=\"sh\">\"<\/span><span class=\"s\">left<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n\n<span class=\"c1\"># Keep only markets that converged decisively \u2014 you can't score a coin-flip\n<\/span><span class=\"n\">resolved<\/span> <span class=\"o\">=<\/span> <span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">dropna<\/span><span class=\"p\">(<\/span><span class=\"n\">subset<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]).<\/span><span class=\"nf\">copy<\/span><span class=\"p\">()<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">resolved<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">nunique<\/span><span class=\"p\">(),<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">decisively-converged markets<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>Be honest with yourself about what this proxy is: a circularity check is worth doing here \u2014 you're using the <em>late<\/em> price to label the outcome and the <em>early<\/em> price as the signal, so as long as your entry snapshot is well before convergence (see \u00a72) the label isn't leaking your signal. But it is still a convergence proxy, not an official settlement, and it silently excludes the ~5% messy markets. State that in any result you publish.<\/p>\n\n<h2>\n  \n  \n  2. The strategy: fade the longshots\n<\/h2>\n\n<p>The favorite-longshot bias is the most-documented inefficiency in betting markets: longshots (low-probability contracts) are systematically <em>over<\/em>-priced, favorites slightly <em>under<\/em>-priced. Translated to Polymarket: a contract trading at 8\u00a2 resolves YES less than 8% of the time on average. So a naive edge is <strong>short the longshots<\/strong> (buy NO when YES is cheap).<\/p>\n\n<p>Let's test whether that holds in the snapshots. Take each market's price in a chosen band at a fixed point before resolution, and compare to the realized outcome.<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"c1\"># For each market, grab the last snapshot at least 24h before its final timestamp\n<\/span><span class=\"k\">def<\/span> <span class=\"nf\">snapshot_24h_out<\/span><span class=\"p\">(<\/span><span class=\"n\">g<\/span><span class=\"p\">):<\/span>\n    <span class=\"n\">end<\/span> <span class=\"o\">=<\/span> <span class=\"n\">g<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">max<\/span><span class=\"p\">()<\/span>\n    <span class=\"n\">cutoff<\/span> <span class=\"o\">=<\/span> <span class=\"n\">end<\/span> <span class=\"o\">-<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nc\">Timedelta<\/span><span class=\"p\">(<\/span><span class=\"n\">hours<\/span><span class=\"o\">=<\/span><span class=\"mi\">24<\/span><span class=\"p\">)<\/span>\n    <span class=\"n\">pre<\/span> <span class=\"o\">=<\/span> <span class=\"n\">g<\/span><span class=\"p\">[<\/span><span class=\"n\">g<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">&lt;=<\/span> <span class=\"n\">cutoff<\/span><span class=\"p\">]<\/span>\n    <span class=\"k\">return<\/span> <span class=\"n\">pre<\/span><span class=\"p\">.<\/span><span class=\"n\">iloc<\/span><span class=\"p\">[<\/span><span class=\"o\">-<\/span><span class=\"mi\">1<\/span><span class=\"p\">]<\/span> <span class=\"k\">if<\/span> <span class=\"nf\">len<\/span><span class=\"p\">(<\/span><span class=\"n\">pre<\/span><span class=\"p\">)<\/span> <span class=\"k\">else<\/span> <span class=\"bp\">None<\/span>\n\n<span class=\"n\">picks<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">resolved<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"n\">group_keys<\/span><span class=\"o\">=<\/span><span class=\"bp\">False<\/span><span class=\"p\">)<\/span>\n         <span class=\"p\">.<\/span><span class=\"nf\">apply<\/span><span class=\"p\">(<\/span><span class=\"n\">snapshot_24h_out<\/span><span class=\"p\">)<\/span>\n         <span class=\"p\">.<\/span><span class=\"nf\">dropna<\/span><span class=\"p\">())<\/span>\n\n<span class=\"c1\"># Longshot band: YES priced 2\u201315\u00a2\n<\/span><span class=\"n\">band<\/span> <span class=\"o\">=<\/span> <span class=\"n\">picks<\/span><span class=\"p\">[(<\/span><span class=\"n\">picks<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">&gt;=<\/span> <span class=\"mf\">0.02<\/span><span class=\"p\">)<\/span> <span class=\"o\">&amp;<\/span> <span class=\"p\">(<\/span><span class=\"n\">picks<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">&lt;=<\/span> <span class=\"mf\">0.15<\/span><span class=\"p\">)]<\/span>\n<span class=\"n\">implied<\/span> <span class=\"o\">=<\/span> <span class=\"n\">band<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">mean<\/span><span class=\"p\">()<\/span>          <span class=\"c1\"># what the market said\n<\/span><span class=\"n\">realized<\/span> <span class=\"o\">=<\/span> <span class=\"n\">band<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">resolved_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">].<\/span><span class=\"nf\">mean<\/span><span class=\"p\">()<\/span>  <span class=\"c1\"># what actually happened\n<\/span><span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"sa\">f<\/span><span class=\"sh\">\"<\/span><span class=\"s\">implied YES <\/span><span class=\"si\">{<\/span><span class=\"n\">implied<\/span><span class=\"si\">:<\/span><span class=\"p\">.<\/span><span class=\"mi\">3<\/span><span class=\"n\">f<\/span><span class=\"si\">}<\/span><span class=\"s\"> vs realized YES <\/span><span class=\"si\">{<\/span><span class=\"n\">realized<\/span><span class=\"si\">:<\/span><span class=\"p\">.<\/span><span class=\"mi\">3<\/span><span class=\"n\">f<\/span><span class=\"si\">}<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<\/code><\/pre>\n\n<\/div>\n\n\n\n<p>If <code>realized &lt; implied<\/code>, the longshots were overpriced \u2014 the bias is present and shorting them has positive expectancy <em>before costs<\/em>.<\/p>\n\n<h2>\n  \n  \n  3. The part most backtests skip \u2014 costs and survivorship\n<\/h2>\n\n<blockquote>\n<p>A backtest without these three corrections is marketing, not research.<\/p>\n<\/blockquote>\n\n<p><strong>1. Spread + fees.<\/strong> You don't trade at the mid. On thin Polymarket longshots the bid\/ask can be 2\u20134\u00a2 wide. A 1\u00a2 edge on a 6\u00a2 contract evaporates the moment you cross a 3\u00a2 spread. Always subtract a realistic fill cost from the implied price before scoring P&amp;L.<\/p>\n\n<p><strong>2. Survivorship \/ resolution timing.<\/strong> If your archive only kept markets that resolved cleanly, you've dropped the messy ones (extended, disputed, voided) \u2014 and those aren't random. Score against <em>every<\/em> market that hit your entry filter, not just the tidy winners.<\/p>\n\n<p><strong>3. Liquidity ceiling.<\/strong> A 20\u00a2 edge on a market with $300 of depth is a $60 edge, not a strategy. Weight every backtested position by the order-book depth at entry, or you'll \"discover\" an edge you can't actually fill. (This is exactly why snapshot data needs the <em>order book<\/em>, not just last-price \u2014 depth is the difference between a paper edge and a real one.)<\/p>\n\n<p>Do these three and the favorite-longshot edge usually survives \u2014 but smaller than the raw number, and only in the deeper markets. That gap <em>is<\/em> the finding.<\/p>\n\n<h2>\n  \n  \n  4. Why 15-minute snapshots specifically\n<\/h2>\n\n<p>Tick data is overkill for this and a nightmare to store; daily closes are too coarse to catch the late convergence where most of the price action lives (markets snap toward 0\/1 in the final hours). 15 minutes is the sweet spot: dense enough to study convergence and intraday moves, sparse enough that three months fits in a few hundred MB of parquet.<\/p>\n\n\n\n\n<p>If you'd rather not run a collector for three months before you can test a single idea, I've packaged the full archive \u2014 parquet + CSV with resolution labels included, plus a ready-to-run Python notebook that runs the exact favorite-longshot backtest above, as a one-time purchase:<\/p>\n\n<p>\ud83d\udc49 <strong><a href=\"https:\/\/manja8.gumroad.com\/l\/agyjd?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-30\" rel=\"noopener noreferrer\">Polymarket Quant Toolkit \u2014 18.6M-snapshot dataset + analysis notebook ($49)<\/a><\/strong><\/p>\n\n<p>It's price history with the convergence-proxy approach above already wired into the notebook \u2014 no over-promised resolution labels, just the honest version of the backtest. And the live market index is free to poke at first: <a href=\"https:\/\/protodex.io?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-06-30\" rel=\"noopener noreferrer\">protodex.io<\/a>.<\/p>\n\n<p>What strategy would you test first \u2014 favorite-longshot, late-convergence momentum, or cross-market arbitrage? Drop it in the comments and I'll point you at the columns you'd need.<\/p>\n\n","category":["trading","python","datascience","data"]},{"title":"I archived 19.1M Polymarket price snapshots. Three things the data shows that the order book hides.","pubDate":"Tue, 09 Jun 2026 10:51:30 +0000","link":"https:\/\/dev.to\/manja316\/i-archived-179m-polymarket-price-snapshots-three-things-the-data-shows-that-the-order-book-hides-do4","guid":"https:\/\/dev.to\/manja316\/i-archived-179m-polymarket-price-snapshots-three-things-the-data-shows-that-the-order-book-hides-do4","description":"<p>If you only ever look at a prediction market through its live order book, you see one number: the current price. That number is the market's best guess at a probability \u2014 \"73\u00a2 = ~73% YES.\" Useful, but it throws away everything about <em>how the market got there<\/em>.<\/p>\n\n<p>I've been archiving Polymarket order-book snapshots every 15 minutes for about eleven weeks. The dataset is now <strong>19.1M+ price points across ~19,000 markets<\/strong>, most of them resolved (so we know the ground-truth outcome). Looking at prediction markets in bulk, instead of one at a time, surfaces structure that's invisible tick-by-tick. Three findings worth your time.<\/p>\n\n<h2>\n  \n  \n  1. Favorite-longshot bias is real here too\n<\/h2>\n\n<p>The oldest result in betting-market research is the <strong>favorite-longshot bias<\/strong>: longshots are systematically overpriced and heavy favorites are systematically underpriced. People overpay for the lottery-ticket thrill of a 5\u00a2 \"it could happen\" and underpay for the boring 95\u00a2 near-certainty.<\/p>\n\n<p>It shows up cleanly when you bucket thousands of resolved markets by their pre-resolution price and compare each bucket's <em>implied<\/em> probability to its <em>actual<\/em> hit rate:<\/p>\n\n<ul>\n<li>Contracts trading in the <strong>2\u201310\u00a2<\/strong> band resolve YES <strong>less often<\/strong> than their price implies \u2014 the longshot tax.<\/li>\n<li>Contracts in the <strong>90\u201398\u00a2<\/strong> band resolve YES <strong>slightly more often<\/strong> than their price implies \u2014 favorites are a hair cheap.<\/li>\n<\/ul>\n\n<p>You cannot see this in one market. You can only see it across a thousand resolved ones \u2014 which is exactly what a bulk archive is for.<\/p>\n\n<h2>\n  \n  \n  2. Convergence is late and lumpy, not smooth\n<\/h2>\n\n<p>Intuition says a market should drift smoothly toward 0 or 1 as resolution nears. The data says otherwise: most markets sit in a noisy band for the majority of their life and then <strong>convergence happens in a short burst<\/strong> near the resolving event \u2014 a debate, an earnings print, an election night.<\/p>\n\n<p>The practical implication: time-to-resolution matters more than price level when you're reasoning about how much a contract can still move. A 60\u00a2 market with three weeks left and a 60\u00a2 market with three hours left are completely different objects, and a single live quote can't tell them apart. A timestamped history can.<\/p>\n\n<h2>\n  \n  \n  3. Volume \u2260 movement\n<\/h2>\n\n<p>The markets with the biggest 24h <strong>price moves<\/strong> are usually not the ones with the biggest <strong>volume<\/strong>. High-volume markets are liquid and efficient \u2014 lots of participants, tight spreads, slow to move. The violent re-pricings happen in <strong>thinner<\/strong> markets where a single piece of news has nobody on the other side to absorb it.<\/p>\n\n<p>If you screen for \"interesting\" markets by volume, you'll mostly find markets that have already finished being interesting. Screening by <em>realized movement relative to liquidity<\/em> finds the ones still in motion. (I built a free screener around exactly this idea \u2014 separate post.)<\/p>\n\n<h2>\n  \n  \n  How to reproduce this yourself\n<\/h2>\n\n<p>Every claim above is just <code>pandas<\/code> over a long-format table:<br>\n<\/p>\n\n<div class=\"highlight js-code-highlight\">\n<pre class=\"highlight python\"><code><span class=\"kn\">import<\/span> <span class=\"n\">pandas<\/span> <span class=\"k\">as<\/span> <span class=\"n\">pd<\/span>\n\n<span class=\"n\">df<\/span> <span class=\"o\">=<\/span> <span class=\"n\">pd<\/span><span class=\"p\">.<\/span><span class=\"nf\">read_parquet<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">polymarket_snapshots.parquet<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n<span class=\"c1\"># columns: market_id, timestamp, price_yes, volume, resolved_outcome\n<\/span>\n<span class=\"c1\"># Favorite-longshot calibration: take each market's last pre-resolution price,\n# bucket it, and compare implied probability to the actual YES rate.\n<\/span><span class=\"n\">last<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">df<\/span><span class=\"p\">.<\/span><span class=\"nf\">dropna<\/span><span class=\"p\">(<\/span><span class=\"n\">subset<\/span><span class=\"o\">=<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">resolved_outcome<\/span><span class=\"sh\">\"<\/span><span class=\"p\">])<\/span>\n          <span class=\"p\">.<\/span><span class=\"nf\">sort_values<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">timestamp<\/span><span class=\"sh\">\"<\/span><span class=\"p\">)<\/span>\n          <span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">).<\/span><span class=\"nf\">tail<\/span><span class=\"p\">(<\/span><span class=\"mi\">1<\/span><span class=\"p\">))<\/span>\n<span class=\"n\">last<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bucket<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">=<\/span> <span class=\"p\">(<\/span><span class=\"n\">last<\/span><span class=\"p\">[<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">]<\/span> <span class=\"o\">*<\/span> <span class=\"mi\">10<\/span><span class=\"p\">).<\/span><span class=\"nf\">round<\/span><span class=\"p\">()<\/span> <span class=\"o\">\/<\/span> <span class=\"mi\">10<\/span>\n<span class=\"n\">calib<\/span> <span class=\"o\">=<\/span> <span class=\"n\">last<\/span><span class=\"p\">.<\/span><span class=\"nf\">groupby<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">bucket<\/span><span class=\"sh\">\"<\/span><span class=\"p\">).<\/span><span class=\"nf\">agg<\/span><span class=\"p\">(<\/span>\n    <span class=\"n\">implied<\/span><span class=\"o\">=<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">price_yes<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">mean<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n    <span class=\"n\">actual<\/span><span class=\"o\">=<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">resolved_outcome<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">mean<\/span><span class=\"sh\">\"<\/span><span class=\"p\">),<\/span>\n    <span class=\"n\">n<\/span><span class=\"o\">=<\/span><span class=\"p\">(<\/span><span class=\"sh\">\"<\/span><span class=\"s\">market_id<\/span><span class=\"sh\">\"<\/span><span class=\"p\">,<\/span> <span class=\"sh\">\"<\/span><span class=\"s\">size<\/span><span class=\"sh\">\"<\/span><span class=\"p\">))<\/span>\n<span class=\"nf\">print<\/span><span class=\"p\">(<\/span><span class=\"n\">calib<\/span><span class=\"p\">)<\/span>  <span class=\"c1\"># actual &lt; implied for low buckets = longshot bias\n<\/span><\/code><\/pre>\n\n<\/div>\n\n\n\n<p>The hard part isn't the analysis \u2014 it's getting clean, timestamped, resolution-labeled history out of an API that's built for live trading, not bulk export. That collection problem is the whole reason this dataset exists.<\/p>\n\n<h2>\n  \n  \n  The dataset\n<\/h2>\n\n<p>If you want the raw archive instead of building the collector yourself, it's here:<\/p>\n\n<p>\ud83d\udc49 <strong><a href=\"https:\/\/manja8.gumroad.com\/l\/polymarket-quant-toolkit?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-07\" rel=\"noopener noreferrer\">Polymarket Full Dataset \u2014 18M+ price snapshots, 22,000+ markets<\/a><\/strong><\/p>\n\n<p>Parquet + CSV, 15-minute snapshot cadence across the full market history, one-time purchase. If you just want to poke at the live numbers first, the market index is free at <a href=\"https:\/\/protodex.io?utm_source=devto&amp;utm_medium=article&amp;utm_campaign=polymarket-data-2026-07\" rel=\"noopener noreferrer\">protodex.io<\/a>.<\/p>\n\n<p>What would you check first with 19.1M+ labeled prediction-market snapshots? I'll run reader requests against the archive and post the results.<\/p>\n\n","category":["datascience","trading","python","data"]}]}}