摘要:同一只A股、同一段 250 个交易日,只改 adjust 参数,累计收益从 −29.92% 变成 +4.50%,差 34.42 个百分点。本文用实测拆清五种复权取值:不传参数默认给不复权,除权日跳空会被当成真实下跌(实测 −36.70% 对实际 −5.60%);前复权按该K线之后的因子调整,未来每次除权都会改写历史,回测不可复现;后复权历史不动,可复现。复权因子可自行复算,实测 250 根全部吻合。读者为搭回测框架的量化开发者与排查「回测稳、实盘偏」的研究员,适用回测取数、历史数据落库与数据源迁移校验;价值在于把复权语义固定在取数层,交付五条验收规矩与一段自检代码。
我在 10 月 19 日取了寒武纪(688256.SH)近 250 个交易日的日K,只改 adjust 参数,取了三次。
累计收益率分别是:
| adjust | 首根收盘 | 末根收盘 | 累计收益 |
|---|---|---|---|
none(不复权) | 1325 | 928.51 | −29.92% |
forward(前复权) | 888.544946 | 928.51 | +4.50% |
backward(后复权) | 1325 | 1384.595969 | +4.50% |
读法:同一区间、同一标的,三次调用只差一个参数。实测取数日 2026-10-19。
一个亏 30%,两个赚 4.5%。这不是数据质量问题——三条序列都是对的,差别在于它们各自假设了一套不同的持仓成本。
区间里有一次除权,发生在 2026 年 5 月 8 日。不复权序列在那天从 1868 跌到 1182.53,−36.70%。而同一天,前复权序列是从 1252.68 跌到 1182.53,−5.60%。
−36.70% 那个数字不是跌出来的,是股本变了。但它在一列收盘价里和真实暴跌长得完全一样。
这篇适合谁:
- 搭回测框架、要保证结果能重跑的量化开发者;
- 在排查"回测稳、实盘偏"的策略研究员——在怀疑策略逻辑之前,先确认价格序列本身没变过;
- 做数据层的工程师:取数契约里该写清哪些参数。
不写代码的读者,读完前三部分就够了,第四部分可以直接转给技术同事。
一、不传 adjust,默认给的是不复权
先说最容易出事的一点。我试了一次不带 adjust 的请求,返回体里的 adjust 字段回显是 none。
也就是说,没写这个参数的取数代码,拿到的是带除权跳空的原始价格序列——上面那条 −29.92% 的曲线。不会报错,字段齐全,时间戳连续,价格范围合理。所有常规校验都会通过。
顺带一个发现:这个参数实际有五个合法值,不是三个。传一个非法值 qfq 时,接口返回的错误信息把全部取值列了出来:
{"error":"2001","message":"invalid adjust value: qfq, must be one of:
forward, backward, forward_additive, backward_additive, none","code":"2001"}
forward_additive 和 backward_additive 是加法复权。我把五个值都跑了一遍:
| adjust | 首根收盘 | 末根收盘 | 累计收益 |
|---|---|---|---|
none | 1325 | 928.51 | −29.92% |
forward | 888.544946 | 928.51 | +4.50% |
backward | 1325 | 1384.595969 | +4.50% |
forward_additive | 888.255034 | 928.51 | +4.53% |
backward_additive | 1325 | 1384.9799 | +4.53% |
读法:乘法复权和加法复权差 0.03 个百分点,来自除权的处理方式不同。实测取数日 2026-10-19。
乘法和加法的差异在这只标的上只有 0.03 个百分点,但它会随回溯期和除权次数累积。重点不是哪个更准,是同一份研究里必须固定用一种,否则不同时间跑出来的结果没法比。
二、两种复权的因子应用方向相反,所以只有一条会变
官方文档对两种复权的定义是这样的:
复权价 = 原始价 × factor_a + factor_b。前复权按时间顺序应用该K线之后的因子,后复权逆序应用该K线及之前的因子。
注意方向:前复权用的是这根K线之后发生的事件,后复权用的是这根K线及之前发生的事件。
"之后"这个词有个直接后果:未来还会发生的除权事件会改写今天这根K线的历史价格。所以前复权序列不是静态数据,每次新的分红送股落地,整条历史都要按新因子重算一遍。
这一点在实测数据里看得很清楚。同一只标的、同一区间:
| 序列 | 首根收盘 | 末根收盘 |
|---|---|---|
| 不复权 | 1325 | 928.51 |
| 后复权 | 1325(与不复权相同) | 1384.595969 |
| 前复权 | 888.544946(被因子压低) | 928.51(与不复权相同) |
读法:后复权的首根等于原始价,说明历史没动;前复权的末根等于原始价,说明它把基准对齐到了现价。
后复权的历史价格就是原始价格,所以它不会变。前复权把历史整体压低到和现价同一基准上,而"现价的基准"会随每次除权移动。
后果是回测不可复现。同一个策略、同一段区间、同一套参数,隔了一次除权再跑,输入数据已经不同,收益曲线自然对不上。这个变化没有任何信号:不报错,不改字段,文件大小可能都差不多。
这就是"回测稳、实盘偏"里最难查的一种。排查通常从策略逻辑、滑点、手续费开始,很少有人回头怀疑价格序列——毕竟上周它还是对的。
三、复权因子可以自己复算,而且能对上
既然文档给了公式,这件事可以验证。
我取了这只标的的复权因子,区间内只有一条除权记录,2026 年 5 月 8 日:
| adjust | factor_a | factor_b |
|---|---|---|
forward | 0.670599958959282512 | 0 |
backward | 1.491202 | 0 |
两个 factor_a 相乘等于 1.000000000,互为倒数——这正是"方向相反"在数值上的体现。
然后按公式复算:把除权日之前的每一根原始收盘价乘上 forward 的 factor_a,和接口返回的前复权序列逐根比对。250 根全部吻合,误差在小数第六位以内。 后复权同理,用 backward 的 factor_a 乘除权日及之后的价格,也是 250 根全中。
这件事的实用价值在第四部分第四条:有了因子和原始价,任何时刻都能复算出当时那条前复权序列。存因子比存一份会过期的复权价可靠。
四、回测取数层的五条规矩
规矩一:adjust 显式传值
默认值是 none,而大多数回测要的不是它。显式写出来,代码读起来也能看出这份数据是什么。注意只接受那五个值,qfq 这类写法返回 2001 错误。
规矩二:落库时记下序列类型
加一列记录,或者写进表名、文件名。几个月后回头看一份历史数据,你需要能立刻判断它是哪一条,而不是重新取一遍比对。
规矩三:要可复现就用后复权
需要跨时间比对、要求三个月后重跑出同样曲线的研究,用 backward,它的历史不会变。只做当期回测、且需要价格和行情软件对得上的,用 forward,但要接受它会随除权重算。
规矩四:用前复权就把因子和取数日期存下来
因子表很小,一次除权一条记录。存了因子和原始价,就能复算出任意时点的前复权序列——上一节已经验证过这条路走得通。
规矩五:回测和实盘的价格变量分开命名
回测用复权序列算收益,实盘下单参照实际成交价,这本来是两个数。用不同的变量名区分,别共用一个 price。
落成一张清单:
| 检查项 | 怎么验 | 不做的后果 |
|---|---|---|
| adjust 是否显式传值 | 搜一遍取数代码里的K线调用 | 默认拿到不复权,收益率差几十个百分点 |
| 落库是否记录序列类型 | 查表结构或文件命名 | 历史数据无法判断来源 |
| 可复现研究是否用后复权 | 确认研究周期是否跨除权 | 回测结果无法重现 |
| 前复权是否存了因子和日期 | 查是否有因子表 | 旧序列无法复算 |
| 回测与实盘价格是否分开 | 搜 price 变量的用处 | 成本假设和实盘不一致 |
读法:前两项是取数层的最小要求,后三项按研究类型取舍。
另外两个会和复权叠加的坑,顺手一起检查。日K的时间戳取各市场当地零点——我实测的这根日K时间戳 1792339200000 对应北京时间 10 月 19 日 00:00,换成 UTC 是 10 月 18 日 16:00,前端按 UTC 格式化会显示成前一天。还有返回顺序不统一,部分品种按时间倒序,不排序就做滚动计算,结果是镜像的。这两个误差和复权偏差叠在一起,排查时很难分清是哪一层出的问题。
怎么做
TickDB 以 REST 和 WebSocket 连接实时与历史行情,并通过统一的数据入口支持研究、监控、产品和 AI 工作流。
文中全部数字来自它的K线接口:GET /market/kline,adjust 五个取值,一次最多 1000 根;复权因子来自 GET /market/kline/ex-factors,返回 factor_a 和 factor_b,支持A股、港股、美股(Free 套餐不提供这个接口)。因为五个取值在同一个接口上切换,上面那组对照才能在同一时刻、同一区间完成,不用拼接多个来源。
下面这段代码先打印三条序列的累计收益,再用因子复算校验前复权序列。输出就是本文第一张表和第三部分的结论:
# 测试环境:Python 3.11 / 2026-10-19(用 688256.SH 近 250 个交易日的真实返回验证)
# 用途:确认自己拿到的是哪条复权序列,并用复权因子复算校验
import os
from decimal import Decimal
import requests
BASE = "https://api.tickdb.ai/v1"
HEAD = {"X-API-Key": os.environ["TICKDB_API_KEY"]} # Key 只放服务端
def call(path, **params):
r = requests.get(f"{BASE}{path}", params=params, headers=HEAD, timeout=20)
body = r.json()
if body.get("code") != 0: # HTTP 200 不代表成功
raise RuntimeError(f"{body.get('code')}: {body.get('message')}")
return body["data"]
def closes(symbol, adjust, limit=250):
data = call("/market/kline", symbol=symbol, interval="1d",
adjust=adjust, limit=limit)
bars = sorted(data["klines"], key=lambda b: int(b["time"])) # 顺序不保证,先排
return [(int(b["time"]), Decimal(b["close"])) for b in bars] # 价格是字符串
def cumulative(series):
return (series[-1][1] / series[0][1] - 1) * 100
def verify_factor(symbol, limit=250):
"""用 forward 因子复算前复权:除权日之前的原始价 × factor_a 应等于前复权价"""
raw, fwd = closes(symbol, "none", limit), closes(symbol, "forward", limit)
ex = call("/market/kline/ex-factors", symbol=symbol)["data"][symbol]
ok = 0
for ts, raw_close in raw:
calc = raw_close
for e in ex:
if e["adjust"] == "forward" and ts < e["timestamp"]:
calc *= Decimal(str(e["factor_a"]))
calc += Decimal(str(e["factor_b"]))
if abs(calc - dict(fwd)[ts]) <= Decimal("0.000001"):
ok += 1
return ok, len(raw)
if __name__ == "__main__":
sym = "688256.SH"
for adj in ("none", "forward", "backward"):
s = closes(sym, adj)
print(f"{adj:9s} 首 {s[0][1]:>12} 末 {s[-1][1]:>12} 累计 {cumulative(s):+7.2f}%")
ok, total = verify_factor(sym)
print(f"因子复算吻合 {ok}/{total} 根")
设置环境变量 TICKDB_API_KEY 后运行,Key 只放在服务端。我这边跑出来是三条序列 −29.92%/+4.50%/+4.50%,因子复算 250/250 吻合。换成你自己的标的跑一次,就知道自己平时用的是哪一条。
前复权和后复权的因子应用方向相反,这一条决定了哪条序列可复现:后复权的历史价格就是原始价格,不会变;前复权的历史会被未来的除权事件改写。要做能重跑的研究就用后复权;要用前复权,把因子和取数日期一起存下来。
今天可以搜一遍自己的取数代码,看K线调用里有没有写 adjust。没写的话默认是不复权——用上面那段代码跑一次,看看你的回测收益率和复权后差多少。
参考资料
- TickDB 官方文档,K线接口
adjust参数与复权因子接口说明。docs.tickdb.ai - 寒武纪(688256.SH)近 250 个交易日日K与复权因子:TickDB 实测,2026 年 10 月 19 日,五个 adjust 取值各取一次。
- 上海证券交易所,除权除息业务规则。sse.com.cn
