Order-book collector
It subscribes to every live market on a prediction-market exchange, keeps an L2 order book for each one, and writes every price-level change to stdout, plus a snapshot of every book once a minute, in the challenge's aligned SBE format. Every file it writes can be replayed to prove the books are exact.
The result
Two hours on AWS, both venues at once: Polymarket's full universe from London (c7i.xlarge, 4 vCPU, 8 GB) and Kalshi's whole demo universe from Virginia. The hour-long run the day before held 550k Polymarket books at 42k updates/s.
The pipeline
One engine, five stages. Only the first two know which venue they are talking to; that seam is the ExchangeAdapter trait (Ext 3).
- 01DiscoveryLists every live market at start and every 10 minutes, and follows the venue's listing and resolution events in between. New markets join; resolved ones are dropped.
- 02Shards and socketsMarkets are split into shards of 2,500 tokens. Each shard gets two websockets on different edge servers (Ext 1), so losing one socket or one edge loses nothing.
- 03DecodeTokio workers read the sockets and turn the venue's JSON into a common event: a price, a size and a timestamp.
- 04Core threadsEach core thread owns whole markets. It applies events to flat sorted books, chooses which replica is the source, and checks each book against the venue's hash.
- 05WriterEncodes updates and snapshots with the template's SBE helpers into 64 MiB chunks. Each chunk is written as it fills, then the file is rotated off the hot path.
When something goes wrong
Each case below has a test that injects the fault against a mock venue, and most were also seen live.
--lag-ms). Quiet sockets are reconnected; every socket is recycled hourly, make-before-break (Ext 2).--supervise restarts it in about a second and keeps every whole chunk of the open file.What the challenge asked for
| Requirement | Where it stands |
|---|---|
| Every live market, many websockets, one L2 book each | Full universe, about 450 sockets, flat sorted books |
| Deltas plus a snapshot every 60 s, the template's helpers | PriceLevelUpdate, Snapshot, AlignedSbeWriter |
| Output to stdout; SIGINT closes sockets and file | Both; SIGTERM and panics too |
| No MVEs (Kalshi) | mve_filter=exclude |
| Ext 1: redundant websockets | 2 per shard on different edges, source arbitration, --supervise |
| Ext 2: subscription monitoring | Rate-aware resubscribe, quiet-socket reconnect, hourly recycle |
| Ext 3: generic encode/decode | Polymarket and Kalshi adapters on one engine |
| Ext 4: latency | Four experiments on three hosts; see Latency |
Each claim, with the command that reproduces it: GRADING.md. The design choices and the alternatives they beat: DECISIONS.md.
Every run, by host
Five stages, in the order they ran. Each move came from what the run before it showed. Every output file that was closed replayed with 0 mismatches.
The link was the bottleneck
A 3.3 Mbit/s link to Singapore could not carry the start-up dumps of 131k tokens.
| Run | Scale | Result | Finding |
|---|---|---|---|
| Run 4 | 131k tokens, 265 sockets | file cut off | VPN collapsed into a reconnect storm |
| Run 6 | 47.5k of 131k got a book | 1.01M updates, 0 mismatches | A coverage gap (fix #2) |
| Run 7 | about 96k of 131k | 614k replayed, 0 mismatches | Wire p50 130 s: move to the cloud (fix #1) |
Scale, faults and a 2.5-hour soak
A few ms from Polymarket's London origin. Most of the fixes were found here, under the full universe and injected faults.
| Run | Scale | Updates | Replay | Finding |
|---|---|---|---|---|
| Cloud 1 | 250k of 446k | 4.44M | 0 / 2.27M | The universe is bigger; 429s (#3) |
| Cloud 2 | 459k, all placed | 5.67M | 0 | Timers starved, core saturated (#4) |
| Cloud 3 | 557k | 9.68M | 0 / 5.27M | Hash hot spot fixed (#5) |
| Soak 1, 90 min | 559k, 452 sockets | 185M | 0, 10 files | Ext 2 over-tuned (#7) |
| Soak 2, 30 min | 560k | 64M | 0, 4 files | Core CPU is the limit (#8) |
| Profile 2 | 561k | 18.6M | 0 / 10.3M | Rotation fsync stalls everything (#9) |
| A/B, 2 × 50 min | 566k | 108.6M ×2 | 0 / 110M | Hot-shard split loops (#10) |
| Soak 3, 2.5 h | 551k, 452 sockets | 320M | 0, 16 files | Stable; 3 GB RSS, disk waits (#11, #12) |
| Final gate | 559k, kill -9 + SIGSTOP | 23.5M | 0, 7 files | Every recovery path works |
| Perf gate 1 | 563k, --pin --kernel-ts | — | 0 / 9.67M | RSS 1.73 GB; a --pin bug (#13, #14) |
| Perf gate 2 | 561k, 1 io worker | — | 0 / 10.8M | One io worker is too few (#15) |
During the soaks: all 224 sockets to one edge were killed, the process was frozen for 15 s, and 448 sockets were recycled every hour. 535 disconnects healed on their own. The Codespace clock drifted up to 88 ms and its disk stalled 8 times, which is why the latency figures come from AWS.
The measured run, with a synced clock
Launched, run and torn down by one script from the laptop, for about $0.50. Flags: --supervise --pin --kernel-ts --rotate-mins 10.
kill -9 at 55 minThe next day, five A/B tuning rounds on the same host type (see Latency), and three regions started at the same second for 10 minutes on 2,000 markets:
| Region | Updates | Replay | p50 | p90 | p99 |
|---|---|---|---|---|---|
| London, eu-west-2 | 675k | 0 / 81,288 | 7 ms | 21 ms | 118 ms |
| Ireland, eu-west-1 | 680k | 0 / 82,052 | 12 ms | 25 ms | 73 ms |
| Virginia, us-east-1 | 679k | 0 / 82,008 | 43 ms | 62 ms | 349 ms |
Kalshi, the challenge's venue, live
Kalshi needs a signed key even for public data, and production accounts are not open in India. The demo environment issues free keys. Same binary; only the URLs change.
| Run | Markets | Sockets | Length | Updates | Snapshots | Mismatches |
|---|---|---|---|---|---|---|
| Smoke | 200 | 12 | 60 s | 80,062 | 332 | 0 |
| Main | 1,000 | 12 | 15 min | 1,726,633 | 29,806 | 0 |
| One subscription | 2,500 | 2 | 150 s | 276,552 | 12,006 | 0 |
Two hosts, each next to its exchange
Polymarket from London on a c7i.xlarge, Kalshi's whole demo universe from Virginia on a c7i-flex.large, started together. Same flags, a kill -9 near the end of each. About $0.72 for both.
| Polymarket, London | Kalshi demo, Virginia | |
|---|---|---|
| Universe | 488k tokens, 392–400 sockets | about 100k markets, 162–166 sockets |
| Updates | 231.7M (31–34k/s) | 32.3M (3.4–4.9k/s) |
| Replay | 0 / 118.2M, 14 files | 0 / 48.9M, 14 files |
| recv − exch p50 / p99 | 5–6 ms / 19–128 ms | 33–36 ms / 43–49 ms |
| Host delay p50 | ≤ 16–32 µs | ≤ 32–64 µs |
| RSS peak, load | 1.97 GB, 1.1 of 4 | 0.63 GB, 0.2 of 2 |
kill -9 | restarted in 1 s, open file recovered | restarted in 1 s |
Raw logs for every run are in bench/; the full story, turn by turn, is the engineering log.
How it got here
The first build was correct but small. Each run at a bigger scale or a harder fault found the next limit; each limit got a fix and a measurement. The charts follow the same metric across the runs, labelled with the fixes that landed before each one.
Reading the charts: each point is one run. The second line under a run names the fixes that came before it (numbers match the list below). Hover or tap a point for its value; each chart has a table view.
The fixes
In the order they were found. Each line says what hit the limit, how it was found, and what the fix moved.
- #1Laptop link behind a VPN, 3.3 Mbit/sRun 4 collapsed into a reconnect storm. Moved to a cloud host near London; added a global outage backoff.wire p99: minutes → 173 ms
- #2Coverage stuck at 47.5k of 131k tokensCoverage grew one shard per 30 s, then stopped. The open limiter now reads the current gap; books that never arrive are re-asked.47.5k → 96k, later 100% placed
- #3Socket cap and rate-limited lookupsCloud 1 left 196k tokens unplaced and 102 lookups got 429s. The cap is sized to the universe; lookups are batched with backoff.250k → 459k placed, 0 429s
- #4Timers starved by inbound messagesCloud 2 printed one status line in 5 minutes. Timers run first; inbound is taken in batches of 256.p99 71 s → 524 ms
- #5The hash check took 45% of the coreA18.9k → 32.3k updates/s
perfprofile showed allocation per level and a full rehash per candidate price. Hash once, copy the partial state, format without allocating. - #6One core thread at 99.9%Per-thread CPU from99.9% → 50–90% per core
/proc. K core threads now each own whole markets. - #7Ext 2 over-tunedSoak 1 made 400k resubscribes in 90 minutes. Quiet detection became rate-aware, and the wait doubles on each strike.resubscribes cut to the truly quiet tokens
- #8Hashing standbys and a full lag walkA whole-process profile. Only the source replica is hashed; the lag check keeps an incremental candidate list.core CPU 50–60% → 31–38%
- #9Rotation fsync stalled every readerProfile 2 had 330 s and 1,153 s blocked in rotation windows. A finisher thread fsyncs and renames off the writer.blocked time at rotation → 0
- #10Hot-shard splitting caused a reconnect loopThe A/B showed 11–19× the disconnects: the venue closes a socket whose assets have all resolved. Resolved tokens are dropped; splitting is opt-in.loop gone
- #11Memory at 2.8–3.3 GBAn offline bench found 7.6 KB of heap per token. mimalloc, flat sorted books, inline replicas, one symbol per token, lazy hash state, FxHash.RSS 3.0 → 1.73 GB
- #12Disk stalls could reach the booksThe 2.5-hour soak had 8 writer waits of 1–1.5 s. A byte-bounded 256 MiB writer queue absorbs them.8 waits → 0
- #13The receive time included TLS and parsinghost delay measured: p50 ≤ 32 µs on AWS
--kernel-tsreads the kernel's arrival time (SO_TIMESTAMPNS) and reports the host's own delay. - #14
--pinstarted one core thread instead of twoFound in the gate log. CPUs are counted once, before pinning.2 core threads, 281k / 280k tokens - #15One tokio worker for about 450 TLS socketsHost delay was 4× worse in gate 2. A 5-minute A/B of three layouts; io workers now default to at least 2.host p99 8–16 ms → 4–8 ms
- #16A
kill -9without the supervisor lost the fileChunks were written at the end. Each chunk is now written in place as it fills.data kept after kill at 120 s: 0 → 119.8 s
Ruled out by measurement: the TCP source port (Ext 4, Experiment 3), hyperthread siblings for the core threads, and the ~1% of tokens with no book (the venue has none).
How correctness is proven
The output carries its own proof. A snapshot of a side plus every update after it must equal the next snapshot of that side, exactly. verify rebuilds every book from the updates and checks this for every snapshot in a file; any mismatch exits non-zero.
kill -9, SIGTERM) and 2 into a mock Kalshi (a seq gap, a new listing), each beside a clean baseline. Each runs the real binary and checks its output with verify.kill -9 of the child under --supervise; all 448 sockets recycled every hour. Every file replayed with 0 mismatches.429s across both venues in the two-hour run, each reconnected on its own while the peer replica kept the books; the files still replayed with 0 mismatches.bench/aws/run.sh all launches the hosts, runs the gates, pulls the results and tears everything down for about $0.50. The raw logs of every run are in bench/.The method
Measure at the next scale, find the limit, fix it, measure again. Every number on this page comes from a logged run, every fix names the run that found it, and an idea that did not beat the default stays off, with its result kept.
The decisions that shape it
Each design choice, the alternative it beat, and the evidence. All 21 are in DECISIONS.md.
- Choice
- Two sockets per shard on different edge servers, each keeping its own book; one replica per token is the source.
- Rejected
- Merging first arrivals across sockets. Polymarket sends no sequence numbers, so dedup would be a heuristic that collapses two real, identical updates in the same millisecond.
- Evidence
- A failover target already holds a live, checked book, so a switch costs no round trip to the venue.
- Choice
- Write the level changes from the old book to the new one as updates, then a snapshot.
- Rejected
- A snapshot alone. It hides whatever was lost before it: a reader cannot tell a switch from missing data.
- Evidence
- The snapshot-only first build gave 15.5k replay mismatches. With the diff: 0 in every file since.
- Choice
- A strike ladder on the venue’s SHA-1 book hash: resync only on a peer disagreeing or a second miss in a row. Only the source replica is hashed.
- Rejected
- Resync on every miss. Recomputing the hash is not exact (98.9% on one correct golden book), so 1% false misses on 566k tokens would be a resync storm.
- Evidence
- Golden captures 100% / 100% / 98.9%; hashing only the source cut core CPU by 38%.
- Choice
- Each core thread is a single-threaded state machine that owns whole markets, routed by hash. Time and side effects are injected.
- Rejected
Arc<Mutex<Book>>per token: locks on the hot path and no single order of events per market.- Evidence
- Event order is correct by construction, and 48 unit tests drive the core directly. One core saturated at 560k tokens; two run at 31–38%.
- Choice
- Two symbols per token,
<token>:bidand:ask; 64 MiB chunks written in place as they fill. - Rejected
- Inferring the side from the price (ambiguous inside the spread); other chunk sizes (the template’s reader only reads 64 MiB).
- Evidence
- Lossless, and byte-compatible with the template reader. A
kill -9at 120 s kept 119.8 s of data.
- Choice
- A writer thread behind a 256 MiB byte-bounded queue; a second thread fsyncs and renames rotated files.
- Rejected
- Inline fsync at rotation.
- Evidence
- Blocked time in two rotation windows: 330 s and 1,153 s → 0 in all 11 rotations. Disk waits in the soak: 8 → 0.
- Choice
- A panic shuts the process down in order and exits non-zero;
--superviserestarts it, trims torn chunks and marks the gap with a reset snapshot. - Rejected
- Restarting a dead core in-process (its sockets, books and writer source would all need rebuilding); systemd alone (it cannot repair a torn chunk or mark the gap).
- Evidence
- 3 injected panics → 3 restarts,
verifyOK. On AWS,kill -9at 55 min → back in 1 s, file intact.
- Choice
- Decimal strings parsed straight to
i64(1e6 = 1.0). More than 6 dp is rejected loudly. - Rejected
- Parsing through
f64. - Evidence
- Every Polymarket price and size is exact; nothing is rounded.
- Choice
- Measure the host clock offset over SNTP and log it; leave steering to chrony.
- Rejected
- Correcting
received_atby the offset: a step mid-run would reorder timestamps. - Evidence
- On AWS, chrony held 0.4–9 µs RMS; the Codespace’s drift of tens of ms was caught this way.
Built, measured, and kept off
Four ideas were built in full and A/B tested. The data did not justify them as defaults, so each stays as an opt-in flag and the null result is written down.
| Idea | What the A/B showed | Default |
|---|---|---|
| Hot-shard splitting | 11–19× the disconnects: the hot markets are 5–15 minute crypto markets that resolve right after a split. It also exposed a reconnect-loop bug, now fixed. | off, --split-ratio |
| Speed-aware source | Three A/Bs, the last on the full universe on AWS: mean 9.1 → 8.8 ms, p99 52 → 50 ms, for 4.8× the switch snapshots. | off, --fast-source |
| 1 s failover window | 0 false switches at 2,000 markets, but no faster delivery; the 3 s margin stays for the full universe’s stalls. | 3 s, --lag-ms |
| Kernel busy polling | The second “off” arm matched “on”: the difference was venue drift. | off |
Where the time goes
Three numbers, each with a different clock problem. Only the last one is time spent inside the collector.
recv − exchbehind--kernel-ts). This is the part the collector controls.The four experiments
Replicas use different edges, and the source is the replica that is ahead.
--fast-source scores sockets and prefers the faster one. It moved means by tenths of a ms and made one p99 worse. opt-in--fast-source, 4 replicas, a 1 s failover window, kernel busy polling. The best gain was under 1 ms at p50. defaults keptExperiment 4, round by round
| Round | Arm | Written p50 / p99 | Behind mean / p99 |
|---|---|---|---|
| r1, full universe, 20 min | default | 5 / 52 ms | 1.5 / 13 ms |
--fast-source | 5 / 50 ms | 1.2 / 11 ms | |
| r2, 2,000 markets, 15 min | --fast-source | 5 / 30 ms | 0.4 / 3 ms |
+ --replicas 4 | 5 / 31 ms | 0.7 / 5 ms | |
| r3, 2,000 markets, 15 min | --lag-ms 3000 (default) | 5 / 36 ms | 1.1 / 15 ms |
--lag-ms 1000 | 5 / 28 ms | 0.8 / 6 ms |
Conclusion. The collector adds microseconds; the milliseconds are the venue and the path to it. The one lever that moved the median by a lot is the region: London 7 ms, Ireland 12 ms, Virginia 43 ms on the same markets at the same second.
Every round, table and raw log: LATENCY.md.
Kalshi, the challenge's venue
The challenge names Kalshi and allows Polymarket as the fallback. --venue kalshi runs the same core, socket actor, writer and verify on a Kalshi adapter (Ext 3). The adapter is the only part that knows Kalshi exists.
| Polymarket | Kalshi | |
|---|---|---|
| Auth | none for market data | a signed handshake: RSA-PSS or Ed25519, fresh per connect |
| Updates | absolute size at a price | relative deltas, turned into absolute sizes by the adapter |
| Gap detection | the venue's book hash | seq per subscription; a gap triggers a resync |
| Universe | about 550k tokens | MVEs excluded with mve_filter=exclude |
| Symbols | <token>:bid, :ask | <ticker>:yes, :no bids |
How it was tested
verify. Cases: a baseline, a seq gap, a new listing.seq gaps. Also 2,500 markets on one subscription../demo.sh both: one process per venue, side by side; each file replays with 0 mismatches.Environment. The live runs used Kalshi's demo environment, which serves the same websocket API and message formats as production. Pointing it at production is the default: ./demo.sh kalshi with a production key.
Details: KALSHI.md.
Run it
Needs stable Rust. One command builds, replays the committed sample, runs the offline tests and collects two minutes of live Polymarket data:
./demo.sh
Polymarket, by hand
cargo build --release --bins
./target/release/collector --max-markets 2000 --duration-secs 120 > ob.sbe
./target/release/verify ob.sbe
The output goes to stdout by default and status lines to stderr. --out ob.sbe writes the file directly; rotation needs that form.
Kalshi
Put a key in a gitignored .env (KALSHI_API_KEY_ID, KALSHI_PRIVATE_KEY_PATH), then:
./demo.sh kalshi # production
KALSHI_DEMO=1 ./demo.sh kalshi # demo environment
./demo.sh both # both venues at once
Elsewhere
docker build -t collector ., then run it with a volume for the output../demo.sh in it.bench/aws/run.sh all launches, runs, pulls and tears down the measured run.--supervise --rotate-mins 10; sizing for 4 vCPU and 8 GB.