Open Source, Open Future!
  menu
118 文章
ღゝ◡╹)ノ❤️

第2篇:数据获取与清洗

上一篇: 第1篇:开篇 - 量化系统从零到一

一、写在前面

上一篇讲了整个系统的架构,这篇开始进入具体实现。第一步就是数据获取,这也是最基础的一步。

说实话,一开始我以为数据获取很简单,不就是调个API下载数据嘛。但真正做起来才发现,数据质量的好坏直接决定了后面所有工作的成败。脏数据进去,再好的模型也没用。

二、数据源的选择

做量化第一个问题就是:数据从哪来?

2.1 调研过程

我前后看了几个数据源:

  • Wind/彭博:专业金融数据库,数据质量最高。但太贵了,个人用不起。
  • Yahoo Finance:免费,数据也全。但国内访问经常超时,不太稳定。
  • Akshare:开源免费,接口简单,A股覆盖也全。但有个实际问题:爬虫性质的接口稳定性一般,批量下载经常被限流,重试逻辑一复杂,代码就乱了。
  • Tushare Pro:需要积分,但接口是真正的API,稳定性比Akshare好得多。

2.2 最终选择:Tushare

最终选了Tushare,主要是两个原因:

  1. 稳定:正经的API服务,不是爬虫,批量下载几百只股票不会被封
  2. 数据质量高:复权因子由Tushare自行生产,精度有保证

最关键的是 pro_bar 接口,前复权数据一次调用直接拿到,不需要自己手动计算:

import tushare as ts

ts.set_token('your_token')
pro = ts.pro_api()

# 前复权日线数据,一行搞定
df = ts.pro_bar(ts_code='600519.SH', adj='qfq',
                start_date='20150101', end_date='20241231', api=pro)

注意 Tushare 的股票代码格式和其他数据源不一样,需要带交易所后缀:沪市用 .SH,深市用 .SZ

三、股票池选择

数据源确定了,下一个问题是:下载哪些股票?

3.1 从5只到800只

一开始只打算下5只代表性股票验证流程。但想了想,这个项目要展示的是完整的量化能力,数据量太少说服力不够。

最终方案:沪深300 + 中证500,共800只,10年历史数据(2015-2024)

3.2 为什么选这个范围

  1. 沪深300:A股市值最大的300只,代表大盘蓝筹
  2. 中证500:市值排名301-800的股票,代表中盘成长
  3. 两者合计800只,覆盖了A股绝大多数有投资价值的股票,互相没有重叠
  4. 10年跨度:覆盖了2015年牛市、2018年熊市、2020年疫情冲击、2022年下跌等各种市场环境,样本充分

3.3 获取成分股列表

成分股列表通过Akshare获取(只用于拿代码,不拉行情):

import akshare as ak

# 获取沪深300成分股
hs300 = ak.index_stock_cons_csindex(symbol='000300')
# 获取中证500成分股
zz500 = ak.index_stock_cons_csindex(symbol='000905')

转换成Tushare格式的代码:

def to_tushare_code(code, exchange):
    return code + ('.SH' if '上海' in exchange else '.SZ')

四、代码实现

4.1 整体设计

数据获取相关功能封装成 StockDataLoader 类:

class StockDataLoader:
    def __init__(self, data_dir='data', token=TUSHARE_TOKEN):
        self.data_dir = data_dir
        ts.set_token(token)
        self.pro = ts.pro_api()

    def get_stock_list(self, market='主板', top_n=50):
        """获取股票列表"""
        pass

    def download_stock_data(self, symbol, start_date, end_date):
        """下载单只股票前复权数据"""
        pass

    def clean_data(self, df):
        """数据清洗"""
        pass

    def download_multiple_stocks(self, symbols, start_date, end_date, delay=0.4):
        """批量下载,含限速"""
        pass

4.2 下载单只股票

