Python量化分析:手把手教你计算股票前复权与后复权价格

1. 项目概述:为什么我们需要自己动手算复权价?

做量化分析或者股票数据回测的朋友,肯定都遇到过“复权”这个概念。无论是从同花顺、东方财富这类软件导出数据,还是用Tushare、AkShare等开源接口获取历史行情,你都会面临一个选择:前复权还是后复权?很多新手会直接使用数据源提供的复权价格,觉得这是“标准答案”。但如果你真的想构建一个严谨、可回溯、逻辑自洽的回测系统,我强烈建议你掌握自己计算复权价的能力。

为什么?因为不同数据源对复权因子的计算逻辑、对分红送股事件的处理细节可能存在差异。直接使用“黑盒”数据,一旦回测结果出现偏差,你很难定位问题是出在策略本身,还是数据源头。自己计算,意味着你对数据的每一个调整都了如指掌,回测的置信度会大大提高。这次,我们就用Python数据分析的利器——pandas,从最原始的除权除息数据出发,手把手推导出前复权价和后复权价。这不是一个简单的函数调用,而是一次对金融数据处理核心逻辑的彻底解构。

2. 核心概念辨析:前复权与后复权到底在做什么?

在动手写代码之前,我们必须把概念掰扯清楚。很多人知道复权是为了消除分红、送股、配股等事件对股价造成的断裂,使历史价格序列具有连续可比性。但“前”和“后”的区别,才是关键。

2.1 后复权:站在过去看现在

后复权的核心思想是:保持上市第一天的价格不变,将之后所有的分红、送股等事件,都“权息”叠加到最新的价格上。

  • 计算方向:从历史到未来(从前往后)进行复权。
  • 价格特征:历史价格低,最新价格高。它反映了如果从上市第一天持有至今,且将所有分红进行再投资,股价理论上会达到的水平。
  • 核心用途观察长期绝对涨幅。后复权价能最真实地反映一只股票自上市以来的累计收益率。你看那些“十年十倍股”的K线图,通常用的就是后复权,这样才能看到股价从几块钱涨到几百块的壮观景象。

2.2 前复权:站在现在看过去

前复权的核心思想是:保持最新交易日(或某个基准日)的价格不变,将历史上所有的分红、送股等事件,都“权息”折算到过去的价格上。

  • 计算方向:从未来到历史(从后往前)进行复权。
  • 价格特征:历史价格被调整,最新价格与当前实际价格一致。它让历史上的每一个价格点,都相当于以“今天的资本结构”来看待。
  • 核心用途技术分析。这是最常用的复权方式。因为K线形态、均线系统、技术指标(如MACD、KDJ)的计算都需要连续的价格序列。前复权保证了最近的价格是真实的,同时历史价格也经过了平滑处理,使得均线等指标具有实际分析意义。你在交易软件上默认看到的K线,基本都是前复权。

注意:这里说的“最新价格一致”通常指与“不复权”的最新价一致。实际上,前复权的最新价就是除权除息日经过调整后的开盘参考价。

2.3 不复权价:最原始的面貌

不复权价就是交易所公布的原始成交价格。在发生除权除息时,股价会有一个向下的跳空缺口。这个缺口使得价格序列不连续,无法直接用于计算涨跌幅或技术指标。

为了更直观地理解,我们假设一只股票发生了一次送股事件:

日期事件不复权收盘价前复权收盘价后复权收盘价说明
2023-05-18股权登记日20.00(待计算)(待计算)送股前最后一个交易日
2023-05-19除权日 (10送5)13.3320.0020.00理论除权价:20 / (1+0.5) ≈ 13.33
2023-05-18(回溯)20.0020.0020.00以这一天为基准点

从上表可以直观看到:

  • 不复权价:在5月19日从20元“断裂”到13.33元。
  • 前复权价:保持5月19日的13.33元不变,将5月18日的价格“还原”为20元,这样看历史,K线是连续的。
  • 后复权价:保持5月18日的20元不变,将5月19日的价格“推导”为20元,这样看现在,股价仿佛没变(但实际上股本已扩大)。

3. 数据准备:获取与理解核心字段

