回测数据清洗:如何处理缺失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线怎么填?三种常见做法:
- 前值填充(forward fill):用上一根K线的close作为缺失K线的OHLC,volume=0。适合流动性好的品种,短期缺失影响不大。
- 线性插值:在前后K线之间做线性插值。适合缺失连续多根的情况。
- 直接删除:如果缺失太多,或者缺失位置关键,直接删掉这段区间,不参与回测。
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
七、几个实战建议
- 保留is_filled标记:回测时可以选择在填充K线上不产生信号,避免虚假交易。
- 填充比例超过5%要警惕:如果缺失太多,说明数据源有问题,考虑换数据源。
- 异常检测的阈值要调:不同品种波动率不同,threshold=5不一定适合所有情况,建议先用历史数据跑一遍看分布。
- 时间对齐要在清洗前做:如果时间戳含义不统一,后面的缺失检测全是错的。
- 清洗日志要保存:每次清洗记录删了多少、填了多少、修了多少,方便追溯。
数据清洗没有一劳永逸的方案,核心原则是:知道数据哪里脏,知道怎么处理,知道处理后有什么影响。把这三点搞清楚,回测结果才有可信度。
更多内容请关注本站。
⭐ 今日观察列表
以下标的来自多周期趋势扫描系统(Gate.io合约),仅供技术分析学习参考,不构成任何投资建议。读者请自行判断。
数据更新时间:2026-10-04 09:01:40
| 标的 | 方向 | 信号类型 | 现价 | 参考止盈 | 参考止损 | 盈亏比 |
|---|---|---|---|---|---|---|
| AIN 🔼 | 做多 | 短平快做多 | $0.0389 | $0.04 | $0.0379 | 1:1.2 |
共 1 个标的。扫描频率:每15分钟更新。
以上数据由量化扫描系统自动生成,如有兴趣了解更多技术细节,请关注本站后续文章。