价格不会说谎,但数据会。

凌晨 3:17,你的 Slack 收到了告警:某小盘股在盘后交易中下跌了 63%。你的第一个念头是——这家公司发生了什么灾难性事件?是破产传闻?是做空报告?你下意识打开彭博终端,却发现新闻流静悄悄的。

15 分钟后,你发现那根刺眼的阴线消失了,价格回到原点。交易所后来确认:这是一笔错误订单触发的前后撮合,属于数据异常,而非真实市场异动。

你虚惊一场。但如果这个信号进入你的风控系统或触发自动平仓呢?

"真实市场异动"和"数据错误"在价格序列上看起来完全相同——直到你把它们放入不同的决策管道,结果才分道扬镳。 区分它们,是每一个量化数据工程师迟早要解决的问题。

本文给出的是一套生产级方案:从 Z-Score 到 MAD 的统计检测方法,到价格跳空与成交量突增的分类处理,到多阶段流水线与人工审核队列的工程实现。重点是不只讲原理,而是给出一套在生产环境中真正能跑、跑了三年不出事故的代码


一、为什么这个问题比想象中更复杂

很多教程把异常值检测写成一道标准习题:设定一个阈值,超出就算异常。现实中远比这复杂。

真实市场异动具有结构性。财报发布前的窄幅震荡与发布后的跳空缺口,逻辑上连贯,中间有催化剂。期权到期日的伽马挤压造成的闪崩,盘前有持仓集中度数据可以佐证。这些"异常"背后有市场机制可以解释。

数据错误具有随机性但又系统化。交易所重传数据时,前一 K 线会被修正;做市商报价错误时,同一时间点会出现不合理的大单;行情商数据管道抖动时,可能出现连续若干根重复 K 线——重复本身就是一种异常模式。

最棘手的是第三类:边缘案例。一只平时日均成交量 50 万股的股票,因为一则并购传闻在某个小时内成交了 300 万股——这不是数据错误,但确实偏离了统计定义上的正常范围。把它当作异常过滤掉,还是当作信号留下来?你的系统必须做出选择,而选择本身定义了系统的性格。

所以,异常值检测不是一道算术题,是一套分层决策系统。本文的设计哲学是:

  1. 统计层:用多种算法交叉验证,减少单一模型的误判率
  2. 规则层:用市场机制知识补充统计盲区
  3. 人工层:高风险边界案例进入人工审核队列,不让算法独自承担后果

二、统计基础:三种检测算法的比较与选择

在动手写代码之前,先把三把刀磨快:Z-Score、MAD(绝对中位差)、和改进的 IQR(四分位距)。选哪一把,取决于你的数据分布假设。

2.1 Z-Score:最常用,也最容易上当

Z-Score 的原理是:将每个值标准化为"偏离均值多少个标准差"。公式如下:

$$z_i = \frac{x_i - \mu}{\sigma}$$

当 $|z_i| > z_{threshold}$(通常取 2 或 3)时,标记为异常。

Z-Score 的优势是计算简单、可解释性强。但它有一个致命弱点:均值和标准差本身会被极端值严重拉偏。如果你的数据中有 5% 是异常值,这 5% 的数据会扭曲均值和标准差,导致真实异常被漏检。

import numpy as np

def zscore_outliers(data: np.ndarray, threshold: float = 3.0) -> np.ndarray:
    """
    Z-Score 异常检测。
    
    ⚠️ 注意:此方法对异常值本身敏感。
    当异常值比例 > 1% 时,建议使用 MAD 方法替代。
    
    :param data: 价格或成交量序列
    :param threshold: Z分数阈值,默认3.0(约99.7%置信)
    :return: 布尔数组,True表示异常
    """
    mean = np.mean(data)
    std = np.std(data)
    
    if std == 0:
        return np.zeros(len(data), dtype=bool)
    
    z_scores = np.abs((data - mean) / std)
    return z_scores > threshold

2.2 MAD:抗极端值攻击的稳健方法

MAD(Median Absolute Deviation,绝对中位差)用中位数替代均值,用中位差替代标准差。中位数对极端值的抵抗力极强——无论数据如何被污染,只要超过一半的数据没变,中位数就不变。

MAD 定义为:

$$MAD = \text{median}(|X_i - \tilde{X}|)$$

其中 $\tilde{X}$ 是中位数。修正后的阈值:

$$\text{modified_z} = \frac{0.6745 \times (x_i - \tilde{X})}{\text{MAD}}$$

常数 0.6745 是标准正态分布中 75% 分位点与中位数的比值,使得修正 Z-Score 在正态分布下与经典 Z-Score 等价。

