NarrowGate:从 Maker Quote EV 到 C++ Tick Replay 的低延迟研究框架

TL;DR:读者收益摘要

NarrowGate 不是一个“C++ 高频做市系统已经跑通并盈利”的项目,而是一个用于验证 maker quote EV 的研究框架。它的核心不是证明某个做市策略有效,而是建立一套让错误结论更难通过的 maker 研究系统。

读完这篇文章,至少可以得到五个结论:

  1. maker fill 不能简单等价于 spread 收益,必须用 fill 后 markout 和 inventory exposure 衡量;
  2. 多市场 reference 有信息量,但不等于统一打开 multi_market 就有正 EV;
  3. tick replay、queue ahead、latency、TTL 和 maker fill gate 会显著改变 bar 回测结论;
  4. C++ 的价值不是替代 Python 写策略,而是把 replay、feature state、quote core 和 live hot path 中稳定、高频、低基数的部分收紧边界;
  5. 当前项目没有宣称“搞定做市终局”:动态到达率、主动对冲、多 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 和决策包?

这篇文章想把这些前因后果完整记录下来,但不把算法和工程混在一起讲。后文按五层展开:

  1. Maker 量化算法与数据假设:先解释 NarrowGate 在研究什么,AS/GLFT/quote EV 如何组成报价框架,以及为什么数据源和 label 连续性会决定结论可信度;
  2. 从算法验证到工程问题:再解释为什么更严格的 tick replay、A/B 和 parity 会把问题自然推到系统吞吐量上;
  3. Python/C++ 技术实现:然后进入 C++,讨论哪些状态机、批处理和 live hot path 值得迁移,哪些不该迁;
  4. 从原理到实操的桥:把参数映射、queue ahead、shadow gate、Python/C++ 胶水层、fixed-array state 和 live 容错讲成可执行规则;
  5. 已完成与未完成边界:最后把金融 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

整个研究链路可以概括成:

NarrowGate maker live hot path

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 里至少有四类数据:

NarrowGate research data lineage

数据层 主要来源 在项目中的角色 最容易出错的地方
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 都可以触发报价,但真正值得暴露的窗口必须经过几道门:

  1. 当前 spread 是否提供了足够的流动性补偿;
  2. 报价相对 mid/BBO 的位置是否合理;
  3. 主动成交和盘口压力是短期流动性冲击,还是信息驱动的重定价;
  4. reference perp 与 spot 是否确认了这个变化;
  5. 库存、挂单 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 的骨架,是因为它给出了稳定、可解释的库存坐标系;但具体的 sigmakappa、depth half-spread、adverse widening 和 quote EV gate 都要由数据重新验证。

实际系统里,AS 公式只是报价骨架。NarrowGate 的 quote core 更接近下面这个组合:

NarrowGate quote ladder

这里最容易误解的是 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 逻辑可以写成:

NarrowGate maker order lifecycle

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 中出现过两种更隐蔽的问题:

  1. 某些小时文件直接缺失;
  2. 文件存在,但内部缺少足够的 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 月度压缩包混合了好日期与坏日期,不能只在月度容器里删掉某一天,所以改为:

  1. 删除覆盖坏日期的月度 aggTrades 容器;
  2. 从 Binance Vision 按日重新下载 retained good days;
  3. 验证坏日期日度文件命中为 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 valid

rolling 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.pycross_market_shock_audit.pytrain_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_inventorypnl_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 summary

C++ 目录由 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.py

bindings.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++ 公式,而是重新设计边界:

  1. Python wrapper 缓存已经物化的 native QuoteCoreConfig,配置对象热重载时失效;
  2. state/pred 通过紧凑 tuple 传入,depth levels 由 C++ 直接读取 Python sequence,不再逐档创建 pybind DepthLevel 对象;
  3. quote EV 关闭时,只返回 side policy 真正读取的 adverse、defense、TTL、local-extreme、near-depth 字段,以及周期诊断日志使用的小型 diagnostics;
  4. 任一侧 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_imbnear_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 rows

golden 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 很慢。为此项目增加了一条研究快筛路径:

  1. 在 baseline quote context 下预计算 bid/ask EV arrays;
  2. C++ replay 在每个 tick 直接读取对应分数;
  3. 快速跑 baseline、bid、ask、both 四个 key arms;
  4. 淘汰明显差的 arm;
  5. 最终候选仍回到 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 -q

