Compare commits

...
163 Commits
Author SHA1 Message Date
floor-lickerandGitHub ea2a47c499 Merge pull request #76 from floor-licker/fix/flush-websocket-pongs
fix(stream): flush websocket pongs
2026-06-30 22:00:02 -04:00
floor-licker 498d3be196 fix(stream): flush websocket pongs 2026-06-30 21:11:47 -04:00
floor-lickerandGitHub 0eedcf7ea7 Merge pull request #75 from floor-licker/fix/release-polyfill-rs-0.4.2
chore: release 0.4.2
2026-06-30 20:23:06 -04:00
floor-licker 6b93901aaa chore: release 0.4.2 2026-06-30 20:12:55 -04:00
floor-lickerandGitHub 1732f0ff99 Merge pull request #74 from floor-licker/fix/poly1271-deposit-wallet-signing
fix: sign POLY_1271 orders as deposit wallet
2026-06-30 20:05:35 -04:00
floor-licker 8499b361c4 fix: sign poly1271 orders as deposit wallet 2026-06-30 19:54:04 -04:00
floor-lickerandGitHub d0c7ce7636 Merge pull request #73 from floor-licker/fix/omit-network-byte-fetch-svg
docs: omit network-only byte fetch from graph
2026-06-26 11:50:28 -04:00
floor-licker fa89b38cda docs: omit network-only byte fetch from graph 2026-06-26 11:42:40 -04:00
floor-lickerandGitHub fd8a2ebdb3 Merge pull request #70 from floor-licker/fix/fail-fast-api-credentials
fix: fail fast on invalid api credentials
2026-06-22 21:21:40 -04:00
floor-lickerandGitHub 2b8f8e35b4 Merge pull request #69 from floor-licker/fix/public-book-snapshot-clocks
fix: expose book snapshot clocks
2026-06-22 21:15:45 -04:00
floor-licker a15924be18 fix: fail fast on invalid api credentials 2026-06-22 21:12:05 -04:00
floor-lickerandGitHub 9bb57dc3d3 Merge pull request #68 from floor-licker/fix/ws-parse-outside-book-lock
fix: parse websocket levels outside book lock
2026-06-22 21:10:54 -04:00
floor-licker ab491d11c6 docs: clarify websocket snapshot ordering 2026-06-22 21:09:23 -04:00
floor-licker 8385922990 fix: expose book snapshot clocks 2026-06-22 21:06:37 -04:00
floor-lickerandGitHub 6d90790d02 Merge pull request #67 from floor-licker/fix/hashless-ws-snapshot-state
fix: avoid websocket snapshot hash allocation
2026-06-22 21:02:00 -04:00
floor-licker 0da5ecb1cf fix: parse websocket levels outside book lock 2026-06-22 20:59:41 -04:00
floor-lickerandGitHub 8a38779661 Merge pull request #66 from floor-licker/fix/exact-decimal-price-conversion
fix: require exact decimal price conversion
2026-06-22 20:57:52 -04:00
floor-lickerandGitHub 9c6fe86fe0 Merge pull request #64 from floor-licker/fix/network-transport-matrix
bench: add network transport matrix
2026-06-22 20:57:44 -04:00
floor-licker 13fc277f98 fix: avoid websocket snapshot hash allocation 2026-06-22 20:55:11 -04:00
floor-lickerandGitHub d6a87636c9 Merge pull request #65 from floor-licker/fix/remove-dead-buffer-pool
fix: remove unused buffer pool
2026-06-22 20:52:46 -04:00
floor-licker 672a821573 fix: require exact decimal price conversion 2026-06-22 20:50:05 -04:00
floor-licker cdb72797b5 fix: remove unused buffer pool 2026-06-22 20:45:05 -04:00
floor-licker c31aa11502 bench: add network transport matrix 2026-06-22 20:41:40 -04:00
floor-lickerandGitHub 4a4a6801c1 Merge pull request #62 from floor-licker/fix/separate-book-update-clocks
fix: separate book delta and snapshot clocks
2026-06-22 20:15:10 -04:00
floor-licker d9f50c6b7a fix: resolve snapshot clock hash conflicts 2026-06-22 20:09:23 -04:00
floor-lickerandGitHub 08a1c13b80 Merge pull request #63 from floor-licker/fix/same-millisecond-snapshot-hash
fix: accept same timestamp snapshots with new hash
2026-06-22 20:02:24 -04:00
floor-licker 3d85584b95 fix: resolve book clock merge conflicts 2026-06-22 20:01:35 -04:00
floor-licker 9124758187 fix: accept same timestamp snapshots with new hash 2026-06-22 19:54:28 -04:00
floor-lickerandGitHub 8babf07cde Merge pull request #61 from floor-licker/fix/count-hot-path-deallocations
test: count deallocations in hot path guards
2026-06-22 19:52:29 -04:00
floor-licker 39ac56356e fix: separate book delta and snapshot clocks 2026-06-22 19:50:13 -04:00
floor-lickerandGitHub 38303c0dc6 Merge pull request #60 from floor-licker/fix/atomic-book-snapshots
fix: apply book snapshots atomically
2026-06-22 19:48:12 -04:00
floor-licker 77d0d9c390 test: cover websocket book churn heap traffic 2026-06-22 19:47:10 -04:00
floor-licker 543329f4d6 test: count deallocations in hot path guards 2026-06-22 19:42:14 -04:00
floor-lickerandGitHub cb1838e77d Merge pull request #59 from floor-licker/docs/clarify-fill-engine-scope
docs: clarify fill engine scope
2026-06-22 19:39:48 -04:00
floor-lickerandGitHub 947607ce6c Merge pull request #58 from floor-licker/docs/clarify-decode-hot-paths
docs: clarify decode hot path boundaries
2026-06-22 19:39:34 -04:00
floor-licker f6851c476b fix: require both sides for book snapshots 2026-06-22 19:38:22 -04:00
floor-licker 43b71fbd1f fix: apply book snapshots atomically 2026-06-22 19:34:37 -04:00
floor-licker 6a9522f0f9 docs: clarify fill engine scope 2026-06-22 19:28:38 -04:00
floor-licker 90c85639c7 docs: clarify decode hot path boundaries 2026-06-22 19:27:09 -04:00
floor-lickerandGitHub c821262af5 Merge pull request #57 from floor-licker/fix/stream-manager-message-receiver
fix: deliver stream manager broadcasts
2026-06-22 19:26:00 -04:00
floor-lickerandGitHub 0372feb578 Merge pull request #56 from floor-licker/fix/sorted-vec-book-side
perf: use sorted vectors for book sides
2026-06-22 19:25:46 -04:00
floor-licker 02fa74d2f5 fix: deliver stream manager broadcasts 2026-06-22 19:14:51 -04:00
floor-licker 3fb2bd8f66 perf: use sorted vectors for book sides 2026-06-22 19:09:06 -04:00
floor-lickerandGitHub cb8f19c246 Merge pull request #55 from floor-licker/fix/prepared-order-path
feat: add prepared order path
2026-06-22 18:35:45 -04:00
floor-lickerandGitHub 9dc228f278 Merge pull request #54 from floor-licker/fix/typed-pagination-pages
fix: parse paginated data through typed pages
2026-06-22 18:32:00 -04:00
floor-licker e4badca6b6 feat: add prepared order path 2026-06-22 18:29:12 -04:00
floor-licker 37f78d199e fix: parse paginated data through typed pages 2026-06-22 18:17:53 -04:00
floor-lickerandGitHub 000d6cf61b Merge pull request #51 from floor-licker/docs/remove-buffer-pool-claim
docs: remove unsupported buffer pool claim
2026-06-22 18:09:04 -04:00
floor-lickerandGitHub b08df372c3 Merge pull request #53 from floor-licker/fix/borrow-batch-request-bodies
fix: borrow batch request bodies
2026-06-22 18:08:49 -04:00
floor-lickerandGitHub 27c6352ab2 Merge pull request #52 from floor-licker/fix/remove-unused-dns-cache
chore: remove unused dns cache
2026-06-22 18:08:27 -04:00
floor-licker d689fcb524 fix: borrow batch request bodies 2026-06-22 18:03:06 -04:00
floor-lickerandGitHub bfd7b1fa84 Merge pull request #50 from floor-licker/fix/rest-simd-json-parsing
feat: use simd json for rest market parsing
2026-06-22 18:00:12 -04:00
floor-licker d06f1669a6 chore: remove unused dns cache 2026-06-22 17:57:42 -04:00
floor-licker 387e685814 docs: remove unsupported buffer pool claim 2026-06-22 17:42:17 -04:00
floor-lickerandGitHub e603104f03 Merge pull request #49 from floor-licker/fix/widen-order-token-amounts
fix: widen order token amounts
2026-06-22 17:34:21 -04:00
floor-licker 7bde69f0ce feat: use simd json for rest market parsing 2026-06-22 17:31:49 -04:00
floor-licker daa6522adf fix: widen order token amounts 2026-06-22 17:21:27 -04:00
floor-lickerandGitHub 91b1f9f463 Merge pull request #48 from floor-licker/fix/exact-order-tick-size-parsing
fix: require exact order tick sizes
2026-06-22 17:19:45 -04:00
floor-licker b35b2b316e fix: require exact order tick sizes 2026-06-22 17:13:26 -04:00
floor-lickerandGitHub 670d6ae034 Merge pull request #47 from floor-licker/fix/hmac-body-benchmark-and-credentials
fix: prepare hmac credentials for auth paths
2026-06-22 17:10:47 -04:00
floor-licker 6f856ab29c fix: prepare hmac credentials for auth paths 2026-06-22 16:26:38 -04:00
floor-lickerandGitHub 2b7221f1b1 Merge pull request #46 from floor-licker/fix/ws-hot-path-benchmark-coverage
test: broaden ws hot-path benchmarks
2026-06-22 16:11:46 -04:00
floor-licker c14557235f test: broaden ws hot-path benchmarks 2026-06-22 16:02:31 -04:00
floor-lickerandGitHub f9fffb27b5 Merge pull request #45 from floor-licker/fix/book-update-benchmark-reliability
fix: make book update benchmarks measure real work
2026-06-22 15:53:26 -04:00
floor-licker c4b4da9c08 fix: make book update benchmarks measure real work 2026-06-22 15:46:45 -04:00
floor-lickerandGitHub 22904ef1a9 Merge pull request #44 from floor-licker/fix/benchmark-results-refresh
docs: refresh benchmark comparison results
2026-06-22 15:44:36 -04:00
floor-licker 56f4993621 docs: refresh benchmark comparison results 2026-06-22 15:36:19 -04:00
floor-lickerandGitHub 3fa1f937b9 Merge pull request #43 from floor-licker/fix/http-transport-tuning
perf: tune Polymarket HTTP transport
2026-06-22 15:30:21 -04:00
floor-licker 38202cef5e perf: tune Polymarket HTTP transport 2026-06-22 15:20:30 -04:00
floor-lickerandGitHub 51a783bc43 Merge pull request #42 from floor-licker/fix/book-fast-api-allocations
perf: avoid allocations in book analysis APIs
2026-06-22 15:00:58 -04:00
floor-licker 2daa120adf chore: update quinn-proto security patch 2026-06-22 14:49:28 -04:00
floor-lickerandGitHub 9f271dde4c Merge pull request #41 from floor-licker/fix/order-path-repeated-work
perf: reduce repeated order auth work
2026-06-22 14:48:24 -04:00
floor-licker deeb9cc84d chore: update quinn-proto security patch 2026-06-22 14:40:16 -04:00
floor-licker 751928c020 perf: avoid allocations in book analysis APIs 2026-06-22 14:37:02 -04:00
floor-licker 85698e0e61 perf: reduce repeated order auth work 2026-06-22 14:31:16 -04:00
floor-lickerandGitHub 74b98da9ab Merge pull request #40 from floor-licker/fix/sharded-order-book-manager
perf: shard order book manager locks
2026-06-22 14:19:31 -04:00
floor-licker 37f5651b65 perf: shard order book manager locks 2026-06-22 14:02:08 -04:00
floor-lickerandGitHub 5eeb6e309e Merge pull request #39 from floor-licker/fix/snapshot-generation-retain
perf: retain snapshots by generation
2026-06-22 13:54:16 -04:00
floor-licker ddbacf5fbb perf: retain snapshots by generation 2026-06-22 13:46:29 -04:00
floor-lickerandGitHub ebc2929b1e Merge pull request #38 from floor-licker/fix/ws-hot-path-direct-parser
perf: enforce strict ws fixed-point parsing
2026-06-22 13:40:32 -04:00
floor-lickerandGitHub 01c7242e6c Merge pull request #37 from floor-licker/fix/realistic-comparison-benchmarks
test: benchmark real order and book hot paths
2026-06-22 13:34:03 -04:00
floor-licker 352f9d053c perf: enforce strict ws fixed-point parsing 2026-06-22 13:33:51 -04:00
floor-licker d5d7dea231 test: benchmark real order and book hot paths 2026-06-22 13:23:46 -04:00
floor-lickerandGitHub 018863c557 Merge pull request #36 from floor-licker/feat/ws-fixed-point-hot-path
perf: parse WS book levels as fixed point
2026-06-22 13:19:40 -04:00
floor-licker 92b7ccab65 perf: parse ws book levels as fixed point 2026-06-22 13:11:58 -04:00
floor-lickerandGitHub 769f868cff Merge pull request #35 from floor-licker/feat/official-sdk-benchmark
perf: benchmark official Polymarket SDK
2026-06-22 13:03:48 -04:00
floor-licker 4536076274 perf: benchmark official Polymarket SDK
Add an opt-in side-by-side benchmark against Polymarket's active Rust SDK and move the HTTP client stack to reqwest 0.13 with rustls/http2 after diagnostics showed the gap was not JSON parsing.
2026-06-22 12:21:09 -04:00
floor-lickerandGitHub a92b98a9b9 Merge pull request #34 from floor-licker/fix/release-v0.4.1
chore(release): 0.4.1
2026-06-16 16:10:38 -03:00
floor-licker ab71232945 chore(release): 0.4.1 2026-06-16 16:01:13 -03:00
floor-lickerandGitHub 2406c41fd3 Merge pull request #33 from floor-licker/fix/ws-book-snapshot-timestamps
fix(book): handle websocket book snapshots
2026-06-16 15:59:25 -03:00
floor-licker 9c52a85208 fix(deps): update hickory resolver 2026-06-16 15:38:00 -03:00
floor-licker 502bd6f3d0 fix(book): handle websocket book snapshots 2026-06-16 15:29:39 -03:00
floor-lickerandGitHub d1ae63af80 Merge pull request #28 from floor-licker/feat/clob-v2-migration
feat: clob v2 migration
2026-04-28 12:01:27 -03:00
floor-licker 69c42673a9 fix: satisfy latest clippy example lint 2026-04-28 11:42:51 -03:00
floor-licker 919b36378c fix: satisfy ci clippy and audit 2026-04-28 11:38:31 -03:00
floor-licker 7ad3229af8 fix: support V2 proxy wallet funders 2026-04-28 11:19:55 -03:00
floor-licker eac25be5b2 test: support live DNS override and credential aliases 2026-04-28 10:05:22 -03:00
floor-licker f7638310b0 merge main into clob v2 migration 2026-04-28 09:54:39 -03:00
floor-licker 54c35a7034 chore: default live CLOB host to production 2026-04-28 09:46:06 -03:00
floor-licker e5994d04dd fix: align V2 client with official SDK fee behavior 2026-04-27 22:36:57 -03:00
floor-licker e78ae17286 feat: migrate CLOB client to V2-only trading 2026-04-24 12:48:30 -03:00
floor-lickerandGitHub 17255926f9 Merge pull request #14 from onsails/batch
Batch orders, GTC/GTD/FOK/FAK order types, typed responses, proxy-wallet support, per-page pagination
2026-02-16 22:37:23 -05:00
Andrey Kuznetsov 4d6ba2ab5c feat(ws): extend TradeMessage with full trade execution fields
TradeMessage was severely under-specified compared to the Polymarket WS
API. Add missing fields needed by the engine: taker_order_id,
maker_orders, trader_side, fee_rate_bps, outcome, owner, trade_owner,
transaction_hash. Replace untyped status/msg_type strings with proper
TradeMessageStatus and TradeMessageType enums.
2026-02-15 21:19:54 +00:00
Andrey Kuznetsov 0280d537ae feat: add proxy wallet support and per-page pagination
- Add sig_type/funder params to with_l2_headers() for proxy wallet order signing
- Add get_trades_page() and get_orders_page() for per-page cursor pagination
- Refactor get_trades/get_orders to use page methods internally
2026-02-15 21:19:54 +00:00
Andrey Kuznetsov 84c3d269ea feat(client): use typed HTTP response models instead of serde_json::Value 2026-02-15 21:16:50 +00:00
Andrey Kuznetsov 6190a24e0c feat(clob): support all order types for order posting 2026-02-15 21:16:50 +00:00
Andrey Kuznetsov d9627d8f6c feat(clob): enforce 15-order limit for batch posting 2026-02-15 21:16:50 +00:00
Andrey Kuznetsov 30d4ea3937 feat(clob): add batch order posting support 2026-02-15 21:16:49 +00:00
floor-lickerandGitHub 0ad1915d2a Merge pull request #16 from floor-licker/feat/pr13-patch-bump
chore(release): bump patch version to 0.3.1
2026-02-15 10:18:58 -05:00
floor-licker 237c98eb36 chore(release): bump patch version to 0.3.1 2026-02-15 10:07:20 -05:00
floor-lickerandGitHub deb3df783f Merge pull request #13 from onsails/alloy
Upgrade alloy dependency
2026-02-15 10:01:50 -05:00
floor-licker 105a66804d Merge origin/main into alloy to resolve conflicts 2026-02-15 09:53:08 -05:00
floor-lickerandGitHub e8459c82b4 Merge pull request #15 from floor-licker/feat/alloy-1x-upgrade
chore: upgrade alloy dependencies to 1.x
2026-02-15 09:49:44 -05:00
floor-licker 51c7401372 fix: remove hard dependency from side-by-side benchmark example 2026-02-15 09:45:08 -05:00
floor-licker 020140a13b chore: upgrade alloy dependencies to 1.x 2026-02-15 09:38:17 -05:00
55c66f5f64 Update Cargo.toml
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-14 23:47:25 -05:00
Andrey Kuznetsov 81d78a4c6a chore(deps): upgrade Alloy and remove incompatible side-by-side benchmark 2026-02-12 23:12:05 +00:00
floor-lickerandGitHub a63a170335 Merge pull request #10 from floor-licker/feat/rfq-endpoints
feat: add CLOB rfq + fee-rate endpoints
2026-02-03 23:15:44 -05:00
floor-licker 6e52dcbd0c feat:implement rfq endpoints 2026-02-03 22:36:34 -05:00
floor-licker 29307e4c90 chore: bump bytes to 1.11.1 2026-02-03 19:48:16 -05:00
floor-licker e0250dbf4b docs: update README.md 2026-02-03 16:32:41 -05:00
floor-licker 443bc1ef1e docs: update README.md 2026-02-03 16:29:57 -05:00
floor-licker 6517c51bd4 chore(crate): trim keywords/categories for crates.io 2026-02-02 21:33:24 -05:00
floor-licker f3e233e172 chore(release): bump to 0.3.0 2026-02-02 21:31:13 -05:00
floor-licker d078e70c73 chore(http): derive user-agent version from package metadata 2026-02-02 21:15:17 -05:00
floor-licker 951b5a8051 style(test): rustfmt prices-history integration test 2026-02-01 10:14:48 -05:00
floor-licker 0b2740a434 test(api): add /prices-history real API integration test 2026-01-31 10:38:44 -05:00
floor-licker 3dfb65ea0e feat(api): add /prices-history helper methods 2026-01-31 10:30:48 -05:00
floor-licker 9f86e24402 docs(readme): add ws_hot_path benchmark command 2026-01-30 23:04:25 -05:00
floor-licker 25f268981f docs(readme): mention WS hot-path benchmark 2026-01-30 23:03:33 -05:00
floor-licker f5a807cfd0 bench(ws): add serde baseline comparison 2026-01-30 22:48:52 -05:00
floor-licker 40b503ef29 bench(ws): add WS hot-path Criterion benchmark 2026-01-30 22:41:51 -05:00
floor-licker 65bab0d3c1 docs: update README.md 2026-01-30 21:30:10 -05:00
floor-licker d4e240714c test(ws): add user channel and stability checks 2026-01-30 21:21:14 -05:00
floor-licker 1e1ef74730 test(ws): add ignored real-market websocket smoke test 2026-01-30 21:14:42 -05:00
floor-licker 2dc3c06155 test: make real-api smoke tests robust 2026-01-30 21:10:15 -05:00
floor-licker 0f577f0b2c fix(api): improve order endpoint compatibility 2026-01-30 21:10:03 -05:00
floor-licker 2570aa08a1 docs: clarify integration test env vars 2026-01-30 20:55:20 -05:00
floor-licker 31062b3ed7 docs: align testing guide with CI 2026-01-30 20:54:21 -05:00
floor-licker d45b202d6d feat(ws): add book-applier stream 2026-01-30 20:40:07 -05:00
floor-licker e0b97c53cb feat(ws): add tape-based book update processor 2026-01-30 20:22:45 -05:00
floor-licker b523a7fd44 docs: update README.md 2026-01-30 19:53:17 -05:00
floor-licker 9fa4174e42 docs: update README.md 2026-01-30 19:48:30 -05:00
floor-licker 3b0765fbec ci: run no-alloc hot-path tests as separate job 2026-01-30 19:37:46 -05:00
floor-licker c1b763309b chore(security): update lockfile for RustSec advisories 2026-01-30 19:20:32 -05:00
floor-licker 7145f74805 docs: update README.md 2026-01-30 19:01:46 -05:00
floor-licker 0e71182ec5 feat(book): apply ws book updates via OrderBookManager 2026-01-30 18:50:52 -05:00
floor-licker a0bf317d97 refactor(ws): replace unbounded channel with bounded queue 2026-01-30 18:50:45 -05:00
floor-licker 75ce261445 perf(ws): avoid cloning batch stream messages 2026-01-30 18:50:28 -05:00
floor-licker 9594058fd5 feat(book): apply websocket book updates 2026-01-30 18:17:45 -05:00
floor-licker dea50163fe test(no-alloc): guard hot paths against heap allocs 2026-01-30 18:01:44 -05:00
floor-licker cc419acaf3 chore(rustfmt): keep config stable-compatible 2026-01-29 19:07:11 -05:00
floor-licker 41db8cabcd chore(fmt): cargo fmt 2026-01-29 18:47:30 -05:00
floor-licker da51caa251 chore(cargo): use crates.io polymarket-rs-client 2026-01-29 18:47:19 -05:00
floor-licker a7ca8e4341 feat(ws): align auth and event_type parsing 2026-01-29 18:35:02 -05:00
floor-licker 703bd9f509 feat(book): align /book response fields 2026-01-29 18:33:27 -05:00
floor-licker e28e1afa0b fix(auth): only fallback derive on API errors
Match upstream behavior: only retry derive-api-key when create-api-key fails with an HTTP status error.
2026-01-29 18:28:42 -05:00
floor-licker 64205eeeaa chore(cargo): fix manifest and dev dep
- Remove duplicate package key in Cargo.toml
- Add local polymarket-rs-client dev-dependency for examples
2026-01-29 18:26:54 -05:00
floor-licker 95b96fb558 chore: update Cargo.toml 2026-01-22 18:14:03 -05:00
floor-licker 1f9c3221e6 docs: update README.md 2026-01-22 17:09:27 -05:00
floor-licker 553698dda2 docs/chores/refactor 2026-01-04 11:24:35 -05:00
floor-licker c8c2048f33 fix: update WebSocket implementation 2026-01-03 23:45:26 -05:00
floor-licker 6da792b2ae fix: fix to wss subscription sigs 2026-01-03 23:36:11 -05:00
39 changed files with 11709 additions and 3199 deletions
+25 -5
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29 -14
View File
@@ -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
+40 -466
View File
@@ -4,7 +4,17 @@
[![Documentation](https://docs.rs/polyfill-rs/badge.svg)](https://docs.rs/polyfill-rs)
[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](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 |
![polyfill-rs benchmark results](assets/benchmark-results.svg)
| 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.
+33
View File
@@ -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
View File
@@ -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();
}
});
});
+177 -33
View File
@@ -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);
+1 -1
View File
@@ -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)
})
})
+576
View File
@@ -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
View File
@@ -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
+299
View File
@@ -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(())
}
+11 -31
View File
@@ -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
+33 -62
View File
@@ -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
View File
@@ -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 &current.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 &current.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
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-120
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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
View File
@@ -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);
}
}
-125
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+416 -208
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+475
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+620
View File
@@ -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
View File
@@ -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
);
},
}
}
+45
View File
@@ -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
View File
@@ -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);
}
}
},
}
}
+191
View File
@@ -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.
},
}
}
}