6ed71ed8cf
根因: 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 页表格渲染。
190 lines
6.4 KiB
Vue
190 lines
6.4 KiB
Vue
<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>
|