前一篇讲了如何用 1 分钟线本地合成任意周期 K 线;本篇往前一步------当你已经能稳定拉数、能合成 K 线,下一个躲不开的问题是:今天的回测,三个月后还能跑出一样的结果吗? 我们把它工程化进 CI,用三道门禁自动卡点。
一、痛点:为什么三个月前的回测今天跑不出同样的结果
做量化的人大多经历过这种事:策略上周跑出年化 25%、夏普 1.8,信心满满准备上实盘;结果换台机器、或者隔了几周重跑,曲线变了,甚至变难看了。第一反应往往是「策略失效了」,但真正的原因常常不在策略,而在输入数据悄悄变了。
社区里把破坏回测再现性的因素归纳得很全(quant67《回测引擎设计》),至少有六类:
- 时间戳精度漂移 ------历史数据从供应商下载是毫秒精度,落到 pandas 被转成
datetime64[ns]丢了时区,再resample('1min')时label='left'还是label='right'不一致,同一段数据两次回测变成两段不同序列。 - 浮点累加顺序------组合净值几万笔成交逐笔累加,浮点加法不满足结合律;回测从单线程改多线程后,事件顺序变了,净值在小数点后第五位漂移,触发「跌破阈值平仓」这种触发器后曲线发散。
- 字典遍历顺序 ------依赖
set遍历顺序产生下单顺序的代码,在不同 Python 版本间有微妙差异。 - 第三方库版本漂移 ------pandas 1.x → 2.x 的
groupby默认行为变了;numpy 的random.default_rng()从某个版本才稳定。没锁版本的回测,明年跑出来的曲线大概率和今年不一样。 - 数据更新(point-in-time 违反)------财报、复权因子会被回填。你今天从快照里取 2019 年的净利润,可能是 2024 年审计后修正过的值,等于把未来的信息注入了历史回测(look-ahead bias)。
- 外部时钟依赖 ------任何
datetime.now()、time.time()、uuid4()出现在策略代码里,再现性立刻破产。
注意,这六条里只有第 5 条是数据供应商的问题,其余五条是你自己的工程问题。但第 5 条恰恰是最致命的:数据供应商一旦「无痕更新」了历史数据,你的回测地基就歪了,而且你根本无从发现------因为没有任何地方记录「这次跑和上次跑,输入是不是同一份」。
火山引擎社区有一篇同主题文章说得直白:自动化回测任务里,大多数人只保存了策略代码、参数、回测区间,数据输入链条(怎么请求的、返回了什么、有没有被清洗过)往往没有固定下来。当定时任务每周重拉数据、或 CI 触发一次新回测,如果输入层没有证据,任何结果变化都无法归因------你分不清是策略失效还是数据漂移。
这正是本篇要解决的:用「内容寻址(content-addressed)+ SHA256 留痕」把数据输入固定下来,再把「基线指纹 → 对账 → 新鲜度」三道门禁接进 CI,让不可复现在合并前就暴露。
二、本文你将得到什么
- 一套零 SDK、纯 HTTP 的行情接入代码(接魔码行情 API),只换域名和证书即可用于你的环境。
- 一个基线指纹函数:把归一化后的行情数据算成 SHA256,作为这次回测的「数据身份证」。
- 一个**对账(reconcile)**门禁:任何一次复跑,先校验当前数据与基线是否一致,不一致当场 FAIL 并定位到「第几行、哪只标的」。
- 一个新鲜度 SLA 门禁:数据不能太旧,隔夜未更新立刻报警。
- 把以上三道门禁包装成一个 CI 可执行件:返回正确退出码、产出 JUnit + JSON 报告、黄金集落盘缓存、命中缓存省配额。
- 一份配置化示例,直接挂进你团队的流水线做 PR 卡点。
三、核心思路:content-addressed dataset
「内容寻址」是工程界对付「输入漂移」的经典办法,和 Docker 镜像哈希、Git 内容寻址如出一辙。原理极简:
不管数据从哪来、什么时候拉的,只要归一化后的内容逐字节一致,SHA256 就一致;内容有任何一比特变化,哈希就翻天覆地。
所以我们不做「下载一份 CSV 存着」,而是做三步:
- 拉取即归一化 :从 API 拿到响应后,只抽取稳定字段(
t/o/h/l/c/v/a),按日期排序,丢掉一切会随环境变化的元数据(请求时间、随机字段)。 - 算指纹 :把归一化后的行序列 JSON 序列化(用
sort_keys保证字段顺序无关),求 SHA256。 - 固定基线:第一次跑把指纹存成「黄金基线」;之后每次跑只比对指纹,不比对原始响应。
这样,哪怕上游某天「优化」了字段顺序、或者悄悄修正了某根 K 线的收盘价,你的 CI 都会在第一时间抓出来。
四、三道门禁逐一看
Stage 1:基线指纹(baseline fingerprint)
给回测 Universe 里每一只标的算一个 SHA256,作为这次实验的「数据身份证」。它回答一个问题:「这次回测吃的数据,和上次是不是同一份?」
python
import hashlib, json, requests
BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111" # 演示证书,请换成你自己的正式证书
def pull_daily(code):
"""拉单只日线;演示证书返回固定样本,真实证书为完整历史。"""
url = f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}"
r = requests.get(url, timeout=15)
r.raise_for_status()
rows = [{"t": b.get("t"), "o": b.get("o"), "h": b.get("h"),
"l": b.get("l"), "c": b.get("c"), "v": b.get("v"), "a": b.get("a")}
for b in r.json()]
rows.sort(key=lambda x: x["t"] or "")
return rows
def digest(rows):
payload = json.dumps(rows, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
注意 sort_keys=True 和按 t 排序------这两步保证「同一份数据、不同抽取顺序」也会算出同一个哈希,避免误报。
Stage 2:对账(reconcile)
复跑时,重新拉当前数据、重新算指纹,和基线比对。不一致的不是笼统地说「数据变了」,而是定位到哪只标的、第几行偏了:
python
def reconcile(golden_digests, current_map, golden_map=None):
passed = True
details = []
for code in UNIVERSE:
cur = current_map[code]
cd = digest(cur)
gd = golden_digests[code]
if gd != cd:
passed = False
ref = golden_map[code] if golden_map else pull_daily(code)
off = next((i for i, (a, b) in enumerate(zip(ref, cur)) if a != b), None)
details.append({"code": code, "status": "DRIFT",
"gold": gd[:10], "cur": cd[:10], "first_diff_row": off})
else:
details.append({"code": code, "status": "MATCH", "digest": gd[:10]})
return passed, details
Stage 3:新鲜度 SLA(freshness)
指纹一致只保证「和上次一样」,不保证「没过期」。如果基线本身就是一个月前的旧数据,你每次都 PASS 但回测用的是过期行情。所以再加一道新鲜度门禁:数据最新日期距今天数不能超过阈值。
python
import datetime
def freshness(current_map, max_age_days):
passed = True
details = []
today = datetime.date.today()
for code, rows in current_map.items():
latest = max(r["t"] for r in rows if r["t"])
latest_date = datetime.datetime.strptime(latest, "%Y-%m-%d").date()
age = (today - latest_date).days
ok = age <= max_age_days
passed = passed and ok
details.append({"code": code, "latest": latest, "age_days": age,
"sla": max_age_days, "status": "PASS" if ok else "STALE"})
return passed, details
生产环境把 max_age_days 设成 2(T+1)即可:任何隔夜未更新的数据都会让 CI 红。
五、CI 化封装:一个可执行的门禁件
把三道门禁包进 run_ci(),让它做四件事:返回退出码、写报告、缓存黄金集、支持注入漂移演示。关键设计点:
- 退出码即契约:门禁全过返回 0(CI 绿),任一 FAIL 返回 1(CI 红、合并被拦)。这是 CI 卡点的核心------CI 平台只看退出码。
- 报告产物 :同时写 JUnit 风格
repro-ci-report.xml(CI 平台原生展示用例成败)和repro-ci-report.json(给人/下游消费)。 - 黄金集缓存 :基线指纹落盘
golden_baseline.json;CI 复跑时命中缓存,只重新拉「当前快照」对账,省下一倍配额------这对按次计费的行情 API 很关键。 - 可注入故障 :允许
inject_drift=True在演示里人为扰动一行,证明门禁真能抓到。
python
def run_ci(use_cache=False, inject_drift=False):
if use_cache and os.path.exists(GOLDEN_CACHE):
golden_digests = json.load(open(GOLDEN_CACHE, encoding="utf-8"))
golden_map = None
print("[基线] 命中缓存(省去重新拉取)")
else:
golden_map = build_golden_map()
golden_digests = {c: digest(r) for c, r in golden_map.items()}
json.dump(golden_digests, open(GOLDEN_CACHE, "w"), ensure_ascii=False, indent=2)
print("[基线] 首次构建并落盘 golden_baseline.json")
current_map = build_golden_map()
if inject_drift:
current_map[UNIVERSE[0]][10]["c"] = round(current_map[UNIVERSE[0]][10]["c"] * 1.05, 2)
print("[注入] 模拟上游数据漂移 +5%")
rec_passed, _ = reconcile(golden_digests, current_map, golden_map)
fr_passed, _ = freshness(current_map, MAX_AGE_DAYS)
gate = rec_passed and fr_passed
write_report(gate, [{"name": "baseline_fingerprint", "status": "PASS"},
{"name": "reconcile", "status": "PASS" if rec_passed else "FAIL"},
{"name": "freshness", "status": "PASS" if fr_passed else "FAIL"}])
return 0 if gate else 1
六、真实运行实证(代码本地跑通,非编造)
下面两段是同一份 main.py 在本机真实运行的输出。演示证书返回固定样本,所以干净数据下门禁自然 PASS;为证明检测能力,第二段注入了一行漂移。
场景一:干净复跑(期望 GATE=PASS, exit 0)
[基线] 首次构建并落盘 golden_baseline.json(5 只)
[Stage1] 基线指纹 SHA256(前 16 位):
600519: 4d848fa3ab7ea895...
000001: 4d848fa3ab7ea895...
300750: 4d848fa3ab7ea895...
601318: 4d848fa3ab7ea895...
000858: 4d848fa3ab7ea895...
[Stage2] 对账结果:5/5 MATCH
[Stage3] 新鲜度 SLA(max_age_days=400):5/5 PASS latest=2025-12-01 age=305d
>>> GATE = PASS | exit 0
注:演示证书返回的是固定历史样本(末条 2025-12-01),故 age=305d 在演示宽松阈值 400 内判 PASS。换成你的正式证书后,
t为当日日期,age 接近 0,门禁更稳。
场景二:注入漂移(期望 GATE=FAIL, exit 1)
[基线] 命中缓存,5 只 digest 已加载(省去重新拉取,省配额)
[注入] 对 600519 第 10 行 close 人为 +5%,模拟上游数据漂移
[Stage2] 对账结果:
600519: DRIFT gold=4d848fa3ab cur=eb6ea231e3 首偏行=10
000001: MATCH
300750: MATCH
601318: MATCH
000858: MATCH
>>> GATE = FAIL | exit 1
门禁当场把漂移定位到 600519 的第 10 行,其余 4 只 MATCH 不受影响------这就是「可归因」:你不用再猜「是不是数据变了」,CI 直接告诉你哪一行变了。
七、配置化:挂进任意 CI 平台做 PR 卡点
把门禁做成配置,而不是硬编码在脚本里。一份中性配置示例:
yaml
# .repro-ci.yml ------ 回测可复现门禁配置(适配任意 CI 平台)
repro_ci:
universe: ["600519", "000001", "300750", "601318", "000858"]
api:
base: "https://api.momaapi.com"
licence_env: "MOMA_LICENCE" # 证书走环境变量,绝不硬编码
gates:
baseline_fingerprint: on
reconcile:
on: true
fail_closed: true # 对账失败即阻断合并
freshness:
max_age_days: 2 # 生产建议 T+1;演示用 400
cache:
golden_baseline: "golden_baseline.json"
report:
junit: "repro-ci-report.xml"
json: "repro-ci-report.json"
trigger:
on_pull_request: true # PR 卡点
on_schedule: "0 9 * * 1-5" # 每个交易日 09:00 定时复跑
CI 平台的流水线步骤只需三步:① 安装依赖(仅 requests);② 注入证书环境变量;③ 执行 python main.py,以退出码作为成败判据。这样,任何一次改策略、改参数、或上游偷偷更新数据,都会在合并前被拦下。
八、工程化建议
- 黄金集版本化 :
golden_baseline.json随策略代码一起进版本库。当你「有意」升级数据源(比如换成更高级套餐、字段扩展),先人工 review 指纹变化,再提交新基线------把「数据变更」变成一次显式、可评审的提交,而不是 silent drift。 - PR 卡点 vs 定时复跑分工:PR 卡点拦「策略/代码改动引入的不可复现」;定时复跑拦「上游数据静默变化」。两者互补。
- point-in-time 单独处理:本文的 SHA256 解决「同一份数据被改写」;若你要防范「用未来复权因子回测历史」(look-ahead),需额外固定复权口径(见 #87 先复权后聚合),并把复权因子版本也纳入指纹。
- 配额友好:命中缓存后只拉当前快照,对账阶段把配额消耗砍半;演示里 5 只标的干净复跑未产生额外基线拉取。
九、小结与下篇预告
回测不可复现,八成不是策略的问题,而是「输入没留痕」。本篇给出一套纯 HTTP、零 SDK 的可复现方案:用 SHA256 给数据上「身份证」,用「基线指纹 + 对账 + 新鲜度」三道门禁在 CI 里自动卡点,干净数据绿、漂移数据红并定位到行。魔码行情 API 稳定、按次计费、rate_limit 透明,配免费证书即可把这套工程件跑起来,免去自研爬虫与 infra 栈。
下一篇 我们解决另一类高频痛点:批量拉数被 429 打断。如何把「令牌桶限频 + 指数退避 + 抖动 + 配额预算」封装进一个客户端,让业务代码完全感知不到限流的存在。