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

TL;DR:读者收益摘要

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

离线侧的低延时,本质是把严格 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 的炫技,它是策略工程化后必须面对的执行条件。

本文只讨论工程实现,不讨论也不建议任何真实交易行为。所有 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。

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

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
  - queue / latency / TTL / policy execution

Python wrapper
  - 加载数组和模型产物
  - 调用 C++ batch/replay
  - 写 trace、label 和 A/B summary

C++ 目录由 pybind11 暴露给 Python:

cpp/
  CMakeLists.txt
  pyproject.toml
  narrowgate_cpp/
    bindings.cpp
    common.hpp
    quote_core.hpp / quote_core.cpp
    tick_replay.hpp / tick_replay.cpp
    streaming_features.hpp / streaming_features.cpp

tests/
  test_cpp_quote_core_parity.py
  test_cpp_tick_replay_parity.py
  test_cpp_signal_features.py

bench/
  bench_live_path.py
  bench_quote_core.py
  bench_tick_replay.py

bindings.cpp 只处理边界,业务逻辑留在普通 C++ 类型和函数里。这样 quote core 和 replay 可以脱离 Python binding 单独测试,也更容易发现“是策略差异,还是对象转换差异”。

构建层使用 scikit-build-core + CMake + pybind11。extension 由当前虚拟环境的 CPython ABI 编译成 .so,并不是在运行时解释 C++:

# cpp/pyproject.toml
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"
# cpp/CMakeLists.txt
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(Python COMPONENTS Interpreter Development.Module REQUIRED)
find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(narrowgate_cpp
    narrowgate_cpp/bindings.cpp
    narrowgate_cpp/quote_core.cpp
    narrowgate_cpp/tick_replay.cpp
    narrowgate_cpp/streaming_features.cpp
)
install(TARGETS narrowgate_cpp LIBRARY DESTINATION .)
python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e cpp
.venv/bin/python -c \
  'import narrowgate_cpp; print(narrowgate_cpp.__file__)'

最后一行很重要。BTCUSDC 和 BTCUSDT 两个仓库都暴露名为 narrowgate_cpp 的模块,共用一个环境时可能导入另一份 build。loader 因此检查模块路径和 editable-install 的 direct_url.json 是否指向当前 repo,也允许用 NARROWGATE_CPP_EXPECT_MODULE_TOKEN 显式约束;NARROWGATE_CPP_STRICT=1 下来源不符会直接失败,不允许悄悄 fallback 到 Python。

本节工程结论

  • 不把 Python binding 和业务逻辑揉在一起:bindings.cpp 只做边界,quote/replay/signal core 用普通 C++ 类型实现。
  • C++ core 必须可以脱离 pybind 单测;否则无法区分策略差异、对象转换差异和 ABI 差异。
  • NARROWGATE_CPP_STRICT=1 是开发期默认心态:模块来源、字段缺失、shape 错误必须 fail fast。
  • 同名 extension 在多个 repo 之间很容易碰撞,import path 和 editable install 来源必须进入 sanity check。
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 只在窗口边界检查 dtype、shape、contiguous;
  • std::span / view 只在函数调用期间借用 Python-owned memory;
  • C++ 不把这些指针存到函数返回之后;
  • trace、diagnostics、DataFrame 构造回到 Python 冷路径。

伪代码可以理解为:

trades = load_trades_as_numpy(day)
l2 = load_l2_as_numpy(day)
params = build_cpp_params(config)

result = narrowgate_cpp.simulate_tick_arrays(
    trades.ts_ms,
    trades.price,
    trades.qty,
    trades.is_buyer_maker,
    params,
    bbo_ts_ms=bbo.ts_ms,
    l2_bid_px=l2.bid_px_matrix,
    l2_bid_qty=l2.bid_qty_matrix,
    l2_ask_px=l2.ask_px_matrix,
    l2_ask_qty=l2.ask_qty_matrix,
)

summary = result.summary
trace = result.quote_trace if trace_enabled else None

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

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

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

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++ 结果
quote + policy 85.48 us 136.33 us C++ 慢约 59%
_compute_quotes 54.93 us 105.07 us C++ 慢约 91%

这张表怎么读

  • 说明什么:单独把 quote 公式搬到 C++,在 pybind/dataclass/dict 往返很重时会负优化。
  • 不能说明什么:不能说明 C++ quote core 算法本身慢,也不能说明所有 native path 都不值得做。
  • 下一步验证:拆掉每 tick 全量物化后,重新测 compact ABI,并同时跑 quote-field parity。

profile 后发现,时间并没有花在 quote_core.cpp 的公式里,而是花在边界两端:每 tick 都要重新创建并复制约 70 个 config 字段、14 个 state 字段、5 个 prediction 字段和整本 depth levels;C++ 返回以后,Python 又把 native result 展开成两个完整 quote_context、quote flags、diagnostics 和 dataclass。一次报价所需的数学运算很少,对象搬运反而成了主任务。

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

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

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

也就是说,完整 context 不是被删除,而是从“每 tick 固定税”变成“功能真正需要时才支付的成本”。

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", "mo_ref",
    "position_open", "hold_time_s", "unrealized_pnl",
)

def _cached_cpp_config(cpp, cfg):
    global _CPP_CFG_CACHE_KEY, _CPP_CFG_CACHE_REF, _CPP_CFG_CACHE_VALUE
    cached = _CPP_CFG_CACHE_REF() if _CPP_CFG_CACHE_REF else None
    if _CPP_CFG_CACHE_KEY == id(cfg) and cached is cfg:
        return _CPP_CFG_CACHE_VALUE
    native = _copy_attrs(cfg, cpp.QuoteCoreConfig(), _CPP_CFG_FIELDS)
    _CPP_CFG_CACHE_KEY = id(cfg)
    _CPP_CFG_CACHE_REF = weakref.ref(cfg)
    _CPP_CFG_CACHE_VALUE = native
    return native

result = cpp.compute_quote_core_live(
    tuple(getattr(state, name) for name in _CPP_STATE_FIELDS),
    _cached_cpp_config(cpp, cfg),
    (pred_dir, pred_vol, pred_ret, tox_bid, tox_ask),
    depth.bids if depth else (),
    depth.asks if depth else (),
)

C++ binding 则对 tuple 做长度检查并填充栈上的普通结构体,depth 不再逐档创建 DepthLevel Python wrapper:

