fix(optimize): 参数优化端到端修复(空statistics/500/不跳页)

根因: vnpy run_optimization 孙进程 spawn re-import setting.py 重置 DB → load 0根 → 空 statistics;连带 task_id 不一致 + statistics 含 date/numpy 序列化失败 + 前端单页轮询 180s 超时。
修复(不动 vnpy 源码):
- cta_optimizer: vt_setting.json 适配(vnpy 原生机制 setting.py:43 load_json,孙进程拿到正确 DB) + task_id 贯通 + 主聚合持久化(combos) + 过滤空 statistics 记录
- result_store: _json_default(date→isoformat + numpy→原生),save_result 两处 json.dumps 加 default
- routes: optimization-results 改读 DB 主聚合, fallback 内存(重启不丢)
- runner: _opt_worker 加 task_id 参数 + submit_optimize 传递(对齐 _cta_worker)
- 前端: Optimize 提交后跳 progress;Progress 用 opt_ 前缀判断跳 optimize-result;新建 OptimizeResult 参数表格页;router 加路由
验证: 端到端 optimization-results http=200 + 4组合非空 statistics + 浏览器提交→跳 optimize-result 页表格渲染。
This commit is contained in:
2026-07-17 10:58:49 +08:00
parent 799292d89c
commit 6ed71ed8cf
8 changed files with 282 additions and 154 deletions
+8 -124
View File
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStrategies, type StrategyItem } from '@/api/strategy'
import { submitOptimize, getOptimizationResults, getStatus, type OptRow } from '@/api/backtest'
import { submitOptimize } from '@/api/backtest'
const router = useRouter()
const strategies = ref<StrategyItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const polling = ref(false)
const stageText = ref('')
const results = ref<OptRow[]>([])
const form = reactive({
strategy: '',
@@ -43,91 +42,21 @@ function parseGrid(): Record<string, [number, number, number]> | null {
return grid
}
const statColumns = computed(() => {
const set = new Set<string>()
results.value.forEach((r) => Object.keys(r.statistics || {}).forEach((k) => set.add(k)))
return Array.from(set)
})
// 排序
const sortKey = ref('')
const sortDir = ref<'desc' | 'asc'>('desc')
function setSort(k: string): void {
if (sortKey.value === k) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
} else {
sortKey.value = k
sortDir.value = 'desc'
}
}
const sortedResults = computed(() => {
if (!sortKey.value) return results.value
const k = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
return [...results.value].sort((a, b) => {
const va = Number(a.statistics?.[k] ?? NaN)
const vb = Number(b.statistics?.[k] ?? NaN)
if (!Number.isFinite(va) && !Number.isFinite(vb)) return 0
if (!Number.isFinite(va)) return 1
if (!Number.isFinite(vb)) return -1
return (va - vb) * dir
})
})
// Top1 行(按当前排序首项)
const topParamsKey = computed(() => {
if (!sortedResults.value.length) return ''
return JSON.stringify(sortedResults.value[0].params)
})
function topRowClass({ row }: { row: OptRow }): string {
return JSON.stringify(row.params) === topParamsKey.value ? 'top-row' : ''
}
function fmt(v: unknown): string {
return typeof v === 'number' ? (Math.round(v * 10000) / 10000).toString() : v == null ? '' : String(v)
}
function isUp(k: string): boolean {
return /return|收益|sharpe|夏普|alpha|ir/i.test(k)
}
async function onSubmit(): Promise<void> {
const grid = parseGrid()
if (!grid) return
submitting.value = true
results.value = []
try {
const tid = await submitOptimize({
symbol: form.symbol, strategy: form.strategy, grid,
start: form.start, end: form.end,
})
ElMessage.success('优化已提交,轮询中…')
submitting.value = false
polling.value = true
let status = 'pending'
for (let i = 0; i < 60; i++) {
const s = await getStatus(tid)
status = s.status
stageText.value = s.stage
if (status === 'done' || status === 'failed') break
await new Promise((r) => setTimeout(r, 3000))
}
polling.value = false
if (status !== 'done') {
ElMessage.error('优化未完成: ' + status)
return
}
results.value = await getOptimizationResults(tid)
// 默认按总收益降序
const retKey = statColumns.value.find((k) => /return|收益/i.test(k))
if (retKey) { sortKey.value = retKey; sortDir.value = 'desc' }
ElMessage.success('优化已提交')
router.push(`/backtest/progress/${tid}`)
} catch {
ElMessage.error('提交失败')
} finally {
submitting.value = false
polling.value = false
}
}
</script>
@@ -137,11 +66,11 @@ async function onSubmit(): Promise<void> {
<div class="page-head">
<div>
<h2 class="page-title">参数优化</h2>
<p class="page-subtitle">参数网格搜索 · 自动轮询 · 结果可排序Top1 高亮</p>
<p class="page-subtitle">参数网格搜索 · 提交后进入进度跟踪</p>
</div>
</div>
<el-card class="blk" shadow="never" v-loading="loading || polling" :element-loading-text="stageText || '优化中…'">
<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="策略">
@@ -167,39 +96,6 @@ async function onSubmit(): Promise<void> {
</el-form-item>
</el-form>
</el-card>
<el-card v-if="results.length" class="blk" shadow="never">
<template #header>
<span class="section-title">优化结果 <span class="count-badge">{{ results.length }}</span></span>
<span class="muted sort-hint">点击列头排序 · 当前
<strong v-if="sortKey" class="mono">{{ sortKey }} ({{ sortDir === 'desc' ? '降序' : '升序' }})</strong>
<em v-else>未排序</em>
</span>
</template>
<el-table :data="sortedResults" size="small" :row-class-name="topRowClass">
<el-table-column label="参数" min-width="220">
<template #default="{ row }">
<span v-for="(v, k) in row.params" :key="String(k)" class="param-pair mono">
{{ k }}=<strong>{{ fmt(v) }}</strong>
</span>
</template>
</el-table-column>
<el-table-column
v-for="k in statColumns" :key="k" :label="k" :align="isUp(k) ? 'right' : 'right'"
:class-name="isUp(k) ? 'num up-col' : 'num'"
>
<template #header>
<span class="th-sort" :class="{ active: sortKey === k }" @click="setSort(k)">
{{ k }}
<span v-if="sortKey === k">{{ sortDir === 'desc' ? '' : '' }}</span>
</span>
</template>
<template #default="{ row }">
<span class="mono">{{ fmt(row.statistics?.[k]) }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -207,16 +103,4 @@ async function onSubmit(): Promise<void> {
.opt-page { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.form-hint { margin-left: 10px; }
.sort-hint { margin-left: 12px; font-size: 12px; }
.th-sort { cursor: pointer; user-select: none; }
.th-sort.active { color: var(--brand); font-weight: 700; }
.th-sort:hover { color: var(--brand); }
.param-pair { margin-right: 10px; color: var(--text-2); }
.param-pair strong { color: var(--text); }
:deep(.num) { font-family: var(--mono); }
:deep(.top-row) { background: rgba(63, 185, 80, 0.10) !important; }
:deep(.top-row td) { font-weight: 600; }
</style>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getOptimizationResults, type OptRow } from '@/api/backtest'
const route = useRoute()
const taskId = String(route.params.id)
const loading = ref(false)
const results = ref<OptRow[]>([])
onMounted(async () => {
loading.value = true
try {
results.value = await getOptimizationResults(taskId)
// 默认按总收益降序
const retKey = statColumns.value.find((k) => /return|收益/i.test(k))
if (retKey) { sortKey.value = retKey; sortDir.value = 'desc' }
} catch {
ElMessage.error('优化结果加载失败')
} finally {
loading.value = false
}
})
const statColumns = computed(() => {
const set = new Set<string>()
results.value.forEach((r) => Object.keys(r.statistics || {}).forEach((k) => set.add(k)))
return Array.from(set)
})
// 排序
const sortKey = ref('')
const sortDir = ref<'desc' | 'asc'>('desc')
function setSort(k: string): void {
if (sortKey.value === k) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
} else {
sortKey.value = k
sortDir.value = 'desc'
}
}
const sortedResults = computed(() => {
if (!sortKey.value) return results.value
const k = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
return [...results.value].sort((a, b) => {
const va = Number(a.statistics?.[k] ?? NaN)
const vb = Number(b.statistics?.[k] ?? NaN)
if (!Number.isFinite(va) && !Number.isFinite(vb)) return 0
if (!Number.isFinite(va)) return 1
if (!Number.isFinite(vb)) return -1
return (va - vb) * dir
})
})
// Top1 行(按当前排序首项)
const topParamsKey = computed(() => {
if (!sortedResults.value.length) return ''
return JSON.stringify(sortedResults.value[0].params)
})
function topRowClass({ row }: { row: OptRow }): string {
return JSON.stringify(row.params) === topParamsKey.value ? 'top-row' : ''
}
function fmt(v: unknown): string {
return typeof v === 'number' ? (Math.round(v * 10000) / 10000).toString() : v == null ? '' : String(v)
}
function isUp(k: string): boolean {
return /return|收益|sharpe|夏普|alpha|ir/i.test(k)
}
</script>
<template>
<div class="page opt-result-page">
<div class="page-head">
<div>
<h2 class="page-title">优化结果</h2>
<p class="page-subtitle">任务 ID<span class="mono">{{ taskId }}</span></p>
</div>
</div>
<el-card class="blk" shadow="never" v-loading="loading">
<template #header>
<span class="section-title">参数组合 <span class="count-badge">{{ results.length }}</span></span>
<span class="muted sort-hint">点击列头排序 · 当前
<strong v-if="sortKey" class="mono">{{ sortKey }} ({{ sortDir === 'desc' ? '降序' : '升序' }})</strong>
<em v-else>未排序</em>
</span>
</template>
<el-table v-if="results.length" :data="sortedResults" size="small" :row-class-name="topRowClass">
<el-table-column label="参数" min-width="220">
<template #default="{ row }">
<span v-for="(v, k) in row.params" :key="String(k)" class="param-pair mono">
{{ k }}=<strong>{{ fmt(v) }}</strong>
</span>
</template>
</el-table-column>
<el-table-column
v-for="k in statColumns" :key="k" :label="k" :align="isUp(k) ? 'right' : 'right'"
:class-name="isUp(k) ? 'num up-col' : 'num'"
>
<template #header>
<span class="th-sort" :class="{ active: sortKey === k }" @click="setSort(k)">
{{ k }}
<span v-if="sortKey === k">{{ sortDir === 'desc' ? '' : '' }}</span>
</span>
</template>
<template #default="{ row }">
<span class="mono">{{ fmt(row.statistics?.[k]) }}</span>
</template>
</el-table-column>
</el-table>
<span v-else class="muted">无有效优化结果</span>
</el-card>
</div>
</template>
<style scoped>
.opt-result-page { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.sort-hint { margin-left: 12px; font-size: 12px; }
.th-sort { cursor: pointer; user-select: none; }
.th-sort.active { color: var(--brand); font-weight: 700; }
.th-sort:hover { color: var(--brand); }
.param-pair { margin-right: 10px; color: var(--text-2); }
.param-pair strong { color: var(--text); }
:deep(.num) { font-family: var(--mono); }
:deep(.top-row) { background: rgba(63, 185, 80, 0.10) !important; }
:deep(.top-row td) { font-weight: 600; }
</style>
+9 -3
View File
@@ -8,6 +8,7 @@ const route = useRoute()
const router = useRouter()
const taskId = String(route.params.id)
const isFactor = computed(() => route.path.startsWith('/factor'))
const isOptimize = computed(() => taskId.startsWith('opt_'))
const { status, stage, start } = useTask(taskId)
@@ -37,8 +38,13 @@ onUnmounted(() => {
watch(status, (s) => {
if (s === 'done') {
const base = isFactor.value ? '/factor' : '/backtest'
router.push(`${base}/result/${taskId}`)
if (isFactor.value) {
router.push(`/factor/result/${taskId}`)
} else if (isOptimize.value) {
router.push(`/backtest/optimize-result/${taskId}`)
} else {
router.push(`/backtest/result/${taskId}`)
}
}
})
@@ -89,7 +95,7 @@ const statusChipClass: Record<string, string> = { running: 'st-running', done: '
<div class="page progress-page">
<div class="page-head">
<div>
<h2 class="page-title">{{ isFactor ? '因子分析' : '回测' }}进行中</h2>
<h2 class="page-title">{{ isFactor ? '因子分析' : isOptimize ? '参数优化' : '回测' }}进行中</h2>
<p class="page-subtitle">任务 ID<span class="mono">{{ taskId }}</span></p>
</div>
<span class="chip" :class="statusChipClass[status]">{{ statusLabel[status] }}</span>