feat(web-ui): 策略组合保存 + Tab 分类 + 审计修复 (v1.18.0)

v1.18.0

策略库新增 kind='multi' 类型:组合回测结果可保存为组合,
卡片「↻ 重跑到今天」一键用今天重跑得到截至当天的策略信号
(持仓/空仓/浮盈/浮亏三态高亮)。策略库拆成「单标的」「组合」
两个 Tab,避免数量多了混排难找。

核心机制:保存配置 → 载入即重跑到今天 → 看持仓表 = 今日信号。
复用现有 SQLite 单表(kind 字段本就是 TEXT),零数据库迁移,
零后端逻辑改动,仅 schema Literal 加 'multi'。

含 10 项代码审计修复:multi-icon 字体误用、lastComboCash 漏赋、
弹窗 ARIA/ESC/focus、三态函数收敛 computed、警示样式合并、
重跑按钮改橙、items 运行时校验、ref 替代 querySelector、
selectedIds 静默替换、文案精简。

补记 v1.17.15 CHANGELOG(橙色按钮 + 名言轮播)。

- 改 web-ui/src/views/StrategiesView.vue(保存组合+Tab+三态+审计修复)
- 改 src/easy_tdx/web/backtest_schemas.py(kind Literal 加 'multi')
- 改 web-ui/src/types.ts(TS 类型同步)
- 改 CHANGELOG.md(补 v1.17.15 + 新增 v1.18.0)
- bump pyproject.toml 1.17.15 → 1.18.0
This commit is contained in:
Justin Gu
2026-07-05 03:01:09 +08:00
parent 15af7a92ee
commit c57cde156b
5 changed files with 518 additions and 24 deletions
+3 -3
View File
@@ -269,11 +269,11 @@ export interface ApiError {
/** 新建一条已保存策略的请求体(前端在回测结果区点「保存」时提交)。 */
export interface SavedStrategyCreate {
name: string
kind: 'single' | 'portfolio'
kind: 'single' | 'portfolio' | 'multi'
strategy: string
strategy_label?: string
params?: Record<string, number | string | boolean>
/** 标的上下文:single 存 symbol/category/start_date/end_dateportfolio 存 stocks */
/** 标的上下文:single 存 symbol/category/start_date/end_dateportfolio 存 stocksmulti 存 items + cash/execution */
context?: Record<string, unknown>
/** 资金与成本配置(cash/commission/... */
trade_config?: Record<string, unknown>
@@ -287,7 +287,7 @@ export interface SavedStrategyCreate {
export interface SavedStrategy {
id: string
name: string
kind: 'single' | 'portfolio'
kind: 'single' | 'portfolio' | 'multi'
strategy: string
strategy_label: string
params: Record<string, number | string | boolean>
+472 -17
View File
@@ -3,7 +3,7 @@
// 以及「组合回测」——勾选多个策略,各拿 1/N 资金、各跑原标的,看综合表现。
// 数据来自后端 SQLiteGET /api/v1/strategies)。空态提示去回测页保存。
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import EquityChart from '../components/EquityChart.vue'
@@ -14,6 +14,7 @@ import {
deleteSavedStrategy,
fetchSavedStrategies,
formatError,
saveStrategy,
} from '../api'
import type { MultiStrategyItem, Performance, SavedStrategy } from '../types'
import { useBacktestStore } from '../stores/backtest'
@@ -26,6 +27,39 @@ const loading = ref(false)
const error = ref('')
const deletingId = ref<string | null>(null)
// ── Tab 分类:单标的 / 组合 ────────────────────────────────────────────────────
// single tab = kind='single'combo tab = kind='portfolio' | 'multi'
// 默认进单标的;组合回测按钮仅在 single tab 显示(组合策略无法再被组合)
type TabKey = 'single' | 'combo'
const activeTab = ref<TabKey>('single')
const singleStrategies = computed(() => strategies.value.filter((s) => s.kind === 'single'))
const comboStrategies = computed(() => strategies.value.filter((s) => s.kind !== 'single'))
// 切 tab 时清空勾选(避免跨 tab 看不到的勾选残留)
function switchTab(tab: TabKey) {
if (activeTab.value === tab) return
activeTab.value = tab
selectedIds.value = new Set()
}
const visibleStrategies = computed(() =>
activeTab.value === 'single' ? singleStrategies.value : comboStrategies.value,
)
// ── 保存组合弹窗 ─────────────────────────────────────────────────────────────
const saveComboOpen = ref(false)
const saveComboName = ref('')
const saveComboNotes = ref('')
const saveComboLoading = ref(false)
// 最近一次组合回测使用的 items(保存时复用),由 onComboBacktest 写入
const lastComboItems = ref<MultiStrategyItem[]>([])
const lastComboCash = ref<number>(1_000_000)
// 结果区引用:跑完后滚动定位
const comboResultRef = ref<HTMLElement | null>(null)
// 保存弹窗里名称输入框:打开时自动聚焦
const saveComboNameRef = ref<HTMLInputElement | null>(null)
// ── 多策略组合回测:勾选 ─────────────────────────────────────────────────────
const selectedIds = ref<Set<string>>(new Set())
@@ -64,12 +98,108 @@ async function onComboBacktest() {
start_date: (s.context.start_date as string) || undefined,
end_date: (s.context.end_date as string) || undefined,
}))
lastComboItems.value = items
lastComboCash.value = 1_000_000
await store.runMultiStrategy({ items, cash: 1_000_000 })
if (skipped > 0) {
store.error = `已跳过 ${skipped} 个缺少单一标的的策略(组合策略无 symbol)。`
}
}
// ── 保存组合(kind: 'multi')─────────────────────────────────────────────────
/** 打开保存组合弹窗:预填名称 + 自动聚焦输入框。 */
function openSaveCombo() {
if (!store.multiStrategyResult) return
saveComboName.value = `组合·${lastComboItems.value.length}策略·${new Date().toISOString().slice(0, 10)}`
saveComboNotes.value = ''
saveComboOpen.value = true
// 等弹窗渲染完再聚焦
nextTick(() => saveComboNameRef.value?.focus())
}
/** ESC 关闭弹窗(绑定在弹窗根元素 @keydown.esc */
function closeSaveCombo() {
if (!saveComboLoading.value) saveComboOpen.value = false
}
/** 提交保存组合:把 items + cash 存进 context,组合级绩效存 snapshot。 */
async function submitSaveCombo() {
if (!store.multiStrategyResult || lastComboItems.value.length === 0) return
if (!saveComboName.value.trim()) {
error.value = '请填写组合名称'
return
}
saveComboLoading.value = true
error.value = ''
try {
const tp = store.multiStrategyResult.total_performance
const created = await saveStrategy({
name: saveComboName.value.trim(),
kind: 'multi',
strategy: 'multi',
strategy_label: `${lastComboItems.value.length} 策略组合`,
context: {
items: lastComboItems.value,
cash: lastComboCash.value,
},
trade_config: { cash: lastComboCash.value },
snapshot: {
total_return: tp.total_return,
annual_return: tp.annual_return,
total_stocks: tp.total_stocks,
total_cash: tp.total_cash,
},
tags: ['组合'],
notes: saveComboNotes.value.trim(),
})
strategies.value = [created, ...strategies.value]
saveComboOpen.value = false
} catch (e) {
error.value = formatError(e)
} finally {
saveComboLoading.value = false
}
}
// ── 载入组合(kind: 'multi')→ 自动重跑到今天 ────────────────────────────────
function isoToday(): string {
return new Date().toISOString().slice(0, 10)
}
/** 载入组合:把保存的 items 的 end_date 全部覆盖为今天,自动触发组合回测。
* 这样跑出来的"当前持仓"= 截至今天的策略信号(哪些该买/该卖)。 */
async function onLoadMulti(s: SavedStrategy) {
const ctx = s.context || {}
const rawItems = Array.isArray(ctx.items) ? (ctx.items as MultiStrategyItem[]) : []
if (rawItems.length === 0) {
error.value = '该组合没有保存策略明细(items),可能数据损坏。'
return
}
// 运行时校验:每条至少要有 strategy + symbol,否则带病跑到后端才报错
const valid = rawItems.every(
(it) => it && typeof it.strategy === 'string' && typeof it.symbol === 'string',
)
if (!valid) {
error.value = '组合数据损坏:部分策略缺少 strategy 或 symbol 字段。'
return
}
if (!confirm(
`载入「${s.name}」并用今天(${isoToday()})重跑 ${rawItems.length} 个策略?\n\n` +
`结果区会显示截至今天的策略信号("哪些该买/该卖")。`,
)) return
const today = isoToday()
const items = rawItems.map((it) => ({ ...it, end_date: today }))
const cash = typeof ctx.cash === 'number' ? ctx.cash : 1_000_000
lastComboItems.value = items
lastComboCash.value = cash
await store.runMultiStrategy({ items, cash })
// 跑完滚动到结果区
await nextTick()
comboResultRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onMounted(load)
async function load() {
@@ -146,6 +276,10 @@ function num(v: unknown, d = 2): string {
}
function ctxLabel(s: SavedStrategy): string {
const ctx = s.context
if (s.kind === 'multi') {
const items = Array.isArray(ctx.items) ? (ctx.items as MultiStrategyItem[]) : []
return items.length ? `${items.length} 策略 · ${items.map((i) => i.symbol).slice(0, 3).join(' ')}${items.length > 3 ? ' …' : ''}` : '-'
}
if (s.kind === 'portfolio') {
const stocks = Array.isArray(ctx.stocks) ? (ctx.stocks as string[]) : []
return stocks.length ? `${stocks.length} 只:${stocks.slice(0, 3).join(' ')}${stocks.length > 3 ? ' …' : ''}` : '-'
@@ -210,6 +344,24 @@ const holdings = computed<Holding[]>(() => {
const holdingCount = computed(() => holdings.value.filter((h) => h.holding).length)
// 持仓三态视图:把 statusClass / label / rowClass 一次性算好,模板只读不调函数。
// 否则每行 ×3 次函数调用 + holdingRowClass 返回新对象会触发 Vue 额外跟踪。
type HoldingView = Holding & {
statusClass: 'win' | 'lose' | 'wait'
statusLabel: string
rowClass: string
}
const holdingViews = computed<HoldingView[]>(() =>
holdings.value.map((h) => {
if (!h.holding) {
return { ...h, statusClass: 'wait', statusLabel: '空仓·等买点', rowClass: 'cleared' }
}
return h.unrealizedPnl >= 0
? { ...h, statusClass: 'win', statusLabel: '持有', rowClass: 'row-win' }
: { ...h, statusClass: 'lose', statusLabel: '持有·浮亏', rowClass: 'row-lose' }
}),
)
// 组合整体绩效(19 项指标)。后端 total_performance 现含完整指标,转成
// MetricTable 需要的 Performance 类型(缺失字段补 0 兜底,保证渲染不崩)。
const comboPerf = computed<Performance | null>(() => {
@@ -250,11 +402,11 @@ const comboPerf = computed<Performance | null>(() => {
<h2>策略库</h2>
<p class="subtitle">
保存你觉得不错的策略下次直接载入或重跑 {{ strategies.length }}
勾选多个单标的策略可做组合回测各拿 1/N 资金各跑原标的看综合表现
</p>
</div>
<div class="header-actions">
<button
v-if="activeTab === 'single'"
class="primary sm"
:disabled="selectedStrategies.length === 0 || store.multiStrategyRunning"
@click="onComboBacktest"
@@ -262,7 +414,7 @@ const comboPerf = computed<Performance | null>(() => {
{{ store.multiStrategyRunning ? '组合回测中…' : `组合回测(${selectedStrategies.length}` }}
</button>
<button
v-if="selectedStrategies.length > 0"
v-if="activeTab === 'single' && selectedStrategies.length > 0"
class="ghost sm"
@click="clearSelection"
>
@@ -274,12 +426,39 @@ const comboPerf = computed<Performance | null>(() => {
</div>
</header>
<!-- Tab 切换 -->
<nav class="tabs" role="tablist">
<button
role="tab"
:aria-selected="activeTab === 'single'"
:class="['tab', { active: activeTab === 'single' }]"
@click="switchTab('single')"
>
单标的<span class="tab-count">{{ singleStrategies.length }}</span>
</button>
<button
role="tab"
:aria-selected="activeTab === 'combo'"
:class="['tab', { active: activeTab === 'combo' }]"
@click="switchTab('combo')"
>
组合<span class="tab-count">{{ comboStrategies.length }}</span>
</button>
</nav>
<div v-if="error || store.error" class="error-banner"> {{ error || store.error }}</div>
<div v-if="!loading && strategies.length === 0 && !error" class="placeholder">
<p>还没有保存的策略</p>
<div v-if="!loading && visibleStrategies.length === 0 && !error" class="placeholder">
<p>{{ activeTab === 'single' ? '还没有保存的单标的策略。' : '还没有保存的组合策略。' }}</p>
<p class="hint">
单标的回测组合回测跑出满意结果后点结果区的保存策略即可收藏到这里
<template v-if="activeTab === 'single'">
单标的回测跑出满意结果后点结果区的保存策略即可收藏到这里
勾选多个单标的策略还能做组合回测
</template>
<template v-else>
勾选多个单标的策略 组合回测 跑出结果后点💾 保存为组合
即可在这里看到下次点 重跑到今天即可获取最新策略信号
</template>
</p>
</div>
@@ -287,15 +466,19 @@ const comboPerf = computed<Performance | null>(() => {
<p>加载中</p>
</div>
<div v-if="strategies.length" class="card-grid">
<div v-if="visibleStrategies.length" class="card-grid">
<article
v-for="s in strategies"
v-for="s in visibleStrategies"
:key="s.id"
class="card"
:class="{ selected: selectedIds.has(s.id) }"
:class="{ selected: selectedIds.has(s.id), 'card-multi': s.kind === 'multi' }"
>
<div class="card-head">
<label class="select-box" :title="s.context?.symbol ? '加入组合回测' : '组合策略暂不支持组合回测'">
<label
v-if="s.kind !== 'multi'"
class="select-box"
:title="s.context?.symbol ? '加入组合回测' : '组合策略暂不支持组合回测'"
>
<input
type="checkbox"
:checked="selectedIds.has(s.id)"
@@ -303,7 +486,10 @@ const comboPerf = computed<Performance | null>(() => {
@change="toggleSelect(s.id)"
/>
</label>
<span class="kind-badge" :class="s.kind">{{ s.kind === 'portfolio' ? '组合' : '单标的' }}</span>
<span v-else class="multi-icon" title="多策略组合">🗂</span>
<span class="kind-badge" :class="s.kind">
{{ s.kind === 'multi' ? '多策略' : s.kind === 'portfolio' ? '多标的' : '单标的' }}
</span>
<h3 class="card-title">{{ s.name }}</h3>
</div>
@@ -341,7 +527,15 @@ const comboPerf = computed<Performance | null>(() => {
<div class="card-foot">
<span class="created">{{ createdShort(s) }}</span>
<span class="actions">
<button class="primary sm" @click="onLoad(s)">载入</button>
<button
v-if="s.kind === 'multi'"
class="rerun-btn sm"
:disabled="store.multiStrategyRunning"
@click="onLoadMulti(s)"
>
{{ store.multiStrategyRunning ? '重跑中…' : '↻ 重跑到今天' }}
</button>
<button v-else class="primary sm" @click="onLoad(s)">载入</button>
<button
class="danger sm"
:disabled="deletingId === s.id"
@@ -354,16 +548,81 @@ const comboPerf = computed<Performance | null>(() => {
</article>
</div>
<!-- 保存组合弹窗 -->
<div
v-if="saveComboOpen"
class="modal-mask"
@click.self="closeSaveCombo"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="combo-modal-title"
class="modal"
@keydown.esc.prevent="closeSaveCombo"
>
<h3 id="combo-modal-title">保存为策略组合</h3>
<p class="modal-desc">
将当前 {{ lastComboItems.length }} 个策略的整体配置策略+参数+标的+资金配比存为组合
下次点 重跑到今天即可用截至今天的行情算出每个策略的当前信号持仓/空仓
</p>
<div class="modal-field">
<label for="combo-name-input">组合名称</label>
<input
id="combo-name-input"
ref="saveComboNameRef"
v-model="saveComboName"
placeholder="如:科技+消费+银行 防守反击组合"
maxlength="120"
/>
</div>
<div class="modal-field">
<label for="combo-notes-input">备注可选</label>
<textarea
id="combo-notes-input"
v-model="saveComboNotes"
rows="3"
placeholder="如:牛市跑得好,震荡市待验证"
maxlength="2000"
/>
</div>
<div class="modal-actions">
<button class="ghost sm" @click="closeSaveCombo">取消</button>
<button class="primary sm" :disabled="saveComboLoading" @click="submitSaveCombo">
{{ saveComboLoading ? '保存中' : '保存' }}
</button>
</div>
</div>
</div>
<!-- 多策略组合回测结果复用组合页图表组件 -->
<section v-if="store.multiStrategyResult || store.multiStrategyRunning" class="combo-result">
<section
v-if="store.multiStrategyResult || store.multiStrategyRunning"
ref="comboResultRef"
class="combo-result"
>
<h3 class="combo-title">
组合回测结果
<span v-if="store.multiStrategyResult" class="combo-meta">
· {{ store.multiStrategyResult.total_performance.total_stocks }} 个策略 ·
总资金 {{ store.multiStrategyResult.total_performance.total_cash.toFixed(0) }}
</span>
<button
v-if="store.multiStrategyResult && !store.multiStrategyRunning"
class="save-combo-btn"
@click="openSaveCombo"
>
💾 保存为组合
</button>
</h3>
<!-- 过拟合警示 -->
<div v-if="store.multiStrategyResult" class="warn-box overfit">
<strong>过拟合提醒</strong>组合的历史回测表现优秀不代表未来一定有效
收益可能来自特定时段的市场环境如某段主升浪切换到震荡/熊市可能失效
把它当作"今日该买该卖"<strong>参考信号</strong>而非"未来必涨"的保证
</div>
<div v-if="store.multiStrategyRunning && !store.multiStrategyResult" class="combo-loading">
组合回测中逐个策略取行情 + 回测请稍候
</div>
@@ -409,8 +668,15 @@ const comboPerf = computed<Performance | null>(() => {
<div class="combo-chart-block">
<h4>
当前持仓{{ holdingCount }}/{{ holdings.length }} 在持仓中
<span class="holdings-hint">回测结束时各策略的持仓快照</span>
<span class="holdings-hint">截至回测结束日的策略信号</span>
</h4>
<!-- 模型仓位免责水印与上方过拟合警示条互补这里只强调"持仓≠你真实账户" -->
<div class="warn-box disclaimer">
表中是<strong>模型仓位</strong>策略说"该持仓"<strong>不是你真实账户的持仓</strong>
基于回测结束日收盘价计算过夜后可能因新 K 线触发买卖而变化
</div>
<p v-if="holdings.length === 0" class="empty-text">无持仓数据</p>
<table v-else class="holdings-table">
<thead>
@@ -426,12 +692,12 @@ const comboPerf = computed<Performance | null>(() => {
</tr>
</thead>
<tbody>
<tr v-for="h in holdings" :key="h.key" :class="{ cleared: !h.holding }">
<tr v-for="h in holdingViews" :key="h.key" :class="h.rowClass">
<td>{{ h.strategyLabel }}</td>
<td class="sym">{{ h.symbol }}</td>
<td>
<span class="status-tag" :class="h.holding ? 'holding' : 'cleared'">
{{ h.holding ? '持仓' : '空仓' }}
<span class="status-tag" :class="h.statusClass">
{{ h.statusLabel }}
</span>
</td>
<td class="num">{{ h.size > 0 ? h.size.toFixed(0) : '-' }}</td>
@@ -489,6 +755,48 @@ const comboPerf = computed<Performance | null>(() => {
.header-actions .primary {
border-radius: var(--radius);
}
/* Tab 切换条 */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-radius: 0;
display: flex;
align-items: center;
gap: 6px;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.tab-count {
font-size: 11px;
padding: 1px 6px;
border-radius: 8px;
background: var(--border);
color: var(--text-dim);
font-weight: 400;
}
.tab.active .tab-count {
background: rgba(74, 158, 255, 0.18);
color: var(--accent);
}
.ghost {
font-size: 12px;
padding: 6px 12px;
@@ -808,4 +1116,151 @@ const comboPerf = computed<Performance | null>(() => {
background: var(--border);
color: var(--text-dim);
}
/* 组合卡片 / multi 视觉差异 */
.card-multi {
border-color: rgba(245, 158, 11, 0.35);
background: linear-gradient(180deg, rgba(245, 158, 11, 0.04), var(--bg-panel) 30%);
}
.card-multi:hover {
border-color: rgba(245, 158, 11, 0.6);
}
.multi-icon {
font-size: 18px;
color: #f59e0b;
flex-shrink: 0;
display: flex;
align-items: center;
width: 16px;
text-align: center;
}
.kind-badge.multi {
background: rgba(245, 158, 11, 0.18);
color: #f59e0b;
}
/* 保存组合按钮(结果区右上) */
.save-combo-btn {
font-size: 12px;
padding: 5px 12px;
margin-left: 12px;
background: linear-gradient(135deg, #f59e0b, #ea580c);
border: 1px solid #f59e0b;
color: #fff;
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
vertical-align: middle;
}
.save-combo-btn:hover {
background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
/* 警示条基础类(过拟合 / 免责共享) */
.warn-box {
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 12px;
font-size: 12px;
line-height: 1.6;
}
.warn-box strong {
color: #fbbf24;
}
.warn-box.overfit {
background: rgba(240, 160, 32, 0.1);
border-left: 3px solid var(--warn);
color: #f0a020;
margin-bottom: 14px;
}
.warn-box.disclaimer {
background: rgba(240, 160, 32, 0.06);
border: 1px dashed rgba(240, 160, 32, 0.4);
color: var(--text-muted);
font-size: 11px;
padding: 8px 12px;
}
/* 多策略组合卡片的"重跑到今天"按钮:橙色 outline,呼应组合主题色 */
.rerun-btn {
background: rgba(245, 158, 11, 0.12);
border: 1px solid rgba(245, 158, 11, 0.5);
color: #f59e0b;
font-weight: 600;
border-radius: var(--radius);
cursor: pointer;
}
.rerun-btn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.2);
border-color: #f59e0b;
color: #fbbf24;
}
.rerun-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 持仓三态 */
.status-tag.win {
background: rgba(24, 160, 88, 0.18);
color: var(--down);
}
.status-tag.lose {
background: rgba(240, 160, 32, 0.18);
color: #f0a020;
}
.status-tag.wait {
background: var(--border);
color: var(--text-dim);
}
.holdings-table tr.row-win {
background: rgba(24, 160, 88, 0.04);
}
.holdings-table tr.row-lose {
background: rgba(240, 160, 32, 0.05);
}
/* 保存组合弹窗 */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 22px;
width: 420px;
max-width: calc(100vw - 32px);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
}
.modal h3 {
font-size: 15px;
font-weight: 600;
margin-bottom: 6px;
}
.modal-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 14px;
}
.modal-field {
margin-bottom: 12px;
}
.modal-field textarea {
resize: vertical;
font-family: inherit;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
</style>