m.def("compute_quote_core_live",
    [](py::sequence state_values,
       const QuoteCoreConfig& cfg,
       py::sequence pred_values,
       py::handle bids,
       py::handle asks) {
        if (py::len(state_values) != 14 || py::len(pred_values) != 5)
            throw std::invalid_argument("live quote state/pred length mismatch");

        QuoteState s;
        s.mid             = py::cast<double>(state_values[0]);
        s.inventory       = py::cast<double>(state_values[1]);
        s.sigma_sq        = py::cast<double>(state_values[2]);
        s.trade_intensity = py::cast<double>(state_values[3]);
        s.best_bid        = py::cast<double>(state_values[4]);
        s.best_ask        = py::cast<double>(state_values[5]);
        // ...其余状态按固定位置读取

        QuotePrediction p;
        p.dir_10s = py::cast<double>(pred_values[0]);
        p.vol_10s = py::cast<double>(pred_values[1]);
        p.ret_10s = py::cast<double>(pred_values[2]);
        p.tox_bid = py::cast<double>(pred_values[3]);
        p.tox_ask = py::cast<double>(pred_values[4]);

        return compute_quote_core(
            s, cfg, p, depth_from_python_levels(bids, asks));
    });
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(...)       # quote EV 需要完整 feature context

这个方案的关键不是少返回几个字段,而是明确了三种生命周期:几乎不变的 config 被缓存,每 tick 变化的 state 用紧凑序列,只有模型训练、trace 或调试需要的长 diagnostics 才完整物化。可观测性仍在,只是不再无条件出现在最热路径。

固定环境复测使用 Python 3.12.13、AppleClang Release build、ML 关闭以隔离 quote/feature CPU,--n 10000 --signal-n 1000,每项跑三次取 mean 的中位数:

路径 Python compact C++ 变化
live _compute_quotes 42.85 us 34.53 us 快 19.4%
quote + policy 74.55 us 62.32 us 快 16.4%

这张表怎么读

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

这不是数量级加速,但它完成了更重要的翻转:scalar native path 终于不再因为 Python 对象往返而负优化。它仍保持显式 opt-in,quote EV 开启后的完整 context 路径也不能套用这组数字。

离线 quote decomposition、shock audit 和 quote EV label 仍使用 depth-aware batch binding。batch 场景一次传连续数组,跨语言成本被大量样本摊薄,依然比 scalar 更适合 C++。book_imbnear_depth_total、kappa/depth、adverse/defense 等字段继续纳入 parity 审计;性能优化不能把 C++ 悄悄变成另一套策略。

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

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

C++ 版本逐项迁入所有机制:

  • historical BBO/L2;
  • exact-level queue ahead;
  • new/cancel latency;
  • maker fill gate;
  • inventory 与 inventory-time summary;
  • spread cap、adverse/defense guard;
  • quote EV policy;
  • local extreme、fragile/adaptive TTL/cooldown;
  • position timeout/emergency path;
  • 完整 trace 和 summary 字段。
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> bbo_ts_ms;
    ArrayView<double> bbo_best_bid;
    ArrayView<double> bbo_best_ask;

    ArrayView<std::int64_t> l2_ts_ms;
    MatrixView<double> l2_bid_px;
    MatrixView<double> l2_bid_qty;
    MatrixView<double> l2_ask_px;
    MatrixView<double> l2_ask_qty;

    ArrayView<std::int64_t> quote_ev_ts_ms;
    ArrayView<double> bid_quote_ev_30s;
    ArrayView<double> ask_quote_ev_30s;
};

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

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

1.4.3.2 queue policy 用模板固定 side 与模式

bid/ask 和 exact-level/through-level queue 的分支在循环内非常频繁。这里用模板把 side 和 queue mode 固定到编译期:

template <Side S, QueueAheadMode Mode>
double visible_queue_ahead(
    ArrayView<double> prices,
    ArrayView<double> quantities,
    double quote_price,
    double tolerance) {

    double visible = 0.0;
    for (std::size_t i = 0; i < prices.size(); ++i) {
        bool include = false;
        if constexpr (Mode == QueueAheadMode::ExactLevel) {
            include = std::abs(prices.data()[i] - quote_price) <= tolerance;
        } else if constexpr (is_buy_v<S>) {
            include = prices.data()[i] >= quote_price;
        } else {
            include = prices.data()[i] <= quote_price;
        }
        if (include) visible += std::max(0.0, quantities.data()[i]);
    }
    return visible;
}

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

struct ReplayOrder {
    Side side;
    double price, quantity, remaining;
    double queue_left, queue_init;
    std::int64_t quote_ts, activate_ts, cancel_effective_ts;
    std::int64_t ttl_ms;
    OrderState state;
    bool side_adverse, defense_guard, local_extreme_guard, quote_ev;

    // 默认不分配;只有打开 trace 时才指向冷诊断对象。
    TraceOrderPtr trace;
};

order.activate_ts = ts + sampled_new_latency_ms;
order.state = sampled_new_latency_ms > 0 ? ORDER_PENDING_NEW : ORDER_OPEN;

if (order.ttl_ms > 0 && ts - order.quote_ts >= order.ttl_ms) {
    order.state = ORDER_PENDING_CANCEL;
    order.cancel_effective_ts = ts + sampled_cancel_latency_ms;
}

PENDING_CANCEL 期间仍允许成交,因此 trace 会单独统计 pending_cancel_fills。这类语义如果只保留“请求撤单后立即删除订单”的简化实现,回测会系统性低估撤单延迟风险。

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++。

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

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. 快速跑 baseline、bid、ask、both 四个 key arms;
  4. 淘汰明显差的 arm;
  5. 最终候选仍回到 Python online inference 全量确认。

本机接电后的 wall-time 记录是:多日 arrays 预计算约 57.84s,C++ replay 跑四臂约 7.84s

研究链路不是一句“打开 C++”就结束,而是把昂贵步骤和快速步骤拆开。下面是实际命令形态(路径缩短为变量):

RESULTS=~/MarketData/NarrowGate_BTCUSDC/backtest_results_btcusdc
MODEL=models/saved_btcusdc_quote_ev_xmarket_cpp_context_20260618

