NarrowGate:回测吞吐与 Live 尾延迟工程

Last materially modified: 2026-08-12

TL;DR:读者收益摘要

这篇文章是 NarrowGate 的工程篇,接在算法篇 《NarrowGate:Maker Quote EV、Order-Level Evidence 与 Causal Action Uplift》 后面。算法篇回答“maker quote 应该验证什么”;本文回答“这些验证如何被工程化成可重复的 replay、可审计的 live hot path,以及可解释的延迟预算”。

版本边界(2026-08-12):本文保留可复现的工程结构、parity 方法与系统 benchmark,但所有数值必须按小节日期和执行身份阅读。当前 operational identity 是 v10、Python 3.12、causal-v12 semantics-v6、q90 shadow ON/action OFF、旧 BUY fill-selection shadow OFF/action OFF;历史 causal-v4/v5/v9、q90-ON、selector-ON 和旧部署主机结果都只代表各自冻结身份。尤其要区分 50 日 compatibility baseline、strict-native queue replay 与 live transport:三者不是同一种证据。

当前工程快照可以先压缩成八条:

  1. 13-head causal-v12 在每个已完成的 10 秒桶刷新一次,桶间 sample-and-hold;它不是 100ms 订单生命周期模型。
  2. live_10s_signal_cutoff.v1 已修复“下一桶第一根 1 秒 bar 进入上一桶特征”的因果越界;features/feature_dag.py 校验 graph、unit、clock、cadence 与 label namespace,但还不是自动生成所有 Python/C++ 特征实现的动态 DAG executor。
  3. 正式 Python→C++ tick replay 只能走 simulate_tick_arrays_ext_policy_v3;较低层的 simulate_tick_arraysbench/ 脚本属于 binding、benchmark 与回归测试面,不能自行获得正式研究权限。
  4. 当前 50 日 control 是 native_derived_top20_100ms_cpp_daily_fresh_start_diagnostic:它使用历史 trades、BBO 和规范化 top-20/100ms L2,但 native raw snapshot/delta queue scheduler、订单/撤单延迟和 receive-time visibility 都是关闭的,因此不是 strict-native queue、连续 live PnL 或 live transport 证据。
  5. strict-native + sampled-latency successor 已在 2026-06-29 完成一日机制验证,真实消费 raw snapshot/delta、执行 19,460 次 queue lookup 且 missing=0;完整 50 日尚未运行,一日差异不能外推成 50 日修正方向。
  6. native queue scheduler 在 exchange clock 上使用公开 MBP snapshot/delta、exact price-level seed、individual-trade consumption 与 cancel-ahead 模型;它不是交易所订单 ID/FIFO 真值。同毫秒 trade/book/activation/ACK 若没有共同序号,必须显式标记歧义或 fail closed,不能偷偷发明顺序。
  7. Binance BTCUSDC live 使用 depth20@100msaggTrade 与事件驱动 bookTicker,另有 REST snapshot + diff-depth 维护 active-order deep book;公开数据仍是 price-level L2/MBP,历史 individual trades 只能改善历史 queue consumption,不能补出真实逐单排队。
  8. continuous_replay_state.v1、restart boundary、continuous accounting 与分层 cache DAG 已形成共享底座;UTC midnight 只作统计切片,不隐含平仓或重置,但 full-path runner、私有 ACK/user-stream 时钟、receive-time transport 与跨日 runtime state 必须逐身份接通后才能声称 live 对齐。

项目源码与构建入口:GitHub - xiao-nanbei/NarrowGateMaker。公开配置是安全研究模板,不是当前私有 live 参数快照。

离线侧的低延时,本质是把严格 replay 从“只能抽样验证”推进到“可以按日、按窗口、按参数臂系统验证”。它关注 wall time、rows/s、windows/hour、内存峰值、路径一致性和结果可重复性;单个函数调用的尾部延迟只有在暴露 fallback、分配、swap 或过度订阅时才有解释价值。

在线侧的低延时,是让报价、撤单和库存控制在真实事件循环里尽量少出现 stale quote、late cancel、跨语言 fallback 和偶发尖刺。这里必须看 mean、p99、p99.9、CPU migration、page fault、context switch、fallback 计数和长样本 soak。

这两个目标最终都服务同一件事:让 maker 策略的 spread、skew、TTL、cooldown 和库存风险控制,可以在足够多反例里被验证,也可以在 live 中按预期执行。低延时不是独立于 alpha 的炫技,它是策略工程化后必须面对的执行条件。

到 2026-07-10,项目里的工程边界又收紧了一层:tracked live/config.yaml 只保留公开模板,私有 live 参数通过 NARROWGATE_LIVE_CONFIG 注入;仓库补了根级 pyproject.tomlnarrowgate CLI、CI、Docker/devcontainer 和 5 分钟 quickstart。这个变化不是“文档好看一点”,而是把低延时研究从个人实验推进到可复现边界:别人应该能跑通 quote demo、smoke test 和 public replay skeleton,但不应该拿到私有基础设施、模型快照和实盘参数。

同一时期,C++ replay 也从“小窗口 parity 工具”推进成 active-parameter fast screening engine。queue side/regime calibration、replace throttle/pending coalesce、reducing-side cooldown、adaptive add cooldown、campaign soft control 和 BUY fill-selection score 已经进入 native 参数结构与状态机;这里 2026-07-06 的旧代 soft-keep probe 与 2026-07-25 的 causal-v5 都只是各自时点的 rolling baseline。每次宽搜索仍必须先用运行当日解析出的 operational baseline identity过 Python/C++ parity gate,不能继续把当时的 ML-OFF 当成永久控制组。xmarket_retreat 的执行入口已经物理删除,只剩 legacy audit reader 可以解释旧日志列;逐样本 empirical REST latency,以及 user-stream mismatch / sync-adjust 这类只有实盘才存在的故障闭环,仍不能由历史 C++ replay 凭空恢复。

2026-07-17 的 checkpoint 又补齐了两个关键边界:Python/live 的 evaluate_common_side_policy() 与 C++ replay 的 evaluate_common_side_policy_cpp() 对 BUY/SELL 使用同一输入/输出契约和行为测试;旧代 BUY soft-keep scorer 的 causal 静态特征通过 ABI v3 进入 native replay,不再只传 quote-time 动态字段。全仓测试为 355 passed, 4 skipped(该数字绑定 2026-07-17 当时的代码快照),May normal/high、Feb sparse、Jan A/B 四个 real-data golden 均通过。这个结果允许 C++ 承接已 parity 参数面的 fast screening,但不等于 queue cancellation-ahead、逐请求 REST latency 或 live-only 故障已经被历史数据识别。

2026-07-18 的研究 checkpoint 又把证据身份收紧了一层。causal-v4 使用完成后的 10 秒桶、merged event clock、empirical P3、显式 queue/REST-latency artifact,形成 122 日 order-level denominator;新 13-head bundle 与 causal-v4 non_toxic / beats_opportunity 两个 rebuilt BUY scorer 都只保留为 shadow,没有替换旧代 live soft-keep probe。固定的 BUY widen、SELL re-center/skip 等 local action family 也没有通过冻结的 development/validation gate,sealed holdout 未读取。下一步改为使用 event-L2 研究 local shock/refill/recovery 与 state-conditioned queue-value,而不是继续扫固定 tick/秒数。这说明系统可以把验证跑得更快、更细,但不能替策略证据补票。

2026-07-25 又完成了一轮纵向重建。正式 L2 输入迁移到版本化的 normalized_l2_100ms_v2,旧的约 1 秒与 100ms 混合目录退出 formal 默认路径;2026-07-04 至 07-11 的 individual-trade maker-side 标志也完成修复。基于这一身份,causal-v5 重新生成 128 日特征、重训 13-head bundle,并在严格 ML-ON/OFF replay 中取得小幅正的 raw/terminal 中心值,但区间仍跨零、fills 下降约 7% 至 9%,inventory time 没有改善。它随后按用户指定进入 live,promotion class 明确记录为 user_directed_trial,不是通过研究 strict gate 的 alpha。

随后 causal-v9 将数据延伸到 2026-07-25,并修复 mutable taker-tempo lineage。正式 Test3 中 ML-ON 相对 ML-OFF 的 raw/terminal 分别为 -2.2308/-2.6268 USDC,tail 增加 3 个,三个 formal days 的 raw delta 全部为负,因此没有部署;截至 2026-07-28,live 回到 ml.enabled=false,empirical P3 保持不变。该快照的全仓测试为 917 passed, 4 skipped。这证明两套引擎能执行同一 corrected baseline,不把模型可加载、一次历史部署或预测 ranking 包装成现役 alpha;它已被上面的 2026-08-03 v7 baseline 状态取代。

本文只讨论工程实现,不讨论也不建议任何真实交易行为。所有 PnL、fill、markout、A/B 和 benchmark 都是研究指标或系统指标,不是收益承诺。

场景 目标 主指标 p99/毛刺的用途
ARM 本机回测 多日、多臂、多窗口跑得动且可复现 wall time、rows/s、windows/hour、parity、内存峰值 排查 fallback、分配、swap、过度订阅
x86 live 单事件路径稳定、报价不过期、撤单不迟到 mean、p99、p99.9、fallback、migration、fault 核心指标,必须长样本 soak

第一部分:ARM 本机回测低延时:吞吐、确定性与 parity

到这里,maker 算法部分已经形成了一个闭环:候选报价、排队成交、fill 后 markout、cross-market attribution、quote EV 校准和库存时间积分。但这个闭环一旦严格起来,工程问题就不再是“以后再说”的附属项。因为每一次反例验证都要重新跑真实窗口、真实队列和真实 policy state,系统吞吐量本身会决定研究能不能继续。

这一部分是算法和 C++ 之间的桥。它回答的不是“怎么写 native 代码”,而是“为什么验证流程会需要 native 状态机”。

1.1 严格 replay 为什么会变成吞吐问题

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。做多日多臂 A/B 时,真正耗时的并不是 LightGBM 训练,而是一次次 replay 和 quote context 生成。

还有一个比速度更重要的问题:如果某个 guard 只存在于回测,或回测拥有事件引擎没有的 queue/TTL 信息,那么回测会比实际策略“更聪明”。此时加速一套不一致的模拟,只会更快地产生错误信心。

前面的数据审计、日度 A/B、strict calibration gate 都指向同一个现实:这类策略不能靠一两个漂亮窗口下结论,必须反复跑多个真实日段、多个 side policy 和多个风险口径。也正是在这里,Python 循环从“足够灵活”变成了研究吞吐量的瓶颈。

所以 C++ 迁移的第一目标不是孤立的微基准数字,而是让更严格的验证在时间上可负担;第二目标是建立同一批真实数据下的 Python/C++ parity,并借此把 policy 边界显式化。若两套引擎不能解释同一条成交路径,跑得再快也没有研究价值。

1.2 双引擎 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。
  • 函数存在不等于 ABI 一致:一次 x86 部署中,旧 pybind 模块仍有 compute_quote_core_live,但返回的 QuoteFlags 缺少新字段 cap_exposure_block。只检查函数名会在第一轮 quote 才失败;当前启动 preflight 同时实例化 QuoteFlags/SideQuoteContext 并核对字段 contract,旧扩展会在连接行情和报单前被拒绝。

所以双引擎 parity 的本质不是“我把 Python 翻译成 C++ 了”,而是建立一套制度:每次 C++ 多迁一个状态机,都必须先在小真实窗口里通过路径级和金额级审讯,然后才允许进入 sweep/A/B

截至 2026-07-17,完整窗口中最后两处 drift 也已关闭。第一处是 Python 漏计 post-policy-only spread-cap compression,导致 final_cap_compress_rate 与 C++ 不一致;第二处是 C++ 旧 helper 只看 shallow depth,可能在 exact L2 深度充足时错误施加 1.1 thin-depth multiplier。修复不是放宽 tolerance,而是让 Python 与 C++ 的等价 evaluator 对 BUY/SELL 产出相同的 spread、size、hard-pause 和 exposure allow 语义。四个 real-data golden 现在同时核对 summary、PnL path、fills、inventory-time 与 trace 长度;相关边界记录在 live/replay parity report

1.3 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 的问题。

1.3.1 用 live 指标校准 queue,而不是只看 fills/day

Top-N 历史盘口看不到隐藏单、真实 order id 排序、你前方订单撤单和交易所撮合内部细节。Queue Ahead 的价值不是“完美复现撮合”,而是避免 bar touch 那种最危险的乐观假设,并把不可观测部分留给 live calibration。

如果要判断 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。这个区别非常关键。

本节工程结论

  • 严格 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。

1.4 Native replay 与 batch API:一次跨边界跑完整窗口

从这里开始才进入 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 先看 wall time、rows/s、windows/hour、parity 和资源占用;p99/p99.9 是 live promotion 与定位异常时才必须上桌的指标。

1.4.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
  - receive-time global-flow 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
    global_flow.hpp / global_flow.cpp
    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_global_flow_batch.py
  bench_live_path.py
  bench_live_routing_bridge.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/global_flow.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。

2026-07-05 之后,仓库边界被进一步收束:维护分支只保留 BTCUSDC 执行项目,BTCUSDT 退回为 reference/source 数据,并且 raw trades 也要与 BTCUSDC minimal complete good-day universe 对齐。这个变化不只是文档整理,它能减少 C++ extension、模型目录、live config 和 replay manifest 之间的符号碰撞,让低延时优化只围绕一个执行标的做 parity。

本节工程结论

  • 不把 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。
1.4.1.1 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 在窗口边界检查 shape,并要求 C-style typed array;当前 binding 使用 c_style | forcecast,输入 dtype/layout 不匹配时可能先复制转换,只有调用方已经提供匹配的 contiguous dtype 时才是借用原 NumPy memory 的零拷贝路径;
  • std::span / view 只在函数调用期间借用 Python-owned memory;
  • C++ 不把这些指针存到函数返回之后;
  • trace、diagnostics、DataFrame 构造回到 Python 冷路径。

