Skip to content
All projects

Market systems research2025–2026Stopped

Building Polymarket trading systems until the evidence said stop

Three phases of research on Polymarket: a dataset builder, a 15-minute execution stack and a second-scale recorder with Rust and LightGBM. Each ended on evidence: the cost of data, then expected value near zero after fees, then a classifier that predicted well and still lost money as a maker.

Role
Independent research and engineering
Timeline
2025–2026, three phases
Stack
Python, Rust (PyO3), Polars, LightGBM, WebSockets
Scope
Data collection, backtesting, live execution, HFT recording
Outcome
Stopped at expected value near zero after fees
  • Python
  • Rust
  • PyO3
  • Polars
  • LightGBM
  • WebSockets
  • Parquet
  • Backtesting
  • +2
Recorded 15-minute SOL window: the Binance ask (blue, left axis, 78.4 to 78.9) plotted against the Polymarket Up bid (orange, right axis, 0 to 0.9) over 900 seconds
One recorded 15-minute SOL window (slug sol_1771810200): the Binance ask against the Polymarket Up bid.
Question
Could I find an edge in Polymarket's binary markets that survived fees, latency and real fills: first over long horizons, then 15 minutes, then seconds?
What I built
A Gamma and Data API dataset builder with holder-level features, a vectorized 15-minute backtester with a live bot on the order book, and a Rust + Python recorder feeding LightGBM.
Outcome
The measurement became strict enough to say no: 15-minute prices were well calibrated, and a 0.921-accuracy classifier still lost money as a maker. I stopped and kept the infrastructure.
Average expected value after fees in 15-minute markets
≈ 0
Phase 02, strict simulator
Backtest hit rate before and after a 300 ms reaction latency
~98% → ~55%
The stale-quote effect
Test accuracy of the 1-second adverse-selection model
0.921
Maker-style PnL still negative
Rows recorded in one 15-minute window
58,613
57 columns, local receipt timestamps

Hit rates are approximate: they describe the typical shape of short-horizon backtests in Phases 02 and 03, as noted in the code's quant audit. Accuracy comes from the LightGBM test evaluation; the row count from recorder slug sol_1771810200.

Each phase started where the last one got stuck

I moved from long-horizon markets to 15-minute windows to second-scale signals to get a faster, cleaner feedback loop. Each move exposed a harder bottleneck than the one before.

  1. Build the dataset that did not exist

    Phase 01 · Long-horizon prediction · Python, Gamma API, Data API, CLOB

    There was no public bulk dump of Polymarket trade history, so I built one: Gamma API for events, Data API for trades, optional order-book features, and a holder layer with the top 20 holders per market, a five-day trader-metrics cache and a whale-weighted market lean. Resume offsets, ID normalisation and 429 backoff kept long runs alive.

    Collection, not modelling, became the bottleneck, and long-horizon markets resolve slowly. The phase worked technically but cost too much for the feedback it gave, so I paused it. Phase 01 note

  2. Make the simulator stop flattering me

    Phase 02 · 15-minute Up/Down markets · Python, WebSockets, CLOB

    15-minute markets gave a fast loop and clean labels. I built a vectorized backtester with block configurations, strategy modes such as FADE, MOMENTUM, REVERSE and DUAL_ENTRY, walk-forward selection and per-slug volatility regimes, then a live bot with a WebSocket-driven state machine, a web dashboard and a server package.

    Once the simulator was strict, prices looked well calibrated and expected value sat near zero after fees. Phase 02 note

  3. Record my own data and trust my own timestamps

    Phase 03 · Second-scale signals · Rust, Python, Polars, LightGBM

    The last question was narrower: did second-scale signals, such as Polymarket lagging Binance, still contain something tradable? I moved WebSocket handling and the feature path into Rust, recorded both feeds with local receipt times, and kept the Parquet dataset pipeline and LightGBM training in Python.

    The models reached good accuracy, but maker-style PnL in evaluation was still negative. The HFT stack never traded live. Phase 03 note

Phase 02 · Measurement

The backtest had to hurt before I trusted it

The biggest improvement in Phase 02 was not a strategy. It was a simulator that stopped flattering me, built on three rules: process stop, then take-profit, then entry inside each block; model a 300–1000 ms reaction latency; and charge fees, slippage and liquidity limits, with 30 bps fees and 10 bps slippage as the baseline. The full method is in How a real backtest works.

Backtest setupHit rateWhat it meant
No reaction latencyUp to ~98%Buying quotes that were already gone. Flattering, not actionable.
300 ms reaction latencyAround the mid-50sA more honest baseline.
300 ms + slippage + liquidity filterOften weak or negativeWhere many apparent edges disappeared.

Typical shape across short-horizon runs, not a single benchmark. Stress runs went further: 1 s latency, 2% slippage and a minimum ask size of 500.

Backtest replay of 15-minute SOL window 1766572200: Up and Down bid and ask lines, five 3-minute blocks with entry levels falling from 0.34 to 0.03, nine trades and a total PnL of $0.52
Nine trades, +$0.52.
Backtest replay of 15-minute SOL window 1768423500 with the same entry levels: two late entries at 0.03 exit at 0.50, eight trades and a total PnL of $32.11
Eight trades, +$32.11.

Two 15-minute SOL windows replayed by the backtester with the same entry levels (plot labels in Spanish). The second looks brilliant only because two 0.03 entries flipped to 0.50; the flip analyzer showed such cheap late zones were often dead zones, not bargains. Only the aggregate after fees counts, and it stayed near zero.

