feat(strategy): TODO#68-#75八项全做+导航自测——#68灯职责拆分:实走/影子在跑=直达/paper/live/{aid}监控页,实盘在跑=/live/monitor/{aid},未跑=发起;回放灯done=直达/paper/result/{aid}(run_meta.replay.account_id);#69回测任务按实例过滤:backtest_stats加instance_id列(ALTER迁移)+_write_back落列+GET /task?instance=N+任务中心过滤条+策略库「回测历史」按钮;回测灯done=直达最新结果页(run_meta.backtest.task_id按前缀分流),跑过无结果=任务中心过滤;#70模拟盘创建后直接跳列表(不跳结果/监控页);列宽重排(策略/实例150模式96频率60操作250,折行实测消除);#71列内容对齐实盘:策略/实例列显示实例名(instances映射,#id退化)+持仓数列(list_papers顺带count volume>0)+删创建时间列(净值日期已含);#72实盘名自动生成={实例名}_v{YYYYMMDD}{minor}(同实例同日递增,listLives计数);模拟盘名={实例名}·{模式};#73术语统一档案→实例(全局8文件UI文案+后端409提示,精确短语防误伤,spec比喻保留文档);#74MonacoDiff v4:优先monaco原生diff(差异高亮),挂载450ms自检original栏占比<30%自动降级双只读编辑器(滚动联动)——全屏模态下大概率吃到原生diff;#75收益标签与数值同源(retInfoOf:运行ret优先含kind,fallback run_returns,标签跟随数值来源,根治影子1044%实为回测收益挂影子标签);927绿+build绿+dev浏览器自测:策略库6入口(回测历史/模拟历史/实盘历史/回测灯/实走灯直达监控/组合无回放)与模拟盘列表新列全过 [vps]
This commit is contained in:
@@ -107,13 +107,16 @@ export interface TaskListItem {
|
||||
start: string
|
||||
end: string
|
||||
instance?: string
|
||||
instance_id?: number | null
|
||||
created_at?: string
|
||||
finished_at?: string
|
||||
duration_s?: number | null
|
||||
}
|
||||
|
||||
export async function getTasks(type?: string): Promise<TaskListItem[]> {
|
||||
const { data } = await apiClient.get<{ tasks: TaskListItem[] }>('/task', { params: type ? { type } : {} })
|
||||
export async function getTasks(type?: string, instance?: number): Promise<TaskListItem[]> {
|
||||
const { data } = await apiClient.get<{ tasks: TaskListItem[] }>('/task', {
|
||||
params: { ...(type ? { type } : {}), ...(instance ? { instance } : {}) },
|
||||
})
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface StrategyCfg {
|
||||
}
|
||||
|
||||
export interface PaperCreate {
|
||||
name?: string
|
||||
mode: string
|
||||
interval: string
|
||||
symbols: string[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 版本对比视图(§12.6 代码快照查看):左=历史快照 右=当前代码,各 50% 只读。
|
||||
// 不用 monaco createDiffEditor:0.56 在抽屉等嵌套容器里分栏测量错误
|
||||
// (original 栏被压 ~36px,layout/固定分栏/内联模式均救不回),双只读编辑器最稳。
|
||||
// 版本对比视图(§12.6 代码快照查看):优先 monaco 原生 diff(差异行高亮/跳转),
|
||||
// 运行时自检分栏宽度——若 original 栏被压扁(0.56 在部分嵌套容器里的测量 bug)
|
||||
// 自动降级为双只读编辑器(滚动联动),保证永远可用。
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import * as monaco from 'monaco-editor'
|
||||
|
||||
@@ -18,12 +18,35 @@ const props = withDefaults(defineProps<{
|
||||
language?: string
|
||||
}>(), { language: 'python' })
|
||||
|
||||
const leftEl = ref<HTMLDivElement>()
|
||||
const rightEl = ref<HTMLDivElement>()
|
||||
const el = ref<HTMLDivElement>()
|
||||
const mode = ref<'diff' | 'dual'>('diff')
|
||||
let diffEd: monaco.editor.IStandaloneDiffEditor | null = null
|
||||
let left: monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let right: monaco.editor.IStandaloneCodeEditor | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
|
||||
const THEME: monaco.editor.IStandaloneThemeData = {
|
||||
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',
|
||||
},
|
||||
}
|
||||
const BASE_OPTS: monaco.editor.IStandaloneEditorConstructionOptions = {
|
||||
theme: 'sanguo-dark',
|
||||
readOnly: true,
|
||||
fontSize: 12,
|
||||
@@ -31,79 +54,110 @@ const OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
lineNumbersMinChars: 4,
|
||||
scrollbar: { alwaysConsumeMouseWheel: false },
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
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',
|
||||
},
|
||||
})
|
||||
if (leftEl.value) left = monaco.editor.create(leftEl.value, { ...OPTIONS, value: props.original, language: props.language })
|
||||
if (rightEl.value) right = monaco.editor.create(rightEl.value, { ...OPTIONS, value: props.modified, language: props.language })
|
||||
// 左右滚动联动:任一侧滚动时同步另一侧
|
||||
left?.onDidScrollChange(() => {
|
||||
if (right && left) {
|
||||
right.setScrollTop(left.getScrollTop())
|
||||
right.setScrollLeft(left.getScrollLeft())
|
||||
}
|
||||
})
|
||||
right?.onDidScrollChange(() => {
|
||||
if (left && right) {
|
||||
left.setScrollTop(right.getScrollTop())
|
||||
left.setScrollLeft(right.getScrollLeft())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.original, (v) => left?.setValue(v))
|
||||
watch(() => props.modified, (v) => right?.setValue(v))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
function disposeAll(): void {
|
||||
const m = diffEd?.getModel()
|
||||
m?.original?.dispose()
|
||||
m?.modified?.dispose()
|
||||
diffEd?.dispose()
|
||||
diffEd = null
|
||||
left?.dispose()
|
||||
right?.dispose()
|
||||
left = null
|
||||
right = null
|
||||
}
|
||||
|
||||
function buildDiff(): void {
|
||||
if (!el.value) return
|
||||
diffEd = monaco.editor.createDiffEditor(el.value, {
|
||||
...BASE_OPTS,
|
||||
renderSideBySide: true,
|
||||
})
|
||||
diffEd.setModel({
|
||||
original: monaco.editor.createModel(props.original, props.language),
|
||||
modified: monaco.editor.createModel(props.modified, props.language),
|
||||
})
|
||||
// 自检:等布局稳定后量 original 栏占比,压扁则降级
|
||||
timer = setTimeout(() => {
|
||||
const box = el.value?.getBoundingClientRect()
|
||||
const orig = el.value?.querySelector('.editor.original')?.getBoundingClientRect()
|
||||
if (!box || !orig || box.width < 50) return
|
||||
if (orig.width < box.width * 0.3) {
|
||||
disposeAll()
|
||||
buildDual()
|
||||
}
|
||||
}, 450)
|
||||
}
|
||||
|
||||
function buildDual(): void {
|
||||
if (!el.value) return
|
||||
mode.value = 'dual'
|
||||
// 双栏 DOM 由模板渲染;此处等下一帧(模板切换后)再建 editor
|
||||
requestAnimationFrame(() => {
|
||||
const l = el.value?.querySelector('.dual-left') as HTMLDivElement | null
|
||||
const r = el.value?.querySelector('.dual-right') as HTMLDivElement | null
|
||||
if (l) left = monaco.editor.create(l, { ...BASE_OPTS, value: props.original, language: props.language })
|
||||
if (r) right = monaco.editor.create(r, { ...BASE_OPTS, value: props.modified, language: props.language })
|
||||
left?.onDidScrollChange(() => {
|
||||
if (right && left) { right.setScrollTop(left.getScrollTop()); right.setScrollLeft(left.getScrollLeft()) }
|
||||
})
|
||||
right?.onDidScrollChange(() => {
|
||||
if (left && right) { left.setScrollTop(right.getScrollTop()); left.setScrollLeft(right.getScrollLeft()) }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
monaco.editor.defineTheme('sanguo-dark', THEME)
|
||||
buildDiff()
|
||||
if (typeof ResizeObserver !== 'undefined' && el.value) {
|
||||
ro = new ResizeObserver(() => diffEd?.layout())
|
||||
ro.observe(el.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => [props.original, props.modified], () => {
|
||||
if (diffEd) {
|
||||
diffEd.setModel({
|
||||
original: monaco.editor.createModel(props.original, props.language),
|
||||
modified: monaco.editor.createModel(props.modified, props.language),
|
||||
})
|
||||
} else {
|
||||
left?.setValue(props.original)
|
||||
right?.setValue(props.modified)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
disposeAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="diff-wrap" data-relayout="v3">
|
||||
<!-- diff 模式:单容器由 diff editor 自渲染;dual 模式:模板双栏 -->
|
||||
<div v-show="mode === 'diff'" ref="el" class="diff-host" data-relayout="v4"></div>
|
||||
<div v-if="mode === 'dual'" class="diff-wrap">
|
||||
<div class="pane">
|
||||
<div class="pane-tag mono">快照版本</div>
|
||||
<div ref="leftEl" class="pane-editor"></div>
|
||||
<div class="pane-editor dual-left"></div>
|
||||
</div>
|
||||
<div class="pane">
|
||||
<div class="pane-tag mono pane-cur">当前代码</div>
|
||||
<div ref="rightEl" class="pane-editor"></div>
|
||||
<div class="pane-editor dual-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-wrap { display: flex; height: 100%; min-height: 300px; gap: 0; background: #05080d; }
|
||||
.diff-host { height: 100%; min-height: 300px; background: #05080d; }
|
||||
.diff-wrap { display: flex; height: 100%; min-height: 300px; background: #05080d; }
|
||||
.pane { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid #16202e; }
|
||||
.pane:last-child { border-right: none; }
|
||||
.pane-tag {
|
||||
font-size: 10px; color: #64798a; padding: 3px 10px; letter-spacing: 1px;
|
||||
border-bottom: 1px solid #16202e; background: #0a0f17;
|
||||
}
|
||||
.pane-tag { font-size: 10px; color: #64798a; padding: 3px 10px; letter-spacing: 1px; border-bottom: 1px solid #16202e; background: #0a0f17; }
|
||||
.pane-cur { color: #00e5ff; }
|
||||
.pane-editor { flex: 1; min-height: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getTasks, getTaskParams, deleteTask, type TaskListItem } from '@/api/backtest'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const tasks = ref<TaskListItem[]>([])
|
||||
const loading = ref(false)
|
||||
const deleting = ref(false)
|
||||
const selection = ref<TaskListItem[]>([])
|
||||
// reserve-selection 后勾选按 row-key 持久;删除完要显式清表内勾选
|
||||
const tableRef = ref()
|
||||
// #69 按实例过滤(策略库「回测历史」跳 ?instance=N 只看该实例的任务)
|
||||
const instFilter = route.query.instance && !Array.isArray(route.query.instance)
|
||||
? ref<number | null>(Number(route.query.instance))
|
||||
: ref<number | null>(null)
|
||||
|
||||
// 筛选
|
||||
const typeFilter = ref<'all' | 'cta' | 'portfolio' | 'optimize' | 'factor'>('all')
|
||||
@@ -94,7 +99,7 @@ onUnmounted(() => {
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
tasks.value = await getTasks()
|
||||
tasks.value = await getTasks(undefined, instFilter.value ?? undefined)
|
||||
} catch {
|
||||
ElMessage.error('任务列表加载失败')
|
||||
} finally {
|
||||
@@ -110,7 +115,7 @@ function hasActive(): boolean {
|
||||
}
|
||||
async function silentRefresh(): Promise<void> {
|
||||
try {
|
||||
tasks.value = await getTasks()
|
||||
tasks.value = await getTasks(undefined, instFilter.value ?? undefined)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -232,6 +237,7 @@ function durationText(row: TaskListItem): string {
|
||||
<p class="page-subtitle">回测 / 参数优化 / 因子分析 的全部任务 · 完成 {{ counts.done }} · 失败 {{ counts.failed }}</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button v-if="instFilter != null" size="small" @click="router.push('/backtest/history')">实例 #{{ instFilter }} 过滤中 · 查看</el-button>
|
||||
<el-button type="danger" plain :disabled="!selection.length" :loading="deleting" @click="doDelete(selection)">
|
||||
删除选中({{ selection.length }})
|
||||
</el-button>
|
||||
|
||||
@@ -246,13 +246,13 @@ async function onSubmit(): Promise<void> {
|
||||
<el-card class="blk" shadow="never" v-loading="loading">
|
||||
<template #header><span class="section-title">策略与参数</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="策略 / 档案">
|
||||
<el-form-item label="策略 / 实例">
|
||||
<el-select v-model="form.strategy" placeholder="选择策略" style="width: 300px">
|
||||
<el-option v-for="s in strategies" :key="s.name" :label="s.name" :value="s.name" />
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="实例档案(可选·带出参数)"
|
||||
placeholder="策略实例(可选·带出参数)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -261,7 +261,7 @@ async function onSubmit(): Promise<void> {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写档案 #{{ selectedInstanceId }}</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写实例 #{{ selectedInstanceId }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="paramsList.length" label="参数">
|
||||
<div class="param-grid">
|
||||
|
||||
@@ -495,13 +495,13 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
</el-select>
|
||||
<span class="muted form-hint">MVP 默认 HS300 子集(20-30 只),快速验证链路</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略 / 档案">
|
||||
<el-form-item label="策略 / 实例">
|
||||
<el-select v-model="form.strategy" style="width: 300px">
|
||||
<el-option v-for="opt in strategyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="实例档案(可选·带出参数)"
|
||||
placeholder="策略实例(可选·带出参数)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -510,7 +510,7 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写档案 #{{ selectedInstanceId }}</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写实例 #{{ selectedInstanceId }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="选股池上限">
|
||||
<el-input-number v-model="form.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
|
||||
|
||||
@@ -177,7 +177,7 @@ async function onStop(a: LiveAccount): Promise<void> {
|
||||
<p class="page-subtitle">miniQMT 直连 · supervisor 轮询 · 每 15s 自动刷新</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<el-button v-if="instFilter != null" size="small" @click="router.push('/live')">档案 #{{ instFilter }} 过滤中 · 查看</el-button>
|
||||
<el-button v-if="instFilter != null" size="small" @click="router.push('/live')">实例 #{{ instFilter }} 过滤中 · 查看</el-button>
|
||||
<el-button type="primary" @click="goNew">+ 新建实盘</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,11 +108,28 @@ async function applyInstance(instId: number | string): Promise<void> {
|
||||
form.value.strategy_name = inst.name || form.value.strategy_name
|
||||
form.value.interval = inst.interval || form.value.interval
|
||||
form.value.setting = { ...form.value.setting, ...(inst.params as object) }
|
||||
// #72 实例名自动生成:{实例名}_v{YYYYMMDD}{minor},同实例同日递增
|
||||
form.value.name = await autoName(inst.name || `inst${instId}`, Number(instId))
|
||||
} catch {
|
||||
/* 预填失败走默认 */
|
||||
}
|
||||
}
|
||||
|
||||
async function autoName(instName: string, instId: number): Promise<string> {
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const prefix = `${instName}_v${today}`
|
||||
let minor = 0
|
||||
try {
|
||||
const { listLives } = await import('@/api/live')
|
||||
const accs = await listLives()
|
||||
minor = accs.filter((a: { instance_id?: number | null; name?: string }) =>
|
||||
a.instance_id === instId && (a.name || '').startsWith(prefix)).length
|
||||
} catch {
|
||||
/* 列表拉不到 → minor=0 */
|
||||
}
|
||||
return `${prefix}${minor}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const instId = route.query.instance
|
||||
if (!instId || Array.isArray(instId)) return
|
||||
@@ -226,7 +243,7 @@ async function onSubmit(): Promise<void> {
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">标的与策略</span></template>
|
||||
<el-form :model="form" label-width="140px">
|
||||
<el-form-item label="策略 / 档案">
|
||||
<el-form-item label="策略 / 实例">
|
||||
<el-select v-model="form.strategy_class" style="width: 300px">
|
||||
<el-option
|
||||
v-for="opt in strategyClassOptions"
|
||||
@@ -237,7 +254,7 @@ async function onSubmit(): Promise<void> {
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="必选:实例档案(策略库可创建)"
|
||||
placeholder="必选:策略实例(策略库可创建)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -246,8 +263,8 @@ async function onSubmit(): Promise<void> {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:实盘从档案发起(仅 DoubleMa 完成 A 股适配)</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:实盘从实例发起(仅 DoubleMa 完成 A 股适配)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标的(vt_symbol)">
|
||||
<el-input v-model="form.vt_symbol" placeholder="600000 或 600000.SSE" style="width: 320px" />
|
||||
@@ -324,7 +341,7 @@ async function onSubmit(): Promise<void> {
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="必选:实例档案(策略库可创建)"
|
||||
placeholder="必选:策略实例(策略库可创建)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -333,8 +350,8 @@ async function onSubmit(): Promise<void> {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:实盘从档案发起(supervisor 拉起 runner_live 子进程)</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:实盘从实例发起(supervisor 拉起 runner_live 子进程)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标的池">
|
||||
<el-select v-model="poolForm.pool" style="width: 320px">
|
||||
|
||||
@@ -86,14 +86,6 @@ function symbolsShort(s: string | undefined): string {
|
||||
return arr.length > 3 ? arr.slice(0, 3).join(',') + ` +${arr.length - 3}` : arr.join(',')
|
||||
}
|
||||
const typeLabel: Record<string, string> = { cta: '个股', portfolio: '组合' }
|
||||
function createdShort(v: string | undefined): string {
|
||||
if (!v) return '—'
|
||||
// created_at 存 UTC isoformat → 转北京时间显示
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v.replace('T', ' ').slice(0, 16)
|
||||
return new Date(d.getTime() + 8 * 3600 * 1000)
|
||||
.toISOString().replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
function open(a: PaperAccount): void {
|
||||
router.push(a.mode === 'replay' ? `/paper/result/${a.id}` : `/paper/live/${a.id}`)
|
||||
}
|
||||
@@ -159,6 +151,22 @@ function openSnapshot(row: PaperAccount & { code_hash?: string; instance_id?: nu
|
||||
}
|
||||
}
|
||||
|
||||
// #71 实例名映射(instance_id → 实例名,对齐实盘列表)
|
||||
const instanceNames = ref<Record<number, string>>({})
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { getInstances } = await import('@/api/strategy')
|
||||
const ins = await getInstances()
|
||||
instanceNames.value = Object.fromEntries(ins.map((i) => [i.id, i.name]))
|
||||
} catch {
|
||||
/* 映射失败退化为 #id */
|
||||
}
|
||||
})
|
||||
function instanceNameOf(instanceId: number | null | undefined): string {
|
||||
if (!instanceId) return ''
|
||||
return instanceNames.value[instanceId] || `实例 #${instanceId}`
|
||||
}
|
||||
|
||||
// 编辑(MVP:名称/标的/初始资金)
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ id: 0, name: '', symbols: '', initial_capital: 0 })
|
||||
@@ -198,7 +206,7 @@ async function saveEdit(): Promise<void> {
|
||||
<p class="page-subtitle">回放 / 实走 · 策略组合模拟盘</p>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<el-button v-if="instFilter != null" size="small" @click="router.push('/paper')">档案 #{{ instFilter }} 过滤中 · 查看</el-button>
|
||||
<el-button v-if="instFilter != null" size="small" @click="router.push('/paper')">实例 #{{ instFilter }} 过滤中 · 查看</el-button>
|
||||
<el-button type="primary" @click="goNew">+ 新建模拟盘</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,22 +248,22 @@ async function saveEdit(): Promise<void> {
|
||||
<div class="cell-id mono">#{{ row.id }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="策略 / 档案" min-width="170">
|
||||
<el-table-column label="策略 / 实例" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-name">{{ strategyOf(row) || '—' }}</div>
|
||||
<div class="cell-id mono muted">{{ row.instance_id ? `档案 #${row.instance_id}` : '未绑档案' }}</div>
|
||||
<div class="cell-id mono muted">{{ instanceNameOf(row.instance_id) || '未绑实例' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模式" width="92">
|
||||
<el-table-column label="模式" width="96">
|
||||
<template #default="{ row }">
|
||||
<span class="chip chip-paper" :class="{ 'chip-shadow': row.mode === 'shadow' }">{{ modeLabel[row.mode] || row.mode }}</span>
|
||||
<span v-if="row.strategy_type === 'portfolio' && row.mode === 'live'" class="chip chip-shadow" title="日终回放=每晚 20:30 全量重放结算">日终</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="频率" width="64">
|
||||
<el-table-column label="频率" width="60">
|
||||
<template #default="{ row }"><span class="mono muted">{{ row.interval }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标的" min-width="130">
|
||||
<el-table-column label="标的" min-width="110">
|
||||
<template #default="{ row }"><span class="mono">{{ symbolsShort(row.symbols) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="收益率" width="86" align="right">
|
||||
@@ -269,13 +277,13 @@ async function saveEdit(): Promise<void> {
|
||||
<div class="cell-id mono muted">{{ row.latest_date ?? '—' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="142">
|
||||
<template #default="{ row }"><span class="mono muted">{{ createdShort(row.created_at) }}</span></template>
|
||||
<el-table-column label="持仓" width="60" align="right">
|
||||
<template #default="{ row }"><span class="mono muted">{{ row.position_count ?? 0 }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel[row.status] || row.status }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="216" fixed="right">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="open(row)">查看</el-button>
|
||||
<el-button
|
||||
|
||||
@@ -112,6 +112,8 @@ async function applyInstance(instId: number | string): Promise<void> {
|
||||
])
|
||||
const inst = ir.instance
|
||||
fromInstance.value = inst.name || String(instId)
|
||||
// #72 名称自动跟随实例(模拟盘口径:实例名·模式)
|
||||
form.value.name = `${inst.name || String(instId)}·${form.value.mode}`
|
||||
const file = fr.files.find((f) => f.name === inst.code_file)
|
||||
const cls = file?.class_name || inst.code_file
|
||||
const sym = inst.symbol_or_pool || '600000'
|
||||
@@ -200,7 +202,8 @@ async function onSubmit(): Promise<void> {
|
||||
: payload.mode === 'live'
|
||||
? `已创建模拟盘 #${aid}(今晚 20:30 起每日结算)`
|
||||
: `已创建回放模拟盘 #${aid}`)
|
||||
router.push(payload.mode === 'replay' ? `/paper/result/${aid}` : `/paper/live/${aid}`)
|
||||
// #70 创建完成直接回列表(用户拍板:不再跳结果/监控页)
|
||||
router.push('/paper')
|
||||
} catch (e: unknown) {
|
||||
ElMessage.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
@@ -269,7 +272,7 @@ function onSymbols(v: string): void {
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="必选:实例档案(策略库可创建)"
|
||||
placeholder="必选:策略实例(策略库可创建)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -278,8 +281,8 @@ function onSymbols(v: string): void {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:模拟盘从档案发起</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:模拟盘从实例发起</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标的池">
|
||||
<el-select v-model="poolForm.pool" style="width: 320px">
|
||||
@@ -336,7 +339,7 @@ function onSymbols(v: string): void {
|
||||
</el-select>
|
||||
<el-select
|
||||
:model-value="selectedInstanceId" clearable filterable
|
||||
placeholder="必选:实例档案(策略库可创建)"
|
||||
placeholder="必选:策略实例(策略库可创建)"
|
||||
style="width: 260px; margin-left: 8px"
|
||||
@update:model-value="onPickInstance"
|
||||
>
|
||||
@@ -345,8 +348,8 @@ function onSymbols(v: string): void {
|
||||
:label="`${i.name}(${i.interval})`"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定档案 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:模拟盘从档案发起(CTA 个股策略)</span>
|
||||
<span v-if="selectedInstanceId != null" class="muted form-hint">绑定实例 #{{ selectedInstanceId }}(发起时快照)</span>
|
||||
<span v-else class="muted form-hint" style="color: var(--amber)">必选:模拟盘从实例发起(CTA 个股策略)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="撮合时点">
|
||||
<el-select v-model="form.strategies[0].match_session" style="width: 280px">
|
||||
|
||||
@@ -118,19 +118,22 @@ const lampOf = (s: string): string => {
|
||||
}
|
||||
const pct = (v: number | null | undefined): string => (v == null ? '—' : (v * 100).toFixed(2) + '%')
|
||||
const retClass = (v: number | null | undefined): string => (v == null ? '' : v >= 0 ? 'up' : 'down')
|
||||
function latestRet(i: Instance): number | null {
|
||||
const runs = (i.running_accounts || []).map((a) => a.ret)
|
||||
const vals = runs.filter((r): r is number => r != null)
|
||||
return vals.length ? vals[vals.length - 1] : i.last_return
|
||||
// #75 收益数值与标签同源:运行类 ret 优先(含其类型),否则回测/回放 run_returns
|
||||
interface RetInfo { v: number | null; label: string }
|
||||
function retInfoOf(i: Instance): RetInfo {
|
||||
const runs = (i.running_accounts || []).filter((a) => a.ret != null)
|
||||
if (runs.length) {
|
||||
const last = runs[runs.length - 1]
|
||||
const label = last.kind === 'live' ? '实盘 · 最新' : last.kind === 'shadow' ? '影子 · 最新' : '模拟实走 · 最新'
|
||||
return { v: last.ret, label }
|
||||
}
|
||||
const rr = (i as Instance & { run_returns?: Record<string, number> }).run_returns || {}
|
||||
if (rr.backtest != null) return { v: rr.backtest, label: '回测 · 最新' }
|
||||
if (rr.replay != null) return { v: rr.replay, label: '回放 · 最新' }
|
||||
return { v: null, label: '未运行' }
|
||||
}
|
||||
function retSub(i: Instance): string {
|
||||
const runs = i.running_accounts || []
|
||||
if (runs.some((r) => r.kind === 'live')) return '实盘 · 最新'
|
||||
if (runs.some((r) => r.kind === 'shadow')) return '影子 · 最新'
|
||||
if (runs.some((r) => r.kind === 'paper')) return '模拟实走 · 最新'
|
||||
if (i.status.backtest === 'done') return '回测 · 最新'
|
||||
if (i.status.replay === 'done') return '回放 · 最新'
|
||||
return '未运行'
|
||||
function latestRet(i: Instance): number | null {
|
||||
return retInfoOf(i).v
|
||||
}
|
||||
const paramSummary = (p: Record<string, unknown>): string =>
|
||||
Object.entries(p).map(([k, v]) => `${k}=${v}`).join(' · ')
|
||||
@@ -145,11 +148,24 @@ function newInstance(): void {
|
||||
function editInstance(i: Instance): void {
|
||||
router.push(`/strategy/instance/${i.id}`)
|
||||
}
|
||||
// 以文件实际类型为准(老实例 type 字段可能不准,如组合档案标了 cta)
|
||||
// 以文件实际类型为准(老实例 type 字段可能不准,如组合实例标了 cta)
|
||||
function fileTypeOf(i: Instance): 'portfolio' | 'cta' {
|
||||
return files.value.find((f) => f.name === i.code_file)?.type || i.type
|
||||
}
|
||||
// #68/#75 灯语义统一:事件型(回测/回放)done→直达最新结果;持续型(实走/影子/实盘)
|
||||
// 在跑→直达该账户监控页;否则→发起(创建页带实例)。「×历史」按钮才进过滤列表。
|
||||
function runBacktest(i: Instance): void {
|
||||
// 有回测结果 → 直达结果页(task_id 前缀判类型);失败/进行中 → 任务中心;没跑过 → 发起
|
||||
const st = i.status?.backtest || '-'
|
||||
const tid = (i as Instance & { run_meta?: Record<string, { task_id?: string }> }).run_meta?.backtest?.task_id
|
||||
if (st === 'done' && tid) {
|
||||
router.push(tid.startsWith('portfolio') ? `/backtest/portfolio?task_id=${tid}` : `/backtest/result/${tid}`)
|
||||
return
|
||||
}
|
||||
if (st !== '-') {
|
||||
router.push(`/backtest/history?instance=${i.id}`)
|
||||
return
|
||||
}
|
||||
const isPf = fileTypeOf(i) === 'portfolio'
|
||||
router.push({
|
||||
path: isPf ? '/backtest/portfolio' : '/backtest/new',
|
||||
@@ -162,17 +178,31 @@ function runBacktest(i: Instance): void {
|
||||
},
|
||||
})
|
||||
}
|
||||
// 运行灯语义:有在跑账户 → 看运行(列表按档案过滤);没有 → 发起(创建页带档案)
|
||||
function runPaper(i: Instance, mode: 'replay' | 'live' | 'shadow'): void {
|
||||
if (mode !== 'replay' && (i.running_accounts || []).some((a) => a.kind === 'paper' || a.kind === 'shadow')) {
|
||||
function runReplay(i: Instance): void {
|
||||
const st = i.status?.replay || '-'
|
||||
const aid = (i as Instance & { run_meta?: Record<string, { account_id?: number }> }).run_meta?.replay?.account_id
|
||||
if (st === 'done' && aid) {
|
||||
router.push(`/paper/result/${aid}`)
|
||||
return
|
||||
}
|
||||
if (st !== '-') {
|
||||
router.push(`/paper?instance=${i.id}`)
|
||||
return
|
||||
}
|
||||
router.push({ path: '/paper/new', query: { mode: 'replay', instance: String(i.id) } })
|
||||
}
|
||||
function runPaper(i: Instance, mode: 'live' | 'shadow'): void {
|
||||
const run = (i.running_accounts || []).find((a) => a.kind === mode || (mode === 'live' && a.kind === 'paper'))
|
||||
if (run) {
|
||||
router.push(`/paper/live/${run.aid}`) // 在跑 → 直达该账户监控页
|
||||
return
|
||||
}
|
||||
router.push({ path: '/paper/new', query: { mode, instance: String(i.id) } })
|
||||
}
|
||||
function runLive(i: Instance): void {
|
||||
if ((i.running_accounts || []).some((a) => a.kind === 'live')) {
|
||||
router.push(`/live?instance=${i.id}`)
|
||||
const run = (i.running_accounts || []).find((a) => a.kind === 'live')
|
||||
if (run) {
|
||||
router.push(`/live/monitor/${run.aid}`) // 在跑 → 直达监控页
|
||||
return
|
||||
}
|
||||
router.push({ path: '/live/new', query: { instance: String(i.id) } })
|
||||
@@ -180,7 +210,7 @@ function runLive(i: Instance): void {
|
||||
async function onSync(i: Instance): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`把档案「${i.name}」当前参数同步到全部运行中的模拟账户?实走明晚结算生效;影子账户需重启影子进程生效。实盘不受影响(停了重发)。`,
|
||||
`把实例「${i.name}」当前参数同步到全部运行中的模拟账户?实走明晚结算生效;影子账户需重启影子进程生效。实盘不受影响(停了重发)。`,
|
||||
'参数同步', { type: 'warning', confirmButtonText: '同步', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
@@ -313,10 +343,10 @@ const runningRows = computed(() =>
|
||||
</div>
|
||||
<div class="params mono muted">{{ paramSummary(i.params) }}</div>
|
||||
<div class="sym mono">{{ i.symbol_or_pool }}</div>
|
||||
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retSub(i) }}</span></div>
|
||||
<div class="ret mono" :class="retClass(latestRet(i))">{{ pct(latestRet(i)) }}<span class="sub">{{ retInfoOf(i).label }}</span></div>
|
||||
<div class="runs">
|
||||
<button class="run-btn" @click="runBacktest(i)"><span class="lamp" :class="lampOf(i.status.backtest)"></span>回测</button>
|
||||
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runPaper(i, 'replay')"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放</button>
|
||||
<button v-if="fileTypeOf(i) !== 'portfolio'" class="run-btn" @click="runReplay(i)"><span class="lamp" :class="lampOf(i.status.replay)"></span>回放</button>
|
||||
<button class="run-btn" :class="{ active: (i.running_accounts || []).some(a => a.kind === 'paper' || a.kind === 'shadow') }" @click="runPaper(i, 'live')">
|
||||
<span class="lamp" :class="lampOf(i.status.paper_live)"></span>{{ runLabel(i, 'paper_live') }}
|
||||
</button>
|
||||
@@ -326,6 +356,7 @@ const runningRows = computed(() =>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<button v-if="i.drift" class="term-btn sm warn" :disabled="syncing" @click="onSync(i)">同步</button>
|
||||
<button class="term-btn sm" @click="router.push(`/backtest/history?instance=${i.id}`)">回测历史</button>
|
||||
<button class="term-btn sm" @click="router.push(`/paper?instance=${i.id}`)">模拟历史</button>
|
||||
<button class="term-btn sm" @click="router.push(`/live?instance=${i.id}`)">实盘历史</button>
|
||||
<button class="term-btn sm" @click="overviewId = i.id">全景</button>
|
||||
@@ -334,7 +365,7 @@ const runningRows = computed(() =>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!selectedInstances.length" class="empty muted">
|
||||
该代码还没有实例档案 — 点右上「+ 新实例」创建,或直接发起运行(发起即建档)
|
||||
该代码还没有实例 — 点右上「+ 新实例」创建,或直接发起运行(发起即建档)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user