回测数据清洗:如何处理缺失K线和异常数据

回测数据清洗:如何处理缺失K线和异常数据

做回测的人都有一个共识:垃圾数据进,垃圾结果出。策略逻辑写得再漂亮,如果喂进去的K线数据有缺失、有异常,回测出来的收益率曲线就是自欺欺人。

这篇文章不讲策略,只讲数据清洗。具体覆盖四块:缺失K线检测与填充、异常价格处理、时间对齐、数据完整性验证。全部用可运行的Python代码演示。

一、先搞清楚数据长什么样

拿到一份K线数据,第一步不是急着清洗,而是先做体检。常见的脏数据有几类:

  • 时间戳缺失:该有K线的时间点没有记录,比如5分钟线里少了10:35这根
  • 时间戳重复:同一时间出现两条记录
  • OHLC逻辑错误:high < low,或者 close 超出 low, high 范围
  • 价格异常:价格突然变成0、负数,或者出现不可能的大幅跳变
  • 成交量异常:成交量为负或为0但价格有波动

先写一个体检函数:

python 复制代码
import pandas as pd
import numpy as np

def data_health_check(df, freq='5min'):
    """
    对K线数据做基础体检
    df: 必须包含 columns: ['datetime', 'open', 'high', 'low', 'close', 'volume']
    freq: K线周期,如 '1min', '5min', '1D'
    """
    df = df.copy()
    df['datetime'] = pd.to_datetime(df['datetime'])
    df = df.sort_values('datetime').reset_index(drop=True)

    print("=" * 50)
    print(f"总记录数: {len(df)}")
    print(f"时间范围: {df['datetime'].iloc[0]} ~ {df['datetime'].iloc[-1]}")

    # 1. 重复时间戳
    dup = df[df.duplicated('datetime', keep=False)]
    print(f"\n重复时间戳记录数: {len(dup)}")

    # 2. OHLC逻辑检查
    bad_ohlc = df[
        (df['high'] < df['low']) |
        (df['close'] > df['high']) |
        (df['close'] < df['low']) |
        (df['open'] > df['high']) |
        (df['open'] < df['low'])
    ]
    print(f"OHLC逻辑错误记录数: {len(bad_ohlc)}")

    # 3. 价格异常
    bad_price = df[
        (df[['open','high','low','close']] <= 0).any(axis=1)
    ]
    print(f"价格<=0的记录数: {len(bad_price)}")

    # 4. 成交量异常
    bad_vol = df[df['volume'] < 0]
    print(f"成交量为负的记录数: {len(bad_vol)}")

    # 5. 缺失K线检测
    expected_range = pd.date_range(
        start=df['datetime'].min(),
        end=df['datetime'].max(),
        freq=freq
    )
    missing = expected_range.difference(df['datetime'])
    print(f"缺失K线数量: {len(missing)}")
    if len(missing) > 0:
        print(f"缺失时间点示例: {list(missing[:5])}")

    print("=" * 50)
    return {
        'duplicates': dup,
        'bad_ohlc': bad_ohlc,
        'bad_price': bad_price,
        'bad_vol': bad_vol,
        'missing_times': missing
    }

这个函数直接告诉你数据有多脏。跑一遍再决定怎么处理。

二、缺失K线的检测与填充

缺失K线分两种情况,处理方式完全不同:

2.1 正常缺失 vs 异常缺失

  • 正常缺失:非交易时段(夜间、周末、节假日),这些不该填充
  • 异常缺失:交易时段内该有K线但没有,必须处理

所以填充之前,先要有一份交易日历。没有交易日历怎么办?可以用数据自身的密度来推断。

