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

19 阅读14分钟

▍阅读指南

  • 如果你只想要代码:直接跳转第三章,三种检测方法的完整实现可复制运行。
  • 如果你想理解方法选型:从第二章开始,有 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 快速验证数据可用:

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:
    """将 TickDB kline 接口的原始响应转为 DataFrame"""
    df = pd.DataFrame(resp_json["data"]["klines"])
    df["time"] = pd.to_datetime(df["time"], unit="ms")  # 毫秒→datetime
    df.set_index("time", inplace=True)
    # 关键:OHLCV 字段从 String 转为 Float
    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 = (x - μ) / σ

当 |z| > 3 时,标记为异常值。

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

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

Z-Score 在金融数据上的具体问题

问题表现例子
厚尾敏感真实极端波动被标记为异常2020 年 3 月美股单日 -12% 会被 Z=3.5 误判
均值漂移长期趋势股的历史价格被全盘误判10 年涨 20 倍的股票,前 5 年的价格看起来全是“异常低值”
异常值污染(掩蔽效应)一条真正的错误数据(150→1500)会拉高均值和标准差,导致其他异常漏检一个极端异常值“保护”了其他中等异常值

Z-Score 的唯一适用场景:截面数据比较(同一时间点多只股票的指标排名),不适合时间序列异常检测。

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

MAD(Median Absolute Deviation,中位数绝对偏差)用中位数替代均值,用中位数偏差替代标准差:

mad = median(|x_i - median(x)|)
modified_z = 0.6745 * (x_i - median(x)) / mad

0.6745 是常数,使 MAD 在正态分布下与标准差具有可比性。

为什么 MAD 更适合金融数据

  • 中位数不受极端值影响——一条 1500 元的错误数据不会拉偏参考基准
  • Modified Z-Score 的 3.5 阈值在实际测试中比 Z=3 的误判率低 60% 以上
  • 适用于时间序列,不需要分布假设

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

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

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

直接设定上下分位数阈值(如 1% 和 99%),超出范围即标记:

lower = df['close'].quantile(0.01)
upper = df['close'].quantile(0.99)
anomalies = df[(df['close'] < lower) | (df['close'] > upper)]

优点

  • 不依赖任何分布假设
  • 解释性强——“剔除价格低于 0.1 元或高于 10000 元的数据”在业务上完全说得通
  • 适合作为其他方法的第一道粗筛

缺点

  • 需要人工设定阈值,缺乏自适应性
  • 对趋势性标的失效——10 年涨 20 倍的股票,前 8 年的正常价格会被后 2 年抬高的分位数误判
  • 不看上下文——一支低价股和一支千元股不能用同一套分位数

2.5 三方法对比速查

方法分布假设鲁棒性自适应性适用场景
Z-Score正态分布差(异常值污染均值)截面排名,不做异常检测的主力
Modified MAD强(中位数免疫异常)时间序列异常检测的主力方案
分位数过滤强(只看排序)差(需手动阈值)第一道粗筛 + 价格合法性检查

▍本章核心结论

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

三、生产级代码实现

3.1 基础函数:三种检测方法的 Python 实现

以下为理解原理的简化实现,使用全局统计量。

⚠️ 前视偏差警告

以下代码使用全量数据的全局中位数和分位数。在真实回测中,这等同于用 2024 年的价格判断 2015 年是否异常——你的异常检测含了未来信息。理解原理用这个版本,生产环境请用扩展方向中的滚动窗口版本。

import numpy as np
import pandas as pd
from typing import Tuple, Dict


def detect_by_zscore(series: pd.Series, threshold: float = 3.0) -> pd.Series:
    """
    Z-Score 异常检测。
    返回布尔 Series,True 表示异常。
    警告:此方法对金融时间序列误判率极高,仅适合截面比较。
    """
    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(series: pd.Series, threshold: float = 3.5) -> pd.Series:
    """
    Modified Z-Score 基于 MAD 的异常检测。
    返回布尔 Series,True 表示异常。
    推荐作为金融时间序列的主力检测方法。
    """
    median = series.median()
    # MAD = 中位数绝对偏差
    mad = np.median(np.abs(series - median))

    # 避免零除(当数据极度集中时 MAD 可能为 0)
    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:
    """
    分位数过滤异常检测。
    返回布尔 Series,True 表示异常。
    适合作为第一道粗筛——剔除明显不可能的价格。
    """
    lower = series.quantile(lower_q)
    upper = series.quantile(upper_q)
    return (series < lower) | (series > upper)

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

