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 总市值加权 | 同一策略在两个源上回测结果不同 |
⑤ 怎么优化:三件事
- 回测全部历史时,若无法获得 point-in-time 价格序列,至少要知道自己用的是事后复权数据,并在结果中标注数据偏差方向。
- 信号计算用复权价,下单价格映射回原始市场价。 均线、布林带、RSI 这些相对指标用前复权算没问题——它们是线性不变的。但你的止损单、限价单必须写原始市场价,否则挂出去成交不了。
- 纯日内策略、不跨除权日持仓时,直接用不复权价格做回测,并在除权日做标记排除开盘跳空时段。 持仓跨除权日则必须做复权。
一个帮你记住区别的类比:前复权和后复权的差异像 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 文档