Python股票行情数据清洗指南:从pandas检查到复权修复的完整流程
上周有个做量化的朋友发来一张截图,问我为什么他算出来的某只股票日线收益率会有-99.99%这种离谱数字。我一看代码,前五分钟刚用Python调接口批量拉了一两年的股票行情,直接就用
pct_change()
算涨跌幅。问题出在哪里?数据源把某天停牌导致的价格缺失填成了0,0除以昨天收盘价,自然就是-100%。
这种场景太常见了。Python批量获取股票行情,本身没什么难度,大部分行情接口几行代码就能下单,把数据拿到手,pandas一返回DataFrame,看起来日期整齐、列名完整,好像可以直接做因子分析了。但很多“看起来能直接分析”的数据,实际是带病上岗的:缺失值、重复行、前复权参数没生效、除权日的价格跳变被当成暴跌、时间戳错位、涨跌停被误删……如果在获取行情之后立刻进入指标计算或向量化建模,前面这些错误会原封不动地带进结论里,越往后越难排查。
这篇文章就聊一件事:批量拿到Python行情数据之后,为什么必须先做数据质量检查,以及一套我用了很久的检查、修复、复查流程。我自己在A股、港美股行情处理上都踩过不少坑,下面这些方法不是教科书的说法,是真实拉数据拉多了之后沉淀下来的经验。
1. 别急着算收益:股票行情数据为什么天生“不干净”
1.1 缺失值、重复值、甚至类型错误:一次真实的数据集体检
先说一个大家最常遇到的场景。从某个免费接口拿到沪深300成分股最近一年的日线行情,保存成CSV后,用pandas读进来,第一反应大多是
df.head()
、
df.describe()
看一眼,觉得没什么问题,就开始算指标了。
我拿真实数据给你演示一遍体检时会出现的情况:有股票的
volume
字段出现0,但
amount
字段大于0;有股票的
high
在某一根K线上低于
open
;有的日期重复出现了两遍;还有的日期缺失了几天。最要命的是
open
、
high
、
low
、
close
这四个价格列里直接混进了字符串,因为数据源偶尔会把异常值写成
"--"
或空字符串。一旦这一列变成object类型,后面所有价格计算都会报错,或者被pandas静默地转成NaN,但你根本不知道从哪一天开始坏的。
用下面这段代码可以快速给每个字段做个体检:
import pandas as pd
def basic_field_report(df):
report = []
for col in df.columns:
row = {
"column": col,
"dtype": str(df[col].dtype),
"null_count": int(df[col].isna().sum()),
"null_pct": round(df[col].isna().mean(), 4),
}
if pd.api.types.is_numeric_dtype(df[col]):
row["min"] = round(float(df[col].min()), 4)
row["max"] = round(float(df[col].max()), 4)
else:
row["min"] = None
row["max"] = None
report.append(row)
return pd.DataFrame(report)
这个函数会告诉你每一列的缺失值比例和取值范围。我在实际项目里把它作为第一步检查,能快速发现整列类型错乱、缺失比例异常的问题。做完这一步再去做更细的“语义检查”。
1.2 停牌、涨跌停、除权除息:这些“异常”到底是脏数据还是真实行情
行情数据里最麻烦的一点,是大量“看起来异常”的记录,其实是真实发生的交易状态。这些不是脏数据,但它们会严重干扰量化计算。例如停牌:某股票因为重大事项停牌三个月,这三个月的数据缺失,属于正常现象。问题在于不同数据源对停牌的表现完全不同——有的直接缺行,有的停牌日填充前一交易日的收盘价,有的填0。同样是停牌,三种表达方式会算出来完全不同的收益率曲线。
涨跌停也一样。A股主板有10%的涨跌幅限制,遇到连续一字涨停的时候成交量极低,盘中几乎没成交,价格却每天封在涨停板上。如果用算法去过滤“价格波动异常”的样本,很容易把真正的涨停K线当成异常数据删掉,这反而是严重的数据损坏。除权除息日更是重灾区——某股票今天收盘价17.5元,明天突然变成11.6元,不懂除权除息的人会以为是暴跌30%,实际上是每10股送转10股加派息,同样金额的持仓数量和成本都变了,股价当然要除权。这里必须搞清楚你拿到的数据是“不复权”“前复权”还是“后复权”,否则后面算出的所有指标都会出现虚假的断崖。
1.3 脏数据进入后续向量化计算的连锁反应
近几年“行情向量化”这个词很火,把时间序列转成特征矩阵,喂给机器学习模型做训练。这个环节对数据质量的要求比普通看盘高得多。普通看图,图上某一天有一条断头铡刀式的假暴跌,你肉眼能分辨;但一旦转成向量,那个错误的-9.9%就是一个数值,模型不会问它从哪里来,只会把它当成真实特征学进去。
我在一个选股特征工程项目里遇到过典型问题:某只股票在除权日用“不复权”价格参与计算,特征“过去20日收益率”直接被拉低到-30%左右,模型训练时把它当成一个强负向特征,导致股票被强行分到低分位。后来检查数据时才发现这只股票实际经营稳定,除权日之前没有任何利空。这就是“脏数据不是缺数据,而是让模型学到了错误规律”的典型例子。
2. 数据质量检查到底查什么:从字段级到时序级再到业务规则
2.1 字段级检查:high/low/close之间的关系必须自洽
字段级检查除了看缺失和类型,还要看字段之间的逻辑关系。对日线数据来说,最基础的关系是:
high >= max(open, close)
且
low <= min(open, close)
,否则说明K线数据互相矛盾。这份数据记录了一个不可能存在的价格区间,比如最高价低于开盘价,那肯定不能直接用。
我用一段逻辑做检查:
def check_kline_logic(df):
logic_errors = pd.DataFrame()
logic_errors["high_less_than_max"] = df["high"] < df[["open", "close"]].max(axis=1)
logic_errors["low_more_than_min"] = df["low"] > df[["open", "close"]].min(axis=1)
logic_errors["negative_volume"] = df["volume"] < 0
logic_errors["non_positive_close"] = df["close"] <= 0
return df[logic_errors.any(axis=1)]
很多新手不会查这一步,直到做K线可视化时才发现某根K线实体反了。这类问题在免费数据源里并不少见。另外我还习惯检查成交量的极值:股票成交量突然比前一天放大1000倍,多数是除权除息或数据拼接错误,少数是突发重大利好,需要结合公告去判断,不能一刀切当成坏数据。
2.2 时序级检查:交易日历对齐和时间戳修正
批量获取行情时,时间戳和时间序是最容易出问题的地方。A股交易日不是自然日,要避开周末、法定节假日,还有个别年份因为临时休市不整齐。如果你拉下来的数据恰好少了某一天,而这一天不是假期,那就要小心了——这多半是接口限流或数据源漏了数据。
我用交易日历比对法来检查日期序列是否完整:
def check_calendar_gaps(df, trade_calendar):
"""
df: 必须包含 date 列,且为datetime类型
trade_calendar: 一个包含全部交易日期的Series或列表
"""
df = df.copy()
df["date"] = pd.to_datetime(df["date"])
actual = set(pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d"))
start, end = df["date"].min(), df["date"].max()
expect_dates = pd.to_datetime(trade_calendar)
expect_dates = expect_dates[(expect_dates >= start) & (expect_dates <= end)]
expect_set = set(expect_dates.strftime("%Y-%m-%d"))
missing = sorted(expect_set - actual)
extra = sorted(actual - expect_set)
return {"missing_dates": missing, "extra_dates": extra}
timezone这件事也必须重视,尤其做港股和美股时。美股的交易时间默认是美东时间,如果你直接用北京时间存日期,整个序列都会错位一天;A股本身没有时区问题,但接口返回的很可能是带
T
的ISO字符串,处理时必须统一成
pd.to_datetime(..., utc=True)
再转换到Asia/Shanghai时区,不能直接当成北京时间的字符串去切
date
。
一个常见的隐蔽问题是:开盘时间戳被解析成UTC后,转换成北京时间会+8小时,数据源返回的时间点明明没这个问题,却因为某一步转换导致所有K线被挪到下一个交易日,这类错误用肉眼几乎发现不了,只能通过和交易日历对齐来抓。
2.3 业务规则检查:涨跌停、复权与新股上市
行情数据最终要服务于交易判断,所以检查时一定要带入A股市场的价格形成规则。A股主板单日涨跌幅限制是±10%,创业板和科创板是±20%,ST股是±5%,北交所是±30%。检查价格时,买家可以拿“某股票今天涨了15%”当异常信号,但如果它是创业板股票,15%在规则内,完全正常,不能被当成错误数据。
复权处理值得单独说。所谓前复权,是保持最新价格为真实价格,把历史价格按分红扩股比例做缩放,所以历史价格看起来像被“调过”;后复权则相反,把全部历史价格统一到上市首日口径。用前复权数据计算收益率,日常回测没问题,但计算市值、估值分位数时会失真;用后复权数据做横截面比较则比较别扭。不要以为“复权就是选一个模式拉数据”就完事了,同一只股票,不同数据源对除权的基准日不同,跨源拼接前复权数据时极容易出现同一天价格不一样的问题。
2.4 把检查规则沉淀成代码
我发现不少团队的数据检查是“一次性脚本”,今天查几个字段,明天换个脚本查日期,最后检查逻辑散落在各台机器上各自为政,根本没法复用。正确做法是把所有检查规则写成一个
QualityChecker
类,让每次拉取的数据都跑同一个流程。
我习惯把字段检查、逻辑检查、日历检查、业务规则检查分成四个方法,最后统一输出一个带
rule_name
、
failed_rows
、
description
的检查报告。这样无论是人工查看还是后续CI自动跑数据,都是一套标准。检查逻辑沉淀成代码之后,每次拿到新的数据源或新的股票池,只要改一下参数就能复用,省掉大量重复工作。
3. 实操过程:用Python给行情数据做一套体检流水线
3.1 环境准备与数据口径确认
在做检查之前,先把环境和数据口径理清楚。你需要pandas、numpy,如果要做可视化同治,可以再装matplotlib和plotly。安装很简单,
pip install pandas numpy matplotlib
就够了。
数据口径我建议在开始之前就写清楚:你拉的数据周期是什么(日线、分钟线)、复权方式是什么(前复权/后复权/不复权)、时间范围是什么、代码里用了哪个交易日历。这些信息不写下来,等你三天后回来看数据时,大概率会困惑“这批数据的复权基准到底是什么时候”。我在项目里会把这些字段放到数据文件的
attrs
里,或者单独写一个
meta.json
。
3.2 完整体检函数的实现
下面是一套我在实际项目里用的精简版体检函数,适合日线数据。它会依次做:基础字段报告、K线自洽性校验、日期连续性校验、价格跳变预警。
import pandas as pd
import numpy as np
def inspect_daily_quotes(df, trade_calendar=None, limit_pct=10):
"""
对日线行情做一次基础体检
返回 (检查报告DataFrame, 异常明细dict)
"""
df = df.copy()
for col in ["open", "high", "low", "close", "volume"]:
if col not in df.columns:
raise ValueError(f"缺少必要列: {col}")
df["date"] = pd.to_datetime(df["date"])
df = df.sort_values("date").reset_index(drop=True)
abnormal_details = {}
# 1. 基础字段报告
field_report = []
for col in df.columns:
field_report.append({
"column": col,
"null_count": int(df[col].isna().sum()),
"null_pct": round(float(df[col].isna().mean()), 4),
"dtype": str(df[col].dtype),
})
abnormal_details["field_report"] = pd.DataFrame(field_report)
# 2. K线自洽性
logic_bad = df[
(df["high"] < df[["open", "close"]].max(axis=1))
| (df["low"] > df[["open", "close"]].min(axis=1))
| (df["volume"] < 0)
| (df["close"] <= 0)
]
abnormal_details["logic_errors"] = logic_bad[["date", "open", "high", "low", "close", "volume"]]
# 3. 日期连续性
if trade_calendar is not None:
cal = pd.to_datetime(trade_calendar)
cal = cal[(cal >= df["date"].min()) & (cal <= df["date"].max())]
missing_days = sorted(set(cal.strftime("%Y-%m-%d")) - set(df["date"].dt.strftime("%Y-%m-%d")))
abnormal_details["missing_days"] = missing_days
# 4. 价格跳变预警:超过涨跌幅限制就提醒
df["prev_close"] = df["close"].shift(1)
df["pct_change"] = (df["close"] / df["prev_close"] - 1) * 100
jump = df[df["pct_change"].abs() > limit_pct].copy()
abnormal_details["price_jump"] = jump[["date", "prev_close", "open", "high", "low", "close", "pct_change"]]
return abnormal_details
这里有一个重要的注意点:价格跳变检测只是一个“预警”,不能直接判定为脏数据。除权除息日、新股上市第二天、垃圾股疯狂炒作时,价格都可能跳变超过10%。所以我要在
price_jump
里记录跳变日期,再结合公告或除权除息标识来判断。如果数据源不提供除权涨跌加息标识,你就要把“疑似除权日”单独列出来,人工核验。
3.3 输出体检报告,而不是只打印几个 describe() 数字
很多人拿到数据就
df.describe()
看一下平均值、最大值、最小值,觉得数据范围正常就放心了。但
describe()
不会告诉你某一天的数据类型错了,也不会告诉你有两个完全相同的日期行。体检结果一定要汇总成报告,最好是把上面几个函数的结果拼接成一个大的
quality_summary
DataFrame,按检查项和异常行数排序。
我在批量检查几十只股票时,常见做法是每只股票生成一个报告对象,最后把这些对象合并成一张“全局质量总表”。这样做的好处是:几十只股票里如果只有两只有问题,你可以只针对它们重点看,不用每只都打开大盘子慢慢翻。
如果把行情数据向量化之后做模型训练,我还会额外检查“特征矩阵的行数与日期是否对齐”。很多人在向量化时只关注模型准确率,忽略了特征矩阵里每一行到底是哪一天,一旦删除某一行,后续标签和特征就全部错位,这类错误最难查,因为模型能正常训练,只是预测结果完全不对。
3.4 批量获取行情时的分层体检策略
批量获取股票行情时,成千上万只股票不可能每只都人工看。我的做法是把体检分成三层。
第一层是拉数前检查:在请求参数里明确复权方式、时间范围、返回格式,从源头上减少脏数据的产生。第二层是拉数后自动检查:对每只股票跑上面的
inspect_daily_quotes
,把异常行数或异常比例超过阈值的股票筛选出来。第三层是人工抽检:针对自动检查发现的异常股票,打开K线图或者看具体日期附近的数据明细,判断是真实行情还是数据错误。
这套分层策略的初衷很简单:自动工具负责“广撒网”,把可疑对象锁在一个很小的范围内;人工再集中精力看可疑对象,效率最高。不要试图让程序完全替代人的判断,数据质量检查需要领域知识,单纯靠自动化是不行的。
4. 查出问题后的修复策略:哪些能修,哪些只能标记
4.1 缺失值的三种处理方式:填充、删除、标记
数据检查总是会查出问题,关键是怎么处理。我把修复方式分成三种:填充、删除、标记。
填充大多用在停牌导致的缺失上。停牌期间的行情根本没有成交,如果你的后续分析需要连续时间序列,可以按前一天的收盘价填充,让它保持价格不变。但严格来说,停牌期间没有真实交易,填充出来的价格是“虚拟的”,如果用来计算20日收益率,停牌前和停牌后的收益率会被拉得很平缓。所以我会在填充的同时加一个
is_suspended
标记列,让模型知道这段是延拓出来的数据。
删除用于真正的坏样本,比如某天的最高价小于最低价,这种数据没有任何修复价值,直接删除最安全。删除之前务必备份原始数据,不要在原文件上直接改,我会先把原始文件另存到
raw/
目录,清洗后的数据放到
clean/
目录。
标记是使用频率被严重低估的一种方式。对于涨跌停、停牌、除权除息这些情况,数据本身没错,但会干扰后续分析,最适合的处理就是标记而不改动原始值。比如加一列
is_limit_up
、
is_limit_down
、
is_ex_dividend
,后续算特征时可以传入这些标记,让模型自己识别。
4.2 修复数据必须遵守的“红绿灯”规则
修复数据要有一个清晰的“红绿灯”规则,这是我踩过几次坑之后总结出来的。
红色规则是“任何情况下都不应该改动”的,包括除权除息日的真实跳变、涨跌停板无法真实成交的价格、新股上市初期的波动,以及停牌期间虽然无成交但价格仍然存在的情况。这些都是市场真实状态的一部分,改了就是在篡改历史。
黄色规则是“需要结合上下文判断”的,比如整段K线出现一个暴跌20%但成交量仅100股的情况,可能是个股闪蹦,更可能是数据源返回了错价;这时去对比另一数据源,看是否存在相同情况,再决定是否修正。绿色规则是“可以直接修”的,比如缺失值、明显的价格错值(high小于low)、重复行。
这个红绿灯规则最大的价值是防止“用力过猛”。新手拿到数据发现有不少异常,喜欢一次性全填了、全删了,最后得到一份“看起来很干净”的数据,但这份数据已经丢失了大量真实市场信息。
4.3 修复后的二次校验:不能改完就完事
数据清洗不是一次性工作,修复完之后还要跑一遍检查流水线,看看新增了多少缺失、是否引入新的逻辑错误。我处理大批量股票时会保存一份“数据清洗日志”,记录每一行数据:原始值是多少、清洗原因是什么、改成多少、判断依据是什么。这不仅仅是记录,更是为了后续模型问题回溯时能回答“这个特征值为什么是这个数”。
二次校验我一般会做三件事:重新跑一次
inspect_daily_quotes
确认没有新错误,统计清洗前后每只股票的行情行情数量差异,对清洗前后的价格序列做一次可视化抽查。如果清洗后样本量缩减超过2%,我会重新考虑清洗规则,而不是硬砍数据。合理的清洗不应该把大量交易数据洗掉,只应该消除少数明确错误。
5. 常见问题与排查技巧实录
5.1 我在实际项目中踩过的几个坑
先讲一个我做过最惨的坑。某个回测项目里,一只股票连续一周的成交量是0,但收盘价每天都有微小的变动,比如收盘价从10.00变成10.01。按理说没有成交就不应该变价,但数据源在某些异常情况下会根据集合竞价或者零股交易返回一个非零价格。我一开始没注意,等到因子计算时发现每天的收益率几乎全是0,但某一天突然出现一个极端值,检查了很久才发现是这七天里的异常价格导致的。
第二个比较常见的坑是“前复权参数没生效”。我用某个接口拉数据,明明指定了
adjust="qfq"
,但返回的价格仍然是原始价。后来才发现接口的前复权默认基准日是以当前日期算的,等过了一个月再拉同一只股票,历史价格数据会出现细微偏差,原因就是除权基准日变了,前复权的“锚”变了。这件事提醒我:前复权数据在不同时间点拉取的结果可能不同,如果做研究需要可复现,最好用后复权或者自己保存一份带复权因子的原始数据。
第三个坑跟时区有关。我拉了一份某美股的日K线数据,日期是带
T00:00:00Z
的ISO字符串,直接转成
datetime
后当成北京时间,结果每根K线的日期都往前偏了一天。等我把数据跟另一种源对比时才察觉,日期集合完全对不上。
5.2 多数据源交叉验证:给数据质量上双保险
在实际拿数据时,我通常会准备两个数据源做交叉验证,一个作为主数据源,一个作为校对数据源。比对的内容包括同一交易日的收盘价差异、同一时间范围的日期集合差异、成交量差异等。如果差异超过一定阈值,就进入人工复核。
交叉验证的代码不复杂,核心是合并两个数据源后计算收盘价差异比例:
def cross_validate_close_price(df_a, df_b):
merged = pd.merge(
df_a[["date", "close"]],
df_b[["date", "close"]],
on="date",
suffixes=("_a", "_b"),
how="outer",
)
merged["diff_pct"] = (merged["close_a"] / merged["close_b"] - 1) * 100
# 找到只有一方有的日期
only_a = merged[merged["close_b"].isna()]
only_b = merged[merged["close_a"].isna()]
# 找到价格差异超过阈值的行
big_diff = merged[merged["diff_pct"].abs() > 0.05]
return {
"only_a": only_a["date"].tolist(),
"only_b": only_b["date"].tolist(),
"big_diff": big_diff[["date", "close_a", "close_b", "diff_pct"]],
}
我建议把差异阈值设为0.05%,因为不同数据源对四分位数的口径差异很小,超过这个比例基本可以确定有问题。
5.3 高频遇到的十大数据质量问题和处理对照表
根据经验总结一张速查表,方便你在检查时直接对照。
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 收盘价连续多日为0 | 停牌或退市整理期 | 标记,不填充 |
| 某天high < low | 数据源字段错位或解析错误 | 删除该行并备份 |
| 日期有重复 | 两个数据源拼接时未去重 | 按日期去重 |
| 日期缺失 | 接口限流、数据源漏数据 | 与交易日历核对后补拉 |
| 涨跌幅超过10% | 除权除息或创业板/科创板振幅更大 | 先确认是否除权日 |
| 成交量突增1000倍 | i股拆股、除权或数据拼接错误 | 对照公告确认 |
| 价格列是字符串 |
接口异常返回
"--"
或
""
| 转换类型并定位出错行 |
| 除权日价格跳变 | 未做前复权/后复权处理 | 按复权规则调整 |
| 涨停日成交量极低 | 一字板,没有多余成交 | 正常现象,不必修复 |
| 不同源同一天收盘价不一致 | 复权基准日不同或数据源口径差异 | 统一复权方式后重新比对 |
5.4 我给纯新手的一个建议
如果你刚开始用Python获取股票行情,不需要一上来就搭一套复杂的质检平台。先养成两个习惯:第一,每次拉数后都跑一次原始的检查脚本,把异常日期打印出来;第二,所有清洗操作都要留原始备份。等数据量大了、需要系统性处理时,再把检查逻辑抽象成类或函数,形成自己的质检工具包。
我还想多提一句,很多人把“数据量多”等同于“数据质量高”,实际上是两回事。你可以拿到十年股票行情,但如果其中有300天的数据是重复的、错误的时间戳或者未复权状态下的断崖,那还不如只拿质量可靠的一年数据。量化分析的基因,永远建立在一份可靠的输入数据之上。
根据我个人的经验,数据质量检查最容易被低估的原因,是它不像写模型、调参数那样有立刻可见的产出,做的全是“看不见的防御工事”。但等到模型上线、回测收益漂亮却实盘亏损时,大家回头查第一件事永远都是数据。既然这样,不如一开始就把这道工序做扎实。
更多推荐


所有评论(0)