数据质量DuckDB数据治理量化数据ETLcronAKShare量化工程

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()

结果:

datevalueunit
2026-043530425.21亿元
2026-038.5%
2026-029.0%
2026-019.0%
2025-128.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% 会导致:

  1. 宽信用评分瞬间打满
  2. 行业配置权重偏向高风险资产
  3. 风险预算模块低估波动风险

一个脏数据能在 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 表需要:

  1. 从 AKShare/Tushare 拉取个股每日资金流向数据
  2. 入库 schema:code, date, main_net_inflow, retail_net_inflow, institutional_net_inflow, ...
  3. 历史数据补齐(至少 3 年)
  4. 纳入 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 日。典型的怀疑方向:

  1. 期货数据源接口切换(如从 AKShare 换到其他源)
  2. 采集脚本异常退出未重启
  3. 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+ 环境运行。

相关文章

💬 评论