Python 行情数据清洗实战:Z-Score、MAD 与分位数过滤的异常值检测

阅读指南

  • 如果你只想要代码:直接跳转第三章,三种检测方法的完整实现可复制运行。
  • 如果你想理解方法选型:从第二章开始,有 Z-Score vs MAD vs 分位数的对比表。
  • 如果你关心生产级细节:第四章有各方法在金融数据上的误判场景和人工审核队列设计。

一、回测收益翻倍?先检查是不是数据错了

拿到 10 年历史 K 线数据后,大多数人的第一反应是直接跑策略回测。结果出来夏普比率 3.2,最大回撤仅 8%,年化收益 35%。兴奋地部署实盘,三个月后亏了 15%。

回测记录里通常有一个隐蔽的凶手:未被清洗的异常数据。

一笔真实成交价 150 元的股票,因为数据源错误记录了 1500 元——你的策略在这一天检测到“突破信号”大举买入。这笔交易在回测中贡献了 10% 的收益,但在实盘中永远不会发生。

问题在于:不是所有价格跳空都是数据错误。财报发布后的真实跳空、拆股除息带来的价格调整、流动性枯竭时的极端波动——这些是需要保留的市场信号。自动清洗的边界在于区分错误和异动。

数据清洗的核心不是“剔除所有异常”,而是区分数据错误(剔除)和真实市场异动(保留)。一条未被清洗的异常 K 线能让回测虚增 5-15% 的年化收益——但实盘无法复现。

二、三种统计方法的原理与金融数据适配性

展开之前,先给出结论速查:

方法选择速查:Z-Score 最常用,但在金融数据上误判率最高,仅适合截面比较。MAD 用中位数免疫极端值,是金融时间序列异常检测的主力方案。分位数过滤适合做第一道粗筛。

2.1 数据准备:从 API 到 DataFrame

在进入异常检测之前,先解决一个工程问题:数据怎么来。这里的重点是类型转换——很多行情 API 的价格和成交量字段返回的是字符串,不转成 float 之前做任何统计计算都会出错。

以下用 TickDB 的历史 K 线接口作为示例演示数据加载流程:

curl "https://api.tickdb.ai/v1/market/kline?symbol=700.HK&interval=1d&limit=100" \
  -H "X-API-Key: YOUR_KEY"

Pandas 加载时注意两件事:时间戳是毫秒 UTC,价格和成交量是字符串——必须在初始化 DataFrame 时显式转换类型:

import pandas as pd

def load_klines_to_df(resp_json: dict) -> pd.DataFrame:
    """将 kline 接口的原始响应转为 DataFrame"""
    df = pd.DataFrame(resp_json["data"]["klines"])
    df["time"] = pd.to_datetime(df["time"], unit="ms")
    df.set_index("time", inplace=True)
    df[["open", "high", "low", "close", "volume"]] = (
        df[["open", "high", "low", "close", "volume"]].astype(float)
    )
    return df

跳过了 String→Float 转换,MAD 函数会直接抛 TypeError。这是对接新数据源时最常见的“第一行代码报错”。

2.2 Z-Score:最常用,但最不适合金融数据

Z-Score 计算每个数据点距离均值有多少个标准差。当 |z| > 3 时,标记为异常值。

这个方法建立在正态分布假设之上。金融收益率分布是典型的厚尾分布——标准差的 3 倍之外并不是稀有事件。美股单日涨跌超过 3 个标准差的情况,每年实际发生 5-8 次,而正态分布预测的次数不到 1 次。

用 Z-Score 检测金融异常值,就像用测量体温的方式判断一个人是否在跑马拉松——马拉松选手完赛时体温超过 38°C 是正常的,不是发烧。

Z-Score 在金融数据上会碰到三个具体问题:2020 年 3 月美股单日 -12% 会被 Z=3.5 误判为异常——这是厚尾敏感。10 年涨 20 倍的股票,前 5 年的价格看起来全是“异常低值”——这是均值漂移。一条真正的错误数据会拉高均值和标准差,导致其他异常漏检——这叫掩蔽效应。

