6c2c217e22
你印象中改过的是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绿。
721 lines
32 KiB
Vue
721 lines
32 KiB
Vue
<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>
|