ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

A股历史数据获取与复权处理实战:从接口拉取到回测验证的完整源码解析

A股历史数据获取与复权处理实战:从接口拉取到回测验证的完整源码解析

简介:这份源码资源面向个人投资者、量化初学者与金融数据研究者,聚焦A股历史行情获取这一常见需求,整理了Tushare、AkShare与Baostock三种免费数据接口的调用方式与示例代码,帮助读者绕开付费数据源,快速搭建本地行情数据库。压缩包共5个文件,约8KB,包含py主程序脚本、md说明文档、html演示页面、txt依赖清单及inscode配置文件,分别承担接口调用示例、使用说明、可视化引导与运行环境声明等用途,结构轻量、便于直接运行与二次修改。目前已有1328人学习下载,说明该方案在量化入门群体中具有一定参考价值。读者可从中获得各平台注册与登录流程、日线级别数据拉取代码、字段与参数说明,以及数据质量与使用限制的提醒,适合作为投资分析或研究工作的起步工具,也可据此对比不同接口的覆盖范围与调用成本,选择契合自身需求的数据方案。

1. 从一次数据断供说起:A股历史数据获取到底难在哪

去年帮一个做量化策略的朋友排查回测偏差,跑出来的年化收益比实盘高了将近一倍。查了两天,最后发现问题出在数据源上——他用的那份日线数据里,前复权价格在除权日附近出现了跳空缺口,而回测框架默认按连续价格计算持仓成本,导致每次分红送股都被算成了一笔“无风险收益”。这不是策略的问题,是数据的问题。

A股历史数据的获取,表面上看无非是找个接口把K线拉下来,但真正落到量化交易的生产环境里,坑远比想象中多。数据源是否稳定、复权因子怎么处理、停牌期间怎么填充、退市股票怎么保留,每一个细节都会直接影响回测结果的可信度。这份项目源码围绕的就是这件事:用代码把A股历史行情数据从公开接口拉取、清洗、存储成可以直接喂给回测引擎的格式。它适合正在搭建自己数据管线的量化开发者,也适合那些用现成数据包跑策略但总觉得“哪里不对”的人。下面我会把这份源码拆开,从接口选型到落地存储,把能踩的坑一个个标出来。

2. 数据接口选型与源码结构拆解

2.1 为什么是这几个接口,而不是别的

做A股数据获取,绕不开几个主流方案:Tushare、AkShare、Baostock、以及各券商开放的行情接口。这份源码里主要用的是AkShare和Baostock的组合,原因很实际——AkShare覆盖的品种全、更新快,适合拉取日线和分钟线;Baostock的复权因子处理得比较干净,而且不需要token,适合做交叉验证。

Tushare Pro的积分门槛对个人开发者不太友好,很多高频接口需要攒到一定积分才能调用。券商接口虽然数据质量高,但通常绑定交易账户,拿来跑批量历史数据拉取不太合适。源码里没有用任何需要付费或注册的接口,这一点对想快速复现的人来说比较友好。

具体到数据字段,源码拉取的核心字段包括:日期、开盘价、最高价、最低价、收盘价、成交量、成交额、换手率、前复权因子、后复权因子。其中复权因子是很多人容易忽略的——如果你只拉价格不拉因子,后面做回测时遇到除权除息就会出问题。

2.2 源码目录结构与核心模块

拿到源码包后,先别急着跑。花五分钟看一下目录结构,能省掉后面很多“文件找不到”的低级错误。典型的目录布局是这样的:

astock_data/ ├── config/ │ └── settings.py # 接口配置、股票池、日期范围 ├── fetcher/ │ ├── ak_fetcher.py # AkShare数据拉取 │ ├── bs_fetcher.py # Baostock数据拉取 │ └── base_fetcher.py # 抽象基类,统一接口 ├── cleaner/ │ ├── adjust.py # 复权处理 │ └── validator.py # 数据校验 ├── storage/ │ ├── csv_writer.py # CSV落盘 │ └── sqlite_writer.py # SQLite入库 ├── utils/ │ ├── logger.py # 日志 │ └── retry.py # 重试装饰器 └── main.py # 入口