golden 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 并不是为了把所有新语法都塞进交易路径,而是优先使用几项收益明确、行为容易验证的能力。

第一项是用 conceptstd::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 同时持有完整 SideQuoteContextTraceOrderRow。即使 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_maxtrace_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 --sweeptick_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 上应该同时看 cyclesinstructionsbranch-missesL1-icache-load-missesiTLB-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.txttorch 会默认拉一大串 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 实例不暴露 cyclesinstructionsL1-icache-load-missesiTLB-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_multsize_multallow_postreason_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

源码审计也需要给模板设预算。当前模板维度主要是 SideQueueAheadMode、固定 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。

审计发现的两个缺口随后都做了收敛:

  1. routing 改为固定 22 字段 input tuple、两个 5 字段 policy tuple 和 11 字段 result tuple;binding 只负责位置解包,业务数学进入普通 LiveRoutingInput/Policy/Result C++ 函数。place/replace 时为了审计日志执行的 asdict() 仍保留,因为它不属于每次 routing 计算。
  2. 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 一致反而会把工程带偏。真正重要的是三层断言:

  1. 路径级断言:fill 数、bid/ask split、最终库存、订单状态迁移、trace 行数必须一致或可解释;
  2. 金额级断言:PnL、InvAdj、inventory time、spread summary 用很小 tolerance 对齐;
  3. 边界级断言: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 必须单点定义floorceilround 在买卖两侧含义不同,不能 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 已经变化了多少。

所以“不自欺欺人”的做法不是假装知道这些,而是把不可观测部分分成两类:

  1. 保守规则:默认排在可见 level 后面,不因为看见 level 变小就自动认为前面的人都撤了;
  2. 校准参数:用 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_10svol_blend 混合 gamma * sigma_sq,以及 regime spread scale spread<100、cap hit、波动窗口下 markout
kappa strategy.kappap3_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

所以机器学习不是直接“预测价格然后下单”。它有三类入口:

  1. 改变物理 quotevol_10sret_10sdir_10s 影响 reservation、spread、asymmetry;
  2. 改变 policy gatetox_bid/ask、markout EMA、microprice shift 影响 widen/shrink/pause;
  3. 改变风险读法: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 的保守口径是:

  1. 如果 activation timestamp 能找到历史 L2,且 quote price 对应某个可见 level,就假设自己排在该 level 可见数量之后;
  2. 如果没有可靠 L2,就用距离 mid 的指数衰减模型兜底;
  3. 后续主动成交先消耗 queue_left,打穿以后才轮到自己的订单;
  4. 不因为历史 level 数量变小就自动把“别人撤单”全部算作自己排位前进;
  5. 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 None

live 路径则不同。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 的处理有三层:

  1. 静态 strategy.kappa 作为 baseline;
  2. p3_kappa_eff / general intensity 用历史 fill probability 拟合更一般的 Lambda(delta)
  3. 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++ 主要服务两个目标:

  1. 研究吞吐:让月份级 replay、source A/B、quote context 和 label 生成跑得动;
  2. 边界收紧:让 live hot path 中稳定的小决策包更少依赖 Python 动态对象。

但它确实说明:不能把当前项目包装成“完整 HFT live stack”。更诚实的定位是:一个带有 native replay/hot-path 原型的 maker 研究系统

6.5 下一阶段真正值得做的事

如果继续往“金融大牛和系统大牛都会认可”的方向推进,下一阶段不是再加一个模型,而是补四个闭环:

  1. 动态 Lambda(delta) 闭环:用 live/shadow fill calibration,把 depth、toxicity、reference shock 映射到整条到达率曲线,而不是只做 widen/pause。
  2. 主动瘦身闭环:设计 BTCUSDT perp/spot hedge simulator,把 hedge cost、basis risk、taker fee 和 inventory tail loss 放进同一张账。
  3. 真实 multi-venue lead-lag 闭环:接入独立 venue 原始盘口/逐笔数据,验证 reference 信号是否早于本地可执行窗口。
  4. 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++ 很快”,而是研究系统要同时守住四条线:

  1. 数据连续性:坏日期、低 coverage 和跨 gap label 必须在统一质量层阻断;
  2. 目标一致性:reference/spot 有预测信息,不代表一个总开关具有执行收益;
  3. 风险完整性:raw PnL 之外,还要看 InvAdj、fill/markout、校准和库存时间;
  4. 引擎一致性: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 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。