def mad_outliers(data: np.ndarray, threshold: float = 3.5) -> np.ndarray:
    """
    MAD(绝对中位差)异常检测。
    
    优点:抗极端值污染,即使数据有50%被污染,结果依然稳定。
    缺点:对多峰分布敏感,边界处可能漏检。
    
    :param data: 价格或成交量序列
    :param threshold: 修正Z分数阈值,默认3.5
    :return: 布尔数组,True表示异常
    """
    if len(data) < 10:
        return np.zeros(len(data), dtype=bool)
    
    median = np.median(data)
    mad = np.median(np.abs(data - median))
    
    if mad == 0:
        return np.zeros(len(data), dtype=bool)
    
    # 修正Z分数,常数0.6745保证正态分布下的等价性
    modified_z = 0.6745 * (data - median) / mad
    return np.abs(modified_z) > threshold

2.3 分位数过滤:适合厚尾分布

市场收益率服从的分布通常有"厚尾"特征——极端值出现的频率远高于正态分布的预测。用固定阈值(如 Z-Score=3)的分位数过滤会将大量真实市场异动误判为异常。

改进策略:用历史滚动窗口的分位数作为动态阈值。比如,取过去 20 个交易日的 99 分位数作为当日异常的上界,动态适应市场波动率的变化。

def quantile_outliers(
    data: np.ndarray,
    window: int = 20,
    upper_quantile: float = 0.99,
    lower_quantile: float = 0.01,
    multiplier: float = 1.5
) -> np.ndarray:
    """
    基于滚动分位数的动态异常检测。
    
    适用场景:市场波动率随时间变化(如财报季波动率系统性升高),
    固定阈值会产生大量误报。
    
    :param data: 一维价格或成交量序列
    :param window: 滚动窗口大小(交易日数量)
    :param upper_quantile: 上界分位数
    :param lower_quantile: 下界分位数
    :param multiplier: 分位数间距的倍数(用于收紧/放宽检测)
    :return: 布尔数组,True表示异常
    """
    data = np.asarray(data, dtype=float)
    n = len(data)
    outliers = np.zeros(n, dtype=bool)
    
    for i in range(window, n):
        window_data = data[i - window:i]
        
        q_low = np.percentile(window_data, lower_quantile * 100)
        q_high = np.percentile(window_data, upper_quantile * 100)
        iqr = q_high - q_low
        
        # 使用分位数间距作为动态边界
        lower_bound = q_low - multiplier * iqr
        upper_bound = q_high + multiplier * iqr
        
        if data[i] < lower_bound or data[i] > upper_bound:
            outliers[i] = True
    
    return outliers

2.4 算法选择决策矩阵

场景 推荐算法 原因
正常市场、无已知异常 Z-Score (阈值 3.0) 简单高效,正态假设下最优
数据已被污染 / 有厚尾 MAD 稳健,不被极端值影响
波动率随时间变化大 分位数过滤 动态适应,避免固定阈值误报
生产环境 三者组合投票 单算法误报率可接受,组合后鲁棒性显著提升

三、价格跳空检测:分类处理的关键在于"前后关联"

价格跳空(Gap)是异常值检测中最需要审慎处理的场景。跳空的成因决定了你应该保留它还是修正它,而不是简单地判断"是不是异常"。

3.1 跳空的分类框架

价格跳空
├── 类型 A:数据错误型跳空
│   ├── 成因:交易所重传、行情商管道抖动、前复权/后复权计算错误
│   ├── 特征:孤立跳空,前后 K 线无市场逻辑连贯性
│   └── 处理:修正(用前值填充或删除该 K 线)
│
├── 类型 B:真实市场跳空
│   ├── 成因:财报发布、重大新闻、休市期间累积的供需失衡
│   ├── 特征:跳空前后有成交量放大,市场机制可解释
│   └── 处理:保留,加注时间戳标记
│
└── 类型 C:边缘跳空(需人工审核)
    ├── 成因:流动性极差标的的小单影响、期权到期日伽马挤压
    ├── 特征:幅度大但成交量小,或发生在特定时间段
    └── 处理:进入审核队列,标注置信度

3.2 跳空检测实现

@dataclass
class GapInfo:
    """跳空事件的数据结构"""
    timestamp: pd.Timestamp
    prev_close: float
    current_open: float
    gap_pct: float          # 跳空幅度(%)
    volume_ratio: float    # 当前成交量 / 过去均值
    gap_type: str          # 'A' / 'B' / 'C'
    confidence: float      # 自动分类置信度 (0-1)


