复盘:一个 `continue` 让风控把 13% 持仓当成 0,自动触发了 -15.7% 假熔断

问题背景

我在本地用 Python + DuckDB 跑了一套无人值守的模拟交易系统:多个策略账户,每天盘后自动拉数据、盯市、调仓、跑校验、出日报。其中有一条组合级风控------回撤熔断:从历史最高净值(peak)算起回撤超过 8%,自动把仓位降到 50%;回撤恢复到 5% 以内再解除。

某一天,日报弹出:

text 复制代码
🔴 组合回撤熔断触发,回撤 -15.7%,已自动降仓

但这个账户当天真实回撤只有 -3% 左右 ,离 -8% 的线很远。更糟的是,这次它不是只打了条日志,而是真的执行了卖出,把仓位砍到了 44%。

排查下来,根因不是阈值、不是网络抖动,而是一个非常经典、几乎每个写过数据处理代码的人都踩过的问题:

"查不到价格"被当成了"价格等于 0"。

完整脱敏代码和最小复现我放在了 GitHub(纯标准库、零依赖、带单元测试),文章里的数字和仓库里跑出来的一致: 👉 github.com/yinboblue/q...


一、风控逻辑本身没问题,问题在输入

熔断逻辑非常朴素:

python 复制代码
def check_drawdown_fuse(current_total, state):
    drawdown = current_total / state["peak_total"] - 1

    if drawdown <= -0.08:
        if not state["is_triggered"]:
            apply_fuse()              # 降仓到 50%
        state["is_triggered"] = True
    elif drawdown >= -0.05:
        state["is_triggered"] = False  # 恢复

逻辑没问题。问题 100% 出在 current_total(当前总资产)这个输入怎么算。

当前总资产 = 现金 + 全部持仓市值。持仓市值函数长这样:

python 复制代码
def calc_position_value(holdings, price_map):
    total = 0
    for code, h in holdings.items():
        price = price_map.get(code)
        # None / NaN / 非正价格防御
        if price is None or not (price == price) or price <= 0:
            continue                  # ← 罪魁祸首
        total += h["shares"] * price
    return total

这段代码"看起来很严谨":处理了 None、用 NaN != NaN 处理了浮点 NaN、挡了负价格。

但它对"查不到价格"的处理是 continue------当这只持仓不存在,市值按 0 计。


二、13% 的持仓权重是怎么凭空消失的

事故链条:

1)数据源残了。 盘后行情分批发布,叠加一段重试逻辑的边界问题,连续几个交易日落库的行情表严重残缺:正常应有数百只标的,事故期最少的一天只有 2 行 、最多也只有 29 行。关键点:更新任务有新行、没报错、正常退出------所以没有任何告警。双源 failover 防得住"全挂",防不住"残了"。

2)买入前有一道"可交易性"过滤。 临期/已公告退出的标的不能新买,一个 mark_tradeable 逻辑把它们从"当日可交易价格表"里剔除。

3)熔断检查复用了这张过滤后的价格表。

4)持仓里正好有 6 只标的不在这张表里。 估值函数遍历到它们,6 次 continue。

5)这 6 只合计约占整个组合 13% 的权重,从"当前总资产"里消失了。

为不暴露账户规模,统一用归一化净值指数 (历史峰值 = 100)对账(仓库 reproduce_incident.py 可复现):

text 复制代码
全量盯市净值指数(最后有效收盘价)≈ 97.2
被 continue 跳过的 6 只合计权重   ≈ 12.9 个指数点
风控看到的净值指数               = 84.3
历史峰值                         = 100

风控回撤 = 84.3 / 100 - 1 = -15.7%   # 击穿 -8%
真实回撤 ≈ -2.8%                      # 离熔断线很远

注意分子分母的口径:

  • 分子 (当前总资产)用的是过滤后的可交易价格池;
  • 分母 (历史峰值)是此前用全量盯市价格记账沉淀的。

同一份持仓,两个口径,这就是 bug 本体。 不是数值精度问题,是会计意义上的分子分母偷换。而日常净值曲线用全量盯市(含前值填充),始终显示正常------监控图岁月静好,风控账本万丈深渊,两套数字各跑各的。

text 复制代码
【出问题的口径】
  每日净值曲线 → 全量盯市价(含前值填充)→ 指数 97.2,正常
  熔断风控检查 → 可交易过滤价(剔除6只) → 指数 84.3,虚低
                 同一持仓,两套价格,必然打架

