当你的回测收益多了一个零:拆解股票复权处理的工程迷雾

"你以为你在 2015 年买入了茅台 100 股,持有到今天价值 50 万。但实际上,如果你按原始价格计算,100 股茅台早已不存在——它经历了多次送股和分红,如果不做复权处理,你的回测引擎会把你带进一个从未存在过的平行世界。"

这是每一位量化开发者迟早会踩到的坑:原始价格数据在经过拆股、分红、派息之后,数据本身已经发生了根本性变化。如果直接用未复权的原始价格做回测,你会得到一个看起来惊人、实际上完全不存在的收益率。

复权处理,是将历史价格"还原"到同一基准线的过程。它不是锦上添花,而是量化回测的基础设施


一、为什么复权处理是量化回测的死生之地

1.1 拆股:价格的突然断层

拆股(Stock Split)是上市公司将一股拆成多股的行为。苹果公司在 2020 年进行了 1:4 的拆股,拆股前股价约 500 美元,拆股后瞬间变为约 125 美元。

对于未做复权处理的数据:

  • 拆股前买入的 10 股,在拆股后自动变成 40 股
  • 如果用原始价格计算持仓市值,你会看到持仓市值骤降 75%
  • 你的回测系统会认为这是一笔灾难性的亏损

实际上,拆股对股东权益没有任何影响——你持有的资产总价值不变,变的只是股数与每股价格。

1.2 分红与派息:被忽略的现金流

分红(Dividend)是公司将部分利润返还给股东的行为。假设你持有微软股票,微软每季度派发股息:

  • 2024 年每股股息约 0.75 美元/季度
  • 如果你持有 1000 股,每年收到的股息约 3000 美元
  • 一次分红后,股价会相应下跌约 0.75 美元(市场自然调整)

如果不复权,股价在除息日后会"跳空下跌",你的回测会把这当成损失。但实际上,这是投资者正常获得的现金流补偿。

1.3 总收益,才是真正的收益

真正的投资回报,应该等于:

总收益 = 资本利得 + 分红再投资

这意味着复权处理不只是"修复"价格图表,它关乎你计算策略收益率时,是否包含了投资者实际获得的所有回报。

前复权 vs 后复权 vs 不复权——三种处理方式的核心差异:

方式 原理 适用场景
前复权 以当前价格为基准,将历史上所有价格乘以复权因子,还原到当前水平 技术分析、趋势跟踪
后复权 以历史价格为基准,将当前价格还原到历史水平 基本面回测、与历史文献对比
不复权 保持原始价格不变 仅用于观察价格断层,不用于回测

二、CRSP 标准:华尔街的复权圣经

2.1 什么是 CRSP

CRSP(Center for Research in Security Prices)是芝加哥大学布斯商学院的研究机构,自 1926 年起维护美国股票市场的历史数据库。CRSP 的数据标准被华尔街公认为"事实标准"(de facto standard)。

CRSP 的复权处理方法,是量化行业最广泛采用的基准。

2.2 CRSP 总收益指数的计算原则

CRSP 总收益指数(Total Return Index)的核心逻辑是:

  1. 加回分红:每日指数不仅反映价格变化,还累积分红再投资的收益
  2. 调整拆股:拆股事件通过调整因子(Adjustment Factor)作用于历史价格
  3. 平滑连续:价格序列在时间维度上保持连续性,不会出现因分红/拆股导致的断层

复权因子的数学定义

复权因子_t = Π(拆分因子_i) × Π(分红因子_j)

其中:
- 拆分因子_i:第 i 次拆股的调整系数(拆股 1:4 → 因子 4.0)
- 分红因子_j:第 j 次分红的调整系数

最终,价格的复权计算为:

复权价格_t = 原始价格_t × 复权因子_t

2.3 分红再投资:被低估的收益来源

以可口可乐(KO)为例:

  • 1962 年股价约 3 美元
  • 2024 年股价约 60 美元
  • 看起来翻了 20 倍

