Files
easy-tdx/web-ui/src/views/PortfolioView.vue
T
Justin Gu 66c49d1a8d feat(web-ui): 三处回测入口默认起始日期统一为 2020-01-06
单标的回测、组合回测、参数寻优原默认起始日为「3 年前」(动态滚动),
统一改为固定日期 2020-01-06,便于跨时间横向对比。结束日仍为今天。
SymbolPicker 共用组件的 defineModel 默认值同步更新。
2026-07-05 00:15:00 +08:00

512 lines
14 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 { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import EquityChart from '../components/EquityChart.vue'
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import StocksPicker from '../components/StocksPicker.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import { formatError, saveStrategy } from '../api'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
const store = useBacktestStore()
const route = useRoute()
const stocks = ref<string[]>(['SZ:000001', 'SH:600519'])
const strategy = ref('ma_cross')
const params = ref<Record<string, number | string | boolean>>({})
const cash = ref(1000000)
const category = ref<Category>('DAY')
const execution = ref<ExecutionMode>('next_open')
// 成交价模式(精简为 开盘价/收盘价)
const EXECUTIONS: { value: ExecutionMode; label: string }[] = [
{ value: 'next_open', label: '开盘价' },
{ value: 'next_close', label: '收盘价' },
]
const CATEGORIES: Category[] = ['DAY', 'WEEK', 'MONTH', 'MIN_5', 'MIN_15', 'MIN_30', 'MIN_60']
// 日期默认(复用单标的逻辑)
function isoDaysFromNow(days: number): string {
const d = new Date()
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
const startDate = ref('2020-01-06')
const endDate = ref(isoDaysFromNow(0))
onMounted(async () => {
store.loadStrategies().catch((e) => {
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
})
// 从 URL query 回填(策略库「载入」组合策略跳转带来)
const qStrategy = route.query.strategy as string | undefined
const qParams = route.query.params as string | undefined
const qStocks = route.query.stocks as string | undefined
const qStartDate = route.query.startDate as string | undefined
const qEndDate = route.query.endDate as string | undefined
const qCategory = route.query.category as Category | undefined
if (qStrategy) {
strategy.value = qStrategy
await nextTick()
}
if (qParams) {
try {
params.value = JSON.parse(qParams) as Record<string, number | string | boolean>
} catch {
// 解析失败忽略
}
}
if (qStocks) {
stocks.value = qStocks
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}
if (qStartDate) startDate.value = qStartDate
if (qEndDate) endDate.value = qEndDate
if (qCategory) category.value = qCategory
})
async function onRun() {
await store.runPortfolio({
strategy: strategy.value,
params: params.value,
cash: cash.value,
execution: execution.value,
stocks: stocks.value,
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
})
}
// ── 保存策略(把当前组合结果 + 配置 + 上下文存进策略库)──────────────────────
const showSaveForm = ref(false)
const saving = ref(false)
const saveName = ref('')
const saveTags = ref('')
const saveNotes = ref('')
const saveMsg = ref('')
const strategyLabel = computed(
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
)
function openSaveForm() {
saveName.value = `${strategyLabel.value} · 组合${stocks.value.length}只`
saveTags.value = ''
saveNotes.value = ''
saveMsg.value = ''
showSaveForm.value = true
}
async function onSave() {
if (!store.portfolioResult || !saveName.value.trim()) return
saving.value = true
saveMsg.value = ''
try {
const perf = store.portfolioResult.total_performance
await saveStrategy({
name: saveName.value.trim(),
kind: 'portfolio',
strategy: strategy.value,
strategy_label: strategyLabel.value,
params: params.value,
context: {
stocks: stocks.value,
category: category.value,
start_date: startDate.value,
end_date: endDate.value,
},
trade_config: {
cash: cash.value,
execution: execution.value,
},
snapshot: {
total_return: perf.total_return,
annual_return: perf.annual_return,
total_stocks: perf.total_stocks,
},
tags: saveTags.value
.split(/[,]/)
.map((t) => t.trim())
.filter(Boolean),
notes: saveNotes.value,
})
saveMsg.value = '✓ 已保存到策略库'
showSaveForm.value = false
} catch (e) {
saveMsg.value = `保存失败:${formatError(e)}`
} finally {
saving.value = false
}
}
</script>
<template>
<div class="portfolio-view">
<aside class="config-panel">
<section class="panel-section">
<h3>标的列表</h3>
<StocksPicker v-model="stocks" />
</section>
<section class="panel-section">
<h3>策略</h3>
<StrategyPicker
v-if="store.strategies.length"
v-model:strategy="strategy"
v-model:params="params"
:strategies="store.strategies"
/>
<p v-else class="loading-text">加载策略中</p>
</section>
<section class="panel-section">
<h3>周期与日期</h3>
<div class="field">
<label>周期</label>
<select v-model="category">
<option v-for="c in CATEGORIES" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div class="row">
<div class="field">
<label>开始</label>
<input v-model="startDate" type="date" />
</div>
<div class="field">
<label>结束</label>
<input v-model="endDate" type="date" />
</div>
</div>
</section>
<section class="panel-section">
<h3>资金</h3>
<div class="field">
<label>组合总资金</label>
<input v-model.number="cash" type="number" min="1000" step="10000" />
</div>
<div class="field">
<label>成交价</label>
<select v-model="execution">
<option v-for="e in EXECUTIONS" :key="e.value" :value="e.value">{{ e.label }}</option>
</select>
</div>
</section>
<button
class="primary run-btn"
:disabled="store.portfolioRunning || stocks.length === 0"
@click="onRun"
>
{{ store.portfolioRunning ? '组合回测中…' : '开始组合回测' }}
</button>
</aside>
<main class="report-panel">
<div v-if="store.error" class="error-banner"> {{ store.error }}</div>
<div
v-if="!store.portfolioResult && !store.portfolioRunning && !store.error"
class="placeholder"
>
<p>添加多只标的选择策略后点击开始组合回测</p>
</div>
<div v-if="store.portfolioResult" class="report-content">
<div class="result-toolbar">
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
<section class="report-section">
<h3>组合整体绩效</h3>
<div class="perf-summary">
<div class="perf-item">
<span class="label">组合总收益</span>
<span
class="value"
:class="store.portfolioResult.total_performance.total_return > 0 ? 'pos' : 'neg'"
>
{{ (store.portfolioResult.total_performance.total_return * 100).toFixed(2) }}%
</span>
</div>
<div class="perf-item">
<span class="label">标的数量</span>
<span class="value">{{ store.portfolioResult.total_performance.total_stocks }}</span>
</div>
<div class="perf-item">
<span class="label">组合总资金</span>
<span class="value">{{ store.portfolioResult.total_performance.total_cash.toFixed(0) }}</span>
</div>
</div>
</section>
<section class="report-section">
<h3>组合净值曲线</h3>
<EquityChart :equity="store.portfolioResult.combined_equity" />
</section>
<section class="report-section">
<h3>各标的绩效对比</h3>
<PortfolioSummaryTable
:results="store.portfolioResult.individual_results"
:allocation="store.portfolioResult.equity_allocation"
/>
</section>
<section class="report-section">
<h3>各标的净值叠加归一化</h3>
<PortfolioCompareChart :results="store.portfolioResult.individual_results" />
</section>
</div>
</main>
<!-- 保存策略对话框 -->
<div v-if="showSaveForm" class="modal-overlay" @click.self="showSaveForm = false">
<div class="modal">
<h3>保存到策略库</h3>
<p class="modal-desc">
将当前组合策略 + 标的列表 + 成绩快照存下下次可在策略库载入或重跑
</p>
<div class="field">
<label>名称</label>
<input v-model="saveName" type="text" placeholder="给这个组合策略起个名" />
</div>
<div class="field">
<label>标签逗号分隔可选</label>
<input v-model="saveTags" type="text" placeholder="如:消费,长线观察" />
</div>
<div class="field">
<label>备注可选</label>
<textarea v-model="saveNotes" rows="2" placeholder="为什么觉得它好?"></textarea>
</div>
<div class="modal-summary">
{{ strategyLabel }} · {{ stocks.length }} ·
{{
store.portfolioResult
? (store.portfolioResult.total_performance.total_return * 100).toFixed(2) + '%'
: ''
}}
</div>
<div class="modal-actions">
<button class="ghost" :disabled="saving" @click="showSaveForm = false">取消</button>
<button class="primary" :disabled="saving || !saveName.trim()" @click="onSave">
{{ saving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.portfolio-view {
display: flex;
height: 100%;
}
.config-panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.panel-section {
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.panel-section:last-of-type {
border-bottom: none;
}
.panel-section h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.loading-text {
color: var(--text-dim);
font-size: 12px;
}
.run-btn {
width: 100%;
padding: 10px;
font-size: 14px;
}
.report-panel {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.report-section {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.report-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
}
.perf-summary {
display: flex;
gap: 32px;
}
.perf-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.perf-item .label {
font-size: 12px;
color: var(--text-dim);
}
.perf-item .value {
font-size: 20px;
font-weight: 600;
font-family: var(--font-mono);
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
/* 结果工具条 + 保存对话框 */
.result-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.result-toolbar .ghost {
font-size: 12px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.result-toolbar .ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.save-msg {
font-size: 12px;
color: var(--up);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
width: 380px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal h3 {
font-size: 15px;
font-weight: 600;
}
.modal-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
}
.modal .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.modal .field label {
font-size: 12px;
color: var(--text-muted);
}
.modal .field input,
.modal .field textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 9px;
font-size: 13px;
color: var(--text);
font-family: inherit;
resize: vertical;
}
.modal-summary {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
padding: 8px 10px;
background: var(--bg);
border-radius: var(--radius);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.modal-actions .ghost {
font-size: 13px;
padding: 7px 16px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-muted);
cursor: pointer;
}
.modal-actions .primary {
font-size: 13px;
padding: 7px 16px;
cursor: pointer;
}
.modal-actions .primary:disabled,
.modal-actions .ghost:disabled {
opacity: 0.5;
cursor: default;
}
</style>