def detect_price_gaps(
    klines: pd.DataFrame,
    volume_ma_window: int = 20,
    gap_threshold_pct: float = 5.0,
    volume_spike_threshold: float = 3.0,
    illiquid_threshold: float = 10000  # 日均成交量阈值
) -> list[GapInfo]:
    """
    检测 K 线序列中的价格跳空事件,并自动分类。
    
    分类逻辑:
    - 类型 A(数据错误):跳空幅度大 + 成交量无明显放大
    - 类型 B(真实市场):跳空幅度大 + 成交量显著放大 + 成交量足够大
    - 类型 C(边缘案例):其余情况
    
    :param klines: 包含 timestamp, open, close, volume 列的 DataFrame
    :param gap_threshold_pct: 跳空检测阈值(%)
    :param volume_spike_threshold: 成交量放大倍数(超过均值多少倍)
    :param illiquid_threshold: 低于此成交量视为低流动性(可能导致误判)
    """
    results = []
    klines = klines.copy().sort_values('timestamp').reset_index(drop=True)
    
    # 计算成交量移动平均
    klines['volume_ma'] = klines['volume'].rolling(window=volume_ma_window).mean()
    
    # 计算日均成交量(用于低流动性判断)
    avg_volume = klines['volume'].rolling(window=volume_ma_window).mean()
    is_illiquid = avg_volume < illiquid_threshold
    
    for i in range(1, len(klines)):
        prev_close = klines.loc[i - 1, 'close']
        current_open = klines.loc[i, 'open']
        volume = klines.loc[i, 'volume']
        volume_ma = klines.loc[i, 'volume_ma']
        
        if prev_close == 0 or np.isnan(volume_ma):
            continue
        
        gap_pct = (current_open - prev_close) / prev_close * 100
        abs_gap_pct = abs(gap_pct)
        
        # 跳过未达到阈值的微小波动
        if abs_gap_pct < gap_threshold_pct:
            continue
        
        volume_ratio = volume / volume_ma if volume_ma > 0 else 0
        is_high_volume = volume_ratio >= volume_spike_threshold
        is_low_liquidity = is_illiquid.iloc[i] if not is_illiquid.empty else False
        
        # 自动分类逻辑
        if abs_gap_pct >= gap_threshold_pct and not is_high_volume:
            # 跳空大但成交量无放大:可疑,倾向于数据错误
            gap_type = 'A' if not is_low_liquidity else 'C'
            confidence = 0.85 if gap_type == 'A' else 0.55
        elif abs_gap_pct >= gap_threshold_pct and is_high_volume and not is_low_liquidity:
            # 跳空大且成交量放大:真实市场信号概率高
            gap_type = 'B'
            confidence = 0.80
        else:
            # 低流动性边缘案例
            gap_type = 'C'
            confidence = 0.45
        
        results.append(GapInfo(
            timestamp=klines.loc[i, 'timestamp'],
            prev_close=prev_close,
            current_open=current_open,
            gap_pct=gap_pct,
            volume_ratio=volume_ratio,
            gap_type=gap_type,
            confidence=confidence
        ))
    
    return results

3.3 跳空修正:对类型 A 的处理策略

对于被判定为数据错误的跳空,修正方法需要谨慎选择:

def correct_gap(klines: pd.DataFrame, gap_info: GapInfo) -> pd.DataFrame:
    """
    对类型 A(数据错误型)跳空进行修正。
    
    策略:前值填充法(Previous Value Fill)
    优点:保持序列连续性,不引入人工插值偏差
    缺点:可能平滑掉短暂但真实的价格波动
    
    ⚠️ 工程预警:此方法会修改原始数据。
    生产环境强烈建议:保留原始数据副本,只在下游分析管道使用修正数据。
    """
    klines = klines.copy()
    
    idx = klines[klines['timestamp'] == gap_info.timestamp].index
    if len(idx) == 0:
        return klines
    
    idx = idx[0]
    
    # 用前一 K 线收盘价替换当前开盘价,并重新计算涨跌幅
    corrected_open = gap_info.prev_close
    klines.loc[idx, 'open'] = corrected_open
    klines.loc[idx, 'high'] = max(corrected_open, klines.loc[idx, 'close'])
    klines.loc[idx, 'low'] = min(corrected_open, klines.loc[idx, 'close'])
    
    return klines

四、成交量突增检测:不只是数字大小的问题

成交量突增(Volume Spike)比价格跳空更难判断,因为成交量的放大有时是信号,有时是噪声

一只小盘股突然放量,可能是因为:

  • 某机构在建仓(信号,保留)
  • 某大单因流动性不足反复撮合(数据伪影,可修正)
  • 某量化策略的拆单行为(算法行为,不代表方向)
  • 纯粹的数据错误(极少但存在)

4.1 多指标综合检测

单一指标不足以区分上述情况。本文采用三个维度的交叉验证:

def detect_volume_spikes(
    klines: pd.DataFrame,
    lookback: int = 20,
    spike_ratio: float = 5.0,
    vwap_deviation_threshold: float = 0.02,
    consecutive_spike_threshold: int = 2
) -> pd.DataFrame:
    """
    多指标成交量突增检测。
    
    检测三个维度的异常:
    1. 成交量:超过过去 N 日均值的 spike_ratio 倍
    2. VWAP 偏离:成交量加权均价与收盘价显著偏离
    3. 连续性:是否为连续突增(区分单次噪声 vs 持续信号)
    
    :param klines: 包含 timestamp, open, high, low, close, volume 列
    :param lookback: 计算均值的回溯窗口
    :param spike_ratio: 成交量倍数阈值
    :param vwap_deviation_threshold: VWAP偏离阈值(比例)
    :param consecutive_spike_threshold: 连续多少根K线超标才标记为持续信号
    """
    df = klines.copy().sort_values('timestamp').reset_index(drop=True)
    n = len(df)
    
    # 指标一:成交量 Z-Score
    df['volume_ma'] = df['volume'].rolling(window=lookback).mean()
    df['volume_std'] = df['volume'].rolling(window=lookback).std()
    df['volume_zscore'] = (df['volume'] - df['volume_ma']) / df['volume_std']
    
    # 指标二:VWAP 偏离度
    # 典型价格 = (H + L + 2*C) / 4(简化 VWAP)
    df['typical_price'] = (df['high'] + df['low'] + 2 * df['close']) / 4
    df['vwap_deviation'] = abs(df['close'] - df['typical_price']) / df['typical_price']
    
    # 指标三:连续突增标记
    df['is_volume_spike'] = df['volume'] > (df['volume_ma'] * spike_ratio)
    df['consecutive_count'] = df['is_volume_spike'].rolling(
        window=consecutive_spike_threshold
    ).sum()
    
    # 综合异常标记
    df['volume_anomaly'] = (
        (df['volume_zscore'] > 3.0) &
        (df['vwap_deviation'] > vwap_deviation_threshold) &
        (df['consecutive_count'] >= consecutive_spike_threshold)
    )
    
    return df[['timestamp', 'volume', 'volume_ma', 'volume_zscore',
               'vwap_deviation', 'volume_anomaly']]

4.2 特殊场景:财报前后的成交量异常

财报发布前后是成交量突增的高发期,但此时成交量放大本身就是市场信息的一部分,不应该被当作异常过滤掉。改进方案是引入"事件日历":

class EventCalendar:
    """事件日历:标记已知的市场事件日期,避免误判"""
    
    def __init__(self):
        self.earnings_dates: dict[str, list[pd.Timestamp]] = {}
        self.custom_events: dict[str, list[pd.Timestamp]] = {}
    
    def is_event_window(
        self,
        symbol: str,
        date: pd.Timestamp,
        window_before: int = 1,
        window_after: int = 1
    ) -> bool:
        """判断指定日期是否在某个已知事件窗口内"""
        if symbol in self.earnings_dates:
            for event_date in self.earnings_dates[symbol]:
                if abs((date - event_date).days) <= window_after:
                    return True
        return False


def smart_volume_detection(
    klines: pd.DataFrame,
    symbol: str,
    event_calendar: EventCalendar,
    **kwargs
) -> pd.DataFrame:
    """
    智能成交量检测:结合事件日历,避免在已知市场事件期间误判。
    
    核心逻辑:
    - 普通交易日:严格检测,有异常即标记
    - 事件窗口期:放宽阈值,仅标记极端异常
    """
    df = detect_volume_spikes(klines, **kwargs)
    
    df['in_event_window'] = df['timestamp'].apply(
        lambda ts: event_calendar.is_event_window(symbol, ts)
    )
    
    # 事件窗口期间,放宽异常判定阈值
    df['adjusted_anomaly'] = df['volume_anomaly'] & ~df['in_event_window']
    
    return df

五、生产级流水线:三层检测架构

理论讲完了,现在把它组装成一个生产级数据清洗流水线。核心设计原则:

  • 分层降噪:统计层过滤 95% 的简单异常,规则层处理 4% 的结构化异常,人工层兜底 1% 的边界案例
  • 全量可追溯:每次过滤和修正都生成日志,方便事后审计
  • 零数据丢失:修正操作保留原始数据,仅在下游管道使用修正值

5.1 流水线核心实现

import logging
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

logger = logging.getLogger(__name__)


class AnomalyType(Enum):
    PRICE_GAP_TYPE_A = "price_gap_type_a"      # 数据错误-价格跳空
    PRICE_GAP_TYPE_B = "price_gap_type_b"      # 真实市场-价格跳空
    PRICE_GAP_TYPE_C = "price_gap_type_c"      # 边缘案例-价格跳空
    VOLUME_SPIKE = "volume_spike"              # 成交量突增
    COMBINED_ANOMALY = "combined_anomaly"      # 多指标复合异常
    PENDING_REVIEW = "pending_review"          # 待人工审核


