3. 数据获取:用Python获取ETF历史行情数据,数据清洗与预处理

好了,到了真正动手的环节了。

前面我们聊了策略逻辑,聊了ETF怎么选。但说句实话,没有数据,一切都是空谈。量化交易说白了就是「用数据说话」的行业。你策略再漂亮,拿到的数据是脏的、错的,那结果就是垃圾进垃圾出。

我个人习惯,在开始任何策略开发前,先花30%的时间把数据这块搞定。别嫌慢,这步省了,后面调试会让你哭出来。

3.1 数据源的选择:免费的午餐在哪里?

获取ETF行情数据,渠道其实不少。我列几个常用的,你根据自己情况选。

数据源 特点 推荐指数
Tushare 老牌金融数据接口,数据全,但新用户有积分限制 ⭐⭐⭐⭐
AkShare 开源免费,接口丰富,更新快,我目前主力用这个 ⭐⭐⭐⭐⭐
Baostock 免费,无需注册,数据质量稳定 ⭐⭐⭐⭐
东方财富/新浪 直接爬取,但容易被封,维护成本高 ⭐⭐⭐
我的建议:新手直接上AkShare。免费、不用折腾注册、文档也写得清楚。我在项目中遇到过几次数据源变更的情况,AkShare的社区活跃度最高,基本当天就有人更新修复。

3.2 用AkShare获取ETF历史数据

先装库,这个不用我多说了吧?

pip install akshare

装好之后,我们来看看怎么拿数据。以我最常跟踪的「沪深300ETF(510300)」为例。

import akshare as ak
import pandas as pd

# 获取ETF历史行情
# 参数说明:symbol是ETF代码,start_date和end_date控制时间范围
df = ak.fund_etf_hist_em(
    symbol="510300",
    period="daily",
    start_date="20200101",
    end_date="20241231",
    adjust="qfq"  # 前复权,这个很重要
)

print(df.head())
print(df.columns.tolist())
注意:adjust参数一定要设成"qfq"(前复权)。我曾经吃过这个亏——直接用原始价格做回测,结果策略表现好得离谱。后来才发现是因为没复权,分红除权造成的价格跳空被当成了交易信号。嗯,那感觉就像考试抄到了答案,结果发现抄的是隔壁班的卷子。

跑完代码,你会看到类似这样的数据:

日期 开盘 收盘 最高 最低 成交量 成交额
2020-01-02 3.892 3.912 3.918 3.882 1.2亿 4.7亿
2020-01-03 3.908 3.885 3.915 3.878 0.9亿 3.5亿

3.3 数据清洗:脏数据是策略的隐形杀手

数据拿到手了,但别急着用。你想想看,真实市场里什么情况都可能发生:停牌、数据缺失、异常波动。这些脏数据不处理,策略回测结果就是骗自己的。

我一般按以下步骤清洗:

  1. 检查缺失值——看看有没有空数据
  2. 处理停牌日——停牌期间价格不变,但成交量是0
  3. 去除无效数据——比如价格<=0的记录
  4. 统一索引——把日期设为索引,方便后续操作
# 数据清洗流程
def clean_etf_data(df):
    # 1. 选择需要的列,重命名
    df = df[['日期', '开盘', '收盘', '最高', '最低', '成交量']]
    df.columns = ['date', 'open', 'close', 'high', 'low', 'volume']
    
    # 2. 日期格式转换
    df['date'] = pd.to_datetime(df['date'])
    df.set_index('date', inplace=True)
    
    # 3. 按日期排序
    df.sort_index(inplace=True)
    
    # 4. 处理缺失值
    # 如果某天数据缺失,用前一天的收盘价填充(保守做法)
    df.fillna(method='ffill', inplace=True)
    
    # 5. 去除异常值
    # 价格不可能为0或负数
    df = df[df['close'] > 0]
    df = df[df['volume'] >= 0]
    
    # 6. 检查停牌日
    # 成交量极低的日子可能是停牌或流动性枯竭
    # 我一般会标记出来,但不删除
    df['is_suspended'] = df['volume'] < 1000  # 假设1000手以下视为停牌
    
    return df