自己计算复权价,起点不是复权价格,而是除权除息事件表。这是整个计算的基石。通常,你可以从数据商、开源接口或财经网站获取到类似下面的DataFrame,我们命名为df_events

import pandas as pd import numpy as np # 示例:除权除息事件数据 (虚构,用于演示) data = { 'trade_date': ['2023-05-19', '2023-08-17', '2023-11-23', '2024-03-15'], 'cash_div': [0.5, 0.0, 0.3, 0.0], # 每股现金分红(元) 'share_split_ratio': [0.5, 0.0, 0.0, 0.2], # 送转股比例,如10送5则为0.5 'allotment_ratio': [0.0, 0.1, 0.0, 0.0], # 配股比例 'allotment_price': [0.0, 10.0, 0.0, 0.0], # 配股价(元) } df_events = pd.DataFrame(data) df_events['trade_date'] = pd.to_datetime(df_events['trade_date']) df_events = df_events.set_index('trade_date').sort_index() print(df_events)

输出可能类似于:

cash_div share_split_ratio allotment_ratio allotment_price trade_date 2023-05-19 0.5 0.5 0.0 0.0 2023-08-17 0.0 0.0 0.1 10.0 2023-11-23 0.3 0.0 0.0 0.0 2024-03-15 0.0 0.2 0.0 0.0

同时,你需要一份不复权的日线行情数据,我们命名为df_daily,至少包含close(收盘价)字段。

# 示例:不复权日线行情数据 (虚构,用于演示) dates = pd.date_range(start='2023-01-01', end='2024-04-01', freq='B') # 交易日历 np.random.seed(42) # 生成一个模拟的上涨趋势价格 base_price = 20.0 trend = np.linspace(0, 0.5, len(dates)) noise = np.random.randn(len(dates)) * 0.5 prices = base_price * (1 + trend + noise) df_daily = pd.DataFrame({'close': prices}, index=dates) print(df_daily.head())

关键字段解读与注意事项:

  1. 现金分红 (cash_div):直接作用于股价。除息日,股价会向下调整该数值。
  2. 送转股比例 (share_split_ratio):比如“10送5”或“10转增5”,比例就是0.5。它导致总股本增加,股价按比例下调。
  3. 配股比例与价格 (allotment_ratio,allotment_price):这是最易出错的部分。配股是向老股东按比例以低于市价的价格发行新股。它不仅改变股本,还涉及股东追加资金,因此复权因子的计算比送股更复杂。
  4. 事件日期 (trade_date):必须是除权除息日,即股权登记日的下一个交易日。所有调整都从这个交易日开始生效。

实操心得:数据质量决定一切。务必仔细核对你的df_events。常见的坑有:把预案公告日当成除权日、漏掉了小额分红、送转股比例填写错误(如10送5填成5)。建议用多源数据交叉验证,或选取几只知名股票,用你的计算结果与主流软件(如同花顺)进行比对,这是验证逻辑正确性的最直接方法。

4. 核心计算:复权因子的推导与计算

理解了概念和数据,现在进入最核心的部分——计算复权因子。复权因子是一个累积乘数,价格乘以它就能得到复权价。它是连接不复权价与复权价的桥梁。

4.1 计算每日复权因子

复权因子的计算是逐事件进行的。对于每一个除权除息事件,我们计算一个当日调整因子,然后将其乘到历史的累积因子上。

单次事件调整因子的计算公式如下:

调整因子 = (前日收盘价 - 每股现金分红 + 配股比例 * 配股价) / (前日收盘价 * (1 + 送转股比例 + 配股比例))

公式拆解与逻辑:

  • 分子:代表了除权除息后,股东权益的理论总值。
    • 前日收盘价:股权登记日的市值基准。
    • - 每股现金分红:除息,现金被分走,市值减少。
    • + 配股比例 * 配股价:配股时,股东需要额外出资,这部分资金增加了公司的净资产,因此要加回来。
  • 分母:代表了除权除息后,公司总股本扩张后的调整。
    • 前日收盘价 * (1 + 送转股比例 + 配股比例):送股和配股都增加了总股本。假设原股本为1,送转股增加share_split_ratio,配股增加allotment_ratio,总股本变为1 + share_split_ratio + allotment_ratio。股价需要按比例摊薄。