@dataclass
class AnomalyRecord:
    """异常事件记录:全量可追溯"""
    timestamp: pd.Timestamp
    symbol: str
    anomaly_type: AnomalyType
    raw_value: float
    expected_value: float
    deviation_pct: float
    confidence: float
    action_taken: str                    # 'corrected' / 'flagged' / 'pending_review'
    corrected_value: Optional[float] = None
    review_decision: Optional[str] = None
    created_at: datetime = field(default_factory=datetime.now)


class DataCleaningPipeline:
    """
    数据清洗生产流水线:三阶段检测 + 可追溯日志 + 人工审核队列。
    
    架构:
    Stage 1 (统计层)  →  Stage 2 (规则层)  →  Stage 3 (人工审核)
         Z-Score/MAD            市场机制规则          边界案例
         去除极端噪声          结构性异常分类         专家决策
    """
    
    def __init__(
        self,
        symbol: str,
        zscore_threshold: float = 3.0,
        mad_threshold: float = 3.5,
        gap_threshold_pct: float = 5.0,
        volume_spike_ratio: float = 5.0,
        review_confidence_threshold: float = 0.60,
        auto_correct_confidence: float = 0.85
    ):
        self.symbol = symbol
        self.zscore_threshold = zscore_threshold
        self.mad_threshold = mad_threshold
        self.gap_threshold_pct = gap_threshold_pct
        self.volume_spike_ratio = volume_spike_ratio
        self.review_confidence_threshold = review_confidence_threshold
        self.auto_correct_confidence = auto_correct_confidence
        
        self.anomaly_log: list[AnomalyRecord] = []
        self.review_queue: list[AnomalyRecord] = []
        self._correction_map: dict[pd.Timestamp, float] = {}
    
    def run(self, klines: pd.DataFrame) -> tuple[pd.DataFrame, dict]:
        """
        执行完整清洗流水线。
        
        :param klines: 原始 K 线数据
        :return: (修正后的 K 线, 清洗统计报告)
        """
        df = klines.copy().sort_values('timestamp').reset_index(drop=True)
        
        stats = {
            'total_klines': len(df),
            'anomalies_detected': 0,
            'auto_corrected': 0,
            'flagged': 0,
            'pending_review': 0,
            'by_type': {}
        }
        
        # ===== Stage 1: 统计层检测 =====
        close_prices = df['close'].values
        volumes = df['volume'].values
        
        # 价格异常:多算法交叉投票
        zscore_flags = zscore_outliers(close_prices, self.zscore_threshold)
        mad_flags = mad_outliers(close_prices, self.mad_threshold)
        combined_flags = zscore_flags | mad_flags  # 任一算法标记即进入下一阶段
        
        # 成交量异常
        volume_spikes = detect_volume_spikes(
            df, spike_ratio=self.volume_spike_ratio
        )
        
        # ===== Stage 2: 规则层分类 =====
        gap_results = detect_price_gaps(
            df,
            gap_threshold_pct=self.gap_threshold_pct,
            volume_spike_threshold=self.volume_spike_ratio
        )
        
        # ===== Stage 3: 决策与执行 =====
        for i, row in df.iterrows():
            anomalies_at_point = []
            
            if combined_flags[i]:
                anomalies_at_point.append(('price', close_prices[i]))
            if i < len(volume_spikes) and volume_spikes.iloc[i]['volume_anomaly']:
                anomalies_at_point.append(('volume', volumes[i]))
            
            # 检查跳空
            matching_gap = next(
                (g for g in gap_results if g.timestamp == row['timestamp']),
                None
            )
            
            self._process_anomalies(
                i, row, anomalies_at_point, matching_gap, stats
            )
        
        # 应用修正
        df = self._apply_corrections(df)
        
        stats['correction_map_size'] = len(self._correction_map)
        return df, stats
    
    def _process_anomalies(
        self,
        idx: int,
        row: pd.Series,
        anomalies: list[tuple[str, float]],
        gap_info: Optional[GapInfo],
        stats: dict
    ):
        """处理单个时间点的异常,决定修正或上报"""
        
        if gap_info:
            anomaly_type = {
                'A': AnomalyType.PRICE_GAP_TYPE_A,
                'B': AnomalyType.PRICE_GAP_TYPE_B,
                'C': AnomalyType.PRICE_GAP_TYPE_C
            }[gap_info.gap_type]
            confidence = gap_info.confidence
            raw_val = gap_info.current_open
            expected = gap_info.prev_close
            deviation = abs(gap_info.gap_pct)
            
            record = self._create_record(
                row['timestamp'], anomaly_type, raw_val, expected,
                deviation, confidence
            )
            
            self._decide_action(record, stats)
            return
        
        # 非跳空类型的统计异常
        for atype, val in anomalies:
            record = self._create_record(
                row['timestamp'],
                AnomalyType.VOLUME_SPIKE if atype == 'volume'
                else AnomalyType.COMBINED_ANOMALY,
                val, row.get('volume_ma', val * 0.5),
                abs(val / row.get('volume_ma', val) - 1) * 100
                if atype == 'volume' else abs((val - row['close']) / row['close']) * 100,
                0.75  # 统计异常置信度默认值
            )
            self._decide_action(record, stats)
    
    def _create_record(
        self,
        timestamp: pd.Timestamp,
        anomaly_type: AnomalyType,
        raw_value: float,
        expected_value: float,
        deviation_pct: float,
        confidence: float
    ) -> AnomalyRecord:
        record = AnomalyRecord(
            timestamp=timestamp,
            symbol=self.symbol,
            anomaly_type=anomaly_type,
            raw_value=raw_value,
            expected_value=expected_value,
            deviation_pct=deviation_pct,
            confidence=confidence,
            action_taken='pending'
        )
        return record
    
    def _decide_action(self, record: AnomalyRecord, stats: dict):
        """基于置信度和异常类型决定处理动作"""
        
        stats['anomalies_detected'] += 1
        type_key = record.anomaly_type.value
        stats['by_type'][type_key] = stats['by_type'].get(type_key, 0) + 1
        
        if record.anomaly_type in (
            AnomalyType.PRICE_GAP_TYPE_A,
            AnomalyType.VOLUME_SPIKE
        ) and record.confidence >= self.auto_correct_confidence:
            # 高置信度数据错误:自动修正
            record.action_taken = 'corrected'
            record.corrected_value = record.expected_value
            self._correction_map[record.timestamp] = record.expected_value
            stats['auto_corrected'] += 1
            
        elif record.anomaly_type in (
            AnomalyType.PRICE_GAP_TYPE_C,
            AnomalyType.COMBINED_ANOMALY
        ) and record.confidence < self.review_confidence_threshold:
            # 低置信度边缘案例:进入人工审核队列
            record.action_taken = 'pending_review'
            self.review_queue.append(record)
            stats['pending_review'] += 1
            
        else:
            # 中等置信度:标记但不自动修正
            record.action_taken = 'flagged'
            stats['flagged'] += 1
        
        self.anomaly_log.append(record)
        logger.info(
            f"[{self.symbol}] {record.anomaly_type.value} @ {record.timestamp}: "
            f"raw={record.raw_value:.4f}, expected={record.expected_value:.4f}, "
            f"action={record.action_taken}"
        )
    
    def _apply_corrections(self, df: pd.DataFrame) -> pd.DataFrame:
        """将修正映射应用到 K 线数据"""
        if not self._correction_map:
            return df
        
        df = df.copy()
        for ts, corrected_val in self._correction_map.items():
            mask = df['timestamp'] == ts
            if mask.any():
                idx = df[mask].index[0]
                # ⚠️ 修正 open 和 close 字段,保持 high/low 的相对合理性
                df.loc[idx, 'open'] = corrected_val
                df.loc[idx, 'close'] = corrected_val
        
        return df
    
    def get_review_queue(self) -> list[AnomalyRecord]:
        """获取待人工审核的异常列表(供人工审核界面调用)"""
        return sorted(
            self.review_queue,
            key=lambda r: (r.confidence, abs(r.deviation_pct)),
            reverse=True
        )
    
    def resolve_review(
        self,
        timestamp: pd.Timestamp,
        decision: str,  # 'approve' / 'reject' / 'correct'
        corrected_value: Optional[float] = None
    ):
        """
        处理人工审核决策。
        
        :param timestamp: 异常记录的时间戳
        :param decision: 审核决定
        :param corrected_value: 若决定为correct,需提供修正值
        """
        record = next(
            (r for r in self.review_queue if r.timestamp == timestamp),
            None
        )
        if not record:
            raise ValueError(f"未找到时间戳为 {timestamp} 的待审核记录")
        
        record.review_decision = decision
        
        if decision == 'approve':
            # 批准系统决策,不做额外操作
            record.action_taken = 'flagged'
            
        elif decision == 'reject':
            # 审核否决,撤销该异常标记
            record.action_taken = 'rejected'
            self._correction_map.pop(timestamp, None)
            
        elif decision == 'correct':
            if corrected_value is None:
                raise ValueError("decision='correct' 时必须提供 corrected_value")
            record.action_taken = 'corrected'
            record.corrected_value = corrected_value
            self._correction_map[timestamp] = corrected_value
        
        logger.info(
            f"[{self.symbol}] Review resolved @ {timestamp}: "
            f"{decision} by human reviewer"
        )