这个结构的好处是职责分离。fetcher只管拉数据,cleaner只管清洗,storage只管存。你想换数据源,只需要在fetcher里加一个模块,不用动其他部分。

2.3 拉取日线数据的完整代码路径

以拉取单只股票日线为例,源码里的调用链是这样的:main.py读取settings.py里的股票池和日期范围,然后依次调用ak_fetcher的fetch_daily方法,拿到原始DataFrame后传给cleaner做复权处理和缺失值检查,最后写入SQLite。

# fetcher/ak_fetcher.py import akshare as ak import pandas as pd from utils.retry import retry class AkFetcher: def __init__(self, adjust="qfq"): # adjust: qfq前复权, hfq后复权, ""不复权 self.adjust = adjust @retry(max_attempts=3, delay=2) def fetch_daily(self, symbol: str, start: str, end: str) -> pd.DataFrame: # symbol格式为"000001",不带交易所前缀 df = ak.stock_zh_a_hist( symbol=symbol, period="daily", start_date=start, end_date=end, adjust=self.adjust ) if df is None or df.empty: raise ValueError(f"空数据: {symbol}") # 统一列名,方便后续处理 df.columns = [ "date", "open", "close", "high", "low", "volume", "amount", "amplitude", "pct_change", "change", "turnover" ] df["symbol"] = symbol return df

这段代码有几个关键点。adjust参数控制复权方式,前复权适合看历史走势,后复权适合做回测。retry装饰器负责在接口超时或返回空数据时自动重试,最多三次,每次间隔两秒。列名统一这一步很重要,因为AkShare返回的列名是中文,不同版本可能略有差异,统一成英文后下游处理就不用反复改。

参数方面,start和end的格式是"YYYYMMDD",比如"20200101"。如果你要拉全市场几千只股票,建议按行业或市值分批,不要一次性全拉——接口有频率限制,拉太快会被临时封禁。

2.4 复权处理的实现细节

复权是A股数据里最容易翻车的地方。简单说,前复权是以当前价格为基准往前调整,历史价格会变;后复权是以上市首日为基准往后调整,当前价格会变。源码里用的是“拉取不复权价格+单独拉复权因子+本地计算”的方式,而不是直接依赖接口返回的复权价。

# cleaner/adjust.py import pandas as pd def apply_adjust(df: pd.DataFrame, factors: pd.DataFrame) -> pd.DataFrame: # factors包含date和factor两列 df = df.merge(factors, on="date", how="left") df["factor"] = df["factor"].ffill().fillna(1.0) # 后复权:价格乘以因子 for col in ["open", "high", "low", "close"]: df[f"{col}_hfq"] = df[col] * df["factor"] return df

这样做的原因是,接口直接返回的复权价在不同数据源之间可能有细微差异,而自己用因子算是可验证的。因子列用ffill前向填充,是因为除权日当天因子会跳变,非除权日保持不变。如果你发现回测里某只股票在除权日附近收益异常,大概率就是复权没处理对。

3. 批量拉取与本地存储的工程化落地

3.1 股票池构建与分批策略

单只股票拉数据没什么难度,难的是全市场几千只股票怎么高效拉完还不被封。源码里的做法是先构建股票池,然后按市值或行业分成若干批,每批之间加随机延时。

# main.py 片段 import time import random from fetcher.ak_fetcher import AkFetcher from storage.sqlite_writer import SQLiteWriter def batch_fetch(symbols, start, end, batch_size=50): fetcher = AkFetcher(adjust="") writer = SQLiteWriter("data/astock.db") for i in range(0, len(symbols), batch_size): batch = symbols[i:i+batch_size] for sym in batch: try: df = fetcher.fetch_daily(sym, start, end) writer.write_daily(sym, df) except Exception as e: print(f"失败 {sym}: {e}") # 每批之间随机等待,降低被封风险 time.sleep(random.uniform(3, 6))

