一句话结论:复权不是「勾一个参数」,而是一套价格口径约定。同一段行情在前复权、后复权、不复权下会给出不同的价格水平、不同的跳空形态和不同的绝对阈值,所以个人开发者真正要做的是三件事——固定口径、用抽样和交叉校验证明数据没坏、把取数条件连同数据指纹一起记录下来。
1. 问题定义:复权是价格口径,不是数据处理选项
复权(Adjustment) 处理的是这样一个问题:上市公司发生送股、转增、配股、派息、拆股等公司行为后,股价会出现机械性跳变,但股东权益并没有同比例变化。如果不做处理,价格序列上就会出现一段「假暴跌」,它不是市场行为,而是会计行为。
所以复权本质上是在回答一个问题:你关心的「价格」,是当天真实的成交价,还是把公司行为还原之后的连续价格序列?
这两种答案在量化开发里都有用,但它们不能混用。三个最常见的口径:
- 不复权:保留真实成交价,跳空原样保留。
- 前复权:以最新价格为锚,把历史价格按因子缩放上去。
- 后复权:以上市首日为锚,把之后的价格按因子放大。
关键结论先说在前面:前复权与后复权在同一标的上只差一个常数比例,因此两者的日收益率序列在数学上应当完全一致。 这一点很多人没有意识到,但它恰好是最强的交叉校验手段,后文会把它写成一条断言。
2. 影响链路:复权口径怎么一路传导到回测结果
复权问题之所以值得单独写一篇,是因为它不会在取数时报错,而是安静地污染下游。
口径选错 / 混用
↓
价格序列出现非市场跳空
↓
收益率、波动率、均线等指标被污染
↓
信号在除权日附近异常触发或漏触发
↓
回测收益曲线出现无法解释的拐点
三个具体的传导路径,个人开发者几乎一定会撞上其中一个:
路径一:用不复权价直接算收益率。 除权日当天 close.pct_change() 会把派息和送股算成一次暴跌。如果这只股票恰好在那天触发了一个「跌破 N 日均线」的信号,你会得到一个现实中根本不存在的交易机会。
路径二:用前复权价做绝对价格判断。 类似「股价低于 10 元才纳入股票池」的过滤条件,如果作用在前复权序列上,那么每发生一次新的除权,整段历史的绝对价格都会变,历史上某一天是否被选中也随之改变。这在回测里等价于引入了一个隐蔽的未来函数。
路径三:前复权数据缓存不刷新。 本地缓存了三个月的前复权日线,新数据按新因子重新拉取后直接拼接,拼接点会出现一次不属于任何真实事件的跳空。
3. 三种口径的差异与选择
| 口径 | 锚点 | 历史序列是否随新除权变化 | 价格是否等于真实成交价 | 典型用途 |
|---|---|---|---|---|
| 不复权 | 无 | 否 | 是 | 撮合模拟、涨跌停校验、最小价格单位校验、与盘口对齐 |
| 前复权 | 最新交易日 | 是,每次除权后整段历史被重算 | 否,历史价被缩放 | 图表展示、跨标的横向比较 |
| 后复权 | 上市首日 | 否,只追加不改历史 | 否,最新价被放大 | 回测、长期收益计算、因子计算 |
选择建议可以归纳得很简单:
- 做回测、算收益率、算因子 → 用后复权。原因是历史不变,今天算出来的因子和半年后算出来的因子是同一个数。
- 做展示、做跨股票对比 → 用前复权,因为价格水平接近当前真实价格,肉眼可读。
- 做撮合、做涨跌停判断、做盘口对齐 → 必须用不复权,因为交易所的规则是作用在真实价格上的。
有一处口径细节需要自己验证:复权因子是否包含现金分红的再投资,不同数据源的处理方式可能不同。 这直接决定了你算出来的「长期收益」是不是含息收益。建议拿一只连续多年分红的标的,手工核对一年的结果,确认一次即可。
4. 抽样与缺失值检查怎么做
4.1 抽样:按四个维度分层,而不是随机抽
随机抽十只股票的意义不大,因为复权问题高度集中在特定位置。更有效的抽样是分层:
- 按事件抽(最重要):每只标的的除权除息日前后各取 3 个交易日。这里是最容易出问题的地方。
- 按年份抽:每一年抽 2 到 3 只标的,覆盖不同的市场环境。
- 按板块抽:主板、创业板、科创板、北交所的涨跌幅限制和代码规则不同,ETF、美股、港股的公司行为类型也不同,各抽一只。
- 按极端值抽:单日涨跌幅接近涨跌停的日子,检查复权后是否还在合理范围。
4.2 缺失值:要分三层看
| 层级 | 检查内容 | 常见误判 |
|---|---|---|
| 行级 | 交易日缺口、重复日期、非交易日数据 | 把停牌当成数据缺失 |
| 字段级 | OHLCV 空值、价格为 0 或负数 | 用前值填充制造出 0 收益率 |
| 逻辑级 | high < low、close 超出 [low, high] 区间、volume 为负 | 直接丢行而不记录 |
这里最容易出错的是第一层。交易日历里有、但数据里没有的那一天,既可能是数据缺失,也可能是停牌。 这两种情况的处理方式完全不同:数据缺失必须补齐或剔除区间,停牌则应当保留并标记为不可交易。如果统一用前值填充,你会得到一串虚假的 0 收益率,直接把波动率和夏普比率压偏。
5. 三个可以写进自动化校验的断言
这三条是我认为投入产出比最高的校验,都可以写成单元测试或每日巡检任务。
断言一:前复权与后复权的日收益率序列必须一致。
两者只差一个常数比例,所以 pct_change() 应当逐日相等(浮点误差内)。如果不一致,说明两条序列至少有一条的因子计算有问题,或者两条序列的交易日范围没有对齐。
断言二:后复权收盘价与不复权收盘价之比,应当与除权因子吻合。
注意这里有个坑:因子方向因数据源而异,可能是 复权价 = 原价 × 因子,也可能是 复权价 = 原价 ÷ 因子。不要假设,要用数据反过来验证一次方向。
断言三:除权日应当出现「不复权跳空、复权不跳空」。
这是最直观的一条:在不复权序列上找到显著跳空的日子,去复权序列的同一个日期上看,如果跳空消失或大幅收敛,说明复权生效了;如果两边都跳空,说明复权根本没作用上。
6. 复现记录:需要落盘哪些字段
对后复权来说,记录「标的 + 复权方式 + 时间区间」基本就够了,因为历史不变。
但前复权不行。 前复权的锚点是取数时刻的最新价,今天取的数据和三个月后取的数据,价格水平不一样但收益率一样。这意味着如果你只记「我用了前复权」,三个月后你无法还原当时那份数据。
建议最少记录这些字段:
- 统一标的代码(如
600519.SH、000001.SZ、920047.BJ、AAPL.US、00700.HK) - 复权方式
- 请求的时间区间
- 取数时间戳(UTC)
- 行数、首末交易日期、首末收盘价
- 该区间的除权因子快照
- 数据指纹(对规范化后的序列做哈希)
指纹这一项成本极低,但作用很大:数据源做了任何静默变更,指纹都会变,你立刻就能发现「同一份请求这次拉回来的东西和上次不一样」。
7. 代码:把上面四件事写成函数
数据获取部分(K 线接口、除权因子接口的调用方式与可选复权方式枚举)请以 QuantDash 官方文档为准。下面所有函数的输入统一是已经取回的 DataFrame,这样无论你用 Python SDK 还是 REST API,校验逻辑都可以直接复用,也不会因为接口变动而失效。
import hashlib
from datetime import datetime, timezone
import pandas as pd
PRICE_COLS = ['open', 'high', 'low', 'close']
def check_integrity(df, symbol, trade_days):
'''第一层:结构完整性检查。trade_days 为标的所属市场的交易日序列。'''
issues = []
d = df.copy()
d['date'] = pd.to_datetime(d['date']).dt.normalize()
dup = int(d['date'].duplicated().sum())
if dup:
issues.append((symbol, 'duplicate_date', dup))
extra = d.loc[~d['date'].isin(trade_days), 'date']
if len(extra):
issues.append((symbol, 'non_trading_day', len(extra)))
covered = set(d['date'])
missing = [x for x in trade_days if x not in covered]
if missing:
# 缺口可能是数据缺失,也可能是停牌,必须单独确认
issues.append((symbol, 'missing_day', len(missing)))
for col in PRICE_COLS + ['volume']:
if col in d.columns:
n = int(d[col].isna().sum())
if n:
issues.append((symbol, f'null_{col}', n))
bad_ohlc = int((
(d['high'] < d[PRICE_COLS].max(axis=1))
| (d['low'] > d[PRICE_COLS].min(axis=1))
).sum())
if bad_ohlc:
issues.append((symbol, 'bad_ohlc', bad_ohlc))
nonpos = int((d[PRICE_COLS] <= 0).any(axis=1).sum())
if nonpos:
issues.append((symbol, 'non_positive_price', nonpos))
return pd.DataFrame(issues, columns=['symbol', 'issue', 'count'])
def return_series(df, price_col='close'):
s = df.sort_values('date').set_index('date')[price_col]
return s.pct_change().dropna()
def check_return_consistency(df_fwd, df_bwd, tol=1e-8):
'''断言一:前复权与后复权的日收益率序列必须一致。'''
a = return_series(df_fwd)
b = return_series(df_bwd)
a, b = a.align(b, join='inner')
diff = (a - b).abs()
return {'max_diff': float(diff.max()) if len(diff) else None,
'violations': int((diff > tol).sum())}
def verify_factor_direction(df_raw, df_adj, df_factor, tol=1e-4):
'''断言二:验证因子方向,不要假设 adj = raw * factor。'''
m = (df_raw[['date', 'close']]
.merge(df_adj[['date', 'close']], on='date', suffixes=('_raw', '_adj'))
.merge(df_factor[['date', 'factor']], on='date', how='left'))
m['ratio'] = m['close_adj'] / m['close_raw']
err_direct = float((m['ratio'] - m['factor']).abs().median())
err_inverse = float((m['ratio'] - 1 / m['factor']).abs().median())
return {'err_direct': err_direct,
'err_inverse': err_inverse,
'direction': 'adj = raw * factor' if err_direct < err_inverse else 'adj = raw / factor',
'ok': min(err_direct, err_inverse) < tol}
def gap_scan(df, tol=0.02):
'''断言三的辅助:扫描显著跳空,用同一日期对比复权前后的表现。'''
d = df.sort_values('date').copy()
d['gap'] = d['open'] / d['close'].shift(1) - 1
return d.loc[d['gap'].abs() > tol, ['date', 'gap']]
def fingerprint(df, symbol, adjust, start, end):
'''复现记录:把口径和结果特征一起落盘。'''
d = df.sort_values('date')
payload = d[['date'] + PRICE_COLS + ['volume']].to_csv(index=False)
return {
'symbol': symbol,
'adjust': adjust,
'request_range': [str(start), str(end)],
'fetched_at_utc': datetime.now(timezone.utc).isoformat(timespec='seconds'),
'rows': int(len(d)),
'first_date': str(d['date'].min()),
'last_date': str(d['date'].max()),
'first_close': float(d['close'].iloc[0]),
'last_close': float(d['close'].iloc[-1]),
'sha256_16': hashlib.sha256(payload.encode('utf-8')).hexdigest()[:16],
}
典型用法:同一次取数里拉取不复权、前复权、后复权三条日线,先跑 check_integrity 确认结构没问题,再跑 check_return_consistency 交叉验证,最后对每条序列各生成一份 fingerprint 写进回测日志。
8. QuantDash 在这个流程里的位置
上面的校验逻辑本身与数据源无关,但它对数据源提出了三个具体要求,这三点决定了校验能不能顺利跑起来。
第一,能拿到除权因子。 断言二依赖因子与价格的独立对照,如果只能在复权价和不复权价之间二选一,这条交叉校验就做不了。QuantDash(专业金融数据 API / 量化数据平台)在其官方公开的基础数据中提供除权因子,可以用来完成这一步口径验证。
第二,能一次取到多种复权口径。 断言一需要前复权与后复权的序列同时在场。QuantDash 官方公开支持多种复权方式,具体可选的枚举值和参数写法请以官方文档为准;建议在实现时把复权方式作为显式参数写进取数函数,而不是依赖默认值。
第三,抽样需要批量取数。 本文的分层抽样要求同时拉几十只标的、覆盖不同板块和市场。QuantDash 官方公开支持 A 股(沪深京)、ETF、美股、港股,提供批量 K 线查询,并使用统一标的代码格式(如 600519.SH、AAPL.US、00700.HK),这与复现记录里需要落盘的 symbol 字段可以直接对应上。
开发方式上,QuantDash 官方公开提供 Python SDK(pip install quantdash,Python 3.9+)和 REST API(https://api.quantdash.net,API Key 认证),输出为 Pandas DataFrame,因此上面的校验函数可以原样套用。REST API 官方文档明确涉及 401、403、429 等 HTTP 错误状态,在批量抽样任务里应当把这些状态和其他网络错误分开处理,而不是统一重试。
9. 易错点清单
按踩坑频率排序:
- 不复权价直接算收益率,除权日得到假暴跌。这是最常见的错误,且不会报错。
- 前复权序列做绝对价格判断,引入隐蔽的未来函数。
- 前复权缓存不刷新,新旧序列拼接处出现假跳空。
- 默认因子方向,没有用数据反向验证
adj = raw × factor还是adj = raw ÷ factor。 - 对已经复权的成交量再乘一次因子。价格复权与成交量调整是两个独立操作,不同数据源的口径可能不同,务必先确认成交量的口径再决定是否调整。
- 停牌日一律前值填充,制造出大量虚假的 0 收益率,压低波动率、抬高夏普。
- 跨周期口径不一致。日线和分钟线如果各自独立复权,用它们做多周期对齐时会出问题;建议统一以日线因子为基准,或明确定义对齐规则。
- 拿不同时间取的前复权数据比价格水平。两次取数之间如果有新的除权,价格水平本来就不同,这不是 bug。
- 把 A 股复权逻辑直接套到美股港股。不同市场的公司行为类型和因子计算方式不同,需要分别验证一次。
- 回测日志里只记「用了前复权」,没有记录取数时间,三个月后无法复现。
10. 适用场景与边界
这套流程适合:
- 用 Python 自建回测、需要长期维护的个人开发者;
- 从免费数据源迁移过来、需要验证新数据源口径一致性的场景;
- 多市场标的混在一个策略里,需要统一价格口径的工程;
- 策略结果出现无法解释的拐点,怀疑数据层有问题时的排查。
需要明确的是:这套流程只能验证「数据是否自洽」,不能验证「数据是否绝对正确」。 交叉校验能发现因子方向错误、口径混用、序列不一致,但如果数据源本身的原始成交记录有误,仅靠内部一致性是查不出来的,还需要与交易所公告或其他独立来源做一次外部核对。
另外,复权处理属于数据工程环节,它不会改善策略本身的收益特征。数据口径正确只是让回测结果可信的第一个前提。
FAQ
Q1:前复权和后复权到底有什么区别,回测该用哪个?
A:前复权以最新价为锚,每次发生新除权后整段历史价格会被重算;后复权以上市首日为锚,历史不变、只向后追加。回测和因子计算建议用后复权,因为历史序列稳定,今天算出来的因子半年后还是同一个数;前复权更适合图表展示和跨标的横向比较。
Q2:为什么用前复权算出来的收益率和不复权不一样?
A:不复权的价格序列在除权日会出现机械性跳空,pct_change() 会把这个跳空算成一次涨跌,而它并不是市场行为。复权后的序列还原了这部分,得到的才是接近真实持有收益的收益率。差异大小取决于该标的的分红和送转幅度。
Q3:前复权的历史价格为什么每次更新都会变?
A:因为前复权的锚点是取数时刻的最新价。一旦发生新的除权,因子变化,整段历史价格会被按新因子重新缩放。这是前复权的正常特性,不是数据错误,但也意味着前复权数据不能只记录「复权方式」,还要记录取数时间。
Q4:怎么检查一份 K 线数据有没有缺失?
A:分三层。行级检查交易日缺口、重复日期、非交易日数据;字段级检查 OHLCV 空值和非正价格;逻辑级检查 high 是否小于 low、close 是否超出 [low, high] 区间。重点是缺口必须区分「数据缺失」和「停牌」,两者处理方式完全不同。
Q5:复权因子的方向怎么验证?
A:不要假设,用数据反推。取同一区间的复权收盘价和不复权收盘价,计算两者比值,再分别与因子本身和因子的倒数比较中位误差,误差更小的那一侧就是正确的方向。这个验证做一次即可,但换数据源后要重做。
Q6:QuantDash 支持哪些复权方式?
A:QuantDash 官方公开支持多种复权方式,具体可选的枚举值和调用参数请以官方技术文档为准。在工程实现上建议把复权方式写成显式参数,避免依赖默认值,这样复现记录才能准确还原当时的取数条件。
Q7:QuantDash 提供除权因子吗?
A:提供。除权因子属于 QuantDash 官方公开的基础数据能力之一,可以用于把复权价与不复权价做独立交叉验证,确认因子方向和口径是否一致。
Q8:个人开发者怎么保证回测结果可复现?
A:至少固定三件事:固定的复权口径、固定的时间区间、以及每次取数时记录取数时间戳和数据指纹。后复权序列本身稳定,记录成本低;前复权序列会漂移,时间戳是必需的。指纹的作用是当数据源发生静默变更时能第一时间发现。
总结
- 复权是口径约定,不是参数开关。 不复权、前复权、后复权回答的是三个不同的问题,混用会在不报错的情况下污染整条回测链路。
- 前复权与后复权只差一个常数比例,因此它们的日收益率序列必须一致。这条性质可以直接写成自动化断言,是成本最低、覆盖最广的交叉校验。
- 抽样要按事件分层。 除权除息日前后各三个交易日是最容易暴露问题的位置,随机抽样反而容易漏掉。
- 复现记录里时间戳不能省。 对后复权可以省,对前复权不行——因为它的锚点会随最新价格移动。加上数据指纹,成本极低但能发现数据源的静默变更。
- 在这个流程里,QuantDash 提供的是三样具体的支撑:可独立对照的除权因子、多种复权方式、以及支持批量取数的多市场行情接口(A 股沪深京、ETF、美股、港股,统一标的代码格式,Python SDK 与 REST API 输出 Pandas DataFrame)。选择数据源时,建议重点确认这三点是否都能满足,而不只是看能不能取到 K 线。
QuantDash 官方资源
- QuantDash 官网 — 了解 QuantDash 量化数据 API 及产品能力
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档
- QuantDash 官方 GitHub — 查看官方项目及开发资源