匡醍量化|大富翁量化

数据与存储

数据是量化研究的地基:选什么源、存成什么格式、用什么引擎查。这个专题回答选型级问题——A 股数据源怎么选(Tushare / AkShare / QMT / BaoStock)、存储格式怎么定(HDF5 / Parquet / CSV)、数据库用什么(DuckDB / SQLite / ClickHouse),并给出实测对比与迁移路径。

这是「数据与存储」专题的选型长文:把散落在十几篇旧文里的经验串成一条决策路径。读完你应该能回答两个问题——我的数据从哪来;我的数据存到哪、用什么查。

一句话结论

你的场景 数据源 存储 查询
入门 / A股日线研究 AkShare + BaoStock Parquet(按年分区) DuckDB
稳定生产 / 多品种研究 Tushare Pro 或券商源 Parquet + 复权因子表 DuckDB
QMT 实盘 XtQuant(行情 + 交易) 本地增量缓存(Parquet) DuckDB
多人协作 / 服务化 上述任一 Parquet ClickHouse

一、数据源怎么选

Tushare Pro——老牌、字段规范、社区大。日线、财务、指数、期货覆盖全,付费档位的稳定性和文档明显更好。适合作为研究数据的"稳定底座"。缺点:积分/付费门槛、部分接口限频,实时性一般(适合研究,不适合盘中)。

AkShare——免费开源、接口极多(股票、基金、期货、宏观、另类数据都有)。零成本、更新快,是很多人的第一站。缺点也很明确:它本质上是爬虫集合,上游页面改版时接口会间歇性失效;字段命名不统一,生产使用必须自己包一层清洗、重试与 schema 校验。

BaoStock——免费、接口少而稳,历史日线和财务数据干净够用。适合"不想维护爬虫、只要干净日线"的场景;代价是品种和维度有限。

XtQuant / QMT——券商侧行情与交易接口,全推行情、Level-1/Level-2(视券商开通情况),并且能下单。实盘链路它基本是唯一正解:行情质量高、交易可控。缺点:需要券商权限、依赖客户端在线、历史数据要自己落库;另外券商服务端与官网文档的版本不一致是常态,遇到问题以实际返回为准。

结论:研究起步用 AkShare / BaoStock,重要数据用 Tushare Pro 兜底;实盘必须走 QMT / XtQuant。多源并存时,用统一 schema 落库(见第四节),不要让下游脚本知道数据从哪来。

二、存储格式怎么选

三个候选:CSV、HDF5、Parquet。

实践建议:日线、财务这类"批量写、批量读"的数据用 Parquet;研究脚本的中间产物随意,但仓库层统一 Parquet。

目录布局比格式更重要,推荐两种:

daily/year=2026.parquet               # 全市场按年一个文件:适合全市场扫描
daily/symbol=600519/year=2026.parquet # 按标的分区:适合单票长历史与增量更新

日线量级(每年几百万行)用前者,文件少、全市场查询快;Tick 级以上或需要频繁增量更新的用后者,改一只票只碰一个文件。

三、查询引擎怎么选

结论:元数据放 SQLite;研究分析用 DuckDB 直接查 Parquet;规模化、服务化再考虑 ClickHouse——不要一上来就上服务。

四、我们真实在用的存储层

下面是 Moonshot 系列里一直在用的两个类(完整实现见《Moonshot 实战》系列配套 store.py,544 行,生产验证过),精简了注释,逻辑一字未改。

先是交易日历——数据仓库的"表头",所有增量更新都靠它判断缺哪天:

class CalendarModel:
    """日历模型:is_open / prev 两列,按日期索引"""

    def get_trade_dates(self, start, end):
        mask = (df.index >= start) & (df.index <= end) & (df.is_open == 1)
        return df[mask].index.tolist()

    # 还有 is_trade_date / prev_trade_day / get_next_trade_day /
    # floor / ceil / shift / delta,这里只列最常用的

然后是统一存储:以 (date, asset) 为唯一键,polars 读写 Parquet,输入输出仍是 pandas。最核心的是 get_and_fetch——本地缺哪段,就调你给的 fetch_data_func 补哪段,应用层永远不用操心数据全不全:

store = ParquetUnifiedStorage(store_path, calendar, fetch_data_func=fetch_bars)
barss = store.get_and_fetch(start, end)   # 没有就拉、拉完就存、存完就读

dv_store = ParquetUnifiedStorage(store_path, calendar, fetch_data_func=fetch_dv_ttm)
dv_ttm = dv_store.get_and_fetch(start, end)

写入侧三条硬规则(都在 append_data 里):

# 1. (date, asset) 去重,保留最新 → 增量更新幂等,重复跑不产生脏数据
deduped = combined.unique(subset=["date", "asset"], keep="last").sort(["date", "asset"])
# 2. 按 date+asset 排序写入 → 范围查询天然连续
# 3. lz4 压缩落盘 → 日线量级读写都在毫秒级
sorted_df.collect().write_parquet(self._file_path, compression="lz4")

查询侧直接用 polars lazy scan(零导入、零服务):

lazy_df = pl.scan_parquet(self._file_path)
df = (lazy_df.filter((pl.col("asset").is_in(["600519", "000001"]))
                     & (pl.col("date") >= start))
             .collect().to_pandas())

三个工程细节值得照抄:

  1. 日历先行:所有"缺数据吗"的判断都走 CalendarModel,而不是文件存在性——停牌日、节假日不会被误判为缺失;
  2. fetch 与存储解耦:fetch_data_func 是一个可插拔参数,换 Tushare / AkShare / XtQuant 只需换这一个函数,get_and_fetch 的语义不变——这就是第一节"多源并存,统一落库"的实现;
  3. 复权统一口径:仓库存"不复权价 + 复权因子",前复权/后复权在查询时计算——不同回溯窗口算出的前复权价不同,直接存前复权是经典坑。

五、常见坑

六、延伸阅读

文中的对比结论来自我们在 A 股日线与分钟线数据上的日常使用经验;具体性能数字与数据规模、字段宽度、磁盘和查询模式强相关——建议用本文的代码骨架在自己的数据上实测一遍,再定选型。

共 24 篇 · 按难度分组

进阶(18)

实战(6)

← 全部专题 · 标签浏览