这是什么¶
▶ 以幻灯片打开 —— 方向键翻页。 本页是同一份 deck 的可读长文形态;下面那些幻灯片标记是 presenterm 指令,在网页上不显示。 源文件可直接在终端演示:
presenterm docs/architecture-deck.zh.md
一个仓库里的 C++20 量化交易系统。
- 零分配的回测引擎
- 自带求值器的
.qe配置语言 - 跑在纸账户上的 IBKR 实盘守护进程
- 终端风格的原生 dashboard —— 唯一交付的二进制
有意思的不是规模,而是这份代码拒绝做什么, 以及那些「拒绝」里有多少是结构性的、而非劝告性的。
先说最重要的一条¶
最近十二个生产缺陷里,有六个是同一个形状:
一个健康信号,对着它并没有在测量的通道 报告一切正常
feed_healthy 在一整个零 bar 的交易时段里是绿的。
fill_integrity 轮询在一条从不下单的 socket 上。
unbacked_sells 数的是一个空账本。
broker_connected 用一个布尔值概括两条 socket。
其中三个,本身就是作为「监控」发布的。
于是有了这条规则,现在写在 CLAUDE.md 里:
一个健康信号,要么带着能把它逼红的测试一起发布, 要么就不发布。
全景¶
离线
.qe 配置
|
求值器 <- 横截面预处理
|
MultiEngine <- 热循环
|
Portfolio
|
指标 / IC / walk-forward
|
results.json + report.html
实盘
dashboard 通过网络只读挂载到守护进程上。
第一部分 —— 引擎¶
数据布局:结构体数组,强制执行¶
// 正确
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 每个标的做一次哈希查找。」
分发: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 个以上的独立分配做指针追逐。
那个循环:未来函数无法被表达¶
引擎就是一个模板化的 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 还没有被构造出来。
没有对象可以偷看。
零分配,以及证明它的测试¶
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 和缓冲区。
第二部分 —— .qe 语言¶
一棵 AST,两种语言¶
同一个词法分析器、同一棵 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 倍。
那条看起来像 bug 的规则¶
and / or 不做短路求值。两边永远都会算。
因为每个有状态的指标必须每根 bar 恰好被喂一次。
如果外层的 and 在左边已经为假的那根 bar 上跳过了右操作数,
那个 sma 就会漏掉一个样本,从此与价格序列失同步 ——
永久地,而且悄无声息。
同样的理由,同一个文件:即使参数用不上也要求值,窗口字面量
用 (void)eval_node(...) 走一遍。
一个有时被遍历、有时不被遍历的参数, 就是有状态指标失同步的方式
流式求值器的正确性,和表达式求值器的正确性不是同一个性质。 那个显而易见的优化,恰恰就是 bug。
七个原语,一个窗口¶
在一根 bar 上横跨整个股票池,七个横截面原语必须寻址 同一个已打分区块:
由预处理按站点算一次。它曾经只在 fill_xs_norm 内部
被重新数一遍 —— 「而这正是为什么 is_top 这个从不调用该
函数的纯选择站点,一直在和一个不同的窗口比较。」
后果是:同一个站点对同一个成员回答了两个不同的问题。
rank(x) == n-1 和 is_top(x, 1) 在同一根 bar 上指向不同
的标的;一个无穷大带着百分位,而它的 z-score 是 NaN。
「不可用」的判据是 !isfinite 而不是 isnan ——
无穷大不是一个排名,但它通过了 isnan 检查,被选成了
全池最好的那一个。
复杂度真正堆在哪里¶
全仓库的圈复杂度:
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。
一致性¶
脊梁 —— 让回测有意义的那个东西
两边不一致,闸门就一文不值¶
回测是给策略放行的闸门; 守护进程是真正去交易的东西。
docs/safety-and-limitations.md 里有一张 13 行的一致性矩阵,
并注明每一行都是从当前构建的源码读出来的,不是从文档抄的。
让其余一切成立的那一行:
| 维度 | 回测 | 实盘 | 一致? |
|---|---|---|---|
| 因子求值器 | qe::dsl::Evaluator |
同一个类、同一棵 AST | 是 |
| 横截面排名 | cross_sectional_prepass |
同一个函数 | 是 |
on_bar由回测引擎所用的同一个qe::dsl::Evaluator求值。实盘模式只是「把 bar 按到达顺序喂给同一个求值器, 而不是喂一个已知的完整序列」。
不是「移植过去的」,不是「保持同步的」。就是同一份代码。
以及那些不一致的行¶
| 维度 | 回测 | 实盘 | 一致? |
|---|---|---|---|
whole_shares = true |
被忽略 | 生效 | 否 |
compound = true |
生效 | 没有实盘路径 | 否 |
whole_shares 会把每笔实盘订单向下取整到整数股,而回测并不建模
它。在 include/qe/backtest、include/qe/strategy、src/backtest
里 grep whole_shares 一处都搜不到 —— 它只存在于
src/live/live_engine.cpp。
实测后果:约 14% 的资金被取整闲置。
而这件事对闸门是不可见的,因为闸门就是回测本身。
所以守护进程有一道启动闸门,在实盘无法复现回测仓位算法时 拒绝运行(退出码 4):
绝不悄悄降级。一次静默的仓位算法分歧让 2026-06 那次纸面 部署损失了六周 —— 它在下单,每一条日志看起来都活着, 而每条腿只买了一股。
第三部分 —— 实盘路径¶
重心所在
每一层的存在,都因为某个绿灯曾经撒过谎¶
到同一个网关的两条 TCP 连接 —— 行情走 client_id,
订单和持仓走 client_id + 1。
这不是风格问题:IB Gateway 禁止两条连接共用一个 client id, 而且它会在所有打开的 socket 之间交错发帧,所以一个空闲对端的 握手可能被无限期拖延。
意图
|
ReconcileGatedRouter <- 最外层,只能「拒绝」
|
PreTradeRisk <- 敞口上限,三种暂停来源
|
SafeBroker <- 单笔限额,kill switch
|
IbkrOrderRouter <- 10 秒确认超时
|
TWS
每一层回答的是不同的问题,衡量的是不同的账本。
对账闸门 —— 五条规则,条条承重¶
2026-07-31,守护进程在 09:30:31–33 记录了十笔成交。
它自己的对账器在九十秒内逐一否定了每一笔,全部读到 broker=0。
它继续对着一个不存在的账本交易。 第二天早上:账户空仓,没有挂单,权益没变。
一个唯一输出是日志行的探测器, 是一个没有人会去响应的探测器。
- 附加闸门 —— 只能把「通过」变成「拒绝」
- 绝不自动采纳 —— 它从不写入券商那边的数字
- 只有一件事能清除它:操作员确认,并陈述一个决定
- 按
(kind, symbol)打水位标记 —— 没有持久化的「永久忽略」 - 初始状态是「已阻塞」 —— 失败即关闭;一次被跳过的评估, 代价是一个不肯交易的守护进程,而绝不是一个未经对账就交易的
代价,写明而不是埋掉¶
闸门自己的文件头里有一节,标题就叫这个:
一次阻塞会同时挡住策略的平仓和开仓。没有办法诚实地 只挡一边:平仓的数量是按一个仓位算出来的,而这个闸门存在 的原因恰恰是我们无法描述那个仓位。
因此,一个在阻塞状态下持有真实仓位的守护进程, 并没有在管理它们。
以及哪些情况故意不阻塞:
- 不确定的对账结果 —— 「我们看不到交易所」不是漂移的证据, 为它阻塞等于把否决下个交易时段的权力交给 IB Gateway 的夜间重启
- 撤单,永远不挡 —— 它只会降低敞口
一份只列举优点的安全文档,是市场宣传。
一个不可能触发的断路器¶
日亏损断路器曾经衡量的是账户:
策略可以把它拥有的一切都亏光,而那个被盯着的数字纹丝不动。 这个断路器在数学上不可能触发。
现在两层都衡量策略自己的账本,对照策略自己的锚点。
策略的账本和账户是两个不同的对象 —— 账户里还有手工买入的
股票,通过 qe_daemon baseline adopt 声明。
把上限调高,不是作用域错误的修复方案。
若干常量¶
订单确认超时 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 秒窗口。
这里的数字是按实测最坏情况定的,不是拍脑袋。
两次什么都没记录的失败¶
预热常量。 守护进程对每个标的都向 IBKR 要
"60 D", "1 day" —— 约 41 根 bar,一个从它被写出来那天起
就没改过的字面量。
需要更多历史的配置没有失败;它正常启动,没有记录任何 异常,然后用 NaN 因子值开始交易。
而 NaN 在横截面预处理里排到最后 —— 于是一个只做多的 is_top
悄悄挑走了碰巧预热够了的那几个标的。现在改为从配置推算,
并且历史不足就拒绝启动。
单向的 kill switch。 断连约 30 秒后触发,故意设计成单向, 而且守护进程不会退出 —— 它继续活着,永久暂停。
进程守护(launchd
KeepAlive、systemdRestart=) 帮不上忙,因为没有东西死掉。
2026-06-19:19:58 ET 因断连触发 kill。守护进程就那样停了 两个半星期。
那个负责清除它的 supervisor 有四道围栏:只限纸账户、
kill_reason 必须精确等于 ibkr_disconnect、重启之前
交易所必须可达、滚动 24 小时内最多 N 次重启。
自动清除一个安全控制是可以存在的 —— 但它必须为自己辩护。
第四部分 —— 分析¶
要么测到了,要么写成「缺失」—— 绝不写成零¶
这一层发布的每一个数字,要么是测出来的, 要么被明确写成「缺失」。绝不写成零。
- 当多标的账本无法配对成完整往返时,
win_rate序列化为null,而不是0.0 provenance.not_computed点名说明哪些被跳过了、为什么- 优化器拒绝一个它无法排名的指标,而不是把那个格子记为零 —— 一个测到了的格子永远排在一个没测到的前面
positions_checked区分「两个账本一致」和 「两个账本从未被比较过」
这条原则只说一次,然后到处贯彻:
一个缺失的预期,不等于一个「预期为零」。
把沉默渲染成 0,曾经把账户里每一个合法持仓都变成了一行漂移。
第五部分 —— Dashboard¶
只读,由守护进程强制¶
一个 8 屏的即时模式 GUI:GLFW + Dear ImGui + ImPlot。
实时报价、K 线、任意外部 results.json 的实时视图、
一个按 mtime 监视的持仓文件 —— 里面还有一个 vim 风格编辑器。
架构上的决定不是那个 GUI,而是这个:
dashboard 以只读方式挂载到远端守护进程,而这个只读性质 是由守护进程里的
ReadOnlyControlHandler强制的 —— 不是靠 dashboard 的自觉。
一个客户端不能靠谎报自己的身份来获得信任。
qe_dashboard 还被拆开,好让测试套件不链接 GUI 那一半 ——
部署主机上没有图形栈。这个拆分是承重的:无头的 nix shell
故意不提供 X11,而这正是让该拆分的回归变得可见的原因。
第六部分 —— 纪律¶
没有 CI。这是故意的。¶
于是每一道本该住在流水线里的闸门,都被推到了人真正会遇到它的地方:
- 一段会和你争辩的 preset 描述
(
dist解释了为什么-march=native不能发布:它编码的是 构建机器的 CPU,所以在 M4 上切的版本会让每台 M1 收到 SIGILL) - 一个给 bundle 把关的发布脚本:每个 Mach-O 必须只链接
/usr/lib和/System、必须是 arm64、必须带着下载页所声称的 那个精确minos - 一个有真实退出码词汇表的每周开盘检查:
4没有守护进程 ·6没有配置目标 ·7目标不可达 requirements-docs.txt里的文档工具链钉版,因为一个裸的mkdocs是「机器上碰巧装了什么就是什么」
「红测试」不成立的三种方式¶
三种在这个仓库里都发生过。
1. 那个红其实是编译失败。 ctest 于是跑了一个过期的二进制并报告 PASS。 要确认「移除修复后」的构建退出码是 0。
2. 那个红来自弄坏测试,而不是移除修复。 它只证明了测试有能力失败。
3. 那个红来自一个从来没坏过的组件的单元测试。
OrderLog 被构造、传进 BrokerSession::wrap、存下来、
再被当作真相读回去 —— 而中间从来没有人调用过 append()。
整个部署期间那是个 0 字节的文件。
把 log 直接挂到 SafeBroker 上的测试,在生产接线被删掉之后
依然是绿的。只有组合层的测试能抓到它。
删掉你以为能修复问题的那行生产代码, 然后看哪个测试会注意到。
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
一份在负载下测出、又没有注明这一点的快照, 比没有快照更糟 —— 它会变成别人用来测量 一个幻影回归的基线。
这个项目记录自己的违规¶
CLAUDE.md 写着:一个 EPIC 一个分支,操作员批准,操作员合并。
而 EPICS/README.md 在一个以
「一次工作流偏离,记录在案」开头的标题下写着:
84、85、86、87 和 88 是直接在
main上完成的。 没有epic/NN-...分支,没有合并前的评审闸门。验收是真实的,每个文件都记录了它建立在什么之上。 合并纪律没有被遵守。这两件事都该记在这里。
同样地,EPIC-83 的 15 项遗留问题,2026-07-30 对着 main
重新核实过:一项都没关闭。 每一项都标注了失败即关闭还是
失败即开放,R1 仍然阻塞重新部署,还有两条验收记录被写明
没有证据就发布了。
一个只记录自己成功的仓库,是一个你无法使用其记录的仓库。
带走什么¶
1. 把不变量做成结构性的,而不是劝告性的。
未来函数之所以不可能,是因为 bar i+1 那时还不存在 ——
而不是因为有条注释请你别偷看。
2.「我说不准」绝不能渲染成「一切正常」。 一个家族里六个缺陷。缺少一次测量,和缺少一个发现, 是两句不同的话。
3. 说明你自己那套安全机制的代价。 对账闸门写明了它同时挡住平仓,以及一个处于阻塞下的守护进程 并没有在管理它的仓位。
4. 一个测试只有在你亲眼看它失败过之后才是真的 —— 对着被移除的修复,在缺陷真正所在的那个接缝上。
提问¶
仓库 约 22 万行 · 3,244 个测试 · 没有 CI
二进制 QE Dashboard.app (arm64,未公证)
守护 IBKR 纸账户,一个策略,30 个标的
文档 mkdocs -> GitHub Pages
CLAUDE.md 是约定文档。
benchmarks/README.md 是测量规程。
EPICS/ 是工作历史。