掩蔽效应的直观理解:假设你有一组正常价格在 100 元附近波动的数据。如果混入一条 1500 元的错误数据,均值会被拉高,标准差会被撑大,Z-Score 的阈值区间随之膨胀。结果这条 1500 元的异常值本身可能刚好落在 ±3 标准差的边界内,而周围真正的异常反倒被“保护”了。

场景 正常数据均值 正常数据标准差 ±3σ 阈值区间 异常值1500的Z-Score
无异常值 100.2 2.5 [92.7, 107.7]
含一条1500 128.1 198.3 [-466.8, 723.0] 6.92 → 仍被检出
含两条1500 156.0 277.6 [-676.8, 988.8] 4.84 → 漏检边缘

随着异常值增多,掩蔽效应呈非线性恶化。这就是为什么 Z-Score 在金融数据清洗中不能单独使用。

Z-Score 的唯一适用场景是截面数据比较,不适合时间序列异常检测。

2.3 MAD:针对厚尾分布的鲁棒替代

MAD 用中位数替代均值,用中位数偏差替代标准差。0.6745 是常数,使 MAD 在正态分布下与标准差具有可比性。

为什么 MAD 更适合金融数据:中位数不受极端值影响——一条错误的高价数据不会拉偏参考基准。Modified Z-Score 的 3.5 阈值在实际测试中比 Z=3 的误判率低得多。适用于时间序列,不需要分布假设。

中位数是你的“正常参考点”,即便有一个离谱数据点,参考点纹丝不动。均值则是被极端值来回拉扯的橡皮筋。

MAD 的局限:对低流动性标的不友好。成交量偏低的股票,价格波动本身就不稳定,MAD 会标记太多“假阳性”。

2.4 分位数过滤:最简单,但需要领域知识

直接设定上下分位数阈值,超出范围即标记。

优点是不依赖任何分布假设,解释性强,适合作为其他方法的第一道粗筛。

缺点是需要人工设定阈值,缺乏自适应性。对趋势性标的失效——10 年涨 20 倍的股票,前期的正常价格会被后期抬高的分位数误判。不同价格区间的标的不能用同一套分位数。

2.5 三方法对比速查

方法 分布假设 鲁棒性 自适应性 适用场景
Z-Score 正态分布 截面排名,不做异常检测主力
Modified MAD 时间序列异常检测主力方案
分位数过滤 第一道粗筛 + 价格合法性检查

Z-Score 在金融数据上是误判率最高的方法——厚尾分布不是 bug,是 feature。生产环境推荐 MAD 做主力、分位数做粗筛的双层过滤架构。

三、生产级代码实现

3.1 理解原理:全局统计量版本(教学用途,含前视偏差)

以下为教学版实现,使用全量数据的全局中位数和分位数,便于理解方法本质。

此版本在真实回测中存在前视偏差——用后来的数据判断早期是否异常。生产环境请使用下一节的滚动窗口版本,此处仅供理解原理。

import numpy as np
import pandas as pd


def detect_by_zscore(series: pd.Series, threshold: float = 3.0) -> pd.Series:
    """Z-Score 异常检测(教学用途)。警告:对金融时间序列误判率极高。"""
    mean = series.mean()
    std = series.std()
    if std == 0:
        return pd.Series(False, index=series.index)
    z_scores = np.abs((series - mean) / std)
    return z_scores > threshold


def detect_by_mad_global(series: pd.Series, threshold: float = 3.5) -> pd.Series:
    """全局 MAD 异常检测(教学用途,含前视偏差)。"""
    median = series.median()
    mad = np.median(np.abs(series - median))
    if mad == 0:
        mad = 1e-8
    modified_z = 0.6745 * (series - median) / mad
    return np.abs(modified_z) > threshold


def detect_by_quantile(
    series: pd.Series, lower_q: float = 0.01, upper_q: float = 0.99
) -> pd.Series:
    """分位数过滤异常检测。适合作为第一道粗筛。"""
    lower = series.quantile(lower_q)
    upper = series.quantile(upper_q)
    return (series < lower) | (series > upper)

3.2 生产级实现:滚动 MAD 检测(杜绝前视偏差)

在生产环境中,你必须保证检测 T 日数据时,绝不使用 T+1 日及以后的任何信息。下面的滚动窗口实现严格满足这一点:

