DEV Community

Matvey
Matvey

Posted on

I Asked Polymarket for 90 Days of Prices and Got 30. Coarser Points Got Me 462

I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads the Polymarket exchange: every tradable contract with its implied probability, the yes and no price, resting liquidity, the 24-hour move, and — in a separate mode — the price history of a market, one row per point.

The history mode had a number in its input form called History window, days, minimum 1, maximum 90. On 24 October 2026 I finally checked whether the 90 was true. It was not. A 90-day request returned 29.7 days, and the reason turned out to be the most quietly destructive kind of API behaviour: a parameter that caps another parameter without saying so.

What came back

The market I used is the busiest contract on the board — one leg of the 2026 US midterms balance-of-power event, $2.9M of 24-hour volume, opened on 11 July 2025. Fifteen months of price history exist for it.

{"mode": "history", "marketIds": ["562829"], "historyDays": 90, "maxMarkets": 25}
Enter fullscreen mode Exit fullscreen mode

60 rows came back. First point 2026-09-24T12:00:17Z, last point 2026-10-24T05:08:37Z: a span of 29.7 days, in 12-hour steps. No error, no warning, no truncation notice. A perfectly plausible dataset of the wrong length, and the only way to notice is to subtract the first timestamp from the last one — which nobody does, because why would the window you typed not be the window you got.

Two ways to ask, two different ceilings

Polymarket's CLOB endpoint takes either a named window (interval=1d, 1w, 1m, max) or an explicit pair of Unix timestamps (startTs, endTs), plus fidelity — the bucket size in minutes. I probed both against the same token in the same minute.

The named window first, interval=max, which should mean "everything there is":

fidelity Points Span First point
1 4,233 30.0 d 2026-09-24
60 698 30.0 d 2026-09-24
180 237 30.0 d 2026-09-24
360 116 30.0 d 2026-09-24
600 72 29.8 d 2026-09-24
719 64 29.5 d 2026-09-24
720 898 462.2 d 2025-07-19
1440 450 462.2 d 2025-07-19

That is not a gradient, it is a cliff. At fidelity=719 the market's entire history is one month long. At 720 — buckets of exactly 12 hours — the same market suddenly has 462 days of it, back to a week after it opened. interval=max is honest at twelve-hour granularity and silently means interval=1m at anything finer.

Reproducing it is two curl calls:

TOKEN=70997927349469817841862065582625658840347600365813612622959588796331622340305
for F in 719 720; do
  curl -s "https://clob.polymarket.com/prices-history?market=$TOKEN&interval=max&fidelity=$F" \
    | python3 -c "import sys,json,datetime as dt; h=json.load(sys.stdin)['history']; \
      a=dt.datetime.fromtimestamp(h[0]['t'],dt.UTC); b=dt.datetime.fromtimestamp(h[-1]['t'],dt.UTC); \
      print(len(h),'points', round((b-a).days), 'days')"
done
Enter fullscreen mode Exit fullscreen mode

Now the explicit timestamp pair, which looks like the grown-up way to ask for a window. It has a ceiling of its own, and a much lower one. Same token, fidelity=180:

  • 336 hours (14 days) → fine, 51 points.
  • 360 hours (15 days) → fine.
  • 366 hours → {"error": "invalid filters: 'startTs' and 'endTs' interval is too long"}.

Fifteen days is the wall, and fidelity does not move it: the same 366-hour request fails identically at fidelity=1 and at fidelity=1440. So the explicit path gives you an exact window up to 15 days and nothing beyond, while the named path gives you 30 days at any resolution and the full life only at 12-hour buckets or coarser. The two mechanisms disagree about what a long request even is, and neither tells you which rule it applied.

The one line in my code that chose the worst of both

My Actor picked its request like this:

points = await polyHistory(token, {
    interval: historyDays > 7 ? '1m' : '1w',
    fidelity: historyDays > 7 ? 720 : 180,
});
Enter fullscreen mode Exit fullscreen mode

Read that against the table above and it is almost funny. It passed fidelity: 720 — the exact magic number that unlocks the full history — and then threw the unlock away by naming the window 1m. Anything above 7 days got a month; anything at or below got a week. historyDays: 1 returned seven days. historyDays: 90 returned thirty. historyDays: 30 was the only value in the whole 1–90 range that was accidentally correct.

