Appearance
Kairos Thetadata — cross-SDK performance benchmarks
Measured numbers for the three Kairos SDKs (Rust core, Python facade, TypeScript / Node addon) on identical hardware. Every number in this document is reproducible from a script checked into the repo (bench/cross-sdk/) and traces to a raw results/*.json artefact. No metrics are hand-computed.
Methodology
All three SDKs ride the same Rust analytics core. The Python facade is a PyO3 binding; the TypeScript SDK is a napi-rs 3.x binding. The benchmarks below isolate the per-call boundary cost each binding pays and the steady-state analytic cost the Rust core pays directly. Wall-clock comparisons mean the same iteration shape was run under each runtime; CPU-time comparisons are not attempted (criterion is wall-clock; pytest-benchmark is wall-clock; process.hrtime.bigint() is wall-clock).
Run host
| Field | Value |
|---|---|
| Host | thetagamma-systems |
| OS | Linux 6.8.0-117-generic |
| Kernel | #117-Ubuntu SMP PREEMPT_DYNAMIC Tue May 5 19:26:24 UTC 2026 |
| Arch | x86_64 |
| CPU | Intel(R) Core(TM) i7-10700KF CPU @ 3.80GHz |
| CPU count | 16 |
| CPU governor | powersave |
| Rust | rustc 1.88.0 (6b00bc388 2025-06-23) |
| Cargo | cargo 1.88.0 (873a06493 2025-05-10) |
| Python | 3.14.5 |
| Node | v26.0.0 |
| Run started (UTC) | 2026-05-21T22:17:42+00:00 |
What is measured
- Rust core (criterion): per-tick + per-batch analytic latency on the analytic hot paths, plus the cell-pair dispatch overhead. Criterion runs 50 sample batches with a 1 s warm-up + 5 s measurement window per benchmark function.
- Python facade (pytest-benchmark): per-call FFI boundary cost — five getter calls on
GreekTickFlags, three setter calls on a spec, oneSpec.default(filter)round-trip, and one empty-batch replay query end-to-end throughticks_to_record_batch+ PyArrow lift. - TypeScript SDK (Node): same shape as the Python facade — spec construct, three setter calls, and three getter reads. Replay is not part of the v0.1.0 TypeScript surface and lands in a follow-up release.
- Live live feed: a single 10-second QQQ Greeks subscribe per SDK. The harness reports ticks observed and first-tick wall-clock latency. The number varies with market activity; the row records the trading session.
What is NOT measured
- Wire-protocol decode throughput (covered in
REPORT.pdfagainst the officialthetadatalib for thethetadatadxcore). - Cross-host network latency to ThetaData's live feed / historical query backend servers.
- Steady-state Greeks IV-solver microbenchmarks on the TS / Python sides — both bindings call into the same Rust hot path, so the Rust criterion numbers are the source of truth.
- The Rust criterion path-cost numbers are NOT directly comparable to the Python / TS per-call FFI numbers below — different work per iteration.
Reproducibility
The cross-SDK harness lives under bench/cross-sdk/ in the source tree. Wall-clock budget: roughly 10 minutes for the Rust benches (cold rebuild dominates), under a minute for Python, under a minute for TS, 10 seconds per live feed subscribe. Re-runs reproduce every number within ±15% on the same host.
Results — Rust core (criterion)
| Benchmark | Mean | Median | Std dev | Ops / sec | Throughput |
|---|---|---|---|---|---|
cycle_alternating_q5_t1/disabled | 88.8 ns | 87.1 ns | 5.7 ns | 11.26 M/s | — |
cycle_alternating_q5_t1/exact_buffered | 129.0 ns | 125.3 ns | 13.6 ns | 7.75 M/s | — |
cycle_alternating_q5_t1/provisional | 105.7 ns | 104.7 ns | 4.1 ns | 9.46 M/s | — |
cycle_quote_only/disabled | 14.7 ns | 14.1 ns | 1.9 ns | 67.84 M/s | — |
cycle_quote_only/exact_buffered | 23.3 ns | 19.9 ns | 5.2 ns | 42.86 M/s | — |
cycle_quote_only/provisional | 20.7 ns | 19.8 ns | 2.7 ns | 48.36 M/s | — |
greeks/cached_quiet_10k_option_trades | 5.03 ms | 4.92 ms | 406.17 μs | 198.7/s | 10000 elements/iter |
greeks/steady_state_10k_option_trades | 7.34 ms | 7.34 ms | 9.77 μs | 136.3/s | 10000 elements/iter |
greeks/uncached_sweep_10k_option_trades | 12.94 ms | 12.77 ms | 673.26 μs | 77.3/s | 10000 elements/iter |
ohlcvc/steady_state_30k_trades | 697.55 μs | 690.46 μs | 28.01 μs | 1.43 K/s | 30000 elements/iter |
ohlcvc_per_tick/sweep_10k_no_emit | 201.14 μs | 196.37 μs | 17.60 μs | 4.97 K/s | 10000 elements/iter |
ohlcvc_per_tick/trade_only_emit_on_bucket_close | 61.7 ns | 59.7 ns | 6.2 ns | 16.22 M/s | 1 elements/iter |
ohlcvc_per_tick/trade_only_no_emit | 25.5 ns | 22.6 ns | 7.5 ns | 39.14 M/s | 1 elements/iter |
volatility_per_tick/warm_grid_alternating_quote_watermark | 16.39 μs | 15.92 μs | 1.95 μs | 61.02 K/s | 2 elements/iter |
volatility_per_tick/warm_grid_emit_on_watermark | 16.17 μs | 15.93 μs | 842.3 ns | 61.85 K/s | 1 elements/iter |
volatility_per_tick/warm_grid_quote_no_recompute | 127.7 ns | 125.7 ns | 7.4 ns | 7.83 M/s | 1 elements/iter |
Results — Python facade (pytest-benchmark)
| Benchmark | Mean | Min | Median | Std dev | Ops / sec |
|---|---|---|---|---|---|
benches/bench_dispatch.py::test_bench_greek_tick_flags_round_trip | 624.8 ns | 419.0 ns | 617.0 ns | 397.5 ns | 1.60 M/s |
benches/bench_dispatch.py::test_bench_spec_default_construct | 1.30 μs | 967.1 ns | 1.28 μs | 526.7 ns | 771.55 K/s |
benches/bench_dispatch.py::test_bench_spec_field_mutation | 437.5 ns | 287.2 ns | 417.7 ns | 1.86 μs | 2.29 M/s |
benches/bench_historical.py::test_bench_record_batch_to_pandas | 765.34 μs | 642.05 μs | 676.25 μs | 441.56 μs | 1.31 K/s |
benches/bench_historical.py::test_bench_replay_empty_query | 218.98 μs | 208.66 μs | 218.54 μs | 6.69 μs | 4.57 K/s |
Results — TypeScript SDK (Node, napi-rs)
| Benchmark | Mean | Min | Median | Std dev | Ops / sec |
|---|---|---|---|---|---|
spec_default_construct | 2.36 μs | 1.70 μs | 2.07 μs | 1.03 μs | 423.33 K/s |
spec_field_mutation | 334.2 ns | 299.8 ns | 335.8 ns | 15.1 ns | 2.99 M/s |
spec_field_read | 307.9 ns | 283.7 ns | 289.1 ns | 24.1 ns | 3.25 M/s |
Results — cross-SDK headline
Same operation across three SDKs. Numbers are mean wall-clock per iteration. The Python / TS rows are point-for-point comparable; the Rust column references the closest core benchmark when one exists (otherwise marked n/a — the work the binding pays is the FFI cost, which the core does not have).
| Operation | Rust core | Python facade | TypeScript / Node |
|---|---|---|---|
Spec construct from Filter.symbols(["..."]) | n/a — no Rust FFI cost | 1.30 μs | 2.36 μs |
| Spec field write — three setters | n/a — no Rust FFI cost | 437.5 ns | 334.2 ns |
| FFI getter read loop | n/a — no Rust FFI cost | 624.8 ns | 307.9 ns |
| Replay empty-batch query + read columns() | n/a — no Rust FFI cost | 218.98 μs | n/a — not in v0.1.0 TS |
Per-getter normalisation
| Binding | Getter count | Per-loop cost | Per-getter cost |
|---|---|---|---|
Python (PyO3 GreekTickFlags) | 5 | 624.8 ns | 125.0 ns |
TypeScript (napi-rs VpinRequest) | 3 | 307.9 ns | 102.6 ns |
The per-getter cost is the comparable number: it normalises out the difference in how many fields each loop reads. PyO3 #[getter] on a #[pyclass] reads a Rust struct field through the GIL-held trampoline; napi-rs #[napi(getter)] issues a JS-side function call into the native addon that returns a primitive. Numbers in this range are dominated by the trampoline itself, not the field-read work.
Headline-table caveats
- Spec construct from
Filter.symbols(["..."])— Python:VpinParams.default(Filter.symbols(["SPY"]))— one PyO3 trampoline per call. TypeScript: same shape via the napi-rs binding. Directly comparable. - Spec field write — three setters — Three setter calls per iteration; spec is constructed out of band. Python: PyO3
#[setter]trampoline. TypeScript: napi-rs#[napi(setter)]trampoline. Directly comparable. - FFI getter read loop — Python: five
GreekTickFlagsgetters per iteration (PyO3#[getter]). TypeScript: threeVpinRequestgetters per iteration (napi-rs#[napi(getter)]). Not 1:1 — see the per-getter normalisation below the table. - Replay empty-batch query + read columns() — Python runs the parquet → RecordBatch → pyarrow lift path end-to-end. Replay is not part of the v0.1.0 TypeScript surface, so the row has no TS counterpart.
Results — live feed (QQQ Greeks, 10 s)
| SDK | Started (UTC) | Trading session | Connect wall-clock | Ticks | Ticks/sec | First-tick wall-clock |
|---|---|---|---|---|---|---|
| python | 2026-05-21T20:52:34+00:00 | after-hours (16:52 EDT) | 3.34 s | 0 | 0.0/s | — |
| ts | 2026-05-21T20:52:55.206Z | after-hours (Thu 16:52 ET) | 2.34 s | 0 | 0.0/s | — |
Notes + caveats
- Warm-up: criterion uses a 1 s warm-up; the Python harness ignores the first round (pytest-benchmark default); the TS harness runs a fixed pre-iteration warm-up before sampling.
- Iteration shape: criterion measures a
iter_batchedclosure; pytest-benchmark times one function call; the TS harness times a tight for-loop over a closure. Per-iteration overhead differs slightly across the three (criterion'siter_batchedadds a few ns the others don't). - No-GIL discipline: the Python live path uses
streaming_async()so the GIL never blocks the live feed reader thread. The TS live path keeps the napinext()Promise off the libuv event-loop critical path. Different mechanisms, same goal — neither binding stalls the engine. - CPU governor:
powersavewas active for this run. Settingcpufreq-set -r -g performancebefore a measurement pass can drop criterion variance by 30-50%. - Single-run vs averaged: all reported numbers are means across the criterion / pytest-benchmark sample distribution. Raw distributions live in the JSON files under
results/. - Live live feed: subject to market hours, live feed load, and per-Nexus session limits. The user account is on Pro tier with all asset classes and one Nexus session, so only one SDK runs at a time.
- Connect cost: the live-feed rows record
connect_ns— the wall-clock fromCredentials(...)toClient.connect(...)returning. This is dominated by the entitlement round-trip + the live feed TLS handshake; it does not move with binding shape.
Raw JSON locations
All numbers above derive from these files (paths relative to repo root):
bench/cross-sdk/results/live-python.jsonbench/cross-sdk/results/live-ts.jsonbench/cross-sdk/results/python.jsonbench/cross-sdk/results/rust-cycle_alternating_q5_t1__disabled.jsonbench/cross-sdk/results/rust-cycle_alternating_q5_t1__exact_buffered.jsonbench/cross-sdk/results/rust-cycle_alternating_q5_t1__provisional.jsonbench/cross-sdk/results/rust-cycle_quote_only__disabled.jsonbench/cross-sdk/results/rust-cycle_quote_only__exact_buffered.jsonbench/cross-sdk/results/rust-cycle_quote_only__provisional.jsonbench/cross-sdk/results/rust-greeks__cached_quiet_10k_option_trades.jsonbench/cross-sdk/results/rust-greeks__steady_state_10k_option_trades.jsonbench/cross-sdk/results/rust-greeks__uncached_sweep_10k_option_trades.jsonbench/cross-sdk/results/rust-ohlcvc__steady_state_30k_trades.jsonbench/cross-sdk/results/rust-ohlcvc_per_tick__sweep_10k_no_emit.jsonbench/cross-sdk/results/rust-ohlcvc_per_tick__trade_only_emit_on_bucket_close.jsonbench/cross-sdk/results/rust-ohlcvc_per_tick__trade_only_no_emit.jsonbench/cross-sdk/results/rust-volatility_per_tick__warm_grid_alternating_quote_watermark.jsonbench/cross-sdk/results/rust-volatility_per_tick__warm_grid_emit_on_watermark.jsonbench/cross-sdk/results/rust-volatility_per_tick__warm_grid_quote_no_recompute.jsonbench/cross-sdk/results/ts.json
Run python3 bench/cross-sdk/aggregate.py to re-render this file from the on-disk JSON. The script never fabricates numbers — missing JSON shows up as — in the tables, not a guess.