【Python量化数据工程实战 #09】数据 Pipeline 跑了一个月才发现缺数?用质量评分卡 5 分钟定位问题

你是不是也有过这种经历:

  • 回测结果看着很美,实盘一跑就崩,回头查数据发现某段行情根本不在库里。
  • 某天因子值突然全是 NaN,查了半天才发现是上游接口字段改名。
  • 多只股票一起跑,跑完才发现其中有两只从头到尾就没拉到数据。

数据问题不会被代码报错暴露,它只会安静地让你的策略失效。与其事后救火,不如给数据流水线配一张质量评分卡------每天跑完自动打分,哪里掉链子一眼看见。

本文你将得到什么

  1. 5 个维度的数据质量评分模型:完整性、及时性、一致性、有效性、覆盖度。
  2. 一份可直接复制运行的 Python 评分卡代码,输入是你的 K 线 DataFrame,输出是评分报告 + 告警。
  3. 阈值告警机制 :评分低于阈值自动打 [ALERT],方便接入飞书/钉钉/企微机器人。
  4. 把评分卡挂到流水线里的最佳实践 :放在 monitor 阶段,失败即停,不让脏数据进模型。

为什么数据质量必须用"评分卡"而不是靠肉眼

很多开发者的数据检查是临时性的:

python 复制代码
df.isna().sum()  # 看一下有没有空值
df.tail()        # 看一下最新日期

这在 3 只股票、80 天数据时还够用;一旦扩展到全市场、多数据源、长期运行,就会面临三个问题:

  • 维度太多:空值只是冰山一角,还要关心价格逻辑、多表对齐、最新日期、覆盖度。
  • 没有基线:今天 "NaN 占比 2%" 是好是坏?没有历史分数对比,无法判断。
  • 无法告警:等肉眼发现问题,往往已经污染了下游特征和回测结果。

评分卡解决的是"把主观判断变成客观分数"。

5 维评分模型设计

维度 含义 典型问题
完整性 核心字段非空占比 接口超时导致某列全 NaN
及时性 最新数据距离今天多久 采集任务失败导致缺最近几天
一致性 价格逻辑是否自洽 high < low、close 不在 low, high 内
有效性 收益率是否在合理区间 极端异常值、未复权导致的跳变
覆盖度 目标股票是否全部拉到 某只代码退市、接口漏返回

每个维度 0--100 分,最后算综合分。你可以按业务重要性给不同维度加权,但入门阶段先等权即可。

代码实战:数据质量评分卡

下面这段代码假设你已经会用 mairui 的 stock_history 拿到日线。如果还没接触过,可以先看本系列前几篇的清洗/特征/流水线内容。

python 复制代码
import json
import sys
from datetime import datetime
from pathlib import Path

import pandas as pd
from mairui import Client

# 演示证书,请换成你自己的正式证书
client = Client("LICENCE-66D8-9F96-0C7F0FBCD073")

CODES = ["600519", "000001", "000002", "600036", "000858"]
REPORT_PATH = Path(__file__).parent / "quality_report.json"


def fetch_daily(code: str, lt: int = 60) -> pd.DataFrame:
    raw = client.stock_history(code, lt=lt, dividend="n")
    df = pd.DataFrame(raw)
    if df.empty:
        return df
    df["t"] = pd.to_datetime(df["t"])
    df["code"] = code
    for col in ["o", "c", "h", "l", "v"]:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    return df[["code", "t", "o", "c", "h", "l", "v"]]


def score_completeness(df: pd.DataFrame) -> dict:
    core_cols = ["o", "c", "h", "l", "v"]
    total = len(df) * len(core_cols)
    missing = df[core_cols].isna().sum().sum()
    score = round((1 - missing / total) * 100, 2) if total else 0.0
    return {"score": score, "missing_cells": int(missing), "total_cells": int(total)}


def score_timeliness(df: pd.DataFrame) -> dict:
    if df.empty:
        return {"score": 0.0, "latest_date": None, "days_behind": None}
    latest = df["t"].max()
    today = datetime.now()
    latest = min(latest, today)
    days_behind = (today - latest).days
    score = max(0.0, 100 - days_behind * 20)
    return {"score": round(score, 2), "latest_date": latest.strftime("%Y-%m-%d"), "days_behind": days_behind}


def score_consistency(df: pd.DataFrame) -> dict:
    checks = {
        "high_ge_low": (df["h"] >= df["l"]).sum(),
        "close_in_range": df["c"].between(df["l"], df["h"]).sum(),
        "open_in_range": df["o"].between(df["l"], df["h"]).sum(),
        "volume_positive": (df["v"] > 0).sum(),
    }
    total = len(df)
    passed = sum(checks.values())
    score = round(passed / (total * len(checks)) * 100, 2) if total else 0.0
    return {"score": score, "checks": {k: int(v) for k, v in checks.items()}, "total_rows": int(total)}


def score_validity(df: pd.DataFrame) -> dict:
    df = df.copy()
    df["ret"] = df.groupby("code")["c"].pct_change() * 100
    valid = df["ret"].between(-20, 20).sum()
    total = df["ret"].notna().sum()
    score = round(valid / total * 100, 2) if total else 0.0
    outliers = df[(df["ret"].notna()) & (~df["ret"].between(-20, 20))]
    return {
        "score": score,
        "valid_rows": int(valid),
        "total_rows": int(total),
        "outlier_count": len(outliers),
        "outlier_examples": outliers[["code", "t", "ret"]].head(3).to_dict("records"),
    }


def score_coverage(codes: list, df: pd.DataFrame) -> dict:
    fetched = df["code"].unique().tolist()
    missing = [c for c in codes if c not in fetched]
    score = round((len(codes) - len(missing)) / len(codes) * 100, 2) if codes else 0.0
    return {"score": score, "expected": len(codes), "fetched": len(fetched), "missing": missing}


