4. 数据获取与清洗:从零搭建你的量化数据管道

做量化回测,最怕什么?

数据不对。我见过太多人,策略写得漂漂亮亮,一跑回测就亏钱。结果查了半天,发现是数据源出了问题。嗯,今天我们就来聊聊数据获取与清洗这件事。

4.1 用 yfinance 获取股票数据

我个人习惯用 yfinance 来拉数据。为什么?因为它免费、简单,而且覆盖全球主要市场。你想想看,一行代码就能拿到苹果、腾讯、茅台的历史数据,多方便。

核心思路: yfinance 本质上是 Yahoo Finance 的 Python 封装。它把复杂的 HTTP 请求、JSON 解析都藏起来了,你只需要告诉它「我要什么股票、什么时间段」就行。

import yfinance as yf

# 获取贵州茅台的历史数据
ticker = "600519.SS"
data = yf.download(ticker, start="2020-01-01", end="2024-12-31")
print(data.head())

这里有个坑,我曾经踩过。A股代码后面要加后缀:上海交易所是 .SS,深圳交易所是 .SZ。港股是 .HK。美股就直接用代码,比如 AAPL

小技巧: 如果你要批量下载多只股票,可以传一个列表进去:

tickers = ["600519.SS", "000001.SZ", "AAPL", "0700.HK"]
data = yf.download(tickers, start="2020-01-01", end="2024-12-31")

返回的是一个 MultiIndex DataFrame,列名是 (价格字段, 股票代码) 的元组。刚开始用可能会有点懵,习惯就好。

4.2 数据清洗:处理缺失值与异常值

数据拉下来,别急着用。先看看有没有坑。

我遇到过最离谱的一次,某只股票连续停牌三个月,中间全是 NaN。如果直接拿这个数据跑回测,策略会以为股价跌到零,然后疯狂买入——结果可想而知。

4.2.1 缺失值处理

缺失值说白了就是数据没拿到。原因很多:停牌、节假日、数据源抽风。处理方式有三种:

方法 适用场景 代码示例
向前填充 停牌、非交易日 data.fillna(method='ffill')
向后填充 数据延迟更新 data.fillna(method='bfill')
插值法 少量缺失、趋势明显 data.interpolate()
直接删除 缺失比例过高 data.dropna()

注意: 向前填充是最常用的,但有个前提——你要确认停牌期间没有重大事件。我曾经用向前填充处理过一只重组停牌的股票,结果复牌当天直接跌停,策略完全没反应过来。所以,停牌超过一个月的,建议直接删除那段时间的数据。

# 我的标准清洗流程
def clean_data(df):
    # 1. 检查缺失比例
    missing_ratio = df.isnull().sum() / len(df)
    print(f"缺失比例:\n{missing_ratio}")
    
    # 2. 缺失超过20%的列,直接删除
    cols_to_drop = missing_ratio[missing_ratio > 0.2].index
    df = df.drop(columns=cols_to_drop)
    
    # 3. 剩余缺失值,向前填充
    df = df.fillna(method='ffill')
    
    # 4. 如果开头还有缺失,向后填充
    df = df.fillna(method='bfill')
    
    return df

4.2.2 异常值处理

异常值比缺失值更隐蔽。你想想看,某天股价突然涨了50%,第二天又跌回去。这可能是数据录入错误,也可能是真的发生了什么事。

我的判断标准很简单:

  • 涨跌幅超过20%:大概率是异常,需要核实
  • 成交量突然放大10倍:可能是拆股或送股
  • 价格出现负数:绝对是数据错误,直接删除
# 异常值检测
def detect_outliers(df, column='Close', threshold=0.2):
    # 计算日收益率
    returns = df[column].pct_change()
    
    # 标记异常点
    outliers = returns[abs(returns) > threshold]
    
    print(f"发现 {len(outliers)} 个异常点")
    return outliers

# 处理异常值:用前后均值替换
def fix_outliers(df, column='Close', threshold=0.2):
    returns = df[column].pct_change()
    outlier_mask = abs(returns) > threshold
    
    # 用前一个交易日和后一个交易日的均值替换
    for idx in df[outlier_mask].index:
        prev_idx = df.index.get_loc(idx) - 1
        next_idx = df.index.get_loc(idx) + 1
        if prev_idx >= 0 and next_idx < len(df):
            df.loc[idx, column] = (df.iloc[prev_idx][column] + df.iloc[next_idx][column]) / 2
    
    return df

避坑指南: 我曾经用3倍标准差法检测异常值,结果把2020年3月美股熔断的数据全给剔除了。那段时间的波动是真实的,不是异常。所以,异常值检测一定要结合业务场景,别光靠统计方法。

4.3 数据重采样:统一时间频率

做量化回测,最烦的就是数据频率不统一。日线数据、分钟数据、Tick 数据混在一起,根本没法算。

重采样说白了就是「降频」或「升频」。把高频数据转成低频,或者反过来。

4.3.1 降频:日线转周线/月线

# 日线转周线
weekly_data = data.resample('W').agg({
    'Open': 'first',
    'High': 'max',
    'Low': 'min',
    'Close': 'last',
    'Volume': 'sum'
})

# 日线转月线
monthly_data = data.resample('M').agg({
    'Open': 'first',
    'High': 'max',
    'Low': 'min',
    'Close': 'last',
    'Volume': 'sum'
})

这里有个细节:开盘价取周期第一天的,收盘价取最后一天的,最高价取周期内的最大值,最低价取最小值。成交量就累加。这个逻辑很直观,但很多人会搞混。

4.3.2 升频:日线转小时线

升频比降频麻烦。因为你需要「无中生有」地填充数据。常用的方法有:

  • 向前填充:假设一天内价格不变
  • 线性插值:假设价格均匀变化
  • 时间加权:更精确,但计算复杂
# 日线转小时线(向前填充)
hourly_data = data.resample('H').ffill()

# 日线转小时线(线性插值)
hourly_data = data.resample('H').interpolate(method='linear')

警告: 升频数据是「人造」的,千万别拿它做高频策略的回测。我见过有人把日线插值成分钟线,然后跑高频策略,回测收益高得离谱。结果实盘一跑,亏得裤衩都不剩。为什么?因为插值出来的数据没有真实的买卖盘口信息。

4.4 本章知识体系

说了这么多,我们来画个图,把整个数据管道的逻辑串起来:

数据获取与清洗流程 数据获取 yfinance.download() 缺失值处理 ffill / bfill / interpolate 异常值处理 涨跌幅检测 / 替换 数据重采样 降频 / 升频 ✅ 干净、对齐的量化数据 数据质量决定回测质量,别在这个环节偷懒

这张图把整个流程串起来了。你想想看,从数据获取到最终可用的数据,中间要经过三道关卡。每一关都可能出问题,但每一关也都有对应的解决方案。

我个人习惯把数据清洗写成独立的函数,每次拉新数据都跑一遍。这样既保证一致性,也方便调试。嗯,今天就先聊到这里。数据准备好了,下一章我们就能开始真正的回测了。


专注资料整理