4、行情数据源接入实战(下):历史Tick数据获取、日线/分钟线数据批量下载、数据存储策略(Parquet/CSV)
好,咱们接着上回聊。上节课我们把实时行情的WebSocket接入了,数据在内存里跑得挺欢。但做量化交易,光有实时数据是不够的——回测需要历史数据,策略分析需要批量数据,模型训练更是离不开干净、规整的数据集。
今天这一讲,我就带大家把「历史数据」这块硬骨头啃下来。说白了,就是三个问题:怎么拿到历史Tick?怎么批量下载日线和分钟线?存成什么格式最划算?
核心观点:历史数据获取的核心不在于「能不能拿到」,而在于「拿得全、拿得快、存得好」。我见过太多团队在数据清洗和存储上浪费了80%的时间。
一、历史Tick数据获取——逐笔还原的硬功夫
先说说Tick数据。很多人以为Tick就是「每秒一条的快照」,其实不对。真正的Tick数据是逐笔成交和逐笔委托,每一笔交易都记录在案。可转债市场因为流动性不如股票,Tick数据反而更有价值——你能看到每一笔资金的进出痕迹。
我个人习惯用聚宽和Tushare的接口来拿历史Tick。但要注意,这两个平台的Tick数据覆盖范围不一样。聚宽对可转债的支持更全,Tushare的字段更丰富。我一般两个都拉,交叉验证。
小技巧:拉Tick数据时,别一次性拉太多。我建议按「天」为单位,一天一天拉。为什么?因为Tick数据量太大了——一只可转债一天的Tick可能有几千条,全市场几百只转债,一天就是几十万条。一次性拉一个月,内存直接爆掉。
来看一段实际代码。这是我常用的Tick获取函数:
import tushare as ts
import pandas as pd
from datetime import datetime, timedelta
def fetch_tick_data(bond_code, date):
"""
获取单只可转债某一天的Tick数据
bond_code: '123456' 格式
date: '2024-01-15' 格式
"""
pro = ts.pro_api('your_token_here')
# 注意:可转债代码需要加上后缀
ts_code = f'{bond_code}.SZ' if bond_code.startswith('12') else f'{bond_code}.SH'
df = pro.tick_data(
ts_code=ts_code,
trade_date=date.replace('-', ''),
start_time='093000',
end_time='150000'
)
if df.empty:
print(f'警告:{bond_code} 在 {date} 无数据,可能是非交易日')
return None
# 我习惯加一列,标记数据来源
df['source'] = 'tushare'
df['bond_code'] = bond_code
return df
# 使用示例
tick_df = fetch_tick_data('123456', '2024-01-15')
print(tick_df.head())
这里有个坑,我曾经踩过——可转债的代码后缀。深交所的可转债代码以12开头,后缀是.SZ;上交所的以11开头,后缀是.SH。如果你搞反了,接口返回的数据会是空的。我当时排查了半天,最后发现是代码写错了。
注意:不同数据商的Tick数据字段名可能不一样。比如「成交价」在Tushare里叫price,在聚宽里叫last_price。建议统一做一层映射,避免后续处理时混乱。
二、日线/分钟线数据批量下载——多线程才是王道
日线和分钟线相对Tick来说,数据量小很多。但如果你要下载全市场几百只可转债的3年数据,单线程跑的话,够你喝一壶的。我记得有一次,我用单线程下载全市场日线,跑了整整一个下午。
后来我改成了多线程并行下载,速度提升了10倍不止。核心思路很简单:把可转债列表分成几组,每组开一个线程去拉。
来看代码:
import concurrent.futures
import tushare as ts
def download_batch(bond_list, start_date, end_date, freq='D'):
"""
批量下载多只可转债的K线数据
freq: 'D' 日线, '30min' 30分钟线, '5min' 5分钟线
"""
pro = ts.pro_api('your_token_here')
results = []
def fetch_single(bond):
try:
ts_code = f'{bond}.SZ' if bond.startswith('12') else f'{bond}.SH'
df = pro.cb_bond_daily(
ts_code=ts_code,
start_date=start_date,
end_date=end_date,
freq=freq
)
if df is not None and not df.empty:
df['bond_code'] = bond
return df
except Exception as e:
print(f'下载 {bond} 失败: {e}')
return None
# 用线程池并行下载,我一般开8个线程
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
futures = {executor.submit(fetch_single, bond): bond for bond in bond_list}
for future in concurrent.futures.as_completed(futures):
result = future.result()
if result is not None:
results.append(result)
if results:
return pd.concat(results, ignore_index=True)
return pd.DataFrame()
# 批量下载示例
bonds = ['123456', '113044', '127018'] # 实际使用时从列表读取
df_daily = download_batch(bonds, '20230101', '20240101', freq='D')
print(f'共下载 {len(df_daily)} 条日线数据')
你可能会问:为什么是8个线程?其实没有硬性规定。我测试过,8个线程对于大多数数据商接口来说,既不会触发限流,又能充分利用带宽。如果你用16个线程,反而可能因为接口限流导致大量请求失败。
避坑指南:我曾经遇到过一个问题——下载到一半,程序崩了,前面下载的数据全丢了。后来我加了一个「断点续传」机制:每下载完一只可转债,就把它的代码记录到一个已完成列表里。下次启动时,先检查哪些已经下载过,跳过它们。这样即使中途崩溃,也不会重复劳动。
三、数据存储策略——Parquet vs CSV,我选谁?
数据拿到了,存成什么格式?这是个大问题。很多人习惯用CSV,因为简单、通用。但我要说:对于量化交易场景,Parquet几乎是碾压CSV的存在。
为什么?我列个对比表你就明白了:
| 对比维度 | CSV | Parquet |
|---|---|---|
| 存储空间 | 大(文本存储,无压缩) | 小(列式压缩,通常只有CSV的20%-30%) |
| 读取速度 | 慢(全量读取,无法列裁剪) | 快(列式存储,只读需要的列) |
| 数据类型 | 弱类型(所有字段都是字符串) | 强类型(保留原始数据类型) |
| 压缩率 | 需手动压缩(如gzip) | 内置压缩(snappy、gzip等) |
| 兼容性 | 几乎所有工具都支持 | 主流大数据工具支持(Spark、Pandas等) |
| 分区支持 | 不支持 | 原生支持(按日期、代码分区) |
我个人习惯的存储策略是这样的:
- 原始Tick数据:存Parquet,按日期分区。因为Tick数据量大,查询时通常按日期筛选,分区后查询效率极高。
- 日线/分钟线:也存Parquet,但按「可转债代码+周期」分区。比如
data/123456/D/和data/123456/30min/。 - 中间处理结果:偶尔用CSV,方便人工查看和调试。
来看一段存储代码:
import pandas as pd
import os
def save_to_parquet(df, base_path, bond_code, freq, date):
"""
将数据存储为Parquet格式,按日期分区
"""
# 构建分区路径:base_path/bond_code/freq/date/
save_path = os.path.join(base_path, bond_code, freq, date)
os.makedirs(save_path, exist_ok=True)
file_path = os.path.join(save_path, f'{bond_code}_{freq}_{date}.parquet')
# 使用snappy压缩,速度和压缩率的平衡点
df.to_parquet(
file_path,
compression='snappy',
index=False
)
print(f'数据已保存:{file_path}')
return file_path
def load_from_parquet(base_path, bond_code, freq, start_date, end_date):
"""
从Parquet分区中读取指定时间范围的数据
"""
import glob
# 构建日期范围
dates = pd.date_range(start_date, end_date, freq='D')
all_data = []
for date in dates:
date_str = date.strftime('%Y%m%d')
pattern = os.path.join(base_path, bond_code, freq, date_str, '*.parquet')
files = glob.glob(pattern)
for file in files:
df = pd.read_parquet(file)
all_data.append(df)
if all_data:
return pd.concat(all_data, ignore_index=True)
return pd.DataFrame()
# 使用示例
save_to_parquet(tick_df, './data', '123456', 'tick', '20240115')
loaded_df = load_from_parquet('./data', '123456', 'tick', '20240115', '20240115')
print(f'读取到 {len(loaded_df)} 条Tick数据')
关于压缩:Parquet支持多种压缩算法。我推荐用snappy,它压缩率不是最高的,但解压速度极快。对于量化交易来说,读取速度比存储空间更重要。你想想看,回测时每次都要加载几GB的数据,如果解压慢,那体验就很糟糕了。
四、实战中的几个关键决策
最后,我想分享几个我在项目中积累的经验,希望能帮你少走弯路。
第一,数据校验不能省。 我遇到过数据商接口返回的数据有缺失——某一天的分钟线少了下午的数据。后来我加了一个校验步骤:下载完成后,检查数据的时间戳是否连续,是否有跳空。如果发现异常,重新下载。
第二,存储路径要有规范。 我见过团队里不同人用不同的命名规则,最后数据目录乱成一锅粥。我建议统一用 数据源/标的代码/周期/日期/文件名 的结构。这样不管是手动查找还是程序读取,都很清晰。
第三,定期做数据备份。 硬盘会坏,云存储会丢数据。我每个月会把整个数据目录打包,上传到另一个存储服务。虽然麻烦,但心里踏实。
总结一下:历史数据接入的核心就三件事——拿得到、拿得快、存得好。Tick数据按天拉,日线分钟线多线程并行,存储用Parquet按日期分区。这套方案我在多个项目中验证过,稳定运行了两年多,没出过问题。
好了,这一讲的内容就到这里。数据源接入的实战部分基本讲完了,接下来我们会进入更核心的策略开发环节。嗯,到时候再聊。
公众号:蓝海资料掘金营,微信deep3321