正式调用边界可以理解为:Python loader 先完成 source identity、时间、shape、dtype、参数与因果可见性校验,再由权威 runner 通过版本化 formal ABI 一次性把窗口交给 C++。不要在研究脚本里直接调用低层 simulate_tick_arrays;它只属于 binding/benchmark/test surface。

.venv/bin/python models/backtest_tick.py --engine cpp ...

live 路径则不同。live 不能一次喂一整个月数组,所以只把很小的 compact context 过桥:

Python event state
        |
        v
fixed tuple / small arrays / cached native config
        |
        v
C++ compact quote core
        |
        v
Python side policy
        |
        v
C++ partial routing decision
        |
        v
compact result: price, size, can_post, reason_mask
        |
        v
Python REST adapter

这也是为什么 scalar quote core 单独迁 C++ 反而可能慢:如果每次都构造 dict、dataclass、字符串 key,跨语言边界比数学本身更贵。真正要优化的是“边界形状”,不是盲目把更多 Python 改成 C++。

1.4.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++ 结果
mixed quote/policy path 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。当前 state ABI 已扩为 16 字段,但固定布局原则不变。一次报价所需的数学运算很少,对象搬运反而成了主任务。

1.4.2.1 解决方法:热路径紧凑,完整对象按需物化

这次没有继续微调 C++ 公式,而是重新设计边界:

  1. Python wrapper 缓存已经物化的 native QuoteCoreConfig,配置对象热重载时失效;
  2. state/pred 通过紧凑 tuple 传入,depth levels 由 C++ 直接读取 Python sequence,不再逐档创建 pybind DepthLevel 对象;
  3. 当时 direct quote EV 关闭时,只返回 side policy 真正读取的 adverse、defense、TTL、local-extreme、near-depth 字段,以及周期诊断日志使用的小型 diagnostics;
  4. 当时任一侧 direct quote EV 开启时会自动回到完整 context,因为模型确实需要完整特征,不能为了速度静默丢列。

这段设计记录的是当时 direct-policy fast path 的边界。2026-07-17 direct Quote-EV executor、配置入口、预计算 action arrays 与 C++ action ABI 已物理删除;当前完整 quote context 只在 offline/shadow audit 或显式诊断需要时物化,不存在可重新打开的 live Quote-EV 分支。

1.4.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",
    "bid_adverse_markout_pause_latch",
    "ask_adverse_markout_pause_latch",
    "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) != 16 || 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]);
        // 其余状态包括 BUY/SELL adverse-markout pause latch;
        // 当前 state ABI 共 16 个固定位置字段。

        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));
    });
1.4.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(...)       # 旧代 BUY soft-keep scorer / offline trace 需要完整 context

这个方案的关键不是少返回几个字段,而是明确了三种生命周期:几乎不变的 config 被缓存,每 tick 变化的 state 用紧凑序列,只有旧代 BUY soft-keep scorer、模型训练、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%
mixed quote/policy path 74.55 us 62.32 us 快 16.4%

这张表怎么读

  • 说明什么:边界收紧后,scalar native path 从负优化翻转为小幅性能增益。
  • 不能说明什么:不能把这组数字套到旧代 BUY soft-keep scorer 或 offline trace 使用的完整 context 路径,也不能推出端到端报单延迟改善 16%。
  • 下一步验证:在目标 x86 live 机型上做 p99/p99.9 soak,并确认 strict 模式没有 fallback。

这不是数量级加速,但它完成了更重要的翻转:scalar native path 终于不再因为 Python 对象往返而负优化。它仍保持显式 opt-in;旧代 BUY soft-keep scorer或 offline trace 触发的完整 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++ 悄悄变成另一套策略。

1.4.3 tick replay:先迁完整状态机,再谈加速倍数

tick replay 是整个迁移中最适合 C++ 的部分,因为 queue、latency、TTL、inventory 和 fill path 都是顺序相关状态。若只把某个公式做成 scalar binding,Python 仍要在每个 tick 调度所有状态;把完整 replay loop 放进一次 C++ 调用,跨语言边界才只出现于窗口输入和 summary/trace 输出。

C++ 版本逐项迁入当前 active replay 所需的机制:

  • historical BBO/L2;
  • exact-level queue ahead;
  • new/cancel latency;
  • maker fill gate;
  • inventory 与 inventory-time summary;
  • spread cap、adverse/defense guard;
  • local extreme、fragile/adaptive TTL/cooldown;
  • side/regime queue calibration、reducing cooldown、adaptive add cooldown;
  • replace throttle、pending coalesce、campaign soft control 与 BUY fill-selection score;
  • position timeout/emergency path;
  • 完整 trace 和 summary 字段。

这里的“完整”指 active baseline 和已显式迁移的参数面,不代表 live-only 故障状态也被历史 replay 凭空重建。user-stream mismatch、REST sync-adjust degrade、真实 ACK 乱序与逐请求 REST latency sample 仍需要 live telemetry 或 Python diagnostic。xmarket_retreat 的 runtime/replay 入口已经删除,只有 legacy audit reader 继续解释旧日志字段,不能再作为可开启或 fail-fast 的候选机制。

1.4.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> var_ts_ms;
    ArrayView<double> var_ssq;

    ArrayView<std::int64_t> ml_ts_ms;
    ArrayView<double> ml_dir_10s;
    ArrayView<double> ml_vol_10s;
    ArrayView<double> ml_ret_10s;
    ArrayView<double> ml_tox_bid;
    ArrayView<double> ml_tox_ask;
    MatrixView<double> buy_fill_static_logit_delta;
    MatrixView<double> buy_fill_static_missing;
    MatrixView<double> buy_fill_static_used;

    ArrayView<std::int64_t> bbo_ts_ms;
    ArrayView<double> bbo_best_bid;
    ArrayView<double> bbo_best_ask;
    ArrayView<double> bbo_bid_qty;
    ArrayView<double> bbo_ask_qty;

    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<double> queue_base_by_trade;
    ArrayView<double> queue_decay_by_trade;

    void validate() const;
};

TickReplayResult simulate_tick_arrays(
    const TickReplayInput& input,
    const TickReplayParams& params);

ArrayView/MatrixView 只保存指针和 shape,不拥有数据。binding 在调用期间持有 NumPy array,C++ 内循环读取连续内存;结果结束后一次性返回 summary 和可选 trace。这样优化的是整个状态机,而不是把每个 tick 的 Python callback 换成一次 pybind callback。

1.4.3.2 queue policy 只保留 exact-level,并用模板固定 side

项目早期曾同时保留 exact-level 与 through-level 两种 queue mode。through-level 会把穿过多个价位的可见量混进同一张订单的 queue ahead,容易把旧近似重新带回 formal replay;当前 Python/C++ 已物理删除 QueueAheadMode 与 through-level ABI,formal 入口只接受 exact_level。热循环只需要把 BUY/SELL side 固定到编译期:

template <Side S>
double l2_visible_queue_ahead(
    const TickReplayInput& input,
    double quote_price,
    std::int64_t target_ts,
    double price_tolerance) {

    const auto row = index_at_or_before(input.l2_ts_ms, target_ts);
    if (row < 0)
        return -1.0;

    double visible = 0.0;
    if constexpr (is_buy_v<S>) {
        for (std::size_t col = 0; col < input.l2_bid_px.cols; ++col) {
            const double px = input.l2_bid_px(row, col);
            if (std::abs(px - quote_price) <= price_tolerance)
                visible += std::max(0.0, input.l2_bid_qty(row, col));
        }
    } else {
        for (std::size_t col = 0; col < input.l2_ask_px.cols; ++col) {
            const double px = input.l2_ask_px(row, col);
            if (std::abs(px - quote_price) <= price_tolerance)
                visible += std::max(0.0, input.l2_ask_qty(row, col));
        }
    }
    return visible;
}

上面省略了 duplicate-level 与 coverage 边界。真实实现找不到可靠 exact level 时,才回到显式的 distance-decay calibration;它不再接受字符串或枚举开关把 formal replay 切回 through-level。

订单本身是一个显式状态机:

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;

    // 默认不分配;只有打开 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。这类语义如果只保留“请求撤单后立即删除订单”的简化实现,回测会系统性低估撤单延迟风险。

1.4.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++。其中 direct quote-EV runner 后来已随 executor 和 action ABI 删除。

完整 CLI 的 wall time 还包含 parquet、模型和窗口加载,因此不能拿 C++ 内层循环的 microbenchmark 直接宣称整月回测有同样倍数。对回测来说,路径一致比一个夸张的加速倍数更重要

2026-07-08 之后,C++ replay 的角色又往前推了一步:它不再只是“基础路径 fast screening”,而是可以在 active live baseline 的 parity gate 通过后 承接宽参数 smoke。这里修掉的关键偏差并不是浮点精度,而是离散状态口径:Python 的 queue 初始化使用成交时钟上的 causal local-rank,而 C++ 曾经复用了 local-extreme rank,在 guard 关闭时会退化成 0.5;同时同一 tick 内多个候选订单的成交优先级也必须按 BUY 高价优先、SELL 低价优先稳定排序。修复后,2026-06-26 与新补齐的 2026-07-04 live-like baseline 窗口都做到了 Python/C++ fill 数与 PnL 对齐,retained-day runner 才允许切到 native engine。

因此新的规则是:C++ 可以用于 active baseline 的参数快筛,但每次 sweep 前要先跑 baseline parity gate;如果 gate 不过,runner 必须 fail closed,由操作者显式修复 parity 或另行选择 Python reference,不能静默 fallback 后继续筛参数。这个边界比“C++ 快不快”更重要,因为参数搜索最怕的不是慢,而是用另一套成交口径选出一个看起来很强的假 arm。

旧的宽参数搜索、markout-sign、spread-cap 与 passive-null 数值已经从本文删除。它们依赖修复前的 feature-ready 时间、event clock、P3/queue identity 或旧 rolling baseline,不能继续充当策略证据。保留下来的工程结论只有两点:宽筛必须先通过当前 baseline 的 Python/C++ parity gate;正式 replay 必须显式绑定 private config、empirical P3、daily queue calibration、历史 BBO/L2、REST latency、代码 commit 与数据 manifest,缺一项就 fail fast。

期末账本本身则做了语义修复。窗口结束只是 valuation boundary,并没有发出 taker order,所以 Python/C++ 现在都令 final_pnl = cash + inventory * terminal_markterminal_fee_drag=0。假想主动平仓成本只作为 terminal_liquidation_fee_estimate 单独报告,不进入 PnL。真实 timeout/emergency taker exit 仍扣 taker fee;当前 BTCUSDC maker fills 按配置的 maker_fee=0 记账。这个改动只修账本边界,不改变报价、queue、fill 或 campaign,不能被解释成 alpha。

同一个 C++ state machine 仍可生成 executable passive null,但旧 seed 排名与 PnL 数值已经删除。这个 null 的用途是检查报价函数是否优于可执行随机对照,并验证 queue、latency、inventory 与 campaign accounting;它不是一条可上线的随机策略。

这个过程说明 C++ fast screening 的正确姿势:先让它快速扩大反例和候选覆盖,再用 daily/campaign gate 识别幸存者偏差与风险转移。当时的 fast_cpp_arm_smoke.py 只收 summary,后来已作为重复入口删除;它留下的方法边界仍成立:summary-only 快筛可以回答“哪些组合值得继续算”,不能回答“哪个组合应该上线”。

1.4.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. 快速筛掉明显不值得继续的 direct-policy probe;
  4. 最终候选仍必须回到 Python online inference、按日隔离 validation、chronological walk-forward 和 campaign gate。

这条路径的价值是工程上的:证明预计算数组 + C++ replay 可以显著降低筛选成本。它不再代表当前策略方向;direct quote-EV executor、配置入口、预计算 action arrays 和 C++ action ABI 均已删除。

研究链路不是一句“打开 C++”就结束,而是把昂贵步骤和快速步骤拆开。早期我曾用 C++ replay + 预计算 quote EV arrays 去快速筛 direct quote-EV policy arms;这条 direct-policy 路线已在因果重置时物理删除,修复前的正负方向不再保留。当前只保留与动作无关的模型训练和 shadow calibration 接口。

这段经历仍然留下一个系统结论:C++ 最适合承接已经固定语义的批处理边界,例如 quote context batch、order-level denominator、shock/label 前的重复 quote-core 计算、以及已 parity 的 replay fast screening;它不应该替一个还没过 evidence gate 的策略机制背书。

当前更稳妥的用法是:Python 先负责 retained-day universe、window cache、feature/model loading 和 evidence schema;C++ 只处理可独立验证的 tight loop,并在 NARROWGATE_CPP_STRICT=1 下 fail fast。模块不可用、字段缺失或 shape 错误时直接终止,而不是悄悄 fallback 后输出一个看似成功的 A/B 文件。

同样,C++ loop 变快以后,CLI wall time 可能仍受 parquet、模型和数据加载支配。后续优化重点因此转向 window cache、数组复用和批量 quote decomposition,而不是继续微调一个已经很快的循环。

1.4.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

当前实现不再保留早期的 RollingStats<WindowSeconds> 首版模板。稳定的长历史统计使用 CircularBuffer<T>CountRollingMoments:ring buffer 覆盖最旧值,rolling moments 用支持删除旧值的 Welford mean/M2 更新,避免低方差序列里的消差。短窗口、仍在变化的实验特征可以继续扫描有限 history,不为了追求“全部 O(1)”过早固化:

class CountRollingMoments {
public:
    explicit CountRollingMoments(std::size_t capacity)
        : values_(capacity) {}

    void push(double value) {
        if (values_.size() == values_.capacity()) {
            remove_value(values_[0], values_.size(), mean_, m2_);
        }
        values_.push_back(value);
        add_value(value, values_.size(), mean_, m2_);
    }

    double mean() const noexcept { return mean_; }
    double stddev() const noexcept;

private:
    CircularBuffer<double> values_;
    double mean_ = 0.0;
    double m2_ = 0.0;
};

更高层的 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%;2026-07-17 已物理删除该实现与环境开关,而不是把一个负优化入口永久留成 dormant option。这里的结论很朴素:只有状态和足够长的计算链一起迁移才有意义,单独迁四则运算没有。

