记录一次毕业门槛的落地(2026-08-17)
记录一次毕业门槛的落地(2026-08-17)
一个策略能不能"毕业",不该由它自己说了算
前几周 factor_ic 一直在攒样本,但"攒到什么时候算成、什么时候算废",始终没被写成硬规则——靠人看、靠感觉判。这天我把这件事做完了:把门槛冻进 PREREG,再落一个每小时跑一次的跟踪器,让它替人盯着。
一、先把门槛冻死
动手写码之前,第一件事是冻结。
PREREG 的三处关键约定——L73 / L75 / L104——逐条核验通过,完全冻结生效。§10 里原本还留着两处"未冻结项:7 天阈值、3 次 tick 阈值——实施阶段按实际数据决定",这次一并回填成硬值:
- KILL 触发 = 7 天(一周复盘周期,够短能响应、够长不误杀)
- regime halt 升级 = 连续 ≥3 tick
- tracker tick = 1h(故 3 tick = 3h)
tick 为什么定 1h,也写明了理由:5min 太密、OOS 没意义;6h 太慢、KILL 响应要等 18h。1h 正好卡在中间。同时补了一句"tracker tick 间隔 = 1h,由专用 automation 驱动",把驱动方式也消了歧义。
先写规则、再看数据,和先看数据、再补规则,是两件事。 前者是纪律,后者是解释。
二、两个脚本:一个只算,一个才敢动
核心设计就一句话:把"看"和"动"彻底分开。
factor_ic_oos_tracker.py—— 只读、只算。它读hl_smart_money.db和factor_monitor的主网 K 线,算 Gate1-4 加 regime 回落率(算法 B,滚动 N=30),把结果写进factor_ic_oos.db的oos_ticks表,并写factor_ic_oos_alerts.md和 pending log。它没有--apply-gate,也绝不触发任何动作——不翻 L359、不重启进程。factor_ic_oos_apply.py—— 独立执行器。只有显式带上--confirm,它才会执行 on_graduate 5 项 / on_halt 4 项那几件动作。
这个拆法的目的只有一个:最严的 fail-closed。 tracker 每天跑在无人值守的 automation 里,它一定会撞上烂数据、烂网络、烂时刻——但只要它连"触发动作"的能力都没有,它跑烂一百次,也绝不会误让一个策略毕业。 动作权限被关进了另一个脚本、另一道 --confirm 闸门里。
顺带一个工程细节:tracker 刻意用纯 stdlib 实现——binomial 双尾自己写(与 scipy 等价),Sharpe = mean/std×√504、样本 std 用 ddof=1,maxDD = 累计 PnL 的峰谷/峰。少一个依赖,就少一个在无人值守时坏掉的理由。
三、tick 交给 automation,不去碰 watchdog
tick 由谁来驱动,那天是一次否决。
原本的建议是把 tracker 挂进 watchdog 的 WATCHED 列表,让它常驻循环。我否掉了,理由三条:
- 改 WATCHED 必须杀旧 daemon 重启进程——那就是"最小侵入"的破口;
- Stage 0 的红线本来就是"不碰 watchdog 结构",加项虽是增量,也还是动了结构;
- 更关键的是不需要——有个已验证的模式可以直接复用:注册一个
automation(FREQ=HOURLY, INTERVAL=1),跑factor_ic_oos_tracker.py once,不碰 watchdog、不新增常驻 daemon、精确 1h tick,跟冻结的 tick 定义严丝合缝。
于是先后注册的是 automation-1786968525546,每小时跑一次(tracker 的命令行是 once 子命令)。
通知通道也是同一种克制:**factor_ic_oos_alerts.md 作持久可读的 artifact**,而"投递到人"这一步,交给 automation 跑完 tracker 时输出的 stdout 中文 summary。一个负责留痕、一个负责提醒,谁也不依赖外部通道。
四、第一次 dry-run:NO_ACTION
跑码前还特意标了一个 TODO:先查清 factor_monitor 到底怎么暴露 K 线,不许凭猜。 查清了才动笔——POST {MAINNET_INFO}/info,请求体是 {"type":"candleSnapshot","req":{...}},每根 K 线带 t(open time,毫秒),可以直接 import fetch_candles from factor_monitor 复用。
首次 dry-run(20:07)的结果是这样的:
| Gate | 值 | 阈值 | 判定 |
|---|---|---|---|
| Gate1 样本 | 17 | ≥20 | ❌ → 早退 |
| Gate2 p | 0.6291(win 10/17) | <0.05 | ❌ |
| Gate3 Sharpe | 4.712 | ≥1.0 | ✅ |
| Gate4 maxDD | 68.5% | ≤30% | ❌ |
| regime 回落率 | 53.8%(n=13) | ≥50% | ✅ |
决策:NO_ACTION。 Gate1 只有 17 笔、不到 20,按规则早退,继续攒样本——这正是"第一天起算、自动动作默认关闭"该有的样子。
但表里有一行必须单独说:Gate4 的最大回撤已经 68.5%,远超 30% 阈值。 这意味着等 A 臂平仓攒到 ≥20 笔那一刻,factor_ic 大概率不是毕业,而是被 Gate4 直接硬阻断。这是纪律在生效,不是 bug——胜率和 Sharpe 都好看,但 OOS 的回撤自己会说话。
这个 68.5% 当时还引出一场口径争论。三种算法给出三个数:按现在的 bare-PnL(分母是累计盈利峰值 $7.11)算是 **68.5%**;换成 $80 + PnL 是 **5.6%**;换成全账户 $10000 + PnL 只剩 0.05%。最后确认 30% 这个阈值本来就是按 bare-PnL 校准的——锚点是 8-13 回测里见过的那两个 maxDD(54.9% / 587%),而 587% 本身就说明了口径:标准权益曲线的回撤上限就是 100%,能算出 587% 的,只有 bare-PnL。顺着曲线核了一遍:第 2 笔就转正(cum=0.77),峰值 $7.11 出在第 12 笔 OP,68.5% 的回撤来自第 13 笔 AVAX 那一单亏 $4.87。数字是真的,但要诚实标注——它衡量的是"盈利回吐比例",不是"本金回撤比例"。
还有一条当天就点出的隐患:那笔 AVAX 的巨亏单是 maxDD 的元凶,属于领先指标,而 Gate4 是事后指标;单笔巨亏告警没被任何 gate 覆盖,被标进了下次 PREREG 修订的候选。
五、A 臂在 testnet,K 线为什么用主网
写码时冒出一个必须拍板的开放问题:A 臂的仓实际开在 testnet(hl_testnet.db),算 forward return 该用主网 K 线还是 testnet K 线? 这直接决定代码怎么写。
最后的拍板是用主网,理由不止一条:PREREG §5.1 已经把数据源唯一绑定到 factor_monitor 主网 K 线;factor_monitor 自己研究因子用的也是主网;而最实际的一条是——testnet 的 K 线流动性太稀薄,factor_monitor.py 的注释里就写着"testnet 成交量极稀薄(BTC 1h volume 常 < 2)",拿它算 forward return,噪声会盖过信号。
顺带纠正了一个假设:sm_positions.side 存的是 SHORT/LONG,不是 BUY/SELL——所以 PREREG §2.3 写的 side='SHORT' 本来就对,不用改。这就是写码前先核 schema 的收益:省掉一次本不存在的"修正"。
六、一次"没有采纳"的分歧
那天还有一个我特意没接的建议。
有人提议把 §10 的 KILL 条件扩写一句:"KILL 仅在该策略曾毕业、或 Sharpe<0.5 且样本充足持续 7 天"。听起来更严谨,但我没采纳,理由三条:
- R5 约束:PREREG 已冻结,扩写等于改冻结项,必须重写、重审;
- 现状不构成该场景:A 臂当时样本还很薄,"曾毕业"这个前提永远不成立,而"Sharpe<0.5 且样本充足持续 7 天"比当前所有 gate 都严——等于把 KILL 推到几乎不可能触发;
- 真正该 KILL 的场景,现有条目已经覆盖。
所以处理方式是:不写进冻结正文,只标进 §10 的"未冻结扩展(future TODO)"。
这件事在我看来比它表面重要:对冻结项的最大考验,往往不是别人来改它,而是自己想"顺手优化"它。 那次我选择不动——因为一旦为了一次"更好的表达"破例,冻结就不再是冻结了。
七、顺手看的 hummingbot:看开源,不等于抄开源
同一天还做了一件事:评估 hummingbot。
结论是整体不引入主路径。它的定位本就不同——交易所连接器 + 策略运行时 + 做市执行层,不是回测 / 因子研发平台;虽然仓库很活跃(两周内有 commit、19.5k stars、CoinAlpha 商业化背景),HL 支持也做得不错(现货 + 永续 connector 都接了,连 8 月新出的 HIP-3 DEX 市场都覆盖了),但它解决的不是我们这条线上的问题。
真正值得拿走的是三个具体工程模式:
- HL connector 的
processed_coins + seen_keys轮询清理——避免平仓后资产消失导致的仓位残留; action_hash的签名实现(msgpack + 8B 大端 nonce + vault flag 字节 + keccak)——可用来核对本地签名实现的一致性;pmm_dynamic.py的 z-score 信号标准化——用于因子值跨波动率环境的对比。
看一个开源项目,最省事也最常犯的错,是"它整体很好,所以整体都值得学"。 这次做法相反:整体读一遍、判定它不解决我们的问题,然后从里面只挑出三个能直接用的螺丝。评估的产出不是"用不用",而是"哪几颗能用"。
最后
这天分三层。
最表面是一套脚本加一条 automation:factor_ic_oos_tracker.py 只算、factor_ic_oos_apply.py 才敢动,automation-1786968525546 每小时推一次。
中间一层是那个设计原则:给"看"的能力拉满,把"动"的能力清零,再用一道 --confirm 把两者隔开。监控链路可以坏,但绝不能因为坏而误改生产——能被随手触发的门槛,等于没有门槛。
最里面一层,是关于"门槛"本身。 毕业门槛的意义,正是在策略看起来不错的时候,仍然按事先写好的规矩办。第一次 dry-run 里 Sharpe 4.712 好看得像要毕业,可 Gate4 的 68.5% 已经投了否决票——我该做的不是去调那 30% 让它好过一点,而是接受它。 门槛是先钉、后量的;先量、后钉的,那就不叫门槛,叫解释。