ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python获取股票历史K线后必做数据校验:量化回测数据清洗实践

Python获取股票历史K线后必做数据校验:量化回测数据清洗实践 做量化回测的朋友应该都有个共同体验写策略反而是最简单的一步真正让人抓狂的是数据校验和清洗。尤其是股票历史 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。它们各有脾气字段命名、日期格式、复权处理都不一样。我列一张表方便大家对照。数据源典型接口日期格式复权支持常见坑aksharestock_zh_a_histstr形式YYYY-MM-DD前复权、后复权、不复权返回的日期是字符串需要手动转datetime停牌日直接没有记录tusharepro.daily/pro.bak_dailystr形式YYYYMMDD需要单独取复权因子积分门槛导致部分接口不稳定停牌日同样缺失baostockquery_history_k_data_plusstr形式YYYY-MM-DD前复权、后复权、不复权字段里有isST等额外列需要先登录这些字段差异是最容易踩的坑。比如 akshare 的接口返回的列名是中文tushare 返回的是英文baostock 又有自己的英文缩写。如果你同时用两个数据源做交叉验证不统一 schema 的话代码里全是各种if判断。我的建议是不管数据源长什么样拿到数据后立刻转成自己的统一标准格式。我一般会定义这样一套基础 schemasymbol股票代码统一为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], keeplast) # 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(axis1) violate_low df[low] df[[open, close]].min(axis1) violations (violate_high | violate_low).sum() print(fOHLC 逻辑冲突行数: {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 天的数据计算信号时无意中混入了第 T1 天甚至更晚的信息。举例你想计算“今天收盘价是否突破 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(..., shuffleFalse)来做切分。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])按 keeplast 去重并检查重复来源OHLC 逻辑冲突多源拼接、数据源 bug检查low min(open,close)标记异常并剔除复权价格出现断崖复权因子未更新计算日收益率是否有超限跳变换后复权数据或手动修正涨跌幅超过涨跌停限制除权除息日、新股首日、数据错误对照公告或指数行情特殊日期豁免其他标记异常分钟线和日线合并后信号错位时间戳对齐错误检查合并前后的索引顺序统一按日期做 key或用merge_asof回测收益率高得离谱未来函数或幸存者偏差检查指标是否有shift股票池是否含退市股修正指标延迟补充退市样本这张表是我自己的经验浓缩版不一定覆盖所有情况但大部分免费数据源的常见问题都能在这里找到对应解法。6.2 我踩过的三个真实数据坑第一个坑发生在数据源切换时。我之前一直用 akshare 拉日线后来为了获取复权因子切换到 tushare结果 akshare 的日期格式是2025-03-14tushare 的日期格式是20250314我把两批数据直接 concat 之后日期排序完全乱掉回测信号全部错位。排查了半天最后发现就是日期格式没统一。现在我的所有适配器第一件事都是pd.to_datetime并且强制格式化成YYYY-MM-DD。第二个坑是涨跌停判断。我做打板策略时需要识别涨停一开始用close prev_close * 1.095结果某天一支股票因为除权除息收盘价“跌了” 45%我把它误判为极端异常行并删掉了后来才发现那是 10 送 10 之后的复权价。从那天起我所有价格校验都会先单独保存一份“复权因子”和“除权除息日”列表凡是除权日都做特殊处理。第三个坑是前复权数据污染。我为了看图方便把前复权数据存成缓存跑完回测后记录了策略绩效。过了两个月数据源更新了一次分红送转前复权历史价格整体重算我再次跑同样的回测结果绩效完全对不上。后来我把所有回测统一改成使用后复权数据并且固定数据快照版本问题才算解决。这件事让我深刻理解了回测结果必须能复现否则一切分析都无从谈起。最后再分享一个小习惯我每次做完整套数据清洗和校验流程后都会生成一份简单的数据质量报告记录处理日期、数据源、样本数量、异常数量、处理方式。这份报告未必每次都会仔细看但一旦回测出现可疑结果它能帮我快速定位是不是数据出问题了。数据质量这件事看起来不产生任何收益但它是所有量化工作的地基。地基不稳楼盖得越高摔得越惨。
返回列表