diff --git a/Cargo.toml b/Cargo.toml index a5e9857..7c9d687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,10 @@ required-features = ["side-by-side-benchmark"] name = "official_client_side_by_side_benchmark" required-features = ["official-client-benchmark"] +[[example]] +name = "live_submit_path_benchmark" +required-features = ["official-client-benchmark"] + [[bench]] name = "book_updates" harness = false diff --git a/README.md b/README.md index 9bd7e5a..cfeebf4 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ Real-world Polymarket API latency broken down by request phase: Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`. +**Live submit-path comparison:** run `cargo run --release --example live_submit_path_benchmark --features official-client-benchmark` with `POLYMARKET_PRIVATE_KEY` set. The setup phase derives API credentials by default; set `POLYMARKET_BENCH_DERIVE_API_CREDS=false` to use `POLYMARKET_API_KEY`, `POLYMARKET_API_SECRET`/`POLYMARKET_SECRET`, and `POLYMARKET_API_PASSPHRASE`/`POLYMARKET_PASSPHRASE` from the environment instead. By default this only runs safe authenticated live reads against Polymarket for `polyfill-rs` and `rs-clob-client-v2`. Actual live order posting is intentionally disabled unless `POLYMARKET_BENCH_LIVE_POST_ORDER=1` is set; if `POLYMARKET_BENCH_TOKEN_ID` is omitted, the benchmark selects an active token with midpoint at least `POLYMARKET_BENCH_MIN_MIDPOINT` and resolves that token's tick-size/neg-risk config before timing. Successful orders are canceled immediately after timing is recorded. + **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. **WebSocket snapshot ordering:** Polymarket `book` messages expose a millisecond `timestamp` and optional `hash`, but no monotonic server sequence/version. The book applier treats newer timestamps as newer, rejects older timestamps, suppresses exact same-timestamp/same-hash duplicates, and accepts same-timestamp/different-hash snapshots in websocket arrival order. The hash distinguishes duplicate vs distinct state; it is not a logical ordering key. diff --git a/examples/live_submit_path_benchmark.rs b/examples/live_submit_path_benchmark.rs new file mode 100644 index 0000000..d4c31df --- /dev/null +++ b/examples/live_submit_path_benchmark.rs @@ -0,0 +1,557 @@ +//! Live authenticated submit-path benchmark against Polymarket CLOB. +//! +//! This benchmark intentionally uses an example binary instead of Criterion so +//! live request counts stay explicit and rate-limit/order-placement behavior is +//! predictable. +//! +//! Safe authenticated reads: +//! `cargo run --release --example live_submit_path_benchmark --features official-client-benchmark` +//! +//! Optional live order submit, followed by best-effort cancel on success: +//! `POLYMARKET_BENCH_LIVE_POST_ORDER=1 POLYMARKET_BENCH_TOKEN_ID=... cargo run --release --example live_submit_path_benchmark --features official-client-benchmark` + +use std::str::FromStr; +use std::time::{Duration, Instant}; + +use alloy_signer_local::PrivateKeySigner; +use polyfill_rs::{ + types::{ApiCredentials, ClientConfig, CreateOrderOptions, OrderType, PostOrderOptions}, + ClobClient, OrderArgs, Side, +}; +use rust_decimal::Decimal; + +use polymarket_client_sdk_v2::{ + auth::{ + state::Authenticated as OfficialAuthenticated, Credentials as OfficialCredentials, + LocalSigner, Normal, Signer, Uuid, + }, + clob::{ + types::{ + request::OrdersRequest as OfficialOrdersRequest, OrderType as OfficialOrderType, + Side as OfficialSide, SignatureType as OfficialSignatureType, TickSize, + }, + Client as OfficialClient, Config as OfficialConfig, + }, + types::{Address as OfficialAddress, Decimal as OfficialDecimal, U256}, + POLYGON, +}; + +type BenchResult = Result>; + +const OFFICIAL_SDK_REV: &str = "8ba5008733c3c03e92041eef8b1cb8495dbed718"; + +#[derive(Debug, Clone)] +struct LiveConfig { + host: String, + chain_id: u64, + private_key: String, + api_credentials: Option, + signature_type: Option, + funder: Option, + iterations: usize, + warmups: usize, + delay: Duration, + live_post_order: bool, + live_post_iterations: usize, + token_id: Option, + min_midpoint: Decimal, + price: Decimal, + size: Decimal, + side: Side, + tick_size: Decimal, + neg_risk: bool, + derive_api_credentials: bool, +} + +#[derive(Debug, Clone, Copy)] +struct Stats { + mean_ms: f64, + std_dev_ms: f64, + min_ms: f64, + max_ms: f64, + p50_ms: f64, + p95_ms: f64, + p99_ms: f64, +} + +fn env_string(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +fn env_required(name: &str) -> BenchResult { + env_string(name).ok_or_else(|| format!("{name} must be set").into()) +} + +fn env_usize(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +fn env_bool(name: &str, default: bool) -> bool { + match std::env::var(name) { + Ok(value) => match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => true, + "0" | "false" | "no" | "off" => false, + _ => default, + }, + Err(_) => default, + } +} + +fn env_decimal(name: &str, default: &str) -> BenchResult { + Ok(Decimal::from_str( + &std::env::var(name).unwrap_or_else(|_| default.to_string()), + )?) +} + +fn percentile(sorted: &[f64], percentile: f64) -> f64 { + let rank = (percentile * sorted.len() as f64).ceil() as usize; + let index = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[index] +} + +fn calc_stats(times: &[Duration]) -> Option { + if times.is_empty() { + return None; + } + + let mut values: Vec = times + .iter() + .map(|duration| duration.as_micros() as f64 / 1000.0) + .collect(); + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let mean_ms = values.iter().sum::() / values.len() as f64; + let variance = values + .iter() + .map(|value| (value - mean_ms).powi(2)) + .sum::() + / values.len() as f64; + + Some(Stats { + mean_ms, + std_dev_ms: variance.sqrt(), + min_ms: values[0], + max_ms: values[values.len() - 1], + p50_ms: percentile(&values, 0.50), + p95_ms: percentile(&values, 0.95), + p99_ms: percentile(&values, 0.99), + }) +} + +fn print_stats(name: &str, times: &[Duration], successes: usize, attempts: usize) { + println!("{name}:"); + match calc_stats(times) { + Some(stats) => { + println!( + " p50/p95/p99: {:.1} / {:.1} / {:.1} ms", + stats.p50_ms, stats.p95_ms, stats.p99_ms + ); + println!( + " mean: {:.1} ms +/- {:.1} ms", + stats.mean_ms, stats.std_dev_ms + ); + println!( + " range: {:.1} - {:.1} ms", + stats.min_ms, stats.max_ms + ); + println!(" success: {successes}/{attempts}"); + }, + None => println!(" success: 0/{attempts}"), + } +} + +impl LiveConfig { + fn from_env() -> BenchResult { + dotenvy::dotenv().ok(); + + let api_secret = + env_string("POLYMARKET_API_SECRET").or_else(|| env_string("POLYMARKET_SECRET")); + let api_passphrase = + env_string("POLYMARKET_API_PASSPHRASE").or_else(|| env_string("POLYMARKET_PASSPHRASE")); + let api_credentials = match (env_string("POLYMARKET_API_KEY"), api_secret, api_passphrase) { + (Some(api_key), Some(secret), Some(passphrase)) => Some(ApiCredentials { + api_key, + secret, + passphrase, + }), + _ => None, + }; + + let side = match std::env::var("POLYMARKET_BENCH_SIDE") + .unwrap_or_else(|_| "BUY".to_string()) + .to_ascii_uppercase() + .as_str() + { + "BUY" => Side::BUY, + "SELL" => Side::SELL, + other => return Err(format!("unsupported POLYMARKET_BENCH_SIDE={other}").into()), + }; + + Ok(Self { + host: std::env::var("POLYMARKET_BENCH_HOST") + .or_else(|_| std::env::var("POLYMARKET_HOST")) + .unwrap_or_else(|_| "https://clob.polymarket.com".to_string()), + chain_id: env_u64("POLYMARKET_CHAIN_ID", 137), + private_key: env_required("POLYMARKET_PRIVATE_KEY")?, + api_credentials, + signature_type: std::env::var("POLYMARKET_SIGNATURE_TYPE") + .ok() + .and_then(|value| value.parse().ok()), + funder: env_string("POLYMARKET_FUNDER") + .or_else(|| env_string("POLYMARKET_FUNDER_ADDRESS")), + iterations: env_usize("POLYMARKET_BENCH_ITERATIONS", 20), + warmups: env_usize("POLYMARKET_BENCH_WARMUPS", 3), + delay: Duration::from_millis(env_u64("POLYMARKET_BENCH_DELAY_MS", 150)), + live_post_order: env_bool("POLYMARKET_BENCH_LIVE_POST_ORDER", false), + live_post_iterations: env_usize("POLYMARKET_BENCH_LIVE_POST_ITERATIONS", 1), + token_id: env_string("POLYMARKET_BENCH_TOKEN_ID"), + min_midpoint: env_decimal("POLYMARKET_BENCH_MIN_MIDPOINT", "0.05")?, + price: env_decimal("POLYMARKET_BENCH_PRICE", "0.01")?, + size: env_decimal("POLYMARKET_BENCH_SIZE", "5")?, + side, + tick_size: env_decimal("POLYMARKET_BENCH_TICK_SIZE", "0.0001")?, + neg_risk: env_bool("POLYMARKET_BENCH_NEG_RISK", false), + derive_api_credentials: env_bool("POLYMARKET_BENCH_DERIVE_API_CREDS", true), + }) + } +} + +async fn resolve_api_credentials(config: &LiveConfig) -> BenchResult { + if config.derive_api_credentials { + let bootstrap = ClobClient::from_config(ClientConfig { + base_url: config.host.clone(), + chain: config.chain_id, + private_key: Some(config.private_key.clone()), + signature_type: config.signature_type, + funder: config.funder.clone(), + ..ClientConfig::default() + })?; + + match bootstrap.create_or_derive_api_key(None).await { + Ok(credentials) => return Ok(credentials), + Err(err) if config.api_credentials.is_some() => { + println!("derive API credentials failed; falling back to env credentials: {err}"); + }, + Err(err) => return Err(err.into()), + } + } + + config.api_credentials.clone().ok_or_else(|| { + "API credentials must be set, or POLYMARKET_BENCH_DERIVE_API_CREDS must be true".into() + }) +} + +fn polyfill_client( + config: &LiveConfig, + api_credentials: ApiCredentials, +) -> BenchResult { + Ok(ClobClient::from_config(ClientConfig { + base_url: config.host.clone(), + chain: config.chain_id, + private_key: Some(config.private_key.clone()), + api_credentials: Some(api_credentials), + signature_type: config.signature_type, + funder: config.funder.clone(), + ..ClientConfig::default() + })?) +} + +async fn official_client( + config: &LiveConfig, + api_credentials: &ApiCredentials, +) -> BenchResult<( + OfficialClient>, + PrivateKeySigner, +)> { + let signer = LocalSigner::from_str(&config.private_key)?.with_chain_id(Some(POLYGON)); + let api_key = Uuid::parse_str(&api_credentials.api_key)?; + let credentials = OfficialCredentials::new( + api_key, + api_credentials.secret.clone(), + api_credentials.passphrase.clone(), + ); + + let client = OfficialClient::new(&config.host, OfficialConfig::default())?; + let mut auth = client + .authentication_builder(&signer) + .credentials(credentials); + + if let Some(signature_type) = config.signature_type { + auth = auth.signature_type(match signature_type { + 0 => OfficialSignatureType::Eoa, + 1 => OfficialSignatureType::Proxy, + 2 => OfficialSignatureType::GnosisSafe, + 3 => OfficialSignatureType::Poly1271, + other => return Err(format!("unsupported POLYMARKET_SIGNATURE_TYPE={other}").into()), + }); + } + if let Some(funder) = &config.funder { + auth = auth.funder(OfficialAddress::from_str(funder)?); + } + + Ok((auth.authenticate().await?, signer)) +} + +async fn run_samples( + label: &str, + warmups: usize, + iterations: usize, + delay: Duration, + mut f: F, +) -> BenchResult> +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + for index in 0..warmups { + if let Err(err) = f().await { + println!(" {label} warmup {} failed: {err}", index + 1); + } + tokio::time::sleep(delay).await; + } + + let mut times = Vec::with_capacity(iterations); + let mut successes = 0; + for index in 0..iterations { + let start = Instant::now(); + match f().await { + Ok(()) => { + successes += 1; + times.push(start.elapsed()); + }, + Err(err) => { + println!(" {label} iteration {} failed: {err}", index + 1); + }, + } + if index + 1 < iterations { + tokio::time::sleep(delay).await; + } + } + + print_stats(label, ×, successes, iterations); + Ok(times) +} + +fn create_order_options(tick_size: Decimal, neg_risk: bool) -> CreateOrderOptions { + CreateOrderOptions { + tick_size: Some(tick_size), + neg_risk: Some(neg_risk), + } +} + +async fn resolve_live_order_token(config: &LiveConfig, client: &ClobClient) -> BenchResult { + if let Some(token_id) = &config.token_id { + return Ok(token_id.clone()); + } + + let markets = client.get_sampling_markets(None).await?; + for market in markets + .data + .iter() + .filter(|market| market.active && !market.closed) + { + for token in &market.tokens { + let midpoint = match client.get_midpoint(&token.token_id).await { + Ok(midpoint) => midpoint.mid, + Err(_) => continue, + }; + if midpoint >= config.min_midpoint { + return Ok(token.token_id.clone()); + } + } + } + + Err(format!( + "POLYMARKET_BENCH_TOKEN_ID was not set and no active token had midpoint >= {}", + config.min_midpoint + ) + .into()) +} + +fn order_args(config: &LiveConfig, token_id: String) -> OrderArgs { + OrderArgs { + token_id, + price: config.price, + size: config.size, + side: config.side, + expiration: None, + builder_code: None, + metadata: None, + } +} + +fn post_options() -> PostOrderOptions { + PostOrderOptions { + order_type: OrderType::GTC, + post_only: true, + defer_exec: false, + } +} + +fn official_tick_size(tick_size: Decimal) -> BenchResult { + let value = tick_size.to_string(); + Ok(match value.as_str() { + "0.1" => TickSize::Tenth, + "0.01" => TickSize::Hundredth, + "0.001" => TickSize::Thousandth, + "0.0001" => TickSize::TenThousandth, + _ => return Err(format!("unsupported POLYMARKET_BENCH_TICK_SIZE={value}").into()), + }) +} + +#[tokio::main] +async fn main() -> BenchResult<()> { + let config = LiveConfig::from_env()?; + let api_credentials = resolve_api_credentials(&config).await?; + let polyfill = polyfill_client(&config, api_credentials.clone())?; + let (official, official_signer) = official_client(&config, &api_credentials).await?; + + println!("Live authenticated Polymarket submit-path benchmark"); + println!("host: {}", config.host); + println!("official SDK rev: {OFFICIAL_SDK_REV}"); + println!( + "api credentials: {}", + if config.derive_api_credentials { + "derived during setup" + } else { + "loaded from env" + } + ); + println!( + "safe reads: {} warmups, {} iterations, {}ms delay", + config.warmups, + config.iterations, + config.delay.as_millis() + ); + println!(); + + run_samples( + "polyfill get_orders", + config.warmups, + config.iterations, + config.delay, + || async { + let orders = polyfill.get_orders(None, Some("MA==")).await?; + std::hint::black_box(orders); + Ok(()) + }, + ) + .await?; + + let official_orders_request = OfficialOrdersRequest::default(); + run_samples( + "rs-clob-client-v2 orders", + config.warmups, + config.iterations, + config.delay, + || async { + let orders = official + .orders(&official_orders_request, Some("MA==".to_string())) + .await?; + std::hint::black_box(orders); + Ok(()) + }, + ) + .await?; + + if !config.live_post_order { + println!(); + println!("live order posting skipped; set POLYMARKET_BENCH_LIVE_POST_ORDER=1 to enable"); + return Ok(()); + } + + let live_token_id = resolve_live_order_token(&config, &polyfill).await?; + let live_tick_size = polyfill + .get_tick_size(&live_token_id) + .await + .unwrap_or(config.tick_size); + let live_neg_risk = polyfill + .get_neg_risk(&live_token_id) + .await + .unwrap_or(config.neg_risk); + println!(); + println!( + "LIVE ORDER POST ENABLED: {} iterations, token_id={}, tick_size={}, neg_risk={}, side={:?}, price={}, size={}, post_only=true", + config.live_post_iterations, + live_token_id, + live_tick_size, + live_neg_risk, + config.side, + config.price, + config.size + ); + println!("orders that return an order id are canceled immediately after timing is recorded"); + + let args = order_args(&config, live_token_id.clone()); + let create_options = create_order_options(live_tick_size, live_neg_risk); + let post_options = post_options(); + + run_samples( + "polyfill live create/sign/post", + 0, + config.live_post_iterations, + config.delay, + || async { + let response = polyfill + .create_and_post_order(&args, Some(&create_options), Some(&post_options)) + .await?; + let order_id = response.order_id.clone(); + std::hint::black_box(response); + if !order_id.is_empty() { + polyfill.cancel(&order_id).await?; + } + Ok(()) + }, + ) + .await?; + + let token_id = U256::from_str(&live_token_id)?; + official.set_tick_size(token_id, official_tick_size(live_tick_size)?); + official.set_neg_risk(token_id, live_neg_risk); + let official_side = match config.side { + Side::BUY => OfficialSide::Buy, + Side::SELL => OfficialSide::Sell, + }; + let official_price = OfficialDecimal::from_str(&config.price.to_string())?; + let official_size = OfficialDecimal::from_str(&config.size.to_string())?; + + run_samples( + "rs-clob-client-v2 live build/sign/post", + 0, + config.live_post_iterations, + config.delay, + || async { + let order = official + .limit_order() + .token_id(token_id) + .side(official_side) + .price(official_price) + .size(official_size) + .order_type(OfficialOrderType::GTC) + .post_only(true) + .build() + .await?; + let signed = official.sign(&official_signer, order).await?; + let response = official.post_order(signed).await?; + let order_id = response.order_id.clone(); + std::hint::black_box(response); + if !order_id.is_empty() { + official.cancel_order(&order_id).await?; + } + Ok(()) + }, + ) + .await?; + + Ok(()) +}