如果只有送股和分红(无配股),公式简化为:

调整因子 = (前日收盘价 - 每股现金分红) / (前日收盘价 * (1 + 送转股比例))

这更常见。

pandas中的实现步骤:

  1. 将事件表df_events与日线表df_daily通过日期对齐。
  2. df_daily创建一个名为adj_factor的列,初始值全部为1.0。
  3. 按时间顺序遍历每一个除权除息事件。
  4. 对于每个事件,找到除权除息日(ex_date)在df_daily中的位置。
  5. 获取除权日前一天的收盘价(prev_close)。
  6. 根据上述公式计算本次事件的single_adj_factor
  7. df_daily中,所有在ex_date之前(不含ex_date)的日期的adj_factor,都乘以这个single_adj_factor

重要提示:这里有一个关键选择:调整因子是乘在除权日之前的价格上(前复权逻辑),还是乘在除权日及之后的价格上(后复权逻辑)?我们目前计算的是后复权因子的累积过程。因为后复权是保持最早价格不变,后续价格向上调整,所以调整因子作用于历史数据(除权日之前)。理解了这一点,前后复权的计算就清晰了。

下面是计算后复权因子的代码示例:

def calculate_backward_adj_factor(df_daily, df_events): """ 计算后复权因子。 逻辑:从前往后累积,调整因子作用于除权除息日之前的所有历史价格。 """ # 复制一份日线数据,避免修改原数据 df = df_daily.copy() # 初始化后复权因子为1.0 df['backward_adj_factor'] = 1.0 # 按日期顺序处理每个事件 for ex_date, event in df_events.iterrows(): # 1. 找到除权除息日在日线数据中的位置 if ex_date not in df.index: print(f"警告:除权除息日 {ex_date} 不在日线数据中,已跳过。") continue # 2. 获取除权日前一天的收盘价 # 需要找到ex_date之前最近的交易日 prev_dates = df.index[df.index < ex_date] if len(prev_dates) == 0: print(f"警告:除权除息日 {ex_date} 之前无交易日数据,无法计算调整因子,已跳过。") continue prev_date = prev_dates[-1] # 取最后一个,即最近的前一个交易日 prev_close = df.at[prev_date, 'close'] # 3. 提取事件参数 cash_div = event['cash_div'] share_split = event['share_split_ratio'] allot_ratio = event['allotment_ratio'] allot_price = event['allotment_price'] # 4. 计算本次事件的单次调整因子 # 防止除零错误 if prev_close == 0: single_adj_factor = 1.0 print(f"警告:除权除息日 {ex_date} 的前收盘价为0,调整因子设为1.0") else: # 使用完整公式 numerator = prev_close - cash_div + allot_ratio * allot_price denominator = prev_close * (1 + share_split + allot_ratio) single_adj_factor = numerator / denominator # 5. 将本次调整因子乘到所有历史日期(ex_date之前)的后复权因子上 df.loc[df.index < ex_date, 'backward_adj_factor'] *= single_adj_factor return df # 调用函数计算 df_with_factors = calculate_backward_adj_factor(df_daily, df_events) print(df_with_factors[['close', 'backward_adj_factor']].tail(10))

4.2 从后复权因子到前复权因子

有了后复权因子,计算前复权因子就非常简单了。它们互为倒数关系,但需要以某个基准点进行归一化。

核心关系:

后复权价格 = 不复权价格 * 后复权因子 前复权价格 = 不复权价格 * 前复权因子

并且,对于任意一天,其前复权价格与后复权价格之比,等于最新交易日的不复权价格与后复权价格之比。推导后可以得到:

前复权因子 = 后复权因子 / 最新交易日的后复权因子

这样做的目的是将最新交易日的前复权价格与不复权价格对齐