但如果计算CRSP 总收益(包含 60 年连续分红再投资),实际回报超过 150 倍

红利再投资,是长期复合收益最重要的来源之一。忽略它,你的回测收益将系统性低估真实回报。


三、复权处理流水线:工程实现架构

3.1 数据管线的整体架构

一个健壮的复权处理流水线,包含以下核心组件:

┌─────────────────────────────────────────────────────────────┐
│                      原始数据源                               │
│         (价格数据 + 拆股公告 + 分红记录)                      │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                   事件日历层                                  │
│         (拆股事件表 + 分红事件表 + 除权日期)                   │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                 复权因子计算层                                │
│    (按 CRSP 标准计算累积调整因子 + 总收益因子)                  │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                   价格复权层                                  │
│         (前复权价格 + 后复权价格 + 总收益价格)                   │
└─────────────────────────┬───────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────────┐
│                 输出与验证层                                  │
│      (写入数据库 + 连续性校验 + 基准对比验证)                   │
└─────────────────────────────────────────────────────────────┘

3.2 关键设计决策

设计点 方案 A 方案 B(推荐)
因子存储方式 每次计算时动态生成 预计算因子表,静态关联
精度处理 浮点数直接计算 Decimal 类型,保留 8 位精度
数据更新 全量重算 增量追加,仅处理新事件
基准对齐 自建复权规则 对标 CRSP 标准

推荐方案 B 的理由:因子预计算可以将 O(n×m) 的复杂度降低为 O(n),其中 n 是历史数据条数,m 是事件数量。


四、生产级复权处理代码

以下代码实现了一个完整的复权因子计算与价格复权流水线,使用 TickDB 的历史 K 线数据作为数据源。

4.1 依赖安装与环境配置

# requirements.txt
# pip install pandas numpy requests python-dotenv

import os
import time
import json
import random
import requests
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
from datetime import datetime, date
from typing import Optional

# API Key 管理(不硬编码在代码中)
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
    raise ValueError("请设置环境变量 TICKDB_API_KEY")

4.2 核心数据模型定义

@dataclass
class CorporateAction:
    """公司行为事件"""
    symbol: str
    event_date: date
    action_type: str  # 'split' | 'dividend'
    ratio: float      # 拆股比例(如 4.0 表示 1 拆 4)或每股分红金额
    factor_before: float  # 该事件前的累积复权因子
    factor_after: float  # 该事件后的累积复权因子


@dataclass
class AdjustedPrice:
    """复权价格记录"""
    timestamp: int
    open: Decimal
    high: Decimal
    low: Decimal
    close: Decimal
    volume: float
    adjustment_factor: Decimal
    dividend_yield: Decimal  # 当日股息率(用于总收益计算)


@dataclass
class AdjustmentFactorTable:
    """复权因子表"""
    symbol: str
    factors: dict  # {date: factor}

4.3 TickDB API 调用封装(含心跳与限频处理)

