Python 行情数据清洗实战:Z-Score、MAD 与分位数过滤的异常值检测
作者: TickDB Research · 发布: 2026/4/28 · 阅读: 15
标签: C 类, 思否, 数据清洗
▍阅读指南
- 如果你只想要代码:直接跳转第三章,三种检测方法的完整实现可复制运行。
- 如果你想理解方法选型:从第二章开始,有 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 直接理解行情接口协议,省去手动查阅文档的步骤。
本文不构成任何投资建议。异常值检测结果仅供数据清洗参考,不构成买卖依据。
通过 TickDB API 获取实时行情数据
一个 API 接入外汇、加密货币、美股、港股、A股、贵金属和全球指数的实时行情。支持 WebSocket 低延迟推送,免费开始使用。
免费领取 API Key查看 API 文档