多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python股票K线数据校验与清洗:量化回测避坑指南

Python股票K线数据校验与清洗:量化回测避坑指南 说实话我用 Python 折腾股票历史 K 线的这几年最深的感触是大多数量化策略跑出来的结果很漂亮但一模拟盘就露馅问题往往不在策略逻辑而在最底层的 K 线数据压根没洗干净。很多人以为从 AkShare、Tushare 这类数据源把行情拿下来df.head() 看一眼没问题就开搞回测结果被除权跳空坑一次、被缺失值坑一次、被未来函数坑一次最后怀疑人生。这篇文章我想完整梳理一下拿到 Python 获取的股票历史 K 线之后从数据校验、清洗到量化回测一套流程到底该怎么走。不只是给你一段能跑的代码更重要的是把每一步背后的“为什么”讲清楚——为什么前复权比后复权更适合回测、为什么 timestamp 必须转成带时区的时间索引、为什么缺口跳空不是靠插值就能糊弄过去的。只要你能把数据质量这关守住后面写策略就踏实多了。这篇文章适合谁看适合那些刚学会用 Python 拉行情数据、正准备做第一版量化回测的新手也适合已经写了几个策略但总觉得回测结果“有点假”的朋友。我会结合自己用过的 AkShare、Tushare、Baostock 几种数据源把真实项目里的校验流程、回测坑位和排查经验全都倒出来。1. 数据获取之后的头等大事先校验再谈回测1.1 为什么数据质量直接决定回测生死聊量化回测有个词绕不开Garbage In, Garbage Out。行情数据质量差策略写得再精致都白搭。K 线数据的质量风险极其隐蔽它不像爬虫抓网页字段缺失了你一眼能看出来。K 线的异常往往是“看起来没问题其实已经错了”比如某一天某只票停牌导致日线缺失、某根 K 线的最高价比最低价还低、成交量字段读出来是负数、前复权价格在除权日出现奇怪的跳变——这些情况在 pandas 的 DataFrame 里显示出来肉眼根本不会注意到。我刚开始做回测时用的数据是从某免费接口直接拉的日线没有做任何校验。策略是一个简单的双均线跑沪深 300 成分股年化收益高得离谱。后来一位做量化的朋友提醒我你这种全市场批量回测只要某只股票在关键日期缺了一根 K 线你的持仓判断就会错位而错位产生的“虚幻收益”会被策略误当成信号。我回去一查还真有不少票因为停牌、新股上市等原因K 线序列是不连续的。所以我的原则是数据拿到手前 30% 的时间做校验和清洗后面才轮到策略。与其在回测结果里花三天找 bug不如在数据入口处花三小时把脏数据挡在门外。1.2 数据源选型AkShare、Tushare、Baostock 怎么挑选数据源是数据质量的第一道关口。国内散户能用的免费 Python 行情源主流就是 AkShare、Tushare Pro、Baostock 这三家各有脾气我直接给结论数据源接口风格复权数据频率稳定性我的建议AkShare爬虫聚合接口多而变化快部分接口支持字段不稳定分钟/日/周/月中等接口偶尔失效需更新适合快速拿数据、研究用Tushare Pro需要 token积分制有专门的复权因子接口分钟/日/周/月较高但积分门槛麻烦适合做正规一点的研究Baostock免费注册接口固定自带前/后复权参数日/周/月/分钟高很少变动我回测最常用它以我自己的经验日常做日线级别的回测我优先用 Baostock因为它的前后复权参数直接写在接口里不需要自己再算省掉一大坑。AkShare 的优势是数据源多能拿到北交所、可转债、行业板块等另类数据但字段格式变化太频繁今天能跑通的接口过一个星期可能就报错“因网站改版导致接口失效”的坑我踩过不止一次。Tushare Pro 的数据质量最稳定但积分制对新手不友好很多高频或财务字段要攒积分才能调。无论你最终选哪个源都要在代码里加一层统一的数据接口封装让上层回测逻辑直接用标准化的 DataFrame而不是直接依赖某个数据源的方法名。这样以后想换数据源只改底层一个函数就行校验逻辑完全复用。2. 核心细节拆解K 线数据校验到底校验什么2.1 先搞清楚 K 线字段的“标准长相”做数据校验之前得先定义一个“正确数据”的标准。我通常会把 K 线数据统一整理成下面这个结构datetime open high low close volume amount symboldatetime交易时间必须转成 pandas 的 DatetimeIndex。open/high/low/close开盘、最高、最低、收盘。必须满足 high max(open, close)low min(open, close)。volume成交量股数。amount成交额元。symbol证券代码最好统一成 6 位数字格式比如 600519。我见过很多新手把股票代码存成 int然后 600519 前面的 0 被吃掉了也见过把 symbol 和 datetime 一起作为两列结果 merge 的时候没有用复合键索引错乱。这些小问题在数据量小的时候无所谓一旦做全市场回测几万行数据 merge 出重复记录排查起来非常痛苦。所以我的建议是统一用 MultiIndex第一层是 symbol第二层是 datetime。这样做截面重采样、滚动计算、日线拼接都非常顺手。2.2 四道校验关卡缺失、重复、时序列、逻辑区间我把 K 线数据校验分成四个层面你可以直接当做一个 checklist第一关缺失值检查。对于日线数据最直接的问题是有没有空值某个交易日是不是整个缺失了在 pandas 里df.isnull().sum()能查到普通空值但查不出“缺失的交易日”因为交易日缺失在 DataFrame 里不是 NaN而是根本没有这一行。这个必须结合交易日历去查我再仔细说一下。第二关重复值检查。接口重复调用、数据源本身有 bug、自己拼接数据时重复 append都可能导致同一根 K 线出现两次。df.index.duplicated().sum()就能直接看到重复索引数量但更隐蔽的是“非索引列的整行重复”比如时间索引相同但 open/close 字段因为复权更新而变化这时候就要用df.drop_duplicates()加 subset 参数来判断。第三关时间序列连续性检查。这一步在日线数据上最容易出问题。A 股有法定节假日、临时停市你不能拿“自然日连续”去判断而要用交易日历。最笨也最稳的办法是拿这个标的里已知的所有交易日pd.date_range生成一个参考序列然后看你拿到的数据是否覆盖了其中每一天。如果缺失看缺失数量是否超过阈值如果超过就要回到数据源重新拉。这一关的价值在于——你的策略如果在缺失区间正好有交易信号等于你在用错误的价格做决策。第四关逻辑区间校验。这一步非常容易被忽略但极其重要。K 线的四个价格是有物理约束的比如 high 必须大于等于 open、closelow 必须小于等于 open、close或者说任何一根 K 线都不能出现 high low 这种倒挂。我写过一个校验函数专门检查价格边界和成交量边界遇到volume 0或者amount 0直接打警示。真实数据里这类脏数据经常来自数据源拼接的 bug比如某一根 K 线被错误地替换成了另一只票的数据但 open、high、low、close 的区间约束往往还在只是和前后 K 线完全不连续。这四关全部通过才能说数据在“结构层面”合格了。但结构合格不等于可以直接用于回测因为还有一个绕不开的问题——复权。2.3 除权除息与复权回测最容易栽的坑复权这个问题真的是量化新手的第一大杀手。我见过有博主直接说“用不复权数据跑回测就行”这种说法害人不浅。A 股股票如果发生分红送股股价在除权除息日会出现一个向下跳空缺口。比如一只股票 100 块钱宣布 10 派 10每股分红 10 元除息日开盘价会直接从 100 跳到 90 附近。如果你用不复权数据那么在回测里你会看到“股价暴跌”策略基于这个信号可能直接触发止损或者改判趋势但现实中根本没有发生亏损——只是钱变成了分红。我自己的习惯是回测一律用前复权数据。前复权的逻辑是把历史价格按照最新的复权因子统一往前调整这样整个序列连续、可比较而且最新价格就是真实市场价你计算买卖信号的时候不会出现“当前价格对不上”的荒谬情况。后复权则相反它把最早的价格作为基准最新价格变成一个很大的数适合做长期收益率统计但不适合用最新价做交易的策略。具体到代码Baostock 的接口里有复权参数import baostock as bs import pandas as pd bs.login() rs bs.query_history_k_data_plus( sh.600519, date,open,high,low,close,volume,amount, start_date2020-01-01, end_date2024-12-31, frequencyd, adjustflag2 # 1后复权, 2前复权, 3不复权 ) df [] while (rs.error_code 0) rs.next(): df.append(rs.get_row_data()) result pd.DataFrame(df, columnsrs.fields) bs.logout()adjustflag2就是前复权。但这里有一个坑前复权数据是“动态”的。如果你今天拉了一次数据明天股票除权除息了前复权因子就会变化导致你今天拉到的历史数据和昨天拉到的历史数据在数值上不一致。我一开始没意识到这个问题后来发现自己把不同时间的备份合并起来居然出现了同一只股票同一个日期有两套 K 线数据。所以团队协作时要约定好使用统一的数据抓取日期并且把复权因子也存下来而不是只存价格。我自己做项目时会把复权因子作为一个单独的表存进数据库回测时实时计算前复权价格而不是直接信任静态的前复权列。如果你用的是 AkShare它有qfq和hfq两种复权接口用法也很简单import akshare as ak # 前复权日线 df ak.stock_zh_a_hist(symbol600519, perioddaily, start_date20200101, end_date20241231, adjustqfq)注意 AkShare 返回的是中文列名比如“日期”“开盘”“收盘”在接入统一校验流程前要先做一次 rename。2.4 缺口与跳空哪些是真实行情哪些是数据错误复权之后K 线上依然会存在“缺口”gap也就是相邻两根 K 线之间价格没有重叠。对于 A 股常见的缺口来源有三种除权除息缺口复权后应该被填平没有填平说明复权处理有问题。交易所临时停牌比如公司重大资产重组停牌一天复牌后股价跳空这是真实缺口。涨跌停板导致的跳空股票开盘直接一字涨停或跌停中间没有成交K 线缺口也是真实的。数据校验时你要做的是判断“这个缺口合不合理”而不是把所有缺口都视为错误。有没有办法自动判断没有一个完美的算法但我个人的经验是结合成交量来看。如果一根 K 线相对前一日的缺口超过 10%或者更多但成交量并没有明显放大那大概率是数据拼接错误反之如果有涨停跌停成交量往往极度萎缩一字板缩量或者极端放大巨量换手需要结合具体情况。我处理停牌缺口时的做法是在清洗阶段先把停牌日期标记出来形成一个“不可交易日期”的黑名单回测时在这些日期即使有信号也不允许成交。这样既不会因为缺失数据报错也不会拿虚假的连续价格去计算止损。3. 实操过程一套可复用的数据校验与回测流水线3.1 完整的数据校验脚本下面这段代码是我在自己项目里沉淀下来的校验函数。它的输入是已经标准化好的 DataFrame输出是数据质量报告。你可以直接复制改改就用。import pandas as pd import numpy as np def validate_kline(df, symbol): df: MultiIndex DataFrame index: (symbol, datetime) columns: open, high, low, close, volume, amount issues [] # 1. 空值检查 if df.isnull().values.any(): null_count df.isnull().sum().sum() issues.append(f存在空值 {null_count} 个) # 2. 重复索引检查 if df.index.duplicated().any(): dup_count df.index.duplicated().sum() issues.append(f存在重复索引 {dup_count} 行) # 3. 区间逻辑检查 df_valid df.dropna() if (df_valid[high] df_valid[low]).any(): issues.append(存在 high low 的K线) if (df_valid[high] df_valid[[open, close]].max(axis1)).any(): issues.append(存在 high 小于 open/close 的K线) if (df_valid[low] df_valid[[open, close]].min(axis1)).any(): issues.append(存在 low 大于 open/close 的K线) # 4. 成交量检查 if (df_valid[volume] 0).any(): issues.append(存在负成交量) # 5. 交易日连续性检查粗略版假设只有工作日 # 真实场景建议用交易所交易日历 dates df_valid.index.get_level_values(datetime) date_range pd.date_range(startdates.min(), enddates.max(), freqB) missing_dates date_range.difference(dates.sort_values()) if len(missing_dates) 0: issues.append(f缺失 {len(missing_dates)} 个交易日示例: {missing_dates[:3].strftime(%Y-%m-%d).tolist()}) return issues这个函数的核心逻辑不复杂但每一道检查都对应真实踩过的坑。有一点需要提醒空值检查时dropna()之后再做区间逻辑检查否则 NaN 参与比较会直接返回 True导致大量误报。另外交易日连续性检查我用的是freqB工作日这在日线上是个粗略近似。A 股有春节、国庆等长假freqB会把这些假期误判为缺失。如果你想做得更严谨可以用交易所公布的交易日历或者直接只标记缺失日期再人工复核。3.2 清洗策略什么时候删、什么时候插、什么时候标记校验发现问题之后怎么清洗我的经验是三句口诀能删的删不能删的标记尽量不插值。重复行直接删。重复 K 线的存在没有任何信息量直接drop_duplicates。简单空值如果是某个字段为空但前后有值比如成交量缺失、收盘价存在可以按“前值填充”处理但要加入数据报告。整根 K 线缺失不要插值。日线缺了一天你无论用线性插值还是前值填充都是在捏造市场行情回测结果没有任何意义。正确做法是把它标记为不可交易日期。价格逻辑错误的 K 线先复查数据源。如果源数据本身就是错的建议整根删除或者标记不要试图用其他字段“修正”它。很多新手一看到数据缺失第一反应就是df.interpolate()觉得插值后数据就“完整了”实际上这是量化里非常危险的习惯。你的回测是来验证策略逻辑的不是来证明插值算法好不好用的。捏造出来的 K 线会让“趋势策略”在虚假的连续价格上产生大量交易信号回测结果完美实盘一塌糊涂。3.3 清洗后的数据如何进入回测一个最小双均线案例数据洗干净之后就能写回测了。这里我用一个最简单的双均线策略来演示清洗后的数据怎么用。这个示例本身没什么实战价值但能完整展示“数据校验 - 数据清洗 - 信号生成 - 回测”的链路。import pandas as pd import numpy as np def dual_ma_backtest(df, fast5, slow20, fee_rate0.0003, initial_cash100000): 双均线策略回测 信号: 金叉买入, 死叉卖出 df df.copy() df[ma_fast] df[close].rolling(fast).mean() df[ma_slow] df[close].rolling(slow).mean() # 信号: 用shift避免未来函数 df[signal] 0 df.loc[df[ma_fast] df[ma_slow], signal] 1 df[position] df[signal].diff().fillna(0) # 交易成本与持仓 cash initial_cash position 0 trades [] equity_curve [] for date, row in df.iterrows(): if row[position] 1 and position 0: # 金叉买入 buy_price row[close] position cash / buy_price trades.append((date, BUY, buy_price)) cash 0 elif row[position] -1 and position 0: # 死叉卖出 sell_price row[close] cash position * sell_price * (1 - fee_rate) trades.append((date, SELL, sell_price, cash)) position 0 equity cash position * row[close] if position 0 else cash equity_curve.append(equity) df[equity] equity_curve df[returns] df[equity].pct_change() return df, trades这段代码有两个细节值得说。第一signal计算后必须diff()而不是直接判断signal 1就买入。因为signal 1在均线多头排列的每一天都成立如果你写成df[signal] 1就买入那你会从金叉第一天开始每天都把仓位重新买入一遍——这等于在模拟一种完全不存在的交易行为。正确做法是只在状态发生变化的当天交易也就是diff()之后得到 1 或 -1 的位置。第二用close作为成交价实际上隐含了一个乐观假设——你能在收盘价那一刻成交。在日线级别回测里这一般可以接受但你要心里有数。如果策略对成交价极其敏感建议用次日开盘价或者引入滑点模型。我在回测时通常按收盘价 * (1 滑点) 来模拟冲击成本这样更接近真实。回测跑完之后输出指标不能只看总收益率。我一般会看这几个指标年化收益率最大回撤从高点到低点的最大跌幅夏普比率收益 / 波动率交易次数太少说明信号稀疏太多说明可能过拟合很多免费数据源的复权数据有一个毛病前复权价格会导致历史价格出现负值如果股票长期连续分红不少见那均线计算没问题但计算对数收益率np.log(close / close.shift(1))时会直接 nan。如果你的数据里有历史价格为负的情况建议改用简单收益率pct_change()。3.4 防止未来函数数据清洗阶段就要布局的一步未来函数是量化回测里最隐蔽、最致命的 bug策略在 T 日计算信号时不小心用到了 T 日收盘之后才能知道的数据等于“开了天眼”。常见的情况就是上面提到的shift使用不当。数据清洗阶段就要为这个问题布局。我在标准化 DataFrame 时会统一自带一列trade_date并强制要求所有基于当根 K 线计算的信号在回测时都通过df[signal].shift(1)来生成真实的次日执行信号。这样做的代价是信号滞后一天但收益是回测结果真实可信。再举个例子很多新手喜欢在清洗时顺手把全样本的均线都算好然后回测时直接在均线序列上判断买卖。这个做法没问题但前提是均线只能用到截至当天的数据pandas 的rolling(window)默认就是只向后看不会用未来数据。真正容易出错的是你自己手写的向量化信号比如# 错误写法: 用到了未来数据 df[signal] (df[close] df[close].rolling(5).mean()) (df[close].shift(-1) df[close])这种“明天涨所以今天买”的写法回测结果必然完美实盘必然崩溃。数据清洗完成后我建议你用一个简单粗暴的检查方法随机挑一只票的几个交易信号手工核对一下信号当天的 K 线是否已经包含未来数据。写脚本自动查未来函数很难但抽查一个样本能发现大多数低级错误。4. 常见问题与排查技巧实录4.1 数据源接口突然挂掉、返回空 DataFrame这是最让人崩溃的场面昨天还能跑的脚本今天早上起来一跑所有数据都是空的。AkShare 尤其常见因为它的数据源是爬虫聚合上游网站改个参数就全挂了。排查思路先确认是网络问题还是接口字段问题。我一般的操作是单独打印一次接口返回的原始内容如果接口返回了数据但是你解析的字段对不上那就是字段名变了如果接口直接返回空那就是数据源挂了。此时最好的方案是切换到备选数据源。所以前面说的“数据源封装层”就非常重要——只要底层封装的接口名字一致切换到备选源只改一个配置。另外要提醒的是写数据抓取脚本一定要加重试机制。我自己常用tenacity库给抓取函数加上retry(stop_max_attempt_number3, wait_fixed2)遇到网络抖动或接口限流能自动重试避免脚本跑一半中断。4.2 前复权数据在全历史区间不一致这个问题很隐蔽。前面提到过前复权价是动态的今天的“前复权”和明天的“前复权”不是同一套数据。如果你有一个长期数据管道每天都在增量更新 K 线那么同一天的历史价格可能每天都变。我的做法是专门建一张adjust_factor表每次抓数据都把复权因子存下来。回测时我拿到的是“不复权价格”乘以“截至当前日期的复权因子”这样历史数据保持稳定不会因为最新除权而全部推翻。这个方案对于实盘模拟特别重要因为实盘里你的历史信号不能因为“今天发生了一次分红”就全部重算。4.3 时间戳时区问题导致对齐出错国内数据源返回的日期多数是datetime.date或str不涉及时区问题但如果你用了美股数据、或者把数据存入 MongoDB/PostgreSQL 时自动转了 UTC再取出来时就会差 8 小时。表现在回测上就是“今天的数据被算到了明天”。统一策略所有数据在入库和回测前一律转成东八区时间的日期格式date不要用带时区的 datetime 做索引。我的代码里固定这么处理df.index pd.to_datetime(df[date]).dt.date这样后续无论对接什么数据源时间索引都是单纯日期不需要考虑时区偏移。4.4 成交额与成交量单位不一致国内接口里成交量单位很不统一。Baostock 返回的 volume 单位是“股”AkShare 有些接口返回的 volume 单位是“手”1 手 100 股还有一些接口直接把成交量写成“万手”。如果你在多个数据源之间切换、或者用成交量做策略筛选因子单位不一致会让你筛选出完全错误的股票池。解决办法是在数据标准化阶段新增一列统一单位# 假设原始数据volume单位是手 df[volume] df[volume_raw] * 100 # 转成股 df[amount] df[amount_raw] * 10000 # 有些接口成交额单位是万元同时增加一个单位校验字段比如 A 股的合理价格在 0.1 到 3000 元之间如果某根 K 线价格超过 10000直接报警。4.5 回测结果和手工计算对不上这是最让人抓狂的调试。回测跑完年化 35%但你自己在 Excel 里对着 K 线算了几笔交易怎么都对不上。我碰到过三种典型原因交易信号被diff()重复触发一天开了两次仓。清洗数据时用了df.dropna()导致整个 DataFrame 的索引重新排序结果shift(1)把“上一行”当成了“上一交易日”而实际上中间缺席的日期被删掉了。手续费率设置得过高或者过低复利计算时出现了偏差。排查方法没有捷径就是把回测里的交易明细全部打印出来挑最近的三笔手工对着 K 线图逐笔核对。很多时候问题出在数据清洗阶段而不是策略阶段。我自己的原则是回测框架宁愿写简单点也不要一开始就上高级框架。很多新手一上来就用 Backtrader、VectorBT 这类框架但这些框架自带的数据预处理器、经纪人模拟器都会引入额外的默认假设出了问题很难定位。先用裸 pandas 写一个 200 行的回测把交易明细彻底理解透了再切换到复杂框架。4.6 停牌日期的最终处理最后再补充一个停牌处理的小技巧。A 股停牌股在回测里特别容易造成“开天眼”如果一只股票从 5 月 1 日停牌到 6 月 1 日复牌当天直接一字涨停但你手里的 K 线数据缺失了这一个月你的策略可能会在停牌期间反复买卖因为没有新 K 线触发不了止损/止盈但一旦复牌策略立刻以涨停价买入或卖出会产生巨大的虚幻收益。所以我在标准化数据时会额外维护一张suspension_dates表记录每只股票的全部停牌日。回测引擎里遇到停牌日直接跳过不开仓、不持仓计算返回值按上一个有效交易日处理。这样处理之后双均线这类趋势策略的回测收益通常会下降但这才更接近实盘。最后再分享一点个人体会做了这么多年数据校验我最大的体会是数据清洗不是回测的准备工作它本身就是策略的一部分。你对 K 线的每一个处理决定——是删还是补、是前复权还是后复权、是跳过停牌还是插值——都在悄悄改变策略的收益和风险特征。与其追求一个亮眼的回测曲线不如先把数据底子打扎实至少你要清楚你给策略喂进去的每一根 K 线来自哪里、经过了什么处理。如果你刚开始接触量化别急着写复杂策略。花几天时间把一个数据源的 K 线拉下来完整跑一遍校验、清洗、回测的流程理解每一行代码在干什么。这个过程的收获比你调几十个参数都大。数据会撒谎但校验不会。
返回列表