python 复制代码
def detect_missing_bars(df, freq='5min', trading_hours=None):
    """
    检测交易时段内的缺失K线
    trading_hours: list of (start, end) tuples, 如 [('09:30','11:30'), ('13:00','15:00')]
                   如果为None,则用数据自身推断
    """
    df = df.copy()
    df['datetime'] = pd.to_datetime(df['datetime'])
    df = df.sort_values('datetime').reset_index(drop=True)

    if trading_hours is not None:
        # 按指定交易时段过滤
        mask = pd.Series(False, index=df.index)
        for start, end in trading_hours:
            t = df['datetime'].dt.time
            mask |= (t >= pd.Timestamp(start).time()) & (t <= pd.Timestamp(end).time())
        trading_df = df[mask]
    else:
        trading_df = df

    if len(trading_df) < 2:
        return pd.DatetimeIndex([])

    # 生成完整时间序列
    full_range = pd.date_range(
        start=trading_df['datetime'].min(),
        end=trading_df['datetime'].max(),
        freq=freq
    )

    # 如果指定了交易时段,过滤掉非交易时段
    if trading_hours is not None:
        mask = pd.Series(False, index=full_range)
        for start, end in trading_hours:
            t = full_range.time
            mask |= (t >= pd.Timestamp(start).time()) & (t <= pd.Timestamp(end).time())
        full_range = full_range[mask]

    missing = full_range.difference(trading_df['datetime'])
    return missing

2.2 填充策略

缺失K线怎么填?三种常见做法:

  1. 前值填充(forward fill):用上一根K线的close作为缺失K线的OHLC,volume=0。适合流动性好的品种,短期缺失影响不大。
  2. 线性插值:在前后K线之间做线性插值。适合缺失连续多根的情况。
  3. 直接删除:如果缺失太多,或者缺失位置关键,直接删掉这段区间,不参与回测。
python 复制代码
def fill_missing_bars(df, freq='5min', method='ffill', trading_hours=None):
    """
    填充缺失K线
    method: 'ffill' (前值填充) 或 'linear' (线性插值)
    """
    df = df.copy()
    df['datetime'] = pd.to_datetime(df['datetime'])
    df = df.sort_values('datetime').set_index('datetime')

    # 生成完整时间索引
    full_range = pd.date_range(start=df.index.min(), end=df.index.max(), freq=freq)

    if trading_hours is not None:
        mask = pd.Series(False, index=full_range)
        for start, end in trading_hours:
            t = full_range.time
            mask |= (t >= pd.Timestamp(start).time()) & (t <= pd.Timestamp(end).time())
        full_range = full_range[mask]

    # 重新索引
    df = df.reindex(full_range)
    df.index.name = 'datetime'

    if method == 'ffill':
        # 前值填充价格,成交量填0
        for col in ['open', 'high', 'low', 'close']:
            df[col] = df[col].ffill()
        df['volume'] = df['volume'].fillna(0)
        # 标记哪些是填充的
        df['is_filled'] = df['volume'] == 0

    elif method == 'linear':
        for col in ['open', 'high', 'low', 'close', 'volume']:
            df[col] = df[col].interpolate(method='linear')
        df['is_filled'] = False
        df.loc[df['volume'] == 0, 'is_filled'] = True

    df = df.reset_index()
    return df

关键点:填充后一定要打标记。回测时可以选择性地跳过填充的K线,或者在填充K线上不产生交易信号。

三、异常价格处理

异常价格比缺失更危险,因为它会直接导致错误的交易信号。

3.1 基于统计的异常检测

用滚动窗口的MAD(Median Absolute Deviation)来检测价格跳变:

python 复制代码
def detect_price_outliers(df, window=20, threshold=5):
    """
    用滚动MAD检测异常价格跳变
    threshold: MAD的倍数,超过则认为异常
    """
    df = df.copy()
    df['return'] = df['close'].pct_change()

    # 滚动中位数和MAD
    rolling_median = df['return'].rolling(window=window, min_periods=5).median()
    rolling_mad = (df['return'] - rolling_median).abs().rolling(window=window, min_periods=5).median()

    # 避免除以0
    rolling_mad = rolling_mad.replace(0, np.nan)

    # 修正Z-score
    modified_z = 0.6745 * (df['return'] - rolling_median) / rolling_mad

    df['is_outlier'] = modified_z.abs() > threshold
    return df

3.2 异常价格修复

检测到异常后,有两种处理方式:

  • 直接删除:把异常K线整根删掉,然后用前面的填充逻辑补回来
  • Winsorize:把异常价格截断到合理范围
