量化交易系统架构
本文按量化交易系统的组成组织,共二十二章:
| 部分 |
章节 |
| 总体架构 |
一、生产级量化交易系统架构 |
| 交易域(按数据流顺序) |
二、行情网关;三、订单簿;四、事件总线;五、策略引擎;六、组合构建;七、执行引擎 EMS;八、事前风控;九、OMS;十、交易网关;十一、高频交易系统的热路径(贯穿上述组件的延迟优化) |
| 支撑服务 |
十二、参考数据服务;十三、配置与参数中心;十四、时钟同步;十五、行情与事件录制 |
| 研究域 |
十六、回测与仿真系统;十七、风险模型 |
| 运营域 |
十八、对账;十九、PnL 归因;二十、交易成本分析(TCA);二十一、监控告警 |
| 展望 |
二十二、技术演进方向 |
开源量化项目的整理见 量化交易系统_qa.md 第 15 题。
一、生产级量化交易系统架构
1. 分层总览
系统分为四部分:研究域离线产出策略和模型;交易域在线执行;支撑服务为交易域提供参考数据、配置、时钟与录制;运营域做事后核对与监控。
flowchart TB
subgraph R[研究域 离线]
R1[历史数据仓库] --> R2[因子 / 特征库] --> R3[模型训练] --> R4[回测 / 仿真] --> R5[策略审批上线]
end
subgraph T[交易域 在线]
BUS{{事件总线 + 事件日志<br/>定序 / 持久化 / 重放}}
MD1[行情网关] --> MD2[行情标准化] --> MD3[订单簿重建]
MD3 -- 订单簿更新 --> BUS
MD2 -- 成交 / 交易状态等 --> BUS
BUS -- 行情 / 回报 / 定时器 --> STR[策略引擎]
STR -- 信号 --> BUS
BUS -- 信号 --> PC[组合构建]
PC -- 目标持仓 --> EMS[执行引擎 EMS]
EMS -- 子订单 --> RISK[事前风控]
RISK -- 放行 --> OMS[OMS]
OMS -- 报单 --> TG[交易网关]
TG -- 报单 --> EX[(交易所 / 券商)]
EX -- 回报 --> TG
TG -- 回报 --> OMS
OMS -- 订单 / 成交 / 持仓事件 --> BUS
RISK -- 拒单 / 熔断事件 --> BUS
end
subgraph SUP[支撑服务]
REF[参考数据<br/>合约 / 日历 / 公司行为]
CFG[配置与参数中心]
CLK[时钟同步 PTP]
REC[行情与事件录制]
end
subgraph O[运营域 事后]
O1[对账<br/>持仓 / 资金 / 成交]
O2[PnL 归因]
O3[交易成本分析 TCA]
O4[监控告警]
O5[审计]
end
R5 -- 策略代码 / 模型 / 参数 --> STR
REF -.-> MD2
CFG -.-> STR
CFG -.-> RISK
CLK -.-> MD1
CLK -.-> TG
BUS --> REC
REC --> R1
REC --> O1
REC --> O2
REC --> O3
REC --> O5
BUS --> O4
1.1 事件总线的位置
总线是交易域的骨干,而不是流水线上的一站。 各组件的输出(订单簿更新、信号、订单状态、成交、拒单、熔断)都以事件形式发布到总线,并按全局序号写入事件日志,这是第 3 节"事件溯源与确定性重放"的基础。录制、监控、运营域都从总线获取数据。
订单路径不经过总线中转。 执行引擎 → 事前风控 → OMS → 交易网关是同步的控制流:顺序必须严格,延迟必须最低,且风控必须拦在报单之前,因此各环节直接调用。每一步的结果再作为事件发布到总线,供其他组件观察。高频做市策略通常跳过组合构建与执行引擎,由策略直接生成订单进入风控。
订单簿在总线之前:集中构建,再分发。 行情侧重建订单簿,总线上发布的是重建后的订单簿更新(或前 N 档快照、最优价变化),而不是原始增量。两种设计的对比:
| 设计 |
做法 |
优点 |
缺点 |
| 集中构建(图中设计) |
行情进程重建订单簿,发布订单簿更新或快照 |
消费者无需重复重建;缺口检测与恢复集中在一处(第三章第 7 节);所有下游看到一致的订单簿;可合并更新,降低下游负载 |
下游拿不到逐笔细节;多一次传递 |
| 分散构建 |
总线发布标准化后的增量,每个消费者自建订单簿 |
消费者可按需选择深度与数据结构;可获得逐笔信息(如排队位置) |
每个消费者都要处理缺口与恢复;CPU 与内存重复消耗;各自状态可能不一致 |
生产系统通常两者结合:总线同时发布标准化增量(供做市策略、录制使用)和订单簿快照或最优价变化(供多数策略使用);延迟最敏感的策略与订单簿运行在同一线程(第十一章 run-to-completion),完全不经过总线。
成交、交易状态(开盘、停牌、熔断)、指数、参考价等不属于订单簿的行情,由标准化层直接发布到总线。
各组件在线程、进程、机器上的划分,以及多机一致性与单机可靠性,见第 5、6 节。
1.2 各组件职责
| 组件 |
输入 |
输出 |
关键问题 |
| 行情网关 |
交易所原生二进制协议、FIX、WebSocket |
原始行情消息 |
断线重连、A/B 仲裁、序号校验、内核旁路 |
| 行情标准化 |
原始行情消息 |
统一格式的行情事件 |
品种映射、价格整数化、时间戳 |
| 订单簿重建 |
增量行情 |
订单簿更新 / 快照 |
快照与增量衔接、缺口恢复(第三章) |
| 事件总线 |
所有组件的事件 |
按序号排列、可重放的事件流 |
定序、持久化、背压、分层传输 |
| 策略引擎 |
行情、回报、定时器 |
信号或订单 |
只依赖抽象接口,不感知回测或实盘;状态恢复 |
| 组合构建 |
多个信号、风险模型 |
目标持仓 |
风险暴露约束、换手约束、交易成本 |
| 执行引擎 |
目标持仓与当前持仓之差 |
子订单序列 |
拆单算法、市场冲击、路由 |
| 事前风控 |
子订单 |
放行或拒绝 |
独立于策略,拥有否决权,不可用时拒绝一切 |
| OMS |
订单与回报 |
订单状态、持仓、资金 |
状态机正确性、幂等、状态未知的处理 |
| 交易网关 |
内部订单 |
交易所协议报文 |
会话管理、限流、断线撤单 |
| 参考数据 |
交易所公告、数据商 |
合约属性、日历、公司行为 |
时点版本化、开盘前校验 |
| 配置与参数中心 |
人工变更、发布流水线 |
策略参数、风控限额 |
版本化、审计、变更写入事件日志 |
| 时钟同步 |
GPS / PTP 主时钟 |
各服务器的统一时间 |
偏移监控、监管精度要求 |
| 行情与事件录制 |
网络报文、总线事件 |
可回放的数据文件 |
完整性、存储成本 |
2. 组件设计与开源方案
各组件的设计要点与可用的开源方案。标注"自建"的组件没有成熟的通用开源实现,通常根据自身业务开发。
2.1 研究域
历史数据仓库
| 设计要点 |
说明 |
| 分层存储 |
原始层(交易所原始报文,不可变,用于重建与审计)→ 标准化层(统一 schema 的逐笔、快照、K 线,按日期与品种分区的 Parquet)→ 衍生层(K 线聚合、因子) |
| 时点(point-in-time) |
每条记录带事件时间、发布时间、入库时间;财务数据保留每次修订的版本(双时态),按"当时可得"取数(第 3 节 ⑥) |
| 数据质量 |
缺失与异常检测、与第二数据源交叉校验、按交易日历对齐 |
| 访问方式 |
研究批量扫描用 DuckDB / Polars 直接读 Parquet;交互查询用 ClickHouse / QuestDB |
商业方案:kdb+、DolphinDB。
因子 / 特征库
| 设计要点 |
说明 |
| 因子定义即代码 |
因子以表达式或函数定义,纳入 Git 版本管理;元数据记录作者、版本、依赖数据、计算频率、历史 IC |
| 离线与在线同一份定义 |
每日收盘后批量计算全市场,盘中在线实时计算,二者执行同一份定义,避免训练与线上不一致(与广告系统特征一致性问题相同) |
| 存储 |
批量结果为"日期 × 股票 × 因子"宽表(Parquet / ArcticDB);在线读取放内存或 KV |
| 自动评估 |
新因子入库时自动产出 IC、分层收益、与已有因子相关性、衰减报告 |
| 开源方案 |
用途 |
| Qlib |
因子表达式引擎(如 Ref($close, 20))与二进制数据层 |
| Feast |
通用特征存储:离线 / 在线一致,支持按时间点关联(point-in-time join) |
| alphalens-reloaded |
因子评估报告 |
| Polars、DuckDB |
因子批量计算 |
模型训练
| 设计要点 |
说明 |
| 滚动训练 |
按时间窗口前滚训练与验证,训练集与测试集之间留出标签长度的间隔(purging / embargo) |
| 实验跟踪 |
记录代码版本、数据快照版本、参数、随机种子、指标,保证可复现 |
| 模型注册 |
模型版本与审批状态(候选 / 已审批 / 已上线 / 已下线) |
| 资源 |
GBDT 以 CPU 为主,深度模型用 GPU;参数搜索并行化 |
| 开源方案 |
用途 |
| LightGBM、XGBoost、PyTorch |
模型 |
| MLflow |
实验跟踪与模型注册 |
| Ray、Dask |
分布式调参与并行训练 |
| Qlib |
滚动训练工作流 |
回测 / 仿真
| 设计要点 |
说明 |
| 与实盘同一套代码 |
事件驱动引擎与实盘共用(第 3 节 ①);向量化回测只用于大规模初筛 |
| 撮合模型分层 |
K 线级、成交量约束、排队位置三档,按策略频率选择(第 3 节 ⑦) |
| 并行 |
参数网格与多品种任务分发到集群 |
| 防过拟合 |
样本外检验;试验次数多时用 Deflated Sharpe Ratio(Bailey & López de Prado 2014)校正夏普比率 |
| 开源方案 |
用途 |
| NautilusTrader、LEAN |
事件驱动、回测实盘一体 |
| vectorbt |
向量化初筛 |
| hftbacktest |
高频、排队位置与延迟建模 |
回测与仿真系统的设计见第十六章。
| Ray、Dask | 回测任务并行 |
策略审批上线
| 设计要点 |
说明 |
| 发布单元 |
策略代码 + 模型 + 参数 + 风控限额打包为一个不可变、带版本号的发布单元,可整体回滚 |
| 上线流程 |
代码评审 → 标准回测报告 → 风控审批限额 → 影子模式(实时运行只记录信号不下单,与回测对比)→ 小资金实盘 → 逐步放量 |
| 上线后监控 |
实盘与回测的信号、成交、收益偏差 |
| 开源方案 |
用途 |
| Git + CI(GitHub Actions、GitLab CI) |
评审、测试、构建 |
| MLflow Model Registry |
模型审批状态 |
| Docker / Kubernetes |
中低频策略部署;高频系统多部署在物理机上,用配置管理工具(如 Ansible)发布 |
2.2 交易域
行情网关
| 设计要点 |
说明 |
| 适配器 |
每个交易所 / 协议一个适配器,差异不向下游泄漏 |
| 会话 |
登录、心跳、订阅、断线重连 |
| 可靠性 |
A/B 双路仲裁、序号校验、缺口恢复(第三章第 7 节) |
| 性能 |
内核旁路、轮询收包、网卡硬件时间戳、按组播通道分核(第十一章) |
| 背压 |
下游变慢时不能阻塞收包;对慢消费者合并更新或断开 |
| 开源方案 |
用途 |
| OpenOnload、DPDK |
内核旁路网络栈 |
| Simple Binary Encoding |
SBE 编解码器生成工具;CME MDP 3.0 与 iLink 3 使用 SBE 编码 |
| QuickFIX |
FIX 协议行情 |
| CCXT、cryptofeed |
加密货币交易所 REST / WebSocket 行情 |
| vnpy_ctp 等 vn.py 接口 |
国内期货 CTP 等柜台 |
| NautilusTrader 适配器 |
多交易所行情接入 |
行情网关的设计见第二章。
行情标准化
| 设计要点 |
说明 |
| 统一 schema |
品种用整数 ID(查参考数据得到);价格整数化;数量;方向;事件类型 |
| 三个时间戳 |
交易所时间、网卡接收时间、处理完成时间,用于延迟分析与排序 |
| 零拷贝二进制 |
热路径使用定长二进制格式(SBE、FlatBuffers、Cap'n Proto),不用 JSON;Protobuf 需要完整解码,也不适合热路径 |
订单簿重建:设计与实现见第三章。
事件总线
| 层级 |
范围 |
延迟量级(未实测) |
技术 |
| 线程间 |
同一进程 |
几十纳秒 |
SPSC / MPSC 环形缓冲区、Disruptor |
| 进程间 |
同一台机器 |
百纳秒级 |
共享内存:Aeron IPC、Chronicle Queue、iceoryx2 |
| 跨机低延迟 |
同一机房 |
微秒级 |
Aeron UDP 单播 / 组播 |
| 跨机非关键路径 |
数据中心之间 |
毫秒级 |
Kafka / Redpanda、NATS、ZeroMQ |
| 设计要点 |
说明 |
| 全局定序 |
每个事件分配单调递增序号,所有消费者看到相同顺序 |
| 持久化与重放 |
事件日志落盘(Chronicle Queue、Aeron Archive),支撑事件溯源、重放、主备复制(第 3 节 ②) |
| 背压 |
热路径的生产者不能被慢消费者拖住:慢消费者自行追赶日志,或被断开后从日志恢复 |
| 单写者 |
每个主题一个写者,避免锁与乱序 |
| schema 演进 |
消息格式带版本号,新增字段向后兼容,保证历史日志可重放 |
事件总线的内部设计与实现见第四章。
策略引擎
| 设计要点 |
说明 |
| 接口 |
事件回调:on_quote、on_book、on_order_filled、on_timer 等;定时器也是事件 |
| 隔离 |
中低频策略按进程隔离,一个策略崩溃不影响其他;高频策略与订单簿同进程同线程 |
| 状态恢复 |
重启后从事件日志恢复内部状态,而不是从零开始 |
| 参数热更新 |
经配置中心下发,变更本身作为事件写入日志,保证重放时参数与当时一致 |
| 语言分层 |
中低频用 Python;高频用 C++ / Rust |
| 开源方案 |
用途 |
NautilusTrader Strategy、LEAN QCAlgorithm |
回测实盘一体的策略接口 |
| vn.py CTA 引擎、WonderTrader(CTA / SEL / HFT / UFT 引擎) |
国内期货与股票 |
| Hummingbot Strategy V2 |
加密货币做市与套利 |
策略引擎的设计与实现见第五章。
组合构建
优化问题的一般形式:
maximize αᵀw − λ · wᵀΣw − cost(w − w₀)
subject to 行业与风格暴露上下限、个股权重上限、换手上限、流动性约束
w:目标权重 w₀:当前权重 α:预期收益(来自信号 / 模型)
Σ:协方差矩阵(来自风险模型:因子暴露 × 因子协方差 × 因子暴露ᵀ + 特异风险)
| 设计要点 |
说明 |
| 风险模型 |
横截面回归估计因子收益,对因子协方差做收缩与半衰期加权,加上特异风险;每日更新 |
| 频率 |
日频或分钟级触发,不在每个 tick 上运行 |
| 多策略 |
在策略之间做风险预算分配,再合并为一个目标持仓,避免策略之间对冲交易浪费成本 |
| 开源方案 |
用途 |
| cvxpy + OSQP / Clarabel / SCS |
凸优化建模与求解;商业求解器有 MOSEK、Gurobi |
| cvxportfolio、Riskfolio-Lib、PyPortfolioOpt |
组合优化 |
| 风险模型 |
自建:开源领域没有成熟的 Barra 类多因子风险模型实现 |
组合构建、风险模型的设计分别见第六章、第十七章。
执行引擎 EMS
| 设计要点 |
说明 |
| 母单与子单 |
目标持仓差额生成母单,算法把母单拆成子单 |
| 算法 |
TWAP、VWAP(依赖成交量曲线预测)、POV(按市场成交量比例)、IS(最小化实施缺口,Almgren–Chriss) |
| 下单决策 |
限价还是市价、挂在第几档、何时撤单重挂 |
| 智能路由 |
多交易所时按价格、费用、成交概率选择去向 |
| 随机化 |
下单时间与数量加随机扰动,避免被其他参与者识别 |
| 约束 |
涨跌停、最小交易单位、交易所报单频率限制 |
| 开源方案 |
用途 |
| vnpy_algotrading |
vn.py 算法交易模块(TWAP、冰山等) |
| LEAN 执行模型 |
如 VolumeWeightedAveragePriceExecutionModel |
| Hummingbot TWAP Executor |
加密货币 TWAP |
| NautilusTrader ExecAlgorithm |
执行算法组件 |
执行引擎的设计见第七章。
事前风控
| 设计要点 |
说明 |
| 独立 |
独立进程或至少独立模块,由独立团队维护;策略无法绕过 |
| 检查项 |
单笔上限、价格带、持仓与敞口、报单频率、撤单率、自成交、日内亏损(第 3 节 ④) |
| 状态来源 |
持仓、挂单、当日成交与亏损来自 OMS 事件,风控自身维护一份 |
| 熔断层级 |
全局、账户、策略、品种四级开关,可由外部独立触发 |
| 失败即拒绝 |
风控不可用或状态不确定时拒绝一切新订单(fail-closed) |
| 延迟 |
内联整数比较;高频场景每项检查为纳秒级 |
| 审计 |
每次拒单记录原因与当时状态 |
| 开源方案 |
用途 |
| NautilusTrader RiskEngine |
下单与改单频率限制、单笔最大名义金额、交易状态控制(crates/risk/src/engine/) |
| vnpy_riskmanager |
vn.py 事前风控模块 |
| LEAN RiskManagementModel |
如 MaximumDrawdownPercentPerSecurity |
事前风控的设计见第八章。
OMS
| 设计要点 |
说明 |
| 订单状态机 |
第 3 节 ⑤;处理回报乱序与状态未知 |
| 持仓与资金 |
多账户、多币种、保证金;多策略共用账户时按策略子账户归属持仓 |
| 持久化 |
所有状态变化写入事件日志,可重建 |
| 启动对账 |
启动时向券商 / 交易所查询挂单、持仓、当日成交,与本地状态合并后才开始交易 |
| Drop copy |
交易所独立推送的成交副本,用于与主回报交叉核对 |
| 开源方案 |
用途 |
| NautilusTrader ExecutionEngine + Cache + Portfolio |
订单、持仓、账户管理(crates/execution/、crates/portfolio/) |
| vn.py OmsEngine |
订单与持仓管理 |
OMS 的设计见第九章。
交易网关
| 设计要点 |
说明 |
| 协议 |
FIX 4.2 / 4.4;CME iLink 3(SBE over TCP);Nasdaq OUCH(SoupBinTCP);国内柜台 API(CTP、XTP、华鑫奇点、易达等);加密货币 REST + WebSocket |
| 会话 |
登录、心跳、序号、重连、ResendRequest |
| 保护 |
按交易所限制节流;启用断线撤单 |
| 凭证 |
密钥不以明文落盘,由密钥管理服务注入 |
| 映射 |
内部订单号与交易所订单号双向映射 |
| 性能 |
预填报文模板(第十一章第 8 节) |
| 开源方案 |
用途 |
| QuickFIX / QuickFIX/J / QuickFIX/n |
FIX 引擎 |
| SBE |
iLink 3 等 SBE 协议编解码 |
| CCXT |
加密货币交易所统一下单接口 |
| vnpy_ctp 等 vn.py 接口、NautilusTrader 适配器 |
国内柜台与多交易所 |
交易网关的设计见第十章。
2.3 支撑服务
| 服务 |
设计要点 |
开源方案 |
| 参考数据 |
合约属性(最小变动价位、乘数、交易单位、涨跌停、保证金率)、交易日历与交易时段(含夜盘)、公司行为、指数成分、期货主力合约映射;按时点版本化;开盘前加载并校验 |
交易日历:pandas_market_calendars、exchange_calendars(PyPI 包);存储:PostgreSQL;其余自建 |
| 配置与参数中心 |
策略参数、风控限额、功能开关;版本化、审计、灰度发布;运行时变更经事件总线下发并写入事件日志 |
etcd、Apollo(携程开源);或 Git + 发布流水线 |
| 时钟同步 |
微秒级用 PTP,配合网卡硬件时间戳;毫秒级用 NTP;持续监控时钟偏移。欧盟 MiFID II RTS 25 要求高频交易的业务时钟与 UTC 偏差不超过 100 µs |
linuxptp(ptp4l、phc2sys)、chrony |
| 行情与事件录制 |
原始报文抓包(交换机镜像或抓包卡);标准化事件存为 Parquet;事件日志归档。用于回测、重放、审计 |
tcpdump;NautilusTrader Parquet 数据目录(crates/persistence/);Chronicle Queue、Aeron Archive |
参考数据服务、配置与参数中心、时钟同步、行情与事件录制的设计分别见第十二章、第十三章、第十四章、第十五章。
2.4 运营域
| 组件 |
设计要点 |
开源方案 |
| 对账 |
OMS 持仓与成交 vs 券商 / 交易所结算单 vs drop copy;盘中定时与日终两级;差异分类(漏回报、手续费差异、公司行为);自动修正需审批 |
自建 |
| PnL 归因 |
按策略、品种、因子拆解;Brinson 归因(配置与选股);因子归因:收益 = Σ 因子暴露 × 因子收益 + 特异收益;实时盯市 PnL 与日终 PnL 分开 |
pyfolio-reloaded、empyrical-reloaded、quantstats |
| TCA |
以决策时价格(arrival price)为基准计算实施缺口,分解为延迟成本、市场冲击、机会成本、手续费;对比 VWAP、收盘价等基准;结果反馈给执行算法参数 |
tcapy(外汇现货为主,最后提交 2024-02);多数自建 |
| 监控告警 |
指标、日志、链路追踪、仪表盘、告警;延迟用直方图记录分位数;业务告警包括 PnL 突变、拒单率、行情中断、订单簿不可用、对账差异;热路径只更新内存计数器,由旁路线程采集,不在热路径上做 IO |
Prometheus + Alertmanager、Grafana、Loki、OpenTelemetry、HdrHistogram |
| 审计 |
事件日志不可变归档(WORM 存储)并按监管要求保留;每笔订单可追溯到策略版本、参数版本与触发事件;可查询(ClickHouse) |
ClickHouse;对象存储的对象锁定功能 |
对账、PnL 归因、TCA、监控告警的设计分别见第十八章、第十九章、第二十章、第二十一章。
3. 核心设计原则
① 回测与实盘同一套代码
策略只依赖抽象接口:Clock、DataFeed、ExecutionClient。回测时注入模拟时钟、历史数据回放器和撮合模拟器,实盘时注入系统时钟、行情网关和交易网关。回测与实盘各写一套代码,二者的行为迟早出现偏差,并且这种偏差很难定位。NautilusTrader 和 LEAN 都采用这种设计。
flowchart LR
S[策略代码] --> I{抽象接口<br/>Clock / DataFeed / ExecutionClient}
I -->|回测| B1[模拟时钟]
I -->|回测| B2[历史数据回放]
I -->|回测| B3[撮合模拟器]
I -->|实盘| L1[系统时钟]
I -->|实盘| L2[行情网关]
I -->|实盘| L3[交易网关]
② 事件溯源与确定性重放
所有输入(行情、回报、定时器、人工指令)按顺序写入日志,策略状态只由事件序列推导。由此获得三项能力:
| 能力 |
做法 |
| 线上问题复现 |
当天日志原样重放,本地得到逐事件一致的状态 |
| 崩溃恢复 |
最近快照 + 快照之后的日志 |
| 主备热切换 |
备机消费同一份日志,状态与主机一致,主机故障时直接接管 |
前提是消除一切非确定性来源:
- 策略内不读取系统时间,只使用事件时间
- 不依赖哈希表的遍历顺序
- 随机数种子固定
- 外部调用的结果本身也作为事件写入日志
③ 单写者与单线程热路径
撮合、策略、OMS 的核心状态由一个线程独占修改,线程之间通过无锁 SPSC 队列传递事件。这样既消除了锁竞争,也保证了处理顺序的确定性。这是 LMAX 架构的核心思想。
④ 风控独立并拥有否决权
事前风控作为硬闸门,常见检查项:
| 检查项 |
防范的问题 |
| 单笔数量 / 金额上限 |
胖手指 |
| 价格偏离带 |
错价单、行情异常时的追单 |
| 持仓与敞口上限 |
策略失控累积头寸 |
| 下单频率、撤单率 |
程序死循环、触发交易所异常交易监控 |
| 自成交防范 |
自买自卖(多数市场禁止) |
| 日内最大亏损 |
策略在异常行情下持续亏损 |
- 风控独立于策略:中低频为独立进程;高频以内联库形式运行在交易进程内,由独立团队开发,限额由风控系统下发且策略无权修改,另有独立进程做全局监视
- 必须具备一键熔断(kill switch):撤销全部挂单、禁止新开仓,可从外部独立触发
- 多个市场的监管对此有明文要求,例如美国 SEC Rule 15c3-5(Market Access Rule)、欧盟 MiFID II RTS 6
检查项、限额体系、熔断与常见问题见第八章。
⑤ 订单状态机与幂等
stateDiagram-v2
[*] --> New
New --> PendingNew: 发送
PendingNew --> Accepted: 交易所确认
PendingNew --> Rejected: 拒绝
Accepted --> PartiallyFilled: 部分成交
PartiallyFilled --> PartiallyFilled: 继续成交
Accepted --> Filled: 全部成交
PartiallyFilled --> Filled: 全部成交
Accepted --> PendingCancel: 撤单请求
PartiallyFilled --> PendingCancel: 撤单请求
PendingCancel --> Canceled: 撤单确认
PendingCancel --> Filled: 撤单前已成交
Accepted --> PendingReplace: 改单请求
PendingReplace --> Accepted: 改单确认
Rejected --> [*]
Filled --> [*]
Canceled --> [*]
- 每笔订单携带全局唯一的
ClientOrderId,网络重发不会导致重复下单
- 回报可能乱序到达(例如成交回报先于确认回报),状态机必须能处理
- 超时未收到回报时,订单处于"状态未知",需要主动向交易所查询,不能默认视为失败
完整的状态机、回报处理与常见问题见第九章。
⑥ 时点数据(point-in-time)
回测收益虚高最常见的三个原因:
| 偏差 |
表现 |
对策 |
| 前视偏差 |
使用了当时尚不可得的数据,例如按报告期而非公告日使用财报 |
所有数据按"可得时间"建索引 |
| 幸存者偏差 |
股票池不含已退市证券 |
证券主数据保留历史全集 |
| 复权错误 |
价格序列在除权日跳变,或用未来的复权因子调整历史价格 |
保存原始价格与公司行为,按需计算复权 |
证券主数据、公司行为、指数成分都需要按时间做版本化存储。
⑦ 撮合模拟的真实度
| 策略频率 |
回测所需的撮合模型 |
| 日频 / 周频 |
bar 级撮合 + 手续费 + 固定或按成交量比例的滑点 |
| 分钟级 |
加入成交量约束(单根 bar 可成交量上限)与冲击成本模型 |
| 高频 |
排队位置(挂单前方剩余量)、下单延迟、行情延迟、部分成交、自身订单对订单簿的影响 |
高频策略的回测如果不模拟排队位置,限价单的成交率会被系统性高估。hftbacktest 是开源实现中处理这些问题较完整的一个。
撮合模拟器、成本模型、偏差控制与仿真环境的完整设计见第十六章。
⑧ 对账与可观测性
- 收盘后将 OMS 持仓、资金、成交与交易所或券商结算单逐笔核对,盘中定时核对
- 监控指标分三类:
| 类别 |
指标 |
| 延迟 |
tick-to-order、order-to-ack 的分位数(p50 / p99 / p99.9) |
| 业务 |
实时 PnL、敞口、订单拒绝率、成交率 |
| 系统 |
GC 停顿、队列积压、网络丢包、行情序号跳变 |
4. 按频率分层的技术选型
| 频率 |
典型延迟要求 |
语言与技术 |
关注重点 |
| 日频 / 周频(多因子选股、CTA) |
秒到分钟 |
Python + Polars / DuckDB / Arrow,LightGBM |
数据质量、因子研究、组合优化、交易成本模型 |
| 分钟到秒级 |
毫秒 |
Python 策略层 + Rust / C++ 核心 |
执行算法、行情标准化 |
| 中高频(做市、统计套利) |
10–100 µs |
C++ / Rust,无 GC |
热路径零分配、无锁队列、CPU 绑核(isolcpus)、大页内存、忙等轮询 |
| 超高频 HFT |
亚微秒到纳秒 |
FPGA / ASIC,内核旁路网卡(AMD Solarflare Onload、ef_vi、DPDK) |
机房托管、PTP / White Rabbit 时间同步、在网卡或 FPGA 上直接解析行情并下单 |
中高频与超高频两档的热路径实现见第十一章。
5. 数据流与部署拓扑
5.1 主要数据流
| # |
数据流 |
路径 |
特点 |
| ① |
行情 |
交易所 → 行情网关 → 标准化 → 订单簿 → 总线 → 策略 / 风控 / 录制 / 监控 |
流量最大;单写者多读者;丢失后可从快照恢复 |
| ② |
下单 |
策略 → 组合构建 → 执行 → 事前风控 → OMS → 交易网关 → 交易所 |
延迟最敏感;不能丢失,也不能重复 |
| ③ |
回报 |
交易所 → 交易网关 → OMS → 总线 → 策略 / 风控状态 / 组合构建 / 监控 |
驱动持仓与风控状态;必须处理乱序 |
| ④ |
控制 |
运维控制台 / 配置中心 → 总线 → 各组件(改参数、启停、熔断) |
流量低、重要性高;写入事件日志并留审计 |
| ⑤ |
参考数据 |
参考数据服务 → 各组件 |
开盘前批量加载,盘中很少变化 |
| ⑥ |
落地与分析 |
总线 → 录制 → 数据仓库 → 研究 / 对账 / TCA / 归因 |
异步,允许延迟,不能丢失 |
| ⑦ |
发布上线 |
研究域 → 审批 → 交易域 |
低频;以不可变发布单元为单位 |
| ⑧ |
遥测 |
各组件 → 监控系统 |
旁路采集,不进入热路径 |
| ⑨ |
独立核对 |
交易所 drop copy → 独立风控 / 对账进程 |
绕过自身 OMS,用于发现自身系统的错误 |
5.2 同线程、同进程、同机、跨机
隔离层级从紧到松依次为同线程、同进程、同机、跨机,每多一层隔离,组件之间的通信延迟约高一个量级(第四章第 1 节)。部署方式由频率决定:
| 频率 |
热路径 |
同机、不同进程 |
跨机 |
| 高频(微秒级) |
行情网关 + 订单簿 + 策略 + 内联风控 + 报单编码 + 交易网关,同一进程、通常同一线程,绑定独占核心 |
录制进程(读共享内存)、监控采集、本机旁路风控监视 |
全公司风控汇总、运营域、研究域;每个交易所机房一台或一组独立机器 |
| 中频(毫秒到秒) |
行情服务(网关 + 订单簿)同一进程;每个策略一个进程 |
行情服务通过共享内存向策略进程扇出;OMS + 风控 + 交易网关为同机另一进程 |
组合构建、参考数据、配置中心、运营域 |
| 低频(日频) |
无严格意义上的热路径 |
— |
各组件为独立服务,可跨机甚至上云;状态存于数据库 |
| 组件 |
典型位置 |
原因 |
| 行情网关 + 标准化 + 订单簿 |
同一进程 |
顺序处理,没有拆分的理由 |
| 策略 |
高频与订单簿同线程;中频为独立进程、同机 |
高频省去跨线程一跳;中频需要故障隔离与独立发布 |
| 事前风控 |
高频以库的形式内联在交易进程中,由独立团队开发、独立配置;另有独立进程做全局监视 |
同时满足纳秒级检查与独立性 |
| OMS |
中低频为独立服务,是持仓的唯一真相来源;高频在交易进程内维护本地订单状态 |
高频不能为查询持仓多走一跳 |
| 交易网关 |
与 OMS 同进程,或每个交易所会话一个进程 |
会话状态(序号)需与订单状态一致 |
| 组合构建 |
独立进程,可跨机 |
周期运行、计算量大、对延迟不敏感 |
| 录制、监控 |
同机独立进程或网络旁路 |
故障不能影响交易 |
| 参考数据、配置中心、运营域 |
跨机独立服务 |
不在热路径上 |
5.3 单进程、多进程与多机
热路径是单进程(高频为单线程),系统整体是多进程、多机。
| 选择 |
原因 |
| 热路径单进程 |
延迟最低、行为确定、调试简单 |
| 系统拆分为多进程 |
故障隔离(录制崩溃不影响交易);独立发布;风控在组织与技术上独立于策略(监管与内控要求);可混合使用多种语言 |
| 必然多机 |
交易所分布在不同地点(CME 在芝加哥 Aurora、Nasdaq 在新泽西 Carteret、上期所在上海、大商所在大连),每个机房运行独立的交易引擎;主备需要第二台机器;研究与运营的算力、存储不应与交易机混用 |
flowchart TB
subgraph C1[交易所机房 A]
A1[交易主机<br/>行情 + 订单簿 + 策略 + 风控 + 网关<br/>同进程]
A2[热备主机]
A3[录制 / 监控进程]
A1 -- 事件日志复制 --> A2
A1 -- 共享内存 --> A3
end
subgraph C2[交易所机房 B]
B1[交易主机]
B2[热备主机]
B1 -- 事件日志复制 --> B2
end
subgraph HQ[中心机房]
RISK[全局风控<br/>额度分配与汇总]
OPS[运营域<br/>对账 / TCA / 审计]
RES[研究域]
WD[外部看门狗<br/>读取 drop copy]
end
RISK -- 额度切片 --> A1
RISK -- 额度切片 --> B1
A1 -- 持仓 / 成交事件 --> RISK
B1 -- 持仓 / 成交事件 --> RISK
A3 --> OPS
WD -. 熔断 .-> A1
WD -. 熔断 .-> B1
6. 多机一致性与单机可靠性
6.1 多机一致性
| 状态 |
一致性要求 |
做法 |
| 订单与持仓 |
强一致,只能有一个真相 |
单一所有者 + 分区 |
| 全公司风险敞口 |
可最终一致,但不能超限 |
额度预分配 |
| 事件顺序 |
确定的顺序 |
按分区定序 |
| 配置 |
所有节点在同一位置切换版本 |
版本号 + 在事件日志的某个序号生效 |
| 最终真相 |
以交易所为准 |
对账 |
① 单一所有者 + 分区。 每个账户(或交易所会话、策略)的订单与持仓只由一个节点写入,其他节点只读副本。按"谁拥有这份状态"切分,不存在分布式写冲突。这是单写者原则(第 3 节 ③)在机器级别的应用。
② 复制状态机(Raft)。 需要高可用且强一致的关键状态(中央 OMS、撮合引擎)使用 Aeron Cluster 等实现:领导者为命令排序,多数节点确认后提交,所有节点按相同顺序执行得到相同状态。每条命令多一次到多数节点的往返(同机房内为微秒到数十微秒量级),因此不用于高频热路径,而用于中频系统与撮合引擎。
③ 额度预分配:跨机风控。 全公司总敞口上限为 1 亿时,不能每笔订单都远程查询剩余额度。中央风控把额度切片预先分配给各机房(例如机房 A 4000 万、机房 B 4000 万、机动 2000 万),各机房在本地纳秒级检查,中央定期汇总并按使用情况重新分配。以少量额度闲置换取绝不超限且无需同步调用,是高频系统跨机风控的标准做法。
④ 以序号而非时间戳定序。 PTP 可以把时钟同步到亚微秒,但跨机状态的先后仍以序号为准:时间戳有误差,时钟调整时可能倒退,序号不会。时间戳用于延迟分析与监管上报。
⑤ 幂等、隔离令牌、以交易所为最终真相。 订单号确定且唯一,重复发送可被识别;主备切换带任期号(epoch),旧主节点的命令被拒绝,防止脑裂;drop copy 与日终对账兜底最终一致性。
⑥ 一致性优先于可用性。 订单与持仓状态不确定时停止交易(fail-closed)。停止交易的损失是错过机会,带着错误状态交易的损失是没有上限的风险敞口。
6.2 单机可靠性
| 故障 |
后果 |
对策 |
| 进程崩溃 |
状态丢失,挂单无人管理 |
事件日志(mmap,进程崩溃不丢失)+ 快照,重启后快速恢复;交易所断线撤单清除挂单 |
| 整机宕机 |
本机一切丢失 |
热备机;事件日志实时复制到另一台机器 |
| 程序失控(循环下单) |
最危险:进程存活但行为错误 |
外部独立监视(读取 drop copy,不依赖自身 OMS)+ 交易所端熔断开关 |
| 网络或线路故障 |
行情中断或无法下单 |
A/B 行情走不同网卡与交换机;交易会话配置备用线路 |
| 交易所会话断开 |
订单状态可能未知 |
重连后先查询、对账,再恢复交易 |
① 重启不等于恢复交易。 进程被 systemd 等拉起后,先进入"恢复中、禁止交易"状态:从日志恢复状态 → 与交易所对账 → 自动检查通过或人工确认后恢复交易。自动重启后直接交易是事故的常见来源。
② 热备机。 备机接收同一组播行情并实时消费主机复制过来的事件日志,状态与主机一致但不发单。切换时带新任期号,先对账再交易;交易所会话互斥(同一会话不能被两台机器同时登录)是防止脑裂的最后一道保险。主备切换的细节见 量化交易系统_qa.md 第 1 题。
③ 硬件冗余。 双网卡、双交换机分别承载 A/B 行情;双电源;ECC 内存;日志盘镜像。
④ 外部看门狗。 部署在另一台机器上,监视进程心跳、行情是否停止更新、下单速率与持仓是否异常,发现问题即触发熔断:撤销全部挂单、禁止新开仓。
⑤ 降级顺序。 预先规定资源紧张或部件故障时的牺牲顺序:先停录制与细粒度监控,交易正确性最后让步;风控不可用时 fail-closed。
⑥ 演练。 定期在生产或仿真环境中实际拔网线、杀进程、切换主备,验证上述机制有效。未经演练的容灾方案视为无效。
二、行情网关的设计与实现
行情网关接入外部行情,输出标准化的行情事件与订单簿。单个数据包在热路径上的处理(内核旁路、零拷贝解析)见第十一章,订单簿重建与缺口恢复见第三章;本章从整个子系统的角度给出行情网关的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 连接行情源:组播、TCP、WebSocket、厂商 API |
交易会话(交易网关,第十章) |
| A/B 仲裁、序号检查、缺口检测与恢复 |
订单簿数据结构(第三章,由行情网关进程调用) |
| 合约定义的接收与品种映射 |
策略计算 |
| 解码与标准化、时间戳 |
历史数据仓库(研究域) |
| 交易状态、成交撤销等特殊消息的处理 |
|
| 分发、录制、数据质量监控 |
|
2. 行情来源
| 来源 |
形式 |
特点 |
| 交易所直连行情 |
UDP 组播(如 Nasdaq TotalView-ITCH 经 MoldUDP64、CME MDP 3.0) |
延迟最低、信息最全;需要机房托管与交易所授权 |
| 交易所 TCP 行情或柜台行情 API |
TCP;厂商 API(如 CTP 行情接口) |
部署简单;国内期货普通行情为每秒 2 次快照 |
| 交易所信息公司及授权机构 |
沪深 Level-2 由上证所信息网络公司、深圳证券信息公司及其授权机构分发 |
需要单独授权 |
| 汇总行情 |
美股 SIP(汇总各交易所的最优报价与成交) |
覆盖全市场,但比各交易所直连行情慢,二者的时间差正是延迟套利的来源 |
| 数据商 |
商业数据商的汇总行情 |
覆盖广、接入简单,延迟较高,适合中低频与备用 |
| 加密货币交易所 |
WebSocket 公共频道,REST 拉取快照 |
各交易所格式不同;快照加增量的衔接见 量化交易系统_qa.md 第 6 题 |
3. 交易所组播行情的通道结构
flowchart TB
subgraph CH1[通道 1:一部分品种]
A1[增量行情 A 路]
B1[增量行情 B 路]
S1[快照 / 恢复行情]
D1[合约定义]
end
subgraph CH2[通道 2:另一部分品种]
A2[增量行情 A 路]
B2[增量行情 B 路]
S2[快照 / 恢复行情]
D2[合约定义]
end
RT[TCP 重传 / 回放服务]
CH1 --> GW[行情网关]
CH2 --> GW
RT -. 缺口时请求 .-> GW
| 要点 |
说明 |
| 按通道分区 |
交易所把品种划分到多个组播通道,每个通道有独立的序号空间 |
| 多种子流 |
增量行情(A、B 两路)、快照或恢复行情、合约定义;部分交易所另有 TCP 重传服务 |
| 按需加入 |
只加入需要的组播组(IGMP),减少网卡与 CPU 负载 |
| 按通道分核 |
每个核独占若干通道,单写者处理(第三章第 10 节) |
4. 处理流程
flowchart LR
RX[收包<br/>A 路 / B 路] --> ARB[A/B 仲裁]
ARB --> SEQ{包序号连续?}
SEQ -- 是 --> DEC[解码]
SEQ -- 否 --> REC[恢复<br/>第三章第 7 节]
REC --> DEC
DEC --> FIL[按品种过滤]
FIL --> NORM[标准化<br/>品种映射 / 价格整数化 / 时间戳]
NORM --> BOOK[订单簿重建<br/>第三章]
NORM --> PUB[发布非订单簿事件<br/>成交 / 状态 / 统计]
BOOK --> PUB2[发布订单簿更新]
RX -. 旁路 .-> PCAP[原始报文录制]
内核旁路收包与零拷贝解析见第十一章第 3、4 节;订单簿重建与缺口恢复见第三章;发布到事件总线见第四章。
5. A/B 仲裁
| 要点 |
说明 |
| 规则 |
每个通道记录期望序号;两路中先到者生效,已处理过的序号直接丢弃 |
| 等待窗口 |
一路出现缺口而另一路尚未到达时短暂等待另一路补齐,窗口过长增加延迟,过短会把正常的两路时差误判为缺口 |
| 线路健康 |
分别统计每一路的丢包数、两路之间的时差;一路持续落后或丢包时告警 |
| 单路故障 |
一路中断时继续使用另一路并告警;两路都出现缺口才进入恢复 |
| 物理隔离 |
A、B 两路使用不同网卡、不同交换机,避免单点故障同时影响两路 |
6. 合约定义与品种映射
| 要点 |
说明 |
| 来源 |
交易所的合约定义消息或通道(最小变动价位、合约乘数、交易状态、期权的标的与行权价),以及参考数据服务 |
| 启动顺序 |
先加载合约定义,再处理增量行情;否则增量消息中的品种无法映射 |
| 盘中变化 |
期权新增行权价、新上市品种、合约属性变更需要实时处理 |
| 品种映射 |
交易所的品种代码或数字 ID 映射为内部整数 ID,并与参考数据服务核对;不一致时告警 |
| 价格精度 |
价格缩放因子按品种设置,不能全局统一 |
7. 标准化
标准化事件的通用字段见第一章第 2.2 节"行情标准化"。需要单独处理的消息类型:
| 消息 |
处理 |
| 订单簿更新 |
交给订单簿重建 |
| 成交 |
带主动方向(买方主动还是卖方主动)与成交条件(零股、场外报告等) |
| 成交撤销与更正 |
交易所撤销或更正已发布的成交,下游的成交量、VWAP 等统计需要回滚 |
| 交易状态 |
集合竞价、连续竞价、停牌、熔断、收市;策略与风控依赖它判断能否交易 |
| 集合竞价信息 |
虚拟成交价、未匹配量(如 Nasdaq 的开盘与收盘失衡信息) |
| 统计信息 |
开盘价、最高价、最低价、结算价、持仓量 |
| 心跳 |
交易所在无数据时发送心跳,用于区分"没有行情"与"行情中断" |
8. 时间戳
| 时间戳 |
来源 |
用途 |
| 撮合时间 |
交易所撮合引擎 |
事件真实发生的时间 |
| 发送时间 |
交易所行情发布系统 |
与撮合时间之差为交易所内部延迟 |
| 网卡接收时间 |
网卡硬件时间戳 |
与发送时间之差为网络延迟 |
| 处理完成时间 |
本地时钟 |
与接收时间之差为网关处理延迟 |
- 本地时钟通过 PTP 与交易所时间对齐(第一章第 2.3 节),所有时间戳统一使用 UTC
- 同一通道内的先后以序号为准;跨通道、跨交易所的先后只能近似依据交易所时间,不能依据本地接收时间
时钟同步的设计见第十四章。
9. 多源与冗余
| 要点 |
说明 |
| 主备数据源 |
直连行情为主,数据商行情为备;主源中断时切换,并标记数据来源 |
| 交叉校验 |
同一品种在不同数据源的价格持续偏离时告警 |
| 映射一致 |
不同数据源的品种标识统一映射到同一内部 ID |
| 自建汇总视图 |
多交易所交易的品种,用各交易所直连行情自行计算全市场最优报价,而不依赖较慢的汇总行情 |
10. 分发
| 方式 |
适用 |
| 同线程回调 |
延迟最敏感的策略与行情网关在同一线程(第十一章) |
| 共享内存 |
同机的多个策略进程(第四章第 4 节) |
| 内部组播 |
向其他服务器转发,重新编号以便下游检测缺口 |
| 快照服务 |
盘中启动的消费者先获取当前订单簿快照,再按序号衔接总线上的增量 |
| 按消费者合并 |
慢消费者只接收最新状态(第四章第 5 节) |
11. 容量与突发
| 要点 |
说明 |
| 峰值时段 |
开盘与收盘集合竞价、重大新闻、指数调整、剧烈波动时消息量为平时的数倍 |
| 微突发 |
毫秒级的突发流量可能在平均带宽不高时打满网卡或缓冲区 |
| 容量设计 |
按历史峰值而非平均值设计,并留出余量;用峰值时段的录制数据做压力测试 |
| 缓冲区 |
网卡接收队列与(非内核旁路时的)套接字缓冲区按峰值设置 |
| 丢包监控 |
监控网卡丢包计数、内核丢包计数、应用层序号缺口,三者分别对应不同层次的问题 |
12. 数据质量监控
| 监控项 |
发现的问题 |
| 各通道消息速率、心跳 |
通道中断 |
| 缺口次数、恢复次数与耗时 |
网络质量、容量不足 |
| 每路丢包与两路时差 |
线路故障 |
| 行情延迟(接收时间 − 发送时间) |
网络或交易所异常 |
| 按品种的更新时效 |
连接正常但某些品种停止更新 |
| 连续竞价阶段订单簿交叉 |
漏消息或处理错误 |
| 价格跳变超出阈值、成交价在买卖价之外 |
错误数据或极端行情 |
| 与备用数据源的偏差 |
数据源错误 |
行情不可用或过期时,向策略与风控发布"不可用"状态(第八章第 4 节:参考价过期时拒单)。
13. 录制与重放
| 要点 |
说明 |
| 原始报文录制 |
交换机镜像或分光器接带硬件时间戳的抓包设备,或在网关旁路写 pcap;原始报文是回测、事故分析、审计的最终依据 |
| 标准化事件录制 |
标准化后的事件存为 Parquet 等列式格式,供研究使用 |
| 不影响热路径 |
录制走旁路,不在热路径上同步写盘 |
| 重放 |
把录制的报文按原速或加速重新输入网关,用于测试、回归与事故复现;网关在相同输入下的输出应完全一致 |
| 存储 |
原始报文数据量大,按价值分级保留:近期全量,远期只保留需要的品种或标准化数据 |
14. 部署
- 机房托管,网关服务器与交易所行情接入点之间的线路尽量短
- 每个交易所或通道组一个网关进程,通道按核分配,网卡、内存、处理核位于同一 NUMA 节点
- A、B 两路接入不同网卡;交换机开启 IGMP 侦听,避免无关组播流量
- 行情网关与交易网关是独立的进程与会话,通常部署在同一台机器上
15. 测试
| 手段 |
说明 |
| 交易所测试行情 |
交易所提供的测试环境与认证流程 |
| 录制重放 |
用生产录制数据重放,比较标准化输出与订单簿结果 |
| 故障注入 |
单路丢包、两路同时缺口、乱序、重复、快照期间的消息突发、盘中合约定义变更、交易状态切换 |
| 压力测试 |
以峰值时段数据加速重放,确认不丢包、延迟分布满足要求 |
16. 常见问题清单
| 问题 |
后果 |
对策 |
| 只接一路行情 |
任何丢包都导致缺口与恢复 |
A/B 双路 |
| A/B 仲裁等待窗口设置不当 |
过长增加延迟,过短误判缺口 |
按两路时差的实测分布设置 |
| 缺口后继续使用订单簿 |
基于错误订单簿交易 |
标记不可用,恢复完成前不交易 |
| 未先加载合约定义 |
增量消息无法映射品种 |
启动时先加载合约定义 |
| 未处理盘中新增合约 |
新合约的行情全部丢失 |
实时处理合约定义消息 |
| 价格缩放因子全局统一 |
部分品种价格错误 |
按品种设置 |
| 忽略交易状态消息 |
停牌、熔断期间仍在交易 |
交易状态作为事件发布给策略与风控 |
| 未处理成交撤销 |
成交量、VWAP 等统计错误 |
发布撤销事件,下游回滚 |
| 用本地接收时间排序跨通道事件 |
事件顺序错误 |
通道内按序号,跨通道按交易所时间近似 |
| 监控只看连接状态 |
连接正常但品种停止更新未被发现 |
按品种监控更新时效 |
| 按平均流量设计容量 |
开盘、收盘时丢包 |
按峰值设计并压力测试 |
| 慢消费者拖慢网关 |
行情延迟上升、丢包 |
背压与合并(第四章第 5 节) |
| 录制在热路径同步写盘 |
延迟抖动 |
旁路录制 |
| 多数据源切换时品种映射不一致 |
行情错配到错误品种 |
统一映射到内部 ID 并校验 |
| 时区与夏令时处理错误 |
时间戳错乱、交易时段判断错误 |
内部统一使用 UTC,按交易所时区与日历换算 |
| 交易日归属错误 |
夜盘数据归入错误的交易日 |
按交易日而非自然日归档 |
17. 开源实现
| 项目 |
实现 |
说明 |
| cryptofeed |
多家加密货币交易所的 WebSocket 行情接入与标准化,可输出到 Redis、Kafka 等后端 |
加密货币行情网关的参考 |
| CCXT |
统一的行情接口,含 WebSocket 订阅 |
覆盖交易所最多 |
| vn.py 接口 |
各柜台网关的行情部分(如 vnpy_ctp 的行情接口) |
国内期货行情 |
| NautilusTrader 数据客户端 |
每个数据源一个数据客户端(交易所、Databento 等) |
统一的数据接入层 |
| Databento DBN |
标准化行情编码格式 |
标准化 schema 参考 |
| CppTrader、itch-order-book |
NASDAQ ITCH 解析与订单簿 |
ITCH 直连行情的参考 |
| Simple Binary Encoding |
根据交易所 SBE 模板生成解码器 |
CME MDP 3.0 等 SBE 行情 |
| hftbacktest |
提供将交易所行情转换为其回测数据格式的工具 |
行情录制与回测衔接 |
三、生产级订单簿的实现
本章实现行情侧订单簿:根据交易所行情在本地维护一份与交易所一致的订单簿,供策略、风控、执行使用。撮合引擎内部的订单簿职责不同,差异见第 11 节。第十一章第 5 节的价位数组只演示了定位思路,本章给出生产环境需要的完整设计:交易所语义归一化、数据结构、缺口恢复、跨线程发布、正确性验证与性能要点。
1. 输入:三种行情形态
| 形态 |
内容 |
本地维护方式 |
例子 |
| 快照 |
每隔一段时间发送前 N 档的完整状态 |
收到即替换,无需重建 |
A 股 Level-2 十档快照;国内期货 CTP 行情;Binance 局部深度流 <symbol>@depth<N> |
| 按价位增量(MBP) |
某价位的新总量,或第 k 档新增 / 删除 |
维护价位表 |
CME MDP 3.0 MBP;Binance 增量深度流 <symbol>@depth |
| 逐笔委托(MBO) |
每笔委托的新增、成交、撤销、修改 |
维护每笔委托,再聚合出价位 |
Nasdaq TotalView-ITCH;CME MDP 3.0 MBO;沪深 Level-2 逐笔委托 + 逐笔成交 |
本章以最复杂的 MBO 为主,MBP 是其子集(只有价位,没有订单)。三种形态的区别见 量化交易系统_qa.md 第 6 题。
2. 需求
| 维度 |
要求 |
| 正确性 |
与交易所状态逐笔一致;覆盖交易所协议中所有影响订单簿的消息;集合竞价等特殊时段行为正确 |
| 可恢复 |
发现序号缺口、未知订单号、校验和不一致时,标记不可用并自动恢复;恢复期间不交易 |
| 延迟 |
热路径操作 O(1) 或接近 O(1);无内存分配、无锁、无系统调用;尾延迟可预测 |
| 容量 |
多品种(期权链可达数十万个合约);活跃订单数有上界且可监控;超出容量时降级而不是崩溃 |
| 可观测 |
每条消息的处理延迟直方图;未知订单、缺口、恢复次数与时长 |
| 可测试 |
可用录制数据确定性重放;有独立的参考实现做差分测试 |
3. 总体结构
flowchart LR
A[A 路组播] --> ARB
B[B 路组播] --> ARB
ARB[A/B 仲裁<br/>按序号去重] --> DEC[协议解码<br/>归一化为内部事件]
DEC --> SEQ{序号连续?}
SEQ -- 是 --> ENG[订单簿引擎<br/>对象池 / 订单索引 / 价位梯]
SEQ -- 否 --> REC[恢复状态机<br/>缓存增量 + 请求快照或重传]
SNAP[快照通道 / 重传服务] --> REC
REC --> ENG
ENG --> PUB[发布<br/>同线程回调 / seqlock 快照]
PUB --> STRAT[策略 / 风控 / 执行]
分层的目的:交易所差异全部在解码层消化,订单簿引擎只处理归一化后的内部事件,换交易所时引擎不变。
4. 交易所语义归一化
不同交易所对"新增、成交、撤单、改单"的表达差异很大:
| 交易所 / 协议 |
形态 |
语义要点(以各自协议规范为准) |
| Nasdaq TotalView-ITCH 5.0 |
MBO |
A / F 新增;E 成交(按订单挂单价);C 带成交价的成交;X 部分撤单;D 删除;U 改单,换新订单号并失去时间优先级;P / Q 为成交记录,不改变订单簿 |
| CME MDP 3.0 |
MBP |
按档位序号更新:New 在第 k 档插入,后面的档位下移;Delete 删除第 k 档,后面的档位上移;另有 DeleteThru、DeleteFrom;深度固定为 N 档 |
| CME MDP 3.0 |
MBO |
按订单号维护,全深度 |
| Binance 增量深度流 |
MBP |
给出价位的新总量(不是变化量),数量为 0 表示删除该价位 |
| 沪深 Level-2 逐笔 |
MBO |
逐笔委托与逐笔成交两路数据共同决定订单簿;市价单、本方最优等订单的价格在撮合时才确定;集合竞价期间买卖价交叉是正常状态 |
解码层把它们归一化为少数几种内部事件:
| 内部事件 |
含义 |
优先级 |
Add(book, id, side, px, qty) |
新增委托,排到该价位队尾 |
新建 |
Reduce(id, qty) |
成交或部分撤单,数量减少 |
保留 |
Delete(id) |
全部撤单或全部成交 |
— |
Replace(old_id, new_id, px, qty) |
改单 |
失去(改价或加量时,多数交易所的规则) |
SetLevel(book, side, px, qty) |
MBP 价位新总量,0 表示删除 |
— |
Clear(book) |
清空(开盘前、恢复前) |
— |
Status(book, phase) |
交易阶段:集合竞价、连续竞价、停牌、熔断 |
— |
5. 数据结构
5.1 价格表示
- 价格一律用
int64 整数表示,单位取该市场的最小价格单位(例如美股 0.0001 美元),在解码层完成转换。订单簿内不出现浮点数
- 有些市场的最小变动价位随价格区间变化(港股价位表;美股 1 美元以下为 0.0001 美元,1 美元及以上为 0.01 美元)。用"最小价格单位"而非"第几个 tick"表示价格,价位梯只做整数比较,不需要 tick 表
5.2 价位梯选型
| 方案 |
优点 |
缺点 |
适用 |
平衡树 / B 树(std::map、Rust BTreeMap) |
通用,任意价格范围 |
每次操作 O(log n) 次指针跳转,节点分散,插入时分配内存 |
对延迟不极端敏感的场景 |
| 全价格区间数组 |
定位 O(1);没有窗口平移,只有一条代码路径 |
需要在开盘前确定价格区间 |
有涨跌停的市场(A 股、国内期货):按当日涨跌停价分配,档数有限,例如昨收 10.00 元、涨跌幅 10%、最小变动价位 0.01 元时为 201 档;上市初期不设涨跌幅的新股等情形单独处理 |
| 固定窗口数组 + 溢出有序结构 |
窗口内定位 O(1) |
窗口外走有序结构,两条代码路径;价格漂移后要平移窗口,平移时有延迟尖刺;最小变动价位分段变化时下标需按价位表换算 |
价格范围大且需要完整深度(如加密货币);第 12 节所列开源实现中没有采用这一组合 |
| 固定窗口数组,窗口外丢弃 |
定位 O(1),内存可控 |
窗口外的价位不维护 |
只关心中间价附近的回测与信号计算(hftbacktest 的 ROIVectorMarketDepth) |
有序 vector(最优价在末尾)+ 价位池 |
内存连续;多数更新发生在前几档,从末尾线性扫描几步即命中;在最优价附近插入 / 删除只移动末尾少量 16 字节的引用;订单经价位池下标 O(1) 修改价位 |
深档更新需要二分;远离最优价的插入移动元素较多 |
通用默认方案;itch-order-book、CppTrader 优化版本采用 |
| 哈希表(价格 → 价位)+ 单独维护最优价 |
定位 O(1) |
最优价被吃空时查找下一档需要额外结构 |
MBP 价位增量 |
选择有序 vector 的依据是订单簿更新集中在最优价附近。这一点可以用自己的历史数据验证:统计每条更新的价格与当时最优价的距离(以档为单位)并画直方图。
查找策略:从末尾(最优价)向前线性扫描最多 8 档,未命中再对剩余部分二分查找。
价格区间有界时优先使用全价格区间数组;其余情况使用有序 vector + 价位池。窗口数组加溢出结构只在价格范围大、又必须维护完整深度、且有序 vector 的实测延迟不满足要求时考虑,并需要用差分测试重点覆盖跨越窗口边界与连续平移的情形(第 6.1 节)。
5.3 订单池与价位池
- 订单节点与价位分别预先分配在两个连续数组中(订单池、价位池),空闲下标用栈管理,分配和释放都是 O(1),热路径不调用
malloc
- 节点之间用 32 位下标而非指针相连:节点更小,池可以整体映射到大页
- 订单节点与价位各 32 字节,两个正好占一个 64 字节缓存行;价位梯的元素只有 16 字节
- 价位池中价位的位置固定,订单节点直接保存所属价位的下标;价位梯只负责排序与查找最优价
| 结构 |
内容 |
组织方式 |
职责 |
| 价位梯 |
(价格,价位池下标),16 字节 |
有序 vector,从差到好排列,最优价在末尾 |
价位排序、最优价、前 N 档遍历 |
| 价位池 |
价格、总量、笔数、队首与队尾订单下标,32 字节 |
固定数组,元素位置不变;本身无序,空闲槽位由下标栈回收 |
为每个价位提供不会移动的存储位置 |
| 订单池 |
订单号、数量、所属价位下标、前后订单下标,32 字节 |
固定数组;同一价位的订单经前后下标串成双向链表(FIFO) |
时间优先顺序;O(1) 摘除任意订单 |
| 订单索引 |
订单号 → 订单池下标 |
开放寻址哈希表;订单号稠密时用按订单号下标的数组 |
回报只带订单号时 O(1) 找到订单 |
双向链表只存在于同一价位的订单之间;价位之间的先后由价位梯决定。撤单与成交经"订单索引 → 订单 → 价位池"完成,不访问价位梯;只有价位被清空时才在价位梯中按价格定位并删除引用。
订单池与价位池都是普通数组,"下标"就是槽位编号。同价位订单之间的双向链表不是独立的容器:每笔订单在自己的槽位中记录前后订单的槽位编号,链表由这些编号串成(侵入式链表,以 32 位下标代替指针)。因此一笔订单既有固定的槽位编号(订单索引记录它),又是某条链表上的节点(prev / next 记录它)。
例子:买方有 A 买 300 @10.00、B 买 200 @10.00(晚于 A 到达)、C 买 100 @9.99。
| 订单池槽位 |
订单号 |
数量 |
所属价位(价位池槽位) |
prev |
next |
| 0 |
A |
300 |
4 |
空 |
1 |
| 1 |
B |
200 |
4 |
0 |
空 |
| 2 |
(空闲) |
|
|
|
|
| 3 |
C |
100 |
2 |
空 |
空 |
| 价位池槽位 |
价格 |
总量 |
笔数 |
队首订单槽位 |
队尾订单槽位 |
| 2 |
9.99 |
100 |
1 |
3 |
3 |
| 4 |
10.00 |
500 |
2 |
0 |
1 |
价位梯为 [(9.99, 2), (10.00, 4)],订单索引为 A → 0、B → 1、C → 3。
- B 撤单:订单索引得到槽位 1 → 读出所属价位 4 → 从链表摘除(订单 0 的
next 置空,价位 4 的队尾改为 0)→ 价位 4 的总量减为 300、笔数减为 1 → 回收槽位 1。全程不访问价位梯
- D 买 50 @9.98(新价位):从价位梯末尾向前查找,确定插入最前面 → 在价位池分配槽位 5 → 价位梯变为
[(9.98, 5), (9.99, 2), (10.00, 4)]。价位梯中原有元素后移了一位,但 9.99 仍在价位池槽位 2、10.00 仍在槽位 4,订单 A、C 保存的价位下标无需修改。若把价位数据直接放在 vector 中、订单记录其在 vector 中的位置,这次插入会使这些位置全部失效;价位池的作用就是让订单对价位的引用不受价位梯移动的影响
OrderPool:连续数组,每个节点 32 字节
下标: 0 1 2 3
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ id qty level │ id qty level │ (空闲) │ id qty level │
│ prev next │ prev next │ │ prev next │
│ book side │ book side │ │ book side │
└──────────────┴──────────────┴──────────────┴──────────────┘
Ladder(买方向):LevelRef 数组,从差到好排序,最优价在末尾,每个元素 16 字节
┌───────────┬───────────┬───────────┬───────────┐
│ 9.97 → 5 │ 9.98 → 2 │ 9.99 → 7 │ 10.00 → 4 │ ← 最优买价
└───────────┴───────────┴───────────┴─────┬─────┘
│ 价位池下标 4
▼
LevelPool:位置固定的价位数组,每个 32 字节
下标 4:px 10.00 │ total │ count │ head ─► 订单 0 ⇄ 订单 3 ⇄ 订单 1(按到达顺序)
▲
订单节点的 level 字段直接保存价位池下标 4
OrderIndex:开放寻址哈希表(或按订单号直接下标的数组),订单号 → 订单池下标
[ id→0 ][ 空 ][ id→3 ][ id→1 ][ 空 ] ...
5.4 订单索引
| 设计点 |
选择 |
原因 |
| 冲突处理 |
开放寻址 + 线性探测 |
探测序列在内存中连续,缓存友好;std::unordered_map 每个元素单独分配节点 |
| 容量 |
2 的幂,负载因子不超过 0.5 |
探测链短,用位与代替取模 |
| 哈希函数 |
斐波那契哈希:(key × 0x9E3779B97F4A7C15) >> (64 − 位数) |
一次乘法;交易所订单号往往是递增序列,乘法能把相邻的号打散 |
| 删除 |
后移删除(backward-shift deletion) |
不留墓碑,删除频繁时探测链不会越来越长 |
| 作用域 |
所有品种共用一个索引 |
ITCH 等协议的订单号在全天所有品种中唯一,消息中可以只带订单号 |
| 订单号稠密时 |
按订单号直接下标的数组 |
查找只需一次解引用,且新订单与近期订单在内存中相邻;ITCH 的订单号当日递增且稠密,itch-order-book 与 CppTrader 激进优化版本采用(后者预留 3 亿个槽位);需按最大订单号预留内存,订单号稀疏或随机时不适用 |
6. 核心实现
以下代码实现第 4 节的全部内部事件(Status 除外),并提供深度查询与排队位置查询。
#pragma once
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <initializer_list>
#include <vector>
namespace ob {
using Px = int64_t; using Qty = uint32_t; using Idx = uint32_t; inline constexpr Idx kNil = UINT32_MAX;
enum class Side : uint8_t { Bid, Ask };
enum class Status : uint8_t { Ok, UnknownOrder, DuplicateOrder, BadId, BadQty, PoolExhausted, IndexFull };
struct OrderNode {
uint64_t id;
Qty qty;
Idx prev; Idx next;
Idx level; uint16_t book; Side side;
uint8_t pad;
};
static_assert(sizeof(OrderNode) == 32, "两个节点占一个缓存行");
struct Level { Px px;
uint64_t total; uint32_t count; Idx head; Idx tail;
};
static_assert(sizeof(Level) == 32);
struct LevelRef { Px px; Idx level; uint32_t pad; }; static_assert(sizeof(LevelRef) == 16);
struct PxQty { Px px; uint64_t qty; uint32_t count; };
template <class T>
class Pool {
std::vector<T> items_; std::vector<Idx> free_;
public:
explicit Pool(size_t cap) : items_(cap) {
assert(cap < kNil);
free_.reserve(cap);
for (size_t i = cap; i-- > 0;) free_.push_back(Idx(i)); }
Idx alloc() {
if (free_.empty()) return kNil;
Idx i = free_.back();
free_.pop_back();
return i;
}
void release(Idx i) { free_.push_back(i); }
T& operator[](Idx i) { return items_[i]; }
const T& operator[](Idx i) const { return items_[i]; }
size_t used() const { return items_.size() - free_.size(); }
};
class OrderIndex {
struct Slot { uint64_t key; Idx val; uint32_t pad; };
static constexpr uint64_t kEmpty = UINT64_MAX;
std::vector<Slot> slots_;
size_t mask_ = 0;
int shift_ = 0;
size_t size_ = 0;
size_t home(uint64_t key) const { return size_t((key * 0x9E3779B97F4A7C15ull) >> shift_); }
public:
explicit OrderIndex(size_t max_orders) {
size_t cap = 16;
int bits = 4;
while (cap < max_orders * 2) { cap <<= 1; ++bits; } slots_.assign(cap, Slot{kEmpty, kNil, 0});
mask_ = cap - 1;
shift_ = 64 - bits;
}
Idx find(uint64_t key) const {
for (size_t i = home(key);; i = (i + 1) & mask_) {
if (slots_[i].key == key) return slots_[i].val;
if (slots_[i].key == kEmpty) return kNil;
}
}
Status insert(uint64_t key, Idx val) {
if (key == kEmpty) return Status::BadId;
if ((size_ + 1) * 2 > slots_.size()) return Status::IndexFull;
for (size_t i = home(key);; i = (i + 1) & mask_) {
if (slots_[i].key == key) return Status::DuplicateOrder;
if (slots_[i].key == kEmpty) { slots_[i] = {key, val, 0}; ++size_; return Status::Ok; }
}
}
bool erase(uint64_t key) {
size_t i = home(key);
for (;; i = (i + 1) & mask_) {
if (slots_[i].key == key) break;
if (slots_[i].key == kEmpty) return false;
}
for (size_t j = i;;) {
j = (j + 1) & mask_;
if (slots_[j].key == kEmpty) break;
size_t k = home(slots_[j].key);
bool k_in_ij = i <= j ? (i < k && k <= j) : (i < k || k <= j); if (!k_in_ij) { slots_[i] = slots_[j]; i = j; }
}
slots_[i].key = kEmpty;
--size_;
return true;
}
size_t size() const { return size_; }
};
class Ladder {
std::vector<LevelRef> lv_; bool bid_;
static constexpr size_t kLinear = 8;
bool worse(Px a, Px b) const { return bid_ ? a < b : a > b; } public:
Ladder(bool is_bid, size_t reserve) : bid_(is_bid) { lv_.reserve(reserve); }
size_t locate(Px px, bool& found) const {
size_t n = lv_.size(), lim = n > kLinear ? n - kLinear : 0, i = n;
for (; i > lim; --i) {
Px p = lv_[i - 1].px;
if (p == px) { found = true; return i - 1; }
if (worse(p, px)) { found = false; return i; }
}
auto it = std::partition_point(lv_.begin(), lv_.begin() + std::ptrdiff_t(lim),
[&](const LevelRef& r) { return worse(r.px, px); });
i = size_t(it - lv_.begin());
found = i < lim && lv_[i].px == px;
return i;
}
void insert(size_t pos, Px px, Idx level) { lv_.insert(lv_.begin() + std::ptrdiff_t(pos), LevelRef{px, level, 0});
}
void erase(size_t pos) { lv_.erase(lv_.begin() + std::ptrdiff_t(pos)); }
const LevelRef& at(size_t i) const { return lv_[i]; }
const LevelRef& level(size_t k) const { return lv_[lv_.size() - 1 - k]; } size_t size() const { return lv_.size(); }
void clear() { lv_.clear(); }
};
struct Book {
Ladder bids, asks;
uint64_t seq = 0; bool stale = true;
explicit Book(size_t reserve) : bids(true, reserve), asks(false, reserve) {}
Ladder& side(Side s) { return s == Side::Bid ? bids : asks; }
const Ladder& side(Side s) const { return s == Side::Bid ? bids : asks; }
bool crossed() const { return bids.size() && asks.size() && bids.level(0).px >= asks.level(0).px;
}
};
class BookManager {
Pool<OrderNode> orders_;
Pool<Level> levels_;
OrderIndex index_;
std::vector<Book> books_;
Idx new_level(Ladder& l, size_t pos, Px px) { Idx li = levels_.alloc();
if (li == kNil) return kNil;
levels_[li] = Level{px, 0, 0, kNil, kNil};
l.insert(pos, px, li);
return li;
}
void drop_level(Ladder& l, Idx li) { bool found;
size_t pos = l.locate(levels_[li].px, found);
assert(found && l.at(pos).level == li);
l.erase(pos);
levels_.release(li);
}
Status remove(Idx i) {
OrderNode& o = orders_[i];
Level& lv = levels_[o.level];
if (o.prev != kNil) orders_[o.prev].next = o.next; else lv.head = o.next;
if (o.next != kNil) orders_[o.next].prev = o.prev; else lv.tail = o.prev;
lv.total -= o.qty;
if (--lv.count == 0) drop_level(books_[o.book].side(o.side), o.level); index_.erase(o.id);
orders_.release(i);
return Status::Ok;
}
public:
BookManager(size_t n_books, size_t max_orders, size_t max_levels, size_t ladder_reserve)
: orders_(max_orders), levels_(max_levels), index_(max_orders) {
books_.reserve(n_books);
for (size_t b = 0; b < n_books; ++b) books_.emplace_back(ladder_reserve);
}
Book& book(uint16_t b) { return books_[b]; }
const Book& book(uint16_t b) const { return books_[b]; }
Status add(uint16_t b, uint64_t id, Side s, Px px, Qty qty) {
if (qty == 0) return Status::BadQty;
Idx i = orders_.alloc();
if (i == kNil) return Status::PoolExhausted;
if (Status st = index_.insert(id, i); st != Status::Ok) { orders_.release(i); return st; }
Ladder& l = books_[b].side(s);
bool found;
size_t pos = l.locate(px, found);
Idx li = found ? l.at(pos).level : new_level(l, pos, px);
if (li == kNil) { index_.erase(id); orders_.release(i); return Status::PoolExhausted; }
Level& lv = levels_[li];
orders_[i] = OrderNode{id, qty, lv.tail, kNil, li, b, s, 0}; if (lv.tail != kNil) orders_[lv.tail].next = i; else lv.head = i;
lv.tail = i;
lv.total += qty;
++lv.count;
return Status::Ok;
}
Status reduce(uint64_t id, Qty qty) { Idx i = index_.find(id);
if (i == kNil) return Status::UnknownOrder;
OrderNode& o = orders_[i];
if (qty == 0 || qty > o.qty) return Status::BadQty;
if (qty == o.qty) return remove(i);
o.qty -= qty;
levels_[o.level].total -= qty; return Status::Ok;
}
Status erase(uint64_t id) {
Idx i = index_.find(id);
return i == kNil ? Status::UnknownOrder : remove(i);
}
Status replace(uint64_t old_id, uint64_t new_id, Px px, Qty qty) { Idx i = index_.find(old_id);
if (i == kNil) return Status::UnknownOrder;
uint16_t b = orders_[i].book;
Side s = orders_[i].side;
remove(i);
return add(b, new_id, s, px, qty);
}
Status set_level(uint16_t b, Side s, Px px, uint64_t qty) {
Ladder& l = books_[b].side(s);
bool found;
size_t pos = l.locate(px, found);
if (qty == 0) {
if (found) { levels_.release(l.at(pos).level); l.erase(pos); }
return Status::Ok;
}
Idx li = found ? l.at(pos).level : new_level(l, pos, px);
if (li == kNil) return Status::PoolExhausted;
levels_[li].total = qty;
return Status::Ok;
}
void clear(uint16_t b) { Book& bk = books_[b];
for (Ladder* l : {&bk.bids, &bk.asks}) {
for (size_t k = 0; k < l->size(); ++k) {
Idx li = l->at(k).level;
for (Idx i = levels_[li].head; i != kNil;) {
Idx nx = orders_[i].next;
index_.erase(orders_[i].id);
orders_.release(i);
i = nx;
}
levels_.release(li);
}
l->clear();
}
bk.stale = true;
}
size_t depth(uint16_t b, Side s, PxQty* out, size_t n) const { const Ladder& l = books_[b].side(s);
size_t m = std::min(n, l.size());
for (size_t k = 0; k < m; ++k) {
const Level& v = levels_[l.level(k).level];
out[k] = {v.px, v.total, v.count};
}
return m;
}
uint64_t queue_ahead(uint64_t id) const { Idx i = index_.find(id);
if (i == kNil) return 0;
uint64_t ahead = 0;
for (Idx p = orders_[i].prev; p != kNil; p = orders_[p].prev) ahead += orders_[p].qty;
return ahead;
}
size_t live_orders() const { return orders_.used(); } size_t live_levels() const { return levels_.used(); } };
}
设计说明:
| 位置 |
说明 |
错误以 Status 返回 |
未知订单号、重复订单号、容量耗尽都不抛异常、不崩溃。调用方收到非 Ok 时把相关品种标记为 stale 并触发恢复(第 7 节) |
| 价位池间接寻址 |
价位数据存放在位置固定的价位池中,价位梯只保存 16 字节的(价格,价位池下标);价位梯插入删除时移动的只是这些引用,订单持有的价位池下标不会失效。撤单、成交经订单的 level 字段 O(1) 修改价位数量,只有价位被清空时才按价格在价位梯中定位并删除(itch-order-book 与 CppTrader 优化版本的做法) |
replace 先删后加 |
与 ITCH U 消息语义一致:新订单号、排到新价位队尾 |
clear 按链表释放 |
只释放该品种的订单,其他品种不受影响,支持单品种恢复 |
| 容量 |
max_orders 按历史峰值活跃订单数的 2 倍左右设置,max_levels 按所有品种同时存在的价位数峰值设置(均需用自己的录制数据统计);live_orders()、live_levels() 接入监控 |
| 订单索引的替代 |
交易所订单号当日递增且稠密时(如 ITCH),可用按订单号直接下标的数组代替哈希表,查找只需一次解引用;代价是按最大订单号预留内存,订单号稀疏或随机时不适用(第 5.4 节) |
6.1 差分测试
订单簿的 bug 往往只在特定消息序列下出现,单元测试难以覆盖。做法是写一个明显正确但很慢的参考实现,用随机操作序列同时驱动两者,定期比较全部价位与排队位置:
#include "order_book.hpp"
#include <cstdio>
#include <iterator>
#include <map>
#include <random>
struct RefOrder { uint16_t book; ob::Side side; ob::Px px; ob::Qty qty; uint64_t t; };
using Ref = std::map<uint64_t, RefOrder>;
static bool check(const ob::BookManager& m, const Ref& ref, uint16_t n_books) {
size_t levels = 0;
for (uint16_t b = 0; b < n_books; ++b)
for (ob::Side s : {ob::Side::Bid, ob::Side::Ask}) {
std::map<ob::Px, std::pair<uint64_t, uint32_t>> agg; for (const auto& [id, r] : ref)
if (r.book == b && r.side == s) { agg[r.px].first += r.qty; ++agg[r.px].second; }
std::vector<ob::PxQty> got(agg.size() + 1);
if (m.depth(b, s, got.data(), got.size()) != agg.size()) return false;
levels += agg.size();
size_t j = 0;
auto same = [&](ob::Px px, const std::pair<uint64_t, uint32_t>& v) {
const ob::PxQty& g = got[j++];
return g.px == px && g.qty == v.first && g.count == v.second;
};
if (s == ob::Side::Bid) {
for (auto it = agg.rbegin(); it != agg.rend(); ++it) if (!same(it->first, it->second)) return false;
} else {
for (const auto& [px, v] : agg) if (!same(px, v)) return false;
}
}
if (m.live_levels() != levels) return false; size_t stride = ref.size() / 100 + 1, k = 0; for (const auto& [id, r] : ref) {
if (k++ % stride) continue;
uint64_t ahead = 0;
for (const auto& [id2, r2] : ref)
if (r2.book == r.book && r2.side == r.side && r2.px == r.px && r2.t < r.t) ahead += r2.qty;
if (m.queue_ahead(id) != ahead) return false;
}
return true;
}
int main() {
constexpr uint16_t kBooks = 4;
ob::BookManager m(kBooks, 1 << 16, 1 << 14, 256);
Ref ref;
std::mt19937_64 rng(42);
uint64_t next_id = 1, clock = 0;
auto pick = [&] { auto it = ref.begin(); std::advance(it, rng() % ref.size()); return it; };
int step = 0;
auto fail = [&](const char* what) { std::printf("FAIL %s at step %d\n", what, step); return 1; };
for (; step < 300000; ++step) {
int op = ref.empty() ? 0 : ref.size() > 3000 ? 5 + int(rng() % 5) : int(rng() % 10);
if (op < 5) { uint16_t b = uint16_t(rng() % kBooks);
ob::Side s = rng() % 2 ? ob::Side::Bid : ob::Side::Ask;
ob::Px px = 1000 + ob::Px(rng() % 64) * (s == ob::Side::Bid ? -1 : 1);
ob::Qty q = ob::Qty(1 + rng() % 500);
uint64_t id = next_id++;
if (m.add(b, id, s, px, q) != ob::Status::Ok) return fail("add");
ref[id] = {b, s, px, q, clock++};
} else if (op < 7) { auto it = pick();
ob::Qty q = ob::Qty(1 + rng() % it->second.qty);
if (m.reduce(it->first, q) != ob::Status::Ok) return fail("reduce");
if ((it->second.qty -= q) == 0) ref.erase(it);
} else if (op < 9) { auto it = pick();
if (m.erase(it->first) != ob::Status::Ok) return fail("erase");
ref.erase(it);
} else { auto it = pick();
RefOrder r = it->second;
ob::Px px = r.px + ob::Px(rng() % 5) - 2;
ob::Qty q = ob::Qty(1 + rng() % 500);
uint64_t nid = next_id++;
if (m.replace(it->first, nid, px, q) != ob::Status::Ok) return fail("replace");
ref.erase(it);
ref[nid] = {r.book, r.side, px, q, clock++};
}
if (step % 1000 == 0 && !check(m, ref, kBooks)) return fail("check");
}
if (!check(m, ref, kBooks)) return fail("final check");
std::printf("ok: %d steps, %zu live orders, %zu live levels\n", step, m.live_orders(), m.live_levels());
return 0;
}
编译运行(未实测):
g++ -std=c++20 -O1 -g -Wall -Wextra -fsanitize=address,undefined test_order_book.cpp -o test_order_book
./test_order_book
测试开启 AddressSanitizer 与 UndefinedBehaviorSanitizer,可同时发现越界、释放后使用、整数溢出。
7. 缺口检测与恢复
stateDiagram-v2
[*] --> Recovering: 启动(晚加入)
Recovering --> Recovering: 缓存增量,等待快照或重传
Recovering --> Live: 快照已加载,缓存增量衔接成功
Live --> Live: 序号连续,应用增量
Live --> Recovering: 序号缺口 / 未知订单号 / 校验和不一致 / 容量耗尽
| 环节 |
做法 |
| A/B 仲裁 |
每个通道记录期望序号;两路中先到者生效,重复者丢弃;某路落后时短暂等待另一路补齐,再判定为缺口 |
| 缺口范围 |
通道级序号(如 ITCH 的 MoldUDP64 序号、MDP 的包序号)出现缺口时,无法确定影响了哪些品种,整个通道的品种都标记为 stale;MDP 另有品种级序号 RptSeq,可以只恢复受影响的品种 |
| 通知下游 |
stale 变化立即通知策略;做市策略的通常做法是撤掉该品种的报价 |
| 恢复来源 |
重传服务(Nasdaq MoldUDP64 重传请求、SoupBinTCP);快照通道(CME 循环发送的恢复快照);REST 快照(Binance) |
| 衔接 |
快照携带"已包含到的增量序号"S(CME 为 LastMsgSeqNumProcessed,Binance 为 lastUpdateId)。clear → 加载快照 → 丢弃缓存中序号 ≤ S 的增量 → 从 S + 1 开始应用,要求序号连续,否则重新恢复 |
| 缓存上限 |
恢复期间的增量缓存有上限,溢出则丢弃并重新开始,避免内存无限增长 |
| 启动 |
进程启动时所有品种处于 Recovering,与盘中缺口走同一条路径 |
8. 向策略发布
| 方式 |
做法 |
适用 |
| 同线程回调 |
订单簿更新后在同一线程直接调用策略(run-to-completion) |
延迟最低;适合最关键的少数品种 |
| seqlock 快照 |
每个品种一块共享内存保存前 N 档,写线程更新后递增版本号,读线程读取前后版本号一致才算有效 |
一个写线程、多个读线程,写者从不等待读者 |
| 合并(conflation) |
下游只关心最新状态时,同一包内的多条更新处理完再发布一次 |
降低下游负载 |
| 事件通知 |
只在最优价或前 N 档变化时通知 |
减少无效唤醒 |
seqlock 骨架(未实测):
#include <atomic>
#include <cstring>
template <size_t N>
struct TopN {
std::atomic<uint64_t> seq{0}; uint32_t nb = 0, na = 0;
ob::PxQty bid[N], ask[N];
};
template <size_t N>
void publish(TopN<N>& t, const ob::BookManager& m, uint16_t b) { uint64_t s = t.seq.load(std::memory_order_relaxed);
t.seq.store(s + 1, std::memory_order_relaxed);
std::atomic_thread_fence(std::memory_order_release);
t.nb = uint32_t(m.depth(b, ob::Side::Bid, t.bid, N));
t.na = uint32_t(m.depth(b, ob::Side::Ask, t.ask, N));
t.seq.store(s + 2, std::memory_order_release);
}
template <size_t N>
bool try_read(const TopN<N>& t, TopN<N>& out) { uint64_t s1 = t.seq.load(std::memory_order_acquire);
if (s1 & 1) return false;
out.nb = t.nb;
out.na = t.na;
std::memcpy(out.bid, t.bid, sizeof t.bid);
std::memcpy(out.ask, t.ask, sizeof t.ask);
std::atomic_thread_fence(std::memory_order_acquire);
return t.seq.load(std::memory_order_relaxed) == s1;
}
按 C++ 内存模型,读线程用普通读取访问正在被写的数据属于数据竞争(未定义行为);严格的写法是把数据字段也声明为 std::atomic 并用 memory_order_relaxed 逐字读写。上面的 memcpy 写法在实践中常见,依赖编译器与 x86 平台的实际行为。
9. 正确性保障
不变量(调试构建与离线重放时逐条检查):
| 不变量 |
违反时说明 |
每个价位 total = 链表中各订单数量之和,count = 链表长度 |
增减数量的路径有遗漏 |
| 价位梯严格有序,不存在空价位 |
删除空价位的路径有遗漏 |
订单索引大小 = 订单池已用数 = 各价位 count 之和 |
订单泄漏或重复释放 |
| 价位池已用数 = 所有价位梯的元素数之和;每个价位梯元素的价格与其价位池中的价格一致 |
价位泄漏,或价位梯与价位池不一致 |
| 连续竞价阶段最优买价 < 最优卖价 |
漏消息,或交易阶段处理错误 |
| 每个品种的序号连续 |
缺口未被发现 |
验证手段:
| 手段 |
做法 |
| 差分测试 |
第 6.1 节 |
| 全天重放 |
用交易所历史文件(Nasdaq 提供 ITCH 样例文件)重放全天,检查不变量;与交易所快照通道或数据商快照定期比对 |
| 校验和 |
部分交易所随增量下发前若干档的校验和(如 OKX 对前 25 档计算 CRC32),每次更新后本地计算并比对,不一致立即恢复 |
| 模糊测试 |
对解码层输入随机与截断的报文,确保不会越界或崩溃 |
| 线上监控 |
未知订单号计数、交叉次数、缺口与恢复次数、恢复耗时、订单池与价位池水位 |
10. 性能要点
| 手段 |
作用 |
| 32 位下标代替指针、节点 32 字节 |
两个节点一个缓存行,对象池更紧凑 |
| 订单池、价位池、索引、价位梯全部预分配 |
热路径无内存分配 |
最优价在 vector 末尾 |
利用更新集中在最优价附近的特征,线性扫描几步即命中;元素只有 16 字节,插入删除移动的数据少 |
| 价位池间接寻址 |
撤单与成交不查找价位梯,只有价位清空时才查找 |
| 开放寻址 + 负载因子 ≤ 0.5 |
查找通常一次缓存未命中 |
| 预取 |
解码出订单号后立即对索引槽位发 __builtin_prefetch,与后续解码并行 |
| 大页 |
对象池与索引放在 2 MB / 1 GB 大页上,减少 TLB 未命中 |
| 按通道分片 |
交易所把品种划分到不同组播通道,每个核独占若干通道的订单簿(单写者),核间不共享可变状态 |
| 按包批处理 |
一个网络包内的多条消息处理完再发布,减少发布次数 |
| 度量 |
每条消息用 rdtsc 计时,输出 p50 / p99 / p99.9 / 最大值 |
11. 撮合引擎侧订单簿的区别
| 方面 |
行情侧订单簿(本章) |
撮合引擎订单簿 |
| 输入 |
交易所行情 |
客户订单 |
| 职责 |
镜像交易所状态 |
决定谁与谁成交:价格优先、时间优先 |
| 订单类型 |
只需理解行情中的事件 |
限价、市价、IOC、FOK、只做挂单(post-only)、冰山、止损 |
| 额外逻辑 |
缺口检测与恢复 |
撮合循环、自成交防范、价格保护、熔断、集合竞价撮合 |
| 确定性 |
需要(重放调试) |
必须(主备复制、审计),见第一章事件溯源 |
| 输出 |
订单簿给策略 |
成交回报与行情(它就是行情的源头) |
12. 开源实现
按用途分三类:行情重建(由交易所行情还原订单簿)、撮合引擎(自己撮合成交)、回测 / 交易框架(框架内置的订单簿组件)。Stars 为 2026-09-24 查询值;核心数据结构来自源码或 README;性能数字均为项目作者给出的数据,未复现。
12.1 C++
| 项目 |
Stars |
用途 |
核心数据结构 |
说明 |
| Liquibook |
1.5k |
撮合引擎 |
价位用 std::multimap<ComparablePrice, Tracker> |
头文件库,结构直观,适合学习撮合逻辑;最后提交 2024-03 |
| CppTrader |
1.1k |
撮合 + ITCH 行情重建 |
标准版:价位用侵入式 AVL 树(CppCommon::BinTreeAVL),订单用 CppCommon::HashMap,节点来自池分配器;两个优化版本(performance/market_manager_optimized*.cpp):有序 std::vector<PriceLevel>(最优价取末尾)+ 价位池(LevelPool),激进优化版本按订单号直接下标 |
三个版本优化程度递进,便于逐项对比优化效果 |
| itch-order-book |
418 |
ITCH 行情重建 |
有序 vector,元素为(价格,价位池下标),最优价在末尾、从末尾向前扫描;价位数据在全局价位池中;订单元数据存放在按订单号直接下标的数组中;不用哈希表和树 |
作者称在 2012 年的 i7-3820 上约 61 ns 处理一条消息,并说明远离最优价的挂撤单会走慢路径;只维护价位聚合量,不跟踪每笔委托的排队位置;最后提交 2022-06 |
| Tzadiko/Orderbook |
325 |
撮合 |
std::map |
配套视频的教学项目,支持多种订单类型 |
| Kautenja/limit-order-book |
311 |
撮合 |
价位二叉搜索树 + tsl::robin_map(价格 → 价位)+ unordered_map(订单号 → 订单) |
C++ 核心,带 Python 绑定;最后提交 2020-07 |
| martinobdl/ITCH |
288 |
ITCH 全深度重建 |
未核实 |
作者称含二进制解析每秒处理 100–200 万条消息 |
| quantcup-orderbook |
213 |
撮合 |
全部可能价位预先分配为数组,每个价位挂订单链表,订单预分配 |
2011 年 QuantCup 撮合引擎竞赛冠军方案的 C++ 改写,"全价位数组"路线的代表;最后提交 2014-01 |
| brprojects/Limit-Order-Book |
212 |
撮合 |
买卖各一棵价位 AVL 树(带父指针)+ 价位内订单双向链表 + std::unordered_map(订单号 → 订单、价格 → 价位)+ 最优价指针;止损单另用两棵树;订单与价位以 new / delete 分配 |
WK Selph《How to Build a Fast Limit Order Book》的经典设计,附 GoogleTest 测试;作者用合成数据(价格围绕 300 正态分布)单线程测得平均 713 ns / 请求(约 140 万笔 / 秒,i5-12450H);热路径有堆分配与节点式哈希表,适合学习撮合逻辑 |
12.2 Rust
| 项目 |
Stars |
用途 |
核心数据结构 |
说明 |
| NautilusTrader |
29.3k |
交易框架(行情 + 回测撮合) |
买卖两侧各一个 BTreeMap<BookPrice, BookLevel>,加订单号到价格的 HashMap 缓存 |
工程质量达到生产级,与完整框架集成;代码在 crates/model/src/orderbook/ |
| hftbacktest |
4.8k |
高频回测 / 实盘 |
多种实现并存:HashMapMarketDepth、BTreeMarketDepth、ROIVectorMarketDepth(只为关注的价格区间分配数组) |
源码注释说明了各实现的取舍,例如 L2 数据缺失时 HashMap 实现比 BTree 实现更稳健;代码在 hftbacktest/src/depth/ |
| OrderBook-rs |
536 |
撮合引擎 |
价位用 crossbeam_skiplist::SkipMap<u128, Arc<PriceLevel>>,配合 DashMap |
无锁、支持多线程并发写入,追求多写者吞吐,与单写者低延迟路线不同 |
| dgtony/orderbook-rs |
454 |
撮合 |
未核实 |
基础实现,适合入门;最后提交 2018-04 |
| ninjabook |
189 |
L2 行情 |
BTreeMap 存价位,另外缓存最优买卖价 |
作者用 30 万条 L2 数据测得处理事件并输出最优买卖价约 50 ns / 条;最后提交 2024-11 |
| lobster |
176 |
撮合 |
BTreeMap<u64, Vec<usize>> + 订单 arena |
README 分析了自身比 QuantCup 冠军方案慢(最多约 10 倍)的原因,可用于理解两条路线的取舍;最后提交 2023-01 |
| hyperliquid order_book_server |
161 |
链上订单簿行情 |
未核实 |
Hyperliquid 官方项目:从非验证节点的数据重建订单簿,除 l2book 外还提供逐笔委托级的 l4book 推送 |
12.3 其他语言
| 项目 |
Stars |
语言 |
核心数据结构 |
说明 |
| exchange-core |
2.6k |
Java |
OrderBookDirectImpl 用自实现的自适应基数树(LongAdaptiveRadixTreeMap)存价位与订单索引,配合对象池;OrderBookNaiveImpl 用 TreeMap 作对照 |
撮合引擎,基于 LMAX Disruptor;最后提交 2023-10 |
12.4 设计路线
| 路线 |
代表 |
取舍 |
| 全价位数组 |
QuantCup 冠军方案 |
定位 O(1)、内存连续,最快;要求价格范围有界,否则内存占用大 |
有序 vector + 价位池 |
itch-order-book、CppTrader 优化版本 |
利用更新集中在最优价附近的特征;远离最优价的操作走慢路径 |
| 树 + 链表 + 哈希 |
CppTrader(AVL 树)、Kautenja、brprojects、Liquibook |
通用,价格范围不受限;查找需要多次指针跳转 |
| 标准库有序容器 |
NautilusTrader、lobster、ninjabook(BTreeMap) |
实现简单,正确性容易保证;性能中等 |
| 价格区间窗口 |
hftbacktest ROIVectorMarketDepth |
中间价附近用数组,速度接近全价位数组,内存可控 |
| 并发无锁 |
OrderBook-rs |
支持多线程同时写入,提升吞吐;单次操作延迟不如单写者方案 |
本章第 5.2 节的默认方案"有序 vector + 价位池"与 itch-order-book、CppTrader 优化版本的做法一致;价格区间有界的市场使用全价位数组。
12.5 按目的选读
| 目的 |
阅读顺序 |
| 撮合逻辑 |
Liquibook → lobster(重点读 README 中与 QuantCup 的对比) |
| 极致优化 |
QuantCup 冠军方案 → itch-order-book → CppTrader 三个版本逐级对比 |
| 生产级 Rust |
NautilusTrader 的 crates/model/src/orderbook/ → hftbacktest 的 hftbacktest/src/depth/ |
2025–2026 年新建的个人项目中,不少在 README 中给出"亚 100 ns""个位数纳秒"等性能数字,缺少第三方验证与长期维护记录,未列入上表。
研究订单簿微观结构可使用 LOBSTER 提供的 Nasdaq 历史订单簿数据(学术用途;与上表的 Rust 项目 lobster 无关)。
四、事件总线的设计与实现
事件总线是交易域的骨干(第一章第 1.1 节):各组件的输出以事件形式发布到总线,并按序号写入事件日志,支撑事件溯源、重放与主备复制。本章说明总线的语义、分层与各层的实现方式。
本章的延迟数字是公开资料中常见的数量级,未实测。
1. 语义与分层
| 问题 |
交易系统的典型选择 |
| 顺序 |
同一分片内全局有序:每个事件带单调递增序号,所有消费者看到相同顺序;分片之间不保证顺序 |
| 投递保证 |
至少一次投递 + 消费者按序号去重,达到"恰好一次"的效果 |
| 持久化 |
事件写入日志后分发,或边写边分发,保证可重放、可复制 |
| 慢消费者 |
热路径上的生产者不被慢消费者拖慢(第 5 节) |
| 扇出 |
单写者、多读者是最常见的形态(行情、回报) |
| 层 |
范围 |
延迟量级 |
核心结构 |
| 线程间 |
同一进程 |
几十 ns(一次缓存行跨核传输约 50–100 ns) |
SPSC 环形缓冲区、Disruptor |
| 进程间 |
同一台机器 |
百 ns 级 |
共享内存中的环形缓冲区或日志:Aeron IPC、Chronicle Queue、iceoryx2 |
| 跨机低延迟 |
同一机房 |
微秒级 |
Aeron UDP(基于 NAK 的可靠组播) |
| 跨机非关键路径 |
跨机房 |
毫秒级 |
Kafka / Redpanda、NATS |
四层的底层是同一种抽象:只追加的有序日志 + 每个消费者各自的读位置。区别只在于日志所在的位置:进程内存、/dev/shm 中的 mmap 文件、磁盘文件或网络上的 term buffer。
2. 核心结构:定序器 + 日志 + 游标
生产者A ─┐ ┌─► 消费者1(策略) 读位置 = 1005
生产者B ─┼─► 定序器 ─► 日志 ────────┼─► 消费者2(风控) 读位置 = 1007
生产者C ─┘ 分配序号 #1001 #1002 └─► 消费者3(录制) 读位置 = 998
写入日志 #1003 ...
↑ 写位置 = 1008
| 部件 |
作用 |
| 定序器 |
把多个输入合并为一条有序流;单线程运行,是日志的唯一写者 |
| 日志 |
连续内存或 mmap 文件,存放带序号的定长或变长记录 |
| 游标 |
每个消费者自行记录读位置;生产者不需要知道消费者的存在,因此扇出成本低 |
不需要全局顺序时,每个生产者各写一条日志(单写者),消费者轮询多条日志,性能最高,但不同生产者的事件之间没有确定先后。需要确定性重放的状态机(第一章第 3 节 ②)前面必须有定序器。
3. 线程间:SPSC 环形缓冲区
单生产者、单消费者是最常用也最快的形态:
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <type_traits>
template <typename T, size_t N> class SpscRing {
static_assert((N & (N - 1)) == 0);
static_assert(std::is_trivially_copyable_v<T>);
alignas(64) std::atomic<uint64_t> head_{0}; uint64_t tail_cache_ = 0; alignas(64) std::atomic<uint64_t> tail_{0}; uint64_t head_cache_ = 0; alignas(64) T slots_[N];
public:
bool try_push(const T& v) { uint64_t t = tail_.load(std::memory_order_relaxed);
if (t - head_cache_ == N) { head_cache_ = head_.load(std::memory_order_acquire);
if (t - head_cache_ == N) return false;
}
slots_[t & (N - 1)] = v;
tail_.store(t + 1, std::memory_order_release); return true;
}
bool try_pop(T& out) { uint64_t h = head_.load(std::memory_order_relaxed);
if (h == tail_cache_) { tail_cache_ = tail_.load(std::memory_order_acquire);
if (h == tail_cache_) return false;
}
out = slots_[h & (N - 1)];
head_.store(h + 1, std::memory_order_release); return true;
}
};
template class SpscRing<uint64_t, 1024>;
编译(未实测):
g++ -std=c++20 -O2 -Wall -Wextra -c spsc_ring.cpp
| 要点 |
作用 |
| 读位置、写位置各占一个缓存行 |
避免伪共享:生产者写 tail_ 不会使消费者的缓存行失效 |
缓存对方的位置(head_cache_ / tail_cache_) |
只有看起来满或空时才读取对方的缓存行,多数操作不触碰对方缓存行 |
| 64 位计数器只增不减 |
取模用位与,实际不会溢出,也无需额外标志区分满与空 |
release / acquire 配对 |
生产者先写槽位再发布 tail_,消费者看到新 tail_ 后一定能看到槽位内容;x86 上二者都编译为普通 mov,没有额外屏障指令 |
Disruptor 的扩展:多个消费者各持一个序号,生产者必须等最慢的消费者越过某个槽位后才能覆盖它(gating);消费者之间可以有依赖关系(例如风控处理完之后 OMS 才处理)。等待策略抽象为 WaitStrategy,按延迟从低到高、CPU 占用从高到低依次为 BusySpin、Yielding、Sleeping、Blocking。
4. 进程间与持久化:变长记录日志
跨进程共享 /dev/shm 或大页上的 mmap 文件,存放变长记录。Aeron 的 term buffer、Agrona(Aeron 的底层库)的环形缓冲区、Chronicle Queue 采用相同的思路:
记录格式(8 字节对齐):
┌────────┬────────┬────────┬───────────┬──────────────┬─────────┐
│ length │ type │ seq │ timestamp │ payload │ padding │
│ 4 字节 │ 4 字节 │ 8 字节 │ 8 字节 │ SBE 编码消息 │ │
└────────┴────────┴────────┴───────────┴──────────────┴─────────┘
提交协议:写者先写 type、seq、timestamp、payload,最后以 release 语义写入 length(写入前为 0)。读者以 acquire 语义读取 length:为 0 表示没有新数据,非 0 则可以安全读取整条记录。写者在写入过程中崩溃时 length 仍为 0,读者不会读到半条记录。
绕回:剩余空间放不下一条记录时,写入一条填充记录(type = PADDING),从缓冲区开头继续写。
两种消费语义:
| 语义 |
做法 |
用于 |
| 无损,慢消费者限制生产者 |
生产者的写位置不超过最慢消费者的读位置加缓冲区容量(Aeron IPC 的流控方式) |
订单、回报等不能丢失的事件 |
| 广播,可被套圈(lapped) |
生产者不等待任何消费者;读者读取前后检查写位置,发现数据已被覆盖则报告丢失并自行重新同步(Agrona 的 BroadcastTransmitter / BroadcastReceiver) |
行情扇出:慢读者不拖累全局,丢失后从快照恢复 |
持久化级别:
| 方式 |
进程崩溃 |
整机宕机 |
| 写入 mmap 文件,不调用 fsync |
不丢失:页缓存属于内核,进程退出后数据仍在 |
可能丢失未刷盘的部分 |
| 批量 fsync |
不丢失 |
最多丢失一个批次 |
| 复制到另一台机器后确认(Aeron Cluster、Chronicle 复制) |
不丢失 |
不丢失,代价是一次网络往返 |
逐条 fsync 太慢,交易系统通常选择第一种或第三种:防止整机宕机靠跨机复制,而不是刷盘。
轮转与索引:日志按天或按大小滚动成新文件,并维护"序号 → 文件偏移"与"时间 → 序号"索引,支持从任意位置重放(Chronicle Queue 的 roll cycle 与 tailer 即此设计)。
5. 后加入的消费者与背压
后加入的消费者(late joiner):消费者启动时总线已产生大量事件。做法是先从日志文件重放追到接近当前位置,再按序号无缝切换到实时流,不留空洞、不重复(Aeron Archive 的 ReplayMerge)。这与第三章订单簿"快照 + 增量"的衔接是同一个问题。
背压策略:
| 策略 |
适用 |
| 阻塞生产者 |
仅用于非热路径,或生产者能容忍等待的场合 |
| 慢消费者从日志追赶 |
默认做法:生产者只追加日志,慢消费者按自身节奏读取 |
| 合并(conflation) |
只关心最新状态的消费者,如 UI、监控只需要最新订单簿 |
| 断开慢消费者 |
落后超过阈值即断开,由其从日志或快照恢复 |
原则:热路径上的生产者不等待任何消费者。录制进程变慢只影响录制,不影响交易。
6. 工程细节
| 方面 |
做法 |
| 路由 |
按消息类型与品种划分主题;热路径上由消费者按类型过滤(读取头部几个字节即可决定是否跳过),比在生产者侧维护订阅表更便宜 |
| schema |
消息用 SBE 编码并带版本号,新字段只追加在末尾,保证旧日志可被新代码重放 |
| 写者存活 |
共享内存中放置写者心跳字段,读者发现心跳停止即判定写者失效,而不是无限等待 |
| 多写者 |
尽量避免;确有需要时用原子操作抢占写位置(Agrona ManyToOneRingBuffer),或每个写者一条日志再由定序器合并 |
| 监控 |
每个消费者的延迟(写位置 − 读位置)、发布延迟直方图、被套圈次数 |
| 请求-应答 |
在总线上用关联 ID 实现;NautilusTrader 的 MessageBus 同时支持发布-订阅与请求-应答 |
开源方案见第一章第 2.2 节"事件总线"。
五、策略引擎的设计与实现
策略引擎是承载策略的运行时:按确定顺序向策略分发事件,为策略提供受控的操作环境(时间、行情、持仓、下单、定时器),并管理策略的生命周期、状态与故障。本章说明其设计要点,最后给出一个可运行的最小实现。
1. 职责与边界
| 负责 |
不负责 |
| 按确定顺序分发事件(行情、回报、定时器、控制指令) |
持仓与订单的最终真相(OMS) |
| 提供策略上下文:时钟、行情缓存、本策略的持仓与在途订单、下单撤单接口、定时器、日志与指标 |
事前风控的最终裁决(独立风控;引擎内的检查只是第一道) |
| 生命周期管理:加载、预热、运行、暂停、停止、故障 |
行情接入与订单簿重建 |
| 状态持久化与重启恢复、参数热更新 |
组合优化(中低频由组合构建层负责) |
| 策略间的隔离与故障处理 |
|
核心约束:策略代码只能通过上下文接触外部世界,不直接读取系统时间、不直接做 IO、不自行创建线程。这是回测与实盘同一套代码的前提(第一章第 3 节 ①)。
2. 整体结构
flowchart LR
IN[事件总线 / 订单簿<br/>行情、回报、定时器、控制指令] --> Q[事件队列<br/>按 时间 + 序号 排序]
Q --> D[分发器<br/>按事件类型 + 品种查订阅表]
D --> CA[策略 A 的 Context]
D --> CB[策略 B 的 Context]
CA --> SA[策略 A 回调<br/>on_quote / on_fill / ...]
CB --> SB[策略 B 回调]
SA -- buy / cancel / set_timer --> OUT[订单出口<br/>在途跟踪 → 引擎内检查]
SB -- buy / cancel / set_timer --> OUT
OUT --> EXE[执行 / 风控 / OMS]
整个引擎在单线程事件循环中运行(高频场景下与订单簿同线程,见第十一章)。事件来源见第四章事件总线。
3. 策略接口
| 回调 |
触发时机 |
on_init / on_start / on_stop |
生命周期;on_init 中加载历史数据预热 |
on_quote / on_trade / on_book / on_bar |
行情,按订阅的品种与类型 |
on_order_update / on_fill |
订单状态变化、成交 |
on_timer |
定时器到期 |
on_session |
交易时段切换:集合竞价、连续竞价、休市、收盘 |
on_param_change |
参数热更新 |
on_save / on_load |
状态保存与恢复 |
两种下单接口:
| 接口 |
策略表达 |
适用 |
特点 |
| 订单式 |
buy(sym, qty, px)、cancel(id) |
高频、做市 |
完全控制报价与撤单;策略需要正确处理订单状态 |
| 目标仓位式 |
set_target(sym, 100) |
中低频(CTA、选股) |
天然幂等:重复调用不会重复下单;重启后重新计算目标即可;与实际持仓的差额交给执行层 |
中低频策略优先使用目标仓位式接口,例如 WonderTrader 的 CTA 引擎以 stra_set_position 设定目标仓位。订单式接口下由策略跟踪订单状态,是缺陷最集中的地方(第 6 节)。
4. 事件分发与确定性
| 要点 |
说明 |
| 单线程事件循环 |
同一策略的回调不会并发执行,策略代码无需加锁 |
| 排序键 (事件时间, 序号) |
同一时刻的多个事件按进入引擎的先后排序,回测与实盘必须一致。例如 t = 5 ms 同时有报价与成交回报,先处理哪个决定了策略下单时看到的持仓是 0 还是 1(第 13 节的输出即有此现象) |
| 订阅表 |
按"品种 × 事件类型"建立索引,分发时 O(1) 找到订阅者,而不是把所有事件交给每个策略自行过滤 |
| 合并(conflation) |
慢策略(Python)跟不上逐笔更新时,同一品种的多次订单簿更新只投递最新一次;策略必须能容忍合并。成交与回报不能合并 |
5. 生命周期
stateDiagram-v2
[*] --> CREATED
CREATED --> INITIALIZING: 加载参数、历史数据预热(禁止下单)
INITIALIZING --> RUNNING: 预热完成且与 OMS 持仓核对一致
RUNNING --> PAUSED: 人工暂停(只允许平仓与撤单)
PAUSED --> RUNNING: 恢复
RUNNING --> DEGRADED: 行情不可用、风控拒单过多(自动降级为只减仓)
DEGRADED --> RUNNING: 条件恢复
RUNNING --> FAILED: 回调异常(撤单、停止,等待人工处理)
RUNNING --> STOPPING: 正常停止
STOPPING --> STOPPED
FAILED --> [*]
STOPPED --> [*]
- 进入
FAILED 时引擎自动撤销该策略的全部挂单,是否平仓由配置决定
PAUSED / DEGRADED 状态下开仓请求由引擎直接拒绝,不依赖策略代码自行检查
- 状态变化本身作为事件写入事件日志并通知监控
6. 订单与在途状态
典型缺陷:策略看到信号并下单,成交回报 2 ms 后才到达;这 2 ms 内又来一个报价,信号仍成立,策略看到持仓仍为 0,再下一单,仓位翻倍。对策是由引擎为策略维护"持仓 + 在途":
| 概念 |
含义 |
| 持仓 |
已成交数量 |
| 在途 |
已发出、尚无最终结果(成交、撤单、拒单)的数量 |
| 敞口 |
持仓 + 在途。所有下单判断基于敞口,而不是持仓 |
| 待撤 |
已发出撤单、尚未确认的订单:仍可能成交,继续计入敞口 |
| 状态未知 |
超时未收到回报:按可能已成交计入敞口,并发起查询 |
ctx.buy() 内部先检查敞口,下单后在途增加;成交回报到达时在途减少、持仓增加;撤单或拒单确认时在途减少。这些由引擎完成,策略只调用 ctx.exposure(sym)。
7. 定时器与交易时段
- 定时器是事件,由引擎时钟触发:回测时是模拟时钟,因此回测中的定时器行为与实盘一致。策略不能使用
sleep 或系统定时器
- 周期定时器每次处理完再安排下一次。回测必须有明确的结束时间,否则周期定时器会使事件队列永远不为空
- 交易时段事件(集合竞价开始、连续竞价开始、午休、收盘前 N 分钟、夜盘)的时段表来自参考数据,不写死在策略中
8. 预热与重启恢复
预热:均线、波动率等指标需要历史数据。把历史数据经同一套回调喂给策略,期间禁止下单,预热结束时指标与"一直在运行"时一致。例如 LEAN 的 SetWarmUp、vn.py CTA 策略在 on_init 中调用 load_bar。
盘中重启后恢复:
| 方式 |
过程 |
适用 |
| 事件溯源 |
加载快照(on_load)→ 从快照对应序号重放事件日志 → 恢复到崩溃前的精确状态 |
状态复杂的策略(做市、高频) |
| 重新预热 + 对账 |
用历史数据重新计算指标,从 OMS 拉取当前持仓与挂单 |
中低频策略 |
| 目标仓位 |
重新计算目标仓位,与实际持仓做差 |
目标仓位式策略,最简单 |
无论哪种方式,恢复期间禁止下单,与 OMS 持仓核对一致后才进入 RUNNING。
9. 参数热更新
- 参数带 schema(类型、范围、默认值),更新前校验
- 更新作为事件进入队列,在两个事件之间生效,不在回调执行中途修改
- 参数变更写入事件日志并带版本号,保证重放时参数与当时一致
- 持仓上限等风控参数走风控配置流程,不作为策略参数
配置的版本化、审批、分发与生效确认见第十三章。
10. 多策略隔离与故障处理
| 隔离方式 |
故障影响范围 |
延迟 |
适用 |
| 同线程 |
一个策略死循环会阻塞所有策略 |
最低 |
高频,每线程一个或少数几个策略 |
| 同进程不同线程 |
一个策略内存越界可能拖垮整个进程 |
低 |
C++ / Rust 中频 |
| 独立进程 |
仅影响自身 |
多一次 IPC |
Python 中低频策略的默认选择 |
同一进程内也需要:
- 异常隔离:所有回调包在引擎的异常捕获中,一个策略抛异常只将其置为
FAILED 并撤单,其他策略继续运行
- 执行时间预算:记录每次回调耗时,超出预算告警,持续超出则降级或暂停。Python 无法强行中断正在执行的回调,死循环只能依靠进程隔离防护
11. 多品种策略的一致视图
配对交易、期现套利等策略需要同时使用多个品种的价格。A 的报价是 10:00:00.001 的,B 的报价可能是 10:00:00.000 的旧值,用不同时刻的价格计算价差会产生虚假信号。做法:
- 策略记录每个品种最后一次更新的时间,二者相差超过阈值时不交易
- 或由引擎按同一逻辑时刻批量投递,例如回测中同一时间戳的事件全部处理完后触发一次
on_snapshot
多腿成交不可能同时完成,先成交的一腿形成单边敞口,策略必须处理"一腿成交、另一腿未成交"的状态(腿风险)。
12. 性能与可观测性
| 场景 |
做法 |
| Python 策略 |
每次回调有解释器开销(微秒级,经验量级,未实测);需要高吞吐时,引擎核心用 Rust / C++ 实现,只有策略逻辑用 Python(NautilusTrader 的路线);指标增量计算,不每次重算整段历史 |
| C++ 高频策略 |
用模板 / CRTP 在编译期绑定回调,可内联,不使用虚函数;回调内不分配内存、不格式化日志字符串 |
CRTP 骨架:
struct Book { long best_bid, best_ask; };
template <class Derived>
struct StrategyBase {
void dispatch_book(const Book& b) { static_cast<Derived*>(this)->on_book(b); } };
struct MarketMaker : StrategyBase<MarketMaker> {
long mid2 = 0;
void on_book(const Book& b) { mid2 = b.best_bid + b.best_ask; } };
long run_once(MarketMaker& mm, const Book& b) { mm.dispatch_book(b); return mm.mid2; }
编译(未实测):
g++ -std=c++20 -O2 -Wall -c crtp_strategy.cpp
决策追踪:策略每次下单时,把当时的关键输入(信号值、看到的价格、敞口、参数版本)与订单号一起记录到旁路二进制日志,配合事件日志重放,可以精确复现"这一单为什么发出"。每个策略还暴露回调耗时分布、信号次数、下单与拒单次数、实时 PnL、当前状态等指标。
13. 最小实现
以下约 150 行 Python 实现了第 4、6、7、10 节的核心机制:按 (时间, 序号) 确定性分发、周期定时器、按"持仓 + 在途"检查下单、回调异常只停止该策略并撤单、明确的结束时间。
import heapq
import itertools
LATENCY = 2 MAX_POS = 2
class Strategy:
def __init__(self, name, symbols):
self.name, self.symbols, self.state = name, symbols, "CREATED"
def on_start(self, ctx): pass
def on_quote(self, ctx, sym, px): pass
def on_fill(self, ctx, order): pass
def on_timer(self, ctx, name): pass
def on_stop(self, ctx): pass
class Context:
def __init__(self, engine, strat):
self.e, self.s = engine, strat
self.position = {} self.inflight = {}
def now(self): return self.e.now
def last(self, sym): return self.e.last[sym]
def exposure(self, sym): return self.position.get(sym, 0) + self.inflight.get(sym, 0)
def buy(self, sym, qty):
if self.exposure(sym) + qty > MAX_POS: self.e.log(f"{self.s.name}: 拒单,持仓+在途将超过上限")
return None
return self.e.submit(self, sym, qty)
def set_timer(self, name, interval):
self.e.push(self.e.now + interval, "timer", (self, name, interval))
class Engine:
def __init__(self):
self.q, self.seq = [], itertools.count() self.now, self.last, self.subs, self.ctxs = 0, {}, {}, []
self.oid, self.orders = itertools.count(1), {}
def log(self, msg): print(f"[t={self.now:>2}ms] {msg}")
def push(self, ts, kind, payload): heapq.heappush(self.q, (ts, next(self.seq), kind, payload))
def add(self, strat):
ctx = Context(self, strat)
self.ctxs.append(ctx)
for sym in strat.symbols:
self.subs.setdefault(sym, []).append(ctx)
return ctx
def submit(self, ctx, sym, qty):
oid = next(self.oid)
self.orders[oid] = {"id": oid, "ctx": ctx, "sym": sym, "qty": qty, "status": "PENDING"}
ctx.inflight[sym] = ctx.inflight.get(sym, 0) + qty
self.push(self.now + LATENCY, "fill", oid) self.log(f"{ctx.s.name}: 报单 #{oid} 买 {qty} {sym}")
return oid
def call(self, ctx, fn, *args):
if ctx.s.state != "RUNNING":
return
try:
fn(ctx, *args)
except Exception as ex:
ctx.s.state = "FAILED"
self.log(f"{ctx.s.name}: 回调异常 {ex!r},策略停止,撤销在途订单")
for o in self.orders.values():
if o["ctx"] is ctx and o["status"] == "PENDING":
o["status"] = "CANCELED"
ctx.inflight[o["sym"]] -= o["qty"]
def run(self, quotes, end):
for ts, sym, px in quotes:
self.push(ts, "quote", (sym, px))
for ctx in self.ctxs:
ctx.s.state = "RUNNING"
self.call(ctx, ctx.s.on_start)
while self.q and self.q[0][0] <= end: self.now, _, kind, p = heapq.heappop(self.q)
if kind == "quote":
sym, px = p
self.last[sym] = px
for ctx in self.subs.get(sym, []):
self.call(ctx, ctx.s.on_quote, sym, px)
elif kind == "fill":
o = self.orders[p]
if o["status"] != "PENDING":
continue o["status"], o["px"] = "FILLED", self.last[o["sym"]]
ctx = o["ctx"]
ctx.inflight[o["sym"]] -= o["qty"]
ctx.position[o["sym"]] = ctx.position.get(o["sym"], 0) + o["qty"]
self.call(ctx, ctx.s.on_fill, o)
elif kind == "timer":
ctx, name, interval = p
if ctx.s.state == "RUNNING":
self.call(ctx, ctx.s.on_timer, name)
ctx.set_timer(name, interval) for ctx in self.ctxs:
if ctx.s.state == "RUNNING":
ctx.s.state = "STOPPED"
self.log(f"{ctx.s.name}: 最终状态 {ctx.s.state},持仓 {ctx.position}")
class Momentum(Strategy):
def on_start(self, ctx):
self.hist = []
ctx.set_timer("report", 4)
def on_quote(self, ctx, sym, px):
self.hist = (self.hist + [px])[-4:]
if len(self.hist) == 4 and all(a < b for a, b in zip(self.hist, self.hist[1:])):
ctx.buy(sym, 1)
def on_fill(self, ctx, o):
ctx.e.log(f"{self.name}: 成交 #{o['id']} @ {o['px']},持仓 {ctx.position[o['sym']]}")
def on_timer(self, ctx, name):
ctx.e.log(f"{self.name}: 定时汇报 持仓={ctx.position} 在途={ctx.inflight}")
class Buggy(Strategy):
def on_start(self, ctx): self.n = 0
def on_quote(self, ctx, sym, px):
self.n += 1
if self.n == 3:
ctx.buy(sym, 1)
if self.n == 4:
raise ZeroDivisionError("bug")
quotes = [(t, "AAA", px) for t, px in enumerate([100, 101, 102, 103, 104, 105, 104, 105, 106, 107, 108])]
eng = Engine()
eng.add(Momentum("动量", ["AAA"]))
eng.add(Buggy("有缺陷", ["AAA"]))
eng.run(quotes, end=12)
运行 python mini_engine.py,输出(Windows 本机,Python 3.14.7;未在 dev 机器上实测):
[t= 2ms] 有缺陷: 报单 #1 买 1 AAA
[t= 3ms] 动量: 报单 #2 买 1 AAA
[t= 3ms] 有缺陷: 回调异常 ZeroDivisionError('bug'),策略停止,撤销在途订单
[t= 4ms] 动量: 报单 #3 买 1 AAA
[t= 4ms] 动量: 定时汇报 持仓={} 在途={'AAA': 2}
[t= 5ms] 动量: 拒单,持仓+在途将超过上限
[t= 5ms] 动量: 成交 #2 @ 105,持仓 1
[t= 6ms] 动量: 成交 #3 @ 104,持仓 2
[t= 8ms] 动量: 定时汇报 持仓={'AAA': 2} 在途={'AAA': 0}
[t= 9ms] 动量: 拒单,持仓+在途将超过上限
[t=10ms] 动量: 拒单,持仓+在途将超过上限
[t=12ms] 动量: 定时汇报 持仓={'AAA': 2} 在途={'AAA': 0}
[t=12ms] 动量: 最终状态 STOPPED,持仓 {'AAA': 2}
[t=12ms] 有缺陷: 最终状态 FAILED,持仓 {}
| 时刻 |
现象 |
对应机制 |
| t = 5 |
持仓为 0 却拒单:在途 2 手,敞口已达上限;只看持仓则会下第 3 单 |
第 6 节:按敞口判断 |
| t = 5 |
报价先于成交 #2 处理:二者时间相同,报价先进入队列,序号更小 |
第 4 节:(时间, 序号) 确定性排序 |
| t = 3 |
"有缺陷"策略抛异常后仅它被停止,其 #1 订单被撤销(最终持仓为空);"动量"策略不受影响 |
第 10 节:异常隔离与故障撤单 |
| t = 4、8、12 |
定时汇报按模拟时钟触发,到结束时间停止 |
第 7 节:定时器是事件 |
14. 开源实现
| 项目 |
策略接口 |
值得参考的设计 |
| NautilusTrader |
Actor(只接收数据)/ Strategy(可下单);on_start、on_quote_tick、on_order_book_deltas、on_order_filled 等;on_save / on_load 保存恢复状态 |
Rust 核心 + Python 策略;回测与实盘同一引擎 |
| LEAN |
Initialize / OnData;Algorithm Framework 把策略拆为选股池、Alpha、组合构建、风控、执行五个模块 |
SetWarmUp 预热;模块可单独替换 |
| vn.py |
CtaTemplate:on_init(其中 load_bar 预热)、on_start、on_tick、on_bar、on_order、on_trade |
BarGenerator 由 tick 合成 K 线;声明为 variables 的变量持久化,重启后恢复 |
| WonderTrader |
按频率划分 CTA、SEL、HFT、UFT 上下文 |
CTA 使用目标仓位式接口(stra_set_position) |
| Hummingbot V2 |
Controller(产生信号)+ Executor(管理单笔仓位或订单:PositionExecutor、TWAP Executor 等) |
把"想做什么"与"如何执行"分离 |
六、组合构建的设计与实现
组合构建把一个或多个策略的信号,在风险模型、约束与交易成本之下,转换为目标持仓,再交给执行引擎。第一章第 2.2 节给出了优化问题的一般形式,本章给出组合构建的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 信号标准化与合并 |
产生信号(策略引擎、研究模型) |
| 风险模型的构建与更新 |
拆单与下单(执行引擎,第七章) |
| 在约束与成本下求解目标持仓 |
逐笔订单检查(事前风控,第八章) |
| 目标持仓到交易清单的转换 |
持仓真相(OMS,第九章) |
| 事前风险与收益归因 |
|
在交易域中的位置:策略引擎 → 组合构建 → 执行引擎(第一章第 1 节)。组合构建按日或按分钟周期运行,不在每个 tick 上运行;高频做市策略不经过组合构建。
2. 输入与输出
| 输入 |
来源 |
| 预期收益或信号分数 |
各策略、各模型 |
| 风险模型:因子暴露、因子协方差、特异风险 |
风险模型模块(第 4 节) |
| 当前持仓与在途数量 |
OMS |
| 交易成本模型:费用、半价差、市场冲击 |
执行引擎的 TCA 结果(第七章第 2 节、第 10 节) |
| 约束 |
风控限额、产品合同、监管要求 |
| 基准权重 |
指数公司(指数增强产品) |
| 可交易性 |
停牌、涨跌停、T+1、限制交易名单(参考数据,第十二章) |
| 输出 |
说明 |
| 目标持仓 |
各品种的目标权重或目标数量 |
| 交易清单 |
目标与当前(含在途)的差额,作为母单交给执行引擎 |
| 事前报告 |
预期风险、跟踪误差、因子暴露、换手、预期成本 |
3. 信号处理
| 步骤 |
说明 |
| 标准化 |
不同策略的信号量纲不同,先转换为横截面标准分数 |
| 转换为预期收益 |
常用近似:预期超额收益 ≈ IC × 波动率 × 标准分数(Grinold 的 alpha 公式),使信号与风险在同一量纲下比较 |
| 多信号合并 |
按各信号的信息比率加权;高度相关的信号先正交化或降权 |
| 期限对齐 |
不同信号的预测期限与衰减速度不同,按调仓周期折算 |
| 收缩 |
对极端值与低置信度信号向零收缩,降低估计误差对优化结果的影响 |
4. 风险模型
结构化多因子模型:
资产收益 r = X·f + u
协方差 Σ = X·F·Xᵀ + D
X:因子暴露(行业、风格) f:因子收益 u:特异收益
F:因子协方差矩阵 D:特异风险(对角矩阵)
| 环节 |
常用做法 |
| 因子暴露 |
行业哑变量 + 风格因子(市值、Beta、动量、波动率、价值、流动性等),横截面标准化 |
| 因子收益 |
每日横截面加权回归(常用市值平方根作权重) |
| 因子协方差 |
指数加权(半衰期),Newey-West 自相关调整,特征值调整,波动率状态调整 |
| 特异风险 |
时间序列估计后做贝叶斯收缩,处理新股与数据不足的股票 |
| 替代方案 |
统计因子模型(主成分分析);样本协方差的 Ledoit–Wolf 收缩 |
| 更新 |
每日收盘后更新,按时点保存历史版本,研究与生产使用同一套 |
风险模型的完整设计见第十七章。
5. 优化问题
maximize αᵀw − λ · wᵀΣw − 线性成本(Δw) − 冲击成本(Δw)
subject to 约束
Δw = w − w₀(本次调整量)
线性成本:费用与半价差,与 |Δw| 成正比
冲击成本:按平方根冲击法则,与 |Δw|^1.5 成正比,仍是凸函数
| 约束 |
例子 |
| 预算 |
权重和为 1(纯多头)或多空金额相等(市场中性) |
| 持仓上下限 |
纯多头不做空;单只股票权重上限 |
| 相对基准的暴露 |
行业偏离、风格偏离不超过设定值(指数增强) |
| 跟踪误差 |
预期跟踪误差不超过上限(指数增强) |
| 换手 |
单次换手不超过上限 |
| 流动性 |
单只股票的交易量不超过其日均成交量的一定比例 |
| 中性化 |
Beta 中性、行业中性 |
| 持股数量 |
持股只数上下限(引入整数变量,问题变为混合整数规划) |
| 问题类型 |
求解器 |
| 凸二次规划、二阶锥规划 |
OSQP、Clarabel、SCS;商业求解器 MOSEK、Gurobi |
| 混合整数问题(持股数量、最小交易单位) |
商业求解器,或先解松弛问题再启发式取整 |
6. 多期与换手
- 单期优化每次只看当前一期,容易在信号噪声驱动下频繁换手
- 多期优化同时规划未来多期的持仓路径,在 alpha 衰减与交易成本之间权衡(cvxportfolio 提供多期优化)
- 部分调仓:每次只向目标持仓移动一部分,偏离不大时不交易(无交易区间),Gârleanu 与 Pedersen(2013)的结论是目标应设在当前持仓与理想持仓之间
- 换手约束与成本项二选一或并用:约束限制上限,成本项让优化器自行权衡
7. 多策略合并
| 方式 |
做法 |
优点 |
缺点 |
| 持仓层合并 |
各策略各自优化,再把目标持仓相加 |
简单,策略之间解耦 |
失去轧差与统一风险控制;合并后可能违反整体约束 |
| 信号层合并 |
各策略的信号合并后统一优化 |
统一的风险预算与约束,内部轧差节省成本 |
策略之间耦合,归因更复杂 |
- 策略之间用风险预算分配(按目标波动率或风险平价)
- 合并后的交易按比例归属回各策略,用于各策略的绩效与成本归因
- 内部相反方向的需求先轧差,避免自成交(第九章第 8 节)
8. 从目标到订单
| 步骤 |
说明 |
| 权重转数量 |
目标数量 = 目标权重 × 组合净值 ÷ 价格 |
| 取整 |
按交易单位取整(A 股买入为 100 股整数倍),取整后重新检查约束 |
| 可交易性 |
停牌品种保持不变;涨停无法买入、跌停无法卖出时调整目标或顺延;当日买入的股票当日不能卖出 |
| 差额 |
目标 − 当前持仓 − 在途数量 |
| 生成母单 |
按信号衰减速度设定紧急程度与执行时长,交给执行引擎(第七章第 3 节) |
9. 运行与降级
| 要点 |
说明 |
| 运行方式 |
日频批量(收盘后计算,次日执行);日内周期运行;事件触发(信号大幅变化时) |
| 求解时间 |
设定时间上限;用上一次的解作为初始值加速求解 |
| 无可行解 |
约束分为硬约束与软约束,软约束以惩罚项进入目标函数;仍无解时按优先级逐级放宽并告警 |
| 失败时的行为 |
求解失败或输入异常时保持原目标持仓不交易,而不是输出一个未经验证的结果 |
10. 验证与监控
| 检查 |
说明 |
| 输出校验 |
权重和、各类暴露、换手、限制名单、单只股票上限,全部重新计算核对 |
| 与上一次比较 |
目标持仓变化异常大时要求人工确认 |
| 起作用的约束 |
记录哪些约束处于边界及其影子价格,判断约束是否过紧 |
| 事前与事后比较 |
预期风险与实际波动、预期跟踪误差与实际跟踪误差 |
| 归因 |
收益拆分为因子收益与特异收益,与运营域的归因衔接(第一章第 2.4 节) |
| 运行状态 |
求解器状态、求解时间、放宽约束的次数 |
11. 常见问题清单
| 问题 |
后果 |
对策 |
| 直接用样本协方差 |
估计误差被优化器放大,权重极端且不稳定 |
因子模型或收缩估计 |
| 信号未转换到收益量纲 |
风险惩罚与信号不可比,λ 难以设定 |
按 IC × 波动率 × 标准分数转换 |
| 忽略交易成本 |
优化器追逐微小的信号变化,换手过高 |
成本项进入目标函数 |
| 冲击成本用线性模型 |
大单成本被低估 |
使用 1.5 次方等凸的冲击模型 |
| 目标持仓未扣除在途数量 |
重复下单 |
差额计入在途 |
| 取整后不再检查约束 |
取整后违反约束 |
取整后重新校验 |
| 忽略涨跌停与停牌 |
目标无法实现,执行引擎反复尝试 |
可交易性约束 |
| 忽略 T+1 |
目标要求卖出当日买入的股票 |
可用持仓约束 |
| 无可行解时直接报错退出 |
当日无法调仓 |
软约束与逐级放宽 |
| 求解失败时输出未验证的结果 |
错误交易 |
失败时保持原持仓 |
| 各策略分别优化后直接相加 |
违反整体约束、产生对冲交易 |
信号层合并或合并后再校验 |
| 风险模型与回测使用不同版本 |
回测与实盘表现不一致 |
风险模型按时点版本化 |
| 持股数量约束用简单截断 |
截断后组合偏离最优且违反其他约束 |
混合整数求解或启发式后重新优化 |
| 流动性约束缺失 |
小盘股持仓过大,无法及时调整 |
按日均成交量限制交易量与持仓 |
12. 开源方案
| 项目 |
用途 |
| cvxpy + OSQP / Clarabel / SCS |
凸优化建模与求解 |
| cvxportfolio |
单期与多期组合优化,内置交易成本模型与回测 |
| Riskfolio-Lib |
多种风险度量下的组合优化 |
| PyPortfolioOpt |
均值方差、Black-Litterman、层次风险平价、Ledoit–Wolf 收缩 |
| scikit-learn |
协方差收缩估计(LedoitWolf 等) |
| Qlib |
EnhancedIndexingOptimizer:带换手上限、基准偏离、因子偏离约束的指数增强优化(qlib/contrib/strategy/optimizer/) |
| LEAN |
组合构建模型:等权、均值方差、Black-Litterman、风险平价、行业权重等(Algorithm.Framework/Portfolio/) |
多因子风险模型通常自建:开源领域没有成熟的 Barra 类实现。
七、执行引擎 EMS 的设计与实现
执行引擎(Execution Management System,EMS)把"要买卖多少"变成"何时、在哪里、以什么价格、分几笔买卖":接收目标持仓与当前持仓之差形成的母单,用执行算法拆成子单,管理子单的挂撤,在约束下使执行成本最小。本章给出 EMS 的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 母单管理:接收、调整、暂停、撤销 |
决定买卖什么、买卖多少(策略、组合构建) |
| 执行算法:TWAP、VWAP、POV、IS 等 |
放行或拒绝子单(事前风控,第八章) |
| 子单决策:类型、价格、数量、时机、挂撤 |
订单与持仓的最终真相(OMS,第九章) |
| 智能路由:多交易所、多通道的分配 |
交易所协议(交易网关) |
| 执行过程监控与实时调整;为 TCA 提供数据 |
|
在交易域中的位置(第一章第 1 节):组合构建 → 执行引擎 → 事前风控 → OMS → 交易网关。高频做市策略通常不经过 EMS,由策略直接生成订单。
买方机构的术语中,OMS 侧重订单、持仓与合规,EMS 侧重执行与市场接入;本文的 OMS 与 EMS 按此划分。
2. 执行成本
执行成本以实施缺口(Implementation Shortfall,Perold 1988)衡量:决策时的价格与最终实际成交结果之差。
| 成本项 |
含义 |
| 延迟成本 |
从做出决策到母单开始执行之间的价格变化 |
| 价差成本 |
主动成交时支付的半个买卖价差 |
| 市场冲击 |
自身交易推动价格的幅度;分为执行结束后回落的临时冲击与不回落的永久冲击 |
| 择时风险 |
执行过程中市场价格的波动 |
| 机会成本 |
未能成交部分在执行结束后的价格变化 |
| 显性费用 |
佣金、印花税、交易所费用 |
核心取舍:执行得快,市场冲击大;执行得慢,择时风险与机会成本大。Almgren–Chriss(2000)在二者之间给出最优执行轨迹:风险厌恶越强,轨迹越前倾(量化交易系统_qa.md 第 14 题的强化学习例子即其离散版本)。
市场冲击的常用经验模型是平方根法则:
冲击 ≈ c · σ · √(Q / V)
σ:日波动率 Q:执行数量 V:日成交量 c:经验系数(按市场与品种标定)
执行量占成交量的比例越大,单位冲击越高,这也决定了策略的资金容量。
3. 母单与子单
母单字段:品种、方向、目标数量、开始与截止时间、算法与参数(参与率上限、价格限制、紧急程度)、评估基准(到达价、VWAP、收盘价)、所属策略与账户。
stateDiagram-v2
[*] --> Pending: 创建
Pending --> Working: 到达开始时间
Working --> Paused: 价格越限 / 人工暂停 / 品种停牌
Paused --> Working: 恢复
Working --> Completed: 全部成交
Working --> Canceled: 撤销(撤回全部在途子单)
Working --> Expired: 到达截止时间(剩余部分按配置撤销或转交)
Paused --> Canceled: 撤销
Completed --> [*]
Canceled --> [*]
Expired --> [*]
| 要点 |
说明 |
| 母单来源 |
目标持仓 − 当前持仓 − 在途数量;多策略对同一品种的母单先在母单层轧差,避免一边买一边卖 |
| 目标变更 |
执行中收到新目标时增量调整母单数量与截止时间,不撤销重来,以保留已挂子单的排队位置 |
| 核心不变量 |
已成交 + 在途子单数量 + 待撤子单数量 ≤ 母单数量,任何时刻成立,防止超额成交 |
| 子单状态 |
来自 OMS 事件;母单撤销时撤回全部在途子单,并等待撤单确认后才进入终态 |
4. 执行算法
| 算法 |
目标 |
做法 |
适用 |
| TWAP |
按时间均匀执行 |
执行期切分为等长时间片,每片执行等量;加入随机扰动 |
成交量分布不明显的品种;简单可预期 |
| VWAP |
成交均价贴近市场成交量加权均价 |
按预测的日内成交量曲线分配各时间片的数量 |
以 VWAP 为考核基准的大单 |
| POV(参与率) |
按市场成交量的固定比例执行 |
实时统计市场成交量,按比例跟随;设置参与率上限 |
不确定执行时长、希望随流动性自适应 |
| IS(到达价) |
最小化实施缺口 |
按 Almgren–Chriss 类模型生成前倾轨迹,结合实时价格调整紧急程度 |
有短期 alpha、担心价格跑掉的订单 |
| 收盘 |
贴近收盘价 |
大部分数量参与收盘集合竞价(上交所、深交所均有收盘集合竞价) |
以收盘价为基准的指数基金调仓 |
| 冰山 |
隐藏真实数量 |
每次只显示一小部分,成交后补充 |
大单、流动性一般的品种 |
| 流动性寻找 |
快速获取流动性 |
多价位、多交易所、暗池同时寻找对手方 |
紧急、大额 |
成交量曲线预测(VWAP、POV 的基础):以历史同时段的平均成交量占比为基础,区分星期效应与特殊日(指数调整日、期货交割日、半日交易日、重大数据发布日),盘中按已实现成交量滚动修正。
5. 子单决策
每个时间片内,EMS 要决定每笔子单怎么下:
| 决策 |
选项与依据 |
| 被动还是主动 |
被动挂单节省价差但可能不成交、并承受逆向选择;主动吃单确定成交但支付价差。依据进度偏差与短期价格信号(订单簿不平衡、第十一章第 13 节)选择 |
| 挂单价位 |
排在最优价、改善一个价位、或挂在更深档位 |
| 子单数量 |
相对盘口显示量不宜过大,避免暴露意图 |
| 时机 |
随机化下单时间与数量,避免形成可识别的规律(算法指纹) |
| 撤单重挂 |
挂单价格偏离最优价超过阈值、挂单时间超过上限、排队位置过于靠后时撤单重挂;设置最小挂单时间,避免频繁撤单 |
| 进度追赶 |
落后进度时逐级提高激进程度(改善价位 → 吃一档 → 吃多档),设置追赶上限;超前时放慢 |
| 价格限制 |
母单的价格上下限对每笔子单生效,越限时暂停而不是继续追价 |
6. 智能路由(SOR)
同一品种在多个交易场所交易时,路由决定子单发往哪里:
| 因素 |
说明 |
| 价格 |
优先最优报价;美国股票受 Reg NMS 订单保护规则约束,不能以劣于全国最优报价的价格成交 |
| 费用 |
maker-taker 费率下,挂单可能得到返佣、吃单需要付费,净价格才是比较依据 |
| 成交概率 |
各场所的历史成交率、隐藏流动性 |
| 延迟 |
同时向多个场所扫单时,按各自的网络延迟错开发送时间,使订单几乎同时到达,避免先到的订单暴露意图后被其他参与者抢先(RBC 的 THOR 系统即此思路) |
| 暗池 |
可以设置最小成交量,减少信息泄露 |
| 市场 |
路由的形态 |
| 美股 |
十余家交易所加暗池,路由是 EMS 的核心能力 |
| 加密货币 |
多交易所,各交易所需预先存放资金或保证金;路由同时考虑余额与资金调拨 |
| A 股 |
同一品种只在一家交易所交易,路由主要体现为在多个券商通道、多个账户之间分配 |
7. 约束与合规
| 约束 |
处理 |
| 涨跌停 |
买入挂在涨停价、卖出挂在跌停价时成交依赖排队,追价没有意义;进入涨跌停后暂停或转为排队模式 |
| 交易单位 |
A 股买入须为 100 股整数倍,卖出时不足 100 股的零股可一次性卖出;期货按手 |
| T+1 与可用持仓 |
当日买入的股票当日不可卖出(第九章第 5 节) |
| 交易时段 |
开盘与收盘集合竞价的订单类型与撤单限制、午间休市、夜盘 |
| 报单频率与报撤比 |
子单撤单重挂受交易所异常交易监控约束 |
| 市场影响合规 |
参与率过高、短时间大量主动成交可能被认定为拉抬或打压,参与率上限兼具合规意义 |
| 大额交易 |
超出市场承受能力的数量,考虑大宗交易等场外方式 |
8. 执行监控与调整
| 监控项 |
触发的动作 |
| 进度偏差(实际成交 vs 计划) |
调整激进程度 |
| 实时滑点(成交均价 vs 基准) |
超出阈值告警 |
| 价格越过母单限制 |
暂停 |
| 成交量异常放大或萎缩 |
调整参与率与计划 |
| 品种停牌、熔断、临时停市 |
暂停并撤回子单 |
| 距截止时间不足且剩余量大 |
提前预警,由交易员或规则决定加速、延期或放弃 |
大额母单通常配有交易员监控界面,可人工暂停、调整参数、接管。
9. 与风控、OMS 的交互
| 要点 |
说明 |
| 母单级预检 |
母单创建时整体检查资金、持仓限额,并预留额度,避免执行到一半因额度不足停止 |
| 子单逐笔过风控 |
每笔子单都经过事前风控;EMS 不能绕过风控 |
| 拒单处理 |
按拒绝原因分类处理:频率类拒单退避后重试;限额类拒单暂停母单并告警;不能立即原样重试 |
| 状态来源 |
子单状态以 OMS 事件为准;EMS 维护的母单进度由 OMS 的成交事件驱动 |
10. 执行质量评估(TCA)
| 阶段 |
内容 |
| 事前 |
根据数量、波动率、成交量预估成本,选择算法与执行时长 |
| 事中 |
实时滑点与进度 |
| 事后 |
实施缺口分解;与到达价、VWAP、收盘价等基准比较;成交后价格走势(markout:成交后 1 秒、10 秒、1 分钟的价格变化),持续为负说明被动成交遭受逆向选择 |
评估基准要与算法目标一致:用 VWAP 基准评估 IS 算法、用到达价评估 VWAP 算法都会得出误导性的结论。TCA 结果反馈到算法参数与成交量模型的标定。TCA 的完整设计见第二十章。
11. 测试与迭代
| 手段 |
说明 |
| 执行回测 |
需要市场冲击与排队模型(第一章第 3 节 ⑦),高频执行使用历史订单簿重放 |
| 仿真与影子模式 |
新算法先在仿真环境运行,再以影子模式计算决策但不下单 |
| 线上 A/B 测试 |
把同类母单随机分配给两个算法版本,比较实施缺口,控制样本量与市场环境差异 |
12. 常见问题清单
| 问题 |
后果 |
对策 |
| 撤单重挂时未计入待撤子单 |
旧子单在撤单确认前成交,新子单也成交,超额成交 |
维护"已成交 + 在途 + 待撤 ≤ 母单数量"不变量 |
| 频繁撤单重挂 |
报撤比超限、排队位置反复丢失 |
改价阈值、最小挂单时间 |
| 落后进度时一次性市价追赶 |
冲击巨大 |
逐级提高激进程度,设置追赶上限 |
| 固定间隔、固定数量下单 |
被其他参与者识别并抢先交易 |
时间与数量随机化 |
| VWAP 曲线忽略特殊交易日 |
指数调整日、交割日严重偏离基准 |
事件日单独建模 |
| 参与率计算包含自身成交 |
自身成交推高市场成交量,参与率自我强化 |
市场成交量剔除自身成交 |
| 涨跌停时继续追价 |
无法成交,徒增撤单 |
感知涨跌停状态,暂停或排队 |
| 忽略交易单位与零股规则 |
子单被拒 |
子单数量按规则取整 |
| 临近截止才发现剩余大量未成交 |
尾盘被迫大额主动成交 |
进度监控与截止前预警 |
| 子单被拒立即原样重试 |
拒单风暴,触发风控或交易所限制 |
按原因分类,退避重试 |
| 多策略对同一品种反向执行 |
相互成交或无谓的费用与冲击 |
母单层轧差 |
| 目标变更时撤销全部重来 |
丢失排队位置,重复承担成本 |
增量调整母单 |
| 路由只看报价不看费用与成交概率 |
实际成本更高 |
按净价格与成交概率路由 |
| 多交易所扫单到达时间不一致 |
被延迟套利者抢先 |
按延迟错开发送,同步到达 |
| 策略回测忽略执行成本与冲击 |
高估策略收益与资金容量 |
回测使用冲击模型,并以 TCA 结果校准 |
| TCA 基准与算法目标不一致 |
误判算法优劣 |
按算法目标选择基准 |
13. 开源实现
| 项目 |
实现 |
说明 |
| vnpy_algotrading |
TWAP、冰山(Iceberg)、狙击手(Sniper)、条件单(Stop)、最优限价(BestLimit)五种算法(vnpy_algotrading/algos/) |
国内期货与股票的算法交易模块 |
| LEAN |
执行模型:VolumeWeightedAveragePriceExecutionModel、StandardDeviationExecutionModel、SpreadExecutionModel(Algorithm.Framework/Execution/) |
执行作为 Algorithm Framework 的独立模块,可替换 |
| Hummingbot |
Strategy V2 的 Executor:TWAP、仓位、定投(DCA)、网格、套利、跨交易所做市(XEMM)、流动性提供等(hummingbot/strategy_v2/executors/) |
把执行逻辑封装为可复用的执行器 |
| NautilusTrader |
执行算法组件(ExecAlgorithm),与策略分离注册 |
母单与子单的关系由框架跟踪 |
| tcapy |
事后 TCA(外汇现货为主) |
执行质量评估 |
八、事前风控的设计与实现
事前风控(pre-trade risk control)在每一笔订单离开系统之前做检查,有权拒绝任何订单,并能在异常时熔断。它是防止单个缺陷演变为巨额损失的最后一道自有防线。本章给出其系统设计与常见问题。
1. 职责与边界
| 阶段 |
做什么 |
时机 |
| 事前风控(本章) |
逐笔检查订单,放行或拒绝;触发熔断 |
订单发出前,同步、阻塞式 |
| 事中风控 |
实时监控持仓、盈亏、敞口、下单行为,接近或突破限额时告警、降级、熔断 |
盘中持续,异步 |
| 事后风控 |
分析风险暴露、限额使用、异常交易,调整限额 |
盘后 |
| 事前风控负责 |
不负责 |
| 订单级、频率类、持仓敞口类、资金类、损失类、合规类、市场状态类检查(第 3 节) |
持仓与订单的最终真相(OMS,第九章) |
| 限额体系与配置管理 |
策略是否赚钱 |
| 熔断:停止下单、批量撤单 |
执行路径选择(执行引擎) |
| 拒单记录与审计 |
|
监管依据:美国 SEC Rule 15c3-5(Market Access Rule)要求提供市场接入的券商实施事前风控;欧盟 MiFID II RTS 6 要求算法交易具备价格区间、最大订单金额与数量、最大报单频率等事前控制与熔断能力;国内证监会《证券市场程序化交易管理规定(试行)》(2024 年施行)对程序化交易的报告与监控提出了要求。
2. 位置、部署形态与纵深防御
在交易域中的位置:执行引擎 → 事前风控 → OMS → 交易网关(第一章第 1 节)。风控依赖 OMS 发布的持仓、挂单、成交事件维护自身状态。
多层防线:
flowchart LR
S[策略自检<br/>策略代码内] --> E[引擎内检查<br/>策略引擎:在途与敞口]
E --> R[事前风控<br/>独立模块 / 独立进程]
R --> B[券商柜台风控<br/>资金、持仓、合规]
B --> X[交易所风控<br/>价格笼子、涨跌停、信用额度、熔断开关]
每一层都不假设上一层正确。自有系统中的事前风控是自身能控制的最后一层;券商与交易所的风控是兜底,不能替代自有风控。
部署形态:
| 形态 |
延迟量级(未实测) |
适用 |
独立性保障 |
| 内联库 |
纳秒级 |
高频:在交易进程内、发单前同步调用 |
由独立团队开发与发布;限额配置由风控系统下发,策略无权修改;发往交易所的唯一代码路径必须经过风控函数 |
| 同机独立进程 |
微秒级(一次 IPC) |
中频 |
进程隔离,策略进程崩溃或失控不影响风控 |
| 中心服务 |
毫秒级 |
低频、全公司汇总 |
独立部署与权限 |
| 全局监视进程 |
异步 |
所有频率:读取 drop copy 与 OMS 事件,发现异常即熔断 |
不依赖交易进程自身,交易进程卡死时仍能动作 |
高频系统通常组合使用:内联库做逐笔检查,另一台机器上的全局监视进程做兜底熔断(第一章第 6.2 节的外部看门狗)。
3. 检查项
| 类别 |
检查 |
说明 |
| 订单合法性 |
品种存在、价格为最小变动价位整数倍、在涨跌停范围内、数量符合交易单位与交易所单笔上限、订单类型与有效期受支持 |
不合法的订单即使发出也会被拒,但会计入报撤单与异常交易统计 |
| 单笔规模 |
单笔最大数量、单笔最大金额(名义价值) |
防止"胖手指"与单位错误 |
| 价格偏离 |
限价与参考价的偏离不超过 x% 或 n 个价位;市价单转为带保护价的限价单 |
参考价的来源见第 4 节 |
| 报单频率 |
每秒报单数、改单数、撤单数(令牌桶);按策略、账户、会话分别限制 |
交易所对报单频率有上限,超限可能被断开会话或处罚 |
| 重复报单 |
短时间内同一品种、方向、价格、数量完全相同的订单反复出现 |
识别程序死循环 |
| 报撤比与撤单率 |
当日撤单数 / 报单数、单位时间撤单数 |
交易所异常交易监控的常见指标 |
| 活动委托数 |
同时处于未成交状态的订单总数 |
限制挂单规模与交易所资源占用 |
| 持仓上限 |
单品种多头、空头上限,按最坏情况计算(第 5 节) |
包括交易所持仓限额 |
| 敞口 |
净敞口、总敞口、行业与板块集中度、杠杆率;期权的 Delta、Gamma、Vega 限额 |
组合层面的风险 |
| 资金 |
可用资金、购买力、保证金占用率 |
与 OMS 冻结逻辑一致(第九章第 5 节) |
| 损失 |
日内最大亏损(已实现 + 浮动)、日内高点回撤、单策略亏损 |
触发后降级为只减仓或熔断 |
| 合规 |
自成交防范;禁止交易名单;卖空规则(A 股融券、美国 Reg SHO 的借券要求);A 股持股 5% 等信息披露触发线 |
违规成本是监管处罚 |
| 市场状态 |
非交易时段、集合竞价阶段的订单类型限制、停牌与熔断品种、行情过期 |
行情过期时价格偏离检查失去依据 |
限额分为两级:软限额(达到 80% 等阈值时告警)与硬限额(拒单)。
4. 参考价
价格偏离检查依赖参考价,参考价错误会使检查失效或产生大量误拒:
| 要点 |
说明 |
| 来源独立 |
参考价来自独立的行情(最新成交价、中间价、昨收、结算价、理论价),不能来自策略自身的公允价,否则策略的错误会同时污染检查依据 |
| 过期处理 |
参考价超过阈值时间未更新时,拒绝依赖它的订单或切换到更宽的偏离带并告警 |
| 流动性差的品种 |
最新成交价可能很陈旧,使用中间价或理论价,并放宽偏离带 |
| 集合竞价 |
连续竞价的参考价在竞价阶段不适用,使用昨收或交易所发布的参考价 |
| 与交易所规则对齐 |
交易所自身的价格限制(如 A 股的价格笼子、涨跌停)作为内部检查的外边界,内部偏离带应更严 |
5. 风控状态:按最坏情况计算
风控需要持仓、挂单、在途、当日成交、盈亏等状态,来源是 OMS 事件;内联部署时,风控在放行订单的同一时刻就把它计入在途,而不是等 OMS 回报。
最坏情况:检查多头上限时,假设所有在途与挂单的买单全部成交、所有卖单都不成交;检查空头上限时相反。
多头最坏持仓 = 当前持仓 + 所有未完成买单数量
空头最坏持仓 = 当前持仓 − 所有未完成卖单数量
买单与卖单不能相互抵消:两边可能同时成交,也可能只有一边成交。待撤订单在撤单确认前仍按未完成计算。
与 OMS 的一致性:风控状态与 OMS 状态定期核对;出现差异时采用更保守的一方,并告警。
6. 限额体系
| 要点 |
说明 |
| 层级 |
公司 → 账户 → 策略 → 品种;一笔订单必须通过所有层级的检查 |
| 跨机分配 |
公司级额度预先切片分配给各机房,本地检查、中心定期汇总与再分配(第一章第 6.1 节) |
| 配置管理 |
版本化;变更需双人复核(四眼原则);变更前做合理性检查(例如新限额与旧限额相差超过 10 倍时要求额外确认);变更作为事件写入事件日志,在某个序号生效 |
| 临时调整 |
盘中临时放宽必须带到期时间,到期自动恢复 |
| 单位 |
限额明确单位:股、手、合约张数、名义金额、币种;期货与期权按合约乘数换算名义价值 |
7. 熔断
| 要点 |
说明 |
| 层级 |
全局、账户、策略、品种四级 |
| 动作 |
① 停止新开仓(进入只减仓);② 停止一切新订单;③ 批量撤销挂单;④ 平仓。前三项可自动执行,平仓通常需人工决定,因为自动平仓在异常行情下可能扩大损失 |
| 自动触发 |
亏损超限、报单频率异常、重复报单、持仓与交易所不一致、行情中断、心跳丢失 |
| 人工触发 |
独立通道:不经过交易进程的界面与网络路径,交易进程卡死时仍然有效 |
| 独立执行路径 |
通过独立会话向交易所发送批量撤单;或断开交易会话,依靠交易所的断线撤单;或使用交易所提供的熔断开关(如 CME Globex 的 Kill Switch) |
| 恢复 |
只能人工解除,并按检查清单确认原因已排除、状态已对账 |
"只减仓"必须严格定义:只放行在最坏情况下使持仓绝对值不增加的订单,防止平仓单因数量错误变成反向开仓。
8. 实现要点
| 要点 |
说明 |
| 预计算 |
参考价更新时预先算好价格上下界,订单到来时只做整数比较;限额按最小单位存为整数 |
| 数据结构 |
令牌桶、计数器、按品种索引的持仓与在途表,全部预分配;单写者,无锁 |
| 检查顺序 |
所有强制检查都要执行;为降低平均延迟,可先执行最便宜、最常拒单的检查,但不能因为某项通过而跳过其他项 |
| 不可绕过 |
发往交易所的唯一代码路径经过风控:交易网关只接受风控签发的订单对象;新订单、改单、撤单后重报都要检查,改价与改量可能突破限额 |
| 失败即拒绝(fail-closed) |
风控未启动、配置加载失败、参考价缺失、状态不确定时拒绝一切新开仓订单 |
| 延迟预算 |
风控检查的耗时纳入热路径延迟预算并持续监控(第十一章第 7 节) |
9. 测试与验证
| 手段 |
做法 |
| 规则测试 |
每条规则按边界值测试:恰好等于限额、超过一个单位、单位换算、多空方向 |
| 影子模式 |
新版本风控规则与线上版本并行运行,只记录决策不生效,比较两者的放行与拒绝差异后再切换 |
| 生产日志重放 |
用历史事件日志重放新规则,确认不会误拒正常订单 |
| 失控演练 |
在仿真环境中注入失控策略(死循环下单、错误单位、反向下单),验证熔断在预期时间内生效 |
| 开盘前检查 |
确认风控进程在线、配置版本正确、参考价在更新、熔断通道可用 |
10. 监控与审计
- 每次拒单记录规则编号、检查值、限额、当时的持仓与在途快照、订单来源(策略 ID 与版本)
- 指标:各规则的拒单率、限额使用率、接近限额的告警次数、风控检查延迟分布
- 拒单率突然升高往往意味着策略或行情出了问题,本身就是告警信号
- 限额配置、熔断触发与解除全部留痕,按监管要求保留
11. 事故案例
| 事件 |
经过 |
暴露的问题 |
| Knight Capital(2012-08-01) |
部署时一台服务器未更新代码,旧功能被意外激活,约 45 分钟内向市场发出大量订单,损失超过 4.6 亿美元;SEC 于 2013 年以违反 Market Access Rule 处罚 |
缺少针对订单数量与持仓的自动熔断;部署与发布流程缺陷 |
| 光大证券(2013-08-16) |
策略交易系统缺陷导致大量错误申购 ETF 成分股,申报金额约 234 亿元,成交约 72.7 亿元 |
风控模块未能拦截异常订单;系统未经充分测试即上线 |
两起事件的共同点:错误订单在短时间内大量发出,而资金、持仓、频率类的硬限额与自动熔断没有起作用。
12. 常见问题清单
| 问题 |
后果 |
对策 |
| 持仓检查只看已成交持仓 |
在途订单成交后超限 |
按最坏情况计入在途与挂单(第 5 节) |
| 买卖挂单相互抵消 |
最坏情况被低估 |
多头、空头分别按最坏情况计算 |
| 只检查新订单,不检查改单与撤单重报 |
改价、改量突破限额 |
所有发往交易所的订单操作都经过风控 |
| 存在绕过风控的路径 |
人工下单工具、应急脚本、测试代码直连网关 |
网关只接受风控签发的订单;定期审查所有能发单的程序 |
| 参考价来自策略自身或已过期 |
价格偏离检查失效 |
独立行情,检查时效性 |
| 单位错误(股与手、合约乘数、价格精度) |
限额被放大或缩小数十倍至上百倍 |
限额注明单位,名义价值按乘数换算,配置合理性检查 |
| 限额配置多写或少写一个 0 |
限额失效或全部误拒 |
双人复核,变更幅度过大时额外确认 |
| 风控默认放行(fail-open) |
风控故障时订单不受控制 |
失败即拒绝 |
| 熔断依赖交易进程自身 |
进程卡死时无法熔断 |
独立通道:独立会话批量撤单、断线撤单、交易所熔断开关 |
| 日内亏损只计已实现盈亏 |
浮亏巨大仍继续开仓 |
已实现与浮动盈亏合并计算 |
| 只限制每秒报单总数 |
程序循环以不高的频率持续下单时不被识别 |
增加重复报单检测、单位时间持仓增量检测 |
| 重启后风控状态从零开始 |
当日已用额度、报撤单计数丢失 |
从事件日志与 OMS 恢复风控状态后才放行订单 |
| 多机房各自使用全公司限额 |
总敞口成倍超限 |
额度切片分配 |
| "只减仓"定义不严 |
平仓单数量错误导致反向开仓 |
只放行最坏情况下持仓绝对值不增加的订单 |
| 限额变更不写事件日志 |
重放与审计时无法还原当时的限额 |
限额变更作为事件,在某个序号生效 |
| 风控与策略由同一团队维护且同步发布 |
策略缺陷与风控缺陷同时发生 |
独立团队、独立代码库、独立发布 |
13. 开源实现
| 项目 |
实现 |
说明 |
| NautilusTrader RiskEngine |
下单与改单频率限制、单笔最大名义金额;交易状态分 Active、Reducing(只减仓)、Halted(停止交易)三种(crates/risk/src/engine/、crates/model/src/enums.rs) |
交易状态的三分法可直接借鉴 |
| vnpy_riskmanager |
内置规则:活动委托数上限(ActiveOrderRule)、全天委托与撤单笔数(DailyLimitRule)、重复报单检测(DuplicateOrderRule)、单笔数量上限(OrderSizeRule)、委托合法性(OrderValidityRule:合约存在、价格为最小变动价位整数倍、不超过交易所单笔上限);规则用 Cython 编译 |
国内期货场景的规则集参考 |
| LEAN RiskManagementModel |
如 MaximumDrawdownPercentPerSecurity,在组合层面调整目标持仓 |
属于策略框架内的风险管理,不是独立的逐笔事前风控 |
九、OMS 的设计与实现
OMS(Order Management System,订单管理系统)是订单、持仓与资金的唯一真相来源:接收经风控放行的订单,交给交易网关发出,处理交易所回报,维护订单状态、持仓与资金,并负责持久化、恢复与对账。本章给出 OMS 的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 订单全生命周期:创建、发送、确认、成交、改单、撤单、终结 |
产生交易信号(策略引擎) |
| 执行回报处理:去重、乱序、缺失检测 |
拆单算法与路由决策(执行引擎 EMS) |
| 持仓、资金、冻结、保证金、盈亏的计算 |
放行或拒绝订单(事前风控;风控依赖 OMS 发布的持仓与挂单事件) |
| 持久化、重启恢复、与交易所 / 券商对账 |
交易所协议细节(交易网关) |
| 批量撤单等运维接口;审计记录 |
|
在架构中的位置(第一章第 1 节):执行引擎 → 事前风控 → OMS → 交易网关 → 交易所;回报经交易网关回到 OMS,OMS 再把订单、成交、持仓事件发布到事件总线。
2. 数据模型
| 实体 |
关键字段 |
说明 |
| 订单 |
客户订单号、交易所订单号、母单号、策略 ID、账户、品种、方向、开平标志、类型、有效期(TIF)、价格、数量、已成交量、剩余量、成交均价、状态、各阶段时间戳 |
剩余量 = 数量 − 已成交量(终态时为 0) |
| 成交 |
成交编号(ExecID)、订单号、成交量、成交价、手续费、流动性标志(挂单方 / 吃单方)、交易所时间 |
成交编号全局唯一,用于去重 |
| 持仓 |
账户 × 品种(× 多空方向 × 今昨)、数量、可用数量、冻结数量、持仓成本、已实现盈亏 |
结构随市场规则变化(第 5 节) |
| 资金 |
余额、冻结资金、占用保证金、可用资金、手续费累计 |
可用 = 余额 − 冻结 − 保证金 |
| 订单链 |
母单 → 子单 → 改单链(OrigClOrdID → ClOrdID) |
审计与归因需要完整血缘 |
标识符的设计:
| 标识符 |
由谁生成 |
要求 |
| 客户订单号(ClOrdID) |
OMS |
确定且全局唯一(如"节点 epoch + 序号"),重发时可被识别为同一笔;不使用随机数,保证主备与重放生成相同编号(量化交易系统_qa.md 第 1 题) |
| 原订单号(OrigClOrdID) |
OMS |
改单、撤单时指向被操作的订单;FIX 的改单会生成新 ClOrdID,形成订单链 |
| 交易所订单号 |
交易所 |
确认回报中返回;部分交易所的撤单只接受交易所订单号 |
| 成交编号(ExecID) |
交易所 |
成交去重的唯一依据 |
3. 订单状态机
stateDiagram-v2
[*] --> PendingNew: 发送
PendingNew --> Accepted: 确认
PendingNew --> Rejected: 拒单
PendingNew --> PartiallyFilled: 未确认先成交(隐式确认)
PendingNew --> Filled: 未确认先全部成交
Accepted --> PartiallyFilled: 部分成交
Accepted --> Filled: 全部成交
PartiallyFilled --> PartiallyFilled: 继续成交
PartiallyFilled --> Filled: 全部成交
Accepted --> PendingCancel: 发出撤单
PartiallyFilled --> PendingCancel: 发出撤单
PendingCancel --> Canceled: 撤单确认
PendingCancel --> Filled: 撤单确认前已全部成交
PendingCancel --> Accepted: 撤单被拒(未成交)
PendingCancel --> PartiallyFilled: 撤单被拒(已部分成交)
Accepted --> PendingReplace: 发出改单
PendingReplace --> Accepted: 改单确认
Accepted --> Expired: 有效期结束
PartiallyFilled --> Expired: 有效期结束
Rejected --> [*]
Canceled --> [*]
Filled --> [*]
Expired --> [*]
状态机规则:
| 规则 |
说明 |
| 终态不可离开 |
Filled、Canceled、Rejected、Expired 为终态;终态后再收到成交属于异常,触发对账 |
| 状态只前进不回退 |
迟到的确认回报不能把 PartiallyFilled 改回 Accepted |
| 撤单中仍可成交 |
PendingCancel 期间收到的成交正常入账;撤单确认前,剩余量继续计入敞口与冻结 |
| 撤单被拒要回退 |
撤单被拒(例如"太晚,已成交")时回到发出撤单前的状态;若订单已进入终态则忽略 |
| 超时即"状态未知" |
发送后超时未收到任何回报,订单视为可能已成交,继续计入敞口,并主动查询;不能默认失败而重发 |
| 改单的语义因交易所而异 |
有的交易所改价后失去时间优先级,有的不支持改单只能撤单重报;数量减少到不超过已成交量的改单会被拒绝 |
4. 执行回报处理
flowchart LR
A[交易网关<br/>解析协议回报] --> B[归一化<br/>统一回报结构]
B --> C{成交编号<br/>已处理?}
C -- 是 --> X[丢弃重复回报]
C -- 否 --> D{找到订单?}
D -- 否 --> Y[登记外部订单并告警]
D -- 是 --> E[校验状态转换]
E --> F[核对累计成交量]
F --> G[更新订单]
G --> H[更新持仓与资金]
H --> I[写入事件日志]
I --> J[发布事件<br/>订单 / 成交 / 持仓]
| 情况 |
处理 |
| 成交先于确认 |
视为隐式确认,直接进入 PartiallyFilled 或 Filled |
| 迟到的确认 |
当前状态已超过 Accepted 时忽略,不回退 |
| 重复回报 |
按成交编号去重;网关重连后的回报重传会大量产生重复 |
| 撤单途中成交 |
正常入账;全部成交时进入 Filled,撤单请求自然失效 |
| 撤单被拒 |
回退到撤单前状态;订单已终态时忽略 |
| 累计成交量缺口 |
回报通常同时携带本次成交量与累计成交量;累计量大于"本地已成交量 + 本次成交量"说明漏掉了成交回报,按本次成交量入账并标记订单待对账,不猜测缺失成交的价格 |
| 未知订单的回报 |
来自其他系统、人工下单,或崩溃时丢失的本地订单;登记为外部订单并告警,持仓以交易所为准 |
| 终态后收到成交 |
异常,触发对账,必要时暂停相关账户的交易 |
处理顺序的原则:先写事件日志,再对外发布。回报一旦被消费并发布,就必须能从日志中重放出来。
5. 持仓与资金
冻结规则:
| 操作 |
冻结 |
释放 |
| 买单(现金账户) |
价格 × 数量 + 预估手续费 |
成交时按冻结价格释放并按成交价扣款;撤单或终态时释放剩余部分 |
| 市价买单 |
按涨停价或保护限价冻结(以柜台规则为准) |
同上 |
| 卖单(现货) |
冻结对应的可用持仓 |
成交时扣减持仓;撤单时释放 |
| 期货开仓 |
冻结保证金 |
成交后转为占用保证金;撤单时释放 |
冻结的目的是让事前风控和策略看到真实的可用资金与可用持仓,防止挂单期间重复使用同一份资金或持仓。
市场规则对持仓模型的影响:
| 市场 |
规则 |
持仓模型的要求 |
| A 股 |
T+1:当日买入次日才能卖出 |
区分总持仓与可用持仓;每日开盘前把昨日买入转为可用 |
| A 股融资融券 |
信用账户、担保品、负债 |
负债与担保比例单独核算 |
| 国内期货 |
多空双向持仓,开平需指定;上期所、上期能源区分平今与平昨,手续费可能不同 |
多空分开记录,每个方向再分今仓与昨仓;下单时由 OMS 把"平仓"转换为平今 / 平昨 |
| 期货通用 |
逐日盯市:每日按结算价结算盈亏 |
持仓成本分为开仓价与结算价两种口径 |
| 加密货币永续合约 |
资金费率定期收付;单向持仓与双向持仓两种模式 |
资金费作为独立的资金流水;按账户模式建模持仓 |
| 期权 |
行权、指派、到期 |
到期日批量处理持仓转换 |
| 股票公司行为 |
拆股、送转、分红 |
盘后批量调整持仓与成本,并记录调整事件 |
盈亏:已实现盈亏在平仓时确认;浮动盈亏按标记价格(中间价、最新价或结算价,按用途选择)实时计算;手续费、资金费、融资利息单独归集,便于归因。
6. 持久化与启动恢复
OMS 采用事件溯源:所有命令(报单、撤单、改单)与回报先写入事件日志,状态由日志推导(第一章第 3 节 ②、第四章第 4 节)。启动或故障切换时的恢复流程:
flowchart TB
S1[加载最近快照] --> S2[重放快照之后的事件日志]
S2 --> S3[连接交易网关<br/>此时禁止交易]
S3 --> S4[查询交易所 / 券商:<br/>挂单、当日成交、持仓、资金]
S4 --> S5{与本地状态一致?}
S5 -- 是 --> S7[开放交易]
S5 -- 否 --> S6[补录缺失成交与外部订单<br/>差异超过阈值则告警并等待人工确认]
S6 --> S7
| 要点 |
说明 |
| 查询接口 |
FIX 用 OrderMassStatusRequest 查询全部挂单状态;国内柜台通过查询委托、查询成交、查询持仓、查询资金接口 |
| 恢复期间禁止交易 |
状态核对一致之前,任何新订单都被拒绝 |
| 以交易所为准 |
本地与交易所不一致时,以交易所的成交与持仓为准修正本地状态,并留下修正记录 |
| 主备切换 |
新主节点带新任期号,先对账再交易(第一章第 6 节) |
7. 对账
| 维度 |
做法 |
| 数据源 |
三方比对:OMS 状态、交易所 / 券商回报与结算单、drop copy(交易所独立推送的成交副本) |
| 频率 |
盘中定时对账(分钟级)+ 日终与结算单对账 |
| 差异类型 |
缺失成交、多出成交、数量或价格不一致、手续费不一致、公司行为未处理 |
| 处理 |
可自动修正的差异按规则修正并记录;持仓差异超过阈值时暂停相关账户或策略的交易(fail-closed),人工确认后恢复 |
对账子系统的完整设计见第十八章。
8. 多策略共享账户
| 问题 |
做法 |
| 持仓归属 |
每个策略一个虚拟子账户,订单与成交带策略 ID,按策略记账;账户层持仓 = 各子账户之和 |
| 自成交 |
同一账户内策略 A 买、策略 B 卖同一品种可能相互成交,多数市场禁止。OMS 在发单前检查同账户反向挂单:拒单、撤掉较早的挂单,或在内部轧差后只把净额发往交易所 |
| 净额与总额 |
内部轧差减少手续费与市场冲击,但各策略仍按各自的成交价记账 |
| 限额分配 |
账户层的资金与持仓限额在策略之间分配,避免一个策略占满整个账户 |
9. 多交易所与多会话
| 问题 |
做法 |
| 订单号映射 |
维护内部订单号 ↔ 交易所订单号 ↔ 会话的双向映射 |
| 撤单所需标识 |
各柜台要求不同,例如 CTP 撤单需要 FrontID + SessionID + OrderRef,或 ExchangeID + OrderSysID;映射表必须保存齐全 |
| 会话路由 |
订单按账户与品种路由到对应会话;会话断开时,该会话上的订单进入状态未知并等待重连后核对 |
| 交易所差异 |
改单语义、订单类型支持、有效期类型、批量撤单能力各不相同,由网关适配,OMS 只处理归一化后的语义 |
10. 并发与性能
| 要点 |
说明 |
| 单写者 |
同一账户的所有状态变化在一个线程中处理,按账户分区扩展(第一章第 6.1 节) |
| 读写分离 |
查询(界面、报表、风控汇总)读取由事件流构建的只读视图,不访问写线程的数据结构 |
| 索引 |
客户订单号、交易所订单号各一个哈希索引;成交编号去重集合按交易日清理 |
| 高频场景 |
在交易进程内维护轻量的本地订单状态,独立的 OMS 服务异步接收事件作为持仓真相来源(第一章第 5.2 节) |
11. 接口
| 接口 |
要求 |
| 报单 / 撤单 / 改单 |
以客户订单号实现幂等:同一订单号重复提交只处理一次 |
| 批量撤单 |
按账户、策略、品种、方向批量撤销,供熔断与运维使用;优先使用交易所的批量撤单能力 |
| 查询 |
订单、成交、持仓、资金,支持按时间点查询历史状态 |
| 订阅 |
订单、成交、持仓、资金变化事件 |
12. 审计与合规
- 每次状态变化记录时间戳(微秒级)、触发来源(策略 ID、策略版本、参数版本、操作员)
- 订单血缘完整:母单 → 子单 → 改单链 → 成交
- 日志不可变,按监管要求保留
- 监管监控指标:报撤单比、撤单频率、自成交次数等;国内证监会《证券市场程序化交易管理规定(试行)》(2024 年施行)对程序化交易的报告与监控提出了要求
13. 常见问题清单
| 问题 |
后果 |
对策 |
| 下单判断只看持仓,不计在途订单 |
回报到达前重复下单,仓位翻倍 |
按"持仓 + 在途"判断(第五章第 6 节) |
| 超时未收到回报就当作失败并重发 |
两笔都成交 |
超时进入状态未知,先查询再决定;重发使用同一客户订单号 |
| 不按成交编号去重 |
网关重连后回报重传,成交被重复入账 |
成交编号去重 |
| 迟到的确认把状态改回 Accepted |
已成交量与状态矛盾,剩余量计算错误 |
状态只前进不回退 |
| 撤单发出后就当作已撤销 |
撤单确认前的成交被漏记;敞口与冻结提前释放 |
PendingCancel 期间剩余量继续计入敞口与冻结 |
| 忽略"撤单被拒:太晚" |
订单卡在 PendingCancel |
撤单被拒时回退状态或确认终态 |
| 不核对累计成交量 |
漏掉的成交长期不被发现 |
用回报中的累计成交量核对,发现缺口即对账 |
| 客户订单号使用随机数 |
主备切换或重放时无法对应同一笔订单 |
订单号由序号推导 |
| 未区分 A 股可用持仓 |
当日买入的股票被卖出,柜台拒单 |
区分总持仓与可用持仓 |
| 未区分期货今仓与昨仓 |
上期所平仓指令错误被拒,或手续费按错误口径计算 |
持仓按方向、今昨分别记录,由 OMS 转换开平标志 |
| 忽略外部订单 |
人工或其他系统下的单导致持仓与交易所不一致 |
未知订单回报登记为外部订单并告警,持仓以交易所为准 |
| 重启后直接开始交易 |
带着错误状态交易 |
恢复与对账完成前禁止交易 |
| 多策略同账户互相成交 |
违反自成交规定 |
发单前检查同账户反向挂单 |
| 日终未处理公司行为 |
次日持仓数量与成本错误 |
盘后批量处理并记录调整事件 |
| 查询与写入共用数据结构 |
查询拖慢回报处理,或读到不一致的中间状态 |
读写分离,查询走只读视图 |
14. 开源实现
| 项目 |
OMS 相关实现 |
值得参考的设计 |
| NautilusTrader |
ExecutionEngine + Cache + Portfolio;订单状态包括 Initialized、Denied、Emulated、Released、Submitted、Accepted、Rejected、Canceled、Expired、Triggered、PendingUpdate、PendingCancel、PartiallyFilled、Filled、Voided(crates/model/src/enums.rs) |
状态划分细致:Denied 表示被本地风控拒绝、Emulated 表示由本地模拟的订单类型(交易所不支持时);回测与实盘同一套执行引擎 |
| vn.py |
OmsEngine 处理订单、成交、持仓、资金事件;OffsetConverter 与 PositionHolding 按今昨仓转换开平标志,对上期所、上期能源单独处理(vnpy/trader/converter.py) |
国内期货今昨仓与开平转换的完整参考 |
| LEAN |
BrokerageTransactionHandler 处理订单与回报;SecurityPortfolioManager 管理持仓与资金 |
多资产持仓与保证金模型 |
| QuickFIX |
只提供 FIX 会话层(登录、心跳、序号、重传)与消息编解码 |
OMS 业务逻辑需要自建 |
十、交易网关的设计与实现
交易网关连接内部 OMS 与外部交易所或券商柜台:把内部订单转换为外部协议报文,维护会话,把外部回报转换为内部统一结构。行情网关与交易网关使用相似的传输与会话技术,但通常是独立的会话与进程;本章只讨论交易网关,给出其系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 协议编解码:内部订单 ↔ 外部报文 |
订单状态与持仓的真相(OMS,第九章) |
| 会话管理:登录、认证、心跳、序号、重连、重传 |
放行或拒绝订单(事前风控,第八章) |
| 标识映射:内部订单号 ↔ 外部订单号 ↔ 会话 |
拆单与路由(执行引擎,第七章) |
| 流控:遵守交易所与柜台的频率限制 |
行情接入(行情网关) |
| 回报归一化:各种回报转换为统一的执行回报 |
|
| 凭证与连接安全 |
|
2. 接入方式
| 方式 |
协议 |
特点 |
| 直连交易所(会员直连 / DMA) |
CME iLink 3(SBE 编码,FIXP 会话层,TCP);Nasdaq OUCH(SoupBinTCP);多数交易所提供 FIX 接口 |
延迟最低;需要会员资格或券商的保荐接入(sponsored access);交易所要求通过接入认证 |
| 通过券商 / 期货公司柜台 |
国内期货 CTP;股票 XTP、华鑫奇点等;极速柜台如易达、盛立 REM;境外券商 FIX |
柜台承担资金、持仓与部分风控;延迟取决于柜台性能与部署位置 |
| 加密货币交易所 |
REST + WebSocket;部分交易所提供 FIX |
请求签名、按权重计的频率限制、私有数据流需单独订阅 |
3. 协议特点
| 协议类型 |
会话层 |
注意点 |
| FIX |
Logon、Heartbeat、TestRequest、ResendRequest、SequenceReset、Logout;每条消息带递增序号(MsgSeqNum) |
序号在会话内连续,缺失时对方请求重传;重传消息带 PossDupFlag,应用层重发的订单带 PossResend;会话有固定的每日开始与结束时间 |
| 二进制原生协议(iLink 3、OUCH) |
FIXP、SoupBinTCP 等轻量会话层 |
定长字段、预填模板(第十一章第 8 节);协议规范与认证要求以交易所文档为准 |
| 柜台 API(CTP 等) |
厂商 C++ 库,请求与回调(API / SPI)模式 |
连接前置机;需要认证码与 AppID(穿透式监管要求);报单引用(OrderRef)在会话内递增;查询接口有流控(CTP 为每秒 1 次) |
| REST / WebSocket |
HTTP 请求签名(HMAC 等),时间戳与有效时间窗口 |
请求按权重计入频率限制;用户订单与成交通过私有 WebSocket 流推送,需维持其有效性(如 Binance 的 listenKey) |
3.1 FIX 协议族的分层
FIX 是由 FIX Trading Community 维护的协议族,分为三层:
| 层 |
内容 |
| 应用层语义 |
消息类型与字段含义,如 NewOrderSingle、ExecutionReport |
| 编码 |
tag=value 文本(通常所说的"FIX")、FIXML、FAST、SBE、Protobuf、JSON |
| 会话层 |
经典 FIX 会话(Logon、Heartbeat、ResendRequest、序号);FIXP(轻量会话层,CME iLink 3 使用) |
SBE 是 FIX 标准中的一种编码。讨论"SBE 与 FIX 的性能"时,比较的是 SBE 二进制编码与 tag=value 文本编码。
3.2 tag=value 与 SBE 的性能差异
|
tag=value 文本 |
SBE |
| 字段定位 |
逐字节扫描分隔符(SOH),字段顺序不固定,需按 tag 分发 |
字段位于固定偏移,直接读取内存 |
| 数值 |
ASCII 与整数、小数之间转换 |
原生二进制整数,价格为定点数 |
| 长度 |
变长,报文头需计算 BodyLength |
定长块加少量变长部分 |
| 校验 |
计算 CheckSum(全部字节求和取模 256) |
无 |
| 时间戳 |
字符串(如 20260927-09:30:00.123456),格式化与解析开销大 |
64 位整数 |
| 体积 |
较大 |
较小 |
| 解码延迟量级(未实测) |
通用引擎为微秒级,深度优化可达亚微秒 |
数十纳秒或更低 |
同样的消息,SBE 的编解码开销比文本编码低一到两个数量级。CME 的行情(MDP 3.0)与交易(iLink 3)均采用 SBE 编码。
3.3 FIX 接入的难点
FIX 是机构接入的通用语言,对冲基金、主经纪商、第三方交易系统普遍使用;加密货币交易所为接入机构客户,也普遍提供 FIX 接口。FIX 接入的主要难点在于正确性,而不在编码:
| 难点 |
说明 |
| 交易所方言 |
各交易所的自定义 tag、字段取值、接入规则(rules of engagement)各不相同 |
| 会话层边界情况 |
序号重置、ResendRequest 与 GapFill、PossDup 与 PossResend 的语义、日切、断线后的订单状态;处理错误可能导致重复下单或漏记成交(第 13 节) |
| 加密货币交易所的登录 |
登录消息携带 HMAC 或 Ed25519 等签名,时间戳须在有效窗口内,连接通常要求 TLS |
| 接入认证 |
多数交易所要求通过认证测试后才能连接生产环境(第 12 节) |
会话管理与各交易所方言应分层实现:会话层作为公共组件,每接入一家交易所只编写方言适配层。
4. 内部结构
flowchart LR
OMS[OMS] -- 内部订单 / 撤单 / 改单 --> ENC[请求编码<br/>预填模板 + 标识映射]
ENC --> THR[流控<br/>令牌桶 / 撤单优先]
THR --> SES[会话层<br/>序号 / 心跳 / 重传缓存]
SES --> TR[传输<br/>TCP / 内核旁路 / HTTPS]
TR --> EX[(交易所 / 柜台)]
EX --> TR2[传输]
TR2 --> SES2[会话层<br/>序号检查 / 重复检测]
SES2 --> DEC[解析与归一化<br/>统一执行回报]
DEC --> OMS
| 组件 |
作用 |
| 请求编码 |
把内部订单转换为协议报文,填入外部标识与会话字段 |
| 流控 |
在发送前按各维度的限额节流 |
| 会话层 |
维护双向序号、心跳、发送缓存(供对方请求重传)、登录状态 |
| 传输 |
连接管理;低延迟场景使用内核旁路与 TCP_NODELAY |
| 解析与归一化 |
把各协议的回报转换为统一的执行回报结构,附加交易所时间与本地接收时间 |
5. 会话管理
stateDiagram-v2
[*] --> Disconnected
Disconnected --> Connecting: 到达会话时间 / 重连
Connecting --> LoggingOn: 连接建立
LoggingOn --> Syncing: 认证通过
LoggingOn --> Disconnected: 认证失败(告警,不自动重试)
Syncing --> Active: 序号对齐、补齐缺失回报、完成订单状态查询
Active --> Disconnected: 心跳超时 / 连接断开
Active --> LoggingOut: 会话结束时间 / 人工下线
LoggingOut --> Disconnected
| 要点 |
说明 |
| 心跳 |
按协议约定的间隔发送;超时未收到对方消息先发 TestRequest,仍无响应则判定断线 |
| 序号持久化 |
双向序号持久化到本地存储,进程重启后从持久化的序号继续;按交易所规则在每日或每周重置 |
| 重传 |
对方请求重传时从发送缓存中重发;缓存中没有的消息用 SequenceReset 跳过,业务上不重发过期订单 |
| 重连 |
指数退避重连;认证失败不自动重试,避免账户被锁定 |
| 同步阶段 |
重连后先补齐缺失回报并查询订单状态,完成前不接受新订单(第九章第 6 节) |
| 多会话 |
主会话与备用会话;订单归属于发出它的会话,撤单必须从同一会话或使用交易所订单号跨会话撤销(以交易所规则为准) |
| 日切 |
交易日切换时重置序号、清理当日映射;国内期货夜盘属于下一个交易日 |
6. 标识与映射
| 要点 |
说明 |
| 外部订单号约束 |
各交易所对客户订单号的长度、字符集、唯一性范围有不同限制;CTP 的 OrderRef 为字符串且须在会话内递增 |
| 映射表 |
内部订单号 ↔ 外部客户订单号 ↔ 交易所订单号 ↔ 会话;持久化,重启后可恢复 |
| 撤单标识 |
CTP 撤单使用 FrontID + SessionID + OrderRef,或 ExchangeID + OrderSysID;映射表需完整保存 |
| 未知标识 |
回报中出现映射表中没有的订单号时,交给 OMS 作为外部订单处理(第九章第 4 节) |
7. 流控
| 限制来源 |
例子 |
| 交易所 |
每会话每秒消息数、报撤比;超限可能被拒单、断开会话或处罚 |
| 柜台 |
CTP 查询每秒 1 次;报单频率限制 |
| 加密货币交易所 |
按请求权重计的每分钟限额、每 10 秒与每日订单数;响应头返回已用权重(如 Binance 的 X-MBX-USED-WEIGHT) |
| 设计要点 |
说明 |
| 多维令牌桶 |
每个限制维度一个令牌桶,发送前全部检查 |
| 撤单优先 |
撤单单独保留额度,或在队列中优先于新单;限流时新单可以被拒,撤单不能被阻塞 |
| 拒绝优于排队 |
新单超限时直接拒绝并通知上游,而不是排队延后发送:排队后的订单以过时的价格进入市场 |
| 以服务端为准 |
使用交易所返回的已用额度校准本地计数 |
| 查询与交易分离 |
查询请求使用独立额度,避免查询挤占下单 |
8. 回报处理
| 回报类型 |
处理 |
| 确认、拒单、成交、撤单确认、改单确认 |
归一化为统一执行回报,交给 OMS |
| 交易所主动撤单 |
有效期到期、自成交防范触发、价格保护、会话结束、断线撤单;标明撤单原因,OMS 据此更新状态 |
| 成交撤销与更正 |
FIX 的 Trade Cancel、Trade Correct 等;OMS 需回滚或修正持仓与资金 |
| 撤单被拒 |
保留交易所给出的原因(如订单已成交),OMS 据此回退状态 |
| Drop copy |
交易所通过独立会话推送的成交副本,交给独立对账与监视进程,而不是 OMS 的主回报流 |
每条回报附带交易所时间戳与本地接收时间戳,用于延迟分析与审计。
9. 可靠性
| 场景 |
做法 |
| 断线期间的订单 |
进入状态未知;重连后查询订单状态再决定,不自动重发 |
| 应用层重发 |
必须重发时使用同一客户订单号并标记为可能重发,由交易所识别重复 |
| 断线撤单 |
在交易所或柜台开启断线撤单(cancel-on-disconnect),会话断开时挂单被自动撤销 |
| 备用线路 |
主线路故障时切换到备用线路或备用会话 |
| 熔断通道 |
保留一条独立会话用于批量撤单,交易会话卡死时仍可操作(第八章第 7 节) |
10. 安全
| 要点 |
说明 |
| 凭证存储 |
密码、API 密钥、证书存放在密钥管理服务或硬件安全模块中,运行时注入;不写入代码、配置文件与日志 |
| 最小权限 |
加密货币交易所的 API 密钥只开通交易权限,不开通提币权限;绑定 IP 白名单 |
| 轮换与审计 |
定期轮换密钥;记录每次登录与认证失败 |
| 监管要求 |
国内期货与证券的穿透式监管要求采集并报送终端信息,接入需完成认证 |
11. 性能
| 要点 |
说明 |
| 预填模板 |
固定字段启动时填好,热路径只修改少量字段(第十一章第 8 节) |
| 传输 |
内核旁路 TCP(Onload、TCPDirect),关闭 Nagle 算法(TCP_NODELAY) |
| 线程 |
发送线程绑核,预分配缓冲区,日志异步写入 |
| 会话隔离 |
延迟敏感的策略使用独立会话,避免与其他策略的消息相互阻塞 |
| 度量 |
报单到确认(order-to-ack)延迟的分布,按会话与交易所分别统计 |
文本 FIX 引擎在报价与撤单量大时 CPU 开销显著,可优化的点:
| 优化点 |
做法 |
| 零分配 |
预分配缓冲区,解析时不创建字符串与字段对象 |
| 报文模板 |
固定字段启动时填好,每次只改价格、数量、订单号 |
| 增量校验和 |
模板固定部分的校验和预先计算,只累加变化的字节 |
| 定长字段 |
价格、数量按固定宽度填充,BodyLength 无需每次重算 |
| 快速数值转换 |
定点数与 ASCII 之间使用专用转换函数,而非通用的格式化与解析函数 |
| 时间戳缓存 |
SendingTime 的日期部分缓存,只更新秒以下部分 |
| 按需解析 |
回报只解析需要的 tag;用 SIMD 扫描分隔符 |
| 异步持久化 |
会话序号与已发消息异步写盘,不在发送路径上同步写入 |
优化前先确认瓶颈所在。多数加密货币交易所部署在公有云上,客户经互联网或云内网络、通过 TLS 接入,网络延迟为毫秒级:此时部署到与交易所相同的云区域收益最大,FIX 引擎的优化主要解决吞吐与 CPU 占用。编码层面的差异在同机房或同可用区接入时才显著。与 WebSocket + JSON 相比,FIX 省去了 JSON 解析与 HTTP 帧处理,部分加密货币交易所也开始提供 SBE 编码的接口(以各交易所当前文档为准)。
12. 测试与接入认证
| 手段 |
说明 |
| 交易所认证 |
多数交易所要求新接入系统通过一致性认证后才能连接生产环境,例如 CME 的 AutoCert+ |
| 仿真环境 |
交易所与柜台提供测试环境;国内期货常用 SimNow 与期货公司仿真环境 |
| 会话重放 |
用录制的会话报文重放,验证解析与状态处理 |
| 故障注入 |
下单途中断线、确认延迟、重复回报、序号缺口、交易所主动撤单、成交更正,验证网关与 OMS 的处理 |
13. 常见问题清单
| 问题 |
后果 |
对策 |
| 会话序号未持久化 |
重启后序号错乱,登录被拒或触发大量重传 |
双向序号持久化,按规则重置 |
| 重连后用新订单号重发未确认订单 |
重复下单 |
先查询状态;必须重发时使用同一订单号 |
| 限流时新单排队延后发送 |
订单以过时价格进入市场 |
超限新单直接拒绝 |
| 撤单与新单共用限额 |
限流时撤单被阻塞,无法止损 |
撤单单独额度或优先 |
| 忽略交易所主动撤单 |
本地认为订单仍在挂着 |
处理全部撤单原因 |
| 未处理成交撤销与更正 |
持仓与资金错误 |
回报类型覆盖撤销与更正,OMS 支持回滚 |
| 未开启断线撤单 |
网关故障时挂单无人管理 |
开启断线撤单 |
| 心跳参数过紧或过松 |
频繁误判断线,或断线后长时间才发现 |
按网络条件设置,并监控心跳延迟 |
| 本地时钟偏差 |
加密货币交易所的签名时间戳超出有效窗口而被拒 |
时钟同步并监控偏差 |
| API 密钥带提币权限、无 IP 白名单 |
密钥泄露后资产被转走 |
最小权限与 IP 白名单 |
| CTP 查询受流控失败被当作"无数据" |
误判持仓为零 |
区分失败与空结果,失败时重试 |
| OrderRef 未递增 |
柜台拒单 |
会话内严格递增,重连后从柜台返回的最大值继续 |
| 日切处理错误 |
夜盘订单归属错误、序号未重置 |
按交易日而非自然日切换 |
| 查询结果覆盖了更新的回报 |
状态倒退(查询发出后又收到成交,查询结果仍显示未成交) |
查询结果只补充缺失信息,不覆盖更新的状态 |
| 撤单发往错误的会话 |
撤单被拒 |
映射表记录订单所属会话 |
| 日志中打印凭证或签名 |
凭证泄露 |
日志脱敏 |
14. 开源实现
| 项目 |
实现 |
说明 |
| QuickFIX / QuickFIX/J / QuickFIX/n |
FIX 会话层(登录、心跳、序号、重传)与消息编解码;消息存储(如文件存储)负责序号与已发消息的持久化;使用最广,是正确性的参照 |
以对象与字符串为中心的消息模型,解析时分配内存;只支持经典 FIX 会话,不支持 FIXP 与 SBE;业务逻辑(订单映射、回报归一化)需自建 |
Artio(artiofix/artio) |
基于 Aeron 的 FIX 与 FIXP 网关(Apache-2.0):引擎(网络、会话、持久化)与应用库分离,二者经 Aeron IPC 通信;编解码器预生成 |
面向低延迟与高可用;Java 生态 |
fix8(fix8/fix8) |
C++ FIX 框架,按交易所 schema 生成代码 |
强调性能与定制,社区较小 |
Philadelphia(paritytrading/philadelphia) |
JVM 上的轻量 FIX 库(Apache-2.0) |
功能面较窄,适合自行组装 |
| Simple Binary Encoding |
根据交易所发布的 SBE 模板生成编解码代码 |
iLink 3 等 SBE 协议 |
| vn.py 接口 |
每个柜台一个网关(如 vnpy_ctp),统一的 BaseGateway 接口:连接、订阅、下单、撤单、查询资金与持仓 |
国内柜台接入的参考 |
| CCXT |
统一的 REST 下单接口,内置频率限制(enableRateLimit);WebSocket 私有流 |
加密货币交易所 |
| NautilusTrader 适配器 |
每个交易场所一个执行客户端,启动时与交易场所对账 |
多交易所统一接入 |
| Hummingbot 连接器 |
加密货币交易所的交易与行情连接器 |
加密货币做市场景 |
FIX 引擎的性能差异取决于语言、消息模型、线程模型与配置(持久化方式、字段校验开关),不同引擎之间没有通用的结论;选型前应在自身硬件与消息组合下测量延迟分布(p50、p99、p99.9)。常见的演进路线:先用 QuickFIX 完成接入与业务验证;消息量上升后,把下单、撤单、回报的热路径换成 Artio 或自研的零分配引擎;会话管理与交易所方言沉淀为内部组件。商业引擎(如 OnixS、Chronicle FIX)以采购成本换取开发时间。
十一、高频交易系统的热路径
热路径指从交易所行情包到达网卡,到订单包离开网卡的这段处理链路,耗时称为 tick-to-trade(也叫 wire-to-wire),是高频系统竞争的核心指标。
本章的延迟数字是公开资料中常见的数量级,未实测;实际数值取决于硬件、交易所协议与实现。
1. 全景与延迟预算
flowchart TB
EX1[(交易所撮合引擎)] -- 组播 UDP 行情 A/B 双路 --> P0
P0["[0] 物理链路<br/>机房托管 / 交叉连接 / L1 交换机"] --> P1
P1["[1] 网卡<br/>内核旁路 / 轮询收包 / 硬件时间戳"] --> P2
P2["[2] 行情解析<br/>零拷贝解码 / 序号校验 / A/B 仲裁"] --> P3
P3["[3] 订单簿<br/>增量更新本地订单簿"] --> P4
P4["[4] 策略<br/>增量信号 → 报价 / 撤单 / 吃单"] --> P5
P5["[5] 事前风控<br/>内联整数比较"] --> P6
P6["[6] 下单编码<br/>预填报文模板,只改几个字段"] --> P7
P7["[7] 网卡发送<br/>内核旁路 TCP"] --> EX2[(交易所订单入口)]
| 阶段 |
通用软件实现 |
优化后的软件 |
FPGA |
| 网卡收包到应用 |
5–20 µs(内核协议栈,抖动大) |
几百 ns 到 1–2 µs(Onload / ef_vi / DPDK) |
在硬件内完成 |
| 解析 + 订单簿 |
几 µs(通用解析、std::map) |
几十到一百多 ns |
几十 ns |
| 策略 + 风控 |
取决于策略 |
几十到几百 ns |
查表与比较,几到几十 ns |
| 编码 + 发送 |
几 µs |
几百 ns |
在硬件内完成 |
| tick-to-trade 合计 |
几十 µs |
约 1–5 µs |
几十到一百多 ns |
软件方案的下限由 PCIe 往返和缓存未命中决定;FPGA 让数据包不经过 CPU,因此再快一个数量级。
2. 物理层:距离就是延迟
| 手段 |
作用 |
| 机房托管(colocation) |
服务器放入交易所机房,交易所通常对各参与者做等长布线以保证公平 |
| 微波 / 毫米波链路 |
光在光纤中约 4.9 µs/km(折射率约 1.47),微波在空气中约 3.3 µs/km。芝加哥到新泽西,光纤单程约 6.5 ms,微波约 4 ms(公开资料量级),跨市场延迟套利依赖这段差距 |
| L1 交换机 |
在物理层直接复制转发,不解析以太网帧,延迟约 5 ns 量级;普通交换机为几百 ns |
| A/B 双路行情 |
交易所通过两条独立的组播线路发送同一份数据,接收端对每个序号取先到的一份(A/B 仲裁) |
3. 网卡:绕过内核
Linux 默认收包路径为:硬中断 → 软中断 → 分配 sk_buff → 协议栈 → socket 缓冲区 → recvfrom 系统调用拷贝到用户态。每一步都有固定开销,调度还会引入抖动。内核旁路方案把这条路径搬到用户态:
| 方案 |
原理 |
特点 |
| Onload(AMD,原 Solarflare) |
LD_PRELOAD 替换 socket 调用,协议栈运行在用户态 |
应用代码不改,保留 socket 语义 |
| ef_vi / TCPDirect |
直接读写网卡收发队列,按原始帧收发 |
延迟更低,协议处理由应用负责 |
| DPDK |
用户态轮询模式驱动 |
通用、生态大,TCP 需自行实现或使用第三方协议栈 |
| XLIO(NVIDIA,原 libvma) |
与 Onload 思路相同 |
用于 Mellanox / NVIDIA 网卡 |
共同做法:
- 轮询代替中断:专用一个核忙等收包,该核 CPU 占用恒为 100%
- 网卡硬件时间戳:记录包到达网卡的时刻,用于计算"包到达后多久才被处理"
- 发送路径预热:ef_vi 的 CTPIO 等机制让 CPU 直接把包写入网卡,省去网卡再发起一次 DMA 读取
4. 行情解析
交易所行情是定长二进制协议,例如 Nasdaq ITCH、CME MDP 3.0(SBE 编码)。
- 零拷贝:在接收缓冲区上按偏移直接读字段,不反序列化为中间对象
- 字节序:ITCH 为大端,SBE 默认小端;x86 上字节序转换是一条
bswap 指令
- 尽早过滤:先读证券 ID,不关心的证券直接丢弃,其余字段不再解码
- 序号校验与恢复:发现序号跳变时,把该证券标记为不可信并暂停交易,从快照通道或重传通道恢复,恢复期间缓存增量。订单簿不一致时不下单
行情网关作为子系统的完整设计(通道结构、A/B 仲裁、合约定义、多源冗余、容量与监控)见第二章。
5. 订单簿
| 做法 |
特性 |
std::map<price, level> |
红黑树查找需要多次指针跳转,节点分散在堆上,插入时分配内存,缓存未命中多 |
| 按价格下标的数组 |
下标 = (价格 − 基准价) / 最小变动价位,O(1) 定位;缓存最优买价与最优卖价的下标 |
L3(逐笔委托)订单簿还需要"订单 ID → 订单"的映射,常用预分配的开放寻址哈希表,配合对象池与价位内的侵入式链表,热路径上没有内存分配。
价位数组的骨架如下(只列买方向;越界检查与基准价重置省略):
#include <cstdint>
struct Level { int64_t qty = 0; uint32_t count = 0; };
class Book {
static constexpr int32_t N = 1 << 16; int64_t base_; Level bids_[N];
int32_t best_bid_ = -1;
public:
explicit Book(int64_t base) : base_(base) {}
void add_bid(int64_t px, int64_t q) {
int32_t i = int32_t(px - base_);
bids_[i].qty += q;
++bids_[i].count;
if (i > best_bid_) best_bid_ = i;
}
void reduce_bid(int64_t px, int64_t q) {
int32_t i = int32_t(px - base_);
bids_[i].qty -= q;
if (bids_[i].qty == 0) {
--bids_[i].count;
if (i == best_bid_) while (best_bid_ >= 0 && bids_[best_bid_].qty == 0) --best_bid_;
}
}
int64_t best_bid() const { return best_bid_ >= 0 ? base_ + best_bid_ : 0; }
};
编译(未实测):
g++ -std=c++20 -O2 -c book.cpp
价格用 int64 表示的 tick 数,不用 double:比较是精确的,也没有浮点运算的延迟。
这里只演示价位定位的思路。L3 订单簿、交易所语义归一化、缺口恢复、跨线程发布与测试等生产级实现见第三章。
6. 策略:把计算移出热路径
- 预计算:公允价、报价阈值、目标价位在行情间隙或另一个线程中算好,热路径只做"比较后决定",例如
if (ask < trigger_px) send(buy_template)
- 增量更新:信号只按本次变化更新,例如订单流不平衡(OFI)只累加本笔的贡献,不重新扫描订单簿
- 静态分派:用模板或 CRTP 代替虚函数,热路径不抛异常
- 异步日志:热路径只把参数和格式串 ID 以二进制写入 SPSC 环形缓冲区,由另一个线程格式化并落盘,NanoLog、fmtlog、quill 采用这种设计
7. 事前风控:内联检查
热路径单线程运行,持仓、挂单量、资金占用都是普通整数,不需要原子操作。检查项与第一章的事前风控一致(单笔上限、价格带、持仓上限、令牌桶限流、自成交防范;完整检查项见第八章第 3 节),实现上是与预计算上限的整数比较,每项几纳秒。
8. 下单:预填报文模板
启动时填好会话 ID、账户、证券 ID、协议头等固定字段,热路径只修改订单号、价格、数量:
#include <cstdint>
struct alignas(64) NewOrderTemplate {
uint8_t header[32]; uint64_t cl_ord_id; int64_t price; uint32_t qty; uint8_t side, tif, pad[2];
};
static_assert(sizeof(NewOrderTemplate) == 64);
编译(未实测):
g++ -std=c++20 -O2 -c order_template.cpp
ClientOrderId 由单调递增计数器生成,不用 UUID 或字符串拼接
- 订单入口大多基于 TCP(CME iLink 3、Nasdaq OUCH 等),TCP 序号、确认、重传由 Onload / TCPDirect 或 FPGA 上的 TCP 卸载引擎处理
9. FPGA 方案
- 整条链路在芯片内完成:PHY / MAC → 解析 → 维护必要的订单簿状态 → 触发判断 → 组装订单 → 发出
- 软件负责下发参数,例如"价格穿过 X 时发送订单 Y",由 FPGA 按条件触发
- 投机发送:行情包尚未收完就开始发送订单包头;最终决定不下单时,故意发出错误的帧校验序列(FCS),使该帧在链路上被丢弃。是否合规取决于交易所规则
- 延迟要求最极端的团队使用 ASIC
10. 系统调优:压低尾延迟
高频系统关注 p99、p99.9 与最大值,而不是均值:一次几十微秒的抖动就足以错过机会。
| 抖动来源 |
对策 |
| 调度器、定时器中断、RCU 回调 |
isolcpus、nohz_full、rcu_nocbs 隔离热路径核,线程绑核 |
| 设备中断落在热路径核 |
设置 IRQ 亲和性,把中断迁到其他核;关闭 irqbalance |
| CPU 降频、深度睡眠唤醒 |
BIOS 关闭 C-state,调频策略设为 performance;按需固定频率或关闭 Turbo |
| SMI(系统管理中断) |
BIOS 关闭相关功能,用 hwlatdetect 检测 |
| 缺页、TLB 未命中 |
显式大页(hugetlbfs)、mlockall、启动时预先访问全部内存页;关闭透明大页 |
| 跨 NUMA 访问 |
网卡、内存、热路径核位于同一 socket |
| 跨核传递数据 |
一个线程完成收包到下单的全过程(run-to-completion),省去一次缓存行跨核传输(同 socket 约 50–100 ns);必须跨核时用 SPSC 队列,并用 alignas(64) 避免伪共享 |
| 冷缓存 |
缓存预热:无行情时周期性执行完整决策路径但不发送,使代码与数据留在 L1 / L2 |
| 超线程 |
热路径核的兄弟逻辑核保持空闲,或关闭超线程 |
| GC |
热路径使用 C++ / Rust;Java 方案依赖零分配编码与低停顿 GC |
内核启动参数示例(未实测,核号按机器调整):
isolcpus=2-7 nohz_full=2-7 rcu_nocbs=2-7 intel_idle.max_cstate=0 processor.max_cstate=1 transparent_hugepage=never hugepagesz=1G hugepages=8
编译选项:-O3 -march=native,配合 LTO 与 PGO;热路径分支用 [[likely]] / [[unlikely]] 标注。
11. 延迟测量:以线上抓包为准
| 方法 |
做法 |
覆盖范围 |
| 进程内打点 |
各阶段用 rdtsc 取时间戳(要求 invariant TSC),输出每段延迟的直方图 |
只覆盖应用内部,看不到网卡与 PCIe 的耗时 |
| 线上抓包 |
交换机镜像口或分光器接带硬件时间戳的抓包卡,对比行情包进入与订单包离开的时刻;商业产品有 Corvil、Pico 等 |
真实的 tick-to-trade |
两者结合使用:抓包给出总延迟,进程内打点定位耗时落在哪一段。
12. 国内市场:柜台是主要瓶颈
国内期货与股票交易的延迟瓶颈通常在柜台(期货公司或券商的交易系统),而不在自有程序。常见的 CTP 柜台延迟在毫秒级;追求低延迟的团队选择极速柜台加机房托管,例如盛立 REM、易达、中泰 XTP、华鑫奇点,以及艾克朗科等 FPGA 方案。具体延迟以厂商公开数据为准。
13. 热路径上如何使用模型
微秒级的热路径上无法运行复杂模型,但高频策略的复杂性不在热路径的计算量上:复杂的部分在热路径之外完成,热路径只做查表与比较。
13.1 高频的收益来源
| 收益来源 |
逻辑复杂度 |
门槛 |
| 延迟套利 |
简单:期货先动,就吃 ETF 或成分股尚未更新的报价;一个交易所先动,就吃另一个交易所的旧报价 |
速度:第二名吃不到这笔单 |
| 做市 |
公允价加减半个价差 |
库存管理、逆向选择防护、排队位置、撤单速度 |
| 短周期微观结构信号 |
订单簿不平衡、订单流不平衡(OFI)等线性组合,可增量更新,每次 O(1) |
特征有效性与参数标定 |
| 结构性套利 |
ETF 申赎、指数期现、跨市场挂牌、外汇三角套利 |
通道、成本、执行速度 |
盈利方式是单笔利润极小、交易次数极多、胜率高,依靠大数定律累积。Virtu 在 2014 年上市招股书中披露,2009–2013 年的 1238 个交易日中仅 1 天亏损。高频的复杂性集中在延迟工程、订单簿微观结构建模、库存与风险控制,而不是单次预测的计算量。
13.2 延迟与信号价值
信号的预测价值随时间衰减。以半衰期估算:半衰期 50 ms 的信号,推理耗时 15 ms 后剩余价值约为 2^(−15/50) ≈ 81%。实际损失通常更大:
| 交易方式 |
慢的后果 |
| 主动吃单 |
更快的对手先成交,到达时报价已消失:结果是没有成交或成交在更差的价格 |
| 被动挂单 |
报价更新慢,旧报价被更快的对手成交,而这些成交恰好是价格即将向不利方向变动的那部分(逆向选择) |
判断标准是信号的时间尺度:预测未来 1 秒到 1 分钟的信号,十毫秒级推理可以接受,属于中高频或日内策略;在 100 µs 内就被套利掉的机会,任何毫秒级模型都来不及。
13.3 快慢路径分离
模型运行在慢路径上,模型的产出供快路径使用:
flowchart TB
L2[第 2 层:秒 / 分钟 / 日,其他机器<br/>大模型训练、市场状态识别、参数标定、模型蒸馏<br/>产出:品种系数、价差参数、触发阈值、查找表]
L1[第 1 层:微秒到毫秒,同机其他核心<br/>慢变特征汇总、小模型推理<br/>产出:公允价偏移、报价宽度、偏斜系数、触发表]
L0[第 0 层:纳秒到微秒,热路径或 FPGA<br/>增量更新快变特征 → 公允价 = microprice + Σ wᵢ·fᵢ<br/>报价 = 公允价 ± 半价差 → 查触发表 → 发单 / 撤单]
L2 -- 参数下发(带版本号,写入事件日志) --> L1
L1 -- seqlock 共享内存(第三章第 8 节) --> L0
| 手段 |
做法 |
| 模型产出参数 |
深度模型或 GBDT 离线学习"何种市场状态下公允价偏移多少、价差开多大",结论压缩为少量系数,热路径只做乘加;32 个特征的点积用 SIMD 为几纳秒量级 |
| 模型蒸馏 |
大模型(DeepLOB、Transformer)作为教师,训练线性模型、少量浅树或小型 MLP 拟合其输出,上线学生模型,保留大部分预测能力,推理成本降低几个数量级 |
| 编译为原生代码 |
GBDT 用 treelite、lleaves(基于 LLVM 的 LightGBM 编译器)编译,几十棵浅树的推理可达微秒级以下;小型神经网络量化后在 CPU 上用 SIMD 推理,或用 hls4ml 类工具综合到 FPGA(源自高能物理实验的触发系统,要求纳秒到微秒级推理);批大小为 1 的小模型在 CPU 上通常比 GPU 快,GPU 的 PCIe 传输与 kernel 启动即需十微秒量级 |
| 投机预计算 |
空闲时预先计算最可能的下一事件对应的动作,例如"卖一被吃且买方不平衡度大于 x 则在卖价买入""买一撤单过半则撤掉我的买单",并预先组装报文,甚至预先放入网卡发送缓冲区(第 3 节的发送路径预热);事件到达时只需一次比较与一次发送。FPGA 的触发逻辑即此模式:软件计算触发表,硬件执行 |
| 异步刷新 |
第 1 层模型每 1 ms 或每 N 个事件运行一次,结果写入共享内存,第 0 层读取最新版本。前提是模型输出的变化慢于刷新周期,市场状态、波动率等本身就是慢变量 |
13.4 例子:带机器学习的做市
| 层 |
频率 |
内容 |
| 第 2 层 |
每日收盘后 |
用 30 个订单簿特征训练 GBDT,预测未来 500 ms 中间价变化;按市场状态(高 / 低波动 × 趋势 / 震荡)蒸馏为 4 组线性系数并下发 |
| 第 1 层 |
每 1 ms |
计算慢变特征(过去 1 秒波动率、相关品种如股指期货的最新变化、成交量状态);识别当前市场状态并选择系数组;根据库存计算报价偏斜;写入共享内存 |
| 第 0 层 |
每条行情,亚微秒级 |
增量更新 OFI、盘口不平衡等快变特征;公允价 = microprice + 系数 · 特征;由报价宽度与偏斜得到买卖价;与当前挂单比较决定是否改价或撤单;查触发表决定是否主动成交 |
模型的作用体现在系数与市场状态的选择上,热路径的计算量只有几十次乘加与几次比较。
13.5 推理延迟取决于模型与推理方式
| 做法 |
推理延迟量级(批大小为 1) |
| Python + PyTorch eager 模式 |
毫秒级,主要是框架开销而非计算本身 |
| ONNX Runtime / TensorRT,C++ 调用,中等模型 |
百微秒到毫秒 |
| 编译后的 GBDT(几十棵浅树)、量化小 MLP |
微秒级以下到微秒级 |
| FPGA 上的小型网络(hls4ml 类) |
百纳秒级 |
| 线性模型 + 增量更新的特征 |
纳秒级 |
推理需要十几毫秒,通常说明模型过大或推理路径未经优化。用 13.3 节的手段仍无法压缩时,该模型适用于中频而非高频。
13.6 国内市场的时间尺度
| 市场 |
行情粒度 |
模型使用方式 |
| A 股 |
Level-2 快照约 3 秒一次;T+1 |
所谓高频主要是日内 T0 与秒级到分钟级的调仓,毫秒级推理足够,机器学习与深度学习模型使用广泛 |
| 国内期货 |
普通行情每秒 2 次快照 |
在 500 ms 粒度下,十毫秒级推理可以接受;拼微秒的是使用 Level-2 逐笔行情、极速柜台与机房托管的做市与套利业务 |
在纳秒到微秒的领域,胜负取决于速度与结构,模型负责离线产出参数;在毫秒到秒级的领域,在线运行复杂模型是主要做法。同一机构通常两类业务并存,通过快慢路径分离把两者连接起来。
十二、参考数据服务的设计与实现
参考数据是交易系统依赖的相对静态的数据:合约属性、交易日历与时段、公司行为、指数成分、费用、保证金、各类名单与限制。它变化不频繁,但一个错误会同时影响行情、策略、风控、OMS 等所有组件,例如合约乘数错一位,持仓与风险就被放大十倍。参考数据服务同时服务于研究(按时点的历史)与交易(每日快照与盘中变更)。本章给出其系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 从多个来源采集参考数据,校验、比对,形成统一的"黄金记录" |
行情与成交数据(行情网关、数据仓库) |
| 双时态版本化存储,支持按时点查询 |
持仓与资金(OMS) |
| 内部标识符的分配与外部标识符映射 |
限额数值的设定(风控;风控限额可作为参考数据的一类分发,但由风控系统审批) |
| 每日快照发布与盘中变更推送 |
|
| 人工修正的审批与审计 |
|
2. 数据范围
| 类别 |
内容 |
| 证券主数据 |
内部 ID、交易所代码、ISIN、FIGI 等外部标识,名称、类型、上市交易所、币种、上市与退市日期、行业分类、股本 |
| 合约属性 |
最小变动价位(含分段价位表)、合约乘数、交易单位与最小申报量、单笔最大申报量、涨跌停规则、交割月、最后交易日、交割方式;期权的标的、行权价、类型、到期日、行权方式 |
| 交易日历与时段 |
交易日、休市日、集合竞价与连续竞价时段、午间休市、夜盘及其所属交易日、临时休市 |
| 公司行为 |
分红、送转、拆股与合股、配股、代码与名称变更、合并、退市;由此得到复权因子 |
| 指数成分 |
成分股、权重,调整的公告日与生效日 |
| 期货主力合约 |
每日主力合约映射、换月日 |
| 费用 |
佣金、印花税、交易所规费、期货手续费(按手或按金额,平今与平昨可不同)、融券费率 |
| 保证金 |
交易所保证金率、期货公司加收部分 |
| 状态与名单 |
停牌、风险警示(ST)、融资融券标的、限制交易名单、交易所持仓限额 |
| 账户与通道 |
账户、券商通道、交易权限的对应关系 |
3. 数据来源与汇集
| 来源 |
提供 |
| 交易所 |
公告、合约定义行情(第二章第 6 节)、交易日历、规则文件 |
| 数据商 |
证券主数据、公司行为、指数成分、行业分类(商业数据服务) |
| 券商与期货公司 |
客户实际适用的手续费率与保证金率(通常高于交易所标准) |
| 内部 |
合规部门的限制交易名单、账户与通道配置 |
| 开源 |
交易日历库;研究用的基础数据接口 |
多来源汇集为"黄金记录"(golden record):每个字段按优先级选取来源,其余来源用于交叉校验。
flowchart LR
SRC[多个来源<br/>交易所 / 数据商 / 券商 / 内部] --> ING[采集]
ING --> VAL[校验<br/>完整性 / 一致性 / 变化幅度]
VAL --> CMP[跨来源比对]
CMP --> REV{有差异或异常?}
REV -- 是 --> MAN[人工复核<br/>双人审批]
REV -- 否 --> GOLD[黄金记录]
MAN --> GOLD
GOLD --> STORE[双时态版本化存储]
STORE --> SNAP[每日快照<br/>交易域]
STORE --> CHG[盘中变更事件<br/>事件总线]
STORE --> PIT[按时点查询<br/>研究域]
4. 双时态版本化
每条记录带两个时间维度:
| 维度 |
含义 |
| 生效时间(valid time) |
这条信息在现实中从何时到何时有效 |
| 知晓时间(knowledge time) |
系统从何时开始知道这条信息 |
例子(假设数据):某股票 5 月 20 日公告 10 送 10,6 月 10 日为除权日。
| 字段 |
值 |
生效起 |
生效止 |
知晓时间 |
| 总股本 |
10 亿 |
2020-01-01 |
2024-06-09 |
2020-01-02 |
| 总股本 |
20 亿 |
2024-06-10 |
— |
2024-05-20 |
| 查询 |
条件 |
用途 |
| 当前值 |
生效时间包含今天,知晓时间取最新 |
交易 |
| 某日的真实值 |
生效时间包含该日,知晓时间取最新 |
事后分析 |
| 某日当时所知的值 |
生效时间包含该日,知晓时间不晚于该日 |
研究回测(避免前视偏差)、复现某日生产系统的决策 |
修正错误时不覆盖旧记录,而是追加一条知晓时间更晚的新版本,旧版本仍可查询。
5. 标识符管理
| 要点 |
说明 |
| 永久内部 ID |
每个证券一个永不复用的内部 ID |
| 外部标识带有效期 |
交易所代码、简称可能变更,退市后代码可能被新证券复用;映射关系带生效区间 |
| 期货合约 |
合约代码按交割月周期出现;主力合约是按日变化的映射,而不是一个证券 |
| 关联关系 |
同一公司的多地上市(如 A 股与 H 股)、期权与标的、ETF 与成分 |
| 热路径 ID |
交易日开始时为当日可交易品种分配连续的整数下标,热路径用数组按下标访问(第一章第 2.2 节"行情标准化");下标只在当日有效,持久化使用永久内部 ID |
6. 发布与分发
| 方式 |
说明 |
| 每日快照 |
开盘前生成当日快照并赋予版本号,所有组件加载同一版本;版本号写入事件日志,重放时可还原 |
| 盘中变更 |
停牌与复牌、盘中新增期权合约、临时调整保证金、限制名单变更等作为事件经事件总线发布,在某个序号生效(第四章) |
| 按时点查询 |
研究域通过接口按"生效日 + 知晓日"查询历史 |
| 本地缓存 |
各进程启动时把快照加载到内存,热路径不调用远程服务 |
| 启动依赖 |
当日快照缺失、版本不一致或校验失败时,相关组件不开放交易(fail-closed) |
7. 校验
| 规则 |
例子 |
| 完整性 |
每个可交易品种都有最小变动价位、乘数、交易单位、涨跌停规则 |
| 一致性 |
最小变动价位与交易所规则一致;涨跌停价按规则计算并按最小变动价位取整后,与交易所发布值一致 |
| 跨来源比对 |
不同来源的同一字段不一致时进入人工复核 |
| 变化幅度 |
合约乘数、交易单位等极少变化的字段发生变化时要求额外确认;新增或删除品种数量异常时告警 |
| 日历 |
交易日不在周末(国内调休的周末不开市);夜盘所属交易日正确;长假前最后一个交易日的夜盘安排与交易所公告一致 |
| 公司行为 |
复权因子的跳变与公告的分红送转比例一致 |
| 期货 |
最后交易日、交割月序列连续;主力合约映射不在两个合约之间来回切换 |
8. 各类数据的要点
| 数据 |
要点 |
| 交易日历 |
按交易所分别维护;交易所每年公布次年休市安排;存在临时休市或延迟开市;国内期货夜盘属于下一个交易日,长假前通常不开夜盘(以交易所公告为准) |
| 涨跌停 |
A 股按板块与状态区分:主板一般 10%、主板风险警示股 5%、创业板与科创板 20%、北交所 30%,新股上市初期与部分复牌情形另有规定;期货涨跌停幅度可由交易所临时调整。规则以交易所最新规定为准,涨跌停价按规则计算后取整到最小变动价位 |
| 公司行为与复权 |
保存原始价格与复权因子,而不是只保存复权后的价格;复权因子在除权日生效,但在公告日才被知晓,研究按知晓时间使用 |
| 主力合约 |
明确判定规则(例如按持仓量或成交量,连续若干日领先才切换,切换后不回切),每日发布映射;研究的连续合约与交易的换月使用同一规则 |
| 费用 |
按账户与券商通道维护实际费率;A 股印花税为卖出时征收 0.05%(2023-08-28 起);期货平今手续费可能不同于平昨;费用变更带生效日期 |
| 保证金 |
实际保证金率 = 交易所标准 + 期货公司加收;节假日前、连续涨跌停时交易所可能上调 |
| 指数成分 |
区分公告日与生效日;权重数据可能受指数公司授权限制 |
9. 消费者与用法
| 组件 |
使用的参考数据 |
| 行情网关 |
品种映射、价格缩放因子、合约定义核对(第二章) |
| 策略引擎 |
最小变动价位、乘数、交易时段、主力合约映射 |
| 事前风控 |
涨跌停与参考价、单位换算、限制交易名单、持仓限额(第八章) |
| OMS |
费用、T+1 规则、今昨仓规则、保证金率(第九章) |
| 执行引擎 |
交易单位、涨跌停、集合竞价时段(第七章) |
| 研究 |
按时点的证券池、复权因子、行业分类、指数成分 |
| 运营 |
结算、对账、归因所需的费用与公司行为 |
10. 运维
| 要点 |
说明 |
| 每日流程 |
盘前定时执行采集、校验、比对、生成快照,设定完成截止时间;超时或失败立即告警 |
| 人工修正 |
通过审批流程修改,双人复核,记录原因与修改人;临时修正带到期时间 |
| 变更报告 |
每日输出与前一日的差异:新增、删除、字段变化,供人工浏览 |
| 降级 |
当日快照无法生成时,不直接沿用前一日数据开放全部交易;只对确认无变化的品种开放,或整体暂停并人工确认 |
| 备份 |
存储与快照有异地副本 |
11. 常见问题清单
| 问题 |
后果 |
对策 |
| 合约乘数或交易单位错误 |
持仓与风险被放大或缩小 |
变化幅度校验、跨来源比对、双人复核 |
| 最小变动价位错误或未建模分段价位表 |
订单被拒或价格错误 |
按交易所价位表建模并校验 |
| 涨跌停价取整规则错误 |
边界价格的订单被拒 |
按规则计算并与交易所发布值核对 |
| 新股、复牌等特殊情形的涨跌幅未处理 |
错误的价格检查 |
按规则维护特殊情形 |
| 风险警示状态变更未同步 |
涨跌幅限制错误 |
状态变更作为事件推送 |
| 交易日历错误 |
休市日运行交易任务,或交易日未启动 |
日历校验与交易所公告核对 |
| 夜盘归属交易日错误 |
成交与持仓归入错误的交易日 |
按交易日处理,而不是自然日 |
| 公司行为按生效日而非公告日提供给研究 |
回测前视偏差 |
双时态存储,研究按知晓时间查询 |
| 更新时覆盖旧值 |
无法复现过去的决策 |
追加版本,不覆盖 |
| 退市代码被复用 |
历史数据错配到新证券 |
永久内部 ID,外部代码带有效期 |
| 主力合约在两个合约间来回切换 |
连续合约失真、频繁换月 |
明确切换规则,切换后不回切 |
| 各组件加载了不同版本 |
风控与策略口径不一致 |
统一版本号,启动时校验 |
| 盘中停牌未推送 |
对停牌品种下单 |
盘中变更经事件总线推送 |
| 手工修改无审计 |
事故原因无法追溯 |
审批流程与审计记录 |
| 时区与夏令时错误 |
交易时段判断错误 |
内部统一 UTC,按交易所时区换算 |
| 单一数据来源的错误直接进入生产 |
错误数据扩散到所有组件 |
多来源交叉校验 |
| 保证金率只用交易所标准 |
低估资金占用,存在强平风险 |
使用期货公司实际费率 |
12. 开源方案
| 项目 |
用途 |
| pandas_market_calendars、exchange_calendars |
各交易所的交易日历与交易时段 |
| OpenFIGI |
开放的金融工具全球标识(FIGI)映射服务,可用于外部标识统一 |
| AKShare、Tushare |
研究用的基础数据(证券列表、公司行为等) |
| Qlib 数据层 |
证券池文件记录每只证券的起止日期,支持按时点的证券池 |
| NautilusTrader Instrument |
合约模型:价格精度、数量步长、乘数、保证金等字段的参考 |
| vn.py ContractData |
国内期货与股票的合约字段参考 |
| 支持系统版本化表的数据库(如 MariaDB、SQL Server 的时态表);Apache Iceberg、Delta Lake 的时间旅行 |
双时态存储的实现基础 |
参考数据的采集、黄金记录与发布流程通常自建。
十三、配置与参数中心的设计与实现
配置与参数中心管理系统内部做出的各种设定:基础设施配置、策略参数、模型版本、风控限额、功能开关。参考数据描述外部世界的事实(第十二章),配置描述的是内部的决定。配置错误与代码缺陷一样会造成事故,而且变更更频繁、审查更容易被忽视。本章给出其系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 配置项的定义(schema)、存储、版本化 |
外部事实数据(参考数据服务,第十二章) |
| 变更流程:校验、审批、灰度发布、回滚 |
密码、密钥等凭证(密钥管理服务,第十章第 10 节) |
| 分发到各组件并确认生效 |
限额数值的业务判断(风控团队决定,本系统负责管理与分发) |
| 权限控制与审计 |
|
2. 配置分类
| 类别 |
例子 |
变更频率 |
审批 |
盘中热更新 |
| 基础设施 |
地址、端口、组播组、绑核、内存与队列大小 |
随发布 |
运维 |
否,需重启 |
| 接入 |
账户、会话标识、线路 |
低 |
运维 + 合规 |
否 |
| 策略参数 |
信号阈值、窗口长度、目标波动率 |
中 |
策略负责人 |
是 |
| 模型产物 |
模型版本、系数、查找表(第十一章第 13 节) |
每日 |
研究 + 风控 |
是,按版本切换 |
| 风控限额 |
持仓、金额、频率、亏损上限 |
中 |
风控,双人复核 |
是,需审批 |
| 开关 |
策略启停、功能开关、只减仓模式 |
高 |
交易员或风控 |
是 |
| 运营 |
告警阈值、报表设置 |
低 |
运营 |
是 |
3. 数据模型
| 字段 |
说明 |
| 键 |
分层命名空间:环境 / 系统 / 组件 / 实例 / 配置名 |
| 值与 schema |
类型、取值范围、单位、默认值、说明 |
| 作用范围 |
全局、账户、策略、品种 |
| 版本 |
每次变更生成新的不可变版本 |
| 生效与到期 |
生效时间;临时调整必须带到期时间 |
| 变更元数据 |
提交人、审批人、原因、关联的工单 |
分层覆盖:默认值 → 环境 → 机房 → 组件 → 实例 → 临时覆盖,后者覆盖前者。解析规则固定且可查询:任何实例都能输出"最终生效的配置"及每个值来自哪一层。
4. 变更流程
flowchart LR
SUB[提交变更<br/>附原因] --> SCH[schema 校验]
SCH --> SAN[合理性检查<br/>变化幅度 / 单位 / 参数间约束]
SAN --> APP{审批<br/>按类别,关键项双人}
APP -- 通过 --> PRE[仿真环境验证]
PRE --> CAN[灰度发布<br/>一个策略 / 一个机房]
CAN --> ACK[生效确认<br/>各实例回报已应用版本]
ACK --> MON[观察指标]
MON -- 正常 --> FULL[全量发布]
MON -- 异常 --> RB[回滚]
APP -- 拒绝 --> END[结束]
紧急通道:熔断等紧急操作允许先执行后审批,但必须留痕并在事后复核。
5. 分发与生效
| 要点 |
说明 |
| 启动加载 |
组件启动时加载完整配置并校验,校验失败则不启动 |
| 运行时变更 |
组件订阅变更(如 etcd 的 watch),变更经事件总线作为事件发布,在两个事件之间生效,不在回调执行中途修改(第五章第 9 节) |
| 写入事件日志 |
配置变更带序号写入事件日志,重放时在同一位置应用同一版本(第一章第 3 节 ②) |
| 原子性 |
相互关联的参数(如价格带的上下限、各策略的额度分配)作为一个版本整体生效,不出现只改了一半的中间状态 |
| 生效确认 |
各实例回报当前应用的版本号,配置中心据此发现未生效或版本漂移 |
| 热路径 |
热路径只读本地内存中的配置,不同步访问配置中心 |
6. 版本化与回滚
- 每个版本不可变,可查看任意两个版本的差异
- 回滚是把旧版本的内容作为新版本发布,而不是删除或改写历史
- 策略代码、模型、参数、限额组成不可变的发布单元(第一章第 2.1 节"策略审批上线"),发布清单引用具体的配置版本
- 同一组配置按"开发 → 仿真 → 生产"逐级晋升,各环境只替换环境相关的部分
7. 校验
| 规则 |
例子 |
| schema |
类型、范围、单位、必填项 |
| 参数间约束 |
下限小于上限;各策略的额度之和不超过账户额度 |
| 变化幅度 |
新值与当前值相差超过设定倍数(如 10 倍)时要求额外确认 |
| 引用完整性 |
引用的品种、账户、策略存在且有效 |
| 试算 |
新限额按当前持仓试算:会不会一生效就触发只减仓或熔断 |
8. 开关
| 要点 |
说明 |
| 生命周期 |
创建时指定负责人与预计删除时间;功能稳定后删除开关及其代码分支 |
| 不复用名称 |
废弃的开关名不再用于新功能。Knight Capital 事故中,一个曾用于旧功能的开关被新功能复用,未更新代码的服务器据此激活了旧逻辑 |
| 熔断开关 |
独立于普通配置分发路径,要求在配置中心不可用时仍能生效(第八章第 7 节) |
| 默认值 |
开关缺失或无法读取时取安全的一侧(关闭新功能、不放宽限额) |
9. 权限与审计
| 要点 |
说明 |
| 分类授权 |
按配置类别与作用范围授权;策略团队不能修改风控限额(第八章第 2 节) |
| 双人复核 |
风控限额、接入配置、开关等关键项需第二人批准 |
| 唯一入口 |
生产配置只能通过配置中心的工具修改,不允许直接修改数据库或文件 |
| 审计 |
记录谁、何时、改了什么、为什么、谁批准;审计记录不可修改 |
10. 可用性
| 要点 |
说明 |
| 配置中心高可用 |
多节点共识集群(如 etcd 的 Raft 集群,3 或 5 个节点),跨机房复制 |
| 本地缓存 |
组件把最后一个有效版本保存在内存与本地磁盘;配置中心不可用时继续运行,只是无法接收变更 |
| 启动策略 |
配置中心不可用时,允许用本地缓存的最后有效版本启动并告警;风控限额缺失时拒绝交易 |
| 与交易解耦 |
配置中心故障不能导致交易停止,也不能导致限额被放宽 |
11. 配置与代码
| 方式 |
适用 |
| 配置即代码 |
静态配置存放在 Git 中,经代码评审与 CI 校验后由发布流水线推送;历史与审查依托 Git |
| 运行时配置 |
盘中需要调整的参数通过配置中心的界面或接口修改,走审批流程,同样保留完整历史 |
两者通常并存。配置中只放数据,不放逻辑:可以执行脚本或复杂表达式的配置无法被校验,也难以审查。
12. 监控
- 版本漂移:同一组件的不同实例应用了不同版本
- 待审批与即将到期的临时调整
- 变更与指标联动:在监控图表上标注配置变更时刻,便于判断异常是否由变更引起
- 校验失败与回滚次数
13. 常见问题清单
| 问题 |
后果 |
对策 |
| 参数没有单位 |
按错误单位理解,限额放大或缩小数十倍 |
schema 中注明单位,界面显示单位 |
| 相关参数分别生效 |
中间状态不一致,例如上限先于下限更新导致上限小于下限 |
相关参数作为一个版本原子生效 |
| 回调执行中途修改参数 |
同一次计算前后使用不同参数 |
在两个事件之间生效 |
| 配置变更不写入事件日志 |
重放结果与生产不一致 |
变更作为事件记录 |
| 复用旧开关名称 |
旧逻辑被意外激活 |
开关不复用,及时删除 |
| 临时调整没有到期时间 |
临时放宽变成永久放宽 |
临时调整必须带到期时间 |
| 直接修改数据库或文件 |
没有审计,也没有校验 |
唯一入口 |
| 配置中心故障导致交易停止 |
单点故障扩散到交易 |
本地缓存最后有效版本 |
| 热路径同步读取配置中心 |
延迟抖动,故障耦合 |
只读本地内存 |
| 实例之间版本不一致 |
同一策略在不同机器上行为不同 |
生效确认与版本漂移监控 |
| 测试环境配置指向生产地址 |
测试流量进入生产 |
环境隔离,生产地址只出现在生产配置中,并在连接时校验 |
| 一次性全量发布 |
错误配置同时影响所有实例 |
灰度发布 |
| 回滚时改写历史 |
审计链断裂 |
回滚即发布新版本 |
| 把密钥写进配置 |
凭证泄露 |
凭证放在密钥管理服务中 |
| 缺失时使用不安全的默认值 |
限额缺失被当作"无限额" |
缺失即拒绝或取保守值 |
| 配置中写逻辑 |
无法校验与审查 |
配置只放数据 |
14. 开源方案
| 项目 |
特点 |
| etcd |
基于 Raft 的强一致键值存储;支持 watch 订阅变更;多版本存储可查询历史修订 |
| Consul |
键值存储、服务发现与健康检查 |
| Apollo(携程开源) |
配置管理平台:灰度发布、版本管理与回滚、权限与审计 |
| Nacos(阿里巴巴开源) |
配置管理与服务发现 |
| Git + CI |
配置即代码:评审、历史、自动校验 |
| JSON Schema、CUE |
配置的 schema 定义与校验 |
| HashiCorp Vault、OpenBao |
凭证管理(Vault 自 2023 年改用 BSL 许可证,OpenBao 是其开源分支),与配置中心分开部署 |
十四、时钟同步的设计与实现
时钟同步让多台服务器的时间与协调世界时(UTC)保持一致,服务于跨机延迟测量、监管时间戳、日志与审计关联、交易时段判断。时钟同步不负责决定状态变化的先后:同一来源内的顺序由序号决定,时间戳只用于度量与记录(第一章第 6.1 节 ④)。本章给出其系统设计与常见问题。
本章的精度数字是公开资料中常见的数量级,未实测。
1. 职责与边界
| 负责 |
不负责 |
| 时间源的接入与分发:卫星授时、主时钟、网络授时协议 |
事件排序(由序号决定) |
| 各服务器网卡时钟与系统时钟的校准 |
回测中的时间(模拟时钟,第五章第 7 节) |
| 时间偏差的监控、告警与合规留证 |
交易日历与时段规则(参考数据,第十二章) |
| 时间使用规范:时钟类型、单位、时区 |
|
2. 用途与精度要求
| 用途 |
需要的精度 |
| 跨机延迟测量(交易所到本机、订单往返的分段耗时) |
亚微秒到微秒 |
| 监管时间戳 |
欧盟 MiFID II RTS 25:高频交易与 UTC 的偏差不超过 100 µs、时间戳粒度 1 µs,其他交易活动的要求更宽;美国 CAT 对会员机构要求与 NIST 时间的偏差不超过 50 ms |
| 跨机日志与审计关联 |
毫秒 |
| 交易时段判断、定时任务 |
毫秒 |
| 分布式链路追踪 |
微秒到毫秒 |
3. 时间源与层级
flowchart TB
GNSS[卫星授时<br/>GPS / 北斗等多星座] --> GM1[主时钟 A<br/>带恒温晶振或铷钟保持]
GNSS --> GM2[主时钟 B<br/>备份,独立天线]
GM1 --> SW[交换机<br/>PTP 透明时钟 / 边界时钟]
GM2 --> SW
SW --> NIC[服务器网卡硬件时钟<br/>ptp4l 校准]
NIC --> SYS[系统时钟<br/>phc2sys 校准]
NIC --> TS[网卡硬件时间戳<br/>收发报文]
SYS --> APP[应用读取时间]
| 层级 |
作用 |
| 卫星授时 |
提供可溯源到 UTC 的时间;天线与接收机是整个链路的起点 |
| 主时钟(grandmaster) |
输出 PTP;卫星信号丢失时靠高稳定度振荡器保持一段时间内的精度(holdover) |
| 交换机 |
透明时钟修正报文在交换机内的停留时间;边界时钟在交换机处重新同步并向下游授时 |
| 网卡硬件时钟(PHC) |
由 PTP 校准;报文的硬件时间戳来自它 |
| 系统时钟 |
由网卡硬件时钟校准,供应用读取 |
部分交易所机房提供 PTP 或卫星时间信号服务,可以直接接入。
4. 协议对比
| 协议 |
精度量级 |
条件 |
| NTP |
局域网内亚毫秒到毫秒 |
软件时间戳,受操作系统调度影响 |
| PTP(IEEE 1588) |
硬件时间戳下为亚微秒,条件良好时数十纳秒 |
网卡与交换机支持硬件时间戳与 PTP |
| White Rabbit |
亚纳秒 |
专用硬件,源自 CERN 的开放硬件项目 |
| PPS(秒脉冲) |
纳秒级 |
卫星接收机直接输出到设备,只能在本地使用 |
5. PTP 原理
主时钟与从时钟交换四个时间戳:
t1:主时钟发送 Sync 的时刻(由 Follow_Up 告知精确值)
t2:从时钟收到 Sync 的时刻
t3:从时钟发送 Delay_Req 的时刻
t4:主时钟收到 Delay_Req 的时刻(由 Delay_Resp 告知)
路径延迟 = ((t2 − t1) + (t4 − t3)) / 2
时钟偏差 = ((t2 − t1) − (t4 − t3)) / 2
| 要点 |
说明 |
| 对称假设 |
公式假设往返路径延迟相等;实际不对称时,产生不对称量一半的系统性偏差,而且偏差不会反映在测得的偏差值上 |
| 硬件时间戳 |
在网卡收发报文的时刻打时间戳,排除操作系统协议栈的抖动 |
| 透明时钟 |
交换机把报文在内部的停留时间写入报文,从时钟据此扣除 |
| 最佳主时钟算法(BMCA) |
多个主时钟时自动选出最优者;主时钟故障时自动切换 |
6. 服务器侧
| 要点 |
说明 |
| 两级校准 |
ptp4l 用 PTP 校准网卡硬件时钟,phc2sys 用网卡硬件时钟校准系统时钟;chrony 也可以把网卡硬件时钟作为参考源 |
| 跨机可比的时间戳 |
各服务器网卡硬件时钟同步后,报文的硬件时间戳可以跨机比较,用于计算网络延迟 |
| 应用读时间 |
clock_gettime 通过 vDSO 读取,开销为数十纳秒量级;热路径常读 TSC(rdtsc,几纳秒),并定期把 TSC 校准到系统时钟,要求 CPU 支持 invariant TSC |
| 时钟类型 |
测量时间间隔用单调时钟(CLOCK_MONOTONIC)或 TSC;记录时刻用 CLOCK_REALTIME;PTP 内部使用 TAI(国际原子时,与 UTC 相差闰秒) |
| 不允许跳变 |
只在启动或开盘前允许一次性校正(step),盘中只允许渐进调整(slew);时间倒退会破坏定时器与延迟计算 |
| 闰秒 |
统一处理策略(平滑分摊或以 TAI 记录);国际计量大会已决定最迟于 2035 年起不再增加闰秒 |
7. 时间使用规范
| 场景 |
规范 |
| 事件先后 |
同一来源内用序号;跨来源只能用交易所时间近似;不用本地时间决定状态 |
| 延迟测量 |
同机内的分段用 TSC 或单调时钟;跨机的分段用同步后的硬件时间戳,并注意同步误差是测量误差的下限 |
| 定时器 |
间隔由单调时钟驱动;交易时段由 UTC 时间结合交易日历判断 |
| 存储格式 |
统一 UTC;64 位整数纳秒(从 1970 年起算,可表示到 2262 年);字段名注明单位与来源(交易所、网卡、应用) |
| 回测 |
使用事件时间驱动的模拟时钟,不读系统时间 |
8. 监控与合规留证
| 监控项 |
说明 |
| 与主时钟的偏差 |
每台服务器的偏差分布;超过阈值告警,阈值按用途设定 |
| 路径延迟 |
突变意味着网络路径变化,可能带来新的不对称 |
| 校准状态 |
伺服是否处于锁定状态 |
| 主时钟切换 |
最佳主时钟算法的选择变化 |
| 卫星状态 |
锁定状态、可见卫星数、保持时长 |
| 超差时的处理 |
说明 |
| 标记时间戳质量 |
超差期间的时间戳标记为降级,延迟统计不采用 |
| 依赖跨机时间的策略 |
暂停依赖跨交易所时间比较的逻辑 |
| 合规记录 |
记录超差的起止时间与原因 |
监管要求能够证明时间可溯源到 UTC,因此同步状态与偏差记录需要长期保存。
9. 冗余与故障
| 故障 |
对策 |
| 主时钟故障 |
双主时钟,最佳主时钟算法自动切换 |
| 卫星信号丢失 |
主时钟的高稳定度振荡器在保持期内维持精度;监控保持时长,超过时告警 |
| 卫星信号干扰或欺骗 |
多星座接收;天线安装位置合理;监测与备用时间源之间的跳变 |
| 网络路径变化 |
监控路径延迟;PTP 走固定路径 |
| 单台服务器失步 |
告警并标记该服务器的时间戳质量 |
10. 部署要点
- 交换机支持 PTP(透明时钟或边界时钟),否则经过交换机的排队抖动会显著降低精度
- 网卡支持硬件时间戳,且 PTP 运行在同一块网卡或同一个硬件时钟上
- 系统时钟只有一个校准来源,NTP 与 PTP 不能同时调整系统时钟
- 开盘前确认所有交易服务器处于锁定状态
- 虚拟机的时间精度受宿主机影响,精度要求高的场景使用物理机;部分云厂商提供基于硬件的高精度时间服务
11. 常见问题清单
| 问题 |
后果 |
对策 |
| 用本地时间戳排序跨机事件 |
顺序错误 |
同源用序号,跨源用交易所时间近似 |
| NTP 与 PTP 同时调整系统时钟 |
相互争夺,偏差来回振荡 |
只保留一个校准来源 |
| 盘中时钟跳变 |
定时器异常、延迟为负、时间倒退 |
盘中只允许渐进调整,开盘前完成同步 |
用 CLOCK_REALTIME 测时间间隔 |
时钟调整时得到错误甚至为负的间隔 |
单调时钟或 TSC |
| TSC 未校准或 CPU 不支持 invariant TSC |
TSC 换算的时间漂移 |
确认 invariant TSC,定期校准 |
| 路径不对称 |
系统性偏差,监控上看不出来 |
交换机支持 PTP,测量并补偿不对称 |
| 交换机不支持 PTP |
精度随网络负载波动 |
使用支持 PTP 的交换机 |
| 用软件时间戳代替硬件时间戳 |
精度不足,受调度抖动影响 |
网卡硬件时间戳 |
| 闰秒处理不一致 |
时间重复或跳过一秒,各系统之间差一秒 |
统一的闰秒策略 |
| 时区与夏令时处理错误 |
时段判断错误 |
内部统一 UTC |
| 时间戳单位混用 |
数值差 1000 倍 |
统一纳秒整数,字段名注明单位 |
| 只监控进程存活,不监控偏差 |
失步长期未被发现 |
监控偏差与锁定状态 |
| 卫星失锁未被发现 |
时钟缓慢漂移 |
监控卫星状态与保持时长 |
| 无法证明时间溯源 |
合规问题 |
长期保存同步记录 |
| 高精度需求运行在虚拟机上 |
精度不足 |
物理机或云厂商的硬件时间服务 |
12. 开源方案
| 项目 |
用途 |
| linuxptp |
Linux 上的 PTP 实现:ptp4l(PTP 协议)、phc2sys(网卡时钟与系统时钟之间校准)、pmc(管理与查询) |
| chrony |
NTP 实现,可以把网卡硬件时钟或秒脉冲作为参考源 |
| White Rabbit |
CERN 的开放硬件项目,亚纳秒级同步 |
| Open Compute Project 时间设备项目 |
开放的时间卡与授时设备设计;Meta 开源了相关的 PTP 软件(facebook/time 仓库) |
主时钟与卫星接收设备通常采购商用产品。
十五、行情与事件录制的设计与实现
录制系统保存外部行情的原始报文、标准化事件,以及系统内部的全部事件(信号、订单、回报、配置变更)。录制数据是回测、确定性重放、事故分析与审计的最终依据,也是研究域数据仓库的来源。第二章第 13 节从行情网关的角度提到了录制,本章给出录制作为独立子系统的设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 抓取并保存原始报文、标准化事件、内部事件日志 |
行情解码与订单簿重建(行情网关) |
| 完整性检查、缺失补录 |
研究数据的特征加工(研究域) |
| 分层存储、索引、保留与归档 |
事件日志的实时分发(事件总线,第四章) |
| 重放工具 |
|
| 访问控制与合规保留 |
|
2. 录制对象
| 对象 |
内容 |
主要用途 |
| 原始行情报文 |
交易所组播 A、B 两路的完整报文,含硬件时间戳 |
重建一切行情数据的最终依据;行情网关测试 |
| 交易会话报文 |
交易网关收发的全部报文 |
订单与回报的原始证据;线上延迟分析 |
| 标准化行情事件 |
行情网关输出的统一格式事件 |
回测、研究 |
| 内部事件日志 |
事件总线上的全部事件:订单簿更新、信号、订单、回报、风控决策、配置变更 |
确定性重放、事故分析、审计 |
| 订单簿快照 |
定期保存的订单簿全量状态 |
重放时从最近的快照开始,而不必从当日开盘重放 |
3. 录制点与方式
flowchart LR
EXL[交易所线路] --> TAP[交换机镜像 / 分光器]
TAP --> CAP[抓包设备<br/>硬件时间戳]
CAP --> HOT[本地高速存储]
GW[行情 / 交易网关] -. 旁路写入 .-> HOT
BUS[事件总线日志] --> REC[录制进程<br/>订阅日志]
REC --> HOT
HOT -- 异步上传 --> WARM[对象存储 / 数据湖]
WARM --> COLD[归档存储]
WARM --> DW[数据仓库<br/>研究域]
| 方式 |
优点 |
缺点 |
| 网络旁路抓包 |
完整,与交易主机无关,不影响热路径;硬件时间戳反映报文在线路上的真实时刻 |
需要专用硬件与网络配置 |
| 进程内旁路写入 |
实现简单,可记录解码后的内容 |
实现不当会影响热路径;进程自身丢包时录制也丢失 |
| 订阅事件总线日志 |
内部事件的唯一来源;日志本身已持久化(第四章第 4 节) |
只包含进入总线的事件 |
录制进程以普通消费者身份读取日志,落后或故障时只影响录制本身,不影响交易(第四章第 5 节)。
4. 完整性
| 手段 |
说明 |
| 录制时序号检查 |
按通道检查序号,缺口实时标记并告警 |
| 双路录制 |
A、B 两路分别录制,或两台设备同时录制,合并时去重补缺 |
| 日终核对 |
每日消息数与交易所发布的统计或历史文件核对 |
| 缺失补录 |
通过交易所重传服务、交易所历史文件或数据商补录缺失部分,并标记来源 |
| 文件校验 |
每个文件保存校验和,上传与归档后再次校验 |
| 录制器监控 |
录制延迟、写入速率、磁盘剩余空间、录制进程心跳 |
5. 存储格式与分层
| 层 |
介质 |
内容 |
保留 |
| 热层 |
本地 NVMe |
当日及最近几日的原始报文与日志 |
天 |
| 温层 |
对象存储 / 数据湖 |
压缩后的原始报文;按日期、交易所、品种分区的 Parquet 标准化数据 |
月到年 |
| 冷层 |
归档存储 |
合规需要保留的数据 |
按监管要求 |
| 要点 |
说明 |
| 格式 |
原始报文用 pcapng(支持纳秒时间戳);内部事件用日志原始格式(Chronicle Queue、Aeron Archive 等);研究用 Parquet 或 DBN |
| 压缩 |
行情数据重复度高,zstd 等压缩算法可显著减小体积 |
| 分区 |
按日期、交易所、通道或品种分区,查询只读取需要的部分 |
| 索引 |
时间 → 文件偏移、序号 → 文件偏移,支持快速定位 |
6. 时间戳与关联
- 网络抓包的硬件时间戳、交易所时间戳、内部事件时间戳三者依赖时钟同步才能关联(第十四章)
- 行情进入与订单离开两个抓包点的时间差即真实的 tick-to-trade(第十一章第 11 节)
- 内部事件带全局序号,与抓包记录通过订单号、行情序号关联
7. 重放
| 模式 |
输入 |
输出与用途 |
| 报文重放 |
原始报文按原始时间间隔或加速送入行情网关 |
网关回归测试、压力测试 |
| 行情重放 |
标准化行情事件送入策略引擎 |
回测 |
| 事件日志重放 |
内部事件日志送入同一版本代码 |
确定性重放:复现线上状态、事故分析、新版本回归比对(第一章第 3 节 ②) |
| 要点 |
说明 |
| 速度 |
原速(测试时序相关逻辑)、尽可能快(回测)、单步(调试) |
| 起点 |
从目标时刻之前最近的快照开始,再重放后续事件 |
| 确定性 |
同样的输入与代码版本必须产生同样的输出;不一致即说明存在非确定性来源 |
8. 数据加工流水线
每日收盘后:原始报文 → 完整性检查 → 标准化 → 订单簿重建 → K 线与特征 → 写入数据仓库(第一章第 2.1 节)。
- 加工代码有版本号,每份产出数据记录由哪个版本的代码从哪份原始数据生成
- 解析器修复缺陷后,从原始报文重新加工受影响的日期;原始报文因此必须长期保留
9. 合规与数据授权
| 要点 |
说明 |
| 保留期限 |
监管要求保存订单、成交与相关记录若干年(如 MiFID II 要求保存 5 年) |
| 不可篡改 |
归档数据使用对象锁定或一次写入多次读取(WORM)存储 |
| 访问控制 |
交易与订单数据属于敏感信息,按角色授权,访问留痕 |
| 行情授权 |
交易所对行情数据的存储、内部使用、再分发有授权与收费规定,录制与分发需符合授权范围 |
10. 容量规划
- 按消息速率 × 消息大小估算每日数据量,按峰值日(指数调整日、剧烈波动日)而非平均日规划
- 规划存储增长、上传带宽、归档成本与检索性能
- 按价值分级保留:交易品种保留全深度,非交易品种可只保留标准化数据或降低深度
11. 常见问题清单
| 问题 |
后果 |
对策 |
| 只录标准化数据,不录原始报文 |
解析器缺陷无法修正,数据无法重新加工 |
原始报文长期保留 |
| 录制在热路径上同步写盘 |
交易延迟抖动 |
网络旁路或订阅日志 |
| 录制缺口未被发现 |
回测与事故分析基于不完整数据 |
录制时序号检查,日终核对 |
| 只录制行情,不录制内部事件 |
无法复现决策过程 |
录制事件总线的全部事件 |
| 抓包时间戳不是硬件时间戳 |
延迟分析失真 |
硬件时间戳 |
| 时钟未同步就关联多处记录 |
时间对不上 |
时钟同步并监控偏差 |
| 没有索引 |
定位某一时刻需要扫描整日数据 |
时间与序号索引 |
| 重放只能从开盘开始 |
分析午后的事件耗时过长 |
定期保存快照 |
| 加工数据不记录来源与代码版本 |
无法判断哪些数据受缺陷影响 |
记录数据血缘 |
| 本地磁盘写满 |
录制中断 |
容量监控,按策略滚动上传与清理 |
| 按平均日规划容量 |
峰值日录制丢失 |
按峰值规划 |
| 归档数据可被修改或删除 |
合规风险 |
对象锁定、WORM |
| 超出行情授权范围使用或分发 |
违反授权协议 |
按授权范围管理 |
12. 开源方案
| 项目 |
用途 |
| libpcap / tcpdump |
抓包与 pcap 文件 |
| tcpreplay |
按原始时间间隔或指定速率重放 pcap 文件 |
| Chronicle Queue、Aeron Archive |
事件日志的持久化与重放 |
| Apache Parquet / Arrow、DuckDB |
标准化数据的列式存储与查询 |
| Databento DBN |
标准化行情编码格式 |
| NautilusTrader 数据目录 |
基于 Parquet 的行情与事件存储(crates/persistence/) |
| hftbacktest |
行情转换为回测数据格式的工具 |
| zstd |
压缩 |
| MinIO 等对象存储 |
温层存储,支持对象锁定 |
十六、回测与仿真系统的设计与实现
回测用历史数据评估策略;仿真在实时或接近实时的环境中验证策略与整个系统。二者与实盘共用同一套策略引擎与策略代码,只替换时钟、数据与执行适配器(第一章第 3 节 ①、第五章)。回测的基本概念与一个最小实现见 量化交易系统_qa.md 第 3、4 题;本章给出回测与仿真平台的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 回测引擎:模拟时钟、事件调度、撮合模拟、账户模拟 |
策略逻辑(策略代码与实盘相同) |
| 回测数据服务:按时点提供行情、参考数据、成本参数 |
原始数据的采集与加工(录制与数据仓库,第十五章) |
| 成本、冲击、延迟模型 |
模型训练(研究域) |
| 任务调度与并行、结果存储与分析 |
实盘交易 |
| 仿真环境:模拟盘、影子模式、全链路仿真 |
|
| 回测与实盘的一致性核对 |
|
2. 回测与仿真的形态
| 形态 |
数据 |
成交 |
验证什么 |
| 向量化回测 |
历史 K 线或因子矩阵 |
按收盘价或次日开盘价假设成交 |
大规模初筛(第一章第 2.1 节) |
| 事件驱动回测 |
历史行情事件 |
撮合模拟器 |
策略在接近实盘的执行逻辑下的表现 |
| 高频回测 |
历史订单簿逐笔重放 |
排队位置与延迟模型 |
做市与高频策略 |
| 模拟盘 |
实时行情 |
撮合模拟器 |
系统在实时数据下的运行,不验证成交真实度 |
| 影子模式 |
实时行情,生产系统 |
只计算不下单 |
实盘信号与回测信号是否一致 |
| 交易所 / 柜台测试环境 |
测试环境行情 |
真实协议与撮合,流动性非真实 |
网关、OMS 的协议与状态处理(第十章第 12 节) |
| 全链路仿真 |
录制的生产行情重放 |
基于订单簿重放的模拟交易所 |
全系统集成、性能、故障演练 |
| 多智能体市场模拟 |
模拟的市场参与者 |
模拟交易所撮合 |
市场冲击、策略之间的相互作用 |
3. 总体架构
flowchart LR
subgraph DATA[回测数据服务]
D1[时点行情<br/>K 线 / 逐笔 / 订单簿]
D2[时点参考数据<br/>第十二章]
D3[成本与延迟参数<br/>由 TCA 标定]
end
JOB[回测任务定义<br/>策略版本 + 参数 + 数据范围 + 模型版本 + 随机种子] --> SCH[调度器]
SCH --> W1[工作节点]
SCH --> W2[工作节点]
DATA --> W1
DATA --> W2
subgraph W[工作节点内部]
CLK[模拟时钟] --> ENG[策略引擎<br/>与实盘相同]
ENG --> RISK[事前风控规则]
RISK --> OMS[模拟 OMS / 账户]
OMS --> MATCH[撮合模拟器]
MATCH --> OMS
end
W1 --> RES[结果存储<br/>曲线 / 成交 / 事件日志 / 指标]
W2 --> RES
RES --> ANA[分析与报告]
ANA --> APPROVE[策略审批上线]
工作节点中的策略引擎、风控规则、OMS 状态机与实盘使用同一份代码;只有时钟、数据源、撮合模拟器是回测专用的适配器。
4. 数据层
| 要点 |
说明 |
| 时点数据 |
所有数据按"当时可得"提供:财务数据按公告日、指数成分按生效日、参考数据按知晓时间(第一章第 3 节 ⑥、第十二章第 4 节) |
| 无幸存者偏差 |
证券池包含已退市证券 |
| 原始价格加事件 |
使用原始价格,分红以现金入账、拆股以持仓变化体现,与实盘一致;复权价格只用于计算信号 |
| 多种粒度 |
K 线、逐笔成交、订单簿快照、逐笔委托,按策略频率选择 |
| 数据可得延迟 |
K 线在收盘后才可得,加上生成与传输延迟;行情事件按接收时间而非交易所时间送达策略 |
| 数据版本 |
回测结果记录所用数据快照的版本号;数据修正后可重新运行并比较 |
| 缺失与停牌 |
停牌期间没有行情也不能交易;缺失数据标记而不是填充为零 |
5. 时间与事件调度
| 要点 |
说明 |
| 模拟时钟 |
由事件时间驱动,策略中不存在系统时间(第五章第 7 节) |
| 事件排序 |
按(事件时间,序号)排序,同一时刻的行情与成交回报的先后规则固定且与实盘一致(第五章第 4 节) |
| 延迟注入 |
策略下单到模拟交易所收到订单、成交回报返回策略,都按延迟模型推迟 |
| 多数据源合并 |
多个品种、多个交易所的事件按时间归并 |
| 确定性 |
随机成交模型、随机延迟使用固定种子;同样的输入与代码得到同样的结果 |
6. 撮合模拟器
按数据粒度选择成交模型:
| 粒度 |
成交规则 |
局限 |
| K 线 |
下一根 K 线开盘价成交,或限价单在最高最低价范围内成交;单根 K 线成交量设上限 |
不知道 K 线内的价格路径,限价单成交被高估 |
| 逐笔成交 |
限价单在成交价穿过或触及挂单价时成交 |
不知道排队位置 |
| 订单簿(L2) |
按挂单时该价位的显示量估计排队位置,随成交与撤单向前推进;撤单发生在自己前面还是后面按概率模型估计 |
排队位置是估计值 |
| 逐笔委托(L3) |
按订单级重放,排队位置精确 |
数据量大,需要 L3 数据 |
| 要点 |
说明 |
| 延迟模型 |
下单延迟、行情延迟、回报延迟分别建模,参数来自实盘测量的分布(第十一章第 11 节),而不是固定常数 |
| 交易规则 |
涨跌停、交易单位、集合竞价撮合、T+1、卖空限制、自成交防范、订单类型与有效期 |
| 部分成交 |
按可成交量部分成交,剩余继续挂单 |
| 拒单 |
模拟风控拒单与交易所拒单,使策略在回测中也要处理拒单 |
| 消耗流动性 |
自己的成交消耗历史订单簿中的对应数量,避免同一份流动性被重复使用 |
| 市场冲击 |
历史数据不会因自己的订单而改变,需要叠加冲击模型(临时冲击与永久冲击,第七章第 2 节);这是回测的根本局限:无法得到"如果我当时下了单,市场会怎样"的真实答案 |
7. 成本模型
| 成本 |
来源 |
| 佣金、税费、交易所费用 |
参考数据中按时点的费率(第十二章) |
| 价差 |
主动成交时按当时的买卖价差计算 |
| 市场冲击 |
平方根冲击模型,系数由实盘 TCA 标定(第七章第 10 节) |
| 融资、借券、资金费率 |
按时点费率计算 |
实盘 TCA 的结果定期回灌到回测的成本参数,使回测成本与实盘保持一致。
8. 账户与组合模拟
| 要点 |
说明 |
| OMS 状态机 |
与实盘相同的订单状态机与冻结规则(第九章) |
| 资金与保证金 |
资金冻结、期货保证金、逐日盯市与追加保证金 |
| 公司行为 |
分红入账、送股与拆股调整持仓 |
| 期货换月 |
信号可以用连续合约计算,但交易必须落在具体合约上,并模拟换月交易与成本 |
| 多币种 |
按时点汇率折算 |
| 风控规则 |
回测中执行与实盘相同的事前风控规则(第八章),避免回测允许而实盘拒绝的订单 |
9. 并行与复现
| 要点 |
说明 |
| 并行维度 |
策略 × 参数 × 时间窗口天然可并行;横截面组合策略需要全部品种在同一进程中,只能按时间段切分 |
| 数据就近 |
列式数据缓存在计算节点附近,避免每个任务重复读取 |
| 中间结果缓存 |
因子、特征等中间结果以"代码版本 + 数据版本 + 参数"为键缓存 |
| 调度 |
Ray、Dask、Kubernetes 等集群调度 |
| 复现 |
每次运行记录代码版本、数据快照版本、参数、随机种子、运行环境(容器镜像),任何结果都能重新得到 |
10. 结果与分析
| 输出 |
内容 |
| 明细 |
资金曲线、订单、成交、持仓、事件日志 |
| 绩效 |
收益、夏普、最大回撤、换手、胜率、资金容量 |
| 成本 |
费用、价差、冲击分项 |
| 归因 |
因子暴露与贡献(第十九章) |
| 标准报告 |
固定模板,作为策略审批的依据(第一章第 2.1 节) |
| 比较 |
不同版本、不同参数的结果对比,存入实验跟踪系统 |
11. 偏差与过拟合
| 问题 |
表现 |
对策 |
| 前视偏差 |
使用了当时不可得的数据 |
时点数据;数据可得延迟 |
| 幸存者偏差 |
证券池只含现存证券 |
包含已退市证券 |
| 多重检验 |
尝试次数多,总有偶然显著的结果 |
记录全部尝试次数;Deflated Sharpe Ratio;回测过拟合概率(PBO,Bailey 等 2017) |
| 样本内调参 |
样本外表现大幅下降 |
滚动前推检验;训练与测试之间留间隔(purging / embargo) |
| 参数尖峰 |
只有某个精确参数有效 |
选择参数平台区域,检查参数敏感性 |
| 成本低估 |
高换手策略收益虚高 |
成本模型由实盘标定 |
| 容量高估 |
小资金有效,放大后失效 |
冲击模型与流动性约束 |
| 市场状态依赖 |
只在特定行情下有效 |
分时期、分市场状态检验,包含压力时期 |
| 事后挑选 |
只汇报最好的一次运行 |
预先登记假设与评估方法;保留一段锁定的最终检验数据,只使用一次 |
12. 仿真环境
| 环境 |
做法 |
验证 |
| 模拟盘 |
实时行情 + 回测所用的撮合模拟器 |
实时数据处理与系统集成 |
| 影子模式 |
生产系统计算信号与订单但不发送 |
实盘信号与同期回测信号的一致性 |
| 交易所 / 柜台测试环境 |
真实协议与撮合 |
网关与 OMS(第十章第 12 节) |
| 全链路仿真 |
录制的生产行情(第十五章)按原速重放,经完整的生产系统,连接由订单簿重放构建的模拟交易所 |
集成、性能、故障切换演练(第一章第 6.2 节) |
| 多智能体市场模拟 |
大量模拟参与者与模拟交易所 |
市场冲击与策略间相互作用的研究 |
13. 回测与实盘的一致性
用实盘同期的数据与同一版本代码运行回测,逐日比较订单、成交、持仓与 PnL:
| 差异来源 |
说明 |
| 数据 |
回测数据与实盘实际收到的行情不同(缺失、延迟、修正) |
| 时序 |
实盘的延迟与事件顺序和回测假设不同 |
| 成交模型 |
回测的成交率、成交价与实盘不同 |
| 成本 |
实际费用与冲击和模型不同 |
| 缺陷 |
回测与实盘代码路径的差异 |
差异按来源分解并持续跟踪,结果用于修正成交模型、延迟模型与成本模型;用实盘录制的数据重新回测,是排除数据差异的最直接方法。
14. 常见问题清单
| 问题 |
后果 |
对策 |
| 回测与实盘两套策略代码 |
结果无法对应,差异无法定位 |
同一引擎,只替换适配器 |
| 用收盘价计算信号并按同一收盘价成交 |
前视偏差 |
下一时刻成交,加入数据可得延迟 |
| 使用复权价格计算持仓与盈亏 |
与实盘现金流不一致 |
原始价格加公司行为事件 |
| 限价单只要价格触及就成交 |
成交率被高估 |
排队模型;按粒度选择成交规则 |
| 自己的成交不消耗历史流动性 |
同一份流动性被重复使用 |
成交后扣减订单簿数量 |
| 固定延迟 |
低估延迟尾部的影响 |
按实测分布抽样 |
| 回测不执行风控规则 |
回测收益包含实盘会被拒绝的订单 |
回测执行相同规则 |
| 连续合约直接交易 |
忽略换月成本与价差 |
交易落在具体合约上 |
| 结果不记录数据与代码版本 |
无法复现 |
运行元数据完整记录 |
| 随机模型不固定种子 |
同一配置结果不同 |
固定种子 |
| 反复在全部数据上调参 |
过拟合 |
锁定最终检验数据 |
| 只汇报最优参数 |
高估策略质量 |
记录全部尝试,报告参数敏感性 |
| 成本参数长期不更新 |
回测成本与实盘偏离 |
TCA 定期回灌 |
| 模拟盘表现良好即认为策略可行 |
模拟盘不验证成交真实度 |
小资金实盘验证 |
| 不做回测与实盘对账 |
实盘跑输时无法区分原因 |
同期回测逐日比较 |
15. 开源方案
| 项目 |
回测与仿真能力 |
| NautilusTrader |
回测与实盘同一引擎;概率成交模型(限价单成交概率、滑点概率)、延迟模型、费用模型(crates/execution/src/models/) |
| LEAN |
事件驱动回测;费用、成交、滑点模型可替换(Common/Orders/Fees、Fills、Slippage) |
| hftbacktest |
订单簿逐笔重放;排队模型包括风险厌恶模型、概率排队模型(多种概率函数)、L3 先进先出模型;延迟模型包括固定延迟与按录制数据插值的延迟(hftbacktest/src/backtest/models/) |
| vectorbt |
向量化回测,参数扫描 |
| zipline-reloaded、backtrader |
事件驱动回测 |
| Qlib |
带交易成本与交易所规则模拟的回测,以及滚动训练工作流 |
| cvxportfolio |
组合层回测,内置成本模型 |
| Ray、Dask、MLflow |
并行调度与实验跟踪 |
| ABIDES(JPMorgan,公开仓库已归档) |
多智能体市场模拟 |
十七、风险模型的设计与实现
风险模型估计资产收益的协方差结构:每个资产暴露于哪些风险来源、这些来源的波动与相关性如何、剩余的特异风险有多大。它是组合优化的输入(第六章第 4、5 节),也用于事前风险与跟踪误差估计、风险分解、风格暴露控制、收益归因(第十九章)、对冲与压力测试。本章给出风险模型的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 因子暴露、因子收益、因子协方差、特异风险的估计 |
预期收益(alpha)的预测(策略与研究模型) |
| 模型检验与版本发布 |
组合优化求解(组合构建,第六章) |
| 风险分解、情景与压力测试所需的计算接口 |
逐笔订单的限额检查(事前风控,第八章) |
2. 模型类型
| 类型 |
做法 |
优点 |
缺点 |
| 样本协方差 |
直接用历史收益估计 N × N 协方差 |
简单 |
参数个数为 N(N+1)/2;历史长度小于资产数时矩阵奇异;估计误差被优化器放大 |
| 收缩估计 |
样本协方差向结构化目标收缩(Ledoit–Wolf) |
简单且稳定 |
缺少可解释的风险来源 |
| 统计因子模型 |
主成分分析提取因子 |
不依赖基本面数据 |
因子含义不明确,随时间旋转 |
| 基本面因子模型 |
以行业、风格等可解释的特征作为暴露,横截面回归估计因子收益 |
可解释,适合风格控制与归因;A 股、美股的主流做法 |
构建与维护成本高 |
| 宏观因子模型 |
以利率、通胀、商品等宏观变量作为因子,时间序列回归估计暴露 |
适合资产配置与宏观情景分析 |
对个股截面的解释力有限 |
以下以基本面因子模型为主。
3. 结构与流程
资产收益 r = X·f + u
协方差 Σ = X·F·Xᵀ + D
X:N × K 因子暴露矩阵 f:K 个因子收益 u:特异收益
F:K × K 因子协方差 D:N × N 对角特异方差矩阵
N 为资产数(数千),K 为因子数(数十),需要估计的参数从 N² 量级降到 K² + N 量级。
flowchart LR
DATA[行情 / 财务 / 行业分类 / 市值<br/>时点数据] --> EXP[计算因子暴露<br/>描述变量 → 因子 → 去极值 / 标准化 / 正交化]
EXP --> REG[每日横截面回归<br/>因子收益 + 特异收益]
REG --> FCOV[因子协方差估计]
REG --> SRISK[特异风险估计]
FCOV --> VAL[模型检验]
SRISK --> VAL
VAL --> PUB[按日版本发布]
PUB --> PC[组合构建]
PUB --> MON[风险监控]
PUB --> ATT[收益归因]
PUB --> BT[回测]
4. 估计域与覆盖域
| 概念 |
说明 |
| 估计域 |
参与回归估计因子收益的股票:流动性好、有代表性,剔除风险警示股、上市不久的新股、长期停牌股 |
| 覆盖域 |
所有需要风险预测的股票都计算暴露并得到协方差,包括不在估计域中的股票 |
| 新股 |
历史数据不足,特异风险用结构化模型估计(第 8 节) |
| 停牌 |
停牌期间收益不参与回归,复牌后按规则处理 |
5. 因子体系
| 因子类别 |
内容 |
| 国家(市场)因子 |
所有股票暴露均为 1,代表市场整体 |
| 行业因子 |
每只股票属于一个行业,暴露为 0 或 1;行业分类来自参考数据(第十二章),分类调整需按时点处理 |
| 风格因子 |
常见的有规模、Beta、动量、残差波动率、非线性规模、账面市值比、流动性、盈利收益率、成长、杠杆 |
风格因子的构建:
| 步骤 |
说明 |
| 描述变量 |
每个风格由若干描述变量组成,例如流动性由不同窗口的换手率组成 |
| 合成 |
描述变量标准化后加权合成 |
| 去极值 |
截断或压缩极端值 |
| 标准化 |
常用约定:按市值加权的均值为 0,等权标准差为 1,使市场组合的风格暴露为 0 |
| 正交化 |
高度相关的因子做正交处理,例如残差波动率对规模与 Beta 回归取残差 |
| 缺失值 |
用行业与规模回归填补,而不是填 0 |
国家因子与全部行业因子之间存在共线性(所有行业暴露之和恒为 1),回归时加约束:按市值加权的行业因子收益之和为 0。
6. 因子收益估计
| 要点 |
说明 |
| 方法 |
每日横截面加权最小二乘回归,得到当日各因子收益与各股票的特异收益 |
| 权重 |
常用市值平方根:大市值股票的特异波动较小,回归中应给予更高权重 |
| 约束 |
行业因子收益的市值加权和为 0 |
| 稳健性 |
对极端收益做稳健处理,防止个别股票主导回归 |
| 监控 |
回归的 R²、各因子收益的显著性与稳定性 |
7. 因子协方差估计
| 调整 |
说明 |
| 指数加权 |
近期数据权重更高;波动率使用较短的半衰期,相关性使用较长的半衰期 |
| Newey–West 调整 |
修正日收益的序列相关,使日频估计可以换算到更长预测期 |
| 特征值调整 |
样本协方差的小特征值方向被系统性低估,优化器恰好偏好这些方向,导致优化后组合的风险被低估;按模拟得到的偏差对特征值做放大修正(Menchero 等 2011) |
| 波动率状态调整 |
用最近一段时间的偏差统计量整体缩放协方差,使模型更快适应市场波动的变化 |
8. 特异风险估计
| 步骤 |
说明 |
| 时间序列估计 |
特异收益的指数加权方差,加 Newey–West 调整 |
| 结构化模型 |
历史不足的股票用"特异波动率对因子暴露的回归"得到估计值 |
| 贝叶斯收缩 |
向同规模分组的均值收缩,减小极端估计 |
| 波动率状态调整 |
与因子协方差相同的整体缩放 |
9. 模型检验
| 方法 |
说明 |
| 偏差统计量 |
标准化收益 z = 实际收益 / 预测波动率,在滚动窗口内计算 z 的标准差:接近 1 表示预测准确,大于 1 表示低估风险 |
| 检验组合 |
分别对随机组合、因子模拟组合、优化后的组合做检验;优化后的组合对模型误差最敏感 |
| 似然与 Q 统计量 |
比较不同模型版本的整体预测质量 |
| 因子显著性 |
因子收益的 t 统计量、解释力 |
| 暴露稳定性 |
暴露的日间变化过大会导致组合不必要的换手 |
10. 使用
风险分解:
组合方差 σ² = wᵀΣw = (Xᵀw)ᵀ F (Xᵀw) + wᵀDw
因子风险 特异风险
边际风险贡献 MCRᵢ = (Σw)ᵢ / σ
风险贡献 RCᵢ = wᵢ × MCRᵢ, Σ RCᵢ = σ
| 用途 |
说明 |
| 组合优化 |
目标函数中的风险项与跟踪误差约束(第六章第 5 节) |
| 事前风险 |
组合波动率、相对基准的跟踪误差 |
| 风险分解 |
按因子、行业、个股分解风险来源与风险贡献 |
| 风格暴露控制 |
监控并限制非预期的风格暴露 |
| 收益归因 |
因子收益 × 因子暴露(第十九章第 5 节) |
| 对冲 |
用股指期货对冲市场因子暴露,计算对冲比例 |
| 压力测试 |
对因子施加冲击(如市场下跌 10%、小市值风格大幅反转),估计组合损失;历史情景回放(如 2015 年股市异常波动、2024 年初 A 股小市值风格的剧烈反转) |
日内风险计算:暴露矩阵与协方差每日更新一次,日内持仓变化时,先计算组合的因子暴露 Xᵀw(N × K 次运算),再计算 K 维二次型,计算量远小于直接使用 N × N 协方差,适合实时风险监控。
11. 工程与运维
| 要点 |
说明 |
| 每日批处理 |
收盘后运行,依赖行情、财务、行业分类、公司行为数据按时就绪;设定完成截止时间并监控 |
| 按时点版本化 |
每日一个版本,研究、回测与生产使用同一套历史版本(第六章第 4 节、第十六章) |
| 输出 |
暴露矩阵(N × K)、因子协方差(K × K)、特异方差(N),数据量小,便于分发与缓存 |
| 模型变更 |
新增或修改因子会改变历史暴露与风险预测,需要重算历史、评估对现有组合与回测的影响,按发布流程上线(第十三章) |
| 监控 |
偏差统计量、覆盖率、极端暴露、日间变化过大的股票与因子(第二十一章) |
12. 其他资产与频率
| 场景 |
做法 |
| 期货与多资产 |
品种数量少,常用收缩后的样本协方差或主成分因子;跨资产类别时使用利率、汇率、商品、权益等资产类别因子 |
| 加密货币 |
全天交易、状态切换频繁;以主要币种作为市场因子,短半衰期估计 |
| 高频与做市 |
关注短周期的库存风险,使用日内波动率与相关性,按分钟或更短周期更新 |
13. 常见问题清单
| 问题 |
后果 |
对策 |
| 大截面直接使用样本协方差 |
矩阵奇异或病态,优化结果极端 |
因子模型或收缩估计 |
| 行业与国家因子共线未加约束 |
回归无唯一解 |
行业因子收益加权和为 0 的约束 |
| 风格因子未标准化或未去极值 |
少数股票主导暴露与回归 |
去极值、标准化 |
| 缺失暴露填 0 |
数据缺失的股票被误认为中性 |
按行业与规模回归填补 |
| 不做特征值调整 |
优化后组合的风险被系统性低估 |
特征值调整,并用优化组合检验 |
| 半衰期过长 |
市场波动变化时反应迟缓 |
波动率与相关性分别设置半衰期,加波动率状态调整 |
| 新股特异风险用极短历史估计 |
估计极不稳定 |
结构化模型与贝叶斯收缩 |
| 只检验随机组合 |
优化组合的偏差未被发现 |
同时检验优化组合 |
| 行业分类调整未按时点处理 |
历史暴露带前视偏差 |
按时点使用参考数据 |
| 研究与生产使用不同版本 |
回测与实盘风险不一致 |
统一的按时点版本 |
| 因子定义修改未重算历史 |
历史与当前不可比 |
修改后重算并评估影响 |
| 只看总风险,不看风格暴露 |
收益被风格押注驱动而不自知 |
风险分解与风格暴露监控 |
| 压力测试只用历史波动率 |
低估极端情景下的损失 |
因子冲击与历史情景回放 |
14. 开源方案
| 项目 |
用途 |
| scikit-learn |
协方差估计:Ledoit–Wolf、OAS 收缩,稀疏逆协方差(GraphicalLasso) |
| PyPortfolioOpt |
风险模型模块:样本协方差、指数加权协方差、半协方差、收缩估计 |
| Qlib |
风险模型:结构化协方差(StructuredCovEstimator)、收缩估计(ShrinkCovEstimator)、POET(POETCovEstimator)(qlib/model/riskmodel/) |
| statsmodels |
加权最小二乘与稳健回归 |
| Riskfolio-Lib |
多种协方差估计方法,用于组合优化 |
完整的多因子风险模型通常自建,或采购商业模型(如 MSCI Barra、Axioma)。
十八、对账的设计与实现
对账核对内部记录与外部真相是否一致:外部真相来自交易所、券商或期货公司、结算机构、托管行与银行。它发现漏记的成交、错误的持仓、费用差异与公司行为处理错误,是状态正确性的最后一道检查。第九章第 7 节从 OMS 的角度概述了对账,本章给出对账作为独立子系统的设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 采集内部与外部数据并标准化 |
订单与持仓的实时维护(OMS,第九章) |
| 匹配、识别差异、分类 |
交易决策 |
| 自动处理已知类型的差异,其余交人工处理 |
盈亏归因(第十九章) |
| 调整记录的审批与审计 |
|
| 日初基线的确认;对账报告与签核 |
|
2. 对账对象与数据源
| 对象 |
内部数据 |
外部数据 |
| 成交 |
OMS 成交记录 |
交易所或柜台回报、drop copy、成交确认、结算单 |
| 持仓 |
OMS 持仓 |
券商或期货公司持仓查询、结算单、托管行持仓 |
| 资金 |
OMS 资金 |
柜台资金、结算单、银行与保证金账户 |
| 费用 |
按费率计算的费用 |
结算单实际收取的费用 |
| 公司行为 |
预期的分红、送股、拆股 |
实际到账的现金与股份 |
| 保证金 |
按保证金率计算的占用 |
期货公司与交易所计算的占用 |
国内期货投资者的结算单可在期货市场监控中心查询,可作为独立于期货公司柜台的外部来源;股票以券商的日终对账单与交割单为准。
3. 对账层次与时点
| 层次 |
频率 |
内容 |
目的 |
| 实时 |
秒级 |
逐笔成交与 drop copy 比对 |
立即发现漏记或多记的成交 |
| 盘中 |
分钟级 |
持仓与资金快照比对 |
发现累积偏差,必要时暂停交易 |
| 日终 |
每日 |
与结算单比对成交、持仓、资金、费用 |
形成次日的基线 |
| 周期 |
月度等 |
与托管行、银行比对 |
资产安全 |
4. 对账流程
flowchart LR
COL[采集<br/>内部 / 外部数据] --> NORM[标准化<br/>标识 / 单位 / 时区 / 精度]
NORM --> MAT[匹配]
MAT --> DIFF[识别差异]
DIFF --> CLS[分类]
CLS --> AUTO{已知类型且<br/>低于阈值?}
AUTO -- 是 --> FIX[自动处理]
AUTO -- 否 --> MAN[人工处理]
FIX --> ADJ[调整事件<br/>审批与审计]
MAN --> ADJ
ADJ --> RPT[对账报告与签核]
5. 匹配规则
| 要点 |
说明 |
| 主键匹配 |
优先用成交编号精确匹配 |
| 组合键匹配 |
缺少成交编号时用"账户 + 品种 + 方向 + 价格 + 数量 + 时间窗口"匹配 |
| 汇总层级 |
结算单常按品种、价格汇总成交,此时先把内部成交按同一口径汇总再比对 |
| 多对一 |
一笔内部成交可能对应外部多笔部分成交,反之亦然 |
| 容差 |
价格精度、费用舍入(如分以下)、时间窗口按数据源设定容差 |
| 标准化 |
品种标识、数量单位(股与手)、时区、交易日(夜盘归属)统一后再匹配 |
匹配结果:完全匹配、部分匹配、单边(内部有外部无,或外部有内部无)、字段不一致。
6. 差异分类与处理
| 差异 |
常见原因 |
处理 |
| 外部有、内部无的成交 |
回报丢失;人工或其他系统下单 |
以外部为准补录,查明原因 |
| 内部有、外部无的成交 |
被交易所撤销的成交;仿真成交混入;重复入账 |
调查后冲销 |
| 数量或价格不一致 |
回报解析错误;成交更正未处理 |
以交易所为准修正 |
| 费用不一致 |
费率配置过期;阶梯费率 |
修正并更新参考数据中的费率(第十二章) |
| 成交一致但持仓不一致 |
期初持仓错误;公司行为未处理;手工调整遗漏 |
追溯期初与公司行为 |
| 资金不一致 |
出入金、利息、费用、分红未记录 |
补录现金流水 |
| 时间差 |
跨日结算、在途资金 |
标记为时间差,下一轮自动复核 |
| 处理原则 |
说明 |
| 自动处理有边界 |
只对已知类型且金额低于阈值的差异自动处理,其余交人工 |
| 暂停交易 |
持仓差异超过阈值时暂停相关账户或策略的交易,确认后恢复(fail-closed) |
| 时限 |
差异按严重程度设定处理时限,超时升级 |
7. 调整与审计
- 调整以新事件的形式写入(冲销、补录、修正),不修改历史记录;OMS 状态随之更新
- 每笔调整关联差异编号,记录原因、提交人、审批人;关键调整双人复核
- 调整事件进入事件日志,重放与审计时可还原
8. 日初基线与日切
| 要点 |
说明 |
| 期初持仓 |
当日期初 = 上一交易日对账确认后的期末 |
| 日切规则 |
A 股把上日买入转为可用持仓;国内期货今仓转为昨仓;期货按结算价盯市 |
| 未关闭差异 |
带着未关闭差异开盘时,相关品种或账户的限额收紧或暂停,并在报告中列出 |
9. 监控与报告
- 差异数量与账龄(未关闭差异持续的天数)
- 自动匹配率、自动处理率、平均处理时长
- 每日对账报告由责任人签核
- 同类差异反复出现说明存在系统性缺陷,需修复根因而不是反复手工调整
10. 常见问题清单
| 问题 |
后果 |
对策 |
| 只做日终对账 |
盘中漏记的成交直到收盘才发现,期间持仓与风控基于错误状态 |
实时与盘中对账 |
| 只与柜台比对 |
柜台自身的错误无法发现 |
引入结算单、drop copy 等独立来源 |
| 汇总口径不一致就比对 |
大量虚假差异 |
统一汇总口径 |
| 单位、时区、交易日未标准化 |
虚假差异 |
匹配前标准化 |
| 容差过宽 |
真实差异被掩盖 |
按数据源设定并定期复核 |
| 直接修改历史记录修正差异 |
审计链断裂 |
调整作为新事件 |
| 自动处理范围过大 |
错误被自动"修正"而未查明原因 |
限定类型与金额阈值 |
| 时间差不跟踪 |
在途款项长期挂账或被遗忘 |
标记并自动复核 |
| 差异不设处理时限 |
差异堆积 |
账龄监控与升级 |
| 同类差异反复手工调整 |
根因长期存在 |
统计差异类型,修复根因 |
| 期初持仓未经确认 |
错误在多日之间传递 |
期初来自对账确认的期末 |
11. 开源与实现
对账高度依赖各券商、期货公司、交易所的数据格式,通常自建:
| 工具 |
用途 |
| DuckDB、PostgreSQL、pandas / Polars |
数据加载、标准化与匹配 |
| NautilusTrader |
实盘启动时的执行对账(与交易场所核对订单与持仓) |
商业领域有专门的对账平台,多用于机构的中后台。
十九、PnL 归因的设计与实现
PnL 归因回答"盈亏从哪里来":按策略、品种、时间拆分,并把收益分解为价差收益、持仓收益、执行成本、因子贡献、选股贡献等来源。它用于评估策略、发现问题、分配资金。本章给出其系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 实时与日终 PnL 计算 |
持仓与成交的真相(OMS、对账) |
| 按多个维度汇总与拆分 |
风险模型的构建(组合构建,第六章第 4 节) |
| 按多种方法分解收益来源 |
执行成本的逐笔分析(TCA,第七章第 10 节,本章引用其结果) |
| PnL 数据的存储、重算与报告 |
|
2. PnL 计算基础
总 PnL = 期末市值 − 期初市值 − 净买入金额 + 现金收入 − 费用
现金收入:分红、利息、资金费率收入等
费用:佣金、税费、交易所费用、融资与借券成本、资金费率支出
| 组成 |
说明 |
| 已实现 PnL |
平仓部分的盈亏 |
| 浮动 PnL |
未平仓部分按标记价格计算的盈亏 |
| 费用与融资 |
单独列示,便于分析成本 |
| 公司行为 |
分红、送股对持仓与现金的影响 |
| 汇兑 |
多币种时,本币收益与汇率变动分开 |
| 要点 |
说明 |
| 标记价格 |
最新价、中间价、收盘价、结算价按用途选择,同一报表内口径一致;期货日终用结算价 |
| 成本计算方法 |
先进先出与移动平均会改变已实现与浮动的划分,但不改变总 PnL |
| 两种算法互相校验 |
按持仓计算(持仓 × 价格变化 + 现金流)与按成交计算(逐笔成交累加)结果必须一致 |
3. 实时 PnL 与日终 PnL
|
实时 PnL |
日终 PnL |
| 数据 |
OMS 成交 + 实时价格 |
对账确认的持仓与成交 + 结算价 |
| 用途 |
盘中监控、风控的亏损限额(第八章) |
正式绩效、归因、报告 |
| 精度 |
近似:标记价格可能用中间价,费用按估算 |
正式口径 |
两者的差异应可解释(标记价格口径、费用估算、盘后调整),不可解释的差异作为告警。
4. 归因维度
公司 → 账户 → 策略 → 品种 → 单笔交易逐级下钻,并可按时间(日内时段、隔夜与日内)、方向(多头与空头)切分。每一层的合计必须等于上一层。
5. 分解方法
| 方法 |
回答的问题 |
适用 |
| 交易层分解 |
做市或高频策略的钱来自价差还是持仓方向 |
做市、高频 |
| 实施缺口分解 |
理想执行与实际执行之间损失了多少 |
所有需要执行的策略 |
| Brinson 归因 |
相对基准的超额来自行业配置还是个股选择 |
相对基准管理的组合 |
| 因子归因 |
收益来自风格与行业暴露,还是特异选股 |
多因子选股、指数增强 |
| 信号归因 |
多个信号各贡献了多少 |
信号层合并的组合 |
交易层分解(做市与高频):
PnL = 价差收益 + 持仓收益 + 返佣 − 费用
价差收益:每笔成交价相对成交时中间价的差额(买在中间价之下、卖在中间价之上为正)
持仓收益:持仓 × 中间价变化
价差收益为正而持仓收益持续为负,通常意味着被逆向选择:成交之后价格往往朝不利方向变动(成交后价格走势见第七章第 10 节)。
实施缺口分解:以决策时价格成交的"理想组合"收益,减去实际组合收益,差额按延迟、价差、市场冲击、机会成本、费用分解(第七章第 2 节),用于区分"信号不好"与"执行不好"。
Brinson 归因(相对基准,按行业 i 分组):
配置效应 = Σ (wₚᵢ − wᵦᵢ) · (Rᵦᵢ − Rᵦ)
选择效应 = Σ wᵦᵢ · (Rₚᵢ − Rᵦᵢ)
交互效应 = Σ (wₚᵢ − wᵦᵢ) · (Rₚᵢ − Rᵦᵢ)
w:权重 R:收益 p:组合 b:基准 Rᵦ:基准总收益
三项之和等于组合相对基准的超额收益。多期归因时各期结果不能直接相加,需要用 Carino、Menchero 等方法链接。
因子归因:
组合收益 = Σₖ 因子暴露ₖ × 因子收益ₖ + 特异收益
因子暴露来自风险模型(第六章第 4 节)。例如指数增强组合的超额收益中,若大部分来自小市值暴露而非特异收益,说明超额主要是风格押注,风格反转时会大幅回撤。
信号归因:信号层合并时,按各信号在合并信号中的权重或边际贡献分摊收益;由于信号相关,分摊结果不严格可加,需要说明所用方法。
6. 多策略与共享账户
| 问题 |
做法 |
| 成交归属 |
按订单上的策略 ID 归属到策略子账户 |
| 内部轧差 |
策略之间内部轧差的部分按轧差时的中间价作为内部转移价格记账,双方各自承担到外部成交的价格差异 |
| 共享成本 |
阶梯佣金、融资成本、平台费用按成交额或资金占用分摊,分摊规则固定并公开 |
| 资金占用 |
按策略的资金与保证金占用计算资金成本,使收益率可比 |
7. 数据与计算架构
flowchart LR
F[成交<br/>OMS] --> ENG[PnL 计算]
P[持仓<br/>对账确认] --> ENG
M[标记价格<br/>行情 / 结算价] --> ENG
ENG --> ATT[归因引擎]
RM[风险模型暴露<br/>第六章] --> ATT
BM[基准权重] --> ATT
TCA[执行成本<br/>第七章] --> ATT
ATT --> CUBE[PnL 数据立方体<br/>日期 × 账户 × 策略 × 品种 × 分项]
CUBE --> RPT[报表 / 监控 / 资金分配]
| 要点 |
说明 |
| 实时路径 |
从事件总线消费成交与价格,增量更新 |
| 日终路径 |
对账完成后批量计算正式口径 |
| 存储 |
多维数据立方体存入 ClickHouse 或 Parquet,支持任意维度汇总 |
| 重算 |
对账调整、价格修正后重算受影响的日期,保留各版本并标明重述原因 |
8. 校验
| 恒等式 |
说明 |
| 分项之和 = 总 PnL |
已实现 + 浮动 + 费用 + 其他 |
| 下层之和 = 上层 |
各策略之和 = 账户,各账户之和 = 公司 |
| 按持仓计算 = 按成交计算 |
两种算法结果一致 |
| 内部 PnL ≈ 外部 PnL |
与券商或结算单的盈亏一致(第十八章) |
| 归因残差 |
各分解项之和与总收益的差额低于阈值,超出则检查数据或方法 |
9. 报告与使用
| 用途 |
内容 |
| 日报 |
各层级 PnL、分项、与前日和基准的比较 |
| 策略评估 |
夏普、回撤、换手、资金容量、实盘与回测的偏差 |
| 执行改进 |
执行成本分项反馈到执行算法参数(第七章) |
| 信号监控 |
实盘 IC 与回测 IC 的偏差,判断 alpha 衰减 |
| 资金分配 |
按风险调整后收益在策略之间分配资金与风险预算 |
| 告警 |
PnL 异常波动、收益主要来自非预期的因子暴露 |
10. 常见问题清单
| 问题 |
后果 |
对策 |
| 标记价格口径不一致 |
同一持仓在不同报表中盈亏不同 |
按用途固定口径并在报表中注明 |
| 期货日终不用结算价 |
与结算单不一致 |
日终用结算价 |
| 费用、融资成本未计入 |
高估策略收益 |
全部成本分项列示 |
| 分红与公司行为未处理 |
除权日出现虚假亏损 |
公司行为进入 PnL 计算 |
| 多币种未分离汇兑 |
汇率波动被误认为策略收益 |
本币收益与汇兑分开 |
| 只看总 PnL 不做分解 |
无法区分信号、执行、风格押注 |
多方法分解 |
| 各期 Brinson 结果直接相加 |
多期合计与实际超额不符 |
使用链接方法 |
| 不监控因子暴露贡献 |
风格押注被当作选股能力 |
因子归因 |
| 内部轧差无转移价格规则 |
策略之间盈亏分配有争议 |
固定内部转移价格规则 |
| 对账调整后不重算 |
归因与正式口径不一致 |
调整后重算并保留版本 |
| 实时与日终差异不解释 |
实时 PnL 不可信,风控受影响 |
差异分项解释并监控 |
| 不做恒等式校验 |
计算错误长期存在 |
每日自动校验 |
11. 开源方案
| 项目 |
用途 |
| pyfolio-reloaded |
绩效与风险报告、逐笔交易分析、基于因子的收益归因 |
| empyrical-reloaded、quantstats |
绩效指标与报告 |
| alphalens-reloaded |
因子层面的收益分析 |
| NautilusTrader Portfolio |
按持仓计算已实现与浮动盈亏 |
| ClickHouse、DuckDB |
PnL 数据立方体的存储与查询 |
Brinson 归因、交易层分解与多策略归属通常自建。
二十、交易成本分析(TCA)的设计与实现
交易成本分析(Transaction Cost Analysis,TCA)度量执行质量与交易成本,并把结果反馈给执行算法、组合构建的成本模型、回测的成本参数以及券商与交易场所的选择。执行引擎一章概述了执行成本的构成与评估基准(第七章第 2 节、第 10 节),本章给出 TCA 作为独立分析系统的设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 事前成本估计、事中执行监控、事后成本分析 |
执行决策本身(执行引擎,第七章) |
| 基准计算、实施缺口分解、成交后价格走势分析 |
全部盈亏的归因(PnL 归因,第十九章;TCA 只分析执行环节) |
| 市场冲击模型的标定 |
订单与成交的真相(OMS,第九章) |
| 按算法、通道、场所、品种等维度的报告 |
|
| 最佳执行的合规证据 |
|
2. 分析阶段
| 阶段 |
时机 |
内容 |
使用者 |
| 事前 |
下单前 |
按数量、波动率、成交量、价差估计成本与风险,选择算法与执行时长 |
组合构建、执行引擎、交易员 |
| 事中 |
执行中 |
实时滑点、进度偏差 |
执行引擎、交易员(第七章第 8 节) |
| 事后 |
执行完成后 |
相对各基准的成本、实施缺口分解、成交后价格走势 |
算法团队、研究、合规 |
3. 基准
| 基准 |
含义 |
注意点 |
| 决策价 |
策略或组合经理做出决策时的价格 |
需要记录决策时刻,否则无法计算延迟成本 |
| 到达价 |
母单到达执行引擎时的中间价 |
实施缺口类算法的主要基准 |
| 区间 VWAP |
执行期间市场的成交量加权均价 |
大单自身的成交会推动 VWAP,参与率高时该基准偏向于"好看" |
| 全日 VWAP |
全天的成交量加权均价 |
执行只占当天一部分时不公平 |
| 参与加权价格(PWP) |
按设定参与率模拟执行得到的价格 |
与参与率类算法对应 |
| 开盘价、收盘价 |
集合竞价价格 |
以收盘价为基准的指数调仓 |
滑点统一换算为基点,正值表示成本:
滑点(bp)= 方向 × (成交均价 − 基准价) / 基准价 × 10000
方向:买入为 +1,卖出为 −1
基准须与算法目标一致,用 VWAP 评估实施缺口类算法、用到达价评估 VWAP 算法都会得出误导性结论(第七章第 10 节)。
4. 实施缺口分解
Pd:决策价 Pa:到达价 pᵢ, qᵢ:第 i 笔成交的价格与数量
Q:母单数量 Qf:已成交数量 Pend:执行结束时的价格
延迟成本 = 方向 × (Pa − Pd) × Qf
执行成本 = 方向 × Σ qᵢ × (pᵢ − Pa)
机会成本 = 方向 × (Pend − Pd) × (Q − Qf)
显性费用 = 佣金 + 税费 + 交易所费用
实施缺口 = 延迟成本 + 执行成本 + 机会成本 + 显性费用
执行成本可以进一步拆分:
| 分项 |
计算 |
| 价差成本 |
每笔成交相对成交时中间价的差额 |
| 临时冲击 |
执行结束后价格回落的部分(结束后若干分钟的中间价相对结束时的回归) |
| 永久冲击 |
执行结束后未回落的部分 |
| 市场漂移 |
同期市场或行业整体的价格变动,按 Beta 乘以指数收益估计并扣除 |
扣除市场漂移(市场调整)后的成本才反映执行本身的好坏:市场整体上涨时买入,未经调整的成本会被高估。
5. 成交后价格走势
对每笔成交,计算成交后 Δ 时刻(如 100 毫秒、1 秒、10 秒、1 分钟、5 分钟)的中间价相对成交价的变化,按有利方向记为正:
| 现象 |
含义 |
| 被动成交后价格走势持续为负 |
被逆向选择:挂单总在价格即将不利时被成交 |
| 主动成交后价格走势为正 |
主动成交捕捉到了短期价格变化 |
| 某个场所的走势明显更差 |
该场所的流动性"有毒",路由时降低权重 |
| 母单结束后价格回落 |
临时冲击,回落幅度可用于标定冲击模型 |
按场所、订单类型、算法、时段、品种流动性分组统计,结果用于调整被动与主动的切换阈值和智能路由(第七章第 5 节、第 6 节)。
6. 冲击模型标定
| 环节 |
说明 |
| 样本 |
母单的执行成本(经市场调整)、数量占日成交量的比例、参与率、波动率、价差 |
| 模型 |
冲击 = c × σ × (Q / V)^β,β 通常接近 0.5(平方根法则);按市场与流动性分组分别估计 |
| 临时与永久 |
用执行结束后的价格回归区分 |
| 更新 |
定期重新估计,参数经配置中心发布(第十三章) |
| 输出 |
事前成本估计;组合构建的冲击成本项(第六章);回测的成本参数(第十六章第 7 节);执行算法的时长与参与率选择 |
| 注意 |
说明 |
| 选择偏差 |
交易员在预期价格会跑时加快执行,在无紧迫性时放慢,样本中"快"的母单天然伴随不利的价格走势,直接回归会高估冲击 |
| 噪声 |
单笔执行成本的噪声远大于冲击本身,需要大量样本并报告置信区间 |
7. 数据要求
| 数据 |
内容 |
| 母单 |
决策时间与决策价、到达时间、数量、方向、算法与参数、所属策略 |
| 子单 |
发送、确认、成交、撤单的时间,价格、数量、场所、订单类型 |
| 行情 |
执行前后的订单簿与成交时序、区间与全日 VWAP、全日成交量(行情与事件录制,第十五章) |
| 参考数据 |
实际费率、交易单位、市场与行业指数(第十二章) |
| 时间戳 |
母单、子单、行情使用同步的时钟(第十四章),毫秒级以下的走势分析需要微秒级精度 |
决策时间与决策价往往没有被记录:策略与组合构建需要在生成目标时写入这两项,否则延迟成本无法计算。
8. 分析维度与报告
- 按算法及参数、券商与通道、交易场所、订单类型、品种流动性分组、时段、订单规模(占日成交量比例)、策略、交易员切分
- 成本高度依赖噪声,报告分布与置信区间,而不只是平均值
- 按成交金额加权与按笔数平均分别报告,避免小单主导结论
- 算法之间的比较优先使用线上 A/B 测试的结果(第七章第 11 节),避免不同订单特征带来的偏差
- 不同市场的最小变动价位与价差不同,同时以基点与"价差倍数"两种口径报告
9. 系统架构
flowchart LR
EV[OMS / 执行引擎事件<br/>母单 / 子单 / 成交] --> PREP[数据准备<br/>母子单关联 / 时间对齐 / 基准计算]
MD[录制行情<br/>第十五章] --> PREP
REF[参考数据<br/>费率 / 指数] --> PREP
PREP --> CALC[计算<br/>滑点 / 实施缺口分解 / 成交后走势]
CALC --> STORE[结果存储<br/>ClickHouse 等]
STORE --> RPT[报告与仪表盘]
STORE --> CAL[冲击模型标定]
CAL --> CFG[配置中心<br/>第十三章]
CFG --> EMS[执行引擎]
CFG --> PC[组合构建]
CFG --> BT[回测]
BUS[事件总线] --> RT[事中监控<br/>实时滑点与进度]
事中监控从事件总线实时计算;事后分析在收盘后批量运行,依赖当日录制的行情与对账后的成交。
10. 反馈闭环
| 使用方 |
用途 |
| 执行引擎 |
算法选择、参与率、被动与主动的切换阈值、路由权重 |
| 组合构建 |
成本模型系数,决定换手与持仓调整幅度 |
| 回测 |
成本参数,使回测成本与实盘一致 |
| 策略研究 |
资金容量评估 |
| 券商与通道管理 |
定期评估券商、通道与场所的执行质量 |
| 合规 |
最佳执行的证据 |
11. 最佳执行
券商与资产管理机构负有最佳执行义务:综合考虑价格、成本、速度、成交可能性等因素,为客户取得尽可能好的结果(如欧盟 MiFID II 的最佳执行要求、美国 FINRA Rule 5310)。TCA 报告是证明履行这一义务的主要依据,需要定期评审执行场所与券商,并保存分析记录。
12. 常见问题清单
| 问题 |
后果 |
对策 |
| 未记录决策时间与决策价 |
无法计算延迟成本,信号到执行之间的损失不可见 |
生成目标时记录 |
| 只用 VWAP 基准 |
大单推动 VWAP,看似跑赢基准 |
以到达价为主要基准 |
| 未做市场调整 |
市场涨跌被算作执行好坏 |
扣除市场或行业漂移 |
| 基准与算法目标不一致 |
误判算法优劣 |
按算法目标选择基准 |
| 忽略未成交部分 |
机会成本漏算,成本显得很低 |
实施缺口包含机会成本 |
| 样本量不足就下结论 |
被噪声误导 |
报告置信区间,积累足够样本 |
| 只按笔数平均 |
小单主导结论 |
同时按金额加权 |
| 时间戳未同步 |
成交后走势计算错误 |
统一时钟 |
| 母单与子单关联丢失 |
无法汇总到母单层面 |
子单携带母单编号 |
| 冲击回归忽略选择偏差 |
高估冲击 |
控制紧迫性变量,或使用 A/B 测试数据 |
| 费用按标准费率而非实际费率 |
成本计算偏差 |
使用实际费率 |
| 只做事后分析 |
无法在下单前选择算法与时长 |
建立事前成本估计 |
| 分析结果不回灌 |
回测、组合构建与实盘成本脱节 |
定期标定并发布参数 |
| 跨市场直接比较基点成本 |
忽略价差与最小变动价位的差异 |
同时报告价差倍数 |
13. 开源方案
| 项目 |
用途 |
| tcapy(Cuemacro) |
事后 TCA,以外汇现货为主(最后提交 2024-02) |
| pandas、Polars、DuckDB |
数据准备与计算 |
| ClickHouse |
成交级明细与多维分析 |
| statsmodels、scikit-learn |
冲击模型回归 |
TCA 的数据准备、基准计算与冲击标定通常自建;券商也常向客户提供其执行的 TCA 报告。
二十一、监控告警的设计与实现
监控告警观察系统健康与业务状态,在问题造成损失之前发现它。交易系统的特殊性在于:资金损失可以在秒级内累积,告警必须快;同时监控不能影响热路径的延迟。本章给出监控告警的系统设计与常见问题。
1. 职责与边界
| 负责 |
不负责 |
| 采集指标、日志、链路与状态事件 |
逐笔拦截订单与自动熔断的最终执行(事前风控,第八章) |
| 告警规则、分级、去重、抑制、升级 |
成交与持仓的正式核对(对账,第十八章) |
| 通知与值班流程 |
盈亏的正式计算(PnL 归因,第十九章) |
| 仪表盘与事故复盘所需的数据 |
|
监控与风控的分工:风控执行硬限额,违规即拒绝或熔断;监控在更早的软阈值上预警,并发现规则无法覆盖的异常,例如策略在应当交易的时段没有任何动作。部分监控条件可以通过风控的独立通道触发熔断(第八章第 7 节)。
2. 监控对象
| 层 |
监控项 |
| 基础设施 |
CPU、内存、磁盘空间与延迟、网卡丢包、温度、电源、时钟偏差(第十四章) |
| 组件 |
进程存活与心跳、队列积压、消费者延迟(第四章第 6 节)、错误率、重启次数 |
| 延迟 |
tick-to-trade、order-to-ack 等各段延迟的分位数(第十一章第 11 节) |
| 行情质量 |
缺口、恢复、线路丢包、品种更新时效、订单簿交叉(第二章第 12 节) |
| 交易行为 |
报单速率、拒单率与拒单原因、撤单率、成交率、报撤比与交易所阈值的距离 |
| 持仓与风险 |
敞口、限额使用率、在途数量、挂单数量 |
| 盈亏 |
实时 PnL、日内高点回撤、异常跳变 |
| 策略状态 |
运行、暂停、降级、故障(第五章第 5 节) |
| 对账 |
差异数量与账龄(第十八章) |
| 合规 |
自成交、交易所异常交易认定指标 |
| 外部依赖 |
交易所状态、柜台连接、数据商、时间源 |
| 批处理任务 |
盘前快照、日终对账、PnL 计算是否按时完成 |
3. 数据类型
| 类型 |
内容 |
用途 |
| 指标 |
计数器、瞬时值、直方图 |
趋势、阈值告警、仪表盘 |
| 日志 |
结构化日志;热路径使用二进制日志,离线解码 |
排查细节 |
| 链路 |
一笔订单从信号、风控、OMS、网关、确认到成交的全过程,以订单号与事件序号串联 |
定位延迟与失败发生在哪一段 |
| 状态事件 |
策略状态变化、配置变更、熔断触发与解除、主备切换 |
告警、审计、在图表上标注 |
交易链路的还原优先使用事件日志(第四章):每个事件都带序号与时间戳,离线即可重建任意订单的完整路径,无需在热路径上做实时链路追踪。
4. 采集架构
flowchart LR
HOT[热路径<br/>只更新内存计数器与直方图] --> SHM[共享内存]
SHM --> COL[采集进程<br/>同机旁路]
LOG[二进制日志] --> COL
BUS[事件总线] --> BIZ[业务监控<br/>PnL / 持仓 / 行为]
COL --> TSDB[时序数据库]
COL --> LOGS[日志存储]
BIZ --> TSDB
TSDB --> RULE[告警引擎]
BIZ --> RULE
RULE --> NOTIFY[通知与值班]
RULE --> ACT[自动动作<br/>暂停策略 / 经风控熔断]
TSDB --> DASH[仪表盘]
| 要点 |
说明 |
| 热路径零负担 |
热路径只做内存中的计数与直方图累加(每线程独立,无锁),不做格式化、网络与磁盘 IO |
| 旁路采集 |
同机的采集进程从共享内存读取并导出,采集进程故障不影响交易 |
| 业务监控 |
作为事件总线的消费者,实时计算 PnL、敞口、行为指标 |
| 时间对齐 |
所有监控数据使用同步后的时钟,便于跨机关联 |
5. 延迟监控
- 按段记录直方图,关注 p50、p99、p99.9 与最大值,不使用平均值
- 按固定时间窗口(如 1 秒、1 分钟)输出,与历史同时段基线比较,尾部延迟上升即告警
- 注意协调遗漏(coordinated omission):系统卡顿期间测量本身也停顿,会漏记最差的样本;按固定节奏发起测量或用硬件抓包兜底
- 进程内打点与网络抓包结合(第十一章第 11 节)
6. 业务监控
| 监控 |
发现的问题 |
| 各策略实时 PnL 与日内回撤 |
策略异常亏损 |
| 限额使用率(80% 预警) |
接近硬限额 |
| 报单速率与拒单原因分布 |
程序异常、风控或交易所限制 |
| 成交率与成交后价格走势 |
执行质量下降、被逆向选择 |
| 品种行情时效 |
行情中断但连接正常 |
| 策略状态变化 |
策略故障或降级 |
| 应有而未发生的活动 |
策略在交易时段内长时间无信号或无订单、日终任务未完成——这类"沉默的失败"不会触发阈值类告警,需要专门的缺失检测 |
7. 告警规则设计
规则类型:
| 类型 |
例子 |
| 阈值 |
队列积压超过 N、时钟偏差超过阈值 |
| 变化率 |
PnL 在 1 分钟内下降超过 X |
| 与基线比较 |
延迟 p99 高于过去 20 日同时段的水平 |
| 缺失 |
心跳停止、品种无更新、定时任务未按时完成(dead man's switch) |
| 组合条件 |
拒单率上升且报单速率上升 |
分级:
| 级别 |
含义 |
通知方式 |
| P1 |
可能造成资金损失或合规问题 |
电话,立即响应 |
| P2 |
功能受损,暂无直接损失 |
即时消息,限时响应 |
| P3 |
需要关注 |
工单 |
| 设计要点 |
说明 |
| 感知交易时段 |
规则区分集合竞价、连续竞价、休市与非交易日,开盘与收盘的正常峰值不告警,非交易时段的交易活动反而告警 |
| 去重与分组 |
同一问题产生的多条告警合并为一条 |
| 抑制 |
上游故障时抑制下游告警,例如交易所连接中断时,不再逐个告警该交易所的品种行情过期 |
| 告警症状而非原因 |
优先告警对业务的影响(订单无法发出),原因类指标用于排查 |
| 负责人与处理手册 |
每条告警规则有负责人与处理手册(runbook),说明含义、影响、处理步骤 |
| 定期清理 |
从不触发或频繁误报的规则需要调整或删除 |
8. 从告警到处置
| 环节 |
说明 |
| 值班与升级 |
值班轮换;告警在规定时间内未被确认则升级到下一级 |
| 自动动作 |
对定义明确的条件自动处置:行情过期时暂停相关策略;亏损超限时经风控通道熔断(第八章第 7 节) |
| 人工确认 |
平仓等不可逆动作由人决定 |
| 事故流程 |
发现 → 判断影响 → 止损(暂停、熔断) → 恢复 → 复盘;复盘产出改进项并跟踪完成 |
9. 监控系统自身的可靠性
| 要点 |
说明 |
| 监控监控系统 |
由外部服务定期接收监控系统的心跳,心跳中断即通知(dead man's switch) |
| 独立部署 |
监控系统与交易系统使用不同的主机与网络路径,交易系统故障时监控仍可用 |
| 通知冗余 |
电话、短信、即时消息多通道 |
| 故障隔离 |
监控系统故障不影响交易 |
| 数据保留 |
指标与日志按事故分析与合规需要保留 |
10. 仪表盘
| 层级 |
内容 |
| 总览 |
各交易所、各策略的健康状态,一屏看清是否有异常 |
| 交易视图 |
PnL、持仓、挂单、限额使用率 |
| 系统视图 |
延迟分位数、队列、资源、行情质量 |
| 下钻 |
从总览逐级进入到单个品种、单笔订单 |
- 在图表上标注发布、配置变更、主备切换的时刻(第十三章第 12 节),便于判断异常是否由变更引起
- 发现问题依靠告警,而不是依靠有人盯着仪表盘
11. 按交易日程的检查
| 时点 |
检查 |
| 开盘前 |
参考数据快照版本(第十二章)、配置版本、时钟锁定(第十四章)、风控在线与熔断通道可用(第八章第 9 节)、网关登录、行情接收 |
| 盘中 |
持续监控 |
| 收盘后 |
日终对账、PnL 计算、数据录制完整性、数据上传是否按时完成 |
| 非交易日 |
抑制交易类告警,保留基础设施监控 |
开盘前检查自动执行,每一项的结果作为监控项,任何一项失败即告警并阻止相关组件开放交易。
12. 常见问题清单
| 问题 |
后果 |
对策 |
| 在热路径上格式化日志或同步上报指标 |
交易延迟抖动 |
热路径只更新内存计数,旁路导出 |
| 只看平均延迟 |
尾部延迟恶化未被发现 |
分位数与最大值 |
| 只监控进程存活 |
进程存活但不工作(卡住、无行情、无订单) |
心跳、活动缺失检测 |
| 不感知交易时段 |
开盘峰值频繁误报,非交易时段异常被忽略 |
规则按交易时段区分 |
| 告警过多 |
告警疲劳,真正的告警被忽视 |
分级、去重、抑制、清理 |
| 告警没有处理手册 |
值班人员不知如何处理,延误 |
每条规则配处理手册 |
| 告警无人确认也不升级 |
问题持续扩大 |
超时升级 |
| 监控系统与交易系统同机同网 |
两者一起故障 |
独立部署 |
| 没有人监控监控系统 |
监控失效无人知晓 |
外部心跳 |
| 通知只有一个通道 |
通道故障时告警丢失 |
多通道 |
| 依赖人盯仪表盘发现问题 |
发现滞后 |
告警驱动 |
| 仪表盘不标注变更 |
难以关联异常与变更 |
标注发布与配置变更 |
| 开盘前检查靠人工 |
漏检 |
自动化并作为开放交易的前提 |
| 复盘不跟踪改进项 |
同类事故重复发生 |
改进项闭环跟踪 |
13. 开源方案
| 项目 |
用途 |
| Prometheus + Alertmanager |
指标采集与存储;告警的分组、抑制、静默与路由 |
| Grafana |
仪表盘与告警 |
| VictoriaMetrics |
兼容 Prometheus 的长期指标存储 |
| Loki、Elasticsearch |
日志存储与检索 |
| OpenTelemetry、Jaeger |
指标、日志、链路的统一采集与链路追踪 |
| HdrHistogram |
延迟直方图 |
| ClickHouse |
交易事件的分析查询 |
值班排班与电话通知多使用商业平台。
二十二、技术演进方向
| 方向 |
现状 |
| Rust 替代 C++ / Cython |
NautilusTrader 核心迁移到 Rust,Barter、hftbacktest 为纯 Rust。无 GC 且保证内存安全,加密货币团队采用最快 |
| Arrow 生态数据栈 |
Polars、DuckDB、Parquet 加 ArcticDB,在中低频研究中替代 pandas + HDF5,因子计算提速一到两个数量级(视负载而定) |
| 机器学习 |
表格类因子仍以 GBDT(LightGBM、XGBoost)为主;深度学习主要用于订单簿微观结构预测(如 DeepLOB);时间序列基础模型(Chronos、TimesFM、Moirai)在金融数据上效果不稳定,原因是信噪比低、分布漂移严重 |
| LLM |
主要用于另类数据处理(新闻、研报、电话会纪要的情绪分析与事件抽取)和研究自动化(RD-Agent 类 Agent 批量生成并验证因子);直接由 LLM 做交易决策的情况少见,延迟、成本和输出稳定性都不满足要求 |
| 强化学习 |
用于最优执行(拆单、挂单位置选择)比用于选股更可靠:目标明确(降低执行成本),反馈周期短 |
| 交易所上云 |
CME 与 Google Cloud 合作迁移,Nasdaq 在部分市场使用 AWS Outposts;对延迟敏感的业务仍留在机房托管 |
| 链上交易 |
DEX 永续合约(Hyperliquid、dYdX 等订单簿型专用链)、MEV、基于意图(intent)的交易;延迟竞争从网络距离转向出块时间、排序规则与 gas 竞价 |
暂无评论,欢迎留下第一条评论。