batch_size设成50是一个经验值。太小拉得慢,太大容易触发频率限制。随机延时比固定延时更安全,因为固定间隔的请求模式更容易被识别。失败的单只股票不会中断整个批次,而是记录日志后继续,最后可以单独补拉。

3.2 SQLite存储表结构设计

存数据看起来简单,但表结构设计不好,后面查询会非常痛苦。源码里用了三张表:日线行情表、复权因子表、股票元信息表。

CREATE TABLE daily_price ( symbol TEXT NOT NULL, date TEXT NOT NULL, open REAL, high REAL, low REAL, close REAL, volume REAL, amount REAL, turnover REAL, PRIMARY KEY (symbol, date) ); CREATE TABLE adjust_factor ( symbol TEXT NOT NULL, date TEXT NOT NULL, factor REAL, PRIMARY KEY (symbol, date) ); CREATE TABLE stock_info ( symbol TEXT PRIMARY KEY, name TEXT, industry TEXT, list_date TEXT, delist_date TEXT );

主键用(symbol, date)联合主键,既保证了唯一性,又天然建立了查询索引。日线表不存复权价,只存原始价格和因子,复权在查询时计算。这样做的好处是数据量小、灵活,你想用前复权还是后复权,写个SQL就能切换。

3.3 增量更新与断点续传

全量拉一次数据可能要几个小时,如果中途断了从头再来,心态容易崩。源码里实现了增量更新:每次拉取前先查本地库里已有数据的最大日期,只拉这个日期之后的部分。

def get_last_date(writer, symbol): row = writer.query( "SELECT MAX(date) FROM daily_price WHERE symbol=?", (symbol,) ) return row[0] if row and row[0] else "19900101" def incremental_fetch(symbols, end): for sym in symbols: last = get_last_date(writer, sym) if last >= end: continue start = (pd.to_datetime(last) + pd.Timedelta(days=1)).strftime("%Y%m%d") df = fetcher.fetch_daily(sym, start, end) writer.write_daily(sym, df)

这段逻辑的核心是get_last_date,它从本地库查出该股票已有数据的最后日期,然后从次日开始拉。注意这里有个边界:如果最后日期是周五,次日是周六,接口会返回空数据,所以实际使用时建议在日期计算后加一个交易日判断,或者直接容忍空数据跳过。

3.4 数据校验:拉完不等于能用

数据拉下来只是第一步,校验才是保证质量的关键。源码里的validator模块做了几件事:检查价格是否为负、检查高低价关系是否合理、检查成交量是否为零、检查日期是否有重复。

def validate_daily(df): errors = [] if (df[["open","high","low","close"]] < 0).any().any(): errors.append("存在负价格") if (df["high"] < df["low"]).any(): errors.append("最高价低于最低价") if df["date"].duplicated().any(): errors.append("日期重复") if (df["volume"] == 0).sum() > len(df) * 0.3: errors.append("超过30%的交易日成交量为零,疑似停牌未标记") return errors

最后一条检查特别重要。A股停牌期间接口可能返回成交量0但价格不变的数据,如果不标记出来,回测时会被当成正常交易日处理,导致持仓无法买卖。常见做法是在日线表里加一个is_trading字段,停牌日标记为0,回测引擎跳过这些日期。

4. 避坑与常见问题排查

4.1 接口返回空数据但股票明明在交易

现象:拉某只股票某段时间的日线,接口返回空DataFrame,但该股票在交易所正常交易。

原因:AkShare的stock_zh_a_hist接口对股票代码的格式有要求,必须是纯数字如"000001",不能带"SZ"或"SH"前缀。另外,如果股票在这段时间内发生过更名或代码变更,旧代码可能查不到数据。

解决:统一股票代码格式,去掉交易所前缀。对于更名股票,用当前的代码去查历史数据,接口通常会自动关联。如果还是空,换Baostock交叉验证一下,确认是接口问题还是数据本身缺失。

4.2 复权因子在除权日出现跳变导致回测收益异常

现象:回测某只高分红股票时,除权日当天收益突然跳升,明显不符合实际。

