从除权因子到复权价格:量化开发者应该知道什么
### 📌 摘要 > 我在量化回测中踩过一个很隐蔽的坑:策略逻辑完全没改,换了一套历史价格之后,收益曲线却明显发生变化。后来我定位到问题核心并不在策略,而在除权因子和复权价格的处理方式。本文从我实际排查这类问题的过程出发,拆解除权、复权、比例复权和差值复权之间的关系,并用 Python 演示如何获取和验证相关数据。 ## 一、问题场景:我在量化实盘中遇到的真实困境 我第一次认真处理复权问题,是在整理一套中长期选股策略的时候。 当时策略本身非常简单:每天收盘后计算过去一段时间的收益率、波动率和均线,然后按照条件进行选股。 最开始的回测结果看起来非常漂亮。 但我把回测数据和实盘每天保存的行情数据逐日对比时,发现了一件很奇怪的事情: **同一个股票,在某些历史日期上的收益率完全对不上。** 最典型的情况是遇到了分红、送股或者其他除权除息事件。 如果直接使用交易所当天的实际成交价格,股票价格可能会突然出现明显变化。但从投资者实际持仓收益的角度来看,这种价格变化并不一定代表真正的市场损失。 我当时犯的第一个错误,是把“交易价格”和“策略研究价格”混在了一起。 我后来把问题拆成三个数据层: 1. 原始成交价格; 1. 除权因子; 1. 根据因子计算得到的复权价格。 这样一拆,问题就清楚了。 **量化回测真正需要的,并不一定是某一天屏幕上看到的原始价格,而是与策略目标匹配的价格序列。** ## 二、问题根因分析 ### 1. 为什么除权后价格会突然变化 我先用一个极简例子理解这个问题。 假设某股票除权前价格是 100 元,发生某种公司行为之后,市场交易价格调整到 80 元。 如果我的程序简单计算: 100 元 → 80 元 那么程序会认为股票发生了 20% 的下跌。 但如果这 20 元的变化来自分红、送股等公司行为,那么这种简单的价格差并不能直接作为策略收益。 这也是为什么历史价格需要进行复权处理。 ### 2. 我后来把“复权”理解成了一个时间序列变换 复权并不是把历史真实成交价格修改掉。 这一点非常重要。 原始行情依然应该保存,因为它代表当时市场真实交易数据。 复权价格更适合用于: - 收益率计算; - 技术指标计算; - 长周期趋势分析; - 策略回测; - 不同时间区间之间的价格比较。 而原始价格更适合用于: - 成交模拟; - 滑点计算; - 订单价格; - 实盘撮合; - 交易日志核对。 我现在做数据工程时,通常不会让“复权行情”和“原始行情”共享同一个字段。 我宁愿明确区分它们,也不愿意后面排查策略收益时才发现价格已经被悄悄修改。 ### 3. 比例复权和差值复权不是一回事 我以前也犯过一个错误:看到“前复权”和“后复权”之后,认为它们只是调整方向不同。 实际上还需要关注复权方法。 比例复权通过乘法因子调整价格,更适合收益率和比例变化的计算。 差值复权则使用加减法调整价格,在观察绝对价格差异时更有意义。 在 QuantDash 的 K 线接口里,我实际使用过的复权参数包括: - forward:前复权,比例复权; - backward:后复权,比例复权; - forward_additive:前复权,差值复权; - backward_additive:后复权,差值复权; - none:不复权。 我现在做策略研究时,如果主要计算收益率,通常优先使用比例复权。 如果研究的是价格绝对变化,则会额外考虑差值复权。 ## 三、解决方案探索与选型 我最开始没有直接使用统一的数据接口。 当时我尝试过三个方向。 ### 方案一:自己维护复权计算 我的第一反应是:除权因子都有了,自己乘一下不就行了吗? 真正写起来才发现没有这么简单。 首先,我需要维护完整的公司行为历史。 其次,需要保证因子的日期和行情日期准确对应。 再次,还要处理不同数据源之间的字段差异。 最后还要考虑历史数据补发和修订。 这套方案不是不能做,但维护成本非常高。 ### 方案二:直接使用原始行情 第二个方案更简单。 策略统一使用不复权数据,所有问题都交给策略自己解决。 结果很快又遇到了问题。 一个长期趋势策略,如果跨越多个除权除息事件,简单使用原始价格计算长期收益,很容易得到与实际持有收益不一致的结果。 所以我最终放弃了这种方式。 ### 方案三:行情和除权因子同时保留 这是我最后采用的思路。 数据层同时保存: **原始 K 线 + 除权因子 + 复权 K 线** 这样策略研究层可以选择复权价格,而交易执行层仍然使用原始行情。 后来我在 Python 项目中使用 QuantDash 获取 K 线和除权因子,正好可以把这两个数据源放在同一套数据流程里。 ## 四、具体实现:代码实战 ### 1. 获取原始行情和前复权行情 下面是我现在比较常用的一种写法。 ```python import os import sys from quantdash import QuantDash def get_klines(symbol: str, adjust: str): """获取指定股票的历史日K线。""" try: qd = QuantDash() df = qd.klines.get( symbol, period="1d", count=100, adjust=adjust, to_dataframe=True ) if df.empty: raise RuntimeError( f"{symbol} 没有返回数据,请检查代码、网络或 API Key" ) return df except Exception as exc: print(f"获取 {symbol} 行情失败: {exc}", file=sys.stderr) return None def main(): if not os.getenv("QUANTDASH_API_KEY"): print("未检测到 QUANTDASH_API_KEY", file=sys.stderr) sys.exit(1) raw_df = get_klines("600519.SH", "none") forward_df = get_klines("600519.SH", "forward") if raw_df is None or forward_df is None: sys.exit(1) columns = [ "trade_date", "open", "high", "low", "close", "volume" ] print("原始价格:") print(raw_df[columns].tail().to_string(index=False)) print("\n前复权价格:") print(forward_df[columns].tail().to_string(index=False)) if __name__ == "__main__": main() ``` 这段代码有一个我比较看重的地方: **原始行情和复权行情没有覆盖彼此。** 我在数据管道里也是这么做的。 ### 2. 获取除权因子 如果我需要排查某个历史价格为什么发生变化,会直接读取除权因子。 ```python import os import sys from quantdash import QuantDash def main(): if not os.getenv("QUANTDASH_API_KEY"): print("未检测到 QUANTDASH_API_KEY", file=sys.stderr) sys.exit(1) try: qd = QuantDash() df = qd.klines.ex_factors( ["600519.SH"], to_dataframe=True ) if df.empty: print("没有获取到除权因子数据") return columns = [ "symbol", "trade_date", "ex_factor" ] print(df[columns].tail(10).to_string(index=False)) except Exception as exc: print(f"获取除权因子失败: {exc}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ``` 我排查复权问题时,经常从“价格异常的日期”反向去查除权因子。 这个方法比盯着收益曲线猜原因有效得多。 ### 3. 对比不同复权方式 我还会直接把几种价格序列拉出来比较。 ```python import os import sys from quantdash import QuantDash def fetch(qd, symbol, adjust): return qd.klines.get( symbol, period="1d", count=30, adjust=adjust, to_dataframe=True ) def main(): if not os.getenv("QUANTDASH_API_KEY"): print("未检测到 QUANTDASH_API_KEY", file=sys.stderr) sys.exit(1) try: qd = QuantDash() symbol = "600519.SH" methods = [ "none", "forward", "backward", "forward_additive", "backward_additive" ] for method in methods: df = fetch(qd, symbol, method) if df.empty: print(f"{method}: 没有数据") continue last_row = df.iloc[-1] print( f"{method:20s} " f"date={last_row['trade_date']} " f"close={last_row['close']}" ) except Exception as exc: print(f"复权数据比较失败: {exc}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ``` 我建议第一次接入数据时就做一次这样的检查。 因为很多问题不是策略代码错了,而是**策略从一开始拿到的价格定义就不一样**。 ## 五、实践效果与避坑指南 经过几轮数据治理之后,我总结出了三个比较实用的经验。 ### 避坑一:回测价格和交易价格必须分开 这是我认为最重要的一条。 如果策略信号基于前复权价格计算,我不会直接拿这个价格模拟成交。 信号计算和交易执行应该有清晰的数据边界。 ### 避坑二:不要把复权因子当成普通行情字段 我会单独存储除权因子,并保留它对应的交易日期。 因为以后出现收益率异常、指标异常时,除权因子往往就是第一排查对象。 ### 避坑三:不要只测试没有公司行为的股票 如果只拿最近几个月的数据测试,很可能一直发现不了问题。 真正的数据质量测试应该覆盖发生过除权除息的标的。 我现在做行情数据验收时,会专门抽取这类股票做回归测试。 ## 六、总结与思考 我现在越来越觉得,量化开发中的复权问题,本质上不是一个简单的数学问题,而是一个**数据语义问题**。 原始价格回答的是“市场当时交易了多少钱”,复权价格回答的是“为了研究连续收益,我应该如何构造价格序列”。两者都正确,只是用途不同。 我目前在 Python 数据处理中,会把原始行情、除权因子和复权行情分层保存。我使用 QuantDash 获取 K 线及除权因子,更多是为了减少自己维护数据转换逻辑的工作,但最终如何选择价格口径,仍然应该由策略研究目标决定。 ## Q&A ### Q1:回测到底应该使用前复权还是不复权? A:如果策略主要研究历史收益率、趋势和技术指标,我通常会优先考虑比例复权。如果涉及真实成交模拟,则需要保留原始价格,不能简单拿复权价格当成交价。 ### Q2:为什么同一只股票不同数据源的历史价格会不一样? A:首先检查是否使用了相同的复权方式,其次检查除权因子和公司行为数据是否一致。很多所谓“行情数据不一致”,最后都能定位到价格口径不同。