综合

A 股回测中的复权与 Point-in-Time 偏差:为什么实盘会追不上回测

作者: TickDB Research · 发布: 2026/5/20 · 阅读: 9

标签: C 类, 思否, 复权

本文仅讨论行情数据处理与回测工程问题,不构成任何投资建议。文中价格数据和收益数字均为示意性示例。

回测跑完,年化收益 31.6%,最大回撤 8.3%,夏普 2.1。你信心满满上线。实盘第一周,收益跑输回测曲线 4 个百分点。排查 3 天,不是过拟合,不是未来函数——是你的回测引擎在 2024 年 12 月做历史回测时,“提前知道”了 2025 年 6 月那次 10 送 10。它用这个未来的除权因子把半年前的价格压了一半,你的均线策略在那个位置多开了 3 次仓——每一次都是实盘里不可能出现的交易。

不是前复权这个操作本身错了。是你用 事后前复权 去回测历史,把未来的公司行为注入了过去的 K 线。回测地基上的这条裂缝,在除权除息日前后悄悄张开,让你的实盘从第一天就开始偏离回测曲线。

复权不是数据预处理的可选项。它是回测的第一层地基——地基歪了,楼盖得再高也是危房。更隐蔽的是分钟线:除权当日如果不加过滤,开盘跳空会被当成趋势突破信号,高频策略在这根线上反复触发,回测虚高,实盘根本做不到。你以为是策略有效,其实是复权方式替你赚了不该赚的钱。

这类问题在统一数据接入层中更容易被定位——以 TickDB 为例,其 /v1/market/kline/v1/market/ticker 共用字段体系,便于做价格基准的一致性验证。

三种复权方式,最简语言讲清楚

① 是什么

股票在除权除息日会发生价格跳空——不是市场跌了,是股本变了。复权就是把这个“人为跳空”填回去,让价格序列连续。

  • 不复权:保留原始成交价。除权日你会看到一根巨大的跳空缺口——10 送 10 之后,股价从 20 块“跌”到 10 块,看起来像暴跌 50%,实际持仓市值一分没变。
  • 前复权:以最新股本为基准,把历史所有价格按除权因子向后修正。最新价不变,历史价被“压缩”。
  • 后复权:以上市首日股本为基准,把之后所有价格向前修正。历史首日价不变,当前价被“放大”。

同一个除权因子,方向不同,结果天差地别。

② 前复权和后复权的技术指标差异:看似很大,实则线性

很多人第一反应:前复权和后复权跑出来的均线金叉位置不一样。但这里有一个关键事实:

如果复权因子计算正确,前复权价格序列和后复权价格序列是严格成比例的。 后复权价格 = 前复权价格 × 最新复权因子(一个常数)。均线交叉、布林带突破、RSI 超买超卖——这些基于相对位置的信号在前后复权下完全一致,因为线性变换不改变相对关系。

那为什么同一只股票,前复权和后复权回测收益还能差不少?根源不在复权方向,而在用复权价格做绝对数值判断。比如你的策略写死了“股价低于 5 元不买”——前复权把历史价压到 3 块,后复权是 6 块,一个触发过滤,一个不触发。这不是复权的锅,是用错了价格基准。

真正致命的坑,在下一节。

复权真正的坑:Point-in-Time

③ 为什么事后前复权是最隐蔽的数据泄露

前复权的本质:用当前最新的股本结构,反推历史上每一天的“等效价格”。

假设今天是 2025 年 6 月 15 日,某股票刚完成 10 送 10。你现在调用行情 API 拉取它的前复权历史日线。系统会把 2024 年 12 月的价格全部除以 2,让你看到“如果当时股本也这么多,股价应该是多少”。

问题来了:2024 年 12 月的真实市场里,根本没有这次除权。 你站在 2024 年 12 月做交易决策时,不可能知道半年后会 10 送 10。但你的回测引擎知道了——它用 2025 年 6 月的复权因子,修正了 2024 年 12 月的价格。未来的信息被注入了历史信号。

这个偏差有一个正式的名字:Point-in-Time Bias(时点偏差)。 金融数据领域公认的回测杀手。它的后果不是数字差一点,而是你的策略在除权除息日前后的历史区间里,看到了一个被“修正过”的世界——这个世界在真实历史中从未存在过。

④ 坑表:复权相关的五种隐蔽错误

原因后果
事后前复权注入未来信息用现在的股本反推历史价格回测信号在除权日前后系统性漂移,实盘无法复现
除权日分钟线跳空当突破未过滤除权开盘跳空假信号触发,高频回测收益虚高
用复权价做绝对价格判断前复权压价,后复权抬价止损/仓位过滤逻辑在前后复权下行为不一致
回测与实盘价格基准不一致回测用复权,实盘用原始价信号触发价格无法执行,滑点吃掉全部利润
不同数据源复权因子差异流通市值加权 vs 总市值加权同一策略在两个源上回测结果不同

