Building the maker/maker trading system

The architecture behind the real-money test of the Kalshi vs Polymarket series: two venue engines, a single-threaded decision core, automatic fixture matching, book freshness, and a trading record built for the replay comparison.

System architecture of maker/maker, a three-order cross-venue market maker for soccer team-win markets on Kalshi and Polymarket: components, fixture discovery, order-book data flow, the decision and reconciliation loop, latency, safety controls and recording.
Author

Zehao Zhou

Published

October 11, 2026

Part 3 of the Kalshi vs Polymarket series ended with a strategy outline and a replay against recorded order books. Three resting orders per team-win outcome, a Polymarket YES bid, a Polymarket YES ask and a Kalshi YES ask, made money in the replay even at the back of every queue. Part 4 sized a first real-money test from the same replay. This article describes the system built to run it.

The live fixtures measure what the replay had to assume: queue position, double fills, latency and holding time. The system records those quantities alongside its orders.

Summary

  1. Four processes from one image. Two venue engines behind one gRPC contract, an execution host for the strategy, and a management service for the control page, linked by mutual TLS (section 2).
  2. Fixtures are matched automatically; enabling one is the human check. Both venues’ moneyline listings in six club competitions are paired automatically; matching pauses while a fixture is starting or trading (section 3).
  3. A book is either fresh or pulled. Engines re-send every valid book once a second while its socket is provably alive; execution cancels an outcome’s three orders when either book is 3 s old (section 4).
  4. The rules are a pure function; everything else is reconciliation. One strategy thread owns all state, computes the desired orders from books with its own orders removed, and issues the fewest writes to get there: an amend on Kalshi, a cancel and re-post on Polymarket (section 5).
  5. One-second replay steps do not bound live latency. The replay groups trades into seconds and changes orders at the boundaries; feed, request and response delays still need measurement (section 6).
  6. Order uncertainty limits new quoting. Unknown orders pause their leg, outstanding cancels reserve room for possible fills, and every start reads inventory from the venues (section 7).
  7. The record is built for the replay comparison. Decisions, order events, queue estimates, books, trades and fill windows go to one SQLite file per day, so the replay can be rerun on exactly the fixtures traded (section 8).

1. From replay to live orders

The replay of part 3 is a model with simplifications. Each simplification becomes a requirement of the live system:

Concern Replay (part 3, section 6) Live system
Market data Recorded snapshots, always present Sockets can stall silently and lose messages; books carry an age and are pulled when stale (section 4)
Own orders Never in the book Shown in the book; prices are read with our own size removed (section 4)
Order changes Applied at second boundaries, without separate request or response delays Round trips, requests in flight and unknown outcomes; a reconciler tracks the state of every order (section 5); section 6 explains the timing assumption
Fixtures A curated, matched list Matched live from both venues’ listings; refreshed between trading sessions (section 3)
Polymarket pairs A YES + NO pair merges back to $1 at once Merges are submitted after 60 s without a Polymarket fill, once at least L pairs are held; cash is released on confirmation
Queue position An assumed share ρ of the displayed size Measured per order, and checked against Kalshi’s own queue positions (section 8)
Inventory at start Zero Read from venue positions at every start and restart (section 7)
Failures None Stale books, engine restarts, lost fill streams, process death (section 7)

The strategy defaults

The two team-win outcomes have separate inventories and share a per-venue cash cap.

Leg Order Price Size Rests only while
pm_yes Polymarket buy YES, post-only b, Polymarket best bid max(0, min(L − q, 3L)) b < Kalshi best ask (the guard)
pm_no Polymarket buy NO at 1 − a, post-only a, Polymarket best ask max(0, min(L + q, 3L)) a > Kalshi best bid (the guard)
k_no Kalshi buy NO at 1 − ak, post-only ak, Kalshi best ask max(0, min(L + q, 3L)) ak > Kalshi best bid, and ak − b − Kalshi maker fee > 0

Inventory q is Polymarket YES held minus NO held on either venue, per outcome. All legs also need both books valid and fresh, a Polymarket mid of 5–95 c, and the in-play window from kickoff to 104 minutes. The defaults are L = 10, a cash cap of $300 per venue and fixture, and no guard buffer. The cap limits opening exposure; the hedging portion is exempt (section 5). Every value is a configuration field, changed one at a time, between fixtures.