1.4.5.1 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::vector<Bar1s> update(ts_ms, price, qty, is_buyer_maker);
    Bar1s current_;
    int last_trade_side_;
    int last_trade_run_len_;
};

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++ 不是为了“看起来硬核”,而是为了把稳定边界收紧。

1.4.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++”。
1.4.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

# 正常、高波动、稀疏和跨 regime 真实窗口
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++ 性能。

1.4.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++ 调用跑完整窗口

这张表怎么读

  • 说明什么:迁移优先级来自计算形态,不来自文件名或语言偏好。
  • 不能说明什么:synthetic CPU isolation 不能裁决 WebSocket、REST、日志是否会制造 live 尾部;后文的 receive-time soak 需要单独审计这些边界。
  • 下一步验证:对候选迁移点先做 flame/profile,再设计最小 native API,而不是照搬 Python 对象模型。

这张表解释的是 C++ 迁移优先级,不是 WebSocket 可以不治理。在这轮 synthetic CPU isolation 中,rolling state、quote/policy bundle 和 replay 状态机比重写 IO 更适合 native;但后续 3600 秒 receive-time soak 证明,多个 Python callback 同步争用 GIL/共享锁时仍会制造明显 p99。随后做的两种 Python dispatcher 实验又补了一条反例:每 source 一个 worker 会增加调度竞争,单个共享 worker 则会把 burst 串行积压到秒级。因此“缩短 callback”仍是正确目标,但不能把工作机械搬给更多 Python 线程;当前 live 已回到同步 reference callback,下一步应收紧 SignalEngine 锁内对象和 native compact global-flow 边界。

在该轮 2026-06-21 代码快照上,相关 quote、signal、tick replay 快速测试共 20 passed;显式开启的四个真实窗口 golden tests 也全部通过。这个数字只绑定当时的 commit、依赖和 fixture,不是当前 working tree 的滚动测试总数。这些 benchmark 是同机 synthetic CPU isolation,关闭 ML 以分离 feature/quote 成本;它们不代表网络延迟、模型推理或完整事件引擎的端到端耗时,也不能直接决定 live 默认配置。

1.4.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%,短窗口本来就不是搬移瓶颈
mixed quote/policy path 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、模型和路由。它说明的是旧容器在长时间运行后存在确定的线性成本,而不是给系统吞吐量制造一个夸张数字。

验证也按风险分层完成:在这次 C++20 迁移的 2026-06-20 快照上,快速 quote/signal/replay 测试为 18 passed, 4 skipped;另行显式运行 May 正常、高波动、Feb 稀疏和 Jan 窗口的 golden parity 为 4 passed。这些数量绑定当时代码与 fixture。此外三个 C++ 核心翻译单元用 -std=c++20 -Wall -Wextra -Wpedantic 编译为零 warning。由于短路径收益接近噪声,本次只更新 native 基础设施和研究路径,没有修改 live 配置。

1.4.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;
    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,但不会自动开启:若外层已经按 day/window/arm 多进程并行,再把每个进程开满内部线程只会过度订阅。

第二轮验证结果在 2026-06-21 对应代码快照上为快速 suite 20 passed, 4 skipped、四个真实窗口 golden parity 4 passed,核心 C++20 翻译单元仍为零 warning。测试数量绑定该轮 commit/fixture;优化改变了 allocation、cache footprint 和输出策略,没有改变成交路径,也没有修改 live 配置。

1.5 ARM 本机 benchmark:离线低延时服务反例产能

ARM 本机回测里的“低延时”,真正要解决的是反例产能:更多质量合格日、更多窗口、更多策略臂、更重的 quote context,以及更严格的 Python/C++ parity。它的价值不是把某个函数调用压到多漂亮,而是把原本只能抽样看的 replay,推进到可以系统性跑日度 panel、blocked-day validation 和 stress diagnostics。

离线 benchmark 里的尾部数字仍然有用,但它主要用来定位非确定性来源:隐式 Python fallback、异常对象分配、I/O 抖动、内存暴涨、swap、多进程过度订阅或路径分叉。只要这些风险被排除,离线侧更核心的指标就是完整窗口耗时、每秒处理行数、每天/每臂 sweep 产能、路径级 parity、金额级 parity、内存峰值和失败可解释性。

这也改变了参数搜索的工程形态。NarrowGate 现在不再把 live/config.yaml 看成一个可以暴力全组合扫描的超大网格,而是先把参数分成五类:

类别 例子 验证方式
active gamma、effective-kappa、kappa_ratioadverse_*fill_cooldown、replace throttle Python reference + baseline-gated C++ fast screening + daily/campaign gate
live-only API、WebSocket watchdog、sync_adjust_*、日志 live soak / fault audit
shadow adaptive cooldown、local extreme、depth execution probes shadow report / replay diagnostic
archived/removed direct Quote-EV live、SELL resiliency live、历史 RL Quote-EV/SELL resiliency executor 与相关运行入口已删除,只保留历史 audit/model evidence;RL 入口、配置和 lazy-loader 也已删除,不进 sweep
live-static symbol、tick/lot、fees 合约事实,不优化

当前入口收束为 models/parameter_selection.pymodels/parameter_racing_sweep.py:前者生成 coverage report 和 one-factor / Sobol arm spec,后者负责 staged racing、partial state 与 retained panel 计划,并把通过 gate 的 arm 交给现有 tick/campaign runner。旧 models/fast_cpp_arm_smoke.py 已删除,不再保留第三套宽筛 CLI:

.venv/bin/python models/parameter_racing_sweep.py \
  --symbol BTCUSDC \
  --tag parameter_quick_20260705 \
  --stage quick-smoke \
  --groups spread guard cooldown execution \
  --single-factor \
  --workers 4 \
  --window-cache-dir ${NARROWGATE_DATA_ROOT}/window_cache \
  --execute

quick-smoke 默认只跑一个上限约 30 个 arms 的 group-balanced 小面板;完整 60+ arm 主效应面必须显式使用 --stage quick-full-main-effect。宽 Sobol/search 先走 summary-only C++ smoke,只有 survivor 才进入 campaign replay。后者在每个 UTC day 完成后写 partial daily / rollup / campaign labels,长任务中断时不会把整段计算全部丢掉。

它不是“自动炼金器”。它只是把过去散落在多个 *_sweep.pystage_t_* 和 bucket 脚本里的调参动作,收束成一个约束优先的实验设计:

早期 sweep 的 winner 和排名已经删除,不能被重新包装成当前候选。当前参数研究必须从严格绑定身份的 rolling live baseline 重新出发,先过 mechanism gate,再看 campaign outcome 和 order-level fill selection。

C++ fast screening、fill-quality score 和 action-level OPE 也必须分开。C++ 的职责是更快生成经过 parity 约束的订单生命周期反事实;score 只负责排序状态;真正估计候选动作价值时,还需要完整 decision denominator、behavior propensity、action overlap、action-specific reward 与 doubly robust evaluation。项目新增的 models.audit.offline_policy_evaluation 会输出 DM、clipped IPS/SNIPS、DR、day-cluster bootstrap、ESS 和 unsupported mass。若 re-center/skip/widen 从未被 behavior policy 尝试,它会 fail overlap gate,而不是让 C++ 回放速度或回归外推替这个动作“补证据”。输入与边界见 GitHub OPE 文档。即使数值 gate 通过,结论仍以 consistency、conditional exchangeability、positivity、无跨 decision 干扰和正确 reward attribution 为条件;C++ parity 只提高反事实生成可信度,不会自动满足这些因果识别假设。

parameter coverage
  -> one-factor / Sobol candidates
  -> quick-smoke retained days
  -> quick-full-main-effect only when explicit
  -> reject mechanism-drift arms early
  -> frozen chronological validation
  -> family-specific sealed holdout
  -> live shadow / soak

排序也不再直接看 raw PnL。新的 constraint_first_score 先看 arm 是否仍像 baseline 一样运行:fills retention、pause/action mix、BUY/SELL split、tail campaign、bad campaign rate 和 inventory time 都不能明显漂移。只有这些硬约束过线,terminal campaign PnL、raw、InvAdj 才进入排序。这个设计比“多开几个 worker”更重要,因为它减少的是错误实验本身,而不只是把错误实验跑得更快。

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,但如果外层已经按 day/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 低延时:尾延迟、毛刺与 hot path

live 的低延时不是把本机回测的优化指标照搬过来。回测可以一次喂完整数组,可以释放 GIL,可以让外层 sweep 多进程并行;live 每次只面对一个最新 market state、一个 depth snapshot 和一组本地订单状态。它的失败形态也不同:毛刺可能让 quote stale、cancel late、replace 迟到,或者让本来该降级的状态继续报价。

所以 x86 live 章节只问三件事:目标机器上 native 开关是否真的降低尾延迟;pybind/对象物化有没有引入 fallback 或尖刺;当前架构离真正 HFT live stack 还差哪些系统边界。

2.1 x86 live benchmark:短跑看方向,soak 看 p99/p99.9

需要在 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 变成配置候选。

2026 年 7 月,我拿当时的 x86 EC2 live host 做了这件事:Amazon Linux 2023、Intel Xeon Platinum 8259CL、2 vCPU(一个物理核的两个 SMT 线程)、L1d/L1i 都是 32KiB,clocksource 是 tsc。这台历史主机一开始甚至没有 gcc/g++/cmake,所以第一步不是调参数,而是把 live benchmark 环境和训练环境拆开;下面的 Python 3.11 环境与性能数字均绑定该冻结 host/runtime,不能直接代表 2026-08-11 之后的 Python 3.12 新 AWS epoch。

这里有个很现实的坑:如果在 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 mixed 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 尖刺。

短跑只能说明方向,所以我又在同一台 x86 benchmark 机型上跑了更长的 soak:baseline 和候选各 --n 100000 --signal-n 10000,routing compact ABI --n 200000。下面采用 EC2 上实际保留的 benchmark artifact,而不是后来手抄过的一组中间数字:

路径 Python baseline mean/p99/p99.9 QUOTE_CORE + SIGNAL_FEATURES mean/p99/p99.9 观察
signal 10s features 15970 / 50395 / 159093 us 3630 / 14858 / 42108 us C++ fixed-array 把 mean、p99、p99.9 都压低约 70%-77%
live _compute_quotes 267 / 566 / 1329 us 229 / 665 / 1909 us 均值改善,但这轮 p99/p99.9 反而更差,不能把 quote core 单独称为稳定胜出
mixed quote/policy path 486 / 1412 / 6592 us 438 / 1175 / 5128 us Python side policy + native compact quote/routing 的 mean 与尾部有小幅性能改善;policy 主体仍在 Python
compact routing ABI n/a 1.94 / 3.10 / 18.91 us 固定 tuple ABI 的 p99 很低,p99.9 仍可见解释器/调度毛刺

这张表怎么读

  • 说明什么:x86 上最明确、可重复的 CPU 收益来自 fixed-array signal features;compact routing ABI 本身也足够轻。
  • 不能说明什么:quote core 的均值改善没有在每轮 p99/p99.9 上稳定复现;而且这组数不包含 Binance 网络、REST new/cancel、交易所撮合和真实异常恢复。
  • 下一步验证:在真实 live 进程旁路 shadow 打点,比较开启/关闭 native 开关时的 HEALTH、requote、ORDER_UPDATE 和 p99.9。

随后我又在当时实际运行 live 的私有 x86 主机上补了这一步:Amazon Linux 2023、Intel Xeon 系列 vCPU,以及该 epoch 的 venv、模型和配置。这里要把 synthetic benchmark、真实 live telemetry 与研究机制 parity 分开;这些表都属于原 host/runtime 的历史证据,可以复用作工程先验或敏感度,但不能因新主机同在 Tokyo 且 CPU/内存规格相近,就重标为新 AWS epoch 的当前延迟。

第一类仍是 synthetic benchmark。它只测 Python/C++ 决策路径本身,不包含真实 Binance REST 往返。本轮命令是:

python bench/bench_live_path.py --n 5000 --signal-n 500 --ml --engine python

NARROWGATE_CPP_QUOTE_CORE=1 \
NARROWGATE_CPP_SIGNAL_FEATURES=1 \
NARROWGATE_CPP_STRICT=1 \
python bench/bench_live_path.py --n 5000 --signal-n 500 --ml --engine cpp --strict-cpp

该冻结 live epoch 可以正常 import narrowgate_cpp。下面这张表只回答“这台 x86 上 C++ fixed-array / quote core 本身是否值得继续进 live shadow”,不包含真实 REST、交易所 ACK 和订单生命周期。

路径 Python baseline mean/p99/p99.9 QUOTE_CORE + SIGNAL_FEATURES mean/p99/p99.9 读法
ingest 1s ws events 91.01 / 178.84 / 441.45 us 108.37 / 206.71 / 640.06 us ingest 仍是 Python 事件路径,native 组合没有带来收益
signal cached 21.14 / 47.81 / 389.72 us 19.00 / 34.12 / 58.40 us cached path 的尾部更稳
signal 10s features 12395.55 / 95617.29 / 139250.92 us 3497.96 / 8772.66 / 19431.87 us fixed-array state 是最清楚的 CPU 收益
live _compute_quotes 357.99 / 558.48 / 1170.40 us 267.88 / 1183.41 / 3128.01 us 均值更低,但 p99/p99.9 在这轮变差
side policy pair 125.20 / 223.37 / 287.34 us 136.84 / 376.36 / 1204.02 us policy 主体仍在 Python,native quote 不解决这里
mixed quote/policy path 536.57 / 895.01 / 1698.98 us 696.11 / 4829.99 / 14519.30 us Python side policy + native quote/routing 的短跑出现明显尖刺,与 100k 长跑方向不一致

这张表和 ARM 本机结论不同:在当前 x86 live 机上,SIGNAL_FEATURES=1 已经不是早期那种负优化;它在短跑和 100k 长跑里都明显降低 10s feature 的均值和尾部。quote core 则更微妙:短跑里 _compute_quotes 均值更低,尾部却更差;100k 长跑里 mixed quote/policy path 又恢复为小幅改善。这种 run-to-run 波动说明共享 VM 上不能凭一次 microbenchmark 宣布 quote core 胜出,真正稳定的结论仍只有 fixed-array signal 与低成本 compact routing ABI。