⑤ 怎么优化:三件事

  1. 回测全部历史时,若无法获得 point-in-time 价格序列,至少要知道自己用的是事后复权数据,并在结果中标注数据偏差方向。
  2. 信号计算用复权价,下单价格映射回原始市场价。 均线、布林带、RSI 这些相对指标用前复权算没问题——它们是线性不变的。但你的止损单、限价单必须写原始市场价,否则挂出去成交不了。
  3. 纯日内策略、不跨除权日持仓时,直接用不复权价格做回测,并在除权日做标记排除开盘跳空时段。 持仓跨除权日则必须做复权。

一个帮你记住区别的类比:前复权和后复权的差异像 Git rebase vs merge。前复权是 rebase——把历史改写到最新基准上,历史看起来“干净”,但实际发生过的事被重写了。事后前复权就更危险——你不是 rebase 到“当前”,而是 rebase 到了一个回测时还不存在的未来 commit

场景选择速查

场景推荐方式条件约束数据要求
日线趋势跟踪(持仓>1周)前复权(接受事后复权偏差)信号计算用复权价,实盘下单映射回原始价需要日线 kline 复权 close
纯日内高频(不跨除权日)不复权须用公司行为日历过滤除权日开盘跳空需要分钟线原始 close + 公司行为日历
多因子选股(收益因子)复权收益率不使用绝对价格比较用复权 close 计算涨跌幅
估值/市值/成交额因子原始口径不经过复权修正使用原始 last_price + 财务数据
实盘信号生成前复权算指标,原始价下单信号计算与撮合使用不同价格基准kline 复权价 + ticker last_price 映射

没有“哪个最好”,只有“你的策略依赖哪个价格基准”。 依赖错了,回测就是一场精心设计的自我欺骗。

代码实操:验证你的复权数据有没有坑

依赖安装:

pip install requests

具体复权参数以 https://docs.tickdb.ai 官方文档为准。

验证前后复权的线性关系

import os, requests

API_KEY = os.getenv("TICKDB_API_KEY")
BASE = "https://api.tickdb.ai/v1"

def get_kline(symbol: str, interval: str = "1d", limit: int = 200):
    """
    拉取日线数据。
    显式处理 3001(限流)/ 1001(鉴权失败)/ 非0 三种错误码。
    kline 数据嵌套在 data.klines 中。
    """
    url = f"{BASE}/market/kline"
    resp = requests.get(
        url,
        headers={"X-API-Key": API_KEY},
        params={"symbol": symbol, "interval": interval, "limit": limit},
        timeout=10
    )
    data = resp.json()

    if data["code"] == 3001:                         # 限流:读 Retry-After 退避
        retry_after = resp.headers.get("Retry-After")
        wait = int(retry_after) if retry_after else 1
        import time; time.sleep(wait)
        return get_kline(symbol, interval, limit)
    if data["code"] == 1001:                         # 鉴权失败:阻断
        raise PermissionError(f"鉴权失败(1001): {data.get('message')}")
    if data["code"] != 0:                            # 非预期错误码必须显式 raise
        raise RuntimeError(f"API错误 code={data['code']}: {data}")

    return data["data"]["klines"]                   # kline 数据嵌套在 data.klines


def verify_linear_relation(klines_qfq: list, klines_hfq: list):
    """
    验证前后复权线性关系:后复权 = 前复权 × 常数。
    如果某天比例不恒定,说明复权因子有问题或数据对齐出错。
    """
    ratios = []
    for q, h in zip(klines_qfq, klines_hfq):
        if float(q["close"]) > 0:
            ratios.append(float(h["close"]) / float(q["close"]))
    if len(ratios) < 2:
        return None
    avg_ratio = sum(ratios) / len(ratios)
    max_dev = max(abs(r / avg_ratio - 1) for r in ratios)
    if max_dev > 0.001:
        print(f"⚠️ 前后复权线性关系不成立,最大偏差 {max_dev*100:.3f}%")
    else:
        print(f"✓ 前后复权线性关系成立,比例常数 ≈ {avg_ratio:.4f},最大偏差 {max_dev*100:.4f}%")
    return avg_ratio


# 示例:真实 A 股股票代码,历史上多次送转,适合做复权验证
symbol = "600519.SH"
klines_raw = get_kline(symbol)
print(f"已拉取 {symbol} 的 {len(klines_raw)} 根日线 K 线(不复权)")
# 若你的数据源可分别提供 qfq/hfq 序列,用 verify_linear_relation(qfq_klines, hfq_klines) 校验

核心是验证前后复权的线性关系,不是调 API。 如果前后复权的比值不是一个常数,说明数据对齐有错或复权因子口径不一致——这个偏差会让你所有基于复权价格的回测结果不可复现。

回测价格到实盘价格的映射验证