# 1. baseline context 下只做一次 online inference,写成按时间对齐的 arrays
.venv/bin/python models/precompute_quote_ev_arrays.py \
  --symbol BTCUSDC \
  --days 2026-05-15 2026-05-16 \
  --model-dir "$MODEL" \
  --orders "$RESULTS/cross_market_shock_audit.orders_features.parquet" \
  --tag cpp_fast_screen

# 2. 四个 policy arm 复用同一份窗口 cache 与 EV arrays
NARROWGATE_CPP_STRICT=1 .venv/bin/python models/quote_ev_ab_tick.py \
  --symbol BTCUSDC \
  --days 2026-05-15 2026-05-16 \
  --arms baseline bid_ev_soft_widen ask_ev_soft_widen both_ev_soft_widen \
  --engine cpp \
  --cpp-quote-ev-data "$RESULTS/quote_ev_arrays_cpp_fast_screen_btcusdc.parquet" \
  --window-cache-dir /tmp/narrowgate_tick_window_cache \
  --workers 1

数组必须按 quote_ev_ts_ms 与 replay 时钟做 backward/as-of 对齐,并在 C++ 入口验证所有 bid/ask EV 列长度一致。NARROWGATE_CPP_STRICT=1 则保证模块不可用、字段缺失或 shape 错误时直接终止,而不是改走 Python 后仍输出一个看似成功的 A/B 文件。

这个数字不能被误读成“完整研究只需八秒”。EV score 被冻结在 baseline context;某个 arm 改变报价后,成交路径、库存和后续 context 都会漂移,而预计算数组不会重算。它适合 screening,不适合 promotion。

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

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

固定时间窗的基础统计用模板表达。每次 push 只追加新值并从 deque 左侧淘汰过期值,mean/stddev 直接使用累计的一阶、二阶矩,不再扫描整个窗口:

template <int WindowSeconds>
class RollingStats {
public:
    void push(std::int64_t ts_ms, double value) {
        values_.push_back({ts_ms, value});
        sum_ += value;
        sum_sq_ += value * value;
        expire(ts_ms);
    }

    void expire(std::int64_t now_ms) {
        const auto cutoff = now_ms - WindowSeconds * 1000LL;
        while (!values_.empty() && values_.front().first <= cutoff) {
            const double old = values_.front().second;
            sum_ -= old;
            sum_sq_ -= old * old;
            values_.pop_front();
        }
    }

    double mean() const {
        return values_.empty() ? 0.0 : sum_ / values_.size();
    }

    double variance() const {
        if (values_.size() < 2) return 0.0;
        const double mu = mean();
        return std::max(0.0, sum_sq_ / values_.size() - mu * mu);
    }

private:
    std::deque<std::pair<std::int64_t, double>> values_;
    double sum_ = 0.0;
    double sum_sq_ = 0.0;
};

RollingStats<3>  tick_sign_3s;
RollingStats<10> tick_sign_10s;
RollingStats<60> log_ret_60s;

更高层的 engine 只暴露增量接口:

class SignalFeatureEngine {
public:
    SignalFeatureEngine(std::size_t max_bars = 320,
                        std::size_t max_history = 60480);
    void push_bar(const Bar1s& bar);
    void push_history(const FeatureHistoryRow& row);
    [[nodiscard]] SignalFeatureVector compute(
        const Bar1s& bar_10s) const;

private:
    CircularBuffer<Bar1s> bars_;
    CircularBuffer<FeatureHistoryRow> history_;
};

这里的返回值也不再是热路径上的 std::map<std::string, double>。80 个稳定字段在 C++ 内部使用 enum class SignalFeatureId 定位,值存进 std::array<double, 80>;只有跨过 pybind 边界返回 Python 时,才按 compile-time 字段表物化一次 dict。这样同时去掉了红黑树节点分配、字符串比较和字段拼写漂移。

Python 只在 warmup/reconnect 后 seed 一次,正常运行时逐根追加:

def _ensure_cpp_feature_engine(self):
    if not self._cpp_feature_engine_seeded:
        for bar in list(self._bar_buffer)[-320:]:
            self._cpp_feature_engine.push_bar(self._bar_to_cpp(bar))
        for row in self._feat_history:
            self._cpp_feature_engine.push_history(self._history_to_cpp(row))
        self._cpp_feature_engine_seeded = True
    return self._cpp_feature_engine

def _finalize_bar(self, bar):
    self._bar_buffer.append(bar)
    self._ensure_cpp_feature_engine().push_bar(self._bar_to_cpp(bar))

overlay = self._ensure_cpp_feature_engine().compute(cpp_bar_10s)

这里故意没有把所有 cross-market book/trade state 一次迁完。当前 C++ overlay 负责稳定、重复的 rolling 数值部分,Python 继续补 execution L2、cross-market、time 和 metrics features。这样的分层可以逐列做 parity;若一口气迁完整 82-feature pipeline,任何时间对齐差异都会很难定位。

Python 负责事件和研究编排,C++ 负责持续演化的数值状态。这个边界也更接近未来真正值得优化的“计算/决策层”,而不是去重写 WebSocket 和 IO。

同一组固定环境三轮中位数中,完整“喂入十秒事件并计算 features”的路径从 Python 803.20 us 降到 C++ persistent state 563.26 us,快约 29.9%。单独的 1s ingest 会从 22.42 us 增到约 25.44 us,但十秒整体仍有净收益,因为昂贵的窗口重拷贝被移除了。

另一个开关 SIGNAL_STATE=1 只把单条 trade aggregation 搬进 C++,Python 仍负责 event dict 解析、锁、回调和 bar 对象同步。它的 ingest 中位数是 23.44 us,比 Python 22.42 us 慢约 4.5%,因此继续保持关闭。这里的结论很朴素:只有状态和足够长的计算链一起迁移才有意义,单独迁四则运算没有。

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

template <int WindowSeconds>
class RollingStats {
    void push(ts_ms, value) {
        values.push_back({ts_ms, value});
        sum += value;
        sum_sq += value * value;
        expire(ts_ms);
    }
    double mean() const;
    double stddev() const;
};

class SignalFeatureEngine {
    CircularBuffer<Bar1s> bars_;
    CircularBuffer<FeatureHistoryRow> history_;
    CountRollingMoments return_abs_2160_;
    CountRollingMoments return_abs_8640_;
    CountRollingMoments vol_regime_6h_60480_;
};

