python量化实操:回测数据缓存与重放

一、代码一行没动,回测结果怎么偷偷变了?

这是量化团队最隐蔽、也最致命的一类事故。你有完整的策略代码、参数、回测区间,半年前跑出一条漂亮的净值曲线;半年后云上定时任务重新跑一次,曲线面目全非。你反复 diff 提交记录------什么都没变。问题出在哪?

quantmemo 把它总结得很准:大多数"回测漂移"不是策略失效,而是输入数据在你不注意时变了。三个常见根因:

  • 供应商静默修订历史库:某只股票三年前的分红被数据商重新处理,整段历史在一次更新里被改写。你的"同一份历史"从来不是固定物,是移动靶。
  • 缓存失效规则缺失 :研究系统把回测结果按"输入"缓存起来复用,但缓存键只记了策略参数,没记数据版本和引擎代码版本。上游数据一修订,系统高高兴兴地把陈旧结果当最新答案返回------比不缓存更糟,因为它"看起来是对的"。
  • 无状态环境天然缺校验:云函数 / 容器每次启动都从最新数据源拉取,根本没有"这次拉的和上次是不是同一份"的机制。

火山引擎和腾讯云开发者社区都给出同一个药方:为每次回测留 SHA256 数据证据链------请求参数固化、原始响应落盘、归一化结果哈希、重叠区间复现检查。本篇就把这套方法论落成可运行的代码:内容寻址缓存 + 失效规则 + 确定性重放。

本文是 #57(本地落库与增量更新)、#65(可复现流水线)、#73(as-of 时点回溯)的承上启下。#57 解决"怎么存",#65 解决"怎么编排",#73 解决"时点语义",本篇解决"怎么让同一份输入永远给同一份输出"。

二、本文你将得到什么

三个可复用的组件:

  • ContentAddressedCache:缓存键不是随便拼的字符串,而是"请求规范化 → SHA-256 → 16 位键"。同一请求永远命中同一文件,不同请求(哪怕只改一个参数)落不同文件,天然隔离、天然去重。
  • 失效规则(invalidation) :缓存键必须包含数据版本(证书档位)+ 引擎版本的哈希,而不只是策略参数。任何一层变了,键就变,自动触发重新拉取,绝不误用旧缓存。
  • ReplaySession :把某个缓存键"冻结"成带版本清单的快照,之后按日期服务数据,全程零网络。回测读重放会话,而不是读 API,确定性就有了保障。

全部用 requests 直连魔码,不引入任何 SDK,证书即取即用。

三、核心概念:为什么"存下来"远远不够

3.1 内容寻址,而非路径寻址

普通缓存的键常是 600519_d_n,人写、易撞、易漏字段。内容寻址的键是请求的规范化表示的哈希:

复制代码
key = sha256(json({ep, code, period, adjust, tier}))[:16]

好处立竿见影:参数顺序无关、增删字段即变键、同一逻辑请求永不分裂成两个缓存。更关键的是------键本身就承诺了"里面是什么",你拿到键就能反推请求,便于审计。

3.2 失效规则是缓存的生死线

一条经典结论:缓存只和它的失效规则一样可信 。如果缓存键没捕获"数据版本",供应商一修订历史,旧键照样命中旧文件,返回陈旧数字。所以本实现的键里显式带 tier(证书档位,代表数据口径版本);生产环境你还应把"引擎/归一化代码版本"也并进键里。任一层变动 → 键变 → 新拉取。

3.3 重放会话:把"实时 API"换成"冻快照"

回测真正要的不是一个会变的端点,而是"当时那一份数据 "。ReplaySession 从冻结快照加载,提供 serve_by_date(date),内部零网络。同一快照喂给同一策略,输出逐字节一致------这才是可复现研究的地基。结合 #73 的 as-of 语义,你甚至能重放"某个决策时点可见的版本",把前视偏差也挡在门外。

四、工程框架

  • canonical_key(code, period, adjust, tier):规范化 + SHA-256,产出 16 位内容键。
  • content_sha(rows):对归一化行列表取哈希,作为数据指纹。
  • ContentAddressedCache.get_or_fetch(key, fetcher):文件存在即 HIT(不触网),否则调 fetcher 拉取并落盘,返回 MISS。
  • ContentAddressedCache.freeze(key, tag):把当前缓存键复制为带 {tag, key, sha, rows, frozen_at} 清单的快照。
  • ReplaySession(tag):加载快照,提供 serve_by_date 与 last_close,全程不调网络。

下面用魔码日线演示:演示证书返回确定性样本(约 2025 下半年 50 行),正好用来证明"同一输入永远给同一输出"。

五、自验代码(先跑通,再成文)

