做量化回测的朋友应该都有个共同体验:写策略反而是最简单的一步,真正让人抓狂的是数据校验和清洗。尤其是股票历史 K 线,你从 akshare、tushare 或者 baostock 上把行情拉下来之后,直接df['close'].pct_change()算收益、跑回测,结果看似漂亮,实则在垃圾数据上跳舞。数据里的缺口、重复、复权错误、停牌异常,任何一个都会让回测结论彻底失真。这篇文章就从一个常被忽略的话题切入:Python 获取股票历史 K 线后怎么处理?从数据校验到量化回测的数据质量实践。
这不是一篇泛泛的概念讲解,我会把整个处理链路拆开讲透:拿到裸数据后的第一轮体检、OHLC 内部逻辑验证、复权口径选择、特征计算时防前视偏差、最后再给出我实际踩过的数据坑。适合两类人看:一是刚入门量化、还在用免费数据源自己拉数据的小伙伴,二是自建数据管道、想系统性做一遍数据体检的工程师。
1. K线数据到手,先别急着跑策略:数据校验的整体思路
1.1 为什么数据质量直接决定回测结论的可信度
很多人对“数据质量”没概念,觉得数据源返回什么就是什么。实际上,免费数据源也好、付费数据源也好,返回的 K 线都可能存在三类致命问题。
第一类是幸存者偏差。如果你的股票池只包含“当前还在交易的股票”,那历史上退市的股票全部被过滤掉了。回测时你会只做那些活下来的股票,相当于拿“开了天眼”的股票池去测策略,收益率虚高是必然的。第二类是未来函数。比如你在某根 K 线上计算 5 日均线,如果代码里不小心用了close.shift(-1)或者rolling(5).mean()没有处理对齐问题,你的信号里就混入了未来信息。这类问题在数据校验阶段很难发现,因为它要等到特征计算环节才暴露,但根源还是数据形态没被严格约束。第三类是复权失真。分红送转之后,价格会出现跳空,如果不复权,10 元跌到 9.5 元可能不是亏损,而是分红除息;如果复权因子给错,历史价格可能被扭曲到完全无法用于回测。
这三种问题都有一个共同点:它们不会在单根 K 线里报错,而是在你跑完回测、看到年化收益率异常高的时候才暴露。等到那时候再回头查数据,排查成本极高。所以我的经验是:数据到手先做一次“体检”,把数据结构、边界、逻辑全部验一遍,再进策略流程。
1.2 常见 K 线数据源与字段差异
先看数据源。国内散户最常用的免费接口无非三个:akshare、tushare、baostock。它们各有脾气,字段命名、日期格式、复权处理都不一样。我列一张表,方便大家对照。
| 数据源 | 典型接口 | 日期格式 | 复权支持 | 常见坑 |
|---|---|---|---|---|
| akshare | stock_zh_a_hist | str形式YYYY-MM-DD | 前复权、后复权、不复权 | 返回的日期是字符串,需要手动转datetime;停牌日直接没有记录 |
| tushare | pro.daily/pro.bak_daily | str形式YYYYMMDD | 需要单独取复权因子 | 积分门槛导致部分接口不稳定;停牌日同样缺失 |
| baostock | query_history_k_data_plus | str形式YYYY-MM-DD | 前复权、后复权、不复权 | 字段里有isST等额外列;需要先登录 |
这些字段差异是最容易踩的坑。比如 akshare 的接口返回的列名是中文,tushare 返回的是英文,baostock 又有自己的英文缩写。如果你同时用两个数据源做交叉验证,不统一 schema 的话,代码里全是各种if判断。
我的建议是:不管数据源长什么样,拿到数据后立刻转成自己的统一标准格式。我一般会定义这样一套基础 schema:
symbol:股票代码,统一为600000.SH这种带交易所后缀的格式trade_date:统一为datetime64[ns]类型的日期open/high/low/close:统一为float64,且全部使用后复权价格volume:统一为整数,单位是股,不是手amount:统一为浮点数,单位是元
这样做的目的是让后续所有代码只认这一套字段,换数据源时只需要写一个适配器。这个标准化动作虽然不起眼,但能省掉后面 80% 的排查时间。
1.3 建立你自己的数据体检流程
数据清洗不该是“想到哪做到哪”,而是有一套固定顺序。我自己的流程大概是这样:
- 结构检查:确认拿到的是 DataFrame,列名、索引、dtype 是否符合预期。
- 完整性检查:用交易日历核对缺失日期,区分散户停牌和真的没拉到数据。
- 重复与空值检查:去重、填充或删除 NaN。
- 极端值检查:价格不为负、涨跌幅不超限、成交量不为负。
- 逻辑一致性检查:OHLC 四价互相验证,跨行价格跳变是否合理。
- 复权检查:确认复权口径,检测复权后的异常跳空。
- 标准化输出:统一列名、索引、类型,写入干净数据表。
这套流程里,前两步是“数据有没有”,中间两步是“数据对不对”,后面两步是“数据能不能用”。每次拿到新数据源,我都会按这个流程走一遍,即使我明明知道这个数据源很稳定。因为数据源偶尔更新字段、改接口风格是家常便饭,不排查就用了,后面出了诡异结果你根本不知道是策略错还是数据错。
2. 第一轮体检:数据完整性和基础校验实操
2.1 用交易日历对齐区分“真缺失”和“停牌”
拿到 K 线数据后,第一件事不是算指标,而是看它“缺不缺日子”。A 股每年大概有 240 多个交易日,你可以用交易所的交易日历文件,或者直接用一个稳定的数据源返回一段已知 K 线作为基准日历。这里我推荐一个比较省事的办法:用 akshare 的tool_trade_date_hist_sina拿到全量交易日历,然后和你的数据做一次set差集。
import akshare as ak import pandas as pd # 获取交易日历 trade_cal = ak.tool_trade_date_hist_sina() trade_cal['trade_date'] = pd.to_datetime(trade_cal['trade_date']) all_days = set(trade_cal['trade_date']) # 假设你的数据是 df df['trade_date'] = pd.to_datetime(df['trade_date']) data_days = set(df['trade_date']) # 缺失日期 = 交易日历 - 数据日期 missing_days = sorted(all_days - data_days) print(f"缺失交易日数量: {len(missing_days)}") print(missing_days[:20]) # 先看前 20 个注意,不要看到缺失就慌。A 股股票经常停牌,尤其是一些壳股,一停就是几个月。停牌的那些交易日,股票本身就没有交易,数据源不会返回 K 线。所以这里的“缺失日期”需要再做一次过滤:如果这只票在这段时间内本身就没有行情,那缺失是正常的;如果有行情但缺失了,那才是真的数据问题。
怎么区分?我的做法是把缺失日期和一个基准指数的交易日做对比。比如沪深 300 指数在那几天有成交,而你的个股没有,那大概率是个股停牌;如果指数也没有,可能是数据源的问题或者节假日。更进一步,你还可以拉一下该股票的“停牌公告列表”,但公告数据通常不好拿,所以我一般用连续缺失的时长来辅助判断:连续缺失超过 5 个交易日且中间没有任何成交的,先标记为“疑似停牌”,最后人工抽检几个日期确认。
2.2 重复、空值与索引乱序排查
重复和空值是数据清洗里的老熟人了。K 线数据常见的重复有两种:一是数据源接口偶尔把同一根 K 线重复返回,二是跨数据源拼接时尾部重复。处理方式很简单,先查再删。
# 1. 检查完全重复的行 dup_count = df.duplicated().sum() print(f"完全重复行数: {dup_count}") # 2. 检查按日期重复(同一天两行,但价格可能不同) dup_date_count = df.duplicated(subset=['trade_date']).sum() print(f"重复日期行数: {dup_date_count}") # 3. 删除重复,保留最后一条 df = df.drop_duplicates(subset=['trade_date'], keep='last') # 4. 检查空值 null_count = df.isnull().sum() print(null_count)空值处理要分列看。open、high、low、close出现空值,大概率是数据源没抓到成交;volume出现空值也可能是停牌或数据缺失。这时候不要急着dropna(),因为停牌日本来就不该有数据,你要删除的是“应该有数据但为空”的行。
我的习惯是:先看空值比例,空值超过 5% 就值得警惕,可能接口参数错了;空值很少,就直接丢弃。另外,索引也有坑。很多数据源返回的 DataFrame 索引是 0 到 N-1 的整数,而日期在列里。如果你不把日期设为索引,后面做rolling、shift的时候很容易错位。所以我拿到数据后一定会先把日期索引设好,并确认索引严格递增。
df = df.set_index('trade_date').sort_index()2.3 极端值检查:价格、成交量和涨跌幅的物理边界
极端值检查有很多门道。首先,价格不可能是负的,这是一条硬规则;其次,A 股有涨跌停限制,主板 10%、创业板和科创板 20%、北交所 30%,如果某天的涨跌幅超过限制很多,多半是数据错了。但这里有个坑:除权除息日、上市首日、恢复上市首日是不受涨跌幅限制的,所以涨跌幅校验不能一刀切。
我的做法是分两步。第一步,只做“物理边界”检查,任何价格小于等于 0、成交量小于 0 的数据直接标记为异常。
mask = (df['open'] <= 0) | (df['high'] <= 0) | (df['low'] <= 0) | (df['close'] <= 0) | (df['volume'] < 0) print(f"物理异常行数: {mask.sum()}")第二步,检查涨跌幅时,我会单独把“除权除息日”排除掉。这是为了避开复权导致的价格跳变。比如某只股票 10 送 10,除权日开盘参考价会直接腰斩,你拿昨天的收盘价和今天的开盘价比,涨幅是 -50%,但这完全正常。
判断除权除息日有个笨办法:用当天开盘价与昨天收盘价的比值,结合当天涨跌停限制。如果偏离超过涨跌停幅度(主板 10%),先标记出来,再去核对是否有分红送转公告。虽然笨,但确实有效。这一步能帮你发现很多数据源返回错误的价格记录。
3. 深入检查:K线内部逻辑与跨行一致性验证
3.1 OHLC 关系校验:最高价必须名副其实
一根标准 K 线包含四个价格:开盘价 open、最高价 high、最低价 low、收盘价 close。它们之间有一条天然的逻辑约束:low <= min(open, close)且high >= max(open, close),同时open、close都应该在low和high之间。这个规则在任何市场、任何品种上都成立,属于硬性约束。
# 检查 low/high 是否真的覆盖了 open/close violate_high = df['high'] < df[['open', 'close']].max(axis=1) violate_low = df['low'] > df[['open', 'close']].min(axis=1) violations = (violate_high | violate_low).sum() print(f"OHLC 逻辑冲突行数: {violations}")如果出现冲突,比如某天 low 比 open 和 close 都高,那说明行情数据本身有问题。这种问题在免费数据源里不少见,尤其是从多个数据源拼接数据时,可能同一根 K 线来自不同源,导致 open 用的是 A 源的,high 用的是 B 源的,最终拼出一个不自洽的组合。
还有一类逻辑检查常常被忽略:成交量和成交额的关系。如果volume是股数,amount是元,那amount / volume应该落在当天价格的合理区间内。粗略算一下平均成交价,如果金额除以股数远高于当天最高价或远低于最低价,那这个成交量或成交额数据肯定有一项是错的。我一般用这个公式来做风控:
avg_price = df['amount'] / df['volume'] outlier = (avg_price > df['high'] * 1.05) | (avg_price < df['low'] * 0.95) print(f"量价关系异常行数: {outlier.sum()})注意,这里的 5% 是一个宽松阈值,因为大宗交易、盘后固定价格交易可能拉高均价,所以超过 5% 需要进一步人工确认。
3.2 涨跌幅跳变与复权缺口检测
跨行校验比行内校验更重要,因为很多数据源的行内价格是对的,但行与行之间出现了不合理的价格跳变。复权缺口就是最常见的情况。
举一个真实场景:某股票今天收盘 10 元,晚上公告 10 派 5(每持有 10 股派发现金红利 5 元),除权除息日参考价会下调。明天开盘价可能直接变成 9.5 元。如果你用的是不复权数据,那么从今天到明天的价格就出现了一个 -5% 的跳空,但这个跳空不是真正的亏损,而是分红除息导致的。
我的处理方式是先计算“日间价格跳跃率”,再结合复权因子判断。如果你拿到的是后复权数据,那分红送转导致的跳空应该已经被抹平了,理论上每天的收益率应该接近真实涨跌幅。如果后复权数据里仍然出现超过 10%(主板)的跳跃,就要小心是复权因子没更新或者数据拼接错误。
df['ret'] = df['close'].pct_change() jump_mask = df['ret'].abs() > 0.105 # 主板涨跌停为 10%,留 0.5% 缓冲 jump_dates = df[jump_mask].index.tolist() print(f"异常跳变交易日数量: {len(jump_dates)}")这里要有耐心。刚上市首日、恢复上市首日、退市整理期,这些特殊日子的涨跌幅限制不同,跳跃大是正常的。所以跳变日期筛出来以后,要去对照公告,而不是一律删掉。我个人习惯是先用一个special_date列表把这些特殊日期保护起来,让校验逻辑跳过它们。
3.3 停牌/复牌、涨跌停连板的识别
停牌和复牌的处理直接关系到数据是否连续。很多策略会用到“昨日收盘价”或者“过去 N 日最高价”,如果停牌日被错误地当成正常交易日来算,就会导致“昨天”其实是一个月前,指标全部错位。
识别停牌比较简单:在交易日历中存在、但个股数据中缺失的日期,基本就是停牌。还有一种情况是当天有数据但volume == 0且amount == 0,这通常也是停牌但数据源仍返回了空壳记录,这类行也要特殊标记。
涨跌停连板检测则是另一类重要任务。很多打板策略依赖涨停板的连续性。如果数据源把涨停板的价格记错,比如某天实际上涨停但价格算出来只涨了 9.8%,策略就会漏掉这个信号。判断涨停可以用“收盘价是否接近涨停参考价”来做,但同样要避开除权日。为了稳妥,我一般会在数据里加一列limit_up和limit_down,标注当天是否触及涨跌停:
prev_close = df['close'].shift(1) df['limit_up'] = (df['close'] >= prev_close * 1.095) & (df['close'] <= prev_close * 1.105) df['limit_down'] = (df['close'] <= prev_close * 0.895) & (df['close'] >= prev_close * 0.885)这里的阈值按主板 10% 设了缓冲带。创业板和科创板要改成 19.5% 到 20.5%。标记了涨停之后,你就可以统计连板天数,也能在回测时对“一字板无法买入”这类交易约束做更真实的模拟。
4. 复权口径:回测前最容易翻车的三个细节
4.1 前复权、后复权、不复权,回测该用哪个
这个问题我几乎每次都会被新手问到。三种复权的区别,我用一句话概括:不复权看真实成交记录,前复权看当前价格视角下的历史走势,后复权看历史价格视角下的累计收益。
回测建议优先级是这样的:
- 仅做信号研究、不涉及具体买卖价格的形态类策略,用后复权数据最稳妥,因为后复权不会用到未来数据,避免前视。
- 涉及具体开平仓价格、止损止盈的实战回测,要使用复权后的价格,但需搞清楚你的回测撮合引擎期望哪种口径。
- 不做任何复权处理,直接用原始收盘价算收益率,在长期回测中会出现严重失真,除权日会被误判为暴跌。
有些平台内部默认使用前复权。前复权用最新价格为基准调整历史价格,方便看图,但它有一个明显问题:每次新的分红送转发生,历史价格都会被重算一遍。如果你把前复权价格缓存下来,两个月后数据源更新,历史价格可能整体变了,回测结果对不上。这就是我偏爱后复权的原因,它不对历史做反复修正,确定性更强。
| 复权类型 | 历史价格是否变化 | 是否适合回测 | 是否适合看图 | 典型用途 |
|---|---|---|---|---|
| 不复权 | 不变 | 不适合长期回测 | 适合看真实价格 | 盘口复盘、大事纪念 |
| 前复权 | 随最新价变化 | 可以用但需谨慎 | 最适合 | 技术分析、形态识别 |
| 后复权 | 不变 | 最合适 | 不太直观 | 因子计算、收益统计 |
4.2 复权后的价格跳变检测与修正
如果你选了后复权数据,一定要再检查一遍“复权后是否还有异常跳变”。因为数据源返回后复权价格时,如果复权因子计算有误,或者某天的分红送转没有被正确计入,历史价格会出现明显的断崖或山峰。
一个可用的检测办法:用后复权价格算每日收益率,然后和真实的涨跌幅对比。这里真实涨跌幅可以用不复权价格在“非除权日”的涨跌幅来近似。更简单的方法是直接看收益率分布,正常情况下 A 股个股日收益率 99% 以上都落在 ±10% 以内,如果出现大量超过 10% 的收益率,优先怀疑复权错误。
修正方式通常是这样:找到异常的除权日,核对数据源的复权因子表,手动重算当天及其之后的价格。但在免费数据源上做手动修正非常痛苦,我的建议是:如果异常比例很低(比如一次回测只出现一两只股票有问题),直接把这些股票从样本里剔除,记录在案;如果异常比例高,就换数据源重新拉取。
4.3 输出标准化:统一列名、索引与数据字典
清洗完成的干净数据一定要保存成标准化格式,否则每次回测前重新清洗,等于把同一个坑反复踩。我在本地会建一个简单的数据仓库,目录结构类似这样:
data/ stocks/ daily/ 600000.SH.parquet 000001.SZ.parquet etfs/ daily/ 510300.SH.parquet每个 Parquet 文件里存储的数据列统一为:open, high, low, close, volume, amount, limit_up, limit_down,索引是trade_date。Parquet 比 CSV 好在自带 schema、压缩率高、读取速度快,而且 pandas 原生支持。
保存前还要处理一下 dtype。我的经验是:volume必须存成int64,金额存成float64,日期统一datetime64[ns]。很多坑都是 dtype 不一致导致的:比如某天volume是float且带小数点,后面做成交量排名时就会莫名多出很多无效位。统一数据字典还有一个好处:不同数据源之间的交叉校验代码可以完全复用。
5. 数据清洗之后,如何避免把回测做成“自欺欺人”
5.1 指标计算务必 shift:杜绝未来函数
数据校验通过后,下一步就是算指标。这里最常见的错误就是未来函数,也就是在用第 T 天的数据计算信号时,无意中混入了第 T+1 天甚至更晚的信息。
举例,你想计算“今天收盘价是否突破 20 日均线”,正确姿势是:
df['ma20'] = df['close'].rolling(20).mean().shift(1) df['signal'] = df['close'] > df['ma20']这里的shift(1)是关键。它让ma20变成“截至昨天收盘的 20 日均值”,用来和今天的收盘价比,信号在明天开盘才能执行,这样才没有未来函数。如果不加shift(1),当天的ma20里包含了当天的收盘价,你在盘中实时计算时其实是拿不到这个值的(除非你是收盘后算第二天开盘的交易),回测就会有偏差。
类似的还有各种技术指标库,比如 TA-Lib,它返回的很多指标默认是用完整序列计算的,如果你直接talib.SMA(close, 20)当作 T 日信号,再和 T 日价格做交集,同样会混入未来信息。所以我的原则是:任何指标只要在信号生成逻辑里被用到,都要检查它是否只依赖“截至 T-1 日”的数据。
5.2 按时间切分数据,防止标签泄露
机器学习类量化策略有一个特别隐蔽的坑:样本内训练和样本外测试时,数据切分没有严格按时间,导致“标签泄露”。这个问题本质上和数据校验关系不大,但很多人在处理 K 线数据时会在这一步犯错。
假设你要用过去 20 天的行情预测未来 5 天的涨跌,你的训练集和验证集应该按时间顺序严格划分:前 80% 时间段的样本做训练,后 20% 时间段的样本做验证。如果你用随机抽样切分,那么训练集里可能包含未来某个时间段的片段,而验证集里也有过去的数据,模型会利用时间上的重叠关系“作弊”,验证集表现虚高。
处理 K 线数据时,我会提前预留一个“冷启动期”。比如建立一个需要 60 天历史数据才能计算指标的模型,那前 60 天的样本就不该进入训练集,因为它们本身指标不完整。简单做法是在构建样本时对指标列做dropna(),再把真正有效的样本按时间排序,用train_test_split(..., shuffle=False)来做切分。
5.3 多周期数据对齐:分钟线回测时的时间戳陷阱
如果你不只是做日线回测,还会做分钟线策略,那数据对齐又是一个大坑。常见的错误是把不同周期的收盘时间戳直接合并,忽略时区、集合竞价时间、以及不同交易所的交易时段差异。
A 股日线数据的时间戳通常是自然日2025-03-14,而分钟线数据的时间戳往往是带时间的2025-03-14 14:00:00。如果你用pd.merge直接按时间戳合并,日线数据会被强制填充成当天 00:00:00 的记录,和 14:00 的分钟线根本对不上。
我建议的规范是:日线数据只保留date字段,分钟线数据单独保留datetime字段,合并时统一用date做 key,并提前想清楚“分钟线收盘时间点”和“日线日期”的对应关系。比如一根 14:00 的分钟 K 线,它的时间戳代表这根 K 线已经走完,那么信号只能从下一根 K 线开始生效。这个问题如果不提前处理,回测绩效会异常高,因为你每根分钟线都在“收盘那一刻”做决策,但实际交易根本来不及。
6. 常见问题速查表与我的实战踩坑记录
6.1 数据源常见问题速查表
| 现象 | 可能原因 | 排查方式 | 解决建议 |
|---|---|---|---|
| 某天数据整行缺失 | 停牌、数据源漏拉 | 对照交易日历,查看前后交易日 | 停牌则保留缺失标记;漏拉则重新请求 |
| 日期重复,且价格不同 | 接口重复或拼接冲突 | df.duplicated(subset=['trade_date']) | 按 keep='last' 去重,并检查重复来源 |
| OHLC 逻辑冲突 | 多源拼接、数据源 bug | 检查low > min(open,close) | 标记异常并剔除 |
| 复权价格出现断崖 | 复权因子未更新 | 计算日收益率是否有超限跳变 | 换后复权数据或手动修正 |
| 涨跌幅超过涨跌停限制 | 除权除息日、新股首日、数据错误 | 对照公告或指数行情 | 特殊日期豁免,其他标记异常 |
| 分钟线和日线合并后信号错位 | 时间戳对齐错误 | 检查合并前后的索引顺序 | 统一按日期做 key,或用merge_asof |
| 回测收益率高得离谱 | 未来函数或幸存者偏差 | 检查指标是否有shift,股票池是否含退市股 | 修正指标延迟,补充退市样本 |
这张表是我自己的经验浓缩版,不一定覆盖所有情况,但大部分免费数据源的常见问题都能在这里找到对应解法。
6.2 我踩过的三个真实数据坑
第一个坑发生在数据源切换时。我之前一直用 akshare 拉日线,后来为了获取复权因子切换到 tushare,结果 akshare 的日期格式是2025-03-14,tushare 的日期格式是20250314,我把两批数据直接 concat 之后,日期排序完全乱掉,回测信号全部错位。排查了半天,最后发现就是日期格式没统一。现在我的所有适配器第一件事都是pd.to_datetime,并且强制格式化成YYYY-MM-DD。
第二个坑是涨跌停判断。我做打板策略时需要识别涨停,一开始用close >= prev_close * 1.095,结果某天一支股票因为除权除息,收盘价“跌了” 45%,我把它误判为极端异常行并删掉了,后来才发现那是 10 送 10 之后的复权价。从那天起,我所有价格校验都会先单独保存一份“复权因子”和“除权除息日”列表,凡是除权日都做特殊处理。
第三个坑是前复权数据污染。我为了看图方便,把前复权数据存成缓存,跑完回测后记录了策略绩效。过了两个月,数据源更新了一次分红送转,前复权历史价格整体重算,我再次跑同样的回测,结果绩效完全对不上。后来我把所有回测统一改成使用后复权数据,并且固定数据快照版本,问题才算解决。这件事让我深刻理解了:回测结果必须能复现,否则一切分析都无从谈起。
最后再分享一个小习惯:我每次做完整套数据清洗和校验流程后,都会生成一份简单的数据质量报告,记录处理日期、数据源、样本数量、异常数量、处理方式。这份报告未必每次都会仔细看,但一旦回测出现可疑结果,它能帮我快速定位是不是数据出问题了。数据质量这件事,看起来不产生任何收益,但它是所有量化工作的地基。地基不稳,楼盖得越高,摔得越惨。