From 6b07389c85cf85702bfbe4723446a0fa570fa979 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 15 Aug 2026 23:38:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(strategy):=20=E4=BB=A3=E7=A0=81=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=BF=AB=E7=85=A7(=C2=A712.6=E8=A1=A5,=E6=96=B9?= =?UTF-8?q?=E6=A1=88B=E5=8F=91=E8=B5=B7=E6=97=B6=E5=BF=AB=E7=85=A7=C2=B7Qu?= =?UTF-8?q?antConnect=E8=BD=BB=E9=87=8F=E7=89=88)=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=8B=8D=E6=9D=BF:=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E3=80=8C=E5=AE=9E=E4=BE=8B=E5=8F=82=E6=95=B0=E6=9C=89=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E4=BD=86=E4=BB=A3=E7=A0=81=E6=B2=A1=E6=9C=89,?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E8=BF=90=E8=A1=8C=E6=97=A0=E6=B3=95=E5=9B=9E?= =?UTF-8?q?=E6=BA=AF=E5=BD=93=E6=97=B6=E8=B7=91=E7=9A=84=E5=93=AA=E7=89=88?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E3=80=8D=E2=80=94=E2=80=94=E2=91=A0sanguo=5F?= =?UTF-8?q?api/code=5Fversions.py:=E5=8F=91=E8=B5=B7=E6=97=B6=E5=85=A8?= =?UTF-8?q?=E6=96=87=E8=90=BD=E7=9B=98data/strategy=5Fcode=5Fversions/{fil?= =?UTF-8?q?e}.{hash8}.py(md5=E5=86=85=E5=AE=B9=E5=AF=BB=E5=9D=80=E5=A4=A9?= =?UTF-8?q?=E7=84=B6=E5=8E=BB=E9=87=8D,=E5=8E=9F=E5=AD=90=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E9=98=B2=E5=B9=B6=E5=8F=91=E5=9D=8F);=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E5=9B=9B=E5=85=A5=E5=8F=A3(paper/live/CTA/=E7=BB=84?= =?UTF-8?q?=E5=90=88=E5=9B=9E=E6=B5=8B)=E5=85=A8=E5=BF=AB=E7=85=A7,?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E5=8A=A0code=5Fhash=E5=88=97(ALTER=E8=BF=81?= =?UTF-8?q?=E7=A7=BB),=E5=9B=9E=E6=B5=8B=E7=BB=8Fspec=E2=86=92run=5Fmeta.c?= =?UTF-8?q?ode=5Fhash=E8=90=BD=E6=A1=A3=E6=A1=88=E2=91=A1=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B:GET=20/strategy/code-versions=E5=88=97=E8=A1=A8+?= =?UTF-8?q?=E5=8D=95=E7=89=88=E6=9C=AC=E5=85=A8=E6=96=87;=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E8=BE=91=E9=A1=B5=E3=80=8C=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E3=80=8D=E6=8A=BD=E5=B1=89(MonacoDiff?= =?UTF-8?q?=E5=B7=A6=E5=8F=B3=E5=AF=B9=E6=AF=94=E5=BD=93=E5=89=8D,?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=90=8C=E6=AC=BE)=E2=91=A2=E3=80=8C?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=B7=B2=E5=8F=98=E6=9B=B4=E3=80=8D=E8=A7=92?= =?UTF-8?q?=E6=A0=87:=E7=AD=96=E7=95=A5=E5=BA=93=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=E8=A1=8C(enriched=20code=5Fchanged)+=E5=85=A8=E6=99=AF?= =?UTF-8?q?=E6=AF=8Frun=E6=A0=87v=E5=93=88=E5=B8=8C=C2=B7=E4=B8=80?= =?UTF-8?q?=E8=87=B4/=E5=B7=B2=E5=8F=98=E6=9B=B4=E2=91=A3=E5=8F=8C?= =?UTF-8?q?=E8=BD=A8=E5=AF=B9=E8=B4=A6=E5=8A=A0=E3=80=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=B8=80=E8=87=B4=E3=80=8D=E7=AC=AC=E4=BA=94?= =?UTF-8?q?=E6=8C=87=E6=A0=87(=E5=AF=B9=E8=B4=A6FAIL=E5=85=88=E6=9F=A5?= =?UTF-8?q?=E8=BF=99=E8=A1=8C,=E4=B8=A4=E8=BE=B9=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=B8=8D=E5=90=8C=E4=BB=B7=E5=B7=AE=E5=BF=85=E7=84=B6=E5=A4=A7?= =?UTF-8?q?);=E8=B7=AF=E5=BE=84=E7=A9=BF=E8=B6=8A=E9=98=B2=E6=8A=A4(?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=E5=8F=AA=E8=AE=A48=E4=BD=8Dhex)+6?= =?UTF-8?q?=E6=B5=8B=E8=AF=95;927=E7=BB=BF+build=E7=BB=BF;=E3=80=8C?= =?UTF-8?q?=E7=94=A8=E5=BD=93=E6=97=B6=E4=BB=A3=E7=A0=81=E9=87=8D=E8=B7=91?= =?UTF-8?q?=E3=80=8D=E7=95=99P2=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/paper.ts | 2 + frontend/src/api/strategy.ts | 23 ++++ frontend/src/components/MonacoDiff.vue | 84 ++++++++++++ frontend/src/mock/data.ts | 20 ++- frontend/src/mock/index.ts | 3 + frontend/src/views/paper/Reconcile.vue | 7 + frontend/src/views/strategy/Code.vue | 68 ++++++++++ .../src/views/strategy/InstanceOverview.vue | 3 + frontend/src/views/strategy/List.vue | 1 + sanguo_api/code_versions.py | 103 +++++++++++++++ sanguo_api/instance_store.py | 8 +- sanguo_api/routes.py | 10 ++ sanguo_api/routes_live.py | 6 + sanguo_api/routes_paper.py | 18 ++- sanguo_api/routes_portfolio.py | 10 ++ sanguo_api/routes_strategy.py | 52 +++++++- sanguo_live/persistence.py | 6 +- sanguo_orchestrator/runner.py | 11 +- sanguo_trader/persistence.py | 10 +- tests/api/test_code_versions.py | 122 ++++++++++++++++++ 20 files changed, 549 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/MonacoDiff.vue create mode 100644 sanguo_api/code_versions.py create mode 100644 tests/api/test_code_versions.py diff --git a/frontend/src/api/paper.ts b/frontend/src/api/paper.ts index a62c9ca..2ce7e7e 100644 --- a/frontend/src/api/paper.ts +++ b/frontend/src/api/paper.ts @@ -212,6 +212,8 @@ export interface ReconcilePair { shadow_account_id: number strategy: string report: ReconcileReport + /** §12.6 补:双轨代码版本一致(null=任一侧无快照不可判;对账 FAIL 先查这行) */ + code_match?: boolean | null } export async function getReconcilePairs(date?: string): Promise { diff --git a/frontend/src/api/strategy.ts b/frontend/src/api/strategy.ts index 35b358a..630fcda 100644 --- a/frontend/src/api/strategy.ts +++ b/frontend/src/api/strategy.ts @@ -62,6 +62,8 @@ export interface RunningAccount { label: string ret: number | null drifted?: boolean + code_changed?: boolean | null + code_version?: string | null } export interface Instance { @@ -78,6 +80,7 @@ export interface Instance { updated_at: string running_accounts?: RunningAccount[] drift?: boolean + code_changed?: boolean | null } export async function getInstances(): Promise { @@ -85,6 +88,24 @@ export async function getInstances(): Promise { return data.instances } +// ----- §12.6 补:代码版本快照 ----- + +export interface CodeVersion { + code_version: string + saved_at: string + size: number +} + +export async function getCodeVersions(file: string): Promise { + const { data } = await apiClient.get<{ versions: CodeVersion[] }>('/strategy/code-versions', { params: { file } }) + return data.versions +} + +export async function getCodeVersion(file: string, h8: string): Promise { + const { data } = await apiClient.get<{ code: string }>(`/strategy/code-versions/${file}/${h8}`) + return data.code +} + export async function syncInstanceParams(instanceId: number): Promise { const { data } = await apiClient.post<{ synced: number }>(`/paper/sync/${instanceId}`) return data.synced @@ -98,6 +119,8 @@ export interface OverviewRun { status: string | null ret: number | null equity: { date: string; equity: number }[] + code_version?: string | null + code_changed?: boolean | null } export interface OverviewPosition { diff --git a/frontend/src/components/MonacoDiff.vue b/frontend/src/components/MonacoDiff.vue new file mode 100644 index 0000000..89a0b41 --- /dev/null +++ b/frontend/src/components/MonacoDiff.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/mock/data.ts b/frontend/src/mock/data.ts index 00021d9..56a73d2 100644 --- a/frontend/src/mock/data.ts +++ b/frontend/src/mock/data.ts @@ -410,17 +410,31 @@ export interface RunningAccountMock { drifted?: boolean } export const strategyInstancesEnrichedMock = { - instances: (strategyInstancesMock.instances as Array).map((i) => ({ + instances: (strategyInstancesMock.instances as Array).map((i) => ({ ...i, running_accounts: i.status.paper_live === 'running' - ? [{ kind: 'paper', aid: 101, label: i.name, ret: i.last_return }] + ? [{ kind: 'paper', aid: 101, label: i.name, ret: i.last_return, code_version: 'a1b2c3d4', code_changed: i.id === 3 }] : (i.status.live === 'running' - ? [{ kind: 'live', aid: 201, label: `${i.name}·live`, ret: i.last_return }] + ? [{ kind: 'live', aid: 201, label: `${i.name}·live`, ret: i.last_return, code_version: 'a1b2c3d4', code_changed: false }] : []), drift: i.id === 2, + code_changed: i.id === 3, })), } +/* §12.6 补:代码版本快照 mock */ +export const codeVersionsMock = (file: string) => ({ + file, + versions: [ + { code_version: 'a1b2c3d4', saved_at: '2026-08-14 21:05:02', size: 8213 }, + { code_version: '9f8e7d6c', saved_at: '2026-08-10 20:31:44', size: 7920 }, + ], +}) +export const codeVersionMock = (file: string, h8: string) => ({ + file, code_version: h8, + code: `# ${file} 快照 v${h8}(mock)\n\ndef initialize(context):\n pass\n`, +}) + export function instanceOverviewMock(id: number) { const inst = strategyInstancesMock.instances.find((i) => i.id === id) || strategyInstancesMock.instances[0] const days = ['07-20', '07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27', '07-28'] diff --git a/frontend/src/mock/index.ts b/frontend/src/mock/index.ts index 2ba8a6a..f6e62a8 100644 --- a/frontend/src/mock/index.ts +++ b/frontend/src/mock/index.ts @@ -31,6 +31,9 @@ const routes: Route[] = [ { m: 'get', re: /^\/strategy\/files$/, build: () => D.strategyFilesMock }, { m: 'post', re: /^\/strategy\/file\/[^/]+\/check$/, build: () => ({ ok: true }) }, { m: 'post', re: /^\/strategy\/file\/[^/]+$/, build: () => ({ ok: true }) }, + // §12.6 补:代码版本快照(列表在前,单版本 4 段路径在后) + { m: 'get', re: /^\/strategy\/code-versions$/, build: (url) => D.codeVersionsMock(new URL(url, 'http://x').searchParams.get('file') || '') }, + { m: 'get', re: /^\/strategy\/code-versions\/[^/]+\/[^/]+$/, build: (url) => { const seg = url.split('/').filter(Boolean); return D.codeVersionMock(decodeURIComponent(seg[2]), seg[3]) } }, // strategy configs(CRUD) { m: 'get', re: /^\/strategy\/configs$/, build: () => D.strategyConfigsMock }, { m: 'post', re: /^\/strategy\/configs$/, build: () => ({ id: 99 }) }, diff --git a/frontend/src/views/paper/Reconcile.vue b/frontend/src/views/paper/Reconcile.vue index 4b88bdf..d569b8a 100644 --- a/frontend/src/views/paper/Reconcile.vue +++ b/frontend/src/views/paper/Reconcile.vue @@ -99,6 +99,13 @@ function sideText(s: string): string { 净值序列不全 +
+ 代码版本一致(§12.6 快照) + {{ p.code_match == null ? '—' : '双端快照' }} + 一致 + 不一致(先查这个) + 无快照 +
diff --git a/frontend/src/views/strategy/Code.vue b/frontend/src/views/strategy/Code.vue index 80c50a9..6ea466a 100644 --- a/frontend/src/views/strategy/Code.vue +++ b/frontend/src/views/strategy/Code.vue @@ -3,7 +3,9 @@ import { ref, computed, onMounted, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import MonacoEditor from '@/components/MonacoEditor.vue' +import MonacoDiff from '@/components/MonacoDiff.vue' import { apiClient } from '@/api/client' +import { getCodeVersions, getCodeVersion, type CodeVersion } from '@/api/strategy' interface StrategyFile { name: string @@ -132,6 +134,35 @@ function runBacktest(): void { router.push({ path: '/backtest/new', query: { class: active.value.class_name } }) } } + +// ===== §12.6 补:版本历史(发起时快照)===== +const versions = ref([]) +const versionsOpen = ref(false) +const diffCode = ref(null) +const versionsLoading = ref(false) + +async function openVersions(): Promise { + if (!active.value) return + versionsOpen.value = true + versionsLoading.value = true + diffCode.value = null + try { + versions.value = await getCodeVersions(active.value.name) + } catch { + ElMessage.error('版本列表加载失败') + } finally { + versionsLoading.value = false + } +} + +async function showDiff(h8: string): Promise { + if (!active.value) return + try { + diffCode.value = await getCodeVersion(active.value.name, h8) + } catch { + ElMessage.error('读取版本失败') + } +} @@ -324,4 +382,14 @@ function runBacktest(): void { min-height: 0; background: #05080d; } + +/* 版本历史抽屉 */ +.ver-drawer { display: flex; flex-direction: column; gap: 10px; padding: 4px 2px; } +.ver-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; } +.ver-row { + display: flex; align-items: center; gap: 12px; padding: 8px 10px; + border: 1px solid var(--border-2); border-radius: var(--r-sm); +} +.ver-row:hover { background: var(--bg-hover); } +.ver-diff { height: 460px; border: 1px solid var(--border-2); border-radius: var(--r-sm); overflow: hidden; } diff --git a/frontend/src/views/strategy/InstanceOverview.vue b/frontend/src/views/strategy/InstanceOverview.vue index 627e78e..942b137 100644 --- a/frontend/src/views/strategy/InstanceOverview.vue +++ b/frontend/src/views/strategy/InstanceOverview.vue @@ -80,6 +80,9 @@ const stLabel: Record = { running: '运行中', done: '已完成 {{ kindLabel[r.kind] }} {{ r.label || `#${r.aid}` }} {{ stLabel[r.status || ''] || r.status }} + + v{{ r.code_version }}{{ r.code_changed ? ' · 已变更' : '' }} + {{ pct(r.ret) }}
该档案还没有账户运行
diff --git a/frontend/src/views/strategy/List.vue b/frontend/src/views/strategy/List.vue index ed28ab3..4f7b5e8 100644 --- a/frontend/src/views/strategy/List.vue +++ b/frontend/src/views/strategy/List.vue @@ -289,6 +289,7 @@ const runningRows = computed(() =>
{{ i.name }} 参数已漂移 + 代码已变更 #{{ i.id }} · {{ i.interval }} · 更新 {{ i.updated_at }}
{{ paramSummary(i.params) }}
diff --git a/sanguo_api/code_versions.py b/sanguo_api/code_versions.py new file mode 100644 index 0000000..2f278fa --- /dev/null +++ b/sanguo_api/code_versions.py @@ -0,0 +1,103 @@ +"""策略代码版本快照(§12.6 补:发起时快照,QuantConnect 轻量版)。 + +痛点:实例参数有发起快照,策略代码没有——历史运行无法回溯「当时跑的哪版代码」。 +方案:发起回测/模拟/实盘那一刻,把当时代码全文按内容寻址落盘 +(data/strategy_code_versions/{file}.{hash8}.py,同内容只存一份天然去重), +运行记录只存 8 位哈希;「代码已变更」= 运行时哈希 ≠ 当前文件哈希。 +""" +from __future__ import annotations + +import hashlib +import os +import re +import time + +# 测试用 monkeypatch 改 _DIR +_DIR: str = os.environ.get( + "SANGUO_CODE_VERSIONS", + os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "strategy_code_versions", + ), +) +_H8 = re.compile(r"^[0-9a-f]{8}$") # 防路径穿越:版本号只认 8 位十六进制 + + +def _version_path(code_file: str, h8: str) -> str: + return os.path.join(_DIR, f"{code_file}.{h8}.py") + + +def snapshot_code(code_file: str) -> dict | None: + """读当前代码 → md5 → 未存过则落盘快照。 + + 返回 {code_file, code_hash, code_version(8位), saved_at};文件不存在返回 None。 + """ + from .strategy_registry import read_strategy_file + + if not code_file: + return None + try: + src = read_strategy_file(code_file)["code"] + except Exception: + return None + full = hashlib.md5(src.encode("utf-8")).hexdigest() + h8 = full[:8] + os.makedirs(_DIR, exist_ok=True) + path = _version_path(code_file, h8) + if not os.path.exists(path): + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write(src) + os.replace(tmp, path) # 原子替换防并发写坏 + return {"code_file": code_file, "code_hash": full, + "code_version": h8, "saved_at": time.strftime("%Y-%m-%d %H:%M:%S")} + + +def current_hash(code_file: str) -> str | None: + """当前文件 8 位哈希(只算不存)。文件不存在返回 None。""" + from .strategy_registry import read_strategy_file + + try: + src = read_strategy_file(code_file)["code"] + except Exception: + return None + return hashlib.md5(src.encode("utf-8")).hexdigest()[:8] + + +def code_changed(code_file: str, code_hash: str | None) -> bool | None: + """运行时哈希 vs 当前文件。哈希缺失(早期账户/未解析文件)返回 None=不可判。""" + if not code_hash: + return None + cur = current_hash(code_file) + return None if cur is None else code_hash[:8] != cur + + +def list_versions(code_file: str) -> list[dict]: + """该文件全部快照(新→旧):{code_version, saved_at(文件mtime), size}。""" + if not os.path.isdir(_DIR): + return [] + prefix = f"{code_file}." + out = [] + for fn in os.listdir(_DIR): + if not (fn.startswith(prefix) and fn.endswith(".py")): + continue + h8 = fn[len(prefix):-3] + if not _H8.match(h8): + continue + st = os.stat(os.path.join(_DIR, fn)) + out.append({"code_version": h8, + "saved_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)), + "size": st.st_size}) + out.sort(key=lambda v: v["saved_at"], reverse=True) + return out + + +def read_version(code_file: str, h8: str) -> str | None: + """读某版本全文。版本号非法(路径穿越尝试)或不存在返回 None。""" + if not _H8.match(h8 or ""): + return None + path = _version_path(code_file, h8) + if not os.path.exists(path): + return None + with open(path, encoding="utf-8") as f: + return f.read() diff --git a/sanguo_api/instance_store.py b/sanguo_api/instance_store.py index 3547bea..2b7e4e4 100644 --- a/sanguo_api/instance_store.py +++ b/sanguo_api/instance_store.py @@ -96,11 +96,13 @@ _KINDS = ("backtest", "replay") def update_instance_run(inst_id: int, kind: str, status: str, - ret: float | None = None) -> bool: + ret: float | None = None, + meta: dict | None = None) -> bool: """事件型运行(回测/回放)完成/失败时回写档案。 kind: backtest/replay(paper_live/live 是持续运行,读时聚合不落盘)。 ret: 小数收益(0.1548=+15.48%),None 则保留旧值。 + meta: 附加信息(如 {"code_hash": ...} 发起时代码版本)。 返回 False = 实例不存在(如已删,静默丢弃)。 """ if kind not in _KINDS: @@ -114,6 +116,10 @@ def update_instance_run(inst_id: int, kind: str, status: str, r["last_return"] = ret r.setdefault("run_returns", {}) r["run_returns"][kind] = ret + if meta: + r.setdefault("run_meta", {}) + r["run_meta"].setdefault(kind, {}) + r["run_meta"][kind].update(meta) r["updated_at"] = time.strftime("%Y-%m-%d") _save(rows) return True diff --git a/sanguo_api/routes.py b/sanguo_api/routes.py index 07ec6fb..b0adfe5 100644 --- a/sanguo_api/routes.py +++ b/sanguo_api/routes.py @@ -100,6 +100,15 @@ async def submit_cta(req: CtaBacktestRequest): cls = get_strategy_class(req.strategy) if cls is None: raise HTTPException(status_code=400, detail=f"未知策略: {req.strategy}") + # §12.6 补:带档案发起 → 当时代码快照(完成回写 run_meta.code_hash) + code_hash = None + if req.instance_id: + from .instance_store import get_instance_params_snapshot + from .code_versions import snapshot_code + + cf = (get_instance_params_snapshot(req.instance_id) or {}).get("code_file") or "" + snap = snapshot_code(cf) + code_hash = snap["code_hash"] if snap else None tid = await get_orchestrator().submit_cta( strategy_class=cls, symbol=req.symbol, @@ -112,6 +121,7 @@ async def submit_cta(req: CtaBacktestRequest): position_pct=req.position_pct, interval=req.interval, instance_id=req.instance_id, + code_hash=code_hash, ) return {"task_id": tid} diff --git a/sanguo_api/routes_live.py b/sanguo_api/routes_live.py index cc4c4c4..faadb25 100644 --- a/sanguo_api/routes_live.py +++ b/sanguo_api/routes_live.py @@ -107,7 +107,13 @@ def create_live(req: LiveCreateRequest): else: # D1 发起时快照:绑已有档案 → setting 用档案当时的参数(API 直调也不绕过) req.setting = dict(instance_store.get_instance_params_snapshot(req.instance_id).get("params") or {}) + # §12.6 补:发起时代码版本快照 + from .code_versions import snapshot_code + + inst_snap = instance_store.get_instance_params_snapshot(req.instance_id) or {} + code_snap = snapshot_code(inst_snap.get("code_file") or "") payload = req.model_dump() + payload["code_hash"] = code_snap["code_hash"] if code_snap else None if payload.get("strategy_type") == "portfolio": # 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env) if not payload.get("strategy_class"): diff --git a/sanguo_api/routes_paper.py b/sanguo_api/routes_paper.py index 781aa8f..8b1eb72 100644 --- a/sanguo_api/routes_paper.py +++ b/sanguo_api/routes_paper.py @@ -115,6 +115,11 @@ def create_paper(req: PaperCreateRequest): snap = instance_store.get_instance_params_snapshot(req.instance_id) if req.instance_id else None if snap and req.strategies: req.strategies[0].params = dict(snap.get("params") or {}) + # §12.6 补:发起时代码版本快照(运行可回溯当时跑的哪版代码) + from .code_versions import snapshot_code + + code_snap = snapshot_code((snap or {}).get("code_file") or "") + code_hash = code_snap["code_hash"] if code_snap else None # 实走/影子是开放账户:起止日期无意义,开始=创建当天(组合日终重放依赖 start_date, # 空值会崩),结束留空;仅回放保留用户填的历史区间 if req.mode in ("live", "shadow"): @@ -129,6 +134,7 @@ def create_paper(req: PaperCreateRequest): if req.mode not in ("live", "shadow"): raise HTTPException(400, "组合策略模拟盘仅支持实走(live)/影子(shadow)模式;历史回放请用「组合回测」") payload = req.model_dump() + payload["code_hash"] = code_hash payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" payload["symbols"] = [req.pool] payload["strategies"] = [{ @@ -141,6 +147,7 @@ def create_paper(req: PaperCreateRequest): return {"account_id": aid, "status": "running"} cta_payload = req.model_dump() + cta_payload["code_hash"] = code_hash cta_payload["engine"] = "shadow" if req.mode == "shadow" else "eod_replay" aid = save_account(db, cta_payload) status = "created" @@ -262,7 +269,16 @@ def list_reconcile_pairs(date: str | None = None): report = saved or build_reconcile_report( db, pair["live_account_id"], pair["shadow_account_id"], date) save_reconcile_report(db, report) - out.append({**pair, "report": report}) + # §12.6 补:双轨代码版本一致性(对账 FAIL 先查这行——两边代码不同价差必然大) + with sqlite3.connect(db) as conn: + hashes = dict(conn.execute( + "SELECT id, code_hash FROM paper_accounts WHERE id IN (?,?)", + (pair["live_account_id"], pair["shadow_account_id"]), + ).fetchall()) + h1 = hashes.get(pair["live_account_id"]) + h2 = hashes.get(pair["shadow_account_id"]) + code_match = None if not (h1 and h2) else h1 == h2 + out.append({**pair, "code_match": code_match, "report": report}) return {"pairs": out} diff --git a/sanguo_api/routes_portfolio.py b/sanguo_api/routes_portfolio.py index 8bfa1f0..b4f1956 100644 --- a/sanguo_api/routes_portfolio.py +++ b/sanguo_api/routes_portfolio.py @@ -54,6 +54,15 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest): if req.interval != "d": raise HTTPException(400, "组合回放暂仅支持日线(影子柜台将支持全周期分钟档)") validate_portfolio_request(req) # 400 中文提示(格式/未来/区间/资金/费率),拦在进队列前 + # §12.6 补:带档案发起 → 当时代码快照(完成回写 run_meta.code_hash) + code_hash = None + if req.instance_id: + from .instance_store import get_instance_params_snapshot + from .code_versions import snapshot_code + + cf = (get_instance_params_snapshot(req.instance_id) or {}).get("code_file") or "" + snap = snapshot_code(cf) + code_hash = snap["code_hash"] if snap else None tid = await get_orchestrator().submit_portfolio( start=req.start_date, end=req.end_date, @@ -68,6 +77,7 @@ async def run_portfolio_backtest(req: PortfolioBacktestRequest): slippage=req.slippage, interval=req.interval, instance_id=req.instance_id, + code_hash=code_hash, ) return {"task_id": tid} diff --git a/sanguo_api/routes_strategy.py b/sanguo_api/routes_strategy.py index 099a02f..f251715 100644 --- a/sanguo_api/routes_strategy.py +++ b/sanguo_api/routes_strategy.py @@ -90,7 +90,7 @@ def get_instances_enriched(): rt = _instance_runtime() out = [] for inst in instance_store.list_instances()["instances"]: - ent = rt.get(inst["id"], {"running_accounts": [], "drift": False}) + ent = rt.get(inst["id"], {"running_accounts": [], "drift": False, "code_changed": False}) paper_runs = [a for a in ent["running_accounts"] if a["kind"] in ("paper", "shadow")] live_runs = [a for a in ent["running_accounts"] if a["kind"] == "live"] status = dict(inst.get("status") or {}) @@ -103,6 +103,7 @@ def get_instances_enriched(): "status": status, "running_accounts": ent["running_accounts"], "drift": ent["drift"], + "code_changed": ent["code_changed"], }) return {"instances": out} @@ -134,17 +135,40 @@ def del_instance(inst_id: int): return {"ok": True} +# ===== §12.6 补:代码版本快照查询 ===== + +@router.get("/strategy/code-versions") +def get_code_versions(file: str): + """该策略文件的全部发起快照(新→旧)。""" + from .code_versions import list_versions + + return {"file": file, "versions": list_versions(file)} + + +@router.get("/strategy/code-versions/{file}/{h8}") +def get_code_version(file: str, h8: str): + """读某版本全文(编辑器 diff 视图左栏)。""" + from .code_versions import read_version + + code = read_version(file, h8) + if code is None: + raise HTTPException(status_code=404, detail="版本不存在") + return {"file": file, "code_version": h8, "code": code} + + # ===== §12.6 做实:读时聚合(持续型运行)+ P1 实例全景 ===== def _instance_runtime() -> dict[int, dict]: """读时聚合每个档案的模拟/实盘账户(持续型运行的"回写")。 - 返回 {instance_id: {running_accounts: [...], drift: bool}};DB 不可达返回 {} - (策略库退化为纯 JSON 状态展示,不阻塞)。 + 返回 {instance_id: {running_accounts: [...], drift: bool, code_changed: bool}}; + DB 不可达返回 {}(策略库退化为纯 JSON 状态展示,不阻塞)。 """ import json as _json import sqlite3 + from .code_versions import code_changed as _code_changed + out: dict[int, dict] = {} try: from .routes_paper import _db_path as paper_db @@ -155,12 +179,12 @@ def _instance_runtime() -> dict[int, dict]: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, name, mode, engine, status, strategies, instance_id, " - "initial_capital FROM paper_accounts WHERE instance_id IS NOT NULL" + "initial_capital, code_hash FROM paper_accounts WHERE instance_id IS NOT NULL" ).fetchall() from sanguo_trader.persistence import load_last_balance for r in rows: - ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False}) + ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False, "code_changed": False}) ret = None last = load_last_balance(pdb, r["id"]) if last and r["initial_capital"]: @@ -169,6 +193,9 @@ def _instance_runtime() -> dict[int, dict]: drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(r))) if drifted: ent["drift"] = True + chg = _code_changed((snapshot or {}).get("code_file") or "", r["code_hash"]) + if chg: + ent["code_changed"] = True if r["status"] == "running": ent["running_accounts"].append({ "kind": "shadow" if r["engine"] == "shadow" else "paper", @@ -176,6 +203,8 @@ def _instance_runtime() -> dict[int, dict]: "label": r["name"] or f"paper#{r['id']}", "ret": ret, "drifted": drifted, + "code_changed": chg, + "code_version": (r["code_hash"] or "")[:8] or None, }) except Exception: pass @@ -189,7 +218,7 @@ def _instance_runtime() -> dict[int, dict]: iid = a.get("instance_id") if not iid: continue - ent = out.setdefault(iid, {"running_accounts": [], "drift": False}) + ent = out.setdefault(iid, {"running_accounts": [], "drift": False, "code_changed": False}) ret = None first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"]) if first and last and first.get("total"): @@ -199,6 +228,9 @@ def _instance_runtime() -> dict[int, dict]: drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(a))) if drifted: ent["drift"] = True + chg = _code_changed((snapshot or {}).get("code_file") or "", a.get("code_hash")) + if chg: + ent["code_changed"] = True if a.get("status") == "running": ent["running_accounts"].append({ "kind": "live", @@ -206,6 +238,8 @@ def _instance_runtime() -> dict[int, dict]: "label": a.get("name") or f"live#{a['id']}", "ret": ret, "drifted": drifted, + "code_changed": chg, + "code_version": (a.get("code_hash") or "")[:8] or None, }) except Exception: pass @@ -217,6 +251,8 @@ def get_instance_overview(inst_id: int): """P1 实例全景:档案 + 全部运行账户(含净值尾部)+ 合并持仓归因。""" import sqlite3 + from .code_versions import code_changed as _code_changed + snap = instance_store.get_instance_params_snapshot(inst_id) if snap is None: raise HTTPException(status_code=404, detail="实例不存在") @@ -256,6 +292,8 @@ def get_instance_overview(inst_id: int): "kind": "shadow" if r["engine"] == "shadow" else "paper", "aid": r["id"], "label": r["name"], "mode": r["mode"], "status": r["status"], "ret": ret, + "code_version": (r["code_hash"] or "")[:8] or None, + "code_changed": _code_changed(snap.get("code_file") or "", r["code_hash"]), "equity": [{"date": c[0], "equity": c[1]} for c in reversed(curve)], }) if r["status"] == "running": @@ -284,6 +322,8 @@ def get_instance_overview(inst_id: int): runs.append({ "kind": "live", "aid": a["id"], "label": a.get("name"), "status": a.get("status"), "ret": ret, "equity": [], + "code_version": (a.get("code_hash") or "")[:8] or None, + "code_changed": _code_changed(snap.get("code_file") or "", a.get("code_hash")), }) if a.get("status") == "running": for p in load_positions(ldb, a["id"]): diff --git a/sanguo_live/persistence.py b/sanguo_live/persistence.py index 39af907..f896eb9 100644 --- a/sanguo_live/persistence.py +++ b/sanguo_live/persistence.py @@ -89,6 +89,7 @@ def init_db(db_path: str) -> None: ("max_pool", "INTEGER"), ("benchmark", "TEXT"), ("instance_id", "INTEGER"), # §12.6 实例做实:账户绑档案 + ("code_hash", "TEXT"), # §12.6 补:发起时代码版本快照 ): try: conn.execute(f"ALTER TABLE live_accounts ADD COLUMN {col} {ddl}") @@ -107,8 +108,8 @@ def save_account(db_path: str, account: dict[str, Any]) -> int: (name, account, vt_symbol, strategy_class, strategy_name, setting, status, interval, initial_capital, connect_wait_sec, init_wait_sec, mini_path, created_at, updated_at, - strategy_type, pool, max_pool, benchmark, instance_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + strategy_type, pool, max_pool, benchmark, instance_id, code_hash) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( account.get("name", "live"), account.get("account", ""), @@ -128,6 +129,7 @@ def save_account(db_path: str, account: dict[str, Any]) -> int: int(account.get("max_pool", 0) or 0), account.get("benchmark", ""), account.get("instance_id"), + account.get("code_hash"), ), ) conn.commit() diff --git a/sanguo_orchestrator/runner.py b/sanguo_orchestrator/runner.py index 1476d5b..b79dda3 100644 --- a/sanguo_orchestrator/runner.py +++ b/sanguo_orchestrator/runner.py @@ -44,7 +44,8 @@ class Orchestrator: async def submit_cta(self, strategy_class, symbol: str, params: dict, start: str, end: str, cfg, benchmark: str = "hs300", capital: float = 1_000_000, position_pct: float = 0.95, - interval: str = "d", instance_id: int | None = None) -> str: + interval: str = "d", instance_id: int | None = None, + code_hash: str | None = None) -> str: """Submit a CTA backtesting task asynchronously""" # Stable uuid up front → reused as the persisted DB task_id, so runner-id == # DB task_id (durable across restarts; previously used id(params) memory addr). @@ -63,6 +64,7 @@ class Orchestrator: position_pct=position_pct, interval=interval, instance_id=instance_id, + code_hash=code_hash, ) await self._notify_stage(task_id, "排队中") @@ -144,7 +146,8 @@ class Orchestrator: min_commission: float = 5.0, slippage: float = 0.0, interval: str = "d", - instance_id: int | None = None) -> str: + instance_id: int | None = None, + code_hash: str | None = None) -> str: """Submit a portfolio backtest task asynchronously. Runs runner_backtest as a subprocess (3600s hard cap) inside the @@ -172,6 +175,7 @@ class Orchestrator: db_path=self.db_path, file_dir=file_dir, instance_id=instance_id, + code_hash=code_hash, ) await self._notify_stage(task_id, "排队中") @@ -217,7 +221,8 @@ class Orchestrator: stats = getattr(result, "statistics", None) or {} m = stats.get("metrics") if isinstance(stats.get("metrics"), dict) else stats ret = m.get("total_return") - update_instance_run(int(inst_id), "backtest", status, ret) + meta = {"code_hash": spec["code_hash"]} if spec.get("code_hash") else None + update_instance_run(int(inst_id), "backtest", status, ret, meta) except Exception: import logging logging.getLogger(__name__).warning( diff --git a/sanguo_trader/persistence.py b/sanguo_trader/persistence.py index d979b48..ac46658 100644 --- a/sanguo_trader/persistence.py +++ b/sanguo_trader/persistence.py @@ -84,6 +84,11 @@ def init_db(db_path: str) -> None: conn.execute("ALTER TABLE paper_accounts ADD COLUMN instance_id INTEGER") except sqlite3.OperationalError: pass # 列已存在 + # 迁移:老库补 code_hash 列(§12.6 补:发起时代码版本快照) + try: + conn.execute("ALTER TABLE paper_accounts ADD COLUMN code_hash TEXT") + except sqlite3.OperationalError: + pass # 列已存在 conn.execute("PRAGMA journal_mode=WAL") conn.commit() @@ -95,8 +100,8 @@ def save_account(db_path: str, account: dict[str, Any]) -> int: (task_id, owner_id, name, strategy_type, mode, interval, symbols, strategies, initial_capital, rate, slippage, size, pricetick, stamp_duty_rate, transfer_fee_rate, min_commission, - status, start_date, end_date, engine, instance_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + status, start_date, end_date, engine, instance_id, code_hash, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( account.get("task_id"), account.get("owner_id", "admin"), account.get("name"), account.get("strategy_type", "cta"), @@ -114,6 +119,7 @@ def save_account(db_path: str, account: dict[str, Any]) -> int: account.get("end_date") or account.get("end"), account.get("engine", "eod_replay"), account.get("instance_id"), + account.get("code_hash"), _now(), _now(), ), ) diff --git a/tests/api/test_code_versions.py b/tests/api/test_code_versions.py new file mode 100644 index 0000000..c27f7bf --- /dev/null +++ b/tests/api/test_code_versions.py @@ -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