问题背景
我在本地用 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%,熔断正确走"解除"分支。
三条可迁移到任何数据系统的原则:
- 缺失值要显式建模。
None / NaN / 查不到的语义是"我不知道",正确动作是"保守估计 + 大声告警",而不是静默按 0。按 0 是乐观的静默错误------不抛异常,却污染下游所有决策,恰好和风控"宁可保守"相反。 - 同一数据服务不同决策时,按决策分别取数。 买什么、值多少、该不该砍仓,是三个决策,别图省事共用一张被业务规则过滤过的表。
- 风控输入口径必须和它要保护的那条净值曲线一致。 用什么口径记账,就得用什么口径算回撤。
五、连带 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 在咬你之前自己跳出来
单点修复只是还债,真正防复发靠机制:
- 数据覆盖率门禁,而不是只看任务成败。 "没报错、有新行"不代表数据完整。现在每天校验关键表行数 vs 近 20 日中位数------从 300+ 只塌缩到二三十只必须当天报红。
- 口径一致性断言。 每天自动比对:风控估值口径与记账估值口径,同一时刻差异必须在容差内,两套口径再分叉测试先红。
- 禁止"把异常改成静默"而不补监控。 给会抛异常的函数加 try/except 让它不崩是对的;异常从此无声无息等于拆烟雾报警器。每把一个异常改静默,必须补一个等价告警。
- 故障注入验证监控本身。 故意把数据改成"全停 / 半残 / 陈旧",确认每种监控都能报红。没被故障注入验证过的监控,只是几行你以为会执行的代码。
- 风控改动必须能历史重放。 这次能把库和账户倒回当天、用修复代码重跑确认回撤从 -15.7% 回到 -2.8%,是我敢说"修好了"的唯一依据。
小结
很多人以为这类系统的风险是"模型预测错了"。我这几个月的体会是:
对个人规模的系统,真正烧钱的往往不是模型,而是数据和工程。 一个自以为严谨的 continue,一张该拆开却复用了的价格表,一次只取最新分区的 SQL,都能在你毫不知情时让忠实的风控做出最糟的决策。
模型错了,你还有止损;工程错了,止损本身会反过来砍你。
风控最难的不是写下"回撤 8% 就降仓",而是保证喂给规则的每一个数字,和你记账时看到的是同一个世界。
完整可运行复现(含错误版/修复版估值、熔断状态机、快照取数,17 个单元测试,纯标准库零依赖):
python scripts/reproduce_incident.py 可以直接看到归一化净值指数 97.2/-2.8%(真实)与 84.3/-15.7%(事故)的对比。如果觉得这个"数据缺失被当成 0"的坑你也踩过,欢迎点个 star 或在 issue 交流你们系统里是怎么处理缺失值口径的。