回测信号用 kline 的复权 close 计算,实盘下单用 ticker 的原始 last_price 执行。验证两者的映射关系是上线前的最后一步:

def get_ticker(symbol: str):
    """
    获取实时快照。
    ticker 数据直接在 data 数组中,用 last_price 和 timestamp。
    """
    url = f"{BASE}/market/ticker"
    resp = requests.get(
        url,
        headers={"X-API-Key": API_KEY},
        params={"symbols": symbol},              # ticker 用复数 symbols
        timeout=5
    )
    data = resp.json()

    if data["code"] == 3001:
        retry_after = resp.headers.get("Retry-After")
        import time; time.sleep(int(retry_after) if retry_after else 1)
        return get_ticker(symbol)
    if data["code"] == 1001:
        raise PermissionError(f"鉴权失败(1001): {data.get('message')}")
    if data["code"] != 0:
        raise RuntimeError(f"Ticker错误 code={data['code']}: {data}")

    items = data.get("data", [])                 # ticker 数组直接在 data 下
    return items[0] if items else None


# 验证回测 close 与实盘 last_price 的映射关系
ticker_data = get_ticker(symbol)
if ticker_data:
    latest_kline = klines_raw[-1]
    print(f"回测最新 close={latest_kline['close']}, 时间={latest_kline['time']}")
    print(f"实盘 last_price={ticker_data['last_price']}, 时间戳={ticker_data['timestamp']}")
    # 若数据源提供同一时钟源,两者时间差可用来计算复权因子映射系数

核心是验证两个端点的价格差异来自复权因子而非时钟漂移,不是调通 API。 若数据源 kline 的 time 和 ticker 的 timestamp 是同一毫秒 UTC 时钟源,排除时差干扰后,两者之间的差就是复权因子——这个差值决定了你的信号价到撮合价的映射系数。

除权除息日识别思路

在实际工程中,识别除权日不能靠价格跳空幅度猜测,必须依赖公司行为日历。伪代码逻辑如下:

# 伪代码:除权日识别与分钟线信号过滤
# corporate_calendar 结构: {"2025-06-15": "10送10", "2025-04-20": "每10股派5元"}
def should_skip_signal(symbol: str, trade_date: str, trade_time: str,
                       corporate_calendar: dict, is_intraday: bool) -> bool:
    if trade_date not in corporate_calendar:
        return False
    if is_intraday and trade_time < "09:35":
        # 纯日内策略:除权日开盘后前 5 分钟跳过,避免跳空假突破
        return True
    return False

核心是依赖公司行为日历,不是猜跳空幅度。 除权日是提前公告的已知事件,策略引擎应把它当作和停牌一样的已知事件处理,而不是被动防御。

统一基座如何收敛这些问题(以 TickDB 为例)

做 A 股回测最头疼的不是策略调参,是复权数据的一致性校验。

不同数据源的复权因子口径可能不同——有的用流通市值加权,有的用总市值加权。同一只股票在两次送转后,两个源的复权价格能差出 0.8%。回测在两个源上跑了两次,收益差了 3 个百分点。你不知道哪个是对的,只能做交叉验证。

分钟线复权的问题更隐蔽。日线复权因子无法直接用在分钟线上——日内跳空是瞬间完成的,如果把它均摊到 240 根分钟线上,每根都偏了几个点。高频策略在这些“微偏”分钟线上反复触发,回测收益虚高。

实盘信号的价格基准更是一道硬坎。回测用复权价算出的止损线,到实盘里要用原始市场价挂单——这两个价格之间差的就是复权因子。如果忘了映射,止损单挂出去永远成交不了。

困境没有统一基座时统一基座思路对回测的改善
复权因子口径不一致两个数据源用不同加权方式,复权价格差 0.8%,回测收益差 3%数据源的日线与分钟线共享公司行为数据库,复权基准唯一消除跨源复权偏差,回测结果唯一
分钟线无可靠复权日线因子均摊到240根分钟线,每根偏几个点,高频信号虚高若数据源直接提供分钟级复权序列,不复用日线因子分钟线信号不再被均摊误差污染
信号价与撮合价两个基准回测复权价 ≠ 实盘原始价,止损单永远成交不了数据源若提供统一字段体系和时钟源,便于做价格映射回测到实盘的价格转换有确定性
多源交叉验证成本每个源单独写字段映射和边界逻辑REST 与 WebSocket 统一鉴权、统一字段体系验证代码从多套收敛为一套

你的回测系统是如何保存公司行为的生效时间的?

你使用的是事后复权序列,还是 point-in-time 可复现序列?欢迎分享你的复权因子校验和信号价映射方案。

通过 TickDB API 获取实时行情数据

一个 API 接入外汇、加密货币、美股、港股、A股、贵金属和全球指数的实时行情。支持 WebSocket 低延迟推送,免费开始使用。

免费领取 API Key查看 API 文档

相关文章