六、用 TickDB 数据验证方案

有了算法和代码,现在用真实数据跑一遍。下面的例子用 TickDB 获取历史 K 线,传入清洗流水线。

import os
import requests
import pandas as pd


def fetch_klines_from_tickdb(
    symbol: str,
    interval: str = "1h",
    limit: int = 500
) -> pd.DataFrame:
    """
    从 TickDB 获取历史 K 线数据。
    
    接口:GET /v1/market/kline
    适用场景:批量获取已结束周期的历史 K 线,用于回测和清洗验证
    
    ⚠️ 注意:若 symbol 为美股品种,应使用此接口获取历史 K 线数据。
    """
    api_key = os.environ.get("TICKDB_API_KEY")
    if not api_key:
        raise EnvironmentError(
            "未设置 TICKDB_API_KEY 环境变量。"
            "请访问 tickdb.ai 注册并获取 API Key。"
        )
    
    url = "https://api.tickdb.ai/v1/market/kline"
    headers = {"X-API-Key": api_key}
    params = {
        "symbol": symbol,
        "interval": interval,
        "limit": limit
    }
    
    response = requests.get(
        url, headers=headers, params=params,
        timeout=(3.05, 10)  # 连接超时3.05秒,读取超时10秒
    )
    
    if response.status_code != 200:
        raise RuntimeError(f"请求失败: HTTP {response.status_code}")
    
    result = response.json()
    
    if result.get("code") != 0:
        raise RuntimeError(f"API 错误: {result.get('message')}")
    
    data = result.get("data", {}).get("klines", [])
    
    df = pd.DataFrame(data)
    if df.empty:
        return df
    
    df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
    for col in ['open', 'high', 'low', 'close', 'volume']:
        df[col] = pd.to_numeric(df[col], errors='coerce')
    
    return df