def detect_by_rolling_mad(
    series: pd.Series,
    window: int = 252,
    threshold: float = 3.5
) -> pd.Series:
    """
    生产级滚动 Modified Z-Score 异常检测。
    基于过去 window 个交易日的滚动窗口,杜绝前视偏差。

    window=252 对应约一年的交易日。窗口不足一半时返回 False,
    即需要约半年数据才开始检测——这是保守但安全的选择。
    """
    min_periods = window // 2  # 至少需要半年的数据才开始计算

    rolling_median = series.rolling(window=window, min_periods=min_periods).median()
    abs_deviation = (series - rolling_median).abs()
    rolling_mad = abs_deviation.rolling(window=window, min_periods=min_periods).median()

    # 避免零 MAD 导致除零错误
    rolling_mad = rolling_mad.replace(0, 1e-8)

    modified_z = 0.6745 * (series - rolling_median) / rolling_mad
    return modified_z.abs() > threshold

这段滚动 MAD 实现可以直接拷进你的实盘回测框架。它保证了在检测任意一天的数据时,参考基准完全由过去数据构成,不掺杂未来信息。

3.3 双层过滤架构:粗筛 + 精检

from typing import Tuple, Dict


def clean_price_data(
    df: pd.DataFrame,
    price_col: str = "close",
    volume_col: str = "volume",
    mad_window: int = 252,
    mad_threshold: float = 3.5,
    quantile_range: Tuple[float, float] = (0.005, 0.995)
) -> Tuple[pd.DataFrame, Dict]:
    """
    双层过滤:先分位数粗筛,再滚动 MAD 精检。

    返回:
    - df: 带异常标记列的 DataFrame(不删除数据)
    - report: 异常统计报告
    """
    df = df.copy()

    # 第一层:分位数粗筛(价格合法性检查)
    price_lower = df[price_col].quantile(quantile_range[0])
    price_upper = df[price_col].quantile(quantile_range[1])
    quantile_masked = (df[price_col] < price_lower) | (df[price_col] > price_upper)

    # 第二层:滚动 MAD 精检(生产级——杜绝前视偏差)
    price_mad_anomalies = detect_by_rolling_mad(
        df[price_col], window=mad_window, threshold=mad_threshold
    )
    volume_mad_anomalies = detect_by_rolling_mad(
        df[volume_col], window=mad_window, threshold=mad_threshold
    )

    # 标记但不删除——流入人工审核队列
    df["anomaly_price"] = price_mad_anomalies
    df["anomaly_volume"] = volume_mad_anomalies
    df["anomaly_quantile"] = quantile_masked
    df["anomaly_any"] = (
        df["anomaly_price"] | df["anomaly_volume"] | df["anomaly_quantile"]
    )

    report = {
        "total_rows": len(df),
        "quantile_outliers": int(quantile_masked.sum()),
        "mad_price_outliers": int(price_mad_anomalies.sum()),
        "mad_volume_outliers": int(volume_mad_anomalies.sum()),
        "total_flagged": int(df["anomaly_any"].sum()),
        "flagged_pct": round(df["anomaly_any"].sum() / len(df) * 100, 2),
        "price_range": (float(price_lower), float(price_upper)),
        "method": f"Quantile({quantile_range}) + RollingMAD(window={mad_window}, threshold={mad_threshold})"
    }
    return df, report

3.4 人工审核队列

def build_review_queue(df: pd.DataFrame) -> pd.DataFrame:
    """将标记为异常的数据点组织为人工审核队列,按置信度降序排列。"""
    if "anomaly_any" not in df.columns:
        raise ValueError("请先运行 clean_price_data 生成异常标记")

    review = df[df["anomaly_any"]].copy()

    median = df["close"].median()
    mad = np.median(np.abs(df["close"] - median))
    if mad == 0:
        mad = 1e-8
    review["confidence"] = np.abs(0.6745 * (review["close"] - median) / mad)
    review = review.sort_values("confidence", ascending=False)

    review["prev_close"] = df["close"].shift(1).loc[review.index]
    review["pct_change"] = (
        (review["close"] - review["prev_close"]) / review["prev_close"] * 100
    )

    return review[[
        "close", "prev_close", "pct_change",
        "volume", "anomaly_price", "anomaly_volume",
        "anomaly_quantile", "confidence"
    ]]

