From 8a5ed52a1f82cb110ec7d216f63737c28bf1743a Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 2 Sep 2026 15:49:41 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.28.1=20=E2=80=94=20WebUI=20?= =?UTF-8?q?=E5=90=8D=E8=AF=8D=E8=A7=A3=E9=87=8A=E6=8A=98=E5=8F=A0=E5=B8=AE?= =?UTF-8?q?=E5=8A=A9=20+=20AI=20=E8=A7=A3=E8=AF=BB=20Prompt=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20+=20WF=20=E7=AA=97=E5=8F=A3=E5=BA=8F=E5=88=97?= =?UTF-8?q?=E5=8C=96=E4=BF=AE=E5=A4=8D=EF=BC=88to=5Fjson=5Fnative=20?= =?UTF-8?q?=E6=9C=89=E9=99=90=20float=20=E8=90=BD=E5=85=A5=20str=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=EF=BC=8C=E9=80=90=E7=AA=97=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E5=8F=98=E5=AD=97=E7=AC=A6=E4=B8=B2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 ++ README.md | 2 +- pyproject.toml | 2 +- src/easy_tdx/backtest/types.py | 7 +- web-ui/e2e/backtest.spec.ts | 59 +++- web-ui/src/__tests__/aiPrompt.test.ts | 219 +++++++++++++ web-ui/src/aiPrompt.ts | 349 +++++++++++++++++++++ web-ui/src/components/EvaluatePanel.vue | 8 + web-ui/src/components/GlossaryList.vue | 145 +++++++++ web-ui/src/components/HelpCollapse.vue | 91 ++++++ web-ui/src/components/MetricTable.vue | 31 +- web-ui/src/components/WalkForwardPanel.vue | 6 + web-ui/src/data/glossary.ts | 324 +++++++++++++++++++ web-ui/src/views/BacktestView.vue | 110 ++++++- 14 files changed, 1354 insertions(+), 17 deletions(-) create mode 100644 web-ui/src/__tests__/aiPrompt.test.ts create mode 100644 web-ui/src/aiPrompt.ts create mode 100644 web-ui/src/components/GlossaryList.vue create mode 100644 web-ui/src/components/HelpCollapse.vue create mode 100644 web-ui/src/data/glossary.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8c4feb..e9cb504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.28.1] — 2026-09-02 + +**Web UI 新手友好化 + AI 解读导出**——回测报告的两个「看不懂」出口:名词解释折叠帮助(新手向)与 AI 解读 Prompt 一键导出(LLM 辅助解读),另修复 Walk-Forward 窗口数据被序列化成字符串的后端 bug。 + +### 新增 + +- **名词解释折叠帮助**(Web UI)——Walk-Forward 样本外验证 / 一条龙评估 / 绩效指标三个报告框底部各内置「? 名词解释」按钮:默认收起、点击展开,共 33 个词条覆盖全部 25 项绩效指标与 WF / 评估术语(每窗独立开仓、盈利窗占比、连乘收益、Ulcer 指数、卡玛比率、α/β/信息比率、适配性体检等)。每条按「一句话定义 → 公式 → 细节 → 怎么看(阈值与经验法则)」组织,重点粗体亮色、阈值橙色粗体、细节细体暗色的字重层级;文案口径与后端实现逐项对齐(WF 预热区 30%、综合评分权重 50/15/10/5/20、体检 8 项 ≥75% 高适配、卡玛 = 年化收益 ÷ 最大回撤等)。新增通用组件 `HelpCollapse`(平滑展开动画;折叠态叠加 `visibility:hidden`,对无障碍树与自动化真正隐藏)与 `GlossaryList`(`**粗体**` 内联标记解析,词条数据集中 `web-ui/src/data/glossary.ts`)。 +- **AI 解读 Prompt 一键导出**(Web UI)——回测报告工具栏新增「🤖 AI 解读」:把当前报告实时组装成结构化 markdown 提示词(角色设定 + 六步解读框架 + 回测配置 + 25 项指标 + 净值概览 + WF 逐窗明细 + 一条龙评估 + 评级 + 最近 8 笔成交 + 免责),一键复制 / 下载 .md,发给任意 LLM(ChatGPT / Claude / DeepSeek / 豆包…)即可获得针对性解读。文风指令经三轮实测迭代:要求「做了十几年量化的老手朋友聊天」口吻、禁八股句式(「事实是」「总的来说」等)、优点毛病都讲、婉转不下判决书、800 字内、只引用报告已有数字,并以 **0-10 信心分**收尾(统一行动刻度:0-3 放弃 / 4-6 继续改 / 7-8 小仓试错 / 9+ 逐步加仓)。可选段落(WF / 评估 / 评级)按数据有无自动拼接,未跑不出现。生成器为纯函数 `web-ui/src/aiPrompt.ts`(仅 type-only 本地导入,node:test 可直跑)。 +- 单测 `web-ui/src/__tests__/aiPrompt.test.ts`(2 例):25 项指标行齐全、可选段按需拼接、WF 窗口字符串值防御性转换回归锁定。 + +### 修复 + +- **`to_json_native` 有限 python float 被序列化成字符串**(`src/easy_tdx/backtest/types.py`)——有限 float 此前会落到末尾的 `str()` 兜底(np.float64 分支则正常转数字);Walk-Forward 窗口字段恰好都经 `float()` 包装全部中招,REST JSON 里逐窗 `total_return` / `sharpe` / `max_drawdown` / `win_rate` 变成字符串,前端严格判型处显示 `-`(AI 解读 Prompt 逐窗数据缺失、LLM 无法引用)。修复后有限 python float 与 np.float64 同口径保持数字、NaN → None。前端 Prompt 组装同步加 `Number()` 防御性转换,兼容仍在缓存的旧任务结果。 + +### 测试 + +- E2E 新增 2 例:名词解释默认折叠 / 点击展开(词条与面板统计标签同名场景用类名作用域定位,规避 strict mode 冲突)、AI 弹窗内容打包断言(textarea 用 `toHaveValue` 而非 `toContainText`)。既有「盈利窗占比」「综合评分」「对比买入持有」等全局 `getByText` 断言收窄到对应容器(词条文案含同名词)。Playwright 本轮以 `PYTHONPATH=src` 对仓库后端运行,序列化修复被 E2E 真实覆盖。 + ## [1.28.0] — 2026-09-02 **深度风险报告 + 移动止损 + 黄金测试**(借鉴 [akquant](https://github.com/akfamily/akquant))——把专业量化框架的「报告深度」与「测试 rigor」搬到散户工具上,三通道(CLI / Web API / Web UI)同步输出。同版本收录 Playwright E2E 前端测试基建与 WebSocket 实时推送联动(升级计划 P4-1 / P4-2)。 diff --git a/README.md b/README.md index 72c375e..b45f3c0 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 行情终端 Web UI(v1.23):市场看板 / 自选行情 / 个股与板块详情 -**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。 +**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。v1.28.1 起新手与 AI 辅助两连击:三个报告框内置**名词解释折叠帮助**(33 个词条讲清每项指标是什么、怎么算、怎么看,默认收起点击展开,重点粗体、阈值橙色、细节细体);回测报告一键导出 **AI 解读 Prompt**——把配置 + 25 项指标 + WF 逐窗 + 一条龙评估 + 评级 + 成交摘要组装成结构化提示词,复制发给 ChatGPT / Claude / DeepSeek / 豆包,即可获得「老手朋友」口吻的通俗解读、改进建议与 **0-10 信心分**(附「该不该执行」行动刻度)。 **数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。 diff --git a/pyproject.toml b/pyproject.toml index ae9d98d..3bb95c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.28.0" +version = "1.28.1" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py index 816c485..dd2562a 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -123,8 +123,11 @@ def to_json_native(obj: Any) -> Any: return f if math.isfinite(f) else None if isinstance(obj, np.bool_): return bool(obj) - if isinstance(obj, float) and not math.isfinite(obj): - return None + if isinstance(obj, float): + # 有限 python float 保持数字(与 np.float64 分支同口径)。 + # 此前有限 float 会落到末尾的 str() 兜底,导致 WF 窗口等 + # 经 float() 包装的字段被序列化成字符串(前端严格判型时显示 "-")。 + return obj if math.isfinite(obj) else None if obj is None or isinstance(obj, str | int | bool): return obj if hasattr(obj, "isoformat"): diff --git a/web-ui/e2e/backtest.spec.ts b/web-ui/e2e/backtest.spec.ts index fbee881..f81107d 100644 --- a/web-ui/e2e/backtest.spec.ts +++ b/web-ui/e2e/backtest.spec.ts @@ -43,12 +43,65 @@ test('勾选附加分析后出现 WF 逐窗柱状图与一条龙评估卡', asyn timeout: 60_000, }) await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 }) - await expect(page.getByText('盈利窗占比', { exact: true })).toBeVisible() + // 收窄到汇总区:词条表里也有「盈利窗占比」(dt),全局 getByText 会触发 strict mode + await expect(page.locator('.wf-summary').getByText('盈利窗占比', { exact: true })).toBeVisible() await expect(page.locator('.wf-summary .stat')).toHaveCount(6) // 一条龙评估:综合评分 + 高适配徽标 + 基准对比 + // 断言收窄到对应容器:名词解释按钮/词条文案里也含这些词,全局匹配会 strict mode await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 }) - await expect(page.getByText('综合评分')).toBeVisible() - await expect(page.getByText('对比买入持有')).toBeVisible() + await expect(page.locator('.eval-header').getByText('综合评分', { exact: true })).toBeVisible() + await expect(page.locator('.eval-header').getByText('对比买入持有')).toBeVisible() await expect(page.getByText(/适配性体检 \d+\/\d+/)).toBeVisible() }) + +test('名词解释默认折叠,点击展开显示词条', async ({ page }) => { + await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31') + await page.getByLabel('Walk-Forward 样本外验证').check() + await page.getByLabel('一条龙评估').check() + await page.getByRole('button', { name: '开始回测' }).click() + await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 }) + + // 词条用 .g-term 类定位,与面板统计标签同名也不冲突 + const wfTerm = page.locator('.wf-panel .g-term', { hasText: '每窗独立开仓' }) + const evalTerm = page.locator('.eval-panel .g-term', { hasText: '高适配' }) + const metricTerm = page.locator('.metric-wrap .g-term', { hasText: '卡玛比率' }) + + // 默认隐藏(内容在 DOM,但折叠框高度为 0) + await expect(wfTerm).toBeHidden() + await expect(evalTerm).toBeHidden() + await expect(metricTerm).toBeHidden() + + // 点击「名词解释」按钮逐个展开 + await page.locator('.wf-panel .help-toggle').click() + await expect(wfTerm).toBeVisible() + await page.locator('.eval-panel .help-toggle').click() + await expect(evalTerm).toBeVisible() + await page.locator('.metric-wrap .help-toggle').click() + await expect(metricTerm).toBeVisible() +}) + +test('AI 解读 Prompt 弹窗打包回测配置与各段报告', async ({ page }) => { + await page.goto('/backtest?startDate=2023-01-01&endDate=2025-12-31') + await page.getByLabel('Walk-Forward 样本外验证').check() + await page.getByLabel('一条龙评估').check() + await page.getByRole('button', { name: '开始回测' }).click() + await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 120_000 }) + + await page.getByRole('button', { name: '🤖 AI 解读' }).click() + const area = page.locator('.ai-prompt-area') + await expect(area).toBeVisible() + // textarea 的内容在 value 属性而非文本节点,用 toHaveValue(正则=子串匹配) + await expect(area).toHaveValue(/# 角色设定/) + await expect(area).toHaveValue(/# 回测配置/) + await expect(area).toHaveValue(/SZ:000001(日线)/) + await expect(area).toHaveValue(/- 总收益率:/) + await expect(area).toHaveValue(/Walk-Forward 样本外验证/) + await expect(area).toHaveValue(/# 一条龙评估/) + await expect(area).toHaveValue(/# 评级(不看收益率/) + await expect(area).toHaveValue(/# 背景与免责/) + + // 关闭弹窗回到报告 + await page.getByRole('button', { name: '关闭' }).click() + await expect(area).toBeHidden() +}) diff --git a/web-ui/src/__tests__/aiPrompt.test.ts b/web-ui/src/__tests__/aiPrompt.test.ts new file mode 100644 index 0000000..f19b42c --- /dev/null +++ b/web-ui/src/__tests__/aiPrompt.test.ts @@ -0,0 +1,219 @@ +// AI 解读 Prompt 生成器自检。 +// +// 项目未引入 vitest,采用 Node 内置 test runner(node:test)跑。 +// aiPrompt.ts 只含 type-only 本地导入(编译期擦除),因此 node --test 可直跑, +// 不需要 tsconfig 路径解析: +// +// node --test src/__tests__/aiPrompt.test.ts +// +// 关键断言:配置/25 项指标/可选段落(WF/评估/评级)随输入增减, +// 缺省可选数据时对应标题不出现(发给 LLM 的内容不撒谎)。 + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { buildAiPrompt } from '../aiPrompt.ts' +import type { BacktestResult, EvaluateReport, Performance } from '../types.ts' +import type { GradeResult } from '../grading/types.ts' + +// ── 京东方案例(与 grade.test.ts 同源的真实回测数据)───────────────────────── + +const PERF: Performance = { + total_return: 1.2643, + annual_return: 0.1401, + max_drawdown: -0.4165, + max_dd_duration: 1, + sharpe: 0.529, + sortino: 0.825, + calmar: 0.336, + total_trades: 90, + win_trades: 32, + lose_trades: 58, + rejected_trades: 0, + win_rate: 0.3556, + profit_factor: 1.107, + avg_win: 0.0444, + avg_loss: -0.0203, + max_win: 0.2554, + max_loss: -0.05, + avg_holding_days: 5.2, + volatility: 0.28, + ulcer_index: 0.08, + var_95: 0.025, + cvar_95: 0.038, + sqn: 1.8, + max_consecutive_wins: 5, + max_consecutive_losses: 8, +} + +const RESULT: BacktestResult = { + performance: PERF, + equity_curve: [ + { datetime: '2020-01-06', cash: 1000000, position_value: 0, total: 1000000, drawdown: 0, drawdown_pct: 0 }, + { datetime: '2021-06-01', cash: 0, position_value: 2264300, total: 2264300, drawdown: 0, drawdown_pct: 0 }, + { datetime: '2022-04-26', cash: 0, position_value: 1320000, total: 1320000, drawdown: -944300, drawdown_pct: -0.4165 }, + ], + trades: [ + { datetime: '2020-02-03', direction: 'BUY', size: 1000, price: 4.52, commission: 5, slippage: 0, pnl: 0, rejected: false }, + { datetime: '2020-03-10', direction: 'SELL', size: 1000, price: 4.71, commission: 5, slippage: 0, pnl: 150, rejected: false }, + ], + positions: [], + config: {}, +} + +const GRADE: GradeResult = { + grade: 'D', + score: 31.2, + dimensions: [ + { key: 'calmar', label: '卡玛比率', raw: 0.336, score: 22.4, weight: 0.18 }, + { key: 'max_drawdown', label: '最大回撤', raw: -0.4165, score: 30.2, weight: 0.17 }, + ], + vetoes: [ + { key: 'high_drawdown', reason: '最大回撤 41.7% > 50%,套牢难回本', cap: 'B' }, + ], + insufficientSample: false, + isLosing: false, + scenario: 'single', +} + +test('基础段:角色/任务/配置/25 项指标/净值/成交/免责齐全', () => { + const p = buildAiPrompt({ + symbol: 'SZ:000001', + category: 'DAY', + startDate: '2020-01-06', + endDate: '2026-09-02', + bars: 1580, + strategyLabel: '双均线交叉', + params: { fast: 5, slow: 20 }, + cash: 1000000, + commission: 0.0003, + slippage: 0, + execution: 'next_open', + result: RESULT, + }) + + assert.match(p, /# 角色设定/) + assert.match(p, /# 任务/) + // 语气要求:优点毛病都讲 + 0-10 信心分 + 禁臆造数字 + assert.match(p, /优点和毛病都要讲/) + assert.match(p, /0-10 的「信心分」/) + assert.match(p, /报告里没有的不要臆造/) + assert.match(p, /# 回测配置/) + assert.match(p, /SZ:000001(日线)/) + assert.match(p, /双均线交叉/) + assert.match(p, /fast=5, slow=20/) + // 25 项指标全部出现 + for (const label of [ + '总收益率', '年化收益', '夏普比率', '索提诺比率', '卡玛比率', + '最大回撤', '回撤持续', '波动率(年化)', 'Ulcer 指数', '日 VaR (95%)', '日 CVaR (95%)', + '总交易数', '盈利次数', '亏损次数', '胜率', '盈亏比(利润因子)', + '平均盈利', '平均亏损', '最大盈利', '最大亏损', '平均持仓天数', + 'SQN 系统质量', '最大连胜', '最大连亏', '拒单数', + ]) { + assert.ok(p.includes(`- ${label}:`), `缺少指标行:${label}`) + } + assert.match(p, /- 总收益率:126\.43%/) + assert.match(p, /# 净值概览/) + assert.match(p, /# 最近成交(最后 8 笔)/) + assert.match(p, /本笔盈亏 \+150 元/) + assert.match(p, /# 背景与免责/) + + // 未提供可选数据时,对应段落不出现(内容不撒谎) + assert.ok(!p.includes('Walk-Forward 样本外验证')) + assert.ok(!p.includes('一条龙评估')) + assert.ok(!p.includes('评级(不看收益率)')) +}) + +test('可选段:WF / 一条龙评估 / 评级按需拼接', () => { + const p = buildAiPrompt({ + symbol: 'SZ:000001', + category: 'MIN_15', + startDate: '2024-01-01', + endDate: '2025-12-31', + bars: 480, + strategyLabel: 'MACD', + params: {}, + cash: 100000, + commission: 0.0003, + slippage: 0.001, + execution: 'next_close', + result: RESULT, + wf: { + n_windows: 7, + warmup_ratio: 0.3, + windows: [ + // 窗1 用字符串值(旧后端 to_json_native 会把有限 float 序列化成字符串), + // 锁定 buildAiPrompt 的防御性数字转换:必须照常渲染为数值 + { + index: 0, + start: '2024-04-01', + end: '2024-07-01', + bars: 60, + total_return: '0.05' as unknown as number, + sharpe: '0.8' as unknown as number, + max_drawdown: '-0.06' as unknown as number, + total_trades: 12, + win_rate: '0.5' as unknown as number, + }, + { index: 1, start: '2024-07-02', end: '2024-10-01', bars: 60, total_return: -0.02, sharpe: -0.3, max_drawdown: -0.08, total_trades: 9, win_rate: 0.44 }, + ], + consistency: 0.5, + chained_return: 0.029, + mean_window_return: 0.015, + median_window_return: 0.015, + worst_window: -0.02, + best_window: 0.05, + mean_sharpe: 0.25, + worst_drawdown: -0.08, + total_trades: 21, + }, + evaluate: { + performance: PERF, + score: { + total: 61.5, + components: { total_return: 78, sharpe: 55, max_drawdown: 30, sortino: 50, wf_consistency: 40 }, + weights_used: { total_return: 0.5, sharpe: 0.15, max_drawdown: 0.1, sortino: 0.05, wf_consistency: 0.2 }, + wf_provided: true, + }, + walkforward: null as never, + fitness: { + segments: [], + checks: [ + { name: 'train_profitable', passed: true, detail: '训练段收益 +18.2% > 0' }, + { name: 'sign_consistent', passed: false, detail: '三段收益存在反号' }, + ], + pass_ratio: 0.5, + passed_count: 1, + total_checks: 2, + high_fitness: false, + split: [0.6, 0.2, 0.2], + }, + benchmark: { + buy_hold: { total_return: 0.32, annual_return: 0.06, max_drawdown: -0.35, sharpe: 0.4, calmar: 0.17, volatility: 0.24 }, + excess_return: 0.94, + alpha: 0.09, + beta: 0.72, + information_ratio: 0.85, + tracking_error: 0.12, + }, + config: {}, + }, + grade: GRADE, + gradeHint: '持有体验差或系统亏损,不建议参与', + }) + + assert.match(p, /15 分钟/) + assert.match(p, /(默认参数)/) + assert.match(p, /Walk-Forward 样本外验证(同参数跨时段稳定性)/) + assert.match(p, /盈利窗占比:50%(1\/2)/) + assert.match(p, /窗1(2024-04-01 ~ 2024-07-01):\+5\.00%,夏普 0\.80,最大回撤 -6\.00%,12 笔(胜率 \+50\.00%)/) + assert.match(p, /# 一条龙评估/) + assert.match(p, /综合评分:61\.5 \/ 100/) + assert.match(p, /超额收益 \+94\.00%/) + assert.match(p, /α(年化超额)\+9\.00%;β(敏感度)0\.72/) + assert.match(p, /适配性体检:1\/2 通过/) + assert.match(p, /✗ 三段收益存在反号/) + assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/) + assert.match(p, /档位:\*\*D\*\*(总分 31\.2\/100)——持有体验差或系统亏损,不建议参与/) + assert.match(p, /一票否决:最大回撤 41\.7%/) +}) diff --git a/web-ui/src/aiPrompt.ts b/web-ui/src/aiPrompt.ts new file mode 100644 index 0000000..a0d9abc --- /dev/null +++ b/web-ui/src/aiPrompt.ts @@ -0,0 +1,349 @@ +// AI 解读 Prompt 生成器:把单标的回测报告组装成一段结构化提示词, +// 用户复制后发给任意 LLM(ChatGPT / Claude / DeepSeek / 豆包…)即可获得针对性解读。 +// +// 约束:本文件只允许 **type-only** 的本地导入(编译期擦除), +// 保证 node --test 可直跑(与 grading/__tests__ 同一套自检方式); +// 运行时逻辑全部自包含,不依赖其他模块的值。 +// +// 文案口径与后端对齐:指标含义见 src/data/glossary.ts, +// 评分权重见 scoring.py,WF/体检语义见 walkforward.py / fitness.py。 + +import type { + BacktestResult, + Category, + EvaluateReport, + ExecutionMode, + Performance, + Trade, + WalkForwardResult, +} from './types' +import type { GradeResult } from './grading/types' + +// ── 输入 ───────────────────────────────────────────────────────────────────── + +export interface AiPromptInput { + /** 完整标的代码(带市场前缀,如 "SZ:000001") */ + symbol: string + category: Category + startDate: string + endDate: string + /** K 线根数(store.ohlcv.length) */ + bars: number + /** 策略中文名(如「双均线交叉」) */ + strategyLabel: string + params: Record + cash: number + commission: number + slippage: number + execution: ExecutionMode + result: BacktestResult + /** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */ + wf?: WalkForwardResult | null + evaluate?: EvaluateReport | null + grade?: GradeResult | null + /** 评级档位的一句话含义(GRADE_META[grade].hint,由组件传入) */ + gradeHint?: string +} + +// ── 展示辅助(自包含,避免运行时依赖其他模块)──────────────────────────────── + +const CATEGORY_LABELS: Record = { + DAY: '日线', + WEEK: '周线', + MONTH: '月线', + MIN_5: '5 分钟', + MIN_15: '15 分钟', + MIN_30: '30 分钟', + MIN_60: '60 分钟', +} + +const EXECUTION_LABELS: Record = { + next_open: '次日开盘价', + next_close: '次日收盘价', +} + +/** 与 MetricTable 同源同序的 25 项指标清单(label + 数字格式) */ +const METRIC_LINES: Array<{ + key: keyof Performance + label: string + format: 'percent' | 'ratio' | 'int' | 'days' + group: string +}> = [ + { key: 'total_return', label: '总收益率', format: 'percent', group: '收益' }, + { key: 'annual_return', label: '年化收益', format: 'percent', group: '收益' }, + { key: 'sharpe', label: '夏普比率', format: 'ratio', group: '收益' }, + { key: 'sortino', label: '索提诺比率', format: 'ratio', group: '收益' }, + { key: 'calmar', label: '卡玛比率', format: 'ratio', group: '收益' }, + { key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' }, + { key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' }, + { key: 'volatility', label: '波动率(年化)', format: 'percent', group: '风险' }, + { key: 'ulcer_index', label: 'Ulcer 指数', format: 'percent', group: '风险' }, + { key: 'var_95', label: '日 VaR (95%)', format: 'percent', group: '风险' }, + { key: 'cvar_95', label: '日 CVaR (95%)', format: 'percent', group: '风险' }, + { key: 'total_trades', label: '总交易数', format: 'int', group: '交易' }, + { key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' }, + { key: 'lose_trades', label: '亏损次数', format: 'int', group: '交易' }, + { key: 'win_rate', label: '胜率', format: 'percent', group: '交易' }, + { key: 'profit_factor', label: '盈亏比(利润因子)', format: 'ratio', group: '交易' }, + { key: 'avg_win', label: '平均盈利', format: 'percent', group: '交易' }, + { key: 'avg_loss', label: '平均亏损', format: 'percent', group: '交易' }, + { key: 'max_win', label: '最大盈利', format: 'percent', group: '交易' }, + { key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' }, + { key: 'avg_holding_days', label: '平均持仓天数', format: 'ratio', group: '交易' }, + { key: 'sqn', label: 'SQN 系统质量', format: 'ratio', group: '交易' }, + { key: 'max_consecutive_wins', label: '最大连胜', format: 'int', group: '交易' }, + { key: 'max_consecutive_losses', label: '最大连亏', format: 'int', group: '交易' }, + { key: 'rejected_trades', label: '拒单数', format: 'int', group: '交易' }, +] + +function fmtMetric(format: 'percent' | 'ratio' | 'int' | 'days', v: number | undefined): string { + if (v === undefined || v === null || !Number.isFinite(v)) return '-' + if (format === 'percent') return `${(v * 100).toFixed(2)}%` + if (format === 'int') return String(Math.round(v)) + if (format === 'days') return `${v.toFixed(0)} 天` + return v.toFixed(3) +} + +function pct(v: number | undefined | null): string { + if (v === undefined || v === null || !Number.isFinite(v)) return '-' + return `${v >= 0 ? '+' : ''}${(v * 100).toFixed(2)}%` +} + +function ratio(v: number | undefined | null): string { + if (v === undefined || v === null || !Number.isFinite(v)) return '-' + return v.toFixed(2) +} + +function fmtParams(params: Record): string { + const entries = Object.entries(params) + if (entries.length === 0) return '(默认参数)' + return entries.map(([k, v]) => `${k}=${v}`).join(', ') +} + +function fmtMoney(v: number): string { + return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) +} + +/** "2025-06-23T00:00:00" → "2025-06-23"(截掉时间部分,提示词更整洁) */ +function fmtDate(dt: string): string { + return dt.slice(0, 10) +} + +/** WF 窗口字段防御性转数字:老版本后端曾把有限 float 序列化成字符串 + * (如 "-0.2078"),Number() 对数字原值透明、对字符串强制转换、非法值给 NaN。 */ +function n(v: number | string | null | undefined): number | undefined { + const x = Number(v) + return Number.isFinite(x) ? x : undefined +} + +// ── 各段落构建 ─────────────────────────────────────────────────────────────── + +function sectionRole(): string { + return [ + '# 角色设定', + '', + '你是一位做了十几年量化交易的老手,说话直来直去,心是好的。现在你正和一个刚入门的朋友(我)聊天,我什么都不懂,你解释什么都要说人话。', + '', + '# 任务', + '', + '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:', + '', + '1. **先给结论**:这策略现在处于什么状态——「可以继续往下走」「底子不错但还差几步」还是「问题不小,得大改」?一句话说清,再讲理由;', + '2. **优点和毛病都要讲**:先说说它强在哪(哪些数字是真的好看、说明策略做对了什么),再讲你担心什么。别只挑刺,也别光报喜——我是想知道这策略能不能用,不是来听审判也不是来听表扬的。挑最有说服力的几组数字讲,不用面面俱到;', + '3. **说说持有体验**:真拿钱跑这个策略,过程大概什么感受——多久交易一次、最惨的时候有多惨、普通人拿不拿得住;', + '4. **判断是规律还是运气**:从分时段数据(Walk-Forward 各窗收益、训练/验证/测试三段、和死拿不动的对比)找证据。有担心就直说,但像朋友提醒那样说,别像下判决书;', + '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;', + '6. **最后打个分**:给这个策略一个 0-10 的「信心分」,代表你现在有多大把握它值得继续投入。打分要和前面说的话一致(前面夸的多就别打低分,反过来也一样),再用一两句话说说为什么是这个分、到几分你会建议我拿小仓位试试。参考刻度:0-3 建议放弃,4-6 值得继续改(说清往哪改),7-8 可以小仓位试错,9 以上才谈逐步加仓。', + '', + '# 说话方式(很重要)', + '', + '- 像靠谱朋友给建议:直接、坦诚,但不刻薄、不吓唬人。指出问题是为了帮我做决定,不是逞口舌之快;', + '- 别像写报告:不要一堆小标题、密集加粗和列表堆砌,自然分段,最关键的结论偶尔加粗就行;', + '- 禁止八股句式:「事实是」「我的推断是」「总的来说」「综上所述」「作为你的…」「好的,收到」这类全部不要;不确定的地方自然地说「我猜」「大概率」,不要贴标签;', + '- 不要客套开场,也不要结尾祝词,上来就说正事,说完就停;', + '- 全文控制在 800 字以内,一屏读完,句句有用;宁可少讲两点,也不要注水;', + '- 只引用报告里有的数字,报告里没有的不要臆造;要估计就明说是估计;', + '- 专业词第一次出现时顺口解释一句,别让我再去查。', + '', + '参考语气:「这策略胜率近八成、盈亏比四倍,底子是好的;但我最担心的是最近两年基本没赚钱——测试段只涨了 0.16%,这就是我不敢给高分的主要原因。」', + '', + ].join('\n') +} + +function sectionConfig(i: AiPromptInput): string { + const lines = [ + '# 回测配置', + '', + `- 标的:${i.symbol}(${CATEGORY_LABELS[i.category] ?? i.category})`, + `- 回测区间:${i.startDate} ~ ${i.endDate}(共 ${i.bars} 根 K 线)`, + `- 策略:${i.strategyLabel}`, + `- 参数:${fmtParams(i.params)}`, + `- 初始资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`, + '', + ] + return lines.join('\n') +} + +function sectionMetrics(perf: Performance): string { + const lines = ['# 绩效指标', ''] + for (const g of ['收益', '风险', '交易']) { + lines.push(`**${g}类**`) + for (const m of METRIC_LINES.filter((x) => x.group === g)) { + lines.push(`- ${m.label}:${fmtMetric(m.format, perf[m.key] as number | undefined)}`) + } + lines.push('') + } + return lines.join('\n') +} + +function sectionEquity(result: BacktestResult): string { + const eq = result.equity_curve + if (!eq || eq.length === 0) return '' + let peak = eq[0] + let trough = eq[0] + for (const p of eq) { + if (p.total > peak.total) peak = p + if (p.total < trough.total) trough = p + } + return [ + '# 净值概览', + '', + `- 期初资产:${fmtMoney(eq[0].total)} 元(${fmtDate(eq[0].datetime)})`, + `- 期末资产:${fmtMoney(eq[eq.length - 1].total)} 元(${fmtDate(eq[eq.length - 1].datetime)})`, + `- 峰值:${fmtMoney(peak.total)} 元(${fmtDate(peak.datetime)});谷值:${fmtMoney(trough.total)} 元(${fmtDate(trough.datetime)})`, + '', + ].join('\n') +} + +function sectionWf(wf: WalkForwardResult): string { + if (!wf || wf.windows.length === 0) return '' + const lines = [ + '# Walk-Forward 样本外验证(同参数跨时段稳定性)', + '', + `- 窗口数:${wf.n_windows};前 ${(wf.warmup_ratio * 100).toFixed(0)}% 数据为预热区不参与评估;每窗从空仓独立开仓`, + `- 盈利窗占比:${(wf.consistency * 100).toFixed(0)}%(${wf.windows.filter((w) => (n(w.total_return) ?? 0) > 0).length}/${wf.windows.length})`, + `- 连乘收益(各窗复利衔接):${pct(wf.chained_return)}`, + `- 最差窗 / 最好窗:${pct(wf.worst_window)} / ${pct(wf.best_window)};平均夏普:${ratio(wf.mean_sharpe)};总交易:${wf.total_trades} 笔`, + '', + '逐窗收益(时间升序,红涨绿跌口径无关,正=赚):', + '', + ] + for (const w of wf.windows) { + lines.push( + `- 窗${w.index + 1}(${w.start} ~ ${w.end}):${pct(n(w.total_return))},夏普 ${ratio(n(w.sharpe))},最大回撤 ${pct(n(w.max_drawdown))},${w.total_trades} 笔(胜率 ${pct(n(w.win_rate))})`, + ) + } + lines.push('') + return lines.join('\n') +} + +function sectionEvaluate(ev: EvaluateReport): string { + const lines = ['# 一条龙评估', ''] + + // 综合评分 + const labels: Record = { + total_return: '收益', + sharpe: '夏普', + max_drawdown: '回撤', + sortino: '索提诺', + wf_consistency: 'WF 一致性', + } + lines.push(`**综合评分:${ev.score.total.toFixed(1)} / 100**(权重:${Object.entries(ev.score.weights_used) + .map(([k, w]) => `${labels[k] ?? k} ${(w * 100).toFixed(0)}%`) + .join(' + ')})`) + lines.push(`- 分项:${Object.entries(ev.score.components) + .map(([k, v]) => `${labels[k] ?? k} ${v.toFixed(0)}`) + .join(',')}`) + lines.push('') + + // 基准对比 + const b = ev.benchmark + lines.push('**对比买入持有基准**') + lines.push(`- 策略总收益 ${pct(ev.performance.total_return)} vs 买入持有 ${pct(b.buy_hold.total_return)},超额收益 ${pct(b.excess_return)}`) + if (b.alpha !== undefined || b.beta !== undefined) { + lines.push( + `- α(年化超额)${pct(b.alpha)};β(敏感度)${ratio(b.beta)};信息比率 ${ratio(b.information_ratio)};跟踪误差 ${pct(b.tracking_error)}`, + ) + } + lines.push(`- 买入持有基准:年化 ${pct(b.buy_hold.annual_return)},最大回撤 ${pct(b.buy_hold.max_drawdown)},夏普 ${ratio(b.buy_hold.sharpe)},卡玛 ${ratio(b.buy_hold.calmar)},波动率 ${pct(b.buy_hold.volatility)}`) + lines.push('') + + // 适配性体检 + const f = ev.fitness + lines.push( + `**适配性体检:${f.passed_count}/${f.total_checks} 通过(train/valid/test = ${f.split.map((s) => (s * 100).toFixed(0)).join('/')})${f.high_fitness ? ',达到「高适配」' : ''}**`, + ) + for (const c of f.checks) { + lines.push(`- ${c.passed ? '✓' : '✗'} ${c.detail}`) + } + lines.push('') + return lines.join('\n') +} + +function sectionGrade(grade: GradeResult, hint: string | undefined): string { + const lines = [ + '# 评级(不看收益率,面向「普通人拿不拿得住」)', + '', + `- 档位:**${grade.grade}**(总分 ${grade.score.toFixed(1)}/100)${hint ? `——${hint}` : ''}`, + ] + if (grade.dimensions.length > 0) { + lines.push( + `- 维度得分:${grade.dimensions + .map((d) => `${d.label} ${d.score.toFixed(0)}/100(权重 ${(d.weight * 100).toFixed(0)}%)`) + .join(',')}`, + ) + } + if (grade.vetoes.length > 0) { + lines.push(`- ⚠ 一票否决:${grade.vetoes.map((v) => v.reason).join(';')}`) + } + if (grade.insufficientSample) { + lines.push('- ⚠ 交易样本有限(< 10 笔),胜率/盈亏比已降权处理') + } + if (grade.isLosing) { + lines.push('- ⚠ 系统亏损(利润因子 < 1)') + } + lines.push('') + return lines.join('\n') +} + +function sectionTrades(trades: Trade[]): string { + const recent = trades.slice(-8) + if (recent.length === 0) return '' + const lines = ['# 最近成交(最后 8 笔)', ''] + for (const t of recent) { + const dir = t.direction === 'BUY' ? '买入' : '卖出' + const pnl = t.direction === 'SELL' && t.pnl !== 0 ? `,本笔盈亏 ${t.pnl >= 0 ? '+' : ''}${fmtMoney(t.pnl)} 元` : '' + lines.push(`- ${fmtDate(t.datetime)} ${dir} ${Math.round(t.size)} 股 @ ${t.price.toFixed(2)}${pnl}`) + } + lines.push('') + return lines.join('\n') +} + +function sectionFooter(): string { + return [ + '# 背景与免责', + '', + '以上数据来自 easy-tdx 的历史 K 线回测(已计入佣金与滑点)。历史回测存在幸存者偏差与未来不确定性,不构成投资建议,你的解读也以研究学习为目的。', + '数据里缺失的项(显示 - 或整段没有的)直接跳过,不用专门解释局限。', + '好了,开始吧。', + ].join('\n') +} + +// ── 主函数 ─────────────────────────────────────────────────────────────────── + +/** 组装 AI 解读 Prompt(markdown 结构,任意 LLM 可直接消费)。 */ +export function buildAiPrompt(input: AiPromptInput): string { + const parts: string[] = [ + sectionRole(), + sectionConfig(input), + sectionMetrics(input.result.performance), + sectionEquity(input.result), + ] + if (input.wf) parts.push(sectionWf(input.wf)) + if (input.evaluate) parts.push(sectionEvaluate(input.evaluate)) + if (input.grade) parts.push(sectionGrade(input.grade, input.gradeHint)) + const trades = sectionTrades(input.result.trades) + if (trades) parts.push(trades) + parts.push(sectionFooter()) + return parts.join('\n') +} diff --git a/web-ui/src/components/EvaluatePanel.vue b/web-ui/src/components/EvaluatePanel.vue index 48de01a..f9a2291 100644 --- a/web-ui/src/components/EvaluatePanel.vue +++ b/web-ui/src/components/EvaluatePanel.vue @@ -5,7 +5,10 @@ import { computed } from 'vue' import GradeDetails from './GradeDetails.vue' +import GlossaryList from './GlossaryList.vue' +import HelpCollapse from './HelpCollapse.vue' import { gradePerformance } from '../grading' +import { evaluateGlossary } from '../data/glossary' import type { EvaluateReport } from '../types' const props = defineProps<{ @@ -137,6 +140,11 @@ function fmtCapm(v: number, fmt: string): string {

评级(不看收益率)

+ + + + + diff --git a/web-ui/src/components/GlossaryList.vue b/web-ui/src/components/GlossaryList.vue new file mode 100644 index 0000000..33ef13c --- /dev/null +++ b/web-ui/src/components/GlossaryList.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/web-ui/src/components/HelpCollapse.vue b/web-ui/src/components/HelpCollapse.vue new file mode 100644 index 0000000..d01c1cf --- /dev/null +++ b/web-ui/src/components/HelpCollapse.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web-ui/src/components/MetricTable.vue b/web-ui/src/components/MetricTable.vue index ccafa16..f223fe1 100644 --- a/web-ui/src/components/MetricTable.vue +++ b/web-ui/src/components/MetricTable.vue @@ -4,7 +4,10 @@ import { computed } from 'vue' +import { metricsGlossary } from '../data/glossary' import type { Performance } from '../types' +import GlossaryList from './GlossaryList.vue' +import HelpCollapse from './HelpCollapse.vue' const props = defineProps<{ perf: Performance @@ -85,22 +88,32 @@ function valueClass(row: MetricRow): string {