# 执行清洗
df_clean = clean_etf_data(df)
print(f"清洗前数据量: {len(df)}")
print(f"清洗后数据量: {len(df_clean)}")
核心原则:清洗数据时,宁可漏掉一些边缘数据,也不要引入错误数据。我在做CTA策略时,就因为没处理停牌日的数据,导致策略在复牌那天开出了天量仓位,回测曲线直接崩了。

3.4 数据预处理:为策略计算铺路

清洗完了,数据基本能用了。但为了后续计算方便,我还会做几项预处理。

说白了,就是给数据「加料」,让策略模块可以直接调用。

# 数据预处理
def preprocess_etf_data(df):
    # 1. 计算收益率(对数收益率更符合金融假设)
    df['log_return'] = np.log(df['close'] / df['close'].shift(1))
    
    # 2. 计算简单收益率
    df['simple_return'] = df['close'].pct_change()
    
    # 3. 计算日均价格(用于某些策略)
    df['avg_price'] = (df['open'] + df['high'] + df['low'] + df['close']) / 4
    
    # 4. 计算振幅
    df['range'] = (df['high'] - df['low']) / df['close']
    
    # 5. 删除第一行(因为收益率计算会产生NaN)
    df.dropna(inplace=True)
    
    return df

# 应用预处理
df_final = preprocess_etf_data(df_clean)
print(df_final.tail())

你可能会问:为什么要算对数收益率?

嗯,这里简单解释一下。对数收益率有个好处——它是可加的。你想想看,如果你要算一周的总收益率,简单收益率需要连乘,而对数收益率直接相加就行。这在后面做统计分析时会方便很多。

3.5 本章知识体系

为了让你对整个流程有个直观印象,我画了张图。数据获取到预处理,其实就是一条流水线:

ETF数据获取与预处理流程 数据源选择 AkShare / Tushare 获取历史行情 日线/前复权 数据清洗 缺失值/异常值/停牌 数据预处理 收益率/振幅计算 各步骤关键点: • 数据源:优先选择社区活跃、文档完善的免费接口 • 获取数据:务必使用前复权,避免分红除权干扰 • 数据清洗:ffill填充缺失值,去除价格≤0的异常记录 • 数据预处理:计算对数收益率和简单收益率,为策略计算做准备 数据质量决定策略上限,花时间在这步上绝对值得

3.6 多ETF批量获取

做轮动策略,肯定不止跟踪一只ETF。我习惯写个循环,一次性把候选池里的数据都拉下来。

# 候选ETF池
etf_pool = {
    '510300': '沪深300ETF',
    '510500': '中证500ETF',
    '159915': '创业板ETF',
    '510050': '上证50ETF',
    '513100': '纳指ETF'
}

# 批量获取并清洗
def load_etf_data(symbol, name, start='20200101', end='20241231'):
    df = ak.fund_etf_hist_em(
        symbol=symbol,
        period="daily",
        start_date=start,
        end_date=end,
        adjust="qfq"
    )
    df = clean_etf_data(df)
    df = preprocess_etf_data(df)
    df['etf_name'] = name
    df['etf_code'] = symbol
    return df

# 执行批量加载
all_etf_data = {}
for code, name in etf_pool.items():
    print(f"正在获取 {name} ({code})...")
    all_etf_data[code] = load_etf_data(code, name)
    print(f"  完成,共 {len(all_etf_data[code])} 条记录")

# 合并成一个DataFrame,方便后续分析
combined_df = pd.concat(all_etf_data.values(), keys=all_etf_data.keys())
print(combined_df.head())
小技巧:第一次拉数据时,建议把结果保存到本地CSV文件。这样下次跑策略时直接读本地文件,不用每次都请求接口。我在项目中就是这么干的——每天收盘后跑一次更新脚本,把数据增量追加到本地文件里。
# 保存到本地
combined_df.to_csv('etf_data_pool.csv')

# 下次直接读取
df_loaded = pd.read_csv('etf_data_pool.csv', index_col=0, parse_dates=True)

好了,数据这块就聊到这儿。你拿到干净的数据后,下一章我们就可以开始真正搭建轮动策略了。记住,数据是你的原材料,质量决定了最终产品的档次。


蓝海数据掘金营,专注资料整理