class TickDBClient:
    """TickDB API 客户端,含生产级错误处理"""
    
    BASE_URL = "https://api.tickdb.ai/v1"
    MAX_RETRIES = 5
    BASE_DELAY = 1.0
    
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "X-API-Key": api_key,
            "Content-Type": "application/json"
        })
    
    def _exponential_backoff_with_jitter(self, retry_count: int) -> float:
        """指数退避 + 抖动(避免惊群效应)"""
        delay = min(self.BASE_DELAY * (2 ** retry_count), 60.0)
        jitter = random.uniform(0, delay * 0.1)
        return delay + jitter
    
    def _handle_rate_limit(self, response: requests.Response) -> Optional[float]:
        """处理限频错误(code: 3001)"""
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 5))
            print(f"[限频] 等待 {retry_after} 秒后重试...")
            time.sleep(retry_after)
            return retry_after
        return None
    
    def get_kline_data(
        self,
        symbol: str,
        interval: str = "1d",
        start_time: Optional[int] = None,
        end_time: Optional[int] = None,
        limit: int = 1000
    ) -> list:
        """
        获取历史 K 线数据
        
        Args:
            symbol: 交易品种,如 'AAPL.US'
            interval: K 线周期,支持 1m/5m/15m/1h/4h/1d
            start_time: 开始时间戳(毫秒)
            end_time: 结束时间戳(毫秒)
            limit: 单次请求最大条数
        
        Returns:
            K 线数据列表
        """
        params = {
            "symbol": symbol,
            "interval": interval,
            "limit": limit
        }
        if start_time:
            params["start"] = start_time
        if end_time:
            params["end"] = end_time
        
        for retry in range(self.MAX_RETRIES):
            try:
                response = self.session.get(
                    f"{self.BASE_URL}/market/kline",
                    params=params,
                    timeout=(3.05, 10)  # (connect_timeout, read_timeout)
                )
                
                # 检查限频
                self._handle_rate_limit(response)
                
                data = response.json()
                
                # 错误码处理
                if data.get("code") == 0:
                    return data.get("data", [])
                elif data.get("code") in (1001, 1002):
                    raise ValueError(f"API Key 无效: {data.get('message')}")
                elif data.get("code") == 2002:
                    raise KeyError(f"交易品种 {symbol} 不存在")
                else:
                    raise RuntimeError(f"API 错误 {data.get('code')}: {data.get('message')}")
            
            except requests.exceptions.Timeout:
                print(f"[超时] 第 {retry + 1} 次重试...")
                time.sleep(self._exponential_backoff_with_jitter(retry))
            except requests.exceptions.RequestException as e:
                print(f"[网络错误] {e},第 {retry + 1} 次重试...")
                time.sleep(self._exponential_backoff_with_jitter(retry))
        
        raise RuntimeError("达到最大重试次数,请求失败")

4.4 复权因子计算引擎

class AdjustmentFactorEngine:
    """
    复权因子计算引擎
    
    实现 CRSP 标准的复权因子计算:
    1. 拆股因子:直接使用拆股比例
    2. 分红因子:(price - dividend) / price 的累积效应
    
    ⚠️ 注意:此处使用简化的分红因子计算。
    生产环境中应接入权威的分红数据源(如 CRSP、Compustat)。
    """
    
    def __init__(self):
        self.factor_tables: dict[str, AdjustmentFactorTable] = {}
    
    def calculate_split_factor(
        self,
        split_ratio: float,
        cumulative_factor: float
    ) -> float:
        """
        计算拆股后的复权因子
        
        Args:
            split_ratio: 拆股比例,如 4.0 表示 1 拆 4
            cumulative_factor: 拆股前的累积因子
        
        Returns:
            拆股后的新累积因子
        """
        # CRSP 规则:拆股后的因子 = 拆股前因子 / 拆股比例
        return cumulative_factor / split_ratio
    
    def calculate_dividend_factor(
        self,
        price: float,
        dividend_per_share: float
    ) -> float:
        """
        计算分红的调整因子
        
        CRSP 分红因子 = (价格 - 分红) / 价格
        
        例如:价格 100 美元,每股分红 1 美元
        → 分红因子 = (100 - 1) / 100 = 0.99
        
        后续所有历史价格 × 0.99 以反映分红的"价格侵蚀"
        """
        if price <= 0:
            return 1.0
        return (price - dividend_per_share) / price
    
    def build_factor_table(
        self,
        symbol: str,
        corporate_actions: list[CorporateAction],
        base_date: date
    ) -> AdjustmentFactorTable:
        """
        构建完整的复权因子表
        
        Args:
            symbol: 股票代码
            corporate_actions: 按时间排序的公司行为事件列表
            base_date: 基准日期(因子归 1.0 的日期)
        
        Returns:
            复权因子表
        """
        factors = {}
        cumulative_factor = 1.0
        
        # 从基准日期向前/后推算因子
        sorted_actions = sorted(corporate_actions, key=lambda x: x.event_date)
        
        for action in sorted_actions:
            # 在事件发生日,新因子生效
            if action.action_type == "split":
                cumulative_factor = self.calculate_split_factor(
                    action.ratio, cumulative_factor
                )
            elif action.action_type == "dividend":
                # ⚠️ 分红因子需要当日价格,实际实现中应从数据源获取
                pass
            
            factors[action.event_date] = Decimal(str(cumulative_factor))
        
        return AdjustmentFactorTable(symbol=symbol, factors=factors)