2. Components and topology

flowchart TB
  user(["Operator browser"])
  subgraph fly["Fly.io private network"]
    mgmt["management<br/>TypeScript"]
    exec["execution<br/>C++<br/>module: maker_maker"]
    vol[("volume<br/>SQLite record, config")]
    epm["engine-pm<br/>C++"]
    ek["engine-kalshi<br/>C++"]
  end
  pm[("Polymarket<br/>CLOB REST and WebSocket,<br/>Gamma listings")]
  ka[("Kalshi<br/>Trade API REST<br/>and WebSocket")]
  live[("Kalshi live_data<br/>match state")]
  user -- "HTTPS, password" --> mgmt
  mgmt -- "ExecutionControl<br/>state, config, enable, stop" --> exec
  exec -- "VenueEngine" --> epm
  exec -- "VenueEngine" --> ek
  exec --> vol
  exec -- "HTTP poll, 5 s" --> live
  epm --> pm
  ek --> ka
Figure 1: Runtime topology. Arrows point from client to server. Every gRPC hop is mutually authenticated TLS on Fly’s private network; only management is reachable from the internet.

The four processes are built from one container image; an environment variable selects which one starts. Each has one responsibility, and the boundaries are where the failure handling sits:

Process Owns Does not
engine-pm Polymarket market and user WebSockets, books, public trades and fills, EIP-712 order signing, orders, positions, the venue-side dead-man heartbeat, Gamma market listings Make trading decisions
engine-kalshi Kalshi WebSocket (books, trades, fills on one socket), RSA request signing, orders including amend, positions, queue positions, market listings Make trading decisions
execution One compiled-in strategy module per process. maker_maker: fixture matching, decisions, order lifecycle, safety rules, the trading record Submit trading orders directly to a venue
management Login, the control page, configuration pass-through, a management heartbeat to execution every 15 s Hold trading state

The engines implement one gRPC service, VenueEngine. Execution registers internal instruments such as <fixture>-home-pm-yes and <fixture>-home-k-no, and each engine maps them to a Polymarket token or a Kalshi ticker and side. Prices, sizes and cash are fixed-point integers (price × 10⁴, contracts × 10⁶, USD × 10⁶), and the order RPCs use those integer units.

3. Fixture discovery and matching

flowchart TB
  K["engine-kalshi listing<br/>listed time − 3 h = kickoff"] --> S{"six club<br/>series?"}
  P["engine-pm listing<br/>moneyline markets only"] --> C
  S -- "yes" --> C["candidates: Polymarket events<br/>with kickoff within ±6 h"]
  C --> T{"teams above<br/>thresholds?"}
  T -- "yes" --> R{"runner-up<br/>within 0.015?"}
  R -- "no" --> O["map outcomes: Kalshi by label or ticker code,<br/>Polymarket by outcome label"]
  R -- "same pair listed twice:<br/>keep the closest kickoff" --> O
  O --> U["one Kalshi event per Polymarket event<br/>→ fixture table on the control page"]
Figure 2: Fixture discovery. Both engines list soccer moneyline markets; execution filters, scores and maps them into fixtures shown on the control page. A Kalshi event that fails a check, or whose runner-up is a different pair of teams within 0.015, is skipped.
Rule Setting
Competitions Premier League, La Liga, Bundesliga, Serie A, Ligue 1, Champions League: the six of the part 3 replay, filtered by Kalshi series (KXEPLGAME, …) only, since a fixture must be listed on both venues anyway
Kickoff window ±6 h, after Kalshi’s 3 h correction
Team score Best of token subset, Jaccard and sequence ratio after an alias table for the six leagues’ clubs; women, reserve and B-team qualifiers must agree; home and away straight or swapped
Accept Fixture score ≥ 0.86 with the weaker team ≥ 0.72; or kickoff within 4 h, score ≥ 0.79 and weaker team ≥ 0.64
Ambiguity No match when the top two candidates score within 0.015, unless both are the same pair listed twice
Outcome mapping Kalshi by outcome label or ticker suffix code; Polymarket by outcome-label similarity ≥ 0.6, with a NO token present