但这里也要诚实:当前并不是所有特征都已经做到完全 O(1) 增量。已经增量化的是一部分长窗口 rolling moments 和 trade/bar state;仍有一些 overlay 特征会扫描短窗口或使用 scratch buffer。原因是这些特征还处在研究变化阶段,过早把所有实验性 feature 都写成复杂 C++ 状态机,会让 parity 和调参变得更难。

当前工程取舍是:

  • 高频、稳定、低基数的窗口统计逐步进 C++;
  • 特征顺序固定,用 array 输出,减少 dict/string 热路径;
  • 实验性特征先保留 Python 或短窗口扫描;
  • 只有当某个特征进入稳定模型并被 profile 证明是热点,再改成长期持有的 rolling state。

这和前面 C++ 的原则一致:C++ 不是为了“看起来硬核”,而是为了把稳定边界收紧。

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

这张表怎么读

  • 说明什么:迁移优先级来自计算形态,不来自文件名或语言偏好。
  • 不能说明什么:不能说明 WebSocket、REST、日志这些冷路径没有工程价值,只是它们不是当前 CPU 热点。
  • 下一步验证:对候选迁移点先做 flame/profile,再设计最小 native API,而不是照搬 Python 对象模型。

这张表也解释了为什么 WebSocket 层没有成为 C++ 迁移重点。网络等待、JSON 解码和 Python callback 不是当前最大的可控 CPU 成本;真正值得迁的是 WebSocket 上方的 rolling state、quote/policy bundle 和订单状态机。把 IO 也改写,只会扩大维护面,却不一定缩短决策尾延迟。

当前相关 quote、signal、tick replay 快速测试共 20 passed;显式开启的四个真实窗口 golden tests 也全部通过。这些 benchmark 是同机 synthetic CPU isolation,关闭 ML 以分离 feature/quote 成本;它们不代表网络延迟、模型推理或完整事件引擎的端到端耗时,也不能直接决定 live 默认配置。

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%,短窗口本来就不是搬移瓶颈
quote + policy 63.09 us 63.18 us 统计上持平,现代化没有伤害 scalar path
60,480 history 满载 push(纯 C++ operation microbench) 80.05 us 0.0026 us 去掉 erase(begin) 后由 O(N) 变 O(1)
60,480 history full snapshot - 111.23 us 常驻 ring + 定长输出下的完整计算

这张表怎么读

  • 说明什么:C++20 现代化主要减少数据搬移和生命周期错误,短路径不一定会显著变快。
  • 不能说明什么:0.0026 us 不是 live 总延迟,也不是策略收益,只是一个容器操作微基准。
  • 下一步验证:继续看真实窗口 golden parity、长 history p99,以及目标 Linux CPU 上的 PMU 或 wall-time soak。

第三行尤其不能直接解读成 live 总延迟快了三万倍:它只隔离测量“满载后保留尾部”的容器操作。真实路径还包含 Python event、pybind、模型和路由。它说明的是旧容器在长时间运行后存在确定的线性成本,而不是给系统吞吐量制造一个夸张数字。

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

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, quote_ev;
    TraceOrderRow* trace = nullptr;
};

只有 trace_quotes_maxtrace_fills_max 大于零时,才从 arena-backed unsynchronized_pool_resource 分配 cold row。hot order 用带 PMR deleter 的 RAII pointer;订单退出后 slot 回到 pool,不会像纯 monotonic allocation 一样随长窗口持续累积。输出 trace 仍复制到 owning result vector,pool pointer 不跨函数生命周期。

fill trace 的时间窗也改成 lower_bound/upper_bound 定位,不再从第零条成交开始扫;1s/5s/30s markout 同样用 lower_bound 找 horizon。trace 默认关闭,这项优化主要服务大规模 quote decomposition。

summary-only 不是简单丢掉曲线

PnL curves 原先还承担 Python 侧 Sharpe 和 max drawdown,所以不能直接禁用。C++ replay 现在每次 requote 在线维护 peak、drawdown、PnL delta、时间增量以及标准化 delta 的一、二阶矩:

const double normalized = pnl_delta / std::sqrt(dt_s);
pnl_delta_sum += pnl_delta;
pnl_dt_sum += dt_s;
normalized_sum += normalized;
normalized_sq_sum += normalized * normalized;
peak_pnl = std::max(peak_pnl, current_pnl);
max_drawdown = std::max(max_drawdown, peak_pnl - current_pnl);

单次 API 的 collect_curves=true 保持兼容;C++ sweep、cap A/B、统一 tick_ab.py 和 quote-EV A/B 自动使用 summary-only。2M trades、200k requotes 的 synthetic replay 中,CPU 只从 42.18ms 降到 41.77ms,约 1%;真正收益是三条 200k vector 变成零行,也无需再转成 NumPy。

长周期 moments 增量化,但保持数值稳定

长 history 的主要扫描来自 2,160/8,640 条 return_abs 均值和最多 60,480 条 vol_regime_6h z-score。它们现在由 fixed-count rolling moments 在 push_history() 时更新。

简单维护 sum_sq - sum²/n 在低方差序列上会严重消差;长历史 parity test 正好抓到了这个问题。因此实现使用支持删除最旧值的 Welford mean/M2 更新,而不是放宽测试:

static void remove_value(double x, std::size_t n,
                         double& mean, double& m2) noexcept {
    const double next_mean = (n * mean - x) / (n - 1);
    m2 = std::max(0.0, m2 - (x - mean) * (x - next_mean));
    mean = next_mean;
}

SignalFeatureEngine.compute_values() 返回固定顺序的 NumPy array;SIGNAL_FEATURE_NAMES 只在模块初始化时创建一次。旧 dict API 仍兼容。满 60,480 history 时,fixed-array snapshot 从第一轮约 111.23us 降到 1.90us,兼容 dict 为 4.42us。短历史 live 整体仍受 Python feature merge/model 路径影响,没有据此切换 live 默认配置。

只给逐行独立 batch 加线程

单个 replay 的订单、库存、现金和 latency 状态强顺序依赖,仍保持单线程。depth quote batch 的每一行独立,才新增显式 workers 参数;默认 1,每 worker 至少 4,096 行:

std::vector<std::jthread> threads;
for (std::size_t worker = 0; worker < worker_count; ++worker) {
    threads.emplace_back(run_rows, begin[worker], end[worker]);
}

