Nautilus Trader Polymarket 适配器集成测试体系:基于进程内 Mock 交易所的确定性验证方案

原创2026-09-10 09:53:231,735 阅读
文章标签:金融科技

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::broadcast channel 把测试准备好的 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 字段。

新增一个测试场景的五个步骤

文档给出了新增场景的标准流程,结合源码可以细化为:

  1. 复用现有 fixture,而不是盲目新增。当某个 fixture 已经精确表示所需交易所消息时直接复用;只有当线上报文状态确实不同(例如新的成交类型、新的订单状态字段组合)时,才在 crates/adapters/polymarket/test_data 下新增一个聚焦的 fixture。fixture 的值要"彼此不同且精确",以便让映射错误显形。
  2. 在派发命令之前配置 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"))。
  3. 经风控引擎或节点策略提交,然后等待精确的缓存状态。seam 层用 pump_until(DEADLINE, predicate) 驱动确定性时钟推进,直到缓存中的订单到达目标状态;node 层用 wait_until_async + 策略生命周期探针(LifecycleProbe)等待策略回调。
  4. 当交易所异步确认时,用 feed_user 发送用户 WebSocket 状态。feed_user(filename) 会自动根据消息 type 补上 event_type(TRADE → trade,否则 → order),并通过 send_user 等待 socket 建立后推送。需要改字段时(如把订单 ID 替换为签名哈希)可先 load_json 再修改再 send_user。
  5. 断言场景影响的稳定报告字段、订单事件、数量、交易所身份、订单簿状态与仓位。参考 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 中的用例,等于通读了交易所交互的全部边界行为。

登录后查看全文
nautilus_trader