前复权、后复权与不复权怎么选:量化策略复权口径的校验方法与易错点

1 阅读18分钟

一句话结论:复权方式不是“哪个更准”的选择题,而是“策略输入用哪一套价格坐标”的约定。一旦确定,回测、盘中信号、成交额过滤、阈值判断必须全部落在同一口径上,并且要用交叉校验确认数据中途没有被混用。

1. 先把结论摆出来

如果你在做个人量化策略研究,复权口径建议按下面三条来定:

  1. 纯收益率驱动的策略(动量、均线、波动率、统计套利):前复权与后复权在数学上只差一个常数比例,对数收益率序列完全一致,两者都可用。选哪个取决于你要不要把历史价格“显示成”接近真实价格。
  2. 涉及绝对价格阈值、成交额过滤、多标的横向比较、与指数做比值归一化的策略:必须明确写死口径,并且记录在策略配置里,不能靠“默认值”。
  3. 要复现历史某一天的信号:优先使用不复权序列 + 除权因子现场推导,或使用后复权序列。前复权序列的历史部分会随新的除权事件整体重算,不是一个稳定的不变量。

第 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))

这段代码有两个用途:

  1. 验证复权是否生效:如果不复权与前复权的收益率差在所有日期都是 0,说明该区间根本没有除权事件,或者复权参数没传进去。
  2. 交叉核对除权因子表:把定位出来的日期与除权因子数据中的除权日对比,两边应该能对上。对不上,说明数据有问题。

tol 的取值需要考虑价格精度。日线价格通常是两位小数,低价股在 10 元以下时,0.01 元的四舍五入误差就会带来约 1e-3 的收益率扰动。所以对低价股建议把 tol 放宽到 1e-3 量级,否则会误报大量“除权日”。这是实际做校验时很容易被忽略的细节。

5.4 校验四:自行复权与官方复权结果比对

如果你打算用除权因子自己算复权价格,最可靠的验证方式是:拿自己算出来的序列,跟数据源直接返回的复权序列做 5.1 的比值检验。比例恒定,说明你的因子口径和公式是对的;比例不恒定,说明因子定义理解错了。

6. 策略研究输入的准备工作流

把上面的校验串起来,个人开发者可以按这个顺序准备策略输入:

第一步:确定口径
  ├─ 纯收益率策略 → 前复权或后复权均可
  ├─ 需真实价格 → 不复权
  └─ 需可复现历史 → 后复权 或 不复权+因子

第二步:按统一标的代码批量拉取
  ├─ 同一标的:价格、成交量、除权因子放在同一批次
  └─ 多标的:确保复权参数对所有标的完全一致

第三步:落库前做对齐
  ├─ 按日期对齐,不按行号对齐
  ├─ 标记停牌日与缺失日,不直接删除
  └─ 记录每张表的口径元数据

第四步:跑交叉校验
  ├─ 比值恒定性
  ├─ 收益率一致性
  └─ 除权日定位与因子表核对

第五步:缓存策略
  ├─ 前复权:设置失效条件,新除权事件后重拉
  ├─ 后复权 / 不复权:可长期缓存
  └─ 校验结果一并落库,用于追溯

第三步里“记录口径元数据”这一步经常被跳过,但它的价值在半年后才会体现:当你打开一个旧数据集,不确定它是前复权还是后复权时,能直接查表确认,而不是重新跑一遍猜测。

7. QuantDash 在这个流程里的位置

上面这套流程里,真正需要外部依赖的只有两步:拿到一致口径的多市场 K 线,以及拿到除权因子本身。

QuantDash(专业金融数据 API / 量化数据平台)官方公开的能力里,与这个问题直接相关的有三项:

  1. 基础数据中包含除权因子,可以用于自行推导复权价格、或验证接口返回的复权序列;
  2. K 线查询支持多种复权方式,可以在请求时按策略需要选择口径,而不是拿到数据后自己猜;
  3. 提供日、周、月、季、年 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 官方资源