第二类是真实 live 进程旁路 telemetry。我在 MakerEngine 里加了 logs/live_perf_telemetry.csv,每次 requote 记录 signal_compute_uscompute_quotes_usupdate_orders_us、REST new/cancel 耗时,以及 futures/spot/reference WebSocket age。

2026-07-01 的受控 live soak 曾经把进程环境切到:

NARROWGATE_CPP_QUOTE_CORE=1
NARROWGATE_CPP_SIGNAL_FEATURES=1
NARROWGATE_CPP_LIVE_ROUTING=1
OMP_NUM_THREADS=1
OPENBLAS_NUM_THREADS=1
MKL_NUM_THREADS=1
NUMEXPR_NUM_THREADS=1
MALLOC_ARENA_MAX=1

第一版只把 replace_min_price_change_ticks 设成 1-2 tick,结果几乎没有减少 REST:BTCUSDC 报价每轮自然跳动经常是几十 tick,2 tick 门槛基本等于没设。后来改成非零的 adding/reducing 门槛,并保留减仓方向更敏捷的 interval。这个改动不是 alpha 参数,而是 order lifecycle coalescing:价格没有变到足够多时,不做 cancel+new;TTL、stale-data、pause 和库存安全撤单不被阻断。

受控 soak 里,cpp_routing_used 覆盖率达到约 97%-99.6%,并出现 REPLACE_THROTTLE。但 2026-07-10 回查当时进程时发现了一个更值得记录的部署问题:进程通过 live/run.sh 重启时只从 live/.env 加载了 API 凭据,NARROWGATE_CPP_QUOTE_CORENARROWGATE_CPP_SIGNAL_FEATURESNARROWGATE_CPP_LIVE_ROUTINGNARROWGATE_CPP_STRICT 都没有持久化。结果是当时约 24 小时 telemetry 中 cpp_routing_used=0%,signal/quote/routing 也都回到了 Python 路径。

这不是 C++ 运行时退化,而是 opt-in feature flag 没有成为部署 profile。它暴露了一个低延时系统很实际的问题:benchmark 命令里的环境变量如果没有进入 run.sh 可审计的 profile,下一次策略参数重启就会静默丢失。以后 live soak 必须同时记录进程启动环境、extension 路径、strict flag 和 telemetry native-hit rate;只看“机器上可以 import narrowgate_cpp”远远不够。

随后把这个部署缺口修成了显式 profile,而不是继续依赖运维人员记住一串 export

# 凭据仍来自不进 Git 的 live/.env;这里只选择非秘密计算 profile。
NARROWGATE_LIVE_PROFILE=python ./live/run.sh restart
NARROWGATE_LIVE_PROFILE=native ./live/run.sh restart

# 在真正重启前先审计将被加载的开关。
NARROWGATE_LIVE_PROFILE=native ./live/run.sh profile

native.env 固化 QUOTE_CORE + SIGNAL_FEATURES + LIVE_ROUTING + STRICT,并把 OMP、OpenBLAS、MKL、NumExpr 的线程数固定为 1;python.env 保留相同线程环境,只关闭 native 实现。main.py 启动时主动 import extension,检查所需 API,并把 profile 名、全部开关和 .so 来源写进启动日志。strict profile 如果缺模块或 binding,会在连接交易所、发送订单之前失败;run.sh status 也会显示当前持久化 profile。这样“Python/native A/B”才是在换实现,而不是顺手换了线程池、配置或环境。

2026-07-11 又发现了一个比函数 benchmark 更危险的部署边界:一段历史进程使用相对路径 .venv/bin/python3 live/main.py 启动,而旧版 run.sh 只按绝对 main.py 路径识别进程。一次 restart 因此误判为“未运行”,又启动了第二个 maker,交易所短暂出现两组双边订单。确认订单归属后先撤单并终止旧进程,随后把 run.sh 改为同时识别绝对/相对入口;即使 PID 文件缺失,start 也会先扫描现有 maker,发现单实例已存在就拒绝重复启动。这个事故没有改变策略参数,却说明低延时工程的正确性还包括单实例、订单所有权和可恢复启动;只优化 p99,而不能证明现场只有一个决策者,性能数字没有生产意义。

下面把两段历史 native soak 与当前 Python 进程放在一起。它们不是同一市场时段的 A/B,不能据此计算“C++ 让端到端快了多少”;这张表只用于检查数量级和部署状态。REST 分位数使用每个 requote 内真实调用的 max sample,不把零调用行混进统计:

live 窗口 native routing hit keep / pause signal p50 / p99 requote p50 / p99 REST new / cancel p99
2026-07-01 controlled native 90m 99.56% 16.34% / 4.62% 15.17 / 341.27 ms 50.78 / 912.87 ms 92.85 / 122.18 ms
2026-07-01 controlled native 60m 96.91% 24.86% / 10.44% 9.38 / 266.86 ms 38.81 / 673.31 ms 91.29 / 109.60 ms
2026-07-09 current Python 24h 0% 13.23% / 12.86% 27.85 / 302.81 ms 63.85 / 781.87 ms 87.44 / 94.83 ms

当前 Python 24 小时窗口的 requote_total_us p99.9 仍约 1.41 秒;update_orders_us p99 约 440ms,而 _compute_quotes p99 约 12.7ms。也就是说,即使重新打开 C++ quote/routing,端到端 p99 的上限仍然主要由 order lifecycle 决定。历史 native soak 的 signal p50 明显更低,但 p99 仍可达数百毫秒,也说明解释器调度、模型/日志、虚拟机抢占和行情状态会盖过单个 C++ tight loop 的收益。

这段 live telemetry 因此保留了最初的工程判断:keep 已经明显出现,说明 replace throttle 确实减少了一部分无意义撤挂;它也暴露出 pending_coalesce 必须有独立 counter,不能只从 action mix 里猜。当时的 gateway 实验曾为此补过独立 telemetry;该 gateway 及其专属 telemetry ABI 后来一起删除。REST 只统计有真实 REST 调用的行时,new/cancel 的 p99 仍然可以进入百毫秒量级;完整 requote 的 p99/p99.9 仍由 order lifecycle 拉长。所以当前 live 尾部的主因仍然不是 quote math,而是:

  1. REST cancel/new 的百毫秒级尾部;
  2. update_orders() 同步等待导致的路径拉长;
  3. signal_compute_us 偶发长尾;
  4. WebSocket 短时 silence/reconnect 对 freshness 的扰动。

安全路径在当前窗口仍然工作:telemetry 有少量 stale_book / risk_block,日志没有进程级 traceback,也没有持续 stream silence;偶发 post-only reject 和 cancel unknown-order 仍会进入订单状态机。关键读法是,网络/交易所路径的毛刺会直接进入 live p99,不能被 synthetic benchmark 消去。

profile 固化后,该冻结 x86 live epoch 的第一段同步 native preflight 做到 cpp_routing_used=100%,strict 模式没有 fallback,extension 来源也与预期的 x86 CPython wheel 一致。这个短窗口只证明当时的部署链路恢复了,不能替代完整的 Python/native 同口径 soak,更不能直接外推到后续 host/runtime;restart 附近的 inventory sync、warmup 和 sync-degrade 行也必须先从 marker 中排除。

同一配置下又做了两段相邻的短窗 preflight。Python-sync 约 8.4 分钟,native-sync 约 9.1 分钟;后者的 requote_total_us p50/p99 从约 44.5/434.7ms 变成 40.5/318.3mssignal_compute_us p99 从约 132.8ms 变成 95.4mscompute_quotes_us p99 从约 14.4ms 变成 3.9ms。native hit 为 100%,没有 strict fallback。这里最重要的不是百分比,而是口径:两个窗口共享代码、配置、线程限制和 marker schema,但市场时间不重叠,因此只能作为 deployment preflight,不能称为严格因果 A/B。

因此当时没有根据短窗口直接调 replace threshold,而是先做同一配置的 Python/native sync A/B,再保持 native profile 不变测试 async gateway。下面记录的长 soak 已经完成并否证这条实现:action mix 和安全链路虽可运行,p99/p99.9 没有改善,coalesce 也几乎没有形成。

当前结论也随之更新:C++ fixed-array 已经进入可持久化的 live profile,能压缩 signal/quote/routing 的计算部分;端到端 p99 的剩余主因仍是 order lifecycle、REST tail、pending state 和系统调度。当前保持同步 adapter;若未来重做异步网关,必须以新的 bounded-queue/backpressure 实验重新开始,不能复活旧开关。

2.1.1 历史失败实验:Per-side latest-wins order gateway

当前状态(2026-07-19):本节只保留 2026-07-10 的系统实验与失败证据。strategy/order_gateway.py、配置开关、专属 telemetry ABI 和测试已于 2026-07-17 物理删除;当前生产与仓库都只有同步 normal-order adapter,不存在可误开的 dormant async path。

当时的出发点是:直接把 rest.new_order() 扔进普通线程池并不安全。行情每几秒可能产生一个新目标,而前一个 cancel/new 还在交易所或用户流中收敛;无界任务队列会让“最新报价”排在一串已经过期的价格后面。实验实现因此使用 每侧容量为 1 的状态机

market / decision thread
    desired[BUY]  = newest bid intent
    desired[SELL] = newest ask intent
              │
              ├── BUY worker: wait pending -> cancel old -> re-read newest -> place
              └── SELL worker: wait pending -> cancel old -> re-read newest -> place

new intent while REST is in flight:
    overwrite desired[side]
    coalesced_count += 1
    do not append another REST job

当时的实现放在后来已删除的 strategy/order_gateway.py。BUY/SELL 各有一个 worker,避免一侧 cancel 尾部阻塞另一侧;每侧只保留最新 QuoteIntent,旧 generation 不会继续下单。worker 在 PENDING_NEW/PENDING_CANCEL 时等待状态收敛,超时只记 telemetry,不绕过订单账本。REST cancel response 只证明请求返回,订单终态和 cancel 前可能发生的 fill 仍由 user stream 驱动;缺失事件继续走原有 stale pending reconciliation。

实验中的异步路径只接管正常 quote/place/replace,以下路径故意保持同步并高优先级:

  • stale book / stream safety cancel;
  • cancel-all 与进程 shutdown;
  • emergency close 和 maker 原生退出;
  • 库存硬上限触发的即时撤单。

当时的 async_order_gateway_enabled 是 startup-only 开关,SIGHUP 不能在有活跃订单时改变线程模型。逐操作的 place/cancel/coalesce/pending_timeout/error 写入独立的 order_gateway_telemetry.csvHEALTH 同时输出累计值。上述开关、CSV schema 和 counter 都随失败实现删除,不能作为当前配置或日志契约引用。

第一段 native-async 安全 preflight 约 10.1 分钟。窗口内记录到 178 次 submit、164 次 cancel、165 次 place,coalesce/timeout/error=0/0/0;gateway new/cancel p99 约为 172/184msupdate_orders_us p50 从同步 native 的约 36.8ms 降到 15.5ms,说明正常 REST 已经离开决策线程;但 p99 从约 249ms 升到 833ms,不能据此宣布尾部改善。逐行复盘发现,最慢的两次 requote 同时伴随 signal、quote 和 update-orders 的整体停顿,而 gateway 没有 pending timeout 或 error,更像 VM 调度、GIL/日志竞争或同一时刻的进程级 stall。每段只有约百个 requote,p99 实际只由一两个样本决定。

后续 native-async 长 soak 累积了约 194.4 分钟。native routing hit 为 99.64%,双边 fills 为 24/27,WebSocket 没有持续 silence,TTL、stale-data block 和 safety cancel 仍然工作。gateway 共记录约 3513 次 submit、2799 次 cancel、2850 次 place,但 coalesce 只有 1 次,window timeout/error 为 0/1。这说明在当前 requote cadence 下,无界 pending replace 并不是常驻瓶颈。

路径 native-sync p50 / p99 / p99.9 native-async p50 / p99 / p99.9 读法
requote total 40.5 / 318.3 / 381.1 ms 31.3 / 720.2 / 1074.8 ms median 下降,tail 明显变差
update orders 36.8 / 248.7 / 249.2 ms 15.9 / 444.1 / 855.1 ms REST 离开常规路径,但进程级 tail 未消失
signal compute 7.1 / 95.4 / 132.5 ms 13.2 / 227.5 / 400.1 ms 同时恶化,不能归因于 REST worker 本身
quote compute 0.38 / 3.86 / 8.87 ms 0.39 / 11.9 / 94.3 ms scalar quote 不是端到端 tail 的决定因素

这仍是相邻、非重叠市场窗口,所以不能把 ratio 当成严格因果 A/B;但工程决策不需要假装有优势:async 没有形成足够 coalesce,p99/p99.9 也没有改善,复杂度和故障面却更高。因此生产 baseline 回滚到 synchronous order adapter,native C++ profile 保留,replace threshold 不因这次 system soak 额外提高。2026-07-17 随后物理删除异步实现、配置、telemetry ABI 和专属测试,不保留“研究路径”式 dormant switch。

当时为了避免 A/B 变成手工摘日志,soak analyzer 曾统一读取 REST/gateway telemetry。当前 scripts/analyze_live_soak.py 已移除 gateway 专属输入,只分析仍存在的 action mix、native hit、WS age、placed/fills,以及 signal/quote/update/requote 与 REST 分位数。scripts/x86_live_profile_ab_soak.sh 仍只能通过 run.sh 管理进程,并要求显式 ACK_LIVE_SOAK=YES,避免误把 benchmark helper 变成无人值守的实盘重启器。

2.1.2 独立 venue 行情:receive-time 也是 hot-path 输入

接入外部 reference 后,延迟预算不能只看 Binance quote、routing 和 REST。当前 x86 live 机以 shadow-only 方式接收 Bitget、Bybit、OKX 的 BTCUSDT spot/perpetual 六条行情;外部 connector 没有下单接口,也不进入当前报价函数,只记录 venue-aware event。Binance USDCUSDT spot bookTicker 另作本地币种换算锚,不算第七个独立 vote。

每条事件同时保留:

