mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 20:24:16 +08:00
单标的回测、组合回测、参数寻优原默认起始日为「3 年前」(动态滚动), 统一改为固定日期 2020-01-06,便于跨时间横向对比。结束日仍为今天。 SymbolPicker 共用组件的 defineModel 默认值同步更新。
512 lines
14 KiB
Vue
512 lines
14 KiB
Vue
<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>
|