def calculate_forward_adj_factor(df_with_backward_factor): """ 计算前复权因子。 逻辑:以后复权因子为基础,除以最新日的后复权因子,进行归一化。 """ df = df_with_backward_factor.copy() # 获取最新交易日的后复权因子 latest_backward_factor = df['backward_adj_factor'].iloc[-1] # 计算前复权因子 df['forward_adj_factor'] = df['backward_adj_factor'] / latest_backward_factor return df df_with_factors = calculate_forward_adj_factor(df_with_factors) print(df_with_factors[['close', 'backward_adj_factor', 'forward_adj_factor']].tail(10))

5. 生成复权价格序列与验证

计算完因子,生成价格序列就是简单的乘法。

# 生成复权价格序列 df_with_factors['close_backward'] = df_with_factors['close'] * df_with_factors['backward_adj_factor'] df_with_factors['close_forward'] = df_with_factors['close'] * df_with_factors['forward_adj_factor'] # 选取关键日期查看效果 key_dates = pd.to_datetime(['2023-05-17', '2023-05-18', '2023-05-19', '2024-03-14', '2024-03-15']) mask = df_with_factors.index.isin(key_dates) print(df_with_factors.loc[mask, ['close', 'close_forward', 'close_backward']])

验证逻辑正确性的几个检查点:

  1. 最新价格对齐:检查最新交易日(例如2024-03-15)的close_forward是否非常接近(或等于,取决于计算精度)close(不复权价)。因为前复权就是以最新价为准进行回溯的。
  2. 历史价格连续性:选取除权除息日(如2023-05-19)及其前一天(2023-05-18),观察close_forward。理论上,前复权价在这两天之间应该是连续的,没有跳空缺口。你可以计算一下这两天的收益率,看看是否与用不复权价、并考虑分红送股后计算的理论收益率一致。
  3. 后复权起点:检查最早交易日(或上市首日)的close_backward是否等于close。因为后复权保持起点价格不变。
  4. 长期一致性:任意两日之间的收益率,用前复权价计算、用后复权价计算,结果应该是一致的。因为复权只是价格的线性变换,不影响收益率序列。

常见问题排查

  • 问题:计算出的前复权价在除权日仍有微小跳空。
  • 排查:大概率是prev_close取错了。确保你用的是股权登记日(即除权除息日前最后一个交易日)的收盘价,而不是日历上的前一天。你的df_daily索引必须是交易日,且没有缺失。
  • 问题:后复权价格趋势异常,比如最新价反而比历史价低。
  • 排查:检查调整因子single_adj_factor的计算公式,尤其是配股部分的处理。确认分子分母没有弄反。对于配股,公式(prev_close + allot_ratio * allot_price) / (prev_close * (1 + allot_ratio))是一个常见变体,其逻辑是将配股视为一次“加权融资”,不同数据源可能有细微差异,需要与你选定的基准数据源保持一致。
  • 问题:与同花顺等软件数据对不上。
  • 排查:首先确认事件数据是否完全一致(日期、比例)。其次,软件可能对红利税股本变动生效时点(是收盘后还是开盘瞬间)有不同处理。对于极高精度的研究,这些细节需要考虑。但对于大多数回测,上述逻辑已足够稳健。

6. 性能优化与工程化实践

上面的循环遍历方法在事件不多时没问题,但面对全市场几千只股票、几十年历史数据时,效率堪忧。我们需要向量化操作。

思路:我们可以为每个除权除息日计算一个“累积调整因子”,然后通过pandasmerge_asoffillna方法,为每一个交易日找到其之后第一个除权除息日的累积因子,从而一次性完成计算。

