量化回测可复现流水线CI化

前一篇讲了如何用 1 分钟线本地合成任意周期 K 线;本篇往前一步------当你已经能稳定拉数、能合成 K 线,下一个躲不开的问题是:今天的回测,三个月后还能跑出一样的结果吗? 我们把它工程化进 CI,用三道门禁自动卡点。

一、痛点:为什么三个月前的回测今天跑不出同样的结果

做量化的人大多经历过这种事:策略上周跑出年化 25%、夏普 1.8,信心满满准备上实盘;结果换台机器、或者隔了几周重跑,曲线变了,甚至变难看了。第一反应往往是「策略失效了」,但真正的原因常常不在策略,而在输入数据悄悄变了。

社区里把破坏回测再现性的因素归纳得很全(quant67《回测引擎设计》),至少有六类:

  1. 时间戳精度漂移 ------历史数据从供应商下载是毫秒精度,落到 pandas 被转成 datetime64[ns] 丢了时区,再 resample('1min') 时 label='left' 还是 label='right' 不一致,同一段数据两次回测变成两段不同序列。
  2. 浮点累加顺序------组合净值几万笔成交逐笔累加,浮点加法不满足结合律;回测从单线程改多线程后,事件顺序变了,净值在小数点后第五位漂移,触发「跌破阈值平仓」这种触发器后曲线发散。
  3. 字典遍历顺序 ------依赖 set 遍历顺序产生下单顺序的代码,在不同 Python 版本间有微妙差异。
  4. 第三方库版本漂移 ------pandas 1.x → 2.x 的 groupby 默认行为变了;numpy 的 random.default_rng() 从某个版本才稳定。没锁版本的回测,明年跑出来的曲线大概率和今年不一样。
  5. 数据更新(point-in-time 违反)------财报、复权因子会被回填。你今天从快照里取 2019 年的净利润,可能是 2024 年审计后修正过的值,等于把未来的信息注入了历史回测(look-ahead bias)。
  6. 外部时钟依赖 ------任何 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 存着」,而是做三步:

  1. 拉取即归一化 :从 API 拿到响应后,只抽取稳定字段(t/o/h/l/c/v/a),按日期排序,丢掉一切会随环境变化的元数据(请求时间、随机字段)。
  2. 算指纹 :把归一化后的行序列 JSON 序列化(用 sort_keys 保证字段顺序无关),求 SHA256。
  3. 固定基线:第一次跑把指纹存成「黄金基线」;之后每次跑只比对指纹,不比对原始响应。

这样,哪怕上游某天「优化」了字段顺序、或者悄悄修正了某根 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 打断。如何把「令牌桶限频 + 指数退避 + 抖动 + 配额预算」封装进一个客户端,让业务代码完全感知不到限流的存在。


相关推荐
骑着蜗牛撵大象3271 小时前
Spring Boot 4.1 + Spring AI 2.0:RAG 供文、图谱供关系、MCP 供工具的 Java Agent 平台
java·spring boot·spring·微服务·知识图谱·rag·mcp
磁场转动100万匹1 小时前
道路巡检系统检测案例:YOLOv5 + Flask + PyQt 全栈实现
python·yolo
衍生星球1 小时前
【Java 语言】— 面向对象:构造方法(无参、有参构造)
java·开发语言
SimonKing1 小时前
素笺( local-notes)全面优化,新增功能体验不一样的笔记
java·后端·程序员
techpupil1 小时前
IDEA基础配置及开发基础插件
java·ide·intellij-idea
yuniko-n1 小时前
【JVM】对象内存布局
java·开发语言·jvm
CoderYanger1 小时前
Java EE 进阶:6.SpringBoot 配置文件(第二弹)
java·spring boot·spring·面试·职场和发展·java-ee·程序员创富
文丑颜不良啊1 小时前
LeetCode1681~1700题目解析
java·leetcode
前端精髓1 小时前
动态选择工具
python·langchain