This repo is currently a minimal, low-latency market-data → strategy skeleton. It builds and runs, and the core components are wired end-to-end, but it is not yet a complete production Polymarket/Kalshi execution engine (no real venue auth/subscribe, no order management, no risk, no reconnect logic).
The design goal is to keep hot paths predictable:
- fixed-size data structures (cache-friendly)
- single-producer/single-consumer handoff (no mutexes)
- busy-wait strategy loop (no scheduler wakeup latency)
Two threads, one unidirectional pipeline:
-
Network thread
- Connect to a
wss://endpoint using Boost.Asio + Boost.Beast. - Read websocket frames into a fixed-capacity buffer.
- Parse JSON payload into a small
MarketTickstruct. - Push the tick into a lock-free SPSC queue.
- Connect to a
-
Strategy thread
- Busy-wait (spin) on the queue.
- Pop ticks, timestamp, update the order book.
- Compute best bid/ask spread.
- Generate a mock "execution" decision and log events to
trading_log.csv(viaAsyncLogger).
-
Location:
include/order_book.hpp -
MarketTickis the internal normalized market-data unit:price: integer cents (0..100)size: resting size at that price level (0 means delete)is_bid: bid vs ask
-
LimitOrderBookstores price-level sizes (not individual orders):std::array<uint32_t, 101> bids_andasks_- Index is
price(0..100); all slots are valid
Operations:
apply_tick(tick): O(1) updatebids_[price] = sizeorasks_[price] = sizeget_best_bid(): scan 100→0 and return the highest level withsize > 0(returnskNoBid == 255when empty)get_best_ask(): scan 0→100 and return the lowest level withsize > 0(returnskNoAsk == 255when empty)
Deletion semantics (L2 feeds):
- exchanges commonly send
size == 0to indicate a price level was deleted (filled/canceled) - this is represented as a
MarketTickupdate that sets that level’s stored size to0, so subsequent best-price scans naturally skip it
This is intentionally simple because prediction-market prices are discretized into a tiny fixed range.
- Location:
include/spsc_queue.hpp - Purpose: hand off ticks from network thread to strategy thread without locks.
Properties:
- bounded ring buffer (
Sizemust be a power of 2) std::atomic<size_t>read/write indicespush()uses acquire/release ordering (returns false when full)pop()uses acquire/release ordering (returns false when empty)- internal storage aligned to cache line size to reduce false sharing
Why SPSC works here:
- there is exactly one producer (
WebSocketClient) and one consumer (StrategyEngine) - the queue is the boundary so the order book stays single-writer (strategy thread only)
- Location:
include/market_parser.hpp - Purpose: convert raw JSON bytes into
MarketTickupdates.
Constraints implemented:
- holds a pre-allocated
simdjson::ondemand::parseras a member - uses a fixed padded buffer (
std::array<char, ...>) for parsing parse_tick(std::string_view payload, queue)returnstrue/false(no exceptions)- emits one
MarketTickper updated price level (snapshot or delta) - supports multiple inbound schemas (all numeric values are treated as strings):
- internal mock feed:
{"bids":[["0.57","1200"],...],"asks":[["0.59","500"]]} - Polymarket Market Channel snapshots:
{"event_type":"book","bids":[{"price":".48","size":"30"},...],"asks":[...]} - Polymarket Market Channel deltas:
{"event_type":"price_change","price_changes":[{"price":"0.5","size":"200","side":"BUY","asset_id":"..."},...]} - Polymarket top-of-book:
{"event_type":"best_bid_ask","best_bid":"0.99","best_ask":"1",...}
- internal mock feed:
- accepts either a single message object or a top-level JSON array of message objects (batched messages like
[{...},{...}]) - single-asset filter via
MarketParser::set_asset_filter(asset_id):- required for Polymarket binary markets: each
price_changeoften includes both YES and NO token legs; without filtering, both legs update oneLimitOrderBookand corrupt best bid/ask - when set,
book/best_bid_ask/last_trade_pricerequire matching top-levelasset_id;price_changeonly emits ticks for matchingprice_changes[].asset_id
- required for Polymarket binary markets: each
- price strings without a decimal point: integer
0or1are treated as dollar extremes (0→ 0¢,1→ 100¢) so Polymarket"best_ask":"1"parses correctly
Snapshot vs deltas (typical L2 feed behavior):
- On connect, venues often send a full snapshot of the book (many price levels)
- After that, they send deltas (small updates) whenever a level changes
Note: real venue payload schemas will likely differ. If your feed doesn’t match this schema, updates will be dropped.
Important architectural note: the parser normalizes “feed-shaped” messages into a single internal representation (MarketTick). This keeps downstream code (queue → book → strategy) feed-agnostic.
- Header:
include/websocket_client.hpp - Implementation:
src/websocket_client.cpp
Responsibilities:
- async resolve → TCP connect → TLS handshake → websocket handshake
- after websocket handshake, send a subscription message via
async_write() - continuous
async_read()loop - read payload into a fixed-capacity Beast
flat_static_buffer<kReadBufferBytes>(currently 16 KiB) - parse using
MarketParser; on success, enqueue oneMarketTickper updated level - log errors to
std::cerr(no exception throwing in hot path)
Current limitations:
- Polymarket live: no application-level
PING/PONGor reconnect/backoff in the C++ client yet - message size is capped by the static read buffer
- only one internal order book per process (filter to one
asset_id; multi-asset routing is future work)
Many real exchanges (including prediction markets) will not send any market data until the client explicitly subscribes.
Current behavior:
- after
ws_.async_handshake(...)completes,WebSocketClientcallsdo_subscribe() do_subscribe()sends a Polymarket Market Channel JSON payload usingws_.async_write()whenpolymarket_asset_id_is non-empty:assets_ids:[<token_id>](from--asset-id/engine_config::kDefaultPolymarketAssetId)type:"market",level:2,initial_dump:true,custom_feature_enabled:true
MarketParseron the client is configured with the same asset filter before reads begin- the ingestion loop (
do_read()) only starts after the subscribe write completes successfully
Implementation note:
- the subscription payload buffer is kept alive across the async write by capturing an owning
std::shared_ptr<std::string>in the completion handler
Default asset IDs live in tools/polymarket_config.py (Python) and engine_config::kDefaultPolymarketAssetId (C++).
- Header:
include/strategy_engine.hpp - Implementation:
src/strategy_engine.cpp
Responsibilities:
run()is awhile (running_)loop- busy-waits on
queue.pop(tick)(nosleep_for) - when a tick is popped:
book.apply_tick(tick)- update one or more modular alpha signals (see below)
- combine alpha confidence scores into a single decision
- update
PositionManagerand emit CSV events viaAsyncLogger
Alpha signal interface:
- Location:
include/alpha_base.hpp IAlphaSignal::update(const LimitOrderBook& book, const MarketTick& tick) -> double- Returns a confidence score in
[-1.0, 1.0]where positive means buy-leaning and negative means sell-leaning.
Current alphas:
MomentumAlpha(include/momentum_alpha.hpp): implements the previous microprice-momentum logicOFIAlpha(include/ofi_alpha.hpp): top-of-book order-flow imbalance + book pressure
Combination / execution:
- StrategyEngine holds
std::vector<std::unique_ptr<IAlphaSignal>> - On each tick, it calls
update()on each alpha and averages the scores - If the combined score magnitude exceeds a threshold, it triggers a mock trade at best bid/ask
Strategy selection (cold path):
- Startup flag:
--strategy {momentum|ofi|both}(default:both) - Startup flag:
--asset-id <polymarket_token_id>(default:engine_config::kDefaultPolymarketAssetId) - Parsed in
src/main.cppand passed into theStrategyEngineconstructor /WebSocketClient - Only affects which alphas are constructed; the hot loop simply iterates over
signals_
Mock execution / PnL:
- Trades are simulated fills at best ask (buy) or best bid (sell), not venue orders.
realized_pnlintrading_log.csvcan stay at0.0on tight, near-settled markets (e.g. 99.9¢ / $1.00) even whenTrows appear, because round-trips close at nearly the same price. That is expected for this skeleton; it is not an indication that the feed lacks “arbitrage.”
Microprice momentum signal (implemented by MomentumAlpha):
- microprice (in cents):
microprice = (best_bid * best_ask_size + best_ask * best_bid_size) / (best_bid_size + best_ask_size)
MomentumAlphamaps a per-tick microprice jump of > 1 cent to confidence+1.0, < -1 cent to-1.0, else0.0StrategyEngineexecutes when the combined confidence (average across enabled alphas) exceeds a threshold
Logging/PnL:
- on each trade, log a
'T'event with the fill price/size and current realized PnL - every 1,000 processed ticks, log a
'P'event with mark-to-market equity PnL (realized + unrealized) using the current mid-price
- Location:
include/position_manager.hpp - Purpose: track a simulated position and PnL without touching any real exchange execution APIs.
State tracked:
position_size(int): positive = long, negative = shortaverage_entry_price(double): current VWAP entry for the open positionrealized_pnl(double): PnL locked in by closing fills
Key methods:
add_fill(fill_size, fill_price): updates position size, updates VWAP on increases, and computes realized PnL when a fill reduces/offsets an existing position (including flip-through-zero)get_unrealized_pnl(current_mid_price): mark-to-market PnL for the open position if we closed it at the given mid
- Location:
include/async_logger.hpp - Purpose: write trades/PnL events to disk without blocking the latency-critical strategy thread.
Design:
- strategy thread calls
AsyncLogger::log_event(...)which does a lock-freepush()into a dedicatedSpscQueue<LogEvent, 4096> - a background I/O thread spins on
pop()and appends formatted CSV rows tostd::ofstream - the file is flushed periodically (every ~1000 events or ~1 second) so external tooling can tail/read it
Output:
- default log file is
trading_log.csvwith columns:timestamp_us,event_type,price,size,realized_pnl,latency_us,strategy
Notes:
latency_usis the per-tick processing latency in microseconds (used to quantify alpha overhead).strategyis populated for metadata rows.- A one-time metadata row (
event_type == 'M') is written at startup to record which strategy selection is active.
Local dashboard:
- see
tools/dashboard.pyfor a Streamlit app that pollstrading_log.csvevery second and plots PnL over time (trade events plus periodic mark-to-market updates) with trade price markers - the sidebar shows the "Active Strategy" parsed from metadata rows
- Location:
tools/record_polymarket.py - Purpose: record Polymarket's live market data websocket messages to
historical_data.jsonlso you can replay them later.
Recorder behavior:
- connects to Polymarket Market Channel:
wss://ws-subscriptions-clob.polymarket.com/ws/market - subscribes using
assets_ids(token IDs), not a "market_id" (see Polymarket docs) - appends each incoming JSON message as one JSONL line with an extra
local_timestamp_nsfield (machine receipt time) - preserves the original websocket payload so a replay can reproduce the on-the-wire format (e.g., under
raw/raw_message) - auto-reconnects on disconnects with exponential backoff
- Location:
tools/replay_server.py,tools/replay_filter.py,tools/polymarket_config.py - Purpose: serve a local
wss://127.0.0.1:8765/websocket that replayshistorical_data.jsonlback into the C++ engine.
Design constraints:
- Preserve “burstiness” by using recorded
local_timestamp_nsdeltas. - Avoid pathological stalls if the recording contains timestamp discontinuities (e.g., mixed sessions/markets in one file) by capping maximum inter-message sleep (
REPLAY_MAX_SLEEP_S, default0.5). - Replay the original websocket payload when possible so the C++ client sees the same JSON shape as it would live.
- Filter to one asset before send (
REPLAY_ASSET_IDSor defaultDEFAULT_ASSET_ID):- skip
new_marketlines (common in recordings, not used by the parser) - for
price_change, stripprice_changes[]entries that do not match the subscribed token - only forward
book/best_bid_ask/last_trade_pricewhenasset_idmatches
- skip
The C++ engine applies the same filter via --asset-id so replay and live ingestion stay consistent even if an unfiltered payload is sent.
- Location:
src/main.cpp
What happens on startup:
- parse
--strategyand--asset-id(seesrc/main.cpp) - create shared objects:
LimitOrderBook,SpscQueue<MarketTick, engine_config::kTickQueueSize>,io_context,ssl::context - create
StrategyEngine(consumer) andWebSocketClient(producer, asset filter + subscribe id) - log active strategy to
trading_log.csv(Mrow) and printPolymarket asset_id filter: ... - start network thread:
io_context.run() - start strategy thread:
StrategyEngine::run()
Linux-only (optional): pin threads to specific CPU cores under #ifdef __linux__.
SIGINT/SIGTERMflips a global stop flag.- main thread stops strategy loop and requests websocket close (best-effort), then stops the
io_context. - both threads are joined.
This document describes architecture and invariants. For exact commands and local workflows (build/run/tests, recorder, replay server, Streamlit dashboard), see README.md.
- Add Polymarket live
PING/PONGand reconnect in the C++ websocket client. - Add per-asset routing (separate books) and message sequencing/consistency checks.
- Add explicit TLS verification policies and optional auth for private channels.
- Add order management + risk checks, and replace mock execution with real order sends.