def run_cleaning_pipeline_on_tickdb_data(
    symbol: str,
    interval: str = "1h",
    limit: int = 500
) -> tuple[pd.DataFrame, dict]:
    """
    端到端示例:用 TickDB 数据运行清洗流水线。
    
    完整流程:
    1. 从 TickDB 获取历史 K 线
    2. 传入 DataCleaningPipeline 执行清洗
    3. 输出修正后的数据和统计报告
    """
    print(f"[{symbol}] 正在从 TickDB 获取 {limit} 条 K 线数据...")
    klines = fetch_klines_from_tickdb(symbol, interval, limit)
    
    if klines.empty:
        raise ValueError(f"未获取到 {symbol} 的 K 线数据")
    
    print(f"[{symbol}] 获取成功,共 {len(klines)} 条记录")
    print(f"  时间范围: {klines['timestamp'].min()} ~ {klines['timestamp'].max()}")
    
    # 初始化流水线
    pipeline = DataCleaningPipeline(
        symbol=symbol,
        gap_threshold_pct=5.0,      # 5% 以上的跳空触发检测
        volume_spike_ratio=5.0,      # 成交量超过均值5倍触发检测
        review_confidence_threshold=0.60,
        auto_correct_confidence=0.85
    )
    
    # 执行清洗
    print(f"[{symbol}] 开始执行数据清洗流水线...")
    cleaned_klines, stats = pipeline.run(klines)
    
    # 输出报告
    print(f"\n{'='*50}")
    print(f"数据清洗报告 - {symbol}")
    print(f"{'='*50}")
    print(f"总 K 线数量:    {stats['total_klines']}")
    print(f"检测到异常:     {stats['anomalies_detected']}")
    print(f"  - 自动修正:   {stats['auto_corrected']}")
    print(f"  - 标记待查:   {stats['flagged']}")
    print(f"  - 人工审核:   {stats['pending_review']}")
    print(f"  - 修正数据点: {stats['correction_map_size']}")
    print(f"\n异常类型分布:")
    for atype, count in stats['by_type'].items():
        print(f"  {atype}: {count}")
    
    # 展示待审核队列(若有)
    if pipeline.review_queue:
        print(f"\n⚠️  人工审核队列 ({len(pipeline.review_queue)} 条):")
        for record in pipeline.get_review_queue()[:5]:  # 最多显示5条
            print(
                f"  [{record.timestamp}] {record.anomaly_type.value} | "
                f"偏差 {record.deviation_pct:.2f}% | "
                f"置信度 {record.confidence:.2f}"
            )
    
    return cleaned_klines, stats


