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
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.
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
- 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).
- 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).
- 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).
- 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).
- 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).
- 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).
- 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
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"]
| 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
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
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
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"]
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
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
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.