exchange_event_ts_ns
local_receive_ts_ns
feature_ready_ts_ns
transport_lag_ms
sequence_number
market_id = venue:instrument_type:symbol

这个双时钟不是装饰。第一次独立 smoke 只按 receive-time 判断 freshness,发现连接刚建立时 trade channel 会返回一小批较旧成交:消息是“刚收到的”,但成交事件已经过去十几秒。如果直接把 receive age 当成 market age,trade p99 会被错误放大,策略也可能把历史成交当作新冲击。修复后,snapshot 同时输出 book/trade event age 与 transport lag;整体 reference availability 由 bookStale 决定,成交流安静只设置独立的 tradeStale,不能覆盖仍然新鲜的 books1

接入过程中的第一个 Bitget-only 窗口曾测到 books1 transport p50/p99 约 5.1/37.9ms,trade 约 7.8/38.5ms。随后第二个 source 刻意先用 REST,验证“HTTP 返回快”与“市场事件新鲜”不能混为一谈。Bybit 早期 warmup 后基线如下:

Bybit public REST samples event lag p50 p95 p99 REST RTT p50 / p95 / p99
BBO 323 139.9 ms 208.9 ms 236.6 ms 76.4 / 82.5 / 95.4 ms
trade 442 190.9 ms 470.2 ms 512.4 ms 80.0 / 96.2 / 105.5 ms

这张 REST 表现在只保留为 transport 反例:它适合说明轮询为什么不能承担 50/100ms maker action,不代表当前 live。当前 Bitget、Bybit、OKX 的 BTCUSDT spot/perpetual 已全部切成无需 API key 的 public WebSocket BBO + trades;connector 只暴露行情方法,没有账户、持仓或下单接口。当前 live HEALTH 中的六个 external ID 是:

bitget:perp:BTCUSDT
bybit:perp:BTCUSDT
bitget:spot:BTCUSDT
bybit:spot:BTCUSDT
okx:perp:BTCUSDT
okx:spot:BTCUSDT

统一 public-WS 的 15 秒零密钥 preflight 得到下表。lag 是样本末端 local_receive_ts - exchange_event_ts 的短窗观测,不是长期 SLA:

source BBO / trade events 末端 BBO / trade lag
Bitget perpetual 173 / 70 约 5 / 6ms
Bybit perpetual 132 / 21 约 38 / 39ms
OKX perpetual 203 / 46 约 29 / 30ms
Bitget spot 67 / 51 约 13 / 6ms
Bybit spot 94 / 12 约 40 / 39ms
OKX spot 75 / 11 约 28 / 28ms

六条 source 的 connector error/reconnect 都为 0,spot/perpetual 各有 3/3 fresh venue。部署后的 HEALTH 继续显示 externalSources=6externalStale=0externalErrors=0marketTapeDropped=0marketTapeInvalid=0globalFlow100Valid=1。receive-time tape 使用无损流式 .jsonl.gz;短窗写盘投影由未压缩约 5.9GB/day 降到约 0.54GB/day,没有为了省空间退回 1 秒聚合。六路 source 仍不进入 active quote policy。

在 AWS Tokyo ap-northeast-1t3.medium(2 vCPU、3.75GiB、x86_64、Amazon Linux 2023)上又固定测了 3600 秒,共 682,110 条事件。外部 BBO p50 是 Bitget 约 5-6ms、OKX 约 29-36ms、Bybit 约 38-40ms;p99 已放大到约 268-701ms。更值得警惕的是,同机 Binance perpetual callback p99 约 2.8 秒,而 feature processing p50 通常只有约 71-136µs。这说明尾部主要不是一个 C++ scalar 公式,也不能简单归因于交易所距离;公网 delivery、callback/GIL 竞争、VM 调度与一次受控 restart 都进入了这张表。

这组环境参数已经写成可重放 profile。captured 使用现场 feature_ready_ts,不再加延迟;exchange_zero 是理想对照;profile_p50/empirical/p99 从 exchange timestamp 重建可见时间。四笔 maker BUY fill 的 wiring smoke 里,p99 profile 基本消除了 10ms flow,并把 100ms 方向相对 captured 的一致率降到 50%。markout 保持不变,因为 external state 仍未接入报价。这只能证明尾延迟足以改变短窗信息集,不能证明 re-center/cancel 有收益。重启时返回的陈旧 trade batch 继续保留在 tape,但 event age 超过 1 秒时不再进入 10-500ms flow。

2.1.3 callback 解耦实验:有界队列不等于低尾延迟

3600 秒 profile 后,我按代码审计结论实现了第一版 callback 解耦:网络线程只记录 local_receive_ts_ns、解 JSON 并入有界队列;BBO 使用每 source 最新值 mailbox,trade/control 保持 FIFO;当时同一 venue frame 内的 trades 通过 on_cross_agg_trade_batch() 一次拿 signal 锁。当前现役 ingress 已改为连续数组接口 on_cross_trade_arrays();旧 dict batch 只属于兼容/测试边界。队列深度、高水位、最老事件年龄、BBO coalesce、trade drop 和 handler error 全部进入 HEALTH,所以排队不能靠换线程从 telemetry 里消失。

这个机制先后测了两种拓扑,结果都没有 promotion:

receive-to-feature 路径 external book feature p99 external trade feature p99 结论
原同步 callback,600s 约 0.43-6.20ms 约 0.72-27.13ms 保留
六个 per-source worker,600s 约 32.97-105.33ms 约 43.85-546.68ms 回退
一个 shared worker,420s 约 17.75-45.64ms 约 747.16-1,960.82ms 回退

六 worker 把六条 source 的 runnable 线程一起压到 2 vCPU 上,调度、GIL 和同一把 SignalEngine._lock 的竞争更重。shared worker 虽然少了线程,却串行承担所有 venue 的归一化和 trade 更新;一次 burst 把 FIFO 高水位推到 1,486 frame,最大排队年龄达到 4.05s。两版都没有 trade drop,也没有 dispatcher handler error,这恰好说明失败不是“少记了坏样本”,而是无损队列本身已经 stale。相邻窗口不是严格因果 A/B,但这种数量级回归与队列机制量足以做系统 fail-fast。

因此当前生产口径是:六路 public WS reference 继续在线、仍然只做 shadow/reference;两种失败的 Python dispatcher 实现与 dispatch_enabled 开关均已删除,callback 直接做 frame-level normalize/array batch;normal-order adapter 保持 synchronous,native compute profile 保留。已经完成所需 receive-time capture 后,六路 external JSONL 与 Binance market tape 也默认关闭;记录期间 Binance writer 曾达到 7,151 行高水位和 4.4s writer queue age,说明完整 raw tape 应该是有起止 marker 的审计模式,不是常驻 live 默认项。关闭录制后的相邻 600 秒 sanity 中,signal_computeupdate_ordersrequote_total p99 分别约从 282/809/989ms 降到 169/542/797ms;REST tail 和 action mix 同时变化,所以这里只把它当作“没有机制损伤”的工程检查,不当成严格因果加速比。

这轮还修了两个容易被性能图掩盖的工程问题。第一,外部连接停机改为并发关闭,整体等待由最慢一路决定,不再把六个 WebSocket join timeout 相加。第二,tape 改成 UTC day + recorder session 独立 gzip;强制停机最多损坏当前 session,不会再向坏 member 后追加一整天数据。下一步若继续优化 callback,不再增加 Python worker,而是先减少锁内 dict/deque 物化,把 5-10ms per-source trade frame 聚合与 fixed-array global-flow state 作为一个较大的 native 边界,再用同一环境标签复测 p50/p99/p99.9。

2.1.4 native fixed-array global-flow batch

这条后续已经实现,而且边界刻意没有扩成新的线程拓扑。Bitget、Bybit、OKX 每个 trade frame 先归一化为 timestamp/price/size/maker 四组连续数组,再一次进入 NativeGlobalFlowEngine.on_trade_batch();C++ 在内部固定容量 ring 上更新 receive-time flow,释放 GIL 后才回到 Python。随后 SignalEngine._lock 只拿一次,原地更新该 market 的最新状态,并通过 TradeBarAggregator.update_batch() 完成 cross-market 1s bar。BBO 同样先更新 native L1 flow,再在锁内原地修改 latest ticker 与同秒 history。这里没有新增 Python worker。

native engine 预留 16 个 market slot;每个 slot 有 32,768 个 trade event 和 8,192 个 book event。构造完成后不再按事件分配对象。当前 live 使用 11/16 个 slot;stale、out-of-order、capacity overflow 都有独立计数,strict profile 缺少 ABI 或耗尽 market slot 时直接失败,不能静默退回 Python。

目标 x86 live 机上的 bench_global_flow_batch.py 同时测纯 flow 与完整 signal ingress。后者包含 NumPy 边界、source-state 更新、bar rollover 和 global-flow,不只测一个理想 C++ tight loop:

venue frame Python signal ingress native signal ingress 加速
1 trade 22.17 us/frame 13.17 us/frame 1.68x
8 trades 82.73 us/frame 14.07 us/frame 5.88x
32 trades 311.25 us/frame 18.55 us/frame 16.78x

这组结果的重点是 burst,不是 scalar。单笔 frame 只有约 1.7x;frame 越大,原实现逐笔创建 dict/dataclass、逐笔过 Python bar loop 的固定税才越明显。纯 native flow 在 32 笔 frame 上接近 39x,但那不是 live callback 的完整收益,所以不能拿来替换上表。

部署后的 10.3 分钟 preflight 保持策略配置与同步 order gateway 不变。11 个 HEALTH 样本的 native global-flow 命中率为 100%;窗口看到 55,254 笔 trade,接受 54,063 笔,处理 441,254 条 BBO;trade/book fixed-ring overflow 为 0/0,external error、reconnect、stream silence 也都是 0。窗口中有 1,188 个 stale counter 增量:这是交易所后来送到的旧 trade 被 exchange-time 年龄门控拒绝,而不是把陈旧成交塞进 10-500ms flow。

同一窗口的 requote、update-orders、signal、quote p99 约为 852/465/324/13.9ms。它们来自一个新的非重叠市场窗口,不能和旧窗口做因果比值;而且 requote tail 仍明显包含 REST、模型、调度和日志。这里可以 promotion 的只有无额外 worker 的 native shadow state 实现,不是“端到端 p99 已解决”,更不是“global flow 已经成为 alpha”。

当前实现也没有把 BBO 冒充 exact L2。GlobalFlowEngine 只从 public top-of-book 与逐笔成交构造 10/25/50/100/250/500ms aggressive buy/sell volume、trade imbalance、L1 OFI、bid/ask depletion/refill proxy、spot/perpetual 2-of-3 consensus 和 global-minus-local residual。只有通过 2-of-3 freshness 的 spot 或 perpetual 层才有资格进入 global common factor;单个 venue 即使数值极端也不能投票。离线 fill_toxicity.py 再严格按 feature_ready_ts_ns <= fill_ts_ns 重放这些事件,并用 Binance BTCUSDC receive-time BBO 计算同 horizon 的 maker-signed markout。它回答的是 fill toxicity sorting,不是 keep、cancel 或 re-center 的反事实收益。

稳定币换算也必须写清方向。Binance 正式 symbol 是 USDCUSDT,表示一单位 USDC 对应多少 USDT,因此 BTCUSDC bridge 是 BTCUSDT / USDCUSDT。live 只为这个 anchor 订阅 bookTicker,不订阅无用的 aggTrade;历史层则只下载 canonical retained days,聚合成 1s bars 后删除 raw CSV/ZIP。BTCUSDC spot 继续作为 cross-check/fallback,而不是外部共识票。这个慢速 anchor 使用独立的 30 秒 level freshness;三家外部 spot/perpetual 的 BBO freshness 与 10-500ms event window 单独计算。二者不能共用同一个阈值,否则稳定币 BBO 几秒不变会制造假的 reference-invalid。旧 retained111 的 1 秒 trade-time Stage 0 已冻结为历史诊断,不再继续调 threshold;新 Stage 0 只使用真实 receive-time tape。HEALTH 也分别输出 fresh venue 数、flow pressure、agreement、basis warmup 样本数和 invalid reason,而不再只给一个无法归因的布尔值。

这些数字只能证明目标机器上的接收链路数量级和实现语义,不能证明 cross-venue alpha,也不能直接与 Binance REST p99 相加。样本仍短、市场窗口单一,而且各 venue 与 Binance 的网络路径不同。后续若研究 50/100ms event cancel 或 1 tick re-center,必须使用同一采集点的 receive-time tape,并把:

external feed age
+ feature / decision latency
+ Binance cancel-or-replace latency
+ exchange acknowledgement / matching latency

一起与信号半衰期比较。只有 predictive horizon 在 p95/p99 端仍长于完整执行链路,cross-venue evidence 才可能进入 policy;否则它最多是 post-fill campaign moderator 或 risk diagnostic。

第三类是研究机制边界。C++ 已能重放 queue-enabled fill-selection 及其周边 side/regime queue lookup、replace throttle、pending coalesce、reducing/adaptive cooldown 和 campaign soft control,因此它不再是“Python-only 缺口”。但当前 v10 control 已把旧 BUY fill-selection 的 action 与 shadow 都关闭;C++ 能执行某个历史 action,不等于该 action 仍属于现役 baseline。

仍不能交给 C++ 历史 replay 裁决的,是三类本质不同的问题:已删除执行入口的 xmarket_retreat 只允许由 legacy audit reader 解释旧日志;empirical REST latency 目前还是分布/样本近似,而不是逐请求真实 ACK tape;user-stream mismatch、sync-adjust degrade、断线重连和交易所 reject 属于 live-only 故障闭环。后两类需要 Python diagnostic 或真实 live soak,不能靠给 C++ 多加几个字段就假装已经回放。

这条边界很关键:C++ 现在适合两件事。第一,active baseline 以及已 parity 参数面的宽 arm / retained fast screening;第二,quote context、order-level denominator、shock/label 前处理这类批处理 tight loop。它不适合替还没过 evidence gate 的新机制背书。换句话说,C++ 可以让反例更便宜,但不能把一个未验证机制变成 alpha。