python 复制代码
def fix_price_outliers(df, window=20, threshold=5):
    """
    修复异常价格:将异常K线的OHLC替换为前一根K线的close
    """
    df = df.copy()
    df = detect_price_outliers(df, window, threshold)

    outlier_idx = df[df['is_outlier']].index
    for idx in outlier_idx:
        if idx == 0:
            continue
        prev_close = df.loc[idx - 1, 'close']
        df.loc[idx, ['open', 'high', 'low', 'close']] = prev_close
        df.loc[idx, 'volume'] = 0
        df.loc[idx, 'is_fixed'] = True

    df['is_fixed'] = df.get('is_fixed', False)
    return df

3.3 OHLC逻辑修正

有些数据源的high/low会写反,或者close超出范围。直接修正:

python 复制代码
def fix_ohlc_logic(df):
    """
    修正OHLC逻辑错误
    """
    df = df.copy()

    # high应该是四个价格中最大的
    df['high'] = df[['open', 'high', 'low', 'close']].max(axis=1)
    # low应该是最小的
    df['low'] = df[['open', 'high', 'low', 'close']].min(axis=1)

    return df

四、时间对齐

时间对齐是回测里最容易被忽视的坑。不同数据源的时间戳含义可能不同:

  • K线开始时间:这根K线的起始时间
  • K线结束时间:这根K线的结束时间
  • 时区问题:UTC vs 本地时间

4.1 统一时间戳含义

python 复制代码
def align_timestamp(df, timestamp_type='start', freq='5min'):
    """
    统一时间戳为K线开始时间
    timestamp_type: 'start' 表示原数据是开始时间,'end' 表示是结束时间
    """
    df = df.copy()
    df['datetime'] = pd.to_datetime(df['datetime'])

    if timestamp_type == 'end':
        # 结束时间转开始时间
        df['datetime'] = df['datetime'] - pd.Timedelta(freq)

    return df

4.2 多数据源对齐

如果你同时用多个数据源(比如价格来自A,成交量来自B),需要按时间对齐:

python 复制代码
def align_multiple_sources(df_price, df_volume, freq='5min'):
    """
    对齐两个数据源
    """
    df_price = df_price.set_index('datetime')
    df_volume = df_volume.set_index('datetime')

    # 取交集
    common_idx = df_price.index.intersection(df_volume.index)

    df_aligned = df_price.loc[common_idx].copy()
    df_aligned['volume'] = df_volume.loc[common_idx, 'volume']

    return df_aligned.reset_index()

五、数据完整性验证

清洗完之后,写一个验证函数,确保数据可以安全用于回测:

python 复制代码
def validate_clean_data(df, freq='5min'):
    """
    清洗后的数据验证
    """
    errors = []
    warnings = []

    df = df.copy()
    df['datetime'] = pd.to_datetime(df['datetime'])

    # 1. 时间戳唯一性
    if df['datetime'].duplicated().any():
        errors.append("存在重复时间戳")

    # 2. 时间戳单调递增
    if not df['datetime'].is_monotonic_increasing:
        errors.append("时间戳不是单调递增")

    # 3. OHLC逻辑
    bad = df[
        (df['high'] < df['low']) |
        (df['close'] > df['high']) |
        (df['close'] < df['low'])
    ]
    if len(bad) > 0:
        errors.append(f"存在{len(bad)}条OHLC逻辑错误")

    # 4. 价格正数
    if (df[['open','high','low','close']] <= 0).any().any():
        errors.append("存在非正价格")

    # 5. 缺失K线
    expected = pd.date_range(df['datetime'].min(), df['datetime'].max(), freq=freq)
    missing_count = len(expected.difference(df['datetime']))
    if missing_count > 0:
        warnings.append(f"存在{missing_count}根缺失K线")

    # 6. 填充比例
    if 'is_filled' in df.columns:
        fill_ratio = df['is_filled'].mean()
        if fill_ratio > 0.05:
            warnings.append(f"填充K线比例过高: {fill_ratio:.2%}")

    print("验证结果:")
    if errors:
        print("  [错误]")
        for e in errors:
            print(f"    - {e}")
    if warnings:
        print("  [警告]")
        for w in warnings:
            print(f"    - {w}")
    if not errors and not warnings:
        print("  数据完整,可以用于回测")

    return len(errors) == 0

