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]
CI/CD / test (push) Successful in 13s
CI/CD / nas-deploy (push) Successful in 1m1s
CI/CD / nas-verify (push) Successful in 19s

This commit is contained in:
2026-08-15 23:38:07 +08:00
parent 1103ae85d5
commit 6b07389c85
20 changed files with 549 additions and 18 deletions
+2
View File
@@ -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<ReconcilePair[]> {
+23
View File
@@ -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<Instance[]> {
@@ -85,6 +88,24 @@ export async function getInstances(): Promise<Instance[]> {
return data.instances
}
// ----- §12.6 补:代码版本快照 -----
export interface CodeVersion {
code_version: string
saved_at: string
size: number
}
export async function getCodeVersions(file: string): Promise<CodeVersion[]> {
const { data } = await apiClient.get<{ versions: CodeVersion[] }>('/strategy/code-versions', { params: { file } })
return data.versions
}
export async function getCodeVersion(file: string, h8: string): Promise<string> {
const { data } = await apiClient.get<{ code: string }>(`/strategy/code-versions/${file}/${h8}`)
return data.code
}
export async function syncInstanceParams(instanceId: number): Promise<number> {
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 {
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
// 版本 diff 视图(§12.6 代码快照查看):左=历史版本 右=当前代码。
// 主题与 MonacoEditor.vue 同名同值(define 幂等,两处挂载互不干扰)。
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'
;(self as unknown as { MonacoEnvironment: unknown }).MonacoEnvironment = {
getWorker: () => ({
postMessage() {}, terminate() {}, addEventListener() {},
removeEventListener() {}, dispatchEvent: () => true,
}) as unknown as Worker,
}
const props = withDefaults(defineProps<{
original: string
modified: string
language?: string
}>(), { language: 'python' })
const el = ref<HTMLDivElement>()
let editor: monaco.editor.IStandaloneDiffEditor | null = null
onMounted(() => {
if (!el.value) return
monaco.editor.defineTheme('sanguo-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '4a5868', fontStyle: 'italic' },
{ token: 'keyword', foreground: '00e5ff' },
{ token: 'string', foreground: 'ffb000' },
{ token: 'number', foreground: '2ee68a' },
{ token: 'type', foreground: 'b48cff' },
{ token: 'function', foreground: '7adfff' },
{ token: 'delimiter', foreground: '7a8a9a' },
],
colors: {
'editor.background': '#05080d',
'editor.foreground': '#d4e4f0',
'editorLineNumber.foreground': '#3a4654',
'editorLineNumber.activeForeground': '#00e5ff',
'editor.lineHighlightBackground': '#0a1018',
'diffEditor.insertedTextBackground': '#12291c',
'diffEditor.removedTextBackground': '#2a1418',
},
})
editor = monaco.editor.createDiffEditor(el.value, {
theme: 'sanguo-dark',
readOnly: true,
renderSideBySide: true,
fontSize: 12,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
})
setModels()
})
function setModels(): void {
if (!editor) return
editor.setModel({
original: monaco.editor.createModel(props.original, props.language),
modified: monaco.editor.createModel(props.modified, props.language),
})
}
watch(() => [props.original, props.modified], setModels)
onBeforeUnmount(() => {
const m = editor?.getModel()
m?.original?.dispose()
m?.modified?.dispose()
editor?.dispose()
editor = null
})
</script>
<template>
<div ref="el" class="diff-editor"></div>
</template>
<style scoped>
.diff-editor { height: 100%; min-height: 300px; }
</style>
+17 -3
View File
@@ -410,17 +410,31 @@ export interface RunningAccountMock {
drifted?: boolean
}
export const strategyInstancesEnrichedMock = {
instances: (strategyInstancesMock.instances as Array<StrategyInstance & { running_accounts?: RunningAccountMock[]; drift?: boolean }>).map((i) => ({
instances: (strategyInstancesMock.instances as Array<StrategyInstance & { running_accounts?: RunningAccountMock[]; drift?: boolean; code_changed?: boolean }>).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']
+3
View File
@@ -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 configsCRUD
{ m: 'get', re: /^\/strategy\/configs$/, build: () => D.strategyConfigsMock },
{ m: 'post', re: /^\/strategy\/configs$/, build: () => ({ id: 99 }) },
+7
View File
@@ -99,6 +99,13 @@ function sideText(s: string): string {
</el-tag>
<el-tag v-else size="small" type="info">净值序列不全</el-tag>
</div>
<div class="stat">
<span class="stat-label">代码版本一致§12.6 快照</span>
<span class="stat-value mono">{{ p.code_match == null ? '—' : '双端快照' }}</span>
<el-tag v-if="p.code_match === true" size="small" type="success">一致</el-tag>
<el-tag v-else-if="p.code_match === false" size="small" type="danger">不一致先查这个</el-tag>
<el-tag v-else size="small" type="info">无快照</el-tag>
</div>
</div>
<div class="pair-nav mono muted" v-if="p.report.nav.note">
+68
View File
@@ -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<CodeVersion[]>([])
const versionsOpen = ref(false)
const diffCode = ref<string | null>(null)
const versionsLoading = ref(false)
async function openVersions(): Promise<void> {
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<void> {
if (!active.value) return
try {
diffCode.value = await getCodeVersion(active.value.name, h8)
} catch {
ElMessage.error('读取版本失败')
}
}
</script>
<template>
@@ -184,6 +215,7 @@ function runBacktest(): void {
<div class="bar-right">
<span class="modified mono">{{ active?.modified }}</span>
<button class="term-btn sm" :disabled="checking" @click="onCheck">{{ checking ? '校验中' : '语法检查' }}</button>
<button class="term-btn sm" :disabled="loadingCode" @click="openVersions">版本历史</button>
<button class="term-btn sm primary" :disabled="!dirty || saving" @click="onSave">{{ saving ? '保存中' : '保存' }}</button>
<button class="term-btn sm" :disabled="loadingCode" @click="runBacktest">运行回测</button>
</div>
@@ -193,6 +225,32 @@ function runBacktest(): void {
</div>
</section>
</div>
<!-- §12.6 版本历史发起时快照列表 + 与当前代码 diff -->
<el-drawer :model-value="versionsOpen" size="860px" :with-header="false" append-to-body destroy-on-close @close="versionsOpen = false">
<div v-loading="versionsLoading" class="ver-drawer">
<div class="ver-head">
<div>
<div style="font-size:15px;font-weight:700;color:var(--text)">版本历史 · {{ activeName }}</div>
<div class="muted" style="font-size:11.5px;margin-top:2px">发起回测/模拟/实盘时的代码快照内容寻址去重对比查看与当前代码差异</div>
</div>
<button class="term-btn sm" @click="versionsOpen = false">关闭</button>
</div>
<div v-if="!versions.length && !versionsLoading" class="muted" style="padding:20px 0;text-align:center;font-size:12.5px">
还没有快照从策略档案发起过回测/模拟/实盘后自动生成
</div>
<div v-for="v in versions" :key="v.code_version" class="ver-row">
<span class="mono" style="color:var(--brand)">v{{ v.code_version }}</span>
<span class="muted mono" style="font-size:11px">{{ v.saved_at }}</span>
<span class="muted mono" style="font-size:11px">{{ (v.size / 1024).toFixed(1) }} KB</span>
<button class="term-btn sm" style="margin-left:auto" @click="showDiff(v.code_version)">对比当前</button>
</div>
<div v-if="diffCode != null" class="ver-diff">
<div class="muted mono" style="font-size:11px;margin-bottom:6px">=快照版本 · =当前代码</div>
<MonacoDiff :original="diffCode" :modified="code" />
</div>
</div>
</el-drawer>
</div>
</template>
@@ -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; }
</style>
@@ -80,6 +80,9 @@ const stLabel: Record<string, string> = { running: '运行中', done: '已完成
<span class="chip" :class="r.kind === 'live' ? 'chip-cta' : 'chip-portfolio'">{{ kindLabel[r.kind] }}</span>
<span class="mono" style="font-size:11px">{{ r.label || `#${r.aid}` }}</span>
<span class="muted mono" style="font-size:10px">{{ stLabel[r.status || ''] || r.status }}</span>
<span v-if="r.code_version" class="mono" style="font-size:10px" :style="{ color: r.code_changed ? 'var(--amber)' : 'var(--text-3)' }" :title="r.code_changed ? '发起后代码已修改' : '与当前代码一致'">
v{{ r.code_version }}{{ r.code_changed ? ' · 已变更' : '' }}
</span>
<span class="mono ret" :class="retClass(r.ret)" style="font-weight:700">{{ pct(r.ret) }}</span>
</div>
<div v-if="!data.runs.length" class="muted" style="font-size:12px;padding:8px 0">该档案还没有账户运行</div>
+1
View File
@@ -289,6 +289,7 @@ const runningRows = computed(() =>
<div class="iname" @click="editInstance(i)">
{{ i.name }}
<span v-if="i.drift" class="chip drift">参数已漂移</span>
<span v-if="i.code_changed" class="chip drift" title="发起后策略代码被修改过,在跑账户仍是旧代码">代码已变更</span>
<span class="sub mono">#{{ i.id }} · {{ i.interval }} · 更新 {{ i.updated_at }}</span>
</div>
<div class="params mono muted">{{ paramSummary(i.params) }}</div>
+103
View File
@@ -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()
+7 -1
View File
@@ -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/replaypaper_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
+10
View File
@@ -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}
+6
View File
@@ -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"):
+17 -1
View File
@@ -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}
+10
View File
@@ -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}
+46 -6
View File
@@ -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"]):
+4 -2
View File
@@ -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()
+8 -3
View File
@@ -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(
+8 -2
View File
@@ -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(),
),
)
+122
View File
@@ -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