mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +08:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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_date;portfolio 存 stocks */
|
||||
/** 标的上下文:single 存 symbol/category/start_date/end_date;portfolio 存 stocks;multi 存 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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 以及「组合回测」——勾选多个策略,各拿 1/N 资金、各跑原标的,看综合表现。
|
||||
// 数据来自后端 SQLite(GET /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>
|
||||
|
||||
Reference in New Issue
Block a user