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:
@@ -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[]> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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']
|
||||
|
||||
@@ -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 }) },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user