下面代码已用演示证书在本地真实跑通。注意:演示证书的历史日线返回固定样本(本文实测 50 行、末收 11.69 元),所以哈希稳定、确定性一目了然;换成你的正式证书可拉取真实历史,缓存与重放逻辑完全不变。

python 复制代码
# -*- coding: utf-8 -*-
"""魔码回测数据缓存与重放:内容寻址 + 失效规则 + 确定性重放。"""
import hashlib
import json
import os
import time

import requests

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书

CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
SNAP_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "snapshots")
os.makedirs(CACHE_DIR, exist_ok=True)
os.makedirs(SNAP_DIR, exist_ok=True)

_NET_CALLS = 0


def _monitored_get(url, timeout=15):
    global _NET_CALLS
    _NET_CALLS += 1
    return requests.get(url, timeout=timeout)


def fetch_history(code, period, adjust):
    url = BASE + "/hsstock/history/%s/%s/%s/%s" % (code, period, adjust, LICENCE)
    r = _monitored_get(url)
    data = r.json()
    if not isinstance(data, list):
        return []
    return [{"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 data]


def canonical_key(code, period, adjust, tier):
    canon = json.dumps({"ep": "hsstock/history", "code": code, "period": period,
                        "adjust": adjust, "tier": tier}, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]


def content_sha(rows):
    return hashlib.sha256(
        json.dumps(rows, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()[:16]


class ContentAddressedCache:
    def get_or_fetch(self, key, fetcher):
        path = os.path.join(CACHE_DIR, key + ".json")
        if os.path.exists(path):
            with open(path, "r", encoding="utf-8") as f:
                return json.load(f), "HIT"
        rows = fetcher()
        with open(path, "w", encoding="utf-8") as f:
            json.dump(rows, f, ensure_ascii=False)
        return rows, "MISS"

    def freeze(self, key, tag):
        src = os.path.join(CACHE_DIR, key + ".json")
        with open(src, "r", encoding="utf-8") as f:
            rows = json.load(f)
        sha = content_sha(rows)
        manifest = {"tag": tag, "key": key, "sha": sha, "rows": len(rows),
                    "frozen_at": time.strftime("%Y-%m-%d %H:%M:%S")}
        with open(os.path.join(SNAP_DIR, tag + ".json"), "w", encoding="utf-8") as f:
            json.dump({"manifest": manifest, "rows": rows}, f, ensure_ascii=False)
        return manifest


class ReplaySession:
    def __init__(self, tag):
        self.tag = tag
        with open(os.path.join(SNAP_DIR, tag + ".json"), "r", encoding="utf-8") as f:
            blob = json.load(f)
        self.manifest = blob["manifest"]
        self.rows = blob["rows"]

    def serve_by_date(self, date):
        for r in self.rows:
            if r.get("t") == date:
                return r
        return None

    def last_close(self):
        return self.rows[-1]["c"] if self.rows else None


def backtest_simulation(rows):
    return rows[-1]["c"] if rows else None


def main():
    global _NET_CALLS
    code, period, adjust, tier = "600519", "d", "n", "demo"
    key = canonical_key(code, period, adjust, tier)

    _NET_CALLS = 0
    rows1, status1 = ContentAddressedCache().get_or_fetch(
        key, lambda: fetch_history(code, period, adjust))
    sha1 = content_sha(rows1)
    print("第1次: status=%s, 网络调用=%d, 行数=%d, sha=%s" % (status1, _NET_CALLS, len(rows1), sha1))

    _NET_CALLS = 0
    rows2, status2 = ContentAddressedCache().get_or_fetch(
        key, lambda: fetch_history(code, period, adjust))
    sha2 = content_sha(rows2)
    print("第2次: status=%s, 网络调用=%d, 行数=%d, sha=%s" % (status2, _NET_CALLS, len(rows2), sha2))
    print("  缓存确定性(sha1==sha2):", "PASS" if sha1 == sha2 else "FAIL")

    cache = ContentAddressedCache()
    man = cache.freeze(key, "bt_600519_d_n_v1")
    print("\n冻结快照:", json.dumps(man, ensure_ascii=False))
    _NET_CALLS = 0
    sess = ReplaySession("bt_600519_d_n_v1")
    replay_close = sess.last_close()
    print("重放: 网络调用=%d, 末收=%.2f, manifest.sha=%s" % (_NET_CALLS, replay_close, man["sha"]))
    print("  重放确定性(重放==缓存):", "PASS" if man["sha"] == sha2 else "FAIL")

    key2 = canonical_key("600519", "m", "n", tier)
    _NET_CALLS = 0
    rows3, status3 = cache.get_or_fetch(
        key2, lambda: fetch_history("600519", "m", "n"))
    print("\n失效校验(周期 d→m): 新键=%s, status=%s, 网络调用=%d, 行数=%d" % (key2, status3, _NET_CALLS, len(rows3)))
    print("  失效正确(新键独立、旧键未被污染):", "PASS" if (status3 == "MISS" and key2 != key) else "FAIL")

    print("\n策略复现: 缓存输入=%.2f, 重放输入=%.2f, 一致=%s" %
          (backtest_simulation(rows2), replay_close,
           "PASS" if backtest_simulation(rows2) == replay_close else "FAIL"))


if __name__ == "__main__":
    main()

本地真实运行输出:

复制代码
第1次: status=MISS, 网络调用=1, 行数=50, sha=d5ea59af503ccd6a
第2次: status=HIT, 网络调用=0, 行数=50, sha=d5ea59af503ccd6a
  缓存确定性(sha1==sha2): PASS

冻结快照: {"tag": "bt_600519_d_n_v1", "key": "030b25df268da842",
          "sha": "d5ea59af503ccd6a", "rows": 50, "frozen_at": "2026-09-26 08:07:50"}
重放: 网络调用=0, 末收=11.69, manifest.sha=d5ea59af503ccd6a
  重放确定性(重放==缓存): PASS

失效校验(周期 d→m): 新键=af19318fbb109de3, status=MISS, 网络调用=1, 行数=11
  失效正确(新键独立、旧键未被污染): PASS

策略复现: 缓存输入=11.69, 重放输入=11.69, 一致=PASS

四个结论一目了然:第一次拉取走网络(MISS,1 次调用)、第二次直接命中缓存(HIT,0 次调用)且哈希与第一次完全一致------缓存确定性 PASS ;从冻结快照重放同样零网络、哈希一致------重放确定性 PASS ;把周期从日线 d 改成周线 m,键随之变化、独立重新拉取(11 行),旧键没被污染------失效规则 PASS ;同一份输入喂给策略,缓存与重放给出的末收都是 11.69------策略级复现 PASS。

六、常见坑与频率提示

  • 快照要随重要结果一起存 :一条研究结论 green-light 之前,把产生它的数据快照 freeze 下来,连同策略代码版本一起归档。半年后复核,直接重放同一快照,而不是重新拉"最新"数据------后者可能已经悄悄变了。
  • 失效维度别漏 :本实现键里带了 tier(数据口径版本)。生产里务必把"归一化/清洗代码版本"也并进键,否则你改了清洗逻辑,缓存却还返回旧逻辑的脏数据。
  • 重放不是"离线"的借口:重放保证确定性,但不保证正确。快照本身若是错的(比如采集当日就有缺口),重放只会稳定地错。配合 #59 质量门禁、#61 回归测试一起用。
  • 频率限制 :首次 MISS 会真实打 API,批量预热缓存时注意魔码套餐的"次/分钟"频率限制(看响应 rate_limit 字段),用 #58 的令牌桶限速器控好节奏。

七、小结与下篇预告

本篇把"数据底座"从"每次重新拉、结果偷偷漂"升级成可审计、可复现的确定性工程:内容寻址让同一请求永命中同一文件,失效规则让任何参数/数据版本变动都自动触发重拉,重放会话让回测读冻快照而非实时端点。配合前面的质量门禁、回归测试、as-of 时点回溯,你已经拥有了一条从"拉数"到"出结论"全链路可复现的流水线雏形。

下一篇 #77 冷热分层存储 接着聊:热数据放内存 / 冷数据落对象存储,按访问频率做分级 TTL,把"确定性重放"的存储成本压下来。

文中演示证书返回的是固定样本,仅作方法论演示;生产环境请使用你自己的正式证书,并以实时返回为准证书。


相关推荐
普贤莲花1 小时前
先学Workbuddy还是“人工智能训练师”,或者python基础课?
开发语言·人工智能·python
Eloudy1 小时前
全文 - Understanding RDMA Microarchitecture Resources for Performance Isolation
1024程序员节
学长毕设1 小时前
基于springboot的农资管理系统-附源码
java·spring boot·后端
瑞克-桑切斯1 小时前
Mycat读写分离部署手册
1024程序员节
晒太羊的猫1 小时前
hot100子串
java·算法·leetcode
小哈里2 小时前
【编译器】从计算机系统原理到 LLM 编译器与算子优化
大模型·编译器·1024程序员节·计算机系统原理·算子优化·推理平台
开开心心就好2 小时前
本地文件搜索工具,双击Ctrl就能呼出搜索
java·服务器·前端·支持向量机·ffmpeg·音视频·模拟退火算法
第404号工位2 小时前
浏览器的“无痕模式”是当代最大安慰剂?我用 30 行 JS 提取硬件指纹,跨网络换代理照样精准锁定你
linux·人工智能·python