100k rows × 10 levels 的 Release benchmark:1 worker 为 35.48ms / 2.82M rows/s,4 workers 为 10.08ms / 9.92M rows/s,快 3.52x。参数已经贯通到 cross_market_shock_audit.py --quote-context-workers N,但不会自动开启:若外层已经按 day/window/arm 多进程并行,再把每个进程开满内部线程只会过度订阅。

第二轮验证结果为快速 suite 20 passed, 4 skipped,四个真实窗口 golden parity 4 passed,核心 C++20 翻译单元仍为零 warning。优化改变了 allocation、cache footprint 和输出策略,没有改变成交路径,也没有修改 live 配置。

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

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

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

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

后来我真的拿了一台 x86 EC2 机器做了这件事:Amazon Linux 2023、Intel Xeon Platinum 8259CL、2 vCPU(一个物理核的两个 SMT 线程)、L1d/L1i 都是 32KiB,clocksource 是 tsc。这台机器一开始甚至没有 gcc/g++/cmake,所以第一步不是调参数,而是把 live benchmark 环境和训练环境拆开。

这里有个很现实的坑:如果在 Linux live 机器上直接 pip install -r requirements.txttorch 会默认拉一大串 CUDA wheels。它对这个 maker live path 没帮助,却会把 32G 根盘迅速塞满。我最后只装了 live benchmark 需要的轻量依赖:

python3.11 -m venv .venv
source .venv/bin/activate

pip install numpy pandas pyarrow lightgbm scikit-learn scipy \
  pyyaml requests binance-futures-connector websocket-client \
  pycryptodome zstandard pytest
pip install -e cpp

为了不再靠手敲命令,我加了两个脚本:

scripts/x86_live_env_audit.sh
scripts/x86_live_benchmark.sh
scripts/x86_live_soak.sh

第一个脚本记录 OS、CPU/cache、clocksource、sysctl、IRQ、Python/narrowgate_cpp import 和 NumPy backend。第二个脚本做短跑 smoke:Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。第三个脚本只跑候选组合长样本 soak,用来观察 p99.9。

export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1
export MALLOC_ARENA_MAX=1

然后依次跑 Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。

这次 x86 结果很有意思,因为它和 Apple Silicon 本机结论并不完全一致:

开关 signal 10s mean/p99 _compute_quotes mean/p99 quote+policy mean/p99 结论
Python baseline 3431 / 4159 us 152 / 201 us 240 / 305 us x86 baseline
NARROWGATE_CPP_QUOTE_CORE=1 3328 / 4233 us 121 / 158 us 210 / 270 us quote core 在 x86 上转正
NARROWGATE_CPP_SIGNAL_FEATURES=1 1973 / 2347 us 153 / 194 us 237 / 302 us signal features 在 x86 上明显有收益
QUOTE_CORE=1 + SIGNAL_FEATURES=1 1949 / 2199 us 120 / 160 us 209 / 269 us 当前 x86 live 候选组合

这张表怎么读

  • 说明什么:ARM 本机结论不能直接外推到 x86 live;目标机型上 quote core 和 signal features 的方向都要重新测。
  • 不能说明什么:短跑 smoke 不能代表长时间 p99.9,也不能代表网络、REST 和交易所响应。
  • 下一步验证:固定 BLAS/OMP 线程后跑 soak,并确认没有 CPU migration、page fault 或 fallback 尖刺。

短跑只能说明方向,所以我又在同一台 x86 benchmark 机型上跑了更长的 soak:baseline 和候选各 --n 100000 --signal-n 10000,routing compact ABI --n 200000。这次输出 p99.9:

路径 Python baseline mean/p99/p99.9 QUOTE_CORE + SIGNAL_FEATURES mean/p99/p99.9 观察
signal 10s features 9610 / 17558 / 40658 us 2167 / 4534 / 16115 us C++ fixed-array 显著压低长历史尾延迟
live _compute_quotes 172 / 236 / 4380 us 123 / 166 / 225 us C++ quote core 降低均值,也避开本轮 quote-only 尖刺
quote + policy 249 / 308 / 364 us 219 / 281 / 358 us 组合路径稳定小幅改善,policy 仍是 Python 主体
compact routing ABI n/a 1.17 / 1.24 / 2.03 us pybind tuple 固定税很低

这张表怎么读

  • 说明什么:x86 上最明显的 live CPU 收益来自 fixed-array signal features;quote core 更像稳定降低小路径尾部。
  • 不能说明什么:它不包含 Binance 网络延迟、REST new/cancel、交易所撮合和真实异常恢复。
  • 下一步验证:在真实 live 进程旁路 shadow 打点,比较开启/关闭 native 开关时的 HEALTH、requote、ORDER_UPDATE 和 p99.9。

后来我又在当前真正跑 live 的机器上补了这一步:ec2-user@52.194.209.205,Amazon Linux 2023,Intel Xeon Platinum 8175M,2 vCPU(1 core / 2 SMT threads),当前 venv、当前模型、当前配置。这里要把三类数字分开:synthetic benchmark、真实 live telemetry,以及研究机制的 parity 边界。

第一类仍是 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 机可以正常 import narrowgate_cpp。下面这张表先看 native path 的旁路 benchmark,它用来回答“这台 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 92.02 / 190.17 / 410.74 us 110.00 / 219.34 / 401.15 us ingest 路径不该因 native quote 变化而被当成收益来源
signal cached 21.19 / 37.32 / 303.32 us 19.93 / 34.63 / 61.52 us cached path 尾部更稳
signal 10s features 10794.80 / 69946.88 / 74770.13 us 5648.25 / 9373.07 / 38222.13 us C++ fixed-array 仍是最清楚的 CPU 收益
live _compute_quotes 218.16 / 406.64 / 1169.29 us 200.31 / 410.18 / 804.16 us 均值略好,p99 接近,p99.9 改善
side policy pair 107.56 / 192.31 / 219.10 us 119.71 / 236.15 / 466.99 us policy 主体仍在 Python,native quote 不解决这里
quote + policy 394.55 / 794.30 / 1407.25 us 410.39 / 705.13 / 1186.53 us 均值略慢,但尾部更好;不能单独据此上线

这张表和 ARM 本机结论不同:在当前 x86 live 机上,SIGNAL_FEATURES=1 已经不是早期那种负优化;它明显降低 10s feature 的均值和 p99。quote core 则更微妙:_compute_quotes 自身略有改善,但 quote + policy 由于 policy 仍在 Python,均值没有变快,只是尾部好一点。因此 native path 下一步若要进入 live shadow,也应该先从 fixed-array signal 和更完整的 quote/policy/routing bundle 评估,而不是单独宣布 quote core 已经赢。

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