4.5 复权流水线主流程

class PriceAdjustmentPipeline:
    """
    股票价格复权处理流水线
    
    完整流程:
    1. 获取历史价格数据(TickDB)
    2. 获取公司行为事件(拆股/分红)
    3. 计算复权因子
    4. 应用复权因子,生成复权价格序列
    5. 验证连续性
    """
    
    def __init__(self, api_key: str):
        self.client = TickDBClient(api_key)
        self.factor_engine = AdjustmentFactorEngine()
    
    def fetch_price_history(
        self,
        symbol: str,
        years: int = 10
    ) -> list:
        """
        获取历史价格数据
        
        使用 TickDB 的 /market/kline 接口获取清洗对齐的历史 K 线
        
        ⚠️ 注意:TickDB 提供 10 年级别的美股历史 K 线数据,
        数据已按交易所标准进行清洗和对齐处理。
        """
        now = int(time.time() * 1000)
        start = now - (years * 365 * 24 * 60 * 60 * 1000)
        
        all_data = []
        current_start = start
        
        # 分页获取(TickDB 单次最多返回 limit 条)
        while True:
            batch = self.client.get_kline_data(
                symbol=symbol,
                interval="1d",
                start_time=current_start,
                end_time=now,
                limit=1000
            )
            
            if not batch:
                break
            
            all_data.extend(batch)
            
            # 下一页:从最后一条的时间戳继续
            if len(batch) < 1000:
                break
            current_start = batch[-1]["t"] + 1
        
        print(f"[获取] {symbol} 历史数据 {len(all_data)} 条")
        return all_data
    
    def apply_forward_adjustment(
        self,
        price_data: list,
        factor_table: AdjustmentFactorTable
    ) -> list[AdjustedPrice]:
        """
        应用前复权处理
        
        前复权:以最新价格为基准,向前回溯调整历史价格
        公式:前复权价格 = 原始价格 × (最新因子 / 历史因子)
        
        ⚠️ 生产环境高频场景建议使用向量化计算(NumPy/Pandas)
        """
        adjusted = []
        
        # 获取基准因子(最新日期)
        latest_factor = Decimal("1.0")
        
        for bar in price_data:
            # 转换时间戳
            ts = bar["t"]
            bar_date = datetime.fromtimestamp(ts / 1000).date()
            
            # 获取该日期的因子(简化版,实际应精确匹配)
            factor = Decimal(str(bar.get("factor", 1.0)))
            
            # 前复权计算
            adjustment_ratio = latest_factor / factor
            
            adjusted_price = AdjustedPrice(
                timestamp=ts,
                open=Decimal(str(bar["o"])) * adjustment_ratio,
                high=Decimal(str(bar["h"])) * adjustment_ratio,
                low=Decimal(str(bar["l"])) * adjustment_ratio,
                close=Decimal(str(bar["c"])) * adjustment_ratio,
                volume=bar["v"],
                adjustment_factor=factor,
                dividend_yield=Decimal("0")
            )
            adjusted.append(adjusted_price)
        
        return adjusted
    
    def calculate_total_return(
        self,
        adjusted_prices: list[AdjustedPrice],
        dividend_yields: list[float]
    ) -> list[AdjustedPrice]:
        """
        计算总收益(Price Return + Dividend Return)
        
        CRSP 总收益指数 = Price Return + Dividend Reinvestment
        
        简化公式:总收益价格 = 价格 × 累积分红再投资因子
        """
        cumulative_dividend_factor = Decimal("1.0")
        
        for i, (price, dividend) in enumerate(zip(adjusted_prices, dividend_yields)):
            if dividend > 0:
                # 分红再投资因子累加
                cumulative_dividend_factor *= (Decimal("1") + Decimal(str(dividend)))
            
            price.dividend_yield = Decimal(str(dividend))
            # 实际生产中应返回新的总收益价格对象
            # 此处简化处理
        
        return adjusted_prices
    
    def validate_continuity(
        self,
        adjusted_prices: list[AdjustedPrice],
        max_gap_pct: float = 0.05
    ) -> bool:
        """
        验证复权价格的连续性
        
        检查相邻交易日的价格变化是否在合理范围内(排除极端行情)
        异常大的跳空可能意味着因子计算错误
        """
        if len(adjusted_prices) < 2:
            return True
        
        for i in range(1, len(adjusted_prices)):
            prev_close = adjusted_prices[i-1].close
            curr_open = adjusted_prices[i].open
            
            if prev_close == 0:
                continue
            
            gap_pct = abs(float(curr_open - prev_close) / float(prev_close))
            
            if gap_pct > max_gap_pct:
                print(f"[警告] 日期 {adjusted_prices[i].timestamp} "
                      f"存在 {gap_pct:.2%} 的价格跳空,需检查因子计算")
                return False
        
        return True