这说明一件事:架构边界不是文档措辞,而是真的会改变工程结论。 在本机 ARM 上,早期 SIGNAL_FEATURES=1 曾经是负优化;经过 fixed-array/ring-state 改造后,在 x86 上它反而变成最明显的 live CPU 收益。相反,历史 SIGNAL_STATE=1 在这台 x86 上没有额外收益,其实现与开关已于 2026-07-17 删除。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 的环境。

2.2 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()报价决策与默认同步 REST new/cancel 在 main thread 串行执行;Binance execution callback 直接更新 SignalEngine。六条 external shadow feed 当前也由各自 callback 做 frame-level normalize 和 batched signal update;两种 Python dispatcher 拓扑都因 p99 回归而关闭。 main thread 在 requote 时读取一致 snapshot。当前 normal-order adapter 没有独立线程,使用同步 REST;旧 async-gateway 实验已经连同代码和开关删除。

Binance callback threads ─► receive/decode ─► SignalEngine update
external callback threads ─► receive/decode/normalize
                          ├─► native fixed-array global-flow batch
                          └─► one-lock source/bar state update
                                                    │
                                                    ▼
                                      SignalEngine shared snapshot
                                                    │ lock-protected snapshot
                                                    ▼
main decision thread: 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。当前 tiny scalar native call 与上述线程/锁模型下,释放 GIL 的 benchmark 没有显示收益;这只是该调用边界的实测结论,不是“live 系统一律不该释放 GIL”。callback thread、decision thread、日志和模型调用仍可能竞争 GIL/锁,是否释放必须连同 snapshot 一致性和 p99 soak 一起测。

早期的错误迁移方式长这样:

# 每 tick 都创建大对象,再跨 pybind 边界,再返回大 dict。
# C++ 只算了 quote 公式,Python 仍然负责 policy/routing。
ctx = asdict(live_quote_state)               # 当前固定 ABI 为 16 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,
    bid_adverse_markout_pause_latch,
    ask_adverse_markout_pause_latch,
    mo_ref,
    position_open, hold_time_s, unrealized_pnl,
)
pred_tuple = (dir_10s, vol_10s, ret_10s, tox_bid, tox_ask)

# result:先返回 compact result,只有旧代 BUY soft-keep scorer / offline 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 的动态对象税。

2.2.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 作缓存键,每次报价的 C++ config 参数零分配,config reload 时才同步一次。

第二层:每 tick 变化的 state(固定位置 tuple)

策略状态(当前 ABI 为 16 个字段:mid、inventory、sigma_sq、BBO、markout EMA、BUY/SELL adverse-markout pause latch 等)每次 requote 都不同,必须传入。用固定位置 tuple 代替 dict,C++ 端按下标读取,无 key hash、无对象构造。

第三层:按需物化的诊断(功能驱动的懒加载)

side policy 每 tick 只需 9 个字段;完整的 100+ 字段 context 只在旧代 BUY soft-keep scorer 或离线 trace 时需要,由 require_full_context 控制。direct Quote-EV executor 已经删除,不是这个开关的现役 consumer。

这三层合在一起,让 compute_quote_core_live 从最初的负优化(慢 59%)翻转为稳定正收益(快 16.4%)——不靠改进 quote math,只靠把「每 tick 固定税」改成「功能需要时才支付」。

2.2.2 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++ compact quote core
        |
        v
Python `_build_side_policy()`
        |
        | fixed policy tuples
        v
C++ partial routing / price-size-action back half
        |
        | compact result: price, size, can_post, reason_mask
        v
Python order adapter
        |
        v
exchange REST/WebSocket

这样做的收益不是“C++ 数学快了多少”,而是减少边界上的动态对象。前面的 live benchmark 里也能看到这个现象:原始 routing 桥接大约是 6us 级别,当前 x86 artifact 中 compact ABI 约为 1.94us mean、3.10us p99;但如果只把 scalar quote core 单独搬过去,pybind/dataclass 往返会把收益吃掉,甚至变慢。

这也是为什么 WebSocket 目前不急着迁 C++。receive-time profile 已经证明 callback 排队值得治理,但没有证明“把 socket 库换成 C++”本身就是答案;两种全 Python dispatcher 已经实测失败,真正应先收紧的是 callback 后的 lock ownership、compact global-flow state,以及 receive-state 与下单 adapter 之间的 rolling state -> native quote -> Python policy -> native partial route decision。这是一条当前机器和代码下的优先级判断,不是关于所有 WebSocket 实现的一般定律。

2.2.3 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 中位延迟通常已经是几十毫秒,本文实测的 new/cancel 尾部还可进入百毫秒以上,比 C++ 函数的收益大多个数量级;强行把 REST 放进 C++ 只会让错误更难 debug,而不会自然消除网络与交易所尾部。

2.2.4 最小且可预测的热指令工作集

HFT 代码短小,不是为了追求源码上的简洁,而是为了控制 CPU 前端真正看见的机器码。这里采用的标准是:让一次高频事件实际触达的指令工作集最小、稳定且分支可预测,而不是让整个扩展模块都塞进 L1I。

这一区别很重要。在 2026-06-22 对应 commit、AppleClang Release + pybind ThinLTO 诊断构建中,narrowgate_cpp__text 是 471,364 bytes,仍然大于本机性能核 192 KiB、能效核 128 KiB 的 L1I;但其中包括 pybind 注册、Python 类型转换、异常处理、批量研究接口和 trace binding,它们不会在一次 live requote 中全部执行。随后 extension 又加入 native global-flow 等代码,因此 471,364 bytes 只是一张历史 build snapshot,不是 2026-07-19 当前二进制大小。该轮 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

源码审计也需要给模板设预算。当前 queue 热循环只把低基数的 Side 固定到编译期;旧 QueueAheadMode 与 through-level 实例已经删除。rolling moments 已收敛为普通运行时容量类型,容器 view 也共享非模板计算主体:

template <Side S>
double l2_visible_queue_ahead(...);

BUY/SELL 只有两个 side 实例;CountRollingMoments 用运行时 capacity 复用同一份机器码,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 总大小,也不是源码有多少行。

2.2.5 Live 脏数据与异常:离线可以删,live 只能降级

离线数据坏了可以物理删除,live 不行。live 遇到乱序 tick、WebSocket 静默、盘口过期、bad trade、用户流漏成交时,策略必须降级,而不是继续假装状态健康。

当前 NarrowGate 的 live 防御层大概是:

异常 当前处理
bad trade parse / 非法价格数量 计数并限频日志,不进入 bar state
aggTrade 静默 watchdog 重连已文档化的 @aggTrade;不切换到未声明的 USD-M futures 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 数和关键 shadow counter

伪代码是:

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

早期版本还曾在这里写 sell_resiliency_shadow.csv denominator log。该 direct live/shadow producer 与 HEALTH counters 已删除;当前只有历史 audit reader 能解释旧 CSV 字段,不能把旧文件名写成现役 telemetry 契约。新的 denominator 统一进入 order-level / action-level evidence panel。

这仍然不是完整高可用交易网关。它没有 kernel bypass、没有独立 order gateway 进程、没有完整 SPSC ring 隔离,也没有多机热备。当前目标是:在研究、影子和小规模在线观察里,遇到脏数据时优先撤单、降级、报警、重连,而不是继续用坏状态报价。

本节实操结论

  • 参数映射要从 gamma/kappa/sigma -> spread/reservation/tick 讲清楚,再谈 ML 是否有用。
  • queue ahead 只在“可见 queue ahead 与 cancellation-ahead 假设”这一维度偏悲观;隐藏流动性、聚合 trades、sequence gap、前后方撤单归属和 receive ordering 仍可能让整体误差双向偏移。应报告 assumption sensitivity,不能把整个 execution simulator 简称为保守。
  • shadow mode 必须有样本量、fill calibration、markout calibration 和库存风险硬 gate。
  • Python/C++ 胶水层的关键是一次传连续数组或固定 tuple,不在每 tick 传动态对象。
  • fixed-array signal 当前是渐进式迁移:稳定热点进 C++,实验性特征保留更灵活的路径。
  • live 脏数据不能删除,只能通过 stale block、watchdog reconnect、SYNC_ADJUST degrade 和 telemetry 降级。
  • live 操作必须走统一脚本,例如 live/run.sh start|stop|restart|status|logs|reload;不要裸 nohup python live/main.py,否则很容易绕过 .env,导致 API key/secret 没进进程环境。

2.2.6 live 路径不该做的事

这套设计也确定了 live 路径不迁移的边界:

  • WebSocket 层:当前不把 socket client 整体迁到 C++;per-source/shared Python dispatcher 都因 p99 回归而关闭。frame-level trade batching、lock ownership 收口与 native fixed-array global-flow state 已完成;继续优化也不增加 Python worker
  • LightGBM 推理:模型本身已 native(LightGBM C API),Python 只做 feature → array 转换
  • 大块 Python 编排逻辑:config reload、日志、HEALTH 上报、REST 重连和当前 order/action evidence 输出——这些不在 CPU 热点,放 Python 更容易验证和修改;已删除的 sell_resiliency_shadow.csv producer 不属于当前契约
  • GIL 释放:当前 tiny scalar native call 和现有 callback/main-thread 模型下未显示收益;更大 native 区域仍需结合锁、snapshot 一致性和长样本 p99 重新测

live 路径的结论和 offline 路径相同,但结论的来路完全不同:offline 关心吞吐,live 关心尾延迟;quote decision 通常是 scalar,交易所一个 frame 内的多笔 external trades 则可以小批量处理;GIL 是否释放必须按具体调用边界验证。把 offline 的大窗口优化模式直接复制到 live,往往得不到同样收益。

本节工程结论

  • live hot path 不应该先重写 WebSocket/REST 库;先收紧 callback、背压、rolling state、quote/policy/routing 边界。
  • 单个公式迁到 C++ 很容易被 pybind、dict、dataclass 和日志对象物化反噬;稳定、高频、低基数的决策包才适合 native。
  • 热路径目标是最小且可预测的指令工作集,而不是让整个 .so 塞进 L1I。
  • ARM 回测吞吐、offline sweep 加速、x86 live p99/p99.9 是三种不同证据,不能互相替代。
  • live shadow counter 和 CSV 打点是“明天能复盘”的安全边界,不是 C++ 热路径优化目标;它们应该清楚、稳定、可 grep,而不是极限快。

2.2.7 系统侧: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 与 receive-time global-flow ring;
  • hot/cold order 拆分;
  • summary-only replay 输出;
  • x86 live 上的 p99/p99.9 soak benchmark。

但 live 线程模型还不是 HFT 终局。当前架构更接近:

Binance WebSocket client callback threads
        |
        v
Python WSHandler: decode + receive timestamp
        |
        v
SignalEngine with lock-protected update/snapshot
        |
        v
main decision loop: engine.tick() / quote / Python side policy
        |
        v
synchronous REST order adapter (default baseline)

因此 callback 可能与 decision thread 竞争 GIL 或 SignalEngine lock,但不会在同一个 Python 调用栈里串行执行。默认 REST 会阻塞 main decision loop;旧 async gateway 已在失败 soak 后物理删除。这个线程边界也是为什么“某个 pybind call 释放 GIL 无收益”不能外推成整个 live 系统的结论。

这和更硬核的低延迟架构不同:

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 当前选择的是前者,并且已经把最热的 replay、signal、quote/routing 边界逐步 native 化;kernel bypass、SPSC ring、NUMA partition、专用 order gateway 和多机热备则属于下一层系统工程。

这不影响当前 C++ 迁移的价值,因为当前 C++ 主要服务两个目标:

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

这个边界很重要:当前项目不是专用交易网关,也不是把所有网络和订单路径都 native 化的极限 HFT stack;它是一个把 maker 研究、tick replay、order-level evidence 和 live hot path 逐步接起来的低延时工程框架。

2.3 下一阶段:策略证据和系统执行分开推进

NarrowGate 后续工作需要分成两条轨道。它们会互相约束,但不能互相替代:策略侧回答“什么状态值得接单”,系统侧回答“这套状态机能否稳定、及时、可审计地执行”。

策略性质工作已经越过“先建一张 order table”的阶段。2026-07-18 causal-v4 action panel 否证了固定 widen/re-center/skip;2026-07-25 又把正式数据面迁到 normalized_l2_100ms_v2,修复分侧 individual trades,并重建 causal-v5、order-level、lifecycle、null 与 spread-fill 证据。causal-v5 曾按用户指定进入 operational live,但后续 causal-v9 在 corrected Test3 上 raw、terminal 与 tail 同时失败;截至 7 月 28 日 live 曾回到 ML-OFF。8 月 3 日 operational control 已滚动到 causal-v12 semantics-v6,q90 与 BUY fill-selection action 同时关闭、shadow 同时保留。固定全局参数研究族也已正式关闭:固定数值仍可作为 baseline、校准或安全边界,但不再通过扩大 grid 寻找 pooled winner。

当前下一阶段也不再是继续扫固定 tick/秒数,而是:

  1. formal 100ms local state:只有 rebuilt / sequence_valid / warmup_valid / formal_eligible 全部通过的日期,才能进入 queue、hazard、spread-fill 与 cancel 研究;旧混合 L2 仅保留历史复现身份。
  2. 先分解执行几何:固定 spread 实验分别估计 action-specific activation、pre-request fill、pending-cancel fill 与 request-conditioned ACK;P(fill | active touch) 不是纯 queue conversion,不能把一个全局指数 κ 当作所有 side、role 和 queue 状态的真实成交曲线。
  3. state-conditioned queue value:最新 ordered common-support prediction family 已在 Development 关闭;Value 与 Action identity 均未创建。未来 keep/cancel/re-entry 仍须冻结 eligibility、propensity、reward、queue/P3/latency identity,并完整模拟 cancel ACK 前 fill 与 queue reset。
  4. multi-venue 只作 moderator:receive-time global flow 与 stablecoin bridge 当前继续 shadow;没有 local M0 action uplift 前,不把跨市场相关性直接翻译成报价动作。

