Skip to content

这是什么

▶ 以幻灯片打开 —— 方向键翻页。 本页是同一份 deck 的可读长文形态;下面那些幻灯片标记是 presenterm 指令,在网页上不显示。 源文件可直接在终端演示:presenterm docs/architecture-deck.zh.md

一个仓库里的 C++20 量化交易系统。

  • 零分配的回测引擎
  • 自带求值器的 .qe 配置语言
  • 跑在纸账户上的 IBKR 实盘守护进程
  • 终端风格的原生 dashboard —— 唯一交付的二进制
C++      370 个文件      13,101 图节点
Bash      23             46,292 条边
Python     5             约 220,000 行
TypeScript 1             3,244 个测试

有意思的不是规模,而是这份代码拒绝做什么, 以及那些「拒绝」里有多少是结构性的、而非劝告性的。

先说最重要的一条

最近十二个生产缺陷里,有六个是同一个形状:

一个健康信号,对着它并没有在测量的通道 报告一切正常

feed_healthy 在一整个零 bar 的交易时段里是绿的。 fill_integrity 轮询在一条从不下单的 socket 上。 unbacked_sells 数的是一个空账本。 broker_connected 用一个布尔值概括两条 socket。

其中三个,本身就是作为「监控」发布的。

于是有了这条规则,现在写在 CLAUDE.md 里:

一个健康信号,要么带着能把它逼红的测试一起发布, 要么就不发布。

全景

离线

  .qe 配置
      |
   求值器        <- 横截面预处理
      |
  MultiEngine   <- 热循环
      |
  Portfolio
      |
  指标 / IC / walk-forward
      |
  results.json + report.html

实盘

  qe_daemon
      |
  LiveEngine
      |
  OrderScheduler   <- 分批切片
      |
  [ 安全栈 ]
      |
  IbkrOrderRouter
      |
   TWS  (两条 socket)

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,两种语言

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

那条看起来像 bug 的规则

and / or 不做短路求值。两边永远都会算。

因为每个有状态的指标必须每根 bar 恰好被喂一次。 如果外层的 and 在左边已经为假的那根 bar 上跳过了右操作数, 那个 sma 就会漏掉一个样本,从此与价格序列失同步 —— 永久地,而且悄无声息。

同样的理由,同一个文件:即使参数用不上也要求值,窗口字面量 用 (void)eval_node(...) 走一遍。

一个有时被遍历、有时不被遍历的参数, 就是有状态指标失同步的方式

流式求值器的正确性,和表达式求值器的正确性不是同一个性质。 那个显而易见的优化,恰恰就是 bug。

七个原语,一个窗口

在一根 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 检查,被选成了 全池最好的那一个。

复杂度真正堆在哪里

全仓库的圈复杂度:

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。

它继续对着一个不存在的账本交易。 第二天早上:账户空仓,没有挂单,权益没变。

一个唯一输出是日志行的探测器, 是一个没有人会去响应的探测器。

  1. 附加闸门 —— 只能把「通过」变成「拒绝」
  2. 绝不自动采纳 —— 它从不写入券商那边的数字
  3. 只有一件事能清除它:操作员确认,并陈述一个决定
  4. 按 (kind, symbol) 打水位标记 —— 没有持久化的「永久忽略」
  5. 初始状态是「已阻塞」 —— 失败即关闭;一次被跳过的评估, 代价是一个不肯交易的守护进程,而绝不是一个未经对账就交易的

代价,写明而不是埋掉

闸门自己的文件头里有一节,标题就叫这个:

一次阻塞会同时挡住策略的平仓和开仓。没有办法诚实地 只挡一边:平仓的数量是按一个仓位算出来的,而这个闸门存在 的原因恰恰是我们无法描述那个仓位。

因此,一个在阻塞状态下持有真实仓位的守护进程, 并没有在管理它们。

以及哪些情况故意不阻塞:

  • 不确定的对账结果 —— 「我们看不到交易所」不是漂移的证据, 为它阻塞等于把否决下个交易时段的权力交给 IB Gateway 的夜间重启
  • 撤单,永远不挡 —— 它只会降低敞口

一份只列举优点的安全文档,是市场宣传。

一个不可能触发的断路器

日亏损断路器曾经衡量的是账户:

账户净清算值     约 $1,000,000   (纸账户)
阈值                 $15,000
策略最大可能亏损     约 $8,333    <- 毛额

策略可以把它拥有的一切都亏光,而那个被盯着的数字纹丝不动。 这个断路器在数学上不可能触发。

现在两层都衡量策略自己的账本,对照策略自己的锚点。 策略的账本和账户是两个不同的对象 —— 账户里还有手工买入的 股票,通过 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、systemd Restart=) 帮不上忙,因为没有东西死掉。

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/ 是工作历史。