旧基线里,真实 live 进程还没有打开 NARROWGATE_CPP_*cpp_routing_used=0。最新一段 1,802 条 requote 显示,尾部主要不在 quote math,而在 REST cancel/new 与同步 replace:

字段 p50 p99 p99.9 读法
requote_total_us 58573 949633 1378031 真实 live 尾部已到接近秒级,不能再只看 quote core 微基准
signal_compute_us 16352 265978 635208 signal path 有长尾,适合 fixed-array / stateful C++
compute_quotes_us 543 13165 133847 quote math 本身不是最大尾部
update_orders_us 39616 623740 1222660 订单更新段才是 p99 主因
rest_new_sum_us 13572 172629 362772 new order REST 往返可到百毫秒级
rest_cancel_sum_us 13324 219712 349000 cancel REST tail 同样明显
cpp_routing_used 0 0 0 native routing 未进入 live

这之后我把 live 进程环境切到:

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 门槛基本等于没设。后来改成加仓侧 20 tick、减仓侧 10 tick,并保留减仓方向更敏捷的 250ms interval。这个改动不是 alpha 参数,而是 order lifecycle coalescing:价格没有变到足够多时,不做 cancel+new;TTL、stale-data、pause 和库存安全撤单不被阻断。

重启后短窗口 smoke 里,cpp_routing_used=1 覆盖全部行,并开始出现 REPLACE_THROTTLE。跳过前 120s warmup / SYNC_ADJUST 降级后的小样本如下:

字段 旧基线 p99 C++ + replace coalescing smoke p99 读法
requote_total_us 949633 113343 p99 从近 1s 压到约 113ms,但样本仍短
signal_compute_us 265978 16076 SIGNAL_FEATURES=1 在当前 x86 live 上有效
compute_quotes_us 13165 2518 native quote/routing 已进入 live
update_orders_us 623740 98710 REST replace 降频后订单段尾部明显下降
rest_new_sum_us 172629 50638 仍是主要尾部之一
rest_cancel_sum_us 219712 22751 cancel tail 被压下,但需要更长 soak
cpp_routing_used 0 1 C++ routing 真实命中

这张表不能被解读成“live 延迟已经解决”。它只是说明两件事:第一,当前 x86 机上 C++ fixed-array / compact routing 不是理论收益,已经能进入真实 live;第二,p99 最大头寸仍在 order lifecycle 和 REST tail,而不是 AS quote 公式。下一阶段更有效的方向是 pending replace coalescing、异步 order manager、quote decision 与 REST submit 分离,以及在 1-2 小时 live soak 中重新看 p99/p99.9。

WebSocket freshness 旧基线整体正常但不是零风险:exec trade age p99 5.4s、bookTicker age p99 1.0s、depth age p99 2.6s。所以当前结论不是“C++ 已经解决 live 延迟”,而是更具体:C++ fixed-array 可以压计算路径,replace coalescing 可以减少 REST 尾部,但真正的 live 工程化还要继续拆 order lifecycle、quote context 物化、pending state 和偶发 routing 尖刺。

第三类是研究机制边界。当前新机制仍然必须用 Python replay 做最终证据,不能拿 C++ fast path 直接扫出 live 结论:side/regime queue calibrationxmarket_retreat_enabledfill_cooldown_apply_reducingfill_cooldown_reducing*、campaign state machine、以及 empirical live REST latency samples 都还没有 C++ replay parity。C++ 现在适合两件事:已 parity 基础路径的 fast screening,以及 quote context / replay 热循环的吞吐优化。下一步更值得做的是 campaign-level label 与 order-level score sanity,而不是急着扫参数或重训 quote EV。

这说明一件事:架构边界不是文档措辞,而是真的会改变工程结论。 在本机 ARM 上,早期 SIGNAL_FEATURES=1 曾经是负优化;经过 fixed-array/ring-state 改造后,在 x86 上它反而变成最明显的 live CPU 收益。相反,SIGNAL_STATE=1 在这台 x86 上没有额外收益。pin 到单个 vCPU 对均值帮助也不大,甚至可能放大 VM 上的尾部抖动。

perf stat 也给了一个现实提醒:这台 KVM 实例不暴露 cyclesinstructionsL1-icache-load-missesiTLB-load-misses 这些硬件事件,最后只能看到 context switches、cpu migrations 和 page faults。因此“x86 benchmark”也要继续细分:普通云主机能做 wall-time/tail-latency 复测,但真正要分析 L1I/iTLB/cache miss,可能需要裸金属、特定实例族或能暴露 PMU 的环境。

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(),整个决策链跑在同一个线程上:

WebSocket callback → aggTrade/depth → signal.on_agg_trade()
                                              ↓
main loop: tick() ──► _requote()
           │              ├── compute_signal()          # LightGBM predict
           │              ├── compute_quote_core_live() # C++ compact path
           │              └── _update_orders()
           │                      ├── _build_side_policy()
           │                      └── REST new/cancel
           └── [sleep rq_interval]

这里没有批量处理的机会:每次报价决策只有一组 state,一个 depth snapshot。释放 GIL 换并行也不适用——WebSocket 回调、quote 决策和 REST 调用都在同一线程中串行执行,GIL 切换反而是额外开销。

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

# 每 tick 都创建大对象,再跨 pybind 边界,再返回大 dict。
# C++ 只算了 quote 公式,Python 仍然负责 policy/routing。
ctx = asdict(live_quote_state)               # 14+ fields
cfg = asdict(live_quote_config)              # 70+ fields
depth = [asdict(level) for level in l2_book] # N levels

quote = narrowgate_cpp.compute_quote_core(ctx, cfg, depth)

bid_policy = build_policy_from_dict(quote["bid_context"])
ask_policy = build_policy_from_dict(quote["ask_context"])
orders = python_route_orders(quote, bid_policy, ask_policy)

这看起来“用了 C++”,但热路径仍然到处是 hash lookup、字符串 key、临时 dict/list、对象分配和 Python 属性访问。改造后的方向是把生命周期不同的数据拆开:

# config:几乎不变,缓存 native object
native_cfg = cached_cpp_config(cfg)