系统性质工作继续围绕真实 x86 live soak 与同步 order lifecycle:

  1. run.sh 继续显式加载并打印 quote core、signal features、live routing、strict mode 与 extension path,重启后由 telemetry native-hit rate 再校验。
  2. normal order adapter 保持同步。旧 latest-wins gateway 已由约 194 分钟 soak 否证并于 2026-07-17 物理删除;未来若重做异步执行,必须作为新的 bounded queue / backpressure 实验,不复活旧 flag、ABI 或 telemetry。
  3. 多行情 callback 不再增加 Python worker。下一步继续压缩 lock 内对象物化、使用 frame-level arrays/native global-flow,并在 2 vCPU/4 GiB 目标机分别报告 feed age、receive-to-feature、signal、quote、REST 与完整 requote 的 p50/p99/p99.9。
  4. event-L2 提升的是离线研究分辨率,不应被误写成 live socket 已经迁到 C++。live WebSocket 优化仍须以 freshness、drop/overflow、GIL/lock contention 和长 soak 为证据。
  5. 公开仓库只暴露模板配置、sample workflow、CI 和 no-data examples;私有 live 参数、基础设施地址、实盘结果和 paid-data manifest 不进入 public docs。

本节边界结论

  • 策略侧的进展必须过 data quality -> mechanism -> fill selection -> OOS bucket -> daily stability -> campaign outcome risk。
  • 系统侧的进展必须过 x86 live soak -> action mix -> REST tail -> stream freshness -> safety path。
  • 两条线最终会在 spread、skew、TTL、cooldown 和 order lifecycle 上汇合,但验证顺序必须分开。
  • 如果某个策略证据没过 daily/campaign gate,C++ 不能把它“加速成可用”;如果某个系统优化降低 p99,也不能自动说明 alpha 更好。

2026-07-15:事件时钟 parity 与 queue calibration 必须分开

该轮 formal repair 把旧 tick replay 的 trade-only clock 改成了 trade、BBO/L2 与 timer 合并事件队列。ML 特征也只能在 bucket 完成后的 feature_ready_ts 被消费,并通过 7 日因果 warmup 保持长周期特征与 live 分布一致。这个改动使 TTL、cooldown、requote 和 book state 不再等待下一笔 execution trade 才推进。所有旧 ML、多行情和受旧时钟影响的精确 PnL 数值因此降级为历史诊断,不能与新结果直接拼接。

修复前的逐日 Python/C++ fill/PnL 对拍数值和固定 queue 倍率排名已经删除。它们能暴露 parity 与 calibration 是两个问题,却不能在旧 replay identity 下继续作为当前误差预算。当前仍成立的机制边界是:同一 frozen queue identity 才能做双引擎 parity;固定 queue-ahead 倍率不能识别跨日、side/regime 条件化的 cancellation-ahead 与 book-refresh 状态。

因此 queue artifact 现升级为 v3,并把 replay multipliers 纳入严格身份;正式输出记录 artifact path、SHA256、schema、fit days 和参数来源。后续 C++ sweep 只能比较相同 calibration identity 下的 paired delta,绝对 PnL 必须同时给出 queue sensitivity。在 queue 跨日 gate 通过前,7-8x 加速提高的是研究吞吐,不是结论置信度。

2026-07-17:旧代 BUY soft-keep scorer ABI v3 与完整 golden 收口

事件时钟修复之后,C++ 仍缺少 2026-07-06 旧代 BUY soft-keep live/Python scorer 使用的 causal Prediction.feature_dict。只把 quote distance、depth、inventory 等动态字段传入 native,会让同一模型在 C++ 中系统性降低 score,属于模型输入漂移,不是模型差异。

当前 adapter 会在每个 feature-ready row 上预编译各 fold 的静态 logit contribution、missing count 与 used count;C++ ABI v3 在 quote decision 时再合并动态字段,执行相同的 contribution shrink、fold average、missing gate 与 actionable hard gate。旧 ABI 或不完整静态 payload 会 fail fast,不能静默把“能加载模型”冒充成“模型输入一致”。

同一轮还修复了 final_cap_compress_rate 的 post-policy 计数差异,以及旧 shallow-depth policy multiplier。2026-07-17 对应代码快照的本地 suite 为 355 passed, 4 skipped,四个 real-data golden 全部通过;该数量不代表后续 working tree 的滚动测试总数。这里的“通过”只说明该快照的 active common-policy 与 BUY scorer surface 可以由 C++ formal replay 执行;P3、queue、latency artifact 仍必须绑定自己的数据和版本身份,不能由 golden 代替校准。

2026-07-18:causal-v4、event-L2 与下一代 queue-value

causal-v4 把前一日的时钟修复冻结成一套可审计 identity:完成后的 10 秒 feature bucket 只能在 bucket_end 可见;volatility 使用 (USDC/BTC)^2 / second;replay 使用 merged event clock;empirical P3、queue 与 REST-latency artifact 都必须显式绑定。122 日 order-level denominator 含 2,219,633 个 placed orders 与 70,650 个 fills。新 13-head bundle 和 causal-v4 non_toxic / beats_opportunity 两个 rebuilt BUY scorer 虽有局部排序能力,但 campaign outcome 与 tail gate 不支持 promotion,因此都保持 shadow-only;它们没有替换 2026-07-06 的旧代 BUY soft-keep rolling-baseline probe,live baseline 不变。

同一 frozen identity 上的固定 local actions 也没有晋级。BUY widen、SELL re-center / prevent-over-widen,以及 short inventory 中 skip 一次 SELL add,都没有同时通过 chronological reward、campaign/downside、support 与 interval gate;sealed holdout 未读取。这一结果关闭的是固定 tick/秒数 family,不是所有 state-conditioned action。

研究分辨率也从旧的约 1Hz BBO/L2 容器推进到 retained event-L2。新路径使用 Binance individual trades、CryptoHFTData price-level snapshot/delta 与 fixed timer events,并保持 state_timestamp <= decision_timestampsnapshotdelta-converged + burn-in 是两个不同的数据身份:后者可用于验证后的 top-20 shock/refill/recovery feature,不能冒充深档 exact queue truth。正式 event-L2 日必须满足 24/24 raw hours、500ms freshness 下至少 99% coverage、完整 top-20 schema、正 spread、有效 anchor,以及零 sequence gap/invalid/time reversal。

因此下一代实验被收敛为预注册的 local shock/refill/recovery eligibility 与 queue_value_keep_cancel_v1。Python 先生成 authoritative randomized replay、propensity 与 chronological OPE;只有 action 定义冻结、support/LCB/campaign/tail gate 通过后,才把相同 contract 迁进 C++ parity 和 fast screening。event-L2 解决的是 10-100ms 状态分辨率,不解决 hidden liquidity、深档真实排位,也不自动证明 keep/cancel 有正 uplift。

2026-07-25:normalized-100ms、formal parity 与 causal-v5 历史 baseline

7 月 18 日的 event-L2 还是一条正在收口的数据路径;7 月 25 日,它被固定为唯一版本化的 formal normalized 身份。normalized_l2_100ms_v2 从 CryptoHFTData 原生 snapshot/delta 重建,不原地覆盖旧 bbo/l2/;每个 UTC 日都附带 rebuilt、sequence、warmup 与 formal eligibility。当前 128 个 rebuilt days 中有 62 日通过 formal normalized gate、53 日具备有效前日上下文。旧约 1 秒与 100ms 混合面板仍可解释历史实验,但不能再被 formal runner 默认 glob。

这一轮还修复了 2026-07-04 至 07-11 的 futures individual-trade maker-side 标志,并重新生成 causal feature、13-head 模型、order-level denominator、campaign lifecycle、null 与 BUY scorer。因果时间、P3、queue、latency、model tree 和 config 都写入独立 manifest/hash;缺 feature、错误 schema 或旧 L2 identity 会 fail fast,而不是补零继续跑。

双引擎随后使用同一 fresh-start、fixed/merged event clock 和固定随机延迟路径完成 formal 对拍。代表日的 ML-OFF、ML-ON 以及 executable passive null 中,Python/C++ 的 fills 完全一致,PnL 误差约为浮点舍入量级;该 checkpoint 的全仓测试快照为 717 passed, 4 skipped。这轮修复覆盖 activation-time queue rank、pause/reducing precedence、Post-Only activation、cancel ACK/fill race、IOC book semantics 与 tick rounding。它证明的是同一模拟契约,不是公开行情能恢复真实交易所内部 queue priority。

causal-v5 的严格 A/B 如下:

Panel Raw delta Terminal delta Fills retention Inventory time
Validation20 +3.56 -0.23 91.46% +271.2 BTC-s
Test17 +2.14 +0.47 92.64% +131.4 BTC-s

raw 与 terminal interval 都跨零,因此研究 strict gate 没有通过。随后按用户明确指令,该 bundle 通过 SIGHUP 热加载进入 live,并成为新的 operational baseline;部署身份标为 user_directed_trial,旧 causal-v3 只作 rollback。文章故意保留这个区分:能由两套引擎一致执行被指定上线观察已证明稳定正 uplift 是三件不同的事。

这段只描述 7 月 25 日的历史状态。7 月 27 日 causal-v9 使用 133 个 good days 和修复后的 taker-tempo lineage 重训后,在三个 formal test days 上 raw 全部为负,terminal 更差且 tail 增加,因此没有晋级或部署。当时 operational baseline 恢复为 ML-OFF,P3 artifact 不变;causal-v5 不再具有现役 live 权限。8 月 3 日的 current pointer 已进一步滚动到 causal-v12 v7 identity,不能用这一段还原当前开关。

同一 formal scheduler 的第一版 fixed-spread 实验后来暴露出一个更重要的工程问题:25 个距离分别运行在独立策略世界,fill 会反馈到库存和后续 activation;scalar matcher 又把 strictly-through trade 当成 exact-price quantity consumption。它制造了错误的 0→1 tick lifecycle 反转,因此旧 queue-discontinuity、local-kappa 和 lookup 结论全部撤回。

替代的 paired_fixed_spread_monotonic_v2 仍只写 6,400 行日度 sufficient statistics,但每个 side/decision 在同一次 C++ replay 中生成全部 25 个距离,共享 ACK-time book、new/cancel latency、TTL 和未来行情路径,且 counterfactual fill 不改变下一次决策。exact-price trade 按数量与冻结 queue calibration 消耗 queue;strictly-through trade 强制 full fill。引擎对 filled/full-filled/quantity 以及 1s/5s/10s/lifecycle 指标逐订单 fail fast:任何“深价成交、浅价未成交”都会终止实验。

128 日 descriptive 与 62 日 formal 最终均为零单调性违例。formal lifecycle fill 的 0→1 tick 变化是 BUY 51.08%→48.66%、SELL 50.18%→48.96%fill | touch 仍会上升到约 99%,但它是 deeper-through 条件样本选择,不是无条件 fill edge。80 tick 后 queue fallback 约 79%,100 tick 约 93%,140 tick 后超过 99%,因此远端曲线仍不能冒充 native deep-book truth,也没有接入 live。这个修正比单纯缩短 runtime 更能说明 C++ replay 的价值:高吞吐只有和共享反事实路径、路径级 invariant 与可观测边界同时存在,才会产生可信证据。

paired v2 仍不是训练面板:它只有 side x distance x day 汇总,固定 initial_inventory=0,也没有 current/-1 tick/+1 tick 的逐 decision activation、queue、partial-fill、cancel request/ACK 与 censoring 路径。下一步先用 native snapshot/delta 建一个单日流式 smoke,随后分开拟合 placement surface 与 active-order KEEP surface;REPLACE/cancel-re-enter 会重置 queue,必须留在独立 lifecycle action 实验中。只有 prediction gate 通过后,才登记带已知 propensity 的 action_execution_v1,不会从这张聚合曲线直接修改 live。

2026-07-26 的单日 native-deep smoke 已经把这条接口真正接通。Python baseline pass 保留真实 inventory role、campaign-so-far 和 side-decision state;独立 sidecar 为每个 place/replace 生成 closer/current/farther 三个 child,并按 exchange-time snapshot/delta 与 individual trades 做 k-way merge。1,000 个 cohort 中,三档 fills 为 42/40/39,exact-queue fills 为 4/6/10,through fills 为 38/34/29,路径单调性违例为 0。request→ACK 期间的 fill、partial fill、action-specific GTX 和 native queue 失效原因都进入宽表。

这仍只是 mechanics smoke:shadow fill 不反馈库存,不能生成 campaign counterfactual;截至该 7 月 26 日身份,BUY q90 cancel/re-entry 没有 replay-equivalent state machine,因此作为独立 treatment 被哈希并排除。后来的 ABI v4 已实现 terminal risk-set 与 fresh prospective recovery,但 action 仍因 40 日 lockstep/transport 未通过而关闭。单 action 只有 39—42 fills,加上磁盘 reserve 限制,当时没有训练 surface、读取 Validation/holdout 或创建 action。换句话说,新的 C++/Python 边界现在能生产正确的训练行,但统计证据还没到可以训练策略的程度。

2026-07-28:Policy clock、common-support 合同与仓库物理边界

placement fill 研究随后证明,cancel ACK 不能被当成从订单激活时刻开始的平稳自然 hazard。它应被拆成确定性的 baseline cancel request、request-to-ACK latency,以及 ACK 前仍可能 partial/full fill 的 pending race。request-state v2 因此让 pre-request fill 与条件 ACK 曲线在 Development 获得支持,但 pending fill 极稀疏,不能靠增加复杂 head 补足证据。

最新 ordered_common_support_fill_surface_v1 又把 closer/current/farther 放进共享的 distance-ordered hazard。它的 action-specific pre-request curves 为 18/18 pass,保守 transport probability bound 也是 18/18 pass;但 activation calibration 只有 11/18、all-three support calibration 只有 9/18,pending economic uncertainty 为 0/36 pass。此外正式 evaluator 将 hazard 积分到 action-specific realized exposure,产生 1,042 行 apparent monotonicity violation,涉及 399 个 cohort,三 action 时钟 max-minus-min 中位差 3,713ms。这是 common-clock implementation-contract failure,不是 LightGBM distance constraint 失效。

