From 105ddf238df71eead8cdfec3f7b9b84d2abaede5 Mon Sep 17 00:00:00 2001 From: floor-licker Date: Mon, 22 Jun 2026 21:32:59 -0400 Subject: [PATCH] bench: add hft loop benchmark --- Cargo.toml | 4 + README.md | 4 + benches/hft_loop.rs | 354 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 benches/hft_loop.rs diff --git a/Cargo.toml b/Cargo.toml index a5e9857..61e7098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,10 @@ harness = false name = "ws_hot_path" harness = false +[[bench]] +name = "hft_loop" +harness = false + [profile.release] # Optimizations for HFT performance opt-level = 3 diff --git a/README.md b/README.md index 9bd7e5a..27d27f2 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,12 @@ Real-world Polymarket API latency broken down by request phase: | **Spread/Mid Calculations** | 26.6 ns | best bid/ask + spread + mid over sorted-vector book sides | | **JSON Parsing (480KB)** | ~0.5 ms | SIMD-backed parsing for large REST market responses and benchmarked polyfill typed parse path | | **WS `book` hot path (decode + apply)** | ~0.24 µs / 1.56 µs / 5.92 µs | 1 / 16 / 64 levels-per-side, strict fixed-point tape parser with generation-marked snapshot retention (see `benches/ws_hot_path.rs`) | +| **Local HFT Loop** | ~39.0 µs | WS apply + best-ask decision + prepared EIP-712 signing + submit-body serialization + L2 auth headers, no live network I/O (see `benches/hft_loop.rs`) | Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`. +Run the local HFT-loop benchmark with `cargo bench --bench hft_loop --features official-client-benchmark`. + +**Local HFT-loop comparison:** In the current local offline run, `polyfill-rs` measured ~39.0 µs for a full synthetic loop with 16 book levels per side: WS `book` apply, best-ask decision, prepared order signing, submit-body serialization, and L2 auth header construction. The comparable order-only path without WS parsing or L2 auth measured ~35.4 µs for `polyfill-rs` and ~35.4 µs for `rs-clob-client-v2`. The `rs-clob-client-v2` row uses its warmed public builder/sign/serialize API; it does not include L2 auth header construction because that helper is not public in the SDK. **Parsing paths:** `polyfill-rs` keeps two parsing layers on purpose. The allocation-sensitive WS `book` path uses `WsBookUpdateProcessor` in `src/ws_hot_path.rs`, which walks a reusable `simd-json` tape and applies fixed-point book levels directly. The generic stream parser in `src/decode.rs` is an ergonomic compatibility path: it parses through `serde_json::Value` so it can tolerate batches, unknown event types, and mixed message shapes. Likewise, several generic numeric/decimal deserializers in `src/decode.rs` accept string-or-number API fields through `serde_json::Value`; they are not the zero-allocation hot path. diff --git a/benches/hft_loop.rs b/benches/hft_loop.rs new file mode 100644 index 0000000..831112a --- /dev/null +++ b/benches/hft_loop.rs @@ -0,0 +1,354 @@ +//! End-to-end local HFT-loop benchmarks. +//! +//! These benches intentionally avoid live network I/O. They measure the local loop a trading +//! process actually controls: receive/apply a book update, make a simple best-ask decision, build +//! and sign an order, serialize the exact submit body, and build L2 auth headers. +//! +//! Run the polyfill benches with: +//! `cargo bench --bench hft_loop` +//! +//! Run the optional rs-clob-client-v2 comparison with: +//! `cargo bench --bench hft_loop --features official-client-benchmark` + +use alloy_signer_local::PrivateKeySigner; +use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; +use polyfill_rs::{ + auth::{create_l2_headers_with_body_bytes, PreparedApiCredentials}, + orders::{OrderBuilder, BYTES32_ZERO}, + types::{ApiCredentials, OrderType, PostOrder, PostOrderOptions}, + OrderBookManager, Side, WsBookUpdateProcessor, +}; +use rust_decimal::Decimal; +use std::sync::atomic::{AtomicU64, Ordering}; + +const ASSET_ID: &str = "12345678901234567890"; +const MARKET: &str = "0xabc"; +const CHAIN_ID: u64 = 137; +const PRIVATE_KEY: &str = "0x1234567890123456789012345678901234567890123456789012345678901234"; +const START_TIMESTAMP: u64 = 1_000_000_000_000_000; + +fn test_signer() -> PrivateKeySigner { + PRIVATE_KEY.parse().expect("valid benchmark private key") +} + +fn api_credentials() -> ApiCredentials { + ApiCredentials { + api_key: "benchmark-api-key".to_string(), + secret: "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1".to_string(), + passphrase: "benchmark-passphrase".to_string(), + } +} + +fn price_decimal_from_ticks(ticks: u32) -> Decimal { + Decimal::new(ticks as i64, 4) +} + +#[derive(Clone, Copy)] +struct TimestampRange { + start: usize, + end: usize, +} + +impl TimestampRange { + fn find(bytes: &[u8]) -> Self { + let needle = b"\"timestamp\":"; + let Some(pos) = bytes.windows(needle.len()).position(|w| w == needle) else { + panic!("timestamp field not found in WS template JSON"); + }; + + let start = pos + needle.len(); + let mut end = start; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + + Self { start, end } + } + + fn write_fixed_width(&self, bytes: &mut [u8], mut value: u64) { + for idx in (self.start..self.end).rev() { + bytes[idx] = b'0' + (value % 10) as u8; + value /= 10; + } + } +} + +fn price_string_from_ticks(ticks: u32) -> String { + let whole = ticks / 10_000; + let frac = ticks % 10_000; + format!("{whole}.{frac:04}") +} + +fn build_ws_book_template(levels_per_side: usize) -> Vec { + let mut json = String::new(); + + json.push_str("{\"event_type\":\"book\",\"asset_id\":\""); + json.push_str(ASSET_ID); + json.push_str("\",\"market\":\""); + json.push_str(MARKET); + json.push_str("\",\"timestamp\":"); + json.push_str(&START_TIMESTAMP.to_string()); + json.push_str(",\"bids\":["); + + for i in 0..levels_per_side { + if i != 0 { + json.push(','); + } + let bid_price = price_string_from_ticks(7_500 - i as u32); + json.push_str("{\"price\":\""); + json.push_str(&bid_price); + json.push_str("\",\"size\":\"100.0000\"}"); + } + + json.push_str("],\"asks\":["); + for i in 0..levels_per_side { + if i != 0 { + json.push(','); + } + let ask_price = price_string_from_ticks(7_501 + i as u32); + json.push_str("{\"price\":\""); + json.push_str(&ask_price); + json.push_str("\",\"size\":\"100.0000\"}"); + } + json.push_str("]}"); + + json.into_bytes() +} + +fn build_polyfill_order_context() -> ( + PrivateKeySigner, + polyfill_rs::orders::PreparedOrderPath, + ApiCredentials, + PreparedApiCredentials, + PostOrderOptions, +) { + let signer = test_signer(); + let builder = OrderBuilder::new(signer.clone(), None, None); + let prepared_order = builder + .prepare_order_path( + CHAIN_ID, + ASSET_ID.to_string(), + Decimal::new(1, 4), + false, + Some(BYTES32_ZERO), + Some(BYTES32_ZERO), + ) + .unwrap(); + let api_creds = api_credentials(); + let prepared_api_creds = PreparedApiCredentials::try_new(api_creds.clone()).unwrap(); + let post_options = PostOrderOptions { + order_type: OrderType::GTD, + post_only: false, + defer_exec: false, + }; + + ( + signer, + prepared_order, + api_creds, + prepared_api_creds, + post_options, + ) +} + +fn benchmark_polyfill_hft_loop(c: &mut Criterion) { + let (signer, prepared_order, api_creds, prepared_api_creds, post_options) = + build_polyfill_order_context(); + let books = OrderBookManager::new(64); + books.get_or_create_book(ASSET_ID).unwrap(); + books + .with_book_mut(ASSET_ID, |book| { + book.set_tick_size_ticks(1); + Ok(()) + }) + .unwrap(); + + let template = build_ws_book_template(16); + let timestamp_range = TimestampRange::find(&template); + let timestamp = AtomicU64::new(START_TIMESTAMP + 1); + let mut processor = WsBookUpdateProcessor::new(template.len()); + + c.bench_function("polyfill_hft_ws_apply_decide_sign_serialize_auth", |b| { + b.iter_batched_ref( + || { + let mut message = template.clone(); + let next_timestamp = timestamp.fetch_add(1, Ordering::Relaxed); + timestamp_range.write_fixed_width(&mut message, next_timestamp); + message + }, + |message| { + let stats = processor.process_bytes(black_box(message), &books).unwrap(); + debug_assert_eq!(stats.book_messages, 1); + + let selected_price = books + .with_book_mut(ASSET_ID, |book| { + let best_ask = book.best_ask_fast().expect("seeded ask side"); + Ok(price_decimal_from_ticks(best_ask.price)) + }) + .unwrap(); + + let signed_order = prepared_order + .create_limit_order( + black_box(Side::BUY), + black_box(selected_price), + black_box(Decimal::new(10_025, 2)), + black_box(Some(1_900_000_000)), + ) + .unwrap(); + let body = PostOrder::new( + black_box(signed_order), + black_box(api_creds.api_key.clone()), + black_box(post_options), + ); + let body_bytes = serde_json::to_vec(black_box(&body)).unwrap(); + let headers = create_l2_headers_with_body_bytes( + &signer, + &prepared_api_creds, + "POST", + "/order", + Some(&body_bytes), + ) + .unwrap(); + + black_box((stats, body_bytes, headers)) + }, + BatchSize::SmallInput, + ) + }); +} + +fn benchmark_polyfill_order_decision_to_payload(c: &mut Criterion) { + let (signer, prepared_order, api_creds, prepared_api_creds, post_options) = + build_polyfill_order_context(); + let selected_price = Decimal::new(7_501, 4); + + c.bench_function("polyfill_decide_sign_serialize_prepared", |b| { + b.iter(|| { + let signed_order = prepared_order + .create_limit_order( + black_box(Side::BUY), + black_box(selected_price), + black_box(Decimal::new(10_025, 2)), + black_box(Some(1_900_000_000)), + ) + .unwrap(); + let body = PostOrder::new( + black_box(signed_order), + black_box(api_creds.api_key.clone()), + black_box(post_options), + ); + let body_bytes = serde_json::to_vec(black_box(&body)).unwrap(); + black_box(body_bytes) + }) + }); + + c.bench_function("polyfill_decide_sign_serialize_auth_prepared", |b| { + b.iter(|| { + let signed_order = prepared_order + .create_limit_order( + black_box(Side::BUY), + black_box(selected_price), + black_box(Decimal::new(10_025, 2)), + black_box(Some(1_900_000_000)), + ) + .unwrap(); + let body = PostOrder::new( + black_box(signed_order), + black_box(api_creds.api_key.clone()), + black_box(post_options), + ); + let body_bytes = serde_json::to_vec(black_box(&body)).unwrap(); + let headers = create_l2_headers_with_body_bytes( + &signer, + &prepared_api_creds, + "POST", + "/order", + Some(&body_bytes), + ) + .unwrap(); + black_box((body_bytes, headers)) + }) + }); +} + +#[cfg(feature = "official-client-benchmark")] +fn benchmark_official_order_decision_to_payload(c: &mut Criterion) { + use polymarket_client_sdk_v2::{ + auth::{Credentials, LocalSigner, Signer, Uuid}, + clob::{ + types::{OrderType as OfficialOrderType, Side as OfficialSide, TickSize}, + Client as OfficialClient, Config as OfficialConfig, + }, + types::{dec, Decimal as OfficialDecimal, U256}, + POLYGON, + }; + use std::str::FromStr as _; + + let mut server = mockito::Server::new(); + let _version_mock = server + .mock("GET", "/version") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"version":2}"#) + .create(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let signer = LocalSigner::from_str(PRIVATE_KEY) + .unwrap() + .with_chain_id(Some(POLYGON)); + let token_id = U256::from_str(ASSET_ID).unwrap(); + let client = runtime.block_on(async { + let client = OfficialClient::new(&server.url(), OfficialConfig::default()).unwrap(); + client.set_tick_size(token_id, TickSize::TenThousandth); + client.set_neg_risk(token_id, false); + client.version().await.unwrap(); + client + .authentication_builder(&signer) + .credentials(Credentials::new( + Uuid::nil(), + "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1".to_string(), + "benchmark-passphrase".to_string(), + )) + .authenticate() + .await + .unwrap() + }); + + c.bench_function("rs_clob_client_v2_decide_sign_serialize_warmed", |b| { + b.iter(|| { + let body_bytes = runtime.block_on(async { + let order = client + .limit_order() + .token_id(black_box(token_id)) + .side(black_box(OfficialSide::Buy)) + .price(black_box(dec!(0.7501))) + .size(black_box(OfficialDecimal::new(10_025, 2))) + .expiration(chrono::DateTime::from_timestamp(1_900_000_000, 0).unwrap()) + .order_type(OfficialOrderType::GTD) + .build() + .await + .unwrap(); + let signed = client.sign(&signer, order).await.unwrap(); + serde_json::to_vec(black_box(&signed)).unwrap() + }); + black_box(body_bytes) + }) + }); +} + +#[cfg(not(feature = "official-client-benchmark"))] +criterion_group!( + benches, + benchmark_polyfill_hft_loop, + benchmark_polyfill_order_decision_to_payload +); + +#[cfg(feature = "official-client-benchmark")] +criterion_group!( + benches, + benchmark_polyfill_hft_loop, + benchmark_polyfill_order_decision_to_payload, + benchmark_official_order_decision_to_payload +); + +criterion_main!(benches);