ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

金融数据清洗实战:复权、停牌与缺失值处理的pandas指南

金融数据清洗实战:复权、停牌与缺失值处理的pandas指南 最近一直在折腾一份股票日线数据从wind和同花顺拉下来之后第一反应是赶紧丢到策略里跑回测。结果只跑了一天就发现问题某天成交量莫名翻倍某只股票的收盘价前一天是10块后一天直接变到6块中间没有任何分红公告数据里却混进了好几个不同来源拼接出来的价格序列。气得我直接关掉脚本把数据一张一张打印出来核对。这就是金融数据清洗最真实的日常。做量化策略、写研报复现、或者只是自己在做资产配置分析的人大概率都经历过这种“数据到手了但不敢直接用”的阶段。金融数据不是普通的Excel表格它有交易日历、停牌复牌、除权除息、涨跌停、复权价格、成交量单位等等一堆特殊属性清洗起来要比处理用户表、订单表麻烦得多。这篇内容我就把我自己反复踩过的坑、实际跑通的流程和一些能直接抄的代码放出来希望对刚接触金融数据或者正在为数据质量头疼的朋友有帮助。1. 先摸清家底金融数据最常见的四类“脏”很多人一上来就写dropna()清理缺失值这是最典型的误区。金融数据的脏远不止“有没有空值”这么简单我把它总结成四类你先对号入座看看自己数据里是不是也存在。1.1 缺失值不只是NaN断档、空仓和属性缺位普通业务数据的缺失通常是一行记录里某几个字段是空比如用户没填手机号。金融数据的缺失更复杂它经常是整个交易日就不该有数据。举个例子一家上市公司因为重大资产重组停牌三个月这段时间你在日线行情表里是找不到任何记录的。这算缺失吗从“数据完整性”角度看算但从“交易事实”角度看它本来就不该有交易所以并不是需要填充的缺失。再比如有些股票在某一天因为临时停牌半小时导致开盘价有、收盘价没有这种才是真正需要处理的缺失。更隐蔽的缺失是“属性缺位”你的行情数据里只有价格和成交量但没有标注复权状态或者你有每日收益率但没有标注这个收益率是简单收益率还是对数收益率。这种字段级的信息缺失会在后续计算时埋下大雷。我的建议是拿到数据第一步先做三类检查按证券代码分组统计每个代码的交易日数量和数据源公布的交易日历做对比找出断档的区间。检查价格字段中NaN或0的位置区分是停牌导致的缺口还是行情本身的异常。把所有“非数值”字段比如复权因子、交易状态单独存档不要轻易丢弃。1.2 重复数据同一交易日出现多行记录重复数据在金融数据里极其常见尤其是你从多个数据源拼接数据的时候。比如tushare拉了一份日线akshare又拉了一份日线两个DataFrame一concat没有去重结果同一只股票同一个交易日出现了两条记录。我遇到过更隐蔽的情况同一个交易日一条记录是“前复权价格”另一条是“不复权价格”价格不一样但日期和代码一模一样如果不看复权状态字段去重逻辑就会把其中一条误删或者保留错误的一条。重复数据最直接的危害是计算成交量、成交额时翻倍回测收益算错。处理思路也有讲究简单drop_duplicates()是不够的要先明确“重复”的定义是以证券代码交易日期为唯一键还是代码日期复权类型三者联合唯一。确定了唯一键之后再去决定保留哪一行。1.3 字段格式不统一代码、时间戳的别名问题金融数据源之间几乎没有统一的标准。同样是上交所的股票A数据源代码是600000.SHB数据源是600000.XSHGC数据源干脆写成sh600000。你要做多源合并第一步就是把这些代码映射到同一套命名体系。时间戳也一样。某些接口返回的日期是2024-01-15某些是20240115某些是毫秒级时间戳还有的数据库存的是datetime64[ns]但时区不明确。用pandas读进来的时候如果不统一处理后面merge、groupby、resample都会出各种奇怪的错。我个人的习惯是无论数据从哪来清洗的第一步永远是把三样东西固定下来证券代码统一映射到标准格式比如600000.SH形式。所有日期统一成datetime64类型并明确是自然日还是交易日。字段名统一成一套内部命名规范比如trade_date、close、volume、amount。这套规范固定下来之后所有清洗逻辑都基于统一后的schema写就不会出现“换个数据源脚本全崩”的问题。1.4 除权除息引起的价格断层金融数据的专属坑这是我认为最值得单独说的一类脏数据。股票分红送转之后价格会向下跳空比如10送10股权登记日收盘价20元除权日开盘可能变成10元附近。如果你拿原始价格直接算收益率会得到一个巨幅下跌的假信号导致策略在除权日附近疯狂误判。这个问题的本质不是数据缺失也不是重复而是价格序列不连续。处理方式就是复权。复权分为前复权、后复权和定点复权用复权后的价格去计算收益率才是对的。至于复权怎么做我在后面实操章节会给出完整代码和解释。这四类问题经常混杂在一起。比如一只停牌股票复牌当天恰好又是除权日那么“缺失区间”和“价格断层”会同时出现。你在清洗时一定要把这些情况都考虑进去而不是单独处理某一个。2. 数据从哪来wind、同花顺和免费接口怎么选开始讲清洗实操之前有必要先说清楚数据源。因为清洗方案很大程度上取决于你用什么接口拿数据、拿到的是什么格式。2.1 专业终端接口wind和同花顺的Python调用如果你在券商、基金或者研究所工作大概率接触过wind金融终端。wind提供Python接口WindPy通过终端授权后可以调用w.wsd()获取历史序列数据。用法大致是from WindPy import w w.start() # 获取平安银行最近60个交易日的日线行情 data w.wsd(000001.SZ, open,high,low,close,volume,amt, 2024-01-01, 2024-04-01, FillForPrevious1)WindPy返回的数据结构比较特别一般是ErrorCode加数据列表需要自己整理成DataFrame。同花顺的iFinD接口用法类似也是先初始化连接再调用对应函数获取数据。专业终端接口的优势是字段齐全、数据质量高、复权因子直接给、交易日历完整。劣势也很明显贵且只能在授权终端上运行不能随便部署到服务器上做无人值守的定时任务。如果你的清洗目标是专业研究或者实盘策略我建议优先用这类接口因为数据源的质量能帮你减少大量的清洗工作量。但即使这样复权、停牌、代码格式等问题依然要靠自己的清洗逻辑解决。2.2 免费数据接口tushare、akshare、baostock的取舍对个人做研究、写分析文章、或者跑学术复现的人来说免费接口是主流选择。我常接触的三个tushare数据质量高覆盖广但部分接口需要注册账号并积累积分积分不够时很多字段拿不到。akshare基于公开数据源爬取整理接口数量多、免费开放但字段格式变动相对频繁数据稳定性一般。baostock不需要注册免费提供日线、分钟线、财务数据复权因子可以直接拿比较适合初学者。我之前接过一个需求要把tushare的日线数据和baostock的复权因子合并使用。两者同一交易日的数据基本能对上但成交量单位不一致tushare返回的是“手”baostock返回的是“股”。如果不清洗计算换手率时就会差100倍。这就是我说的“字段格式不统一”问题在真实项目里的体现。2.3 接到数据后的第一道检查schema确认不管用哪个接口我接到数据后的第一件事件一定不是开始清洗而是先看结构。打开DataFrame看一眼info()、head()、dtypes确认好这些再动手每一列的类型是否符合预期价格应该是float64日期应该是datetime64。是否存在隐藏的重复列比如有时候接口既返回volume又返回vol含义可能一样。索引是默认整数索引还是日期索引很多人在这一步没注意后面merge直接出错。这一步看起来不起眼但能省掉后面大量的返工。金融数据的清洗本质上是“先定义什么是干净再把数据改造成干净的样子”而定义干净就是从schema开始。3. pandas实操记录我在项目里实际跑通的清洗流程以下代码我都基于自己实际处理过的场景简化而来环境是Python 3.9 pandas 1.5。你可以直接复制到自己的jupyter里改一改字段名就能用。3.1 列名与索引标准化假设我从某个接口拿到了原始数据列名可能长这样ts_code、trade_date、open、high、low、close、pre_close、change、pct_chg、vol、amount。我第一步先重命名、统一类型import pandas as pd import numpy as np df df.rename(columns{ ts_code: code, trade_date: trade_date, vol: volume_hand, amount: amount_yuan }) # 日期统一 df[trade_date] pd.to_datetime(df[trade_date]) # 证券代码标准化统一转成 600000.SH 这种风格 def normalize_code(c): if c.endswith((.SH, .SZ, .BJ)): return c.upper() if c.startswith((sh, sz, bj)): return c[:2].upper() . c[2:].upper() if len(c) 6: if c.startswith((6, 9)): return c .SH else: return c .SZ return c df[code] df[code].map(normalize_code)为什么要把代码统一因为后续你很可能要合并多个数据源比如用tushare的行情算指标、用同花顺的财务数据算估值两边的代码不统一merge就永远匹配不上。这一步做完你的清洗流水线才算真正有了一个稳定的“主键”。3.2 交易日索引处理与重采样金融数据几乎全是时间序列把trade_date设成索引会方便很多但要注意金融数据的索引不是连续的自然日而是交易日。直接在索引上做resample(D)是错的因为周末和节假日本来就没有交易。正确做法是需要维护一份交易日历。tushare有trade_cal接口baostock也有交易日历获取方法。你也可以自己维护一份沪深交易所的开市日期列表。有了交易日历之后如果需要“补齐缺失的交易日”可以这样做# cal: 交易日历 DataFrame包含 trade_date 列 all_trade_days pd.to_datetime(cal[trade_date]) # 对每只股票找到应该出现的交易日 full_idx pd.MultiIndex.from_product( [df[code].unique(), all_trade_days], names[code, trade_date] ) df ( df.set_index([code, trade_date]) .reindex(full_idx) .reset_index() )这段代码会把所有股票的交易日网格全部铺开没有数据的日期变成NaN。这就是在“交易日”维度上补全缺失。补全之后你才能区分该交易日停牌补全后还是NaN还是本来就不开市不在交易日历里。3.3 缺失值填充策略按字段属性区别对待补全交易日网格之后问题就变成这些NaN怎么处理我的原则是不同字段用不同策略不要一刀切ffill()。以下表是我常用的处理方案字段处理方式原因open/high/low/close停牌期间不填充保留NaN停牌时没有真实成交价格填了反而引入噪声pre_close使用最近一个交易日的close填充复牌后涨跌幅计算需要引用前收volume/amount停牌期间填0不复用的不填成交量在停牌时为0是事实而非缺失复权因子不填充仅在有交易数据的日期保留复权因子只对实际成交价格有意义基本面指标PE/PB向前填充最多填充有限期基本面数据是低频的公告前的每日值沿用上一次披露值具体代码df[pre_close] df.groupby(code)[close].shift(1) # 停牌期间成交量填0但涨跌幅保留NaN df[volume_hand] df[volume_hand].fillna(0) # 如果接口给的成交量单位是手转成股 df[volume_share] df[volume_hand] * 100 # 行情价格字段不填充用于后续判断停牌 df[is_suspended] df[close].isna().astype(int)这里有个关键点groupby(code)[close].shift(1)算出来的pre_close天然就是上一交易日收盘价比用ffill更准确因为它不会跨越停牌期把很久之前的价格带过来。3.4 去重与合并的纪律去重最容易犯的错是不看数据语义就duplicated().drop_duplicates()。我给自己定了一条规则去重之前必须先定义“主键”然后显式表达“保留哪一条记录”。# 定义主键 key [code, trade_date, adjust_type] # 看重复情况 dup_mask df.duplicated(subsetkey, keepFalse) print(df[dup_mask].sort_values(key).head()) # 按主键去重保留最后一条 df df.drop_duplicates(subsetkey, keeplast)如果主键相同但内容不一样说明是多数据源冲突先确认保留规则再删除。比如不同源的价格差了0.01元你要决定是取平均值、取信任源数据还是标记为冲突待查。这不能靠工具解决得靠业务规则。3.5 复权处理与收益率计算复权这块值得单独说。我见过很多人拿不复权价格算收益结果回测在分红季一片惨绿。复权的核心逻辑是把“历史价格调整到和当前一致”。pandas本身没有内置复权函数复权因子一般由数据源提供。计算公式也很简单后复权价格 不复权价格 × 复权因子前复权价格 不复权价格 × 复权因子 / 最新复权因子用代码实现# 假设 df 里已经有不复权价格 close_raw 和复权因子 adjust_factor latest_factor ( df.sort_values(trade_date) .groupby(code)[adjust_factor] .last() ) # 对齐每只股票的最新复权因子 df df.merge(latest_factor.rename(latest_factor), oncode) df[close_hfq] df[close_raw] * df[adjust_factor] df[close_qfq] df[close_raw] * df[adjust_factor] / df[latest_factor] # 用前复权价格计算日收益率 df[daily_return_qfq] df.groupby(code)[close_qfq].pct_change()这里有个非常容易踩的坑如果用未来信息去复权最新因子那么实时计算的时候最新因子是会变化的前复权价格只是一个“以今天视角看历史”的序列。如果你写的是实盘信号建议用后复权价格因为后复权价格是随着时间递增的不会因为新分红导致历史序列全部改变避免了“未来函数”式的数据穿越。4. 专治金融数据的顽固问题停牌、涨跌停、异常值清洗流程跑通之后还有一批金融数据特有的“顽固分子”处理不好后面分析照样翻车。4.1 停牌数据不要盲目填充先标记状态停牌的股票在日线数据里一般表现为当日无记录或者是NaN。如果你做了3.2节的交易日网格补全停牌日就会变成close为NaN的行。处理停牌时我建议额外建一个状态标记而不是直接删掉行。因为后续计算流动性指标、波动率时停牌信息本身是有用的。df[is_suspended] df[close].isna().astype(int) # 如果不想保留停牌行至少先保存一份停牌记录 suspension_record df[df[is_suspended] 1][[code, trade_date]] suspension_record.to_csv(suspension_record.csv, indexFalse)判断一只股票停牌多久也很简单对is_suspended分组计数或者用连续停牌的游程编码。这个数据对回测里处理“停牌后无法卖出”的风险特别重要。4.2 涨跌停与零成交同是0.0含义完全不同A股有涨跌停制度主板通常±10%创业板和科创板±20%。涨停当天可能买不进跌停当天可能卖不出这是实盘必须考虑的事实但在数据里你看到的只是价格涨幅到了上限、成交量可能急剧萎缩甚至为0。零成交和停牌的区别在于零成交是还在交易状态但没有买卖盘达成交易停牌则是交易所明确暂停了交易。如果清洗时把零成交直接当成停牌会把流动性差的股票误判成停牌后续做流动性因子就会错。判断方法可以结合成交量、最高价最低价和涨跌幅来判断# 连续零成交且价格不变很可能是停牌或流动性枯竭 no_trade (df[volume_share] 0) (df[high] df[low]) # 结合是否在交易日历里判定为疑似停牌 df[is_no_trade] no_trade.astype(int)涨跌停识别则是# 简化判断收盘价到达当日涨跌停价 df[limit_up] ( (df[close] df[pre_close] * 1.099) (df[close] df[open]) ).astype(int) df[limit_down] ( (df[close] df[pre_close] * 0.9) (df[close] df[open]) ).astype(int)这里用1.099而不是1.1是因为四舍五入和精确涨停价规则不同严谨的做法是用数据源提供的涨跌停价字段或者在沪深交易所规则里按精确到分的涨停价判断。4.3 异常值价格跳动、成交量爆炸的统计识别金融数据里还经常混入明显的错误值。比如某天某只股票的价格突然跳了50%但那天既没有除权除息也没有开盘涨停基本就可以判定是接口返回了错误数据。我常用的异常值识别手段是“分组统计 阈值判定”# 计算每只股票的相邻收益率找出偏离过大的点 df[pct_chg] df.groupby(code)[close].pct_change() # 超过30%的日涨跌幅且不是涨跌停、不是复权日标记为异常 abnormal df[ (df[pct_chg].abs() 0.3) (df[limit_up] 0) (df[limit_down] 0) ].copy()注意市值小的次新股、长期停牌后复牌的股票一天涨几十个百分点是真实存在的所以不能直接用abs()0.3当作错误值删掉只能先“标记”再结合公告、复权因子等外部信息人工确认。成交量也一样可以用rolling中位数加倍数法识别异常尖峰med_vol df.groupby(code)[volume_share].transform(lambda x: x.rolling(20, min_periods5).median()) df[vol_ratio] df[volume_share] / med_vol # 成交量突然放大5倍以上需要人工复核 df[vol_abnormal] (df[vol_ratio] 5).astype(int)在清洗阶段我的原则永远是“宁可多标记不可乱删除”。知道哪些数据可疑比直接把可疑数据删掉重要得多因为后面的模型训练和因子计算需要能追溯每一次数据处理的变更。5. 把清洗过程做成可复用工具封装、审计与交叉验证清洗一次容易难的是把清洗过程沉淀下来每次拉新数据都能自动跑一遍并且输出一份别人能看懂的清洗报告。5.1 清洗函数按职责拆分我不建议把所有清洗逻辑写在一个巨大的脚本里。我自己的项目是按下面几个模块拆分的load_raw.py负责连接数据源、拉取原始数据、保存原始副本。normalize.py负责列名、代码、日期格式的统一。adjust_price.py负责复权因子合并、价格复权计算。fill_missing.py负责交易日网格补全、缺失值策略。quality_check.py负责生成质检报告、标记异常。output_clean.py负责输出清洗后的数据和清洗日志。每个模块的输入是上一个模块的输出模块之间用pickle或parquet文件衔接不要全部放在内存里。这样做的好处是一旦发现清洗逻辑有bug只需要修一个模块重新跑那一段就行不用从头开始拉数据。5.2 清洗报告每次清洗后必须输出的审计记录我给自己定了一个硬性要求每次清洗完成必须同时输出一份清洗报告记录以下内容原始数据行数、清洗后行数、删除的行数。每种缺失值填充了多少条、填充规则是什么。复权因子来源、复权方式前复权/后复权。标记的停牌天数、异常值数量。每个证券代码的交易日覆盖率和原始数据源是否一致。报告可以简单做成一文本或csv关键是留痕。后续如果策略结果不对能靠这份报告定位是数据问题还是策略问题不用重新扒数据。5.3 和原始数据源交叉验证最后一步也是最容易被忽略的清洗之后要抽样本和原始数据源做交叉验证。我的验证方法是随便抽3只股票、随机抽20个交易日把清洗后的收盘价、成交量、涨跌幅和原始接口再去拉一遍逐条核对。如果发现对不上多半是清洗逻辑出了问题或者是复权因子用错了。还有一个非常有效的验证手段用清洗后的数据计算一个大家都很熟的指标比如贵州茅台过去一年的累计收益率、沪深300的日收益率均值拿这个结果和公开研报、财经网站上的数值对比误差在小数点后两位以内就说明清洗基本没问题。每次跑完一套新的清洗流水线我还会额外验证一下复权数据的“连续性”看价格序列里是否有异常的巨大跳空尤其在分红送转密集的月份如果还有超过涨跌停限制的跳动那就是复权遗漏了。一些经验之外的话这几轮金融数据清洗做下来我最大的感受是数据清洗不是一个“用工具处理一下”的动作而是一个“定义标准、执行标准、验证标准”的闭环。哪怕是最成熟的wind和同花顺接口给你的数据也不会是完全干净的因为金融世界的复杂性本身就是数据的一部分——停牌、除权、涨跌停、流动性枯竭这不是错误而是市场发生过的真实事件。清洗的本质是让数据如实反映这些事件而不是把它们一律抹成整齐的表格。如果你现在正准备开始处理金融数据我给的建议是先别急着写代码花半天时间把你手里的数据从头到尾翻一遍看看代码格式有多少种、日期有没有缺、价格有没有跳空。把这些问题列成清单再动手你会发现后续的清洗代码写得异常顺手。遇到不确定的数据多留一个标记字段少删一行真实记录这是金融数据清洗里最不亏本的原则。
返回列表