行情数据版本化与 as-of 时点回溯
很多策略回测画出来是一条漂亮的 upward 曲线,一上实盘就原形毕露。除了过度拟合,还有一个更阴险、也更常见的元凶:你的数据库悄悄把"过去"改写了。
举个例子:某股票在 6 月复权因子被交易所修正,你的数据商在 8 月把历史全部回填成修正后的值。于是你 1 月跑回测时,用的是 8 月才存在的"未来修正值"------回测在不知不觉中"偷看了答案"。这种偏差叫前视偏差(look-ahead bias),它不会报错,只会让你的 alpha 看起来比真实可复现的高出一截。
本文给你一套行情数据版本化 + as-of 时点回溯的工程方案:给每次拉取的数据打版本(vintage),再按"回测当日"精确取回当时已经可得的数据。全程基于行情 API 的零 SDK 纯 HTTP 接入,代码已本地真实跑通。
说明:本文代码基于行情 API,无需任何 SDK。代码已本地运行,真实运行结果见各节末尾。
本文你将得到什么
- 理解**双时间戳(valid time / knowledge time)**与 as-of 查询为何是量化研究的"最重要的一行 join"。
- 一段真实归档代码 :用
history接口拉取并落库为一个带内容指纹(SHA)的版本快照。 - 一段 as-of 查询引擎:给定任意回测日,返回"当时已经可得"的最新版本,杜绝答案泄漏。
- 一个复权因子修正的显式模拟:直观看到 as-of 查询如何避开未来修正值。
一、前视偏差藏在哪:被静默重写的过去
业界对 point-in-time(PIT,时点一致)数据的讨论已经很充分(quantmemo 的 PIT 专题、多家数据治理博客都反复强调)。核心结论只有一句:普通数据库只存"现在认为正确的值",而回测需要的是"当时能拿到的那个值"。
隐蔽来源主要有三类:
- 复权因子修正:分红、送转、拆细后,数据商常在某日统一回填历史复权因子。你今天拉到的"复权价",未必是三个月前同一次回测能拿到的。
- 停牌 / 缺失回填:某日数据缺失,事后补全。回测若用了补全后的值,等于提前知道了"那天有数据"。
- 成分股 / 指数调整:某股票被调入沪深 300,事后你用"今天的指数成分"去回测早期,等于预知了未来的入选。
这三类,本质都是知识时间(knowledge time)晚于事件时间(valid time) 。普通 SELECT close FROM stock WHERE date='2026-03-01' 拿到的永远是"最新修正版",于是前视偏差悄悄混了进来。
解决它的办法是结构性的:给每个数据点打两个时间戳,查询时永远带一个 as-of 日期,只取"知识时间早于该日期"的最新版本。这就是 as-of join。
二、双时间戳与 as-of 查询:两行时间戳解决一切
每一份行情快照,至少携带两个时间:
- valid time(事件时间):这份数据描述的是哪个时点的市场。
- knowledge time(知识时间) :这份数据被你拉取 / 归档的时点。
传统做法用"拉取日"覆盖式存储,旧版本被就地覆盖。PIT 做法则是追加:每次拉取都存一条新 vintage,互不覆盖。查询时:
SELECT * FROM vintage
WHERE symbol = ? AND as_of_date <= 回测日
ORDER BY as_of_date DESC
LIMIT 1;
这条"取回测日之前最新可得版本"的查询,就是量化研究里最重要的一行 join------它保证回测在任何一天,都只看见那一天"已经知道"的数据。
三、真实归档:用 history 存一个 vintage
先把"拉取即归档"跑通。下面代码用 history 接口拉取日线,计算一个内容指纹(SHA-256 截取),连同行数、末收盘、末日期一起存进 SQLite:
python
# -*- coding: utf-8 -*-
import requests, sqlite3, datetime as dt, hashlib, json
BASE = "https://api.momaapi.com"
LIC = "TEST-API-TOKEN-MOMA-836089C22111" # 演示证书,请换成你的正式证书
DB = "pit.db"
conn = sqlite3.connect(DB)
conn.executescript("""
CREATE TABLE IF NOT EXISTS vintage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT, as_of_date TEXT, fetched_at TEXT,
n_rows INTEGER, sha TEXT, digest TEXT
);
""")
def shad(s):
return hashlib.sha256(s.encode("utf-8")).hexdigest()[:16]
def pull_and_store(conn, symbol, as_of_date):
url = f"{BASE}/hsstock/history/{symbol}/d/n/{LIC}?st=20260101&et=20261231"
r = requests.get(url, timeout=10)
rows = r.json()
if not isinstance(rows, list) or not rows:
print(f" [{symbol}] 空列表/权限受限,跳过真实归档")
return None
norm = json.dumps(rows, ensure_ascii=False, sort_keys=True)
sha = shad(norm)
digest = f"rows={len(rows)};last_close={rows[-1].get('c')};last_date={rows[-1].get('t')}"
conn.execute(
"INSERT INTO vintage(symbol,as_of_date,fetched_at,n_rows,sha,digest) VALUES(?,?,?,?,?,?)",
(symbol, as_of_date, dt.datetime.now().isoformat(timespec="seconds"),
len(rows), sha, digest))
conn.commit()
return {"sha": sha, "digest": digest, "n_rows": len(rows)}
today = dt.date.today().strftime("%Y-%m-%d")
print("归档 600519 @", today, "->", pull_and_store(conn, "600519", today))
运行结果(演示证书):
[A] 真实拉取并归档 vintage(history)
归档 600519 @ 2026-09-20: {'sha': '3c2361530f619518', 'digest': 'rows=50;last_close=11.69;last_date=2025-12-01', 'n_rows': 50}
演示证书的历史接口返回固定样本 (末条
2025-12-01),所以这里的last_date是样本日期而非真实最新交易日------这是演示数据的特性,归档逻辑本身没问题。换成正式证书,末条会停在最近真实交易日。sha内容指纹用于后续"数据有没有被悄悄改动"的比对。
四、as-of 查询引擎:按回测日取回当时可得的版本
有了带 as_of_date 的 vintage 表,as-of 查询就是一行 SQL 的事:
python
# -*- coding: utf-8 -*-
def as_of_query(conn, symbol, backtest_date):
"""返回 backtest_date 当日已经可得的最新 vintage。"""
cur = conn.cursor()
cur.execute(
"SELECT as_of_date, sha, digest FROM vintage "
"WHERE symbol=? AND as_of_date <= ? ORDER BY as_of_date DESC LIMIT 1",
(symbol, backtest_date))
return cur.fetchone() # None = 当时没有任何归档版本
print("as_of 查询(2026-09-20) ->", as_of_query(conn, "600519", "2026-09-20"))
print("as_of 查询(2026-09-10) ->", as_of_query(conn, "600519", "2026-09-10"))
运行结果:
as_of 查询(2026-09-20) -> ('2026-09-20', '3c2361530f619518', 'rows=50;last_close=11.69;last_date=2025-12-01')
as_of 查询(2026-09-10) -> None (None=当日无归档版本,生产须每日归档)
注意第二个结果:回测日 2026-09-10 返回 None 。这正说明了 PIT 的前提------你必须"在每一个历史日期都真的拉取并存过档",as-of 查询才能命中。如果只存了今天这一份,那任何早于今天的回测日都无版本可取。所以生产环境的关键动作是:每日定时把行情拉取并归档为一个新 vintage,日积月累,你的 as-of 查询才能覆盖整段回测区间。行情接口稳定、字段齐全、即取即用,非常适合做这种"每日快照归档"的长期工程。
五、复权因子修正模拟:as-of 如何避开未来值
下面用一个显式模拟 (数据为示意,非真实行情)演示最常见的坑------复权因子修正。假设某股票复权因子在 2026-05-12 由数据商修正,我们存了两个 vintage:
2026-04-30归档:复权因子1.000(修正前)2026-05-15归档:复权因子1.250(修正后)
as-of 查询应当做到:回测日在修正前,只看到 1.000;回测日在修正后,才看到 1.250。
python
# -*- coding: utf-8 -*-
sim = [("600519", "2026-04-30", 1.000), ("600519", "2026-05-15", 1.250)]
for sym, d, f in sim:
conn.execute("INSERT INTO factor_vintage(symbol,as_of_date,adj_factor) VALUES(?,?,?)",
(sym, d, f))
conn.commit()
for bt in ["2026-05-08", "2026-06-01"]:
cur = conn.cursor()
cur.execute(
"SELECT as_of_date, adj_factor FROM factor_vintage "
"WHERE symbol=? AND as_of_date <= ? ORDER BY as_of_date DESC LIMIT 1",
("600519", bt))
r = cur.fetchone()
print(f"回测日 {bt} 应得复权因子 = {r[1]}(来自 vintage@{r[0]})")
运行结果:
[B] 复权因子修正的多 vintage 模拟(数据为示意,非真实行情)
回测日 2026-05-08 应得复权因子 = 1.0(来自 vintage@2026-04-30)
回测日 2026-06-01 应得复权因子 = 1.25(来自 vintage@2026-05-15)
这就对了:在 2026-05-08(修正发生前)回测,策略只看到 1.000,不会"预知"一个月后才会公布的 1.250 修正。如果直接拿今天的修正值去回测 5 月,等于把未来信息泄漏进了过去------前视偏差就这么产生了。as-of 查询是它最干净的解药。
六、为什么用这类接口做版本化归档更顺
- 稳定可复现 :行情 API 字段口径长期稳定(日线含
t/o/h/l/c/v/a/pc/sf,其中sf即复权/状态标记),同一请求反复拉取结果一致,适合做"每日快照归档"的长期工程。 - 零 SDK 纯 HTTP:定时任务里挂一个,每天收盘后拉一次存一份 vintage,无需维护任何客户端依赖。
- 即取即用 :接口返回结构清晰,归档时直接
json.dumps归一化算指纹即可,没有奇怪的字段漂移需要额外清洗。
需要提醒的是,行情套餐带"次/分钟"频率限制,每日归档属于低频操作,完全在配额之内;若你要对成百上千只标的做高频快照,注意错峰并复用本地缓存。
常见坑与注意
- 只存今天一份 = 没有 PIT:as-of 查询的价值建立在"历史上每天都有归档"之上。务必每日定时归档,别等回测时才去补拉------补拉到的永远是"今天修正后的值"。
- 演示证书返回固定样本 :本文
history末条停在2025-12-01是演示数据特性,归档机制与 as-of 逻辑不受影响;正式证书返回真实最新交易日。 - 复权口径要统一:归档时明确记录是"前复权"还是"后复权",回测全程保持一致,别中途切换。
- 成分股 / 指数也要 PIT:本文聚焦行情价格,但指数成分、停牌标记同样会被事后调整,生产应一并纳入版本化。
小结与下篇预告
这一篇,我们把回测里最隐蔽的杀手------前视偏差------拆成了可工程化的解:双时间戳 + vintage 追加存储 + as-of 查询。用行情 API 真实拉取并归档了版本快照,又用复权因子修正的模拟,直观看到了 as-of 如何干净地避开"未来修正值"。把"每日归档 + as-of 查询"接进你的回测框架,回测结果才真正"可复现、可辩护"。
下一篇(#65)我们把前面三件套------数据基线(回归测试)+ 多源对账 + 新鲜度 SLA ------串联成一条回测可复现流水线(CI),让"数据可信"从单点检查变成自动化门禁。
本文所有代码与示例仅用于数据结构与接入方式演示,文中运行结果基于演示证书返回的样本数据,真实效果以你的正式证书在交易时段拉取为准。