From c57cde156b23ea427d78e3306a164394b6ce69ac Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sun, 5 Jul 2026 03:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(web-ui):=20=E7=AD=96=E7=95=A5=E7=BB=84?= =?UTF-8?q?=E5=90=88=E4=BF=9D=E5=AD=98=20+=20Tab=20=E5=88=86=E7=B1=BB=20+?= =?UTF-8?q?=20=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20(v1.18.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 39 +++ pyproject.toml | 2 +- src/easy_tdx/web/backtest_schemas.py | 6 +- web-ui/src/types.ts | 6 +- web-ui/src/views/StrategiesView.vue | 489 ++++++++++++++++++++++++++- 5 files changed, 518 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f25b916..72d6964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 笔交易)不会被一刀切否决——交易笔数少时只把胜率/利润因子降权,不影响基于净值的评级。 diff --git a/pyproject.toml b/pyproject.toml index bba4acd..8ef1acb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/web/backtest_schemas.py b/src/easy_tdx/web/backtest_schemas.py index a966286..e84164f 100644 --- a/src/easy_tdx/web/backtest_schemas.py +++ b/src/easy_tdx/web/backtest_schemas.py @@ -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] = {} diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index cd99356..7d7bf58 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -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 - /** 标的上下文:single 存 symbol/category/start_date/end_date;portfolio 存 stocks */ + /** 标的上下文:single 存 symbol/category/start_date/end_date;portfolio 存 stocks;multi 存 items + cash/execution */ context?: Record /** 资金与成本配置(cash/commission/...) */ trade_config?: Record @@ -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 diff --git a/web-ui/src/views/StrategiesView.vue b/web-ui/src/views/StrategiesView.vue index 6102fc9..39b7ab1 100644 --- a/web-ui/src/views/StrategiesView.vue +++ b/web-ui/src/views/StrategiesView.vue @@ -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(null) +// ── Tab 分类:单标的 / 组合 ──────────────────────────────────────────────────── +// single tab = kind='single';combo tab = kind='portfolio' | 'multi' +// 默认进单标的;组合回测按钮仅在 single tab 显示(组合策略无法再被组合) +type TabKey = 'single' | 'combo' +const activeTab = ref('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([]) +const lastComboCash = ref(1_000_000) +// 结果区引用:跑完后滚动定位 +const comboResultRef = ref(null) +// 保存弹窗里名称输入框:打开时自动聚焦 +const saveComboNameRef = ref(null) + // ── 多策略组合回测:勾选 ───────────────────────────────────────────────────── const selectedIds = ref>(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(() => { 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(() => + 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(() => { @@ -250,11 +402,11 @@ const comboPerf = computed(() => {

策略库

保存你觉得不错的策略,下次直接载入或重跑。共 {{ strategies.length }} 条。 - 勾选多个单标的策略可做「组合回测」——各拿 1/N 资金、各跑原标的,看综合表现。

+ + +
⚠ {{ error || store.error }}
-
-

还没有保存的策略。

+
+

{{ activeTab === 'single' ? '还没有保存的单标的策略。' : '还没有保存的组合策略。' }}

- 在「单标的回测」或「组合回测」跑出满意结果后,点结果区的「保存策略」即可收藏到这里。 + +

@@ -287,15 +466,19 @@ const comboPerf = computed(() => {

加载中…

-
+
-
@@ -341,7 +527,15 @@ const comboPerf = computed(() => {
{{ createdShort(s) }} - + +
+ +