It was never a lie about the data. Every price in those 60 rows was a real price. It was a lie about the extent of the data, which is worse in one specific way: a chart drawn from it looks right. A 90-day volatility figure computed from 30 days of points is a number you will quote.

What it does now

The fix is to stop pretending one request shape fits every window, and to let the bucket size follow the window — because the venue makes that choice for you anyway:

export async function polyHistory(tokenId, { days = 7 } = {}) {
    const d = Math.max(1, Math.min(90, Number(days) || 7));
    const now = Math.floor(Date.now() / 1000);
    const url = d <= 15
        ? `${CLOB}/prices-history?market=${tokenId}&startTs=${now - d * 86400}&endTs=${now}&fidelity=${d <= 2 ? 60 : 180}`
        : `${CLOB}/prices-history?market=${tokenId}&interval=max&fidelity=${d <= 30 ? 720 : 1440}`;
    const json = await getJson(url);
    const cutoff = (now - d * 86400) * 1000;
    return (json.history || [])
        .filter((h) => (h.t || 0) * 1000 >= cutoff)
        .map((h) => ({ time: new Date((h.t || 0) * 1000).toISOString(), price: num(h.p), probability: pct(h.p) }));
}
Enter fullscreen mode Exit fullscreen mode

Under 15 days it sends real dates and gets an exact window. Over 15 it asks for the market's entire life at a resolution the endpoint will actually serve, then throws away everything older than the window you asked for. Four runs on the rebuilt Actor, same market, same afternoon:

historyDays Rows Span delivered Median gap
2 42 2.0 d 1.0 h
7 51 7.0 d 3.0 h
30 60 29.7 d 12.0 h
90 90 89.2 d 24.0 h

And a sample row, straight from the 90-day run:

{
  "type": "price_point",
  "platform": "polymarket",
  "marketId": "562829",
  "question": "2026 Balance of Power: D Senate, R House",
  "time": "2026-07-27T00:00:05.000Z",
  "price": 0.0165,
  "probability": 1.7
}
Enter fullscreen mode Exit fullscreen mode

Trimming cuts, it never pads. In the same batch a market that had only existed for nine days returned nine days of points for a 90-day request, which is the correct answer and not something a caller should have to guess at.

The part that costs money

This Actor bills per row, and a price point is a row at $0.0002. So resolution is literally the price of the query: the broken 90-day request charged $0.012 for a month of 12-hour points, and the fixed one charges $0.018 for three months of daily points. If I had "fixed" it by keeping three-hour buckets across 90 days — stitching six consecutive 15-day windows together, which the explicit path would allow — the same answer would have been about 720 rows and $0.144, eight times the bill for detail nobody asked for. A window parameter that quietly picks your bucket size is also quietly picking your invoice, in both directions.

Two things to pass if you call it yourself:

curl -X POST "https://api.apify.com/v2/acts/lergassy~polymarket-scraper/run-sync-get-dataset-items?token=<TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"mode":"history","marketIds":["562829"],"historyDays":90,"maxMarkets":25}'
Enter fullscreen mode Exit fullscreen mode

marketIds takes Polymarket's numeric market id, and history needs the market's CLOB token, which the Actor looks up for you — so run markets mode once, keep the marketId of whatever you want to track, and feed those ids back in. Filter type != "notice" and the dataset is only price points; the free notice row tells you what was charged before you go looking in the billing tab. The listing is Polymarket Scraper & API, and if you need the same shape across venues, the Kalshi half lives in Prediction Markets API — Kalshi's history comes from a candlestick endpoint with its own, unrelated rules, which is a grid I have not mapped yet.

When this is the wrong tool

If you need every trade rather than a sampled price curve, this is not it: the endpoint behind it returns buckets, and no fidelity setting turns buckets back into ticks. If you need level-by-level depth, that is the order-book mode, one call per market, and per-row billing makes a thousand books expensive. And if you want the history of a market that has already resolved, check the market list with closed markets included first — a slug that no longer appears on the board will not grow a token id just because you asked for its past.

The general lesson is the one I keep paying for: when an API takes a parameter for how much and a parameter for how fine, assume they are not independent until you have measured the grid. One of them is capping the other, probably without an error message, and the result will be a dataset that is the right shape, the right schema, full of real numbers, and the wrong size.

Top comments (0)