Phase 02 · Execution

Live trading was mostly synchronisation

The live bot moved through five states against the order book: FLAT, ENTRY_WORKING, OPEN, EXITING and ROLLING_OVER. The hard part was agreeing with the exchange. WebSocket events, at roughly 50 ms, were the source of truth for fills; REST, closer to 500 ms, only reconciled; filled inventory stayed separate from open orders.

Partial fills, 15-minute rollovers and ghost orders (closed on the exchange, still active in the bot) were normal conditions, not exceptions. I added forced cleanup for ghost orders, a proactive stop placed when price comes within 0.10 of the stop level instead of 0.02, and no re-entry until price has moved away from the last stop. More in The reality of bots on Polymarket.

Polymarket trading dashboard for the live 15-minute bot: status Running, uptime 31 h 25 min, hourly restart timer, balance, realized PnL, one open take-profit sell order on SOL Up and one open SOL position
The web dashboard I used to monitor the live 15-minute bot remotely: status, uptime, the hourly restart, open orders and positions. A snapshot of one session, not a performance record.

Phase 03 · Architecture

Rust where timing matters, Python where iteration matters

The HFT phase split the stack by responsibility. Latency-sensitive code moved to Rust (poly_rust_core); everything that needed fast iteration (ETL, datasets, training, experiments) stayed in Python.

  1. 01

    Rust hot path

    poly_rust_core

    • Binance and Polymarket WebSockets
    • Event-driven wait on each Binance tick
    • Single 40-feature path: spread, OBI, lags
    • Recorder and latency primitives
  2. 02

    Recorder and ETL

    Python

    • Recorder orchestration, token refresh
    • Raw JSONL with local receipt time
    • Incremental JSONL → Parquet
    • Last-known-value alignment
    • Freshness columns, leakage checks
  3. 03

    Datasets

    prepared_l1, prepared_l2

    • Train, validation and test splits
    • Rows filtered at 200–500 ms latency
    • Checked in the feature explorer
  4. 04

    Models

    LightGBM

    • Taker: hft_1s, hft_5s, hft_10s
    • Maker: maker_1s
    • Adverse: adverse_bid_1s
    • Fair value and expiry variants
  5. 05

    Execution

    real_trading_hft

    • Paper and live modes, one design
    • No API calls in the hot loop
    • Queue-backed API worker
    • Ping checks, latency breaker, 0.10–0.90 price band

The execution layer supports paper and live modes; the HFT stack never traded live.

Inspect one recorded window yourself

This is the explorer I used to debug features: one recorded 15-minute SOL window (sol_1771810200, 58,613 rows, 57 columns). Plot Binance against the Polymarket book, switch the Hampel, EWMA and rolling-median filters, and see which features stay stable. There is also a full-page version.

Recorded L1 feature explorer, SOL 15-minute window

Open full screen
Feature explorer controls above a chart of binance_ask against up_bid across one recorded 15-minute window

About 6 MB. Loads Plotly from its CDN and plots a 12,000-point sample of the window.

Phase 03 · Models

A 0.921-accuracy model still lost money as a maker

I trained LightGBM on several target families built from the same features: taker horizons, maker protection, adverse selection, fair value and expiry. The most telling case was the 1-second adverse-selection model: test accuracy around 0.921, Brier score around 0.068, log loss around 0.244, and inference in the tens of microseconds per batch.

Its maker-style PnL in the same evaluation was negative. Whether a prediction pays depends on queue position, reaction time and real fills, which even an honest simulator only approximates. I trust that result more than a headline metric: the pipeline built a decent classifier and still refused to call it an edge.

Why I stopped, and what I kept

By the end, each phase had answered its question. Phase 01 worked technically but cost too much for the feedback it gave. In Phase 02, 15-minute prices were well calibrated and timing did not unlock a durable edge after fees. In Phase 03, a good classifier still produced negative maker-style PnL.

Stopping on that evidence was a result, not a failure. What I kept is the infrastructure and the habit: a reusable backtesting and optimisation framework, a live state machine with server packaging, a recorder and ETL that respect timestamps, and a simulator used to disqualify ideas rather than fall in love with them.

The system had become honest enough to reject the thesis instead of flattering it.

Read the notes in order

  1. Phase 01: the prediction dataset. The scraper, holder features and the real cost of building data.
  2. Phase 02: 15-minute trading. Vectorized research, the live bot and the result that ended it.
  3. Phase 03: HFT, Rust and the 98% lie. Recorder-first design, prepared datasets and model evaluation.
  4. How a real backtest works. Look-ahead, stale quotes, payout bugs and fantasy fills.
  5. The reality of bots. States, fills, workers and risk rails in a live bot.
  6. HFT feature explorer. The recorded-window explorer at full size.

Next case study

Classifying gym exercises from wrist-worn IMU data

Trading 15-minute Polymarket markets until the data said stop

I built a vectorized backtester with four entry modes and walk-forward selection, then ran a live bot on Polymarket's order book. Once the simulator charged for latency, fees and slippage, expected value stayed near zero, so I stopped.

Polymarket journey · 2/3

8 min read

HFT on Polymarket: Rust, timestamps and the 98% hit rate that wasn't

Second-scale trading research on Polymarket, built recorder-first: Rust on the hot path, Python for datasets and LightGBM. Good classifier metrics did not turn into profit, and a 98% backtest hit rate fell to about 55% with 300 ms of reaction latency.

Polymarket journey · 3/3

8 min

Get in touch

Working on something similar? I am happy to compare notes.