def evaluate_alerts(scores: dict, thresholds: dict) -> list:
    alerts = []
    for dim, thr in thresholds.items():
        s = scores.get(dim, {}).get("score", 0)
        if s < thr:
            alerts.append(f"[ALERT] {dim} 评分 {s} 低于阈值 {thr}")
    return alerts


def main():
    frames = []
    for code in CODES:
        df = fetch_daily(code, lt=60)
        if not df.empty:
            frames.append(df)
            print(f"[pull] {code}: {len(df)} 行, latest={df['t'].max().strftime('%Y-%m-%d')}")

    df_all = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()

    scores = {
        "completeness": score_completeness(df_all),
        "timeliness": score_timeliness(df_all),
        "consistency": score_consistency(df_all),
        "validity": score_validity(df_all),
        "coverage": score_coverage(CODES, df_all),
    }

    thresholds = {
        "completeness": 99.0,
        "timeliness": 80.0,
        "consistency": 99.0,
        "validity": 95.0,
        "coverage": 100.0,
    }

    alerts = evaluate_alerts(scores, thresholds)
    overall = round(sum(s["score"] for s in scores.values()) / len(scores), 2)

    report = {
        "generated_at": datetime.now().isoformat(),
        "codes": CODES,
        "total_rows": int(len(df_all)),
        "overall_score": overall,
        "scores": scores,
        "thresholds": thresholds,
        "alerts": alerts,
        "status": "PASS" if not alerts else "WARN",
    }

    REPORT_PATH.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
    print(f"\n[report] overall={overall}, status={report['status']}")
    for dim, s in scores.items():
        print(f"  {dim:<15}: {s['score']}")
    if alerts:
        print("\n[alerts]")
        for a in alerts:
            print(f"  {a}")


if __name__ == "__main__":
    main()

跑完你会得到类似这样的输出:

ini 复制代码
[pull] 600519: 50 行, latest=2025-12-01
[pull] 000001: 50 行, latest=2025-12-01
...

[report] overall=80.0, status=WARN
  completeness   : 100.0
  timeliness     : 0.0
  consistency    : 100.0
  validity       : 100.0
  coverage       : 100.0

[alerts]
  [ALERT] timeliness 评分 0.0 低于阈值 80.0

如果你用的是演示证书,数据最新日期固定在某一天,timeliness 会很低,这是预期现象------换成正式证书后,它会真实反映你的数据延迟。

怎么把评分卡挂到流水线里

在上一篇的 6 阶段流水线中,评分卡就是第 6 阶段 monitor 的升级:

rust 复制代码
pull -> store -> clean -> merge -> feat -> monitor(评分卡)

推荐两个落地规则:

  1. 评分卡不通过,下游不执行 :只要出现 [ALERT],特征工程/回测/模型训练都停止,避免脏数据污染结果。
  2. 分数要持久化 :把 quality_report.json 写到日志目录,长期追踪,方便发现"分数缓慢下降"这类慢性问题。

常见坑

  • 及时性阈值别太严:A股不是每天都有数据,周末/节假日没有 bar 是正常的。建议用"最新数据距离今天超过 3 个交易日"才算异常。
  • 有效性要分板块:科创板/创业板涨跌停是 20%,主板是 10%,全市场统一用 +/-20% 会漏掉主板的真实涨停,但不容易误报;反过来用 +/-10% 会误杀科创板。
  • 覆盖度不能只看行数 :有些接口会返回空列表表示"成功但无数据",此时要检查 code 是否真的在 DataFrame 里。

小结

数据质量评分卡的本质是把"数据有没有问题"从玄学变成工程指标。5 个维度覆盖了量化数据最常见的问题类型,100 行出头的代码就能每天自动巡检一次。

记住三件事:

  1. 不要只看 NaN:价格逻辑、最新日期、覆盖度同样重要。
  2. 阈值要可配置:不同策略、不同数据源的标准不一样。
  3. 告警必须可行动 :每个 [ALERT] 都要能定位到具体阶段或具体股票。

下篇预告 :评分卡让我们知道数据好不好,但研究阶段的 Notebook 怎么变成能稳定上线的工程?#10 给你一份从研究到上线的最小闭环方案。


免责声明

本文仅供技术学习交流,不构成任何投资建议。市场有风险,决策需谨慎。


了解更多

完整示例代码与文档:github.com/MaiRuiApi

相关推荐
吃饱了得干活40 分钟前
数据放哪儿:从 HashMap 到一致性哈希
java·后端
旺仔不是程序员40 分钟前
pg_trgm GIN 索引:PostgreSQL 正则、模糊与近似度查询的三合一加速器
数据库·后端·sql
一粒麦仔40 分钟前
SafeTensors vs GGUF:大模型权重格式的硬核拆解
人工智能·后端·架构
1360967572340 分钟前
报错总在"跑完之后"
后端
爱勇宝40 分钟前
程序员该不该自己掏钱买Token:这笔账该怎么算
前端·后端·程序员
用户06035170543841 分钟前
iPhone 用户传不上图?浏览器端 HEIC 转码 + 超 10MB 自动压缩的完整实现(createImageBitmap 优先,WASM 按需加载)
后端
Bazingga41 分钟前
13张图讲透RAG:从“向量是什么”到评测体系的Spring AI实战
后端
MacroZheng41 分钟前
Redis 已正式接入 AI !
人工智能·redis·后端
JavaGuide41 分钟前
GPT-6 Sol 和 Claude Opus 5.5 发布,直接杀死比赛!
前端·后端