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
+39
View File
@@ -2,6 +2,45 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.18.0] — 2026-07-05
**Web UI 策略库新增「策略组合」保存能力 + 分类 Tab** —— 此前用户在策略库勾选多个单标的策略做组合回测,跑出满意结果后却**无法保存**这个组合,下次要重新勾选重新跑。更关键的是,用户真正的诉求是「下次打开就知道哪些该买、哪些该卖」——这本质是要**截至今天的策略信号**,而非静态存档。本次落地:在策略库融入 `kind: 'multi'` 类型,组合回测结果区加「💾 保存为组合」按钮,组合卡片「↻ 重跑到今天」一键用今天作为结束日重跑,跑出来的"当前持仓"就是截至今天的策略信号(持仓/空仓/浮盈/浮亏)。同时把策略库拆成「单标的」/「组合」两个 Tab,避免数量多了之后混排难找。
### 新增
- **策略组合保存**`web-ui/src/views/StrategiesView.vue` + 后端 schema 扩展)—— 组合回测结果区右上「💾 保存为组合」橙色按钮,弹窗输入名称 + 备注。存为 `kind: 'multi'``context.items` 存完整 `MultiStrategyItem[]`(各策略的 strategy+params+symbol+日期),`snapshot` 存组合级绩效(总收益/年化/策略数/资金)。复用现有 SQLite 单表(`kind` 字段本就是 TEXT),**零数据库迁移,零后端逻辑改动**。
- **载入即重跑到今天** —— multi 卡片按钮不是普通「载入」而是「↻ 重跑到今天」:confirm 后自动把 `end_date` 全部覆盖为今天,触发组合回测,跑完滚动到结果区。这样"当前持仓"表 = 截至今天的策略信号,直接回答用户「哪些该买该卖」。
- **持仓三态高亮** —— 当前持仓表的状态徽章从两态(持仓/空仓)升级为三态:🟢 **持有**(浮盈,绿底)/ 🟠 **持有·浮亏**(橙底,提示注意止损)/ ⚪ **空仓·等买点**(灰底,行半透明)。让用户一眼分辨"该继续拿"还是"该警惕"。
- **策略库 Tab 分类** —— 顶部新增「单标的 [N]」/「组合 [N]」两个 Tab(带计数徽章 + 蓝色下划线 active 指示),切换时清空勾选避免跨 Tab 残留。「组合回测」按钮仅在单标的 Tab 显示(组合策略无法再被组合);空态文案按 Tab 分别引导。
- **过拟合警示 + 模型仓位免责** —— 组合回测结果区顶部橙色警示条明确告知「历史回测优秀 ≠ 未来一定有效,收益可能来自特定时段市场环境」;持仓表上方水印标注「表中是**模型仓位**,不是你真实账户的持仓,过夜后可能因新 K 线触发买卖而变化」。载入组合的 confirm 弹窗也提示策略信号非投资建议。
- **保存组合弹窗 A11y** —— `role="dialog"` + `aria-modal="true"` + `aria-labelledby`,ESC 关闭,打开时自动 focus 到名称输入框。
### 变更
- **后端 schema kind 扩展**`src/easy_tdx/web/backtest_schemas.py`)—— `SavedStrategyCreate.kind` / `SavedStrategy.kind``Literal``["single", "portfolio"]` 扩展为 `["single", "portfolio", "multi"]`。前端 `web-ui/src/types.ts` 镜像同步。
- **卡片 Badge 细化** —— multi 显示「多策略」橙色徽章 + 🗂 图标 + 橙色卡片边框;portfolio 显示「多标的」紫色徽章。两类组合在「组合」Tab 内可一眼区分。
- **代码审计修复(10 项)** —— 修复 `multi-icon` 误用未引入的 Material Icons 字体导致显示英文文本;补齐 `onComboBacktest` 漏写的 `lastComboCash`;持仓三态计算收敛为 `holdingViews` computed(避免模板每行 3 次函数调用);`.overfit-warn``.signal-disclaimer` 合并为 `.warn-box` 基础类 + modifier`ctx.items` 加运行时校验;`document.querySelector` 改用 ref;删除 `selectedIds` 静默替换的副作用。
### 已知约束(非 bug
- **策略信号 ≠ 投资建议,也非真实账户持仓** —— 系统显示的"持仓"是**模型仓位**(策略说该持仓),不是用户真实账户的持仓。UI 已在多处(过拟合警示条 + 持仓表水印 + 载入 confirm)明确标注。用户应把它当作参考信号,而非"未来必涨"的保证。
- **信号会漂移** —— 今天重跑说"持仓",明天大跌触发止损可能变"空仓"。需要定期打开重跑。水印标注的是计算当天的收盘价信号。
- **不做真实账户追踪** —— 不存用户实际买卖了多少股、真实成本。那是投资日记/MRP 级别功能,工程量 5x,本次未做。
## [1.17.15] — 2026-07-05
**Web UI 参数寻优体验升级:一键寻优按钮改橙色 + 等待期间投资大师名言轮播** —— 参数寻优是一个后台 Task,跑一遍全策略预设网格往往要 30 秒到几分钟。此前点击「一键寻优所有策略」后,按钮变灰、右侧整片纯色空白,用户根本不知道系统在干什么,容易以为卡住。本次给两段体验都做了升级:按钮改为暖橙渐变(`#f59e0b → #ea580c`)在深色金融主题下比 primary 蓝更醒目;寻优进行中右侧展示 100 条全球投资大师名言(巴菲特/芒格/格雷厄姆/林奇/索罗斯/利弗莫尔/塔勒布/达利欧等),3 秒随机轮播一条,Fisher-Yates 洗牌避免短期重复,让等待不枯燥、还能学到东西。
### 新增
- **100 条投资大师名言数据**`web-ui/src/data/investment-quotes.ts`)—— 覆盖价值投资(巴菲特/芒格/格雷厄姆/林奇/费雪/博格尔/邓普顿)、宏观对冲(索罗斯/罗杰斯/达利欧/塔勒布)、技术趋势(利弗莫尔/江恩)、行为金融(霍华德·马克斯)等流派,纯中文面向中文用户。每条 `{ text, author }` 结构。
- **名言轮播组件**`web-ui/src/components/QuoteCarousel.vue`)—— Props 驱动 `interval`(默认 3000ms),mount 时 Fisher-Yates 洗牌取第 0 条,每 `interval` ms 推进,一轮播完重新洗牌;`onUnmounted``setInterval` 防泄漏。视觉:顶部 3 秒线性进度条(橙色填充暗示"还在跑"+ 装饰大引号 + fade/slide 过渡(350ms)+ 底部脉动橙点「后台寻优进行中,大师智慧伴你等待…」。CSS 变量 `--quote-interval` 把 props 透传给动画时长,保证进度条与切换同步。
- **一键寻优按钮橙色样式**`web-ui/src/views/OptimizeView.vue`)—— 新增 `.run-all-btn` 暖橙渐变 + 橙色光晕;hover 加亮上移 1px;运行中保留暗橙识别度(不像普通按钮变纯灰)。
### 已知约束(非 bug
- **轮播组件目前仅接入「一键寻优」** —— 设计成 Props 驱动、零业务耦合,未来可复用到「开始寻优」「组合回测」等同样有后台 Task 等待的页面,本次未做。
## [1.17.14] — 2026-07-05
**Web UI 新增「数据评级」系统(S/A/B/C/D 五档)** —— 此前回测结果只有冷冰冰的 19 项指标,普通用户看到「总收益 126%」会觉得不错,却看不出胜率仅 35%、最大回撤 41% 背后的「套牢拿不住」风险。本次给单标的回测、组合回测、参数寻优三个入口都加上一个一眼可读的评级徽章,让普通人 1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率(套牢回本难度)、最大回撤、胜率、利润因子、夏普、波动率六个维度加权评分,再叠加一票否决(系统亏损/深回撤/低胜率)。京东方那种「收益好看但风险高」的案例会评 **D 档**,明确告诉用户「别碰」。长线低频策略(如 6 年 6 笔交易)不会被一刀切否决——交易笔数少时只把胜率/利润因子降权,不影响基于净值的评级。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.17.15"
version = "1.18.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+3 -3
View File
@@ -275,14 +275,14 @@ class SavedStrategyCreate(BaseModel):
"""
name: str = Field(..., min_length=1, max_length=120, description="策略名称(用户自拟)")
kind: Literal["single", "portfolio"] = Field(..., description="来源:单标的/组合")
kind: Literal["single", "portfolio", "multi"] = Field(..., description="来源:单标的/组合/多策略组合")
strategy: str = Field(..., description="策略名(注册表 key,如 ma_cross")
strategy_label: str = Field(default="", description="策略展示名")
params: dict[str, Any] = Field(default_factory=dict)
context: dict[str, Any] = Field(
default_factory=dict,
description="标的上下文:single 存 symbol/category/start_date/end_date"
"portfolio 存 stocks 列表",
"portfolio 存 stocks 列表multi 存 items(MultiStrategyItem[]) + cash/execution",
)
trade_config: dict[str, Any] = Field(
default_factory=dict, description="资金与成本配置(cash/commission/..."
@@ -299,7 +299,7 @@ class SavedStrategy(BaseModel):
id: str
name: str
kind: Literal["single", "portfolio"]
kind: Literal["single", "portfolio", "multi"]
strategy: str
strategy_label: str = ""
params: dict[str, Any] = {}
+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>