Files
sanguo_vnpy_v2/frontend/src/views/backtest/Progress.vue
T
claude_dev 6ed71ed8cf 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 页表格渲染。
2026-07-17 10:58:49 +08:00

190 lines
6.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTask } from '@/composables/useTask'
import { getLog } from '@/api/backtest'
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)
// 同时拉日志(3s
const logText = ref('')
const logBox = ref<HTMLElement | null>(null)
let logTimer: ReturnType<typeof setInterval> | null = null
async function pullLog(): Promise<void> {
try {
logText.value = await getLog(taskId)
await nextTick()
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight
} catch {
/* log optional */
}
}
onMounted(() => {
start()
void pullLog()
logTimer = setInterval(pullLog, 3000)
})
onUnmounted(() => {
if (logTimer) clearInterval(logTimer)
})
watch(status, (s) => {
if (s === 'done') {
if (isFactor.value) {
router.push(`/factor/result/${taskId}`)
} else if (isOptimize.value) {
router.push(`/backtest/optimize-result/${taskId}`)
} else {
router.push(`/backtest/result/${taskId}`)
}
}
})
// 步骤推导
interface Step { key: string; label: string }
const steps: Step[] = [
{ key: 'submit', label: '任务提交' },
{ key: 'data', label: '数据加载' },
{ key: 'run', label: '回测执行' },
{ key: 'metrics', label: '指标计算' },
{ key: 'done', label: '完成' },
]
function stepState(idx: number): 'done' | 'active' | 'pending' {
const s = status.value
if (s === 'done') return 'done'
if (s === 'failed') {
// 失败:当前进行到的步骤标 active(红),之前标 done
return idx < currentIdx.value ? 'done' : idx === currentIdx.value ? 'active' : 'pending'
}
if (idx < currentIdx.value) return 'done'
if (idx === currentIdx.value) return 'active'
return 'pending'
}
const currentIdx = computed(() => {
const st = (stage.value || '').toLowerCase()
const s = status.value
if (s === 'done') return steps.length - 1
if (s === 'pending' || s === 'unknown') return 0
if (st.includes('metric') || st.includes('指标')) return 3
if (st.includes('run') || st.includes('backtest') || st.includes('回测')) return 2
if (st.includes('data') || st.includes('load') || st.includes('数据')) return 1
return 1
})
const pct = computed(() => {
if (status.value === 'done') return 100
if (status.value === 'failed') return Math.round((currentIdx.value / (steps.length - 1)) * 100)
return Math.round((currentIdx.value / (steps.length - 1)) * 100)
})
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', pending: '排队中', unknown: '初始化' }
const statusChipClass: Record<string, string> = { running: 'st-running', done: 'st-done', failed: 'st-failed', pending: 'st-pending', unknown: 'st-pending' }
</script>
<template>
<div class="page progress-page">
<div class="page-head">
<div>
<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>
</div>
<!-- 进度条 -->
<el-card class="blk" shadow="never">
<el-progress
:percentage="pct"
:status="status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
:stroke-width="10"
/>
<div class="stage-text muted">当前阶段{{ stage || '—' }}</div>
</el-card>
<!-- 步骤时间线 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">执行步骤</span></template>
<div class="timeline">
<div
v-for="(s, i) in steps" :key="s.key"
class="tl-node"
:class="[stepState(i), { failed: status === 'failed' && i === currentIdx }]"
:style="{ flex: i === steps.length - 1 ? '0 0 auto' : '1' }"
>
<div class="tl-dot">
<span v-if="stepState(i) === 'done'"></span>
<span v-else>{{ i + 1 }}</span>
</div>
<div class="tl-label">{{ s.label }}</div>
<div v-if="i < steps.length - 1" class="tl-bar" />
</div>
</div>
</el-card>
<!-- 日志 -->
<el-card class="blk" shadow="never">
<template #header><span class="section-title">运行日志 <em class="muted"> 3s 刷新</em></span></template>
<div ref="logBox" class="log-box">
<pre v-if="logText">{{ logText }}</pre>
<span v-else class="muted">暂无日志输出</span>
</div>
</el-card>
</div>
</template>
<style scoped>
.progress-page { display: flex; flex-direction: column; gap: 16px; }
.blk { border: 1px solid var(--border-2); }
.stage-text { margin-top: 10px; }
.timeline { display: flex; align-items: flex-start; }
.tl-node { display: flex; flex-direction: column; align-items: center; position: relative; padding: 0 8px; }
.tl-dot {
width: 26px; height: 26px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
background: var(--bg-hover); color: var(--text-3);
border: 1px solid var(--border-2);
z-index: 1;
}
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); }
.tl-bar { position: absolute; top: 13px; left: 50%; width: 100%; height: 2px; background: var(--border-2); z-index: 0; }
.tl-node.done .tl-dot { background: var(--down); color: #fff; border-color: var(--down); }
.tl-node.done .tl-label { color: var(--text); }
.tl-node.done + .tl-node .tl-bar,
.tl-node.done .tl-bar { background: var(--down); }
.tl-node.active .tl-dot { background: var(--brand); color: #fff; border-color: var(--brand); }
.tl-node.active .tl-label { color: var(--brand); }
.tl-node.active.failed .tl-dot { background: var(--danger); border-color: var(--danger); }
.tl-node.active.failed .tl-label { color: var(--danger); }
.log-box {
background: var(--bg);
border: 1px solid var(--border-2);
border-radius: var(--r-sm);
padding: 12px;
max-height: 360px;
overflow: auto;
}
.log-box pre {
margin: 0;
font-family: var(--mono);
font-size: 12px;
line-height: 1.6;
color: var(--text-2);
white-space: pre-wrap;
word-break: break-all;
}
</style>