在任何交易系统里,最攸关安全的操作,就是把一切平掉、回到现金。Flatten all positions 是紧急刹车——信号误触发、交易场地报错、风险限额被打穿的时候,它是最后一道防线。它应该是整个平台里最可靠的那个按钮。
听起来很简单。实际做起来,这是执行工程里最难的正确性问题之一。
这篇文章讲为什么——也讲在一次券商集成的 close call 之后,我们往 Dnalyaw 里加进了什么样的架构模式。Dnalyaw 刻意采用混合场地布局:Interactive Brokers(量化原生的机构标准,多数系统化基金都在用)加上 Futu HK(主打亚洲,迭代更快,也更贴近 retail-and-prop flow)。无论我们最后选定什么模式,它在两边的表现必须完全一致。问题的具体形态带着交易味儿,但底层的教训远不止于市场:验证层需要的是显式状态模型,不是 timeout。
为什么 Flatten 比看起来更难
直觉很诱人:全部平掉,回到现金,止血。但有三股力量,会联手让事情没这么简单:
- Fill 是异步的。 你提交 close。交易场地 acknowledge。fill 到了。几秒后,commission 又从另一条 callback 进来。“全部成交了吗?”——这个问题没有同步的答案。
- 你的内部持仓视图可能落后于现实。 大多数交易系统会维护一个内存里的持仓 snapshot,并用场地 callback 更新。如果 callback 延迟或丢失,snapshot 里就会躺着早已平掉的仓位——或者漏掉已经开出去的仓位。
- 验证本身也是一种动作。 如果你靠重读一遍持仓来检查“平掉没有”,那你信任的还是同一个数据源——而问题可能恰恰就出在它身上。
这三股力量会叠加。一个为了稳健而设计的天真 flatten,可能恰好级联成它本来要防住的那种失败:
策略发送 flatten。订单管理器发送 close。交易场地 acknowledge。verifier 读取持仓。ledger 还没有处理场地 callback,所以仍然反映 flatten 前的状态。verifier 得出“没平掉”的结论,又发出第二个 close。片刻之后,场地 callback 落地,ledger 更新——而第二个 close 打在一个其实已经正确归零的仓位上,成交了。你平过头了。
我们的一个交易场地——Futu HK——出过一次险情,追查下来正是这个 race。底层风险跟场地无关:每家券商的 callback path 都有自己的时序脾气,也各有各的办法让内部 ledger 跟不上现实。Interactive Brokers、Futu、未来任何新场地——这层抽象必须成立,否则整个技术栈就是建在沙子上。Futu HK 只不过是我们这组具体条件最先把问题暴露出来的地方。系统按设计运行。错的是设计。
第一层:别再耍小聪明
最直接的修法是把 retry 拿掉。flatten 之后的检查如果和预期对不上,系统就停手,不再有任何后续动作。检查本身留着当诊断日志,但系统会直接报错,而不是再发一笔订单。
这让系统躲开了这个具体的 race,但也让 flatten 变脆了:任何瞬态状况——partial fill、晚到的 acknowledgement、一次短暂的连接抖动——都会当场失败,因为 verifier 除了干等、再去读那个它本来就信不过的数据源,什么也做不了。
我们拿“宁可当场报错”换掉了“悄悄出错”。 这笔交换顶一周还行。但不能当长期设计。
第二层:验证意图,而不是验证 Ledger
关键就一句话:别再靠读 position ledger,去验证 order manager 刚刚做了什么。
order manager 本来就知道自己提交了什么,也本来就在接收 fill callback。每一次提交、每一次 fill 的系统记录——也就是 trade intent 及其结果的权威日志——才是该信的 source of truth。fill event 就是这个 log 自己产出来的;它不可能相对自己 stale。
第二层把 position-ledger diagnostic 换成了一个直接从权威日志读取的intent-lifecycle convergence check。
verifier 以亚秒级间隔轮询 order manager,总预算几秒钟。每次 poll 返回每个 symbol 的 lifecycle summary:提交了什么、成交了多少、订单到没到 terminal state,以及没有完整走完的话,原因是什么。
convergence 的定义很精确:每个 intent 都必须到达 terminal state,且必须完全成交。 空结果也接受——没有订单,就意味着没什么可平的。任何 partial fill、任何 non-terminal 的 intent、任何 terminal 但 underfilled 的 intent,到了预算边界都会直接报错。
没有第二次 submit。永远没有。这个 invariant 是永久的,在 regression-test suite 里有名有姓,每个 commit 都会重新断言一遍。
Intent 状态机
verifier 的决策是 intent state 的纯函数。它轮询的状态空间如下:
只有完全成交才算通过。其他所有情况,要么留在 poll loop 里等它走到 terminal state,要么 timeout、当场报错。Cancelled 和 rejected 的 intent 是 terminal,同时也是 failed——它们会带着 rejection reason 往上冒,让 operator 一眼就能看到 flatten 为什么没走完。
三种不同的 terminal failure modes,三种不同签名,三种不同 operator response。terminal states 的集合只在一个地方声明;没有任何 case 藏在代码里。
写死的保险
这一层不管怎么改,有两个 invariant 必须保住:
- 不 resubmit。 Stale read、partial fill、rejection——这些都不能触发另一次 close。verifier 的工作是观察,永远不是重新出手。
- flatten path 不读取 position ledger。 那个会撒谎的数据源,我们干脆问都不问。如果在预算内没法从权威日志建立 convergence,flatten 就直接报错,停下来等人处理。
安全关键的系统,需要的是写死的、叫得出名字的 invariant。可配置的限制,总会在凌晨 2 点被人改掉——尤其是仓位在流血、operator 已经累瘫的时候。常量不会。
我们没堵上的缺口
有一种失败模式,这个设计明确不修:如果 position ledger 在 flatten request 组装的那一刻就已经 stale——显示没有 open position,但场地上其实还有仓位——那整个 flatten plan 就是拿这份过期输入攒出来的。order manager 什么都不提交。verifier 看到空 intent set,照样放行。真实仓位还开着。
这个缺口是明着留的。要检测它,需要一个独立的 venue-position 数据源,在 flatten 还没提交之前,就和策略自己的视图交叉核对——那是另一条 workstream,有自己的设计文档和自己的测试。
我们把它写进了设计文档,而不是藏起来。一个会悄悄糊住另一类 bug 的 verifier,正是我们刚刚拆掉的那种东西。
测试面
我们不会假装 unit tests 能替代生产经验,但它们能抓 regression。八个 lifecycle 场景,加上一个带真实数据库和模拟场地的 integration test,覆盖了设计里承认的每一种失败模式:
- 第一次 poll 就 convergence(happy path)
- 后续 poll convergence(partial → full transition)
- 持续 partial fill 直到 timeout
- rejected intent,reason 往上冒
- failed intent,走同一个 terminal predicate
- 空 intent set(幂等 no-op)
- 执行中途的 cancellation
- 数据库错误传播
另外还有一条有名字的 invariant test:无论 verifier 看到什么验证输入,它都绝不 resubmit。这一层的每个 commit,都要先过这条 invariant 的关。
最微妙的是 cancellation 这条。天真的 poll loop 在两次 poll 之间会 sleep;operator 发出 cancel 时,它要等下一次 sleep 醒来才会发现。响应式的 cancellation,要求 sleep 本身可以被打断——这一点,等你第一次见到凌晨 2 点的 operator 刚按下中止、flatten 却毫无反应、人只能干坐着盯的时候,自然就懂了。
可以推广的教训
具体架构——权威日志当真相来源、lifecycle convergence 当验收谓词、terminal states 显式声明——是 Dnalyaw 特有的。可推广的原则不是。
验证层需要的是显式状态模型,不是 timeout。
timeout 告诉你系统不在预期的状态上,但不会告诉你为什么。是 fill 慢了?订单被 rejected 了?callback 丢了?三个答案对应三种完全不同的处置——而没有状态模型的时候,最顺手的下一步就是 retry,恰恰是这一步会制造级联失败。
显式状态模型——每个 intent 都有定义良好的 lifecycle,convergence 是这个 lifecycle 上的纯函数,failure mode 有名字、有 rejection reason——让 verifier 能分清“还在进行中”“terminal 但结果不对”“被风控拒了”。不同的失败,不同的处置。而且零 retry——因为观察就只是观察,不负责驱动。
retry loop 是这次 incident 的近因。而 retry loop 之所以会存在,是因为状态模型一开始就缺位。
如果你在审计任何系统里的 verifier——交易、分布式任务、长时间运行的 workflow——第一个要查的 invariant,就是它会不会 resubmit。 会 retry 的观察不是观察,是藏起来的控制。找到那一行,你就找到了整个系统里最容易把瞬态异常变成相关性异常的地方。
为什么这不只是 Flatten 的事
如果你正在评估一个量化交易平台——不管是作为投资人、quant 还是 operator——edge 就藏在这种工作里。研究层人人看得见,策略摆在明面上。正确性是在执行层一条 invariant 一条 invariant 攒出来的——而且大多攒在别人看不见的地方。
平台一旦跑在混合场地布局上,这种积累还会进一步放大。IB 是量化原生的机构标准;Futu HK 是通向亚洲的桥;两边 API 哲学不同、callback 时序不同、failure mode 也不同。一个在两边表现完全一致的 verifier,比只在一边能用的 verifier 说服力强得多——它说明这层抽象是真正承重的,不是碰巧成立。每接入一个新场地,走的都是同一个 verification gate;不会多出新的代码路径,也不会多出让 flatten 悄悄失败的新花样。
Dnalyaw 整个就建在一个 thesis 上:研究和执行的垂直整合,才是现代量化真正的护城河。Flatten verifier 只是看进这个 thesis 的一扇小窗。同样的设计纪律——写死的 invariant、显式状态模型、诚实记下剩余风险——贯穿风险引擎、场地适配器、回测到实盘的校准 pipeline,以及多区域执行布局。
Flatten 是正确性最要紧的地方。其他一切,都从这块地基上开始复利。