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 一致反而会把工程带偏。真正重要的是三层断言:
- 路径级断言:fill 数、bid/ask split、最终库存、订单状态迁移、trace 行数必须一致或可解释;
- 金额级断言:PnL、InvAdj、inventory time、spread summary 用很小 tolerance 对齐;
- 边界级断言:NaN、缺盘口、长 gap、pending cancel fill、同 timestamp 多事件排序必须 fail fast 或有显式规则。
整个流程可以概括成这样:
Parquet/CSV/raw logs
|
v
Python loader: timezone, int64 timestamp, tick/lot rounding, bad segment mask
|
v
Normalized NumPy arrays
|
+--------------------+
| |
v v
Python replay C++ replay
reference strict engine
| |
+---------+----------+
v
summary + trace comparator伪代码大概是:
def assert_replay_parity(window, params):
arrays = load_window_as_arrays(
window,
timestamp_dtype="int64_ms",
price_dtype="float64",
drop_bad_quality_segments=True,
)
py = simulate_tick_python(arrays, params)
cpp = simulate_tick_cpp(arrays, params, strict=True)
# 路径级:这些不应该靠 tolerance 蒙混过关。
for key in [
"fills_total",
"fills_bid",
"fills_ask",
"final_inventory",
"n_requotes",
"trace_rows",
]:
assert py[key] == cpp[key], key
# 金额级:允许浮点末位误差,但不允许经济含义变化。
for key in [
"raw_pnl",
"inventory_adjusted_pnl",
"abs_inventory_time_s",
"avg_final_spread_bps",
]:
np.testing.assert_allclose(
py[key],
cpp[key],
rtol=1e-8,
atol=1e-8,
err_msg=key,
)这里踩过的坑很多,而且大多不是 C++ 语法问题:
- 时间戳必须是整数:不要在 hot loop 里用 float seconds。Binance、CryptoHFTData、内部日志可能混用毫秒、微秒和 ISO 字符串,先统一到
int64。 - as-of 边界必须一致:Python
searchsorted(..., side="right") - 1和 C++upper_bound - 1要写成同一条规则,否则刚好落在 book update timestamp 上的订单会错一格盘口。 - tick rounding 必须单点定义:
floor、ceil、round在买卖两侧含义不同,不能 Python 一套、C++ 一套。 - NaN 不能“顺手补 0”:spot/reference/orderbook 缺失时,应该进入 quality mask 或 segment reset,而不是让 C++ 默认值悄悄参与训练和回放。
- pending cancel fill 要显式建模:订单发出 cancel 后,在 cancel latency 到达前仍可能成交。Python 如果已经把它当撤单、C++ 还把它当 live order,fill path 会马上分叉。
- 模块来源要 strict:两个项目都叫
narrowgate_cpp时,同一个 venv 可能 import 到另一个 repo 的扩展。strict 模式下必须检查narrowgate_cpp.__file__、字段 shape 和 ABI version。
所以双引擎 parity 的本质不是“我把 Python 翻译成 C++ 了”,而是建立一套制度:每次 C++ 多迁一个状态机,都必须先在小真实窗口里通过路径级和金额级审讯,然后才允许进入 sweep/A/B。
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 已经变化了多少。
所以“不自欺欺人”的做法不是假装知道这些,而是把不可观测部分分成两类:
- 保守规则:默认排在可见 level 后面,不因为看见 level 变小就自动认为前面的人都撤了;
- 校准参数:用 live/order log 反推 queue model、new/cancel latency、fill gate,让回放的 placed orders/day、fills/day、side split 和 live 同数量级。
如果只用 Top-N L2,queue ahead 永远是估计值,而不是事实。因此 replay 结果要同时报告:
- placed orders/day;
- fills/day;
- fills / placed order;
- cap/guard/pause rate;
- active-trade-day fills;
- quality-day fills;
- full-calendar-day fills。
这样才能区分“成交概率模型太保守”和“策略根本没有挂出足够多订单”。前者是 queue/fill calibration,后者往往是 guard/pause/requote gating 的问题。
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 summaryC++ 目录由 pybind11 暴露给 Python:
cpp/
CMakeLists.txt
pyproject.toml
narrowgate_cpp/
bindings.cpp
common.hpp
quote_core.hpp / quote_core.cpp
tick_replay.hpp / tick_replay.cpp
streaming_features.hpp / streaming_features.cpp
tests/
test_cpp_quote_core_parity.py
test_cpp_tick_replay_parity.py
test_cpp_signal_features.py
bench/
bench_live_path.py
bench_quote_core.py
bench_tick_replay.pybindings.cpp 只处理边界,业务逻辑留在普通 C++ 类型和函数里。这样 quote core 和 replay 可以脱离 Python binding 单独测试,也更容易发现“是策略差异,还是对象转换差异”。
构建层使用 scikit-build-core + CMake + pybind11。extension 由当前虚拟环境的 CPython ABI 编译成 .so,并不是在运行时解释 C++:
# cpp/pyproject.toml
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.12"]
build-backend = "scikit_build_core.build"# cpp/CMakeLists.txt
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(Python COMPONENTS Interpreter Development.Module REQUIRED)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(narrowgate_cpp
narrowgate_cpp/bindings.cpp
narrowgate_cpp/quote_core.cpp
narrowgate_cpp/tick_replay.cpp
narrowgate_cpp/streaming_features.cpp
)
install(TARGETS narrowgate_cpp LIBRARY DESTINATION .)python3.12 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e cpp
.venv/bin/python -c \
'import narrowgate_cpp; print(narrowgate_cpp.__file__)'最后一行很重要。BTCUSDC 和 BTCUSDT 两个仓库都暴露名为 narrowgate_cpp 的模块,共用一个环境时可能导入另一份 build。loader 因此检查模块路径和 editable-install 的 direct_url.json 是否指向当前 repo,也允许用 NARROWGATE_CPP_EXPECT_MODULE_TOKEN 显式约束;NARROWGATE_CPP_STRICT=1 下来源不符会直接失败,不允许悄悄 fallback 到 Python。
本节工程结论
- 不把 Python binding 和业务逻辑揉在一起:
bindings.cpp只做边界,quote/replay/signal core 用普通 C++ 类型实现。- C++ core 必须可以脱离 pybind 单测;否则无法区分策略差异、对象转换差异和 ABI 差异。
NARROWGATE_CPP_STRICT=1是开发期默认心态:模块来源、字段缺失、shape 错误必须 fail fast。- 同名 extension 在多个 repo 之间很容易碰撞,import path 和 editable install 来源必须进入 sanity check。
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 Nonelive 路径则不同。live 不能一次喂一整个月数组,所以只把很小的 compact context 过桥:
Python event state
|
v
fixed tuple / small arrays / cached native config
|
v
C++ quote + policy + routing decision
|
v
compact result: price, size, can_post, reason_mask
|
v
Python REST adapter这也是为什么 scalar quote core 单独迁 C++ 反而可能慢:如果每次都构造 dict、dataclass、字符串 key,跨语言边界比数学本身更贵。真正要优化的是“边界形状”,不是盲目把更多 Python 改成 C++。
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++ 公式,而是重新设计边界:
- Python wrapper 缓存已经物化的 native
QuoteCoreConfig,配置对象热重载时失效; - state/pred 通过紧凑 tuple 传入,depth levels 由 C++ 直接读取 Python sequence,不再逐档创建 pybind
DepthLevel对象; - quote EV 关闭时,只返回 side policy 真正读取的 adverse、defense、TTL、local-extreme、near-depth 字段,以及周期诊断日志使用的小型 diagnostics;
- 任一侧 quote EV 开启时自动回到完整 context,因为模型确实需要完整特征,不能为了速度静默丢列。
也就是说,完整 context 不是被删除,而是从“每 tick 固定税”变成“功能真正需要时才支付的成本”。
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_imb、near_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 rowsgolden window 覆盖正常、高波动、低成交/稀疏以及不同日期状态。只有差异能够被解释,C++ engine 才被允许进入 sweep、cap A/B、quote EV A/B 等批量 runner。默认行为仍保留 Python,显式 --engine cpp 才切换,strict 模式下 extension 不可用会直接报错,避免静默 fallback 让人误以为跑了 C++。
完整 CLI 的 wall time 还包含 parquet、模型和窗口加载,因此不能拿 C++ 内层循环的 microbenchmark 直接宣称整月回测有同样倍数。对回测来说,路径一致比一个夸张的加速倍数更重要。
1.4.4 quote EV fast screening:快筛不是最终裁决
quote EV online inference 会对大量 quote 逐行构造 feature 并调用模型,多日 A/B 很慢。为此项目增加了一条研究快筛路径:
- 在 baseline quote context 下预计算 bid/ask EV arrays;
- C++ replay 在每个 tick 直接读取对应分数;
- 快速跑 baseline、bid、ask、both 四个 key arms;
- 淘汰明显差的 arm;
- 最终候选仍回到 Python online inference 全量确认。
本机接电后的 wall-time 记录是:多日 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 -qgolden test 不要求所有浮点数逐 bit 相同,但必须给每类差异设显式容忍度。尤其不能只比最终 PnL,因为两条不同成交路径可能偶然得到接近的 PnL。当前比较集合至少包括:
SUMMARY_KEYS = (
"pnl", "inventory_adjusted_pnl", "inventory_pnl",
"fills_total", "fills_bid", "fills_ask", "final_inventory",
"avg_markout", "markout_count",
"abs_inventory_time_s", "signed_inventory_time_s",
"sq_inventory_time_s", "notional_inventory_time_s",
"avg_spread", "avg_final_spread", "n_requotes",
"quote_spread_lt_100_rate", "cap_hit_rate",
"sharpe", "max_drawdown",
)开发阶段还必须打开 strict 模式:
NARROWGATE_CPP_QUOTE_CORE=1 \
NARROWGATE_CPP_SIGNAL_FEATURES=1 \
NARROWGATE_CPP_STRICT=1 \
.venv/bin/python bench/bench_live_path.py \
--n 10000 --signal-n 1000 --engine cpp --strict-cpp如果 C++ import、模块来源检查或某个 native 调用失败,这条命令必须非零退出。否则 benchmark 很可能测到 fallback 后的 Python,却被错误记录为 C++ 性能。
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 并不是为了把所有新语法都塞进交易路径,而是优先使用几项收益明确、行为容易验证的能力。
第一项是用 concept 和 std::span 表达“不拥有、连续、只读的数值数组”。quote core、tick replay 和 streaming feature 不再各自传裸指针与长度:
template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>;
template <Arithmetic T>
using ArrayView = std::span<const T>;
template <Arithmetic T>
struct MatrixView {
ArrayView<T> values;
std::size_t rows{};
std::size_t cols{};
[[nodiscard]] T at(std::size_t row,
std::size_t col) const noexcept {
return values[row * cols + col];
}
};这不会自动让循环变快,但它把 shape、ownership 和 constness 写进类型,减少 Python buffer 绑定和 replay 数组新增字段时的错误面。[[nodiscard]] 则放在 quote result、feature snapshot 和 replay result 上,避免调用方无意丢弃策略结果。
第二项是固定容量 ring buffer。旧的 60,480 行 history 使用 vector.erase(begin()) 淘汰头部,每次满载 push 都会搬动后续元素;现在只覆盖 head:
template <typename T>
class CircularBuffer {
public:
explicit CircularBuffer(std::size_t capacity)
: storage_(capacity) {}
void push(const T& value) {
if (size_ < storage_.size()) {
storage_[(head_ + size_) % storage_.size()] = value;
++size_;
return;
}
storage_[head_] = value;
head_ = (head_ + 1) % storage_.size();
}
[[nodiscard]] const T& operator[](std::size_t i) const {
return storage_[(head_ + i) % storage_.size()];
}
private:
std::vector<T> storage_;
std::size_t head_{};
std::size_t size_{};
};对应的 80 维 feature 输出改成稳定布局;字段名只在边界出现:
enum class SignalFeatureId : std::size_t {
AggressionImbalance10s,
BookImbalance,
// ... 其余稳定字段
Count
};
struct SignalFeatureVector {
std::array<double,
static_cast<std::size_t>(SignalFeatureId::Count)> values{};
double& operator[](SignalFeatureId id) noexcept {
return values[static_cast<std::size_t>(id)];
}
};名称诊断使用 std::ranges::lower_bound,失败路径附带 std::source_location;窗口常量使用 std::to_array。这些都在冷路径或编译期工作,不给每 tick 增加新分配。
第三项是让 replay 的状态和 trace 从字符串变成强类型枚举,并用 std::pmr::monotonic_buffer_resource 承担单次 replay 内部短生命周期容器:
enum class OrderState : std::uint8_t {
PendingNew, Active, PendingCancel, Filled, Cancelled
};
enum class TraceOutcome : std::uint8_t {
None, Filled, Cancelled, Expired, Rejected
};
std::array<std::byte, 64 * 1024> replay_scratch{};
std::pmr::monotonic_buffer_resource arena(
replay_scratch.data(), replay_scratch.size());
std::pmr::vector<ReplayOrder> bid_orders{&arena};
std::pmr::vector<ReplayOrder> ask_orders{&arena};pybind property 仍返回旧的字符串值,所以 Python trace schema、parquet 报告和 A/B 脚本无需改变。PMR 只管理函数内部的临时订单和 pending markout;需要返回 Python 的结果继续使用普通 owning container,避免把 arena 生命周期泄漏到边界外。
本次现代化后的同机结果如下:
| 测量 | 旧实现 | C++20 实现 | 解读 |
|---|---|---|---|
| 500-row 短历史 signal 10s | 557.32 us | 553.17 us | 快约 0.7%,短窗口本来就不是搬移瓶颈 |
| quote + policy | 63.09 us | 63.18 us | 统计上持平,现代化没有伤害 scalar path |
| 60,480 history 满载 push(纯 C++ operation microbench) | 80.05 us | 0.0026 us | 去掉 erase(begin) 后由 O(N) 变 O(1) |
| 60,480 history full snapshot | - | 111.23 us | 常驻 ring + 定长输出下的完整计算 |
这张表怎么读
- 说明什么:C++20 现代化主要减少数据搬移和生命周期错误,短路径不一定会显著变快。
- 不能说明什么:
0.0026 us不是 live 总延迟,也不是策略收益,只是一个容器操作微基准。- 下一步验证:继续看真实窗口 golden parity、长 history p99,以及目标 Linux CPU 上的 PMU 或 wall-time soak。
第三行尤其不能直接解读成 live 总延迟快了三万倍:它只隔离测量“满载后保留尾部”的容器操作。真实路径还包含 Python event、pybind、模型和路由。它说明的是旧容器在长时间运行后存在确定的线性成本,而不是给系统吞吐量制造一个夸张数字。
验证也按风险分层完成:第一轮快速 quote/signal/replay 测试为 18 passed, 4 skipped;另行显式运行 May 正常、高波动、Feb 稀疏和 Jan 窗口的 golden parity 为 4 passed。此外三个 C++ 核心翻译单元用 -std=c++20 -Wall -Wextra -Wpedantic 编译为零 warning。由于短路径收益接近噪声,本次只更新 native 基础设施和研究路径,没有修改 live 配置。
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 同时持有完整 SideQuoteContext 和 TraceOrderRow。即使 trace 关闭,transition_orders()、best_live_order()、process_side_fill() 扫描的每个订单也背着一大块冷数据。现在热结构只保留成交状态:
struct ReplayOrder {
Side side;
double price, remaining, queue_left, queue_init;
std::int64_t quote_ts, activate_ts, cancel_effective_ts, ttl_ms;
OrderState state;
bool side_adverse, defense_guard, local_extreme_guard, quote_ev;
TraceOrderRow* trace = nullptr;
};只有 trace_quotes_max 或 trace_fills_max 大于零时,才从 arena-backed unsynchronized_pool_resource 分配 cold row。hot order 用带 PMR deleter 的 RAII pointer;订单退出后 slot 回到 pool,不会像纯 monotonic allocation 一样随长窗口持续累积。输出 trace 仍复制到 owning result vector,pool pointer 不跨函数生命周期。
fill trace 的时间窗也改成 lower_bound/upper_bound 定位,不再从第零条成交开始扫;1s/5s/30s markout 同样用 lower_bound 找 horizon。trace 默认关闭,这项优化主要服务大规模 quote decomposition。
summary-only 不是简单丢掉曲线
PnL curves 原先还承担 Python 侧 Sharpe 和 max drawdown,所以不能直接禁用。C++ replay 现在每次 requote 在线维护 peak、drawdown、PnL delta、时间增量以及标准化 delta 的一、二阶矩:
const double normalized = pnl_delta / std::sqrt(dt_s);
pnl_delta_sum += pnl_delta;
pnl_dt_sum += dt_s;
normalized_sum += normalized;
normalized_sq_sum += normalized * normalized;
peak_pnl = std::max(peak_pnl, current_pnl);
max_drawdown = std::max(max_drawdown, peak_pnl - current_pnl);单次 API 的 collect_curves=true 保持兼容;C++ sweep、cap A/B、统一 tick_ab.py 和 quote-EV A/B 自动使用 summary-only。2M trades、200k requotes 的 synthetic replay 中,CPU 只从 42.18ms 降到 41.77ms,约 1%;真正收益是三条 200k vector 变成零行,也无需再转成 NumPy。
长周期 moments 增量化,但保持数值稳定
长 history 的主要扫描来自 2,160/8,640 条 return_abs 均值和最多 60,480 条 vol_regime_6h z-score。它们现在由 fixed-count rolling moments 在 push_history() 时更新。
简单维护 sum_sq - sum²/n 在低方差序列上会严重消差;长历史 parity test 正好抓到了这个问题。因此实现使用支持删除最旧值的 Welford mean/M2 更新,而不是放宽测试:
static void remove_value(double x, std::size_t n,
double& mean, double& m2) noexcept {
const double next_mean = (n * mean - x) / (n - 1);
m2 = std::max(0.0, m2 - (x - mean) * (x - next_mean));
mean = next_mean;
}SignalFeatureEngine.compute_values() 返回固定顺序的 NumPy array;SIGNAL_FEATURE_NAMES 只在模块初始化时创建一次。旧 dict API 仍兼容。满 60,480 history 时,fixed-array snapshot 从第一轮约 111.23us 降到 1.90us,兼容 dict 为 4.42us。短历史 live 整体仍受 Python feature merge/model 路径影响,没有据此切换 live 默认配置。
只给逐行独立 batch 加线程
单个 replay 的订单、库存、现金和 latency 状态强顺序依赖,仍保持单线程。depth quote batch 的每一行独立,才新增显式 workers 参数;默认 1,每 worker 至少 4,096 行:
std::vector<std::jthread> threads;
for (std::size_t worker = 0; worker < worker_count; ++worker) {
threads.emplace_back(run_rows, begin[worker], end[worker]);
}100k rows × 10 levels 的 Release benchmark:1 worker 为 35.48ms / 2.82M rows/s,4 workers 为 10.08ms / 9.92M rows/s,快 3.52x。参数已经贯通到 cross_market_shock_audit.py --quote-context-workers N,但不会自动开启:若外层已经按 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 --sweep、tick_ab.py --workers、quote EV A/B workers 缩短的是多月、多臂、多窗口研究 wall time;live 的决策路径仍然是单事件、强顺序、低尾延迟问题。 compute_quote_core_batch_depth(..., workers=N)的 worker 分片。100k rows × 10 levels 在本机 4 workers 快 3.52x,但如果外层已经按 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 上应该同时看 cycles、instructions、branch-misses、L1-icache-load-misses、iTLB-load-misses、LLC miss、context switches。只有这些指标和策略 parity 一起过,native 开关才有资格从 shadow 变成配置候选。
后来我真的拿了一台 x86 EC2 机器做了这件事:Amazon Linux 2023、Intel Xeon Platinum 8259CL、2 vCPU(一个物理核的两个 SMT 线程)、L1d/L1i 都是 32KiB,clocksource 是 tsc。这台机器一开始甚至没有 gcc/g++/cmake,所以第一步不是调参数,而是把 live benchmark 环境和训练环境拆开。
这里有个很现实的坑:如果在 Linux live 机器上直接 pip install -r requirements.txt,torch 会默认拉一大串 CUDA wheels。它对这个 maker live path 没帮助,却会把 32G 根盘迅速塞满。我最后只装了 live benchmark 需要的轻量依赖:
python3.11 -m venv .venv
source .venv/bin/activate
pip install numpy pandas pyarrow lightgbm scikit-learn scipy \
pyyaml requests binance-futures-connector websocket-client \
pycryptodome zstandard pytest
pip install -e cpp为了不再靠手敲命令,我加了两个脚本:
scripts/x86_live_env_audit.sh
scripts/x86_live_benchmark.sh
scripts/x86_live_soak.sh第一个脚本记录 OS、CPU/cache、clocksource、sysctl、IRQ、Python/narrowgate_cpp import 和 NumPy backend。第二个脚本做短跑 smoke:Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。第三个脚本只跑候选组合长样本 soak,用来观察 p99.9。
export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1
export NUMEXPR_NUM_THREADS=1
export MALLOC_ARENA_MAX=1然后依次跑 Python baseline、C++ quote core、C++ signal features、候选组合和 compact live-routing ABI。
这次 x86 结果很有意思,因为它和 Apple Silicon 本机结论并不完全一致:
| 开关 | signal 10s mean/p99 | _compute_quotes mean/p99 |
quote+policy mean/p99 |
结论 |
|---|---|---|---|---|
| Python baseline | 3431 / 4159 us | 152 / 201 us | 240 / 305 us | x86 baseline |
NARROWGATE_CPP_QUOTE_CORE=1 |
3328 / 4233 us | 121 / 158 us | 210 / 270 us | quote core 在 x86 上转正 |
NARROWGATE_CPP_SIGNAL_FEATURES=1 |
1973 / 2347 us | 153 / 194 us | 237 / 302 us | signal features 在 x86 上明显有收益 |
QUOTE_CORE=1 + SIGNAL_FEATURES=1 |
1949 / 2199 us | 120 / 160 us | 209 / 269 us | 当前 x86 live 候选组合 |
这张表怎么读
- 说明什么:ARM 本机结论不能直接外推到 x86 live;目标机型上 quote core 和 signal features 的方向都要重新测。
- 不能说明什么:短跑 smoke 不能代表长时间 p99.9,也不能代表网络、REST 和交易所响应。
- 下一步验证:固定 BLAS/OMP 线程后跑 soak,并确认没有 CPU migration、page fault 或 fallback 尖刺。
短跑只能说明方向,所以我又在同一台 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_us、compute_quotes_us、update_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 calibration、xmarket_retreat_enabled、fill_cooldown_apply_reducing、fill_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 实例不暴露 cycles、instructions、L1-icache-load-misses、iTLB-load-misses 这些硬件事件,最后只能看到 context switches、cpu migrations 和 page faults。因此“x86 benchmark”也要继续细分:普通云主机能做 wall-time/tail-latency 复测,但真正要分析 L1I/iTLB/cache miss,可能需要裸金属、特定实例族或能暴露 PMU 的环境。
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_mult、size_mult、allow_post、reason_mask,大量工作是评估 10+ 个 policy guard 的条件。
这些条件判断本身不重,但每次需要访问 Python dict(quote_ctx.get("side_adverse", False) 等),在单 tick 热路径上积少成多。
NARROWGATE_CPP_LIVE_ROUTING 仍只接管后半段的价格调整、spread cap、库存准入、size 和 cancel/replace 判断,但跨语言接口已经从动态 dict 收紧为固定布局:
bid_policy = self._build_side_policy(Side.BUY, mid, q, pred)
ask_policy = self._build_side_policy(Side.SELL, mid, q, pred)
routed = cpp_route.compute_live_routing_decision(
(
mid, q, base_bid_price, base_ask_price,
best_bid, best_ask, tick, lot, min_qty, min_notional,
order_size, max_inventory, eta, symmetric_size,
requote_threshold_bps, max_spread,
bid_alive, bid_active_price, bid_age_ms,
ask_alive, ask_active_price, ask_age_ms,
),
(
bid_policy.allow_post,
bid_policy.allow_exposure_increase,
bid_policy.spread_mult,
bid_policy.size_mult,
bid_policy.order_ttl_ms,
),
(
ask_policy.allow_post,
ask_policy.allow_exposure_increase,
ask_policy.spread_mult,
ask_policy.size_mult,
ask_policy.order_ttl_ms,
),
)native 侧对应的是普通结构体,而不是把业务逻辑继续堆在 pybind lambda 中:
struct LiveRoutingPolicy {
bool allow_post;
bool allow_exposure_increase;
double spread_mult;
double size_mult;
double order_ttl_ms;
};
LiveRoutingResult compute_live_routing_decision(
const LiveRoutingInput& input,
const LiveRoutingPolicy& bid,
const LiveRoutingPolicy& ask);它仍不是 quote → policy → routing 的完全 fused kernel,因为 _build_side_policy() 还在 Python;但 routing 边界自身已不再创建两个 20+ 字段 dict、逐个做字符串 key 查询、再创建结果 dict。旧/新二进制使用同一组输入做 100,000 次对照,checksum 完全一致:mean 6.024 → 0.472 us,p99 6.375 → 0.583 us。这个约 92% 的下降只描述 routing binding,不代表完整 _update_orders() 或网络报单延迟也下降 92%。
最终更理想的 live C++ 决策包应该继续向下面这个形态收敛:一次调用接收 market state、model prediction、inventory state 和 live order state,返回两个 side 的完整动作,但仍不负责 REST IO。
enum class OrderAction : std::uint8_t {
Keep,
Cancel,
Replace,
NewOrder,
};
struct LiveDecisionInput {
double mid;
double best_bid;
double best_ask;
double tick_size;
double lot_size;
double inventory;
double max_inventory;
double sigma_sq;
double ret_10s;
double tox_bid;
double tox_ask;
ActiveOrder bid_order;
ActiveOrder ask_order;
DepthView depth;
};
struct SideDecision {
OrderAction action;
double price;
double quantity;
double ttl_ms;
std::uint32_t reason_mask;
};
struct LiveDecision {
SideDecision bid;
SideDecision ask;
double final_spread;
bool inventory_blocked;
};
LiveDecision compute_live_decision(
const LiveDecisionInput& in,
const QuoteCoreConfig& quote_cfg,
const PolicyConfig& policy_cfg) {
auto quote = compute_quote_core_compact(in, quote_cfg);
auto bid_policy = evaluate_side_policy<Side::Buy>(
quote.bid, in.inventory, in.tox_bid, policy_cfg);
auto ask_policy = evaluate_side_policy<Side::Sell>(
quote.ask, in.inventory, in.tox_ask, policy_cfg);
return route_quote_to_orders(
quote, bid_policy, ask_policy, in.bid_order, in.ask_order);
}这段伪代码里有两个刻意保留的边界:
ActiveOrder是当前本地状态,不在 C++ 里发 REST;C++ 只说“应该怎么做”,Python 负责实际 cancel/new 和异常处理。reason_mask是整数位图,不是字符串列表;日志需要人类可读 reason 时再冷路径翻译。这样热路径不做字符串拼接,也不会因为审计文本拖慢每次报价。
设计上有一点克制:进出 REST 的部分(cancel/new order、TTL check、GIL 相关操作)仍留在 Python,不放进 C++ call 内。这条边界的理由是:REST 调用本身的延迟在 10-50ms 量级,比 C++ 函数的收益大两个数量级;强行把 REST 放进 C++ 只会让错误更难 debug,而不会降低端到端延迟。
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源码审计也需要给模板设预算。当前模板维度主要是 Side、QueueAheadMode、固定 rolling window 和容器 view:
template <Side S, QueueAheadMode Mode>
double visible_queue_ahead(...);
template <int WindowSeconds>
class RollingStats;Side x QueueAheadMode 只有四种 queue 实例,RollingStats<60> 即使有多个对象也只有同一种类型;quote core 的大主体本身不是模板。这里没有 EnableAdverse x EnableDefense x EnableQuoteEV x ... 这样的布尔笛卡尔积,因此目前不存在明显的模板组合爆炸。side-specific helper 展开后,compute_quote_core() 总体仍只有约 7.4 KiB。
审计发现的两个缺口随后都做了收敛:
- routing 改为固定 22 字段 input tuple、两个 5 字段 policy tuple 和 11 字段 result tuple;binding 只负责位置解包,业务数学进入普通
LiveRoutingInput/Policy/ResultC++ 函数。place/replace 时为了审计日志执行的asdict()仍保留,因为它不属于每次 routing 计算。 compute_signal_feature_vector<Bars, History>改成接收SegmentedSpanView<Bar1s/FeatureHistoryRow>的单一非模板主体。CircularBuffer暴露 ring 的两段 span,legacy vector 暴露一段 span,无复制共享同一份机器码。相关大计算代码由8,868 + 10,120 B收到9,184 + 1,136 B,减少 8,668 B;整个扩展__text从 478,392 降到 471,364 B。
代码变小没有换来性能回退。满 60,480 history 的 compute_values() 对照中,mean 1.901 → 1.748 us,p99 2.500 → 1.917 us。这说明 two-span 访问增加的一次边界判断,代价小于共享代码布局和编译器优化带来的收益;但这些仍是同机微基准,不能替代目标 Linux CPU 的 PMU 计数。
因此这里对模板元编程的约束是:只把真正高频、低基数的结构性分支固定到编译期;数值阈值和频繁实验的 policy 留在数据中;大段公共计算只保留一份非模板主体;trace、日志、异常和兼容 binding 保持冷路径。评估是否合格最终看的是固定/轮换模板实例实验中的 L1I MPKI、iTLB MPKI、branch miss 和 p99/p99.9,而不是 .text 总大小,也不是源码有多少行。
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++ 主要服务两个目标:
- 研究吞吐:让多日 replay、source A/B、quote context 和 label 生成跑得动;
- 边界收紧:让 live hot path 中稳定的小决策包更少依赖 Python 动态对象。
这个边界很重要:当前项目不是专用交易网关,也不是把所有网络和订单路径都 native 化的极限 HFT stack;它是一个把 maker 研究、tick replay、order-level evidence 和 live hot path 逐步接起来的低延时工程框架。
2.3 下一阶段真正值得做的事
继续推进时,下一阶段的重点不是再堆一个模型,而是补四个闭环:
- 动态
Lambda(delta)闭环:用 live/shadow fill calibration,把 depth、toxicity、reference shock 映射到整条到达率曲线,而不是只做 widen/pause。 - 主动瘦身闭环:设计 BTCUSDT perp/spot hedge simulator,把 hedge cost、basis risk、taker fee 和 inventory tail loss 放进同一张账。
- 真实 multi-venue lead-lag 闭环:接入独立 venue 原始盘口/逐笔数据,验证 reference 信号是否早于本地可执行窗口。
- live threading 闭环:把 WebSocket decode、rolling state、quote decision 和 order gateway 分层,至少用 SPSC ring + pinned strategy thread 做一次 x86 p99/p99.9 对照。
本节边界结论
- NarrowGate 已经把 maker 研究从 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 数字,而是几条工程判断:
- ARM 本机低延时的工程收益主要体现在反例吞吐和 parity 成本,主指标是完整窗口耗时、rows/s、windows/hour、内存峰值和可重复性;
- x86 live 低延时的工程收益主要体现在报价时序和订单生命周期,主指标是 p99/p99.9、fallback、CPU migration、page fault、context switch 和长样本 soak;
- Python 仍然适合数据清洗、训练编排、报告和实验组织;
- C++ 适合长期驻留的状态、高频低基数分支、完整窗口 replay 和稳定的 live hot path 小决策包;
- pybind 边界必须按数组、固定 tuple 或 compact context 设计,不能每 tick 传大 dict/dataclass;
- parity 比速度更重要,没有路径级和金额级一致性,sweep 越快越危险;
- 当前 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 交易及相关业务活动的监管环境具有不确定性;任何人将相关代码用于连接交易平台、交易、商业展业或投资决策,其合规风险、资金损失、技术故障及其他后果均由使用者自行承担,与作者无关。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!