feat(strategy): 代码版本快照(§12.6补,方案B发起时快照·QuantConnect轻量版)——用户拍板:解决「实例参数有快照但代码没有,历史运行无法回溯当时跑的哪版代码」——①sanguo_api/code_versions.py:发起时全文落盘data/strategy_code_versions/{file}.{hash8}.py(md5内容寻址天然去重,原子替换防并发坏);发起四入口(paper/live/CTA/组合回测)全快照,账户加code_hash列(ALTER迁移),回测经spec→run_meta.code_hash落档案②查看:GET /strategy/code-versions列表+单版本全文;代码编辑页「版本历史」抽屉(MonacoDiff左右对比当前,主题同款)③「代码已变更」角标:策略库档案行(enriched code_changed)+全景每run标v哈希·一致/已变更④双轨对账加「代码版本一致」第五指标(对账FAIL先查这行,两边代码不同价差必然大);路径穿越防护(版本号只认8位hex)+6测试;927绿+build绿;「用当时代码重跑」留P2 [vps]
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""§12.6 补:策略代码版本快照(发起时快照/去重/防穿越/代码已变更标记)。"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from sanguo_api import code_versions as CV
|
||||
from sanguo_api import instance_store
|
||||
from sanguo_api.app import create_app
|
||||
from sanguo_api.auth import create_token, set_jwt_config
|
||||
from sanguo_api.routes_paper import set_db_path as set_paper_db
|
||||
|
||||
|
||||
def test_snapshot_dedup_and_read(tmp_path, monkeypatch):
|
||||
"""同内容只存一份(内容寻址去重);list/read 往返一致。"""
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
# 造一个假策略文件(monkeypatch registry 读)
|
||||
class _FakeFile:
|
||||
def __init__(self, src):
|
||||
self._src = src
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": self._src}
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", _FakeFile("print(1)\n"))
|
||||
s1 = CV.snapshot_code("demo.py")
|
||||
s2 = CV.snapshot_code("demo.py")
|
||||
assert s1["code_version"] == s2["code_version"]
|
||||
files = os.listdir(tmp_path / "versions")
|
||||
assert len(files) == 1 # 去重
|
||||
vers = CV.list_versions("demo.py")
|
||||
assert len(vers) == 1 and vers[0]["code_version"] == s1["code_version"]
|
||||
assert CV.read_version("demo.py", s1["code_version"]) == "print(1)\n"
|
||||
|
||||
|
||||
def test_read_version_rejects_traversal(tmp_path, monkeypatch):
|
||||
"""版本号只认 8 位 hex:路径穿越串直接拒绝。"""
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
assert CV.read_version("demo.py", "../../etc/passwd") is None
|
||||
assert CV.read_version("demo.py", "ZZZZZZZZ") is None
|
||||
|
||||
|
||||
def test_code_changed_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
class _FakeFile:
|
||||
def __init__(self, src):
|
||||
self._src = src
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": self._src}
|
||||
fake = _FakeFile("v1")
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", fake)
|
||||
snap = CV.snapshot_code("demo.py")
|
||||
assert CV.code_changed("demo.py", snap["code_hash"]) is False
|
||||
fake._src = "v2-changed" # 文件改了
|
||||
assert CV.code_changed("demo.py", snap["code_hash"]) is True
|
||||
assert CV.code_changed("demo.py", None) is None # 早期账户无哈希=不可判
|
||||
|
||||
|
||||
def _client(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(instance_store, "_STORE_PATH", str(tmp_path / "inst.json"))
|
||||
monkeypatch.setattr(CV, "_DIR", str(tmp_path / "versions"))
|
||||
set_jwt_config(secret="t", expire_minutes=60)
|
||||
pdb = os.path.join(str(tmp_path), "p.db")
|
||||
app = create_app(db_path=pdb)
|
||||
set_paper_db(pdb)
|
||||
return TestClient(app), create_token("admin"), pdb
|
||||
|
||||
|
||||
def test_paper_create_stores_code_hash(tmp_path, monkeypatch):
|
||||
"""发起模拟盘 → 账户落 code_hash + 快照文件存在(code_file 能解析到真实策略时)。"""
|
||||
c, token, pdb = _client(tmp_path, monkeypatch)
|
||||
r = c.post("/api/v1/paper/create", json={
|
||||
"mode": "live",
|
||||
"symbols": ["600000"],
|
||||
"strategies": [{"name": "AShareDoubleMaStrategy", "symbol": "600000",
|
||||
"params": {"fast_window": 5}}],
|
||||
"start": "2024-01-01", "end": "",
|
||||
}, headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
h = sqlite3.connect(pdb).execute(
|
||||
"SELECT code_hash FROM paper_accounts WHERE id=1").fetchone()[0]
|
||||
# 类名解析到 double_ma.py → 快照成功有哈希;解析不到也是 None 不崩
|
||||
if h:
|
||||
assert len(h) == 32
|
||||
assert any(f.startswith("double_ma.py.") for f in os.listdir(tmp_path / "versions"))
|
||||
|
||||
|
||||
def test_enriched_code_changed_flag(tmp_path, monkeypatch):
|
||||
"""文件后续被改 → enriched 亮 code_changed。"""
|
||||
c, token, pdb = _client(tmp_path, monkeypatch)
|
||||
c.post("/api/v1/paper/create", json={
|
||||
"mode": "live",
|
||||
"symbols": ["600000"],
|
||||
"strategies": [{"name": "AShareDoubleMaStrategy", "symbol": "600000",
|
||||
"params": {}}],
|
||||
"start": "2024-01-01", "end": "",
|
||||
}, headers={"Authorization": f"Bearer {token}"})
|
||||
insts = c.get("/api/v1/strategy/instances/enriched").json()["instances"]
|
||||
assert insts[0]["code_changed"] in (False, None) # 刚发起必然一致/不可判
|
||||
# 改当前文件哈希(模拟代码变更)
|
||||
monkeypatch.setattr(CV, "current_hash", lambda f: "deadbeef")
|
||||
insts = c.get("/api/v1/strategy/instances/enriched").json()["instances"]
|
||||
me = insts[0]
|
||||
if me["running_accounts"]:
|
||||
acc = me["running_accounts"][0]
|
||||
if acc.get("code_version"): # 有哈希的账户才可判
|
||||
assert me["code_changed"] is True
|
||||
|
||||
|
||||
def test_code_version_endpoints(tmp_path, monkeypatch):
|
||||
"""GET /strategy/code-versions 列表 + 单版本全文。"""
|
||||
c, token, _ = _client(tmp_path, monkeypatch)
|
||||
class _FakeFile:
|
||||
def __call__(self, name):
|
||||
return {"name": name, "code": "x = 1\n"}
|
||||
monkeypatch.setattr("sanguo_api.strategy_registry.read_strategy_file", _FakeFile())
|
||||
snap = CV.snapshot_code("demo.py")
|
||||
lst = c.get("/api/v1/strategy/code-versions?file=demo.py").json()
|
||||
assert lst["versions"] and lst["versions"][0]["code_version"] == snap["code_version"]
|
||||
one = c.get(f"/api/v1/strategy/code-versions/demo.py/{snap['code_version']}").json()
|
||||
assert one["code"] == "x = 1\n"
|
||||
assert c.get("/api/v1/strategy/code-versions/demo.py/00000000").status_code == 404
|
||||
Reference in New Issue
Block a user