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
| Source | What it returns | Limit we hit |
|---|---|---|
CLOB GET /prices-history | Price points for one outcome token. No sizes, no trades | startTs/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 /trades | Trades with size, price, wallet and tx hash | "max historical trades offset of 10000 exceeded" past offset 10,000 |
Gamma API GET /markets | Market metadata: question, outcomes, clobTokenIds, conditionId | Closed markets only appear with closed=true |
Polygon OrderFilled logs | Every fill, both sides, since the CTF Exchange went live | Your 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.
| Contract | Address | Fills on chain |
|---|---|---|
| CTF Exchange (V1) | 0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E | Until block 86,126,998 (2026-04-28 11:00 UTC) |
| NegRisk CTF Exchange (V1) | 0xC5d563A36AE78145C45a50134d48A1215220f80a | Until block 86,126,998 |
| CTFExchangeV2 | 0xE111180000d2663C0091e4f400237545B87B996B | From block 86,100,351 (2026-04-27 20:12 UTC) |
| NegRiskCtfExchangeV2 | 0xe2222d279d744050d28e00520010520000310F59 | From 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
| V1 | V2 | |
|---|---|---|
| Signature | OrderFilled(bytes32,address,address,uint256,uint256,uint256,uint256,uint256) | OrderFilled(bytes32,address,address,uint8,uint256,uint256,uint256,uint256,bytes32,bytes32) |
| topic0 | 0xd0a08e8c...bfec0f6 | 0xd543adfd...a84d8ee |
| Indexed | orderHash, maker, taker | orderHash, maker, taker |
| Data | makerAssetId, takerAssetId, makerAmountFilled, takerAmountFilled, fee | side, tokenId, makerAmountFilled, takerAmountFilled, fee, builder, metadata |
| Side | Asset ID 0 is collateral. If the maker gave asset 0, it is a BUY | side is 0 for BUY, 1 for SELL |
| Fee unit | The asset the order received | Always 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:
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,000Wallet 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.

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, setMAX_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
pip install requests pandas
export POLYGON_RPC_URL="https://api-polygon-mainnet-full.n.dwellir.com/<YOUR_API_KEY>"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>"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>"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.

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.
"""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}")//! 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(())
}// 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)
}
}/**
* 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:
python polymarket_fills.py --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csvcargo run --release -- --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csvgo run . --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csvnode polymarket_fills.ts --start 2026-11-02T12:55 --end 2026-11-02T13:20 --out fills.csvblocks 96,612,429 to 96,613,428
wrote 99,511 fills to fills.csvThat took 14 seconds with the Python version. The CSV has one row per fill:
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,collateralThose 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.
"""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:
python price_series.py --fills fills.csv --slug btc-updown-15m-1793624400Bitcoin 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 721The 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:
python polymarket_fills.py --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
--maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csvcargo run --release -- --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
--maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csvgo run . --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
--maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csvnode polymarket_fills.ts --start 2026-10-29T00:00 --end 2026-11-02T13:00 \
--maker 0x55941ce9f9029293c4c5385fcfcec36e41dfb390 --out wallet.csvThat 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:
| Market | On-chain txs | Data API txs | Matched |
|---|---|---|---|
| Bitcoin Up or Down, Nov 2, 8:15-8:30 AM ET | 418 | 418 | 418 |
| Counter-Strike: megoshort vs Falcons Force, Map 2 | 310 | 310 | 310 |
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:
| Range | Blocks | Fills | Requests | JSON downloaded | Time |
|---|---|---|---|---|---|
| Nov 2, 2026 (V2), 2.5 hours | 6,000 | 303,455 | 56 | 330 MB | 67 s |
| Nov 2025 (V1), 3.3 hours | 6,000 | 205,936 | 30 | 198 MB | 24 s |
| One wallet, 4.5 days | 261,548 | 10,008 | about 30 | small | 26 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
removedfield 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:
| Endpoint | What it returns |
|---|---|
GET /markets/trades | Recent trades with price, count and taker side, paged by cursor |
GET /historical/trades | Trades older than the cutoff |
GET /historical/cutoff | The cutoff dates. On November 2, 2026 it returned trades_created_ts: 2026-09-03 |
GET /series/{series}/markets/{ticker}/candlesticks | 1-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
- Get a Polygon RPC endpoint and run the fills script for one day you care about.
- Read the Polygon eth_getLogs reference for filter options.
- Compare Polygon providers for this workload in Polygon RPC for Polymarket.
- New to the topic? Start with what a prediction market is.
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.