def clean_price_data(
    df: pd.DataFrame,
    price_col: str = "close",
    volume_col: str = "volume",
    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_mad(df[price_col], mad_threshold)
    volume_mad_anomalies = detect_by_mad(df[volume_col], 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}) + MAD({mad_threshold})"
    }

    return df, report

3.3 人工审核队列

自动标记之后,不是直接删除。人工审核队列的设计:

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

    # 计算异常置信度:Modified Z-Score 的绝对值越大,越可疑
    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.4 使用示例

# 假设 df 已包含 close 和 volume 列
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.07% 的数据点为异常——在 10 年级别的原始行情数据中,这个比例是合理的。
  • 审核队列按置信度降序排列,审核者可以从最可疑的数据开始处理。

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

问题现象根因解决方案
拆股被误判为暴跌股价从 500 “跌”到 100,Z=4.2拆股导致价格断崖式变化先用复权因子调整价格,再做异常检测
财报跳空被误判财报发布次日高开 20%,MAD 标记为异常真实市场异动,不应剔除检查跳空日成交量——真实异动通常伴随放量
低流动性标的 MAD 失效单日成交只有几手,价格连续几天不变,MAD=0零 MAD 导致 Modified Z-Score 除零MAD=0 时跳过该标的,标记为“数据不足无法检测”
成交量天然高方差同一标的成交量从 10 万股到 1000 万股都可能正常成交量分布极度右偏对成交量取对数后再做 MAD 检测
全局中位数是未来函数用全量数据的中位数和分位数判断早期数据是否异常你会用 2024 年的股价判断 2015 年是否正常——前视偏差使用滚动窗口统计量(见扩展方向)

▍常见问题

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

五、结语

▍一句话记住本文

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

本文实现了双层过滤架构(分位数粗筛 + MAD 精检)和人工审核队列:

  • 为什么不用 Z-Score:金融数据是厚尾分布,Z-Score 的误判率在实盘数据上实测超过 15%
  • 为什么用 MAD:中位数免疫极端值,不需要假设正态分布,适配时间序列
  • 为什么不自动删除:财报跳空、拆股调整、流动性枯竭——这些都是策略需要知道的市场信号

在构建上述清洗流程时,异常检测的误判率高度依赖数据源本身的质量。如果数据源的成交量字段存在负值、时间戳时区不一致、或同一标的在不同日期出现重复 K 线——这些“脏数据”都会被 MAD 标记为异常,增加人工审核队列的工作量。

本文示例数据使用的 K 线接口,其字段格式经过标准化(时间戳统一为毫秒 UTC、成交量无负值),让异常检测的基线噪声更可控。如果你对接的是多数据源或自行爬取的数据,建议在进入 MAD 检测前先做一轮字段级的合法性检查(成交量 ≥ 0、最高价 ≥ 最低价、时间戳单调递增)。

扩展方向

  • 滚动窗口 MAD:用过去 252 个交易日的数据计算滚动中位数和滚动 MAD,避免前视偏差。核心实现如下:
def detect_by_rolling_mad(
    series: pd.Series,
    window: int = 252,
    threshold: float = 3.5
) -> pd.Series:
    """
    【生产级】滚动 MAD 检测——杜绝前视偏差。
    用过去 window 个数据点的滚动中位数,而非全量数据的中位数。
    """
    rolling_median = series.rolling(window=window, min_periods=window // 2).median()

    # 滚动 MAD:对 (series - rolling_median) 的绝对值求滚动中位数
    abs_deviation = (series - rolling_median).abs()
    rolling_mad = abs_deviation.rolling(window=window, min_periods=window // 2).median()

    # 避免除以零
    rolling_mad = rolling_mad.replace(0, 1e-8)

    modified_z = 0.6745 * (series - rolling_median) / rolling_mad
    return modified_z.abs() > threshold
  • 成交量对数变换:对成交量取 log(volume+1) 后再做 MAD 检测,解决成交量分布极度右偏的问题。
  • 多维度联合检测:结合价格 MAD + 成交量 MAD + 日内分时形态,做三维异常评分。

AI 辅助开发:如果你在编码时使用 AI 助手,可以通过 Clawhub 平台的「TickDB-market-data」Skill 让 AI 直接理解行情接口协议,省去手动查阅文档的步骤。


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