feat(portfolio): 任务中心UX(GitHub Actions模式)—提交后跳历史任务页+自动刷新运行中状态+发起新回测入口; PortfolioBacktest 回归表单/查看两态(治单任务跟踪丢失) [nas]
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getTasks, type TaskListItem } from '@/api/backtest'
|
||||
@@ -62,7 +62,11 @@ watch([typeFilter, statusFilter, keyword], () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
startAutoRefresh()
|
||||
})
|
||||
onUnmounted(stopAutoRefresh)
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
@@ -72,6 +76,33 @@ async function load(): Promise<void> {
|
||||
ElMessage.error('历史加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动刷新:有 running/pending 任务时每 3s 静默拉取,全部终态后停(GitHub Actions 模式)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
function hasActive(): boolean {
|
||||
return tasks.value.some((t) => t.status === 'running' || t.status === 'pending')
|
||||
}
|
||||
async function silentRefresh(): Promise<void> {
|
||||
try {
|
||||
tasks.value = await getTasks()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!hasActive()) stopAutoRefresh()
|
||||
}
|
||||
function startAutoRefresh(): void {
|
||||
stopAutoRefresh()
|
||||
if (hasActive()) {
|
||||
refreshTimer = setInterval(silentRefresh, 3000)
|
||||
}
|
||||
}
|
||||
function stopAutoRefresh(): void {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +133,7 @@ function statusLabel(s: string): string {
|
||||
<p class="page-subtitle">回测 / 参数优化 / 因子分析 的全部任务 · 完成 {{ counts.done }} · 失败 {{ counts.failed }}</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button type="primary" @click="router.push('/backtest/portfolio')">发起新回测</el-button>
|
||||
<el-button :loading="loading" :icon="'Refresh'" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { EChartsCoreOption } from 'echarts'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
|
||||
import {
|
||||
postPortfolioBacktest,
|
||||
getPortfolioTaskStatus,
|
||||
getPortfolioResult,
|
||||
type PortfolioBacktestResult,
|
||||
type EquityPoint,
|
||||
@@ -16,11 +15,10 @@ import {
|
||||
type PortfolioMetrics,
|
||||
} from '@/api/portfolio'
|
||||
|
||||
// 三态:表单 / 进行中(可切走,回来恢复) / 完成 / 失败
|
||||
// task 持久在 localStorage(last_portfolio_task),切页不丢——治"只能原地等"的同步体验。
|
||||
type Phase = 'form' | 'running' | 'done' | 'failed'
|
||||
const phase = ref<Phase>('form')
|
||||
const trackingTid = ref('')
|
||||
// 两态:表单(发起) / 查看(route.query.task_id 历史结果)。任务跟踪统一在「历史任务」页(任务中心)。
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const viewTaskId = computed(() => (route.query.task_id as string) || '')
|
||||
const result = ref<PortfolioBacktestResult | null>(null)
|
||||
const equityCurve = ref<EquityPoint[]>([])
|
||||
const stocks = ref<StockPicked[]>([])
|
||||
@@ -28,12 +26,6 @@ const trades = ref<PortfolioTrade[]>([])
|
||||
const metrics = ref<PortfolioMetrics | null>(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<typeof setInterval> | null = null
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
function loadDateRange(): { start: string; end: string } {
|
||||
try {
|
||||
@@ -57,7 +49,6 @@ const form = reactive({
|
||||
end: dr.end,
|
||||
cash: 1_000_000,
|
||||
benchmark: '000300.XSHG',
|
||||
// A 股费用(对齐个股回测)
|
||||
commission_rate: 0.0003,
|
||||
stamp_duty_rate: 0.001,
|
||||
min_commission: 5,
|
||||
@@ -78,7 +69,6 @@ const poolOptions = [
|
||||
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
|
||||
{ label: '全市场(慢,非 MVP)', value: 'all' },
|
||||
]
|
||||
|
||||
const strategyOptions = [
|
||||
{ label: '全天候轮动', value: 'all_weather' },
|
||||
{ label: '牛熊动量', value: 'momentum_timing' },
|
||||
@@ -91,19 +81,11 @@ const BENCHMARK_OPTIONS = [
|
||||
{ label: '中证1000', value: '000852.XSHG' },
|
||||
{ label: '中证2000', value: '932000.XSHG' },
|
||||
]
|
||||
|
||||
const strategyLabel = computed(
|
||||
() => strategyOptions.find((o) => o.value === form.strategy)?.label ?? form.strategy,
|
||||
)
|
||||
const pageTitle = computed(() => (viewTaskId.value ? '组合回测结果' : '组合策略回测'))
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
if (phase.value === 'running') return '组合回测进行中'
|
||||
if (phase.value === 'done') return '组合回测结果'
|
||||
if (phase.value === 'failed') return '组合回测失败'
|
||||
return '组合策略回测'
|
||||
})
|
||||
|
||||
// 净值曲线
|
||||
const equityEl = ref<HTMLDivElement>()
|
||||
const { setOption: setEquityOption } = useChart(equityEl)
|
||||
function renderEquity(): void {
|
||||
@@ -118,10 +100,7 @@ function renderEquity(): void {
|
||||
yAxis: { type: 'value', scale: true, name: '净值(元)', ...darkAxis() },
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
name: '策略净值',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values,
|
||||
@@ -131,72 +110,26 @@ function renderEquity(): void {
|
||||
setEquityOption(option)
|
||||
}
|
||||
|
||||
function clearTimers(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
async function loadResult(tid: string): Promise<void> {
|
||||
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('加载结果失败')
|
||||
}
|
||||
if (elapsedTimer) {
|
||||
clearInterval(elapsedTimer)
|
||||
elapsedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// 跟踪一个 task:查状态,running 则轮询(切走停,回来 onMounted 再调本函数恢复),
|
||||
// done 显示结果(并清 last——完成的不必再恢复),failed 显示错误。
|
||||
async function trackTask(tid: string): Promise<void> {
|
||||
trackingTid.value = tid
|
||||
result.value = null
|
||||
errorMsg.value = ''
|
||||
phase.value = 'running'
|
||||
stageHint.value = '排队中'
|
||||
elapsedSec.value = 0
|
||||
elapsedTimer = setInterval(() => {
|
||||
elapsedSec.value += 1
|
||||
}, 1000)
|
||||
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<void> {
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const tid = await postPortfolioBacktest({
|
||||
await postPortfolioBacktest({
|
||||
pool: form.pool,
|
||||
strategy: form.strategy,
|
||||
max_pool: form.max_pool,
|
||||
@@ -209,44 +142,17 @@ async function onSubmit(): Promise<void> {
|
||||
min_commission: Number(form.min_commission),
|
||||
slippage: Number(form.slippage),
|
||||
})
|
||||
try {
|
||||
localStorage.setItem('last_portfolio_task', tid)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await trackTask(tid)
|
||||
ElMessage.success('回测已提交,后台运行中')
|
||||
router.push('/backtest/history') // 跳任务中心(历史任务页)看进度/结果
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { detail?: string } }; message?: string }
|
||||
phase.value = 'failed'
|
||||
errorMsg.value = err.response?.data?.detail || err.message || '提交失败'
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if (viewTaskId.value) loadResult(viewTaskId.value)
|
||||
})
|
||||
onUnmounted(clearTimers)
|
||||
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
|
||||
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
@@ -264,124 +170,92 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
<p class="page-subtitle">
|
||||
BulletTrade + {{ strategyLabel }} · 本地执行回测 · MVP 验证链路
|
||||
</p>
|
||||
<p class="page-subtitle">BulletTrade + {{ strategyLabel }} · 本地执行回测 · MVP 验证链路</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 策略与选股 -->
|
||||
<el-card v-if="phase === 'form'" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">策略与选股</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="标的池">
|
||||
<el-select v-model="form.pool" style="width: 320px">
|
||||
<el-option v-for="opt in poolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<span class="muted form-hint">MVP 默认 HS300 子集(20-30 只),快速验证链路</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略">
|
||||
<el-select v-model="form.strategy" style="width: 320px">
|
||||
<el-option v-for="opt in strategyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<span class="muted form-hint">选择回测策略</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="选股池上限">
|
||||
<el-input-number v-model="form.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
|
||||
<span class="muted form-hint">0=全市场不限, N=前N只(MVP验证用, 默认30)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 时间与资金 -->
|
||||
<el-card v-if="phase === 'form'" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">时间与资金</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始资金">
|
||||
<el-input-number v-model="form.cash" :min="10000" :step="100000" :controls="false" style="width: 220px" />
|
||||
<span class="muted form-hint">元</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 基准与费用 -->
|
||||
<el-card v-if="phase === 'form'" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">基准与费用</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="比较基准">
|
||||
<el-select v-model="form.benchmark" style="width: 220px">
|
||||
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="佣金率">
|
||||
<el-input v-model="form.commission_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.0003=万3(双边,最低5元)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="印花税率">
|
||||
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001=千1(仅卖出)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低佣金">
|
||||
<el-input v-model="form.min_commission" style="width: 160px" />
|
||||
<span class="muted form-hint">元/笔</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="滑点(比率)">
|
||||
<el-input v-model="form.slippage" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001=万10,0=不加</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 提交 -->
|
||||
<div v-if="phase === 'form'" class="submit-bar">
|
||||
<el-button type="primary" size="large" @click="onSubmit">开始回测</el-button>
|
||||
<span class="muted form-hint">提交后可切换其他页面,回来自动续看进度/结果</span>
|
||||
</div>
|
||||
|
||||
<!-- 进行中(可切走,回来恢复) -->
|
||||
<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>
|
||||
<div class="submit-bar">
|
||||
<el-button @click="resetToForm">发起新回测</el-button>
|
||||
<span class="muted form-hint">当前回测仍在后台运行,完成后可在「历史任务」查看</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 失败 -->
|
||||
<el-alert
|
||||
v-if="phase === 'failed'"
|
||||
type="error"
|
||||
:title="`回测失败:${errorMsg}`"
|
||||
:closable="false"
|
||||
show-icon
|
||||
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="!viewTaskId">
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">策略与选股</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="标的池">
|
||||
<el-select v-model="form.pool" style="width: 320px">
|
||||
<el-option v-for="opt in poolOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<span class="muted form-hint">MVP 默认 HS300 子集(20-30 只),快速验证链路</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略">
|
||||
<el-select v-model="form.strategy" style="width: 320px">
|
||||
<el-option v-for="opt in strategyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选股池上限">
|
||||
<el-input-number v-model="form.max_pool" :min="0" :step="10" :controls="false" style="width: 220px" />
|
||||
<span class="muted form-hint">0=全市场不限, N=前N只(MVP验证用, 默认30)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">时间与资金</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始资金">
|
||||
<el-input-number v-model="form.cash" :min="10000" :step="100000" :controls="false" style="width: 220px" />
|
||||
<span class="muted form-hint">元</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">基准与费用</span></template>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="比较基准">
|
||||
<el-select v-model="form.benchmark" style="width: 220px">
|
||||
<el-option v-for="b in BENCHMARK_OPTIONS" :key="b.value" :label="b.label" :value="b.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="佣金率">
|
||||
<el-input v-model="form.commission_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.0003=万3(双边,最低5元)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="印花税率">
|
||||
<el-input v-model="form.stamp_duty_rate" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001=千1(仅卖出)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低佣金">
|
||||
<el-input v-model="form.min_commission" style="width: 160px" />
|
||||
<span class="muted form-hint">元/笔</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="滑点(比率)">
|
||||
<el-input v-model="form.slippage" style="width: 160px" />
|
||||
<span class="muted form-hint">0.001=万10,0=不加</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="submit-bar">
|
||||
<el-button type="primary" size="large" @click="onSubmit">开始回测</el-button>
|
||||
<span class="muted form-hint">提交后跳转「历史任务」看进度,可继续发起多个回测</span>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="errorMsg" type="error" :title="`提交失败:${errorMsg}`" :closable="false" show-icon class="blk" />
|
||||
</template>
|
||||
|
||||
<!-- 查看模式(历史结果) -->
|
||||
<template v-if="viewTaskId">
|
||||
<div class="submit-bar result-actions">
|
||||
<el-button @click="router.push('/backtest/portfolio')">新建回测</el-button>
|
||||
<el-button @click="router.push('/backtest/history')">返回任务列表</el-button>
|
||||
</div>
|
||||
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header>
|
||||
<span class="section-title">关键指标</span>
|
||||
<span class="muted period">
|
||||
@@ -389,85 +263,43 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
</span>
|
||||
</template>
|
||||
<div class="metric-row">
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">总收益</div>
|
||||
<div class="metric-value" :class="(metrics?.total_return ?? 0) >= 0 ? 'up' : 'down'">
|
||||
{{ fmtPct(metrics?.total_return) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">年化收益</div>
|
||||
<div class="metric-value" :class="(metrics?.annual_return ?? 0) >= 0 ? 'up' : 'down'">
|
||||
{{ fmtPct(metrics?.annual_return) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">最大回撤</div>
|
||||
<div class="metric-value down">{{ fmtPct(metrics?.max_drawdown) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">夏普比率</div>
|
||||
<div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">日胜率</div>
|
||||
<div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<div class="metric-label">交易胜率</div>
|
||||
<div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div>
|
||||
</div>
|
||||
<div class="metric-cell"><div class="metric-label">总收益</div><div class="metric-value" :class="(metrics?.total_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.total_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">年化收益</div><div class="metric-value" :class="(metrics?.annual_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.annual_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">最大回撤</div><div class="metric-value down">{{ fmtPct(metrics?.max_drawdown) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">夏普比率</div><div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">日胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">交易胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">净值曲线</span></template>
|
||||
<div ref="equityEl" class="chart-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header>
|
||||
<span class="section-title">选股名单(末日持仓)</span>
|
||||
</template>
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">选股名单(末日持仓)</span></template>
|
||||
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
|
||||
<el-table-column label="代码" prop="code" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="amount" align="right" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.amount.toFixed(0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="均价" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ row.avg_cost.toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="现价" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ row.price.toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="市值" align="right" width="160">
|
||||
<template #default="{ row }"><span class="mono">{{ row.value.toFixed(2) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="代码" prop="code" width="140"><template #default="{ row }"><span class="mono">{{ row.code }}</span></template></el-table-column>
|
||||
<el-table-column label="数量" prop="amount" align="right" width="140"><template #default="{ row }"><span class="mono">{{ row.amount.toFixed(0) }}</span></template></el-table-column>
|
||||
<el-table-column label="均价" align="right" width="120"><template #default="{ row }"><span class="mono">{{ row.avg_cost.toFixed(3) }}</span></template></el-table-column>
|
||||
<el-table-column label="现价" align="right" width="120"><template #default="{ row }"><span class="mono">{{ row.price.toFixed(3) }}</span></template></el-table-column>
|
||||
<el-table-column label="市值" align="right" width="160"><template #default="{ row }"><span class="mono">{{ row.value.toFixed(2) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="blk" shadow="never">
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">成交明细({{ trades.length }} 条)</span></template>
|
||||
<el-table :data="trades" stripe size="small" max-height="500" empty-text="无成交">
|
||||
<el-table-column label="时间" width="180">
|
||||
<template #default="{ row }"><span class="mono">{{ row.datetime || row.date || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="代码" prop="code" width="140">
|
||||
<template #default="{ row }"><span class="mono">{{ row.code || '-' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方向" width="100">
|
||||
<template #default="{ row }">{{ row.side || row.action || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ (row.filled_amount ?? row.amount ?? 0).toFixed(0) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" align="right" width="120">
|
||||
<template #default="{ row }"><span class="mono">{{ (row.filled_price ?? row.price ?? 0).toFixed(3) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" width="180"><template #default="{ row }"><span class="mono">{{ row.datetime || row.date || '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="代码" prop="code" width="140"><template #default="{ row }"><span class="mono">{{ row.code || '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="方向" width="100"><template #default="{ row }">{{ row.side || row.action || '-' }}</template></el-table-column>
|
||||
<el-table-column label="数量" align="right" width="120"><template #default="{ row }"><span class="mono">{{ (row.filled_amount ?? row.amount ?? 0).toFixed(0) }}</span></template></el-table-column>
|
||||
<el-table-column label="价格" align="right" width="120"><template #default="{ row }"><span class="mono">{{ (row.filled_price ?? row.price ?? 0).toFixed(3) }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-alert v-if="!result" type="info" title="加载结果中或结果为空" :closable="false" show-icon class="blk" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -477,35 +309,16 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.section-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.period { margin-left: 12px; font-size: 12px; }
|
||||
|
||||
.form-hint { margin-left: 10px; }
|
||||
.submit-bar { padding: 4px 0 8px; }
|
||||
.result-actions { padding: 0 0 4px; }
|
||||
|
||||
.result-actions { display: flex; gap: 8px; padding: 0 0 4px; }
|
||||
.chart-box { width: 100%; height: 360px; }
|
||||
|
||||
.metric-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.metric-cell {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.metric-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
|
||||
.metric-cell { background: var(--bg-hover); border: 1px solid var(--border-2); border-radius: 6px; padding: 12px 14px; }
|
||||
.metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||
.metric-value { font-size: 20px; font-weight: 600; color: var(--text); font-family: var(--mono); }
|
||||
.metric-value.up { color: #f56c6c; }
|
||||
.metric-value.down { color: #67c23a; }
|
||||
|
||||
.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; }
|
||||
.mono { font-family: var(--mono); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user