Nautilus Trader Polymarket 适配器集成测试体系:基于进程内 Mock 交易所的确定性验证方案
Nautilus Trader Polymarket 适配器集成测试体系:基于进程内 Mock 交易所的确定性验证方案
本文聚焦 Nautilus Trader 中 Polymarket 适配器(nautilus-polymarket crate)的集成测试设计与实现。整套测试在一个进程内的 HTTP + WebSocket Mock 交易所上运行,从不真正连接 Polymarket、也从不提交任何带资金订单,却能完整验证从策略指令、风控引擎、执行引擎到交易所客户端、再到缓存状态的整条事件链路。阅读本文后,你将掌握这些测试的分层边界、ExecutionHarness 缝合层的用法、Mock 交易所的路由与状态设计,以及如何为新的交易所边界行为新增一个集成测试场景。
测试定位:进程内 Mock,绝不触碰真实资金
Polymarket 集成测试位于 crates/adapters/polymarket/tests/README.md 所描述的结构中,全部由 crates/adapters/polymarket/tests/integration/main.rs 统一组织模块。其核心原则只有一句话:这些测试练习的是 Polymarket 适配器与一个进程内 HTTP 和 WebSocket 交易所之间的交互,永不连接 Polymarket,也永不提交带资金订单。
这意味着:
- 所有 HTTP 端点(下单、撤单、余额、订单查询、成交查询、Gamma 市场、费率、心跳、健康检查)都由本地 axum 路由提供;
- 所有 WebSocket 帧(市场行情、用户订单事件、用户成交事件)都由测试进程内的 socket 服务推送;
- 适配器的
PolymarketExecutionClient/PolymarketDataClient通过配置中的base_url_*覆盖指向本机地址,与生产工厂走完全相同的代码路径。
测试的安全性由结构保证,而不是由环境隔离保证——即使测试代码存在缺陷,最坏情况也只是本地 Mock 的请求计数或状态不符,绝不涉及真实资产。
运行测试:使用 nextest 隔离每个测试进程
文档明确建议使用 nextest 运行,原因在于 nextest 会让每一个测试用例都获得独立的进程、消息总线、事件发送器和日志器,避免共享的全局状态(如系统事件 channel)在用例之间相互污染:
# 只运行执行客户端(execution client)层级的 seam 测试
cargo nextest run -p nautilus-polymarket --test integration live::
# 只运行 LiveNode 全节点冒烟测试
cargo nextest run -p nautilus-polymarket --test integration node::
# 运行全部集成测试
cargo nextest run -p nautilus-polymarket --test integration
最后一条命令除了 live 与 node 之外,还会运行适配器更底层的 data-client、HTTP、WebSocket 与 Python 集成模块。从 crates/adapters/polymarket/tests/integration/main.rs 的模块声明可以看到完整范围:data_client、exec_client、harness、http、live、mock_venue、node、python、websocket。
运行前提:nautilus-live 需要启用 test-support 与 node 两个 feature,这一点在 crates/adapters/polymarket/Cargo.toml 的 [dev-dependencies] 中声明为 nautilus-live = { workspace = true, features = ["node", "test-support"] },并额外依赖 nautilus-testkit(testers feature)提供测试构建器。
三个测试层次:边界、链路与装配
| 模块 | 边界 | 目的 |
|---|---|---|
integration/exec_client.rs |
执行客户端 → Mock HTTP/WebSocket 交易所 | 验证适配器细节行为与交易所边界场景 |
integration/live.rs |
风控引擎 → 执行引擎 → 客户端 → 交易所 → 缓存 | 确定性的跨层生命周期与路由验证 |
integration/node.rs |
策略 → LiveNode → 生产客户端工厂 → 交易所 → 缓存 |
生产装配与执行管理器冒烟验证 |
三个层次的关注点各不相同:
- exec_client 层:直接构造裸的
PolymarketExecutionClient,通过替换事件发送器(set_exec_event_sender、replace_system_event_sender)来捕获适配器发出的ExecutionEvent与SystemEvent。它适合验证单个 API 调用的细粒度行为,例如 CLOB 协议版本握手、心跳重同步、批量撤单分块、签名类型校验等。 - live 层(seam 层):通过
nautilus_live::testing::ExecutionHarness把风控引擎、执行引擎、时钟、缓存与事件路由全部接起来,让命令真正走完"策略或指令 → RiskEngine → ExecutionEngine → 客户端 → Mock 交易所"的完整链路。 - node 层:启动一个真实的
LiveNode,注册生产工厂PolymarketExecutionClientFactory,让ExecutionManager消费适配器事件,验证生产装配是否正确。
Seam 缝合层:ExecutionHarness 与共享生命周期断言
文档明确说明了 seam 层的依赖:它使用 nautilus_live::testing::ExecutionHarness,由 nautilus-live 的 test-support feature 提供。该 harness 拥有测试时钟、缓存、风控引擎、执行引擎、事件路由以及共享的生命周期断言(nautilus_live::testing::invariants)。
Polymarket 专属的客户端构造、合约与订单构建集中在 crates/adapters/polymarket/tests/integration/harness/mod.rs:
Harness::build()创建ExecutionHarness,注册一个 1000 pUSD 余额的POLYMARKET-001现金账户,启动 Mock 服务器,构造ExecutionClientCore(OmsType::Netting+AccountType::Cash),再用PolymarketExecutionClient::new(core, execution_config(addr))创建客户端并注册进 harness;- 合约是
TEST-TOKEN.POLYMARKET的二元期权(BinaryOption),条件 ID 与 token ID 来自mock_venue的TEST_CONDITION_ID/TEST_TOKEN_ID,价格精度采用POLYMARKET_PRICE_PRECISION(0.0001); - 订单通过
OrderTestBuilder构造限价买单,价格0.5000、数量100.0000,TimeInForce::Gtc。
而交易所的路由与状态则全部归属 crates/adapters/polymarket/tests/integration/mock_venue.rs。
node 层则启用 nautilus-live 的 node feature,通过 PolymarketExecutionClientFactory 构造适配器;PolymarketExecutionClientConfig 中的端点覆盖字段把生产工厂指向本地交易所。这一点在 crates/adapters/polymarket/tests/integration/node.rs 的 build_node 中得到印证:它使用 LiveNodeBuilder、add_exec_client(None, Box::new(PolymarketExecutionClientFactory), Box::new(execution_config(addr))),并把二元期权合约注入节点内核的缓存与执行客户端适配器。
被跟踪的生命周期事件与严格断言
文档强调:被跟踪的生命周期事件必须走类型化的订单事件路由。所谓"被跟踪"(tracked),指的是由本引擎提交、在缓存中有完整事件历史的订单;与之相对的是交易所上独立存在、仅通过批量对账(mass-status reconciliation)发现的外部订单。对账报告覆盖交易所外部状态与错过的终态更新。
测试断言的是精确值而非近似值,harness::invariants 提供了一组可复用的不变量检查,包括:
- 精确的订单状态(
Accepted/Rejected/Canceled/PartiallyFilled/Filled等); - 精确的成交量(已成交数量、待撤销数量);
- 交易所身份(
venue_order_id必须等于 Mock 返回的DEFAULT_ACCEPTED_ORDER_ID或签名哈希); - 风控命令计数(
risk_command_count(),验证命令确实经过风控引擎); - 自有订单簿成员资格(
assert_in_own_book)与一致性(assert_own_book_consistent); - 成交去重(重复推送同一成交帧不会重复累计成交量)。
例如 crates/adapters/polymarket/tests/integration/live.rs 中的 tracked_partial_then_full_fill_is_exact_and_deduplicated:先推送一次部分成交帧使订单进入 PartiallyFilled 并断言成交量为 25,再重复推送同一帧并等待 200ms 后再次断言成交量仍为 25(去重生效),最后推送成交完成帧断言总量为 100 且恰好产生 2 个 Filled 事件。
Seam 层覆盖的典型场景
按照文档的归纳,seam 场景覆盖:
- 连接与客户端注册:
harness_builds_and_connects验证构建后引擎就绪、合约入缓存、用户 WebSocket 已建立(user_socket_count == 1); - 经风控引擎的直接提交与
ExecTester提交:submit_routes_to_accepted_in_cache验证提交后进入Accepted且风控命令计数为 1;exec_tester_drives_submit_to_accepted则用register_exec_tester注册的测试执行器在on_quote驱动下提交; - 接受、拒绝、取消、部分成交、全部成交与重复成交处理:分别对应
submit_venue_error_rejects_and_stays_out_of_book(交易所返回失败响应 → 订单被拒绝且不进自有订单簿)、tracked_cancel_emits_event_and_shrinks_own_book(取消后订单移出自有订单簿)、tracked_full_fill_emits_event_and_closes(成交后订单关闭)等; - 带稳定客户端订单 ID 与替换后交易所订单 ID 的 cancel-replace:
cancel_replace_rotates_venue_id_and_updates_exact_values验证改价(0.5000 → 0.6000)改量(100 → 120)后,客户端订单 ID 不变、交易所订单 ID 轮换、Mock 中只保留一个 open order; - 来自 mass-status 对账的外部订单与外部成交报告:
external_order_is_parsed_by_mass_status_reconciliation断言外部订单报告的账户、instrument、方向、类型、价格、时间戳等精确字段;external_fill_is_parsed_by_mass_status_reconciliation断言外部成交报告的trade_id、成交量、成交价、LiquiditySide::Taker与ts_event; - 启动对账与错过的终态取消恢复:
startup_reconciliation_correlates_tracked_open_order验证对账不重复产生事件;reconciliation_applies_missed_terminal_cancel验证对账把处于PendingCancel的订单收敛为Canceled; - 由用户 WebSocket 事件解决的歧义提交与歧义取消:
ambiguous_submit_is_resolved_by_signed_hash_websocket_order中,下单 HTTP 返回 500,但 Mock 记录了请求体的签名订单哈希;测试用order_hash计算出哈希、构造ws_user_order_placement帧并推送,订单随即被解析为Accepted(恰好 1 个 Accepted 事件、0 个 Rejected);ambiguous_cancel_is_resolved_by_websocket_cancel同理,取消请求返回 500 后订单停留在PendingCancel,推送ws_user_order_cancellation帧后收敛为Canceled。
全节点冒烟测试:真实 LiveNode 装配
node 层测试会真正 boot 和 stop 一个 LiveNode,由一个策略在启动时(on_start)通过节点提交订单,并让 ExecutionManager 消费适配器事件。场景覆盖:
- 接受(
submit_routes_through_node_to_accepted:策略收到OrderAccepted,缓存状态Accepted,venue ID 正确); - 拒绝(
submit_venue_error_routes_through_node_to_rejected:策略收到OrderRejected,缓存状态Rejected且无 venue ID); - 取消(
cancel_routes_through_node:收到OrderAccepted后自动撤单,Mock 撤单计数为 1,最终Canceled); - 成交驱动的建仓(
fill_routes_through_node_execution_manager:策略收到OrderFilled,缓存出现一个数量 100 的开仓仓位); - 成交作废与仓位移除(
failed_fill_routes_void_through_node_execution_manager:先成交再推送失败成交帧,订单进入Voided、filled_qty归零、voided_qty为 100,且开仓仓位为空)。
一个值得注意的设计决策:这些测试中禁用对账(LiveExecutionEngineConfig { reconciliation: false, .. }),使冒烟边界聚焦于节点装配与订单事件运行循环;对账行为由 seam 层测试单独覆盖。这体现了"每个测试层只验证一件事"的分层哲学。
此外 node_boots_connects_and_stops_clean 还验证了启动路径的请求序列精确等于 ["/version", "/ws", "/balance-allowance"]——即客户端启动时依次做协议版本握手、建立用户 WebSocket、查询余额/授权,任何顺序变化都会让测试失败。
Mock 交易所与 Fixture 设计
integration/mock_venue.rs 是整套测试的"地基",它承担以下职责:
- 记录请求:保存每个请求的路径、查询参数、请求头与请求体(
last_path、last_query、last_headers、last_body),测试可据此断言适配器发出的请求体与认证头; - 提供可配置的 HTTP 响应:
TestServerState中的每个响应字段(订单、批量订单、取消、批量取消、余额、版本、心跳、费率、订单列表、成交列表、仓位、Gamma、盘口)都可单独覆盖,包括响应状态码与响应头,从而构造 500、429、401 等故障路径; - 跟踪交易所侧 open order ID:
open_order_ids集合随下单成功而插入、随撤单成功而移除,用于验证 cancel-replace 后旧 ID 消失、新 ID 保留; - 在客户端连接后推送 fixture 背书的用户 WebSocket 帧:
send_user/feed_user通过tokio::sync::broadcastchannel 把测试准备好的 JSON 帧推给已建立的 socket,模拟交易所异步确认(下单后被接受、成交、取消等用户流事件)。
fixture 统一存放在 crates/adapters/polymarket/test_data 下(即文档中所指的 ../test_data/,相对 README 所在目录)。文档强调:这些 fixture 使用互不相同的精确值,使得错误的字段映射、关联(correlation)、去重或数量核算会立刻以可见的方式失败——例如成交量精确到 10.0000、25.0000、100.0000,价格精确到 0.5000、0.6000,时间戳用明确的纳秒值。TestServerState::configure_default_order_success 一次性设置余额、被接受的订单响应与撤单响应,供端到端 happy path 复用。
从 crates/adapters/polymarket/tests/integration/mock_venue.rs 的路由表可以看到 Mock 覆盖的交易所面(均挂载于 create_test_router):
GET /data/orders、GET /data/order/{id}、GET /data/trades:批量订单/单笔订单/成交查询(对账用);GET /version:CLOB 协议版本握手(适配器只支持 V2,版本不符则拒绝启动);GET /balance-allowance:余额与授权查询;POST/DELETE /order、POST/DELETE /orders:单笔与批量下单/撤单;DELETE /cancel-all、DELETE /cancel-market-orders:全部撤单与按市场撤单;GET /markets、GET /book、GET /fee-rate:Gamma 市场、盘口、费率;POST /v1/heartbeats:认证心跳(订单安全心跳);GET /health:健康检查(服务器就绪探测);GET /positions:仓位查询;GET /ws:用户 WebSocket 升级端点。
start_mock_server 绑定 127.0.0.1:0(随机端口),并轮询 /health 直至就绪后返回地址,随后把地址塞进 PolymarketExecutionClientConfig 的 base_url_http / base_url_ws / base_url_data_api 字段。
新增一个测试场景的五个步骤
文档给出了新增场景的标准流程,结合源码可以细化为:
- 复用现有 fixture,而不是盲目新增。当某个 fixture 已经精确表示所需交易所消息时直接复用;只有当线上报文状态确实不同(例如新的成交类型、新的订单状态字段组合)时,才在
crates/adapters/polymarket/test_data下新增一个聚焦的 fixture。fixture 的值要"彼此不同且精确",以便让映射错误显形。 - 在派发命令之前配置
TestServerState。TestServerState是Arc共享的可变状态,测试可以在Harness::build()之后、submit_via_risk/cancel_via_execution之前设置响应覆盖。例如拒绝路径:*h.mock_state.order_response.lock().await = Some(load_json("http_order_response_failed.json"))。 - 经风控引擎或节点策略提交,然后等待精确的缓存状态。seam 层用
pump_until(DEADLINE, predicate)驱动确定性时钟推进,直到缓存中的订单到达目标状态;node 层用wait_until_async+ 策略生命周期探针(LifecycleProbe)等待策略回调。 - 当交易所异步确认时,用
feed_user发送用户 WebSocket 状态。feed_user(filename)会自动根据消息type补上event_type(TRADE→trade,否则 →order),并通过send_user等待 socket 建立后推送。需要改字段时(如把订单 ID 替换为签名哈希)可先load_json再修改再send_user。 - 断言场景影响的稳定报告字段、订单事件、数量、交易所身份、订单簿状态与仓位。参考
harness::invariants中的断言族:assert_order_status、assert_filled_qty、assert_in_own_book、assert_own_book_consistent、assert_tracked_used_events,必要时直接用event_count统计某类事件的精确个数。
深入底层:配置覆盖与对账的源码支撑
最后补充两个与测试直接相关的源码事实,帮助你理解为什么测试能"指哪打哪"。
端点覆盖与凭据回落:PolymarketExecutionClientConfig(见 crates/adapters/polymarket/src/config.rs)支持 base_url_http、base_url_ws、base_url_data_api 三个端点覆盖字段,测试正是靠它们把生产客户端指向 Mock;private_key / api_key / api_secret / passphrase / funder 在未配置时分别回落 POLYMARKET_PK / POLYMARKET_API_KEY / POLYMARKET_API_SECRET / POLYMARKET_PASSPHRASE / POLYMARKET_FUNDER 环境变量。测试中还验证了 SignatureType::Poly1271 要求 funder 与签名地址不同,以及 SOCKS 代理不被支持、代理凭据在 Debug 输出中被脱敏。
对账的作用域:PolymarketExecutionClientConfig 上的 instrument_config 参与对账分类——当其中的 load_ids 非空时,其他合约的交易所记录被判定为超出作用域(test_generate_order_status_reports_drops_foreign_open_order 验证了这一点);当未设置或为空时,每条记录都在作用域内,任何在作用域内但无法映射的 open order 都会让对账失败(test_generate_order_status_reports_errors_on_in_scope_unmapped_order)。
结论:这套集成测试通过"进程内 Mock + 精确值 fixture + 分层 seam/node 装配 + 共享不变量断言"的组合,把 Polymarket 适配器最复杂的部分——异步订单确认、对账收敛、歧义结果解析、成交去重——全部转化为确定性的、可复现的、无资金风险的测试场景。它是你理解该适配器行为契约的最佳入口:阅读 crates/adapters/polymarket/tests/integration/live.rs 与 crates/adapters/polymarket/tests/integration/node.rs 中的用例,等于通读了交易所交互的全部边界行为。