There is no accept step. Enable is the human check. Before a fixture is enabled, its row shows both venues’ raw titles, the kickoff gap between them and both venues’ prices, so a mismatch is visible at a glance.

stateDiagram-v2
  [*] --> Upcoming: matched
  Upcoming --> Scheduled: Enable
  Scheduled --> Upcoming: Disable
  Scheduled --> Starting: kickoff − 30 s
  Starting --> Quoting: orders swept, positions read
  Starting --> Scheduled: failed, retry in 10 s
  Quoting --> Starting: fill stream lost
  Quoting --> Upcoming: Disable or stop all
  Quoting --> Stopped: loss stop
  Quoting --> Ended: 104 min after kickoff
  Ended --> Finished: market closed
  Stopped --> Finished: market closed
  Finished --> [*]: 48 h later
Figure 3: Fixture lifecycle. Enabling registers the fixture’s instruments and subscribes its books at once, so they are warm at kickoff. Quoting runs on Polymarket’s scheduled kickoff.

The quoting clock is Polymarket’s scheduled start. Match state and significant events are recorded from Kalshi’s live_data feed, polled every 5 s from 15 minutes before kickoff. This feed is used for the record and display, rather than quoting.

4. Order books: from venue socket to decision

All the strategy reads from market data is, per outcome and venue, a best bid and a best ask with our own orders removed, and the age of the book they came from.

flowchart TB
  pws["Polymarket market WebSocket<br/>YES token only, client PING every 2 s"] --> ps["engine-pm book store<br/>valid after a snapshot"]
  kws["Kalshi WebSocket, orderbook_delta<br/>one sid, client ping every 2 s"] --> ks["engine-kalshi book store<br/>valid after a snapshot, while seq has no gap"]
  ps -- "StreamOrderbook: on change, re-sent every 1 s" --> bt
  ks -- "StreamOrderbook: on change, re-sent every 1 s" --> bt
  subgraph X["execution"]
    bt["book stream threads<br/>stamp receive time"] --> qq[["strategy queue"]] --> xs["ex-self best bid and ask, book age"] --> d["decide()"]
  end
Figure 4: Order-book data flow. Engines keep one book per subscribed instrument and stream it to execution on every change and at least once a second while the book is valid and its socket alive.

One book per venue and outcome

maker_maker subscribes to the YES instrument only. On Kalshi the YES market’s book already carries both sides. Orders still go to the NO instrument where a leg buys NO; their prices are read off the YES book, because a NO bid at x is a YES ask at 1 − x.

Leg Order sent to Order price Read from the YES book
pm_yes Polymarket YES token b Polymarket best bid, ex-self
pm_no Polymarket NO token 1 − a Polymarket best ask, ex-self
k_no Kalshi market, NO side 1 − ak Kalshi best ask, ex-self

Freshness

A quiet book can be healthy. Engines re-send valid books once a second while the socket is alive; if either venue’s book reaches 3 s without an update in execution, the outcome’s three legs are pulled.

Cancelling all three legs on either stale book is deliberate: the Polymarket legs need Kalshi’s book for the guard, and k_no needs Polymarket’s bid for its edge check. A closed connection invalidates its books and stops their re-sends. A silent stall first has to be detected by the engine before execution’s stale-book rule can act.

sequenceDiagram
  participant V as Venue WebSocket
  participant E as Engine
  participant X as Execution
  V->>E: delta
  E->>X: book (changed)
  Note over V,X: quiet market, socket answers pings
  E->>X: book (re-send, 1 s)
  E->>X: book (re-send, 1 s)
  V--xE: socket stalls
  Note over E: no frame for 5 s: socket dead, re-sends stop
  Note over X: book age reaches 3 s
  X->>X: cancel pm_yes, pm_no, k_no
  V->>E: reconnect and snapshot
  E->>X: book
  X->>X: quote again
Figure 5: A quiet book and a dead feed look different downstream. While the socket answers pings, the engine re-sends the book every second; once it goes silent, re-sends stop and execution pulls the outcome’s orders.

Removing our own orders

The strategy joins the best price of everyone else. If it priced against a book that includes its own order, the order would peg to itself and stay behind when the rest of the market moves. Execution therefore subtracts its own resting size at each level before reading the top of the book. Our NO bids at 1 − p count as YES asks at p.

