The executor process from prediction-market-infra, rewritten in C++20 against the same wire protocol, so the Python poller drives either one and the two are timed against each other in the same run on the same machine.
This project is a rewrite in C++ of one program taken from a Python trading system. The program sits
on a local connection, reads an incoming order message, checks it, replies, and records how long
that took. The rewrite keeps the exact message format of the Python original, so the original Python
client drives either version without being changed, and in the benchmark run it did so across 4400
messages with every one accepted. The time the program spent handling a message fell from 0.0045
milliseconds to 0.0016 milliseconds at the median, a factor of about 2.8, and about 2.4 at the slow
end of the range. That comparison also moved the program into a separate process, so part of the
gain belongs to the move and part to the language change, and the repository says the two cannot be
separated from this data. Anyone can build the project, run its 92 tests, and check the published
figures against the raw file bench_history.csv.
The Python poller drove this executor across 4400 frames with poller_client.py unmodified, every
frame accepted and no telemetry row dropped. Executor-side wake_recv dropped by a factor of 2.4 at
p99 and 2.8 at p50 under uvloop, 0.0045ms to 0.0016ms at p50. The caveat is that the two
configurations differ by more than language: the Python baseline runs the poller and the executor on
one event loop and the C++ configuration spawns a second process, so every ratio here is an upper
bound on what the rewrite bought. RESULTS.md is where those numbers live and it
carries the confound in full.
The pre-registered expectation in BENCHMARK.md section 6 was written before any
code existed, and it was wrong in both halves. It predicted wake_recv would fall by roughly an
order of magnitude, and 2.4x at p99 and 2.8x at p50 is not that. It predicted end-to-end wake_send
could not move because the span brackets the executor with Python work on both sides, and
wake_send halved, because the span ends at drain() and never contained the executor at all.
The Python side is a published reference implementation, not a running system.
prediction-market-infra is an extraction from a private trading bot, and that bot was shut down on
2026-08-29 when the last surviving strategy failed its pre-registered replication. What is on offer
is two implementations of one wire protocol, timed on one machine in one run. What is not on offer
is a production A/B, and no number here will be presented as one.
The frames the C++ is tested against were produced by running the Python, not by reading it. Ten of
them: five WakeMessage shapes, the v1 through v3 legacy frames, and both WakeAck statuses. The
C++ suite asserts field-for-field equality against the five message shapes and the two acks. The v1
frame asserts that every field added since arrives at its Python default, two of which are refusals
rather than permissions; the v2 and v3 frames assert the fields added after each of them. The
rejection cases are separate, in test_protocol.cpp and test_executor.cpp: an oversize length
prefix closes the connection, and a malformed body produces a rejected ack whose reason names the
field when the failure is a missing or unexpected one, and names the parse error otherwise. The
reason text is this port's own, where the Python sends str(exc); the poller only logs it, and
PORT-FIDELITY.md records the difference.
The encoder is byte identical to orjson.dumps over WakeMessage and WakeAck. That is a stricter
claim than valid JSON, because orjson serializes a dataclass in field-declaration order, so the
wire key order is the attribute order and any reordering is a protocol change.
Float formatting is where a general-purpose C++ serializer would diverge. orjson writes shortest
round-trip digits in fixed notation for a decimal exponent in [-5, 16) and scientific outside it, so
1e15 is 1000000000000000.0 and 1e16 is 1e+16. tests/golden/doubles.tsv pins 1,000 values
against orjson's own output. Price snapping goes through round_half_even, since Python's
round() is half-to-even and std::round is half-away-from-zero, and 424 cases the Python produced
hold it there.
PORT-FIDELITY.md records what is identical, the six places this decoder is
stricter than Python's, and the evidence behind each.
uv run --with orjson python tests/golden/generate_golden.py --infra ../prediction-market-infraNothing in CI regenerates the fixtures. They are committed, and changing one is a deliberate act
with a diff to review. Regenerating against prediction-market-infra at e3fd937 under Python
3.14.7 reproduced all ten frames, all 1,000 doubles and all 424 snap cases byte for byte, and the
same three fixtures regenerate identically under CPython 3.11 on x86-64 Linux. percentiles.tsv is
the one that does not travel: its sample vectors come from random.lognormvariate, which goes
through the platform's exp, and three of the 2,000 samples land one ulp apart between Apple's
libm and glibc's. The percentile values the test asserts are unchanged by that, and the file is only
ever regenerated on the machine that produced it.
Run 2026-08-30, one machine, one session. Executor-side wake_recv in milliseconds, under uvloop,
n=2000 after 200 discarded warm-up iterations:
| Configuration | p50 | p90 | p99 |
|---|---|---|---|
| py-to-py | 0.0045 | 0.0056 | 0.0062 |
| py-to-cpp | 0.0016 | 0.0019 | 0.0026 |
| cpp-to-cpp | 0.0019 | 0.0020 | 0.0026 |
Those bottom two rows are one executor measured from two different clients, and they should agree,
because the executor does not know what is on the other end of the socket. The same binary reports
1.6us and 1.9us across the two. The 0.3us spread is the resolution of this measurement at this
scale, and any claim about wake_recv finer than "between 1 and 2 microseconds" is reading noise.
Inside the C++ executor, decode measured standalone at 1.6us accounts for most of wake_recv at
1.9us and encode_ack at 0.1us for a small remainder, with the ack's send inside the difference.
Both figures are rounded to 100ns and the two are separate measurements, so no percentage split
should be read off them. The decoder is the only place with anything in it.
The signer, benchmarked in the same session, turned out not to compare what it was built to
compare. That row is 2.8x in this port's favour and it is not a language result: cryptography
bundles OpenSSL 4.0.2 and this port links Homebrew's OpenSSL 3.6.3, so the row compares two library
builds.
openssl speed rsa2048 against that Homebrew build lands within 2% of what this port's signer
measures, so the C++ wrapper costs nothing and contributes nothing to the gap. Binding overhead was
ruled out with an experiment rather than an argument. The row stays in the table because removing a
measurement that came out inconvenient is worse than publishing it with its meaning corrected.
RESULTS.md has the wake_send and component tables, the disclosed deviation from
the pre-registered method, and the three things that would have to change for a cleaner answer.
Raw rows are bench_history.csv here and benchmarks/history.csv in prediction-market-infra.
WakeMessage, WakeAck, the length-prefixed frame codec with its 64 KiB cap, the JSON layer, the
executor server from accept to the point where the Python calls dispatch(), the telemetry path
that carries wake_recv off that server and into SQLite, the Kalshi request signer, and the
benchmark harness on both sides of the wire.
The server is one thread on one Unix domain socket, and the read loop runs in the Python's order: read the body, stamp, decode, ack, fire. The stamp comes after the read returns, since the read blocks until a frame arrives and a stamp taken ahead of it measures the gap between wakes. The ack goes out before the fire, so a dispatch never lands inside the span the poller measures.
--telemetry-db writes latency_events rows into the same SQLite file and the same columns
benchmarks/latency_bench.py queries, so the Python harness reads the C++ executor's numbers with
its own SELECT unchanged. A record call copies the row into a fixed-size ring and returns; one
writer thread owns the connection and commits in batches of 500.
Nothing on the read loop can wait for a write. The ring holds 8192 rows, pushing to a full one drops the row rather than blocking, and the four counters are printed at exit next to the server's:
frames=5 accepted=5 rejected=0 fired=5 refused_price=0 refused_kill_switch=0
telemetry rows_written=5 dropped_ring_full=0 dropped_oversized_id=0 dropped_write_failed=0
A writer that fell behind shows up as rows_written short of frames. The drops are counted rather
than logged because a log line is something to go looking for, and a run that silently
under-reported would look like a run that was quiet.
Opening a file the C++ wrote with the Python's own TelemetryDB.initialize() afterwards fills in
the seven tables the executor does not write and leaves every row in place.
RSA-PSS over timestamp + method + path, MGF1 and the digest both SHA-256, salt length equal to the
digest length, base64 out, through OpenSSL 3's EVP interface. Kalshi's published construction, and
auth/signer.py is the specification for it.
PSS salts randomly, so signing one message twice with one key gives two different valid signatures
and there is no golden signature to compare bytes against. The check runs both ways instead.
tests/golden/signing holds five signatures the Python produced against a throwaway RSA-2048 key
and the public key that verifies them, and the suite verifies each against the message this port
builds. A signature carrying OpenSSL's default salt, 222 bytes rather than 32, fails that same
verifier, which is what keeps the assertion from being vacuous.
The other direction is a command rather than a gate, because gating it would put cryptography on
all three CI runners to check a value that is different every run:
uv run python tests/golden/generate_signing_fixture.py --verify-cpp build/dev/signer_cross_checkNo private key is committed. Both sides generate one into a temporary directory and only the public half is written out.
The poller, the matcher, the decision logic, and Polymarket. Position sizing, the risk gate, and the
orders_fired write. ExecutorConfig::dispatch is where a real order would go and main.cpp
leaves it unset. The REST client is a fake, exactly as in benchmarks/latency_bench.py: no network,
no credentials, no exchange call. A half-built version of any of those would be worse than its
absence.
epoll and kqueue tuning beyond what a single-connection Unix socket server needs, kernel bypass,
and custom allocators. The executor handles one connection from one local peer, and the benchmark
exists to find out where the time goes rather than to assume it.
Throughput, process startup, memory, and binary size. This is a latency project.
simdjson, which is the obvious choice for the decoder and is not used here. The encoder has to be
byte identical to orjson and no general-purpose serializer is, so the encoder was going to be
hand-written either way, and the decoder was written alongside it to match. Parsing here is not
obviously faster than simdjson would be and has never been measured against it. src/json.hpp
carries that trade-off, including the half of it that did not survive.
The C++ poller client in bench/ is a benchmark fixture, not a product. It exists so a cpp-to-cpp
round trip can be measured next to py-to-py and py-to-cpp.
Cross-platform comparison. CI builds on Linux and macOS so the code is portable. The numbers come off one machine and are never compared across two.
SQLite and OpenSSL 3 are the external dependencies and both come from the system: macOS ships SQLite
and Homebrew's openssl@3 supplies the rest, Ubuntu needs libsqlite3-dev and libssl-dev. Catch2
comes from FetchContent.
cmake --preset dev
cmake --build --preset dev
ctest --preset dev --no-tests=error./build/dev/bin/executor_hotpath --socket /tmp/executor.sock --kill-switch /tmp/halt \
--telemetry-db /tmp/telemetry.dbUnder sanitizers. asan-ubsan is what CI runs on Linux, ubsan is what runs on this Mac:
cmake --preset ubsan
cmake --build --preset ubsan
ctest --preset ubsan --no-tests=errorAddressSanitizer does not run on macOS here. On Darwin 25.5 with Apple clang 17 an
ASan-instrumented int main(){return 0;} hangs before reaching main, with no local workaround.
UndefinedBehaviorSanitizer works, which is why it gets its own preset. ASan and LeakSanitizer are
exercised by the Linux job in CI, and only those results are believed.
Two harnesses, one estimator. bench/ times four things inside this process, and
benchmarks/latency_bench.py in prediction-market-infra gained --executor cpp:PATH, which puts
this binary on the far end of the poller's socket with poller_client.py unmodified:
cmake --preset release && cmake --build --preset release
./build/release/bin/executor_hotpath_bench --csv bench_history.csvuv run python benchmarks/latency_bench.py \
--executor cpp:/path/to/executor-hotpath-cpp/build/release/bin/executor_hotpathBoth discard 200 warm-up iterations and report p50, p90 and p99 over the 2000 that follow, from a
sorted vector rather than a histogram, which at that sample size is exact and buys no dependency.
Both use the type-7 estimator _percentile uses; tests/golden/percentiles.tsv holds that
function's own output for seven vectors and the C++ is compared against it as bit patterns, because
a nearest-rank implementation would agree to the printed precision and be wrong.
roundtrip from the cpp-to-cpp configuration is write-to-ack and is not the Python's wake_send,
which never waits for an ack. The number the two languages can be compared on is wake_recv.
Version-pinned, because Ubuntu ships clang-format 18 and Homebrew ships 23 and the two format the same file differently:
uv tool install clang-format==23.1.0
uv tool install clang-tidy==22.1.8clang-format --dry-run --Werror $(git ls-files '*.cpp' '*.hpp')
clang-tidy -p build/dev --warnings-as-errors='*' \
--extra-arg="-isysroot$(xcrun --show-sdk-path)" \
$(git ls-files 'src/*.cpp' 'tests/*.cpp' 'bench/*.cpp')The -isysroot argument is a macOS detail. The pip-installed clang-tidy is not Apple's, so it does
not know where the SDK headers live, and without it every #include <cstddef> fails to parse and
the resulting cascade invents findings that are not real. CI runs on Linux and needs no equivalent.
MIT.