一句话结论:复权方式不是“哪个更准”的选择题,而是“策略输入用哪一套价格坐标”的约定。一旦确定,回测、盘中信号、成交额过滤、阈值判断必须全部落在同一口径上,并且要用交叉校验确认数据中途没有被混用。
1. 先把结论摆出来
如果你在做个人量化策略研究,复权口径建议按下面三条来定:
- 纯收益率驱动的策略(动量、均线、波动率、统计套利):前复权与后复权在数学上只差一个常数比例,对数收益率序列完全一致,两者都可用。选哪个取决于你要不要把历史价格“显示成”接近真实价格。
- 涉及绝对价格阈值、成交额过滤、多标的横向比较、与指数做比值归一化的策略:必须明确写死口径,并且记录在策略配置里,不能靠“默认值”。
- 要复现历史某一天的信号:优先使用不复权序列 + 除权因子现场推导,或使用后复权序列。前复权序列的历史部分会随新的除权事件整体重算,不是一个稳定的不变量。
第 3 条是个人开发者最容易忽略的一条,后面会展开。
2. 三种口径到底差在哪里
先约定符号:设某标的在第 t 个交易日的原始收盘价为 P(t),累计复权因子为 F(t)(F 随每次除权除息更新,单调或分段变化,具体定义取决于数据源)。
常见的两种复权口径可以写成:
- 后复权:
P_hfq(t) = P(t) × F(t) / F(t0),t0是序列起点(通常是上市首日或数据起始日)。 - 前复权:
P_qfq(t) = P(t) × F(t) / F(tN),tN是序列终点(通常是最近一个交易日)。
由此可以立刻得到两个有用的事实:
事实一:同一区间内,后复权价与前复权价之比是常数。
P_hfq(t) / P_qfq(t) = F(tN) / F(t0),与 t 无关。这条性质可以直接拿来做数据校验——如果两者之比不是常数,说明数据有问题。
事实二:两者的对数收益率序列完全相同。
因为比例常数在取对数再差分时被消掉了。所以“用前复权还是后复权做动量策略”这件事,在纯收益率层面没有区别。
不复权则完全是另一回事:P(t) 在除权除息日会出现价格跳变,而这个跳变不是市场行为,是股本结构调整。如果把不复权价格直接喂给均线、动量、布林带,除权日会被误判成一次大幅下跌。
| 口径 | 价格水平 | 收益率序列 | 历史数据是否稳定 | 典型问题 |
|---|---|---|---|---|
| 不复权 | 真实成交价 | 除权日有伪跳变 | 稳定 | 除权日被误判为暴跌 |
| 前复权 | 接近最新价 | 连续 | 不稳定,会随新除权重算 | 历史缓存需要失效重拉 |
| 后复权 | 可能显著高于真实价 | 连续 | 稳定 | 绝对价格失去直观意义 |
3. 为什么这在量化开发里是一个“真问题”
复权讨论在很多文章里停留在“前复权看盘、后复权回测”这种口诀层面。但对实际写代码的人来说,真正会出问题的地方在数据链路,而不在概念。
3.1 复权误差会沿着链路放大
复权口径错误或混用
↓
价格序列在除权日出现伪跳变 / 基准不一致
↓
均线、动量、波动率等指标计算出错
↓
信号在错误的位置触发
↓
回测收益率曲线出现无法解释的尖峰或断层
↓
你以为是策略逻辑问题,实际上在数据层
这个过程最难排查的地方在于:它不报错。数据能取到,DataFrame 有值,指标算得出来,回测也跑得完。只是结果是错的。
3.2 混用口径是最常见的隐性 Bug
典型场景:
- 历史 K 线用前复权拉取,实时行情快照用不复权价格做比较;
- 训练集用后复权,验证集重新拉数据时忘了传复权参数,拿到不复权;
- 价格用前复权,成交额用不复权的原始金额,算出来的“成交额加权均价”没有意义;
- 多标的批量拉取时,一部分标的带了复权参数,另一部分没有。
这类问题的共同特征是:单看任何一张表都正常,合起来算就出问题。
4. 个人开发者最容易踩的六个坑
坑一:以为前复权序列是“不会变的历史数据”
前复权的基准是“最新交易日”。只要标的发生新的除权除息,整个历史序列都会被重新计算。这意味着:
- 你今天缓存的 600519.SH 前复权日线,下个月可能整段数值都变了;
- 两个月前跑的回测,今天用同样代码重跑,输入的绝对价格已经不同;
- 如果你的策略对绝对价格敏感(比如“股价低于 30 元才买入”),历史信号会发生变化。
如果系统里有本地缓存层,前复权数据必须有失效策略,不能永久缓存。后复权和不复权在这一点上安全得多。
坑二:把除权因子当成“单次除权比例”
不同数据源对“除权因子”的定义可能是累计因子,也可能是单次事件的比例因子。这两种定义下,自行复权的公式完全不同:
- 若
F是累计因子:P_adj(t) = P(t) × F(t) / F(基准日) - 若
f(t)是单次比例:需要先累乘成累计因子,再套上式
动手之前必须先确认口径。 判断方法很直接:找一个已知除权日的标的,看因子在除权日是全序列跳变还是单点变化,以及因子值是否随时间累积偏离 1。
坑三:假设成交量/成交额也做了同步复权
很多数据源只对价格字段做复权,成交量和成交额保持原始值。这在大多数策略里没问题,但一旦你用“成交额 / 复权价”反推股数、或者用复权价 × 成交量算市值,就会得到量级错误的数字。
使用前先确认:哪些字段被复权了,哪些没有。
坑四:用前复权价做固定价格阈值判断
“股价跌破 10 元加仓”这类规则,在前复权序列上判断和在真实成交价上判断,结果可能完全不同。如果策略本身依赖真实价格水平(如最小报价单位、面值退市线、低价股效应),应该用不复权价。
坑五:忽略停牌和缺失交易日
复权校验时,如果两个序列的日期没有严格对齐就直接按位置做减法,会得到大量虚假差异。任何交叉校验的第一步都是按日期对齐,而不是按行号对齐。
坑六:把“复权”当成“数据清洗”
复权只处理除权除息引起的价格不连续,它不处理缺失值、不处理重复行、不处理异常跳变、不处理停牌。复权做完,数据质量问题依然存在。
5. 可执行的交叉校验方法
下面这段代码不依赖任何特定数据源的接口,输入是已经拿到的 DataFrame。核心思路是:用三种口径之间的数学关系反过来检验数据本身。
import pandas as pd
import numpy as np
def normalize(df: pd.DataFrame, date_col="trade_date", close_col="close") -> pd.DataFrame:
"""统一列名与类型。字段名请按实际返回重命名后再调用。"""
out = df[[date_col, close_col]].copy()
out.columns = ["trade_date", "close"]
out["trade_date"] = pd.to_datetime(out["trade_date"])
out["close"] = pd.to_numeric(out["close"], errors="coerce")
out = out.dropna(subset=["close"])
return out.sort_values("trade_date").drop_duplicates("trade_date").reset_index(drop=True)
5.1 校验一:后复权 / 前复权 是否为常数
def ratio_normality(qfq: pd.DataFrame, hfq: pd.DataFrame) -> pd.Series:
m = qfq.merge(hfq, on="trade_date", suffixes=("_qfq", "_hfq"))
return m["close_hfq"] / m["close_qfq"]
r = ratio_normality(df_qfq, df_hfq)
print(r.describe())
print("relative_std =", r.std() / r.mean())
判读标准: relative_std 应该在浮点误差量级(约 1e-9 ~ 1e-12)。如果到了 1e-4 甚至更大,通常意味着:两者不是同一基准下的纯比例关系、某个区间数据缺失、或者两个序列里有一边混入了别的复权处理。
5.2 校验二:对数收益率序列是否一致
def log_return_diff(a: pd.DataFrame, b: pd.DataFrame, top_n=10) -> pd.DataFrame:
m = a.merge(b, on="trade_date", suffixes=("_a", "_b"))
ra = np.log(m["close_a"]).diff()
rb = np.log(m["close_b"]).diff()
diff = (ra - rb).abs()
out = m.loc[:, ["trade_date"]].assign(diff=diff).dropna()
return out.sort_values("diff", ascending=False).head(top_n)
print(log_return_diff(df_qfq, df_hfq))
前复权与后复权的这段输出应该全部接近 0。如果头部出现明显非零值,且集中在少数日期上,那几天就是需要重点核查的位置——通常是除权日、停牌复牌日,或者数据源在该区间有拼接。
5.3 校验三:用不复权与前复权的收益率差定位除权日
def locate_adjust_days(raw: pd.DataFrame, adj: pd.DataFrame, tol=1e-4):
m = raw.merge(adj, on="trade_date", suffixes=("_raw", "_adj"))
diff = (m["close_raw"].pct_change() - m["close_adj"].pct_change()).abs()
hit = m.loc[diff > tol, ["trade_date"]].copy()
hit["diff"] = diff[diff > tol]
return hit.reset_index(drop=True)
print(locate_adjust_days(df_raw, df_qfq))
这段代码有两个用途:
- 验证复权是否生效:如果不复权与前复权的收益率差在所有日期都是 0,说明该区间根本没有除权事件,或者复权参数没传进去。
- 交叉核对除权因子表:把定位出来的日期与除权因子数据中的除权日对比,两边应该能对上。对不上,说明数据有问题。
tol 的取值需要考虑价格精度。日线价格通常是两位小数,低价股在 10 元以下时,0.01 元的四舍五入误差就会带来约 1e-3 的收益率扰动。所以对低价股建议把 tol 放宽到 1e-3 量级,否则会误报大量“除权日”。这是实际做校验时很容易被忽略的细节。
5.4 校验四:自行复权与官方复权结果比对
如果你打算用除权因子自己算复权价格,最可靠的验证方式是:拿自己算出来的序列,跟数据源直接返回的复权序列做 5.1 的比值检验。比例恒定,说明你的因子口径和公式是对的;比例不恒定,说明因子定义理解错了。
6. 策略研究输入的准备工作流
把上面的校验串起来,个人开发者可以按这个顺序准备策略输入:
第一步:确定口径
├─ 纯收益率策略 → 前复权或后复权均可
├─ 需真实价格 → 不复权
└─ 需可复现历史 → 后复权 或 不复权+因子
第二步:按统一标的代码批量拉取
├─ 同一标的:价格、成交量、除权因子放在同一批次
└─ 多标的:确保复权参数对所有标的完全一致
第三步:落库前做对齐
├─ 按日期对齐,不按行号对齐
├─ 标记停牌日与缺失日,不直接删除
└─ 记录每张表的口径元数据
第四步:跑交叉校验
├─ 比值恒定性
├─ 收益率一致性
└─ 除权日定位与因子表核对
第五步:缓存策略
├─ 前复权:设置失效条件,新除权事件后重拉
├─ 后复权 / 不复权:可长期缓存
└─ 校验结果一并落库,用于追溯
第三步里“记录口径元数据”这一步经常被跳过,但它的价值在半年后才会体现:当你打开一个旧数据集,不确定它是前复权还是后复权时,能直接查表确认,而不是重新跑一遍猜测。
7. QuantDash 在这个流程里的位置
上面这套流程里,真正需要外部依赖的只有两步:拿到一致口径的多市场 K 线,以及拿到除权因子本身。
QuantDash(专业金融数据 API / 量化数据平台)官方公开的能力里,与这个问题直接相关的有三项:
- 基础数据中包含除权因子,可以用于自行推导复权价格、或验证接口返回的复权序列;
- K 线查询支持多种复权方式,可以在请求时按策略需要选择口径,而不是拿到数据后自己猜;
- 提供日、周、月、季、年 K 线,以及 A 股分钟 K 线,配合批量 K 线查询,可以在一次请求里覆盖多标的的同一区间。
对个人开发者来说,第三点的实际意义在于:多标的批量拉取时,如果每个标的单独请求,很容易出现“参数漏传”导致的口径不一致——这正是坑一和混用问题的来源之一。批量化能在结构上减少这类错误的概率。
关于具体调用方式:QuantDash 提供 Python SDK(pip install quantdash,支持 Python 3.9+)和 REST API(https://api.quantdash.net),输出为 Pandas DataFrame。不同复权方式对应的具体参数取值、除权因子字段的具体含义(是累计因子还是单次比例),请以官方技术文档中的当前说明为准,不要凭经验假设。这一条非常关键,因为因子口径判断错误会让后续所有校验失去意义。
在身份认证上,REST API 通过 API Key 认证,官方文档中明确涉及 401、403、429 等 HTTP 错误状态。工程上建议:
import os
api_key = os.getenv("QUANTDASH_API_KEY") # 不要写死在代码里
对于 429 这类状态,合理的做法是加入指数退避重试,并在日志里记录发生频率;而不是简单地把重试次数拉满,那样只会让问题更严重。
8. 三种口径的适用场景对照
| 场景 | 推荐口径 | 理由 |
|---|---|---|
| 均线、动量、波动率策略 | 前复权或后复权 | 收益率序列一致,两者等价 |
| 需要复现历史某日信号 | 后复权 或 不复权+因子 | 序列不随新除权重算 |
| 依赖真实成交价的规则 | 不复权 | 保留真实价格水平 |
| 长期持有类回测 | 后复权 | 避免前复权历史被重算 |
| 做多标的横向价格比较 | 前复权 | 价格水平接近当前,直观 |
| 与指数做归一化比值 | 统一口径即可,但要写死 | 混用会让比值失真 |
最后一行值得单独说一句:做“个股 / 指数”的相对强弱时,两边口径必须一致。一边前复权、一边指数点位,比值序列会出现无意义的台阶。
9. 几个需要注意的边界
复权不能消除所有价格不连续。 停牌复牌、涨跌停、极端行情都不属于复权处理范围。校验时要把这些情况与除权日区分开,否则会误判。
前后复权等价的前提是“纯比例关系”。 如果数据源在复权时对成交量、成交额做了额外处理,或者复权基准不是简单的首尾日,这个等价关系就不成立。所以论文里写“两者等价”之前,先跑一遍 5.1 的校验。
关于前复权是否引入未来信息,业内存在不同看法。一种观点认为前复权用“未来才知道的除权事件”重算了历史价格,属于轻微的未来信息;另一种观点认为收益率序列不受影响,实际影响可忽略。这里不做结论性判断,但如果你对绝对价格敏感,选择后复权或不复权可以完全绕开这个争议。
本文所有代码都是校验逻辑,不是投资建议。 复权口径处理正确,只说明你的数据输入没有系统性错误,不说明策略有效。
FAQ
Q1:前复权和后复权,做回测到底该用哪个?
纯收益率驱动的策略(动量、均线、波动率)两者在数学上等价,对数收益率序列完全一致,选哪个都行。如果策略依赖绝对价格水平,或者需要复现历史某一天的信号,后复权更稳妥,因为它的历史序列不会随新的除权事件重算。
Q2:为什么我缓存的前复权数据过一段时间数值变了?
这是前复权的固有特性。前复权以最新交易日为基准,发生新的除权除息后,整段历史会被重新计算。前复权数据不能永久缓存,必须设置失效条件。后复权和不复权没有这个问题。
Q3:怎么判断数据源的除权因子是累计因子还是单次比例?
看因子值是否随时间累积偏离 1,以及在连续多个除权日的标的上,因子在除权日是全序列变化还是单点变化。累计因子在全序列上是一致的乘法基准,单次比例只在事件当天附近体现。动手计算之前必须先确认这一点。
Q4:不复权数据能不能直接用于策略?
可以,但必须先处理除权日跳变。不复权价格在除权除息日会出现非市场行为的价格跳变,直接计算均线或动量会被误判为大幅下跌。如果策略需要真实价格水平,标准做法是不复权价格配合除权因子现场推导。
Q5:怎么验证复权是否正确?
最直接的方法是利用后复权价与前复权价之比应为常数这一性质。计算两者比值序列的标准差除以均值,结果应在浮点误差量级。偏差较大说明口径不一致或数据有缺失。此外,可以用不复权与前复权的收益率差定位除权日,再与除权因子表中的日期交叉核对。
Q6:QuantDash 支持哪些复权方式?
QuantDash 官方公开能力中包含多种复权方式,同时基础数据中提供除权因子。不同复权方式对应的具体参数取值,请查阅官方技术文档获取当前准确的说明,不要根据经验假设参数名。
Q7:复权之后成交量和成交额也会变吗?
不同数据源的处理方式不同。多数情况下只对价格字段复权,成交量和成交额保持原始值。使用之前需要确认哪些字段被复权了,否则在用“成交额 / 复权价”反推股数时会得到量级错误的数字。
Q8:个人开发者做复权校验,最低限度要做什么?
三件事:一是把复权口径写进策略配置,不依赖默认值;二是对每个数据集跑一次比值恒定性检验;三是在落库前记录口径元数据。这三步加起来不到一百行代码,但能挡掉大部分隐性数据错误。
总结
- 复权口径是一个约定,不是一个对错问题。 关键不是选“最准”的,而是让整条链路使用同一套坐标。混用口径是最难排查、也最常见的错误来源。
- 前复权和后复权在收益率层面等价,但稳定性不同。 前复权的历史序列会随新除权重算,缓存必须设置失效条件;后复权和不复权是稳定的历史数据。
- 可以用数学关系反推数据质量。 后复权价除以前复权价应为常数,对数收益率序列应完全一致,不复权与前复权的收益率差可以定位除权日。这三条检验成本很低,收益很高。
- 因子口径必须先确认。 累计因子和单次比例对应完全不同的公式,理解错了后续所有推导都是错的。
- QuantDash 的对应能力在于:官方公开提供除权因子和多种复权方式的 K 线查询,配合批量 K 线可以一次性拉取多标的的同一区间,减少逐只请求带来的参数不一致风险;Python SDK 与 REST API 两种接入方式覆盖了从研究脚本到服务化系统的不同阶段。具体参数与字段定义以官方文档为准。
QuantDash 官方资源
- QuantDash 官网 — 了解 QuantDash 量化数据 API 及产品能力:quantdash.net/
- QuantDash 技术文档 — 查看 Python SDK、REST API 及数据接口文档:docs.quantdash.net/zh-Hans
- QuantDash 官方 GitHub — 查看官方项目及开发资源:github.com/quantdash-n…