M2 同比 353 万%、主力资金表凭空消失:一个量化库的 5 个数据噩梦与自救指南
TL;DR 一个量化策略系统的可靠性瓶颈往往不在策略本身,而在它依赖的数据管道。本文用 5 个真实踩坑——M2 同比 353 万%、主力资金表凭空消失、可转债数据停更 24 天、期货 ETL 滞后 4 周、cron 静默死亡——讲述如何从”写代码采集数据”到”让数据自己证明健康”。
引子:当估值周报吐出一个荒诞的数字
2026 年 6 月 29 日凌晨,我的估值周报 cron 和往常一样自动运行。它从 DuckDB 读取宏观数据,生成行业估值地图,然后飞书群推送。一切看起来都很正常——直到我扫了一眼 M2 数据行:
M2:3530425.21(亿元),同比 +8.6%
等等。M2 同比 353 万百分之一?
即使按最激进的货币宽松估算,M2 增速也不可能超过 100%,遑论 353 万%。这个数字如果进入任何策略——估值模型、宏观因子、风险预算——都会瞬间污染下游所有计算。
当天晚上我坐下来,把量化数据库里所有”看起来不对劲”的地方翻了一遍。找到了 5 个实实在在的数据质量噩梦。它们不全是我的错——有些是数据源接口变更、有些是 cron 静默死亡、有些是设计之初就没有考虑数据保鲜。
但噩梦醒了之后,也留下了一套自救体系。今天把它完整写出来。
噩梦 ①:M2 同比 3530425.21%——字段错位的连锁反应
表象
从 DuckDB 的 macro_daily 表查 M2 数据:
import duckdb
con = duckdb.connect('quant_v2.duckdb', read_only=True)
con.execute("SELECT date, value, unit FROM macro_daily WHERE indicator='M2' ORDER BY date DESC LIMIT 5").fetchall()
结果:
| date | value | unit |
|---|---|---|
| 2026-04 | 3530425.21 | 亿元 |
| 2026-03 | 8.5 | % |
| 2026-02 | 9.0 | % |
| 2026-01 | 9.0 | % |
| 2025-12 | 8.5 | % |
一眼就能看出问题:3 月前的 value 字段存的是 M2 同比增速(百分比),而 4 月的 value 存的是 M2 绝对规模(亿元)。4 月份的 M2 实际增速应为 8.6%(见 change_pct 列),但 value 列被写入了当月 M2 总量 3530425.21 亿元。
根因排查
AKShare 的宏观数据接口在提取 M2 时,存在两种数据形态:
- 增速(同比%):通常标注为”M2_同比”或”m2_yoy”
- 绝对规模(亿元):通常标注为”M2”或”货币供应量”
采集脚本可能在某次接口升级(或字段重命名)后,错误地将绝对规模值写入了本应存增速的 value 列。而由于 M2 绝对值(353 万亿)与增速(8.6%)数量级相差 10 万倍——任何基于 value 的宏观因子计算都会炸裂。
传播链:17 个下游指标
更可怕的是传播链。如果估值周报使用 M2 增速作为宏观因子(比如 M2 同比 < 10% → 宽信用评分 +1),那么 3530425.21% 会导致:
- 宽信用评分瞬间打满
- 行业配置权重偏向高风险资产
- 风险预算模块低估波动风险
一个脏数据能在 3 步之内污染整个策略栈。
噩梦 ②:主力资金表凭空消失——“主力净流入 TOP10”从何说起?
表象
信息采集 cron 每天早上 6:30 执行 4 路收集,其中一路是”最近 5 日主力资金净流入 TOP 10”。但在实际执行时,这段逻辑被直接跳过了。原因很简单:
tables = con.execute("SHOW TABLES").fetchall()
# 找 moneyflow 相关表...
# 结果:无任何 moneyflow 表
quant_v2.duckdb 共有 20 张表,覆盖日线、指数、ETF、期货、宏观、行业估值等,但没有一张表叫 moneyflow、capital_flow 或资金流向。
影响
主力资金流入/流出是 A 股最常用的资金面指标之一。缺少这张表意味着:
- ❌ 无法做”主力净流入 TOP 10”榜单
- ❌ 无法计算主力资金 vs 散户资金的背离信号
- ❌ 无法验证”大跌日主力抄底”的叙事(比如 6 月 26 日大跌时 CSI800 出现 242 只买入信号,但无法确认主力是否同步买入)
解决方案
补建 moneyflow_daily 表需要:
- 从 AKShare/Tushare 拉取个股每日资金流向数据
- 入库 schema:
code, date, main_net_inflow, retail_net_inflow, institutional_net_inflow, ... - 历史数据补齐(至少 3 年)
- 纳入 ETL 管道,每日增量更新
但重点是:这个坑不是数据坏了,而是数据从未存在过。 当你的采集逻辑假设某张表存在而实际不存在时,就会产生”静默跳过”——代码正常执行,输出正常生成,只是那个关键数据点永远为空。
噩梦 ③:可转债数据停更 24 天——策略回测的降级
表象
# cb_list_jsl 表不存在,试 bond_daily
con.execute("SELECT COUNT(*), MAX(date) FROM bond_daily").fetchall()
# 结果:747272 条,最新日期 2026-06-26(正常)
# 但旧有的 cb_list_jsl 采集逻辑已废弃,bond_daily 是纯日线,缺乏转债专属字段
# 而素材简报依赖的"到期收益率>0 的可转债"筛选条件无法满足
更关键的是素材简报中的记录:
cb_list_jsl(集思录转债表):仅 30 只,停于 06-05
虽然 bond_daily 有最新数据,但它只有 price/volume 等基础 K 线字段——没有到期收益率、转股溢价率、纯债价值等可转债专属指标。双低策略、强赎博弈、YTM 筛选全部无法做。
根源
集思录的转债数据采集脚本因为接口变更(或反爬策略升级)在 6 月 5 日之后停止工作,但没有任何人收到告警。ETL 管道静默失败,直到手动检查才发现已断更 24 天。
噩梦 ④:期货 ETL 滞后 4 周——对冲信号断档
表象
con.execute("SELECT MAX(date) FROM futures_daily").fetchall()
# 结果:2026-06-02
6 月 2 日之后,期货日线再也没有更新。 到我发现时已经滞后了接近 4 周。
期货数据对期现对冲、跨期套利、商品择时策略至关重要。6 月 2 日至 6 月 26 日之间,铁矿石、螺纹钢、沪铜等品种出现了明显的趋势行情——但策略对此完全”失明”。
根因排查
从 _etl_checkpoint 表看,股票数据的 ETL 正常运行到 6 月 26 日(有最近的文件导入记录),但期货 ETL 管道的断点在 6 月 2 日。典型的怀疑方向:
- 期货数据源接口切换(如从 AKShare 换到其他源)
- 采集脚本异常退出未重启
- ETL 调度配置变更导致该任务被排除
最危险的在于:没有新鲜度探针。
噩梦 ⑤:蓝筹 v2 cron 静默死亡——最危险的故障模式
表象
蓝筹 v2 策略 cron(ID: 6b4ec2ef8a02)每天早上 19:20 执行,生成买入/卖出信号。它的最新输出文件:
2026-06-11_19-21-41.md
2026-06-12_19-22-52.md
2026-06-15_19-23-08.md
2026-06-16_19-23-08.md
2026-06-17_19-21-46.md
6 月 17 日之后的输出文件夹为空。 这意味着从 6 月 17 日到 6 月 29 日(整整 12 天),蓝筹策略信号完全中断,而没有任何人知道。
为什么最危险
数据错误可以被检测(数值超出合理范围),表缺失可以被发现(查询报错),但cron 静默死亡的典型特征是:
- ✅ 系统”看起来”正常运行(没有报错弹窗)
- ❌ 但输出文件不再更新
- ❌ 下游依赖此信号的流程继续按最后一份快照运行
蓝筹策略的最后一份快照显示”招商蛇口 RSI=6.3 极度超卖”——但 12 天过去了,市场已经经历了 06-26 的大跌,仓位信号早已失效。
自救指南:数据健康三层防线
5 个噩梦各有差异,但共性问题只有一个:缺乏对数据管道本身的监控。 我们花大量精力写策略、调参数,却很少检查数据是否新鲜、格式是否正确、管道是否还在跑。
以下是当天晚上搭建的三层防线,全程基于 DuckDB + Python,不需要额外基础设施。
第一层:入库前校验(Schema + 范围检查)
每次 ETL 写入数据前,插入一个校验步骤:
def validate_macro_row(row: dict) -> bool:
"""宏观数据入库前校验"""
checks = {
'M2': lambda v: 0 < v < 30, # M2同比通常在0-30%
'CPI': lambda v: -5 < v < 20, # CPI一般在-5%到20%
'PMI': lambda v: 30 < v < 60, # PMI在30-60之间
'GDP': lambda v: -10 < v < 15, # GDP同比
}
indicator = row['indicator']
if indicator in checks:
if not checks[indicator](row['value']):
# 触发告警,写入异常日志,跳过该记录
log_alert(f"数据校验失败:{indicator} = {row['value']}")
return False
return True
M2 3530425.21 如果经过这道检查,会在入库前就被拦截。
第二层:定时 Fresh-check 探针
每天采集完成后,自动运行数据新鲜度检查:
def freshness_check(con: duckdb.DuckDBPyConnection) -> list:
"""检查各表的最新数据日期是否在预期范围内"""
checks = [
('stock_daily', 'trade_date', 1, '日线数据滞后'),
('futures_daily', 'date', 1, '期货数据严重滞后'),
('macro_daily', 'date', 7, '宏观数据超过一周'),
('etf_daily', 'date', 1, 'ETF数据滞后'),
]
alerts = []
for table, date_col, max_lag_days, label in checks:
try:
last = con.execute(f"SELECT MAX({date_col}) FROM {table}").fetchone()[0]
lag = (date.today() - last).days
if lag > max_lag_days:
alerts.append(f"⚠️ {label}:{table} 最后更新 {last},已滞后 {lag} 天")
except Exception as e:
alerts.append(f"❌ {label}:查询 {table} 异常 — {e}")
return alerts
如果有这个探针,期货滞后 4 周的问题会在滞后第 2 天就触发飞书告警,而不是 4 周后才发现。
第三层:Cron 心跳 + 飞书告警
最关键的防线:cron 本身需要健康检查。
# 在每个 cron 任务的最后,写入心跳文件
def heartbeat(cron_id: str):
path = f"/tmp/cron_heartbeat/{cron_id}"
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(datetime.now().isoformat())
# 独立的 cron 检查任务(每小时运行一次),检查所有登记的心跳
def check_heartbeats():
for cron_id, max_hours in registered_crons.items():
path = f"/tmp/cron_heartbeat/{cron_id}"
if not path.exists():
alert(f"❌ cron {cron_id} 心跳缺失")
continue
last = datetime.fromisoformat(Path(path).read_text())
elapsed = (datetime.now() - last).total_seconds() / 3600
if elapsed > max_hours:
alert(f"⚠️ cron {cron_id} 最后一次心跳在 {elapsed:.1f} 小时前,超限 {max_hours}h")
蓝筹 v2 cron 如果配置了心跳机制,6 月 17 日之后的第一次缺失(18 日)就会触发告警,而非 12 天后才被发现。
结论:量化系统的可靠性瓶颈不在策略,在数据
5 个噩梦复盘之后,我想说一件事:一个量化系统的脆弱性往往不是策略逻辑不行,而是它背后数据的健康状态从没有被检查过。
| 噩梦 | 故障类型 | 发现时间 | 可预防 |
|---|---|---|---|
| M2 异常 353 万% | 字段错位 | 第 1 天(碰巧看到) | ✅ 入库前校验 |
| 主力资金表缺失 | 表结构设计缺失 | 第 0 天(写采集时) | ✅ Schema 规划 |
| 可转债数据停更 | ETL 静默失败 | 第 24 天 | ✅ 新鲜度探针 |
| 期货 ETL 滞后 | ETL 管道断连 | 第 27 天 | ✅ 新鲜度探针 |
| 蓝筹 cron 停跑 | 调度器静默死亡 | 第 12 天 | ✅ 心跳告警 |
5 个坑里,4 个属于”早该防住但没防”。而防止它们的代码可能不到 50 行。
下一步,我计划把”数据缺口附录”做成一个数据健康仪表盘——每天自动生成各表的新鲜度、完整性评分,异常自动飞书告警。只有当仪表盘全部绿色时,策略信号才被认为是可用的。
毕竟,如果你的 M2 增速是 353 万%,任何策略都是噪音。
本文所有数据均为真实 DuckDB 查询结果(quant_v2.duckdb,只读模式)。DuckDB 连接代码可在任何 Python 3.8+ 环境运行。