原因:复权因子在除权日会发生变化,如果因子没有正确对齐到日期,或者用了前复权价格但因子没同步更新,就会导致价格序列出现断层。

解决:不要混用接口返回的复权价和自己算的因子。要么全用接口复权价,要么全用原始价加因子。源码里用的是后者,因子从Baostock单独拉取,按日期merge到行情数据上,确保对齐。

4.3 批量拉取到一半被限流

现象:拉了几百只股票后,接口开始返回超时或空数据,等一段时间又恢复。

原因:接口有频率限制,短时间内请求过多会触发临时封禁。固定间隔的请求模式比随机间隔更容易被识别。

解决:降低batch_size,增加随机延时。源码里默认每批50只,批间延时3到6秒随机。如果还是被限,把batch_size降到20,延时加到5到10秒。另外,把拉取任务放在非交易时段跑,接口压力小,成功率更高。

4.4 SQLite并发写入报database is locked

现象:多线程拉数据时,写入SQLite报database is locked错误。

原因:SQLite默认的锁机制不支持高并发写入,多个线程同时写同一张表会互相阻塞。

解决:最简单的方法是改成单线程写入,拉取可以用多线程,但写入串行化。如果一定要并发写,可以每个线程写独立的临时库,最后合并。源码里用的是单线程写入加队列的方式,拉取和写入解耦,写入端单线程消费队列。

4.5 停牌股票数据缺失导致回测持仓无法平仓

现象:回测时某只股票停牌期间仍有持仓,但数据里没有停牌标记,引擎按正常价格计算市值,复牌后价格跳空导致收益异常。

原因:接口返回的日线数据在停牌期间可能直接缺失,或者返回成交量0但价格不变的数据,没有明确的停牌标记。

解决:在数据校验阶段识别停牌日。判断逻辑是:如果某交易日成交量为0且价格与前一交易日相同,标记为停牌。在日线表里加is_trading字段,回测引擎读取时跳过停牌日,持仓按停牌前最后价格计算。

5. 从数据到回测:一个可复现的验证流程

数据拉完了,怎么确认它真的能用?我的习惯是跑一个最小验证流程:选一只分红送股比较多的股票,拉取包含除权日的区间数据,分别用不复权、前复权、后复权三种方式计算区间收益,然后和行情软件上的数据对比。

import sqlite3 import pandas as pd conn = sqlite3.connect("data/astock.db") # 拉取某只股票2020年到2023年的数据 df = pd.read_sql( "SELECT * FROM daily_price WHERE symbol='000001' AND date BETWEEN '20200101' AND '20231231' ORDER BY date", conn ) factors = pd.read_sql( "SELECT * FROM adjust_factor WHERE symbol='000001' ORDER BY date", conn ) # 不复权收益 raw_return = df["close"].iloc[-1] / df["close"].iloc[0] - 1 # 后复权收益 df = df.merge(factors, on="date", how="left") df["factor"] = df["factor"].ffill().fillna(1.0) df["close_hfq"] = df["close"] * df["factor"] hfq_return = df["close_hfq"].iloc[-1] / df["close_hfq"].iloc[0] - 1 print(f"不复权收益: {raw_return:.2%}") print(f"后复权收益: {hfq_return:.2%}")

跑完之后,把后复权收益和行情软件上同一区间的“后复权涨幅”对比。如果差异在0.5%以内,说明数据处理基本正确。如果差异大,优先检查因子对齐和除权日处理。

还有一个验证技巧:选一只经历过10送10的股票,看除权日当天不复权价格是否腰斩,后复权价格是否连续。如果后复权价格在除权日出现跳空,说明因子没算对。

参数方面,验证区间建议至少包含一次分红和一次送股,否则测不出复权处理的问题。股票选择上,银行股分红稳定但送股少,中小板股票送股多但数据可能不全,可以各选一只交叉验证。

从那以后我每次拉完新数据,都会先跑一遍这个验证流程,确认复权处理没问题再喂给回测引擎。数据这件事,宁可多花半小时验证,也别等回测跑出漂亮曲线之后才发现是数据在骗你。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进