六、完整清洗流程

把上面的步骤串起来:

python 复制代码
def clean_kline_data(df, freq='5min', trading_hours=None):
    """
    完整的K线数据清洗流程
    """
    # 1. 体检
    print(">>> 数据体检")
    health = data_health_check(df, freq)

    # 2. 去重
    df = df.drop_duplicates('datetime', keep='first')

    # 3. 修正OHLC逻辑
    df = fix_ohlc_logic(df)

    # 4. 修复异常价格
    df = fix_price_outliers(df)

    # 5. 填充缺失K线
    df = fill_missing_bars(df, freq, method='ffill', trading_hours=trading_hours)

    # 6. 验证
    print("\n>>> 清洗后验证")
    validate_clean_data(df, freq)

    return df

七、几个实战建议

  1. 保留is_filled标记:回测时可以选择在填充K线上不产生信号,避免虚假交易。
  2. 填充比例超过5%要警惕:如果缺失太多,说明数据源有问题,考虑换数据源。
  3. 异常检测的阈值要调:不同品种波动率不同,threshold=5不一定适合所有情况,建议先用历史数据跑一遍看分布。
  4. 时间对齐要在清洗前做:如果时间戳含义不统一,后面的缺失检测全是错的。
  5. 清洗日志要保存:每次清洗记录删了多少、填了多少、修了多少,方便追溯。

数据清洗没有一劳永逸的方案,核心原则是:知道数据哪里脏,知道怎么处理,知道处理后有什么影响。把这三点搞清楚,回测结果才有可信度。

更多内容请关注本站。


⭐ 今日观察列表

以下标的来自多周期趋势扫描系统(Gate.io合约),仅供技术分析学习参考,不构成任何投资建议。读者请自行判断。

数据更新时间:2026-10-04 09:01:40

标的 方向 信号类型 现价 参考止盈 参考止损 盈亏比
AIN 🔼 做多 短平快做多 $0.0389 $0.04 $0.0379 1:1.2

共 1 个标的。扫描频率:每15分钟更新。


以上数据由量化扫描系统自动生成,如有兴趣了解更多技术细节,请关注本站后续文章。

相关推荐
for_ever_love__2 天前
电商订单数据清洗实战——Pandas 处理缺失值、重复单与异常金额
python·数据分析·pandas·数据清洗
东莞市云毅网络有限公司18 天前
用 SQLite FTS5 给企业文档建本地全文索引:中文分词与权重调优
python·数据清洗·rag·企业知识库·文档解析
Patrick在香港20 天前
Claude Prompt 香港场景:公文里「今日」落在 6 个日历日上,「翌日」锚错了 5 天
python·自然语言处理·正则表达式·claude·数据清洗
2601_9623824324 天前
Python自动化办公实战指南:从入门到精通
数据清洗·python自动化办公·报表生成·文档处理·邮件分发
沙淘金数据服务1 个月前
电商订单数据整合难题拆解:多源异构数据如何实现自动化治理?
大数据·人工智能·数据治理·数据清洗·电商·外贸
东莞市云毅网络有限公司1 个月前
企业非结构化资料拆分成可引用片段:一套能跑通的预处理流水线
python·数据清洗·rag·企业知识库·文档解析
Patrick在香港1 个月前
把 Claude 塞进 pandas 管道:7 条脏地址实测,5 条自动清洗、2 条被闸门拦下
python·pandas·etl·claude·数据清洗
whcyhhh1 个月前
头歌实践教学平台:数据科学与大数据技术导论(十二)
大数据·开发语言·python·数据清洗
Hi_Amos1 个月前
记一次 yfinance 源码调试:SOCKS5 代理下 Chart API 正常,历史数据却一直超时
python·k线·yfinance