def calculate_adj_factor_vectorized(df_daily, df_events): """ 向量化方法计算复权因子(以后复权为例)。 """ # 1. 计算每个事件的单次调整因子(需要前收) # 将事件表与日线表合并,获取每个事件日的前收 df_events_with_prev = df_events.copy() # 使用 merge_asof 为每个事件找到前一个交易日的收盘价 # 需要先将索引重置以便操作 df_daily_reset = df_daily[['close']].reset_index() df_events_reset = df_events.reset_index() df_merged = pd.merge_asof(df_events_reset.sort_values('trade_date'), df_daily_reset.sort_values('trade_date'), left_on='trade_date', right_on='trade_date', direction='backward') # 计算单次调整因子 prev_close = df_merged['close'] cash_div = df_merged['cash_div'] share_split = df_merged['share_split_ratio'] allot_ratio = df_merged['allotment_ratio'] allot_price = df_merged['allotment_price'] numerator = prev_close - cash_div + allot_ratio * allot_price denominator = prev_close * (1 + share_split + allot_ratio) single_factor = numerator / denominator df_merged['single_adj_factor'] = single_factor df_merged.set_index('trade_date', inplace=True) # 2. 计算累积调整因子(从过去到现在) # 按时间倒序,因为后复权因子是历史累积 df_merged = df_merged.sort_index(ascending=False) df_merged['cum_adj_factor'] = df_merged['single_adj_factor'].cumprod() df_merged = df_merged.sort_index(ascending=True) # 3. 为每个交易日匹配其“未来”第一个事件的累积因子 # 创建一个包含所有交易日和事件日的完整日期序列 all_dates = df_daily.index.union(df_merged.index).sort_values() # 构建一个Series,在事件日存放其累积因子 event_factors = pd.Series(index=df_merged.index, data=df_merged['cum_adj_factor']) # 前向填充:每个交易日都使用其之后最近一个事件日的累积因子 # 对于最早事件日之前的日期,因子为1(用bfill后的结果再处理) aligned_factors = event_factors.reindex(all_dates).fillna(method='bfill') # 将没有更早事件的日期(即bfill后仍为NaN的,通常是最后一段日期)因子设为1 aligned_factors.fillna(1.0, inplace=True) # 4. 将因子对齐到原始日线索引 backward_adj_factor = aligned_factors.reindex(df_daily.index).fillna(1.0) df_daily['backward_adj_factor_vec'] = backward_adj_factor return df_daily # 测试向量化版本 df_daily_vec = calculate_adj_factor_vectorized(df_daily, df_events) print(df_daily_vec[['close', 'backward_adj_factor_vec']].tail())

向量化操作能提升数十倍甚至上百倍的性能。在实际工程中,你还需要考虑:

  • 数据存储:计算好的复权因子可以持久化到数据库或文件中,避免每次重复计算。
  • 批量处理:对全市场股票进行循环计算,利用多进程或分布式框架加速。
  • 异常处理:增加对数据缺失、除零错误、日期错位等情况的鲁棒性处理。
  • 基准对比:定期用你的计算结果与权威数据源进行抽样对比,确保长期计算的准确性。

7. 在量化回测中的应用与注意事项

自己计算复权价,最终目的是为了更可靠的回测。在应用时,有以下几个关键点:

  1. 价格选择:在回测中,买卖信号基于前复权价,因为这与交易软件看到的K线一致。但计算收益时,特别是涉及分红再投资的收益,需要理解其与后复权收益的关系。
  2. 收益率计算:使用前复权价计算的日收益率序列,与使用后复权价计算的序列是完全一致的。这是复权因子线性性质决定的。所以回测中直接用前复权价计算收益率即可。
  3. 初始资金与复权:如果你的回测考虑分红再投资,那么后复权价模拟的就是“分红立即以收盘价再投资”的净值曲线。这时,用后复权价计算策略净值会更方便。
  4. 事件日处理:在除权除息日,你的df_events表中的事件已经发生。在回测中,如果你在股权登记日持有股票,你将有权获得分红或配股。这部分现金流入或股份增加,需要在你的回测引擎的资金和持仓模块中精确模拟,而不仅仅依赖价格复权。价格复权解决了K线连续性问题,但真实的现金流和股份变动需要单独处理。

我的个人体会:自己实现一遍复权计算,是量化入门极好的一课。它强迫你去理解每一个市场规则细节。最初我直接使用数据接口的复权数据,直到有一次回测结果与论文差异巨大,排查一周才发现是数据源对某次特殊配股的处理方式不同。从那以后,核心数据的生成我都尽量掌握在自己手中。这份代码可能不是最快的,但逻辑的透明度给了我最大的安全感。当你看到自己计算出的复权价格曲线与主流软件完美贴合时,那种对数据的掌控感,是直接调用API无法比拟的。