python量化:行情数据回溯

行情数据版本化与 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 专题、多家数据治理博客都反复强调)。核心结论只有一句:普通数据库只存"现在认为正确的值",而回测需要的是"当时能拿到的那个值"。

隐蔽来源主要有三类:

  1. 复权因子修正:分红、送转、拆细后,数据商常在某日统一回填历史复权因子。你今天拉到的"复权价",未必是三个月前同一次回测能拿到的。
  2. 停牌 / 缺失回填:某日数据缺失,事后补全。回测若用了补全后的值,等于提前知道了"那天有数据"。
  3. 成分股 / 指数调整:某股票被调入沪深 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),让"数据可信"从单点检查变成自动化门禁。

本文所有代码与示例仅用于数据结构与接入方式演示,文中运行结果基于演示证书返回的样本数据,真实效果以你的正式证书在交易时段拉取为准。

文档接入

相关推荐
今年下半年1 小时前
AI智能体项目实战记录(02):初始化 Python 项目
开发语言·人工智能·python·fastapi
Jlzn88881 小时前
华东电芯分选通道数怎么配才不堵线
java·人工智能·架构·自动化·制造
Williamtym1 小时前
PickGo Lab实践:用浏览器、Python和MCP串起机器人搬运仿真
开发语言·python·机器人
香吧香1 小时前
GRPC连接机制---长连接分析
java·网络与传输协议
DongQiShanRen1 小时前
六根硬件映射(四):归属澄清、两处不一致与卷十一总表
开发语言·人工智能·算法·rust·哈希算法·集成学习
CS创新实验室1 小时前
Python 对象“死“了之后去了哪?——引用计数与分代垃圾回收
开发语言·jvm·python
CCblandy1 小时前
雪花 ID 四个边界实测:时钟回拨重号 500 个,前端 JSON.parse 直接丢精度
后端·python·算法·性能优化·架构
我是人✓1 小时前
SpringBoot项目的五种创建方式
java·spring boot·后端
quantdash_cc2 小时前
Pandas 处理分钟级股票行情:从原始数据清洗到 K 线聚合
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash