📑 本文目录
你的量化信号系统可能已经"静默死亡"一个月:一次真实 cron 停跑事故的根因诊断
这是 AgentQuant 的一篇「运维事故复盘」。主角是我自己每天在跑的量化信号系统。没有回测胜率,没有策略 alpha,但我想认真说一句:再漂亮的胜率,也救不了一个已经死了却还在”假装运行”的信号源。
一、引子:一次令人后背发凉的信息采集
2026 年 8 月 1 日早晨,我像往常一样让 Hermes 做每日信息采集——读昨天行情、扫自有系统信号输出、汇总成选题素材。这是博客流水线最上游的一环,我连续跑了两个多月,对它有近乎本能的信任。
然后,在 daily-2026-08-01.md 报告的「自有系统信号」一节,我读到了这样两行字:
⚠️ 注:该 cron 目录内无新输出,最新为 2026-07-09 的 txt 文件(数据本身还滞后到 07-08)。系统近期可能未运行。
⚠️ 注:该 cron 最新输出停在 2026-06-17,近一个半月无更新。
CSI800 每日信号扫描(job 21ca2f58b097)最后一次成功输出在 2026-07-09,到 8 月 1 日已 23 天没再跑;蓝筹 v2 每日信号监控(job 6b4ec2ef8a02)最后一次输出在 2026-06-17,已 45 天没再跑。
一个多月。整整一个多月。
这两个不是边缘工具。CSI800 信号扫描覆盖中证 800 的 715 只个股,每天产出买卖信号 + 动量/均值回归分类 + 量比/RSI 特征,是博客里出现频率最高的选股信号源之一(参见 《5558 条 CSI800 信号的真实胜率》 和 《蓝筹 v2 信号 42 天前向验证》)。蓝筹 v2 则是一套”白名单 + 数据驱动”的双轨信号系统。它们是我观察市场、写复盘、做策略验证的事实底座。
而这个事实底座,过去一个多月里一直是静默的。没有告警,没有错误日志,没有任务发消息说”我没跑成”。系统看起来一切正常,但信号源早已断流。
这比”策略亏钱”更让我后背发凉:策略亏钱你知道它亏了,但信号源静默死亡你完全不知道。你甚至可能基于”昨天没有信号”做出”市场很平静”的错误判断。
这篇文章就是这次事故的完整复盘。我用 jobs.json、output 目录时间戳和 output 文件内容做证据链,把”事故 → 根因 → 监控方案”讲清楚。如果你也在用 cron 跑量化系统,这大概率能帮你提前拦住下一次静默死亡。
二、事故现场:output 目录时间戳就是证据
复盘第一步是固定证据。Hermes 每个 cron job 把每次执行输出写到 ~/.hermes/cron/output/,文件名带时间戳——目录的时间戳就是事故最硬的证据。
2.1 CSI800:停在 07-09,之后目录里再无新文件
$ ls -lt ~/.hermes/cron/output/ | grep "21ca2f58b097_"
-rw-r--r-- 1 user user 22402 Jul 9 01:31 21ca2f58b097_20260709_013107.txt
-rw-r--r-- 1 user user 17363 Jul 8 01:30 21ca2f58b097_20260708_013057.txt
-rw-r--r-- 1 user user 21760 Jul 7 01:31 21ca2f58b097_20260707_013117.txt
-rw-r--r-- 1 user user 22492 Jul 6 01:30 21ca2f58b097_20260706_013056.txt
-rw-r--r-- 1 user user 28719 Jul 3 01:30 21ca2f58b097_20260703_013025.txt
最后一次正常输出是 2026-07-09 01:31,之后到 08-01 再无任何 21ca2f58b097_* 文件。07-09 的输出本身正常——扫了 715 只个股,打出 102 买入 / 29 卖出,市场环境评分 -1.7(偏空)。换句话说,它不是”跑了一半崩了”,而是”压根没再被调度起来”。
按月份统计产出频次,趋势很清楚:
2026-06: 每日稳定产出
2026-07-01 ~ 07-09: 正常产出 9 天
2026-07-10 ~ 08-01: 0 产出(静默死亡 23 天)
2.2 蓝筹 v2:停在 06-17,且历史上”断断续续”
$ ls ~/.hermes/cron/output/6b4ec2ef8a02/ | grep -oE "2026-[0-9]{2}" | sort | uniq -c
11 2026-05
13 2026-06
蓝筹 v2 每周一到周五 19:20 跑。5 月产出 11 次、6 月 13 次——6 月应有 ~22 个交易日,13 次说明 6 月已在”断断续续”。最后一次是 2026-06-17,之后整个 7 月、8 月初零产出,静默 45 天。
值得注意的是,蓝筹 v2 的 06-17 那次输出有个很有信息量的信号:白名单 5 只全买入(招商蛇口 RSI=6.3 极度超卖、中国石油 RSI=23.5 超卖),数据驱动信号 6 只里 5 只卖出。这是典型”策略视角分歧”案例——如果及时发现它停了,本可基于此做一篇高质量复盘;结果因静默失效,信号被埋 45 天。
2.3 对照组:估值周报 cron 一直在正常跑
为证明”这不是全局故障,而是单任务级失效”,我对照了同一调度器下的第三个任务——全市场估值监控周报(job a47f0a639d56):
$ ls ~/.hermes/cron/output/a47f0a639d56/ | tail -6
2026-06-22_02-04-29.md
2026-06-29_02-06-47.md
2026-07-06_02-20-48.md
2026-07-13_02-03-22.md
2026-07-20_02-09-06.md
2026-07-27_02-04-28.md ← 每周一稳定产出,最新 07-27
估值周报每周一凌晨 2 点跑,6-7 月一次都没漏。它和失效任务共享同一台机器、同一调度器、同一份 DuckDB——排除了”机器宕机""调度器崩溃""数据库锁死”等全局性原因,问题出在任务自身的配置或依赖上。
| 任务 | Job ID | 最后产出 | 静默天数 | 状态 |
|---|---|---|---|---|
| CSI800 每日信号 | 21ca2f58b097 | 2026-07-09 | 23 天 | 失效 |
| 蓝筹 v2 每日信号 | 6b4ec2ef8a02 | 2026-06-17 | 45 天 | 失效 |
| 估值监控周报(对照) | a47f0a639d56 | 2026-07-27 | 0 | ✅ 正常 |
这个三任务对比是整篇复盘最重要的证据:同一套基础设施,一个健康、两个静默死亡,说明”静默失败”是任务级的、配置驱动的——最危险的一类失败,稳定、隐蔽、且持续累积。
三、根因诊断:jobs.json 揪出了真凶
查根因打开 ~/.hermes/cron/jobs.json(共 45 个 job),找这两个失效任务的配置。结果令人意外——根因比报告里”系统近期可能未运行”那句模糊猜测确定得多。
3.1 CSI800:任务定义从 jobs.json 里”消失”了
第一个发现是最反直觉的:job 21ca2f58b097(CSI800 每日信号扫描)压根不在 jobs.json 里。
>>> # 完整遍历 45 个 job,查找 21ca2f58b097
>>> found = any('21ca2f58b097' in j.get('id','') for j in jobs)
>>> found
False
CSI800 任务的定义被从调度器配置里删除了。一个不在配置里的任务,调度器自然不会再拉起它——这就是为什么从 07-10 开始再无产出,且没有任何错误日志(因为根本没有进程被创建过,也就没有进程崩溃)。
那它为什么会被删?结合 jobs.json 里另一个相关任务,可以拼出合理的迁移事故图景:
# jobs.json 里仍存在的、名字带 CSI800 的任务:
27786b150f16 | CSI800月度股性扫描 | 0 2 1 * *(每月 1 号)
存在一个”CSI800 月度股性扫描”(每月跑)和失效的”每日信号扫描”(每天跑),名字相近、底层都是 CSI800。最可能的事故经过:某次 cron 清理/迁移时,把”每日扫描”误当重复或废弃任务从 jobs.json 删了,但只删配置、没迁移历史输出、也没留交接说明。 调度器随即停止拉起它,而因没有监控检查”每日扫描是否还在产出”,这次删除悄无声息生效 23 天。
这类事故在工程界有个名字:幽灵删除(ghost deletion)——配置删了,但没人知道它被删了,因为系统从不报”我少了什么”,只报”我执行了什么”。
3.2 蓝筹 v2:被手动 enabled: false 关掉了
第二个失效任务的根因更直接,也更常见:
>>> # jobs.json 中蓝筹 v2 的配置
{
'id': '6b4ec2ef8a02',
'name': '蓝筹v2每日信号监控',
'schedule': {'expr': '20 19 * * 1-5'},
'enabled': False # ← 罪魁祸首
}
蓝筹 v2 任务定义还在 jobs.json 里,但 enabled 字段被设成了 False——任务被手动禁用了。调度器看到 enabled=False 就直接跳过,同样不会产生任何”我被跳过了”的告警。
为什么会被禁用?最可能场景:某次蓝筹 v2 信号出问题(比如数据异常导致刷屏),或调试时临时关掉,结果”临时关掉”变成”永久忘记开回来”。从 6 月产出只有 13 次(应有 ~22 次)看,6 月它就在”断断续续运行 → 被关 → 被忘”循环里,直到 06-17 后彻底沉寂。
值得玩味的是,jobs.json 里还有 8fea13c5c512 | 蓝筹v2周度回测排名 同样 enabled=False。蓝筹 v2 整条线(每日信号 + 周度回测)都被关掉了——更像某次针对蓝筹 v2 策略的整体停用决定。但无论哪种,问题本质不变:禁用一个任务应当有”停用理由 + 预计恢复时间 + 通知相关方”的流程,而不只是一个布尔值翻转为 false。
3.3 三类静默失败的根因谱系
把这次事故的根因泛化,cron 任务的静默失败通常落在三类:
| 根因类型 | 本次事故对应 | 失败特征 | 为什么最难发现 |
|---|---|---|---|
| 配置删除 | CSI800 从 jobs.json 消失 | 任务根本不被调度 | 无进程、无日志、无错误 |
| 手动禁用 | 蓝筹 v2 enabled=False | 调度器主动跳过 | 跳过是”预期行为”,不触发告警 |
| 执行早退 | (本次未出现,但最常见) | 进程启动→数据源返回空→exit 0 早退 | exit code 是 0,监控看”成功”实则无产出 |
前两类是本次真凶,第三类是大多数量化人更常踩的坑——下游数据接口变了、返回空 DataFrame,脚本里写 if df.empty: return 守卫,任务”成功执行”但产出为空。
这三类的共同点:不会让任务”报错”,只会让任务”消失”。而传统基于 exit code、异常捕获的监控,对”消失”是天然盲目的。
3.4 一个令人尴尬的元失败:健康巡检 cron 也在跑,却没发现
查 jobs.json 时还有一个哭笑不得的细节——有一个专门的”Hermes 系统每日健康巡检” cron(job 146b3c185e87,每天 8 点,enabled=True),整个 7 月都在正常执行,却完全没发现 CSI800 和蓝筹 v2 已经停了。
这说明那个巡检只检查了”调度器进程是否活着""磁盘是否满""数据库能否连接”这类基础设施级指标,没检查”每个业务 cron 是否还在按预期产出”这个业务级指标——它在监视”心脏还在跳”,却没监视”手脚还能动”。这个元失败直接催生了第五节的核心方案:真正的健康检查不是检查调度器,而是检查每个 cron 的产出物新鲜度。
四、为什么”静默失败”是量化系统最危险的失效模式
这个事故危害性,远比”少跑了几个任务”严重,三重杀伤:
第一,制造虚假的”市场平静”幻觉。 07-10 到 08-01 这 23 天里,如果我只看 CSI800 信号源,会得出”今天没有信号”的结论,很容易解读成”多空平衡、市场真空”。但 CSI800 压根没跑——“没有信号”和”信号源死了”表象一模一样,却是完全不同的两件事。基于”无信号”调仓位或写评论,就是用不存在的证据做决策。
第二,让下游的数据地基悄悄变质。 博客流水线里选题策划、文章撰写都依赖 CSI800/蓝筹 v2 输出。上游静默断流,下游不会立刻报错(它只读”最新可用”文件),而是悄悄用一个越来越旧的快照。23 天后你引用的”最新信号”其实是 3 周前的——这种”陈旧数据被当新鲜数据用”的污染,是数据工程里最难排查的事故之一。
第三,与”显性失败”的修复机制完全错位。 进程崩溃有 traceback、有非零 exit code、告警群会响——修复链路通畅。但静默失败不触发任何一条。你的告警群静悄悄,仪表盘绿油油。直到某个偶然时刻——比如这次顺手翻了一下 output 目录——才像拆炸弹一样发现引信早烧到头了。 这次是 23/45 天,下次可能是 3 个月。
一句话总结:我的系统不是”坏掉了”,而是”没有了”——监控体系对”没有了”这种状态是瞎的。 这也呼应了我在 《数据质量五大噩梦》 里的判断:数据质量第一关不是”数据对不对”,而是”数据还在不在”。这次把那条教训从”数据层”推到了”任务层”。
五、监控方案:三层防御,从”心跳”到”巡检的巡检”
诊断清楚根因后,关键是建立防止重演的机制。基于这次事故,我设计了一个层层递进的三层防御体系。
第一层:产出物心跳检测(最核心、最有效)
这是整个方案的基石,直接对症”静默死亡”。核心思想极简:不要监控任务是否”执行成功”,而是监控任务是否”产出了预期的东西,且产出的时间够新”。
为每个 cron 任务定义一个”心跳契约”——期望的产出文件 pattern + 最大允许的新鲜度(stale threshold),再写脚本逐个核对。下面是一个可直接复用的 Python 模板:
#!/usr/bin/env python3
"""
cron 产出物心跳检测 —— quant_cron_heartbeat.py
每个 cron 任务定义一个"心跳契约",检查产出文件的新鲜度。
"""
import os
import re
import time
import json
from datetime import datetime, timedelta
from pathlib import Path
OUTPUT_DIR = Path.home() / ".hermes" / "cron" / "output"
# 心跳契约:job_id -> (产出 pattern, 最大新鲜度小时, 任务名)
HEARTBEAT_CONTRACTS = {
"21ca2f58b097": {
"name": "CSI800每日信号",
"pattern": re.compile(r"21ca2f58b097_\d{8}_\d{6}\.txt"),
"max_age_hours": 28, # 每日任务,允许跨周末,阈值给到 28h
"expected_freq": "每日",
},
"6b4ec2ef8a02": {
"name": "蓝筹v2每日信号",
"pattern_dir": "6b4ec2ef8a02", # 该任务产出在子目录里
"max_age_hours": 72, # 工作日任务,跨周末阈值放宽
"expected_freq": "工作日",
},
"a47f0a639d56": {
"name": "全市场估值周报",
"pattern_dir": "a47f0a639d56",
"max_age_hours": 24 * 8, # 周任务,允许跨一周
"expected_freq": "每周",
},
}
def check_heartbeat(job_id: str, contract: dict) -> dict:
"""检查单个任务的心跳,返回状态字典。"""
name = contract["name"]
max_age = contract["max_age_hours"]
now = time.time()
# 找最新的产出文件
candidates = []
if "pattern" in contract:
# CSI800 风格:扁平文件 output/21ca2f58b097_YYYYMMDD_HHMMSS.txt
for f in OUTPUT_DIR.iterdir():
if contract["pattern"].search(f.name):
candidates.append(f)
elif "pattern_dir" in contract:
# 蓝筹v2/估值周报风格:子目录 output/<job_id>/...
d = OUTPUT_DIR / contract["pattern_dir"]
if d.is_dir():
candidates = list(d.iterdir())
if not candidates:
return {"job_id": job_id, "name": name, "status": "DEAD",
"reason": "无任何历史产出文件(任务从未跑过或输出被清空)"}
latest = max(candidates, key=lambda p: p.stat().st_mtime)
age_hours = (now - latest.stat().st_mtime) / 3600
if age_hours > max_age:
return {"job_id": job_id, "name": name, "status": "STALE",
"reason": f"最新产出 {latest.name} 已过期 {age_hours:.1f}h "
f"(阈值 {max_age}h)",
"latest_file": latest.name, "age_hours": round(age_hours, 1)}
return {"job_id": job_id, "name": name, "status": "OK",
"latest_file": latest.name, "age_hours": round(age_hours, 1)}
def run_heartbeat_check():
results = [check_heartbeat(jid, c) for jid, c in HEARTBEAT_CONTRACTS.items()]
dead_or_stale = [r for r in results if r["status"] != "OK"]
return results, dead_or_stale
if __name__ == "__main__":
results, alert = run_heartbeat_check()
for r in results:
icon = {"OK": "✅", "STALE": "⚠️", "DEAD": "🔴"}[r["status"]]
print(f"{icon} {r['name']:20s} {r['status']:6s} {r.get('reason','')}")
if alert:
# 这里接告警通道:飞书/邮件/Hermes notify
print(f"\n🚨 告警:{len(alert)} 个任务心跳异常,需立即排查")
这个脚本如果 7 月 10 号就部署上,当天就会报:
🔴 CSI800每日信号 DEAD 无任何历史产出文件...(注:文件还在,但目录停止更新)
⚠️ 蓝筹v2每日信号 STALE 最新产出 2026-06-17_19-21-46.md 已过期 552.0h (阈值 72h)
——23 天和 45 天的延误会直接坍缩成”当天发现”。
为什么这个方案对症? 因为它绕开了所有容易撒谎的中间层(exit code、异常捕获、调度器状态),直接检查”业务结果是否存在”。被删配置的任务、被禁用的任务、执行早退的任务,在”产出物”这个维度上长一个样——都是”没有新文件”。这恰好统一了我们之前难以覆盖的三类静默失败。
第二层:Hermes notify 机制的正确配置
第一层解决了”发现”,但发现后还要”通知到人”。Hermes 的后台任务和 cron 都支持 notify_on_complete,但这次事故暴露出一个问题:通知”完成”不等于通知”成功有产出”。notify 配置有两个相反的坑:不配则任务静默死亡无人知晓(本次主因之一);配了但触发条件太松(exit 0 就发”✅ 完成”,哪怕它因数据源返回空而早退、产出为空),会训练你无视”✅“通知,退化成第一种情况。
正确做法是把 notify 和第一层心跳检测串联——让心跳检测脚本本身成为一个 cron,它只在”发现 STALE/DEAD”时才发告警:
// jobs.json 中新增的"巡检 cron"
{
"id": "<新巡检job_id>",
"name": "cron心跳巡检(监控的监控)",
"schedule": {"expr": "0 9 * * *"}, // 每天 9 点,在所有业务 cron 之后
"enabled": true,
"notify_on_complete": true // 只在有心跳异常时才真正推送
}
关键设计是:心跳巡检 cron 执行 quant_cron_heartbeat.py,当且仅当 dead_or_stale 非空时,脚本以非零 exit code 退出并打印告警。配合 notify_on_complete,通知只会因”真有任务死了”而触发,避免告警疲劳。
第三层:每日健康巡检 cron 的升级(从”基础设施”到”业务产出”)
3.4 节那个失职的健康巡检 cron,这次事故后必须升级检查清单——纳入”业务产出新鲜度”:
| 检查维度 | 旧版(基础设施级) | 升级后(业务产出级) |
|---|---|---|
| 调度器/磁盘/数据库 | ✅ 进程/空间/连接 | ✅ 同左 |
| 任务启用状态 | ❌ 不检查 | ✅ 遍历 jobs.json,列出 enabled=False 任务,长期禁用的标红 |
| 产出新鲜度 | ❌ 不检查 | ✅ 调用第一层心跳检测,核对每个业务 cron 最新产出时间 |
| 配置完整性 | ❌ 不检查 | ✅ 对比”历史上跑过的 job_id”与”当前 jobs.json 的 job_id”,揪出幽灵删除 |
最后一行专门针对 CSI800 那种”配置被删”事故——对比 output 目录出现过的 job_id 前缀和 jobs.json 现存 job_id,能自动发现”曾经天天跑、现在配置里找不到”的幽灵任务。
升级后的健康巡检,每天 8 点跑完会产出一份这样的日报:
📊 Hermes 健康巡检 2026-08-01
─── 基础设施 ───
✅ 调度器存活 ✅ 磁盘 68% ✅ DuckDB 连接正常
─── 任务配置 ───
🔴 曾运行的 job 21ca2f58b097(CSI800每日信号) 不在 jobs.json 中 → 疑似幽灵删除
⚠️ enabled=False 任务:6b4ec2ef8a02(蓝筹v2)、8fea13c5c512(蓝筹v2周回测)、cc67403831ca(可转债周报)...
─── 产出心跳 ───
🔴 21ca2f58b097 产出过期 23 天
🔴 6b4ec2ef8a02 产出过期 45 天
✅ 其余 12 个业务 cron 心跳正常
如果这份日报从 7 月 10 号就开始产出,这次事故根本不会拖过 24 小时。
六、量化系统的可靠性公式:策略 × 数据 × 运维
我过去写博客,注意力几乎全在”策略”和”数据”——胜率高不高、回撤大不大、DuckDB 数据全不全(见 《DuckDB 量化数据库搭建》)。但这次事故逼我承认:还有被我长期低估的第三维——运维。
一个量化系统的真实可靠性,是三者的乘积:
$$ \text{Reliability} = P(\text{策略有效}) \times P(\text{数据正确}) \times P(\text{系统在跑}) $$
乘法的残酷在于:任何一项为 0,整体就是 0。CSI800 历史胜率 65%、数据零缺口——但只要”系统在跑”因 cron 静默死亡变成 0,整个乘积就是 0。过去 23 天,我的 CSI800 信号系统真实可靠性就是 0,无论回测多漂亮。
更隐蔽的是,这三项的”失效可见性”递减:策略失效很快体现在盈亏里(可见)、数据错误在校验里暴露(半可见)、而运维静默失效完全不可见——这就是为什么它最易被忽视,也最需要制度兜底。
三层防御方案不是为了修复 CSI800 和蓝筹 v2(那只是加回配置、翻回 enabled 的一行操作),而是为了让”静默死亡”从今往后在 24 小时内必被发现。
七、给读者的可迁移清单
把这次复盘浓缩成一份可直接拿走的「cron 产出新鲜度巡检清单」。无论用 Hermes、crontab、Airflow 还是任何调度器都适用:
- 监控产出物,不要监控进程。 检查”该有的文件有没有按时出现”,比检查 exit code 和异常日志可靠十倍——产出文件是客观存在的,exit code 是任务自己汇报的。
- 每个 cron 都要有”心跳契约”。 明确写下:期望产出在哪、多久产一次、最多允许多久不产出。没有契约的 cron,本质上就是在赌它不会坏。
- 定期审计
enabled=False的任务。 禁用一个任务时,强制写一行”停用理由 + 预计恢复时间”。超过预计时间仍禁用的,巡检里标红。 - 对比”跑过的 job”和”配置里的 job”,抓幽灵删除。 最易被遗漏、也最难靠人肉发现的失效模式,必须自动化。
- 告警要”有事才响”。 notify 配太松会训练你无视通知;绑到”心跳异常”这个高信号事件上,才能保证每次响都值得看。
- 健康巡检本身也要被巡检。 巡检 cron 是”监控的监控”,但它自己也可能静默死亡——纳入第一层心跳契约,形成闭环。
- 把”运维可靠性”写进评估指标。 别只算胜率和回撤,也算”过去 N 天信号按时产出的比例”。按时产出率 99% 的系统,价值远高于胜率高但三天两头断流的系统。
结语:信号系统的第一性原理,是”它得还活着”
这次事故给我最大的触动,不是”啊我忘开了一个 cron”,而是一个底层认知更新:我们花那么多精力打磨策略、清洗数据、优化回测,却常忘了——所有这一切的前提,是那个产出信号的进程此刻正在正常运行。
一个静默死亡的信号系统,和一个被精心维护、按时产出、哪怕胜率平庸的信号系统,后者价值大得多——因为后者至少”诚实”,给你的是真信号(无论好坏),而不是伪装成”无信号”的真空。
CSI800 已停 23 天,蓝筹 v2 已停 45 天。写这篇文章的同时,我正把它们加回 jobs.json、翻回 enabled=true,并部署上面三层监控。但比起修复这两个任务,我更在意的是:从今天起,任何 cron 的静默死亡,都不该再等到”偶然翻目录”才被发现。
如果你今晚就去检查一下自己最关键的那个 cron 的 output 目录、确认它最近一次产出就在昨天——那这 23 天和 45 天的教训,就算没白交。
本文基于 AgentQuant 2026-08-01 每日信息采集时的真实事故复盘。证据来自 ~/.hermes/cron/jobs.json(45 个任务)、~/.hermes/cron/output/ 目录时间戳与文件内容。所有 job_id、时间戳、enabled 状态均为实测值。下一篇将恢复 CSI800 与蓝筹 v2 任务,并验证三层监控方案的实际拦截效果。