三、为什么第一次没卖,第二次真砍了

这是最反直觉、也最值得写的一段。

第一次触发:只标记,没成交。

熔断状态此前已经是"触发中"。而真正执行降仓的条件是"新触发 且 之前不在熔断"。状态已为真,条件不成立------系统只更新了回撤数字,没有卖出。一个 bug 被另一个状态判断意外挡住了。没有损失,也没有引起注意。

第二次触发:人工排雷反而拆掉了保险。

月末调仓日盘前,我看到 -15.7% 明显虚假,担心熔断导致今晚只调半仓,于是手动把熔断状态文件从"触发中"改回"正常"。

我还用前一天的快照 验证过:用那份数据重算确实是 -3%,解除安全。但当晚正式链路用的是当天更新后的数据 ------残缺依旧,6 只依然被剔除。系统重新算出 -15.7%,一看状态是"正常",判定为一次全新触发:

text 复制代码
新触发 且 之前没在熔断 → 条件成立 → 执行降仓到 50%

熔断本身的目标仓位是 50%。但当天正好是月末调仓日,熔断卖出和调仓成交在同一晚叠加,账户最终净仓位落到 44%------本来该满仓调进的票,也因为组合处于"已降仓"状态而没买够。

教训一:根因没修之前,手动翻风控状态位,等于火警误报时去把控制面板的灯按灭------你以为解除了警报,其实让系统以为"火是新烧起来的",于是它启动了自动喷淋。

教训二:验证"解除是否安全"必须用系统当晚真正会读到的那份数据,不能用前一天快照。用昨天的世界给今天的风控做担保,是这次最致命的想当然。


四、修复:把"可买池"和"估值池"拆成两张表

修复原则一句话:

决定"能不能买"的价格表,和决定"持仓值多少钱"的价格表,必须是两张表。

"可交易过滤"服务的是买什么 (临期标的不能新买,合理);"盯市估值"服务的是现在值多少、要不要砍仓(我手里的标的哪怕明天退市,今天也不是 0 元)。

修复后,风控估值对"持有但不在可交易价格表"的标的,查它最后一个有效收盘价 ;极端数据真空下用成本价兜底------任何情况下不允许按 0:

python 复制代码
# 风控专用估值表:当日可交易价 + 缺失持仓的保守兜底
fuse_price_map = dict(tradeable_prices)

missing = [c for c in holdings if c not in fuse_price_map]
if missing:
    last_valid = get_last_valid_price(missing, as_of_date)  # 最后有效收盘价
    for code, px in last_valid:
        if code in holdings and px and px > 0:
            fuse_price_map[code] = px
    # 极端真空兜底:连最后有效价都没有,用成本价,而不是 0
    for code in missing:
        if fuse_price_map.get(code, 0) <= 0:
            fuse_price_map[code] = holdings[code]["cost_price"]

pos_val = calc_position_value(holdings, fuse_price_map)

如果连成本价都没有(说明上游数据真的异常),修复版会直接抛异常而不是静默跳过:

python 复制代码
if not is_valid_price(value_map.get(code)):
    raise ValueError(f"无法对持仓 {code} 估值:缺少价格且无成本兜底")

用事故数据重放:6 只按最后有效价计入,回撤回到 -2.8%,熔断正确走"解除"分支。

三条可迁移到任何数据系统的原则:

  1. 缺失值要显式建模。 None / NaN / 查不到 的语义是"我不知道",正确动作是"保守估计 + 大声告警",而不是静默按 0。按 0 是乐观的静默错误------不抛异常,却污染下游所有决策,恰好和风控"宁可保守"相反。
  2. 同一数据服务不同决策时,按决策分别取数。 买什么、值多少、该不该砍仓,是三个决策,别图省事共用一张被业务规则过滤过的表。
  3. 风控输入口径必须和它要保护的那条净值曲线一致。 用什么口径记账,就得用什么口径算回撤。

五、连带 bug:摘牌实体从全量快照里"消失"

顺着"用过滤/最新分区数据做资产决策"这条线排查,又抓到一个同类问题。

这类标的的"退出名单"(公告退出、最后交易日)是每天一份全量快照表,原查询只取最新分区:

sql 复制代码
SELECT code, status, last_trade_date
FROM instrument_exit_snapshot
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM instrument_exit_snapshot)