4.6 使用示例

def main():
    """复权处理流水线完整示例"""
    
    # 初始化(从环境变量读取 API Key)
    api_key = os.environ.get("TICKDB_API_KEY")
    pipeline = PriceAdjustmentPipeline(api_key)
    
    # 目标股票:苹果(AAPL.US),历史上有多次拆股
    symbol = "AAPL.US"
    
    # 步骤 1:获取历史价格
    price_data = pipeline.fetch_price_history(symbol, years=10)
    
    # 步骤 2:构建复权因子表(此处需要接入分红/拆股数据源)
    # ⚠️ 实际项目中应使用权威数据源(CRSP、Wind、Polygon 等)
    corporate_actions = [
        CorporateAction(
            symbol=symbol,
            event_date=date(2020, 8, 31),
            action_type="split",
            ratio=4.0,
            factor_before=1.0,
            factor_after=0.25
        ),
        CorporateAction(
            symbol=symbol,
            event_date=date(2005, 2, 28),
            action_type="split",
            ratio=2.0,
            factor_before=1.0,
            factor_after=0.5
        ),
    ]
    
    factor_table = pipeline.factor_engine.build_factor_table(
        symbol=symbol,
        corporate_actions=corporate_actions,
        base_date=date.today()
    )
    
    # 步骤 3:应用复权因子
    adjusted_prices = pipeline.apply_forward_adjustment(
        price_data=price_data,
        factor_table=factor_table
    )
    
    # 步骤 4:验证连续性
    is_valid = pipeline.validate_continuity(adjusted_prices)
    print(f"[验证] 复权价格连续性检查: {'通过' if is_valid else '失败'}")
    
    # 步骤 5:计算总收益(如有分红数据)
    # total_return_prices = pipeline.calculate_total_return(
    #     adjusted_prices, dividend_yields
    # )
    
    # 输出示例
    if adjusted_prices:
        latest = adjusted_prices[-1]
        print(f"[结果] 最新前复权收盘价: ${latest.close:.4f}")
    
    return adjusted_prices


if __name__ == "__main__":
    main()

⚠️ 工程预警

  1. 分红数据源:本示例的分红因子计算是简化版本。生产环境中必须接入权威分红数据(如 CRSP、Compustat、Polygon 的 corporate_actions 端点)。
  2. 实时更新:复权因子表需要随新事件(拆股公告、分红)动态更新。建议设置增量更新机制,而非全量重算。
  3. 精度处理:金融计算必须使用 Decimal 类型,避免浮点数精度丢失导致的复权因子误差。