def download_stock_data(self, symbol, start_date='20150101', end_date='20241231'):
    """用 pro_bar 下载前复权日线数据"""
    df = ts.pro_bar(
        ts_code=symbol,
        adj='qfq',
        start_date=start_date,
        end_date=end_date,
        api=self.pro
    )

    if df is None or len(df) == 0:
        return None

    df = df.rename(columns={'trade_date': 'date', 'vol': 'volume'})
    df['date'] = pd.to_datetime(df['date'])
    df['symbol'] = symbol
    df = df.sort_values('date').reset_index(drop=True)

    return df

4.3 数据清洗:三层过滤

def clean_data(self, df):
    # 第一层:删除核心字段缺失的行
    df = df.dropna(subset=['open', 'high', 'low', 'close', 'volume'])

    # 第二层:过滤停牌日(成交量为0)
    df = df[df['volume'] > 0]

    # 第三层:过滤异常涨跌幅(>30%,可能是数据错误)
    df['return'] = df['close'].pct_change()
    df = df[abs(df['return']) <= 0.3]
    df = df.drop('return', axis=1)

    return df.sort_values('date').reset_index(drop=True)

为什么不填充缺失值?

缺了就是缺了,用前一天的数据填充相当于在造假数据。后续特征工程里RSI、MACD这些指标都依赖OHLCV,缺一个就算不对,还不如直接删掉。

停牌的影响

停牌期间成交量为0,价格不变。如果不过滤:

  • 收益率会出现大量假的"0"
  • 动量指标(RSI、MACD)会在停牌期间锁死在某个值
  • 模型会学到错误的模式:"价格不变 = 收益率为零"

30% 的阈值

A股涨跌停限制:普通股票 ±10%,科创板/创业板 ±20%,北交所±30%

4.4 批量下载:断点续传 + 限速

800只股票不可能一口气下完,需要考虑两个问题:网络中断和API限速。

# download_index_data.py
CACHE_DIR = 'data/cache_parquet'  # 每只股票单独缓存

for i, ts_code in enumerate(todo, 1):
    for attempt in range(3):   # 失败自动重试3次
        try:
            df = download_one(ts_code)
            df.to_parquet(f'{CACHE_DIR}/{ts_code}.parquet', compression='snappy')
            break
        except Exception as e:
            time.sleep((attempt + 1) * 5) 

    time.sleep(0.4)  # 每次请求间隔400ms,避免超出限速

断点续传逻辑:

# 已缓存的股票直接跳过
done = {f.replace('.parquet', '') for f in os.listdir(CACHE_DIR)}
todo = [s for s in stocks if s not in done]

这样中途断了可以直接重跑,不会从头开始。

五、数据存储:为什么选 Parquet

之前用CSV存,换成Parquet之后几个明显的好处:

对比项CSVParquet
文件大小极小(压缩率极高)
读取速度极快
类型保存不保存(date每次要重新parse)保存(date直接是datetime)
列过滤读完整个文件再筛只读需要的列

读写方式和CSV完全一样:

# 保存
df.to_parquet('data/stock_data.parquet', index=False, compression='snappy')

# 读取(date列自动是datetime类型,不用parse_dates)
df = pd.read_parquet('data/stock_data.parquet')

# 只读部分列(CSV做不到)
df = pd.read_parquet('data/stock_data.parquet', columns=['date', 'symbol', 'close'])

六、踩过的坑

6.1 坑1:前复权的重要性

A股经常有分红、送股操作,除权后价格会出现跳空缺口。比如某股票100元,10送10后价格直接调整到50元——但这不是真的跌了50%,是股本扩大了。

不复权的数据里这个缺口会直接影响收益率计算:

不复权:昨天100元 → 今天50元 = 收益率 -50%(实际是0%)
前复权:历史价格都按比例调整,收益率计算正确

解决方案:始终用前复权数据,adj='qfq'

6.2 坑2:目标变量构建错误

这个是机器学习的大忌,但很容易忽视。

