量化交易系统架构

本文按量化交易系统的组成组织,共二十二章:

部分 章节
总体架构 一、生产级量化交易系统架构
交易域(按数据流顺序) 二、行情网关;三、订单簿;四、事件总线;五、策略引擎;六、组合构建;七、执行引擎 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
开源方案 用途
Apache Arrow / Parquet 列式内存格式与文件格式,各工具之间零拷贝交换
Apache Iceberg、Delta Lake 表格式:版本化与时间旅行(time travel),可复现"某日看到的数据"
DuckDB、Polars 单机列式分析引擎,直接查询 Parquet
ClickHouse、QuestDB、TimescaleDB 服务化的时序 / 分析数据库
ArcticDB Man Group 出品,版本化的 DataFrame 存储(BSL 1.1 许可证)
Databento DBN 公开的标准化行情编码格式(MBO、MBP、K 线等 schema),可作为自定义 schema 的参考
Airflow、Dagster、Prefect 数据采集与清洗任务的调度编排

商业方案: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 需要完整解码,也不适合热路径
开源方案 用途
SBE、FlatBuffers、Cap'n Proto 零拷贝序列化
Databento DBN 标准化行情 schema 参考

订单簿重建:设计与实现见第三章。

事件总线

层级 范围 延迟量级(未实测) 技术
线程间 同一进程 几十纳秒 SPSC / MPSC 环形缓冲区、Disruptor
进程间 同一台机器 百纳秒级 共享内存:Aeron IPC、Chronicle Queue、iceoryx2
跨机低延迟 同一机房 微秒级 Aeron UDP 单播 / 组播
跨机非关键路径 数据中心之间 毫秒级 Kafka / Redpanda、NATS、ZeroMQ
设计要点 说明
全局定序 每个事件分配单调递增序号,所有消费者看到相同顺序
持久化与重放 事件日志落盘(Chronicle Queue、Aeron Archive),支撑事件溯源、重放、主备复制(第 3 节 ②)
背压 热路径的生产者不能被慢消费者拖住:慢消费者自行追赶日志,或被断开后从日志恢复
单写者 每个主题一个写者,避免锁与乱序
schema 演进 消息格式带版本号,新增字段向后兼容,保证历史日志可重放
开源方案 用途
LMAX Disruptor 线程间环形缓冲区(Java)
Aeron IPC / UDP 传输,Archive 持久化,Cluster 基于 Raft 的复制状态机
Chronicle Queue 基于 mmap 的持久化队列(Java)
iceoryx2 零拷贝进程间通信(Rust,提供 C / C++ 绑定)
ZeroMQ、NATS 跨机消息,非关键路径
Kafka、Redpanda 持久化事件流,供运营域、数据仓库消费
NautilusTrader MessageBus 进程内消息总线,可通过外部后端扩展(crates/common/src/msgbus/)

事件总线的内部设计与实现见第四章。

策略引擎

设计要点 说明
接口 事件回调: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 除外),并提供深度查询与排队位置查询。

// order_book.hpp —— 行情侧订单簿:订单池 + 价位池 + 开放寻址索引 + 有序价位梯 + 价位内 FIFO
#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;                     // 单笔数量(ITCH 等协议为 32 位)
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;                        // 同价位 FIFO 双向链表
    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; }   // 负载因子 ≤ 0.5
        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);   // k 是否在环形区间 (i, j]
            if (!k_in_ij) { slots_[i] = slots_[j]; i = j; }
        }
        slots_[i].key = kEmpty;
        --size_;
        return true;
    }
    size_t size() const { return size_; }
};

// ---------------- 价位梯:有序 vector,只存(价格,价位池下标),最优价在末尾 ----------------
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; }   // a 比 b 差
public:
    Ladder(bool is_bid, size_t reserve) : bid_(is_bid) { lv_.reserve(reserve); }

    // 返回 px 所在下标(found = true)或应插入的位置(found = false)
    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) {   // 超出预留容量时 vector 会重新分配:监控档数并调大预留
        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]; }   // 第 k 档,0 为最优
    size_t size() const { return lv_.size(); }
    void clear() { lv_.clear(); }
};

// ---------------- 单个品种 ----------------
struct Book {
    Ladder   bids, asks;
    uint64_t seq   = 0;                   // 最后应用的消息序号(MDP 的 RptSeq 等)
    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) {   // 在价位梯 pos 处插入新价位,返回价位池下标
        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;                      // O(1):经价位池下标直接修改
        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);
    }

    // MBP 价位增量:直接设置价位总量,0 表示删除。同一品种不能与逐笔事件混用
    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 {   // 前 n 档,最优在前
        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(); }   // 监控:价位池水位
};

}  // namespace ob

设计说明:

位置 说明
错误以 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 往往只在特定消息序列下出现,单元测试难以覆盖。做法是写一个明显正确但很慢的参考实现,用随机操作序列同时驱动两者,定期比较全部价位与排队位置:

// test_order_book.cpp —— 与朴素参考实现做差分测试
#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) {
        // 订单数超过 3000 时只做减少操作,使价位梯保持足够深度,覆盖二分查找路径
        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) {                // 返回 false 时重试
    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 环形缓冲区

单生产者、单消费者是最常用也最快的形态:

// spsc_ring.cpp —— 单生产者单消费者环形缓冲区
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <type_traits>

template <typename T, size_t N>                   // N 必须是 2 的幂
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 骨架:

// crtp_strategy.cpp —— 编译期绑定策略回调
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 节的核心机制:按 (时间, 序号) 确定性分发、周期定时器、按"持仓 + 在途"检查下单、回调异常只停止该策略并撤单、明确的结束时间。

# mini_engine.py —— 最小策略引擎:确定性事件分发、定时器、在途订单跟踪、策略故障隔离
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:
    """策略能看到的全部世界:时间、行情、持仓、下单、定时器。不允许直接访问系统时间或 IO"""

    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):
    """连续上涨 3 个报价就买 1 手"""

    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):
    """第 3 个报价时下单,第 4 个报价时触发一个 bug"""

    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 → 订单"的映射,常用预分配的开放寻址哈希表,配合对象池与价位内的侵入式链表,热路径上没有内存分配。

价位数组的骨架如下(只列买方向;越界检查与基准价重置省略):

// book.cpp
#include <cstdint>

struct Level { int64_t qty = 0; uint32_t count = 0; };

class Book {
    static constexpr int32_t N = 1 << 16;   // 覆盖的价格档数
    int64_t base_;                          // 下标 0 对应的价格(以 tick 为单位的整数)
    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、协议头等固定字段,热路径只修改订单号、价格、数量:

// order_template.cpp
#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 竞价