quant-engine · 架构讲解 一个长出了实盘守护进程的 C++20 回测引擎 架构讲解 · v0.4.1 ██ 这是什么 一个仓库里的 C++20 量化交易系统。
• 零分配的回测引擎 • 自带求值器的 .qe 配置语言
• 跑在纸账户上的 IBKR 实盘守护进程 • 终端风格的原生 dashboard —— 唯一交付的二进制
C++ 370 个文件 13,101 图节点
Bash 23 46,292 条边
Python 5 约 220,000 行
TypeScript 1 3,244 个测试
有意思的不是规模,而是这份代码拒绝做什么,
以及那些「拒绝」里有多少是结构性的、而非劝告性的。 2 / 35 ██ 先说最重要的一条 最近十二个生产缺陷里,有六个是同一个形状: ▍ 一个健康信号,对着它并没有在测量的通道
▍ 报告一切正常
feed_healthy 在一整个零 bar 的交易时段里是绿的。 fill_integrity 轮询在一条从不下单的 socket 上。
unbacked_sells 数的是一个空账本。 broker_connected 用一个布尔值概括两条 socket。
其中三个,本身就是作为「监控」发布的。 于是有了这条规则,现在写在 CLAUDE.md 里:
▍ 一个健康信号,要么带着能把它逼红的测试一起发布,
▍ 要么就不发布。
3 / 35 ██ 全景 离线 实盘
.qe 配置 qe_daemon
| |
求值器 <- 横截面预处理 LiveEngine
| |
MultiEngine <- 热循环 OrderScheduler <- 分批切片
| |
Portfolio [ 安全栈 ]
| |
指标 / IC / walk-forward IbkrOrderRouter
| |
results.json + report.html TWS (两条 socket)
dashboard 通过网络只读挂载到守护进程上。
4 / 35 ██ 第一部分 —— 引擎 5 / 35 ▓▓▓ 数据布局:结构体数组,强制执行 // 正确
struct BarSeries {
std::vector<int64_t> timestamps; // 纳秒时间戳
std::vector<double> open, high, low, close, volume;
};
// 错误 —— CLAUDE.md 把它印在
// 「不要引入这个」的标题下
struct Bar { int64_t ts; double o,h,l,c,v; };
std::vector<Bar> bars;
三个理由,不是一个:按列扫描是线性命中缓存,而不是在交错的 记录字段间跳跃;循环可以在 AVX2/NEON 上自动向量化;指标拿走 的是一整列,不是一个跨步。 多标的是按标的分组的 vector<BarSeries>,用 size_t 索引。 unordered_map<symbol, BarSeries>
被点名否决 —— 「热循环里禁止每根 bar 每个标的做一次哈希查找。」
6 / 35 ▓▓▓ 分发:CRTP,以及唯一允许虚函数的地方 template <typename Derived>
class IStrategy {
void on_bar(std::size_t i, const BarView& b, Portfolio& pf) {
static_cast<Derived*>(this)->on_bar_impl(i, b, pf);
}
};
每根 bar 没有虚表;调用完全内联。 虚函数只在一个文件里被允许 —— factory.hpp,也就是 JSON / .qe 配置边界,它用大写字母写着:
▍ VIRTUAL FUNCTIONS ARE ALLOWED HERE — and only here
BarView 是按值传递的 48 字节拷贝,不是指向列的指针: 指针会把视图的生命周期绑死在序列上,并且每次访问字段都要 跨 5
个以上的独立分配做指针追逐。 7 / 35 ▓▓▓ 那个循环:未来函数无法被表达 引擎就是一个模板化的 for。它的语句顺序本身就是那条安全性质:
bar i-1 策略看到 bar i-1
submit_buy() -> 只设置 pending_qty,什么都不成交
|
| 意图跨过 bar 边界
v
bar i (1) 用 bar i 的开盘价成交 pending_qty
(2) 用 bar i 的收盘价盯市
(3) 现在才调用 strategy.on_bar(i, ...)
|
| 只能下单到 i+1
v
bar i+1
未来函数不是靠一道可以绕过的检查挡住的。 策略运行的时候,bar i+1 还没有被构造出来。 没有对象可以偷看。
8 / 35 ▓▓▓ 零分配,以及证明它的测试 Engine::run() 内部:没有 new,没有未 reserve 的 push_back,没有 std::string,没有 map
查找,不抛异常。 由计数分配器验证,不是靠人眼审查 —— tests/alloc_counter.{hpp,cpp} 加 15 个测试。
目标 │ 实测 │ 余量 ────────────────────┼──────────┼─────── 10 年日线 < 10 ms │ 0.022 ms │ 454 倍
1 年分钟线 < 100 ms │ 0.916 ms │ 109 倍
万格参数扫描 < 30 s │ 0.554 s │ 54 倍
CSV > 500 MB/s │ 514 MB/s │ 通过
参数扫描在配置层并行 —— 绝不在单次回测内部并行。 每个格子有自己的 Portfolio、Engine
和缓冲区。 9 / 35 ██ 第二部分 —— .qe 语言
10 / 35 ▓▓▓ 一棵 AST,两种语言 backtest(...) sweep(...) walk_forward(...) -> qe_run
research(...) -> qe_factor
同一个词法分析器、同一棵 AST,服务两个层:
│ 配置层 │ 信号层 ─────────┼──────────────────┼──────────── 何时求值 │ 加载时一次 │ 每根 bar 产出 │ 带类型的 QeValue │ 一个 double
内建函数 │ 26 个 │ 25 个 分配内存 │ 自由 │ 从不
信号表达式是从同一棵树上切下来的,重新绑定到数字 id 和预分配的状态槽。let fast = 10
会作为常量内联进信号树, 零成本。 代价,如实写出:同样 2,500 根 bar 上,BM_Dsl_MaCross 0.166 ms 对 BM_Hardcoded_MaCross
0.022 ms。 DSL 是手写策略的 7.5 倍 —— 但仍然比目标快 60 倍。
11 / 35 ▓▓▓ 那条看起来像 bug 的规则 and / or 不做短路求值。两边永远都会算。
因为每个有状态的指标必须每根 bar 恰好被喂一次。 如果外层的 and 在左边已经为假的那根 bar 上跳过了右操作数,
那个 sma 就会漏掉一个样本,从此与价格序列失同步 —— 永久地,而且悄无声息。
同样的理由,同一个文件:即使参数用不上也要求值,窗口字面量 用 (void)eval_node(...)
走一遍。 ▍ 一个有时被遍历、有时不被遍历的参数,
▍ 就是有状态指标失同步的方式
流式求值器的正确性,和表达式求值器的正确性不是同一个性质。 那个显而易见的优化,恰恰就是
bug。 12 / 35 ▓▓▓ 七个原语,一个窗口 在一根 bar 上横跨整个股票池,七个横截面原语必须寻址 同一个已打分区块:
[ -- 非有限 -- | ------ 已打分 ------ | -- 非有限 -- ]
^ ^
xs_finite_lo_[s] + xs_finite_n_[s]
由预处理按站点算一次。它曾经只在 fill_xs_norm 内部 被重新数一遍 —— 「而这正是为什么
is_top 这个从不调用该 函数的纯选择站点,一直在和一个不同的窗口比较。」
后果是:同一个站点对同一个成员回答了两个不同的问题。 rank(x) == n-1 和 is_top(x, 1) 在同一根
bar 上指向不同 的标的;一个无穷大带着百分位,而它的 z-score 是 NaN。 「不可用」的判据是 !isfinite 而不是 isnan —— 无穷大不是一个排名,但它通过了 isnan 检查,被选成了
全池最好的那一个。 13 / 35 ▓▓▓ 复杂度真正堆在哪里 全仓库的圈复杂度: call_builtin src/dsl/config_eval.cpp 513
eval src/dsl/config_eval.cpp 104
process_input apps/dashboard/vim_editor.cpp 76
tick src/live/order_scheduler.cpp 59
clone_for_signal src/dsl/config_eval.cpp 49
一个函数是第二名的五倍。那是 DSL 内建函数的分发中枢 —— 一个 switch,不是逻辑。这正是你希望复杂度呈现的形状:宽而扁、
集中在一处,而不是薄薄地摊在整个代码库上。 对比热路径:BM_XsPrepass_30sym_IsTop10 跑 1.62 µs。
14 / 35 ██ 一致性 脊梁 —— 让回测有意义的那个东西 15 / 35 ▓▓▓ 两边不一致,闸门就一文不值 ▍ 回测是给策略放行的闸门;
▍ 守护进程是真正去交易的东西。
docs/safety-and-limitations.md 里有一张 13 行的一致性矩阵, 并注明
每一行都是从当前构建的源码读出来的,不是从文档抄的。
让其余一切成立的那一行: 维度 │ 回测 │ 实盘 │ 一致? ───────────┼─────────────────────────┼──────────────────────┼────── 因子求值器 │ qe::dsl::Evaluator │ 同一个类、同一棵 AST │ 是
横截面排名 │ cross_sectional_prepass │ 同一个函数 │ 是
▍ on_bar 由回测引擎所用的同一个 qe::dsl::Evaluator
▍ 求值。实盘模式只是*「把 bar 按到达顺序喂给同一个求值器,
▍ 而不是喂一个已知的完整序列」*。
不是「移植过去的」,不是「保持同步的」。就是同一份代码。
16 / 35 ▓▓▓ 以及那些不一致的行 维度 │ 回测 │ 实盘 │ 一致? ────────────────────┼────────┼──────────────┼────── whole_shares = true │ 被忽略 │ 生效 │ 否
compound = true │ 生效 │ 没有实盘路径 │ 否
whole_shares 会把每笔实盘订单向下取整到整数股,而回测并不建模 它。在 include/qe/backtest、include/qe/strategy、src/backtest 里 g
实测后果:约 14% 的资金被取整闲置。
而这件事对闸门是不可见的,因为闸门就是回测本身。
所以守护进程有一道启动闸门,在实盘无法复现回测仓位算法时 拒绝运行(退出码 4):
▍ 绝不悄悄降级。一次静默的仓位算法分歧让 2026-06 那次纸面
▍ 部署损失了六周 —— 它在下单,每一条日志看起来都活着,
▍ 而每条腿只买了一股。
17 / 35 ██ 第三部分 —— 实盘路径 重心所在 18 / 35 ▓▓▓ 每一层的存在,都因为某个绿灯曾经撒过谎 到同一个网关的两条 TCP 连接 —— 行情走 client_id, 订单和持仓走 client_id + 1。
这不是风格问题:IB Gateway 禁止两条连接共用一个 client id, 而且它会在所有打开的 socket 之间交错发帧,所以一个空闲对端的
握手可能被无限期拖延。 意图
|
ReconcileGatedRouter <- 最外层,只能「拒绝」
|
PreTradeRisk <- 敞口上限,三种暂停来源
|
SafeBroker <- 单笔限额,kill switch
|
IbkrOrderRouter <- 10 秒确认超时
|
TWS
每一层回答的是不同的问题,衡量的是不同的账本。
19 / 35 ▓▓▓ 对账闸门 —— 五条规则,条条承重 2026-07-31,守护进程在 09:30:31–33 记录了十笔成交。 它自己的对账器在九十秒内逐一否定了每一笔,全部读到
broker=0。
它继续对着一个不存在的账本交易。 第二天早上:账户空仓,没有挂单,权益没变。
▍ 一个唯一输出是日志行的探测器,
▍ 是一个没有人会去响应的探测器。
1. 附加闸门 —— 只能把「通过」变成「拒绝」
2. 绝不自动采纳 —— 它从不写入券商那边的数字
3. 只有一件事能清除它:操作员确认,并陈述一个决定
4. 按 (kind, symbol) 打水位标记 —— 没有持久化的「永久忽略」
5. 初始状态是「已阻塞」 —— 失败即关闭;一次被跳过的评估,
代价是一个不肯交易的守护进程,而绝不是一个未经对账就交易的 20 / 35 ▓▓▓ 代价,写明而不是埋掉 闸门自己的文件头里有一节,标题就叫这个: ▍ 一次阻塞会同时挡住策略的平仓和开仓。没有办法诚实地
▍ 只挡一边:平仓的数量是按一个仓位算出来的,而这个闸门存在
▍ 的原因恰恰是我们无法描述那个仓位。
▍
▍ 因此,一个在阻塞状态下持有真实仓位的守护进程,
▍ 并没有在管理它们。
以及哪些情况故意不阻塞:
• 不确定的对账结果 —— 「我们看不到交易所」不是漂移的证据, 为它阻塞等于把否决下个交易时段的权力交给 IB Gateway
的夜间重启 • 撤单,永远不挡 —— 它只会降低敞口
一份只列举优点的安全文档,是市场宣传。 21 / 35 ▓▓▓ 一个不可能触发的断路器 日亏损断路器曾经衡量的是账户:
账户净清算值 约 $1,000,000 (纸账户)
阈值 $15,000
策略最大可能亏损 约 $8,333 <- 毛额
策略可以把它拥有的一切都亏光,而那个被盯着的数字纹丝不动。 这个断路器在数学上不可能触发。
现在两层都衡量策略自己的账本,对照策略自己的锚点。 策略的账本和账户是两个不同的对象 ——
账户里还有手工买入的 股票,通过 qe_daemon baseline adopt 声明。
把上限调高,不是作用域错误的修复方案。 22 / 35 ▓▓▓ 若干常量 订单确认超时 10 秒
快照查询 15 秒
断连看门狗 1 Hz;3 次软暂停,30 次硬 kill
行情静默上限 600 秒(约为实测最坏间隔的 10 倍)
成交对账器 60 秒轮询,连续 2 次才确认
1e-6 股容差
权益轮询 30 秒(每个交易日约 780 次)
journal 轮转 64 MiB(实测三周约 267 KB)
TWS PLACE_ORDER 钉死 v151,static_assert 保证
2026-06-16 观测到一次 submit 阻塞了 177 秒。 十条腿 × 30 秒 = 正好是调度器容忍的 300
秒窗口。 这里的数字是按实测最坏情况定的,不是拍脑袋。
23 / 35 ▓▓▓ 两次什么都没记录的失败 预热常量。 守护进程对每个标的都向 IBKR 要 "60 D", "1 day" —— 约 41 根 bar,一个从它被写出来那天起
就没改过的字面量。 ▍ 需要更多历史的配置没有失败;它正常启动,没有记录任何
▍ 异常,然后用 NaN 因子值开始交易。
而 NaN 在横截面预处理里排到最后 —— 于是一个只做多的 is_top 悄悄挑走了碰巧预热够了的那几个标的。现在改为从配置推算,
并且历史不足就拒绝启动。
单向的 kill switch。 断连约 30 秒后触发,故意设计成单向, 而且守护进程不会退出 ——
它继续活着,永久暂停。 ▍ 进程守护(launchd KeepAlive、systemd Restart=)
▍ 帮不上忙,因为没有东西死掉。
2026-06-19:19:58 ET 因断连触发 kill。守护进程就那样停了 两个半星期。
那个负责清除它的 supervisor 有四道围栏:只限纸账户、 kill_reason 必须精确等于
ibkr_disconnect、重启之前 交易所必须可达、滚动 24 小时内最多 N 次重启。
自动清除一个安全控制是可以存在的 —— 但它必须为自己辩护。 24 / 35 ██ 第四部分 —— 分析 25 / 35 ▓▓▓ 要么测到了,要么写成「缺失」—— 绝不写成零 ▍ 这一层发布的每一个数字,要么是测出来的,
▍ 要么被明确写成「缺失」。绝不写成零。
• 当多标的账本无法配对成完整往返时,win_rate 序列化为 null,而不是 0.0
• provenance.not_computed 点名说明哪些被跳过了、为什么
• 优化器拒绝一个它无法排名的指标,而不是把那个格子记为零 ——
一个测到了的格子永远排在一个没测到的前面 • positions_checked 区分*「两个账本一致」*和 「两个账本从未被比较过」
这条原则只说一次,然后到处贯彻: ▍ 一个缺失的预期,不等于一个「预期为零」。
把沉默渲染成 0,曾经把账户里每一个合法持仓都变成了一行漂移。
26 / 35 ██ 第五部分 —— Dashboard 27 / 35 ▓▓▓ 只读,由守护进程强制 一个 8 屏的即时模式 GUI:GLFW + Dear ImGui + ImPlot。 实时报价、K 线、任意外部 results.json 的实时视图、 一个按 mtime 监视的持仓文件 —— 里面还有一个 vim 风格编辑器。
架构上的决定不是那个 GUI,而是这个: ▍ dashboard 以只读方式挂载到远端守护进程,而这个只读性质
▍ 是由守护进程里的 ReadOnlyControlHandler 强制的 ——
▍ 不是靠 dashboard 的自觉。
一个客户端不能靠谎报自己的身份来获得信任。 qe_dashboard 还被拆开,好让测试套件不链接 GUI 那一半 —— 部署主机上没有图形栈。这个拆分是承重的:无头的 nix shell
故意不提供 X11,而这正是让该拆分的回归变得可见的原因。 28 / 35 ██ 第六部分 —— 纪律 29 / 35 ▓▓▓ 没有 CI。这是故意的。 于是每一道本该住在流水线里的闸门,都被推到了人真正会遇到它的地方: • 一段会和你争辩的 preset 描述 (dist 解释了为什么 -march=native 不能发布:它编码的是
构建机器的 CPU,所以在 M4 上切的版本会让每台 M1 收到 SIGILL)
• 一个给 bundle 把关的发布脚本:每个 Mach-O 必须只链接 /usr/lib 和 /System、必须是
arm64、必须带着下载页所声称的 那个精确 minos
• 一个有真实退出码词汇表的每周开盘检查: 4 没有守护进程 · 6 没有配置目标 · 7
目标不可达 • requirements-docs.txt 里的文档工具链钉版,因为一个裸的 mkdocs
是「机器上碰巧装了什么就是什么」 30 / 35 ▓▓▓ 「红测试」不成立的三种方式 三种在这个仓库里都发生过。 1. 那个红其实是编译失败。 ctest 于是跑了一个过期的二进制并报告 PASS。 要确认「移除修复后」的构建退出码是
0。 2. 那个红来自弄坏测试,而不是移除修复。 它只证明了测试有能力失败。
3. 那个红来自一个从来没坏过的组件的单元测试。 OrderLog 被构造、传进 BrokerSession::wrap
、存下来、 再被当作真相读回去 —— 而中间从来没有人调用过 append()。 整个部署期间那是个 0
字节的文件。 把 log 直接挂到 SafeBroker 上的测试,在生产接线被删掉之后 依然是绿的。只有组合层的测试能抓到它。
▍ 删掉你以为能修复问题的那行生产代码,
▍ 然后看哪个测试会注意到。
31 / 35 ▓▓▓ Benchmark 是关于机器状态的断言 benchmarks/results/ 里的每个文件都记录了它是在多大负载下测的, 以及当时有什么在跑。
v0.4.1 时 BM_QeSweep 读数 +21%,而它没有被记为回归:
• 三个未被改动的对照 benchmark 同向移动了 +2% 到 +6% —— 是机器,不是代码
• 0.554 s 落在基线二进制自己的散布区间内 (四次运行:0.457 / 0.478 / 0.614 / 0.715)
• 在负载 4.2 下重测得到 0.71–0.76 s;负载 2.6 下是 0.554 s ▍ 一份在负载下测出、又没有注明这一点的快照,
▍ 比没有快照更糟 —— 它会变成别人用来测量
▍ 一个幻影回归的基线。
32 / 35 ▓▓▓ 这个项目记录自己的违规 CLAUDE.md 写着:一个 EPIC 一个分支,操作员批准,操作员合并。 而 EPICS/README.md 在一个以
**「一次工作流偏离,记录在案」**开头的标题下写着: ▍ 84、85、86、87 和 88 是直接在 main 上完成的。
▍ 没有 epic/NN-... 分支,没有合并前的评审闸门。
▍
▍ 验收是真实的,每个文件都记录了它建立在什么之上。
▍ 合并纪律没有被遵守。这两件事都该记在这里。
同样地,EPIC-83 的 15 项遗留问题,2026-07-30 对着 main 重新核实过:一项都没关闭。
每一项都标注了失败即关闭还是 失败即开放,R1 仍然阻塞重新部署,还有两条验收记录被写明 没有证据就发布了。
一个只记录自己成功的仓库,是一个你无法使用其记录的仓库。 33 / 35 ██ 带走什么 1. 把不变量做成结构性的,而不是劝告性的。 未来函数之所以不可能,是因为 bar i+1 那时还不存在 ——
而不是因为有条注释请你别偷看。 2.「我说不准」绝不能渲染成「一切正常」。 一个家族里六个缺陷。缺少一次测量,和缺少一个发现,
是两句不同的话。 3. 说明你自己那套安全机制的代价。 对账闸门写明了它同时挡住平仓,以及一个处于阻塞下的守护进程
并没有在管理它的仓位。 4. 一个测试只有在你亲眼看它失败过之后才是真的 —— 对着被移除的修复,在缺陷真正所在的那个接缝上。
34 / 35 ██ 提问 仓库 约 22 万行 · 3,244 个测试 · 没有 CI
二进制 QE Dashboard.app (arm64,未公证)
守护 IBKR 纸账户,一个策略,30 个标的
文档 mkdocs -> GitHub Pages
CLAUDE.md 是约定文档。 benchmarks/README.md 是测量规程。 EPICS/ 是工作历史。
35 / 35