所以该 family 在 Development 关闭,Validation/holdout 保持未读,Prediction、Transport、Value、Action 与 Live 权限全部为 false;仓库中也没有创建 placement_action_value_surface_v1placement_quote_action_uplift_v1。未来 identity 必须由冻结 Spec 绑定 cohort-common、ex-ante、非 outcome-derived 的 scheduled clock,并对缺任一 counterfactual action 的 cohort fail fast。

同一天,仓库按研究所有权物理拆成 10 个策略/证据 family、1 条系统工程线和 D/R/S/G 四层共享基础设施,不保留旧路径 symlink。运行时目录也进一步收口:data/ 只存离线下载、导入与规范化代码,真实 payload 位于 ${NARROWGATE_DATA_ROOT};Binance 执行市场的 REST snapshot + diff-depth 本地簿迁入 live/orderbook/;我方活动订单的 queue/path 状态继续由 execution/ 持有。这样仓库不再同时出现含义模糊的 data/market_data/ Python package。

对应冻结边界见 causal-v9 replayordered common-support resultcontract errata

2026-08-03:双时钟生命周期、Feature DAG 与连续回放底座

7 月 28 日之后,工程工作的重心从“再加一个模型或 action”转向了三个更底层的合同:策略到底看见了哪一时刻的数据、订单何时真正离开风险集、跨日回放怎样保留经济状态。

在 2026-08-03 这个历史 checkpoint,live/backtest control 由不可变 v7 identity 和一个可变 current pointer 共同解析;下表记录的是当日状态,不是 2026-08-12 的现役开关:

组件 当前状态 工程含义
causal-v12 semantics-v6 ML ON,13 heads,173 features/head 完成 10 秒桶后刷新,桶间持有
BUY fill-selection shadow ON,action OFF 继续记 scorer,不再改变报价权限
BUY q90 shadow ON,action OFF 继续观测 100ms active-order score,不撤单/重入
empirical P3 10 秒 touch calibration 仍是 touch,不冒充 queue-aware fill probability
runtime authority startup、restart、preflight、SIGHUP 共用 未授权 action 默认 fail-fast,owner override 必须留痕

这里最重要的变化不是某个布尔开关,而是 shadow 与 action 已经拆成两套权限。模型能加载、scorer 能计算、shadow 有日志,都不再暗示 quote permission 已经开启。

行情侧也需要把“实时”拆成明确的市场数据身份:

市场输入 live 粒度 能做什么 不能做什么
Binance BTCUSDC depth20@100ms 买卖各 20 档 partial snapshot,100ms 报价、microprice、top-N state 不能恢复每个订单身份
Binance BTCUSDC bookTicker BBO 价或量变化即推送 低延迟 best bid/ask 与 MTM 没有第二档以后深度
Binance BTCUSDC aggTrade 同一 taker order 聚合,USD-M 最快 100ms 推送 live flow 与聚合成交方向 不是 historical individual trade
Binance deep book REST 1000 档 snapshot + diff-depth 100ms active-order exact-level path 与 q90 shadow 仍是 price-level MBP,不是 MBO
Bitget / Bybit / OKX spot+perp books1 / orderbook.1 / bbo-tbt 与 public trades AWS receive-time external shadow 只有 L1/BBO,且不进入当前 quote action
user data stream 我方 order/fill/account event 自有订单生命周期与仓位同步 看不到其他参与者的逐笔委托

因此历史 replay 可以用 Binance Vision individual trades 更细地消耗可见 queue,但 live 策略面对的成交源仍是 aggTrade,全市场盘口仍只有价位聚合量。任何 queue-ahead 结果都应写成 在冻结 MBP、trade 与 latency 合同下的估计,不能包装成交易所真实逐单排位。

Feature DAG 则把此前散落在函数调用顺序里的时钟边界显式化。当前不是动态 graph executor,而是静态实现加机器可读 contract。已登记两张图:

live_10s_signal_cutoff.v1
    finalized 1s bars
      -> strict cutoff-exclusive completed 10s bucket
      -> 13-head feature vector
      -> sample-and-held prediction

buy_q90_visibility_lifecycle_path_score.v3
    exchange book + exchange order events
      -> causal visibility
      -> remaining-quantity lifecycle
      -> E_q^exchange / E_q^visible
      -> active-order depth path
      -> q90 score

第二张图修复了一个会直接污染 action-rate transport 的问题:exchange-terminal 订单不能继续伪装成 PENDING_CANCEL 接受 hazard evaluation。当前共享 lifecycle 显式区分 SUBMITTED / ACTIVE / PARTIALLY_FILLED / CANCEL_PENDING / EXCHANGE_TERMINAL / POST_CANCEL_RECOVERY / REENTRY_ELIGIBLE;只有三种 exchange-live 状态累积 fill risk。partial fill、cancel reject、ACK 前成交与 remaining quantity 都保留,缺失或倒退的 exchange timestamp 会让物理 $E_q$ 变成 null,而不是借 visibility clock 补值。

fresh prospective recovery 也不再复用旧订单:cancel ACK 且剩余数量大于零时,才以当前价格、age=0、当前因果可见簿、queue-at-tail 和当前 GTX support 评估一张新候选单。绑定旧 v5 配置的冻结 v1.6 一日 ABI-v4 smoke 曾做到 Python/C++ transitions 零 mismatch;但 61 次 recovery evaluation 没有一个满足有效 transport 条件。当前 v7 config hash 与继续开发后的 backtest_tick.py、bindings/native module hash 已偏离该冻结身份,因此旧 v1.6 不能直接复跑或冒充 v7 parity;需要新 successor/linkage 后才能继续 40 日 run。这项修复仍是 historical local baseline-integrity evidence,不是 q90 action 恢复依据。

最后,跨日研究不再把“连续 replay”理解成把坏日硬拼起来。新的 versioned substrate 有三种不同权限:native strict 只在原生 sequence 合格段声明 exact lifecycle;restart-aware 把数据缺口冻结为计划停机,停机前必须完成 cancel terminal,期间不报单但持仓继续 MTM;Tardis provider-normalized 只做预测/source sensitivity,永远不获得 native queue 权限。UTC midnight 仍可作为 bootstrap cluster,却不再意味着平仓、清库存或结束经济 campaign。

这套 substrate 与三层 cache DAG 都比 F03/F05/F09/F10 更底层,但它们只统一 replay 语义,不统一各 family 的 action、reward 或 promotion gate。当前 full tick-runner binding 仍然 fail-closed;在第一条权威连续路径跑出来前,不能拿单元测试通过冒充新的连续 PnL baseline。

相关当前合同见 Feature DAGcontinuous replay substratereplay cache DAG governanceoperational baseline v7

2026-08-12:benchmark、compatibility baseline 与 strict-native 证据分层

bench/ 目录确实主要用于测量 C++ 路径的性能,但“跑到了 C++”不等于“跑到了正式回测”。bench_quote_core.pybench_tick_replay.py 和 live-routing benchmark 测的是绑定成本、吞吐、尾延迟或回归稳定性;正式 Python→C++ tick replay 只允许通过 simulate_tick_arrays_ext_policy_v3 进入,并继续由 Python 负责数据身份、因果时钟、参数合同和 fail-fast 校验。较低层的 simulate_tick_arrays 即使更快,也只是 benchmark/test surface。

2026-08-11 起,现役 runtime 已切到新的 AWS Tokyo epoch,并在 Python 3.12 环境重新构建 x86 extension;此前旧 AWS 与短暂 Vultr epoch 都已退役,常规历史查询走本地归档。本文前面的 7 月 x86 benchmark、REST/WebSocket latency、dispatcher 与 global-flow soak 可以继续作为绑定原 provider/host/runtime 的历史工程证据或敏感度,但不能因为新主机同在 Tokyo 且 CPU/内存规格相近,就改名为当前新 AWS 的实测延迟。

当前 50 日 control 也需要按这个边界重读。它完整复现了 40 日前缀并新增 10 日,但实际执行身份是规范化 top-20/100ms L2 的 C++ daily-fresh-start diagnostic,native raw snapshot/delta queue scheduler、订单/撤单延迟和 execution-book receive-time visibility 均未启用。

Panel Terminal MTM Closed-campaign value Fills
Immutable first 40 days -144.251748 USDC -147.466348 USDC 17,118
Added 10 days -21.314331 USDC -21.064631 USDC 3,029
Pooled 50 days -165.566079 USDC -168.530979 USDC 20,147

这些数字是当前 common-simulator control 的权威诊断分母,却不是 strict-native order-path PnL,更不是连续 live 账本。daily_fresh_start 会在每个 UTC 日重启进程态,公开 top-20/100ms L2 也没有驱动 native queue lookup;所以任何 cooldown、replace、cancel/re-entry 或 fill-selection 候选若要解释订单路径增量,必须在两臂共同升级到更严格的执行身份。

对应的 strict-native + sampled-latency successor 已完成 2026-06-29 一日机制验证:消费 5,086,247 个 native events,完成 19,460 次 queue lookup,其中 exact 9,797、known-zero 9,663、missing 0,并应用 14,825 次 visibility delay。该日 fills 从 compatibility 路径的 489 变为 921,terminal MTM 从 -7.878888 变为 -7.132700 USDC;这证明 raw queue 与 latency 输入真实改变路径,但只证明机制已接通,不能据此推断完整 50 日修正的符号或幅度。

更严格也不意味着可以越过数据不可识别性。后续 F05 strict-native cooldown 标签执行发现,历史 public trade 只有毫秒时间,而 raw book 含同毫秒内更细事件;trade、book、activation 与 ACK 跨流没有共同序号时,哪个事件先可见无法从源数据恢复。正式路径因此保留歧义并 fail closed,没有用任意 tie-break 伪造 exact queue 标签。这是 source identifiability 边界,不是把一个 C++ 排序规则写得更复杂就能消失的问题。

即便 strict native queue 可识别,它仍只重建公开 exchange-time MBP 与我方反事实订单:看不到交易所私有订单 ID/FIFO、hidden liquidity、真实 private ACK/user-stream receive clock、live 多线程调度、完整进程内 warm state 和市场对我方订单的反馈。工程文章里的“parity”因此必须总是带限定词:Python/C++ mechanics parity、historical strict queue evidence、empirical transport sensitivity 与 exact live reproduction 是四种不同主张。

当前证据见 50 日 compatibility baselineexecution-scope amendmentstrict-native 一日机制结果C++ 当前权威边界

结论:低延时最终服务报价决策

NarrowGate 的工程迁移不是从“我要写一个高速交易系统”开始的,而是从 maker 研究本身的压力长出来的:一旦需要真实 L2、queue ahead、latency、TTL、guard、inventory-time、quote EV labels 和多日多臂 A/B,Python orchestration 仍然合适,但 hot loop 和状态机必须收紧边界。

这篇工程复盘里,最重要的结论不是某个 benchmark 数字,而是几条工程判断:

  1. 离线引擎优化的价值,是把严格 replay、quote context、order-level evidence 和多日反例验证变成日常流程,而不是偶尔抽样;
  2. x86 live 优化的价值,是让同一套 spread、skew、TTL、cooldown 和库存控制在真实 REST/WebSocket 尾部下仍能按预期执行;
  3. Python 仍然适合数据清洗、训练编排、报告和实验组织;
  4. C++ 适合长期驻留的状态、高频低基数分支、完整窗口 replay 和稳定的 live hot path 小决策包;
  5. pybind 边界必须按数组、固定 tuple 或 compact context 设计,不能每 tick 传大 dict/dataclass;
  6. parity 比速度更重要,没有路径级和金额级一致性,sweep 越快越危险;
  7. 当前 live 架构使用可持久化 native profile、shadow/perf telemetry 与同步 normal-order adapter;历史 per-side latest-wins gateway 已完成 soak,并因 p99/p99.9 无稳定改善、coalesce 几乎未形成而于 2026-07-17 物理删除。下一步是继续收紧状态 snapshot、WebSocket callback/lock ownership、REST/order lifecycle 与真实延迟校准,而不是复活旧开关或继续增加 Python worker。
  8. 包名也必须表达生命周期:离线 ETL 在 data/,live 公共簿在 live/orderbook/,我方订单状态在 execution/,大型行情文件只存在于仓库外数据根。

所以 C++ 在 NarrowGate 里的定位不是外置加速器,而是 maker 策略工程化的一部分:离线侧让严格 replay、反例搜索、null baseline、order-level evidence 和参数 racing 变得可负担;live 侧把稳定、高频、低基数的状态和决策边界收紧到更少动态对象、更少无意义 replace、更可控的尾延迟。低延时本身不是 alpha 结论,但它决定了一个 maker alpha 能否被真实验证、稳定执行,并在风险变坏时及时撤退。

截至 2026-08-12,13-head causal-v12 仍是 owner-promoted operational/backtest control,research prediction/live authority 仍为 false;q90 保持 shadow ON/action OFF,旧 BUY fill-selection 已连 shadow 一并关闭。50 日 compatibility control 已形成稳定诊断分母,但它明确禁用 raw native queue、latency 与 live transport;strict-native successor 只完成一日机制验证,而 F05 的正式 strict 标签又在同毫秒跨流顺序处正确 fail closed。这正是工程系统应有的边界:生产代码可以继续变快、变清楚,owner 可以做可回滚的运营选择,但 benchmark、单日 mechanics、诊断 PnL 和模型可加载都不能替未通过的 Prediction、Value、Action 与 Live 证据生成权限。

面向开源版本,C++ 还有另一层价值:它迫使项目把边界说清楚。哪些路径有 Python/C++ parity,哪些只能 fast screening,哪些仍必须由 Python replay 做正式证据,都应该在 public README、CI 和 examples 里体现出来。否则一个看起来很快的 extension 只会让陌生人更快踩进旧结论。


本文及 NarrowGate 项目仅用于 C++ 低延时系统、市场微观结构、价格行为学、做市模型和机器学习回测方法的学习交流与技术研究,不构成财务、投资、法律或合规建议。中国大陆关于 crypto 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。


NarrowGate:回测吞吐与 Live 尾延迟工程
https://xiao-nanbei.github.io/2026/07/01/NarrowGate-Cpp-Low-Latency-Market-Making/
作者
Xuan Tan
发布于
2026年7月1日
许可协议