回测结果不可信,原因很少出在策略逻辑上,更多出在数据管道。一个看起来能跑通的回测,可能因为复权处理错误、时间对齐偏差或缺失值填充,得出了完全失真的结论——而且这些错误不会报错,只会让回测曲线变好看。
这篇文章讲回测数据管道的四个阶段,以及每个阶段最容易出错的地方。
回测数据管道的四个阶段
| 阶段 | 目标 | 主要风险 |
|---|---|---|
| 采集与粒度选择 | 拿到正确粒度的原始数据 | 粒度不足导致信号丢失 |
| 复权处理 | 价格序列可比 | 拆股分红造成虚假跳空 |
| 时间对齐 | 多标的时间轴一致 | 时区与交易日历错位 |
| 存储与查询 | 高效可复现地取数 | 查询口径不一致 |
四个阶段的顺序不能颠倒——先采集后复权,再对齐,最后存储。反过来做会导致大量重复计算。
阶段一:粒度选择决定了策略的上限
粒度选择的判断标准很简单:策略信号的产生频率,必须高于数据粒度。
- 按日调仓:日线数据足够,且体积小、计算快。
- 日内策略:至少需要分钟线,具体取决于信号周期。
- 高频与微观结构策略:必须用 tick 数据,且要关注 tick 级的成交方向。
一个常见的错误是「用分钟线回测分钟级信号」。这样做会引入系统性偏差:分钟线只保留该分钟的 OHLC 四个点,策略在分钟内的入场与出场价格无法还原,回测结果通常比实际乐观。
另一个错误是反过来——用 tick 数据回测日频策略。这不会让结果更准,只会让计算量和存储成本高出一个数量级。
阶段二:复权处理,最容易出错的一步
股票发生拆股或分红时,价格会「跳空」。如果不做复权处理,回测会把这次跳空当成一次真实的暴跌或暴涨,从而产生虚假信号。
复权有前复权与后复权两种方式,选择取决于回测类型:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 前复权 | 以最新价格为基准向前调整历史价格 | 技术指标计算、图表展示 |
| 后复权 | 以上市首日为基准向后调整 | 长期收益计算、回测 |
| 不复权 | 保留原始成交价 | 需要真实成交价的场景,如撮合模拟 |
回测场景通常推荐后复权,因为它不会因为新增数据而改变历史价格,保证回测结果可复现。前复权的历史序列会随最新价格变化而变化,用前复权数据做的回测在不同时间跑出来的结果可能不同——这是很隐蔽的复现性问题。
还需要注意:复权因子必须来自可靠数据源,且要覆盖所有公司行为(拆股、合股、分红、配股)。漏掉任何一个都会在历史上留下一个假跳空。
阶段三:时间对齐与缺失处理
多标的回测时,时间对齐的核心原则是:不要用「向前填充」掩盖缺失。
三种缺失需要区别对待:
- 休市:正常情况,从时间轴上移除即可,不应该算作缺失。
- 停牌:该标的当日无交易。回测中应保持仓位不变、标记为不可交易,而不是填充价格。
- 数据缺失:数据源问题,需要告警并补数,绝不能静默填充。
把停牌当成缺失并向前填充价格,会让回测产生「零波动区间的虚假持仓」,从而低估风险、高估收益。这是回测失真最常见的原因之一。
阶段四:存储与查询口径
最后一步的目标是可复现。同一个回测,在不同时间、不同机器上跑,应该得到完全相同的结果。要做到这一点需要:
- 数据版本化:每次回测记录所用数据的快照标识或时间范围,而不是「取最新数据」。
- 查询口径固定:明确复权方式、时区、缺失处理规则,并写进代码而不是文档。
- 列式存储:回测通常只需要少数几列,列式格式能显著减少扫描量。
- 保留原始数据:聚合数据可以快速迭代,但原始数据是重算的依据,不应丢弃。
三个最常见的回测数据错误
- 幸存者偏差:只用当前仍在交易的标的回测,退市与并购的标的被排除,结果系统性偏乐观。
- 未来函数:用当日收盘价生成信号,又用当日开盘价成交。这在真实交易中不可能实现。
- 填充掩盖缺失:用向前填充处理停牌,人为压低波动率。
这三个错误都不会让回测报错,只会让曲线变好看。所以回测系统的第一优先级不是「跑得快」,而是「能发现自己的数据问题」。
Python 实现:回测数据准备
import httpx
import pandas as pd
API_KEY = "your_api_key"
BASE = "https://api.tiqex.io/api/v1/market"
def fetch_candles(symbol: str, market: str, interval: str = "1d") -> pd.DataFrame:
"""拉取 K 线并规范化为 UTC 索引的 DataFrame。"""
resp = httpx.get(
f"{BASE}/{market}/kline",
params={"symbols": symbol, "interval": interval},
headers={"X-API-Key": API_KEY},
timeout=30,
)
rows = resp.json()["data"]
df = pd.DataFrame(rows)
# 1) 时间统一为 UTC,避免跨市场错位
df["ts"] = pd.to_datetime(df["timestampMs"], unit="ms", utc=True)
df = df.set_index("ts").sort_index()
# 2) 校验时间戳单调递增,出现回退说明数据有问题
if not df.index.is_monotonic_increasing:
raise ValueError(f"{symbol} 时间戳非单调递增,需人工检查")
# 3) 只保留回测需要的列,降低扫描量
keep = [c for c in ("open", "high", "low", "close", "volume") if c in df.columns]
return df[keep]
def apply_backward_adjustment(df: pd.DataFrame, factors: pd.Series) -> pd.DataFrame:
"""后复权:以上市首日为基准向后调整,保证历史价格可复现。"""
aligned = factors.reindex(df.index, method="ffill")
out = df.copy()
for col in ("open", "high", "low", "close"):
if col in out.columns:
out[col] = out[col] * aligned
return out
def mark_trading_gaps(df: pd.DataFrame, calendar) -> pd.DataFrame:
"""把休市 / 停牌 / 缺失分开标记,而不是一律向前填充。"""
expected = pd.DatetimeIndex(calendar.sessions_in_range(df.index[0], df.index[-1]))
missing = expected.difference(df.index)
if len(missing) == 0:
return df
# 用显式标志记录,不填充价格
df = df.reindex(df.index.union(missing))
df["no_trade"] = df["close"].isna()
return df
candles = fetch_candles("AAPL", "equity")
print(f"行数 {len(candles)} 区间 {candles.index[0].date()} → {candles.index[-1].date()}")
print(candles.tail(3))
这段代码体现了三个关键约束:时间统一为 UTC、时间戳单调性校验、缺失用显式标志而非填充。apply_backward_adjustment 用后复权保证历史序列不随新数据变化,这是回测可复现的前提。
常见问题
回测应该用前复权还是后复权?
通常推荐后复权。后复权的历史价格不会随新增数据变化,保证回测结果可复现;前复权的历史序列会随最新价格调整,同一策略在不同时间跑出的结果可能不同。
停牌期间应该怎么处理?
保持仓位不变并标记为不可交易,绝对不要向前填充价格。填充会产生零波动区间的虚假持仓,低估风险并高估收益。
回测需要 tick 数据吗?
取决于策略周期。日频与分钟级策略用 K 线数据即可;只有高频与微观结构策略才需要 tick 数据。用 tick 回测日频策略只会增加成本,不会提高准确性。
为什么同样的回测跑两次结果不一样?
通常是数据口径不固定:用了「取最新数据」而不是固定快照,或者用了前复权数据。解决办法是数据版本化,并把复权方式、时区、缺失规则写进代码。
怎么避免未来函数?
严格区分「信号生成时点」与「成交时点」。信号只能使用该时点已经可得的数据,成交价必须用信号之后的价格,并在回测框架里显式约束这一点。
下一步
回测数据管道的核心是三个词:可复现、不填充、显式标记。完整的数据准备流程见 回测数据准备指南,复权机制的细节见 拆股复权,K 线字段定义见 K 线 OHLCV 字段,场景方案可参考 回测方案。历史数据的拉取量直接影响 套餐额度,建议先做一次量级估算。