From b48a3b5c75febb3436160ad8cc57dcd21614788a Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 13 Aug 2026 10:14:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20=E7=BB=84=E5=90=88=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B=E6=94=B9=20trackTask=20=E4=B8=89=E6=80=81+localStorag?= =?UTF-8?q?e=20=E6=8C=81=E4=B9=85(=E6=8F=90=E4=BA=A4=E5=90=8E=E5=8F=AF?= =?UTF-8?q?=E5=88=87=E9=A1=B5,=E5=9B=9E=E6=9D=A5=E7=BB=AD=E7=9C=8B?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6/=E7=BB=93=E6=9E=9C,=E6=B2=BB=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E7=AD=89=E5=BE=85=E4=BD=93=E9=AA=8C)=20[nas]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/backtest/PortfolioBacktest.vue | 254 +++++++++++------- 1 file changed, 151 insertions(+), 103 deletions(-) diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index 9d1496e..2857829 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -16,7 +16,11 @@ import { type PortfolioMetrics, } from '@/api/portfolio' -const submitting = ref(false) +// 三态:表单 / 进行中(可切走,回来恢复) / 完成 / 失败 +// task 持久在 localStorage(last_portfolio_task),切页不丢——治"只能原地等"的同步体验。 +type Phase = 'form' | 'running' | 'done' | 'failed' +const phase = ref('form') +const trackingTid = ref('') const result = ref(null) const equityCurve = ref([]) const stocks = ref([]) @@ -25,7 +29,11 @@ const metrics = ref(null) const period = ref<{ start: string; end: string; trading_days: number } | null>(null) const errorMsg = ref('') const stageHint = ref('') +const elapsedSec = ref(0) let pollTimer: ReturnType | null = null +let elapsedTimer: ReturnType | null = null + +const route = useRoute() function loadDateRange(): { start: string; end: string } { try { @@ -83,6 +91,13 @@ const strategyLabel = computed( () => strategyOptions.find((o) => o.value === form.strategy)?.label ?? form.strategy, ) +const pageTitle = computed(() => { + if (phase.value === 'running') return '组合回测进行中' + if (phase.value === 'done') return '组合回测结果' + if (phase.value === 'failed') return '组合回测失败' + return '组合策略回测' +}) + // 净值曲线 const equityEl = ref() const { setOption: setEquityOption } = useChart(equityEl) @@ -110,57 +125,71 @@ function renderEquity(): void { } setEquityOption(option) } -const route = useRoute() -const viewTaskId = computed(() => (route.query.task_id as string) || '') -const elapsedSec = ref(0) -let elapsedTimer: ReturnType | null = null -async function loadResult(tid: string): Promise { - try { - const r = await getPortfolioResult(tid) - result.value = r - equityCurve.value = r.equity_curve || [] - stocks.value = r.stocks_selected || [] - trades.value = r.trades || [] - metrics.value = r.metrics || null - period.value = r.period || null - await nextTick() - renderEquity() - } catch { - ElMessage.error('加载历史结果失败') +function clearTimers(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + if (elapsedTimer) { + clearInterval(elapsedTimer) + elapsedTimer = null } } -onMounted(() => { - if (viewTaskId.value) loadResult(viewTaskId.value) -}) -onUnmounted(() => { - if (pollTimer) clearInterval(pollTimer) -}) -watch(equityCurve, renderEquity, { deep: true, flush: 'post' }) - -function fmtPct(v: number | null | undefined): string { - if (v === null || v === undefined || !Number.isFinite(v)) return '-' - return `${v.toFixed(2)}%` -} -function fmtNum(v: number | null | undefined, digits = 2): string { - if (v === null || v === undefined || !Number.isFinite(v)) return '-' - return v.toFixed(digits) +function showResult(r: PortfolioBacktestResult): void { + result.value = r + equityCurve.value = r.equity_curve || [] + stocks.value = r.stocks_selected || [] + trades.value = r.trades || [] + metrics.value = r.metrics || null + period.value = r.period || null + phase.value = 'done' + clearTimers() + // v-if done 块(含 chart-box)此帧才挂载;nextTick 后手动渲染兜底 echarts.init + nextTick(() => renderEquity()) } -async function onSubmit(): Promise { - submitting.value = true +// 跟踪一个 task:查状态,running 则轮询(切走停,回来 onMounted 再调本函数恢复), +// done 显示结果(并清 last——完成的不必再恢复),failed 显示错误。 +async function trackTask(tid: string): Promise { + trackingTid.value = tid + result.value = null errorMsg.value = '' - stageHint.value = '' + phase.value = 'running' + stageHint.value = '排队中' elapsedSec.value = 0 elapsedTimer = setInterval(() => { elapsedSec.value += 1 }, 1000) - result.value = null - equityCurve.value = [] - stocks.value = [] - trades.value = [] - metrics.value = null + clearTimers() + pollTimer = setInterval(async () => { + try { + const s = await getPortfolioTaskStatus(tid) + if (s.stage) stageHint.value = s.stage + if (s.status === 'done') { + const r = await getPortfolioResult(tid) + showResult(r) + try { + localStorage.removeItem('last_portfolio_task') + } catch { + /* ignore */ + } + ElMessage.success(`回测完成: ${r.period?.trading_days ?? 0} 交易日, 选股 ${(r.stocks_selected || []).length} 只`) + } else if (s.status === 'failed') { + phase.value = 'failed' + errorMsg.value = s.error_msg || '回测失败' + clearTimers() + } + // pending/running: 继续轮询 + } catch { + // transient poll error — keep polling + } + }, 2500) +} + +async function onSubmit(): Promise { + errorMsg.value = '' try { const tid = await postPortfolioBacktest({ pool: form.pool, @@ -171,66 +200,69 @@ async function onSubmit(): Promise { initial_cash: form.cash, benchmark: form.benchmark, }) - // Poll task status until done/failed, then fetch result - const r = await new Promise((resolve, reject) => { - pollTimer = setInterval(async () => { - try { - const s = await getPortfolioTaskStatus(tid) - if (s.stage) stageHint.value = s.stage - if (s.status === 'done') { - if (pollTimer) { clearInterval(pollTimer); pollTimer = null } - const res = await getPortfolioResult(tid) - resolve(res) - } else if (s.status === 'failed') { - if (pollTimer) { clearInterval(pollTimer); pollTimer = null } - reject(new Error(s.error_msg || '回测失败')) - } - // pending/running: keep polling - } catch (pollErr) { - // transient network error on poll — keep polling - } - }, 2500) - }) - result.value = r - equityCurve.value = r.equity_curve || [] - stocks.value = r.stocks_selected || [] - trades.value = r.trades || [] - metrics.value = r.metrics || null - period.value = r.period || null - // v-if result 块(含 chart-box div)在此帧才挂载, watch(equityCurve, flush:post) - // 在异步轮询 done 后赋值时序下会漏触发 echarts.init → 净值曲线空白。 - // nextTick 等 chart-box 挂载后手动渲染兜底。 - await nextTick() - renderEquity() - ElMessage.success( - `回测完成: ${r.period?.trading_days ?? 0} 交易日, 选股 ${(r.stocks_selected || []).length} 只`, - ) + try { + localStorage.setItem('last_portfolio_task', tid) + } catch { + /* ignore */ + } + await trackTask(tid) } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } }; message?: string } + phase.value = 'failed' errorMsg.value = err.response?.data?.detail || err.message || '提交失败' - ElMessage.error('回测失败,详见页面提示') - } finally { - submitting.value = false - if (elapsedTimer) { - clearInterval(elapsedTimer) - elapsedTimer = null - } } } + +function resetToForm(): void { + clearTimers() + trackingTid.value = '' + try { + localStorage.removeItem('last_portfolio_task') + } catch { + /* ignore */ + } + result.value = null + errorMsg.value = '' + phase.value = 'form' +} + +onMounted(() => { + const qid = (route.query.task_id as string) || '' + let last = '' + try { + last = localStorage.getItem('last_portfolio_task') || '' + } catch { + /* ignore */ + } + const tid = qid || last + if (tid) trackTask(tid) +}) +onUnmounted(clearTimers) +watch(equityCurve, renderEquity, { deep: true, flush: 'post' }) + +function fmtPct(v: number | null | undefined): string { + if (v === null || v === undefined || !Number.isFinite(v)) return '-' + return `${v.toFixed(2)}%` +} +function fmtNum(v: number | null | undefined, digits = 2): string { + if (v === null || v === undefined || !Number.isFinite(v)) return '-' + return v.toFixed(digits) +}