Files
easy-tdx/web-ui/src/views/PortfolioView.vue
T
Justin Gu 05dc9a74af release: v1.17.11 — Web UI 策略库(SQLite 持久化)+ 多策略资金分仓组合回测
新增两层能力:
1. 策略库:单标的/组合回测结果可保存到本地 SQLite 单文件
   (~/.easy_tdx/strategies.db),策略库页可载入回填、重跑、删除。
2. 多策略组合回测:勾选 N 个单标的策略,各拿 1/N 资金、各跑原标的,
   净值曲线按日期并集对齐求和,组合结果含 19 项完整绩效指标 + 持仓表。

后端:strategy_store.py(SQLite CRUD) + multi_strategy_engine.py(资金分仓引擎)
+ routers/strategies.py + /backtest/multi-strategy/run/async。
前端:StrategiesView.vue + 保存策略按钮 + 复用组合页图表组件。

895 单测全绿(+24 新增),ruff/mypy strict/前端 vue-tsc 全通过。
2026-07-04 20:40:57 +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(isoDaysFromNow(-365 * 3))
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>