# state/pred:每 tick 改变,但字段位置固定
state_tuple = (
    mid, inventory, sigma_sq, trade_intensity,
    best_bid, best_ask, ber_active,
    mo_ema_all, mo_ema_bid, mo_ema_ask, mo_ref,
    position_open, hold_time_s, unrealized_pnl,
)
pred_tuple = (dir_10s, vol_10s, ret_10s, tox_bid, tox_ask)

# result:先返回 compact result,只有 quote EV/trace 才物化完整 context
quote = narrowgate_cpp.compute_quote_core_live(
    state_tuple, native_cfg, pred_tuple, depth_bids, depth_asks)

route = narrowgate_cpp.compute_live_routing_decision(
    routing_input_tuple,
    bid_policy_tuple,
    ask_policy_tuple,
)

这里的学习点很清楚:C++ 不是为了替代 Python 的事件编排,而是为了让高频决策包使用固定布局、固定字段、固定分支,减少每次 tick 的动态对象税。

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)

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

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

side policy 每 tick 只需 9 个字段;完整的 100+ 字段 context 只在 quote EV 或离线 trace 时才需要,由 require_full_context 控制。

这三层合在一起,让 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++ quote + side policy + routing bundle
        |
        | compact result: price, size, can_post, reason_mask
        v
Python order adapter
        |
        v
exchange REST/WebSocket

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

这也是为什么 WebSocket 不急着迁 C++。行情接收、日志、健康检查、REST adapter 都不是最值得先动的地方。真正应该收紧的是 WebSocket 上方、下单 adapter 下方的这一小段:rolling state -> quote -> policy -> route decision

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 调用本身的延迟在 10-50ms 量级,比 C++ 函数的收益大两个数量级;强行把 REST 放进 C++ 只会让错误更难 debug,而不会降低端到端延迟。

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

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

这一区别很重要。优化后 narrowgate_cpp__text 是 471,364 bytes,仍然大于本机性能核 192 KiB、能效核 128 KiB 的 L1I;但其中包括 pybind 注册、Python 类型转换、异常处理、批量研究接口和 trace binding,它们不会在一次 live requote 中全部执行。使用与发布扩展一致的 Release + pybind ThinLTO 诊断构建和 linker map 查看真正的函数尺寸,结果是:

native 函数 text 大小 热路径判断
compute_quote_core() 7,528 B live quote 主体紧凑,明显小于常见 32 KiB L1I
shared compute_signal_feature_vector() 9,184 B stateful/legacy 共用的唯一大计算主体
simulate_tick_arrays() 23,596 B replay 顺序状态机接近 32 KiB 级别,仍需连同被调用 helper 用 PMU 验证
legacy compute_signal_feature_overlay() wrapper 1,136 B 只构造 span view 并调用共享主体,不再复制大函数
depth batch pybind worker 23,668 B 离线批量入口,不属于 live scalar 热路径

诊断命令本身也应进入性能工程流程,而不是只看 benchmark 平均值:

BIN=$(.venv/bin/python -c \
  'import narrowgate_cpp; print(narrowgate_cpp.__file__)')

xcrun llvm-size -A "$BIN"
xcrun llvm-objdump -d --demangle --no-show-raw-insn "$BIN" \
  > /tmp/narrowgate.disasm

# 诊断构建额外生成 linker map:-Wl,-map,/tmp/narrowgate.map
# Linux 研究机再看 L1I/iTLB/branch miss 与 p99 是否同步恶化。
perf stat -r 20 \
  -e cycles,instructions,branches,branch-misses,\
L1-icache-loads,L1-icache-load-misses,\
iTLB-loads,iTLB-load-misses \
  ./bench_hot_path

源码审计也需要给模板设预算。当前模板维度主要是 SideQueueAheadMode、固定 rolling window 和容器 view:

template <Side S, QueueAheadMode Mode>
double visible_queue_ahead(...);

template <int WindowSeconds>
class RollingStats;

Side x QueueAheadMode 只有四种 queue 实例,RollingStats<60> 即使有多个对象也只有同一种类型;quote core 的大主体本身不是模板。这里没有 EnableAdverse x EnableDefense x EnableQuoteEV x ... 这样的布尔笛卡尔积,因此目前不存在明显的模板组合爆炸。side-specific helper 展开后,compute_quote_core() 总体仍只有约 7.4 KiB。

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

  1. routing 改为固定 22 字段 input tuple、两个 5 字段 policy tuple 和 11 字段 result tuple;binding 只负责位置解包,业务数学进入普通 LiveRoutingInput/Policy/Result C++ 函数。place/replace 时为了审计日志执行的 asdict() 仍保留,因为它不属于每次 routing 计算。
  2. compute_signal_feature_vector<Bars, History> 改成接收 SegmentedSpanView<Bar1s/FeatureHistoryRow> 的单一非模板主体。CircularBuffer 暴露 ring 的两段 span,legacy vector 暴露一段 span,无复制共享同一份机器码。相关大计算代码由 8,868 + 10,120 B 收到 9,184 + 1,136 B,减少 8,668 B;整个扩展 __text 从 478,392 降到 471,364 B。

代码变小没有换来性能回退。满 60,480 history 的 compute_values() 对照中,mean 1.901 → 1.748 us,p99 2.500 → 1.917 us。这说明 two-span 访问增加的一次边界判断,代价小于共享代码布局和编译器优化带来的收益;但这些仍是同机微基准,不能替代目标 Linux CPU 的 PMU 计数。

因此这里对模板元编程的约束是:只把真正高频、低基数的结构性分支固定到编译期;数值阈值和频繁实验的 policy 留在数据中;大段公共计算只保留一份非模板主体;trace、日志、异常和兼容 binding 保持冷路径。评估是否合格最终看的是固定/轮换模板实例实验中的 L1I MPKI、iTLB MPKI、branch miss 和 p99/p99.9,而不是 .text 总大小,也不是源码有多少行。

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

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

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

