Files
sanguo_vnpy_v2/frontend/src/views/backtest/PortfolioBacktest.vue
T
claude_dev 6c2c217e22
CI/CD / test (push) Successful in 12s
CI/CD / nas-deploy (push) Successful in 42s
CI/CD / nas-verify (push) Successful in 9s
fix(web+api): 组合回测max_pool默认30→0——用户拍板「前端页面肯定要改」(回测口径与实盘对齐,f214e2f只翻了live/paper链漏了回测) [nas]
你印象中改过的是6ecdaab#95标的池pool→all(三表单+后端四处),max_pool当时没动。本次三处对齐: PortfolioBacktest表单默认30→0+过时提示(「MVP验证用,默认30」)清理+routes_portfolio Field default=30→0(防API直调口径分裂,#95同款对齐法)。语义同全链:0=不限,策略层>0才截断。build绿+tests/api 165绿。
2026-08-24 20:54:56 +08:00

721 lines
32 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, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
import { getTaskParams } from '@/api/backtest'
import { getInstances } from '@/api/strategy'
import { apiClient } from '@/api/client'
import { disableFutureDate } from '@/utils/dates'
import { INTERVAL_OPTIONS } from '@/constants/intervals'
import {
postPortfolioBacktest,
getPortfolioResult,
type PortfolioBacktestResult,
type EquityPoint,
type StockPicked,
type PortfolioTrade,
type PortfolioMetrics,
type BenchmarkPoint,
type DrawdownPoint,
type HoldingsPoint,
} from '@/api/portfolio'
// 两态:表单(发起) / 查看(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 benchmarkCurve = ref<BenchmarkPoint[]>([])
const drawdownCurve = ref<DrawdownPoint[]>([])
const holdingsCurve = ref<HoldingsPoint[]>([])
const stocks = ref<StockPicked[]>([])
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('')
function loadDateRange(): { start: string; end: string } {
try {
const s = localStorage.getItem('portfolio_date_range')
if (s) return JSON.parse(s) as { start: string; end: string }
} catch {
/* ignore */
}
const end = new Date()
const start = new Date(end)
start.setFullYear(start.getFullYear() - 1)
const f = (d: Date): string => d.toISOString().slice(0, 10)
return { start: f(start), end: f(end) }
}
const dr = loadDateRange()
// §12.6 选档案(跳转带入或页面下拉),提交带 instance_id 回写四格
const selectedInstanceId = ref<number | null>(null)
const form = reactive({
pool: 'all',
strategy: 'all_weather',
max_pool: 0,
start: dr.start,
end: dr.end,
cash: 1_000_000,
benchmark: '000300.XSHG',
commission_rate: 0.0003,
stamp_duty_rate: 0.001,
min_commission: 5,
slippage: 0.001,
interval: 'd',
})
watch(
() => [form.start, form.end],
([s, e]) => {
try {
localStorage.setItem('portfolio_date_range', JSON.stringify({ start: s, end: e }))
} catch {
/* ignore */
}
},
)
const poolOptions = [
{ label: 'HS300 子集(小范围验证)', value: 'hs300_subset' },
{ label: '全市场', value: 'all' },
]
const strategyOptions = [
{ label: '全天候轮动', value: 'all_weather' },
{ label: '牛熊动量', value: 'momentum_timing' },
{ label: '价值精选', value: 'value_selection' },
{ label: '小市值轮动', value: 'small_cap' },
{ label: '通路测试(影子vs实盘双轨)', value: 'channel_test' },
// TET Phase2 验证副本(issue#19):取数走 _ex strict 接口,逻辑与原策略同源
{ label: '全天候轮动(TET对照)', value: 'all_weather_ex' },
{ label: '牛熊动量(TET对照)', value: 'momentum_timing_ex' },
{ label: '价值精选(TET对照)', value: 'value_selection_ex' },
{ label: '小市值轮动(TET对照)', value: 'small_cap_ex' },
]
const BENCHMARK_OPTIONS = [
{ label: '沪深300', value: '000300.XSHG' },
{ label: '中证500', value: '000905.XSHG' },
{ 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 equityEl = ref<HTMLDivElement>()
const { setOption: setEquityOption } = useChart(equityEl)
function renderEquity(): void {
if (!equityCurve.value.length) return
const dates = equityCurve.value.map((p) => p.date)
// 策略净值归一化(首日=1),与基准同轴对比
const base = equityCurve.value[0]?.equity || 1
const values = equityCurve.value.map((p) => p.equity / base)
const hasBench = benchmarkCurve.value.length === equityCurve.value.length
const series: Array<Record<string, unknown>> = [
{
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
lineStyle: { color: '#c23531', width: 1.6 },
areaStyle: { color: '#c23531', opacity: 0.12 },
data: values.map((v) => Number(v.toFixed(4))),
},
]
if (hasBench) {
series.push({
type: 'line', name: '基准净值', smooth: true, showSymbol: false,
lineStyle: { color: '#4b7bce', width: 1.4, type: 'dashed' },
data: benchmarkCurve.value.map((p) => Number((p.benchmark ?? 1).toFixed(4))),
})
}
const option: EChartsCoreOption = {
title: darkTitle(hasBench ? '净值对比(归一化)' : '净值曲线'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } },
grid: darkGrid(),
xAxis: { type: 'category', data: dates, ...darkAxis() },
yAxis: { type: 'value', scale: true, name: '净值', ...darkAxis() },
series,
}
setEquityOption(option)
}
const ddEl = ref<HTMLDivElement>()
const { setOption: setDdOption } = useChart(ddEl)
function renderDrawdown(): void {
if (!drawdownCurve.value.length) return
const option: EChartsCoreOption = {
title: darkTitle('回撤(%)'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
grid: darkGrid(),
xAxis: { type: 'category', data: drawdownCurve.value.map((p) => p.date), ...darkAxis() },
yAxis: { type: 'value', name: '回撤%', ...darkAxis() },
series: [
{
type: 'line', name: '回撤', smooth: true, showSymbol: false,
lineStyle: { color: '#6f42c1', width: 1.4 },
areaStyle: { color: '#6f42c1', opacity: 0.18 },
data: drawdownCurve.value.map((p) => Number((p.drawdown ?? 0).toFixed(2))),
},
],
}
setDdOption(option)
}
// 滚动指标(业界标准:滚动窗口年化波动/夏普,看稳定性而非单值)
const ROLLING_WINDOW = 63 // ≈ 一个季度
const rollEl = ref<HTMLDivElement>()
const { setOption: setRollOption } = useChart(rollEl)
function renderRolling(): void {
const curve = equityCurve.value
if (curve.length < ROLLING_WINDOW + 1) return
const rets: number[] = []
for (let i = 1; i < curve.length; i++) {
rets.push(curve[i].equity / curve[i - 1].equity - 1)
}
const ann = Math.sqrt(252)
const dates: string[] = []
const vols: number[] = []
const sharpes: number[] = []
for (let i = ROLLING_WINDOW - 1; i < rets.length; i++) {
const w = rets.slice(i - ROLLING_WINDOW + 1, i + 1)
const mean = w.reduce((a, b) => a + b, 0) / w.length
const variance = w.reduce((a, b) => a + (b - mean) ** 2, 0) / (w.length - 1)
const sd = Math.sqrt(variance)
dates.push(curve[i + 1].date)
vols.push(Number((sd * ann * 100).toFixed(2)))
sharpes.push(sd > 0 ? Number(((mean / sd) * ann).toFixed(2)) : 0)
}
const option: EChartsCoreOption = {
title: darkTitle(`滚动夏普 / 波动(${ROLLING_WINDOW}日)`),
tooltip: { ...darkTooltip(), trigger: 'axis' },
legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } },
grid: darkGrid(),
xAxis: { type: 'category', data: dates, ...darkAxis() },
yAxis: [
{ type: 'value', name: '波动%', scale: true, ...darkAxis() },
{ type: 'value', name: '夏普', scale: true, ...darkAxis() },
],
series: [
{
type: 'line', name: '滚动年化波动%', smooth: true, showSymbol: false, yAxisIndex: 0,
lineStyle: { color: '#e0a458', width: 1.4 },
areaStyle: { color: '#e0a458', opacity: 0.10 },
data: vols,
},
{
type: 'line', name: '滚动夏普', smooth: true, showSymbol: false, yAxisIndex: 1,
lineStyle: { color: '#36cfc9', width: 1.4 },
data: sharpes,
},
],
}
setRollOption(option)
}
// 持仓变化图(聚宽「每日持仓」):柱=持仓标的数,线=持仓市值(仓位暴露)
const holdEl = ref<HTMLDivElement>()
const { setOption: setHoldOption } = useChart(holdEl)
function renderHoldings(): void {
if (!holdingsCurve.value.length) return
const option: EChartsCoreOption = {
title: darkTitle('持仓变化'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } },
grid: darkGrid(),
xAxis: { type: 'category', data: holdingsCurve.value.map((p) => p.date), ...darkAxis() },
yAxis: [
{ type: 'value', name: '持仓数', minInterval: 1, ...darkAxis() },
{ type: 'value', name: '市值(元)', scale: true, ...darkAxis() },
],
series: [
{
type: 'bar', name: '持仓标的数', yAxisIndex: 0,
itemStyle: { color: 'rgba(54, 207, 201, 0.55)' },
data: holdingsCurve.value.map((p) => p.count),
},
{
type: 'line', name: '持仓市值', smooth: true, showSymbol: false, yAxisIndex: 1,
lineStyle: { color: '#e0a458', width: 1.4 },
data: holdingsCurve.value.map((p) => Number((p.value ?? 0).toFixed(0))),
},
],
}
setHoldOption(option)
}
// 超额收益曲线(聚宽标配):策略净值/基准净值 - 1,围绕 0 轴看相对强弱
const excessEl = ref<HTMLDivElement>()
const { setOption: setExcessOption } = useChart(excessEl)
function renderExcess(): void {
const curve = equityCurve.value
if (curve.length < 2 || benchmarkCurve.value.length !== curve.length) return
const base = curve[0]?.equity || 1
const excess = curve.map((p, i) => {
const nav = p.equity / base
const bench = benchmarkCurve.value[i]?.benchmark ?? 1
return Number((((nav / bench) - 1) * 100).toFixed(2))
})
const option: EChartsCoreOption = {
title: darkTitle('超额收益(%)'),
tooltip: { ...darkTooltip(), trigger: 'axis' },
grid: darkGrid(),
xAxis: { type: 'category', data: curve.map((p) => p.date), ...darkAxis() },
yAxis: { type: 'value', name: '超额%', ...darkAxis() },
series: [
{
type: 'line', name: '超额收益', smooth: true, showSymbol: false,
lineStyle: { color: '#c23531', width: 1.4 },
areaStyle: {
color: '#c23531', opacity: 0.10,
origin: 'start',
},
markLine: {
silent: true, symbol: 'none',
lineStyle: { color: 'var(--text-3, #888)', type: 'dashed', width: 1 },
data: [{ yAxis: 0 }],
},
data: excess,
},
],
}
setExcessOption(option)
}
// 月度收益热力图(前端从净值算:月末净值环比,首月为部分月)
interface MonthlyTable {
years: number[]
cells: Record<string, number | null> // `${year}-${month}` -> 月收益(小数)
yearTotals: Record<number, number | null>
}
const monthly = computed<MonthlyTable | null>(() => {
const curve = equityCurve.value
if (curve.length < 2) return null
const monthLast = new Map<string, number>()
const monthFirst = new Map<string, number>()
for (const p of curve) {
const key = p.date.slice(0, 7)
if (!monthFirst.has(key)) monthFirst.set(key, p.equity)
monthLast.set(key, p.equity)
}
const keys = Array.from(monthLast.keys()).sort()
const cells: Record<string, number | null> = {}
for (let i = 0; i < keys.length; i++) {
const prev = i > 0 ? monthLast.get(keys[i - 1]) : monthFirst.get(keys[i])
const cur = monthLast.get(keys[i])
if (prev == null || cur == null || prev <= 0) continue
cells[keys[i]] = cur / prev - 1
}
const years: number[] = []
const yearTotals: Record<number, number | null> = {}
for (const y of Array.from(new Set(keys.map((k) => Number(k.slice(0, 4))))).sort()) {
years.push(y)
const rets = Array.from({ length: 12 }, (_, m) => cells[`${y}-${String(m + 1).padStart(2, '0')}`])
.filter((v): v is number => v != null)
yearTotals[y] = rets.length
? rets.reduce((acc, r) => acc * (1 + r), 1) - 1
: null
}
return { years, cells, yearTotals }
})
const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
function monthKey(y: number, m: number): string {
return `${y}-${String(m).padStart(2, '0')}`
}
function cellColor(v: number | null | undefined): string {
if (v == null) return 'transparent'
const intensity = Math.min(Math.abs(v) / 0.08, 1) // 8% 封顶
const alpha = 0.12 + intensity * 0.55
return v >= 0 ? `rgba(245, 108, 108, ${alpha.toFixed(2)})` : `rgba(103, 194, 58, ${alpha.toFixed(2)})`
}
function fmtMonth(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return ''
return (v * 100).toFixed(1)
}
async function loadResult(tid: string): Promise<void> {
try {
const r = await getPortfolioResult(tid)
result.value = r
equityCurve.value = r.equity_curve || []
benchmarkCurve.value = r.benchmark_curve || []
drawdownCurve.value = r.drawdown_curve || []
holdingsCurve.value = r.holdings_curve || []
stocks.value = r.stocks_selected || []
trades.value = r.trades || []
metrics.value = r.metrics || null
period.value = r.period || null
await nextTick()
renderEquity()
renderDrawdown()
renderRolling()
renderHoldings()
renderExcess()
} catch {
ElMessage.error('加载结果失败')
}
}
async function onSubmit(): Promise<void> {
errorMsg.value = ''
try {
await postPortfolioBacktest({
pool: form.pool,
strategy: form.strategy,
max_pool: form.max_pool,
start_date: form.start,
end_date: form.end,
initial_cash: form.cash,
benchmark: form.benchmark,
commission_rate: Number(form.commission_rate),
stamp_duty_rate: Number(form.stamp_duty_rate),
min_commission: Number(form.min_commission),
slippage: Number(form.slippage),
interval: form.interval,
instance_id: selectedInstanceId.value,
})
ElMessage.success('回测已提交,后台运行中')
router.push('/backtest/history') // 跳任务中心(历史任务页)看进度/结果
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } }; message?: string }
errorMsg.value = err.response?.data?.detail || err.message || '提交失败'
}
}
const fromTaskId = computed(() => (route.query.from_task as string) || '')
onMounted(async () => {
if (viewTaskId.value) {
loadResult(viewTaskId.value)
} else if (route.query.strategy || route.query.class) {
// 跳转预填:strategy=文件名去.py;兼容旧链接的 class=(可能是 all_weather.py 带后缀)
form.strategy = String(route.query.strategy || route.query.class).replace(/\.py$/, '')
} else if (fromTaskId.value) {
// 从任务列表「点任务ID」跳转:按该任务参数完整预填
void prefillFromTask(fromTaskId.value)
}
// §12.6 从策略库档案发起:按档案参数预填(max_pool/benchmark 等)
const instQ = route.query.instance
if (instQ && !Array.isArray(instQ)) {
selectedInstanceId.value = Number(instQ)
void prefillFromInstance(Number(instQ))
}
try {
instanceOptions.value = await getInstances()
} catch {
/* 档案下拉失败不阻塞 */
}
})
// §12.6 选档案:跳转带入或页面下拉选择,提交带 instance_id 回写四格
const instanceOptions = ref<{ id: number; name: string; code_file: string; interval: string }[]>([])
// 级联:实例按 code_file 归属策略(form.strategy=文件名去 .py
const instancesOfStrategy = computed(() =>
instanceOptions.value.filter((i) => i.code_file.replace(/\.py$/, '') === form.strategy),
)
watch(() => form.strategy, () => {
if (selectedInstanceId.value != null && !instancesOfStrategy.value.some((i) => i.id === selectedInstanceId.value)) {
selectedInstanceId.value = null
}
})
function onPickInstance(id: number | null): void {
selectedInstanceId.value = id
if (id != null) void prefillFromInstance(id)
}
async function prefillFromInstance(instId: number): Promise<void> {
try {
const { data } = await apiClient.get<{
instance: { name?: string; code_file: string; params: Record<string, unknown>; symbol_or_pool?: string }
}>(`/strategy/instances/${instId}`)
const inst = data.instance
if (inst.code_file) form.strategy = inst.code_file.replace(/\.py$/, '')
const p = inst.params || {}
if (typeof p.max_pool === 'number') form.max_pool = p.max_pool
if (typeof p.benchmark === 'string') form.benchmark = p.benchmark
// #89 标的池不预填:#86a 定稿「实例不选标的,发起时定」——老实例存的
// symbol_or_pool(all 等)不该悄悄改用户的池选择
} catch {
/* 预填失败走默认 */
}
}
async function prefillFromTask(tid: string): Promise<void> {
try {
const tp = await getTaskParams(tid)
if (tp.strategy) form.strategy = tp.strategy
if (tp.start) form.start = tp.start
if (tp.end) form.end = tp.end
if (tp.symbol) form.benchmark = tp.symbol
const p = tp.params || {}
if (p.max_pool != null) form.max_pool = Number(p.max_pool)
if (p.cash != null) form.cash = Number(p.cash)
if (p.benchmark != null) form.benchmark = String(p.benchmark)
if (p.commission_rate != null) form.commission_rate = Number(p.commission_rate)
if (p.stamp_duty_rate != null) form.stamp_duty_rate = Number(p.stamp_duty_rate)
if (p.min_commission != null) form.min_commission = Number(p.min_commission)
if (p.slippage != null) form.slippage = Number(p.slippage)
if (p.interval) form.interval = String(p.interval)
} catch {
/* 预填失败,走默认 */
}
}
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' })
function fmtPct(v: number | null | undefined): string {
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
// metrics 已统一小数口径(2026-08-16;历史字面值已在 NAS 一次性迁移)
return `${(v * 100).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>
<template>
<div class="page bt-portfolio">
<div class="page-head">
<div>
<h2 class="page-title">{{ pageTitle }}</h2>
<p class="page-subtitle">BulletTrade + {{ strategyLabel }} · 本地执行回测 · MVP 验证链路</p>
</div>
</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: 300px">
<el-option v-for="opt in strategyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
</el-select>
<el-select
:model-value="selectedInstanceId" clearable filterable
placeholder="策略实例(可选·带出参数)"
style="width: 260px; margin-left: 8px"
@update:model-value="onPickInstance"
>
<el-option
v-for="i in instancesOfStrategy" :key="i.id" :value="i.id"
:label="`${i.name}${i.interval}`"
/>
</el-select>
<span v-if="selectedInstanceId != null" class="muted form-hint">完成后回写实例 #{{ selectedInstanceId }}</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只</span>
</el-form-item>
<el-form-item label="K 线周期">
<el-select v-model="form.interval" style="width: 220px">
<el-option v-for="o in INTERVAL_OPTIONS" :key="o.value" :value="o.value" :label="o.label" :disabled="o.value !== 'd'">
<span>{{ o.label }}</span>
<span v-if="o.value !== 'd'" class="muted" style="float: right; font-size: 12px">影子柜台实时可用·组合回放暂仅日线</span>
</el-option>
</el-select>
<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-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" :disabled-date="disableFutureDate" 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">
({{ period?.start }} ~ {{ period?.end }} · {{ period?.trading_days ?? 0 }} 交易日)
</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">{{ fmtPct(metrics?.annual_volatility) }}</div></div>
<div class="metric-cell"><div class="metric-label">Sortino</div><div class="metric-value">{{ fmtNum(metrics?.sortino) }}</div></div>
<div class="metric-cell"><div class="metric-label">Calmar</div><div class="metric-value">{{ fmtNum(metrics?.calmar) }}</div></div>
<div class="metric-cell"><div class="metric-label">基准收益</div><div class="metric-value" :class="(metrics?.benchmark_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.benchmark_return) }}</div></div>
<div class="metric-cell"><div class="metric-label">超额收益</div><div class="metric-value" :class="(metrics?.excess_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.excess_return) }}</div></div>
<div class="metric-cell"><div class="metric-label">Alpha / Beta</div><div class="metric-value">{{ fmtNum(metrics?.alpha, 2) }} / {{ fmtNum(metrics?.beta, 2) }}</div></div>
</div>
</el-card>
<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 v-if="result && drawdownCurve.length" class="blk" shadow="never">
<template #header><span class="section-title">回撤曲线</span></template>
<div ref="ddEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result && monthly" class="blk" shadow="never">
<template #header><span class="section-title">月度收益热力图(%)</span></template>
<div class="heat-wrap">
<table class="heat-table mono">
<thead>
<tr>
<th>年份</th>
<th v-for="(m, i) in MONTH_LABELS" :key="i">{{ m }}</th>
<th>年度</th>
</tr>
</thead>
<tbody>
<tr v-for="y in monthly.years" :key="y">
<td class="heat-year">{{ y }}</td>
<td
v-for="m in 12" :key="m"
class="heat-cell"
:style="{ background: cellColor(monthly.cells[monthKey(y, m)]) }"
>{{ fmtMonth(monthly.cells[monthKey(y, m)]) }}</td>
<td
class="heat-cell heat-year-total"
:style="{ background: cellColor(monthly.yearTotals[y]) }"
>{{ fmtMonth(monthly.yearTotals[y]) }}</td>
</tr>
</tbody>
</table>
</div>
</el-card>
<el-card v-if="result && equityCurve.length > 64" class="blk" shadow="never">
<template #header><span class="section-title">滚动风险指标</span></template>
<div ref="rollEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result && holdingsCurve.length" class="blk" shadow="never">
<template #header><span class="section-title">持仓变化</span></template>
<div ref="holdEl" class="chart-box dd-box" />
</el-card>
<el-card v-if="result && benchmarkCurve.length === equityCurve.length && equityCurve.length" class="blk" shadow="never">
<template #header><span class="section-title">超额收益</span></template>
<div ref="excessEl" class="chart-box dd-box" />
</el-card>
<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>
</el-card>
<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>
</el-card>
<el-alert v-if="!result" type="info" title="加载结果中或结果为空" :closable="false" show-icon class="blk" />
</template>
</div>
</template>
<style scoped>
.bt-portfolio { display: flex; flex-direction: column; gap: 16px; }
.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 { display: flex; gap: 8px; padding: 0 0 4px; }
.chart-box { width: 100%; height: 360px; }
.dd-box { height: 240px; }
.heat-wrap { overflow-x: auto; }
.heat-table { border-collapse: collapse; width: 100%; font-size: 12px; }
.heat-table th { color: var(--text-3); font-weight: 500; padding: 6px 8px; text-align: center; border-bottom: 1px solid var(--border-2); }
.heat-table td { padding: 7px 8px; text-align: center; }
.heat-year { color: var(--text-2); font-weight: 600; text-align: left; }
.heat-cell { border: 1px solid var(--border-2); border-radius: 3px; color: var(--text); }
.heat-year-total { font-weight: 700; }
.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; }
.muted { color: var(--text-3); font-size: 13px; }
.mono { font-family: var(--mono); }
</style>