The subtraction has a race: just after an order is placed, the venue may still send a book that does not show it. The rule is: others = displayed − own when displayed ≥ own, otherwise displayed.

5. The decision core

One thread, one queue

flowchart TB
  b["book streams ×2"] --> Q
  t["trade streams ×2"] --> Q
  f["fill streams ×2"] --> Q
  c["management commands"] --> Q
  Q[["event queue"]] --> a
  subgraph ST["strategy thread"]
    a["apply events: books, fills, order states"] --> tm["100 ms timer: matching, health, heartbeats, sweeps"] --> dc["decide() per fixture"] --> rc["reconcile per leg"]
  end
  rc --> pmio["Polymarket I/O pool, 4 threads"]
  rc --> kio["Kalshi I/O pool, 4 threads"]
  pmio -. "responses" .-> Q
  kio -. "responses" .-> Q
  rc --> out["state snapshot to the page, ≤ 250 ms<br/>rows to the recorder queue"]
Figure 6: The strategy thread. Stream threads and I/O pools only post events; all trading state is read and written on one thread, so it needs no locks.

One strategy thread owns all trading state and drains one queue. Blocking calls run on worker pools, so a place and a cancel can be in flight together, and their responses come back as events. Each batch ends with decide and reconciliation for every active fixture.

decide: the rules as a pure function

decide is based on the replay’s quoting step, and it stays pure: the same input gives the same targets. Its input is, per outcome, the two ex-self tops of book with their ages and q, plus cash spent per venue, time since kickoff and the configuration. Its output is, per outcome and leg, a price and size or a reason for not quoting.

The cash cap is per venue and fixture, shared by both outcomes. Cash used includes positions and outstanding orders, including orders being cancelled. The portion of a leg that reduces |q|, up to |q| contracts, is exempt from the cap; only opening contracts share the remaining room. This can take cash used above the configured cap, and still requires sufficient venue balance. Confirmed Polymarket merges release cash.

The reconciler

The touch is only one of the reasons orders change: fills change q and therefore sizes, and the guard, the cap and the window add or remove legs. Instead of one rule per trigger, the reconciler diffs the desired orders against the live ones after every event and issues the fewest writes. Each leg is a stack of up to four child orders at one price.

Situation Kalshi Polymarket
Touch moved Amend the oldest child to the new price and size, less possible fills from departing children; cancel the others Cancel old children; post only the size left after counting their possible fills, then top up as cancellations resolve
Leg too large Amend the youngest child down; it keeps its queue priority Cancel the youngest children
Leg too small New child at the back of the queue, at least 1 contract New child at the back, at least 5 contracts
Leg must not quote Cancel at once, unthrottled Cancel at once, unthrottled

Repricing is limited to once per 500 ms per leg, with overlapping requests restricted by the reconciler. Token buckets budget 15 writes/s on Polymarket and 8 on Kalshi; cancels bypass the buckets. Kalshi’s amend has no post-only flag: the local price check cannot prevent the book from moving before the amend arrives, so an amend can fill as a taker. Every fill records whether it was a taker.

stateDiagram-v2
  [*] --> PendingNew: place
  PendingNew --> Live: ack
  PendingNew --> [*]: reject
  PendingNew --> Unknown: timeout
  Live --> PendingAmend: amend
  PendingAmend --> Live: done
  PendingAmend --> Unknown: timeout
  Live --> PendingCancel: cancel
  PendingCancel --> [*]: confirmed
  PendingCancel --> Live: failed, retry
  PendingCancel --> MaybeFilled: not found or possible unreported fill
  PendingAmend --> MaybeFilled: not found
  MaybeFilled --> [*]: fills received or 10 s elapsed
  Unknown --> Live: open at venue
  Unknown --> [*]: not found
  Live --> [*]: filled or gone
Figure 7: States of one child order. A reject makes its leg wait 1 s; a failed cancel returns the order to Live, to be cancelled again after a backoff of 1 s, doubling to 10 s. An unknown order pauses its leg until resolved. A possibly filled order reserves room for late fill reports for up to 10 s.

Unknown orders are resolved against the venue’s open orders every 2 s. Every 30 s live orders are checked; an order missing from the list is cancelled rather than simply forgotten. Orders on our instruments that execution does not track, seen in two sweeps in a row, are cancelled.

