3. 数据准备与清洗:如何获取、清洗和预处理股票/期货数据
做因子回测,最怕什么?
不是策略亏钱,而是数据有坑。我见过太多人,因子算出来漂亮得很,一上实盘就崩。查到最后,发现是数据里藏了几个脏点。嗯,今天我们就聊聊数据准备这件事。
3.1 数据获取:从哪里来,拿什么用
数据源的选择,直接决定了你后续工作的质量。我个人习惯把数据源分成三类:
| 类型 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| 免费公开 | Tushare、AKShare、Yahoo Finance | 零成本,上手快 | 稳定性差,字段不全 |
| 商业数据 | Wind、Choice、Bloomberg | 质量高,字段丰富 | 贵,年费几万起 |
| 自建采集 | 爬虫抓取交易所公告 | 定制化强 | 维护成本高 |
我在项目中遇到过最头疼的事:用免费数据跑回测,夏普比率做到2.8,换到商业数据一测,直接掉到1.2。为什么?免费数据里缺失了分红除权的调整,复权价格算错了。
3.2 数据清洗:脏数据是因子杀手
数据拿到手,第一件事不是算因子,而是洗数据。我总结了一套「四步清洗法」:
3.2.1 缺失值处理
股票停牌、期货换月,都会产生缺失值。处理方法要看场景:
- 时间序列因子:用前向填充(ffill),假设停牌期间价格不变
- 截面因子:用行业均值填充,或者直接剔除
- 极端情况:超过连续5天缺失,我建议直接标记为无效
# 我个人常用的缺失值处理逻辑
import pandas as pd
def clean_missing(df, method='ffill', max_gap=5):
"""
df: 多级索引 DataFrame (date, asset)
method: ffill / drop / fill_mean
max_gap: 最大连续缺失天数
"""
if method == 'ffill':
# 前向填充,但限制最大连续缺失
df = df.groupby(level='asset').apply(
lambda x: x.fillna(method='ffill', limit=max_gap)
)
elif method == 'drop':
df = df.dropna()
return df
3.2.2 异常值检测
说白了,就是找出那些「离谱」的数据点。比如某只股票一天涨了50%,但当天大盘没动,也没有重大利好——这八成是数据错误。
我常用的方法:
- MAD法:中位数绝对偏差,比3σ更稳健
- 分位数法:剔除上下1%的极端值
- 行业对比法:同行业同一天的收益率偏离超过5个标准差,标记为异常
3.2.3 复权处理
做因子回测,必须用复权价格。为什么?你想想看,一只股票分红10元,价格从100跌到90,你的因子计算如果基于原始价格,就会误判为「下跌10%」,但实际持有者并没有亏损。
复权有两种方式:
| 类型 | 逻辑 | 适用场景 |
|---|---|---|
| 前复权 | 调整历史价格,保持当前价格不变 | 回测时常用,方便看历史走势 |
| 后复权 | 调整当前价格,保持历史价格不变 | 计算真实收益率时用 |
我个人习惯用后复权算因子,前复权做可视化。两者结合,基本能避开复权带来的坑。
3.2.4 幸存者偏差剔除
这是新手最容易忽略的问题。你拿今天还在交易的股票做回测,当然表现好——因为退市的、暴跌的都被你自动过滤掉了。
正确的做法:
- 回测时使用「当时存在」的股票池
- 包含已退市、被ST的股票
- 定期更新成分股列表
3.3 数据预处理:为因子计算铺路
数据洗干净了,接下来就是预处理。这一步做好了,因子计算会顺畅很多。
3.3.1 对齐与重采样
不同数据源的频率可能不一样:日线、分钟线、Tick级。做因子研究,我建议统一到日频。为什么?
- 日频数据最稳定,受微观结构噪声影响小
- 大部分学术研究都基于日频,便于对比
- 计算成本低,迭代速度快
# 日频重采样示例
def resample_to_daily(df, rule='D', agg_method='last'):
"""
将高频数据重采样为日频
rule: 'D' 日频, 'W' 周频, 'M' 月频
agg_method: 'last' 收盘价, 'mean' 均价
"""
return df.resample(rule).agg(agg_method)
3.3.2 行业分类标准化
不同数据源的行业分类可能不一样。有的用申万一级,有的用GICS,有的用中信。做截面因子时,行业中性化处理必须统一分类标准。
我建议的做法:
- 建立行业映射表,把不同分类映射到统一标准
- 至少保留两级分类(行业+子行业)
- 定期更新行业分类(每年至少一次)
3.3.3 数据切片与存储
数据量大了之后,每次全量加载会非常慢。我习惯按年份切片存储:
# 按年切片存储
def slice_and_save(df, path_template='./data/{year}.parquet'):
df['year'] = df.index.year
for year, group in df.groupby('year'):
group.to_parquet(path_template.format(year=year))
这样做的好处是:回测时只需要加载对应年份的数据,内存占用小,速度快。
3.4 数据质量检查清单
每次拿到新数据,我都会过一遍这个清单:
- 时间范围检查:数据覆盖的时间段是否完整?有没有跳空?
- 股票数量检查:每个时间截面的股票数量是否合理?有没有突然减少?
- 价格合理性检查:有没有负价格?有没有异常涨跌停?
- 成交量检查:有没有零成交量的交易日?
- 财务数据检查:营收、利润等指标有没有缺失?有没有异常值?
3.5 数据清洗流程总览
下面这张图,是我多年实践总结出来的数据清洗流程。你可以把它贴在工位上,每次做数据都对照着走一遍。
这张图看起来简单,但每一步都有很多细节。我在实际项目中,光是「异常值检测」这一步,就踩过不下10个坑。比如:
- 新股上市首日不设涨跌幅,容易被误判为异常
- ST股连续跌停,成交量极低,容易被误判为缺失
- 期货换月时的价格跳空,需要特殊处理
这些细节,只有在实战中才会遇到。书本上不会告诉你,数据商也不会提醒你。