3.5 使用示例

df_cleaned, report = clean_price_data(df)

print(f"清洗报告:")
print(f"  总数据量: {report['total_rows']} 条")
print(f"  分位数异常: {report['quantile_outliers']} 条")
print(f"  MAD 价格异常: {report['mad_price_outliers']} 条")
print(f"  MAD 成交量异常: {report['mad_volume_outliers']} 条")
print(f"  总计标记: {report['total_flagged']} 条 ({report['flagged_pct']}%)")

review_queue = build_review_queue(df_cleaned)
print(f"\n前 5 条待审核异常点:")
print(review_queue.head(5))

输出示例(示意性数据):

清洗报告:
  总数据量: 2518 条
  分位数异常: 25 条
  MAD 价格异常: 18 条
  MAD 成交量异常: 32 条
  总计标记: 52 条 (2.07%)
  价格范围: (12.50, 385.00)

前 5 条待审核异常点:
            close  prev_close  pct_change    volume  confidence
2020-03-16  85.30      105.20      -18.92  48200000        5.21
2018-08-03  185.40     150.60       23.11  38100000        4.68
2019-01-14  205.10     218.30       -6.05  12100000        3.95

双层过滤标记了约 2% 的数据点为异常——在 10 年级别的原始行情数据中,这个比例是合理的。审核队列按置信度降序排列,可以从最可疑的数据开始处理。

四、踩坑记录:各方法在金融数据上的真实误判场景

问题 现象 根因 解决方案
拆股被误判为暴跌 股价“腰斩”,Z 值极高 拆股导致价格断崖式变化 先用复权因子调整价格再做检测
财报跳空被误判 跳空高开 20% 被标记 真实市场异动,不应剔除 检查当日成交量——真实异动通常放量
低流动性标的 MAD 失效 MAD 为零,除零报错 交易极不活跃,价格连续不变 跳过该标的,标记“数据不足”
成交量天然高方差 同一标的成交量波动极大 成交量分布极度右偏 取对数后再做 MAD 检测
滚动窗口初期数据不足 前 126 天无 MAD 输出 min_periods 设置过长 适当降低 min_periods 或对早期数据单独处理

你在历史数据中遇到过最离谱的异常值是什么?是价格后面多了一个零,还是成交量为负数?关于“真实异动”和“数据错误”的区分,你的策略是怎么处理的?

五、结语

数据清洗不是“把异常值删掉”,而是标记→审核→决策——自动删除的每一次操作,都可能是在删除市场真相。

本文实现了一套双层过滤架构和人工审核队列。为什么不用 Z-Score:金融数据是厚尾分布,误判率偏高,掩蔽效应会让异常值互相“保护”。为什么用滚动 MAD:中位数免疫极端值,滚动窗口杜绝未来函数,不需要正态假设。为什么不自动删除:财报跳空、拆股、流动性枯竭——这些都是需要保留的市场信号。

异常检测的误判率高度依赖数据源本身的质量。如果你使用的数据源已经做了标准化处理——比如时间戳统一为毫秒 UTC、成交量字段经过合法性校验——那么 MAD 检测的基线噪声会小很多。反之,多数据源混合或自行爬取的数据,建议在统计检测之前先做一轮字段级合法性检查:成交量不为负、最高价不低于最低价、时间戳严格递增。市面上的标准化行情接口,例如 TickDB 的 kline 接口,可以作为一个参考起点,具体字段定义可搜索官方文档了解。

扩展方向

  • 成交量对数变换:对成交量取对数后再做 MAD 检测,改善右偏分布。
df["log_volume"] = np.log(df["volume"] + 1)
volume_anomalies = detect_by_rolling_mad(df["log_volume"])
  • 多维度联合检测:结合价格 MAD、成交量 MAD、日内形态做三维异常评分。
  • 自适应窗口:根据标的流动性动态调整窗口大小——大盘股用 252,小盘股用 504。

📡 数据由 TickDB.ai 提供

本文不构成任何投资建议。异常值检测结果仅供数据清洗参考,不构成买卖依据。

工程笔记

posted @ 2026-05-02 10:35  Walter先生  阅读(12)  评论(0)    收藏  举报