异常 当前处理
bad trade parse / 非法价格数量 计数并限频日志,不进入 bar state
aggTrade 静默 watchdog 切 raw @trade 或重连
execution/reference/spot stream 静默 market stream reconnect
execution depth 过期 cancel live quotes,停止 requote,原因 stale_hard
bookTicker / post-only crossing GTX reject 当作正常不可挂事件处理
user stream 漏 fill / REST sync 差异 SYNC_ADJUST hard degrade,暂停增加暴露的一侧并重连 user stream
stale local order 周期性清理 PENDING_NEW 卡死订单
C++ 扩展异常 strict 模式直接 fail;非 strict live 可 fallback/disable native path
SELL resiliency shadow/evidence 直接 live arm 已关闭;HEALTH 可输出 sellResilEval/sellResilHit/sellResilHitRate,独立写 sell_resiliency_shadow.csv
运行健康 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 sell_resiliency_candidate:
    write_shadow_row(
        hit=hit,
        ref_adv=ref_adv,
        spot_adv=spot_adv,
        local_rank=local_price_rank,
        flow_decel=flow_decel,
        near_depth=near_depth,
        refill_edge=refill_edge,
    )

if bad_trade:
    bad_trade_counter += 1
    return

这里的 sell_resiliency_shadow.csv 不是交易结论,而是一个很工程化的 denominator log。只看 quote_decisions.csv 里的某个原因标签,你只能知道触发了多少次;但不知道有多少次差一点触发、失败在 reference、spot、local rank、flow decay、depth、refill 还是 hard gate。独立 shadow log 把这些 quote-time 字段都记下来,第二天才能回答“这个窄 bucket 是真有选择力,还是只是偶尔撞上”。

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

本节实操结论

  • 参数映射要从 gamma/kappa/sigma -> spread/reservation/tick 讲清楚,再谈 ML 是否有用。
  • queue ahead 的可信度来自保守初始化、逐笔消耗和 live 校准,而不是声称知道真实 FIFO。
  • shadow mode 必须有样本量、fill calibration、markout calibration 和库存风险硬 gate。
  • Python/C++ 胶水层的关键是一次传连续数组或固定 tuple,不在每 tick 传动态对象。
  • fixed-array signal 当前是渐进式迁移:稳定热点进 C++,实验性特征保留更灵活的路径。
  • live 脏数据不能删除,只能通过 stale block、watchdog reconnect、SYNC_ADJUST degrade 和 telemetry 降级。
  • 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 层:网络延迟主导,C++ 写 WebSocket client 不缩短决策尾延迟,只增加维护面
  • LightGBM 推理:模型本身已 native(LightGBM C API),Python 只做 feature → array 转换
  • 大块 Python 编排逻辑:config reload、日志、HEALTH 上报、REST 重连、sell_resiliency_shadow.csv 这类可观测性输出——这些不在 CPU 热点,放 Python 更容易验证和修改
  • GIL 释放:live 主路径单线程,释放 GIL 无收益;WebSocket 回调在不同 thread 里,releasing GIL 反而引入竞争

live 路径的结论和 offline 路径相同,但结论的来路完全不同:offline 关心吞吐,live 关心尾延迟;offline 可以 batch,live 只能 scalar;offline 释放 GIL 有意义,live 中通常没有。把 offline 的优化模式复制到 live,往往只是更快地得到一个无收益的结果。

本节工程结论

  • live hot path 不应该先迁 WebSocket/REST,而应该先收紧 WebSocket 上方的 rolling state、quote/policy/routing 边界。
  • 单个公式迁到 C++ 很容易被 pybind、dict、dataclass 和日志对象物化反噬;稳定、高频、低基数的决策包才适合 native。
  • 热路径目标是最小且可预测的指令工作集,而不是让整个 .so 塞进 L1I。
  • ARM 回测吞吐、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;
  • hot/cold order 拆分;
  • summary-only replay 输出;
  • x86 live 上的 p99/p99.9 soak benchmark。

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

Binance WebSocket client callback threads
        |
        v
Python WSHandler
        |
        v
SignalEngine with Lock
        |
        v
main loop: engine.tick() every short sleep
        |
        v
REST order adapter

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

NIC / kernel bypass or tuned socket
        |
        v
pinned reactor thread
        |
        v
SPSC ring buffer / sequence barrier
        |
        v
pinned strategy thread: rolling state + quote + routing
        |
        v
order gateway thread

前者适合研究、shadow、审计和可维护性;后者才是面向极端尾延迟的 live 系统。NarrowGate 当前选择的是前者,并且已经把最热的 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 下一阶段真正值得做的事

继续推进时,下一阶段的重点不是再堆一个模型,而是补四个闭环:

  1. 动态 Lambda(delta) 闭环:用 live/shadow fill calibration,把 depth、toxicity、reference shock 映射到整条到达率曲线,而不是只做 widen/pause。
  2. 主动瘦身闭环:设计 BTCUSDT perp/spot hedge simulator,把 hedge cost、basis risk、taker fee 和 inventory tail loss 放进同一张账。
  3. 真实 multi-venue lead-lag 闭环:接入独立 venue 原始盘口/逐笔数据,验证 reference 信号是否早于本地可执行窗口。
  4. live threading 闭环:把 WebSocket decode、rolling state、quote decision 和 order gateway 分层,至少用 SPSC ring + pinned strategy thread 做一次 x86 p99/p99.9 对照。

本节边界结论

  • NarrowGate 已经把 maker 研究从 bar-level 假设推进到 order-level evidence、campaign risk 和 live telemetry。
  • 下一层挑战是尾部冲击中的主动瘦身、跨市场 lead-lag、真实 REST/order gateway 尾部,以及线程隔离。
  • 这些边界不是自我降格,而是策略工程化路线图:每补一个闭环,策略结论才多一层可执行性。

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

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

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

  1. ARM 本机低延时的工程收益主要体现在反例吞吐和 parity 成本,主指标是完整窗口耗时、rows/s、windows/hour、内存峰值和可重复性;
  2. x86 live 低延时的工程收益主要体现在报价时序和订单生命周期,主指标是 p99/p99.9、fallback、CPU migration、page fault、context switch 和长样本 soak;
  3. Python 仍然适合数据清洗、训练编排、报告和实验组织;
  4. C++ 适合长期驻留的状态、高频低基数分支、完整窗口 replay 和稳定的 live hot path 小决策包;
  5. pybind 边界必须按数组、固定 tuple 或 compact context 设计,不能每 tick 传大 dict/dataclass;
  6. parity 比速度更重要,没有路径级和金额级一致性,sweep 越快越危险;
  7. 当前 live 架构已经能做 native hot-path 原型、shadow telemetry 和 order lifecycle coalescing;下一层是专用 order gateway、线程隔离和更长时间的 p99/p99.9 soak。

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


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