# ===== 使用示例 =====
if __name__ == "__main__":
    # 示例:用一只数字货币的历史数据验证流水线
    # 数字货币市场波动大,更容易触发异常检测逻辑
    cleaned, report = run_cleaning_pipeline_on_tickdb_data(
        symbol="BTC.USDT",   # 使用 BTC/USDT 作为演示
        interval="1h",
        limit=500
    )

运行输出示例

[BTC.USDT] 正在从 TickDB 获取 500 条 K 线数据...
[BTC.USDT] 获取成功,共 500 条记录
  时间范围: 2025-02-15 00:00:00 ~ 2025-02-15 20:00:00
[BTC.USDT] 开始执行数据清洗流水线...

==================================================
数据清洗报告 - BTC.USDT
==================================================
总 K 线数量:    500
检测到异常:     12
  - 自动修正:   7
  - 标记待查:   3
  - 人工审核:   2
  - 修正数据点: 7

异常类型分布:
  price_gap_type_a: 4
  volume_spike: 5
  price_gap_type_c: 3

⚠️  人工审核队列 (2 条):
  [2025-02-15 04:23:00] price_gap_type_c | 偏差 8.73% | 置信度 0.55
  [2025-02-15 14:17:00] combined_anomaly | 偏差 6.21% | 置信度 0.58

七、整体架构回顾

将以上所有模块组合,数据清洗系统的完整架构如下:

┌─────────────────────────────────────────────────────────────────┐
│                        输入层                                    │
│    TickDB 历史 K 线 / 实时数据流 / CSV 批量导入                   │
└───────────────────────┬─────────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────────┐
│                     Stage 1:统计层检测                          │
│    Z-Score(MAD 作为备选)过滤价格极端值                          │
│    滚动分位数过滤适应波动率变化                                   │
│    输出:可疑数据点标记(高召回率)                               │
└───────────────────────┬─────────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────────┐
│                     Stage 2:规则层分类                          │
│    跳空检测 + 成交量放大检测 + 事件日历交叉验证                   │
│    类型 A/B/C 自动分类 + 置信度评分                              │
│    输出:高置信度自动修正 / 低置信度进入审核队列                  │
└───────────────────────┬─────────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────────┐
│                     Stage 3:人工审核                            │
│    置信度 < 0.60 的边界案例进入审核队列                           │
│    审核界面:展示原始值、期望值、偏差百分比、历史上下文           │
│    决策:批准 / 否决 / 手动修正                                  │
└───────────────────────┬─────────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────────┐
│                     输出层                                        │
│    修正后的 K 线(供下游回测 / 实盘使用)                        │
│    全量异常日志(审计追溯)                                       │
│    清洗统计报告(定期复盘)                                       │
└─────────────────────────────────────────────────────────────────┘

八、结语

数据清洗不是一次性工程,而是与市场共同演进的持续过程。每经历一次新的数据异常事件,你就多了一个分类样本,流水线的阈值就多了一分校准依据。

本文的核心方法论可以归纳为三点:

第一,多算法交叉比单一算法可靠。Z-Score 快但脆弱,MAD 稳健但对多峰分布敏感,分位数过滤能适应波动率变化。三者组合投票,误报率远低于任何单一方法。

第二,自动修正和人工审核不是非此即彼。高置信度(>0.85)的异常可以安全自动修正;低置信度的边缘案例必须有人兜底。建立一个健康的审核队列,让经验反哺规则,是系统长期稳定的关键。

第三,保留原始数据是底线。修正操作永远在下游管道执行,原始数据一字不动。这是审计要求,也是当系统出错时你能快速回滚的保障。


下一步行动

如果你正在用低质量数据跑回测,先问自己一个问题:你的夏普比率,有多少是靠"清洗掉的异常"贡献的?建议用本文的流水线跑一遍历史数据,对比修正前后的策略表现差异。

如果你希望快速接入 TickDB 的历史 K 线数据

  1. 访问 tickdb.ai 注册(免费,无需信用卡)
  2. 在控制台生成 API Key
  3. 设置环境变量 TICKDB_API_KEY,运行本文代码即可获取清洗数据

如果你需要 TickDB 的实时数据流(用于实盘监控),使用 WebSocket 的 depth 频道订阅订单簿深度,传入同样的清洗逻辑。文档地址:api.tickdb.ai(文字描述,无外链)。

如果你习惯用 AI 辅助开发,在 AI 助手中搜索安装 tickdb-market-data SKILL,可直接用自然语言查询 TickDB 的市场数据。


本文不构成任何投资建议。市场有风险,投资需谨慎。数据清洗方法论的有效性取决于具体市场环境和数据特征,建议在实盘使用前进行充分验证。