# 错误做法:target 是今天收盘 vs 昨天收盘,是"过去"的数据
df['target'] = df['close'].pct_change(1)

# 正确做法:target 是明天收盘 vs 今天收盘,才是"未来"的数据
df['target'] = df.groupby('symbol')['close'].shift(-1) / df['close'] - 1

shift(-1) 取的是下一行的值,也就是明天的价格。必须加 groupby('symbol'),否则最后一个交易日的 shift(-1) 会取到下一只股票第一天的价格。

说明:这里说的是训练数据的目标变量构建。和第5篇回测里的"未来函数"不是一回事:

  • 目标变量:训练时可以用未来数据(因为是要预测的目标)
  • 未来函数:回测时决策不能用未来数据(否则是作弊)

6.3 坑3:GroupBy 的必要性

多只股票合并后,任何涉及时序的计算都必须按股票分组:

# 错误:把茅台和五粮液的数据混在一起算均线
df['ma_20'] = df['close'].rolling(20).mean()

# 正确:每只股票独立计算
df['ma_20'] = df.groupby('symbol')['close'].transform(
    lambda x: x.rolling(20).mean()
)

不加 groupby 的后果:茅台的20日均线会用到五粮液前几天的价格,特征完全失真。

七、最终效果

运行 python download_index_data.py,约15分钟完成下载:

获取指数成分股...
  沪深300: 300 只
  中证500: 500 只
  合并去重: 800 只

[  1/800] 000001.SZ ... 2431 行
[  2/800] 000002.SZ ... 2296 行
...
[800/800] 689009.SH ... 1015 行

==================================================
✓ 完成!
  股票数:   797
  总行数:   1,589,404
  日期范围: 2015-01-05 ~ 2024-12-31
  文件大小: 38.5 MB
  保存路径: data/stock_data.parquet
==================================================

797只(3只因数据源缺失被跳过),158万条记录,覆盖10年完整交易数据。

以贵州茅台为例看一下数据质量:

df = pd.read_parquet('data/stock_data.parquet')
maotai = df[df['symbol'] == '600519.SH']
print(f'共 {len(maotai)} 条')  # 2431 条,10年无缺失
print(maotai.head(3))
date     symbol   open   high    low  close    volume       amount  pct_chg  change
2015-01-05 600519.SH 142.59 153.58 141.89 152.29  94515.17  1875063.136     6.80    9.70
2015-01-06 600519.SH 150.39 152.32 147.40 148.76  55020.01  1094977.375    -2.32   -3.53
2015-01-07 600519.SH 147.41 150.02 142.86 145.08  54797.84  1063925.641    -2.47   -3.68

注意2015年茅台显示约142元,是前复权价格(当时实际价格约202元),这是正常的。

八、Web界面

为了方便查看和验证数据,做了一个简单的可视化界面:

streamlit run app.py

功能包括:

  • 下拉框选股票(显示代码+中文名,支持搜索)
  • 日期范围筛选
  • K线图 + 成交量图 + 均线(MA5/MA20/MA60)
  • 日收益率分布直方图
  • 基础统计摘要(均值、标准差、偏度、峰度)
  • 原始数据表格
图片 图片

九、总结

数据获取看起来简单,细节挺多:

技术选型:

  • Tushare Pro 稳定性好,pro_bar 一次调用拿前复权数据
  • Parquet 替代 CSV:体积更小,读取更快,类型自动保存

数据规模:

  • 沪深300 + 中证500,800只,10年,158万条
  • 覆盖牛市(2015/2020)、熊市(2018)、震荡(2022-2023)

数据清洗:

  • 三层过滤:缺失值、停牌(成交量=0)、异常涨跌幅(>30%)
  • 宁缺毋滥,不造假数据

工程设计:

  • 断点续传:单股票缓存,中断可重跑
  • 限速:400ms间隔,避免API封禁
  • 容错:单只失败不影响整体

下一篇: 第3篇:特征工程