五、TickDB 数据能力与本方案的配合

5.1 TickDB 在复权流水线中的定位

复权处理流水线需要两类核心数据:

数据类型 用途 TickDB 支持情况
历史 K 线数据 原始价格序列、复权计算的基础 ✅ 支持美股 10 年级别 K 线,清洗对齐
实时价格数据 前复权的基准价格来源 ✅ 支持 WebSocket 实时推送
拆股/分红事件数据 复权因子的核心输入 ⚠️ TickDB 当前不提供公司行为事件,需对接第三方数据源

推荐的数据组合

TickDB(价格数据)+ CRSP/Polygon(公司行为事件)= 完整的复权处理闭环

5.2 功能对比

能力维度 仅用 TickDB TickDB + CRSP
历史价格 ✅ 10 年 K 线 ✅ 同左
拆股调整 ❌ 需手动处理 ✅ 自动应用
分红调整 ❌ 需手动处理 ✅ 自动应用
总收益计算 ❌ 不支持 ✅ 支持
数据一致性 需自行校验 符合华尔街标准

六、常见陷阱与避坑指南

6.1 拆股事件日的价格归属陷阱

问题:除权日的开盘价已经反映了拆股的影响,但 OHLC 数据中的成交量和价格都需要正确处理。

避坑方案

def apply_split_adjustment(bar: dict, split_ratio: float) -> dict:
    """
    正确处理拆股当日的数据
    
    CRSP 标准:
    - 拆股当日:原始 OHLCV 不变,factor 标记为 split_ratio
    - 拆股后首个交易日:factor 更新为 1.0(旧因子/split_ratio)
    """
    adjusted_bar = bar.copy()
    
    if bar.get("is_split_date"):
        # 拆股当日:factor 记录拆股比例
        adjusted_bar["factor"] = split_ratio
        # 价格和成交量保持不变(已是除权后状态)
    else:
        # 历史数据:价格需要回溯调整
        adjusted_bar["factor"] = 1.0
    
    return adjusted_bar

6.2 分红与拆股的顺序依赖

问题:当同一天发生分红和拆股时,因子计算的顺序会影响最终结果。

CRSP 规则:先应用分红因子,再应用拆股因子。

# 正确的因子计算顺序
def calculate_combined_factor(split_ratio: float, dividend_factor: float) -> float:
    # 先分红(降低价格)
    price_after_dividend = price * dividend_factor
    # 再拆股(降低单价,提高股数)
    final_factor = dividend_factor / split_ratio
    return final_factor

6.3 跨交易所数据的一致性

问题:同一只股票在不同数据源中的复权因子可能存在微小差异(通常 < 0.01%)。

避坑方案

  1. 选择一个权威基准(推荐 CRSP),保持全程一致
  2. 设置容差阈值(如 0.001%),允许合理范围内的差异
  3. 记录数据来源版本号,便于问题追溯

七、结语

复权处理不是量化工程中最"性感"的部分,但它决定了你的回测结果是否可信。

核心原则

  1. 永远使用复权价格做回测——原始价格只存在于价格图表的展示层
  2. 总收益才是真正的收益——对于长期策略,红利再投资是复利的核心来源
  3. 因子表是基础设施——预计算、版本化管理、增量更新

下一步行动

  • 如果你需要高质量的历史 K 线数据:访问 tickdb.ai 注册,获取 10 年级别的美股清洗数据
  • 如果你需要完整的复权解决方案:联系 [email protected],了解机构级数据方案与 CRSP 标准对齐支持
  • 如果你正在搭建数据管线:参考本文的流水线架构,确保复权因子计算的可审计性和可回溯性

价格是结果,复权因子才是原因。
把原因搞清楚,结果才会可信。


风险提示:本文不构成任何投资建议。历史数据与回测结果仅供参考,不代表未来实际收益。复权处理方法的选择应基于具体策略需求和数据可得性综合判断。