问题在于:标的过了最后交易日、摘牌之后,就不会再出现在新的全量快照里。 我持仓里有一只标的(记为 X)在事故前几天的快照里还标着"公告退出、临近最后交易日",事故当天的新快照已经没它了 → 查不到退出记录 → "到期强平退出"逻辑永远不命中 → 该退出的标的一直挂在持仓里。

修复:不取"全表最新分区",而是按实体取它自己最后一次出现的记录:

sql 复制代码
SELECT code, status, last_trade_date FROM (
    SELECT *,
           ROW_NUMBER() OVER (
               PARTITION BY code ORDER BY snapshot_date DESC
           ) AS rn
    FROM instrument_exit_snapshot
) WHERE rn = 1

这条教训比业务本身通用得多:

任何"周期性全量覆盖"的快照表------退出名单、指数成分股、在职员工表、设备清单、好友关系------只要实体会"移出/删除/退市",就不能只取最新分区,否则这些实体的历史属性全部丢失。要按实体主键取"末值"。

另外加了一条监控:列出"历史出现过、但最新分区消失"的实体,触发数据核对,而不是让它们静默蒸发。


六、防复发:让这类 bug 在咬你之前自己跳出来

单点修复只是还债,真正防复发靠机制:

  1. 数据覆盖率门禁,而不是只看任务成败。 "没报错、有新行"不代表数据完整。现在每天校验关键表行数 vs 近 20 日中位数------从 300+ 只塌缩到二三十只必须当天报红。
  2. 口径一致性断言。 每天自动比对:风控估值口径与记账估值口径,同一时刻差异必须在容差内,两套口径再分叉测试先红。
  3. 禁止"把异常改成静默"而不补监控。 给会抛异常的函数加 try/except 让它不崩是对的;异常从此无声无息等于拆烟雾报警器。每把一个异常改静默,必须补一个等价告警。
  4. 故障注入验证监控本身。 故意把数据改成"全停 / 半残 / 陈旧",确认每种监控都能报红。没被故障注入验证过的监控,只是几行你以为会执行的代码。
  5. 风控改动必须能历史重放。 这次能把库和账户倒回当天、用修复代码重跑确认回撤从 -15.7% 回到 -2.8%,是我敢说"修好了"的唯一依据。

小结

很多人以为这类系统的风险是"模型预测错了"。我这几个月的体会是:

对个人规模的系统,真正烧钱的往往不是模型,而是数据和工程。 一个自以为严谨的 continue,一张该拆开却复用了的价格表,一次只取最新分区的 SQL,都能在你毫不知情时让忠实的风控做出最糟的决策。

模型错了,你还有止损;工程错了,止损本身会反过来砍你。

风控最难的不是写下"回撤 8% 就降仓",而是保证喂给规则的每一个数字,和你记账时看到的是同一个世界。


完整可运行复现(含错误版/修复版估值、熔断状态机、快照取数,17 个单元测试,纯标准库零依赖):

👉 github.com/yinboblue/q...

python scripts/reproduce_incident.py 可以直接看到归一化净值指数 97.2/-2.8%(真实)与 84.3/-15.7%(事故)的对比。如果觉得这个"数据缺失被当成 0"的坑你也踩过,欢迎点个 star 或在 issue 交流你们系统里是怎么处理缺失值口径的。

相关推荐
Solara1 小时前
我重算了 11 天,抓出自己抄错的 1 天:AI 会把"公式"记成"数列"
人工智能·程序员·ai编程
cyf313 小时前
程序员到架构师工作和生活反思
程序员·生活·架构师
Thneonl3 小时前
故意弄坏生产:混沌工程不是乱砸,是实验设计
后端·程序员
Thneonl3 小时前
别上来就 strace:60 秒十条命令看清一台病机
后端·程序员
Sam_Deep_Thinking4 小时前
CountDownLatch实现原理
java·后端·面试·程序员
程序员cxuan19 小时前
腾讯又来一王炸,开源版 WorkBuddy 太夯了!
人工智能·后端·程序员
穆梓兰煊20 小时前
WebAssembly AI 推理学习路线图
程序员
穆梓东海20 小时前
公司从 400 多人缩减到 100 多人,我开始重新思考程序员的未来
程序员
newerp20 小时前
Execution Trace 深入实战:可视化调度与系统停顿分析
后端·程序员·go