mirror of
https://github.com/floor-licker/polyfill-rs.git
synced 2026-08-16 22:18:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2a47c499 | ||
|
|
498d3be196 | ||
|
|
0eedcf7ea7 | ||
|
|
6b93901aaa | ||
|
|
1732f0ff99 | ||
|
|
8499b361c4 | ||
|
|
d0c7ce7636 | ||
|
|
fa89b38cda | ||
|
|
fd8a2ebdb3 | ||
|
|
2b8f8e35b4 | ||
|
|
a15924be18 | ||
|
|
9bb57dc3d3 | ||
|
|
ab491d11c6 | ||
|
|
8385922990 | ||
|
|
6d90790d02 | ||
|
|
0da5ecb1cf | ||
|
|
8a38779661 | ||
|
|
9c6fe86fe0 | ||
|
|
13fc277f98 | ||
|
|
d6a87636c9 | ||
|
|
672a821573 | ||
|
|
cdb72797b5 | ||
|
|
c31aa11502 | ||
|
|
4a4a6801c1 | ||
|
|
d9f50c6b7a | ||
|
|
08a1c13b80 | ||
|
|
3d85584b95 | ||
|
|
9124758187 | ||
|
|
8babf07cde | ||
|
|
39ac56356e | ||
|
|
38303c0dc6 | ||
|
|
77d0d9c390 | ||
|
|
543329f4d6 | ||
|
|
cb1838e77d | ||
|
|
947607ce6c | ||
|
|
f6851c476b | ||
|
|
43b71fbd1f | ||
|
|
6a9522f0f9 | ||
|
|
90c85639c7 | ||
|
|
c821262af5 | ||
|
|
0372feb578 | ||
|
|
02fa74d2f5 | ||
|
|
3fb2bd8f66 | ||
|
|
cb8f19c246 | ||
|
|
9dc228f278 | ||
|
|
e4badca6b6 | ||
|
|
37f78d199e | ||
|
|
000d6cf61b | ||
|
|
b08df372c3 | ||
|
|
27c6352ab2 | ||
|
|
d689fcb524 | ||
|
|
bfd7b1fa84 | ||
|
|
d06f1669a6 | ||
|
|
387e685814 | ||
|
|
e603104f03 | ||
|
|
7bde69f0ce | ||
|
|
daa6522adf | ||
|
|
91b1f9f463 | ||
|
|
b35b2b316e | ||
|
|
670d6ae034 | ||
|
|
6f856ab29c | ||
|
|
2b7221f1b1 | ||
|
|
c14557235f | ||
|
|
f9fffb27b5 | ||
|
|
c4b4da9c08 | ||
|
|
22904ef1a9 | ||
|
|
56f4993621 | ||
|
|
3fa1f937b9 | ||
|
|
38202cef5e | ||
|
|
51a783bc43 | ||
|
|
2daa120adf | ||
|
|
9f271dde4c | ||
|
|
deeb9cc84d | ||
|
|
751928c020 | ||
|
|
85698e0e61 | ||
|
|
74b98da9ab | ||
|
|
37f5651b65 | ||
|
|
5eeb6e309e | ||
|
|
ddbacf5fbb | ||
|
|
ebc2929b1e | ||
|
|
01c7242e6c | ||
|
|
352f9d053c | ||
|
|
d5d7dea231 | ||
|
|
018863c557 | ||
|
|
92b7ccab65 | ||
|
|
769f868cff | ||
|
|
4536076274 | ||
|
|
a92b98a9b9 | ||
|
|
ab71232945 | ||
|
|
2406c41fd3 | ||
|
|
9c52a85208 | ||
|
|
502bd6f3d0 | ||
|
|
d1ae63af80 | ||
|
|
69c42673a9 | ||
|
|
919b36378c | ||
|
|
7ad3229af8 | ||
|
|
eac25be5b2 | ||
|
|
f7638310b0 | ||
|
|
54c35a7034 | ||
|
|
e5994d04dd | ||
|
|
e78ae17286 | ||
|
|
17255926f9 | ||
|
|
4d6ba2ab5c | ||
|
|
0280d537ae | ||
|
|
84c3d269ea | ||
|
|
6190a24e0c | ||
|
|
d9627d8f6c | ||
|
|
30d4ea3937 | ||
|
|
0ad1915d2a | ||
|
|
237c98eb36 | ||
|
|
deb3df783f | ||
|
|
105a66804d | ||
|
|
e8459c82b4 | ||
|
|
51c7401372 | ||
|
|
020140a13b | ||
|
|
55c66f5f64 | ||
|
|
81d78a4c6a | ||
|
|
a63a170335 | ||
|
|
6e52dcbd0c | ||
|
|
29307e4c90 | ||
|
|
e0250dbf4b | ||
|
|
443bc1ef1e | ||
|
|
6517c51bd4 | ||
|
|
f3e233e172 | ||
|
|
d078e70c73 | ||
|
|
951b5a8051 | ||
|
|
0b2740a434 | ||
|
|
3dfb65ea0e | ||
|
|
9f86e24402 | ||
|
|
25f268981f | ||
|
|
f5a807cfd0 | ||
|
|
40b503ef29 | ||
|
|
65bab0d3c1 | ||
|
|
d4e240714c | ||
|
|
1e1ef74730 | ||
|
|
2dc3c06155 | ||
|
|
0f577f0b2c | ||
|
|
2570aa08a1 | ||
|
|
31062b3ed7 | ||
|
|
d45b202d6d | ||
|
|
e0b97c53cb | ||
|
|
b523a7fd44 | ||
|
|
9fa4174e42 | ||
|
|
3b0765fbec | ||
|
|
c1b763309b | ||
|
|
7145f74805 | ||
|
|
0e71182ec5 | ||
|
|
a0bf317d97 | ||
|
|
75ce261445 | ||
|
|
9594058fd5 | ||
|
|
dea50163fe | ||
|
|
cc419acaf3 | ||
|
|
41db8cabcd | ||
|
|
da51caa251 | ||
|
|
a7ca8e4341 | ||
|
|
703bd9f509 | ||
|
|
e28e1afa0b | ||
|
|
64205eeeaa | ||
|
|
95b96fb558 | ||
|
|
1f9c3221e6 | ||
|
|
553698dda2 | ||
|
|
c8c2048f33 | ||
|
|
6da792b2ae |
@@ -33,15 +33,15 @@ jobs:
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Run clippy
|
||||
run: cargo clippy --lib --bins --tests --benches --all-features -- -D warnings && cargo clippy --example benchmark_with_keepalive --example comprehensive_demo --example final_benchmark --example http2_tuning_benchmark --example performance_benchmark --example quick_demo --example snipe -- -D warnings
|
||||
run: cargo clippy --lib --bins --tests --benches --all-features -- -D warnings && cargo clippy --example benchmark_with_keepalive --example demo --example final_benchmark --example http2_tuning_benchmark --example performance_benchmark --example quick_demo --example snipe -- -D warnings
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
- name: Run tests (excluding no-alloc hot paths)
|
||||
run: cargo test --all-features -- --skip no_alloc_
|
||||
|
||||
- name: Build examples
|
||||
run: |
|
||||
cargo build --example benchmark_with_keepalive
|
||||
cargo build --example comprehensive_demo
|
||||
cargo build --example demo
|
||||
cargo build --example final_benchmark
|
||||
cargo build --example http2_tuning_benchmark
|
||||
cargo build --example performance_benchmark
|
||||
@@ -64,4 +64,24 @@ jobs:
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Run security audit
|
||||
run: cargo audit
|
||||
run: cargo audit
|
||||
|
||||
no_alloc:
|
||||
name: No-alloc hot paths
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run no-alloc hot path tests
|
||||
run: cargo test --all-features --test no_alloc_hot_paths
|
||||
|
||||
Generated
+1640
-755
File diff suppressed because it is too large
Load Diff
+29
-14
@@ -1,17 +1,16 @@
|
||||
[package]
|
||||
name = "polyfill-rs"
|
||||
version = "0.2.2"
|
||||
version = "0.4.3"
|
||||
edition = "2021"
|
||||
authors = ["Julius Tranquilli <julius@example.com>"]
|
||||
authors = ["Julius Tranquilli <jtranqs@gmail.com>"]
|
||||
description = "The Fastest Polymarket Client On The Market."
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/floor-licker/polyfill-rs"
|
||||
readme = "README.md"
|
||||
keywords = ["polymarket", "trading", "hft", "crypto", "prediction-markets"]
|
||||
categories = ["api-bindings", "network-programming", "finance"]
|
||||
keywords = ["polymarket", "clob", "trading", "prediction-market", "crypto"]
|
||||
categories = ["api-bindings", "network-programming"]
|
||||
documentation = "https://docs.rs/polyfill-rs"
|
||||
homepage = "https://github.com/floor-licker/polyfill-rs"
|
||||
|
||||
[dependencies]
|
||||
# Pin base64ct to avoid Edition 2024 requirement
|
||||
base64ct = "=1.6.0"
|
||||
@@ -21,8 +20,7 @@ futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "gzip"] }
|
||||
hickory-resolver = "0.24"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "query", "stream", "gzip", "rustls", "http2"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -31,10 +29,10 @@ simd-json = "0.13"
|
||||
dotenvy = "0.15"
|
||||
|
||||
# Ethereum and crypto
|
||||
alloy-primitives = "0.8.26"
|
||||
alloy-sol-types = { version = "0.8.26", features = ["eip712-serde", "json"] }
|
||||
alloy-signer = { version = "0.7", features = ["eip712"] }
|
||||
alloy-signer-local = { version = "0.7", features = ["eip712"] }
|
||||
alloy-primitives = "1"
|
||||
alloy-sol-types = { version = "1", features = ["eip712-serde", "json"] }
|
||||
alloy-signer = { version = "1", features = ["eip712"] }
|
||||
alloy-signer-local = { version = "1", features = ["eip712"] }
|
||||
|
||||
# Numeric types
|
||||
rust_decimal = { version = "1.36", features = ["serde-with-str"] }
|
||||
@@ -59,23 +57,36 @@ chrono = { version = "0.4", features = ["serde"] }
|
||||
# Utilities
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
bytes = "1.0"
|
||||
bytes = "1.11.1"
|
||||
rand = "0.8"
|
||||
parking_lot = "0.12"
|
||||
|
||||
# Optional WebSocket support for streaming
|
||||
tokio-tungstenite = { version = "0.21", optional = true, features = ["native-tls"] }
|
||||
|
||||
# Optional benchmark-only dependency for comparing against Polymarket's active Rust SDK.
|
||||
polymarket_client_sdk_v2 = { version = "0.6.0-canary.1", git = "https://github.com/Polymarket/rs-clob-client-v2", rev = "8ba5008733c3c03e92041eef8b1cb8495dbed718", features = ["clob"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
tokio-test = "0.4"
|
||||
mockito = "1.0"
|
||||
proptest = "1.0"
|
||||
env_logger = "0.10"
|
||||
# polymarket-rs-client = { path = "external/polymarket-rs-client" } # Uncomment to run side-by-side benchmark
|
||||
|
||||
[features]
|
||||
default = ["stream"]
|
||||
stream = ["tokio-tungstenite"]
|
||||
side-by-side-benchmark = []
|
||||
official-client-benchmark = ["dep:polymarket_client_sdk_v2"]
|
||||
|
||||
[[example]]
|
||||
name = "side_by_side_benchmark"
|
||||
required-features = ["side-by-side-benchmark"]
|
||||
|
||||
[[example]]
|
||||
name = "official_client_side_by_side_benchmark"
|
||||
required-features = ["official-client-benchmark"]
|
||||
|
||||
[[bench]]
|
||||
name = "book_updates"
|
||||
@@ -93,6 +104,10 @@ harness = false
|
||||
name = "network_benchmarks"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "ws_hot_path"
|
||||
harness = false
|
||||
|
||||
[profile.release]
|
||||
# Optimizations for HFT performance
|
||||
opt-level = 3
|
||||
@@ -107,4 +122,4 @@ opt-level = 1
|
||||
[profile.test]
|
||||
# Optimizations for test performance
|
||||
opt-level = 2
|
||||
debug = true
|
||||
debug = true
|
||||
|
||||
@@ -4,7 +4,17 @@
|
||||
[](https://docs.rs/polyfill-rs)
|
||||
[](LICENSE)
|
||||
|
||||
A high-performance, drop-in replacement for `polymarket-rs-client` with latency-optimized data structures and zero-allocation hot paths.
|
||||
A high-performance Polymarket Rust client with latency-optimized data structures and allocator-conscious hot paths. The `0.4.x` line is V2-native and intentionally breaking for authenticated trading flows.
|
||||
|
||||
At the time that this project was started, `polymarket-rs-client` was a Polymarket Rust Client with a few GitHub stars, but which seemed to be unmaintained. I took on the task of creating a Rust client which could beat the benchmarks quoted in the README.md of that project, with the added constraint of also maintaining zero alloc hot paths.
|
||||
|
||||
I also want to take a moment to clarify what zero-alloc means because I've now recieved double digit messages about this on twitter/x and telegram. In this repository the strict claim is limited to tested, warmed hot paths: existing-level book updates and selected read-side calculations are covered by no-heap-traffic tests that count allocations, reallocations, and deallocations. Snapshot churn, first-seen books, and new price levels can still touch the allocator by design.
|
||||
|
||||
Notably order book paths that can touch the allocator by design:
|
||||
- First time seeing a token/book (HashMap insert + key clone): `src/book.rs`
|
||||
- New price levels when a sorted side needs to grow: `src/book.rs`
|
||||
- Book removal/drop paths that release owned buffers: `src/book.rs`
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -12,7 +22,7 @@ Add to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
polyfill-rs = "0.2.2"
|
||||
polyfill-rs = "0.4.0"
|
||||
```
|
||||
|
||||
Replace your imports:
|
||||
@@ -30,222 +40,57 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
```
|
||||
|
||||
**That's it!** Your existing code works unchanged, but now runs significantly faster.
|
||||
|
||||
## Why polyfill-rs?
|
||||
|
||||
**100% API Compatible**: Drop-in replacement for `polymarket-rs-client` with identical method signatures
|
||||
|
||||
**Latency Optimized**: Fixed-point arithmetic with cache-friendly data layouts for sub-microsecond order book operations
|
||||
|
||||
**Market Microstructure Aware**: Handles tick alignment, sequence validation, and market impact calculations with nanosecond precision
|
||||
|
||||
**Production Hardened**: Designed for co-located environments processing 100k+ market data updates per second
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
**Real-World API Performance (with network I/O)**
|
||||
|
||||
End-to-end performance with Polymarket's API, including network latency, JSON parsing, and decompression:
|
||||
Real-world Polymarket API latency broken down by request phase:
|
||||
|
||||
| Operation | polyfill-rs | polymarket-rs-client | Official Python Client |
|
||||
|-----------|-------------|----------------------|------------------------|
|
||||
| **Fetch Markets** | **321.6 ms ± 92.9 ms** | 409.3 ms ± 137.6 ms | 1.366 s ± 0.048 s |
|
||||

|
||||
|
||||
| Operation | Metric | polyfill-rs | rs-clob-client-v2 | polymarket-rs-client | Official Python Client |
|
||||
|-----------|--------|-------------|-------------------|----------------------|------------------------|
|
||||
| **Fetch Markets** | mean ± sd | **321.6 ms ± 92.9 ms** | - | 409.3 ms ± 137.6 ms | 1.366 s ± 0.048 s |
|
||||
| **Cold Start** | single run | 759.3 ms | **568.0 ms** | - | - |
|
||||
| **Warm Connection** | single run | **153.0 ms** | 191.9 ms | - | - |
|
||||
| **Steady Typed Total** | p50 / p95 / p99 | **228.2 / 509.9 / 611.2 ms** | 242.3 / 514.2 / 641.3 ms | - | - |
|
||||
| **Network-Only Byte Fetch*** | p50 / p95 / p99 | 200.0 / **327.3 / 518.6 ms** | **123.3** / 456.9 / 867.2 ms | - | - |
|
||||
| **CPU Parse Only** | p50 / p95 / p99 | **0.5 / 1.1 / 1.3 ms** | 1.3 / 1.6 / 1.7 ms | - | - |
|
||||
|
||||
|
||||
**Performance vs Competition:**
|
||||
- **21.4% faster** than polymarket-rs-client - 87.6ms improvement
|
||||
- **32.5% more consistent** than polymarket-rs-client
|
||||
**Performance vs polymarket-rs-client:**
|
||||
- **21.4% faster**
|
||||
- **32.5% more consistent**
|
||||
- **4.2x faster** than Official Python Client
|
||||
|
||||
**Benchmark Methodology:** All benchmarks run side-by-side on the same machine, same network, same time using identical testing methodology (20 iterations, 100ms delay between requests, /simplified-markets endpoint). Best performance achieved with connection keep-alive enabled. See `examples/side_by_side_benchmark.rs` for the complete benchmark implementation.
|
||||
**Benchmark Methodology:** The `rs-clob-client-v2` comparison separates cold start, warm connection, steady-state typed requests, network-only byte fetches, and CPU-only parsing. The latest local live-network run was on June 22, 2026 against `https://clob.polymarket.com/simplified-markets?next_cursor=MA==`. Steady-state rows use 40 paired iterations with alternating order and 100ms delay after 5 warmups; parse rows use 300 iterations from a cached 480KB payload. The network-only row is a selected raw HTTP diagnostic, not a typed SDK method result: it compares polyfill's actual HTTP client with a reqwest client using the `rs-clob-client-v2` default headers and no typed deserialization. The benchmark now prints a full network-only transport matrix covering default reqwest, `rs-clob-client-v2` headers, polyfill's actual client, and polyfill-tuned header variants. The CPU parse row compares polyfill's SIMD-backed typed parser against the `rs-clob-client-v2` request-helper parse path; direct serde parsing of the SDK response type measured 0.5 / 0.6 / 0.6 ms. Run it with `cargo run --release --example official_client_side_by_side_benchmark --features official-client-benchmark`. See `examples/side_by_side_benchmark.rs` in commit `a63a170`: https://github.com/floor-licker/polyfill-rs/blob/a63a170/examples/side_by_side_benchmark.rs for the original legacy benchmark implementation.
|
||||
|
||||
**Computational Performance (pure CPU, no I/O)**
|
||||
|
||||
| Operation | Performance | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| **Order Book Updates (1000 ops)** | 159.6 µs ± 32 µs | 6,260 updates/sec, zero-allocation |
|
||||
| **Spread/Mid Calculations** | 70 ns ± 77 ns | 14.3M ops/sec, optimized BTreeMap |
|
||||
| **JSON Parsing (480KB)** | ~2.3 ms | SIMD-accelerated parsing (1.77x faster than serde_json) |
|
||||
| **Order Book Updates (1000 ops)** | 69.6 µs | ~14.4M updates/sec, no allocator traffic for warmed existing-level paths |
|
||||
| **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`) |
|
||||
|
||||
Run the WS hot-path benchmark locally with `cargo bench --bench ws_hot_path`.
|
||||
|
||||
**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.
|
||||
|
||||
**Key Performance Optimizations:**
|
||||
|
||||
polyfill-rs achieves 21.4% better performance than polymarket-rs-client through several targeted optimizations and infrastructure integration, as verified through side-by-side benchmarking on identical infrastructure. We use simd-json for SIMD-accelerated JSON parsing, which provides a 1.77x speedup over standard serde_json deserialization and saves approximately 1-2ms per request. Our HTTP/2 configuration has been tuned through systematic benchmarking, with a 512KB initial stream window size proving optimal for the typical 469KB payload sizes from Polymarket's API. The client includes integrated DNS caching to eliminate redundant lookups, a connection manager with background keep-alive to maintain warm connections (preventing costly reconnections), and a buffer pool to reduce memory allocation overhead during request processing. These optimizations collectively achieve 321.6ms mean latency compared to polymarket-rs-client's 409.3ms while maintaining production-safe, conservative approaches.
|
||||
|
||||
**Performance Breakdown:**
|
||||
- Network (DNS/TCP/TLS): ~150ms (optimized with DNS caching and HTTP/2 tuning)
|
||||
- Download: ~230ms (improved with 512KB stream window)
|
||||
- JSON Parse: ~2.3ms (SIMD-accelerated, 1.77x faster than standard parsing)
|
||||
- Payload: 469KB compressed for simplified markets
|
||||
|
||||
**Connection Reuse is Critical:**
|
||||
- First request: ~500ms (connection establishment)
|
||||
- Subsequent requests: ~220-280ms (35.5% faster with connection pooling)
|
||||
- Keep client alive between requests for best performance
|
||||
|
||||
**Real Performance Factors:**
|
||||
- Network latency dominates (200-400ms)
|
||||
- Payload size matters (simplified: 480KB, full: 2.4MB)
|
||||
- Connection reuse critical for performance
|
||||
- Different endpoints serve different use cases
|
||||
|
||||
### Benchmarking Methodology
|
||||
|
||||
**Side-by-Side Testing:**
|
||||
To ensure fair comparison, we benchmark polyfill-rs and polymarket-rs-client side-by-side on the same machine under identical conditions. Both clients are tested sequentially with the same network state, same API endpoint (/simplified-markets), and identical testing parameters (20 iterations, 100ms delay between requests). This eliminates variables like network conditions, time of day, or geographic differences that could skew results. The side-by-side benchmark reveals that polymarket-rs-client's claimed variance of ±22.9ms significantly understates their actual variance of ±137.6ms (500% higher), while our measurements remain consistent and reproducible.
|
||||
|
||||
**What We Measure:**
|
||||
- Real-world API performance with actual network I/O
|
||||
- Statistical analysis with multiple runs (mean ± standard deviation)
|
||||
- Connection establishment overhead and warm connection performance
|
||||
- Variance analysis to measure consistency
|
||||
|
||||
|
||||
**Reproducible Benchmarks:**
|
||||
```bash
|
||||
# Run real-world performance benchmarks (requires .env with API credentials)
|
||||
cargo run --example performance_benchmark --release
|
||||
|
||||
# Run side-by-side comparison with polymarket-rs-client
|
||||
# (Requires uncommenting polymarket-rs-client in Cargo.toml dev-dependencies)
|
||||
cargo run --example side_by_side_benchmark --release
|
||||
```
|
||||
|
||||
All benchmarks use identical testing methodology and are reproducible on any machine with the same network conditions. The side-by-side benchmark validates our performance claims by running both clients sequentially under identical conditions.
|
||||
|
||||
## Migration from polymarket-rs-client
|
||||
|
||||
**Drop-in replacement in 2 steps:**
|
||||
|
||||
1. **Update Cargo.toml:**
|
||||
```toml
|
||||
# Before: polymarket-rs-client = "0.x.x"
|
||||
polyfill-rs = "0.2.2"
|
||||
```
|
||||
|
||||
2. **Update imports:**
|
||||
```rust
|
||||
// Before: use polymarket_rs_client::{ClobClient, Side, OrderType};
|
||||
use polyfill_rs::{ClobClient, Side, OrderType};
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
**Basic Trading Bot:**
|
||||
```rust
|
||||
use polyfill_rs::{ClobClient, OrderArgs, Side, OrderType};
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
let client = ClobClient::with_l2_headers(host, private_key, chain_id, api_creds);
|
||||
|
||||
// Create and submit order
|
||||
let order_args = OrderArgs::new("token_id", dec!(0.75), dec!(100.0), Side::BUY);
|
||||
let result = client.create_and_post_order(&order_args).await?;
|
||||
```
|
||||
|
||||
**High-Frequency Market Making:**
|
||||
```rust
|
||||
use polyfill_rs::{OrderBookImpl, WebSocketStream};
|
||||
|
||||
// Real-time order book with fixed-point optimizations
|
||||
let mut book = OrderBookImpl::new("token_id".to_string(), 100);
|
||||
let mut stream = WebSocketStream::new("wss://ws-subscriptions-clob.polymarket.com").await?;
|
||||
|
||||
// Process thousands of updates per second
|
||||
while let Some(update) = stream.next().await {
|
||||
book.apply_delta_fast(&update.into())?;
|
||||
let spread = book.spread_fast(); // Returns in ticks for maximum speed
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The library has four main pieces that work together:
|
||||
|
||||
### Order Book Engine
|
||||
Critical path optimization through fixed-point arithmetic and memory layout design:
|
||||
|
||||
- **Before**: `BTreeMap<Decimal, Decimal>` (heap allocations, decimal arithmetic overhead)
|
||||
- **After**: `BTreeMap<u32, i64>` (stack-allocated keys, branchless integer operations)
|
||||
|
||||
Order book updates achieve ~10x throughput improvement by eliminating decimal parsing in the critical path. Price quantization happens at ingress boundaries, maintaining IEEE 754 compatibility at API surfaces while using fixed-point internally for cache efficiency.
|
||||
|
||||
*Want to see how this works?* Check out `src/book.rs` - every optimization has the commented-out "before" code so you can see exactly what changed and why.
|
||||
|
||||
### Market Impact Engine
|
||||
Liquidity-aware execution simulation with configurable market impact models:
|
||||
|
||||
```rust
|
||||
let impact = book.calculate_market_impact(Side::BUY, Decimal::from(1000));
|
||||
// Returns: VWAP, total cost, basis point impact, liquidity consumption
|
||||
```
|
||||
|
||||
Implements linear and square-root market impact models with parameterizable liquidity curves. Includes circuit breakers for adverse selection protection and maximum drawdown controls.
|
||||
|
||||
### Market Data Infrastructure
|
||||
Fault-tolerant WebSocket implementation with sequence gap detection and automatic recovery. Exponential backoff with jitter prevents thundering herd reconnection patterns. Message ordering guarantees maintained across reconnection boundaries.
|
||||
|
||||
### Protocol Layer
|
||||
EIP-712 signature validation, HMAC-SHA256 authentication, and adaptive rate limiting with token bucket algorithms. Request pipelining and connection pooling optimized for co-located deployment patterns.
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
Designed for deterministic latency profiles in high-frequency environments:
|
||||
|
||||
### Critical Path Optimizations
|
||||
|
||||
The library achieves deterministic latency through several fundamental design choices. Fixed-point arithmetic eliminates floating-point pipeline stalls and decimal parsing overhead that would otherwise introduce variable execution times. Lock-free updates using compare-and-swap operations enable concurrent book modifications without mutex contention or priority inversion. Cache-aligned structures maintain 64-byte alignment for optimal L1/L2 cache utilization, ensuring that hot data structures fit within single cache lines. Vectorized operations leverage SIMD-friendly data layouts to enable batch price level processing, allowing modern CPUs to process multiple price levels in parallel.
|
||||
The 21.4% performance improvement comes from HTTP/2 tuning with 512KB stream windows optimized for 469KB payloads, explicit Polymarket request headers, SIMD-backed parsing where the client uses the typed fast-response helper for large REST market responses, and opt-in connection prewarming/keep-alive support.
|
||||
|
||||
### Memory Architecture
|
||||
|
||||
The memory subsystem is designed around predictable allocation patterns to prevent latency spikes. Pre-allocated pools eliminate garbage collection pressure and allocation latency spikes by maintaining warm buffers ready for immediate use. Configurable book depth limiting prevents memory bloat in illiquid markets where maintaining deep order books provides diminishing returns. Hot data structures are designed with temporal locality in mind, grouping frequently-accessed fields together to maximize cache line efficiency and minimize memory bandwidth consumption.
|
||||
Configurable book depth limiting prevents memory bloat. Hot data structures group frequently-accessed fields for cache line efficiency. Allocation-sensitive hot paths are covered by targeted no-heap-traffic tests where the implementation currently avoids allocation, reallocation, and deallocation.
|
||||
|
||||
### Architectural Principles
|
||||
|
||||
The library optimizes the precision-performance tradeoff through strategic boundary quantization. At system ingress, all price data converts to fixed-point representation at system boundaries while maintaining tick-aligned precision required by exchange protocols. The critical path operates exclusively on integer arithmetic with branchless comparisons and arithmetic operations in order matching logic, eliminating conditional jumps that would pollute the branch predictor. At system egress, all data converts back to IEEE 754 floating-point representation to ensure API surface compatibility with downstream consumers. This architecture enables deterministic execution with predictable instruction counts for latency-sensitive code paths. Performance-critical sections include cycle count analysis and memory access pattern documentation, with cache miss profiling and branch prediction optimization detailed in inline comments.
|
||||
|
||||
|
||||
### Performance Advantages
|
||||
|
||||
The library achieves superior performance through multiple orthogonal optimization strategies working in concert. Fixed-point arithmetic enables sub-nanosecond price calculations compared to the overhead of decimal operations, while zero-allocation updates allow order book modifications without triggering memory allocation or garbage collection pauses. Data structures use cache-optimized layouts with careful alignment to maximize CPU cache efficiency and minimize memory bandwidth requirements. Lock-free operations enable concurrent access patterns without mutex contention or context switching overhead. Network optimizations including HTTP/2 multiplexing, connection pooling, TCP_NODELAY for immediate packet transmission, and adaptive timeouts reduce end-to-end latency. Connection pre-warming provides 1.7x faster subsequent requests by maintaining warm TCP connections and pre-resolved DNS entries. Request parallelization achieves 3x speedup when batching operations by maximizing connection utilization and reducing round-trip overhead. Run benchmarks using `cargo bench --bench comparison_benchmarks` to measure these improvements on your hardware.
|
||||
|
||||
## Network Optimization Deep Dive
|
||||
|
||||
### How We Achieve Superior Network Performance
|
||||
|
||||
polyfill-rs implements advanced HTTP client optimizations specifically designed for latency-sensitive trading:
|
||||
|
||||
#### **HTTP/2 Connection Management**
|
||||
```rust
|
||||
// Optimized client with connection pooling
|
||||
let client = ClobClient::new_internet("https://clob.polymarket.com");
|
||||
|
||||
// Pre-warm connections for 70% faster subsequent requests
|
||||
client.prewarm_connections().await?;
|
||||
```
|
||||
|
||||
- **Connection pooling**: 5-20 persistent connections per host
|
||||
- **TCP_NODELAY**: Disables Nagle's algorithm for immediate packet transmission
|
||||
- **HTTP/2 multiplexing**: Multiple requests over single connection
|
||||
- **Keep-alive optimization**: Reduces connection establishment overhead
|
||||
|
||||
#### **Request Batching & Parallelization**
|
||||
```rust
|
||||
// Sequential requests (slow)
|
||||
for token_id in token_ids {
|
||||
let price = client.get_price(&token_id).await?;
|
||||
}
|
||||
|
||||
// Parallel requests (200% faster)
|
||||
let futures = token_ids.iter().map(|id| client.get_price(id));
|
||||
let prices = futures_util::future::join_all(futures).await;
|
||||
```
|
||||
|
||||
#### **Adaptive Network Resilience**
|
||||
- **Circuit breaker pattern**: Prevents cascade failures during network instability
|
||||
- **Adaptive timeouts**: Dynamic timeout adjustment based on network conditions
|
||||
- **Connection affinity**: Sticky connections for consistent performance
|
||||
- **Automatic retry logic**: Exponential backoff with jitter
|
||||
Price data converts to fixed-point at ingress boundaries while maintaining tick-aligned precision. The critical path uses integer arithmetic with branchless operations. Data converts back to IEEE 754 at egress for API compatibility. This enables deterministic execution with predictable instruction counts.
|
||||
|
||||
### Measured Network Improvements
|
||||
|
||||
@@ -255,274 +100,3 @@ let prices = futures_util::future::join_all(futures).await;
|
||||
| **Connection pre-warming** | **70% faster subsequent requests** | Application startup |
|
||||
| **Request parallelization** | **200% faster batch operations** | Multi-market data fetching |
|
||||
| **Circuit breaker resilience** | **Better uptime during instability** | Production trading systems |
|
||||
|
||||
### Environment-Specific Configurations
|
||||
|
||||
```rust
|
||||
// For co-located servers (aggressive settings)
|
||||
let client = ClobClient::new_colocated("https://clob.polymarket.com");
|
||||
|
||||
// For internet connections (conservative, reliable)
|
||||
let client = ClobClient::new_internet("https://clob.polymarket.com");
|
||||
|
||||
// Standard balanced configuration
|
||||
let client = ClobClient::new("https://clob.polymarket.com");
|
||||
```
|
||||
|
||||
**Configuration details:**
|
||||
- **Colocated**: 20 connections, 1s timeouts, no compression (CPU optimization)
|
||||
- **Internet**: 5 connections, 60s timeouts, full compression (bandwidth optimization)
|
||||
- **Standard**: 10 connections, 30s timeouts, balanced settings
|
||||
|
||||
## Getting Started
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
polyfill-rs = "0.2.2"
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### If You're Coming From polymarket-rs-client
|
||||
|
||||
Good news: your existing code should work without changes. I kept the same API.
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// Same initialization as before
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
"your_private_key",
|
||||
137,
|
||||
);
|
||||
|
||||
// Same API calls
|
||||
let api_creds = client.create_or_derive_api_key(None).await?;
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
// Same order creation
|
||||
let order_args = OrderArgs::new(
|
||||
"token_id",
|
||||
Decimal::from_str("0.75")?,
|
||||
Decimal::from_str("100.0")?,
|
||||
Side::BUY,
|
||||
);
|
||||
|
||||
let result = client.create_and_post_order(&order_args).await?;
|
||||
```
|
||||
|
||||
The difference is sub-microsecond order book operations and deterministic latency profiles.
|
||||
|
||||
### Real-Time Order Book Tracking
|
||||
|
||||
Here's where it gets interesting. You can track live order books for multiple tokens:
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{OrderBookManager, OrderDelta, Side};
|
||||
|
||||
let mut book_manager = OrderBookManager::new(50); // Keep top 50 price levels
|
||||
|
||||
// This is what happens when you get a WebSocket update
|
||||
let delta = OrderDelta {
|
||||
token_id: "market_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.75")?,
|
||||
size: Decimal::from_str("100.0")?, // 0 means remove this price level
|
||||
sequence: 1,
|
||||
};
|
||||
|
||||
book_manager.apply_delta(delta)?; // This is now super fast
|
||||
|
||||
// Get current market state
|
||||
let book = book_manager.get_book("market_token")?;
|
||||
let spread = book.spread(); // How tight is the market?
|
||||
let mid_price = book.mid_price(); // Fair value estimate
|
||||
let best_bid = book.best_bid(); // Highest buy price
|
||||
let best_ask = book.best_ask(); // Lowest sell price
|
||||
```
|
||||
|
||||
The `apply_delta` operation now executes in constant time with predictable cache behavior.
|
||||
|
||||
### Market Impact Analysis
|
||||
|
||||
Before you place a big order, you probably want to know what it'll cost you:
|
||||
|
||||
```rust
|
||||
use polyfill_rs::FillEngine;
|
||||
|
||||
let mut fill_engine = FillEngine::new(
|
||||
Decimal::from_str("0.001")?, // max slippage: 0.1%
|
||||
Decimal::from_str("0.02")?, // fee rate: 2%
|
||||
10, // fee in basis points
|
||||
);
|
||||
|
||||
// Simulate buying $1000 worth
|
||||
let order = MarketOrderRequest {
|
||||
token_id: "market_token".to_string(),
|
||||
side: Side::BUY,
|
||||
amount: Decimal::from_str("1000.0")?,
|
||||
slippage_tolerance: Some(Decimal::from_str("0.005")?), // 0.5%
|
||||
client_id: None,
|
||||
};
|
||||
|
||||
let result = fill_engine.execute_market_order(&order, &book)?;
|
||||
|
||||
println!("If you bought $1000 worth right now:");
|
||||
println!("- Average price: ${}", result.average_price);
|
||||
println!("- Total tokens: {}", result.total_size);
|
||||
println!("- Fees: ${}", result.fees);
|
||||
println!("- Market impact: {}%", result.impact_pct * 100);
|
||||
```
|
||||
|
||||
This tells you exactly what would happen without actually placing the order. Super useful for position sizing.
|
||||
|
||||
### WebSocket Streaming (The Fun Part)
|
||||
|
||||
Here's how you connect to live market data. The library handles all the annoying reconnection stuff:
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{WebSocketStream, StreamManager};
|
||||
|
||||
let mut stream = WebSocketStream::new("wss://clob.polymarket.com/ws");
|
||||
|
||||
// Set up authentication (you'll need API credentials)
|
||||
let auth = WssAuth {
|
||||
address: "your_eth_address".to_string(),
|
||||
signature: "your_signature".to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
nonce: "random_nonce".to_string(),
|
||||
};
|
||||
stream = stream.with_auth(auth);
|
||||
|
||||
// Subscribe to specific markets
|
||||
stream.subscribe_market_channel(vec!["token_id_1".to_string(), "token_id_2".to_string()]).await?;
|
||||
|
||||
// Process live updates
|
||||
while let Some(message) = stream.next().await {
|
||||
match message? {
|
||||
StreamMessage::MarketBookUpdate { data } => {
|
||||
// This is where the fast order book updates happen
|
||||
book_manager.apply_delta_fast(data)?;
|
||||
}
|
||||
StreamMessage::MarketTrade { data } => {
|
||||
println!("Trade: {} tokens at ${}", data.size, data.price);
|
||||
}
|
||||
StreamMessage::Heartbeat { .. } => {
|
||||
// Connection is alive
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The stream automatically reconnects when it drops. You just keep processing messages.
|
||||
|
||||
### Example: Simple Spread Trading Bot
|
||||
|
||||
Here's a basic bot that looks for wide spreads and tries to capture them:
|
||||
|
||||
```rust
|
||||
use polyfill_rs::{ClobClient, OrderBookManager, FillEngine};
|
||||
|
||||
struct SpreadBot {
|
||||
client: ClobClient,
|
||||
book_manager: OrderBookManager,
|
||||
min_spread_pct: Decimal, // Only trade if spread > this %
|
||||
position_size: Decimal, // How much to trade each time
|
||||
}
|
||||
|
||||
impl SpreadBot {
|
||||
async fn check_opportunity(&mut self, token_id: &str) -> Result<bool> {
|
||||
let book = self.book_manager.get_book(token_id)?;
|
||||
|
||||
// Get current market state
|
||||
let spread_pct = book.spread_pct().unwrap_or_default();
|
||||
let best_bid = book.best_bid();
|
||||
let best_ask = book.best_ask();
|
||||
|
||||
// Only trade if spread is wide enough and we have liquidity
|
||||
if spread_pct > self.min_spread_pct && best_bid.is_some() && best_ask.is_some() {
|
||||
println!("Found opportunity: {}% spread on {}", spread_pct, token_id);
|
||||
|
||||
// Check if our order size would move the market too much
|
||||
let impact = book.calculate_market_impact(Side::BUY, self.position_size);
|
||||
if let Some(impact) = impact {
|
||||
if impact.impact_pct < Decimal::from_str("0.01")? { // < 1% impact
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn execute_trade(&mut self, token_id: &str) -> Result<()> {
|
||||
// This is where you'd actually place orders
|
||||
// Left as an exercise for the reader :)
|
||||
println!("Would place orders for {}", token_id);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key insight: with fast order book updates, you can check hundreds of tokens for opportunities without the library being the bottleneck.
|
||||
|
||||
**Pro tip**: The trading strategy examples in the code include detailed comments about market microstructure, order flow, and risk management techniques.
|
||||
|
||||
## Configuration Tips
|
||||
|
||||
### Order Book Depth Settings
|
||||
|
||||
The most important performance knob is how many price levels to track:
|
||||
|
||||
```rust
|
||||
// For most trading bots: 10-50 levels is plenty
|
||||
let book_manager = OrderBookManager::new(20);
|
||||
|
||||
// For market making: maybe 100+ levels
|
||||
let book_manager = OrderBookManager::new(100);
|
||||
|
||||
// For analysis/research: could go higher, but memory usage grows
|
||||
let book_manager = OrderBookManager::new(500);
|
||||
```
|
||||
|
||||
Why this matters: Each price level takes memory, but 90% of trading happens in the top 10 levels anyway. More levels = more memory usage for diminishing returns.
|
||||
|
||||
*The code comments in `src/book.rs` explain the memory layout and why we chose these specific data structures for different use cases.*
|
||||
|
||||
### WebSocket Reconnection
|
||||
|
||||
The defaults are pretty good, but you can tune them:
|
||||
|
||||
```rust
|
||||
let reconnect_config = ReconnectConfig {
|
||||
max_retries: 5, // Give up after 5 attempts
|
||||
base_delay: Duration::from_secs(1), // Start with 1 second delay
|
||||
max_delay: Duration::from_secs(60), // Cap at 1 minute
|
||||
backoff_multiplier: 2.0, // Double delay each time
|
||||
};
|
||||
|
||||
let stream = WebSocketStream::new("wss://clob.polymarket.com/ws")
|
||||
.with_reconnect_config(reconnect_config);
|
||||
```
|
||||
|
||||
### Memory Usage
|
||||
|
||||
If you're tracking lots of tokens, you might want to clean up stale books:
|
||||
|
||||
```rust
|
||||
// Remove books that haven't updated in 5 minutes
|
||||
let removed = book_manager.cleanup_stale_books(Duration::from_secs(300))?;
|
||||
println!("Cleaned up {} stale order books", removed);
|
||||
```
|
||||
|
||||
### Market Microstructure Compliance
|
||||
Automatic tick size validation and price quantization prevent market fragmentation and ensure exchange compatibility. Sub-tick pricing rejection happens at ingress with zero-cost integer modulo operations.
|
||||
|
||||
*Tick alignment implementation includes detailed analysis of market maker adverse selection and the role of minimum price increments in maintaining orderly markets.*
|
||||
|
||||
### Memory Management
|
||||
Bounded memory growth through configurable depth limits and automatic stale data eviction. Memory usage scales linearly with active price levels rather than total market depth, preventing memory exhaustion in volatile market conditions.
|
||||
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="260" viewBox="0 0 980 260" role="img" aria-labelledby="title desc">
|
||||
<title id="title">polyfill-rs benchmark bars</title>
|
||||
<desc id="desc">Minimal horizontal bars visualizing selected p50 benchmark latency. Lower values are faster.</desc>
|
||||
<style>
|
||||
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
.group { fill: #6b7280; font-size: 12px; font-weight: 650; }
|
||||
.label { fill: #374151; font-size: 12px; font-weight: 600; }
|
||||
.value { fill: #111827; font-size: 12px; font-weight: 650; }
|
||||
.rule { stroke: #e5e7eb; stroke-width: 1; }
|
||||
.polyfill { fill: #0f766e; }
|
||||
.compare { fill: #9ca3af; }
|
||||
</style>
|
||||
|
||||
<rect width="980" height="260" fill="#ffffff"/>
|
||||
|
||||
<text class="group" x="40" y="38">Steady typed total, p50</text>
|
||||
<text class="label" x="40" y="68">polyfill-rs</text>
|
||||
<rect class="polyfill" x="210" y="54" width="565" height="18" rx="3"/>
|
||||
<text class="value" x="793" y="68">228.2 ms</text>
|
||||
<text class="label" x="40" y="96">rs-clob-client-v2</text>
|
||||
<rect class="compare" x="210" y="82" width="600" height="18" rx="3"/>
|
||||
<text class="value" x="828" y="96">242.3 ms</text>
|
||||
|
||||
<line class="rule" x1="40" y1="128" x2="940" y2="128"/>
|
||||
|
||||
<text class="group" x="40" y="158">CPU parse only, p50</text>
|
||||
<text class="label" x="40" y="188">polyfill-rs</text>
|
||||
<rect class="polyfill" x="210" y="174" width="231" height="18" rx="3"/>
|
||||
<text class="value" x="459" y="188">0.5 ms</text>
|
||||
<text class="label" x="40" y="216">rs-clob-client-v2</text>
|
||||
<rect class="compare" x="210" y="202" width="600" height="18" rx="3"/>
|
||||
<text class="value" x="828" y="216">1.3 ms</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
+33
-15
@@ -37,17 +37,21 @@ fn bench_delta_application(c: &mut Criterion) {
|
||||
book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
let mut sequence = 10;
|
||||
|
||||
c.bench_function("delta_application", |b| {
|
||||
b.iter(|| {
|
||||
sequence += 1;
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: black_box(Side::SELL),
|
||||
price: black_box(dec!(0.52)),
|
||||
size: black_box(dec!(50)),
|
||||
sequence: black_box(11),
|
||||
sequence: black_box(sequence),
|
||||
};
|
||||
book.apply_delta(delta).unwrap();
|
||||
black_box(book.sequence);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -161,23 +165,40 @@ fn bench_high_frequency_updates(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn bench_concurrent_access(c: &mut Criterion) {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
let mut initial_book = OrderBook::new("test_token".to_string(), 100);
|
||||
|
||||
for i in 1..=20 {
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
};
|
||||
initial_book.apply_delta(delta).unwrap();
|
||||
}
|
||||
|
||||
let book = Arc::new(RwLock::new(initial_book));
|
||||
let sequence = Arc::new(AtomicU64::new(20));
|
||||
|
||||
c.bench_function("concurrent_access", |b| {
|
||||
b.iter(|| {
|
||||
let book = Arc::new(RwLock::new(OrderBook::new("test_token".to_string(), 100)));
|
||||
let book_clone = book.clone();
|
||||
|
||||
// Simulate concurrent reads and writes
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
// Spawn writer tasks
|
||||
for i in 1..=10 {
|
||||
let book = book.clone();
|
||||
let sequence = sequence.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut book = book.write().await;
|
||||
let sequence = sequence.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let price = Decimal::from(50 + i) / Decimal::from(100);
|
||||
let delta = OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
@@ -185,26 +206,23 @@ fn bench_concurrent_access(c: &mut Criterion) {
|
||||
side: if i % 2 == 0 { Side::BUY } else { Side::SELL },
|
||||
price,
|
||||
size: dec!(100),
|
||||
sequence: i,
|
||||
sequence,
|
||||
};
|
||||
let mut book = book.write().await;
|
||||
book.apply_delta(delta).unwrap();
|
||||
black_box(book.sequence);
|
||||
}));
|
||||
}
|
||||
|
||||
// Spawn reader tasks
|
||||
for _ in 0..20 {
|
||||
let book = book_clone.clone();
|
||||
let book = book.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let book = book.read().await;
|
||||
let _bid = book.best_bid();
|
||||
let _ask = book.best_ask();
|
||||
black_box((book.best_bid(), book.best_ask()));
|
||||
}));
|
||||
}
|
||||
|
||||
// Wait for all tasks
|
||||
for task in tasks {
|
||||
let _ = task.await;
|
||||
task.await.unwrap();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,157 @@
|
||||
use alloy_signer_local::PrivateKeySigner;
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use polyfill_rs::{OrderArgs, OrderBookImpl, Side};
|
||||
use polyfill_rs::{
|
||||
auth::{create_l2_headers_with_body_bytes, PreparedApiCredentials},
|
||||
orders::{OrderBuilder, BYTES32_ZERO},
|
||||
types::{
|
||||
ApiCredentials, CreateOrderOptions, FastOrderDelta, OrderDelta, OrderType, PostOrder,
|
||||
PostOrderOptions,
|
||||
},
|
||||
OrderArgs, OrderBookImpl, Side,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
// Benchmark: Create an order with EIP-712 signature (computational cost only)
|
||||
const TOKEN_ID: &str = "12345678901234567890";
|
||||
const CHAIN_ID: u64 = 137;
|
||||
const PRIVATE_KEY: &str = "0x1234567890123456789012345678901234567890123456789012345678901234";
|
||||
|
||||
fn test_signer() -> PrivateKeySigner {
|
||||
PRIVATE_KEY.parse().expect("valid benchmark private key")
|
||||
}
|
||||
|
||||
fn test_order_args() -> OrderArgs {
|
||||
OrderArgs {
|
||||
token_id: TOKEN_ID.to_string(),
|
||||
price: Decimal::from_str("0.7537").unwrap(),
|
||||
size: Decimal::from_str("100.25").unwrap(),
|
||||
side: Side::BUY,
|
||||
expiration: Some(1_900_000_000),
|
||||
builder_code: Some(BYTES32_ZERO.to_string()),
|
||||
metadata: Some(BYTES32_ZERO.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_order_options() -> CreateOrderOptions {
|
||||
CreateOrderOptions {
|
||||
tick_size: Some(Decimal::from_str("0.0001").unwrap()),
|
||||
neg_risk: Some(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn token_hash(token_id: &str) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
token_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn fast_delta(token_id_hash: u64) -> FastOrderDelta {
|
||||
FastOrderDelta {
|
||||
token_id_hash,
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: 7_537,
|
||||
size: 1_002_500,
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal_delta() -> OrderDelta {
|
||||
OrderDelta {
|
||||
token_id: TOKEN_ID.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.7537").unwrap(),
|
||||
size: Decimal::from_str("100.25").unwrap(),
|
||||
sequence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark: Create and EIP-712 sign a limit order.
|
||||
fn benchmark_create_order_eip712(c: &mut Criterion) {
|
||||
let signer = test_signer();
|
||||
let builder = OrderBuilder::new(signer, None, None);
|
||||
let order_args = test_order_args();
|
||||
let options = test_order_options();
|
||||
|
||||
c.bench_function("create_order_eip712_signature", |b| {
|
||||
b.iter(|| {
|
||||
// Create order arguments - this benchmarks the computational cost
|
||||
let order_args = OrderArgs::new(
|
||||
"test_token_id",
|
||||
Decimal::from_str("0.75").unwrap(),
|
||||
Decimal::from_str("100.0").unwrap(),
|
||||
Side::BUY,
|
||||
);
|
||||
let signed_order = builder
|
||||
.create_order(
|
||||
black_box(CHAIN_ID),
|
||||
black_box(&order_args),
|
||||
black_box(&options),
|
||||
)
|
||||
.unwrap();
|
||||
black_box(signed_order)
|
||||
})
|
||||
});
|
||||
|
||||
// Simulate the computational work of order creation
|
||||
black_box(order_args)
|
||||
let prepared = builder
|
||||
.prepare_order_path(
|
||||
CHAIN_ID,
|
||||
order_args.token_id.clone(),
|
||||
options.tick_size.unwrap(),
|
||||
options.neg_risk.unwrap(),
|
||||
order_args.builder_code.as_deref(),
|
||||
order_args.metadata.as_deref(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
c.bench_function("prepared_create_order_eip712_signature", |b| {
|
||||
b.iter(|| {
|
||||
let signed_order = prepared
|
||||
.create_limit_order(
|
||||
black_box(order_args.side),
|
||||
black_box(order_args.price),
|
||||
black_box(order_args.size),
|
||||
black_box(order_args.expiration),
|
||||
)
|
||||
.unwrap();
|
||||
black_box(signed_order)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Benchmark: Serialize a signed order body and build L2 auth headers for POST /order.
|
||||
fn benchmark_order_submit_payload_auth(c: &mut Criterion) {
|
||||
let signer = test_signer();
|
||||
let builder = OrderBuilder::new(signer.clone(), None, None);
|
||||
let signed_order = builder
|
||||
.create_order(CHAIN_ID, &test_order_args(), &test_order_options())
|
||||
.unwrap();
|
||||
let post_options = PostOrderOptions {
|
||||
order_type: OrderType::GTD,
|
||||
post_only: false,
|
||||
defer_exec: false,
|
||||
};
|
||||
let api_creds = ApiCredentials {
|
||||
api_key: "benchmark-api-key".to_string(),
|
||||
secret: "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1".to_string(),
|
||||
passphrase: "benchmark-passphrase".to_string(),
|
||||
};
|
||||
let prepared_api_creds = PreparedApiCredentials::try_new(api_creds.clone()).unwrap();
|
||||
|
||||
c.bench_function("order_submit_body_and_l2_headers", |b| {
|
||||
b.iter(|| {
|
||||
let body = PostOrder::new(
|
||||
black_box(signed_order.clone()),
|
||||
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))
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -34,30 +169,37 @@ fn benchmark_json_parsing(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
// Benchmark: Order book operations
|
||||
fn benchmark_order_book_operations(c: &mut Criterion) {
|
||||
c.bench_function("order_book_updates", |b| {
|
||||
// Benchmark: Core fixed-point order book update path.
|
||||
fn benchmark_order_book_core_operations(c: &mut Criterion) {
|
||||
let token_id_hash = token_hash(TOKEN_ID);
|
||||
let mut book = OrderBookImpl::new(TOKEN_ID.to_string(), 100);
|
||||
let delta_template = fast_delta(token_id_hash);
|
||||
let mut sequence = 0;
|
||||
|
||||
c.bench_function("order_book_apply_delta_fast_core", |b| {
|
||||
b.iter(|| {
|
||||
let mut book = OrderBookImpl::new("test_token".to_string(), 100);
|
||||
sequence += 1;
|
||||
let mut delta = delta_template;
|
||||
delta.sequence = sequence;
|
||||
book.apply_delta_fast(black_box(delta)).unwrap();
|
||||
black_box(book.sequence)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate rapid order book updates
|
||||
for i in 0..1000 {
|
||||
let price = Decimal::from_str(&format!("0.{:04}", 5000 + (i % 100))).unwrap();
|
||||
let size = Decimal::from_str("100.0").unwrap();
|
||||
// Benchmark: External Decimal OrderDelta ingestion through the ergonomic API.
|
||||
fn benchmark_order_book_external_ingestion(c: &mut Criterion) {
|
||||
let mut book = OrderBookImpl::new(TOKEN_ID.to_string(), 100);
|
||||
let delta_template = decimal_delta();
|
||||
let mut sequence = 0;
|
||||
|
||||
let bid_delta = polyfill_rs::OrderDelta {
|
||||
token_id: "test_token".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: polyfill_rs::Side::BUY,
|
||||
price,
|
||||
size,
|
||||
sequence: i as u64,
|
||||
};
|
||||
|
||||
let _ = book.apply_delta(bid_delta);
|
||||
}
|
||||
|
||||
black_box(book)
|
||||
c.bench_function("order_book_external_decimal_ingestion", |b| {
|
||||
b.iter(|| {
|
||||
sequence += 1;
|
||||
let mut delta = delta_template.clone();
|
||||
delta.sequence = sequence;
|
||||
book.apply_delta(black_box(delta)).unwrap();
|
||||
black_box(book.sequence)
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -100,8 +242,10 @@ fn benchmark_fast_operations(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
benchmark_create_order_eip712,
|
||||
benchmark_order_submit_payload_auth,
|
||||
benchmark_json_parsing,
|
||||
benchmark_order_book_operations,
|
||||
benchmark_order_book_core_operations,
|
||||
benchmark_order_book_external_ingestion,
|
||||
benchmark_fast_operations
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -69,7 +69,7 @@ fn benchmark_real_order_creation(c: &mut Criterion) {
|
||||
);
|
||||
|
||||
// This is the real EIP-712 signing + network request
|
||||
let result = client.create_order(&order_args, None, None, None).await;
|
||||
let result = client.create_order(&order_args, None).await;
|
||||
black_box(result)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
//! Benchmarks for the WebSocket `book` hot path.
|
||||
//!
|
||||
//! These include the warmed happy path plus focused stress cases for level churn,
|
||||
//! multi-asset routing, timestamp edges, malformed input, and production-like
|
||||
//! bursts. The allocation checks live in `tests/no_alloc_hot_paths.rs`; these
|
||||
//! benches focus on throughput/latency of the processing path.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion, Throughput};
|
||||
use polyfill_rs::types::BookUpdate;
|
||||
use polyfill_rs::{OrderBookManager, OrderSummary, StreamMessage, WsBookUpdateProcessor};
|
||||
use rust_decimal::Decimal;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const START_TIMESTAMP: u64 = 1_000_000_000_000_000;
|
||||
const BOOK_ASSET_ID: &str = "test_asset_id";
|
||||
const BOOK_MARKET: &str = "0xabc";
|
||||
|
||||
#[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;
|
||||
}
|
||||
|
||||
if start == end {
|
||||
panic!("timestamp digits not found in WS template JSON");
|
||||
}
|
||||
|
||||
Self { start, end }
|
||||
}
|
||||
|
||||
fn write_fixed_width(&self, bytes: &mut [u8], mut value: u64) {
|
||||
let width = self.end - self.start;
|
||||
|
||||
// Write digits right-to-left into the existing digit window.
|
||||
for idx in (0..width).rev() {
|
||||
let digit = (value % 10) as u8;
|
||||
bytes[self.start + idx] = b'0' + digit;
|
||||
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 bench_shard_index(token_id: &str, shard_count: usize) -> usize {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in token_id.as_bytes() {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
(hash as usize) % shard_count
|
||||
}
|
||||
|
||||
fn asset_ids_on_same_shard(count: usize, shard_count: usize) -> Vec<String> {
|
||||
let anchor = "bench_asset_0".to_string();
|
||||
let target_shard = bench_shard_index(&anchor, shard_count);
|
||||
let mut assets = vec![anchor];
|
||||
|
||||
for idx in 1..20_000 {
|
||||
if assets.len() == count {
|
||||
break;
|
||||
}
|
||||
|
||||
let asset_id = format!("bench_asset_{idx}");
|
||||
if bench_shard_index(&asset_id, shard_count) == target_shard {
|
||||
assets.push(asset_id);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(assets.len(), count, "failed to find same-shard assets");
|
||||
assets
|
||||
}
|
||||
|
||||
fn asset_ids_on_distinct_shards(count: usize, shard_count: usize) -> Vec<String> {
|
||||
assert!(count <= shard_count);
|
||||
let mut assets = Vec::with_capacity(count);
|
||||
let mut seen = vec![false; shard_count];
|
||||
|
||||
for idx in 0..20_000 {
|
||||
if assets.len() == count {
|
||||
break;
|
||||
}
|
||||
|
||||
let asset_id = format!("bench_asset_{idx}");
|
||||
let shard = bench_shard_index(&asset_id, shard_count);
|
||||
if !seen[shard] {
|
||||
seen[shard] = true;
|
||||
assets.push(asset_id);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(assets.len(), count, "failed to find distinct-shard assets");
|
||||
assets
|
||||
}
|
||||
|
||||
fn build_book_update(levels_per_side: usize) -> BookUpdate {
|
||||
build_book_update_for(BOOK_ASSET_ID, levels_per_side)
|
||||
}
|
||||
|
||||
fn build_book_update_for(asset_id: &str, levels_per_side: usize) -> BookUpdate {
|
||||
let mut bids = Vec::with_capacity(levels_per_side);
|
||||
let mut asks = Vec::with_capacity(levels_per_side);
|
||||
|
||||
let size = Decimal::new(1_000_000, 4); // 100.0000
|
||||
|
||||
for i in 0..levels_per_side {
|
||||
let bid_ticks = 7_500u32 - i as u32;
|
||||
let ask_ticks = 7_501u32 + i as u32;
|
||||
bids.push(OrderSummary {
|
||||
price: Decimal::new(bid_ticks as i64, 4),
|
||||
size,
|
||||
});
|
||||
asks.push(OrderSummary {
|
||||
price: Decimal::new(ask_ticks as i64, 4),
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
BookUpdate {
|
||||
asset_id: asset_id.to_string(),
|
||||
market: BOOK_MARKET.to_string(),
|
||||
timestamp: 1,
|
||||
bids,
|
||||
asks,
|
||||
hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ws_book_template(levels_per_side: usize) -> Vec<u8> {
|
||||
build_ws_book_template_for(BOOK_ASSET_ID, BOOK_MARKET, levels_per_side, 0)
|
||||
}
|
||||
|
||||
fn build_ws_book_template_for(
|
||||
asset_id: &str,
|
||||
market: &str,
|
||||
levels_per_side: usize,
|
||||
price_shift_ticks: u32,
|
||||
) -> Vec<u8> {
|
||||
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\":[");
|
||||
|
||||
let size = "100.0000";
|
||||
for i in 0..levels_per_side {
|
||||
if i != 0 {
|
||||
json.push(',');
|
||||
}
|
||||
let bid_ticks = 7_500u32 - price_shift_ticks - i as u32;
|
||||
let bid_price = price_string_from_ticks(bid_ticks);
|
||||
json.push_str("{\"price\":\"");
|
||||
json.push_str(&bid_price);
|
||||
json.push_str("\",\"size\":\"");
|
||||
json.push_str(size);
|
||||
json.push_str("\"}");
|
||||
}
|
||||
|
||||
json.push_str("],\"asks\":[");
|
||||
for i in 0..levels_per_side {
|
||||
if i != 0 {
|
||||
json.push(',');
|
||||
}
|
||||
let ask_ticks = 7_501u32 + price_shift_ticks + i as u32;
|
||||
let ask_price = price_string_from_ticks(ask_ticks);
|
||||
json.push_str("{\"price\":\"");
|
||||
json.push_str(&ask_price);
|
||||
json.push_str("\",\"size\":\"");
|
||||
json.push_str(size);
|
||||
json.push_str("\"}");
|
||||
}
|
||||
json.push_str("]}");
|
||||
|
||||
json.into_bytes()
|
||||
}
|
||||
|
||||
fn build_missing_price_template() -> Vec<u8> {
|
||||
let mut json = String::new();
|
||||
|
||||
json.push_str("{\"event_type\":\"book\",\"asset_id\":\"");
|
||||
json.push_str(BOOK_ASSET_ID);
|
||||
json.push_str("\",\"market\":\"");
|
||||
json.push_str(BOOK_MARKET);
|
||||
json.push_str("\",\"timestamp\":");
|
||||
json.push_str(&START_TIMESTAMP.to_string());
|
||||
json.push_str(",\"bids\":[{\"size\":\"100.0000\"}],\"asks\":[]}");
|
||||
|
||||
json.into_bytes()
|
||||
}
|
||||
|
||||
fn build_invalid_price_template() -> Vec<u8> {
|
||||
let mut json = String::new();
|
||||
|
||||
json.push_str("{\"event_type\":\"book\",\"asset_id\":\"");
|
||||
json.push_str(BOOK_ASSET_ID);
|
||||
json.push_str("\",\"market\":\"");
|
||||
json.push_str(BOOK_MARKET);
|
||||
json.push_str("\",\"timestamp\":");
|
||||
json.push_str(&START_TIMESTAMP.to_string());
|
||||
json.push_str(",\"bids\":[{\"price\":\"0.75001\",\"size\":\"100.0000\"}],\"asks\":[]}");
|
||||
|
||||
json.into_bytes()
|
||||
}
|
||||
|
||||
fn build_missing_asset_id_template() -> Vec<u8> {
|
||||
br#"{"event_type":"book","market":"0xabc","timestamp":1000000000000000,"bids":[],"asks":[]}"#
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
fn build_same_millisecond_template(levels_per_side: usize) -> Vec<u8> {
|
||||
let mut template = build_ws_book_template(levels_per_side);
|
||||
let ts_range = TimestampRange::find(&template);
|
||||
ts_range.write_fixed_width(template.as_mut_slice(), START_TIMESTAMP + 1);
|
||||
template
|
||||
}
|
||||
|
||||
fn warm_books(books: &OrderBookManager, assets: &[String], levels_per_side: usize) {
|
||||
for asset_id in assets {
|
||||
let _ = books.get_or_create_book(asset_id).unwrap();
|
||||
let warmup_update = build_book_update_for(asset_id, levels_per_side);
|
||||
books.apply_book_update(&warmup_update).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn message_from_template(template: &[u8], ts_range: TimestampRange, timestamp: u64) -> Vec<u8> {
|
||||
let mut msg = template.to_vec();
|
||||
ts_range.write_fixed_width(msg.as_mut_slice(), timestamp);
|
||||
msg
|
||||
}
|
||||
|
||||
fn bench_ws_book_process_bytes(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_hot_path");
|
||||
|
||||
for levels_per_side in [1usize, 16, 64] {
|
||||
let hot_path_books = OrderBookManager::new(levels_per_side * 2);
|
||||
let _ = hot_path_books.get_or_create_book(BOOK_ASSET_ID).unwrap();
|
||||
|
||||
// Warm up: ensure all levels exist so the steady-state path doesn't allocate.
|
||||
let warmup_update = build_book_update(levels_per_side);
|
||||
hot_path_books.apply_book_update(&warmup_update).unwrap();
|
||||
|
||||
let template = build_ws_book_template(levels_per_side);
|
||||
let tape_template = template.clone();
|
||||
let ts_range = TimestampRange::find(&tape_template);
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(tape_template.len());
|
||||
let mut warmup_msg = tape_template.clone();
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &hot_path_books)
|
||||
.unwrap();
|
||||
|
||||
let counter = AtomicU64::new(START_TIMESTAMP);
|
||||
|
||||
group.throughput(Throughput::Bytes(tape_template.len() as u64));
|
||||
group.bench_function(
|
||||
format!("tape_process_and_apply_levels_per_side_{levels_per_side}"),
|
||||
move |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let mut msg = tape_template.clone();
|
||||
let ts = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
ts_range.write_fixed_width(msg.as_mut_slice(), ts);
|
||||
msg
|
||||
},
|
||||
|mut msg| {
|
||||
let stats = processor
|
||||
.process_bytes(
|
||||
black_box(msg.as_mut_slice()),
|
||||
black_box(&hot_path_books),
|
||||
)
|
||||
.unwrap();
|
||||
black_box(stats);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Baseline: serde_json DOM -> StreamMessage -> BookUpdate -> apply to books.
|
||||
//
|
||||
// This is representative of our "non-hot-path" decoding approach and provides
|
||||
// a direct comparison within the same benchmark.
|
||||
let serde_books = OrderBookManager::new(levels_per_side * 2);
|
||||
let _ = serde_books.get_or_create_book(BOOK_ASSET_ID).unwrap();
|
||||
serde_books.apply_book_update(&warmup_update).unwrap();
|
||||
|
||||
let serde_template = template;
|
||||
let serde_ts_range = TimestampRange::find(&serde_template);
|
||||
let serde_counter = AtomicU64::new(START_TIMESTAMP);
|
||||
|
||||
group.bench_function(
|
||||
format!("serde_decode_and_apply_levels_per_side_{levels_per_side}"),
|
||||
move |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let mut msg = serde_template.clone();
|
||||
let ts = serde_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
serde_ts_range.write_fixed_width(msg.as_mut_slice(), ts);
|
||||
msg
|
||||
},
|
||||
|msg| {
|
||||
let messages = polyfill_rs::decode::parse_stream_messages_bytes(black_box(
|
||||
msg.as_slice(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
for message in messages {
|
||||
if let StreamMessage::Book(update) = message {
|
||||
serde_books.apply_book_update(&update).unwrap();
|
||||
}
|
||||
}
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ws_book_price_level_churn(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_churn");
|
||||
let levels_per_side = 64usize;
|
||||
let books = OrderBookManager::new(levels_per_side * 2);
|
||||
let _ = books.get_or_create_book(BOOK_ASSET_ID).unwrap();
|
||||
books
|
||||
.apply_book_update(&build_book_update(levels_per_side))
|
||||
.unwrap();
|
||||
|
||||
let templates = (0..32)
|
||||
.map(|idx| build_ws_book_template_for(BOOK_ASSET_ID, BOOK_MARKET, levels_per_side, idx * 8))
|
||||
.collect::<Vec<_>>();
|
||||
let ts_ranges = templates
|
||||
.iter()
|
||||
.map(|template| TimestampRange::find(template))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(templates[0].len());
|
||||
let counter = AtomicU64::new(START_TIMESTAMP);
|
||||
let mut template_idx = 0usize;
|
||||
|
||||
group.throughput(Throughput::Bytes(templates[0].len() as u64));
|
||||
group.bench_function("price_level_churn_64_levels", |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let idx = template_idx;
|
||||
template_idx = (template_idx + 1) % templates.len();
|
||||
let ts = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
message_from_template(&templates[idx], ts_ranges[idx], ts)
|
||||
},
|
||||
|mut msg| {
|
||||
let stats = processor
|
||||
.process_bytes(black_box(msg.as_mut_slice()), black_box(&books))
|
||||
.unwrap();
|
||||
black_box(stats);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ws_book_multi_asset_routing(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_multi_asset");
|
||||
let shard_count = 4usize;
|
||||
let levels_per_side = 16usize;
|
||||
|
||||
for (bench_name, assets) in [
|
||||
(
|
||||
"same_shard_4_assets_16_levels",
|
||||
asset_ids_on_same_shard(4, shard_count),
|
||||
),
|
||||
(
|
||||
"different_shards_4_assets_16_levels",
|
||||
asset_ids_on_distinct_shards(4, shard_count),
|
||||
),
|
||||
] {
|
||||
let books = OrderBookManager::with_shard_count(levels_per_side * 2, shard_count);
|
||||
warm_books(&books, &assets, levels_per_side);
|
||||
|
||||
let templates = assets
|
||||
.iter()
|
||||
.map(|asset_id| build_ws_book_template_for(asset_id, BOOK_MARKET, levels_per_side, 0))
|
||||
.collect::<Vec<_>>();
|
||||
let ts_ranges = templates
|
||||
.iter()
|
||||
.map(|template| TimestampRange::find(template))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(templates[0].len());
|
||||
let counter = AtomicU64::new(START_TIMESTAMP);
|
||||
let mut template_idx = 0usize;
|
||||
|
||||
group.throughput(Throughput::Bytes(templates[0].len() as u64));
|
||||
group.bench_function(bench_name, |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let idx = template_idx;
|
||||
template_idx = (template_idx + 1) % templates.len();
|
||||
let ts = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
message_from_template(&templates[idx], ts_ranges[idx], ts)
|
||||
},
|
||||
|mut msg| {
|
||||
let stats = processor
|
||||
.process_bytes(black_box(msg.as_mut_slice()), black_box(&books))
|
||||
.unwrap();
|
||||
black_box(stats);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ws_book_timestamp_edges(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_timestamp_edges");
|
||||
let levels_per_side = 16usize;
|
||||
let books = OrderBookManager::new(levels_per_side * 2);
|
||||
let _ = books.get_or_create_book(BOOK_ASSET_ID).unwrap();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
let mut warmup_msg = build_same_millisecond_template(levels_per_side);
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &books)
|
||||
.unwrap();
|
||||
|
||||
let template = build_same_millisecond_template(levels_per_side);
|
||||
group.throughput(Throughput::Bytes(template.len() as u64));
|
||||
group.bench_function("same_millisecond_snapshot_rejection", |b| {
|
||||
b.iter_batched(
|
||||
|| template.clone(),
|
||||
|mut msg| {
|
||||
let stats = processor
|
||||
.process_bytes(black_box(msg.as_mut_slice()), black_box(&books))
|
||||
.unwrap();
|
||||
black_box(stats);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ws_book_malformed_inputs(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_malformed_inputs");
|
||||
|
||||
for (bench_name, template, rewrite_timestamp) in [
|
||||
("missing_asset_id", build_missing_asset_id_template(), false),
|
||||
("missing_price", build_missing_price_template(), true),
|
||||
(
|
||||
"invalid_price_precision",
|
||||
build_invalid_price_template(),
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let books = OrderBookManager::new(32);
|
||||
let _ = books.get_or_create_book(BOOK_ASSET_ID).unwrap();
|
||||
books.apply_book_update(&build_book_update(16)).unwrap();
|
||||
|
||||
let ts_range = rewrite_timestamp.then(|| TimestampRange::find(&template));
|
||||
let counter = AtomicU64::new(START_TIMESTAMP);
|
||||
let mut processor = WsBookUpdateProcessor::new(template.len());
|
||||
|
||||
group.throughput(Throughput::Bytes(template.len() as u64));
|
||||
group.bench_function(bench_name, |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let mut msg = template.clone();
|
||||
if let Some(ts_range) = ts_range {
|
||||
let ts = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
ts_range.write_fixed_width(msg.as_mut_slice(), ts);
|
||||
}
|
||||
msg
|
||||
},
|
||||
|mut msg| {
|
||||
let result =
|
||||
processor.process_bytes(black_box(msg.as_mut_slice()), black_box(&books));
|
||||
black_box(result.is_err());
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_ws_book_bursts(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("ws_book_bursts");
|
||||
let levels_per_side = 32usize;
|
||||
let shard_count = 4usize;
|
||||
let assets = asset_ids_on_distinct_shards(4, shard_count);
|
||||
let books = OrderBookManager::with_shard_count(levels_per_side * 2, shard_count);
|
||||
warm_books(&books, &assets, levels_per_side);
|
||||
|
||||
let templates = (0..32)
|
||||
.map(|idx| {
|
||||
let asset_id = &assets[idx % assets.len()];
|
||||
let shift = ((idx % 8) as u32) * 4;
|
||||
build_ws_book_template_for(asset_id, BOOK_MARKET, levels_per_side, shift)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ts_ranges = templates
|
||||
.iter()
|
||||
.map(|template| TimestampRange::find(template))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(templates[0].len());
|
||||
let counter = AtomicU64::new(START_TIMESTAMP);
|
||||
|
||||
group.throughput(Throughput::Elements(templates.len() as u64));
|
||||
group.bench_function("mixed_32_message_burst", |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
templates
|
||||
.iter()
|
||||
.zip(ts_ranges.iter().copied())
|
||||
.map(|(template, ts_range)| {
|
||||
let ts = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
message_from_template(template, ts_range, ts)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
},
|
||||
|mut messages| {
|
||||
let mut total_levels = 0usize;
|
||||
for msg in &mut messages {
|
||||
let stats = processor
|
||||
.process_bytes(black_box(msg.as_mut_slice()), black_box(&books))
|
||||
.unwrap();
|
||||
total_levels += stats.book_levels_applied;
|
||||
}
|
||||
black_box(total_levels);
|
||||
},
|
||||
BatchSize::SmallInput,
|
||||
);
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_ws_book_process_bytes,
|
||||
bench_ws_book_price_level_churn,
|
||||
bench_ws_book_multi_asset_routing,
|
||||
bench_ws_book_timestamp_edges,
|
||||
bench_ws_book_malformed_inputs,
|
||||
bench_ws_book_bursts
|
||||
);
|
||||
criterion_main!(benches);
|
||||
+48
-31
@@ -13,21 +13,21 @@ This document describes how to run tests for polyfill-rs, with a focus on integr
|
||||
### Integration Tests
|
||||
- **Location**: `tests/integration_tests.rs`
|
||||
- **Purpose**: Verify the client can communicate with the real Polymarket API
|
||||
- **Dependencies**: Network connectivity, optional authentication credentials
|
||||
- **Dependencies**: Network connectivity + credentials (tests are `#[ignore]` by default)
|
||||
- **Speed**: Slower (network calls)
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Quick Start (Basic Tests)
|
||||
```bash
|
||||
# Run all unit tests
|
||||
cargo test
|
||||
# Run unit tests + doc tests (real-API tests are `#[ignore]` by default)
|
||||
cargo test --all-features
|
||||
|
||||
# Run only integration tests
|
||||
cargo test --test integration_tests
|
||||
# Run the "no-alloc hot paths" regression tests
|
||||
cargo test --all-features --test no_alloc_hot_paths
|
||||
|
||||
# Run with verbose output
|
||||
cargo test --test integration_tests -- --nocapture
|
||||
# Compile-check all examples
|
||||
cargo build --examples
|
||||
```
|
||||
|
||||
### Full Integration Testing
|
||||
@@ -37,27 +37,42 @@ cargo test --test integration_tests -- --nocapture
|
||||
Create a `.env` file or export variables:
|
||||
|
||||
```bash
|
||||
# Required for authentication tests
|
||||
# Required for ignored real-API tests
|
||||
export POLYMARKET_PRIVATE_KEY="your_private_key_here"
|
||||
|
||||
# Required for order management tests
|
||||
# Optional: API credentials (some tests/tools may use these)
|
||||
export POLYMARKET_API_KEY="your_api_key"
|
||||
export POLYMARKET_API_SECRET="your_api_secret"
|
||||
export POLYMARKET_API_PASSPHRASE="your_passphrase"
|
||||
|
||||
# Optional (defaults provided)
|
||||
# Backward-compatible aliases also accepted by test helpers
|
||||
export POLYMARKET_SECRET="your_api_secret"
|
||||
export POLYMARKET_PASSPHRASE="your_passphrase"
|
||||
|
||||
# Optional (defaults provided in test helpers)
|
||||
export POLYMARKET_HOST="https://clob.polymarket.com"
|
||||
export POLYMARKET_CHAIN_ID="137"
|
||||
|
||||
# Optional, but required when funds live in a Polymarket proxy/Safe wallet.
|
||||
# 0 = EOA, 1 = Proxy, 2 = Gnosis Safe/browser wallet, 3 = Poly1271.
|
||||
export POLYMARKET_SIGNATURE_TYPE="2"
|
||||
|
||||
# Optional: override the derived funder/proxy wallet address.
|
||||
export POLYMARKET_FUNDER_ADDRESS="0x..."
|
||||
|
||||
# Optional: pin clob.polymarket.com to a known A record if local DNS is blocked.
|
||||
# Keep the hostname in POLYMARKET_HOST so HTTPS/SNI still validates.
|
||||
export POLYMARKET_RESOLVE_IP="104.18.34.205"
|
||||
```
|
||||
|
||||
#### 2. Run Integration Tests
|
||||
|
||||
```bash
|
||||
# Using the test runner script
|
||||
# Using the test runner script (runs ignored tests that hit the real API)
|
||||
./scripts/run_integration_tests.sh
|
||||
|
||||
# Or directly with cargo
|
||||
cargo test --test integration_tests -- --nocapture
|
||||
cargo test --all-features --test integration_tests -- --ignored --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
## Test Categories
|
||||
@@ -93,10 +108,9 @@ Performance test passed
|
||||
Markets returned: 50
|
||||
```
|
||||
|
||||
### Skip Indicators
|
||||
### Ignored Indicators (default)
|
||||
```
|
||||
Skipping authentication test - no private key provided
|
||||
Skipping order management test - missing auth credentials
|
||||
test test_real_api_* ... ignored
|
||||
```
|
||||
|
||||
### Failure Indicators
|
||||
@@ -127,6 +141,11 @@ curl -I https://clob.polymarket.com/
|
||||
|
||||
# Check DNS resolution
|
||||
nslookup clob.polymarket.com
|
||||
|
||||
# If local DNS fails but outbound HTTPS works, get a current A record and pin it:
|
||||
curl -sS -H 'accept:application/dns-json' \
|
||||
'https://cloudflare-dns.com/dns-query?name=clob.polymarket.com&type=A'
|
||||
export POLYMARKET_RESOLVE_IP="104.18.34.205"
|
||||
```
|
||||
|
||||
#### Authentication Issues
|
||||
@@ -134,15 +153,13 @@ nslookup clob.polymarket.com
|
||||
# Verify private key format
|
||||
echo $POLYMARKET_PRIVATE_KEY | wc -c # Should be 66 characters (0x + 64 hex)
|
||||
|
||||
# Test with minimal credentials
|
||||
export POLYMARKET_PRIVATE_KEY="0x1234567890123456789012345678901234567890123456789012345678901234"
|
||||
cargo test test_authentication
|
||||
# Run a small ignored auth smoke-test (requires real credentials)
|
||||
cargo test --all-features --test simple_auth_test -- --ignored --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
#### Rate Limiting
|
||||
```bash
|
||||
# If tests fail due to rate limiting, add delays
|
||||
export POLYMARKET_TEST_DELAY=1000 # 1 second between requests
|
||||
# If tests fail due to rate limiting, consider adding delays between manual runs.
|
||||
```
|
||||
|
||||
### Debug Mode
|
||||
@@ -151,25 +168,25 @@ Run tests with detailed logging:
|
||||
|
||||
```bash
|
||||
# Enable debug logging
|
||||
RUST_LOG=debug cargo test --test integration_tests -- --nocapture
|
||||
RUST_LOG=debug cargo test --all-features --test integration_tests -- --ignored --nocapture --test-threads=1
|
||||
|
||||
# Enable trace logging for maximum detail
|
||||
RUST_LOG=trace cargo test --test integration_tests -- --nocapture
|
||||
RUST_LOG=trace cargo test --all-features --test integration_tests -- --ignored --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
Our CI runs integration tests automatically:
|
||||
Our CI runs formatting, clippy, unit tests, docs, security audit, and a separate no-alloc job. Real-API integration tests are `#[ignore]` and are not run in CI.
|
||||
|
||||
```yaml
|
||||
# .github/workflows/ci.yml
|
||||
- name: Run Integration Tests
|
||||
env:
|
||||
POLYMARKET_HOST: ${{ secrets.POLYMARKET_HOST }}
|
||||
POLYMARKET_CHAIN_ID: ${{ secrets.POLYMARKET_CHAIN_ID }}
|
||||
run: cargo test --test integration_tests
|
||||
- name: Run tests (excluding no-alloc hot paths)
|
||||
run: cargo test --all-features -- --skip no_alloc_
|
||||
|
||||
- name: Run no-alloc hot path tests
|
||||
run: cargo test --all-features --test no_alloc_hot_paths
|
||||
```
|
||||
|
||||
### Local CI
|
||||
@@ -180,8 +197,8 @@ Run the same tests locally:
|
||||
# Install cargo-nextest for faster test execution
|
||||
cargo install cargo-nextest
|
||||
|
||||
# Run with nextest
|
||||
cargo nextest run --test integration_tests
|
||||
# Run with nextest (ignored tests are not run by default)
|
||||
cargo nextest run --all-features
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
@@ -235,4 +252,4 @@ async fn test_new_feature() -> Result<()> {
|
||||
- **Never commit credentials**: All test credentials are loaded from environment variables
|
||||
- **Use test accounts**: If testing with real credentials, use dedicated test accounts
|
||||
- **Read-only tests**: Order management tests only create orders, they don't execute them
|
||||
- **Rate limiting**: Tests include delays to respect API rate limits
|
||||
- **Rate limiting**: Tests include delays to respect API rate limits
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Comprehensive Demo for polyfill-rs
|
||||
//! Demo for polyfill-rs
|
||||
//!
|
||||
//! This example demonstrates all the major functions and capabilities of the polyfill-rs library:
|
||||
//! - Basic client operations and API calls
|
||||
@@ -26,7 +26,7 @@ use polyfill_rs::{
|
||||
types::*,
|
||||
|
||||
// Utility functions
|
||||
utils::{address, crypto, math, rate_limit, retry, time, url},
|
||||
utils::{address, math, rate_limit, retry, time, url},
|
||||
|
||||
// Configuration
|
||||
ClientConfig,
|
||||
@@ -43,9 +43,9 @@ use rust_decimal::Decimal;
|
||||
use rust_decimal_macros::dec;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, error, info};
|
||||
use tracing::{error, info};
|
||||
|
||||
/// Comprehensive demo showcasing all polyfill-rs functionality
|
||||
/// Demo showcasing polyfill-rs functionality
|
||||
#[allow(dead_code)]
|
||||
pub struct PolyfillDemo {
|
||||
/// Basic HTTP client
|
||||
@@ -91,24 +91,24 @@ impl Default for DemoStats {
|
||||
}
|
||||
|
||||
impl PolyfillDemo {
|
||||
/// Create a new comprehensive demo
|
||||
/// Create a new demo
|
||||
pub fn new() -> Result<Self> {
|
||||
// Create basic client
|
||||
let client = ClobClient::new("https://clob.polymarket.com");
|
||||
|
||||
// Create advanced client with configuration
|
||||
let _config = ClientConfig {
|
||||
let config = ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain_id: 137, // Polygon
|
||||
private_key: None, // Would be set in production
|
||||
api_credentials: None, // Would be set in production
|
||||
max_slippage: Some(dec!(0.01)), // 1% max slippage
|
||||
fee_rate: Some(dec!(0.02)), // 2% fee rate
|
||||
chain: 137,
|
||||
private_key: None,
|
||||
api_credentials: None,
|
||||
builder_code: None,
|
||||
signature_type: None,
|
||||
funder: None,
|
||||
timeout: Some(Duration::from_secs(30)),
|
||||
max_connections: Some(100),
|
||||
};
|
||||
let client = ClobClient::new(&config.base_url);
|
||||
|
||||
let advanced_client = PolyfillClient::new("https://clob.polymarket.com");
|
||||
// Create advanced client with configuration
|
||||
let advanced_client = PolyfillClient::from_config(config.clone())?;
|
||||
|
||||
// Create order book manager
|
||||
let book_manager = OrderBookManager::new(100);
|
||||
@@ -579,54 +579,56 @@ impl PolyfillDemo {
|
||||
info!("=== Demo 8: Streaming Capabilities ===");
|
||||
|
||||
// Create a mock WebSocket stream
|
||||
let _stream = WebSocketStream::new("wss://stream.polymarket.com");
|
||||
let _stream = WebSocketStream::new("wss://ws-subscriptions-clob.polymarket.com/ws/market");
|
||||
|
||||
info!("Created WebSocket stream");
|
||||
|
||||
// Simulate subscription
|
||||
let subscription = WssSubscription {
|
||||
auth: WssAuth {
|
||||
address: "0x1234567890123456789012345678901234567890".to_string(),
|
||||
signature: "mock_signature".to_string(),
|
||||
timestamp: time::now_secs(),
|
||||
nonce: crypto::generate_nonce().to_string(),
|
||||
},
|
||||
markets: Some(vec!["market1".to_string(), "market2".to_string()]),
|
||||
asset_ids: Some(vec!["12345".to_string(), "67890".to_string()]),
|
||||
channel_type: "USER".to_string(),
|
||||
channel_type: "user".to_string(),
|
||||
operation: Some("subscribe".to_string()),
|
||||
markets: vec!["market1".to_string(), "market2".to_string()],
|
||||
asset_ids: vec!["12345".to_string(), "67890".to_string()],
|
||||
initial_dump: Some(true),
|
||||
custom_feature_enabled: None,
|
||||
auth: Some(WssAuth {
|
||||
api_key: "test-api-key".to_string(),
|
||||
secret: "test-secret".to_string(),
|
||||
passphrase: "test-passphrase".to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
info!("Created subscription: {:?}", subscription);
|
||||
|
||||
// Simulate receiving stream messages
|
||||
let messages = vec![
|
||||
StreamMessage::Heartbeat {
|
||||
timestamp: chrono::Utc::now(),
|
||||
},
|
||||
StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: "12345".to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
StreamMessage::Book(BookUpdate {
|
||||
asset_id: "12345".to_string(),
|
||||
market: "market1".to_string(),
|
||||
timestamp: time::now_millis(),
|
||||
bids: vec![OrderSummary {
|
||||
price: dec!(0.75),
|
||||
size: dec!(100.0),
|
||||
sequence: 1,
|
||||
},
|
||||
},
|
||||
StreamMessage::Trade {
|
||||
data: FillEvent {
|
||||
id: "fill1".to_string(),
|
||||
order_id: "order1".to_string(),
|
||||
token_id: "12345".to_string(),
|
||||
side: Side::BUY,
|
||||
price: dec!(0.75),
|
||||
}],
|
||||
asks: vec![OrderSummary {
|
||||
price: dec!(0.76),
|
||||
size: dec!(50.0),
|
||||
timestamp: chrono::Utc::now(),
|
||||
maker_address: alloy_primitives::Address::ZERO,
|
||||
taker_address: alloy_primitives::Address::ZERO,
|
||||
fee: dec!(0.375),
|
||||
},
|
||||
},
|
||||
}],
|
||||
hash: None,
|
||||
}),
|
||||
StreamMessage::Trade(TradeMessage {
|
||||
id: "fill1".to_string(),
|
||||
market: "market1".to_string(),
|
||||
asset_id: "12345".to_string(),
|
||||
side: Side::BUY,
|
||||
size: dec!(50.0),
|
||||
price: dec!(0.75),
|
||||
status: Some("MATCHED".to_string()),
|
||||
msg_type: None,
|
||||
last_update: None,
|
||||
matchtime: None,
|
||||
timestamp: None,
|
||||
}),
|
||||
];
|
||||
|
||||
for message in messages {
|
||||
@@ -635,27 +637,37 @@ impl PolyfillDemo {
|
||||
|
||||
// Process message based on type
|
||||
match &message {
|
||||
StreamMessage::BookUpdate { data } => {
|
||||
info!(" Processing book update for token: {}", data.token_id);
|
||||
if let Err(e) = self.book_manager.apply_delta(data.clone()) {
|
||||
error!(" Failed to apply book update: {}", e);
|
||||
self.stats.errors += 1;
|
||||
StreamMessage::Book(book) => {
|
||||
info!(" Processing book update for asset: {}", book.asset_id);
|
||||
// This is a demo: apply snapshot levels as deltas.
|
||||
for level in &book.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: book.asset_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
for level in &book.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: book.asset_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
},
|
||||
StreamMessage::Trade { data } => {
|
||||
StreamMessage::Trade(trade) => {
|
||||
info!(
|
||||
" Processing trade: {} {} @ {}",
|
||||
data.side.as_str(),
|
||||
data.size,
|
||||
data.price
|
||||
trade.side.as_str(),
|
||||
trade.size,
|
||||
trade.price
|
||||
);
|
||||
if let Err(e) = self.fill_processor.process_fill(data.clone()) {
|
||||
error!(" Failed to process fill: {}", e);
|
||||
self.stats.errors += 1;
|
||||
}
|
||||
},
|
||||
StreamMessage::Heartbeat { timestamp } => {
|
||||
debug!(" Received heartbeat at: {}", timestamp);
|
||||
},
|
||||
_ => {
|
||||
info!(" Unhandled message type");
|
||||
@@ -711,7 +723,7 @@ impl PolyfillDemo {
|
||||
|
||||
/// Run all demos
|
||||
pub async fn run_all_demos(&mut self) -> Result<()> {
|
||||
info!("Starting comprehensive polyfill-rs demo...");
|
||||
info!("Starting polyfill-rs demo...");
|
||||
|
||||
// Run all demo sections
|
||||
self.demo_basic_api_operations().await?;
|
||||
@@ -740,7 +752,7 @@ impl PolyfillDemo {
|
||||
|
||||
self.demo_performance_analytics().await?;
|
||||
|
||||
info!("Comprehensive demo completed successfully!");
|
||||
info!("Demo completed successfully!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -750,7 +762,7 @@ async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
info!("Polyfill-rs Comprehensive Demo");
|
||||
info!("Polyfill-rs Demo");
|
||||
info!("==============================");
|
||||
|
||||
// Create and run demo
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Compare HTTP transport configurations against the Polymarket CLOB endpoint.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo run --release --example http_transport_matrix`
|
||||
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONNECTION, CONTENT_TYPE, USER_AGENT};
|
||||
use reqwest::{Client, ClientBuilder};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const INITIAL_CURSOR: &str = "MA==";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Variant {
|
||||
name: &'static str,
|
||||
build: fn() -> Result<Client, reqwest::Error>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Stats {
|
||||
mean_ms: f64,
|
||||
sd_ms: f64,
|
||||
p50_ms: f64,
|
||||
p95_ms: f64,
|
||||
p99_ms: f64,
|
||||
}
|
||||
|
||||
fn official_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("rs_clob_client"));
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn polyfill_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(concat!("polyfill-rs/", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
headers.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn polyfill_headers_no_connection() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_static(concat!("polyfill-rs/", env!("CARGO_PKG_VERSION"))),
|
||||
);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("*/*"));
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
headers
|
||||
}
|
||||
|
||||
fn polyfill_builder() -> ClientBuilder {
|
||||
Client::builder()
|
||||
.no_proxy()
|
||||
.http2_adaptive_window(true)
|
||||
.http2_initial_stream_window_size(512 * 1024)
|
||||
.tcp_nodelay(true)
|
||||
.pool_max_idle_per_host(10)
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
}
|
||||
|
||||
fn light_polyfill_builder() -> ClientBuilder {
|
||||
Client::builder()
|
||||
.no_proxy()
|
||||
.tcp_nodelay(true)
|
||||
.pool_max_idle_per_host(10)
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
}
|
||||
|
||||
fn build_polyfill_current() -> Result<Client, reqwest::Error> {
|
||||
polyfill_builder().build()
|
||||
}
|
||||
|
||||
fn build_reqwest_default() -> Result<Client, reqwest::Error> {
|
||||
Client::builder().build()
|
||||
}
|
||||
|
||||
fn build_official_headers_default() -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.default_headers(official_headers())
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_polyfill_official_headers() -> Result<Client, reqwest::Error> {
|
||||
polyfill_builder()
|
||||
.default_headers(official_headers())
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_polyfill_headers() -> Result<Client, reqwest::Error> {
|
||||
polyfill_builder()
|
||||
.default_headers(polyfill_headers())
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_polyfill_headers_no_connection() -> Result<Client, reqwest::Error> {
|
||||
polyfill_builder()
|
||||
.default_headers(polyfill_headers_no_connection())
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_default_polyfill_headers() -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.default_headers(polyfill_headers())
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_polyfill_light() -> Result<Client, reqwest::Error> {
|
||||
light_polyfill_builder().build()
|
||||
}
|
||||
|
||||
fn build_http1_official_headers() -> Result<Client, reqwest::Error> {
|
||||
Client::builder()
|
||||
.http1_only()
|
||||
.default_headers(official_headers())
|
||||
.build()
|
||||
}
|
||||
|
||||
async fn fetch_once(client: &Client, url: &str) -> Result<(Duration, usize), reqwest::Error> {
|
||||
let start = Instant::now();
|
||||
let bytes = client.get(url).send().await?.bytes().await?;
|
||||
Ok((start.elapsed(), bytes.len()))
|
||||
}
|
||||
|
||||
fn percentile(sorted_ms: &[f64], percentile: f64) -> f64 {
|
||||
if sorted_ms.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let idx = ((sorted_ms.len() - 1) as f64 * percentile).round() as usize;
|
||||
sorted_ms[idx.min(sorted_ms.len() - 1)]
|
||||
}
|
||||
|
||||
fn calc_stats(samples: &[Duration]) -> Stats {
|
||||
let values: Vec<f64> = samples
|
||||
.iter()
|
||||
.map(|duration| duration.as_micros() as f64 / 1000.0)
|
||||
.collect();
|
||||
let mean_ms = values.iter().sum::<f64>() / values.len() as f64;
|
||||
let variance = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let delta = value - mean_ms;
|
||||
delta * delta
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ values.len() as f64;
|
||||
let mut sorted = values;
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
|
||||
Stats {
|
||||
mean_ms,
|
||||
sd_ms: variance.sqrt(),
|
||||
p50_ms: percentile(&sorted, 0.50),
|
||||
p95_ms: percentile(&sorted, 0.95),
|
||||
p99_ms: percentile(&sorted, 0.99),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let host = std::env::var("POLYMARKET_BENCH_HOST")
|
||||
.unwrap_or_else(|_| "https://clob.polymarket.com".to_string());
|
||||
let url = format!("{host}/simplified-markets?next_cursor={INITIAL_CURSOR}");
|
||||
let iterations = env_usize("POLYMARKET_HTTP_MATRIX_ITERATIONS", 12);
|
||||
let warmups = env_usize("POLYMARKET_HTTP_MATRIX_WARMUPS", 2);
|
||||
let delay = Duration::from_millis(env_u64("POLYMARKET_HTTP_MATRIX_DELAY_MS", 100));
|
||||
|
||||
let mut variants = vec![
|
||||
Variant {
|
||||
name: "polyfill-current",
|
||||
build: build_polyfill_current,
|
||||
},
|
||||
Variant {
|
||||
name: "reqwest-default",
|
||||
build: build_reqwest_default,
|
||||
},
|
||||
Variant {
|
||||
name: "official-headers-default",
|
||||
build: build_official_headers_default,
|
||||
},
|
||||
Variant {
|
||||
name: "polyfill-current-official-headers",
|
||||
build: build_polyfill_official_headers,
|
||||
},
|
||||
Variant {
|
||||
name: "polyfill-current-polyfill-headers",
|
||||
build: build_polyfill_headers,
|
||||
},
|
||||
Variant {
|
||||
name: "polyfill-current-polyfill-headers-no-connection",
|
||||
build: build_polyfill_headers_no_connection,
|
||||
},
|
||||
Variant {
|
||||
name: "reqwest-default-polyfill-headers",
|
||||
build: build_default_polyfill_headers,
|
||||
},
|
||||
Variant {
|
||||
name: "polyfill-light-no-h2-window-tuning",
|
||||
build: build_polyfill_light,
|
||||
},
|
||||
Variant {
|
||||
name: "http1-official-headers",
|
||||
build: build_http1_official_headers,
|
||||
},
|
||||
];
|
||||
if let Ok(filter) = std::env::var("POLYMARKET_HTTP_MATRIX_FILTER") {
|
||||
let filters: Vec<_> = filter
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
variants.retain(|variant| filters.iter().any(|filter| variant.name.contains(filter)));
|
||||
}
|
||||
|
||||
println!("HTTP transport matrix");
|
||||
println!("Endpoint: {url}");
|
||||
println!("Iterations: {iterations}");
|
||||
println!("Warmups: {warmups}");
|
||||
println!("Delay: {} ms", delay.as_millis());
|
||||
println!();
|
||||
|
||||
println!("Cold client construction + first byte fetch");
|
||||
println!("------------------------------------------------------------");
|
||||
for variant in &variants {
|
||||
let start = Instant::now();
|
||||
let client = (variant.build)()?;
|
||||
let (fetch_elapsed, bytes) = fetch_once(&client, &url).await?;
|
||||
let total = start.elapsed();
|
||||
println!(
|
||||
"{:<38} total {:>7.1} ms | fetch {:>7.1} ms | {bytes} bytes",
|
||||
variant.name,
|
||||
total.as_micros() as f64 / 1000.0,
|
||||
fetch_elapsed.as_micros() as f64 / 1000.0
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
println!();
|
||||
|
||||
let clients: Vec<_> = variants
|
||||
.into_iter()
|
||||
.map(|variant| Ok((variant.name, (variant.build)()?)))
|
||||
.collect::<Result<Vec<_>, reqwest::Error>>()?;
|
||||
|
||||
for _ in 0..warmups {
|
||||
for (_, client) in &clients {
|
||||
let _ = fetch_once(client, &url).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
let mut samples = vec![Vec::with_capacity(iterations); clients.len()];
|
||||
|
||||
for iteration in 0..iterations {
|
||||
for offset in 0..clients.len() {
|
||||
let idx = (iteration + offset) % clients.len();
|
||||
let (_, client) = &clients[idx];
|
||||
let (elapsed, bytes) = fetch_once(client, &url).await?;
|
||||
if bytes == 0 {
|
||||
eprintln!("empty response for {}", clients[idx].0);
|
||||
}
|
||||
samples[idx].push(elapsed);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Warm steady-state byte fetch");
|
||||
println!("------------------------------------------------------------");
|
||||
for ((name, _), sample) in clients.iter().zip(samples.iter()) {
|
||||
let stats = calc_stats(sample);
|
||||
println!(
|
||||
"{name:<38} mean {:>7.1} +/- {:>5.1} ms | p50/p95/p99 {:>7.1} / {:>7.1} / {:>7.1} ms",
|
||||
stats.mean_ms, stats.sd_ms, stats.p50_ms, stats.p95_ms, stats.p99_ms
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! Side-by-side benchmark comparing polyfill-rs against Polymarket's
|
||||
//! `rs-clob-client-v2`.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo run --release --example official_client_side_by_side_benchmark --features official-client-benchmark`
|
||||
//!
|
||||
//! Optional env vars:
|
||||
//! - `POLYMARKET_BENCH_HOST` defaults to `https://clob.polymarket.com`
|
||||
//! - `POLYMARKET_BENCH_ITERATIONS` defaults to `20`
|
||||
//! - `POLYMARKET_BENCH_WARMUPS` defaults to `3`
|
||||
//! - `POLYMARKET_BENCH_PARSE_ITERATIONS` defaults to `200`
|
||||
//! - `POLYMARKET_BENCH_DELAY_MS` defaults to `100`
|
||||
//! - `POLYMARKET_BENCH_KEEPALIVE` defaults to `true`
|
||||
|
||||
use std::hint::black_box;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use polyfill_rs::ClobClient;
|
||||
use polymarket_client_sdk_v2::clob::types::response::{
|
||||
Page as OfficialPage, SimplifiedMarketResponse as OfficialSimplifiedMarketResponse,
|
||||
};
|
||||
use polymarket_client_sdk_v2::clob::{Client as OfficialClient, Config as OfficialConfig};
|
||||
|
||||
type BenchResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
const INITIAL_CURSOR: &str = "MA==";
|
||||
const OFFICIAL_SDK_REV: &str = "8ba5008733c3c03e92041eef8b1cb8495dbed718";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Sample {
|
||||
elapsed: Duration,
|
||||
count: usize,
|
||||
}
|
||||
|
||||
struct RawHttpVariant {
|
||||
name: &'static str,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[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_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 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
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<Stats> {
|
||||
if times.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut values: Vec<f64> = 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::<f64>() / values.len() as f64;
|
||||
let variance = values
|
||||
.iter()
|
||||
.map(|value| (value - mean_ms).powi(2))
|
||||
.sum::<f64>()
|
||||
/ 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, stats: Option<Stats>, successes: usize, attempts: usize) {
|
||||
println!("{name}:");
|
||||
match stats {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_single(name: &str, sample: &Result<Sample, String>) {
|
||||
match sample {
|
||||
Ok(sample) => println!(
|
||||
"{name}: {:.1} ms ({} items)",
|
||||
sample.elapsed.as_micros() as f64 / 1000.0,
|
||||
sample.count
|
||||
),
|
||||
Err(error) => println!("{name}: ERROR - {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn time_polyfill_typed(client: &ClobClient) -> Result<Sample, String> {
|
||||
let start = Instant::now();
|
||||
let page = client
|
||||
.get_simplified_markets(Some(INITIAL_CURSOR))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Sample {
|
||||
elapsed: start.elapsed(),
|
||||
count: page.data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn time_official_typed(client: &OfficialClient) -> Result<Sample, String> {
|
||||
let start = Instant::now();
|
||||
let page = client
|
||||
.simplified_markets(Some(INITIAL_CURSOR.to_string()))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Sample {
|
||||
elapsed: start.elapsed(),
|
||||
count: page.data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn time_polyfill_cold(host: &str) -> Result<Sample, String> {
|
||||
let start = Instant::now();
|
||||
let client = ClobClient::new(host);
|
||||
let page = client
|
||||
.get_simplified_markets(Some(INITIAL_CURSOR))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Sample {
|
||||
elapsed: start.elapsed(),
|
||||
count: page.data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn time_official_cold(host: &str) -> Result<Sample, String> {
|
||||
let start = Instant::now();
|
||||
let client =
|
||||
OfficialClient::new(host, OfficialConfig::default()).map_err(|error| error.to_string())?;
|
||||
let page = client
|
||||
.simplified_markets(Some(INITIAL_CURSOR.to_string()))
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Sample {
|
||||
elapsed: start.elapsed(),
|
||||
count: page.data.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn time_reqwest_raw(client: &reqwest::Client, url: &str) -> Result<Sample, String> {
|
||||
let start = Instant::now();
|
||||
let bytes = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(Sample {
|
||||
elapsed: start.elapsed(),
|
||||
count: bytes.len(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_typed_pairs(
|
||||
polyfill_client: &ClobClient,
|
||||
official_client: &OfficialClient,
|
||||
iterations: usize,
|
||||
delay: Duration,
|
||||
) -> (Vec<Duration>, Vec<Duration>) {
|
||||
let mut polyfill_times = Vec::with_capacity(iterations);
|
||||
let mut official_times = Vec::with_capacity(iterations);
|
||||
|
||||
for i in 1..=iterations {
|
||||
let polyfill_first = i % 2 == 1;
|
||||
let (polyfill_result, official_result) = if polyfill_first {
|
||||
let polyfill_result = time_polyfill_typed(polyfill_client).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
let official_result = time_official_typed(official_client).await;
|
||||
(polyfill_result, official_result)
|
||||
} else {
|
||||
let official_result = time_official_typed(official_client).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
let polyfill_result = time_polyfill_typed(polyfill_client).await;
|
||||
(polyfill_result, official_result)
|
||||
};
|
||||
|
||||
if let Ok(sample) = polyfill_result {
|
||||
polyfill_times.push(sample.elapsed);
|
||||
}
|
||||
if let Ok(sample) = official_result {
|
||||
official_times.push(sample.elapsed);
|
||||
}
|
||||
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
(polyfill_times, official_times)
|
||||
}
|
||||
|
||||
async fn run_raw_warmups(variants: &[RawHttpVariant], url: &str, delay: Duration) {
|
||||
for variant in variants {
|
||||
let _ = time_reqwest_raw(&variant.client, url).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_raw_matrix(
|
||||
variants: &[RawHttpVariant],
|
||||
url: &str,
|
||||
iterations: usize,
|
||||
delay: Duration,
|
||||
) -> Vec<Vec<Duration>> {
|
||||
let mut samples = vec![Vec::with_capacity(iterations); variants.len()];
|
||||
|
||||
for iteration in 0..iterations {
|
||||
for offset in 0..variants.len() {
|
||||
let idx = (iteration + offset) % variants.len();
|
||||
if let Ok(sample) = time_reqwest_raw(&variants[idx].client, url).await {
|
||||
samples[idx].push(sample.elapsed);
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
samples
|
||||
}
|
||||
|
||||
async fn run_typed_warmups(
|
||||
polyfill_client: &ClobClient,
|
||||
official_client: &OfficialClient,
|
||||
warmups: usize,
|
||||
delay: Duration,
|
||||
) {
|
||||
for _ in 0..warmups {
|
||||
let _ = time_polyfill_typed(polyfill_client).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = time_official_typed(official_client).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn official_headers() -> reqwest::header::HeaderMap {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static("rs_clob_client"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("*/*"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONNECTION,
|
||||
reqwest::header::HeaderValue::from_static("keep-alive"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
fn polyfill_headers() -> reqwest::header::HeaderMap {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static(concat!(
|
||||
"polyfill-rs/",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::ACCEPT,
|
||||
reqwest::header::HeaderValue::from_static("*/*"),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/json"),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
fn polyfill_tuned_builder() -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.http2_adaptive_window(true)
|
||||
.http2_initial_stream_window_size(512 * 1024)
|
||||
.tcp_nodelay(true)
|
||||
.pool_max_idle_per_host(10)
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
}
|
||||
|
||||
fn polyfill_light_builder() -> reqwest::ClientBuilder {
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.tcp_nodelay(true)
|
||||
.pool_max_idle_per_host(10)
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
}
|
||||
|
||||
fn build_raw_http_variants(
|
||||
polyfill_client: &ClobClient,
|
||||
) -> Result<Vec<RawHttpVariant>, reqwest::Error> {
|
||||
Ok(vec![
|
||||
RawHttpVariant {
|
||||
name: "polyfill-rs actual HTTP",
|
||||
client: polyfill_client.http_client.clone(),
|
||||
},
|
||||
RawHttpVariant {
|
||||
name: "reqwest default",
|
||||
client: reqwest::Client::builder().build()?,
|
||||
},
|
||||
RawHttpVariant {
|
||||
name: "rs-clob-client-v2 headers",
|
||||
client: reqwest::Client::builder()
|
||||
.default_headers(official_headers())
|
||||
.build()?,
|
||||
},
|
||||
RawHttpVariant {
|
||||
name: "polyfill tuned + official headers",
|
||||
client: polyfill_tuned_builder()
|
||||
.default_headers(official_headers())
|
||||
.build()?,
|
||||
},
|
||||
RawHttpVariant {
|
||||
name: "polyfill tuned + polyfill headers",
|
||||
client: polyfill_tuned_builder()
|
||||
.default_headers(polyfill_headers())
|
||||
.build()?,
|
||||
},
|
||||
RawHttpVariant {
|
||||
name: "polyfill light no h2 tuning",
|
||||
client: polyfill_light_builder()
|
||||
.default_headers(polyfill_headers())
|
||||
.build()?,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn parse_polyfill_once(bytes: &[u8]) -> BenchResult<Duration> {
|
||||
let start = Instant::now();
|
||||
let page: polyfill_rs::types::SimplifiedMarketsResponse =
|
||||
polyfill_rs::decode::fast_parse::parse_json_fast_owned(bytes)?;
|
||||
black_box(page);
|
||||
Ok(start.elapsed())
|
||||
}
|
||||
|
||||
fn parse_official_direct_once(bytes: &[u8]) -> BenchResult<Duration> {
|
||||
let start = Instant::now();
|
||||
let page: OfficialPage<OfficialSimplifiedMarketResponse> = serde_json::from_slice(bytes)?;
|
||||
black_box(page);
|
||||
Ok(start.elapsed())
|
||||
}
|
||||
|
||||
fn parse_official_helper_once(bytes: &[u8]) -> BenchResult<Duration> {
|
||||
let start = Instant::now();
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
let page: Option<OfficialPage<OfficialSimplifiedMarketResponse>> =
|
||||
serde_json::from_value(value)?;
|
||||
black_box(page);
|
||||
Ok(start.elapsed())
|
||||
}
|
||||
|
||||
fn run_parse_samples<F>(
|
||||
bytes: &[u8],
|
||||
iterations: usize,
|
||||
mut parse_once: F,
|
||||
) -> BenchResult<Vec<Duration>>
|
||||
where
|
||||
F: FnMut(&[u8]) -> BenchResult<Duration>,
|
||||
{
|
||||
let mut times = Vec::with_capacity(iterations);
|
||||
for _ in 0..iterations {
|
||||
times.push(parse_once(bytes)?);
|
||||
}
|
||||
Ok(times)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let host = std::env::var("POLYMARKET_BENCH_HOST")
|
||||
.unwrap_or_else(|_| "https://clob.polymarket.com".to_string());
|
||||
let url = format!("{host}/simplified-markets?next_cursor={INITIAL_CURSOR}");
|
||||
let iterations = env_usize("POLYMARKET_BENCH_ITERATIONS", 20);
|
||||
let warmups = env_usize("POLYMARKET_BENCH_WARMUPS", 3);
|
||||
let parse_iterations = env_usize("POLYMARKET_BENCH_PARSE_ITERATIONS", 200);
|
||||
let delay = Duration::from_millis(env_u64("POLYMARKET_BENCH_DELAY_MS", 100));
|
||||
let keepalive = env_bool("POLYMARKET_BENCH_KEEPALIVE", true);
|
||||
|
||||
println!("=======================================================");
|
||||
println!(" polyfill-rs vs rs-clob-client-v2 benchmark");
|
||||
println!("=======================================================");
|
||||
println!("Endpoint: {url}");
|
||||
println!("Iterations: {iterations}");
|
||||
println!("Warmups: {warmups}");
|
||||
println!("Parse iterations: {parse_iterations}");
|
||||
println!("Delay: {} ms", delay.as_millis());
|
||||
println!("polyfill-rs background keepalive: {keepalive}");
|
||||
println!("rs-clob-client-v2 rev: {OFFICIAL_SDK_REV}");
|
||||
println!();
|
||||
|
||||
println!("Cold start: client construction + first typed request");
|
||||
println!("-------------------------------------------------------");
|
||||
let polyfill_cold = time_polyfill_cold(&host).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
let official_cold = time_official_cold(&host).await;
|
||||
print_single("polyfill-rs", &polyfill_cold);
|
||||
print_single("rs-clob-client-v2", &official_cold);
|
||||
println!();
|
||||
|
||||
let polyfill_client = ClobClient::new(&host);
|
||||
let official_client = OfficialClient::new(&host, OfficialConfig::default())?;
|
||||
let raw_http_variants = build_raw_http_variants(&polyfill_client)?;
|
||||
|
||||
if keepalive {
|
||||
polyfill_client
|
||||
.start_keepalive(Duration::from_secs(30))
|
||||
.await;
|
||||
}
|
||||
|
||||
run_typed_warmups(&polyfill_client, &official_client, warmups, delay).await;
|
||||
|
||||
println!("Warm connection: first typed request after warmup");
|
||||
println!("-------------------------------------------------------");
|
||||
let polyfill_warm = time_polyfill_typed(&polyfill_client).await;
|
||||
tokio::time::sleep(delay).await;
|
||||
let official_warm = time_official_typed(&official_client).await;
|
||||
print_single("polyfill-rs", &polyfill_warm);
|
||||
print_single("rs-clob-client-v2", &official_warm);
|
||||
println!();
|
||||
|
||||
println!("Steady state: typed client total time");
|
||||
println!("-------------------------------------------------------");
|
||||
let (polyfill_typed_times, official_typed_times) =
|
||||
run_typed_pairs(&polyfill_client, &official_client, iterations, delay).await;
|
||||
print_stats(
|
||||
"polyfill-rs",
|
||||
calc_stats(&polyfill_typed_times),
|
||||
polyfill_typed_times.len(),
|
||||
iterations,
|
||||
);
|
||||
println!();
|
||||
print_stats(
|
||||
"rs-clob-client-v2",
|
||||
calc_stats(&official_typed_times),
|
||||
official_typed_times.len(),
|
||||
iterations,
|
||||
);
|
||||
println!();
|
||||
|
||||
println!("Steady state: network-only byte fetch transport matrix");
|
||||
println!("-------------------------------------------------------");
|
||||
run_raw_warmups(&raw_http_variants, &url, delay).await;
|
||||
let raw_samples = run_raw_matrix(&raw_http_variants, &url, iterations, delay).await;
|
||||
for (variant, samples) in raw_http_variants.iter().zip(raw_samples.iter()) {
|
||||
print_stats(variant.name, calc_stats(samples), samples.len(), iterations);
|
||||
println!();
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("CPU-only parse from cached payload");
|
||||
println!("-------------------------------------------------------");
|
||||
let payload = polyfill_client
|
||||
.http_client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await?
|
||||
.bytes()
|
||||
.await?
|
||||
.to_vec();
|
||||
println!("payload: {} bytes", payload.len());
|
||||
let polyfill_parse_times = run_parse_samples(&payload, parse_iterations, parse_polyfill_once)?;
|
||||
let official_direct_parse_times =
|
||||
run_parse_samples(&payload, parse_iterations, parse_official_direct_once)?;
|
||||
let official_helper_parse_times =
|
||||
run_parse_samples(&payload, parse_iterations, parse_official_helper_once)?;
|
||||
print_stats(
|
||||
"polyfill-rs typed parse",
|
||||
calc_stats(&polyfill_parse_times),
|
||||
polyfill_parse_times.len(),
|
||||
parse_iterations,
|
||||
);
|
||||
println!();
|
||||
print_stats(
|
||||
"rs-clob-client-v2 direct typed parse",
|
||||
calc_stats(&official_direct_parse_times),
|
||||
official_direct_parse_times.len(),
|
||||
parse_iterations,
|
||||
);
|
||||
println!();
|
||||
print_stats(
|
||||
"rs-clob-client-v2 request-helper parse",
|
||||
calc_stats(&official_helper_parse_times),
|
||||
official_helper_parse_times.len(),
|
||||
parse_iterations,
|
||||
);
|
||||
|
||||
if keepalive {
|
||||
polyfill_client.stop_keepalive().await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -128,7 +128,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Create client with API credentials only (no private key needed for custodial trading)
|
||||
let mut client = ClobClient::new("https://clob.polymarket.com");
|
||||
client.set_api_creds(api_creds);
|
||||
client.set_api_creds(api_creds)?;
|
||||
|
||||
println!("✅ Client configured for custodial API trading");
|
||||
|
||||
@@ -150,24 +150,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
// Just verify we got data
|
||||
if json["data"].as_array().is_some() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::other(
|
||||
"Invalid response",
|
||||
)) as Box<dyn std::error::Error>)
|
||||
Err(Box::new(std::io::Error::other("Invalid response")) as Box<dyn std::error::Error>)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -187,24 +181,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
let json: serde_json::Value = response.json().await.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
// Just verify we got data
|
||||
if json["data"].as_array().is_some() {
|
||||
Ok(json)
|
||||
} else {
|
||||
Err(Box::new(std::io::Error::other(
|
||||
"Invalid response",
|
||||
)) as Box<dyn std::error::Error>)
|
||||
Err(Box::new(std::io::Error::other("Invalid response")) as Box<dyn std::error::Error>)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
@@ -224,15 +212,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
let json1: serde_json::Value = response1.json().await.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
let response2 = client
|
||||
@@ -244,15 +228,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
let json2: serde_json::Value = response2.json().await.map_err(|e| {
|
||||
Box::new(std::io::Error::other(
|
||||
e.to_string(),
|
||||
)) as Box<dyn std::error::Error>
|
||||
Box::new(std::io::Error::other(e.to_string())) as Box<dyn std::error::Error>
|
||||
})?;
|
||||
|
||||
// Count markets
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Side-by-side benchmark comparing polyfill-rs vs polymarket-rs-client
|
||||
//! Side-by-side benchmark comparing polyfill-rs vs a plain reqwest baseline.
|
||||
//!
|
||||
//! To run this benchmark, uncomment the polymarket-rs-client dependency in Cargo.toml:
|
||||
//! ```toml
|
||||
//! [dev-dependencies]
|
||||
//! polymarket-rs-client = { path = "external/polymarket-rs-client" }
|
||||
//! ```
|
||||
//! Run with:
|
||||
//! `cargo run --example side_by_side_benchmark --features side-by-side-benchmark`
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -21,33 +18,42 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(" - 20 iterations each");
|
||||
println!(" - 100ms delay between requests\n");
|
||||
|
||||
// Test 1: polymarket-rs-client
|
||||
// Test 1: baseline reqwest
|
||||
println!("══════════════════════════════════════");
|
||||
println!("Test 1: polymarket-rs-client");
|
||||
println!("Test 1: baseline reqwest");
|
||||
println!("══════════════════════════════════════");
|
||||
|
||||
let their_client = polymarket_rs_client::ClobClient::new("https://clob.polymarket.com");
|
||||
let baseline_http = reqwest::Client::new();
|
||||
|
||||
let mut their_times = Vec::new();
|
||||
for i in 1..=20 {
|
||||
let start = Instant::now();
|
||||
match their_client.get_simplified_markets(Some("MA==")).await {
|
||||
Ok(_markets) => {
|
||||
let elapsed = start.elapsed();
|
||||
their_times.push(elapsed);
|
||||
match baseline_http
|
||||
.get("https://clob.polymarket.com/simplified-markets?next_cursor=MA==")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => match response.json::<serde_json::Value>().await {
|
||||
Ok(_json) => {
|
||||
let elapsed = start.elapsed();
|
||||
their_times.push(elapsed);
|
||||
|
||||
if i <= 3 || i > 17 {
|
||||
println!(
|
||||
" Request {:2}: {:.1} ms",
|
||||
i,
|
||||
elapsed.as_micros() as f64 / 1000.0
|
||||
);
|
||||
} else if i == 4 {
|
||||
println!(" ...");
|
||||
}
|
||||
if i <= 3 || i > 17 {
|
||||
println!(
|
||||
" Request {:2}: {:.1} ms",
|
||||
i,
|
||||
elapsed.as_micros() as f64 / 1000.0
|
||||
);
|
||||
} else if i == 4 {
|
||||
println!(" ...");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!(" Request {:2}: PARSE ERROR - {}", i, e);
|
||||
},
|
||||
},
|
||||
Err(e) => {
|
||||
println!(" Request {:2}: ERROR - {}", i, e);
|
||||
println!(" Request {:2}: NETWORK ERROR - {}", i, e);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -139,13 +145,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(" HONEST RESULTS ");
|
||||
println!("═══════════════════════════════════════════════════════\n");
|
||||
|
||||
println!("polymarket-rs-client:");
|
||||
println!("baseline reqwest:");
|
||||
println!(" Mean: {:.1} ms ± {:.1} ms", their_mean, their_std);
|
||||
println!(" Median: {:.1} ms", their_median);
|
||||
println!(" Range: {:.1} - {:.1} ms", their_min, their_max);
|
||||
println!(" Variance: {:.1}%", (their_std / their_mean) * 100.0);
|
||||
println!(" Success: {}/20 requests", their_times.len());
|
||||
println!("\n (They claimed in README: 404.5 ms ± 22.9 ms)");
|
||||
|
||||
println!("\n");
|
||||
println!("polyfill-rs (with keep-alive):");
|
||||
@@ -172,7 +177,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"❌ polymarket-rs-client is {:.1}% faster ({:.1} ms faster)",
|
||||
"❌ baseline reqwest is {:.1}% faster ({:.1} ms faster)",
|
||||
pct, diff
|
||||
);
|
||||
}
|
||||
@@ -184,7 +189,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("\n\nVariance Analysis:");
|
||||
println!("────────────────────────────────────────────────────");
|
||||
println!(
|
||||
" polymarket-rs-client: ±{:.1} ms ({:.1}% variance)",
|
||||
" baseline reqwest: ±{:.1} ms ({:.1}% variance)",
|
||||
their_std,
|
||||
(their_std / their_mean) * 100.0
|
||||
);
|
||||
@@ -200,42 +205,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!(" ✅ polyfill-rs is {:.1}% more consistent", improvement);
|
||||
} else {
|
||||
let diff = ((our_std - their_std) / their_std) * 100.0;
|
||||
println!(" ⚠️ polymarket-rs-client is {:.1}% more consistent", diff);
|
||||
println!(" ⚠️ baseline reqwest is {:.1}% more consistent", diff);
|
||||
}
|
||||
|
||||
// Claims validation
|
||||
println!("\n\nClaims Validation:");
|
||||
println!("────────────────────────────────────────────────────");
|
||||
|
||||
let their_claimed_mean = 404.5;
|
||||
let their_claimed_std = 22.9;
|
||||
let our_claimed_mean = 368.6;
|
||||
let our_claimed_std = 67.1;
|
||||
|
||||
let their_mean_diff = ((their_mean - their_claimed_mean).abs() / their_claimed_mean) * 100.0;
|
||||
let their_std_diff = ((their_std - their_claimed_std).abs() / their_claimed_std) * 100.0;
|
||||
let our_mean_diff = ((our_mean - our_claimed_mean).abs() / our_claimed_mean) * 100.0;
|
||||
let our_std_diff = ((our_std - our_claimed_std).abs() / our_claimed_std) * 100.0;
|
||||
|
||||
println!("polymarket-rs-client claimed vs actual:");
|
||||
println!(
|
||||
" Mean: {:.1} ms vs {:.1} ms ({:.1}% difference)",
|
||||
their_claimed_mean, their_mean, their_mean_diff
|
||||
);
|
||||
println!(
|
||||
" Variance: ±{:.1} ms vs ±{:.1} ms ({:.1}% difference)",
|
||||
their_claimed_std, their_std, their_std_diff
|
||||
);
|
||||
|
||||
println!("\npolyfill-rs claimed vs actual:");
|
||||
println!(
|
||||
" Mean: {:.1} ms vs {:.1} ms ({:.1}% difference)",
|
||||
our_claimed_mean, our_mean, our_mean_diff
|
||||
);
|
||||
println!(
|
||||
" Variance: ±{:.1} ms vs ±{:.1} ms ({:.1}% difference)",
|
||||
our_claimed_std, our_std, our_std_diff
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+77
-43
@@ -101,43 +101,82 @@ impl SnipeStrategy {
|
||||
/// Process a market data update
|
||||
pub fn process_update(&mut self, message: StreamMessage) -> Result<()> {
|
||||
match message {
|
||||
StreamMessage::BookUpdate { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_book_update(data)?;
|
||||
}
|
||||
StreamMessage::Book(book) if book.asset_id == self.token_id => {
|
||||
self.process_book_update(book)?;
|
||||
},
|
||||
StreamMessage::Trade { data } => {
|
||||
if data.token_id == self.token_id {
|
||||
self.process_trade(data)?;
|
||||
}
|
||||
},
|
||||
StreamMessage::Heartbeat { timestamp: _ } => {
|
||||
self.check_stale_quotes()?;
|
||||
StreamMessage::Trade(trade) if trade.asset_id == self.token_id => {
|
||||
self.process_trade(trade)?;
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
|
||||
// Opportunistically check for staleness on any incoming update.
|
||||
self.check_stale_quotes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Process order book update
|
||||
fn process_book_update(&mut self, delta: OrderDelta) -> Result<()> {
|
||||
fn process_book_update(&mut self, book: BookUpdate) -> Result<()> {
|
||||
// Ensure book exists
|
||||
self.book_manager.get_or_create_book(&self.token_id)?;
|
||||
|
||||
// Update local order book
|
||||
self.book_manager.apply_delta(delta.clone())?;
|
||||
// Clear the existing book and rebuild from the snapshot.
|
||||
if let Ok(current) = self.book_manager.get_book(&self.token_id) {
|
||||
for level in ¤t.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: Decimal::ZERO,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Get current book state
|
||||
let book = self.book_manager.get_book(&self.token_id)?;
|
||||
for level in ¤t.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: Decimal::ZERO,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update best prices
|
||||
if let Some(best_bid) = book.bids.first() {
|
||||
self.last_best_bid = Some(best_bid.price);
|
||||
let ts = chrono::DateTime::from_timestamp(
|
||||
(book.timestamp / 1000) as i64,
|
||||
((book.timestamp % 1000) * 1_000_000) as u32,
|
||||
)
|
||||
.unwrap_or_else(chrono::Utc::now);
|
||||
|
||||
for level in &book.bids {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: ts,
|
||||
side: Side::BUY,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
if let Some(best_ask) = book.asks.first() {
|
||||
self.last_best_ask = Some(best_ask.price);
|
||||
|
||||
for level in &book.asks {
|
||||
let _ = self.book_manager.apply_delta(OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: ts,
|
||||
side: Side::SELL,
|
||||
price: level.price,
|
||||
size: level.size,
|
||||
sequence: book.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Update best prices directly from the snapshot
|
||||
self.last_best_bid = book.bids.first().map(|l| l.price);
|
||||
self.last_best_ask = book.asks.first().map(|l| l.price);
|
||||
|
||||
self.last_update = time::now_secs();
|
||||
|
||||
// Check for trading opportunities
|
||||
@@ -147,17 +186,17 @@ impl SnipeStrategy {
|
||||
}
|
||||
|
||||
/// Process trade update
|
||||
fn process_trade(&mut self, fill: FillEvent) -> Result<()> {
|
||||
fn process_trade(&mut self, trade: TradeMessage) -> Result<()> {
|
||||
info!(
|
||||
"Trade: {} {} @ {} (size: {})",
|
||||
fill.side.as_str(),
|
||||
fill.token_id,
|
||||
fill.price,
|
||||
fill.size
|
||||
trade.side.as_str(),
|
||||
trade.asset_id,
|
||||
trade.price,
|
||||
trade.size
|
||||
);
|
||||
|
||||
// Update statistics
|
||||
self.stats.total_volume += fill.size;
|
||||
self.stats.total_volume += trade.size;
|
||||
|
||||
// Calculate P&L if this was our trade
|
||||
// (In a real implementation, you'd track your own orders)
|
||||
@@ -327,24 +366,19 @@ impl MockMarketData {
|
||||
let price_change = random_factor * Decimal::from(2) * self.volatility;
|
||||
let new_price = self.base_price * (Decimal::from(1) + price_change);
|
||||
|
||||
// Generate order book update
|
||||
let side = if rand::random::<bool>() {
|
||||
Side::BUY
|
||||
} else {
|
||||
Side::SELL
|
||||
};
|
||||
// Generate a simple orderbook snapshot update
|
||||
let size = Decimal::from(rand::random::<u64>() % 1000 + 100);
|
||||
let bid = new_price - dec!(0.01);
|
||||
let ask = new_price + dec!(0.01);
|
||||
|
||||
StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: self.token_id.clone(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side,
|
||||
price: new_price,
|
||||
size,
|
||||
sequence: self.sequence,
|
||||
},
|
||||
}
|
||||
StreamMessage::Book(BookUpdate {
|
||||
asset_id: self.token_id.clone(),
|
||||
market: "0xmock".to_string(),
|
||||
timestamp: time::now_millis(),
|
||||
bids: vec![OrderSummary { price: bid, size }],
|
||||
asks: vec![OrderSummary { price: ask, size }],
|
||||
hash: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-31
@@ -1,5 +1,8 @@
|
||||
# Rustfmt configuration for polyfill-rs
|
||||
# Optimized for readability and consistency
|
||||
#
|
||||
# Note: This file is kept compatible with stable `rustfmt` so that local dev and
|
||||
# CI (`cargo fmt --all -- --check`) behave consistently.
|
||||
|
||||
# Basic formatting
|
||||
edition = "2021"
|
||||
@@ -7,52 +10,21 @@ max_width = 100
|
||||
tab_spaces = 4
|
||||
newline_style = "Unix"
|
||||
|
||||
# Indentation
|
||||
indent_style = "Block"
|
||||
merge_derives = true
|
||||
use_small_heuristics = "Default"
|
||||
|
||||
# Spacing
|
||||
spaces_around_ranges = false
|
||||
binop_separator = "Front"
|
||||
remove_nested_parens = true
|
||||
format_code_in_doc_comments = true
|
||||
|
||||
# Imports
|
||||
imports_granularity = "Module"
|
||||
group_imports = "StdExternalCrate"
|
||||
reorder_imports = true
|
||||
|
||||
# Comments
|
||||
wrap_comments = true
|
||||
comment_width = 80
|
||||
|
||||
# Match
|
||||
match_arm_leading_commas = true
|
||||
match_block_trailing_comma = true
|
||||
|
||||
# Control flow
|
||||
control_brace_style = "AlwaysSameLine"
|
||||
|
||||
# Functions
|
||||
fn_call_width = 60
|
||||
fn_params_layout = "Tall"
|
||||
|
||||
# Structs and enums
|
||||
struct_field_align_threshold = 0
|
||||
enum_discrim_align_threshold = 0
|
||||
|
||||
# Arrays and tuples
|
||||
array_width = 60
|
||||
tuple_width = 60
|
||||
|
||||
# Chains
|
||||
chain_width = 60
|
||||
chain_split_single_child = true
|
||||
|
||||
# Other
|
||||
format_macro_matchers = true
|
||||
format_macro_bodies = true
|
||||
format_strings = true
|
||||
overflow_delimited_expr = true
|
||||
normalize_doc_attributes = true
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Runs the real-API integration tests for polyfill-rs.
|
||||
#
|
||||
# These tests hit the live Polymarket API and are `#[ignore]` by default.
|
||||
# You must provide credentials via environment variables or a local `.env` file.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
echo "Running ignored integration tests against the live Polymarket API..."
|
||||
|
||||
if [[ -z "${POLYMARKET_PRIVATE_KEY:-}" ]]; then
|
||||
if [[ -f .env ]] && grep -q '^POLYMARKET_PRIVATE_KEY=' .env; then
|
||||
echo "Using POLYMARKET_PRIVATE_KEY from .env"
|
||||
else
|
||||
echo "ERROR: POLYMARKET_PRIVATE_KEY is not set (env or .env)."
|
||||
echo "Set POLYMARKET_PRIVATE_KEY in your environment or add it to .env."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
ARGS=(--ignored --test-threads=1)
|
||||
if [[ "${NO_CAPTURE:-0}" == "1" ]]; then
|
||||
ARGS+=(--nocapture)
|
||||
fi
|
||||
|
||||
# Run serially to reduce the chance of hitting rate limits.
|
||||
cargo test --all-features --test integration_tests -- "${ARGS[@]}"
|
||||
cargo test --all-features --test ws_integration_tests -- "${ARGS[@]}"
|
||||
cargo test --all-features --test simple_auth_test -- "${ARGS[@]}"
|
||||
cargo test --all-features --test order_posting_test -- "${ARGS[@]}"
|
||||
+355
-46
@@ -5,15 +5,18 @@
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::ApiCredentials;
|
||||
use alloy_primitives::{hex::encode_prefixed, Address, U256};
|
||||
use alloy_primitives::{hex::encode_prefixed, Address, B256, U256};
|
||||
use alloy_signer::SignerSync;
|
||||
use alloy_signer_local::PrivateKeySigner;
|
||||
use alloy_sol_types::{eip712_domain, sol};
|
||||
use alloy_sol_types::{eip712_domain, sol, Eip712Domain, SolStruct};
|
||||
use base64::engine::Engine;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Serialize;
|
||||
use sha2::Sha256;
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// Header constants
|
||||
@@ -26,6 +29,72 @@ const POLY_PASS_HEADER: &str = "poly_passphrase";
|
||||
|
||||
type Headers = HashMap<&'static str, String>;
|
||||
|
||||
pub trait HmacApiCredentials {
|
||||
fn api_key(&self) -> &str;
|
||||
fn passphrase(&self) -> &str;
|
||||
fn decoded_secret_bytes(&self) -> Result<Cow<'_, [u8]>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreparedApiCredentials {
|
||||
credentials: ApiCredentials,
|
||||
decoded_secret: Arc<[u8]>,
|
||||
}
|
||||
|
||||
impl PreparedApiCredentials {
|
||||
pub fn try_new(credentials: ApiCredentials) -> Result<Self> {
|
||||
let decoded_secret = base64::engine::general_purpose::URL_SAFE
|
||||
.decode(&credentials.secret)
|
||||
.map(Into::into)
|
||||
.map_err(|e| PolyfillError::crypto(format!("Failed to decode base64 secret: {e}")))?;
|
||||
|
||||
Ok(Self {
|
||||
credentials,
|
||||
decoded_secret,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn credentials(&self) -> &ApiCredentials {
|
||||
&self.credentials
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for PreparedApiCredentials {
|
||||
type Target = ApiCredentials;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.credentials
|
||||
}
|
||||
}
|
||||
|
||||
impl HmacApiCredentials for ApiCredentials {
|
||||
fn api_key(&self) -> &str {
|
||||
&self.api_key
|
||||
}
|
||||
|
||||
fn passphrase(&self) -> &str {
|
||||
&self.passphrase
|
||||
}
|
||||
|
||||
fn decoded_secret_bytes(&self) -> Result<Cow<'_, [u8]>> {
|
||||
Ok(Cow::Owned(decode_secret_bytes(&self.secret)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl HmacApiCredentials for PreparedApiCredentials {
|
||||
fn api_key(&self) -> &str {
|
||||
&self.credentials.api_key
|
||||
}
|
||||
|
||||
fn passphrase(&self) -> &str {
|
||||
&self.credentials.passphrase
|
||||
}
|
||||
|
||||
fn decoded_secret_bytes(&self) -> Result<Cow<'_, [u8]>> {
|
||||
Ok(Cow::Borrowed(self.decoded_secret.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
// EIP-712 struct for CLOB authentication
|
||||
sol! {
|
||||
struct ClobAuth {
|
||||
@@ -42,15 +111,64 @@ sol! {
|
||||
uint256 salt;
|
||||
address maker;
|
||||
address signer;
|
||||
address taker;
|
||||
uint256 tokenId;
|
||||
uint256 makerAmount;
|
||||
uint256 takerAmount;
|
||||
uint256 expiration;
|
||||
uint256 nonce;
|
||||
uint256 feeRateBps;
|
||||
uint8 side;
|
||||
uint8 signatureType;
|
||||
uint256 timestamp;
|
||||
bytes32 metadata;
|
||||
bytes32 builder;
|
||||
}
|
||||
|
||||
struct TypedDataSign {
|
||||
Order contents;
|
||||
string name;
|
||||
string version;
|
||||
uint256 chainId;
|
||||
address verifyingContract;
|
||||
bytes32 salt;
|
||||
}
|
||||
}
|
||||
|
||||
const DEPOSIT_WALLET_NAME: &str = "DepositWallet";
|
||||
const DEPOSIT_WALLET_VERSION: &str = "1";
|
||||
const ERC7739_TYPED_DATA_SIGN_SALT: B256 = B256::ZERO;
|
||||
const ORDER_TYPE_STRING: &str = "Order(uint256 salt,address maker,address signer,uint256 tokenId,uint256 makerAmount,uint256 takerAmount,uint8 side,uint8 signatureType,uint256 timestamp,bytes32 metadata,bytes32 builder)";
|
||||
|
||||
/// V2 order signing payload. The REST body still carries `expiration`, but the EIP-712 payload
|
||||
/// follows the V2 exchange struct.
|
||||
#[derive(Clone)]
|
||||
pub struct SignedOrderMessage {
|
||||
pub salt: U256,
|
||||
pub maker: Address,
|
||||
pub signer: Address,
|
||||
pub token_id: U256,
|
||||
pub maker_amount: U256,
|
||||
pub taker_amount: U256,
|
||||
pub side: u8,
|
||||
pub signature_type: u8,
|
||||
pub timestamp: U256,
|
||||
pub metadata: B256,
|
||||
pub builder: B256,
|
||||
}
|
||||
|
||||
/// Prepared EIP-712 domain for signing orders against one exchange contract.
|
||||
#[derive(Clone)]
|
||||
pub struct PreparedOrderDomain {
|
||||
domain: Eip712Domain,
|
||||
}
|
||||
|
||||
impl PreparedOrderDomain {
|
||||
pub fn new(chain_id: u64, verifying_contract: Address) -> Self {
|
||||
let domain = eip712_domain!(
|
||||
name: "Polymarket CTF Exchange",
|
||||
version: "2",
|
||||
chain_id: chain_id,
|
||||
verifying_contract: verifying_contract,
|
||||
);
|
||||
|
||||
Self { domain }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,24 +212,97 @@ pub fn sign_clob_auth_message(
|
||||
/// Sign order message using EIP-712
|
||||
pub fn sign_order_message(
|
||||
signer: &PrivateKeySigner,
|
||||
order: Order,
|
||||
order: SignedOrderMessage,
|
||||
chain_id: u64,
|
||||
verifying_contract: Address,
|
||||
) -> Result<String> {
|
||||
let domain = eip712_domain!(
|
||||
name: "Polymarket CTF Exchange",
|
||||
version: "1",
|
||||
chain_id: chain_id,
|
||||
verifying_contract: verifying_contract,
|
||||
);
|
||||
let domain = PreparedOrderDomain::new(chain_id, verifying_contract);
|
||||
sign_order_message_with_domain(signer, order, &domain)
|
||||
}
|
||||
|
||||
/// Sign order message using a prepared EIP-712 domain.
|
||||
pub fn sign_order_message_with_domain(
|
||||
signer: &PrivateKeySigner,
|
||||
order: SignedOrderMessage,
|
||||
domain: &PreparedOrderDomain,
|
||||
) -> Result<String> {
|
||||
let order = order_sol(order);
|
||||
|
||||
let signature = signer
|
||||
.sign_typed_data_sync(&order, &domain)
|
||||
.sign_typed_data_sync(&order, &domain.domain)
|
||||
.map_err(|e| PolyfillError::crypto(format!("Order signature failed: {}", e)))?;
|
||||
|
||||
Ok(encode_prefixed(signature.as_bytes()))
|
||||
}
|
||||
|
||||
/// Sign a POLY_1271 deposit-wallet order using the ERC-7739 wrapper expected by
|
||||
/// Polymarket's V2 deposit wallet verifier.
|
||||
///
|
||||
/// The order itself must have `maker == signer == deposit_wallet` and
|
||||
/// `signatureType == 3`. The EOA key signs a Solady `TypedDataSign(...)`
|
||||
/// envelope under the CTF exchange order domain; the wire signature appends the
|
||||
/// app-domain separator, order contents hash, order type string, and a uint16
|
||||
/// big-endian type-string length.
|
||||
pub fn sign_poly1271_order_message_with_domain(
|
||||
signer: &PrivateKeySigner,
|
||||
order: SignedOrderMessage,
|
||||
domain: &PreparedOrderDomain,
|
||||
deposit_wallet: Address,
|
||||
chain_id: u64,
|
||||
) -> Result<String> {
|
||||
if order.maker != deposit_wallet || order.signer != deposit_wallet {
|
||||
return Err(PolyfillError::validation(
|
||||
"POLY_1271 orders require maker and signer to equal the deposit wallet",
|
||||
));
|
||||
}
|
||||
|
||||
let order = order_sol(order);
|
||||
let app_domain_separator = domain.domain.hash_struct();
|
||||
let contents_hash = order.eip712_hash_struct();
|
||||
let envelope = TypedDataSign {
|
||||
contents: order,
|
||||
name: DEPOSIT_WALLET_NAME.to_string(),
|
||||
version: DEPOSIT_WALLET_VERSION.to_string(),
|
||||
chainId: U256::from(chain_id),
|
||||
verifyingContract: deposit_wallet,
|
||||
salt: ERC7739_TYPED_DATA_SIGN_SALT,
|
||||
};
|
||||
|
||||
let inner = signer
|
||||
.sign_typed_data_sync(&envelope, &domain.domain)
|
||||
.map_err(|e| PolyfillError::crypto(format!("POLY_1271 order signature failed: {e}")))?;
|
||||
|
||||
let type_bytes = ORDER_TYPE_STRING.as_bytes();
|
||||
let type_len: u16 = type_bytes
|
||||
.len()
|
||||
.try_into()
|
||||
.map_err(|_| PolyfillError::validation("POLY_1271 order type string too long"))?;
|
||||
let mut wrapped = Vec::with_capacity(65 + 32 + 32 + type_bytes.len() + 2);
|
||||
wrapped.extend_from_slice(&inner.as_bytes());
|
||||
wrapped.extend_from_slice(app_domain_separator.as_slice());
|
||||
wrapped.extend_from_slice(contents_hash.as_slice());
|
||||
wrapped.extend_from_slice(type_bytes);
|
||||
wrapped.extend_from_slice(&type_len.to_be_bytes());
|
||||
|
||||
Ok(encode_prefixed(wrapped))
|
||||
}
|
||||
|
||||
fn order_sol(order: SignedOrderMessage) -> Order {
|
||||
Order {
|
||||
salt: order.salt,
|
||||
maker: order.maker,
|
||||
signer: order.signer,
|
||||
tokenId: order.token_id,
|
||||
makerAmount: order.maker_amount,
|
||||
takerAmount: order.taker_amount,
|
||||
side: order.side,
|
||||
signatureType: order.signature_type,
|
||||
timestamp: order.timestamp,
|
||||
metadata: order.metadata,
|
||||
builder: order.builder,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build HMAC signature for L2 authentication
|
||||
///
|
||||
/// Performs cryptographic message authentication using SHA-256 with
|
||||
@@ -126,41 +317,60 @@ pub fn build_hmac_signature<T>(
|
||||
where
|
||||
T: ?Sized + Serialize,
|
||||
{
|
||||
// Apply inverse transformation to key material for digest initialization
|
||||
// This ensures compatibility with the expected cryptographic envelope format
|
||||
let decoded_secret = base64::engine::general_purpose::URL_SAFE
|
||||
.decode(secret)
|
||||
.map_err(|e| PolyfillError::crypto(format!("Failed to decode base64 secret: {}", e)))?;
|
||||
let decoded_secret = decode_secret_bytes(secret)?;
|
||||
let body_bytes =
|
||||
match body {
|
||||
Some(b) => Some(serde_json::to_vec(b).map_err(|e| {
|
||||
PolyfillError::parse(format!("Failed to serialize body: {}", e), None)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Initialize MAC with transformed key material to maintain protocol coherence
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&decoded_secret)
|
||||
build_hmac_signature_bytes(
|
||||
&decoded_secret,
|
||||
timestamp,
|
||||
method,
|
||||
request_path,
|
||||
body_bytes.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_hmac_signature_bytes(
|
||||
decoded_secret: &[u8],
|
||||
timestamp: u64,
|
||||
method: &str,
|
||||
request_path: &str,
|
||||
body_bytes: Option<&[u8]>,
|
||||
) -> Result<String> {
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(decoded_secret)
|
||||
.map_err(|e| PolyfillError::crypto(format!("Invalid HMAC key: {}", e)))?;
|
||||
|
||||
// Construct canonical message representation for signature verification
|
||||
// Message components are concatenated in strict order to preserve cryptographic binding
|
||||
let message = format!(
|
||||
"{}{}{}{}",
|
||||
timestamp,
|
||||
method.to_uppercase(),
|
||||
request_path,
|
||||
match body {
|
||||
Some(b) => serde_json::to_string(b).map_err(|e| PolyfillError::parse(
|
||||
format!("Failed to serialize body: {}", e),
|
||||
None
|
||||
))?,
|
||||
None => String::new(),
|
||||
}
|
||||
);
|
||||
let timestamp = timestamp.to_string();
|
||||
mac.update(timestamp.as_bytes());
|
||||
let method_upper;
|
||||
let method_bytes = if method.bytes().all(|b| !b.is_ascii_lowercase()) {
|
||||
method.as_bytes()
|
||||
} else {
|
||||
method_upper = method.to_ascii_uppercase();
|
||||
method_upper.as_bytes()
|
||||
};
|
||||
mac.update(method_bytes);
|
||||
mac.update(request_path.as_bytes());
|
||||
if let Some(body_bytes) = body_bytes {
|
||||
mac.update(body_bytes);
|
||||
}
|
||||
|
||||
// Compute authentication tag over canonical message form
|
||||
mac.update(message.as_bytes());
|
||||
let result = mac.finalize();
|
||||
|
||||
// Apply URL-safe encoding transformation for transport layer compatibility
|
||||
// This encoding scheme ensures proper signature validation across network boundaries
|
||||
Ok(base64::engine::general_purpose::URL_SAFE.encode(result.into_bytes()))
|
||||
}
|
||||
|
||||
fn decode_secret_bytes(secret: &str) -> Result<Vec<u8>> {
|
||||
base64::engine::general_purpose::URL_SAFE
|
||||
.decode(secret)
|
||||
.map_err(|e| PolyfillError::crypto(format!("Failed to decode base64 secret: {}", e)))
|
||||
}
|
||||
|
||||
/// Create L1 headers for authentication (using private key signature)
|
||||
///
|
||||
/// Generates initial authentication envelope using elliptic curve cryptography
|
||||
@@ -189,7 +399,7 @@ pub fn create_l1_headers(signer: &PrivateKeySigner, nonce: Option<U256>) -> Resu
|
||||
/// to satisfy bilateral verification requirements at the protocol layer.
|
||||
pub fn create_l2_headers<T>(
|
||||
signer: &PrivateKeySigner,
|
||||
api_creds: &ApiCredentials,
|
||||
api_creds: &(impl HmacApiCredentials + ?Sized),
|
||||
method: &str,
|
||||
req_path: &str,
|
||||
body: Option<&T>,
|
||||
@@ -202,22 +412,58 @@ where
|
||||
let timestamp = get_current_unix_time_secs();
|
||||
|
||||
// Generate cryptographic authenticator using temporal and message context
|
||||
let hmac_signature =
|
||||
build_hmac_signature(&api_creds.secret, timestamp, method, req_path, body)?;
|
||||
let decoded_secret = api_creds.decoded_secret_bytes()?;
|
||||
let body_bytes =
|
||||
match body {
|
||||
Some(b) => Some(serde_json::to_vec(b).map_err(|e| {
|
||||
PolyfillError::parse(format!("Failed to serialize body: {}", e), None)
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let hmac_signature = build_hmac_signature_bytes(
|
||||
&decoded_secret,
|
||||
timestamp,
|
||||
method,
|
||||
req_path,
|
||||
body_bytes.as_deref(),
|
||||
)?;
|
||||
|
||||
// Construct header map with authentication primitives in canonical order
|
||||
Ok(HashMap::from([
|
||||
(POLY_ADDR_HEADER, address),
|
||||
(POLY_SIG_HEADER, hmac_signature),
|
||||
(POLY_TS_HEADER, timestamp.to_string()),
|
||||
(POLY_API_KEY_HEADER, api_creds.api_key.clone()),
|
||||
(POLY_PASS_HEADER, api_creds.passphrase.clone()),
|
||||
(POLY_API_KEY_HEADER, api_creds.api_key().to_string()),
|
||||
(POLY_PASS_HEADER, api_creds.passphrase().to_string()),
|
||||
]))
|
||||
}
|
||||
|
||||
pub fn create_l2_headers_with_body_bytes(
|
||||
signer: &PrivateKeySigner,
|
||||
api_creds: &(impl HmacApiCredentials + ?Sized),
|
||||
method: &str,
|
||||
req_path: &str,
|
||||
body_bytes: Option<&[u8]>,
|
||||
) -> Result<Headers> {
|
||||
let address = encode_prefixed(signer.address().as_slice());
|
||||
let timestamp = get_current_unix_time_secs();
|
||||
let decoded_secret = api_creds.decoded_secret_bytes()?;
|
||||
let hmac_signature =
|
||||
build_hmac_signature_bytes(&decoded_secret, timestamp, method, req_path, body_bytes)?;
|
||||
|
||||
Ok(HashMap::from([
|
||||
(POLY_ADDR_HEADER, address),
|
||||
(POLY_SIG_HEADER, hmac_signature),
|
||||
(POLY_TS_HEADER, timestamp.to_string()),
|
||||
(POLY_API_KEY_HEADER, api_creds.api_key().to_string()),
|
||||
(POLY_PASS_HEADER, api_creds.passphrase().to_string()),
|
||||
]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn test_unix_timestamp() {
|
||||
@@ -266,6 +512,28 @@ mod tests {
|
||||
assert_eq!(sig1, sig2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_signature_bytes_matches_serialized_body() {
|
||||
let secret = "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1";
|
||||
let timestamp = 1234567890;
|
||||
let body = serde_json::json!({"orderID": "abc123"});
|
||||
let body_bytes = serde_json::to_vec(&body).unwrap();
|
||||
let decoded_secret = decode_secret_bytes(secret).unwrap();
|
||||
|
||||
let object_signature =
|
||||
build_hmac_signature(secret, timestamp, "delete", "/order", Some(&body)).unwrap();
|
||||
let bytes_signature = build_hmac_signature_bytes(
|
||||
&decoded_secret,
|
||||
timestamp,
|
||||
"DELETE",
|
||||
"/order",
|
||||
Some(&body_bytes),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(object_signature, bytes_signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hmac_signature_different_inputs() {
|
||||
let secret = "dGVzdF9zZWNyZXRfa2V5XzEyMzQ1";
|
||||
@@ -335,7 +603,7 @@ mod tests {
|
||||
passphrase: "test_passphrase".to_string(),
|
||||
};
|
||||
|
||||
let result = create_l2_headers::<String>(&signer, &api_creds, "/test", "GET", None);
|
||||
let result = create_l2_headers::<String>(&signer, &api_creds, "GET", "/test", None);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let headers = result.unwrap();
|
||||
@@ -368,6 +636,47 @@ mod tests {
|
||||
assert_eq!(signature.len(), 132); // 0x + 130 hex chars = 132 total
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_poly1271_wrapped_order_signature_golden() {
|
||||
let signer: PrivateKeySigner =
|
||||
"0x2222222222222222222222222222222222222222222222222222222222222222"
|
||||
.parse()
|
||||
.expect("valid private key");
|
||||
let deposit_wallet =
|
||||
Address::from_str("0x000000000000000000000000000000000000d077").unwrap();
|
||||
let exchange = Address::from_str("0xE111180000d2663C0091e4f400237545B87B996B").unwrap();
|
||||
let order = SignedOrderMessage {
|
||||
salt: U256::from(12345),
|
||||
maker: deposit_wallet,
|
||||
signer: deposit_wallet,
|
||||
token_id: U256::from_str_radix(
|
||||
"100000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
10,
|
||||
)
|
||||
.unwrap(),
|
||||
maker_amount: U256::from(1_000_000),
|
||||
taker_amount: U256::from(5_000_000),
|
||||
side: 0,
|
||||
signature_type: 3,
|
||||
timestamp: U256::from(1_716_000_000_000_u64),
|
||||
metadata: B256::ZERO,
|
||||
builder: B256::ZERO,
|
||||
};
|
||||
|
||||
let got = sign_poly1271_order_message_with_domain(
|
||||
&signer,
|
||||
order,
|
||||
&PreparedOrderDomain::new(137, exchange),
|
||||
deposit_wallet,
|
||||
137,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
const WANT: &str = "0xc1c199d3822d7f465b1f213793ebb7bbc3a77810ceefa89b58ecfeb284e708953c5ec946a41c6fa2a6fac373c78b167dd6834fae6f3e37581111ffb06200f1d21b3264e159346253e26a64e00b69032db0e7d32f94628de3e6eecb50304d7af3d26814859c22020105275eba8b46528be2edd6078dea415bec6d90f2076b0c8ac64f726465722875696e743235362073616c742c61646472657373206d616b65722c61646472657373207369676e65722c75696e7432353620746f6b656e49642c75696e74323536206d616b6572416d6f756e742c75696e743235362074616b6572416d6f756e742c75696e743820736964652c75696e7438207369676e6174757265547970652c75696e743235362074696d657374616d702c62797465733332206d657461646174612c62797465733332206275696c6465722900ba";
|
||||
assert_eq!(got, WANT);
|
||||
assert!(got.len() > 600 && got.len() < 700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_generation() {
|
||||
let ts1 = get_current_unix_time_secs();
|
||||
|
||||
+1079
-183
File diff suppressed because it is too large
Load Diff
@@ -1,120 +0,0 @@
|
||||
//! Buffer pooling for reducing allocation overhead
|
||||
//!
|
||||
//! This module provides a buffer pool for reusing memory allocations
|
||||
//! across multiple HTTP requests, reducing GC pressure and improving performance.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// A pool of reusable buffers for HTTP response bodies
|
||||
pub struct BufferPool {
|
||||
buffers: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
buffer_size: usize,
|
||||
max_pool_size: usize,
|
||||
}
|
||||
|
||||
impl BufferPool {
|
||||
/// Create a new buffer pool
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `buffer_size` - Initial size of each buffer (e.g., 512KB for typical market data)
|
||||
/// * `max_pool_size` - Maximum number of buffers to keep in the pool
|
||||
pub fn new(buffer_size: usize, max_pool_size: usize) -> Self {
|
||||
Self {
|
||||
buffers: Arc::new(Mutex::new(Vec::with_capacity(max_pool_size))),
|
||||
buffer_size,
|
||||
max_pool_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a buffer from the pool, or create a new one if pool is empty
|
||||
pub async fn get(&self) -> Vec<u8> {
|
||||
let mut buffers = self.buffers.lock().await;
|
||||
|
||||
match buffers.pop() {
|
||||
Some(mut buffer) => {
|
||||
buffer.clear();
|
||||
buffer
|
||||
},
|
||||
None => {
|
||||
// Pool is empty, create a new buffer
|
||||
Vec::with_capacity(self.buffer_size)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a buffer to the pool
|
||||
pub async fn return_buffer(&self, mut buffer: Vec<u8>) {
|
||||
let mut buffers = self.buffers.lock().await;
|
||||
|
||||
// Only return to pool if we're under the size limit
|
||||
if buffers.len() < self.max_pool_size {
|
||||
buffer.clear();
|
||||
// Shrink if buffer grew too large
|
||||
if buffer.capacity() > self.buffer_size * 2 {
|
||||
buffer.shrink_to(self.buffer_size);
|
||||
}
|
||||
buffers.push(buffer);
|
||||
}
|
||||
// Otherwise, let the buffer be dropped
|
||||
}
|
||||
|
||||
/// Get the current number of buffers in the pool
|
||||
pub async fn size(&self) -> usize {
|
||||
let buffers = self.buffers.lock().await;
|
||||
buffers.len()
|
||||
}
|
||||
|
||||
/// Pre-allocate buffers in the pool
|
||||
pub async fn prewarm(&self, count: usize) {
|
||||
let mut buffers = self.buffers.lock().await;
|
||||
for _ in 0..count.min(self.max_pool_size) {
|
||||
buffers.push(Vec::with_capacity(self.buffer_size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BufferPool {
|
||||
fn default() -> Self {
|
||||
// Default: 512KB buffers, pool of 10
|
||||
Self::new(512 * 1024, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_buffer_pool_get_and_return() {
|
||||
let pool = BufferPool::new(1024, 5);
|
||||
|
||||
let buffer = pool.get().await;
|
||||
assert_eq!(buffer.capacity(), 1024);
|
||||
|
||||
pool.return_buffer(buffer).await;
|
||||
assert_eq!(pool.size().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_buffer_pool_prewarm() {
|
||||
let pool = BufferPool::new(1024, 5);
|
||||
pool.prewarm(3).await;
|
||||
assert_eq!(pool.size().await, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_buffer_pool_max_size() {
|
||||
let pool = BufferPool::new(1024, 2);
|
||||
|
||||
let buf1 = pool.get().await;
|
||||
let buf2 = pool.get().await;
|
||||
let buf3 = pool.get().await;
|
||||
|
||||
pool.return_buffer(buf1).await;
|
||||
pool.return_buffer(buf2).await;
|
||||
pool.return_buffer(buf3).await; // This should be dropped, not added to pool
|
||||
|
||||
assert_eq!(pool.size().await, 2); // Max size is 2
|
||||
}
|
||||
}
|
||||
+2131
-392
File diff suppressed because it is too large
Load Diff
@@ -100,14 +100,14 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_connection_manager_creation() {
|
||||
let client = Client::new();
|
||||
let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap();
|
||||
let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string());
|
||||
assert!(!manager.is_running());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_keepalive_start_stop() {
|
||||
let client = Client::new();
|
||||
let client = reqwest::ClientBuilder::new().no_proxy().build().unwrap();
|
||||
let manager = ConnectionManager::new(client, "https://clob.polymarket.com".to_string());
|
||||
|
||||
manager.start_keepalive(Duration::from_secs(30)).await;
|
||||
|
||||
+189
-41
@@ -1,7 +1,14 @@
|
||||
//! Data decoding utilities for Polymarket client
|
||||
//! Data decoding utilities for the Polymarket client.
|
||||
//!
|
||||
//! This module provides high-performance decoding functions for various
|
||||
//! data formats used in trading environments.
|
||||
//! This module contains the ergonomic decoding layer used for broad API compatibility:
|
||||
//! tolerant string-or-number deserializers, raw REST response adapters, and generic
|
||||
//! WebSocket message parsing. Some helpers intentionally parse through
|
||||
//! [`serde_json::Value`] to support Polymarket fields that vary between strings,
|
||||
//! numbers, nulls, and mixed event batches.
|
||||
//!
|
||||
//! The zero-allocation WS `book` processing path is separate. Use
|
||||
//! [`crate::ws_hot_path::WsBookUpdateProcessor`] when applying high-throughput book
|
||||
//! messages directly to [`crate::book::OrderBookManager`].
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
@@ -12,12 +19,15 @@ use serde::{Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Fast string to number deserializers
|
||||
/// Flexible string-or-number deserializers for inconsistent API fields.
|
||||
pub mod deserializers {
|
||||
use super::*;
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Deserialize number from string or number
|
||||
/// Deserialize a number from a string or JSON number.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes by first decoding into
|
||||
/// `serde_json::Value`. It is not intended for allocation-sensitive hot paths.
|
||||
pub fn number_from_string<'de, T, D>(deserializer: D) -> std::result::Result<T, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -44,7 +54,10 @@ pub mod deserializers {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize optional number from string
|
||||
/// Deserialize an optional number from a string, JSON number, or null.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes by first decoding into
|
||||
/// `serde_json::Value`. It is not intended for allocation-sensitive hot paths.
|
||||
pub fn optional_number_from_string<'de, T, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<T>, D::Error>
|
||||
@@ -108,6 +121,108 @@ pub mod deserializers {
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a vec that may be `null` (treat `null` as empty vec).
|
||||
pub fn vec_from_null<'de, D, T>(deserializer: D) -> std::result::Result<Vec<T>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
T: serde::Deserialize<'de>,
|
||||
{
|
||||
Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Deserialize an optional Decimal from string/number/null.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes by first decoding into
|
||||
/// `serde_json::Value`. It is not intended for allocation-sensitive hot paths.
|
||||
///
|
||||
/// - `null` => `None`
|
||||
/// - `""` => `None`
|
||||
/// - invalid values => error
|
||||
pub fn optional_decimal_from_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<Decimal>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
s.parse::<Decimal>()
|
||||
.map(Some)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
},
|
||||
serde_json::Value::Number(n) => Decimal::from_str(&n.to_string())
|
||||
.map(Some)
|
||||
.map_err(serde::de::Error::custom),
|
||||
other => Err(serde::de::Error::custom(format!(
|
||||
"Expected decimal as string/number/null, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `optional_decimal_from_string`, but returns `None` on parse errors.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes by first decoding into
|
||||
/// `serde_json::Value`. It is not intended for allocation-sensitive hot paths.
|
||||
pub fn optional_decimal_from_string_default_on_error<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<Decimal>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
match value {
|
||||
serde_json::Value::Null => Ok(None),
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(s.parse::<Decimal>().ok())
|
||||
}
|
||||
},
|
||||
serde_json::Value::Number(n) => Ok(Decimal::from_str(&n.to_string()).ok()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a Decimal from string/number.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes through
|
||||
/// `optional_decimal_from_string`. It is not intended for allocation-sensitive
|
||||
/// hot paths.
|
||||
///
|
||||
/// - `""` => error
|
||||
/// - invalid values => error
|
||||
pub fn decimal_from_string<'de, D>(deserializer: D) -> std::result::Result<Decimal, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
optional_decimal_from_string(deserializer)?.ok_or_else(|| {
|
||||
serde::de::Error::custom("Expected decimal as string/number, got null/empty string")
|
||||
})
|
||||
}
|
||||
|
||||
/// Deserialize a Decimal from string/number/null, defaulting missing-ish values to zero.
|
||||
///
|
||||
/// This compatibility helper accepts multiple API shapes through
|
||||
/// `optional_decimal_from_string`. It is not intended for allocation-sensitive
|
||||
/// hot paths.
|
||||
pub fn decimal_from_string_or_zero<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Decimal, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(optional_decimal_from_string(deserializer)?.unwrap_or(Decimal::ZERO))
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw API response types for efficient parsing
|
||||
@@ -226,6 +341,8 @@ impl Decoder<OrderBook> for RawOrderBookResponse {
|
||||
bids,
|
||||
asks,
|
||||
sequence: 0, // TODO: Get from response if available
|
||||
last_delta_sequence: 0,
|
||||
last_snapshot_timestamp_ms: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -367,44 +484,62 @@ impl Decoder<Market> for RawMarketResponse {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket message parsing
|
||||
pub fn parse_stream_message(raw: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(raw)?;
|
||||
/// Ergonomic WebSocket message parsing (official `event_type` shape).
|
||||
///
|
||||
/// Polymarket WebSocket servers may send either a single JSON object or a batch array.
|
||||
/// This parser is tolerant:
|
||||
/// - Unknown/unsupported `event_type`s are ignored.
|
||||
/// - Invalid entries inside a batch are skipped (do not fail the whole batch).
|
||||
///
|
||||
/// This is the compatibility parser for general stream consumers. It parses into
|
||||
/// `serde_json::Value` first so it can inspect event types and skip unknown messages.
|
||||
/// For allocation-sensitive WS `book` updates, use
|
||||
/// [`crate::ws_hot_path::WsBookUpdateProcessor`] instead.
|
||||
pub fn parse_stream_messages(raw: &str) -> Result<Vec<StreamMessage>> {
|
||||
parse_stream_messages_bytes(raw.as_bytes())
|
||||
}
|
||||
|
||||
let msg_type = value["type"]
|
||||
.as_str()
|
||||
.ok_or_else(|| PolyfillError::parse("Missing message type".to_string(), None))?;
|
||||
/// See `parse_stream_messages`.
|
||||
pub fn parse_stream_messages_bytes(bytes: &[u8]) -> Result<Vec<StreamMessage>> {
|
||||
let value: Value = serde_json::from_slice(bytes)?;
|
||||
|
||||
match msg_type {
|
||||
"book_update" => {
|
||||
let data = value["data"].clone();
|
||||
let delta: OrderDelta = serde_json::from_value(data)?;
|
||||
Ok(StreamMessage::BookUpdate { data: delta })
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let event_type = map.get("event_type").and_then(Value::as_str);
|
||||
match event_type {
|
||||
None => Ok(vec![]),
|
||||
Some(_) => {
|
||||
let msg: StreamMessage = serde_json::from_value(Value::Object(map))?;
|
||||
match msg {
|
||||
StreamMessage::Unknown => Ok(vec![]),
|
||||
other => Ok(vec![other]),
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"trade" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_trade: RawTradeResponse = serde_json::from_value(data)?;
|
||||
let fill = raw_trade.decode()?;
|
||||
Ok(StreamMessage::Trade { data: fill })
|
||||
},
|
||||
"order_update" => {
|
||||
let data = value["data"].clone();
|
||||
let raw_order: RawOrderResponse = serde_json::from_value(data)?;
|
||||
let order = raw_order.decode()?;
|
||||
Ok(StreamMessage::OrderUpdate { data: order })
|
||||
},
|
||||
"heartbeat" => {
|
||||
let timestamp = value["timestamp"]
|
||||
.as_str()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(Utc::now);
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
},
|
||||
_ => Err(PolyfillError::parse(
|
||||
format!("Unknown message type: {}", msg_type),
|
||||
None,
|
||||
)),
|
||||
Value::Array(arr) => Ok(arr
|
||||
.into_iter()
|
||||
.filter_map(|elem| {
|
||||
let Value::Object(map) = elem else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let event_type = map.get("event_type").and_then(Value::as_str)?;
|
||||
// Skip unknown event types early (forward compatibility).
|
||||
match event_type {
|
||||
"book" | "price_change" | "tick_size_change" | "last_trade_price"
|
||||
| "best_bid_ask" | "new_market" | "market_resolved" | "trade" | "order" => {},
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
match serde_json::from_value::<StreamMessage>(Value::Object(map)) {
|
||||
Ok(StreamMessage::Unknown) => None,
|
||||
Ok(msg) => Some(msg),
|
||||
Err(_) => None,
|
||||
}
|
||||
})
|
||||
.collect()),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,4 +707,17 @@ mod tests {
|
||||
let results: Vec<serde_json::Value> = decoder.parse_json_stream(data).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_book_message_requires_bids_and_asks() {
|
||||
let missing_asks = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"bids":[]}"#;
|
||||
assert!(parse_stream_messages_bytes(missing_asks).is_err());
|
||||
|
||||
let missing_bids = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"asks":[]}"#;
|
||||
assert!(parse_stream_messages_bytes(missing_bids).is_err());
|
||||
|
||||
let empty_sides = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"bids":[],"asks":[]}"#;
|
||||
let messages = parse_stream_messages_bytes(empty_sides).unwrap();
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
//! DNS caching to reduce lookup latency
|
||||
//!
|
||||
//! This module provides DNS caching functionality to avoid repeated DNS lookups
|
||||
//! which can add 10-20ms per request.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
use hickory_resolver::config::*;
|
||||
use hickory_resolver::TokioAsyncResolver;
|
||||
|
||||
/// DNS cache entry with TTL
|
||||
#[derive(Clone, Debug)]
|
||||
struct DnsCacheEntry {
|
||||
ips: Vec<IpAddr>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// DNS cache for resolving hostnames
|
||||
pub struct DnsCache {
|
||||
resolver: TokioAsyncResolver,
|
||||
cache: Arc<RwLock<HashMap<String, DnsCacheEntry>>>,
|
||||
default_ttl: Duration,
|
||||
}
|
||||
|
||||
impl DnsCache {
|
||||
/// Create a new DNS cache with system configuration
|
||||
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let resolver =
|
||||
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
|
||||
Ok(Self {
|
||||
resolver,
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
default_ttl: Duration::from_secs(300), // 5 minutes default TTL
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a DNS cache with custom TTL
|
||||
pub async fn with_ttl(ttl: Duration) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let resolver =
|
||||
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
|
||||
|
||||
Ok(Self {
|
||||
resolver,
|
||||
cache: Arc::new(RwLock::new(HashMap::new())),
|
||||
default_ttl: ttl,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a hostname, using cache if available
|
||||
pub async fn resolve(&self, hostname: &str) -> Result<Vec<IpAddr>, Box<dyn std::error::Error>> {
|
||||
// Check cache first
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(entry) = cache.get(hostname) {
|
||||
if entry.expires_at > Instant::now() {
|
||||
return Ok(entry.ips.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss or expired, do actual lookup
|
||||
let lookup = self.resolver.lookup_ip(hostname).await?;
|
||||
let ips: Vec<IpAddr> = lookup.iter().collect();
|
||||
|
||||
// Store in cache
|
||||
let entry = DnsCacheEntry {
|
||||
ips: ips.clone(),
|
||||
expires_at: Instant::now() + self.default_ttl,
|
||||
};
|
||||
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.insert(hostname.to_string(), entry);
|
||||
|
||||
Ok(ips)
|
||||
}
|
||||
|
||||
/// Pre-warm the cache by resolving a hostname
|
||||
pub async fn prewarm(&self, hostname: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
self.resolve(hostname).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the cache
|
||||
pub async fn clear(&self) {
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/// Get cache size
|
||||
pub async fn cache_size(&self) -> usize {
|
||||
let cache = self.cache.read().await;
|
||||
cache.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dns_cache_resolve() {
|
||||
let cache = DnsCache::new().await.unwrap();
|
||||
let ips = cache.resolve("clob.polymarket.com").await.unwrap();
|
||||
assert!(!ips.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dns_cache_prewarm() {
|
||||
let cache = DnsCache::new().await.unwrap();
|
||||
cache.prewarm("clob.polymarket.com").await.unwrap();
|
||||
assert_eq!(cache.cache_size().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dns_cache_clear() {
|
||||
let cache = DnsCache::new().await.unwrap();
|
||||
cache.prewarm("clob.polymarket.com").await.unwrap();
|
||||
cache.clear().await;
|
||||
assert_eq!(cache.cache_size().await, 0);
|
||||
}
|
||||
}
|
||||
+18
-7
@@ -1,7 +1,9 @@
|
||||
//! Trade execution and fill handling for Polymarket client
|
||||
//! Trade execution simulation and fill handling for the Polymarket client.
|
||||
//!
|
||||
//! This module provides high-performance trade execution logic and
|
||||
//! fill event processing for latency-sensitive trading environments.
|
||||
//! This module is intended for local simulation, slippage checks, and fill-event
|
||||
//! bookkeeping. It is not a hot-path matching or execution engine: market-order
|
||||
//! simulation materializes book levels as `Decimal` values, creates UUID-backed
|
||||
//! fill IDs, clones order identifiers, and stores owned fill history.
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
@@ -38,7 +40,11 @@ pub enum FillStatus {
|
||||
Rejected,
|
||||
}
|
||||
|
||||
/// Fill execution engine
|
||||
/// Fill simulation and bookkeeping engine.
|
||||
///
|
||||
/// `FillEngine` favors ergonomic `Decimal` outputs and owned `FillEvent` records
|
||||
/// over allocation-sensitive execution. It should not be used as evidence that the
|
||||
/// order-routing or market-data hot paths are allocation-free.
|
||||
#[derive(Debug)]
|
||||
pub struct FillEngine {
|
||||
/// Minimum fill size for market orders
|
||||
@@ -52,7 +58,7 @@ pub struct FillEngine {
|
||||
}
|
||||
|
||||
impl FillEngine {
|
||||
/// Create a new fill engine
|
||||
/// Create a new fill simulation engine.
|
||||
pub fn new(min_fill_size: Decimal, max_slippage_pct: Decimal, fee_rate_bps: u32) -> Self {
|
||||
Self {
|
||||
min_fill_size,
|
||||
@@ -62,7 +68,12 @@ impl FillEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a market order against an order book
|
||||
/// Simulate executing a market order against an order book.
|
||||
///
|
||||
/// This method allocates and converts internal book levels back to `Decimal`
|
||||
/// via `OrderBook::asks`/`OrderBook::bids`, then builds owned `FillEvent`
|
||||
/// records. It is suitable for simulation and reporting, not latency-sensitive
|
||||
/// execution benchmarking.
|
||||
pub fn execute_market_order(
|
||||
&mut self,
|
||||
order: &MarketOrderRequest,
|
||||
@@ -205,7 +216,7 @@ impl FillEngine {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Execute a limit order (simulation)
|
||||
/// Simulate executing a limit order.
|
||||
pub fn execute_limit_order(
|
||||
&mut self,
|
||||
order: &OrderRequest,
|
||||
|
||||
+21
-3
@@ -26,6 +26,8 @@ pub async fn prewarm_connections(client: &Client, base_url: &str) -> Result<(),
|
||||
/// Benchmarked configuration: 309.3ms vs 349ms baseline (11.4% faster)
|
||||
pub fn create_optimized_client() -> Result<Client, reqwest::Error> {
|
||||
ClientBuilder::new()
|
||||
// Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs)
|
||||
.no_proxy()
|
||||
// Connection pooling optimizations - aggressive reuse
|
||||
.pool_max_idle_per_host(10) // Keep connections alive
|
||||
.pool_idle_timeout(Duration::from_secs(90)) // Longer reuse window
|
||||
@@ -37,7 +39,11 @@ pub fn create_optimized_client() -> Result<Client, reqwest::Error> {
|
||||
// Compression - all algorithms enabled by default in reqwest
|
||||
.gzip(true) // Ensure gzip is enabled
|
||||
// User agent for identification
|
||||
.user_agent("polyfill-rs/0.2.2 (high-frequency-trading)")
|
||||
.user_agent(concat!(
|
||||
"polyfill-rs/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (high-frequency-trading)"
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -45,6 +51,8 @@ pub fn create_optimized_client() -> Result<Client, reqwest::Error> {
|
||||
/// (even more aggressive settings for when you're close to the exchange)
|
||||
pub fn create_colocated_client() -> Result<Client, reqwest::Error> {
|
||||
ClientBuilder::new()
|
||||
// Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs)
|
||||
.no_proxy()
|
||||
// More aggressive connection pooling
|
||||
.pool_max_idle_per_host(20) // More connections
|
||||
.pool_idle_timeout(Duration::from_secs(60)) // Longer reuse
|
||||
@@ -62,7 +70,11 @@ pub fn create_colocated_client() -> Result<Client, reqwest::Error> {
|
||||
// Disable compression in co-located environments (CPU vs network tradeoff)
|
||||
.gzip(false)
|
||||
.no_brotli() // Disable brotli compression
|
||||
.user_agent("polyfill-rs/0.2.2 (colocated-hft)")
|
||||
.user_agent(concat!(
|
||||
"polyfill-rs/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (colocated-hft)"
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -70,6 +82,8 @@ pub fn create_colocated_client() -> Result<Client, reqwest::Error> {
|
||||
/// (more conservative settings for internet connections)
|
||||
pub fn create_internet_client() -> Result<Client, reqwest::Error> {
|
||||
ClientBuilder::new()
|
||||
// Avoid reading OS proxy settings (can be slow and/or unavailable in some sandboxed envs)
|
||||
.no_proxy()
|
||||
// Conservative connection pooling
|
||||
.pool_max_idle_per_host(5)
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
@@ -83,7 +97,11 @@ pub fn create_internet_client() -> Result<Client, reqwest::Error> {
|
||||
.http1_title_case_headers()
|
||||
// Enable compression (gzip and brotli are enabled by default)
|
||||
.gzip(true)
|
||||
.user_agent("polyfill-rs/0.2.2 (internet-trading)")
|
||||
.user_agent(concat!(
|
||||
"polyfill-rs/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (internet-trading)"
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
+37
-16
@@ -5,7 +5,7 @@
|
||||
//! - **High-performance order book management** with optimized data structures
|
||||
//! - **Real-time market data streaming** with WebSocket support
|
||||
//! - **Trade execution simulation** with slippage protection
|
||||
//! - **Comprehensive error handling** with specific error types
|
||||
//! - **Detailed error handling** with specific error types
|
||||
//! - **Rate limiting and retry logic** for robust API interactions
|
||||
//! - **Ethereum integration** with EIP-712 signing support
|
||||
//! - **Benchmarking tools** for performance analysis
|
||||
@@ -13,22 +13,27 @@
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
//! use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side};
|
||||
//! use rust_decimal::Decimal;
|
||||
//! use std::str::FromStr;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! // Create client (compatible with polymarket-rs-client)
|
||||
//! let mut client = ClobClient::with_l1_headers(
|
||||
//! "https://clob.polymarket.com",
|
||||
//! "your_private_key",
|
||||
//! 137,
|
||||
//! );
|
||||
//! let bootstrap = ClobClient::from_config(ClientConfig {
|
||||
//! base_url: "https://clob.polymarket.com".to_string(),
|
||||
//! chain: 137,
|
||||
//! private_key: Some("your_private_key".to_string()),
|
||||
//! ..ClientConfig::default()
|
||||
//! })?;
|
||||
//!
|
||||
//! // Get API credentials
|
||||
//! let api_creds = client.create_or_derive_api_key(None).await.unwrap();
|
||||
//! client.set_api_creds(api_creds);
|
||||
//! let api_creds = bootstrap.create_or_derive_api_key(None).await?;
|
||||
//! let client = ClobClient::from_config(ClientConfig {
|
||||
//! base_url: "https://clob.polymarket.com".to_string(),
|
||||
//! chain: 137,
|
||||
//! private_key: Some("your_private_key".to_string()),
|
||||
//! api_credentials: Some(api_creds),
|
||||
//! ..ClientConfig::default()
|
||||
//! })?;
|
||||
//!
|
||||
//! // Create and post order
|
||||
//! let order_args = OrderArgs::new(
|
||||
@@ -38,7 +43,7 @@
|
||||
//! Side::BUY,
|
||||
//! );
|
||||
//!
|
||||
//! let result = client.create_and_post_order(&order_args).await.unwrap();
|
||||
//! let result = client.create_and_post_order(&order_args, None, None).await?;
|
||||
//! println!("Order posted: {:?}", result);
|
||||
//!
|
||||
//! Ok(())
|
||||
@@ -99,6 +104,7 @@ pub use crate::types::{
|
||||
BookParams,
|
||||
ClientConfig,
|
||||
ClientResult,
|
||||
FeeRateResponse,
|
||||
FillEvent,
|
||||
Market,
|
||||
MarketSnapshot,
|
||||
@@ -117,7 +123,22 @@ pub use crate::types::{
|
||||
OrderSummary,
|
||||
OrderType,
|
||||
PriceResponse,
|
||||
PricesHistoryInterval,
|
||||
PricesHistoryResponse,
|
||||
Rewards,
|
||||
RfqApproveOrderResponse,
|
||||
RfqCancelQuote,
|
||||
RfqCancelRequest,
|
||||
RfqCreateQuote,
|
||||
RfqCreateQuoteResponse,
|
||||
RfqCreateRequest,
|
||||
RfqCreateRequestResponse,
|
||||
RfqListResponse,
|
||||
RfqOrderExecutionRequest,
|
||||
RfqQuoteData,
|
||||
RfqQuotesParams,
|
||||
RfqRequestData,
|
||||
RfqRequestsParams,
|
||||
Side,
|
||||
SimplifiedMarket,
|
||||
SimplifiedMarketsResponse,
|
||||
@@ -136,7 +157,7 @@ pub use crate::types::{
|
||||
pub use crate::client::{ClobClient, PolyfillClient};
|
||||
|
||||
// Re-export compatibility types (for easy migration from polymarket-rs-client)
|
||||
pub use crate::client::OrderArgs;
|
||||
pub use crate::types::OrderArgs;
|
||||
|
||||
// Re-export error types
|
||||
pub use crate::errors::{PolyfillError, Result};
|
||||
@@ -145,7 +166,8 @@ pub use crate::errors::{PolyfillError, Result};
|
||||
pub use crate::book::{OrderBook as OrderBookImpl, OrderBookManager};
|
||||
pub use crate::decode::Decoder;
|
||||
pub use crate::fill::{FillEngine, FillResult};
|
||||
pub use crate::stream::{MarketStream, StreamManager, WebSocketStream};
|
||||
pub use crate::stream::{MarketStream, StreamManager, WebSocketBookApplier, WebSocketStream};
|
||||
pub use crate::ws_hot_path::{WsBookApplyStats, WsBookUpdateProcessor};
|
||||
|
||||
// Re-export utilities
|
||||
pub use crate::utils::{crypto, math, rate_limit, retry, time, url};
|
||||
@@ -153,11 +175,9 @@ pub use crate::utils::{crypto, math, rate_limit, retry, time, url};
|
||||
// Module declarations
|
||||
pub mod auth;
|
||||
pub mod book;
|
||||
pub mod buffer_pool;
|
||||
pub mod client;
|
||||
pub mod connection_manager;
|
||||
pub mod decode;
|
||||
pub mod dns_cache;
|
||||
pub mod errors;
|
||||
pub mod fill;
|
||||
pub mod http_config;
|
||||
@@ -165,6 +185,7 @@ pub mod orders;
|
||||
pub mod stream;
|
||||
pub mod types;
|
||||
pub mod utils;
|
||||
pub mod ws_hot_path;
|
||||
|
||||
// Benchmarks
|
||||
#[cfg(test)]
|
||||
|
||||
+929
-136
File diff suppressed because it is too large
Load Diff
+416
-208
@@ -5,9 +5,12 @@
|
||||
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::*;
|
||||
use crate::ws_hot_path::{WsBookApplyStats, WsBookUpdateProcessor};
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, Stream, StreamExt};
|
||||
use futures::{ready, SinkExt, Stream, StreamExt};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
use std::collections::VecDeque;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use tokio::sync::mpsc;
|
||||
@@ -44,10 +47,12 @@ pub struct WebSocketStream {
|
||||
auth: Option<WssAuth>,
|
||||
/// Current subscriptions
|
||||
subscriptions: Vec<WssSubscription>,
|
||||
/// Message sender for internal communication
|
||||
tx: mpsc::UnboundedSender<StreamMessage>,
|
||||
/// Message receiver
|
||||
rx: mpsc::UnboundedReceiver<StreamMessage>,
|
||||
/// Parsed messages awaiting delivery to the caller.
|
||||
///
|
||||
/// This replaces an internal unbounded channel to avoid per-message
|
||||
/// allocations in the buffering layer and to enforce a bounded backlog.
|
||||
pending: VecDeque<StreamMessage>,
|
||||
pending_capacity: usize,
|
||||
/// Connection statistics
|
||||
stats: StreamStats,
|
||||
/// Reconnection configuration
|
||||
@@ -60,6 +65,7 @@ pub struct StreamStats {
|
||||
pub messages_received: u64,
|
||||
pub messages_sent: u64,
|
||||
pub errors: u64,
|
||||
pub dropped_messages: u64,
|
||||
pub last_message_time: Option<chrono::DateTime<Utc>>,
|
||||
pub connection_uptime: std::time::Duration,
|
||||
pub reconnect_count: u32,
|
||||
@@ -88,19 +94,20 @@ impl Default for ReconnectConfig {
|
||||
impl WebSocketStream {
|
||||
/// Create a new WebSocket stream
|
||||
pub fn new(url: &str) -> Self {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let pending_capacity = 1024;
|
||||
|
||||
Self {
|
||||
connection: None,
|
||||
url: url.to_string(),
|
||||
auth: None,
|
||||
subscriptions: Vec::new(),
|
||||
tx,
|
||||
rx,
|
||||
pending: VecDeque::with_capacity(pending_capacity),
|
||||
pending_capacity,
|
||||
stats: StreamStats {
|
||||
messages_received: 0,
|
||||
messages_sent: 0,
|
||||
errors: 0,
|
||||
dropped_messages: 0,
|
||||
last_message_time: None,
|
||||
connection_uptime: std::time::Duration::ZERO,
|
||||
reconnect_count: 0,
|
||||
@@ -109,6 +116,14 @@ impl WebSocketStream {
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue(&mut self, message: StreamMessage) {
|
||||
if self.pending.len() >= self.pending_capacity {
|
||||
let _ = self.pending.pop_front();
|
||||
self.stats.dropped_messages += 1;
|
||||
}
|
||||
self.pending.push_back(message);
|
||||
}
|
||||
|
||||
/// Set authentication credentials
|
||||
pub fn with_auth(mut self, auth: WssAuth) -> Self {
|
||||
self.auth = Some(auth);
|
||||
@@ -160,12 +175,10 @@ impl WebSocketStream {
|
||||
}
|
||||
|
||||
// Send subscription message in the format expected by Polymarket
|
||||
let message = serde_json::json!({
|
||||
"auth": subscription.auth,
|
||||
"markets": subscription.markets,
|
||||
"asset_ids": subscription.asset_ids,
|
||||
"type": subscription.channel_type,
|
||||
});
|
||||
// The subscription struct will serialize correctly with proper field names
|
||||
let message = serde_json::to_value(&subscription).map_err(|e| {
|
||||
PolyfillError::parse(format!("Failed to serialize subscription: {}", e), None)
|
||||
})?;
|
||||
|
||||
self.send_message(message).await?;
|
||||
self.subscriptions.push(subscription.clone());
|
||||
@@ -183,17 +196,70 @@ impl WebSocketStream {
|
||||
.clone();
|
||||
|
||||
let subscription = WssSubscription {
|
||||
auth,
|
||||
markets: Some(markets),
|
||||
asset_ids: None,
|
||||
channel_type: "USER".to_string(),
|
||||
channel_type: "user".to_string(),
|
||||
operation: Some("subscribe".to_string()),
|
||||
markets,
|
||||
asset_ids: Vec::new(),
|
||||
initial_dump: Some(true),
|
||||
custom_feature_enabled: None,
|
||||
auth: Some(auth),
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Subscribe to market channel (order book and trades)
|
||||
/// Market subscriptions do not require authentication
|
||||
pub async fn subscribe_market_channel(&mut self, asset_ids: Vec<String>) -> Result<()> {
|
||||
let subscription = WssSubscription {
|
||||
channel_type: "market".to_string(),
|
||||
operation: Some("subscribe".to_string()),
|
||||
markets: Vec::new(),
|
||||
asset_ids,
|
||||
initial_dump: Some(true),
|
||||
custom_feature_enabled: None,
|
||||
auth: None,
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Subscribe to market channel with custom features enabled
|
||||
/// Custom features include: best_bid_ask, new_market, market_resolved events
|
||||
pub async fn subscribe_market_channel_with_features(
|
||||
&mut self,
|
||||
asset_ids: Vec<String>,
|
||||
) -> Result<()> {
|
||||
let subscription = WssSubscription {
|
||||
channel_type: "market".to_string(),
|
||||
operation: Some("subscribe".to_string()),
|
||||
markets: Vec::new(),
|
||||
asset_ids,
|
||||
initial_dump: Some(true),
|
||||
custom_feature_enabled: Some(true),
|
||||
auth: None,
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Unsubscribe from market channel
|
||||
pub async fn unsubscribe_market_channel(&mut self, asset_ids: Vec<String>) -> Result<()> {
|
||||
let subscription = WssSubscription {
|
||||
channel_type: "market".to_string(),
|
||||
operation: Some("unsubscribe".to_string()),
|
||||
markets: Vec::new(),
|
||||
asset_ids,
|
||||
initial_dump: None,
|
||||
custom_feature_enabled: None,
|
||||
auth: None,
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Unsubscribe from user channel
|
||||
pub async fn unsubscribe_user_channel(&mut self, markets: Vec<String>) -> Result<()> {
|
||||
let auth = self
|
||||
.auth
|
||||
.as_ref()
|
||||
@@ -201,42 +267,18 @@ impl WebSocketStream {
|
||||
.clone();
|
||||
|
||||
let subscription = WssSubscription {
|
||||
auth,
|
||||
markets: None,
|
||||
asset_ids: Some(asset_ids),
|
||||
channel_type: "MARKET".to_string(),
|
||||
channel_type: "user".to_string(),
|
||||
operation: Some("unsubscribe".to_string()),
|
||||
markets,
|
||||
asset_ids: Vec::new(),
|
||||
initial_dump: None,
|
||||
custom_feature_enabled: None,
|
||||
auth: Some(auth),
|
||||
};
|
||||
|
||||
self.subscribe_async(subscription).await
|
||||
}
|
||||
|
||||
/// Unsubscribe from market data
|
||||
pub async fn unsubscribe_async(&mut self, token_ids: &[String]) -> Result<()> {
|
||||
// Note: Polymarket WebSocket API doesn't seem to have explicit unsubscribe
|
||||
// We'll just remove from our local subscriptions
|
||||
self.subscriptions
|
||||
.retain(|sub| match sub.channel_type.as_str() {
|
||||
"USER" => {
|
||||
if let Some(markets) = &sub.markets {
|
||||
!token_ids.iter().any(|id| markets.contains(id))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
},
|
||||
"MARKET" => {
|
||||
if let Some(asset_ids) = &sub.asset_ids {
|
||||
!token_ids.iter().any(|id| asset_ids.contains(id))
|
||||
} else {
|
||||
true
|
||||
}
|
||||
},
|
||||
_ => true,
|
||||
});
|
||||
|
||||
info!("Unsubscribed from {} tokens", token_ids.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming WebSocket messages
|
||||
#[allow(dead_code)]
|
||||
async fn handle_message(
|
||||
@@ -247,12 +289,10 @@ impl WebSocketStream {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
|
||||
// Parse the message according to Polymarket's format
|
||||
let stream_message = self.parse_polymarket_message(&text)?;
|
||||
|
||||
// Send to internal channel
|
||||
if let Err(e) = self.tx.send(stream_message) {
|
||||
error!("Failed to send message to internal channel: {}", e);
|
||||
// Parse the message according to Polymarket's `event_type` format
|
||||
let stream_messages = crate::decode::parse_stream_messages(&text)?;
|
||||
for stream_message in stream_messages {
|
||||
self.enqueue(stream_message);
|
||||
}
|
||||
|
||||
self.stats.messages_received += 1;
|
||||
@@ -286,115 +326,10 @@ impl WebSocketStream {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse Polymarket WebSocket message format
|
||||
/// Parse Polymarket WebSocket message(s) in `event_type` format.
|
||||
#[allow(dead_code)]
|
||||
fn parse_polymarket_message(&self, text: &str) -> Result<StreamMessage> {
|
||||
let value: Value = serde_json::from_str(text).map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse WebSocket message: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Extract message type
|
||||
let message_type = value.get("type").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
PolyfillError::parse("Missing 'type' field in WebSocket message", None)
|
||||
})?;
|
||||
|
||||
match message_type {
|
||||
"book_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse book update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::BookUpdate { data })
|
||||
},
|
||||
"trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::Trade { data })
|
||||
},
|
||||
"order_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse order update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::OrderUpdate { data })
|
||||
},
|
||||
"user_order_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse user order update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::UserOrderUpdate { data })
|
||||
},
|
||||
"user_trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse user trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::UserTrade { data })
|
||||
},
|
||||
"market_book_update" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse market book update: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::MarketBookUpdate { data })
|
||||
},
|
||||
"market_trade" => {
|
||||
let data =
|
||||
serde_json::from_value(value.get("data").unwrap_or(&Value::Null).clone())
|
||||
.map_err(|e| {
|
||||
PolyfillError::parse(
|
||||
format!("Failed to parse market trade: {}", e),
|
||||
Some(Box::new(e)),
|
||||
)
|
||||
})?;
|
||||
Ok(StreamMessage::MarketTrade { data })
|
||||
},
|
||||
"heartbeat" => {
|
||||
let timestamp = value
|
||||
.get("timestamp")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|ts| chrono::DateTime::from_timestamp(ts as i64, 0).unwrap_or_default())
|
||||
.unwrap_or_else(Utc::now);
|
||||
Ok(StreamMessage::Heartbeat { timestamp })
|
||||
},
|
||||
_ => {
|
||||
warn!("Unknown message type: {}", message_type);
|
||||
// Return heartbeat as fallback
|
||||
Ok(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
})
|
||||
},
|
||||
}
|
||||
fn parse_polymarket_messages(&self, text: &str) -> Result<Vec<StreamMessage>> {
|
||||
crate::decode::parse_stream_messages(text)
|
||||
}
|
||||
|
||||
/// Reconnect with exponential backoff
|
||||
@@ -445,37 +380,234 @@ impl WebSocketStream {
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_send_pong(
|
||||
connection: &mut tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>,
|
||||
cx: &mut Context<'_>,
|
||||
data: Vec<u8>,
|
||||
) -> Poll<Result<()>> {
|
||||
ready!(connection.poll_ready_unpin(cx)).map_err(|e| {
|
||||
PolyfillError::stream(
|
||||
format!("Failed to prepare pong: {}", e),
|
||||
crate::errors::StreamErrorKind::MessageCorrupted,
|
||||
)
|
||||
})?;
|
||||
connection
|
||||
.start_send_unpin(tokio_tungstenite::tungstenite::Message::Pong(data))
|
||||
.map_err(|e| {
|
||||
PolyfillError::stream(
|
||||
format!("Failed to send pong: {}", e),
|
||||
crate::errors::StreamErrorKind::MessageCorrupted,
|
||||
)
|
||||
})?;
|
||||
ready!(connection.poll_flush_unpin(cx)).map_err(|e| {
|
||||
PolyfillError::stream(
|
||||
format!("Failed to flush pong: {}", e),
|
||||
crate::errors::StreamErrorKind::MessageCorrupted,
|
||||
)
|
||||
})?;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
/// WebSocket stream wrapper that applies `book` updates directly into an [`crate::book::OrderBookManager`].
|
||||
///
|
||||
/// This bypasses `StreamMessage` decoding (serde/DOM parsing) for the `book` hot path by using
|
||||
/// [`WsBookUpdateProcessor`]. Non-`book` WS payloads are ignored.
|
||||
///
|
||||
/// Note: the underlying WS transport may still allocate when producing `Message::Text(String)`.
|
||||
pub struct WebSocketBookApplier<'a> {
|
||||
stream: WebSocketStream,
|
||||
books: &'a crate::book::OrderBookManager,
|
||||
processor: WsBookUpdateProcessor,
|
||||
}
|
||||
|
||||
impl WebSocketStream {
|
||||
/// Convert this stream into a book-applier stream.
|
||||
///
|
||||
/// The caller is expected to "warm up" the [`crate::book::OrderBookManager`] by creating books for all
|
||||
/// subscribed asset IDs ahead of time. Missing books are treated as an error.
|
||||
pub fn into_book_applier<'a>(
|
||||
mut self,
|
||||
books: &'a crate::book::OrderBookManager,
|
||||
processor: WsBookUpdateProcessor,
|
||||
) -> WebSocketBookApplier<'a> {
|
||||
// Drop any pre-parsed messages to avoid mixing the two streaming modes.
|
||||
self.pending.clear();
|
||||
WebSocketBookApplier {
|
||||
stream: self,
|
||||
books,
|
||||
processor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> WebSocketBookApplier<'a> {
|
||||
/// Access the underlying WebSocket stream (e.g., for subscribe/unsubscribe calls).
|
||||
pub fn stream_mut(&mut self) -> &mut WebSocketStream {
|
||||
&mut self.stream
|
||||
}
|
||||
|
||||
/// Current WebSocket connection stats.
|
||||
pub fn stream_stats(&self) -> StreamStats {
|
||||
self.stream.stats.clone()
|
||||
}
|
||||
|
||||
/// Access the hot-path processor (e.g., to reuse it across connections).
|
||||
pub fn processor_mut(&mut self) -> &mut WsBookUpdateProcessor {
|
||||
&mut self.processor
|
||||
}
|
||||
|
||||
/// Apply a single WS text payload (useful for custom transports and for testing).
|
||||
///
|
||||
/// This convenience method consumes an owned `String`, so it may release that
|
||||
/// buffer after processing. Use [`Self::apply_bytes_message`] for the
|
||||
/// allocation-sensitive path when the caller owns a reusable mutable buffer.
|
||||
pub fn apply_text_message(&mut self, text: String) -> Result<WsBookApplyStats> {
|
||||
let stats = self.processor.process_text(text, self.books)?;
|
||||
self.stream.stats.messages_received += 1;
|
||||
self.stream.stats.last_message_time = Some(Utc::now());
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
/// Apply a single WS payload from a caller-owned mutable byte buffer.
|
||||
///
|
||||
/// The buffer is mutated by `simd-json`. After processor warmup, this is the
|
||||
/// allocation-sensitive book-applier entry point because no owned message buffer
|
||||
/// is created or dropped by this method.
|
||||
pub fn apply_bytes_message(&mut self, bytes: &mut [u8]) -> Result<WsBookApplyStats> {
|
||||
let stats = self.processor.process_bytes(bytes, self.books)?;
|
||||
self.stream.stats.messages_received += 1;
|
||||
self.stream.stats.last_message_time = Some(Utc::now());
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Stream for WebSocketBookApplier<'a> {
|
||||
type Item = Result<WsBookApplyStats>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
loop {
|
||||
let Some(connection) = &mut self.stream.connection else {
|
||||
return Poll::Ready(None);
|
||||
};
|
||||
|
||||
match connection.poll_next_unpin(cx) {
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Some(Ok(msg))) => match msg {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
match self.apply_text_message(text) {
|
||||
Ok(stats) => {
|
||||
if stats.book_messages == 0 {
|
||||
continue;
|
||||
}
|
||||
return Poll::Ready(Some(Ok(stats)));
|
||||
},
|
||||
Err(e) => {
|
||||
self.stream.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
},
|
||||
}
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||
info!("WebSocket connection closed by server");
|
||||
self.stream.connection = None;
|
||||
return Poll::Ready(None);
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Ping(data) => {
|
||||
match poll_send_pong(connection, cx, data) {
|
||||
Poll::Ready(Ok(())) => continue,
|
||||
Poll::Ready(Err(e)) => {
|
||||
self.stream.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
},
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Binary(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Frame(_) => continue,
|
||||
},
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
self.stream.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e.into())));
|
||||
},
|
||||
Poll::Ready(None) => {
|
||||
info!("WebSocket stream ended");
|
||||
return Poll::Ready(None);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for WebSocketStream {
|
||||
type Item = Result<StreamMessage>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
// First check internal channel
|
||||
if let Poll::Ready(Some(message)) = self.rx.poll_recv(cx) {
|
||||
return Poll::Ready(Some(Ok(message)));
|
||||
}
|
||||
loop {
|
||||
if let Some(message) = self.pending.pop_front() {
|
||||
return Poll::Ready(Some(Ok(message)));
|
||||
}
|
||||
|
||||
let Some(connection) = &mut self.connection else {
|
||||
return Poll::Ready(None);
|
||||
};
|
||||
|
||||
// Then check WebSocket connection
|
||||
if let Some(connection) = &mut self.connection {
|
||||
match connection.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(_message))) => {
|
||||
// Simplified message handling
|
||||
Poll::Ready(Some(Ok(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
})))
|
||||
Poll::Pending => return Poll::Pending,
|
||||
Poll::Ready(Some(Ok(ws_message))) => match ws_message {
|
||||
tokio_tungstenite::tungstenite::Message::Text(text) => {
|
||||
match crate::decode::parse_stream_messages(&text) {
|
||||
Ok(messages) => {
|
||||
let mut iter = messages.into_iter();
|
||||
let Some(first) = iter.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for msg in iter {
|
||||
self.enqueue(msg);
|
||||
}
|
||||
self.stats.messages_received += 1;
|
||||
self.stats.last_message_time = Some(Utc::now());
|
||||
return Poll::Ready(Some(Ok(first)));
|
||||
},
|
||||
Err(e) => {
|
||||
self.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
},
|
||||
}
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||
info!("WebSocket connection closed by server");
|
||||
self.connection = None;
|
||||
return Poll::Ready(None);
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Ping(data) => {
|
||||
match poll_send_pong(connection, cx, data) {
|
||||
Poll::Ready(Ok(())) => continue,
|
||||
Poll::Ready(Err(e)) => {
|
||||
self.stats.errors += 1;
|
||||
return Poll::Ready(Some(Err(e)));
|
||||
},
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
},
|
||||
tokio_tungstenite::tungstenite::Message::Pong(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Binary(_) => continue,
|
||||
tokio_tungstenite::tungstenite::Message::Frame(_) => continue,
|
||||
},
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
self.stats.errors += 1;
|
||||
Poll::Ready(Some(Err(e.into())))
|
||||
return Poll::Ready(Some(Err(e.into())));
|
||||
},
|
||||
Poll::Ready(None) => {
|
||||
info!("WebSocket stream ended");
|
||||
Poll::Ready(None)
|
||||
return Poll::Ready(None);
|
||||
},
|
||||
Poll::Pending => Poll::Pending,
|
||||
}
|
||||
} else {
|
||||
Poll::Ready(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -568,6 +700,7 @@ impl MarketStream for MockStream {
|
||||
messages_received: self.messages.len() as u64,
|
||||
messages_sent: 0,
|
||||
errors: self.messages.iter().filter(|m| m.is_err()).count() as u64,
|
||||
dropped_messages: 0,
|
||||
last_message_time: None,
|
||||
connection_uptime: std::time::Duration::ZERO,
|
||||
reconnect_count: 0,
|
||||
@@ -579,8 +712,7 @@ impl MarketStream for MockStream {
|
||||
#[allow(dead_code)]
|
||||
pub struct StreamManager {
|
||||
streams: Vec<Box<dyn MarketStream>>,
|
||||
message_tx: mpsc::UnboundedSender<StreamMessage>,
|
||||
message_rx: mpsc::UnboundedReceiver<StreamMessage>,
|
||||
message_subscribers: Mutex<Vec<mpsc::UnboundedSender<StreamMessage>>>,
|
||||
}
|
||||
|
||||
impl Default for StreamManager {
|
||||
@@ -591,12 +723,9 @@ impl Default for StreamManager {
|
||||
|
||||
impl StreamManager {
|
||||
pub fn new() -> Self {
|
||||
let (message_tx, message_rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
streams: Vec::new(),
|
||||
message_tx,
|
||||
message_rx,
|
||||
message_subscribers: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,42 +734,42 @@ impl StreamManager {
|
||||
}
|
||||
|
||||
pub fn get_message_receiver(&mut self) -> mpsc::UnboundedReceiver<StreamMessage> {
|
||||
// Note: UnboundedReceiver doesn't implement Clone
|
||||
// In a real implementation, you'd want to use a different approach
|
||||
// For now, we'll return a dummy receiver
|
||||
let (_, rx) = mpsc::unbounded_channel();
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
self.message_subscribers.lock().push(tx);
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn broadcast_message(&self, message: StreamMessage) -> Result<()> {
|
||||
self.message_tx
|
||||
.send(message)
|
||||
.map_err(|e| PolyfillError::internal("Failed to broadcast message", e))
|
||||
let mut subscribers = self.message_subscribers.lock();
|
||||
subscribers.retain(|tx| tx.send(message.clone()).is_ok());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal::Decimal;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn test_mock_stream() {
|
||||
let mut stream = MockStream::new();
|
||||
|
||||
// Add some test messages
|
||||
stream.add_message(StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
});
|
||||
stream.add_message(StreamMessage::BookUpdate {
|
||||
data: OrderDelta {
|
||||
token_id: "test".to_string(),
|
||||
timestamp: Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: rust_decimal_macros::dec!(0.5),
|
||||
size: rust_decimal_macros::dec!(100),
|
||||
sequence: 1,
|
||||
},
|
||||
});
|
||||
stream.add_message(StreamMessage::Book(BookUpdate {
|
||||
asset_id: "1".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_890,
|
||||
bids: vec![],
|
||||
asks: vec![],
|
||||
hash: None,
|
||||
}));
|
||||
stream.add_message(StreamMessage::PriceChange(PriceChange {
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_891,
|
||||
price_changes: vec![],
|
||||
}));
|
||||
|
||||
assert!(stream.is_connected());
|
||||
assert_eq!(stream.get_stats().messages_received, 2);
|
||||
@@ -651,11 +780,90 @@ mod tests {
|
||||
let mut manager = StreamManager::new();
|
||||
let mock_stream = Box::new(MockStream::new());
|
||||
manager.add_stream(mock_stream);
|
||||
let mut first_rx = manager.get_message_receiver();
|
||||
let mut second_rx = manager.get_message_receiver();
|
||||
|
||||
// Test message broadcasting
|
||||
let message = StreamMessage::Heartbeat {
|
||||
timestamp: Utc::now(),
|
||||
};
|
||||
let message = StreamMessage::Book(BookUpdate {
|
||||
asset_id: "1".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_890,
|
||||
bids: vec![],
|
||||
asks: vec![],
|
||||
hash: None,
|
||||
});
|
||||
assert!(manager.broadcast_message(message).is_ok());
|
||||
|
||||
assert!(matches!(
|
||||
first_rx.try_recv().unwrap(),
|
||||
StreamMessage::Book(BookUpdate { asset_id, .. }) if asset_id == "1"
|
||||
));
|
||||
assert!(matches!(
|
||||
second_rx.try_recv().unwrap(),
|
||||
StreamMessage::Book(BookUpdate { asset_id, .. }) if asset_id == "1"
|
||||
));
|
||||
|
||||
drop(first_rx);
|
||||
let message = StreamMessage::PriceChange(PriceChange {
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1_234_567_891,
|
||||
price_changes: vec![],
|
||||
});
|
||||
assert!(manager.broadcast_message(message).is_ok());
|
||||
|
||||
assert!(matches!(
|
||||
second_rx.try_recv().unwrap(),
|
||||
StreamMessage::PriceChange(PriceChange { market, .. }) if market == "0xabc"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_book_applier_apply_text_message_updates_book() {
|
||||
let books = crate::book::OrderBookManager::new(64);
|
||||
let _ = books.get_or_create_book("12345").unwrap();
|
||||
|
||||
let processor = WsBookUpdateProcessor::new(1024);
|
||||
let stream = WebSocketStream::new("wss://example.com/ws");
|
||||
let mut applier = stream.into_book_applier(&books, processor);
|
||||
|
||||
let msg = r#"{"event_type":"book","asset_id":"12345","timestamp":1,"bids":[{"price":"0.75","size":"10"}],"asks":[{"price":"0.76","size":"5"}]}"#.to_string();
|
||||
let stats = applier.apply_text_message(msg).unwrap();
|
||||
assert_eq!(stats.book_messages, 1);
|
||||
assert_eq!(stats.book_levels_applied, 2);
|
||||
|
||||
let snapshot = books.get_book("12345").unwrap();
|
||||
assert_eq!(snapshot.bids.len(), 1);
|
||||
assert_eq!(snapshot.asks.len(), 1);
|
||||
assert_eq!(snapshot.bids[0].price, Decimal::from_str("0.75").unwrap());
|
||||
assert_eq!(snapshot.bids[0].size, Decimal::from_str("10").unwrap());
|
||||
assert_eq!(snapshot.asks[0].price, Decimal::from_str("0.76").unwrap());
|
||||
assert_eq!(snapshot.asks[0].size, Decimal::from_str("5").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_websocket_book_applier_replaces_snapshot() {
|
||||
let books = crate::book::OrderBookManager::new(64);
|
||||
let _ = books.get_or_create_book("12345").unwrap();
|
||||
|
||||
let processor = WsBookUpdateProcessor::new(1024);
|
||||
let stream = WebSocketStream::new("wss://example.com/ws");
|
||||
let mut applier = stream.into_book_applier(&books, processor);
|
||||
|
||||
let first = r#"{"event_type":"book","asset_id":"12345","timestamp":"1757908892351","bids":[{"price":"0.74","size":"8"},{"price":"0.75","size":"10"}],"asks":[{"price":"0.76","size":"5"},{"price":"0.77","size":"4"}]}"#.to_string();
|
||||
applier.apply_text_message(first).unwrap();
|
||||
|
||||
let second = r#"{"event_type":"book","asset_id":"12345","timestamp":"1757908892352","bids":[{"price":"0.75","size":"11"}],"asks":[{"price":"0.76","size":"6"}]}"#.to_string();
|
||||
let stats = applier.apply_text_message(second).unwrap();
|
||||
assert_eq!(stats.book_messages, 1);
|
||||
assert_eq!(stats.book_levels_applied, 2);
|
||||
|
||||
let snapshot = books.get_book("12345").unwrap();
|
||||
assert_eq!(snapshot.timestamp.timestamp_millis(), 1_757_908_892_352);
|
||||
assert_eq!(snapshot.bids.len(), 1);
|
||||
assert_eq!(snapshot.asks.len(), 1);
|
||||
assert_eq!(snapshot.bids[0].price, Decimal::from_str("0.75").unwrap());
|
||||
assert_eq!(snapshot.bids[0].size, Decimal::from_str("11").unwrap());
|
||||
assert_eq!(snapshot.asks[0].price, Decimal::from_str("0.76").unwrap());
|
||||
assert_eq!(snapshot.asks[0].size, Decimal::from_str("6").unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
+998
-91
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,475 @@
|
||||
//! Zero-allocation-ish WebSocket hot-path processing.
|
||||
//!
|
||||
//! This module is focused on the "decode + apply" path for WS `book` events:
|
||||
//! after warmup, existing-level happy-path processing should not perform heap
|
||||
//! allocation, reallocation, or deallocation.
|
||||
//!
|
||||
//! Important: using the current tokio-tungstenite transport, the *network layer*
|
||||
//! may still allocate when producing `Message::Text(String)`. This module aims to
|
||||
//! make the *processing* layer allocation-free so we can enforce it with tests.
|
||||
|
||||
use crate::book::{OrderBookManager, ParsedBookLevel};
|
||||
use crate::errors::{PolyfillError, Result};
|
||||
use crate::types::{Price, Qty, Side, MAX_PRICE_TICKS, MAX_QTY, MIN_PRICE_TICKS, SCALE_FACTOR};
|
||||
use simd_json::prelude::*;
|
||||
|
||||
/// Summary of what happened while processing a WS payload.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WsBookApplyStats {
|
||||
pub book_messages: usize,
|
||||
pub book_levels_applied: usize,
|
||||
}
|
||||
|
||||
/// In-place WS `book` message processor built on `simd-json`'s tape API.
|
||||
///
|
||||
/// This avoids building a DOM (which allocates for arrays/objects) by decoding into a
|
||||
/// reusable tape, then traversing it to extract the fields needed for order book updates.
|
||||
pub struct WsBookUpdateProcessor {
|
||||
buffers: simd_json::Buffers,
|
||||
tape: Option<simd_json::Tape<'static>>,
|
||||
parsed_levels: Vec<ParsedBookLevel>,
|
||||
}
|
||||
|
||||
impl WsBookUpdateProcessor {
|
||||
/// Create a new processor.
|
||||
///
|
||||
/// `input_len_hint` should be set to the typical WS message size to reduce warmup reallocs.
|
||||
pub fn new(input_len_hint: usize) -> Self {
|
||||
Self {
|
||||
buffers: simd_json::Buffers::new(input_len_hint),
|
||||
// Store an empty tape with a `'static` lifetime so we can reuse its allocation.
|
||||
tape: Some(simd_json::Tape::null().reset()),
|
||||
parsed_levels: Vec::with_capacity((input_len_hint / 32).max(8)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a WS payload in-place (bytes will be mutated by the JSON parser).
|
||||
pub fn process_bytes(
|
||||
&mut self,
|
||||
bytes: &mut [u8],
|
||||
books: &OrderBookManager,
|
||||
) -> Result<WsBookApplyStats> {
|
||||
let mut tape = self
|
||||
.tape
|
||||
.take()
|
||||
.expect("WsBookUpdateProcessor tape must be present")
|
||||
.reset();
|
||||
|
||||
let result = match simd_json::fill_tape(bytes, &mut self.buffers, &mut tape) {
|
||||
Ok(()) => {
|
||||
let root = tape.as_value();
|
||||
process_root_value(root, books, &mut self.parsed_levels)
|
||||
},
|
||||
Err(e) => Err(PolyfillError::parse(
|
||||
"Failed to parse WebSocket JSON",
|
||||
Some(Box::new(e)),
|
||||
)),
|
||||
};
|
||||
|
||||
// Reset the tape to detach lifetimes and keep capacity for reuse.
|
||||
self.tape = Some(tape.reset());
|
||||
result
|
||||
}
|
||||
|
||||
/// Convenience: process an owned text message without allocating an additional buffer.
|
||||
pub fn process_text(
|
||||
&mut self,
|
||||
text: String,
|
||||
books: &OrderBookManager,
|
||||
) -> Result<WsBookApplyStats> {
|
||||
let mut bytes = text.into_bytes();
|
||||
self.process_bytes(bytes.as_mut_slice(), books)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_root_value<'tape, 'input>(
|
||||
value: simd_json::tape::Value<'tape, 'input>,
|
||||
books: &OrderBookManager,
|
||||
parsed_levels: &mut Vec<ParsedBookLevel>,
|
||||
) -> Result<WsBookApplyStats> {
|
||||
if let Some(obj) = value.as_object() {
|
||||
return process_stream_object(obj, books, parsed_levels);
|
||||
}
|
||||
|
||||
let Some(arr) = value.as_array() else {
|
||||
return Ok(WsBookApplyStats::default());
|
||||
};
|
||||
|
||||
let mut total = WsBookApplyStats::default();
|
||||
for elem in arr.iter() {
|
||||
let Some(obj) = elem.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let stats = process_stream_object(obj, books, parsed_levels)?;
|
||||
total.book_messages += stats.book_messages;
|
||||
total.book_levels_applied += stats.book_levels_applied;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
fn process_stream_object<'tape, 'input>(
|
||||
obj: simd_json::tape::Object<'tape, 'input>,
|
||||
books: &OrderBookManager,
|
||||
parsed_levels: &mut Vec<ParsedBookLevel>,
|
||||
) -> Result<WsBookApplyStats> {
|
||||
let Some(event_type) = obj.get("event_type").and_then(|v| v.into_string()) else {
|
||||
return Ok(WsBookApplyStats::default());
|
||||
};
|
||||
|
||||
if event_type != "book" {
|
||||
return Ok(WsBookApplyStats::default());
|
||||
}
|
||||
|
||||
let asset_id = obj
|
||||
.get("asset_id")
|
||||
.and_then(|v| v.into_string())
|
||||
.ok_or_else(|| PolyfillError::parse("Missing asset_id", None))?;
|
||||
|
||||
let timestamp_value = obj
|
||||
.get("timestamp")
|
||||
.ok_or_else(|| PolyfillError::parse("Missing timestamp", None))?;
|
||||
let timestamp = parse_u64(timestamp_value)
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid timestamp", None))?;
|
||||
let hash = obj.get("hash").and_then(|v| v.into_string());
|
||||
|
||||
let bids = obj
|
||||
.get("bids")
|
||||
.ok_or_else(|| PolyfillError::parse("Missing bids", None))?
|
||||
.as_array()
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid bids", None))?;
|
||||
let asks = obj
|
||||
.get("asks")
|
||||
.ok_or_else(|| PolyfillError::parse("Missing asks", None))?
|
||||
.as_array()
|
||||
.ok_or_else(|| PolyfillError::parse("Invalid asks", None))?;
|
||||
|
||||
parsed_levels.clear();
|
||||
|
||||
let should_parse = books.with_book_mut(asset_id, |book| {
|
||||
book.should_apply_ws_book_update(asset_id, timestamp, hash)
|
||||
})?;
|
||||
if !should_parse {
|
||||
return Ok(WsBookApplyStats {
|
||||
book_messages: 1,
|
||||
book_levels_applied: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let parsed_count = match (|| {
|
||||
collect_levels(Side::BUY, bids, parsed_levels)?;
|
||||
collect_levels(Side::SELL, asks, parsed_levels)?;
|
||||
Ok(parsed_levels.len())
|
||||
})() {
|
||||
Ok(parsed_count) => parsed_count,
|
||||
Err(error) => {
|
||||
parsed_levels.clear();
|
||||
return Err(error);
|
||||
},
|
||||
};
|
||||
|
||||
let result = books.with_book_mut(asset_id, |book| {
|
||||
if book.apply_ws_book_snapshot_fast(asset_id, timestamp, hash, parsed_levels)? {
|
||||
Ok(parsed_count)
|
||||
} else {
|
||||
Ok(0)
|
||||
}
|
||||
});
|
||||
parsed_levels.clear();
|
||||
let levels_applied = result?;
|
||||
|
||||
Ok(WsBookApplyStats {
|
||||
book_messages: 1,
|
||||
book_levels_applied: levels_applied,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64<'tape, 'input>(value: simd_json::tape::Value<'tape, 'input>) -> Option<u64> {
|
||||
value
|
||||
.as_u64()
|
||||
.or_else(|| value.into_string().and_then(|s| s.parse::<u64>().ok()))
|
||||
}
|
||||
|
||||
fn collect_levels<'tape, 'input>(
|
||||
side: Side,
|
||||
levels: simd_json::tape::Array<'tape, 'input>,
|
||||
parsed_levels: &mut Vec<ParsedBookLevel>,
|
||||
) -> Result<usize> {
|
||||
let mut applied = 0usize;
|
||||
for level in levels.iter() {
|
||||
let Some(obj) = level.as_object() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let price_str = obj
|
||||
.get("price")
|
||||
.and_then(|v| v.into_string())
|
||||
.ok_or_else(|| PolyfillError::parse("Missing price", None))?;
|
||||
let size_str = obj
|
||||
.get("size")
|
||||
.and_then(|v| v.into_string())
|
||||
.ok_or_else(|| PolyfillError::parse("Missing size", None))?;
|
||||
|
||||
let price_ticks = parse_price_ticks_4dp(price_str)?;
|
||||
let size_units = parse_qty_scaled_4dp(size_str)?;
|
||||
|
||||
parsed_levels.push(ParsedBookLevel {
|
||||
side,
|
||||
price_ticks,
|
||||
size_units,
|
||||
});
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
Ok(applied)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_price_ticks_4dp(value: &str) -> Result<Price> {
|
||||
let scaled = parse_scaled_4_u64(value)?;
|
||||
if scaled < MIN_PRICE_TICKS as u64 {
|
||||
return Err(PolyfillError::validation("Invalid price"));
|
||||
}
|
||||
if scaled > MAX_PRICE_TICKS as u64 {
|
||||
return Err(PolyfillError::validation("Invalid price"));
|
||||
}
|
||||
|
||||
Ok(scaled as Price)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_qty_scaled_4dp(value: &str) -> Result<Qty> {
|
||||
let scaled = parse_scaled_4_u64(value)?;
|
||||
if scaled > MAX_QTY as u64 {
|
||||
return Err(PolyfillError::validation("Invalid size"));
|
||||
}
|
||||
|
||||
Ok(scaled as Qty)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn parse_scaled_4_u64(value: &str) -> Result<u64> {
|
||||
if value.is_empty() {
|
||||
return Err(PolyfillError::parse("invalid decimal", None));
|
||||
}
|
||||
|
||||
let mut whole = 0u64;
|
||||
let mut frac = 0u64;
|
||||
let mut frac_digits = 0u8;
|
||||
let mut seen_dot = false;
|
||||
let mut seen_digit = false;
|
||||
|
||||
for &byte in value.as_bytes() {
|
||||
match byte {
|
||||
b'0'..=b'9' => {
|
||||
seen_digit = true;
|
||||
let digit = (byte - b'0') as u64;
|
||||
if seen_dot {
|
||||
if frac_digits >= 4 {
|
||||
if digit != 0 {
|
||||
return Err(PolyfillError::parse("too many decimal places", None));
|
||||
}
|
||||
} else {
|
||||
frac = frac
|
||||
.checked_mul(10)
|
||||
.and_then(|x| x.checked_add(digit))
|
||||
.ok_or_else(|| PolyfillError::parse("scaled value overflow", None))?;
|
||||
frac_digits += 1;
|
||||
}
|
||||
} else {
|
||||
whole = whole
|
||||
.checked_mul(10)
|
||||
.and_then(|x| x.checked_add(digit))
|
||||
.ok_or_else(|| PolyfillError::parse("scaled value overflow", None))?;
|
||||
}
|
||||
},
|
||||
b'.' if !seen_dot => {
|
||||
seen_dot = true;
|
||||
},
|
||||
_ => return Err(PolyfillError::parse("invalid decimal", None)),
|
||||
}
|
||||
}
|
||||
|
||||
if !seen_digit {
|
||||
return Err(PolyfillError::parse("invalid decimal", None));
|
||||
}
|
||||
|
||||
while frac_digits < 4 {
|
||||
frac *= 10;
|
||||
frac_digits += 1;
|
||||
}
|
||||
|
||||
whole
|
||||
.checked_mul(SCALE_FACTOR as u64)
|
||||
.and_then(|x| x.checked_add(frac))
|
||||
.ok_or_else(|| PolyfillError::parse("scaled value overflow", None))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{BookUpdate, OrderSummary};
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
#[test]
|
||||
fn fixed_point_parser_matches_expected_price_ticks() {
|
||||
assert_eq!(parse_price_ticks_4dp("0.6543").unwrap(), 6543);
|
||||
assert_eq!(parse_price_ticks_4dp("1.0000").unwrap(), 10_000);
|
||||
assert_eq!(parse_price_ticks_4dp("1.000000").unwrap(), 10_000);
|
||||
assert!(parse_price_ticks_4dp("0.00005").is_err());
|
||||
assert!(parse_price_ticks_4dp("0").is_err());
|
||||
assert!(parse_price_ticks_4dp("-0.1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_point_parser_matches_expected_qty_units() {
|
||||
assert_eq!(parse_qty_scaled_4dp("100.0").unwrap(), 1_000_000);
|
||||
assert_eq!(parse_qty_scaled_4dp("0.0000").unwrap(), 0);
|
||||
assert_eq!(parse_qty_scaled_4dp("1.234500").unwrap(), 12_345);
|
||||
assert!(parse_qty_scaled_4dp("-50.5").is_err());
|
||||
assert!(parse_qty_scaled_4dp("0.00004").is_err());
|
||||
assert!(parse_qty_scaled_4dp("0.00005").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_recovers_after_parse_and_validation_errors() {
|
||||
let books = OrderBookManager::new(10);
|
||||
books.get_or_create_book("test_asset_id").unwrap();
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
|
||||
let mut malformed_json = br#"{"event_type":"#.to_vec();
|
||||
assert!(processor
|
||||
.process_bytes(malformed_json.as_mut_slice(), &books)
|
||||
.is_err());
|
||||
|
||||
let mut invalid_level = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"bids":[{"price":"0.75001","size":"1.0000"}],"asks":[]}"#.to_vec();
|
||||
assert!(processor
|
||||
.process_bytes(invalid_level.as_mut_slice(), &books)
|
||||
.is_err());
|
||||
|
||||
let mut valid_update = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1001,"bids":[{"price":"0.7500","size":"1.0000"}],"asks":[]}"#.to_vec();
|
||||
let stats = processor
|
||||
.process_bytes(valid_update.as_mut_slice(), &books)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stats.book_messages, 1);
|
||||
assert_eq!(stats.book_levels_applied, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_error_keeps_existing_snapshot() {
|
||||
let books = OrderBookManager::new(10);
|
||||
books.get_or_create_book("test_asset_id").unwrap();
|
||||
books
|
||||
.apply_book_update(&BookUpdate {
|
||||
asset_id: "test_asset_id".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1000,
|
||||
bids: vec![OrderSummary {
|
||||
price: dec!(0.50),
|
||||
size: dec!(10),
|
||||
}],
|
||||
asks: vec![OrderSummary {
|
||||
price: dec!(0.60),
|
||||
size: dec!(20),
|
||||
}],
|
||||
hash: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
let mut invalid_snapshot = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1001,"bids":[{"price":"0.5100","size":"11.0000"},{"price":"0.51001","size":"12.0000"}],"asks":[{"price":"0.6100","size":"21.0000"}]}"#.to_vec();
|
||||
assert!(processor
|
||||
.process_bytes(invalid_snapshot.as_mut_slice(), &books)
|
||||
.is_err());
|
||||
|
||||
let snapshot = books.get_book("test_asset_id").unwrap();
|
||||
assert_eq!(snapshot.sequence, 0);
|
||||
assert_eq!(snapshot.last_delta_sequence, 0);
|
||||
assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000);
|
||||
assert_eq!(snapshot.timestamp.timestamp_millis(), 1000);
|
||||
assert_eq!(snapshot.bids.len(), 1);
|
||||
assert_eq!(snapshot.asks.len(), 1);
|
||||
assert_eq!(snapshot.bids[0].price, dec!(0.50));
|
||||
assert_eq!(snapshot.bids[0].size, dec!(10));
|
||||
assert_eq!(snapshot.asks[0].price, dec!(0.60));
|
||||
assert_eq!(snapshot.asks[0].size, dec!(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_missing_side_keeps_existing_snapshot() {
|
||||
let books = OrderBookManager::new(10);
|
||||
books.get_or_create_book("test_asset_id").unwrap();
|
||||
books
|
||||
.apply_book_update(&BookUpdate {
|
||||
asset_id: "test_asset_id".to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 1000,
|
||||
bids: vec![OrderSummary {
|
||||
price: dec!(0.50),
|
||||
size: dec!(10),
|
||||
}],
|
||||
asks: vec![OrderSummary {
|
||||
price: dec!(0.60),
|
||||
size: dec!(20),
|
||||
}],
|
||||
hash: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
let mut missing_asks = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1001,"bids":[{"price":"0.5100","size":"11.0000"}]}"#.to_vec();
|
||||
assert!(processor
|
||||
.process_bytes(missing_asks.as_mut_slice(), &books)
|
||||
.is_err());
|
||||
|
||||
let snapshot = books.get_book("test_asset_id").unwrap();
|
||||
assert_eq!(snapshot.sequence, 0);
|
||||
assert_eq!(snapshot.last_delta_sequence, 0);
|
||||
assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000);
|
||||
assert_eq!(snapshot.timestamp.timestamp_millis(), 1000);
|
||||
assert_eq!(snapshot.bids.len(), 1);
|
||||
assert_eq!(snapshot.asks.len(), 1);
|
||||
assert_eq!(snapshot.bids[0].price, dec!(0.50));
|
||||
assert_eq!(snapshot.asks[0].price, dec!(0.60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn processor_allows_same_timestamp_with_different_hash() {
|
||||
let books = OrderBookManager::new(10);
|
||||
books.get_or_create_book("test_asset_id").unwrap();
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
|
||||
let mut first = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"hash":"hash_a","bids":[{"price":"0.5000","size":"10.0000"}],"asks":[{"price":"0.6000","size":"20.0000"}]}"#.to_vec();
|
||||
let first_stats = processor
|
||||
.process_bytes(first.as_mut_slice(), &books)
|
||||
.unwrap();
|
||||
assert_eq!(first_stats.book_levels_applied, 2);
|
||||
|
||||
let mut second = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"hash":"hash_b","bids":[{"price":"0.5100","size":"11.0000"}],"asks":[{"price":"0.6100","size":"21.0000"}]}"#.to_vec();
|
||||
let second_stats = processor
|
||||
.process_bytes(second.as_mut_slice(), &books)
|
||||
.unwrap();
|
||||
assert_eq!(second_stats.book_levels_applied, 2);
|
||||
|
||||
let snapshot = books.get_book("test_asset_id").unwrap();
|
||||
assert_eq!(snapshot.sequence, 0);
|
||||
assert_eq!(snapshot.last_delta_sequence, 0);
|
||||
assert_eq!(snapshot.last_snapshot_timestamp_ms, 1000);
|
||||
assert_eq!(snapshot.timestamp.timestamp_millis(), 1000);
|
||||
assert_eq!(snapshot.bids[0].price, dec!(0.51));
|
||||
assert_eq!(snapshot.bids[0].size, dec!(11));
|
||||
assert_eq!(snapshot.asks[0].price, dec!(0.61));
|
||||
assert_eq!(snapshot.asks[0].size, dec!(21));
|
||||
|
||||
let mut duplicate = br#"{"event_type":"book","asset_id":"test_asset_id","market":"0xabc","timestamp":1000,"hash":"hash_b","bids":[{"price":"0.5200","size":"12.0000"}],"asks":[{"price":"0.6200","size":"22.0000"}]}"#.to_vec();
|
||||
let duplicate_stats = processor
|
||||
.process_bytes(duplicate.as_mut_slice(), &books)
|
||||
.unwrap();
|
||||
assert_eq!(duplicate_stats.book_levels_applied, 0);
|
||||
|
||||
let snapshot = books.get_book("test_asset_id").unwrap();
|
||||
assert_eq!(snapshot.bids[0].price, dec!(0.51));
|
||||
assert_eq!(snapshot.asks[0].price, dec!(0.61));
|
||||
}
|
||||
}
|
||||
+44
-12
@@ -1,6 +1,6 @@
|
||||
//! Common utilities for integration tests
|
||||
|
||||
use polyfill_rs::{ClobClient, Result};
|
||||
use polyfill_rs::{ClientConfig, ClobClient, Result};
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -8,32 +8,51 @@ use std::time::Duration;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TestConfig {
|
||||
pub host: String,
|
||||
pub chain_id: u64,
|
||||
pub chain: u64,
|
||||
pub private_key: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub api_secret: Option<String>,
|
||||
pub api_passphrase: Option<String>,
|
||||
pub signature_type: Option<u8>,
|
||||
pub funder: Option<String>,
|
||||
pub test_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for TestConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: env::var("POLYMARKET_HOST").unwrap_or_else(|_| "https://clob.polymarket.com".to_string()),
|
||||
chain_id: env::var("POLYMARKET_CHAIN_ID")
|
||||
host: env::var("POLYMARKET_HOST")
|
||||
.unwrap_or_else(|_| "https://clob.polymarket.com".to_string()),
|
||||
chain: env::var("POLYMARKET_CHAIN_ID")
|
||||
.unwrap_or_else(|_| "137".to_string())
|
||||
.parse()
|
||||
.unwrap_or(137),
|
||||
private_key: env::var("POLYMARKET_PRIVATE_KEY").ok(),
|
||||
api_key: env::var("POLYMARKET_API_KEY").ok(),
|
||||
api_secret: env::var("POLYMARKET_API_SECRET").ok(),
|
||||
api_passphrase: env::var("POLYMARKET_API_PASSPHRASE").ok(),
|
||||
api_secret: env::var("POLYMARKET_API_SECRET")
|
||||
.or_else(|_| env::var("POLYMARKET_SECRET"))
|
||||
.ok(),
|
||||
api_passphrase: env::var("POLYMARKET_API_PASSPHRASE")
|
||||
.or_else(|_| env::var("POLYMARKET_PASSPHRASE"))
|
||||
.ok(),
|
||||
signature_type: env::var("POLYMARKET_SIGNATURE_TYPE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u8>().ok()),
|
||||
funder: env::var("POLYMARKET_FUNDER")
|
||||
.or_else(|_| env::var("POLYMARKET_FUNDER_ADDRESS"))
|
||||
.ok(),
|
||||
test_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TestConfig {
|
||||
/// Load a test configuration from environment variables (and a local `.env` file, if present).
|
||||
pub fn from_env() -> Self {
|
||||
dotenvy::dotenv().ok();
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Check if we have authentication credentials
|
||||
pub fn has_auth(&self) -> bool {
|
||||
self.private_key.is_some()
|
||||
@@ -51,19 +70,32 @@ impl TestConfig {
|
||||
|
||||
/// Create an authenticated client for testing
|
||||
pub fn create_auth_client(&self) -> Result<ClobClient> {
|
||||
let private_key = self.private_key.as_ref()
|
||||
.ok_or_else(|| polyfill_rs::PolyfillError::auth("No private key provided", polyfill_rs::errors::AuthErrorKind::InvalidCredentials))?;
|
||||
|
||||
Ok(ClobClient::with_l1_headers(&self.host, private_key, self.chain_id))
|
||||
let private_key = self.private_key.as_ref().ok_or_else(|| {
|
||||
polyfill_rs::PolyfillError::auth(
|
||||
"No private key provided",
|
||||
polyfill_rs::errors::AuthErrorKind::InvalidCredentials,
|
||||
)
|
||||
})?;
|
||||
|
||||
ClobClient::from_config(ClientConfig {
|
||||
base_url: self.host.clone(),
|
||||
chain: self.chain,
|
||||
private_key: Some(private_key.clone()),
|
||||
signature_type: self.signature_type,
|
||||
funder: self.funder.clone(),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Print test configuration (without sensitive data)
|
||||
pub fn print_config(&self) {
|
||||
println!("Test Configuration:");
|
||||
println!(" Host: {}", self.host);
|
||||
println!(" Chain ID: {}", self.chain_id);
|
||||
println!(" Chain ID: {}", self.chain);
|
||||
println!(" Has Auth: {}", self.has_auth());
|
||||
println!(" Has API Creds: {}", self.has_api_creds());
|
||||
println!(" Signature Type: {:?}", self.signature_type);
|
||||
println!(" Has Funder Override: {}", self.funder.is_some());
|
||||
println!(" Timeout: {:?}", self.test_timeout);
|
||||
}
|
||||
}
|
||||
@@ -163,4 +195,4 @@ impl TestReporter {
|
||||
pub fn performance(test_name: &str, duration: Duration) {
|
||||
println!("{} completed in {:?}", test_name, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+314
-154
@@ -2,7 +2,7 @@
|
||||
// These tests hit the real Polymarket API and are ignored by default
|
||||
// Run with: cargo test --test integration_tests -- --ignored --test-threads=1
|
||||
|
||||
use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side};
|
||||
use rust_decimal_macros::dec;
|
||||
use std::env;
|
||||
|
||||
@@ -11,32 +11,80 @@ const CHAIN_ID: u64 = 137;
|
||||
|
||||
fn load_env_vars() -> (String, Option<String>, Option<String>, Option<String>) {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
let api_key = env::var("POLYMARKET_API_KEY").ok();
|
||||
let api_secret = env::var("POLYMARKET_API_SECRET").ok();
|
||||
let api_passphrase = env::var("POLYMARKET_API_PASSPHRASE").ok();
|
||||
|
||||
let api_secret = env::var("POLYMARKET_API_SECRET")
|
||||
.or_else(|_| env::var("POLYMARKET_SECRET"))
|
||||
.ok();
|
||||
let api_passphrase = env::var("POLYMARKET_API_PASSPHRASE")
|
||||
.or_else(|_| env::var("POLYMARKET_PASSPHRASE"))
|
||||
.ok();
|
||||
|
||||
(private_key, api_key, api_secret, api_passphrase)
|
||||
}
|
||||
|
||||
fn env_signature_type() -> Option<u8> {
|
||||
env::var("POLYMARKET_SIGNATURE_TYPE")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
}
|
||||
|
||||
fn env_funder() -> Option<String> {
|
||||
env::var("POLYMARKET_FUNDER")
|
||||
.or_else(|_| env::var("POLYMARKET_FUNDER_ADDRESS"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn bootstrap_client(private_key: &str) -> ClobClient {
|
||||
ClobClient::from_config(ClientConfig {
|
||||
base_url: HOST.to_string(),
|
||||
chain: CHAIN_ID,
|
||||
private_key: Some(private_key.to_string()),
|
||||
signature_type: env_signature_type(),
|
||||
funder: env_funder(),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build bootstrap client")
|
||||
}
|
||||
|
||||
fn authenticated_client(
|
||||
private_key: String,
|
||||
api_credentials: polyfill_rs::ApiCredentials,
|
||||
) -> ClobClient {
|
||||
ClobClient::from_config(ClientConfig {
|
||||
base_url: HOST.to_string(),
|
||||
chain: CHAIN_ID,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(api_credentials),
|
||||
signature_type: env_signature_type(),
|
||||
funder: env_funder(),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_real_api_create_derive_api_key() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
|
||||
|
||||
let client = bootstrap_client(&private_key);
|
||||
|
||||
// Test creating/deriving API key
|
||||
let result = client.create_or_derive_api_key(None).await;
|
||||
assert!(result.is_ok(), "Failed to create/derive API key: {:?}", result);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to create/derive API key: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
let api_creds = result.unwrap();
|
||||
assert!(!api_creds.api_key.is_empty());
|
||||
assert!(!api_creds.secret.is_empty());
|
||||
assert!(!api_creds.passphrase.is_empty());
|
||||
|
||||
|
||||
println!("PASS: Successfully created/derived API key");
|
||||
}
|
||||
|
||||
@@ -44,89 +92,118 @@ async fn test_real_api_create_derive_api_key() {
|
||||
#[ignore]
|
||||
async fn test_real_api_authenticated_order_flow() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
|
||||
// Initialize client with L1 headers
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
|
||||
// Step 1: Create/derive API credentials
|
||||
println!("Step 1: Creating/deriving API credentials...");
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
println!("PASS: API credentials set");
|
||||
|
||||
|
||||
// Step 2: Get a valid token_id from active markets
|
||||
println!("Step 2: Fetching active markets...");
|
||||
let markets = client.get_sampling_markets(None).await
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("Failed to get markets");
|
||||
|
||||
let active_market = markets.data.iter()
|
||||
.find(|m| m.active && !m.closed)
|
||||
.expect("No active markets found");
|
||||
|
||||
let token_id = &active_market.tokens[0].token_id;
|
||||
|
||||
let mut selected_token = None;
|
||||
let mut selected_midpoint = None;
|
||||
for market in markets.data.iter().filter(|m| m.active && !m.closed) {
|
||||
for token in &market.tokens {
|
||||
let midpoint = match client.get_midpoint(&token.token_id).await {
|
||||
Ok(midpoint) => midpoint.mid,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if midpoint > dec!(0.05) {
|
||||
selected_token = Some(token.token_id.clone());
|
||||
selected_midpoint = Some(midpoint);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if selected_token.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let token_id = selected_token.expect("No active token with midpoint safely above 0.01 found");
|
||||
let midpoint = selected_midpoint.expect("midpoint should be selected with token");
|
||||
println!("PASS: Found active token: {}", token_id);
|
||||
|
||||
// Step 3: Get current price to place a reasonable order
|
||||
println!("Step 3: Getting current market price...");
|
||||
let midpoint = client.get_midpoint(token_id).await
|
||||
.expect("Failed to get midpoint");
|
||||
println!("PASS: Current midpoint: {}", midpoint.mid);
|
||||
|
||||
// Step 4: Create and post a small order well away from market price
|
||||
// (so it won't fill immediately)
|
||||
let order_price = if midpoint.mid > dec!(0.5) {
|
||||
dec!(0.01) // Very low buy price, won't fill
|
||||
} else {
|
||||
dec!(0.99) // Very high sell price, won't fill
|
||||
};
|
||||
|
||||
println!("Step 4: Posting order at price {}...", order_price);
|
||||
println!("PASS: Current midpoint: {}", midpoint);
|
||||
|
||||
// Step 4: Create and post a tiny non-marketable BUY order.
|
||||
// A BUY at 0.01 validates pUSD balance/allowance without requiring outcome-token inventory.
|
||||
let side = Side::BUY;
|
||||
let order_price = dec!(0.01);
|
||||
let order_book = client
|
||||
.get_order_book(&token_id)
|
||||
.await
|
||||
.expect("Failed to get selected order book");
|
||||
let order_size = order_book.min_order_size.max(dec!(5));
|
||||
|
||||
println!(
|
||||
"Step 4: Posting {:?} order at price {} and size {}...",
|
||||
side, order_price, order_size
|
||||
);
|
||||
let order_args = OrderArgs {
|
||||
token_id: token_id.clone(),
|
||||
token_id,
|
||||
price: order_price,
|
||||
size: dec!(1.0), // Minimum size
|
||||
side: Side::BUY,
|
||||
size: order_size,
|
||||
side,
|
||||
expiration: None,
|
||||
builder_code: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let post_result = client.create_and_post_order(&order_args).await;
|
||||
|
||||
|
||||
let post_result = client.create_and_post_order(&order_args, None, None).await;
|
||||
|
||||
// This is the critical test - did we get past the 401 error?
|
||||
match &post_result {
|
||||
Ok(response) => {
|
||||
println!("PASS: Order posted successfully!");
|
||||
|
||||
|
||||
// Step 5: Cancel the order
|
||||
if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) {
|
||||
println!("Step 5: Canceling order {}...", order_id);
|
||||
let cancel_result = client.cancel(order_id).await;
|
||||
assert!(cancel_result.is_ok(), "Failed to cancel order: {:?}", cancel_result);
|
||||
if !response.order_id.is_empty() {
|
||||
println!("Step 5: Canceling order {}...", response.order_id);
|
||||
let cancel_result = client.cancel(&response.order_id).await;
|
||||
assert!(
|
||||
cancel_result.is_ok(),
|
||||
"Failed to cancel order: {:?}",
|
||||
cancel_result
|
||||
);
|
||||
println!("PASS: Order canceled successfully");
|
||||
} else {
|
||||
println!("WARNING: Order posted but no orderID in response: {:?}", response);
|
||||
println!(
|
||||
"WARNING: Order posted but no orderID in response: {:?}",
|
||||
response
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
|
||||
// Check if it's a 401 (authentication failure)
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: CRITICAL: 401 Unauthorized error - HMAC authentication is broken!");
|
||||
// The critical failure: did we get a 401 (authentication failure)?
|
||||
match &e {
|
||||
polyfill_rs::PolyfillError::Api { status: 401, .. } => {
|
||||
panic!(
|
||||
"FAIL: CRITICAL: 401 Unauthorized error - HMAC authentication is broken!"
|
||||
);
|
||||
},
|
||||
// Any 4xx other than 401 indicates auth succeeded and we reached server-side validation.
|
||||
polyfill_rs::PolyfillError::Api {
|
||||
status: 400..=499, ..
|
||||
} => {
|
||||
println!("PASS: Authentication successful (got expected validation error)");
|
||||
println!(" Error: {:?}", e);
|
||||
},
|
||||
_ => {
|
||||
panic!("FAIL: Unexpected error: {:?}", e);
|
||||
},
|
||||
}
|
||||
|
||||
// Check if it's a 400 with specific validation errors (these are OK)
|
||||
if err_str.contains("400") && (
|
||||
err_str.contains("insufficient") ||
|
||||
err_str.contains("balance") ||
|
||||
err_str.contains("allowance") ||
|
||||
err_str.contains("POLY_AMOUNT_TOO_SMALL")
|
||||
) {
|
||||
println!("PASS: Authentication successful (got expected validation error)");
|
||||
println!(" Error: {}", err_str);
|
||||
} else {
|
||||
panic!("FAIL: Unexpected error: {:?}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,27 +211,29 @@ async fn test_real_api_authenticated_order_flow() {
|
||||
#[ignore]
|
||||
async fn test_real_api_get_orders() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
|
||||
println!("Testing get_orders...");
|
||||
let result = client.get_orders(None, None).await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(orders) => {
|
||||
println!("PASS: Successfully fetched orders");
|
||||
println!(" Found {} orders", orders.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
panic!("Failed to get orders: {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,26 +241,28 @@ async fn test_real_api_get_orders() {
|
||||
#[ignore]
|
||||
async fn test_real_api_get_trades() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
|
||||
println!("Testing get_trades...");
|
||||
let result = client.get_trades(None, None).await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(_trades) => {
|
||||
println!("PASS: Successfully fetched trades");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
panic!("Failed to get trades: {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,40 +270,87 @@ async fn test_real_api_get_trades() {
|
||||
#[ignore]
|
||||
async fn test_real_api_get_balance_allowance() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
|
||||
println!("Testing get_balance_allowance...");
|
||||
|
||||
|
||||
use polyfill_rs::types::{AssetType, BalanceAllowanceParams};
|
||||
let collateral_update_params = BalanceAllowanceParams {
|
||||
asset_type: Some(AssetType::COLLATERAL),
|
||||
token_id: None,
|
||||
signature_type: None,
|
||||
};
|
||||
let collateral_update = client
|
||||
.update_balance_allowance(Some(collateral_update_params))
|
||||
.await;
|
||||
match collateral_update {
|
||||
Ok(update) => {
|
||||
println!("PASS: Successfully requested collateral balance/allowance update");
|
||||
println!(" Collateral update: {:?}", update);
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
println!("WARNING: Collateral balance update failed: {:?}", e);
|
||||
},
|
||||
}
|
||||
|
||||
let collateral_params = BalanceAllowanceParams {
|
||||
asset_type: Some(AssetType::COLLATERAL),
|
||||
token_id: None,
|
||||
signature_type: None,
|
||||
};
|
||||
let collateral_result = client.get_balance_allowance(Some(collateral_params)).await;
|
||||
|
||||
match collateral_result {
|
||||
Ok(balance) => {
|
||||
println!("PASS: Successfully fetched collateral balance/allowance");
|
||||
println!(" Collateral: {:?}", balance);
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
println!("WARNING: Collateral balance check failed: {:?}", e);
|
||||
},
|
||||
}
|
||||
|
||||
// Get a valid token_id first
|
||||
let markets = client.get_sampling_markets(None).await
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("Failed to get markets");
|
||||
let token_id = &markets.data[0].tokens[0].token_id;
|
||||
|
||||
use polyfill_rs::types::{BalanceAllowanceParams, AssetType};
|
||||
|
||||
let params = BalanceAllowanceParams {
|
||||
asset_type: Some(AssetType::CONDITIONAL),
|
||||
token_id: Some(token_id.clone()),
|
||||
signature_type: None,
|
||||
};
|
||||
|
||||
|
||||
let result = client.get_balance_allowance(Some(params)).await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(balance) => {
|
||||
println!("PASS: Successfully fetched balance/allowance");
|
||||
println!(" Balance: {:?}", balance);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
println!("WARNING: Balance check failed (may be expected): {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,27 +358,29 @@ async fn test_real_api_get_balance_allowance() {
|
||||
#[ignore]
|
||||
async fn test_real_api_get_api_keys() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
|
||||
println!("Testing get_api_keys...");
|
||||
let result = client.get_api_keys().await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
println!("PASS: Successfully fetched API keys");
|
||||
println!(" Found {} keys", keys.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
panic!("Failed to get API keys: {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,27 +388,29 @@ async fn test_real_api_get_api_keys() {
|
||||
#[ignore]
|
||||
async fn test_real_api_get_notifications() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
let api_creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let bootstrap = bootstrap_client(&private_key);
|
||||
let api_creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create/derive API key");
|
||||
client.set_api_creds(api_creds);
|
||||
|
||||
let client = authenticated_client(private_key, api_creds);
|
||||
|
||||
println!("Testing get_notifications...");
|
||||
let result = client.get_notifications().await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(notifications) => {
|
||||
println!("PASS: Successfully fetched notifications");
|
||||
println!(" Notifications: {:?}", notifications);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
panic!("FAIL: 401 Unauthorized - authentication failed!");
|
||||
}
|
||||
panic!("Failed to get notifications: {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,48 +418,66 @@ async fn test_real_api_get_notifications() {
|
||||
#[ignore]
|
||||
async fn test_real_api_market_data_endpoints() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
|
||||
|
||||
let client = bootstrap_client(&private_key);
|
||||
|
||||
println!("Testing market data endpoints (no auth required)...");
|
||||
|
||||
|
||||
// Get a valid token_id
|
||||
let markets = client.get_sampling_markets(None).await
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("Failed to get markets");
|
||||
let token_id = &markets.data[0].tokens[0].token_id;
|
||||
println!("PASS: Using token_id: {}", token_id);
|
||||
|
||||
|
||||
// Test multiple endpoints
|
||||
println!("Testing get_order_book...");
|
||||
let book = client.get_order_book(token_id).await
|
||||
let book = client
|
||||
.get_order_book(token_id)
|
||||
.await
|
||||
.expect("Failed to get order book");
|
||||
println!("PASS: Order book: {} bids, {} asks", book.bids.len(), book.asks.len());
|
||||
|
||||
println!(
|
||||
"PASS: Order book: {} bids, {} asks",
|
||||
book.bids.len(),
|
||||
book.asks.len()
|
||||
);
|
||||
|
||||
println!("Testing get_midpoint...");
|
||||
let midpoint = client.get_midpoint(token_id).await
|
||||
let midpoint = client
|
||||
.get_midpoint(token_id)
|
||||
.await
|
||||
.expect("Failed to get midpoint");
|
||||
println!("PASS: Midpoint: {}", midpoint.mid);
|
||||
|
||||
|
||||
println!("Testing get_spread...");
|
||||
let spread = client.get_spread(token_id).await
|
||||
let spread = client
|
||||
.get_spread(token_id)
|
||||
.await
|
||||
.expect("Failed to get spread");
|
||||
println!("PASS: Spread: {}", spread.spread);
|
||||
|
||||
|
||||
println!("Testing get_price...");
|
||||
let price = client.get_price(token_id, Side::BUY).await
|
||||
let price = client
|
||||
.get_price(token_id, Side::BUY)
|
||||
.await
|
||||
.expect("Failed to get price");
|
||||
println!("PASS: Buy price: {}", price.price);
|
||||
|
||||
|
||||
println!("Testing get_tick_size...");
|
||||
let tick_size = client.get_tick_size(token_id).await
|
||||
let tick_size = client
|
||||
.get_tick_size(token_id)
|
||||
.await
|
||||
.expect("Failed to get tick size");
|
||||
println!("PASS: Tick size: {}", tick_size);
|
||||
|
||||
|
||||
println!("Testing get_markets...");
|
||||
let all_markets = client.get_markets(None).await
|
||||
let all_markets = client
|
||||
.get_markets(None)
|
||||
.await
|
||||
.expect("Failed to get all markets");
|
||||
println!("PASS: Found {} markets", all_markets.data.len());
|
||||
|
||||
|
||||
println!("\nPASS: All market data endpoints working!");
|
||||
}
|
||||
|
||||
@@ -335,34 +485,42 @@ async fn test_real_api_market_data_endpoints() {
|
||||
#[ignore]
|
||||
async fn test_real_api_batch_endpoints() {
|
||||
let (private_key, _, _, _) = load_env_vars();
|
||||
|
||||
let client = ClobClient::with_l1_headers(HOST, &private_key, CHAIN_ID);
|
||||
|
||||
|
||||
let client = bootstrap_client(&private_key);
|
||||
|
||||
println!("Testing batch endpoints...");
|
||||
|
||||
|
||||
// Get multiple valid token_ids
|
||||
let markets = client.get_sampling_markets(None).await
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("Failed to get markets");
|
||||
let token_ids: Vec<String> = markets.data[0..2.min(markets.data.len())]
|
||||
.iter()
|
||||
.map(|m| m.tokens[0].token_id.clone())
|
||||
.collect();
|
||||
|
||||
|
||||
println!("Testing get_order_books (batch)...");
|
||||
let books = client.get_order_books(&token_ids).await
|
||||
let books = client
|
||||
.get_order_books(&token_ids)
|
||||
.await
|
||||
.expect("Failed to get order books");
|
||||
println!("PASS: Fetched {} order books", books.len());
|
||||
|
||||
|
||||
println!("Testing get_midpoints (batch)...");
|
||||
let midpoints = client.get_midpoints(&token_ids).await
|
||||
let midpoints = client
|
||||
.get_midpoints(&token_ids)
|
||||
.await
|
||||
.expect("Failed to get midpoints");
|
||||
println!("PASS: Fetched {} midpoints", midpoints.len());
|
||||
|
||||
|
||||
println!("Testing get_spreads (batch)...");
|
||||
let spreads = client.get_spreads(&token_ids).await
|
||||
let spreads = client
|
||||
.get_spreads(&token_ids)
|
||||
.await
|
||||
.expect("Failed to get spreads");
|
||||
println!("PASS: Fetched {} spreads", spreads.len());
|
||||
|
||||
|
||||
println!("\nPASS: All batch endpoints working!");
|
||||
}
|
||||
|
||||
@@ -370,14 +528,16 @@ async fn test_real_api_batch_endpoints() {
|
||||
#[ignore]
|
||||
async fn test_real_api_health_check() {
|
||||
let client = ClobClient::new(HOST);
|
||||
|
||||
|
||||
println!("Testing health check endpoints...");
|
||||
|
||||
|
||||
let ok = client.get_ok().await;
|
||||
assert!(ok, "API health check failed!");
|
||||
println!("PASS: API is healthy");
|
||||
|
||||
let server_time = client.get_server_time().await
|
||||
|
||||
let server_time = client
|
||||
.get_server_time()
|
||||
.await
|
||||
.expect("Failed to get server time");
|
||||
println!("PASS: Server time: {}", server_time);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::cell::Cell;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt::Write as _;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::Utc;
|
||||
use polyfill_rs::{
|
||||
book::OrderBookManager, OrderBookImpl, Side, WebSocketStream, WsBookUpdateProcessor,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
thread_local! {
|
||||
static HEAP_OPERATIONS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
struct CountingAllocator;
|
||||
|
||||
unsafe impl GlobalAlloc for CountingAllocator {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
HEAP_OPERATIONS.with(|count| count.set(count.get() + 1));
|
||||
System.alloc(layout)
|
||||
}
|
||||
|
||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||||
HEAP_OPERATIONS.with(|count| count.set(count.get() + 1));
|
||||
System.alloc_zeroed(layout)
|
||||
}
|
||||
|
||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||||
HEAP_OPERATIONS.with(|count| count.set(count.get() + 1));
|
||||
System.realloc(ptr, layout, new_size)
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
HEAP_OPERATIONS.with(|count| count.set(count.get() + 1));
|
||||
System.dealloc(ptr, layout)
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static GLOBAL: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn heap_operation_count() -> usize {
|
||||
HEAP_OPERATIONS.with(|count| count.get())
|
||||
}
|
||||
|
||||
struct NoHeapTrafficGuard {
|
||||
before: usize,
|
||||
}
|
||||
|
||||
impl NoHeapTrafficGuard {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
before: heap_operation_count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_heap_traffic(self) {
|
||||
let after = heap_operation_count();
|
||||
assert_eq!(
|
||||
after,
|
||||
self.before,
|
||||
"expected no heap traffic, but saw {} allocator operation(s)",
|
||||
after - self.before
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocator_counter_tracks_deallocations() {
|
||||
let vec = Vec::<u8>::with_capacity(1024);
|
||||
let before = heap_operation_count();
|
||||
drop(vec);
|
||||
let after = heap_operation_count();
|
||||
|
||||
assert!(
|
||||
after > before,
|
||||
"expected dropping an allocated Vec to count as heap traffic"
|
||||
);
|
||||
}
|
||||
|
||||
fn token_id_hash(token_id: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
token_id.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
fn mk_delta(
|
||||
token_id_hash: u64,
|
||||
side: Side,
|
||||
price_ticks: polyfill_rs::types::Price,
|
||||
size_units: polyfill_rs::types::Qty,
|
||||
sequence: u64,
|
||||
) -> polyfill_rs::types::FastOrderDelta {
|
||||
polyfill_rs::types::FastOrderDelta {
|
||||
token_id_hash,
|
||||
timestamp: chrono::DateTime::<Utc>::from_timestamp(0, 0).unwrap(),
|
||||
side,
|
||||
price: price_ticks,
|
||||
size: size_units,
|
||||
sequence,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_book_levels(
|
||||
manager: &OrderBookManager,
|
||||
asset_id: &str,
|
||||
bid_ticks: &[i64],
|
||||
ask_ticks: &[i64],
|
||||
) {
|
||||
let mut sequence = 1u64;
|
||||
for &price_ticks in bid_ticks {
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::new(price_ticks, 4),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence,
|
||||
})
|
||||
.unwrap();
|
||||
sequence += 1;
|
||||
}
|
||||
|
||||
for &price_ticks in ask_ticks {
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: Decimal::new(price_ticks, 4),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence,
|
||||
})
|
||||
.unwrap();
|
||||
sequence += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn ws_book_message(
|
||||
asset_id: &str,
|
||||
timestamp: u64,
|
||||
bid_ticks: &[i64],
|
||||
ask_ticks: &[i64],
|
||||
) -> Vec<u8> {
|
||||
let mut json = String::with_capacity(160 + (bid_ticks.len() + ask_ticks.len()) * 40);
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":{timestamp},\"bids\":["
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for (idx, price_ticks) in bid_ticks.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
json.push(',');
|
||||
}
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"price\":\"{}\",\"size\":\"100.0000\"}}",
|
||||
Decimal::new(*price_ticks, 4)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
json.push_str("],\"asks\":[");
|
||||
for (idx, price_ticks) in ask_ticks.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
json.push(',');
|
||||
}
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"price\":\"{}\",\"size\":\"100.0000\"}}",
|
||||
Decimal::new(*price_ticks, 4)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
json.push_str("]}");
|
||||
json.into_bytes()
|
||||
}
|
||||
|
||||
fn ws_book_message_with_hash(
|
||||
asset_id: &str,
|
||||
timestamp: u64,
|
||||
hash: &str,
|
||||
bid_ticks: &[i64],
|
||||
ask_ticks: &[i64],
|
||||
) -> Vec<u8> {
|
||||
let mut json =
|
||||
String::with_capacity(190 + hash.len() + (bid_ticks.len() + ask_ticks.len()) * 40);
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":{timestamp},\"hash\":\"{hash}\",\"bids\":["
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
for (idx, price_ticks) in bid_ticks.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
json.push(',');
|
||||
}
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"price\":\"{}\",\"size\":\"100.0000\"}}",
|
||||
Decimal::new(*price_ticks, 4)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
json.push_str("],\"asks\":[");
|
||||
for (idx, price_ticks) in ask_ticks.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
json.push(',');
|
||||
}
|
||||
write!(
|
||||
&mut json,
|
||||
"{{\"price\":\"{}\",\"size\":\"100.0000\"}}",
|
||||
Decimal::new(*price_ticks, 4)
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
json.push_str("]}");
|
||||
json.into_bytes()
|
||||
}
|
||||
|
||||
fn contiguous_ticks(start: i64, len: usize, step: i64) -> Vec<i64> {
|
||||
(0..len).map(|idx| start + (idx as i64 * step)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_mid_and_spread_fast() {
|
||||
let token_id = "test_token";
|
||||
let token_hash = token_id_hash(token_id);
|
||||
let mut book = OrderBookImpl::new(token_id.to_string(), 100);
|
||||
|
||||
// Allocate during setup: create initial price levels.
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7500, 1_000_000, 1))
|
||||
.unwrap();
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::SELL, 7600, 1_000_000, 2))
|
||||
.unwrap();
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
assert!(book.best_bid_fast().is_some());
|
||||
assert!(book.best_ask_fast().is_some());
|
||||
assert!(book.spread_fast().is_some());
|
||||
assert!(book.mid_price_fast().is_some());
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_book_analysis_fast_paths() {
|
||||
let token_id = "test_token";
|
||||
let token_hash = token_id_hash(token_id);
|
||||
let mut book = OrderBookImpl::new(token_id.to_string(), 100);
|
||||
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7500, 1_000_000, 1))
|
||||
.unwrap();
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7400, 500_000, 2))
|
||||
.unwrap();
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::SELL, 7600, 800_000, 3))
|
||||
.unwrap();
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::SELL, 7700, 1_200_000, 4))
|
||||
.unwrap();
|
||||
|
||||
let impact_size = Decimal::from_str("150.0").unwrap();
|
||||
let min_price = Decimal::from_str("0.74").unwrap();
|
||||
let max_price = Decimal::from_str("0.77").unwrap();
|
||||
let min_average_price = Decimal::from_str("0.76").unwrap();
|
||||
let expected_buy_liquidity = Decimal::from_str("200.0").unwrap();
|
||||
let expected_sell_liquidity = Decimal::from_str("150.0").unwrap();
|
||||
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
let impact = book
|
||||
.calculate_market_impact(Side::BUY, impact_size)
|
||||
.unwrap();
|
||||
assert!(impact.average_price > min_average_price);
|
||||
assert_eq!(
|
||||
book.liquidity_in_range(min_price, max_price, Side::BUY),
|
||||
expected_buy_liquidity
|
||||
);
|
||||
assert_eq!(
|
||||
book.liquidity_in_range(min_price, max_price, Side::SELL),
|
||||
expected_sell_liquidity
|
||||
);
|
||||
assert!(book.is_valid());
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_apply_delta_fast_existing_level_update() {
|
||||
let token_id = "test_token";
|
||||
let token_hash = token_id_hash(token_id);
|
||||
let mut book = OrderBookImpl::new(token_id.to_string(), 100);
|
||||
|
||||
// Allocate during setup: create an initial level.
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7500, 1_000_000, 1))
|
||||
.unwrap();
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
// Updating an existing level should not touch the heap allocator.
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7500, 2_000_000, 2))
|
||||
.unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_apply_book_update_existing_levels() {
|
||||
let asset_id = "test_asset_id";
|
||||
let token_hash = token_id_hash(asset_id);
|
||||
let mut book = OrderBookImpl::new(asset_id.to_string(), 100);
|
||||
|
||||
// Allocate during setup: create initial price levels.
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::BUY, 7500, 1_000_000, 1))
|
||||
.unwrap();
|
||||
book.apply_delta_fast(mk_delta(token_hash, Side::SELL, 7600, 1_000_000, 2))
|
||||
.unwrap();
|
||||
|
||||
let update = polyfill_rs::types::BookUpdate {
|
||||
asset_id: asset_id.to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 10,
|
||||
bids: vec![polyfill_rs::types::OrderSummary {
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("200.0").unwrap(),
|
||||
}],
|
||||
asks: vec![polyfill_rs::types::OrderSummary {
|
||||
price: Decimal::from_str("0.76").unwrap(),
|
||||
size: Decimal::from_str("50.0").unwrap(),
|
||||
}],
|
||||
hash: None,
|
||||
};
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
book.apply_book_update(&update).unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_book_manager_apply_book_update_existing_levels() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
|
||||
// Warm up the internal book with initial levels (allocator traffic allowed).
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 1,
|
||||
})
|
||||
.unwrap();
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: Decimal::from_str("0.76").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 2,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let update = polyfill_rs::types::BookUpdate {
|
||||
asset_id: asset_id.to_string(),
|
||||
market: "0xabc".to_string(),
|
||||
timestamp: 10,
|
||||
bids: vec![polyfill_rs::types::OrderSummary {
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("200.0").unwrap(),
|
||||
}],
|
||||
asks: vec![polyfill_rs::types::OrderSummary {
|
||||
price: Decimal::from_str("0.76").unwrap(),
|
||||
size: Decimal::from_str("50.0").unwrap(),
|
||||
}],
|
||||
hash: None,
|
||||
};
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
manager.apply_book_update(&update).unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_ws_book_update_processor_apply_existing_levels() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
|
||||
// Warm up the internal book with initial levels (allocator traffic allowed).
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 1,
|
||||
})
|
||||
.unwrap();
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: Decimal::from_str("0.76").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 2,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(1024);
|
||||
|
||||
// Warm up simd-json buffers/tape outside the guarded section.
|
||||
let mut warmup_msg = format!(
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":10,\"bids\":[{{\"price\":\"0.75\",\"size\":\"200.0\"}}],\"asks\":[{{\"price\":\"0.76\",\"size\":\"50.0\"}}]}}"
|
||||
)
|
||||
.into_bytes();
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
|
||||
let mut msg = format!(
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":11,\"bids\":[{{\"price\":\"0.75\",\"size\":\"150.0\"}}],\"asks\":[{{\"price\":\"0.76\",\"size\":\"75.0\"}}]}}"
|
||||
)
|
||||
.into_bytes();
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
processor
|
||||
.process_bytes(msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_ws_book_update_processor_same_timestamp_hash_change() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
seed_book_levels(&manager, asset_id, &[7500], &[7600]);
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(4096);
|
||||
let mut warmup_msg = ws_book_message_with_hash(asset_id, 10, "hash_a", &[7500], &[7600]);
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
|
||||
let mut msg = ws_book_message_with_hash(asset_id, 10, "hash_b", &[7500], &[7600]);
|
||||
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
processor
|
||||
.process_bytes(msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_ws_book_update_processor_one_new_level_with_reserved_capacity() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
seed_book_levels(&manager, asset_id, &[7500], &[7600]);
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(4096);
|
||||
let mut warmup_msg = ws_book_message(asset_id, 10, &[7500], &[7600]);
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
let mut parser_capacity_warmup = ws_book_message(asset_id, 10, &[7500, 7400], &[7600]);
|
||||
processor
|
||||
.process_bytes(parser_capacity_warmup.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
|
||||
let mut msg = ws_book_message(asset_id, 11, &[7500, 7400], &[7600]);
|
||||
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
processor
|
||||
.process_bytes(msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_ws_book_update_processor_one_removed_level() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
seed_book_levels(&manager, asset_id, &[7500, 7400], &[7600]);
|
||||
|
||||
let mut processor = WsBookUpdateProcessor::new(4096);
|
||||
let mut warmup_msg = ws_book_message(asset_id, 10, &[7500, 7400], &[7600]);
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
|
||||
let mut msg = ws_book_message(asset_id, 11, &[7500], &[7600]);
|
||||
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
processor
|
||||
.process_bytes(msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_book_update_processor_full_churn_64_levels_touches_allocator() {
|
||||
let asset_id = "test_asset_id";
|
||||
let levels_per_side = 64;
|
||||
let manager = OrderBookManager::new(levels_per_side);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
|
||||
let initial_bids = contiguous_ticks(7500, levels_per_side, -1);
|
||||
let initial_asks = contiguous_ticks(7600, levels_per_side, 1);
|
||||
seed_book_levels(&manager, asset_id, &initial_bids, &initial_asks);
|
||||
|
||||
let mut warmup_msg = ws_book_message(asset_id, 1000, &initial_bids, &initial_asks);
|
||||
let mut processor = WsBookUpdateProcessor::new(warmup_msg.len());
|
||||
processor
|
||||
.process_bytes(warmup_msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
|
||||
let churn_bids = contiguous_ticks(7300, levels_per_side, -1);
|
||||
let churn_asks = contiguous_ticks(7800, levels_per_side, 1);
|
||||
let mut msg = ws_book_message(asset_id, 1001, &churn_bids, &churn_asks);
|
||||
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let before = heap_operation_count();
|
||||
processor
|
||||
.process_bytes(msg.as_mut_slice(), &manager)
|
||||
.unwrap();
|
||||
let after = heap_operation_count();
|
||||
|
||||
assert!(
|
||||
after > before,
|
||||
"expected 64-level full churn to touch the allocator while old and new levels coexist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_alloc_websocket_book_applier_apply_bytes_message_existing_levels() {
|
||||
let asset_id = "test_asset_id";
|
||||
let manager = OrderBookManager::new(100);
|
||||
manager.get_or_create_book(asset_id).unwrap();
|
||||
|
||||
// Warm up the internal book with initial levels (allocator traffic allowed).
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::BUY,
|
||||
price: Decimal::from_str("0.75").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 1,
|
||||
})
|
||||
.unwrap();
|
||||
manager
|
||||
.apply_delta(polyfill_rs::types::OrderDelta {
|
||||
token_id: asset_id.to_string(),
|
||||
timestamp: chrono::Utc::now(),
|
||||
side: Side::SELL,
|
||||
price: Decimal::from_str("0.76").unwrap(),
|
||||
size: Decimal::from_str("100.0").unwrap(),
|
||||
sequence: 2,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let processor = WsBookUpdateProcessor::new(1024);
|
||||
let stream = WebSocketStream::new("wss://example.com/ws");
|
||||
let mut applier = stream.into_book_applier(&manager, processor);
|
||||
|
||||
// Warm up simd-json buffers/tape outside the guarded section.
|
||||
let mut warmup_msg = format!(
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":10,\"bids\":[{{\"price\":\"0.75\",\"size\":\"200.0\"}}],\"asks\":[{{\"price\":\"0.76\",\"size\":\"50.0\"}}]}}"
|
||||
)
|
||||
.into_bytes();
|
||||
applier
|
||||
.apply_bytes_message(warmup_msg.as_mut_slice())
|
||||
.unwrap();
|
||||
|
||||
let mut msg = format!(
|
||||
"{{\"event_type\":\"book\",\"asset_id\":\"{asset_id}\",\"market\":\"0xabc\",\"timestamp\":11,\"bids\":[{{\"price\":\"0.75\",\"size\":\"150.0\"}}],\"asks\":[{{\"price\":\"0.76\",\"size\":\"75.0\"}}]}}"
|
||||
)
|
||||
.into_bytes();
|
||||
|
||||
// Warm up allocator-counter TLS access before measuring (defensive).
|
||||
let _ = heap_operation_count();
|
||||
|
||||
let guard = NoHeapTrafficGuard::new();
|
||||
applier.apply_bytes_message(msg.as_mut_slice()).unwrap();
|
||||
guard.assert_no_heap_traffic();
|
||||
}
|
||||
+57
-37
@@ -1,5 +1,5 @@
|
||||
// Test order posting - the critical endpoint that had the 401 bug
|
||||
use polyfill_rs::{ClobClient, OrderArgs, Side};
|
||||
use polyfill_rs::{ClientConfig, ClobClient, OrderArgs, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use std::env;
|
||||
use std::str::FromStr;
|
||||
@@ -8,81 +8,101 @@ use std::str::FromStr;
|
||||
#[ignore]
|
||||
async fn test_post_order_authentication() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
&private_key,
|
||||
137
|
||||
);
|
||||
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let bootstrap = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key.clone()),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build bootstrap client");
|
||||
|
||||
println!("Step 1: Creating API credentials...");
|
||||
let creds = client.create_or_derive_api_key(None).await
|
||||
let creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create API key");
|
||||
client.set_api_creds(creds);
|
||||
let client = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(creds),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
println!("API credentials set");
|
||||
|
||||
|
||||
// Use a well-known token ID (we'll use an extreme price so it won't fill)
|
||||
let token_id = "21742633143463906290569050155826241533067272736897614950488156847949938836455"; // Example token
|
||||
|
||||
|
||||
println!("\nStep 2: Attempting to post order (testing authentication)...");
|
||||
let order_args = OrderArgs {
|
||||
token_id: token_id.to_string(),
|
||||
price: Decimal::from_str("0.01").unwrap(), // Very low price, won't fill
|
||||
size: Decimal::from_str("1.0").unwrap(),
|
||||
side: Side::BUY,
|
||||
expiration: None,
|
||||
builder_code: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
let result = client.create_and_post_order(&order_args).await;
|
||||
|
||||
|
||||
let result = client.create_and_post_order(&order_args, None, None).await;
|
||||
|
||||
match result {
|
||||
Ok(response) => {
|
||||
println!("AUTHENTICATION SUCCESSFUL! Order was accepted by API");
|
||||
println!(" Response: {:?}", response);
|
||||
|
||||
|
||||
// Try to cancel it if we got an order ID
|
||||
if let Some(order_id) = response.get("orderID").and_then(|v| v.as_str()) {
|
||||
if !response.order_id.is_empty() {
|
||||
println!("\nStep 3: Canceling order...");
|
||||
match client.cancel(order_id).await {
|
||||
match client.cancel(&response.order_id).await {
|
||||
Ok(_) => println!("Order canceled successfully"),
|
||||
Err(e) => println!("Cancel failed (order might have expired): {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
|
||||
|
||||
// The critical test: Is it a 401 error?
|
||||
if err_str.contains("401") {
|
||||
panic!("CRITICAL FAILURE: 401 Unauthorized!\n\
|
||||
panic!(
|
||||
"CRITICAL FAILURE: 401 Unauthorized!\n\
|
||||
The HMAC authentication bug is NOT fixed!\n\
|
||||
Error: {:?}", e);
|
||||
Error: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// If it's a 400 error with validation issues, that's actually GOOD
|
||||
// It means authentication worked, but there's an issue with the order parameters
|
||||
if err_str.contains("400") {
|
||||
println!("AUTHENTICATION SUCCESSFUL!");
|
||||
println!(" (Got 400 validation error, which means auth passed)");
|
||||
println!(" Error details: {}", err_str);
|
||||
|
||||
|
||||
// These are expected validation errors when auth works
|
||||
if err_str.contains("insufficient") ||
|
||||
err_str.contains("balance") ||
|
||||
err_str.contains("allowance") ||
|
||||
err_str.contains("POLY_AMOUNT_TOO_SMALL") ||
|
||||
err_str.contains("invalid") ||
|
||||
err_str.contains("market") {
|
||||
if err_str.contains("insufficient")
|
||||
|| err_str.contains("balance")
|
||||
|| err_str.contains("allowance")
|
||||
|| err_str.contains("POLY_AMOUNT_TOO_SMALL")
|
||||
|| err_str.contains("invalid")
|
||||
|| err_str.contains("market")
|
||||
{
|
||||
println!(" This is an expected validation error - authentication is working!");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Any other error type
|
||||
println!("Got unexpected error (not 401, so auth might be OK): {:?}", e);
|
||||
}
|
||||
println!(
|
||||
"Got unexpected error (not 401, so auth might be OK): {:?}",
|
||||
e
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Real API integration tests for /prices-history.
|
||||
//
|
||||
// These tests hit Polymarket's live HTTP API and are ignored by default.
|
||||
//
|
||||
// Run with:
|
||||
// cargo test --all-features --test prices_history_integration_tests -- --ignored --nocapture --test-threads=1
|
||||
|
||||
use polyfill_rs::{ClobClient, PricesHistoryInterval};
|
||||
|
||||
const HOST: &str = "https://clob.polymarket.com";
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_real_api_get_prices_history_interval_parses() {
|
||||
let client = ClobClient::new(HOST);
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("failed to fetch sampling markets");
|
||||
|
||||
let token_ids: Vec<String> = markets
|
||||
.data
|
||||
.iter()
|
||||
.filter(|m| m.active && !m.closed)
|
||||
.filter_map(|m| m.tokens.first().map(|t| t.token_id.clone()))
|
||||
.take(20)
|
||||
.collect();
|
||||
|
||||
assert!(!token_ids.is_empty(), "no active token IDs found");
|
||||
|
||||
// The API sometimes returns empty history for some markets; try a few and
|
||||
// assert at least one returns a non-empty series (verifies endpoint semantics).
|
||||
for token_id in token_ids {
|
||||
let response = client
|
||||
.get_prices_history_interval(&token_id, PricesHistoryInterval::OneDay, Some(5))
|
||||
.await
|
||||
.expect("failed to fetch prices history");
|
||||
|
||||
if !response.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
panic!("expected at least one active market to return non-empty price history");
|
||||
}
|
||||
+102
-71
@@ -1,38 +1,51 @@
|
||||
// Simple authentication test to verify HMAC works
|
||||
use polyfill_rs::ClobClient;
|
||||
use polyfill_rs::{ClientConfig, ClobClient};
|
||||
use std::env;
|
||||
|
||||
fn build_bootstrap_client(private_key: &str) -> ClobClient {
|
||||
ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key.to_string()),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build bootstrap client")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_create_api_key_simple() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
&private_key,
|
||||
137
|
||||
);
|
||||
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let bootstrap = build_bootstrap_client(&private_key);
|
||||
|
||||
println!("Step 1: Creating/deriving API key...");
|
||||
let result = client.create_or_derive_api_key(None).await;
|
||||
|
||||
let result = bootstrap.create_or_derive_api_key(None).await;
|
||||
|
||||
match result {
|
||||
Ok(creds) => {
|
||||
println!("Successfully created/derived API key");
|
||||
println!(" API Key: {}", creds.api_key);
|
||||
client.set_api_creds(creds);
|
||||
|
||||
println!(" API Key created (len={})", creds.api_key.len());
|
||||
let client = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(creds),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
|
||||
// Now try to get orders (requires auth)
|
||||
println!("\nStep 2: Testing authenticated endpoint (get_orders)...");
|
||||
let orders_result = client.get_orders(None, None).await;
|
||||
|
||||
|
||||
match orders_result {
|
||||
Ok(orders) => {
|
||||
println!("Successfully authenticated! Got {} orders", orders.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
@@ -40,12 +53,12 @@ async fn test_create_api_key_simple() {
|
||||
} else {
|
||||
println!("Authentication successful (non-401 error): {:?}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
panic!("Failed to create/derive API key: {:?}", e);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,27 +66,32 @@ async fn test_create_api_key_simple() {
|
||||
#[ignore]
|
||||
async fn test_get_api_keys() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
&private_key,
|
||||
137
|
||||
);
|
||||
|
||||
let creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let bootstrap = build_bootstrap_client(&private_key);
|
||||
|
||||
let creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create API key");
|
||||
client.set_api_creds(creds);
|
||||
|
||||
let client = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(creds),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
|
||||
println!("Testing get_api_keys (requires HMAC auth)...");
|
||||
let result = client.get_api_keys().await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(keys) => {
|
||||
println!("Authentication successful! Found {} keys", keys.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
@@ -81,7 +99,7 @@ async fn test_get_api_keys() {
|
||||
} else {
|
||||
panic!("Failed with non-401 error: {:?}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,27 +107,32 @@ async fn test_get_api_keys() {
|
||||
#[ignore]
|
||||
async fn test_get_trades() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
&private_key,
|
||||
137
|
||||
);
|
||||
|
||||
let creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let bootstrap = build_bootstrap_client(&private_key);
|
||||
|
||||
let creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create API key");
|
||||
client.set_api_creds(creds);
|
||||
|
||||
let client = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(creds),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
|
||||
println!("Testing get_trades (requires HMAC auth)...");
|
||||
let result = client.get_trades(None, None).await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
println!("Authentication successful!");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
@@ -117,7 +140,7 @@ async fn test_get_trades() {
|
||||
} else {
|
||||
println!("Authentication successful (got non-401 error): {:?}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,27 +148,36 @@ async fn test_get_trades() {
|
||||
#[ignore]
|
||||
async fn test_get_notifications() {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let private_key = env::var("POLYMARKET_PRIVATE_KEY")
|
||||
.expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let mut client = ClobClient::with_l1_headers(
|
||||
"https://clob.polymarket.com",
|
||||
&private_key,
|
||||
137
|
||||
);
|
||||
|
||||
let creds = client.create_or_derive_api_key(None).await
|
||||
|
||||
let private_key =
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set in .env");
|
||||
|
||||
let bootstrap = build_bootstrap_client(&private_key);
|
||||
|
||||
let creds = bootstrap
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("Failed to create API key");
|
||||
client.set_api_creds(creds);
|
||||
|
||||
let client = ClobClient::from_config(ClientConfig {
|
||||
base_url: "https://clob.polymarket.com".to_string(),
|
||||
chain: 137,
|
||||
private_key: Some(private_key),
|
||||
api_credentials: Some(creds),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
|
||||
println!("Testing get_notifications (requires HMAC auth)...");
|
||||
let result = client.get_notifications().await;
|
||||
|
||||
|
||||
match result {
|
||||
Ok(notifs) => {
|
||||
println!("Authentication successful! Notifications: {:?}", notifs);
|
||||
}
|
||||
let count = notifs.as_array().map(|arr| arr.len());
|
||||
match count {
|
||||
Some(n) => println!("Authentication successful! Notifications: {n}"),
|
||||
None => println!("Authentication successful! Notifications received"),
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let err_str = format!("{:?}", e);
|
||||
if err_str.contains("401") {
|
||||
@@ -153,7 +185,6 @@ async fn test_get_notifications() {
|
||||
} else {
|
||||
println!("Authentication successful (got non-401 error): {:?}", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// WebSocket integration tests for polyfill-rs
|
||||
//
|
||||
// These tests connect to Polymarket's live WS endpoints and are ignored by default.
|
||||
//
|
||||
// Run with:
|
||||
// cargo test --all-features --test ws_integration_tests -- --ignored --nocapture --test-threads=1
|
||||
|
||||
#![cfg(feature = "stream")]
|
||||
|
||||
use futures::StreamExt;
|
||||
use polyfill_rs::{
|
||||
ClientConfig, ClobClient, OrderBookManager, WebSocketStream, WsBookUpdateProcessor,
|
||||
};
|
||||
use std::env;
|
||||
use std::time::Duration;
|
||||
|
||||
const HOST: &str = "https://clob.polymarket.com";
|
||||
const WS_MARKET_URL: &str = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
|
||||
const WS_USER_URL: &str = "wss://ws-subscriptions-clob.polymarket.com/ws/user";
|
||||
const CHAIN_ID: u64 = 137;
|
||||
|
||||
fn load_private_key() -> String {
|
||||
dotenvy::dotenv().ok();
|
||||
env::var("POLYMARKET_PRIVATE_KEY").expect("POLYMARKET_PRIVATE_KEY must be set (env or .env)")
|
||||
}
|
||||
|
||||
fn stability_secs(default_secs: u64) -> u64 {
|
||||
env::var("POLYFILL_WS_STABILITY_SECS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.unwrap_or(default_secs)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_real_ws_market_book_applier_receives_book_update() {
|
||||
// Pick an active token ID so the market channel should produce data.
|
||||
let client = ClobClient::new(HOST);
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("failed to fetch markets");
|
||||
|
||||
let token_id = markets
|
||||
.data
|
||||
.iter()
|
||||
.find(|m| m.active && !m.closed)
|
||||
.and_then(|m| m.tokens.first())
|
||||
.map(|t| t.token_id.clone())
|
||||
.expect("no active markets found");
|
||||
|
||||
let books = OrderBookManager::new(256);
|
||||
books
|
||||
.get_or_create_book(&token_id)
|
||||
.expect("failed to create book");
|
||||
|
||||
let mut ws = WebSocketStream::new(WS_MARKET_URL);
|
||||
ws.subscribe_market_channel(vec![token_id.clone()])
|
||||
.await
|
||||
.expect("failed to subscribe market channel");
|
||||
|
||||
let processor = WsBookUpdateProcessor::new(256 * 1024);
|
||||
let mut applier = ws.into_book_applier(&books, processor);
|
||||
|
||||
let stats = tokio::time::timeout(Duration::from_secs(10), applier.next())
|
||||
.await
|
||||
.expect("timed out waiting for WS book message")
|
||||
.expect("WS stream ended unexpectedly")
|
||||
.expect("WS processing error");
|
||||
|
||||
assert!(
|
||||
stats.book_messages > 0,
|
||||
"expected at least one book message"
|
||||
);
|
||||
|
||||
let snapshot = books.get_book(&token_id).expect("failed to read book");
|
||||
assert!(
|
||||
!snapshot.bids.is_empty() || !snapshot.asks.is_empty(),
|
||||
"expected some book levels after applying an update"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_real_ws_market_book_applier_connection_stable() {
|
||||
// Subscribe to a handful of active tokens and keep the connection open for a while.
|
||||
// We don't require constant message flow; we just fail on close or error.
|
||||
let client = ClobClient::new(HOST);
|
||||
let markets = client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("failed to fetch markets");
|
||||
|
||||
let token_ids: Vec<String> = markets
|
||||
.data
|
||||
.iter()
|
||||
.filter(|m| m.active && !m.closed)
|
||||
.filter_map(|m| m.tokens.first().map(|t| t.token_id.clone()))
|
||||
.take(10)
|
||||
.collect();
|
||||
|
||||
assert!(!token_ids.is_empty(), "no active token IDs found");
|
||||
|
||||
let books = OrderBookManager::new(256);
|
||||
for token_id in &token_ids {
|
||||
books
|
||||
.get_or_create_book(token_id)
|
||||
.expect("failed to create book");
|
||||
}
|
||||
|
||||
let mut ws = WebSocketStream::new(WS_MARKET_URL);
|
||||
ws.subscribe_market_channel(token_ids.clone())
|
||||
.await
|
||||
.expect("failed to subscribe market channel");
|
||||
|
||||
let processor = WsBookUpdateProcessor::new(256 * 1024);
|
||||
let mut applier = ws.into_book_applier(&books, processor);
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(stability_secs(15));
|
||||
let mut saw_book_message = false;
|
||||
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), applier.next()).await {
|
||||
Ok(Some(Ok(stats))) => {
|
||||
if stats.book_messages > 0 {
|
||||
saw_book_message = true;
|
||||
}
|
||||
},
|
||||
Ok(Some(Err(e))) => panic!("WS processing error: {:?}", e),
|
||||
Ok(None) => panic!("WS stream ended unexpectedly"),
|
||||
Err(_) => {
|
||||
// No message in this interval; keep waiting.
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
saw_book_message,
|
||||
"did not observe any `book` messages during stability window"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore]
|
||||
async fn test_real_ws_user_channel_connection_stable() {
|
||||
// Connect + subscribe to the user channel and keep the connection open for a while.
|
||||
//
|
||||
// Note: depending on account activity, the WS may not emit any `order`/`trade` messages
|
||||
// during the window. This test primarily asserts we can authenticate + subscribe
|
||||
// and that the connection doesn't immediately drop.
|
||||
let private_key = load_private_key();
|
||||
let auth_client = ClobClient::from_config(ClientConfig {
|
||||
base_url: HOST.to_string(),
|
||||
chain: CHAIN_ID,
|
||||
private_key: Some(private_key),
|
||||
..ClientConfig::default()
|
||||
})
|
||||
.expect("failed to build authenticated client");
|
||||
let api_creds = auth_client
|
||||
.create_or_derive_api_key(None)
|
||||
.await
|
||||
.expect("failed to create/derive api key");
|
||||
|
||||
let markets = auth_client
|
||||
.get_sampling_markets(None)
|
||||
.await
|
||||
.expect("failed to fetch markets");
|
||||
let market_id = markets
|
||||
.data
|
||||
.iter()
|
||||
.find(|m| m.active && !m.closed)
|
||||
.map(|m| m.condition_id.clone())
|
||||
.expect("no active markets found");
|
||||
|
||||
let mut ws = WebSocketStream::new(WS_USER_URL).with_auth(api_creds);
|
||||
ws.subscribe_user_channel(vec![market_id])
|
||||
.await
|
||||
.expect("failed to subscribe user channel");
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(stability_secs(10));
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), ws.next()).await {
|
||||
Ok(Some(Ok(_msg))) => {},
|
||||
Ok(Some(Err(e))) => panic!("WS error: {:?}", e),
|
||||
Ok(None) => panic!("WS stream ended unexpectedly"),
|
||||
Err(_) => {
|
||||
// No message in this interval; keep waiting.
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user