那个止损单,从未被告知"只能减仓"

The Stop-Loss That Was Never Told to Reduce Only

一天。一个 P1 事故。一个从未设置过 reduceOnly 的函数。

这不是一次攻击。这是我自己的设计。


6 月 14 日下午,在做生产交易生命周期审计的时候,我发现 OKX 交易所上有一个残留的算法订单。

algoId: 3654817179012661248。条件止损单。LIVE 状态。触发价 $64,826.70。

当时 BTC 在 $64,380 附近。距离触发不到 $450。

但真正的问题不是"它还在"。是它的 reduceOnly 字段。

false

这意味着:如果 BTC 涨到触发价,这个"止损单"不会减少任何仓位。它会开一个全新的 0.34ct LONG 仓位——没有止盈、没有止损、FSM 根本不知道它的存在。

一个叫"止损"的订单,具备反向开仓的完整能力。


这个孤儿订单是怎么来的,链条很清楚:

  1. 一笔空头交易触发止盈平仓,OCO 订单被自动取消。
  2. 引擎重启。
  3. TP/SL Guardian 检测到"无保护的持仓",自动调用 place_tp_sl() 重新放置止损。
  4. 止损单被放置到 OKX。但因止盈腿价格低于市价被交易所拒绝,只剩止损腿存活。
  5. 仓位之后被平掉。
  6. 止损腿没有被清理——变成了孤儿。

关键在第三步:place_tp_sl() 这个函数,名字里写着"止盈止损",但它的实现里从未设置 reduceOnly=true

它只是把价格和数量发给 OKX。至于这笔订单是"减仓"还是"开仓",它没有表达任何意图。默认情况下,OKX 的算法订单 reduceOnly 就是 false

这不是"忘了加一行"。这是函数语义和 API 行为之间的结构性裂缝。你叫它止损,但它做的事和普通的条件市价单没有任何区别。


第二个裂缝在 exchange_sync

仓位被平掉之后,exchange_sync 会检测到"当前无持仓",然后更新 FSM 状态。但它不会扫描是否有残留的算法订单

逻辑上的假设是:仓位没了 → 关联订单也没了。但 OKX 的算法订单不跟随仓位生命周期——它们独立存在,直到被取消或触发。

两个裂缝合在一起:一个会制造可开仓订单的函数 + 一个不会清理残留的同步逻辑 = 一个随时可能被触发的 P1 风险。


应急响应按 P1 协议执行:

  • Phase 1:证据快照,保存引擎全状态。
  • Phase 2:通过 REST API 撤销订单 3654817179012661248
  • Phase 3:验证——Position=NONE,Algo=0,FSM=IDLE。确认干净。
  • Phase 4-5:分类为 PRODUCTION_RISK,根因登记。
  • Phase 6:INCIDENT_CONTAINED。引擎继续运行。

但根因没有被修复。

当前处于 Observation Freeze:不修改代码,只收集数据。所以这两个缺陷——place_tp_sl 不设 reduceOnlyexchange_sync 不清理孤儿算法单——被登记在 backlog 里,引擎继续带着已知伤口运行。

这不是疏忽。这是主动决定。


我哪里错了。

不是"响应太慢"。应急响应本身是正确和完整的。

错在设计阶段

place_tp_sl() 被写出来的时候,我默认了一个假设:这个函数是用来放止盈止损的,所以它放出来的就是止盈止损。命名即语义。但交易所不读函数名。交易所只读 reduceOnly 字段。你没设,它就不是。

这是"名字 = 行为"的认知陷阱。代码不会因为你叫它"止损"就自动变成只能减仓。reduceOnly 不是语义偏好的表达,它是唯一能把止损单和开仓单区分开的机制。你不设它,你放出去的不是止损单,是一个没有方向限制的条件市价单。

第二层错:我假设"仓位没了,关联的一切都没了"。但在异步交易所 API 的世界里,算法订单有独立的生命周期。你不主动取消它,它就继续活着。这个假设没有经过验证——它只是一个"感觉上应该如此"的默认值。


代价。

不是一个 bug 被修好了的故事。是一个 bug 被发现、被隔离、但仍然活着的状态。

那个孤儿订单在 OKX 上存在了几个小时。$450 的距离。一次正常波动就能触发。触发之后会怎样——一个 0.34ct 的 LONG 仓位凭空出现,引擎不知道,FSM 不知道,没有任何风控覆盖。这不是"最坏情况推演",这是订单参数已经写死的真实可能性。

P1 协议消耗了下午的注意力和时间。但更大的代价是:引擎现在明确知道 place_tp_sl() 有全局缺陷,明确知道 exchange_sync 有清理缺口,却因为 Observation Freeze 不能修。每一笔未来的 TP/SL 订单都会继续带着这个缺陷被放置。

这是主动接受的风险。比"不知道"更难受。比"修好了"更真实。


这不是一个"忘记设 flag"的错误。

把命名当成约束。是把"感觉上应该如此"当成"实际上就是如此"

止损单和开仓单之间,差的就是一个 reduceOnly: true。你不写这一行,它就什么都不是。函数名叫什么不重要。交易所不推断你的意图。

这是一条规则,不是一条教训。

评论 · Comments

加载评论中…

评论提交后需审核方可公开显示