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>