NarrowGate:从 Maker Quote EV 到 C++ Tick Replay 的低延迟研究框架
TL;DR:读者收益摘要
NarrowGate 不是一个“C++ 高频做市系统已经跑通并盈利”的项目,而是一个用于验证 maker quote EV 的研究框架。它的核心不是证明某个做市策略有效,而是建立一套让错误结论更难通过的 maker 研究系统。
读完这篇文章,至少可以得到五个结论:
- maker fill 不能简单等价于 spread 收益,必须用 fill 后 markout 和 inventory exposure 衡量;
- 多市场 reference 有信息量,但不等于统一打开
multi_market就有正 EV; - tick replay、queue ahead、latency、TTL 和 maker fill gate 会显著改变 bar 回测结论;
- C++ 的价值不是替代 Python 写策略,而是把 replay、feature state、quote core 和 live hot path 中稳定、高频、低基数的部分收紧边界;
- 当前项目没有宣称“搞定做市终局”:动态到达率、主动对冲、多 venue lead-lag 和 HFT 级线程模型仍然是明确的未完成边界。
换句话说,C++ 没有替项目找到策略答案。它做的是更有用的事:让错误答案更快暴露,让好看的数字更难逃过验证。
NarrowGate 是我最近一段时间一直在做的 maker/被动做市研究项目。它不是从“我要写一个 C++ 高频系统”开始的,而是从一个更朴素的问题开始:当市场有人急于成交时,站在被动一侧提供流动性,究竟是在收取 spread,还是在替更快的信息交易者接风险?
最初的实现只是一个基于 Avellaneda-Stoikov 模型的 Python 研究框架。随后,为了检验这个问题,项目才逐步加入逐笔成交、原生 L2 盘口、LightGBM、BTCUSDT 永续 reference、BTCUSDC/BTCUSDT spot anchor、逐侧 quote EV、库存时间风险,以及 Python/C++ 双引擎回放。也就是说,C++ 是研究越做越深之后出现的工程结果,不是项目的前提。
先把阅读边界说清楚:本文只从 C++ 低延时工程、市场微观结构、价格行为学、数据质量和回测方法的角度分析 NarrowGate,不讨论也不建议任何真实交易行为。文中的 PnL、fill、markout、A/B 都是研究指标,用来验证模型假设、执行口径和系统性能,而不是收益承诺。
一路做下来,真正困难的部分并不是把某个公式写出来,而是连续回答几个会推翻直觉的问题:
- 一笔被动挂单获得的究竟是 spread 补偿,还是承担信息风险后的偶然结果?
- 新增一个数据源,为什么可能让策略更差,而不是更健壮?
- 历史数据中缺一小时、缺一次 orderbook snapshot,会怎样污染 rolling feature、label 和回测?
- 回测比事件引擎“更聪明”时,漂亮的结果还有多少可信度?
- C++ 到底应该负责什么,才能既加快研究,又不制造第二套策略?
- 为什么 live 路径里“把单个公式搬到 C++”反而会变慢,而把 quote、policy、routing 的数据边界收紧后才开始变快?
- 为什么 WebSocket、REST、日志和健康检查不该急着迁到 C++,真正的热路径反而是 WebSocket 上方的 rolling state 和决策包?
这篇文章想把这些前因后果完整记录下来,但不把算法和工程混在一起讲。后文按五层展开:
- Maker 量化算法与数据假设:先解释 NarrowGate 在研究什么,AS/GLFT/quote EV 如何组成报价框架,以及为什么数据源和 label 连续性会决定结论可信度;
- 从算法验证到工程问题:再解释为什么更严格的 tick replay、A/B 和 parity 会把问题自然推到系统吞吐量上;
- Python/C++ 技术实现:然后进入 C++,讨论哪些状态机、批处理和 live hot path 值得迁移,哪些不该迁;
- 从原理到实操的桥:把参数映射、queue ahead、shadow gate、Python/C++ 胶水层、fixed-array state 和 live 容错讲成可执行规则;
- 已完成与未完成边界:最后把金融 Alpha 终局和低延迟系统终局的问题单独摊开,说明哪些已经完成、哪些只是部分完成、哪些还没有完成。
这样读下来,C++ 不是突然闯进来的“性能炫技”,而是前面算法验证不断收紧之后产生的工程压力。
阅读路线:先算法,再工程
如果只看现在的代码目录,很容易以为 NarrowGate 是一个“AS + LightGBM + C++”的技术拼盘。实际演进顺序恰好相反:每一层都是上一层暴露问题后的修正。
| 阶段 | 最初假设 | 真实问题 | 后续修正 |
|---|---|---|---|
| AS 与 1s-bar 原型 | spread、波动和库存足以描述报价 | bar touch 无法表示 maker 排队、延迟和逆向选择 | 引入逐笔回放、BBO/L2、queue ahead 和 maker fill gate |
| 原生盘口接入 | 有文件就能重建盘口 | CryptoHFTData 存在缺小时、低 snapshot coverage;gap 会污染 rolling 和未来 label | audit 成为硬边界,物理清理坏数据,并统一 continuous segment / horizon guard |
| BTCUSDT reference | 高流动性参考市场应提升收益 | reference 同时包含信息冲击和已被本地吸收的流动性冲击,统一开关会误杀机会 | 由布尔开关转向 shock attribution 和逐侧 quote EV |
| enhanced spot | 更多市场能让模型更健壮 | 特征有信息不等于 policy 有正 EV,bid/ask 响应还不对称 | 重建 spot features、逐月 A/B、按 side/regime 验证 |
| quote EV | ask 侧 raw PnL 改善即可晋级 | 有效 fill 太少,校准和 30s markout gate 不通过 | strict promotion gates、walk-forward 扩样、shadow/research 定位 |
| 多臂 tick A/B | 更严格的验证只要多跑几次 | Python replay 与逐行 quote context 让完整月份实验过慢 | C++ batch quote core、tick replay 状态机、window cache 与双引擎 parity |
| live C++ 优化 | native 代码一定比 Python 快 | 每 tick dict/dataclass/asdict 往返比 quote 数学还贵,WebSocket/REST 不在 CPU 热点 |
cached config、fixed tuple、compact context、routing bridge、fixed-array signal |
整个研究链路可以概括成:
Binance Vision / CryptoHFTData
│
▼
data audit + physical cleanup + gap/horizon guard
│
▼
1s bars + BBO/L2 + reference/spot features
│
├──► 主 LightGBM:ret / vol / toxicity 等状态预测
│
▼
AS quote core + side policy + inventory controls
│
▼
tick replay:queue / latency / TTL / maker fill gate
│
▼
quote trace + fill markout + cross-market shock attribution
│
▼
quote EV labels/model + monthly A/B + strict gates这条链路很重要,因为 quote EV 并不是从原始行情直接训练出来的万能模型。它依赖上游报价轨迹、成交路径、未来 markout 和 cross-market context;任何一层的数据边界或执行口径出错,最后得到的 AUC 和 PnL 都可能只是被污染后的精确数字。
这也是为什么数据源需要先分层,而不是全部丢进一个 dataframe。NarrowGate 里至少有四类数据:
| 数据层 | 主要来源 | 在项目中的角色 | 最容易出错的地方 |
|---|---|---|---|
| execution trades | Binance Vision daily/aggTrades,perp 与 spot |
构造 1s/10s flow、taker tempo、spot/reference return | 月度容器混入坏日期;日度缺口会污染 rolling 和 future label |
| execution orderbook | CryptoHFTData 原生 hourly orderbook,重放为 daily bbo/l2 |
tick replay 的 BBO、top-N L2、queue ahead 和执行层 microprice | 小时文件缺失,或文件存在但 snapshot coverage 太低 |
| slow market context | Binance OI、long/short ratio、旧 bookDepth 百分比桶 |
慢变量和历史 proxy,不作为精确 L2 撮合依据 | bookDepth 是百分比聚合桶,不等价于运行时 top-20 depth |
| cross-market anchors | BTCUSDT perp、BTCUSDT spot、BTCUSDC spot | 区分本地流动性冲击和更广泛的信息重定价 | 多个 Binance 市场高度相关,不能伪装成独立 venue consensus |
因此本文里的“数据清理”不是普通 ETL 卫生问题,而是模型定义的一部分。maker 的 label 常常是条件事件:先有候选 quote,再看是否 fill,fill 后再看 1s/5s/30s mid。只要 orderbook 缺口或长 gap 横跨这个链条,P(fill)、E(markout | fill) 和库存风险都会被误标。
为了避免后面术语来回跳,这里先给一个小词典。它不是交易教材,只是本文后续代码和公式里的统一含义:
| 术语 | 在 NarrowGate 里的含义 |
|---|---|
| maker | 挂被动限价单,等待别人主动成交;不是追着价格打单,而是在盘口提供流动性 |
| taker | 主动吃掉盘口流动性的人;他的成交方向常被用来估计短期 flow pressure |
| BBO | best bid / best ask,即当前最优买一和卖一 |
| mid | (best_bid + best_ask) / 2,最简单的中间价 |
| microprice | 用买卖一深度加权后的中间价;它不是预测模型,只是表达盘口哪侧更薄 |
| spread edge | maker 买在 bid、卖在 ask 所获得的价格让步;这是毛收益来源,不等于最终收益 |
| queue ahead | 自己挂单前方同价位或更优价位的可见数量;价格碰到不代表一定轮到自己成交 |
| markout | fill 后若干秒的价格变化;用来问“这笔成交后来是不是变成了坏成交” |
| maker-signed markout | 按 maker 方向归一后的 markout;买入后上涨为正,卖出后下跌为正 |
| adverse selection | 成交后价格向不利方向移动;直觉上就是“我被更快或更有信息的一方打到了” |
| toxicity | 某侧成交后出现 adverse markout 的概率或强度估计 |
| quote EV | 不是预测价格涨跌,而是问“这一侧、这个价格、这一笔被动挂单,成交之后是否值得” |
| TTL / cooldown | 挂单最长存活时间 / 成交后短时间限制同侧继续暴露 |
第一部分:Maker 量化算法与数据假设
这一部分只讨论策略研究本身:maker 订单为什么有条件 EV,AS/GLFT 公式如何提供报价坐标系,数据源和 quote labels 为什么必须经过连续性校验。暂时不谈 C++,因为如果算法假设和数据边界没立住,任何优化都只是更快地产生错误结论。
1.1 为什么叫 NarrowGate
NarrowGate 取自“窄门”的意象。对 maker 策略来说,市场里的机会并不是越多越好。每一个 tick 都可以触发报价,但真正值得暴露的窗口必须经过几道门:
- 当前 spread 是否提供了足够的流动性补偿;
- 报价相对 mid/BBO 的位置是否合理;
- 主动成交和盘口压力是短期流动性冲击,还是信息驱动的重定价;
- reference perp 与 spot 是否确认了这个变化;
- 库存、挂单 TTL 和撤单频率是否仍在风险预算内。
因此 NarrowGate 不是“预测涨跌然后追单”的趋势策略。它研究的是被动限价单:当别人为了立刻成交而付出成本时,maker 是否值得站在另一边接住这笔流动性。
这里最关键的分界是:
- 流动性冲击:某一侧突然有主动成交,但价格影响短暂,其他市场没有持续确认,本地盘口可能很快恢复;
- 信息冲击:BTCUSDT perp、spot 或更广泛的盘口同时重定价,此时在旧价格继续挂单,往往是在给更快的信息交易者提供退出流动性。
所以每一侧 quote 都应该被看作一个条件 EV 问题:
P(fill) 高不一定是好事。一个几乎必然成交、但成交后价格立刻向不利方向移动的报价,可能比完全不成交更糟。
把 maker 的一次决策写成伪代码,大概是下面这样。真实系统当然有更多异常处理、日志和 REST 细节,但热路径上的逻辑并不神秘:
def on_requote_tick(state, market, models, cfg):
# 1. 更新短周期市场状态:mid、BBO、depth、trade flow、reference/spot
signal = signal_engine.compute(
exec_trades=market.btcusdc_trades,
exec_book=market.btcusdc_l2,
ref_perp=market.btcusdt_perp,
exec_spot=market.btcusdc_spot,
ref_spot=market.btcusdt_spot,
)
# 2. 主模型只回答状态问题:短期 ret / vol / toxicity
pred = models.main.predict(signal.feature_vector)
# 3. AS quote core 给出报价坐标系
quote = compute_quote_core(
mid=market.mid,
inventory=state.position,
sigma_sq=pred.vol_10s,
ret_10s=pred.ret_10s,
tox_bid=pred.tox_bid,
tox_ask=pred.tox_ask,
depth=market.l2_topN,
cfg=cfg.quote,
)
# 4. side policy 决定每一侧是否值得暴露
bid_policy = build_side_policy(
side="bid",
quote_ctx=quote.bid_ctx,
inventory=state.position,
fill_cooldown=state.fill_cooldown,
quote_ev=models.bid_quote_ev,
)
ask_policy = build_side_policy(
side="ask",
quote_ctx=quote.ask_ctx,
inventory=state.position,
fill_cooldown=state.fill_cooldown,
quote_ev=models.ask_quote_ev,
)
# 5. routing 只做本次订单动作:保留、撤单、换价、新挂
decision = route_orders(
quote=quote,
bid_policy=bid_policy,
ask_policy=ask_policy,
live_orders=state.live_orders,
filters=market.exchange_filters,
)
return decision这段伪代码也解释了为什么 live C++ 优化不能只盯着某一个公式。compute_quote_core() 的浮点数学很短;真正反复发生的是 feature state、quote context、side policy 和 routing decision 在 Python 对象之间来回搬运。C++ 如果只接住第 3 步,却每次都要复制完整 dataclass/dict,就会被边界成本反噬;如果把第 3 到第 5 步中稳定的计算和固定字段打包,才可能降低尾延迟。
1.2 从 AS 报价骨架开始
项目最早使用经典 Avellaneda-Stoikov 做市模型。它给出了两个很有用的结构:reservation price 和最优 spread。
其中 q 是库存,sigma 是短期波动,kappa 描述成交概率随报价距离衰减的速度。直觉上,库存偏多时 reservation price 下移,报价会更愿意卖出、更不愿继续买入;波动上升时 spread 变宽,以补偿更高的逆向选择风险。
这几个式子背后的论文假设可以压缩成三步。
第一步,Avellaneda-Stoikov 假设中间价近似服从扩散过程:
做市商持有库存 q,财富受现金和库存市值共同影响,并使用 CARA utility:
在这个设定下,多持有一单位库存会暴露未来价格方差,CARA + Brownian price 的风险惩罚项近似为:
于是 reservation price 可以理解成“愿意用来给库存做无差异估值的价格”:
第二步,论文用一个很简洁的到达率假设描述“报价离中间价越远,越不容易成交”:
在 CARA utility 下最大化一次被动成交的效用增量,会得到每一侧报价距离中的 log 项:
再叠加库存持有风险,得到总 spread:
第三步,GLFT/Guéant 体系把“指数到达率”推广成更一般的强度函数 Lambda(delta)。这时不必假设所有市场都满足 A exp(-kappa delta),而是通过 Hamiltonian 写成:
指数强度只是一个特例;如果真实 order arrival 对报价距离的衰减不是纯指数,就应该校准 Lambda 或等价的 kappa_eff,而不是迷信一个固定 kappa。NarrowGate 里保留 AS 的骨架,是因为它给出了稳定、可解释的库存坐标系;但具体的 sigma、kappa、depth half-spread、adverse widening 和 quote EV gate 都要由数据重新验证。
实际系统里,AS 公式只是报价骨架。NarrowGate 的 quote core 更接近下面这个组合:
这里最容易误解的是 microprice。如果买一数量很厚、卖一很薄,microprice 会向卖一侧偏移,暗示向上打穿更容易;反过来则向买一侧偏移。它不是预测模型,而是盘口当前形状给出的局部压力。
把 microprice 写成这个形式,也可以从“下一笔成交更可能打哪一侧”来理解。若 bid_qty 远大于 ask_qty,卖一更薄,价格向上移动的局部概率更高,于是 microprice 靠近 best_ask:
这也是为什么它只能作为局部盘口压力,而不能替代未来收益模型。一个厚 bid 可能是支撑,也可能只是即将撤掉的虚假流动性;真正进入策略时,还必须经过 freshness、depth coverage、reference confirmation 和 fill 后 markout 验证。
side policy 则在候选价格之后再做一次门控:
allow_bid =
fresh_book
and not bid_adverse_pause
and not bid_local_extreme_pause
and inventory < max_inventory
and not fill_cooldown_blocks_bid
allow_ask =
fresh_book
and not ask_adverse_pause
and not ask_local_extreme_pause
and inventory > -max_inventory
and not fill_cooldown_blocks_ask当库存偏多时,bid 是增加风险的一侧,ask 是降低风险的一侧;库存偏空时反过来。因此同一个 adverse guard 对 bid/ask 的含义并不对称,这也是 quote EV 必须按 side 训练和评估的原因。
但真实盘口不会只服从这几个变量。因此项目后来逐步加入:
- microprice 与盘口 imbalance;
- 动态 kappa 和近端深度;
- fee-aware spread floor;
- LightGBM 的波动率、收益和 toxicity 预测;
- CJP 风格库存偏移和库存衰减;
- adverse guard、defense guard 与逐侧暂停;
- adaptive/fragile TTL 与 cooldown;
- BTCUSDT reference perp、BTCUSDC/BTCUSDT spot;
- quote EV 对 fill probability、markout bucket 和 extreme adverse 的建模。
AS 在这里不是一个直接给答案的“万能公式”,而是稳定的报价坐标系。机器学习和策略 guard 不应该随意取代这个坐标系,而应该回答更局部的问题:这一侧现在是否值得挂、要离 BBO 多远、该暴露多久、库存代价是否已经过高。
主模型和 quote EV 在这里扮演不同角色。主 LightGBM 描述市场状态,例如短期收益、波动与 toxicity;quote EV 则站在某一笔候选报价的角度,估计“是否会成交、成交后是否有利、尾部是否危险”。前者回答市场正在发生什么,后者回答这一侧是否值得承担一次具体的 maker 暴露。把二者混成一个方向预测器,反而会丢掉 maker 最关心的条件性。
项目也因此从早期 1s-bar 回测逐渐转向 tick replay。bar 回测适合快速检查公式和参数方向,但只知道价格是否触及;maker 是否真的成交,还取决于挂单前方的队列、订单到达时间、撤单延迟和同价位主动成交量。没有这些状态,回测很容易把“价格碰到过”错误地当成“我成交了”。
简化后的 queue/fill 逻辑可以写成:
def process_aggressive_trade(order, trade):
if not order.is_live:
return None
# BUY maker order fills only when sell aggressor trades at or below bid.
# SELL maker order fills only when buy aggressor trades at or above ask.
if not trade.crosses(order.side, order.price):
return None
remaining = trade.qty
# Visible queue ahead must be consumed before our order can fill.
eaten = min(order.queue_left, remaining)
order.queue_left -= eaten
remaining -= eaten
if remaining <= 0:
return None
fill_qty = min(order.remaining_qty, remaining)
order.remaining_qty -= fill_qty
return Fill(side=order.side, price=order.price, qty=fill_qty)成交后再按 maker 方向计算 markout:
这个定义让“对 maker 有利”的成交质量统一为正值。比如买入后价格上涨是正 markout,卖出后价格下跌也是正 markout。quote EV 和 adverse guard 关心的不是成交本身,而是 fill 条件下未来 markout 是否补偿了库存和尾部风险。
1.3 第一场真正的麻烦来自数据,而不是模型
项目的数据主要来自两类来源:
- Binance Vision 的 aggTrades、bookDepth、metrics,以及后续补充的 spot 数据;
- CryptoHFTData 的原生 orderbook 数据,用于重建 BBO 和 top-N L2。
最开始很容易产生一种错觉:文件存在,就代表这一小时的数据可用。实际并不是这样。CryptoHFTData 中出现过两种更隐蔽的问题:
- 某些小时文件直接缺失;
- 文件存在,但内部缺少足够的 orderbook snapshot,无法重建可靠的盘口状态。
如果这些日期继续进入训练和回测,问题不只是一段时间“没有成交”。maker 的 order lifetime 可能很短,日与日之间离散本身未必严重;真正危险的是 rolling feature 或未来 label 跨过了坏日期和长 gap。
例如一个 30 秒 markout label,如果第 30 秒对应的价格来自长时间缺口之后,那么这个 label 在数学上有值,在市场含义上却已经失效。rolling volatility、taker tempo、cross-market return 也可能把两个不连续的市场状态拼在一起。
因此后来做了三层修正。
1.3.1 审计结果成为硬边界
orderbook audit 中不可用或低 coverage 的日期不再只是报告里的警告,而是进入统一的 data_quality.py。训练、回测、quote trace、shock audit 和 quote EV 都必须二次过滤,不能依赖某一个上游脚本“碰巧已经删过”。
1.3.2 物理数据也同步清理
坏日期对应的 CryptoHFTData 和 Binance raw 数据被物理删除。由于 Binance 月度压缩包混合了好日期与坏日期,不能只在月度容器里删掉某一天,所以改为:
- 删除覆盖坏日期的月度 aggTrades 容器;
- 从 Binance Vision 按日重新下载 retained good days;
- 验证坏日期日度文件命中为 0,月度容器残留为 0。
这样既避免磁盘继续保留不可用数据,也让物理层和逻辑层使用同一份质量边界。2026-06-06 的清理最终保留了 375 个好日期日度文件、删除 23 个月度容器,并验证坏日期命中与月度容器残留都为 0。
1.3.3 rolling 与 label 使用同一套连续段定义
项目增加了统一的:
continuous_segment_ids(index, max_gap_s=5)
mask_valid_horizon(index, horizon_s, max_gap_s=5)这两个函数不是简单的 dropna。连续段要同时切断无效时间戳、时间倒退和超长间隔;future label 则先用 searchsorted 找到目标 horizon 的第一条观测,再验证它仍处于同一 segment,并且没有晚于目标太多。当前 Python fallback 的核心逻辑如下(省略时间戳类型兼容代码):
def continuous_segment_ids(index_like, max_gap_s=5.0):
ts = _coerce_utc_timestamps(index_like)
ns = ts.view("int64")
invalid = np.asarray(ts.isna(), dtype=bool)
breaks = np.zeros(len(ts), dtype=bool)
breaks[0] = True
if len(ts) > 1:
delta = ns[1:].astype(float) - ns[:-1].astype(float)
breaks[1:] = (
invalid[:-1]
| invalid[1:]
| (delta < 0.0)
| (delta > max_gap_s * 1e9)
)
return np.cumsum(breaks, dtype=np.int64) - 1
def mask_valid_horizon(index_like, horizon_s, max_gap_s=5.0):
ts = _coerce_utc_timestamps(index_like)
ns = ts.view("int64")
segments = continuous_segment_ids(ts, max_gap_s)
target_ns = ns.astype(float) + horizon_s * 1e9
future_idx = np.searchsorted(ns, target_ns, side="left")
valid = (~ts.isna()) & (future_idx < len(ts))
rows = np.flatnonzero(valid)
future = future_idx[rows]
valid[rows] = (
(segments[future] == segments[rows])
& ((ns[future] - target_ns[rows]) <= max_gap_s * 1e9)
)
return validrolling feature 则按 segment 分组后再滚动,而不是算完 rolling 再把坏日期删掉:
segment = continuous_segment_ids(frame.index, max_gap_s=5)
frame["ret_std_60s"] = (
frame.groupby(segment, sort=False)["log_ret"]
.rolling(60, min_periods=20)
.std()
.reset_index(level=0, drop=True)
)
valid_30s = mask_valid_horizon(frame.index, horizon_s=30, max_gap_s=5)
labels.loc[~valid_30s, "markout_30s"] = np.nan在时间戳全部有效时,同一语义还能委托给 C++ 的 continuous_segment_ids_ms / mask_valid_horizon_ms;有非法值时保留 Python fallback,避免 native 快路径为了性能吞掉数据异常。
feature_engineer.py、cross_market_shock_audit.py 和 train_quote_ev.py 共用同一口径。rolling feature 只在连续 segment 内计算,未来 horizon 跨 gap 时 label 直接失效。
这件事看起来不像模型升级,但它比多加几十个 feature 更重要。一个更复杂的模型只会更擅长拟合被污染的数据。
到这里,项目的研究顺序也被迫改变了:不是先训练一个更大的模型,再在结果异常时检查数据;而是先证明每个 feature 和 label 都来自连续、可用的市场片段,然后才允许它进入训练和回放。对于订单生命周期很短的 maker 来说,日与日之间不连续未必有问题,跨越不连续边界计算 rolling 或 future horizon 才是问题。
1.4 为什么加入 BTCUSDT 和 spot 后,结果反而更差
BTCUSDC 的本地成交和盘口并不是孤立市场。项目先加入 BTCUSDT perpetual 作为 reference,后来又重建 enhanced spot features:
cv_ref_perp_*
cv_exec_spot_*
cv_ref_spot_*从直觉上说,多一个高流动性的 reference、多两个 spot anchor,算法应该更健壮。数据也确实带来了信息:cross-market shock audit 显示,很多 adverse fill 能被 BTCUSDT reference move 解释;补齐 spot 后,相关窗口的 spot_available_rate 达到 1.0。
但“数据有信息”不等于“把开关打开就能形成正 EV”。
multi_market.enabled=true/false 的 Jan/Feb/Apr/May 月度 A/B 中,早期 minimal 口径下 true 的 raw PnL 四个月全部差于 false,合计约 -14.05。enhanced spot + audit-quality 重跑后,同样的整体开关仍然没有变成稳定收益来源,raw 差值合计约 -18.14。
这次 enhanced 主模型并不是只拿几天 2026 数据做 smoke test。它的 train/validation split 实际包含 2025-08/09 和 2025-11/12,补齐后的 cv_exec_spot_*、cv_ref_spot_* 在 train/val/test 中非零覆盖率均为 1.0,audit 坏日期残留为 0。换言之,“结果更差”不能简单归因于 spot 列全零或坏日期仍在训练;它更像是 policy 对新增信息的使用方式有问题。
这不是简单说明 reference 无效,而是说明当前使用方式太粗。cross-market 分组给出了更细的答案:
reference_information_shock的 fill 后 markout 明显更差,说明 reference 确认了信息冲击;reference_confirmed_absorbed多数反而为正,说明 reference 虽然移动,但本地流动性已经吸收冲击;- spot adverse return 有解释力,但整体弱于 BTCUSDT perp 的短期 adverse return;
- bid 与 ask 两侧的响应并不对称。
如果把 reference 信号直接变成统一 widen/pause 开关,就会把“应该避开的信息冲击”和“可以承接的已吸收流动性冲击”一起过滤掉。新增数据提高的是可分辨性,不是自动提供一条单调的收益规则。
这也解释了一个常见的机器学习误区:feature 更多、数据源更多,只会扩大模型可以表达的空间;它不会自动保证目标函数、时间对齐、样本覆盖和执行 policy 都正确。
1.5 quote EV:方向看起来对,但统计还不允许下结论
为了避免把 cross-market 信息硬编码成一个布尔开关,项目又训练了逐侧 quote EV 模型。它不直接预测 BTC 下一秒涨跌,而是分别估计:
P(fill);- fill 后 1s/5s/30s markout bucket;
- extreme adverse probability;
- reference/spot 对冲击性质的确认。
在 enhanced spot A/B 中,ask_ev_soft_widen 曾得到相对 baseline 的 raw PnL +14.55、InvAdj +0.0049。只看这两个研究指标,它很像一个值得继续做 shadow 验证的候选。
但再往下看,证据并不够:
- 用于 baseline quote label 的真实 fill 只有 bid/ask 各约 334 条;
- May validation 的有效 filled rows 只有 bid 58、ask 56;
- strict promotion gate 要求至少 100 条 valid filled rows,且 fill calibration MAE 不高于 0.05;
- 实际 bid/ask calibration MAE 约为 0.078/0.243,均未通过;
- 30s expected-markout calibration MAE 也仍然很大。
后来把质量合格的 2025-08/09/11/12 加入 walk-forward,训练侧 fill 增加到 bid 414、ask 415,但 May 可用 feature 当时只覆盖到 5 月 17 日,validation 只剩 bid 26、ask 25 条有效 fill。扩样改善了训练数量,却没有解决验证集覆盖和校准问题。
这里的“两万多行 labels”和“三百多条 fill”也不是矛盾。前者是被评估的候选 quote rows,绝大多数报价本来就不会成交;后者才是能够观测 fill 后 markout、用于校准条件结果的有效样本。P(fill) 可以从大量未成交报价中学习,但 E(markout | fill) 的统计强度最终仍受真实 filled rows 限制。这正是 maker quote-level 模型最容易出现“数据行很多、有效监督很少”的地方。
所以目前最诚实的结论是:ask-only 的方向值得继续研究,但仍是 shadow/research 候选,不能因为一次 raw PnL 改善就跳过样本质量与校准 gate。bid 侧在 enhanced shadow eval 中还出现过明显退化,更不能把 both-side 结果当作自然升级。
1.6 只看 PnL 还不够:把库存风险写成积分
maker 策略可能用更长时间、更大库存换取同样的 PnL。如果 A/B 报告只列 raw PnL 和最终库存,这种风险会被隐藏。
项目因此加入库存时间暴露:
它表达的不是某一时刻仓位有多大,而是整个持仓期间承担了多少风险预算。两个 arm 即使最终都回到零库存,持仓 10 秒与持仓 2 小时也不应该被视为相同。
这些字段被接入 tick replay summary、事件引擎 HEALTH/metrics 和 A/B report,并进一步派生 time_avg_abs_inventory 与 pnl_per_abs_inventory_hour。从此比较 ask EV、multi-market 和 enhanced spot 时,不只问“raw PnL 改善多少”,还要问“为这点改善占用了多少库存时间”。
本节工程结论
- maker 研究不能只看
spread或 raw PnL,必须把 fill 后 markout、库存时间和校准质量放进同一张表。- 多市场数据首先用于解释冲击来源,不应该直接变成一个全局开关。
- 数据质量不是 ETL 细节,而是模型定义的一部分;rolling feature 和 future label 必须共享连续段口径。
- quote EV 的 promotion gate 应该先过样本量、Brier/calibration、bucket realized-vs-pred 和分侧稳定性,再谈上线。
第二部分:从算法验证到工程问题
到这里,maker 算法部分已经形成了一个闭环:候选报价、排队成交、fill 后 markout、cross-market attribution、quote EV 校准和库存时间积分。但这个闭环一旦严格起来,工程问题就不再是“以后再说”的附属项。因为每一次反例验证都要重新跑真实窗口、真实队列和真实 policy state,系统吞吐量本身会决定研究能不能继续。
这一部分是算法和 C++ 之间的桥。它回答的不是“怎么写 native 代码”,而是“为什么验证流程会需要 native 状态机”。
2.1 严格验证带来了新的工程问题
NarrowGate 的 tick replay 不是拿下一根 K 线 high/low 判断是否成交。为了尽量接近 maker 的执行过程,它需要模拟:
- 历史 BBO 与 L2 best bid/ask;
- exact-level queue ahead;
- new order latency 与 cancel latency;
- maker fill gate;
- final spread cap;
- adverse/defense guard;
- flat、fragile、adaptive TTL 与 cooldown;
- local extreme guard;
- position timeout 和 emergency path;
- 库存时间积分与完整 trace。
这些机制大多是高频、顺序相关、状态明确的循环。Python 写起来灵活,但每一个策略臂都重复读取数据、更新 queue、计算 quote、写 summary。做 Jan/Feb/Apr/May 多臂 A/B 时,真正耗时的并不是 LightGBM 训练,而是一次次 replay 和 quote context 生成。
还有一个比速度更重要的问题:如果某个 guard 只存在于回测,或回测拥有事件引擎没有的 queue/TTL 信息,那么回测会比实际策略“更聪明”。此时加速一套不一致的模拟,只会更快地产生错误信心。
前面的数据审计、逐月 A/B、strict calibration gate 都指向同一个现实:这类策略不能靠一两个漂亮窗口下结论,必须反复跑多个真实月份、多个 side policy 和多个风险口径。也正是在这里,Python 循环从“足够灵活”变成了研究吞吐量的瓶颈。
所以 C++ 迁移的第一目标不是炫耀速度,而是让更严格的验证在时间上可负担;第二目标才是建立同一批真实数据下的 Python/C++ parity,并借此把 policy 边界显式化。若两套引擎不能解释同一条成交路径,跑得再快也没有研究价值。
本节工程结论
- 严格 tick replay 的价值不是“更复杂”,而是阻止 bar 回测把触价误判成成交。
- Python/C++ 双引擎先做 parity,再做 sweep 加速;路径不一致时,速度没有意义。
- A/B 不能只比最终 PnL,还要比 fills、bid/ask split、final inventory、InvAdj、inventory time、spread/cap 和 trace 行数。
- replay、queue、TTL、latency、guard 是强顺序状态机,适合一次 C++ 调用跑完整窗口;pandas IO 和训练编排继续留给 Python。
第三部分:Python/C++ 技术实现
从这里开始才进入 C++。前面的 maker 章节回答“应该验证什么”,这一部分回答“哪些计算形态适合迁移、迁移后如何保证和 Python 口径一致”。因此它不会把下载、pandas 清洗、LightGBM 训练和报告生成都重写,而只关注状态长期驻留、调用粒度足够大、语义相对稳定的路径。
如果从量化 C++ 面试展示角度看,这部分真正想展示的不是“我会写 pybind11”,而是这些工程判断:
- pybind11 边界成本和 object materialization 往往比公式本身更贵;
- native path 必须 fail fast,不能静默 fallback 后仍把结果当 C++ benchmark;
- parity test 用来区分策略差异、对象转换差异和 ABI 差异;
- SoA view、fixed-array feature vector、ring buffer 和 O(1) rolling state 比“把函数搬到 C++”更重要;
- PMR 只适合生命周期清楚的 replay scratch,不应该跨 Python 边界泄漏 arena 生命周期;
- live scalar path 和 offline batch path 是两种问题:offline 关心吞吐,live 关心尾延迟;offline 可以 batch,live 只能 scalar;offline 释放 GIL 有意义,live 主路径通常没有;
- benchmark 要看 p99/p99.9,而不是只看 mean;KVM 不暴露 PMU 时,要明确 wall-time soak 的局限。
3.1 为什么 C++ 是结果,而不是起点
Python 仍然很适合:
- 数据下载、清洗和 parquet/CSV IO;
- pandas/NumPy 特征验证;
- LightGBM 训练和校准;
- 实验编排、图表和报告。
真正适合 C++ 的是:
- 每个 tick 都要更新的 streaming state;
- quote core 的大批量数值计算;
- queue、latency、TTL、guard、inventory 组成的 replay 状态机;
- 同一数据窗口被几十个参数臂反复执行的 sweep/A/B。
这里有一个很关键的取舍:项目没有把数据下载、pandas 清洗、LightGBM 训练和报告生成全部重写。它们要么不是 CPU 热点,要么迭代频繁、Python 生态更合适。迁移对象集中在“状态长期驻留、每 tick 重复执行、分支语义相对稳定”的部分。这样 C++ 服务的是研究方法,而不是制造一个难以验证的新系统。
最终的分层大致是:
Python / pandas / LightGBM
- 数据质量、特征工程、模型训练、校准和报告
C++ extension
- quote core batch
- tick replay state machine
- streaming feature state
- queue / latency / TTL / policy execution
Python wrapper
- 加载数组和模型产物
- 调用 C++ batch/replay
- 写 trace、label 和 A/B summaryC++ 目录由 pybind11 暴露给 Python:
cpp/
CMakeLists.txt
pyproject.toml
narrowgate_cpp/
bindings.cpp
common.hpp
quote_core.hpp / quote_core.cpp
tick_replay.hpp / tick_replay.cpp
streaming_features.hpp / streaming_features.cpp
tests/
test_cpp_quote_core_parity.py
test_cpp_tick_replay_parity.py
test_cpp_signal_features.py
bench/
bench_live_path.py
bench_quote_core.py
bench_tick_replay.pybindings.cpp 只处理边界,业务逻辑留在普通 C++ 类型和函数里。这样 quote core 和 replay 可以脱离 Python binding 单独测试,也更容易发现“是策略差异,还是对象转换差异”。
构建层使用 scikit-build-core + CMake + pybind11。extension 由当前虚拟环境的 CPython ABI 编译成 .so,并不是在运行时解释 C++:
# cpp/pyproject.toml
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"# cpp/CMakeLists.txt
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(Python COMPONENTS Interpreter Development.Module REQUIRED)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(narrowgate_cpp
narrowgate_cpp/bindings.cpp
narrowgate_cpp/quote_core.cpp
narrowgate_cpp/tick_replay.cpp
narrowgate_cpp/streaming_features.cpp
)
install(TARGETS narrowgate_cpp LIBRARY DESTINATION .)python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e cpp
.venv/bin/python -c \
'import narrowgate_cpp; print(narrowgate_cpp.__file__)'最后一行很重要。BTCUSDC 和 BTCUSDT 两个仓库都暴露名为 narrowgate_cpp 的模块,共用一个环境时可能导入另一份 build。loader 因此检查模块路径和 editable-install 的 direct_url.json 是否指向当前 repo,也允许用 NARROWGATE_CPP_EXPECT_MODULE_TOKEN 显式约束;NARROWGATE_CPP_STRICT=1 下来源不符会直接失败,不允许悄悄 fallback 到 Python。
本节工程结论
- 不把 Python binding 和业务逻辑揉在一起:
bindings.cpp只做边界,quote/replay/signal core 用普通 C++ 类型实现。- C++ core 必须可以脱离 pybind 单测;否则无法区分策略差异、对象转换差异和 ABI 差异。
NARROWGATE_CPP_STRICT=1是开发期默认心态:模块来源、字段缺失、shape 错误必须 fail fast。- 同名 extension 在多个 repo 之间很容易碰撞,import path 和 editable install 来源必须进入 sanity check。
3.2 quote core:真正慢的是“每 tick 全量物化”
做市 quote core 包含 microprice、reservation price、volatility/kappa/depth spread、inventory skew、adverse/defense guard,以及最终 bid/ask 到 BBO 的距离。它的浮点计算并不重,早期 scalar C++ 路径却明显更慢:
| 路径 | Python | 旧 C++ | 结果 |
|---|---|---|---|
| quote + policy | 85.48 us | 136.33 us | C++ 慢约 59% |
_compute_quotes |
54.93 us | 105.07 us | C++ 慢约 91% |
这张表怎么读
- 说明什么:单独把 quote 公式搬到 C++,在 pybind/dataclass/dict 往返很重时会负优化。
- 不能说明什么:不能说明 C++ quote core 算法本身慢,也不能说明所有 native path 都不值得做。
- 下一步验证:拆掉每 tick 全量物化后,重新测 compact ABI,并同时跑 quote-field parity。
profile 后发现,时间并没有花在 quote_core.cpp 的公式里,而是花在边界两端:每 tick 都要重新创建并复制约 70 个 config 字段、14 个 state 字段、5 个 prediction 字段和整本 depth levels;C++ 返回以后,Python 又把 native result 展开成两个完整 quote_context、quote flags、diagnostics 和 dataclass。一次报价所需的数学运算很少,对象搬运反而成了主任务。
3.2.1 解决方法:热路径紧凑,完整对象按需物化
这次没有继续微调 C++ 公式,而是重新设计边界:
- Python wrapper 缓存已经物化的 native
QuoteCoreConfig,配置对象热重载时失效; - state/pred 通过紧凑 tuple 传入,depth levels 由 C++ 直接读取 Python sequence,不再逐档创建 pybind
DepthLevel对象; - quote EV 关闭时,只返回 side policy 真正读取的 adverse、defense、TTL、local-extreme、near-depth 字段,以及周期诊断日志使用的小型 diagnostics;
- 任一侧 quote EV 开启时自动回到完整 context,因为模型确实需要完整特征,不能为了速度静默丢列。
也就是说,完整 context 不是被删除,而是从“每 tick 固定税”变成“功能真正需要时才支付的成本”。
3.2.2 把配置、状态和诊断分成三种生命周期
原接口几乎把 Python dataclass 一比一复制成 pybind 对象。改造后,配置只在对象变化时同步;高频 state/pred 使用固定位置 tuple;L2 sequence 由 binding 直接读取。下面是实际 wrapper 的裁剪版:
_CPP_STATE_FIELDS = (
"mid", "inventory", "sigma_sq", "trade_intensity",
"best_bid", "best_ask", "ber_active",
"mo_ema_all", "mo_ema_bid", "mo_ema_ask", "mo_ref",
"position_open", "hold_time_s", "unrealized_pnl",
)
def _cached_cpp_config(cpp, cfg):
global _CPP_CFG_CACHE_KEY, _CPP_CFG_CACHE_REF, _CPP_CFG_CACHE_VALUE
cached = _CPP_CFG_CACHE_REF() if _CPP_CFG_CACHE_REF else None
if _CPP_CFG_CACHE_KEY == id(cfg) and cached is cfg:
return _CPP_CFG_CACHE_VALUE
native = _copy_attrs(cfg, cpp.QuoteCoreConfig(), _CPP_CFG_FIELDS)
_CPP_CFG_CACHE_KEY = id(cfg)
_CPP_CFG_CACHE_REF = weakref.ref(cfg)
_CPP_CFG_CACHE_VALUE = native
return native
result = cpp.compute_quote_core_live(
tuple(getattr(state, name) for name in _CPP_STATE_FIELDS),
_cached_cpp_config(cpp, cfg),
(pred_dir, pred_vol, pred_ret, tox_bid, tox_ask),
depth.bids if depth else (),
depth.asks if depth else (),
)C++ binding 则对 tuple 做长度检查并填充栈上的普通结构体,depth 不再逐档创建 DepthLevel Python wrapper:
m.def("compute_quote_core_live",
[](py::sequence state_values,
const QuoteCoreConfig& cfg,
py::sequence pred_values,
py::handle bids,
py::handle asks) {
if (py::len(state_values) != 14 || py::len(pred_values) != 5)
throw std::invalid_argument("live quote state/pred length mismatch");
QuoteState s;
s.mid = py::cast<double>(state_values[0]);
s.inventory = py::cast<double>(state_values[1]);
s.sigma_sq = py::cast<double>(state_values[2]);
s.trade_intensity = py::cast<double>(state_values[3]);
s.best_bid = py::cast<double>(state_values[4]);
s.best_ask = py::cast<double>(state_values[5]);
// ...其余状态按固定位置读取
QuotePrediction p;
p.dir_10s = py::cast<double>(pred_values[0]);
p.vol_10s = py::cast<double>(pred_values[1]);
p.ret_10s = py::cast<double>(pred_values[2]);
p.tox_bid = py::cast<double>(pred_values[3]);
p.tox_ask = py::cast<double>(pred_values[4]);
return compute_quote_core(
s, cfg, p, depth_from_python_levels(bids, asks));
});3.2.3 diagnostics 不是全有或全无
side policy 每 tick 真正读取的字段远少于离线 quote EV。compact path 只物化 guard、距离、TTL 和少量 cap 诊断:
def compact_side_context(side, ctx, result):
return {
"near_depth_total": float(result.near_depth_total),
"final_quote_delta_to_bbo": float(ctx.final_quote_delta_to_bbo),
"side_adverse": bool(ctx.side_adverse),
"side_adverse_pause": bool(ctx.side_adverse_pause),
"defense_guard": bool(ctx.defense_guard),
"defense_pause": bool(ctx.defense_pause),
"mid_guard": bool(ctx.mid_guard),
"post_only": bool(ctx.post_only),
"delta_cap": bool(result.flags.delta_cap),
}
def compute_quote_core_live(..., require_full_context=False):
if cpp_enabled and not require_full_context:
return _compute_quote_core_cpp_compact(...)
return compute_quote_core(...) # quote EV 需要完整 feature context这个方案的关键不是少返回几个字段,而是明确了三种生命周期:几乎不变的 config 被缓存,每 tick 变化的 state 用紧凑序列,只有模型训练、trace 或调试需要的长 diagnostics 才完整物化。可观测性仍在,只是不再无条件出现在最热路径。
固定环境复测使用 Python 3.12.13、AppleClang Release build、ML 关闭以隔离 quote/feature CPU,--n 10000 --signal-n 1000,每项跑三次取 mean 的中位数:
| 路径 | Python | compact C++ | 变化 |
|---|---|---|---|
live _compute_quotes |
42.85 us | 34.53 us | 快 19.4% |
| quote + policy | 74.55 us | 62.32 us | 快 16.4% |
这张表怎么读
- 说明什么:边界收紧后,scalar native path 从负优化翻转为小幅正收益。
- 不能说明什么:不能把这组数字套到 quote EV 开启后的完整 context 路径,也不能推出端到端报单延迟改善 16%。
- 下一步验证:在目标 x86 live 机型上做 p99/p99.9 soak,并确认 strict 模式没有 fallback。
这不是数量级加速,但它完成了更重要的翻转:scalar native path 终于不再因为 Python 对象往返而负优化。它仍保持显式 opt-in,quote EV 开启后的完整 context 路径也不能套用这组数字。
离线 quote decomposition、shock audit 和 quote EV label 仍使用 depth-aware batch binding。batch 场景一次传连续数组,跨语言成本被大量样本摊薄,依然比 scalar 更适合 C++。book_imb、near_depth_total、kappa/depth、adverse/defense 等字段继续纳入 parity 审计;性能优化不能把 C++ 悄悄变成另一套策略。
3.3 tick replay:先迁完整状态机,再谈加速倍数
tick replay 是整个迁移中最适合 C++ 的部分,因为 queue、latency、TTL、inventory 和 fill path 都是顺序相关状态。若只把某个公式做成 scalar binding,Python 仍要在每个 tick 调度所有状态;把完整 replay loop 放进一次 C++ 调用,跨语言边界才只出现于窗口输入和 summary/trace 输出。
C++ 版本逐项迁入所有机制:
- historical BBO/L2;
- exact-level queue ahead;
- new/cancel latency;
- maker fill gate;
- inventory 与 inventory-time summary;
- spread cap、adverse/defense guard;
- quote EV policy;
- local extreme、fragile/adaptive TTL/cooldown;
- position timeout/emergency path;
- 完整 trace 和 summary 字段。
3.3.1 输入是只读 view,窗口数据不再逐 tick 穿过 Python
replay 的入口不是 on_trade(dict),而是一组生命周期覆盖整个调用的连续数组 view。Python 完成 parquet/Arrow 加载和 dtype 归一化后,只跨一次 pybind 边界:
struct TickReplayInput {
ArrayView<std::int64_t> trade_ts_ms;
ArrayView<double> trade_price;
ArrayView<double> trade_qty;
ArrayView<std::uint8_t> is_buyer_maker;
ArrayView<std::int64_t> bbo_ts_ms;
ArrayView<double> bbo_best_bid;
ArrayView<double> bbo_best_ask;
ArrayView<std::int64_t> l2_ts_ms;
MatrixView<double> l2_bid_px;
MatrixView<double> l2_bid_qty;
MatrixView<double> l2_ask_px;
MatrixView<double> l2_ask_qty;
ArrayView<std::int64_t> quote_ev_ts_ms;
ArrayView<double> bid_quote_ev_30s;
ArrayView<double> ask_quote_ev_30s;
};
TickReplayResult simulate_tick_arrays(
const TickReplayInput& input,
const TickReplayParams& params);ArrayView/MatrixView 只保存指针和 shape,不拥有数据。binding 在调用期间持有 NumPy array,C++ 内循环读取连续内存;结果结束后一次性返回 summary 和可选 trace。这样优化的是整个状态机,而不是把每个 tick 的 Python callback 换成一次 pybind callback。
3.3.2 queue policy 用模板固定 side 与模式
bid/ask 和 exact-level/through-level queue 的分支在循环内非常频繁。这里用模板把 side 和 queue mode 固定到编译期:
template <Side S, QueueAheadMode Mode>
double visible_queue_ahead(
ArrayView<double> prices,
ArrayView<double> quantities,
double quote_price,
double tolerance) {
double visible = 0.0;
for (std::size_t i = 0; i < prices.size(); ++i) {
bool include = false;
if constexpr (Mode == QueueAheadMode::ExactLevel) {
include = std::abs(prices.data()[i] - quote_price) <= tolerance;
} else if constexpr (is_buy_v<S>) {
include = prices.data()[i] >= quote_price;
} else {
include = prices.data()[i] <= quote_price;
}
if (include) visible += std::max(0.0, quantities.data()[i]);
}
return visible;
}订单本身是一个显式状态机:
struct ReplayOrder {
Side side;
double price, quantity, remaining;
double queue_left, queue_init;
std::int64_t quote_ts, activate_ts, cancel_effective_ts;
std::int64_t ttl_ms;
OrderState state;
bool side_adverse, defense_guard, local_extreme_guard, quote_ev;
// 默认不分配;只有打开 trace 时才指向冷诊断对象。
TraceOrderPtr trace;
};
order.activate_ts = ts + sampled_new_latency_ms;
order.state = sampled_new_latency_ms > 0 ? ORDER_PENDING_NEW : ORDER_OPEN;
if (order.ttl_ms > 0 && ts - order.quote_ts >= order.ttl_ms) {
order.state = ORDER_PENDING_CANCEL;
order.cancel_effective_ts = ts + sampled_cancel_latency_ms;
}PENDING_CANCEL 期间仍允许成交,因此 trace 会单独统计 pending_cancel_fills。这类语义如果只保留“请求撤单后立即删除订单”的简化实现,回测会系统性低估撤单延迟风险。
3.3.3 fill 先消耗 queue,再改变 inventory
maker fill 不由价格 touch 直接触发。主动成交必须跨过订单价格,并先消耗 queue_left:
if (!trade_crosses_order<S>(trade_price, order.price))
continue;
if (order.queue_left > 0.0) {
const double eaten = std::min(order.queue_left, remaining_trade_qty);
order.queue_left -= eaten;
remaining_trade_qty -= eaten;
}
double fill_qty = floor_lot(
std::min(order.remaining, remaining_trade_qty), lot_size);
if (fill_qty < lot_size)
continue;
cash += side_cash_delta<S>(order.price, fill_qty, maker_fee);
inventory += inventory_delta_sign<S>() * fill_qty;
order.remaining -= fill_qty;库存时间风险也在同一事件时钟中积分,不使用“最终仓位乘总时长”的粗略近似:
const double dt_s = (ts[i] - ts[i - 1]) / 1000.0;
const double mark = trade_price[i - 1];
summary.signed_inventory_time_s += inventory * dt_s;
summary.abs_inventory_time_s += std::abs(inventory) * dt_s;
summary.sq_inventory_time_s += inventory * inventory * dt_s;
summary.notional_inventory_time_s += std::abs(inventory) * mark * dt_s;迁移顺序刻意很慢:每增加一个机制,就在固定真实窗口同时跑 Python 和 C++,比较:
PnL
fills / bid-ask split
final inventory
InvAdj
inventory time
spread<100 / cap_hit
Sharpe
trace rowsgolden window 覆盖正常、高波动、低成交/稀疏以及不同月份。只有差异能够被解释,C++ engine 才被允许进入 sweep、cap A/B、quote EV A/B 等批量 runner。默认行为仍保留 Python,显式 --engine cpp 才切换,strict 模式下 extension 不可用会直接报错,避免静默 fallback 让人误以为跑了 C++。
完整 CLI 的 wall time 还包含 parquet、模型和窗口加载,因此不能拿 C++ 内层循环的 microbenchmark 直接宣称整月回测有同样倍数。对回测来说,路径一致比一个夸张的加速倍数更重要。
3.4 quote EV fast screening:快筛不是最终裁决
quote EV online inference 会对大量 quote 逐行构造 feature 并调用模型,完整月度 A/B 很慢。为此项目增加了一条研究快筛路径:
- 在 baseline quote context 下预计算 bid/ask EV arrays;
- C++ replay 在每个 tick 直接读取对应分数;
- 快速跑 baseline、bid、ask、both 四个 key arms;
- 淘汰明显差的 arm;
- 最终候选仍回到 Python online inference 全量确认。
本机接电后的 wall-time 记录是:Jan/Feb/Apr/May arrays 预计算约 57.84s,C++ replay 跑四臂约 7.84s。
研究链路不是一句“打开 C++”就结束,而是把昂贵步骤和快速步骤拆开。下面是实际命令形态(路径缩短为变量):
RESULTS=~/MarketData/NarrowGate_BTCUSDC/backtest_results_btcusdc
MODEL=models/saved_btcusdc_quote_ev_xmarket_cpp_context_20260618
# 1. baseline context 下只做一次 online inference,写成按时间对齐的 arrays
.venv/bin/python models/precompute_quote_ev_arrays.py \
--symbol BTCUSDC \
--months 2026-01 2026-02 2026-04 2026-05 \
--model-dir "$MODEL" \
--orders "$RESULTS/cross_market_shock_audit.orders_features.parquet" \
--tag cpp_fast_screen
# 2. 四个 policy arm 复用同一份窗口 cache 与 EV arrays
NARROWGATE_CPP_STRICT=1 .venv/bin/python models/quote_ev_ab_tick.py \
--symbol BTCUSDC \
--months 2026-01 2026-02 2026-04 2026-05 \
--arms baseline bid_ev_soft_widen ask_ev_soft_widen both_ev_soft_widen \
--engine cpp \
--cpp-quote-ev-data "$RESULTS/quote_ev_arrays_cpp_fast_screen_btcusdc.parquet" \
--window-cache-dir /tmp/narrowgate_tick_window_cache \
--workers 1数组必须按 quote_ev_ts_ms 与 replay 时钟做 backward/as-of 对齐,并在 C++ 入口验证所有 bid/ask EV 列长度一致。NARROWGATE_CPP_STRICT=1 则保证模块不可用、字段缺失或 shape 错误时直接终止,而不是改走 Python 后仍输出一个看似成功的 A/B 文件。
这个数字不能被误读成“完整研究只需八秒”。EV score 被冻结在 baseline context;某个 arm 改变报价后,成交路径、库存和后续 context 都会漂移,而预计算数组不会重算。它适合 screening,不适合 promotion。
同样,C++ loop 变快以后,CLI wall time 可能仍受 parquet、模型和数据加载支配。后续优化重点因此转向 window cache、数组复用和批量 quote decomposition,而不是继续微调一个已经很快的循环。
3.5 streaming feature:迁状态,而不是反复搬列表
逐笔成交进入系统后,需要更新 1s bar、10s feature、taker tempo、rolling volume、L2 execution 和 cross-market state。
第一版 SIGNAL_FEATURES=1 恰好犯了 quote scalar 相同的错误:每十秒把最多 320 根 Python Bar1s 和全部 feature history 逐字段复制成 pybind 对象,C++ 算完 overlay 后再返回 dict。旧基准从 Python 约 1.40ms 变成 C++ 约 2.65ms,明显负优化。
修正后的 SignalFeatureEngine 长期持有最近 320 根 1s bars 和最多 60,480 条 10s history。Python 每完成一根 1s bar 增量推送一次,每十秒只推一条新的 history,再请求一个 feature snapshot:
on_trade(price, qty, side, ts)
-> update current 1s bar
-> close gap/completed bars
-> update rolling state
-> emit compact feature snapshot固定时间窗的基础统计用模板表达。每次 push 只追加新值并从 deque 左侧淘汰过期值,mean/stddev 直接使用累计的一阶、二阶矩,不再扫描整个窗口:
template <int WindowSeconds>
class RollingStats {
public:
void push(std::int64_t ts_ms, double value) {
values_.push_back({ts_ms, value});
sum_ += value;
sum_sq_ += value * value;
expire(ts_ms);
}
void expire(std::int64_t now_ms) {
const auto cutoff = now_ms - WindowSeconds * 1000LL;
while (!values_.empty() && values_.front().first <= cutoff) {
const double old = values_.front().second;
sum_ -= old;
sum_sq_ -= old * old;
values_.pop_front();
}
}
double mean() const {
return values_.empty() ? 0.0 : sum_ / values_.size();
}
double variance() const {
if (values_.size() < 2) return 0.0;
const double mu = mean();
return std::max(0.0, sum_sq_ / values_.size() - mu * mu);
}
private:
std::deque<std::pair<std::int64_t, double>> values_;
double sum_ = 0.0;
double sum_sq_ = 0.0;
};
RollingStats<3> tick_sign_3s;
RollingStats<10> tick_sign_10s;
RollingStats<60> log_ret_60s;更高层的 engine 只暴露增量接口:
class SignalFeatureEngine {
public:
SignalFeatureEngine(std::size_t max_bars = 320,
std::size_t max_history = 60480);
void push_bar(const Bar1s& bar);
void push_history(const FeatureHistoryRow& row);
[[nodiscard]] SignalFeatureVector compute(
const Bar1s& bar_10s) const;
private:
CircularBuffer<Bar1s> bars_;
CircularBuffer<FeatureHistoryRow> history_;
};这里的返回值也不再是热路径上的 std::map<std::string, double>。80 个稳定字段在 C++ 内部使用 enum class SignalFeatureId 定位,值存进 std::array<double, 80>;只有跨过 pybind 边界返回 Python 时,才按 compile-time 字段表物化一次 dict。这样同时去掉了红黑树节点分配、字符串比较和字段拼写漂移。
Python 只在 warmup/reconnect 后 seed 一次,正常运行时逐根追加:
def _ensure_cpp_feature_engine(self):
if not self._cpp_feature_engine_seeded:
for bar in list(self._bar_buffer)[-320:]:
self._cpp_feature_engine.push_bar(self._bar_to_cpp(bar))
for row in self._feat_history:
self._cpp_feature_engine.push_history(self._history_to_cpp(row))
self._cpp_feature_engine_seeded = True
return self._cpp_feature_engine
def _finalize_bar(self, bar):
self._bar_buffer.append(bar)
self._ensure_cpp_feature_engine().push_bar(self._bar_to_cpp(bar))
overlay = self._ensure_cpp_feature_engine().compute(cpp_bar_10s)这里故意没有把所有 cross-market book/trade state 一次迁完。当前 C++ overlay 负责稳定、重复的 rolling 数值部分,Python 继续补 execution L2、cross-market、time 和 metrics features。这样的分层可以逐列做 parity;若一口气迁完整 82-feature pipeline,任何时间对齐差异都会很难定位。
Python 负责事件和研究编排,C++ 负责持续演化的数值状态。这个边界也更接近未来真正值得优化的“计算/决策层”,而不是去重写 WebSocket 和 IO。
同一组固定环境三轮中位数中,完整“喂入十秒事件并计算 features”的路径从 Python 803.20 us 降到 C++ persistent state 563.26 us,快约 29.9%。单独的 1s ingest 会从 22.42 us 增到约 25.44 us,但十秒整体仍有净收益,因为昂贵的窗口重拷贝被移除了。
另一个开关 SIGNAL_STATE=1 只把单条 trade aggregation 搬进 C++,Python 仍负责 event dict 解析、锁、回调和 bar 对象同步。它的 ingest 中位数是 23.44 us,比 Python 22.42 us 慢约 4.5%,因此继续保持关闭。这里的结论很朴素:只有状态和足够长的计算链一起迁移才有意义,单独迁四则运算没有。
3.6 pybind11 的教训:不要估算边界,要 profile 边界
编译后的 extension 会被 CPython 作为 native module 加载。进入 C++ 后当然运行机器码,但调用前后的 tuple/dict 解包、属性访问、容器复制和返回对象构造依旧发生。NarrowGate 这次最有价值的经验不是某个通用的“pybind 调用耗时”,而是同一条路径在不同接口形状下会从负优化变成正收益:
| 接口形状 | 结果 |
|---|---|
| scalar quote + 每 tick 完整 dataclass/dict | C++ 明显慢于 Python |
| scalar quote + cached config + compact context | C++ quote+policy 快约 16.4% |
| 每 10s 重传 320 bars/history | C++ features 明显变慢 |
| C++ 常驻 rolling state + 增量更新 | 10s features 快约 29.9% |
| 单条 trade aggregation 跨一次 pybind | 基本打平且略慢 |
| 离线连续数组 batch / 完整 replay 状态机 | 最适合迁移 |
这张表怎么读
- 说明什么:迁移对象应该按“状态是否能常驻、单次调用是否足够大”来选择。
- 不能说明什么:不能简单得出“Python 慢、C++ 快”;接口形状比语言标签更关键。
- 下一步验证:每个 native 开关都必须同时通过 parity、wall-time 和 strict import/source 检查。
因此现在的判断标准是:
- 计算很少、对象很多时,保留 Python;
- 配置稳定时缓存 native representation;
- rolling window 让 C++ 持有状态,不重复搬历史;
- 离线大数组用 batch,一次跨边界处理许多样本;
- 完整诊断对象按需要生成,不把可观测性变成每 tick 的固定税;
- 每个开关都必须同时通过 parity 和 wall-time,不能只证明“确实执行了 C++”。
3.6.1 一条 native path 要过三层测试
第一层是小输入的字段 parity,用专门构造的 inventory、depth、adverse/defense 场景逐字段比较;第二层是 synthetic replay,检查成交路径、queue、latency、TTL 和 emergency 分支;第三层才是真实窗口 golden test:
# quote / signal / synthetic replay
.venv/bin/python -m pytest \
tests/test_cpp_quote_core_parity.py \
tests/test_cpp_signal_features.py \
tests/test_cpp_tick_replay_parity.py -q
# 正常、高波动、稀疏和跨月真实窗口
RUN_NARROWGATE_GOLDEN=1 NARROWGATE_CPP_STRICT=1 \
.venv/bin/python -m pytest \
tests/test_cpp_tick_replay_golden_parity.py -qgolden test 不要求所有浮点数逐 bit 相同,但必须给每类差异设显式容忍度。尤其不能只比最终 PnL,因为两条不同成交路径可能偶然得到接近的 PnL。当前比较集合至少包括:
SUMMARY_KEYS = (
"pnl", "inventory_adjusted_pnl", "inventory_pnl",
"fills_total", "fills_bid", "fills_ask", "final_inventory",
"avg_markout", "markout_count",
"abs_inventory_time_s", "signed_inventory_time_s",
"sq_inventory_time_s", "notional_inventory_time_s",
"avg_spread", "avg_final_spread", "n_requotes",
"quote_spread_lt_100_rate", "cap_hit_rate",
"sharpe", "max_drawdown",
)开发阶段还必须打开 strict 模式:
NARROWGATE_CPP_QUOTE_CORE=1 \
NARROWGATE_CPP_SIGNAL_FEATURES=1 \
NARROWGATE_CPP_STRICT=1 \
.venv/bin/python bench/bench_live_path.py \
--n 10000 --signal-n 1000 --engine cpp --strict-cpp如果 C++ import、模块来源检查或某个 native 调用失败,这条命令必须非零退出。否则 benchmark 很可能测到 fallback 后的 Python,却被错误记录为 C++ 性能。
3.6.2 是否迁移,用“状态驻留 × 调用粒度”决定
项目现在用下面这套决策矩阵,而不是看到 Python 函数就重写:
| 路径 | 状态是否可常驻 | 单次工作量 | 方案 |
|---|---|---|---|
| parquet/特征表 IO | 否 | 大,但 Arrow 已优化 | 保留 Python/Arrow |
| LightGBM 训练 | 模型库内部已 native | 大 | 保留 Python orchestration |
| scalar side policy | 少 | 约几十微秒 | 与 quote/routing 合并后再迁 |
| rolling signal | 是 | 每 trade/每秒重复 | C++ 长期持有窗口状态 |
| quote decomposition | 否 | 数万至数百万行 | NumPy contiguous batch |
| tick replay | 是 | 完整窗口顺序状态机 | 一次 C++ 调用跑完整窗口 |
这张表怎么读
- 说明什么:迁移优先级来自计算形态,不来自文件名或语言偏好。
- 不能说明什么:不能说明 WebSocket、REST、日志这些冷路径没有工程价值,只是它们不是当前 CPU 热点。
- 下一步验证:对候选迁移点先做 flame/profile,再设计最小 native API,而不是照搬 Python 对象模型。
这张表也解释了为什么 WebSocket 层没有成为 C++ 迁移重点。网络等待、JSON 解码和 Python callback 不是当前最大的可控 CPU 成本;真正值得迁的是 WebSocket 上方的 rolling state、quote/policy bundle 和订单状态机。把 IO 也改写,只会扩大维护面,却不一定缩短决策尾延迟。
当前相关 quote、signal、tick replay 快速测试共 20 passed;显式开启的四个真实窗口 golden tests 也全部通过。这些 benchmark 是同机 synthetic CPU isolation,关闭 ML 以分离 feature/quote 成本;它们不代表网络延迟、模型推理或完整事件引擎的端到端耗时,也不能直接决定 live 默认配置。
3.6.3 C++20 现代化:改的是数据运动,不只是标准号
完成 Python/C++ parity 后,构建标准从 C++17 升到了 C++20:
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
target_compile_features(narrowgate_cpp PRIVATE cxx_std_20)选择 C++20 并不是为了把所有新语法都塞进交易路径,而是优先使用几项收益明确、行为容易验证的能力。
第一项是用 concept 和 std::span 表达“不拥有、连续、只读的数值数组”。quote core、tick replay 和 streaming feature 不再各自传裸指针与长度:
template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>;
template <Arithmetic T>
using ArrayView = std::span<const T>;
template <Arithmetic T>
struct MatrixView {
ArrayView<T> values;
std::size_t rows{};
std::size_t cols{};
[[nodiscard]] T at(std::size_t row,
std::size_t col) const noexcept {
return values[row * cols + col];
}
};这不会自动让循环变快,但它把 shape、ownership 和 constness 写进类型,减少 Python buffer 绑定和 replay 数组新增字段时的错误面。[[nodiscard]] 则放在 quote result、feature snapshot 和 replay result 上,避免调用方无意丢弃策略结果。
第二项是固定容量 ring buffer。旧的 60,480 行 history 使用 vector.erase(begin()) 淘汰头部,每次满载 push 都会搬动后续元素;现在只覆盖 head:
template <typename T>
class CircularBuffer {
public:
explicit CircularBuffer(std::size_t capacity)
: storage_(capacity) {}
void push(const T& value) {
if (size_ < storage_.size()) {
storage_[(head_ + size_) % storage_.size()] = value;
++size_;
return;
}
storage_[head_] = value;
head_ = (head_ + 1) % storage_.size();
}
[[nodiscard]] const T& operator[](std::size_t i) const {
return storage_[(head_ + i) % storage_.size()];
}
private:
std::vector<T> storage_;
std::size_t head_{};
std::size_t size_{};
};对应的 80 维 feature 输出改成稳定布局;字段名只在边界出现:
enum class SignalFeatureId : std::size_t {
AggressionImbalance10s,
BookImbalance,
// ... 其余稳定字段
Count
};
struct SignalFeatureVector {
std::array<double,
static_cast<std::size_t>(SignalFeatureId::Count)> values{};
double& operator[](SignalFeatureId id) noexcept {
return values[static_cast<std::size_t>(id)];
}
};名称诊断使用 std::ranges::lower_bound,失败路径附带 std::source_location;窗口常量使用 std::to_array。这些都在冷路径或编译期工作,不给每 tick 增加新分配。
第三项是让 replay 的状态和 trace 从字符串变成强类型枚举,并用 std::pmr::monotonic_buffer_resource 承担单次 replay 内部短生命周期容器:
enum class OrderState : std::uint8_t {
PendingNew, Active, PendingCancel, Filled, Cancelled
};
enum class TraceOutcome : std::uint8_t {
None, Filled, Cancelled, Expired, Rejected
};
std::array<std::byte, 64 * 1024> replay_scratch{};
std::pmr::monotonic_buffer_resource arena(
replay_scratch.data(), replay_scratch.size());
std::pmr::vector<ReplayOrder> bid_orders{&arena};
std::pmr::vector<ReplayOrder> ask_orders{&arena};pybind property 仍返回旧的字符串值,所以 Python trace schema、parquet 报告和 A/B 脚本无需改变。PMR 只管理函数内部的临时订单和 pending markout;需要返回 Python 的结果继续使用普通 owning container,避免把 arena 生命周期泄漏到边界外。
本次现代化后的同机结果如下:
| 测量 | 旧实现 | C++20 实现 | 解读 |
|---|---|---|---|
| 500-row 短历史 signal 10s | 557.32 us | 553.17 us | 快约 0.7%,短窗口本来就不是搬移瓶颈 |
| quote + policy | 63.09 us | 63.18 us | 统计上持平,现代化没有伤害 scalar path |
| 60,480 history 满载 push(纯 C++ operation microbench) | 80.05 us | 0.0026 us | 去掉 erase(begin) 后由 O(N) 变 O(1) |
| 60,480 history full snapshot | - | 111.23 us | 常驻 ring + 定长输出下的完整计算 |
这张表怎么读
- 说明什么:C++20 现代化主要减少数据搬移和生命周期错误,短路径不一定会显著变快。
- 不能说明什么:
0.0026 us不是 live 总延迟,也不是策略收益,只是一个容器操作微基准。- 下一步验证:继续看真实窗口 golden parity、长 history p99,以及目标 Linux CPU 上的 PMU 或 wall-time soak。
第三行尤其不能直接解读成 live 总延迟快了三万倍:它只隔离测量“满载后保留尾部”的容器操作。真实路径还包含 Python event、pybind、模型和路由。它说明的是旧容器在长时间运行后存在确定的线性成本,而不是给系统吞吐量制造一个夸张数字。
验证也按风险分层完成:第一轮快速 quote/signal/replay 测试为 18 passed, 4 skipped;另行显式运行 May 正常、高波动、Feb 稀疏和 Jan 窗口的 golden parity 为 4 passed。此外三个 C++ 核心翻译单元用 -std=c++20 -Wall -Wextra -Wpedantic 编译为零 warning。由于短路径收益接近噪声,本次只更新 native 基础设施和研究路径,没有修改 live 配置。
3.6.4 第二轮:消除 depth 分配,拆开热状态与诊断状态
第一轮解决了 Python/C++ 边界和长 history 头删,但 profile 里还有三类成本:每次 requote 临时构造 L2 vectors、热订单携带完整 trace、长周期 vol-regime 每次重新扫历史。第二轮没有改策略公式,而是继续压缩数据运动。
L2 使用 view,不再逐行复制
原始 L2 输入是四个 SoA 矩阵:bid price/qty、ask price/qty。把它们先拼成 vector<DepthLevel>,既分配又把 SoA 重新交错。现在 quote core 直接消费两个 span pair:
struct DepthSideView {
std::span<const DepthLevel> levels; // owned API 可用
std::span<const double> prices; // matrix row 可用
std::span<const double> quantities;
[[nodiscard]] double price(std::size_t i) const noexcept;
[[nodiscard]] double quantity(std::size_t i) const noexcept;
};
struct DepthView {
DepthSideView bids;
DepthSideView asks;
};
const DepthView depth{
DepthSideView{{}, bid_px.row(i), bid_qty.row(i)},
DepthSideView{{}, ask_px.row(i), ask_qty.row(i)},
};
const auto quote = compute_quote_core(state, cfg, pred, depth);公开 pybind API 仍保留 owning DepthSnapshot,它只需提供 .view()。replay 和 batch 则直接 view NumPy buffer 的矩阵行,不再为每个 tick/row 分配两个 vector。
BBO/L2 的 current-time snapshot 也从每次 binary search 改成 loop-owned cursor:
bbo_idx = advance_index(input.bbo_ts_ms, bbo_idx, ts);
l2_idx = advance_index(input.l2_ts_ms, l2_idx, ts);
const auto book = book_snapshot_at(input, ts, bbo_idx, l2_idx, ...);这里有一个不能“顺手优化”的边界:queue ahead 使用未来 activate_ts,而 latency jitter 会让相邻订单的 activation timestamp 不保证单调。它不能共享 current-time cursor,因此仍保留二分。错误共享 cursor 会把未来盘口泄漏给当前 quote,速度快了,回测却坏了。
ReplayOrder 只保存会参与成交循环的字段
旧 ReplayOrder 同时持有完整 SideQuoteContext 和 TraceOrderRow。即使 trace 关闭,transition_orders()、best_live_order()、process_side_fill() 扫描的每个订单也背着一大块冷数据。现在热结构只保留成交状态:
struct ReplayOrder {
Side side;
double price, remaining, queue_left, queue_init;
std::int64_t quote_ts, activate_ts, cancel_effective_ts, ttl_ms;
OrderState state;
bool side_adverse, defense_guard, local_extreme_guard, quote_ev;
TraceOrderRow* trace = nullptr;
};只有 trace_quotes_max 或 trace_fills_max 大于零时,才从 arena-backed unsynchronized_pool_resource 分配 cold row。hot order 用带 PMR deleter 的 RAII pointer;订单退出后 slot 回到 pool,不会像纯 monotonic allocation 一样随长窗口持续累积。输出 trace 仍复制到 owning result vector,pool pointer 不跨函数生命周期。
fill trace 的时间窗也改成 lower_bound/upper_bound 定位,不再从第零条成交开始扫;1s/5s/30s markout 同样用 lower_bound 找 horizon。trace 默认关闭,这项优化主要服务大规模 quote decomposition。
summary-only 不是简单丢掉曲线
PnL curves 原先还承担 Python 侧 Sharpe 和 max drawdown,所以不能直接禁用。C++ replay 现在每次 requote 在线维护 peak、drawdown、PnL delta、时间增量以及标准化 delta 的一、二阶矩:
const double normalized = pnl_delta / std::sqrt(dt_s);
pnl_delta_sum += pnl_delta;
pnl_dt_sum += dt_s;
normalized_sum += normalized;
normalized_sq_sum += normalized * normalized;
peak_pnl = std::max(peak_pnl, current_pnl);
max_drawdown = std::max(max_drawdown, peak_pnl - current_pnl);单次 API 的 collect_curves=true 保持兼容;C++ sweep、cap A/B、统一 tick_ab.py 和 quote-EV A/B 自动使用 summary-only。2M trades、200k requotes 的 synthetic replay 中,CPU 只从 42.18ms 降到 41.77ms,约 1%;真正收益是三条 200k vector 变成零行,也无需再转成 NumPy。
长周期 moments 增量化,但保持数值稳定
长 history 的主要扫描来自 2,160/8,640 条 return_abs 均值和最多 60,480 条 vol_regime_6h z-score。它们现在由 fixed-count rolling moments 在 push_history() 时更新。
简单维护 sum_sq - sum²/n 在低方差序列上会严重消差;长历史 parity test 正好抓到了这个问题。因此实现使用支持删除最旧值的 Welford mean/M2 更新,而不是放宽测试:
static void remove_value(double x, std::size_t n,
double& mean, double& m2) noexcept {
const double next_mean = (n * mean - x) / (n - 1);
m2 = std::max(0.0, m2 - (x - mean) * (x - next_mean));
mean = next_mean;
}SignalFeatureEngine.compute_values() 返回固定顺序的 NumPy array;SIGNAL_FEATURE_NAMES 只在模块初始化时创建一次。旧 dict API 仍兼容。满 60,480 history 时,fixed-array snapshot 从第一轮约 111.23us 降到 1.90us,兼容 dict 为 4.42us。短历史 live 整体仍受 Python feature merge/model 路径影响,没有据此切换 live 默认配置。
只给逐行独立 batch 加线程
单个 replay 的订单、库存、现金和 latency 状态强顺序依赖,仍保持单线程。depth quote batch 的每一行独立,才新增显式 workers 参数;默认 1,每 worker 至少 4,096 行:
std::vector<std::jthread> threads;
for (std::size_t worker = 0; worker < worker_count; ++worker) {
threads.emplace_back(run_rows, begin[worker], end[worker]);
}100k rows × 10 levels 的 Release benchmark:1 worker 为 35.48ms / 2.82M rows/s,4 workers 为 10.08ms / 9.92M rows/s,快 3.52x。参数已经贯通到 cross_market_shock_audit.py --quote-context-workers N,但不会自动开启:若外层已经按 month/window/arm 多进程并行,再把每个进程开满内部线程只会过度订阅。
第二轮验证结果为快速 suite 20 passed, 4 skipped,四个真实窗口 golden parity 4 passed,核心 C++20 翻译单元仍为零 warning。优化改变了 allocation、cache footprint 和输出策略,没有改变成交路径,也没有修改 live 配置。
3.6.5 ARM 本机 benchmark 与 x86 live 的边界
这里还要补一个容易被忽略的限制:我的训练和回测主要在 Apple Silicon/ARM64 本机上完成,而真正的 live 机器往往是 x86 Linux。两边的 CPU 前端、cache 层级、memory ordering、线程调度、LightGBM/OpenMP runtime 都可能不同。更细一点说,x86 的原子读改写通常对应 LOCK 前缀一类硬件原语和更强的内存模型;ARM 上常见实现会通过 load-exclusive / store-exclusive retry loop 或 LSE 指令完成,不同 CPU、编译参数和竞争程度下成本差异很大。
NarrowGate 当前 C++ hot path 并没有把 std::atomic 当作优化手段;代码里真正显式的 C++ 内部并发,是 compute_quote_core_batch_depth(..., workers=N) 的 std::jthread 分片。因此风险不是“某个 atomic trick 在 ARM 上慢”,而是更朴素:本机离线吞吐优化不等于 x86 live 尾延迟优化。
更专业一点说,我后来把优化项按 CPU/OS 层拆成下面这张表,而不是笼统地说“C++ 更快”:
| 层面 | Apple Silicon / ARM 本机更容易看到的收益 | x86 Linux live 需要重新判断的点 | 对 NarrowGate 的工程含义 |
|---|---|---|---|
| CPU 前端与 I-cache | 本机编译器、P-core 前端、统一内存带来的微基准很稳定,适合看代码布局和 pybind 固定税 | 目标 live 机器通常是云上 Intel/AMD,32KiB L1I 很常见,SMT/虚拟化会改变 p99 | 只缩小整个 .so 的 __text 没意义;要看 live 事件实际经过的 hot instruction working set |
| D-cache / TLB / allocator | replay 的 DepthView、hot/cold order、summary-only curves 很容易在本机吞吐 benchmark 中体现 |
live 的状态更小,但 tail latency 更怕偶发分配、page fault、跨 NUMA/跨 socket 访问 | replay 优化主要服务研究吞吐;live 侧要优先去掉每 tick 动态对象和字符串查找 |
| memory ordering / atomics | ARM 上不同实现可能走 LL/SC retry 或 LSE,竞争时成本形态和 x86 不同 | x86 有更强内存模型和 LOCK 前缀原语,但 false sharing 仍会毁掉 tail |
当前 hot path 不用 std::atomic;未来 lock-free queue 必须分别在 ARM/x86 测 |
| SIMD / 数学库 | ARM64/NEON、Apple Accelerate/本机 wheel 影响训练和 NumPy/LightGBM 吞吐 | x86 wheel 可能走 AVX2/AVX512/OpenBLAS/OpenMP,不同实例族差别很大 | models/ml_model.py 的 Apple 参数只服务本机训练;live venv 要固定 BLAS/OMP 线程 |
| 线程调度 / SMT / IRQ | 本机桌面负载和核心调度相对可控 | 云上 2 vCPU 可能只是同一个物理核的两个 SMT 线程;ENA/NVMe IRQ 与 softirq 会抢 CPU | taskset 不一定总是好事;先固定库线程,再观察 CPU migration 和 p99.9 |
| Python/C++ ABI | pybind 往返在 ARM 上可能比 quote 数学本身更贵,导致 scalar C++ 负优化 | x86 上 tuple ABI 可能足够便宜,但 dict/asdict 仍是固定税 | live C++ 要迁“大包决策”,不要迁单个 scalar 函数;固定 tuple/array 比 dict 更重要 |
| 可观测性 | 本机可用 Instruments、time、Python profiler 和局部 microbench |
云 KVM 可能不暴露 PMU,perf stat 看不到 cycles/L1I/iTLB |
没有 PMU 时只能先用 wall-time p99/p99.9 soak;最终 promotion 要在真实 live 机型复测 |
我把这些结果分成两类。
第一类是“本机有效但 live 不一定有效”:
models/ml_model.py里的 Apple Silicon/ARM64 假设。LightGBM 的 native ARM64、NEON、统一内存和本机核心数,只说明本机训练吞吐;x86 研究机应重新设置MM_LGB_THREADS、histogram pool 和 OpenMP 亲和性。- 所有 multiprocessing sweep。
backtest_tick.py --sweep、tick_ab.py --workers、quote EV A/B workers 缩短的是多月、多臂、多窗口研究 wall time;live 的决策路径仍然是单事件、强顺序、低尾延迟问题。 compute_quote_core_batch_depth(..., workers=N)的 worker 分片。100k rows × 10 levels 在本机 4 workers 快 3.52x,但如果外层已经按 month/window/arm 多进程并行,再在每个进程内部开满线程,只会过度订阅。x86 上 worker 数要重新扫。- tick replay 的 allocator/cache locality benchmark。
DepthView、hot/cold order、summary-only curves 和 PMR trace pool 减少了 replay 的内存运动与 Python 转换,但它们首先服务离线研究吞吐,不自动证明 live quote/routing 更快。
第二类是在 x86 live 目标机器上必须单独 benchmark 的开关:
scripts/x86_live_env_audit.sh
scripts/x86_live_benchmark.sh
scripts/x86_live_soak.sh这些 benchmark 也不能只看 mean。live 关心 p99/p99.9、偶发抖动和是否触发跨语言 fallback。目标 x86 Linux 上应该同时看 cycles、instructions、branch-misses、L1-icache-load-misses、iTLB-load-misses、LLC miss、context switches。只有这些指标和策略 parity 一起过,native 开关才有资格从 shadow 变成配置候选。
后来我真的拿了一台 x86 EC2 机器做了这件事:Amazon Linux 2023、Intel Xeon Platinum 8259CL、2 vCPU(一个物理核的两个 SMT 线程)、L1d/L1i 都是 32KiB,clocksource 是 tsc。这台机器一开始甚至没有 gcc/g++/cmake,所以第一步不是调参数,而是把 live benchmark 环境和训练环境拆开。
这里有个很现实的坑:如果在 Linux live 机器上直接 pip install -r requirements.txt,torch 会默认拉一大串 CUDA wheels。它对这个 maker live path 没帮助,却会把 32G 根盘迅速塞满。我最后只装了 live benchmark 需要的轻量依赖:
python3.11 -m venv .venv
source .venv/bin/activate
pip install numpy pandas pyarrow lightgbm scikit-learn scipy \
pyyaml requests binance-futures-connector websocket-client \
pycryptodome zstandard pytest
pip install -e cpp为了不再靠手敲命令,我加了两个脚本:
scripts/x86_live_env_audit.sh
scripts/x86_live_benchmark.sh
scripts/x86_live_soak.sh第一个脚本记录 OS、CPU/cache、clocksource、sysctl、IRQ、Python/narrowgate_cpp import 和 NumPy backend。第二个脚本做短跑 smoke:Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。第三个脚本只跑候选组合长样本 soak,用来观察 p99.9。
export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1
export MALLOC_ARENA_MAX=1然后依次跑 Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。
这次 x86 结果很有意思,因为它和 Apple Silicon 本机结论并不完全一致:
| 开关 | signal 10s mean/p99 | _compute_quotes mean/p99 |
quote+policy mean/p99 |
结论 |
|---|---|---|---|---|
| Python baseline | 3431 / 4159 us | 152 / 201 us | 240 / 305 us | x86 baseline |
NARROWGATE_CPP_QUOTE_CORE=1 |
3328 / 4233 us | 121 / 158 us | 210 / 270 us | quote core 在 x86 上转正 |
NARROWGATE_CPP_SIGNAL_FEATURES=1 |
1973 / 2347 us | 153 / 194 us | 237 / 302 us | signal features 在 x86 上明显有收益 |
QUOTE_CORE=1 + SIGNAL_FEATURES=1 |
1949 / 2199 us | 120 / 160 us | 209 / 269 us | 当前 x86 live 候选组合 |
这张表怎么读
- 说明什么:ARM 本机结论不能直接外推到 x86 live;目标机型上 quote core 和 signal features 的方向都要重新测。
- 不能说明什么:短跑 smoke 不能代表长时间 p99.9,也不能代表网络、REST 和交易所响应。
- 下一步验证:固定 BLAS/OMP 线程后跑 soak,并确认没有 CPU migration、page fault 或 fallback 尖刺。
短跑只能说明方向,所以我又在同一台最终 live 机型上跑了更长的 soak:baseline 和候选各 --n 100000 --signal-n 10000,routing compact ABI --n 200000。这次输出 p99.9:
| 路径 | Python baseline mean/p99/p99.9 | QUOTE_CORE + SIGNAL_FEATURES mean/p99/p99.9 |
观察 |
|---|---|---|---|
| signal 10s features | 9610 / 17558 / 40658 us | 2167 / 4534 / 16115 us | C++ fixed-array 显著压低长历史尾延迟 |
live _compute_quotes |
172 / 236 / 4380 us | 123 / 166 / 225 us | C++ quote core 降低均值,也避开本轮 quote-only 尖刺 |
quote + policy |
249 / 308 / 364 us | 219 / 281 / 358 us | 组合路径稳定小幅改善,policy 仍是 Python 主体 |
| compact routing ABI | n/a | 1.17 / 1.24 / 2.03 us | pybind tuple 固定税很低 |
这张表怎么读
- 说明什么:x86 上最明显的 live CPU 收益来自 fixed-array signal features;quote core 更像稳定降低小路径尾部。
- 不能说明什么:它不包含 Binance 网络延迟、REST new/cancel、交易所撮合和真实异常恢复。
- 下一步验证:在真实 live 进程旁路 shadow 打点,比较开启/关闭 native 开关时的 HEALTH、requote、ORDER_UPDATE 和 p99.9。
这说明一件事:架构边界不是文档措辞,而是真的会改变工程结论。 在本机 ARM 上,早期 SIGNAL_FEATURES=1 曾经是负优化;经过 fixed-array/ring-state 改造后,在 x86 上它反而变成最明显的 live CPU 收益。相反,SIGNAL_STATE=1 在这台 x86 上没有额外收益。pin 到单个 vCPU 对均值帮助也不大,甚至可能放大 VM 上的尾部抖动。
perf stat 也给了一个现实提醒:这台 KVM 实例不暴露 cycles、instructions、L1-icache-load-misses、iTLB-load-misses 这些硬件事件,最后只能看到 context switches、cpu migrations 和 page faults。因此“x86 benchmark”也要继续细分:普通云主机能做 wall-time/tail-latency 复测,但真正要分析 L1I/iTLB/cache miss,可能需要裸金属、特定实例族或能暴露 PMU 的环境。
3.7 live 路径的 C++ 优化:与 replay 完全不同的约束
前面几节讲的 quote core batch、tick replay 状态机和 streaming features,都是以”吞吐量”为优化目标:输入是连续数组或完整窗口,可以释放 GIL、多线程并行、用 SoA 布局喂给 SIMD。但 live 路径是另一种世界。
从 C++ 学习的角度看,live 优化最容易犯的错是把“语言更快”误解为“函数搬过去就会更快”。maker live path 里单个 quote 公式可能只要几十微秒,pybind 边界、Python 字典、dataclass 展开、日志对象构造和 feature merge 却可能比公式本身更贵。因此这里分析的重点不是 C++ 语法,而是 C++ 函数边界应该长什么样。
事件引擎每隔 requote_interval 秒被 Python main loop 调用一次 tick(),整个决策链跑在同一个线程上:
WebSocket callback → aggTrade/depth → signal.on_agg_trade()
↓
main loop: tick() ──► _requote()
│ ├── compute_signal() # LightGBM predict
│ ├── compute_quote_core_live() # C++ compact path
│ └── _update_orders()
│ ├── _build_side_policy()
│ └── REST new/cancel
└── [sleep rq_interval]这里没有批量处理的机会:每次报价决策只有一组 state,一个 depth snapshot。释放 GIL 换并行也不适用——WebSocket 回调、quote 决策和 REST 调用都在同一线程中串行执行,GIL 切换反而是额外开销。
早期的错误迁移方式长这样:
# 每 tick 都创建大对象,再跨 pybind 边界,再返回大 dict。
# C++ 只算了 quote 公式,Python 仍然负责 policy/routing。
ctx = asdict(live_quote_state) # 14+ fields
cfg = asdict(live_quote_config) # 70+ fields
depth = [asdict(level) for level in l2_book] # N levels
quote = narrowgate_cpp.compute_quote_core(ctx, cfg, depth)
bid_policy = build_policy_from_dict(quote["bid_context"])
ask_policy = build_policy_from_dict(quote["ask_context"])
orders = python_route_orders(quote, bid_policy, ask_policy)这看起来“用了 C++”,但热路径仍然到处是 hash lookup、字符串 key、临时 dict/list、对象分配和 Python 属性访问。改造后的方向是把生命周期不同的数据拆开:
# config:几乎不变,缓存 native object
native_cfg = cached_cpp_config(cfg)
# state/pred:每 tick 改变,但字段位置固定
state_tuple = (
mid, inventory, sigma_sq, trade_intensity,
best_bid, best_ask, ber_active,
mo_ema_all, mo_ema_bid, mo_ema_ask, mo_ref,
position_open, hold_time_s, unrealized_pnl,
)
pred_tuple = (dir_10s, vol_10s, ret_10s, tox_bid, tox_ask)
# result:先返回 compact result,只有 quote EV/trace 才物化完整 context
quote = narrowgate_cpp.compute_quote_core_live(
state_tuple, native_cfg, pred_tuple, depth_bids, depth_asks)
route = narrowgate_cpp.compute_live_routing_decision(
routing_input_tuple,
bid_policy_tuple,
ask_policy_tuple,
)这里的学习点很清楚:C++ 不是为了替代 Python 的事件编排,而是为了让高频决策包使用固定布局、固定字段、固定分支,减少每次 tick 的动态对象税。
3.7.1 热路径的三个生命周期层
live 路径的优化策略由三个不同的生命周期决定,不是统一的”全部 C++”:
第一层:几乎不变的 config(按配置对象缓存)
QuoteCoreConfig 包含 70+ 个策略参数(gamma、kappa、adverse/defense 阈值、spread cap 系数等)。直接每次 requote 都从 Python cfg 解包再构造一次 native 对象,benchmark 测出来比 Python 还慢——单次约 300µs 的 object 构造完全抹平了 quote 数学的收益。
改造后用 id(cfg) + weakref 作缓存键(具体实现见第九节 9.1),每次报价的 C++ config 参数零分配,config reload 时才同步一次。
第二层:每 tick 变化的 state(固定位置 tuple)
策略状态(14 个字段:mid、inventory、sigma_sq、BBO、markout EMA 等)每次 requote 都不同,必须传入。用固定位置 tuple 代替 dict,C++ 端按下标读取,无 key hash、无对象构造(接口见第九节 9.1)。
第三层:按需物化的诊断(功能驱动的懒加载)
side policy 每 tick 只需 9 个字段;完整的 100+ 字段 context 只在 quote EV 或离线 trace 时才需要,由 require_full_context 控制(详见第九节 9.2)。
这三层合在一起,让 compute_quote_core_live 从最初的负优化(慢 59%)翻转为稳定正收益(快 16.4%)——不靠改进 quote math,只靠把「每 tick 固定税」改成「功能需要时才支付」。
3.7.2 routing bridge:当前还不是完整 native 决策包
_build_side_policy() 是 live 路径的另一个计算热点。它接收 quote_context、L2 metrics 和 pred,输出 spread_mult、size_mult、allow_post、reason_mask,大量工作是评估 10+ 个 policy guard 的条件。
这些条件判断本身不重,但每次需要访问 Python dict(quote_ctx.get("side_adverse", False) 等),在单 tick 热路径上积少成多。
NARROWGATE_CPP_LIVE_ROUTING 仍只接管后半段的价格调整、spread cap、库存准入、size 和 cancel/replace 判断,但跨语言接口已经从动态 dict 收紧为固定布局:
bid_policy = self._build_side_policy(Side.BUY, mid, q, pred)
ask_policy = self._build_side_policy(Side.SELL, mid, q, pred)
routed = cpp_route.compute_live_routing_decision(
(
mid, q, base_bid_price, base_ask_price,
best_bid, best_ask, tick, lot, min_qty, min_notional,
order_size, max_inventory, eta, symmetric_size,
requote_threshold_bps, max_spread,
bid_alive, bid_active_price, bid_age_ms,
ask_alive, ask_active_price, ask_age_ms,
),
(
bid_policy.allow_post,
bid_policy.allow_exposure_increase,
bid_policy.spread_mult,
bid_policy.size_mult,
bid_policy.order_ttl_ms,
),
(
ask_policy.allow_post,
ask_policy.allow_exposure_increase,
ask_policy.spread_mult,
ask_policy.size_mult,
ask_policy.order_ttl_ms,
),
)native 侧对应的是普通结构体,而不是把业务逻辑继续堆在 pybind lambda 中:
struct LiveRoutingPolicy {
bool allow_post;
bool allow_exposure_increase;
double spread_mult;
double size_mult;
double order_ttl_ms;
};
LiveRoutingResult compute_live_routing_decision(
const LiveRoutingInput& input,
const LiveRoutingPolicy& bid,
const LiveRoutingPolicy& ask);它仍不是 quote → policy → routing 的完全 fused kernel,因为 _build_side_policy() 还在 Python;但 routing 边界自身已不再创建两个 20+ 字段 dict、逐个做字符串 key 查询、再创建结果 dict。旧/新二进制使用同一组输入做 100,000 次对照,checksum 完全一致:mean 6.024 → 0.472 us,p99 6.375 → 0.583 us。这个约 92% 的下降只描述 routing binding,不代表完整 _update_orders() 或网络报单延迟也下降 92%。
最终更理想的 live C++ 决策包应该继续向下面这个形态收敛:一次调用接收 market state、model prediction、inventory state 和 live order state,返回两个 side 的完整动作,但仍不负责 REST IO。
enum class OrderAction : std::uint8_t {
Keep,
Cancel,
Replace,
NewOrder,
};
struct LiveDecisionInput {
double mid;
double best_bid;
double best_ask;
double tick_size;
double lot_size;
double inventory;
double max_inventory;
double sigma_sq;
double ret_10s;
double tox_bid;
double tox_ask;
ActiveOrder bid_order;
ActiveOrder ask_order;
DepthView depth;
};
struct SideDecision {
OrderAction action;
double price;
double quantity;
double ttl_ms;
std::uint32_t reason_mask;
};
struct LiveDecision {
SideDecision bid;
SideDecision ask;
double final_spread;
bool inventory_blocked;
};
LiveDecision compute_live_decision(
const LiveDecisionInput& in,
const QuoteCoreConfig& quote_cfg,
const PolicyConfig& policy_cfg) {
auto quote = compute_quote_core_compact(in, quote_cfg);
auto bid_policy = evaluate_side_policy<Side::Buy>(
quote.bid, in.inventory, in.tox_bid, policy_cfg);
auto ask_policy = evaluate_side_policy<Side::Sell>(
quote.ask, in.inventory, in.tox_ask, policy_cfg);
return route_quote_to_orders(
quote, bid_policy, ask_policy, in.bid_order, in.ask_order);
}这段伪代码里有两个刻意保留的边界:
ActiveOrder是当前本地状态,不在 C++ 里发 REST;C++ 只说“应该怎么做”,Python 负责实际 cancel/new 和异常处理。reason_mask是整数位图,不是字符串列表;日志需要人类可读 reason 时再冷路径翻译。这样热路径不做字符串拼接,也不会因为审计文本拖慢每次报价。
设计上有一点克制:进出 REST 的部分(cancel/new order、TTL check、GIL 相关操作)仍留在 Python,不放进 C++ call 内。这条边界的理由是:REST 调用本身的延迟在 10-50ms 量级,比 C++ 函数的收益大两个数量级;强行把 REST 放进 C++ 只会让错误更难 debug,而不会降低端到端延迟。
3.7.3 最小且可预测的热指令工作集
HFT 代码短小,不是为了追求源码上的简洁,而是为了控制 CPU 前端真正看见的机器码。这里采用的标准是:让一次高频事件实际触达的指令工作集最小、稳定且分支可预测,而不是让整个扩展模块都塞进 L1I。
这一区别很重要。优化后 narrowgate_cpp 的 __text 是 471,364 bytes,仍然大于本机性能核 192 KiB、能效核 128 KiB 的 L1I;但其中包括 pybind 注册、Python 类型转换、异常处理、批量研究接口和 trace binding,它们不会在一次 live requote 中全部执行。使用与发布扩展一致的 Release + pybind ThinLTO 诊断构建和 linker map 查看真正的函数尺寸,结果是:
| native 函数 | text 大小 | 热路径判断 |
|---|---|---|
compute_quote_core() |
7,528 B | live quote 主体紧凑,明显小于常见 32 KiB L1I |
shared compute_signal_feature_vector() |
9,184 B | stateful/legacy 共用的唯一大计算主体 |
simulate_tick_arrays() |
23,596 B | replay 顺序状态机接近 32 KiB 级别,仍需连同被调用 helper 用 PMU 验证 |
legacy compute_signal_feature_overlay() wrapper |
1,136 B | 只构造 span view 并调用共享主体,不再复制大函数 |
| depth batch pybind worker | 23,668 B | 离线批量入口,不属于 live scalar 热路径 |
诊断命令本身也应进入性能工程流程,而不是只看 benchmark 平均值:
BIN=$(.venv/bin/python -c \
'import narrowgate_cpp; print(narrowgate_cpp.__file__)')
xcrun llvm-size -A "$BIN"
xcrun llvm-objdump -d --demangle --no-show-raw-insn "$BIN" \
> /tmp/narrowgate.disasm
# 诊断构建额外生成 linker map:-Wl,-map,/tmp/narrowgate.map
# Linux 研究机再看 L1I/iTLB/branch miss 与 p99 是否同步恶化。
perf stat -r 20 \
-e cycles,instructions,branches,branch-misses,\
L1-icache-loads,L1-icache-load-misses,\
iTLB-loads,iTLB-load-misses \
./bench_hot_path源码审计也需要给模板设预算。当前模板维度主要是 Side、QueueAheadMode、固定 rolling window 和容器 view:
template <Side S, QueueAheadMode Mode>
double visible_queue_ahead(...);
template <int WindowSeconds>
class RollingStats;Side x QueueAheadMode 只有四种 queue 实例,RollingStats<60> 即使有多个对象也只有同一种类型;quote core 的大主体本身不是模板。这里没有 EnableAdverse x EnableDefense x EnableQuoteEV x ... 这样的布尔笛卡尔积,因此目前不存在明显的模板组合爆炸。side-specific helper 展开后,compute_quote_core() 总体仍只有约 7.4 KiB。
审计发现的两个缺口随后都做了收敛:
- routing 改为固定 22 字段 input tuple、两个 5 字段 policy tuple 和 11 字段 result tuple;binding 只负责位置解包,业务数学进入普通
LiveRoutingInput/Policy/ResultC++ 函数。place/replace 时为了审计日志执行的asdict()仍保留,因为它不属于每次 routing 计算。 compute_signal_feature_vector<Bars, History>改成接收SegmentedSpanView<Bar1s/FeatureHistoryRow>的单一非模板主体。CircularBuffer暴露 ring 的两段 span,legacy vector 暴露一段 span,无复制共享同一份机器码。相关大计算代码由8,868 + 10,120 B收到9,184 + 1,136 B,减少 8,668 B;整个扩展__text从 478,392 降到 471,364 B。
代码变小没有换来性能回退。满 60,480 history 的 compute_values() 对照中,mean 1.901 → 1.748 us,p99 2.500 → 1.917 us。这说明 two-span 访问增加的一次边界判断,代价小于共享代码布局和编译器优化带来的收益;但这些仍是同机微基准,不能替代目标 Linux CPU 的 PMU 计数。
因此这里对模板元编程的约束是:只把真正高频、低基数的结构性分支固定到编译期;数值阈值和频繁实验的 policy 留在数据中;大段公共计算只保留一份非模板主体;trace、日志、异常和兼容 binding 保持冷路径。评估是否合格最终看的是固定/轮换模板实例实验中的 L1I MPKI、iTLB MPKI、branch miss 和 p99/p99.9,而不是 .text 总大小,也不是源码有多少行。
3.7.4 live 路径不该做的事
这套设计也确定了 live 路径不迁移的边界:
- WebSocket 层:网络延迟主导,C++ 写 WebSocket client 不缩短决策尾延迟,只增加维护面
- LightGBM 推理:模型本身已 native(LightGBM C API),Python 只做 feature → array 转换
- 大块 Python 编排逻辑:config reload、日志、HEALTH 上报、REST 重连——这些不在 CPU 热点,放 Python 更容易验证和修改
- GIL 释放:live 主路径单线程,释放 GIL 无收益;WebSocket 回调在不同 thread 里,releasing GIL 反而引入竞争
live 路径的结论和 offline 路径相同,但结论的来路完全不同:offline 关心吞吐,live 关心尾延迟;offline 可以 batch,live 只能 scalar;offline 释放 GIL 有意义,live 中通常没有。把 offline 的优化模式复制到 live,往往只是更快地得到一个无收益的结果。
本节工程结论
- live hot path 不应该先迁 WebSocket/REST,而应该先收紧 WebSocket 上方的 rolling state、quote/policy/routing 边界。
- 单个公式迁到 C++ 很容易被 pybind、dict、dataclass 和日志对象物化反噬;稳定、高频、低基数的决策包才适合 native。
- 热路径目标是最小且可预测的指令工作集,而不是让整个
.so塞进 L1I。- ARM 本机 microbench、offline sweep 加速、x86 live p99/p99.9 是三种不同证据,不能互相替代。
4. 五个读者一定会追问的实现细节
前面把 NarrowGate 的算法假设、数据问题、C++ 边界和 live hot path 都展开了。但如果我是读者,尤其是做过低延迟回放或交易系统的人,读到这里还会继续追问:你说“双引擎 parity”,到底怎么对齐?你说“逐侧 pause”,到底看什么动态量?你说 compact context,具体从什么对象变成什么 ABI?你说 queue ahead,到底有没有承认自己看不见真实队列?你说 shadow mode,回测里的 fill probability 在实盘行情里有没有崩?
这一节专门回答这些“抓到深处”的问题。
4.1 双引擎 parity:不是相信 C++,而是让 C++ 持续被 Python 审讯
Python/C++ 双引擎最容易做成两套系统:Python 负责研究,C++ 负责速度,最后两个结果不一样,却不知道是策略变了、浮点误差、时间戳 as-of 边界,还是 C++ 少实现了一个 guard。
NarrowGate 里我采用的原则是:Python 仍然是研究口径的 reference implementation,C++ 必须在同一批 normalized arrays 上被持续审讯。
这里的 “parity” 不等于所有浮点数 bitwise 完全一致。tick replay 里有成交价、PnL、markout、inventory time、rounding、latency gate 等连续变量,要求 bitwise 一致反而会把工程带偏。真正重要的是三层断言:
- 路径级断言:fill 数、bid/ask split、最终库存、订单状态迁移、trace 行数必须一致或可解释;
- 金额级断言:PnL、InvAdj、inventory time、spread summary 用很小 tolerance 对齐;
- 边界级断言:NaN、缺盘口、长 gap、pending cancel fill、同 timestamp 多事件排序必须 fail fast 或有显式规则。
整个流程可以概括成这样:
Parquet/CSV/raw logs
|
v
Python loader: timezone, int64 timestamp, tick/lot rounding, bad segment mask
|
v
Normalized NumPy arrays
|
+--------------------+
| |
v v
Python replay C++ replay
reference strict engine
| |
+---------+----------+
v
summary + trace comparator伪代码大概是:
def assert_replay_parity(window, params):
arrays = load_window_as_arrays(
window,
timestamp_dtype="int64_ms",
price_dtype="float64",
drop_bad_quality_segments=True,
)
py = simulate_tick_python(arrays, params)
cpp = simulate_tick_cpp(arrays, params, strict=True)
# 路径级:这些不应该靠 tolerance 蒙混过关。
for key in [
"fills_total",
"fills_bid",
"fills_ask",
"final_inventory",
"n_requotes",
"trace_rows",
]:
assert py[key] == cpp[key], key
# 金额级:允许浮点末位误差,但不允许经济含义变化。
for key in [
"raw_pnl",
"inventory_adjusted_pnl",
"abs_inventory_time_s",
"avg_final_spread_bps",
]:
np.testing.assert_allclose(
py[key],
cpp[key],
rtol=1e-8,
atol=1e-8,
err_msg=key,
)这里踩过的坑很多,而且大多不是 C++ 语法问题:
- 时间戳必须是整数:不要在 hot loop 里用 float seconds。Binance、CryptoHFTData、内部日志可能混用毫秒、微秒和 ISO 字符串,先统一到
int64。 - as-of 边界必须一致:Python
searchsorted(..., side="right") - 1和 C++upper_bound - 1要写成同一条规则,否则刚好落在 book update timestamp 上的订单会错一格盘口。 - tick rounding 必须单点定义:
floor、ceil、round在买卖两侧含义不同,不能 Python 一套、C++ 一套。 - NaN 不能“顺手补 0”:spot/reference/orderbook 缺失时,应该进入 quality mask 或 segment reset,而不是让 C++ 默认值悄悄参与训练和回放。
- pending cancel fill 要显式建模:订单发出 cancel 后,在 cancel latency 到达前仍可能成交。Python 如果已经把它当撤单、C++ 还把它当 live order,fill path 会马上分叉。
- 模块来源要 strict:两个项目都叫
narrowgate_cpp时,同一个 venv 可能 import 到另一个 repo 的扩展。strict 模式下必须检查narrowgate_cpp.__file__、字段 shape 和 ABI version。
所以双引擎 parity 的本质不是“我把 Python 翻译成 C++ 了”,而是建立一套制度:每次 C++ 多迁一个状态机,都必须先在小真实窗口里通过路径级和金额级审讯,然后才允许进入 sweep/A/B。
4.2 Side Policy 的逐侧 pause:高波动先 widening,信息冲击才 pause
很多人一听到 “pause” 会理解成:市场波动大,所以不挂单。这个理解不够精确。
在 NarrowGate 里,高波动首先应该由 AS/GLFT 或 regime spread 处理,也就是把 spread 拉宽:
如果只是 realized vol 高、盘口跳动快,但没有明显的方向性信息冲击,直接 pause 可能会把所有高 spread 补偿机会都关掉。因此 pause 更像“熔断某一侧暴露”的机制,主要看逐侧 adverse context,而不是看总波动。
核心判断可以写成人话:
- widen:这一侧可能更危险,但还可以用更差的价格提供流动性;
- pause:这一侧成交后大概率是接信息风险,继续挂单没有意义;
- cooldown:市场刚打过这一侧,短时间内不急着恢复;
- decay:如果很久没有新证据,旧的 adverse 不能永久锁死策略。
一个更接近当前策略口径的简化伪代码是:
def update_side_pause(side, now, fill_markout_bps, state):
dt = now - state.last_update_ts
# wall-clock decay:坏日、长 gap、静默行情后不能让旧 markout 永久锁死。
state.markout_ema *= math.exp(-dt / state.tau_s)
if fill_markout_bps is not None:
state.markout_ema = (
state.alpha * fill_markout_bps
+ (1.0 - state.alpha) * state.markout_ema
)
if state.markout_ema < -state.pause_threshold_bps:
extra = state.base_pause_s * abs(state.markout_ema) / state.pause_threshold_bps
state.pause_until = now + clamp(extra, state.min_pause_s, state.max_pause_s)
return now < state.pause_until在策略层面还要区分“增加库存风险的一侧”和“降低库存风险的一侧”:
def is_exposure_increasing(side, inventory):
if side == "bid":
return inventory >= 0.0 # 继续买入会加大 long 暴露
if side == "ask":
return inventory <= 0.0 # 继续卖出会加大 short 暴露因此 side policy 不是一个全局开关,而是一个逐侧、逐库存方向、逐市场状态的 gate:
if vol_high:
quote.spread *= vol_spread_multiplier
if side_toxic and exposure_increasing:
quote.allow_post = False
quote.reason = "ADVERSE_SIDE_PAUSE"
elif side_toxic:
quote.spread *= adverse_widen_multiplier
if local_extreme_against_side and not inventory_reducing:
quote.allow_post = False
quote.reason = "DEFENSE_GUARD"所以,高波动不必然 pause;pause 专门针对“这侧成交之后的 markout/毒性证据已经不能靠 spread 补偿”的状态。否则策略会把 volatility risk 和 information risk 混为一谈。
4.3 Compact Context:从嵌套对象到固定字段 ABI
live 路径上最反直觉的一点是:把单个报价公式搬到 C++ 可能更慢。原因不是 C++ 慢,而是 Python/C++ 边界太重。
早期路径接近这样:
ctx = {
"config": asdict(config),
"state": asdict(engine_state),
"inventory": asdict(inventory_state),
"prediction": prediction_dict,
"book": {
"best_bid": best_bid,
"best_ask": best_ask,
"depth": [{"price": p, "qty": q} for p, q in levels],
},
}
result = narrowgate_cpp.compute_quote(ctx)这看起来很灵活,但在 live hot path 里会反复制造这些成本:
asdict()深拷贝;- 字符串 key 查找;
- Python list/dict/object materialization;
- pybind 再把动态对象解析成 C++ 类型;
- C++ 算完以后再组一个 Python dict 返回。
Compact Context 的方向不是“把所有东西都迁到 C++”,而是把边界变成固定字段 ABI:
struct CompactMarketView {
double mid;
double best_bid;
double best_ask;
std::span<const DepthLevel> bids;
std::span<const DepthLevel> asks;
};
struct CompactInventoryView {
double position;
double abs_inventory_time_s;
double signed_inventory_time_s;
};
struct CompactPredictionView {
double ret_1s_bps;
double ret_5s_bps;
double toxicity;
double vol_bps;
double ref_move_bps;
};
struct CompactRoutingResult {
double bid_px;
double ask_px;
double bid_qty;
double ask_qty;
bool can_bid;
bool can_ask;
uint32_t bid_reason_mask;
uint32_t ask_reason_mask;
};Python 侧则只传固定顺序的 tuple/array 和已经缓存的 native config:
market = (
mid,
best_bid,
best_ask,
bid_prices_view,
bid_qtys_view,
ask_prices_view,
ask_qtys_view,
)
state = (
inventory,
abs_inventory_time_s,
signed_inventory_time_s,
bid_markout_ema,
ask_markout_ema,
bid_pause_until,
ask_pause_until,
)
pred = (
ret_1s_bps,
ret_5s_bps,
toxicity,
vol_bps,
ref_move_bps,
)
decision = cpp_live_routing(native_cfg, market, state, pred)架构上是:
WebSocket / REST / logging
|
| 仍然留在 Python
v
Python normalized event state
|
| fixed tuple / array / span-like views
v
C++ quote + side policy + routing bundle
|
| compact result: price, size, can_post, reason_mask
v
Python order adapter
|
v
exchange REST/WebSocket这样做的收益不是“C++ 数学快了多少”,而是减少边界上的动态对象。前面的 live benchmark 里也能看到这个现象:原始 routing 桥接大约是 6us 级别,compact ABI 后可以压到亚微秒级;但如果只把 scalar quote core 单独搬过去,pybind/dataclass 往返会把收益吃掉,甚至变慢。
这也是为什么 WebSocket 不急着迁 C++。行情接收、日志、健康检查、REST adapter 都不是最值得先动的地方。真正应该收紧的是 WebSocket 上方、下单 adapter 下方的这一小段:rolling state -> quote -> policy -> route decision。
4.4 Queue Ahead:保守地承认自己看不见的队列
bar 回测最容易自欺欺人的地方是:只要下一根 bar 的 low/high 碰到我的价格,就认为成交。maker 策略里这几乎一定会高估结果,因为真实世界里还有队列。
NarrowGate 的 tick replay 更保守:价格碰到只是必要条件,不是充分条件。订单进入盘口后,需要估计自己前面的 queue ahead,然后用后续 aggressive trade 去消耗这个 queue。
简化逻辑如下:
def activate_order(side, quote_px, activation_ts, l2_book):
level_qty = l2_book.visible_qty_at(quote_px, activation_ts)
if level_qty is not None:
# 保守假设:自己排在当前可见 level 的后面。
queue_left = level_qty
else:
# 没有可靠 L2 时,用距离 mid 的衰减模型兜底。
distance = abs(quote_px - mid_at(activation_ts))
queue_left = queue_base * math.exp(-queue_decay * distance)
return LiveOrder(side=side, price=quote_px, queue_left=queue_left)
def process_trade(order, trade):
if not trade_crosses_order(trade, order):
return
aggress_qty = trade.qty
# 先消耗排在我前面的可见队列。
consumed_ahead = min(order.queue_left, aggress_qty)
order.queue_left -= consumed_ahead
aggress_qty -= consumed_ahead
# 只有 queue ahead 被打穿后,剩余 aggressive qty 才可能打到我。
if order.queue_left <= 0.0 and aggress_qty > 0.0:
fill_qty = min(order.remaining_qty, aggress_qty)
fill(order, fill_qty)这当然仍然不是“真实交易所撮合队列”。历史 Top-N 深度看不到三件事:
- 这个 level 内部每个订单的真实排队顺序;
- 其他参与者在你前面的撤单;
- 你的订单到达交易所前,level 已经变化了多少。
所以“不自欺欺人”的做法不是假装知道这些,而是把不可观测部分分成两类:
- 保守规则:默认排在可见 level 后面,不因为看见 level 变小就自动认为前面的人都撤了;
- 校准参数:用 live/order log 反推 queue model、new/cancel latency、fill gate,让回放的 placed orders/day、fills/day、side split 和 live 同数量级。
如果只用 Top-N L2,queue ahead 永远是估计值,而不是事实。因此 replay 结果要同时报告:
- placed orders/day;
- fills/day;
- fills / placed order;
- cap/guard/pause rate;
- active-trade-day fills;
- quality-day fills;
- full-calendar-day fills。
这样才能区分“成交概率模型太保守”和“策略根本没有挂出足够多订单”。前者是 queue/fill calibration,后者往往是 guard/pause/requote gating 的问题。
4.5 Shadow Mode:在线校准比回测 PnL 更诚实
NarrowGate 不应该把 quote EV 直接从回测 A/B 推到 live。更合理的中间层是 shadow mode:实盘收行情,在线计算 quote、quote EV、side policy 和 would-place/would-fill,但不把它当成交易建议,也不把它当成真实成交承诺。
shadow mode 的核心问题不是“预测收益是多少”,而是:
以及:
需要看的不是一个 raw PnL 数字,而是一组校准表:
| Shadow 检查 | 说明 | 如果失败 |
|---|---|---|
| fill probability calibration | 预测的 P(fill) 分桶后,真实/可回放 fill rate 是否接近 |
quote EV 只能做排序,不能做阈值 |
| realized-vs-pred markout | 高 EV bucket 是否真的有更好 markout | EV label 或 feature 泄漏/错配 |
| placed orders/day | shadow/replay 是否和 live 同数量级 | guard/pause/requote gating 不一致 |
| fills / placed order | 每个挂单的成交概率是否接近 live | queue ahead 或 latency floor 不对 |
| side split | bid/ask fill 是否失衡 | side policy 或库存压力错误 |
| inventory time | 正 edge 是否靠堆库存换来 | 风险调整后不可用 |
实盘里很常见的情况是:回测看起来排到了,live 却因为 latency floor 根本没进入同一个队列位置。这个问题不能用“C++ 更快”简单解决,因为它通常来自三层延迟:
- 行情到达本机的网络/解码延迟;
- 策略从行情到下单的决策延迟;
- 订单从本机到交易所并 ack 的新单延迟。
因此 shadow mode 需要把 measured new/cancel latency 注入 replay,再比较 live order log 与 replay would-fill:
def shadow_fill_check(event, measured_latency):
quote = compute_quote(event)
activation_ts = event.ts + measured_latency.new_order_ms
replay_fill = replay_queue_fill(
side=quote.side,
price=quote.price,
activation_ts=activation_ts,
l2=event.future_l2,
trades=event.future_trades,
)
return {
"pred_fill_prob": quote.ev_fill_prob,
"would_fill": replay_fill is not None,
"latency_ms": measured_latency.new_order_ms,
"queue_ahead": replay_fill.queue_ahead if replay_fill else None,
}这也是为什么 quote EV promotion 要比普通模型上线更保守。当前这种逐侧 quote EV 如果 valid fills 仍然偏少,或者 calibration/Brier/bucket markout 不过 gate,即使某个月 ask-only A/B 看起来 raw 为正,也只能算 shadow/research 候选,不能直接当作 live 结论。
本节工程结论
- 双引擎 parity 靠 normalized arrays、strict mode、路径级断言和金额级断言,而不是靠“C++ 理论上等价”。
- side pause 应该区分 volatility risk 和 information risk:高波动先 widening,逐侧 toxic/adverse 证据才 pause。
- compact context 的重点是固定字段 ABI 和减少动态对象物化,不是把所有 Python 都迁掉。
- queue ahead 必须承认不可观测撤单和真实排队顺序,用保守规则加 live 校准,而不是把价格触碰当成交。
- shadow mode 的价值是校准 fill probability、markout bucket、latency floor 和 placed/fill 数量级,比单月回测 PnL 更诚实。
5. 从原理到实操:参数、队列、Shadow 与胶水层
前面讲了很多公式、架构和 benchmark,但普通金融工作者真正会卡在一个问题上:模型输出怎么变成我这一单要后撤几个 tick? 普通工程师也会卡在另一个问题上:Python 的研究矩阵到底怎么安全、低拷贝地跑进 C++ 状态机?
这一节不再讲宏观动机,而是把几个落地口径写清楚。
5.1 参数映射:gamma/kappa/sigma 不应该靠玄学拍脑袋
AS 公式里的几个参数在代码里有非常具体的含义:
| 参数 / 信号 | 代码里的来源 | 对报价的直接影响 | 调参时主要看什么 |
|---|---|---|---|
gamma |
strategy.gamma,也会被库存比例和方向信号调整成 g_eff |
库存 reservation price 偏移、基础 spread 项 | abs_inventory_time_s、InvAdj、max inventory hit |
sigma_sq |
tick replay / live rolling variance;可与 LightGBM vol_10s 按 vol_blend 混合 |
gamma * sigma_sq,以及 regime spread scale |
spread<100、cap hit、波动窗口下 markout |
kappa |
strategy.kappa 或 p3_kappa_eff;可用 depth-kappa 根据近端深度调整 |
log(1 + gamma / kappa) spread 项;kappa 越小 spread 越宽 |
fills/day、fills/order、queue calibration |
dir_10s |
LightGBM 方向概率 | reservation price shift、asymmetry、gamma_dir_bonus | 方向分桶 markout、库存是否被方向信号放大 |
ret_10s |
LightGBM 未来收益预测 | ret_skew * mid,再被 ret_shift_max_pct 限幅 |
逐侧 1s/5s markout、cap compression |
tox_bid/ask |
LightGBM 逐侧 toxicity | side adverse widen / shrink / pause | toxic bucket realized markout、false pause rate |
| markout EMA | fill 后 maker-signed markout | markout spread scale、hybrid pause latch | pause 时长、恢复后 fill markout |
简化后的 quote mapping 可以这样理解:
sigma_sq = rolling_sigma_sq
if ml_enabled and vol_blend > 0 and pred_vol > 0:
sigma_sq = (1 - vol_blend) * rolling_sigma_sq + vol_blend * pred_vol
kappa_base = p3_kappa_eff if p3_kappa_eff > 0 else cfg.kappa
kappa_used = depth_kappa(kappa_base, depth) if use_depth_kappa else kappa_base
g_eff = gamma
if abs(inventory) > 0:
g_eff *= 1.0 + (abs(inventory) / max_inventory) ** 2
reservation = fair_price - inventory * g_eff * sigma_sq
kappa_spread = max(kappa_used * kappa_ratio, 1e-12)
spread = gamma * sigma_sq + (2.0 / gamma) * log(1.0 + gamma / kappa_spread)
spread *= regime_spread_scale
if ret_skew > 0:
shift = clamp(
pred_ret * ret_skew * mid,
-ret_shift_max_pct * spread / 2,
+ret_shift_max_pct * spread / 2,
)
reservation += shift
bid = floor_tick(reservation - spread / 2 * (1 - asym))
ask = ceil_tick(reservation + spread / 2 * (1 + asym))再往后才是逐侧 policy:
if tox_bid >= adverse_toxicity_threshold:
bid_policy.spread_mult = max(bid_policy.spread_mult, adverse_spread_mult)
bid_policy.size_mult = min(bid_policy.size_mult, 0.70)
bid_policy.allow_exposure_increase = False
if bid_side_adverse_pause:
bid_policy.allow_post = False所以机器学习不是直接“预测价格然后下单”。它有三类入口:
- 改变物理 quote:
vol_10s、ret_10s、dir_10s影响 reservation、spread、asymmetry; - 改变 policy gate:
tox_bid/ask、markout EMA、microprice shift 影响 widen/shrink/pause; - 改变风险读法:quote EV 与 shadow calibration 不直接替代 AS,而是判断某一侧 quote 是否值得继续提供流动性。
实操调参时,不应该先问“哪个 gamma PnL 最高”,而应该按顺序看:
1. spread/cap sanity:spread<100、cap hit、final spread distribution
2. execution sanity:placed orders/day、fills/day、fills/order
3. adverse sanity:1s/5s/30s maker-signed markout by side
4. inventory sanity:abs inventory-time、InvAdj、pnl per inventory-hour
5. calibration sanity:toxicity / quote EV bucket 是否和 realized markout 对齐如果一个参数组合 raw PnL 更高,但靠库存时间暴露翻倍换来,或者只在 cap_hit 很高的状态下好看,它就不是更稳的参数。
5.2 Queue Ahead:回测撮合状态机的初始化边界
普通 trader 最关心的不是“有没有 queue 模型”,而是:我的订单进 bid1 的那一刻,到底被假设排在哪里?
NarrowGate 的保守口径是:
- 如果 activation timestamp 能找到历史 L2,且 quote price 对应某个可见 level,就假设自己排在该 level 可见数量之后;
- 如果没有可靠 L2,就用距离 mid 的指数衰减模型兜底;
- 后续主动成交先消耗
queue_left,打穿以后才轮到自己的订单; - 不因为历史 level 数量变小就自动把“别人撤单”全部算作自己排位前进;
- cancel latency 生效前,pending cancel order 仍然可能被 fill。
伪代码如下:
def init_queue(order, activation_ts, l2):
level_qty = l2.visible_qty_at(order.price, activation_ts)
if level_qty is not None:
# worst-case within visible top-N: 排在当前可见同价位后面
return level_qty
dist = abs(order.price - mid_at(activation_ts))
return queue_base * exp(-queue_decay * dist)
def on_aggressive_trade(order, trade):
if not crosses(order.price, trade.price, order.side):
return
remaining = trade.qty
eaten_ahead = min(order.queue_left, remaining)
order.queue_left -= eaten_ahead
remaining -= eaten_ahead
if order.queue_left <= 0 and remaining > 0:
fill_qty = min(order.remaining, remaining)
fill(order, fill_qty)这仍然不是交易所真实 FIFO 队列,因为 Top-N 历史盘口看不到隐藏单、真实 order id 排序、你前方订单撤单和交易所撮合内部细节。它的价值不是“完美复现撮合”,而是避免 bar touch 那种最危险的乐观假设。
如果要判断 queue 模型是否太保守或太乐观,不能只看 fills/day,要同时看:
| 指标 | 解释 |
|---|---|
| placed orders/day | 策略是否真的挂出了足够多订单 |
| fills/day | 最终成交频率 |
| fills / placed order | queue/fill 概率是否和 live 同数量级 |
| bid/ask split | 是否一侧被系统性高估 |
| queue_left at fill | 是否经常“刚好被打穿” |
| pending cancel fill count | cancel latency 下是否仍然能被打 |
如果 fills/day 低一个数量级,但 fills/order 与 live 接近,问题往往不是 queue,而是策略在回测里被 guard/pause 挡掉了太多 quote。这个区别非常关键。
5.3 Shadow Mode 的硬 gate:方向对还不够,必须校准过关
Shadow mode 不是为了再看一个漂亮 PnL,而是为了回答:
以及:
实操上我会把 gate 分成四层:
| Gate | 最低要求 | 含义 |
|---|---|---|
| 样本量 | 每 side validation valid fills >= 100,更理想是数百 |
少于这个数,bucket calibration 很容易飘 |
| fill calibration | 分桶 MAE <= 0.05 |
预测 20% fill 的桶,真实 fill rate 不能长期是 5% 或 50% |
| markout calibration | bucket realized-vs-pred 同向,MAE 不恶化 | 高 EV bucket 至少不能有更差 markout |
| 风险效率 | InvAdj 不差,abs inventory-time 不恶化,fills/day 不塌 | 不能用堆库存换 raw PnL |
当前 quote EV 的真实状态并没有过这些 gate。比如之前 strict-gated run 中,May valid fills 只有几十个,fill calibration MAE 也超阈值。因此即使 ask_ev_soft_widen 在某个 A/B 里 raw 看起来不错,也只能被标为 shadow/research 候选。
一个更像上线前检查表的伪代码是:
def promotion_gate(summary):
return (
summary.valid_filled_rows >= 100
and summary.fill_calibration_mae <= 0.05
and summary.markout_bucket_direction_ok
and summary.inv_adj_delta >= -small_tolerance
and summary.abs_inventory_time_delta <= 0
and summary.monthly_sign_stability_ok
)这不是说阈值永远不变,而是说必须有硬门槛。没有门槛的 shadow mode,很容易退化成“我又看了一张更漂亮的图”。
5.4 Python/C++ 胶水层:不是每 tick 传 JSON,而是一次传连续数组
普通工程师最担心跨语言部分:Python 和 C++ 到底怎么一起工作?答案是:回测/batch 路径里,Python 负责 IO 和编排,C++ 负责一次调用内的状态机;不会每 tick 传 JSON。
离线路径是:
Parquet / CSV / logs
|
v
Python pandas/pyarrow loader
|
v
NumPy contiguous arrays
|
v
pybind11 binding
|
v
C++ ArrayView / MatrixView / std::span
|
v
simulate full window / compute batch quote context
|
v
summary + optional trace arrays back to Python核心原则是:
- C++ 不在 hot loop 里读 parquet;
- Python 不在每个 tick 调 C++;
- pybind 只在窗口边界检查 dtype、shape、contiguous;
std::span/ view 只在函数调用期间借用 Python-owned memory;- C++ 不把这些指针存到函数返回之后;
- trace、diagnostics、DataFrame 构造回到 Python 冷路径。
伪代码可以理解为:
trades = load_trades_as_numpy(month)
l2 = load_l2_as_numpy(month)
params = build_cpp_params(config)
result = narrowgate_cpp.simulate_tick_arrays(
trades.ts_ms,
trades.price,
trades.qty,
trades.is_buyer_maker,
params,
bbo_ts_ms=bbo.ts_ms,
l2_bid_px=l2.bid_px_matrix,
l2_bid_qty=l2.bid_qty_matrix,
l2_ask_px=l2.ask_px_matrix,
l2_ask_qty=l2.ask_qty_matrix,
)
summary = result.summary
trace = result.quote_trace if trace_enabled else Nonelive 路径则不同。live 不能一次喂一整个月数组,所以只把很小的 compact context 过桥:
Python event state
|
v
fixed tuple / small arrays / cached native config
|
v
C++ quote + policy + routing decision
|
v
compact result: price, size, can_post, reason_mask
|
v
Python REST adapter这也是为什么 scalar quote core 单独迁 C++ 反而可能慢:如果每次都构造 dict、dataclass、字符串 key,跨语言边界比数学本身更贵。真正要优化的是“边界形状”,不是盲目把更多 Python 改成 C++。
5.5 Fixed-Array Signal:哪些是 O(1),哪些还不是
fixed-array signal 不是一个神秘概念,本质就是把“特征名字符串字典”换成“固定顺序数组”:
enum class SignalFeatureId {
Return1,
Volatility60s,
TradeIntensity60s,
TakerQuoteImbalance10s,
// ...
Count,
};
class SignalFeatureVector {
public:
double& operator[](SignalFeatureId id);
const std::array<double, kSignalFeatureCount>& values() const;
private:
std::array<double, kSignalFeatureCount> values_{};
};行情状态维护则分两层:
class TradeBarAggregator {
std::optional<Bar1s> update(ts_ms, price, qty, is_buyer_maker);
Bar1s current_;
int last_trade_side_;
int last_trade_run_len_;
};
template <int WindowSeconds>
class RollingStats {
void push(ts_ms, value) {
values.push_back({ts_ms, value});
sum += value;
sum_sq += value * value;
expire(ts_ms);
}
double mean() const;
double stddev() const;
};
class SignalFeatureEngine {
CircularBuffer<Bar1s> bars_;
CircularBuffer<FeatureHistoryRow> history_;
CountRollingMoments return_abs_2160_;
CountRollingMoments return_abs_8640_;
CountRollingMoments vol_regime_6h_60480_;
};但这里也要诚实:当前并不是所有特征都已经做到完全 O(1) 增量。已经增量化的是一部分长窗口 rolling moments 和 trade/bar state;仍有一些 overlay 特征会扫描短窗口或使用 scratch buffer。原因是这些特征还处在研究变化阶段,过早把所有实验性 feature 都写成复杂 C++ 状态机,会让 parity 和调参变得更难。
当前工程取舍是:
- 高频、稳定、低基数的窗口统计逐步进 C++;
- 特征顺序固定,用 array 输出,减少 dict/string 热路径;
- 实验性特征先保留 Python 或短窗口扫描;
- 只有当某个特征进入稳定模型并被 profile 证明是热点,再改成长期持有的 rolling state。
这和前面 C++ 的原则一致:C++ 不是为了“看起来硬核”,而是为了把稳定边界收紧。
5.6 Live 脏数据与异常:离线可以删,live 只能降级
离线数据坏了可以物理删除,live 不行。live 遇到乱序 tick、WebSocket 静默、盘口过期、bad trade、用户流漏成交时,策略必须降级,而不是继续假装状态健康。
当前 NarrowGate 的 live 防御层大概是:
| 异常 | 当前处理 |
|---|---|
| bad trade parse / 非法价格数量 | 计数并限频日志,不进入 bar state |
| aggTrade 静默 | watchdog 切 raw @trade 或重连 |
| execution/reference/spot stream 静默 | market stream reconnect |
| execution depth 过期 | cancel live quotes,停止 requote,原因 stale_hard |
| bookTicker / post-only crossing | GTX reject 当作正常不可挂事件处理 |
| user stream 漏 fill / REST sync 差异 | SYNC_ADJUST hard degrade,暂停增加暴露的一侧并重连 user stream |
| stale local order | 周期性清理 PENDING_NEW 卡死订单 |
| C++ 扩展异常 | strict 模式直接 fail;非 strict live 可 fallback/disable native path |
| 运行健康 | HEALTH 输出 position、PnL、库存时间、订单数、requote 数 |
伪代码是:
if depth_age_s > max_exec_book_age_s:
cancel_active_quotes()
skip_requote(reason="stale_hard")
if stream_silent(symbol, timeout_s):
reconnect_market_streams()
if sync_adjust_count >= threshold:
pause_exposure_increasing_quotes(duration_s)
reconnect_user_stream()
if bad_trade:
bad_trade_counter += 1
return这仍然不是完整高可用交易网关。它没有 kernel bypass、没有独立 order gateway 进程、没有完整 SPSC ring 隔离,也没有多机热备。当前目标是:在研究/影子/小规模 live-style 运行里,遇到脏数据时优先撤单、降级、报警、重连,而不是继续用坏状态报价。
本节实操结论
- 参数映射要从
gamma/kappa/sigma -> spread/reservation/tick讲清楚,再谈 ML 是否有用。- queue ahead 的可信度来自保守初始化、逐笔消耗和 live 校准,而不是声称知道真实 FIFO。
- shadow mode 必须有样本量、fill calibration、markout calibration 和库存风险硬 gate。
- Python/C++ 胶水层的关键是一次传连续数组或固定 tuple,不在每 tick 传动态对象。
- fixed-array signal 当前是渐进式迁移:稳定热点进 C++,实验性特征保留更灵活的路径。
- live 脏数据不能删除,只能通过 stale block、watchdog reconnect、SYNC_ADJUST degrade 和 telemetry 降级。
6. 已完成与未完成边界:不要把研究系统误读成 Alpha 终局
如果站在金融 Alpha 的终局视角看,前面的内容还不能被理解为“这个 maker 系统已经解决了做市问题”。更准确的说法是:NarrowGate 已经完成了一个更严格的研究框架,让很多错误结论更难通过;但动态到达率、主动对冲、多 venue lead-lag 和 HFT 级线程模型这些真正决定上限的问题,仍然没有完全解决。
把完成度摊开,比把每个模块都写得很漂亮更重要。
| 追问 | 当前完成度 | 当前真实状态 |
|---|---|---|
动态 kappa / Lambda(delta) 微观校准 |
部分完成 | 有 depth-kappa、P3 kappa_eff 和 general intensity hook,但 toxicity 主要进入 widen/pause policy,没有连续回写成 kappa_eff(toxicity) |
| 极端尾部下的 toxicity 处理 | 部分完成 | 已有逐侧 adverse guard、markout EMA、hybrid pause 和 wall-clock decay;还不是完整的 arrival-intensity deformation |
| 库存主动瘦身 / hedging | 部分完成 | 有库存时间积分、硬库存限制、可选 timeout/IOC/MARKET 兜底;没有跨市场 BTCUSDT perp 主动对冲器 |
| 多 venue 虚假流动性 / 延迟套利 | 部分完成 | 有 source audit、freshness gate、shock attribution;但本地缺少独立 venue 原始数据,不能验证真实 multi-venue lead-lag |
| Python/C++ replay parity | 基本完成 | synthetic parity + 真实窗口 golden parity 已覆盖 PnL、fills、库存、trace 行数等核心指标 |
| C++ cache / allocator / hot path | 部分完成 | replay/batch/signal 已做 DepthView、fixed array、ring state、summary-only、hot/cold 拆分;live orderbook 还不是完整 native flat book |
| live 线程模型 / 网络 I/O 隔离 | 未完成 | 当前是 Python WebSocket callback threads + SignalEngine lock + 主循环 tick,不是 pinned reactor + SPSC ring buffer + strategy core |
6.1 金融侧:AS 是坐标系,不是答案
Avellaneda-Stoikov 给了一个非常有用的坐标系:
但真正困难的是右边这些变量如何变成实时市场状态,而不是公式本身。
当前 NarrowGate 对 kappa 的处理有三层:
- 静态
strategy.kappa作为 baseline; p3_kappa_eff/ general intensity 用历史 fill probability 拟合更一般的Lambda(delta);- depth-kappa 用近端盘口深度把
kappa做有限比例调整。
这还不等于“动态到达率已经被完全校准”。真正金融上更尖锐的问题是:
当市场发生信息冲击时,远端 quote 的成交概率可能不是指数衰减,而是突然变成“深价位也会被扫”。这时 kappa 不是简单变大或变小,而是整条 Lambda(delta) 曲线形状改变。当前系统里 toxicity 更多是进入逐侧 policy:
if toxicity >= toxicity_threshold:
side_policy.spread_mult = max(side_policy.spread_mult, widen_mult)
side_policy.size_mult = min(side_policy.size_mult, shrink_mult)
side_policy.allow_exposure_increase = False
if side_adverse_pause:
side_policy.allow_post = False也就是说,当前实现更像:
而不是:
这是一条明确的未完成边界。未来如果要真正“搞定 AS 坐标系”,需要让 toxicity、reference shock 和 live fill calibration 共同形变 Lambda(delta),并要求变形后的曲线能通过 shadow fill probability calibration,而不只是让某个月 A/B 的 raw PnL 变好。
6.2 库存终局:被动斜率不是万能退出路径
库存控制现在已经比早期完整很多。系统会记录:
这让 A/B 不只比较 raw PnL,还能比较“赚这点 PnL 占用了多少库存时间”。但这仍然不是库存终局。
做市系统真正被打穿时,reservation price 的斜率可能已经不够。举例说,当库存接近 max_inventory,且市场继续沿不利方向重定价时,继续靠“少挂增加库存的一侧、多挂减少库存的一侧”来恢复,可能会遇到两个问题:
- 减仓一侧一直排不到队;
- 能排到队时,价格已经变差,库存损失扩大。
当前 NarrowGate 有可选的 timeout close / IOC / emergency MARKET 兜底,但默认主线并不启用主动 taker 对冲。也就是说,它还没有实现这种路径:
BTCUSDC maker inventory
|
| risk budget breached
v
choose hedge venue / instrument
|
| estimate hedge cost + basis + latency
v
BTCUSDT perp / spot taker hedge
|
v
residual basis + inventory budget update如果站在资金效率和风险终局看,下一阶段应该研究的是:
只有当主动瘦身成本、basis risk、taker fee、延迟和残余库存都进入同一张账,库存控制才从“被动做市参数”变成“风险闭环”。当前项目还没有走到这里。
6.3 多市场 reference:有信息不等于能赚钱
BTCUSDT perp、BTCUSDT spot、BTCUSDC spot 确实有信息量。source audit 和 shock attribution 都显示:很多 adverse fills 能被 BTCUSDT reference move 解释,spot adverse return 也有增量信号。
但这不是最终答案。真正的问题是 reference 信号的时效性:
如果 reference 的重定价领先本地执行市场 50ms,而你的端到端反应是 5ms,这个信号可能有执行价值;如果 reference 已经在 0.5ms 内传导完,本地 quote 看到它时,只是在看一个滞后指标。此时统一打开 multi_market 可能不是增强模型,而是把“已经被吸收的流动性冲击”和“真正的信息冲击”混在一起,导致该接的流动性不接、该躲的信息冲击又躲不掉。
当前 NarrowGate 对这个问题的回答是 partial:
- 已经把
multi_market.enabled从“收益开关”降级为“需要被审计的 source 条件变量”; - 已经做了 local-only、perp、spot、组合 source profile 的隔离 A/B;
- 已经要求 freshness、basis residual、coverage 和 shock label;
- 但没有独立 venue 的原始毫秒/微秒级数据,因此不能验证真正的 cross-venue lead-lag 和 latency arbitrage。
换句话说,现在能说的是:Binance 内部多源数据有解释力,但不能证明它形成了可执行的多 venue alpha。
6.4 系统侧:C++ replay 很强,live 架构还不是终局
工程侧完成度更高,但也要分清 offline 和 live。
已经比较扎实的是:
- Python/C++ 双引擎 replay parity;
- depth-aware quote-core batch;
- C++ tick replay 状态机;
DepthView/std::span无分配访问;- fixed-array signal feature vector;
- hot/cold order 拆分;
- summary-only replay 输出;
- x86 live 上的 p99/p99.9 soak benchmark。
但 live 线程模型还不是 HFT 终局。当前架构更接近:
Binance WebSocket client callback threads
|
v
Python WSHandler
|
v
SignalEngine with Lock
|
v
main loop: engine.tick() every short sleep
|
v
REST order adapter这和更硬核的低延迟架构不同:
NIC / kernel bypass or tuned socket
|
v
pinned reactor thread
|
v
SPSC ring buffer / sequence barrier
|
v
pinned strategy thread: rolling state + quote + routing
|
v
order gateway thread前者适合研究、shadow、审计和可维护性;后者才是面向极端尾延迟的 live 系统。NarrowGate 目前还没有做 kernel bypass、CPU pinning、SPSC ring、NUMA partition、专用 order gateway,也没有把 WebSocket 解码和策略计算完全隔离。
这不影响当前 C++ 迁移的价值,因为当前 C++ 主要服务两个目标:
- 研究吞吐:让月份级 replay、source A/B、quote context 和 label 生成跑得动;
- 边界收紧:让 live hot path 中稳定的小决策包更少依赖 Python 动态对象。
但它确实说明:不能把当前项目包装成“完整 HFT live stack”。更诚实的定位是:一个带有 native replay/hot-path 原型的 maker 研究系统。
6.5 下一阶段真正值得做的事
如果继续往“金融大牛和系统大牛都会认可”的方向推进,下一阶段不是再加一个模型,而是补四个闭环:
- 动态
Lambda(delta)闭环:用 live/shadow fill calibration,把 depth、toxicity、reference shock 映射到整条到达率曲线,而不是只做 widen/pause。 - 主动瘦身闭环:设计 BTCUSDT perp/spot hedge simulator,把 hedge cost、basis risk、taker fee 和 inventory tail loss 放进同一张账。
- 真实 multi-venue lead-lag 闭环:接入独立 venue 原始盘口/逐笔数据,验证 reference 信号是否早于本地可执行窗口。
- live threading 闭环:把 WebSocket decode、rolling state、quote decision 和 order gateway 分层,至少用 SPSC ring + pinned strategy thread 做一次 x86 p99/p99.9 对照。
本节边界结论
- NarrowGate 已经比较好地回答了“如何让 maker 研究少自欺欺人”。
- 它还没有完全回答“如何在尾部冲击中存活、如何主动对冲、如何穿透多 venue 延迟、如何构建 HFT 级 live stack”。
- 把这些未完成项写清楚,不会削弱项目;反而能防止读者把研究框架误读成已经闭环的交易系统。
结论:算法假设、验证流程和 C++ 边界要同时成立
到现在,NarrowGate 给我的最大教训不是“C++ 很快”,而是研究系统要同时守住四条线:
- 数据连续性:坏日期、低 coverage 和跨 gap label 必须在统一质量层阻断;
- 目标一致性:reference/spot 有预测信息,不代表一个总开关具有执行收益;
- 风险完整性:raw PnL 之外,还要看 InvAdj、fill/markout、校准和库存时间;
- 引擎一致性:Python、C++、回放与事件引擎必须共享同一套 policy 语义。
目前 enhanced spot 已经证明“更多市场能帮助解释 adverse flow”,但没有证明 multi_market.enabled=true 是稳定收益开关。ask quote EV 是最值得继续观察的方向,但 validation fill 和 calibration 仍未过 gate。C++ replay 已经显著缩短快筛时间,但它的价值首先是让更多真实窗口、更多反例和更严格 parity 变得可负担。
这也回答了文章开头的问题:NarrowGate 当前得到的并不是一条“接入更多数据、换成 C++,收益自然变好”的直线。相反,它得到的是一套逐层收紧的否证流程:数据源先证明连续,feature 再证明非零且时间对齐,模型再证明校准,policy 再证明跨月收益和库存效率,最后 C++ 只负责让这些检验跑得更快、更一致。
换句话说,C++ 没有替项目找到策略答案。它做的是更有用的事:让错误的答案更快暴露,让好看的数字更难逃过验证。
结语
NarrowGate 从一个 AS 公式实验,走到了数据审计、跨市场冲击归因、逐侧 quote EV、库存时间积分和 C++ 状态机。这个过程不断把最初的问题变窄:不是“能不能预测 BTC”,而是“在这一侧、这个价格、这个盘口和这段库存暴露下,是否值得提供一次短暂的被动流动性”。
这可能也是“窄门”最贴切的地方。一个研究系统的成熟,不在于它接入了多少模型和数据源,而在于它愿意用多少层约束拒绝一个看起来很诱人的结论。
本文及 NarrowGate 项目仅用于 C++ 低延时系统、市场微观结构、价格行为学、做市模型和机器学习回测方法的学习交流与技术研究,不构成财务、投资、法律或合规建议。中国大陆关于 crypto 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!