6. Latency: what the one-second replay assumes

A one-second state grid is not a one-second latency budget. Parts 3 and 4 summarize the replay’s latency as 1 s. More precisely, it is a once-per-second reaction model without separate feed, order-entry or response delays. That does not establish that those delays add up to nearly, or at most, one second.

What happens at a second boundary

The replay in mm_sim.py first sends all trades in second s against the orders already resting. It then reads the latest recorded books at or before s + 1, applies cancellations, resizes and placements, and uses the resulting orders for trades in second s + 1. A book from time t is therefore used for the trade bucket beginning at t; there is no additional one-second transit delay after that book is read. Inventory changes from all fills in a bucket are known at its end, and a cancel and replacement can both take effect at the same boundary.

An event between boundaries waits for the next decision. With ideal, immediately available data, that scheduling wait is between zero and one second; it is not necessarily close to one second.

The delays that matter to a maker

HftBacktest’s latency model distinguishes feed latency, order-entry latency and order-response latency. The same distinction helps identify what this system must measure:

Delay Path in this system
Market data Venue event → engine WebSocket → gRPC → execution
Local reaction Execution’s event queue, decision, reconciliation and write throttles
Place, amend or cancel becoming effective Execution → venue engine, signing and REST → venue processing
Acknowledgement and fill reports Venue → engine → execution

7. Fills, inventory and safety

Fills arrive on each engine’s fill stream: Kalshi’s fill channel and Polymarket’s user channel. Execution deduplicates them by venue and fill id, updates the outcome’s holdings, q and cash, and the next decide resizes the legs.

Inventory is never assumed. Thirty seconds before kickoff, a fixture registers its instruments again, cancels every open order on them and reads positions and balances from both venues, so a restart mid-match continues from the true inventory. A lost fill stream restarts every quoting fixture the same way, because fills may have been missed while it was down. P&L counts fills since the fixture started, marked at Kalshi’s mid.

Trigger Scope Action
Either book 3 s old Outcome Pull the three legs; quote again on the next fresh book
Guard Leg Pull the Polymarket leg
Kill switch All Disable every fixture and sweep every open order

maker_maker is the only module trading its accounts.

8. The trading record

The record keeps only what the replay comparison needs and what cannot be downloaded later. It is one SQLite file per UTC day on the execution volume. The strategy thread enqueues rows and a single writer thread writes them, so trading never waits on disk, and a full disk drops rows without stopping trading.

Table One row per Answers
decisions Order action, with kind (new, top-up, shrink, cancel, re-post, amend) and reason Why each order changed
orders Request, ack or reject, cancel, amend and fill, with exchange, engine and execution times Latency, rejects, fills, fees, taker fills
state Decision Both books, q, holdings, cash headroom, guard state and book ages at that moment
queue Resting order, when it ends Displayed size at our price on ack, volume traded there, shrink from cancels ahead: the realized queue share ρ
kalshi_queue Kalshi order, every 5 s and 1 s after a trade at our price Kalshi’s own count of contracts ahead, to check queue
books_1s Traded market and second, in play Full recorded ladder; the replay extracts the levels it needs
trades Public print on a traded market Fill types and markouts
fill_windows Book message or trade from 5 s before to 30 s after each fill, both venues What happened around every fill
match_events Match-state change or significant event Scores, half, status and reported significant events
fixtures Fixture start or merge result Configuration, code commit, balances, merge outcomes
flowchart TB
  st["strategy thread"] -- "rows, non-blocking" --> rq[["recorder queue"]] --> w["writer thread"] --> db[("SQLite, one file per day<br/>10 GB volume")]
  db -- "after each batch of fixtures" --> pq["export to Parquet"]
  pq -- "books_1s, trades" --> sim["replay on the recorded books"]
  pq -- "orders, queue, fill_windows" --> cmp["fixture by fixture, order by order:<br/>fills, fill types, ρ, markouts, P&L"]
  sim --> cmp
Figure 8: From the live record to the replay comparison.

About this article

This article describes the current implementation reviewed on 2026-10-12: commit 43f9cfe plus the working-tree changes to matching and order-book handling. Replay assumptions come from parts 3 and 4; the live behavior is checked against the strategy and engine code. Live performance results are outside this article.