Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
How to Get Polymarket Historical Data: Rebuild Every Trade From Polygon Logs

How to Get Polymarket Historical Data: Rebuild Every Trade From Polygon Logs

By Ben Chatwin35min read

Polymarket's Data API stops at 10,000 rows per query and lists each trade once per side. Its price-history endpoint returns prices with no sizes. Neither gives you the full trade tape of a busy market: one 15-minute Bitcoin market on November 2 logged 13,288 trades between 12:55 and 13:20 UTC alone.

Every Polymarket trade settles on Polygon, though, and every settlement emits an OrderFilled event. Those events are the complete trade record: price, size, maker, taker and outcome token for each fill, for the whole order-book era. (Markets from Polymarket's earlier AMM era settled on different contracts and are not covered here.) This guide shows you how to pull them with eth_getLogs, decode both exchange versions, and turn them into a trade tape and price bars for any market.

We ran every script against a Dwellir Polygon endpoint on November 2, 2026. The output below is what the scripts printed.

What Polymarket historical data you can get

SourceWhat it returnsLimit we hit
CLOB GET /prices-historyPrice points for one outcome token. No sizes, no tradesstartTs/endTs windows of 15 days or less. On the resolved market we tested, interval=max returned nothing at fidelity=60 and only 12-hour points at fidelity=720
Data API GET /tradesTrades with size, price, wallet and tx hash"max historical trades offset of 10000 exceeded" past offset 10,000
Gamma API GET /marketsMarket metadata: question, outcomes, clobTokenIds, conditionIdClosed markets only appear with closed=true
Polygon OrderFilled logsEvery fill, both sides, since the CTF Exchange went liveYour RPC plan's eth_getLogs block range and bandwidth

Third-party indexers sit in between. Polymarket publishes its subgraph code on GitHub, Dune has decoded Polymarket tables, and scraper tools wrap the Data API. They save you the decoding work. You give up control of the schema and of how fast fixes land, and you take on their quotas.

For a backtest, the on-chain logs are the source to trust. They are the settlement record itself, and you can re-derive them at any time.

If you only need current prices, order books or your own positions, read our Polymarket API guide instead.

Where Polymarket trades live on Polygon

Polymarket matches orders off-chain. An operator then submits each match to an exchange contract on Polygon, which moves the pUSD collateral and the ERC-1155 outcome tokens and emits events. Standard binary markets use the CTF Exchange. Multi-outcome markets, such as elections with many candidates, use the NegRisk CTF Exchange.

On April 28, 2026, Polymarket shipped CLOB V2 with new exchange contracts and a new OrderFilled event. History before that date is on the V1 contracts and history after it is on V2, so a backfill has to read both.

ContractAddressFills on chain
CTF Exchange (V1)0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982EUntil block 86,126,998 (2026-04-28 11:00 UTC)
NegRisk CTF Exchange (V1)0xC5d563A36AE78145C45a50134d48A1215220f80aUntil block 86,126,998
CTFExchangeV20xE111180000d2663C0091e4f400237545B87B996BFrom block 86,100,351 (2026-04-27 20:12 UTC)
NegRiskCtfExchangeV20xe2222d279d744050d28e00520010520000310F59From block 86,100,351

We found the cutover blocks by bisecting eth_getLogs results. The two versions overlap for about 15 hours, and our 5-minute test window at 05:00 UTC on April 28 still had 19,569 V1 fills and 2 V2 fills. Don't switch versions at a single block. Query both across the overlap.

The V2 addresses come from Polymarket's ctf-exchange-v2 repository. Collateral moved from USDC.e to pUSD (0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB) in the same upgrade. Both use 6 decimals, and so do outcome token amounts.

The OrderFilled event in V1 and V2

V1V2
SignatureOrderFilled(bytes32,address,address,uint256,uint256,uint256,uint256,uint256)OrderFilled(bytes32,address,address,uint8,uint256,uint256,uint256,uint256,bytes32,bytes32)
topic00xd0a08e8c...bfec0f60xd543adfd...a84d8ee
IndexedorderHash, maker, takerorderHash, maker, taker
DatamakerAssetId, takerAssetId, makerAmountFilled, takerAmountFilled, feeside, tokenId, makerAmountFilled, takerAmountFilled, fee, builder, metadata
SideAsset ID 0 is collateral. If the maker gave asset 0, it is a BUYside is 0 for BUY, 1 for SELL
Fee unitThe asset the order receivedAlways pUSD

The outcome token ID is not indexed in either version. You cannot ask the node for one market's fills only. You pull every fill in a block range and filter on your side. A wallet is different: maker is indexed, so one wallet's maker fills are a cheap query (see one wallet's history).

How one trade appears in the logs

Here is a V2 match from November 2, 2026, decoded:

Text
OrderFilled  maker=0x6972...  taker=0x9d57...  side=BUY   tokenId=2511...  makerAmount=9,200,000   takerAmount=10,000,000  fee=0
FeeCharged   amount=51,520
OrderFilled  maker=0x9d57...  taker=0xE111... side=SELL  tokenId=2511...  makerAmount=10,000,000  takerAmount=9,200,000   fee=51,520
OrdersMatched takerOrderMaker=0x9d57...  side=SELL  tokenId=2511...  makerAmount=10,000,000  takerAmount=9,200,000

Wallet 0x6972... had a resting order to buy 10 shares at $0.92. Wallet 0x9d57... sold into it and paid a $0.05 fee. The exchange logs two OrderFilled events for that one trade: one for the maker order, with the taker wallet as counterparty, and one for the taker order, with the exchange contract itself as taker.

Table comparing the maker leg and the taker leg of one Polymarket trade: only the maker leg counts as a trade.

That gives you the rule for counting trades. Each maker-side fill is one trade. The taker-side fill (where taker is the exchange address) is one aggregate row for the whole taker order, summed across every maker it hit. Count maker legs for trades and volume, and use the taker leg for the taker's total fee. The script keeps both and tags them with a role column.

The legs don't always share a token. When a taker buys "Down" at $0.53 against a maker buying "Up" at $0.47, the exchange mints a new pair. The maker fill is on the Up token and the taker fill is on the Down token. Pricing every fill in one outcome (Up at $0.47 is Down at $0.53) keeps the series consistent. The price script below does this.

Prerequisites

The extractor below comes in Python, Rust, Go and TypeScript. All four make the same calls and write the same CSV, so pick the one your stack uses. The tabs switch every example on the page.

  • A Polygon RPC endpoint with eth_getLogs. Dwellir's Growth and Scale plans allow 10,000-block ranges and the Developer plan allows 500. On Developer, set MAX_BLOCK_RANGE=500. Full-market scans stay near 200 blocks per call either way, but wallet queries grow toward the cap. Create a key if you don't have one
  • One of: Python 3.10 or later, Rust 1.85 or later, Go 1.21 or later, or Node.js 22.18 or later (which runs TypeScript files directly)
  • Python 3.10 or later with pandas for the price bars section, whichever language you use for the extractor
Bash
pip install requests pandas
export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
Bash
cargo new polymarket-fills && cd polymarket-fills
cargo add chrono num-bigint serde_json
cargo add ureq@2 --features json
export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
Bash
mkdir polymarket-fills && cd polymarket-fills
go mod init polymarketfills  # standard library only, nothing to install
export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
Bash
mkdir polymarket-fills && cd polymarket-fills
echo '{"type": "module"}' > package.json  # built-in fetch, nothing to install
export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"

You don't need an archive node. Logs come from transaction receipts, which full nodes keep. Our test pulled November 2024 fills from a full-node endpoint.

Pull every fill with eth_getLogs

The script converts your UTC start and end times to blocks, then pages through OrderFilled logs for whichever exchange versions were active. Polymarket now emits about 50 fill events per Polygon block, so a 200-block request returns around 10 MB of JSON. The script shrinks the range when a response gets too large or a request times out, and grows it again when blocks are quiet.

Four steps from Polygon logs to a Polymarket trade file: time to blocks, eth_getLogs, decode, fills.csv.

Dwellir's Polygon nodes include blockTimestamp in every log, so you don't need a second call per block for the time. If your provider's logs lack that field, fetch block headers for the timestamps instead.

polymarket_fills.py
"""Rebuild Polymarket trade history from OrderFilled logs on Polygon.

Usage:
    export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
    python polymarket_fills.py --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
"""
import argparse
import csv
import os
import time
from datetime import datetime, timezone

import requests

RPC_URL = os.environ["POLYGON_RPC_URL"]

# CTF Exchange and NegRisk CTF Exchange, before and after the CLOB V2 upgrade.
EXCHANGES = {
    "v1": ["0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e",   # CTF Exchange
           "0xc5d563a36ae78145c45a50134d48a1215220f80a"],  # NegRisk CTF Exchange
    "v2": ["0xe111180000d2663c0091e4f400237545b87b996b",   # CTFExchangeV2
           "0xe2222d279d744050d28e00520010520000310f59"],  # NegRiskCtfExchangeV2
}
# keccak256 of the OrderFilled signature in each version
ORDER_FILLED = {
    "v1": "0xd0a08e8c493f9c94f29311604c9de1b4e8c8d4c06bd0c789af57f2d65bfec0f6",
    "v2": "0xd543adfd945773f1a62f74f0ee55a5e3b9b1a28262980ba90b1a89f2ea84d8ee",
}
V2_FIRST_FILL_BLOCK = 86_100_351  # 2026-04-27 20:12 UTC
V1_LAST_FILL_BLOCK = 86_126_998   # 2026-04-28 11:00 UTC
# eth_getLogs block limit: 10,000 on Dwellir Growth and Scale, 500 on Developer
MAX_BLOCK_RANGE = int(os.environ.get("MAX_BLOCK_RANGE", 10_000))
TARGET_LOGS_PER_CALL = 10_000     # keeps each response near 10 MB

session = requests.Session()


def rpc(method, params, retries=4):
    error = None
    for attempt in range(retries):
        try:
            body = session.post(RPC_URL, json={"jsonrpc": "2.0", "id": 1,
                                               "method": method, "params": params},
                                timeout=120).json()
            if "error" not in body:
                return body["result"]
            error = body["error"]
        except (requests.RequestException, ValueError) as exc:
            error = exc
        time.sleep(2 ** attempt)
    raise RuntimeError(f"{method} failed: {error}")


def block_at(ts):
    """First block with timestamp >= ts (binary search on block headers)."""
    lo, hi = 1, int(rpc("eth_blockNumber", []), 16)
    while lo < hi:
        mid = (lo + hi) // 2
        if int(rpc("eth_getBlockByNumber", [hex(mid), False])["timestamp"], 16) < ts:
            lo = mid + 1
        else:
            hi = mid
    return lo


def get_logs(addresses, topics, start, end):
    """Yield logs for [start, end]. Shrink the range when a call fails or returns too much."""
    step, block = min(200, MAX_BLOCK_RANGE), start
    while block <= end:
        to = min(block + step - 1, end)
        try:
            logs = rpc("eth_getLogs", [{"address": addresses, "topics": topics,
                                        "fromBlock": hex(block), "toBlock": hex(to)}], retries=2)
        except RuntimeError:
            if step == 1:
                raise
            step //= 2
            continue
        yield from logs
        block = to + 1
        if len(logs) > TARGET_LOGS_PER_CALL:
            step = max(1, step // 2)
        elif len(logs) < TARGET_LOGS_PER_CALL // 4:
            step = min(MAX_BLOCK_RANGE, step * 2)


def words(data):
    raw = data[2:]
    return [int(raw[i:i + 64], 16) for i in range(0, len(raw), 64)]


def decode(log, version):
    maker = "0x" + log["topics"][2][-40:]
    taker = "0x" + log["topics"][3][-40:]
    w = words(log["data"])
    if version == "v2":
        side = "BUY" if w[0] == 0 else "SELL"
        token_id, maker_amount, taker_amount, fee = w[1], w[2], w[3], w[4]
        fee_asset = "collateral"  # V2 fees are always in pUSD
    else:
        maker_asset, taker_asset, maker_amount, taker_amount, fee = w[:5]
        side = "BUY" if maker_asset == 0 else "SELL"  # asset id 0 is collateral
        token_id = taker_asset if side == "BUY" else maker_asset
        fee_asset = "shares" if side == "BUY" else "collateral"  # V1 charges fees in proceeds
    # A BUY order pays collateral and receives outcome tokens; a SELL does the reverse.
    cash, shares = (maker_amount, taker_amount) if side == "BUY" else (taker_amount, maker_amount)
    return {
        "block": int(log["blockNumber"], 16),
        "timestamp": int(log["blockTimestamp"], 16),
        "tx_hash": log["transactionHash"],
        "log_index": int(log["logIndex"], 16),
        "version": version,
        "exchange": log["address"],
        # The taker order's fill is logged with the exchange as counterparty.
        "role": "taker" if taker == log["address"] else "maker",
        "order_hash": log["topics"][1],
        "maker": maker,
        "taker": taker,
        "side": side,
        "token_id": str(token_id),
        "price": round(cash / shares, 6) if shares else None,
        "shares": shares / 1e6,
        "collateral": cash / 1e6,  # USDC.e in V1, pUSD in V2; both 6 decimals
        "fee": fee / 1e6,
        "fee_asset": fee_asset,
    }


def fills(start_block, end_block, maker=None):
    """Yield decoded fills from every exchange version active in the range."""
    for version in ("v1", "v2"):
        lo = start_block if version == "v1" else max(start_block, V2_FIRST_FILL_BLOCK)
        hi = min(end_block, V1_LAST_FILL_BLOCK) if version == "v1" else end_block
        if lo > hi:
            continue
        topics = [ORDER_FILLED[version], None, None if maker is None else
                  "0x" + maker.lower()[2:].rjust(64, "0")]
        for log in get_logs(EXCHANGES[version], topics, lo, hi):
            yield decode(log, version)


def parse_time(value):
    return int(datetime.fromisoformat(value).replace(tzinfo=timezone.utc).timestamp())


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--start", required=True, help="UTC, e.g. 2026-11-02T12:55")
    ap.add_argument("--end", required=True)
    ap.add_argument("--maker", help="only fills of orders signed by this wallet (maker and taker legs)")
    ap.add_argument("--out", default="fills.csv")
    args = ap.parse_args()

    start_block, end_block = block_at(parse_time(args.start)), block_at(parse_time(args.end)) - 1
    print(f"blocks {start_block:,} to {end_block:,}")
    count = 0
    with open(args.out, "w", newline="") as fh:
        writer = None
        for row in fills(start_block, end_block, args.maker):
            if writer is None:
                writer = csv.DictWriter(fh, fieldnames=row.keys())
                writer.writeheader()
            writer.writerow(row)
            count += 1
    print(f"wrote {count:,} fills to {args.out}")
src/main.rs
//! Rebuild Polymarket trade history from OrderFilled logs on Polygon.
//!
//! Usage:
//!     export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
//!     cargo run --release -- --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
use std::{env, fs::File, io::BufWriter, io::Write, thread, time::Duration};

use chrono::NaiveDateTime;
use num_bigint::BigUint;
use serde_json::{json, Value};

#[derive(Clone, Copy)]
enum Version {
    V1,
    V2,
}

impl Version {
    // CTF Exchange and NegRisk CTF Exchange, before and after the CLOB V2 upgrade.
    fn exchanges(self) -> [&'static str; 2] {
        match self {
            Version::V1 => ["0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e",  // CTF Exchange
                            "0xc5d563a36ae78145c45a50134d48a1215220f80a"], // NegRisk CTF Exchange
            Version::V2 => ["0xe111180000d2663c0091e4f400237545b87b996b",  // CTFExchangeV2
                            "0xe2222d279d744050d28e00520010520000310f59"], // NegRiskCtfExchangeV2
        }
    }

    // keccak256 of the OrderFilled signature in each version
    fn order_filled(self) -> &'static str {
        match self {
            Version::V1 => "0xd0a08e8c493f9c94f29311604c9de1b4e8c8d4c06bd0c789af57f2d65bfec0f6",
            Version::V2 => "0xd543adfd945773f1a62f74f0ee55a5e3b9b1a28262980ba90b1a89f2ea84d8ee",
        }
    }

    fn name(self) -> &'static str {
        match self {
            Version::V1 => "v1",
            Version::V2 => "v2",
        }
    }
}

const V2_FIRST_FILL_BLOCK: u64 = 86_100_351; // 2026-04-27 20:12 UTC
const V1_LAST_FILL_BLOCK: u64 = 86_126_998; // 2026-04-28 11:00 UTC
const TARGET_LOGS_PER_CALL: usize = 10_000; // keeps each response near 10 MB

struct Rpc {
    url: String,
    agent: ureq::Agent,
}

impl Rpc {
    fn new(url: String) -> Self {
        let agent = ureq::AgentBuilder::new().timeout(Duration::from_secs(120)).build();
        Rpc { url, agent }
    }

    fn call(&self, method: &str, params: Value, retries: u32) -> Result<Value, String> {
        let body = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
        let mut error = String::new();
        for attempt in 0..retries {
            let response = self.agent.post(&self.url).send_json(&body)
                .map_err(|e| e.to_string())
                .and_then(|r| r.into_json::<Value>().map_err(|e| e.to_string()));
            match response {
                Ok(mut v) if v.get("error").is_none() => return Ok(v["result"].take()),
                Ok(v) => error = v["error"].to_string(),
                Err(e) => error = e,
            }
            thread::sleep(Duration::from_secs(1 << attempt));
        }
        Err(format!("{method} failed: {error}"))
    }
}

fn hex_u64(value: &Value) -> u64 {
    u64::from_str_radix(value.as_str().unwrap_or("0x0").trim_start_matches("0x"), 16).unwrap_or(0)
}

/// First block with timestamp >= ts (binary search on block headers).
fn block_at(rpc: &Rpc, ts: u64) -> Result<u64, String> {
    let (mut lo, mut hi) = (1, hex_u64(&rpc.call("eth_blockNumber", json!([]), 4)?));
    while lo < hi {
        let mid = (lo + hi) / 2;
        let block = rpc.call("eth_getBlockByNumber", json!([format!("{mid:#x}"), false]), 4)?;
        if hex_u64(&block["timestamp"]) < ts { lo = mid + 1 } else { hi = mid }
    }
    Ok(lo)
}

/// Call `each` for every log in [start, end]. Shrink the range when a call fails or returns too much.
fn get_logs(rpc: &Rpc, addresses: &[&str], topics: &Value, start: u64, end: u64, max_range: u64,
            mut each: impl FnMut(&Value) -> std::io::Result<()>) -> Result<(), String> {
    let (mut step, mut block) = (max_range.min(200), start);
    while block <= end {
        let to = (block + step - 1).min(end);
        let filter = json!([{"address": addresses, "topics": topics,
                             "fromBlock": format!("{block:#x}"), "toBlock": format!("{to:#x}")}]);
        let logs = match rpc.call("eth_getLogs", filter, 2) {
            Ok(logs) => logs,
            Err(e) if step == 1 => return Err(e),
            Err(_) => { step /= 2; continue; }
        };
        let logs = logs.as_array().ok_or("eth_getLogs returned no list")?;
        for log in logs {
            each(log).map_err(|e| e.to_string())?;
        }
        block = to + 1;
        if logs.len() > TARGET_LOGS_PER_CALL {
            step = (step / 2).max(1);
        } else if logs.len() < TARGET_LOGS_PER_CALL / 4 {
            step = (step * 2).min(max_range);
        }
    }
    Ok(())
}

const HEADER: &str = "block,timestamp,tx_hash,log_index,version,exchange,role,order_hash,maker,taker,\
side,token_id,price,shares,collateral,fee,fee_asset";

/// One CSV row per OrderFilled log.
fn decode(log: &Value, version: Version) -> String {
    let topic = |i: usize| log["topics"][i].as_str().unwrap_or_default();
    let field = |name: &str| log[name].as_str().unwrap_or_default();
    let maker = format!("0x{}", &topic(2)[26..]);
    let taker = format!("0x{}", &topic(3)[26..]);
    let data = &field("data")[2..];
    let words: Vec<&str> = (0..data.len() / 64).map(|i| &data[i * 64..i * 64 + 64]).collect();
    // Amounts are 6-decimal integers; the low 128 bits of each word hold them.
    let amount = |i: usize| u128::from_str_radix(&words[i][32..], 16).unwrap_or(0);
    let is_zero = |i: usize| words[i].bytes().all(|b| b == b'0');

    let (buy, token_id, maker_amount, taker_amount, fee, fee_asset) = match version {
        // V2 fees are always in pUSD
        Version::V2 => (is_zero(0), words[1], amount(2), amount(3), amount(4), "collateral"),
        Version::V1 => {
            let buy = is_zero(0); // asset id 0 is collateral
            let token = if buy { words[1] } else { words[0] };
            // V1 charges fees in proceeds
            (buy, token, amount(2), amount(3), amount(4), if buy { "shares" } else { "collateral" })
        }
    };
    // A BUY order pays collateral and receives outcome tokens; a SELL does the reverse.
    let (cash, shares) = if buy { (maker_amount, taker_amount) } else { (taker_amount, maker_amount) };
    let price = if shares > 0 {
        ((cash as f64 / shares as f64) * 1e6).round() / 1e6
    } else {
        f64::NAN
    };
    let exchange = field("address");
    // The taker order's fill is logged with the exchange as counterparty.
    let role = if taker == exchange { "taker" } else { "maker" };
    format!(
        "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
        hex_u64(&log["blockNumber"]), hex_u64(&log["blockTimestamp"]), field("transactionHash"),
        hex_u64(&log["logIndex"]), version.name(), exchange, role, topic(1), maker, taker,
        if buy { "BUY" } else { "SELL" }, BigUint::parse_bytes(token_id.as_bytes(), 16).unwrap_or_default(),
        if price.is_nan() { String::new() } else { price.to_string() },
        shares as f64 / 1e6, cash as f64 / 1e6, fee as f64 / 1e6, fee_asset, // USDC.e in V1, pUSD in V2
    )
}

/// Write decoded fills from every exchange version active in the range.
fn fills(rpc: &Rpc, start: u64, end: u64, maker: Option<&str>, max_range: u64,
         mut each: impl FnMut(String) -> std::io::Result<()>) -> Result<(), String> {
    for version in [Version::V1, Version::V2] {
        let (lo, hi) = match version {
            Version::V1 => (start, end.min(V1_LAST_FILL_BLOCK)),
            Version::V2 => (start.max(V2_FIRST_FILL_BLOCK), end),
        };
        if lo > hi {
            continue;
        }
        let maker_topic = maker.map(|m| format!("0x{:0>64}", m.to_lowercase().trim_start_matches("0x")));
        let topics = json!([version.order_filled(), null, maker_topic]);
        get_logs(rpc, &version.exchanges(), &topics, lo, hi, max_range, |log| each(decode(log, version)))?;
    }
    Ok(())
}

fn parse_time(value: &str) -> Result<u64, String> {
    NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M")
        .map(|t| t.and_utc().timestamp() as u64)
        .map_err(|e| format!("bad time {value}: {e}"))
}

fn commas(n: u64) -> String {
    let digits = n.to_string();
    let mut out = String::new();
    for (i, c) in digits.chars().enumerate() {
        if i > 0 && (digits.len() - i) % 3 == 0 {
            out.push(',');
        }
        out.push(c);
    }
    out
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // --start and --end in UTC, e.g. 2026-11-02T12:55; --maker for one wallet's orders (maker and taker legs)
    let args: Vec<String> = env::args().collect();
    let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
    let (start, end) = (arg("--start").ok_or("--start is required")?, arg("--end").ok_or("--end is required")?);
    let maker = arg("--maker");
    let out = arg("--out").unwrap_or_else(|| "fills.csv".into());
    // eth_getLogs block limit: 10,000 on Dwellir Growth and Scale, 500 on Developer
    let max_range = env::var("MAX_BLOCK_RANGE").ok().and_then(|v| v.parse().ok()).unwrap_or(10_000);

    let rpc = Rpc::new(env::var("POLYGON_RPC_URL")?);
    let start_block = block_at(&rpc, parse_time(&start)?)?;
    let end_block = block_at(&rpc, parse_time(&end)?)? - 1;
    println!("blocks {} to {}", commas(start_block), commas(end_block));

    let mut writer = BufWriter::new(File::create(&out)?);
    writeln!(writer, "{HEADER}")?;
    let mut count = 0;
    fills(&rpc, start_block, end_block, maker.as_deref(), max_range, |row| {
        count += 1;
        writeln!(writer, "{row}")
    })?;
    writer.flush()?;
    println!("wrote {} fills to {out}", commas(count));
    Ok(())
}
main.go
// Rebuild Polymarket trade history from OrderFilled logs on Polygon.
//
// Usage:
//
//	export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
//	go run . --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
package main

import (
	"bufio"
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"log"
	"math"
	"math/big"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

// CTF Exchange and NegRisk CTF Exchange, before and after the CLOB V2 upgrade.
var exchanges = map[string][]string{
	"v1": {"0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e", // CTF Exchange
		"0xc5d563a36ae78145c45a50134d48a1215220f80a"}, // NegRisk CTF Exchange
	"v2": {"0xe111180000d2663c0091e4f400237545b87b996b", // CTFExchangeV2
		"0xe2222d279d744050d28e00520010520000310f59"}, // NegRiskCtfExchangeV2
}

// keccak256 of the OrderFilled signature in each version
var orderFilled = map[string]string{
	"v1": "0xd0a08e8c493f9c94f29311604c9de1b4e8c8d4c06bd0c789af57f2d65bfec0f6",
	"v2": "0xd543adfd945773f1a62f74f0ee55a5e3b9b1a28262980ba90b1a89f2ea84d8ee",
}

const (
	v2FirstFillBlock  = 86_100_351 // 2026-04-27 20:12 UTC
	v1LastFillBlock   = 86_126_998 // 2026-04-28 11:00 UTC
	targetLogsPerCall = 10_000     // keeps each response near 10 MB
	header            = "block,timestamp,tx_hash,log_index,version,exchange,role,order_hash,maker,taker,side,token_id,price,shares,collateral,fee,fee_asset"
)

var (
	rpcURL = os.Getenv("POLYGON_RPC_URL")
	client = &http.Client{Timeout: 120 * time.Second}
	// eth_getLogs block limit: 10,000 on Dwellir Growth and Scale, 500 on Developer
	maxBlockRange = envInt("MAX_BLOCK_RANGE", 10_000)
)

type Log struct {
	Address         string   `json:"address"`
	Topics          []string `json:"topics"`
	Data            string   `json:"data"`
	BlockNumber     string   `json:"blockNumber"`
	BlockTimestamp  string   `json:"blockTimestamp"`
	TransactionHash string   `json:"transactionHash"`
	LogIndex        string   `json:"logIndex"`
}

func rpc(method string, params any, retries int, result any) error {
	body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
	var err error
	for attempt := 0; attempt < retries; attempt++ {
		err = post(body, result)
		if err == nil {
			return nil
		}
		time.Sleep(time.Second << attempt)
	}
	return fmt.Errorf("%s failed: %w", method, err)
}

func post(body []byte, result any) error {
	resp, err := client.Post(rpcURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	var out struct {
		Result json.RawMessage `json:"result"`
		Error  json.RawMessage `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return err
	}
	if out.Error != nil {
		return fmt.Errorf("%s", out.Error)
	}
	return json.Unmarshal(out.Result, result)
}

func hexInt(s string) uint64 {
	n, _ := strconv.ParseUint(strings.TrimPrefix(s, "0x"), 16, 64)
	return n
}

// blockAt returns the first block with timestamp >= ts (binary search on block headers).
func blockAt(ts uint64) uint64 {
	var head string
	must(rpc("eth_blockNumber", []any{}, 4, &head))
	lo, hi := uint64(1), hexInt(head)
	for lo < hi {
		mid := (lo + hi) / 2
		var block struct{ Timestamp string }
		must(rpc("eth_getBlockByNumber", []any{fmt.Sprintf("%#x", mid), false}, 4, &block))
		if hexInt(block.Timestamp) < ts {
			lo = mid + 1
		} else {
			hi = mid
		}
	}
	return lo
}

// getLogs calls each for every log in [start, end]. It shrinks the range when a call fails or returns too much.
func getLogs(addresses []string, topics []any, start, end uint64, each func(Log)) error {
	step, block := min(200, maxBlockRange), start
	for block <= end {
		to := min(block+step-1, end)
		filter := map[string]any{"address": addresses, "topics": topics,
			"fromBlock": fmt.Sprintf("%#x", block), "toBlock": fmt.Sprintf("%#x", to)}
		var logs []Log
		if err := rpc("eth_getLogs", []any{filter}, 2, &logs); err != nil {
			if step == 1 {
				return err
			}
			step /= 2
			continue
		}
		for _, l := range logs {
			each(l)
		}
		block = to + 1
		if len(logs) > targetLogsPerCall {
			step = max(1, step/2)
		} else if len(logs) < targetLogsPerCall/4 {
			step = min(maxBlockRange, step*2)
		}
	}
	return nil
}

func decode(l Log, version string) []string {
	maker := "0x" + l.Topics[2][26:]
	taker := "0x" + l.Topics[3][26:]
	raw := l.Data[2:]
	word := func(i int) *big.Int {
		n, _ := new(big.Int).SetString(raw[i*64:i*64+64], 16)
		return n
	}
	var buy bool
	var tokenID, makerAmount, takerAmount, fee *big.Int
	var feeAsset string
	if version == "v2" {
		buy = word(0).Sign() == 0
		tokenID, makerAmount, takerAmount, fee = word(1), word(2), word(3), word(4)
		feeAsset = "collateral" // V2 fees are always in pUSD
	} else {
		makerAsset, takerAsset := word(0), word(1)
		buy = makerAsset.Sign() == 0 // asset id 0 is collateral
		makerAmount, takerAmount, fee = word(2), word(3), word(4)
		tokenID, feeAsset = makerAsset, "collateral"
		if buy {
			tokenID, feeAsset = takerAsset, "shares" // V1 charges fees in proceeds
		}
	}
	// A BUY order pays collateral and receives outcome tokens; a SELL does the reverse.
	cash, shares, side := makerAmount, takerAmount, "BUY"
	if !buy {
		cash, shares, side = takerAmount, makerAmount, "SELL"
	}
	price := ""
	if shares.Sign() > 0 {
		price = num(math.Round(float(cash)/float(shares)*1e6) / 1e6)
	}
	role := "maker"
	if taker == l.Address { // the taker order's fill is logged with the exchange as counterparty
		role = "taker"
	}
	return []string{
		strconv.FormatUint(hexInt(l.BlockNumber), 10), strconv.FormatUint(hexInt(l.BlockTimestamp), 10),
		l.TransactionHash, strconv.FormatUint(hexInt(l.LogIndex), 10), version, l.Address, role,
		l.Topics[1], maker, taker, side, tokenID.String(), price,
		num(float(shares) / 1e6), num(float(cash) / 1e6), // USDC.e in V1, pUSD in V2; both 6 decimals
		num(float(fee) / 1e6), feeAsset,
	}
}

// fills calls each with every decoded fill from the exchange versions active in the range.
func fills(start, end uint64, maker string, each func([]string)) error {
	for _, version := range []string{"v1", "v2"} {
		lo, hi := start, min(end, v1LastFillBlock)
		if version == "v2" {
			lo, hi = max(start, v2FirstFillBlock), end
		}
		if lo > hi {
			continue
		}
		topics := []any{orderFilled[version], nil, nil}
		if maker != "" {
			topics[2] = "0x" + fmt.Sprintf("%064s", strings.TrimPrefix(strings.ToLower(maker), "0x"))
		}
		if err := getLogs(exchanges[version], topics, lo, hi, func(l Log) { each(decode(l, version)) }); err != nil {
			return err
		}
	}
	return nil
}

func main() {
	start := flag.String("start", "", "UTC, e.g. 2026-11-02T12:55")
	end := flag.String("end", "", "UTC")
	maker := flag.String("maker", "", "only fills of orders signed by this wallet (maker and taker legs)")
	out := flag.String("out", "fills.csv", "output CSV")
	flag.Parse()

	startBlock, endBlock := blockAt(parseTime(*start)), blockAt(parseTime(*end))-1
	fmt.Printf("blocks %s to %s\n", commas(startBlock), commas(endBlock))

	file, err := os.Create(*out)
	must(err)
	w := bufio.NewWriter(file)
	fmt.Fprintln(w, header)
	count := uint64(0)
	must(fills(startBlock, endBlock, *maker, func(row []string) {
		fmt.Fprintln(w, strings.Join(row, ",")) // no field contains a comma
		count++
	}))
	must(w.Flush())
	must(file.Close())
	fmt.Printf("wrote %s fills to %s\n", commas(count), *out)
}

func parseTime(value string) uint64 {
	t, err := time.Parse("2006-01-02T15:04", value)
	must(err)
	return uint64(t.Unix())
}

func float(n *big.Int) float64 { f, _ := new(big.Float).SetInt(n).Float64(); return f }

func num(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }

func commas(n uint64) string {
	s := strconv.FormatUint(n, 10)
	for i := len(s) - 3; i > 0; i -= 3 {
		s = s[:i] + "," + s[i:]
	}
	return s
}

func envInt(name string, fallback uint64) uint64 {
	if n, err := strconv.ParseUint(os.Getenv(name), 10, 64); err == nil {
		return n
	}
	return fallback
}

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}
polymarket_fills.ts
/**
 * Rebuild Polymarket trade history from OrderFilled logs on Polygon.
 *
 * Usage (Node.js 22.18 or later runs TypeScript directly):
 *   export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"
 *   node polymarket_fills.ts --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
 */
import { createWriteStream } from "node:fs";
import { once } from "node:events";
import { parseArgs } from "node:util";

const RPC_URL = process.env.POLYGON_RPC_URL!;

type Version = "v1" | "v2";

// CTF Exchange and NegRisk CTF Exchange, before and after the CLOB V2 upgrade.
const EXCHANGES: Record<Version, string[]> = {
  v1: ["0x4bfb41d5b3570defd03c39a9a4d8de6bd8b8982e",  // CTF Exchange
       "0xc5d563a36ae78145c45a50134d48a1215220f80a"], // NegRisk CTF Exchange
  v2: ["0xe111180000d2663c0091e4f400237545b87b996b",  // CTFExchangeV2
       "0xe2222d279d744050d28e00520010520000310f59"], // NegRiskCtfExchangeV2
};
// keccak256 of the OrderFilled signature in each version
const ORDER_FILLED: Record<Version, string> = {
  v1: "0xd0a08e8c493f9c94f29311604c9de1b4e8c8d4c06bd0c789af57f2d65bfec0f6",
  v2: "0xd543adfd945773f1a62f74f0ee55a5e3b9b1a28262980ba90b1a89f2ea84d8ee",
};
const V2_FIRST_FILL_BLOCK = 86_100_351; // 2026-04-27 20:12 UTC
const V1_LAST_FILL_BLOCK = 86_126_998;  // 2026-04-28 11:00 UTC
// eth_getLogs block limit: 10,000 on Dwellir Growth and Scale, 500 on Developer
const MAX_BLOCK_RANGE = Number(process.env.MAX_BLOCK_RANGE ?? 10_000);
const TARGET_LOGS_PER_CALL = 10_000;    // keeps each response near 10 MB

interface Log {
  address: string;
  topics: string[];
  data: string;
  blockNumber: string;
  blockTimestamp: string;
  transactionHash: string;
  logIndex: string;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function rpc<T>(method: string, params: unknown[], retries = 4): Promise<T> {
  let error: unknown;
  for (let attempt = 0; attempt < retries; attempt++) {
    try {
      const res = await fetch(RPC_URL, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
        signal: AbortSignal.timeout(120_000),
      });
      const body = await res.json();
      if (!("error" in body)) return body.result as T;
      error = JSON.stringify(body.error);
    } catch (exc) {
      error = exc;
    }
    await sleep(1000 * 2 ** attempt);
  }
  throw new Error(`${method} failed: ${error}`);
}

/** First block with timestamp >= ts (binary search on block headers). */
async function blockAt(ts: number): Promise<number> {
  let lo = 1;
  let hi = Number(await rpc<string>("eth_blockNumber", []));
  while (lo < hi) {
    const mid = Math.floor((lo + hi) / 2);
    const block = await rpc<{ timestamp: string }>("eth_getBlockByNumber", ["0x" + mid.toString(16), false]);
    if (Number(block.timestamp) < ts) lo = mid + 1;
    else hi = mid;
  }
  return lo;
}

/** Yield logs for [start, end]. Shrink the range when a call fails or returns too much. */
async function* getLogs(addresses: string[], topics: (string | null)[], start: number, end: number) {
  let step = Math.min(200, MAX_BLOCK_RANGE);
  let block = start;
  while (block <= end) {
    const to = Math.min(block + step - 1, end);
    let logs: Log[];
    try {
      logs = await rpc<Log[]>("eth_getLogs", [{ address: addresses, topics,
        fromBlock: "0x" + block.toString(16), toBlock: "0x" + to.toString(16) }], 2);
    } catch (exc) {
      if (step === 1) throw exc;
      step = Math.floor(step / 2);
      continue;
    }
    yield* logs;
    block = to + 1;
    if (logs.length > TARGET_LOGS_PER_CALL) step = Math.max(1, Math.floor(step / 2));
    else if (logs.length < TARGET_LOGS_PER_CALL / 4) step = Math.min(MAX_BLOCK_RANGE, step * 2);
  }
}

function decode(log: Log, version: Version) {
  const maker = "0x" + log.topics[2].slice(-40);
  const taker = "0x" + log.topics[3].slice(-40);
  const raw = log.data.slice(2);
  const w = Array.from({ length: raw.length / 64 }, (_, i) => BigInt("0x" + raw.slice(i * 64, i * 64 + 64)));
  let side: "BUY" | "SELL", tokenId: bigint, makerAmount: bigint, takerAmount: bigint, fee: bigint;
  let feeAsset: "collateral" | "shares";
  if (version === "v2") {
    side = w[0] === 0n ? "BUY" : "SELL";
    [tokenId, makerAmount, takerAmount, fee] = [w[1], w[2], w[3], w[4]];
    feeAsset = "collateral"; // V2 fees are always in pUSD
  } else {
    const [makerAsset, takerAsset] = [w[0], w[1]];
    [makerAmount, takerAmount, fee] = [w[2], w[3], w[4]];
    side = makerAsset === 0n ? "BUY" : "SELL"; // asset id 0 is collateral
    tokenId = side === "BUY" ? takerAsset : makerAsset;
    feeAsset = side === "BUY" ? "shares" : "collateral"; // V1 charges fees in proceeds
  }
  // A BUY order pays collateral and receives outcome tokens; a SELL does the reverse.
  const [cash, shares] = side === "BUY" ? [makerAmount, takerAmount] : [takerAmount, makerAmount];
  return {
    block: Number(log.blockNumber),
    timestamp: Number(log.blockTimestamp),
    tx_hash: log.transactionHash,
    log_index: Number(log.logIndex),
    version,
    exchange: log.address,
    // The taker order's fill is logged with the exchange as counterparty.
    role: taker === log.address ? "taker" : "maker",
    order_hash: log.topics[1],
    maker,
    taker,
    side,
    token_id: tokenId.toString(),
    price: shares ? Math.round((Number(cash) / Number(shares)) * 1e6) / 1e6 : null,
    shares: Number(shares) / 1e6,
    collateral: Number(cash) / 1e6, // USDC.e in V1, pUSD in V2; both 6 decimals
    fee: Number(fee) / 1e6,
    fee_asset: feeAsset,
  };
}

/** Yield decoded fills from every exchange version active in the range. */
async function* fills(startBlock: number, endBlock: number, maker?: string) {
  for (const version of ["v1", "v2"] as const) {
    const lo = version === "v1" ? startBlock : Math.max(startBlock, V2_FIRST_FILL_BLOCK);
    const hi = version === "v1" ? Math.min(endBlock, V1_LAST_FILL_BLOCK) : endBlock;
    if (lo > hi) continue;
    const makerTopic = maker ? "0x" + maker.toLowerCase().slice(2).padStart(64, "0") : null;
    for await (const log of getLogs(EXCHANGES[version], [ORDER_FILLED[version], null, makerTopic], lo, hi)) {
      yield decode(log, version);
    }
  }
}

const parseTime = (value: string) => Date.parse(value + "Z") / 1000;

const { values: args } = parseArgs({
  options: {
    start: { type: "string" }, // UTC, e.g. 2026-11-02T12:55
    end: { type: "string" },
    maker: { type: "string" }, // only fills of orders signed by this wallet (maker and taker legs)
    out: { type: "string", default: "fills.csv" },
  },
});
if (!args.start || !args.end) throw new Error("--start and --end are required");

const startBlock = await blockAt(parseTime(args.start));
const endBlock = (await blockAt(parseTime(args.end))) - 1;
console.log(`blocks ${startBlock.toLocaleString("en-US")} to ${endBlock.toLocaleString("en-US")}`);

const out = createWriteStream(args.out!);
let count = 0;
for await (const row of fills(startBlock, endBlock, args.maker)) {
  if (count === 0) out.write(Object.keys(row).join(",") + "\n");
  if (!out.write(Object.values(row).map((v) => v ?? "").join(",") + "\n")) await once(out, "drain");
  count++;
}
out.end();
await once(out, "finish");
console.log(`wrote ${count.toLocaleString("en-US")} fills to ${args.out}`);

Run it for a 25-minute window:

Bash
python polymarket_fills.py --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
Bash
cargo run --release -- --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
Bash
go run . --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
Bash
node polymarket_fills.ts --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csv
Text
blocks 96,612,429 to 96,613,428
wrote 99,511 fills to fills.csv

That took 14 seconds with the Python version. The CSV has one row per fill:

Text
block,timestamp,tx_hash,log_index,version,exchange,role,order_hash,maker,taker,side,token_id,price,shares,collateral,fee,fee_asset
96612429,1793624100,0x00e3...33d0,171,v2,0xe111...996b,maker,0xd15d...9728,0x5594...b390,0x3089...35e4,BUY,4808...4855,0.47,57.64151,27.09151,0.0,collateral
96612429,1793624100,0x00e3...33d0,175,v2,0xe111...996b,taker,0x17f8...823d,0x3089...35e4,0xe111...996b,BUY,1246...4140,0.53,57.64151,30.55,1.00509,collateral

Those two rows are a mint: the maker bought 57.6 shares of one outcome at $0.47, and the taker bought the same number of the other outcome at $0.53 and paid a $1.01 fee. The collateral column is USDC.e for V1 rows and pUSD for V2 rows, both with 6 decimals.

The same script reads V1 history without changes. A 10-minute window on US election night, starting 2024-11-05 23:00 UTC, returned 4,654 fills from blocks 63,931,740 to 63,932,015.

Build a trade tape and price bars for one market

Fills carry outcome token IDs, not market names. The Gamma API maps a market slug to its two clobTokenIds. Ask with closed=true as well, because Gamma leaves resolved markets out by default.

This step is in Python only, because pandas does the minute bars in a few lines. It reads the fills.csv from any of the four extractors.

Python
"""Turn fills.csv into a trade tape and 1-minute bars for one Polymarket market.

Usage:
    python price_series.py --fills fills.csv --slug btc-updown-15m-1793624400
"""
import argparse
import json

import pandas as pd
import requests

GAMMA = "https://gamma-api.polymarket.com"


def market_by_slug(slug):
    # Gamma hides closed markets unless you ask for them.
    for closed in ("false", "true"):
        rows = requests.get(f"{GAMMA}/markets", params={"slug": slug, "closed": closed},
                            timeout=30).json()
        if rows:
            m = rows[0]
            return {"question": m["question"],
                    "outcomes": json.loads(m["outcomes"]),
                    "token_ids": json.loads(m["clobTokenIds"])}
    raise SystemExit(f"no market with slug {slug}")


def trade_tape(fills, token_ids):
    """One row per matched trade, priced in the first outcome (YES, Up, ...)."""
    first, second = token_ids
    tape = fills[(fills.role == "maker") & fills.token_id.isin([first, second])].copy()
    # A fill on the second outcome at p is the same trade as the first outcome at 1 - p.
    tape["price"] = tape.price.where(tape.token_id == first, 1 - tape.price)
    tape["time"] = pd.to_datetime(tape.timestamp, unit="s", utc=True)
    return tape.sort_values(["block", "log_index"])[
        ["time", "block", "tx_hash", "maker", "taker", "token_id", "side", "price", "shares", "collateral"]]


def bars(tape, freq="1min"):
    grouped = tape.set_index("time")
    ohlc = grouped.price.resample(freq).ohlc()
    ohlc["volume_shares"] = grouped.shares.resample(freq).sum()
    ohlc["volume_collateral"] = grouped.collateral.resample(freq).sum()
    ohlc["trades"] = grouped.price.resample(freq).count()
    return ohlc.dropna(subset=["close"])


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--fills", default="fills.csv")
    ap.add_argument("--slug", required=True)
    args = ap.parse_args()

    market = market_by_slug(args.slug)
    fills = pd.read_csv(args.fills, dtype={"token_id": str})
    tape = trade_tape(fills, market["token_ids"])
    print(market["question"], "| priced in", market["outcomes"][0])
    print(f"{len(tape):,} trades, {tape.shares.sum():,.0f} shares, ${tape.collateral.sum():,.0f}")

    ohlc = bars(tape)
    print(ohlc.head(10).round(3).to_string())
    tape.to_csv(f"{args.slug}_tape.csv", index=False)
    ohlc.to_csv(f"{args.slug}_1m.csv")

Run it on the 15-minute Bitcoin market from the fills above:

Bash
python price_series.py --fills fills.csv --slug btc-updown-15m-1793624400
Text
Bitcoin Up or Down - November 2, 8:00AM-8:15AM ET | priced in Up
13,288 trades, 309,638 shares, $186,564
                           open  high   low  close  volume_shares  volume_collateral  trades
time
2026-11-02 12:55:00+00:00  0.51  0.51  0.51   0.51          5.882        2.882       2
2026-11-02 12:57:00+00:00  0.51  0.51  0.50   0.50         72.974       36.134       8
2026-11-02 12:58:00+00:00  0.51  0.52  0.51   0.51        238.332      115.342      14
2026-11-02 12:59:00+00:00  0.52  0.59  0.52   0.59        461.782      222.584      42
2026-11-02 13:00:00+00:00  0.55  0.59  0.47   0.47        668.098      361.707      51
2026-11-02 13:01:00+00:00  0.44  0.51  0.40   0.40        225.596      103.866      19
2026-11-02 13:02:00+00:00  0.43  0.43  0.29   0.31        799.467      517.345      79
2026-11-02 13:03:00+00:00  0.33  0.39  0.11   0.13      99666.540    57486.263    3742
2026-11-02 13:04:00+00:00  0.12  0.34  0.12   0.30       6776.749     4451.008     465
2026-11-02 13:05:00+00:00  0.30  0.36  0.16   0.20      12251.979     5539.052     721

The 13:03 bar holds 3,742 trades and $57,486 of volume, with "Up" trading between $0.39 and $0.11 and closing the minute at $0.13. That is the detail a backtest needs and a price-only series hides. volume_collateral is the cash the makers paid or received, so it mixes Up and Down notional. Multiply shares by price if you want notional in Up terms.

For a market that ran for weeks, pull the fills for its whole life first. The startDate and endDate fields from Gamma give you the window.

Pull one wallet's history

maker is an indexed topic, so the node filters by wallet for you. Pass --maker to get every fill of that wallet's own orders. Rows with role=maker are orders that rested on the book. Rows with role=taker are orders that crossed the spread, which the exchange logs with the wallet as maker and itself as taker:

Bash
python polymarket_fills.py --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
  --maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csv
Bash
cargo run --release -- --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
  --maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csv
Bash
go run . --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
  --maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csv
Bash
node polymarket_fills.ts --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
  --maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csv

That covered 261,548 blocks (4.5 days) in about 30 eth_getLogs calls and 26 seconds, and returned 10,008 fills: 9,711 maker legs and 297 taker legs. A full market scan of the same range would need about 2,400 calls. With MAX_BLOCK_RANGE=500, a 1.5-day run of the same query took about 180 calls. To see who the wallet's taker orders filled against, query the taker topic (position 3) with the wallet address, which returns each counterparty's maker leg. For live copy-trading on top of this, see how to build a Polymarket trading bot.

How we checked the data

We compared the on-chain tape with Polymarket's Data API on two markets, matching by transaction hash:

MarketOn-chain txsData API txsMatched
Bitcoin Up or Down, Nov 2, 8:15-8:30 AM ET418418418
Counter-Strike: megoshort vs Falcons Force, Map 2310310310

We used the 8:15 market because the 8:00 market is past the Data API's 10,000-row cap. Every transaction appeared in both sources. The Data API lists each trade once per side, so its share totals were twice the maker-only totals. Watch for the same doubling if you mix the two sources.

We also ran the Python, Rust, Go and TypeScript extractors on the same three windows: 3 minutes of V2, 15 minutes across the V1/V2 overlap, and one day of the wallet query below. All four returned the same 55,180 fills with the same fields. Three of the 42,871 overlap rows differed by 0.000001 in price. Each was a half-way case such as 35.631 / 80 = 0.4453875, which Python's round() and the other languages' round(x * 1e6) / 1e6 settle in opposite directions.

The /prices-history series does not line up minute by minute with traded prices in a fast market. On the Bitcoin market, our 1-minute closes differed from it by a median of $0.055. It is a sampled price series, not a record of trades, so use it for charts and the logs for fills.

Cost and limits

Polymarket trades heavily. We measured our pulls against the Dwellir Polygon endpoint:

RangeBlocksFillsRequestsJSON downloadedTime
Nov 2, 2026 (V2), 2.5 hours6,000303,45556330 MB67 s
Nov 2025 (V1), 3.3 hours6,000205,93630198 MB24 s
One wallet, 4.5 days261,54810,008about 30small26 s

At the current rate, one day of full Polymarket history is about 57,600 blocks, 2.9 million fill events, roughly 540 requests and 3.2 GB of JSON. A year is about 200,000 requests.

Request count isn't what limits you. On Dwellir's Developer plan (25 million responses a month for $42) a year of backfill uses less than 1% of one month's quota, because Dwellir counts responses and does not weight eth_getLogs with compute units. On providers that do weight it, check the per-call cost before you start a backfill.

Plan for bandwidth, time and storage instead:

  • Store Parquet rather than CSV. Token IDs are 77-digit strings, and the CSV for 25 minutes is 40 MB.
  • Write one file per day, so you can restart a failed day without redoing the backfill.
  • Run days in parallel. The script is single-threaded and takes about 11 minutes per day of current volume. Six processes on different days finish a month in about an hour.
  • For a live tail, read a few blocks behind the latest block, and check the removed field on logs from WebSocket subscriptions.

For more on the method itself, see our guides to eth_getLogs limits and eth_getLogs cost traps.

Kalshi historical data

Kalshi is the other large prediction market, but it is a centralized, CFTC-regulated exchange. Its trades never touch a chain, so there are no logs to read. History comes from its REST API:

EndpointWhat it returns
GET /markets/tradesRecent trades with price, count and taker side, paged by cursor
GET /historical/tradesTrades older than the cutoff
GET /historical/cutoffThe cutoff dates. On November 2, 2026 it returned trades_created_ts: 2026-09-03
GET /series/{series}/markets/{ticker}/candlesticks1-minute, 1-hour and 1-day bars

The public market data endpoints need no API key. Our Kalshi API guide covers keys, request signing and the WebSocket feed. If you trade both venues, the Polymarket tape from this guide and Kalshi's trades both reduce to time, price, size and side. Kalshi returns prices as dollar strings (yes_price_dollars) and sizes as contract counts (count_fp). Treat one contract as one share, and one backtest can read both.

Next steps

If you plan to backfill Polymarket's full history or keep a live tape running, talk to the Dwellir team about throughput for that load.

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 150+ networks. Upgrade when you need more.