feat(portfolio): 组合回测改 trackTask 三态+localStorage 持久(提交后可切页,回来续看进度/结果,治同步等待体验) [nas]
This commit is contained in:
@@ -16,7 +16,11 @@ import {
|
|||||||
type PortfolioMetrics,
|
type PortfolioMetrics,
|
||||||
} from '@/api/portfolio'
|
} from '@/api/portfolio'
|
||||||
|
|
||||||
const submitting = ref(false)
|
// 三态:表单 / 进行中(可切走,回来恢复) / 完成 / 失败
|
||||||
|
// task 持久在 localStorage(last_portfolio_task),切页不丢——治"只能原地等"的同步体验。
|
||||||
|
type Phase = 'form' | 'running' | 'done' | 'failed'
|
||||||
|
const phase = ref<Phase>('form')
|
||||||
|
const trackingTid = ref('')
|
||||||
const result = ref<PortfolioBacktestResult | null>(null)
|
const result = ref<PortfolioBacktestResult | null>(null)
|
||||||
const equityCurve = ref<EquityPoint[]>([])
|
const equityCurve = ref<EquityPoint[]>([])
|
||||||
const stocks = ref<StockPicked[]>([])
|
const stocks = ref<StockPicked[]>([])
|
||||||
@@ -25,7 +29,11 @@ const metrics = ref<PortfolioMetrics | null>(null)
|
|||||||
const period = ref<{ start: string; end: string; trading_days: number } | null>(null)
|
const period = ref<{ start: string; end: string; trading_days: number } | null>(null)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const stageHint = ref('')
|
const stageHint = ref('')
|
||||||
|
const elapsedSec = ref(0)
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
function loadDateRange(): { start: string; end: string } {
|
function loadDateRange(): { start: string; end: string } {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +91,13 @@ const strategyLabel = computed(
|
|||||||
() => strategyOptions.find((o) => o.value === form.strategy)?.label ?? form.strategy,
|
() => 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<HTMLDivElement>()
|
const equityEl = ref<HTMLDivElement>()
|
||||||
const { setOption: setEquityOption } = useChart(equityEl)
|
const { setOption: setEquityOption } = useChart(equityEl)
|
||||||
@@ -110,57 +125,71 @@ function renderEquity(): void {
|
|||||||
}
|
}
|
||||||
setEquityOption(option)
|
setEquityOption(option)
|
||||||
}
|
}
|
||||||
const route = useRoute()
|
|
||||||
const viewTaskId = computed(() => (route.query.task_id as string) || '')
|
|
||||||
const elapsedSec = ref(0)
|
|
||||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
async function loadResult(tid: string): Promise<void> {
|
function clearTimers(): void {
|
||||||
try {
|
if (pollTimer) {
|
||||||
const r = await getPortfolioResult(tid)
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
if (elapsedTimer) {
|
||||||
|
clearInterval(elapsedTimer)
|
||||||
|
elapsedTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(r: PortfolioBacktestResult): void {
|
||||||
result.value = r
|
result.value = r
|
||||||
equityCurve.value = r.equity_curve || []
|
equityCurve.value = r.equity_curve || []
|
||||||
stocks.value = r.stocks_selected || []
|
stocks.value = r.stocks_selected || []
|
||||||
trades.value = r.trades || []
|
trades.value = r.trades || []
|
||||||
metrics.value = r.metrics || null
|
metrics.value = r.metrics || null
|
||||||
period.value = r.period || null
|
period.value = r.period || null
|
||||||
await nextTick()
|
phase.value = 'done'
|
||||||
renderEquity()
|
clearTimers()
|
||||||
} catch {
|
// v-if done 块(含 chart-box)此帧才挂载;nextTick 后手动渲染兜底 echarts.init
|
||||||
ElMessage.error('加载历史结果失败')
|
nextTick(() => renderEquity())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// 跟踪一个 task:查状态,running 则轮询(切走停,回来 onMounted 再调本函数恢复),
|
||||||
if (viewTaskId.value) loadResult(viewTaskId.value)
|
// done 显示结果(并清 last——完成的不必再恢复),failed 显示错误。
|
||||||
})
|
async function trackTask(tid: string): Promise<void> {
|
||||||
onUnmounted(() => {
|
trackingTid.value = tid
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
result.value = null
|
||||||
})
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
|
||||||
submitting.value = true
|
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
stageHint.value = ''
|
phase.value = 'running'
|
||||||
|
stageHint.value = '排队中'
|
||||||
elapsedSec.value = 0
|
elapsedSec.value = 0
|
||||||
elapsedTimer = setInterval(() => {
|
elapsedTimer = setInterval(() => {
|
||||||
elapsedSec.value += 1
|
elapsedSec.value += 1
|
||||||
}, 1000)
|
}, 1000)
|
||||||
result.value = null
|
clearTimers()
|
||||||
equityCurve.value = []
|
pollTimer = setInterval(async () => {
|
||||||
stocks.value = []
|
try {
|
||||||
trades.value = []
|
const s = await getPortfolioTaskStatus(tid)
|
||||||
metrics.value = null
|
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<void> {
|
||||||
|
errorMsg.value = ''
|
||||||
try {
|
try {
|
||||||
const tid = await postPortfolioBacktest({
|
const tid = await postPortfolioBacktest({
|
||||||
pool: form.pool,
|
pool: form.pool,
|
||||||
@@ -171,51 +200,53 @@ async function onSubmit(): Promise<void> {
|
|||||||
initial_cash: form.cash,
|
initial_cash: form.cash,
|
||||||
benchmark: form.benchmark,
|
benchmark: form.benchmark,
|
||||||
})
|
})
|
||||||
// Poll task status until done/failed, then fetch result
|
|
||||||
const r = await new Promise<PortfolioBacktestResult>((resolve, reject) => {
|
|
||||||
pollTimer = setInterval(async () => {
|
|
||||||
try {
|
try {
|
||||||
const s = await getPortfolioTaskStatus(tid)
|
localStorage.setItem('last_portfolio_task', tid)
|
||||||
if (s.stage) stageHint.value = s.stage
|
} catch {
|
||||||
if (s.status === 'done') {
|
/* ignore */
|
||||||
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
|
await trackTask(tid)
|
||||||
} 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} 只`,
|
|
||||||
)
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { detail?: string } }; message?: string }
|
const err = e as { response?: { data?: { detail?: string } }; message?: string }
|
||||||
|
phase.value = 'failed'
|
||||||
errorMsg.value = err.response?.data?.detail || err.message || '提交失败'
|
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)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -223,14 +254,15 @@ async function onSubmit(): Promise<void> {
|
|||||||
<div class="page bt-portfolio">
|
<div class="page bt-portfolio">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">{{ viewTaskId ? '组合回测结果' : '组合策略回测' }}</h2>
|
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
BulletTrade + {{ strategyLabel }} · 本地执行回测 · MVP 验证链路
|
BulletTrade + {{ strategyLabel }} · 本地执行回测 · MVP 验证链路
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card v-if="!viewTaskId" class="blk" shadow="never">
|
<!-- 表单 -->
|
||||||
|
<el-card v-if="phase === 'form'" class="blk" shadow="never">
|
||||||
<template #header><span class="section-title">回测参数</span></template>
|
<template #header><span class="section-title">回测参数</span></template>
|
||||||
<el-form :model="form" label-width="120px">
|
<el-form :model="form" label-width="120px">
|
||||||
<el-form-item label="标的池">
|
<el-form-item label="标的池">
|
||||||
@@ -297,32 +329,45 @@ async function onSubmit(): Promise<void> {
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div v-if="!viewTaskId" class="submit-bar">
|
<div class="submit-bar">
|
||||||
<el-button
|
<el-button type="primary" size="large" @click="onSubmit">开始回测</el-button>
|
||||||
type="primary"
|
<span class="muted form-hint">提交后可切换其他页面,回来自动续看进度/结果</span>
|
||||||
size="large"
|
|
||||||
:loading="submitting"
|
|
||||||
@click="onSubmit"
|
|
||||||
>
|
|
||||||
{{ submitting ? `回测中${stageHint ? '(' + stageHint + ')' : '...'}` : '开始回测' }}
|
|
||||||
</el-button>
|
|
||||||
<span v-if="submitting" class="muted form-hint">
|
|
||||||
异步执行 · 已等待 {{ elapsedSec }}s · 组合回测加载大库数据预计 1-3 分钟,请勿关闭
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 进行中(可切走,回来恢复) -->
|
||||||
|
<el-card v-if="phase === 'running'" class="blk" shadow="never">
|
||||||
|
<template #header><span class="section-title">回测进行中</span></template>
|
||||||
|
<div class="running-box">
|
||||||
|
<span class="lamp lamp-ok lamp-pulse"></span>
|
||||||
|
<span class="running-stage">{{ stageHint || '回测中' }}</span>
|
||||||
|
<span class="running-elapsed">已等待 {{ elapsedSec }}s</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted form-hint running-task">
|
||||||
|
task: <span class="mono">{{ trackingTid }}</span> · 组合回测加载大库数据预计 1-3 分钟。
|
||||||
|
<strong>可切换其他页面,本页保留进度,回来继续。</strong>
|
||||||
|
</p>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 失败 -->
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="errorMsg"
|
v-if="phase === 'failed'"
|
||||||
type="error"
|
type="error"
|
||||||
:title="`回测失败:${errorMsg}`"
|
:title="`回测失败:${errorMsg}`"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
class="blk"
|
class="blk"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="phase === 'failed'" class="submit-bar">
|
||||||
|
<el-button type="primary" @click="resetToForm">返回重新设置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果 -->
|
||||||
|
<template v-if="phase === 'done' && result">
|
||||||
|
<div class="submit-bar result-actions">
|
||||||
|
<el-button @click="resetToForm">新建回测</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="result">
|
|
||||||
<!-- 指标卡片 -->
|
|
||||||
<el-card class="blk" shadow="never">
|
<el-card class="blk" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="section-title">关键指标</span>
|
<span class="section-title">关键指标</span>
|
||||||
@@ -362,13 +407,11 @@ async function onSubmit(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 净值曲线 -->
|
|
||||||
<el-card class="blk" shadow="never">
|
<el-card class="blk" shadow="never">
|
||||||
<template #header><span class="section-title">净值曲线</span></template>
|
<template #header><span class="section-title">净值曲线</span></template>
|
||||||
<div ref="equityEl" class="chart-box" />
|
<div ref="equityEl" class="chart-box" />
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 选股名单 -->
|
|
||||||
<el-card class="blk" shadow="never">
|
<el-card class="blk" shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="section-title">选股名单(末日持仓)</span>
|
<span class="section-title">选股名单(末日持仓)</span>
|
||||||
@@ -392,7 +435,6 @@ async function onSubmit(): Promise<void> {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 成交明细 -->
|
|
||||||
<el-card class="blk" shadow="never">
|
<el-card class="blk" shadow="never">
|
||||||
<template #header><span class="section-title">成交明细({{ trades.length }} 条)</span></template>
|
<template #header><span class="section-title">成交明细({{ trades.length }} 条)</span></template>
|
||||||
<el-table :data="trades" stripe size="small" max-height="500" empty-text="无成交">
|
<el-table :data="trades" stripe size="small" max-height="500" empty-text="无成交">
|
||||||
@@ -425,6 +467,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
|
|
||||||
.form-hint { margin-left: 10px; }
|
.form-hint { margin-left: 10px; }
|
||||||
.submit-bar { padding: 4px 0 8px; }
|
.submit-bar { padding: 4px 0 8px; }
|
||||||
|
.result-actions { padding: 0 0 4px; }
|
||||||
|
|
||||||
.chart-box { width: 100%; height: 360px; }
|
.chart-box { width: 100%; height: 360px; }
|
||||||
|
|
||||||
@@ -443,7 +486,12 @@ async function onSubmit(): Promise<void> {
|
|||||||
.metric-value { font-size: 20px; font-weight: 600; color: var(--text); font-family: var(--mono); }
|
.metric-value { font-size: 20px; font-weight: 600; color: var(--text); font-family: var(--mono); }
|
||||||
.metric-value.up { color: #f56c6c; }
|
.metric-value.up { color: #f56c6c; }
|
||||||
.metric-value.down { color: #67c23a; }
|
.metric-value.down { color: #67c23a; }
|
||||||
/* A 股惯例涨红跌绿;回撤是负值用绿色(表示"少亏方向")但习惯 down class 显绿 */
|
|
||||||
|
.running-box { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||||
|
.running-stage { font-size: 15px; font-weight: 600; color: var(--brand); }
|
||||||
|
.running-elapsed { font-size: 13px; color: var(--text-3); font-family: var(--mono); }
|
||||||
|
.running-task { margin-top: 8px; line-height: 1.7; }
|
||||||
|
.running-task strong { color: var(--brand); }
|
||||||
|
|
||||||
.muted { color: var(--text-3); font-size: 13px; }
|
.muted { color: var(--text-3); font-size: 13px; }
|
||||||
.mono { font-family: var(--mono); }
|
.mono { font-family: var(--mono); }
|
||||||
|
|||||||
Reference in New Issue
Block a user