From cf843cebaddace8a0a4b8bdb3ec821132c7e470a Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sun, 5 Jul 2026 01:21:18 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.17.14=20=E2=80=94=20Web=20UI=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AF=84=E7=BA=A7=E7=B3=BB=E7=BB=9F=EF=BC=88?= =?UTF-8?q?S/A/B/C/D=20=E4=BA=94=E6=A1=A3=EF=BC=8C=E4=B8=89=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯前端 TypeScript 实现,零后端改动。回测/组合/寻优三处结果顶部 显示评级徽章,让普通人 1 秒判断「适不适合经常参与」。 评级不看收益率(避免被近期大涨误导),只看风险调整后的持有体验: 卡玛/最大回撤/胜率/利润因子/夏普/波动率六维加权 + 一票否决。 京东方「收益 126% 但胜率 35%」案例 = D 档(核心验证点)。 长线低频策略(6年6笔)不会被冤枉:交易<10笔时只降权胜率/利润因子 维度,不否决整个评级(修复用户实测反馈)。 - 新增 web-ui/src/grading/ 核心模块(engine/thresholds/combinedMetrics/index) - 新增 GradeBadge.vue + GradeDetails.vue 组件 - 接入 BacktestView/PortfolioView/OptimizeView(寻优排名表加评级列) - 15 个自检测试(node:test + rolldown 打包),覆盖京东方 D / 长线 B 等场景 --- CHANGELOG.md | 22 ++ README.md | 2 + pyproject.toml | 2 +- web-ui/scripts/run-grading-tests.mjs | 50 ++++ web-ui/src/components/GradeBadge.vue | 133 +++++++++ web-ui/src/components/GradeDetails.vue | 184 ++++++++++++ web-ui/src/grading/__tests__/grade.test.ts | 319 ++++++++++++++++++++ web-ui/src/grading/combinedMetrics.ts | 180 ++++++++++++ web-ui/src/grading/engine.ts | 112 +++++++ web-ui/src/grading/index.ts | 322 +++++++++++++++++++++ web-ui/src/grading/thresholds.ts | 170 +++++++++++ web-ui/src/grading/types.ts | 112 +++++++ web-ui/src/views/BacktestView.vue | 14 + web-ui/src/views/OptimizeView.vue | 31 ++ web-ui/src/views/PortfolioView.vue | 13 + web-ui/tsconfig.app.json | 3 +- 16 files changed, 1667 insertions(+), 2 deletions(-) create mode 100644 web-ui/scripts/run-grading-tests.mjs create mode 100644 web-ui/src/components/GradeBadge.vue create mode 100644 web-ui/src/components/GradeDetails.vue create mode 100644 web-ui/src/grading/__tests__/grade.test.ts create mode 100644 web-ui/src/grading/combinedMetrics.ts create mode 100644 web-ui/src/grading/engine.ts create mode 100644 web-ui/src/grading/index.ts create mode 100644 web-ui/src/grading/thresholds.ts create mode 100644 web-ui/src/grading/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cde3876..f25b916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.17.14] — 2026-07-05 + +**Web UI 新增「数据评级」系统(S/A/B/C/D 五档)** —— 此前回测结果只有冷冰冰的 19 项指标,普通用户看到「总收益 126%」会觉得不错,却看不出胜率仅 35%、最大回撤 41% 背后的「套牢拿不住」风险。本次给单标的回测、组合回测、参数寻优三个入口都加上一个一眼可读的评级徽章,让普通人 1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率(套牢回本难度)、最大回撤、胜率、利润因子、夏普、波动率六个维度加权评分,再叠加一票否决(系统亏损/深回撤/低胜率)。京东方那种「收益好看但风险高」的案例会评 **D 档**,明确告诉用户「别碰」。长线低频策略(如 6 年 6 笔交易)不会被一刀切否决——交易笔数少时只把胜率/利润因子降权,不影响基于净值的评级。 + +### 新增 + +- **评级核心模块**(`web-ui/src/grading/`)—— 纯前端 TypeScript 实现,零后端改动。`engine.ts`(线性插值 + 加权 + 一票否决)、`thresholds.ts`(8 维度阈值锚点表,集中可调)、`combinedMetrics.ts`(从组合净值曲线重算夏普/卡玛/波动率)、`index.ts`(三个场景入口)。 +- **三个场景评级** —— 单标的回测用 6 维度(卡玛 18% + 最大回撤 17% + 胜率 17% + 利润因子 18% + 夏普 15% + 波动率 15%);组合回测从 `combined_equity` 重算 5 维度(卡玛 25% + 最大回撤 22% + 夏普 22% + 索提诺 15% + 波动率 16%,因净值算不出胜率/利润因子);参数寻优用 4 维度降级版(夏普 30% + 最大回撤 28% + 胜率 22% + 利润因子 20%,因 GridPointResult 只有 6 字段)。 +- **一票否决规则** —— 系统亏损(`profit_factor < 1` → D)、深回撤(`max_drawdown > 60%` → D)、高回撤(> 50% 最高 B)、低胜率(< 30% 且样本充足最高 C)、微利(利润因子 < 1.2 最高 B)。 +- **样本不足降权(不否决)** —— 交易笔数 < 10 时,把依赖逐笔成交的维度(胜率/利润因子)权重降到 0,重分配给净值类维度;评级照常给出,旁边标「⚠ 交易样本有限」。修复了「长线策略 6 年 6 笔被打到 D」的过度惩罚。 +- **评级 UI 组件**(`GradeBadge.vue` / `GradeDetails.vue`)—— 圆形徽章(S 金/A 绿/B 蓝/C 橙/D 红,遵循 A 股颜色惯例)+ 展开式详情(维度得分条 + 否决原因 + 样本提示)。接入 `BacktestView` / `PortfolioView` / `OptimizeView`,寻优排名表新增「评级」列。 +- **评级自检测试**(`web-ui/src/grading/__tests__/grade.test.ts`)—— 15 个测试用 Node 内置 `node:test` + rolldown 打包跑,覆盖核心场景:京东方 = D(核心断言)、长线策略 = B、否决规则、组合评级、插值边界。 + +### 变更 + +- **`tsconfig.app.json` 排除测试目录** —— `src/**/__tests__/**` 和 `scripts/**` 不进 app bundle(测试用 rolldown 独立打包跑,不经 vue-tsc)。 + +### 已知约束(非 bug) + +- **评级阈值需在真实数据上观察后微调** —— 所有阈值集中在 `thresholds.ts`,当前用金融惯例值校准。如果某批真实回测的评级不符合直觉,可在该文件单点调整,无需动评分引擎。 +- **寻优排名表全量算评级** —— 大表(200 行)未做虚拟化,目前性能可接受。若未来卡顿再优化。 + ## [1.17.13] — 2026-07-04 **修复多策略组合回测「最大回撤」严重虚高** —— 用户反馈:3 个策略各自最大回撤仅 45.53%/40.16%/16.89%,组合在一起却显示 **83.76%**。根因是 `MultiStrategyEngine._build_combined_equity` 计算 `drawdown_pct` 时**分母误用初始资金(`initial`)而非逐点峰值(`peak`)**:净值大涨后峰值是初始值的好几倍(本例总收益 545%,峰值≈6.45×初始),同样的绝对回撤额除以小的初始值,百分比被等比放大。正确公式应为 `drawdown / peak`(相对当时峰值的回撤,0~1),与单标的 `PortfolioTracker.equity_curve` 的 `drawdown_pct` 定义一致。修复后最大回撤回到合理区间(≤ 各策略最大回撤的加权,不可能超过 100%)。**连带修复**:卡玛比率(`年化收益 / 最大回撤`)此前因 max_drawdown 虚高而被压低,修复后恢复正常。其余指标(总收益/年化/夏普/索提诺/波动率/交易数/胜率/盈亏比)经逐一核对**均正确**,不受此 bug 影响。 diff --git a/README.md b/README.md index 3e92a68..914b135 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 **回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。 +**数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。 + 装上就能跑。**Python API + CLI + Web API 三通道**,输出 JSON 天然喂给 AI Agent:Claude Code、OpenClaw、Hermes 直接吃。`easy-tdx serve` 一键起 REST 服务,浏览器打开就是交互式 API 文档。 **你不懂 TCP 协议?不用。** diff --git a/pyproject.toml b/pyproject.toml index 9866db5..9b748e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.17.13" +version = "1.17.14" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/web-ui/scripts/run-grading-tests.mjs b/web-ui/scripts/run-grading-tests.mjs new file mode 100644 index 0000000..90cb36a --- /dev/null +++ b/web-ui/scripts/run-grading-tests.mjs @@ -0,0 +1,50 @@ +/** + * 把评级测试入口打包成单文件 JS,再用 node:test 跑。 + * + * 项目未引入 vitest/jest,临时用 rolldown(vite 自带依赖)打包, + * 避免 Node 原生 strip-types 对 ESM 无后缀 import 的限制。 + * + * 运行:node scripts/run-grading-tests.mjs + */ + +import { build } from 'rolldown' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' + +const tmpDir = mkdtempSync(join(tmpdir(), 'grading-tests-')) +const bundlePath = join(tmpDir, 'bundle.mjs') + +// 入口:导入测试文件,触发 node:test 注册 +const entryPath = join(tmpDir, 'entry.mjs') +writeFileSync( + entryPath, + `import '${resolve('src/grading/__tests__/grade.test.ts').replace(/\\/g, '/')}'\n`, +) + +console.log('→ 打包中…') +try { + await build({ + input: entryPath, + output: { + file: bundlePath, + format: 'esm', + }, + // 顶层 await / dynamic import 都 OK + platform: 'node', + // 不外部化 node 内置 + external: ['node:test', 'node:assert', 'node:assert/strict', 'node:os', 'node:fs', 'node:path', 'node:child_process'], + // 强制把测试文件和 grading 模块都打进 bundle + treeshake: false, + }) +} catch (e) { + console.error('打包失败:', e) + process.exit(1) +} + +console.log('→ 运行测试…') +const r = spawnSync('node', ['--test', bundlePath], { stdio: 'inherit' }) + +rmSync(tmpDir, { recursive: true, force: true }) +process.exit(r.status ?? 0) diff --git a/web-ui/src/components/GradeBadge.vue b/web-ui/src/components/GradeBadge.vue new file mode 100644 index 0000000..43a9bf4 --- /dev/null +++ b/web-ui/src/components/GradeBadge.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/web-ui/src/components/GradeDetails.vue b/web-ui/src/components/GradeDetails.vue new file mode 100644 index 0000000..9714335 --- /dev/null +++ b/web-ui/src/components/GradeDetails.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/web-ui/src/grading/__tests__/grade.test.ts b/web-ui/src/grading/__tests__/grade.test.ts new file mode 100644 index 0000000..fe0a89d --- /dev/null +++ b/web-ui/src/grading/__tests__/grade.test.ts @@ -0,0 +1,319 @@ +/** + * 评级系统自检脚本。 + * + * 项目未引入 vitest,采用 Node 内置 test runner(node:test)跑。 + * 评级逻辑是纯函数 + 零 DOM 依赖,可直接 import ESM TypeScript(Node v22+ 原生支持)。 + * + * 运行:node --test src/grading/__tests__/grade.test.ts + * + * 关键断言:京东方案例(126.43% 收益但胜率 35.56%、回撤 41.65%、卡玛 0.336) + * 必须落在 D 档——这是产品诉求的核心验证点。 + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { gradePerformance, gradeGridPoint, gradePortfolio } from '../index.ts' +import { interpolate } from '../engine.ts' +import { THRESHOLDS } from '../thresholds.ts' +import { computeCombinedMetrics } from '../combinedMetrics.ts' +import type { Performance, PortfolioResult, GridPointResult, EquityPoint } from '../../types.ts' + +// ── 京东方案例(用户提供的真实回测数据)────────────────────────────────────── +const BOE_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: 11.222, + volatility: 0.2496, +} + +test('京东方回测必须评为 D 档(用户核心诉求验证点)', () => { + const r = gradePerformance(BOE_PERF) + console.log('京东方评级:', r.grade, '分数:', r.score) + console.log('维度明细:', r.dimensions.map((d) => `${d.label}=${d.score.toFixed(1)}`).join(', ')) + console.log('否决:', r.vetoes.map((v) => v.reason).join('; ')) + assert.equal(r.grade, 'D', `期望 D,实际 ${r.grade}(分数 ${r.score})。这个评级必须让用户认可。`) +}) + +test('京东方评分应在 30-43 区间(C 与 D 的边界)', () => { + const r = gradePerformance(BOE_PERF) + // 京东方分项:卡玛低 + 回撤深 + 胜率低,应在 D 档中段 + assert.ok(r.score >= 25 && r.score < 43, `分数 ${r.score} 不在 D 档合理区间`) +}) + +test('低利润因子触发系统亏损否决', () => { + const r = gradePerformance({ ...BOE_PERF, profit_factor: 0.95 }) + assert.equal(r.grade, 'D') + assert.equal(r.isLosing, true) + assert.ok(r.vetoes.some((v) => v.key === 'losing_system')) +}) + +test('样本不足(< 10 笔交易)降权但不否决整个评级', () => { + // 用户实测场景:策略本身数据不错(高夏普、低回撤),但因为长线策略天然交易少, + // 旧逻辑会直接打到 D。修复后应该只降权 win_rate/profit_factor,评级照常给。 + // 这里用一个净值质量中等的案例,验证它不会无脑掉到 D。 + const r = gradePerformance({ + ...BOE_PERF, + total_trades: 6, + sharpe: 1.2, + max_drawdown: 0.2, + calmar: 1.5, + volatility: 0.15, + // win_rate / profit_factor 故意留噪音值,验证它们不影响总分 + win_rate: 0.5, + profit_factor: 1.5, + }) + assert.equal(r.insufficientSample, true, '应标记样本不足') + // 修复后不应再否决到 D —— 高夏普/低回撤的长线策略应得 B 或更好 + assert.ok( + ['A', 'B', 'S'].includes(r.grade), + `高夏普长线策略不应因交易少被打到 D,实际 ${r.grade}(分数 ${r.score})`, + ) + // win_rate / profit_factor 权重应为 0 + const wr = r.dimensions.find((d) => d.key === 'win_rate') + const pf = r.dimensions.find((d) => d.key === 'profit_factor') + assert.equal(wr?.weight, 0, 'win_rate 权重应降为 0') + assert.equal(pf?.weight, 0, 'profit_factor 权重应降为 0') +}) + +test('用户实测场景:6年6笔交易 + 高夏普 → 应得 A/B(核心回归测试)', () => { + // 用户反馈:「有些策略数据不错,夏普也挺高,但是6年只有6次交易,评级就是D了」 + // 这条测试就是为这个场景兜底,确保修复后不再回归。 + const longTermGood: Performance = { + total_return: 1.8, // 6 年 80% + annual_return: 0.103, // 年化约 10% + max_drawdown: 0.18, // 浅回撤 + max_dd_duration: 120, + sharpe: 1.4, // 高夏普 + sortino: 1.8, + calmar: 0.57, // 年化/回撤 + total_trades: 6, // ← 关键:长线策略交易少 + win_trades: 4, + lose_trades: 2, + rejected_trades: 0, + win_rate: 0.667, // 6 笔里 4 笔赢,但样本太小不可信 + profit_factor: 2.5, // 同上 + avg_win: 0.15, + avg_loss: -0.05, + max_win: 0.3, + max_loss: -0.08, + avg_holding_days: 365, // 平均持仓 1 年 + volatility: 0.16, + } + const r = gradePerformance(longTermGood) + console.log('长线优质策略评级:', r.grade, '分数:', r.score) + console.log('维度权重:', r.dimensions.map((d) => `${d.label}=${(d.weight * 100).toFixed(0)}%`).join(', ')) + assert.equal(r.insufficientSample, true) + // 这是核心断言:高夏普长线策略不该因交易少被打到 D + assert.ok( + ['A', 'B', 'S'].includes(r.grade), + `用户场景必须修复:期望 A/B/S,实际 ${r.grade}(分数 ${r.score})`, + ) +}) + +test('深回撤 > 60% 触发直接 D 否决', () => { + const r = gradePerformance({ ...BOE_PERF, max_drawdown: 0.65 }) + assert.equal(r.grade, 'D') + assert.ok(r.vetoes.some((v) => v.key === 'deep_drawdown')) +}) + +test('优质回测应得 A 或 S 档', () => { + // 卡玛 2.0、夏普 1.8、回撤 15%、胜率 55%、利润因子 2.0、波动率 12% → 应是 A 或 S + const good: Performance = { + ...BOE_PERF, + max_drawdown: 0.15, + max_dd_duration: 30, + sharpe: 1.8, + sortino: 2.5, + calmar: 2.0, + win_rate: 0.55, + profit_factor: 2.0, + volatility: 0.12, + total_trades: 80, + } + const r = gradePerformance(good) + console.log('优质案例评级:', r.grade, '分数:', r.score) + assert.ok(r.grade === 'A' || r.grade === 'S', `期望 A/S,实际 ${r.grade}`) +}) + +test('高回撤但收益高 → 最高 B(一票否决 cap)', () => { + // 收益 200% 但回撤 55%,不该得高分 + const r = gradePerformance({ + ...BOE_PERF, + max_drawdown: 0.55, + total_return: 2.0, + annual_return: 0.25, + calmar: 0.45, + }) + assert.ok(['B', 'C', 'D'].includes(r.grade), `回撤 55% 不应高于 B,实际 ${r.grade}`) + assert.ok(r.vetoes.some((v) => v.key === 'high_drawdown')) +}) + +test('插值函数:边界值取端点分数', () => { + assert.equal(interpolate(THRESHOLDS.max_drawdown.anchors, 0), 100) + assert.equal(interpolate(THRESHOLDS.max_drawdown.anchors, 0.7), 0) + assert.equal(interpolate(THRESHOLDS.max_drawdown.anchors, -1), 100) // 越界取端点 +}) + +test('插值函数:中间值线性插值', () => { + // 夏普 0.5 → 40, 0.8 → 55,0.65 应在中间附近 + const s = interpolate(THRESHOLDS.sharpe.anchors, 0.65) + assert.ok(s > 40 && s < 55, `夏普 0.65 应在 40-55 之间,实际 ${s}`) +}) + +test('寻优评级:4 维度降级版', () => { + const point: GridPointResult = { + params: {}, + total_return: 1.5, + sharpe: 1.5, + max_drawdown: 0.2, + total_trades: 50, + win_rate: 0.5, + profit_factor: 1.8, + } + const r = gradeGridPoint(point) + assert.equal(r.scenario, 'optimize') + assert.ok(['A', 'B', 'S'].includes(r.grade), `优质寻优点应得 A/B/S,实际 ${r.grade}(${r.score})`) + console.log('寻优点评级:', r.grade, r.score) +}) + +test('寻优评级:网格点交易太少 → 降权但评级照常', () => { + const point: GridPointResult = { + params: {}, + total_return: 0.5, + sharpe: 2.0, + max_drawdown: 0.1, + total_trades: 3, + win_rate: 0.7, + profit_factor: 2.5, + } + const r = gradeGridPoint(point) + assert.equal(r.insufficientSample, true) + // 高夏普 + 浅回撤,即使交易少也应该得高分(不再否决到 D) + assert.ok( + ['A', 'B', 'S'].includes(r.grade), + `优质寻优点不应因交易少被打到 D,实际 ${r.grade}`, + ) +}) + +// ── 组合评级:构造合成净值曲线验证 ───────────────────────────────────────── + +function makeSyntheticEquity( + startValue: number, + dailyReturns: number[], + startDate = '2022-01-03', +): EquityPoint[] { + const points: EquityPoint[] = [] + let value = startValue + let peak = startValue + let dt = new Date(startDate) + for (let i = 0; i < dailyReturns.length; i++) { + if (i > 0) value *= 1 + dailyReturns[i] + if (value > peak) peak = value + const drawdown_pct = peak > 0 ? (peak - value) / peak : 0 + points.push({ + datetime: dt.toISOString().slice(0, 10), + cash: 0, + position_value: value, + total: value, + drawdown: peak - value, + drawdown_pct, + }) + dt.setDate(dt.getDate() + 1) + } + return points +} + +test('组合评级:稳定上涨净值应得 A 或 S', () => { + // 252 个交易日,日均 0.05% → 年化约 13%,回撤极小 + const returns = Array.from({ length: 252 }, (_, i) => { + // 平稳上涨 + 小幅噪声,偶尔回调 + return i % 30 === 0 ? -0.008 : 0.0008 + (Math.sin(i) * 0.0003) + }) + const equity = makeSyntheticEquity(1000000, returns) + const m = computeCombinedMetrics(equity) + console.log('组合重算指标:', { + 年化: (m.annual_return * 100).toFixed(2) + '%', + 回撤: (m.max_drawdown * 100).toFixed(2) + '%', + 夏普: m.sharpe.toFixed(2), + 卡玛: m.calmar.toFixed(2), + }) + + const result: PortfolioResult = { + total_performance: { + total_return: m.total_return, + annual_return: m.annual_return, + total_stocks: 3, + total_cash: 1000000, + }, + individual_results: {}, + equity_allocation: {}, + combined_equity: equity, + } + const r = gradePortfolio(result) + console.log('稳定上涨组合评级:', r.grade, r.score) + assert.equal(r.scenario, 'portfolio') + // 这种平滑上涨应该有不错的评级 + assert.ok(['A', 'B', 'S'].includes(r.grade), `稳定组合应得 A/B/S,实际 ${r.grade}`) +}) + +test('组合评级:高波动深回撤净值 → 低档', () => { + // 模拟一个大幅震荡、最终亏损 + 深回撤的净值 + const returns = Array.from({ length: 252 }, (_, i) => { + if (i < 60) return -0.01 + Math.sin(i) * 0.015 // 前 60 日大跌 + if (i < 120) return 0.005 + Math.sin(i) * 0.012 + return -0.002 + Math.sin(i) * 0.02 // 后期大幅震荡 + }) + const equity = makeSyntheticEquity(1000000, returns) + const m = computeCombinedMetrics(equity) + console.log('波动组合重算:', { + 回撤: (m.max_drawdown * 100).toFixed(2) + '%', + 夏普: m.sharpe.toFixed(2), + 持续: m.max_dd_duration, + }) + + const result: PortfolioResult = { + total_performance: { + total_return: m.total_return, + annual_return: m.annual_return, + total_stocks: 3, + total_cash: 1000000, + }, + individual_results: {}, + equity_allocation: {}, + combined_equity: equity, + } + const r = gradePortfolio(result) + console.log('波动组合评级:', r.grade, r.score) + // 高波动 + 深回撤应得低评级 + assert.ok(['C', 'D', 'B'].includes(r.grade), `差组合应得 B/C/D,实际 ${r.grade}`) +}) + +test('combinedMetrics:单点净值返回全 0(兜底)', () => { + const m = computeCombinedMetrics([{ + datetime: '2022-01-03', + cash: 1000000, + position_value: 0, + total: 1000000, + drawdown: 0, + drawdown_pct: 0, + }]) + assert.equal(m.total_return, 0) + assert.equal(m.sharpe, 0) + assert.equal(m.n_points, 1) +}) diff --git a/web-ui/src/grading/combinedMetrics.ts b/web-ui/src/grading/combinedMetrics.ts new file mode 100644 index 0000000..879535b --- /dev/null +++ b/web-ui/src/grading/combinedMetrics.ts @@ -0,0 +1,180 @@ +/** + * 从组合净值曲线(EquityPoint[])重算绩效指标。 + * + * 背景:PortfolioResult.total_performance 只有 4 个字段(total_return/annual_return/ + * total_stocks/total_cash),不够评级。但 combined_equity 提供了完整的组合净值序列, + * 可以在前端重算夏普/索提诺/卡玛/回撤/波动率/回撤持续天数。 + * + * 注意:净值序列算不出胜率/利润因子/交易数(这些是逐笔成交统计),所以组合评级 + * 不用这两个维度,改用风险调整收益 + 索提诺补位。 + * + * 频率假设:combined_equity 的每个点对应一个交易日(日线), + * 夏普/波动率按 √252 年化。如果是周线/月线,年化因子需要调整。 + */ + +import type { EquityPoint } from '../types' + +/** 年化因子(按交易日)。 */ +const TRADING_DAYS_PER_YEAR = 252 + +/** 重算后的组合级指标(仅包含净值可推导的字段)。 */ +export interface CombinedMetrics { + /** 总收益率(小数,1.2643 = +126.43%) */ + total_return: number + /** 年化收益率(小数)。按 (1+total)^(年数) - 1 反推。 */ + annual_return: number + /** 最大回撤(小数,0.4165 = 41.65%) */ + max_drawdown: number + /** 回撤持续天数(峰值到恢复的最长交易日数,未恢复则到末日) */ + max_dd_duration: number + /** 夏普比率(年化,无风险利率按 0 处理) */ + sharpe: number + /** 索提诺比率(年化,仅用下行波动) */ + sortino: number + /** 卡玛比率 = 年化收益 / 最大回撤 */ + calmar: number + /** 波动率(年化,小数) */ + volatility: number + /** 净值点数 */ + n_points: number + /** 跨度(年数,用于年化) */ + years: number +} + +/** + * 从净值序列重算组合级绩效指标。 + * + * @param equity 净值序列,按时间升序。至少需要 2 个点才有统计意义。 + * @returns 重算结果;如果数据不足,相关字段返回 0(调用方应结合 n_points 判断)。 + */ +export function computeCombinedMetrics(equity: EquityPoint[]): CombinedMetrics { + const n = equity.length + + // 兜底:数据极少时返回全 0,避免除零或 NaN 污染 + if (n < 2) { + return { + total_return: 0, + annual_return: 0, + max_drawdown: 0, + max_dd_duration: 0, + sharpe: 0, + sortino: 0, + calmar: 0, + volatility: 0, + n_points: n, + years: 0, + } + } + + // 取每个点的 total(= cash + position_value),与 EquityChart 口径一致 + const totals = equity.map((e) => e.total) + const startValue = totals[0] + const endValue = totals[n - 1] + + // ── 总收益 & 年化 ──────────────────────────────────────────────────────── + const total_return = startValue > 0 ? endValue / startValue - 1 : 0 + // 跨度(年):按交易日数 / 252。若无日期信息,退化为 n / 252。 + const firstDt = Date.parse(equity[0].datetime) + const lastDt = Date.parse(equity[n - 1].datetime) + const years = + Number.isFinite(firstDt) && Number.isFinite(lastDt) && lastDt > firstDt + ? (lastDt - firstDt) / (365.25 * 24 * 3600 * 1000) + : n / TRADING_DAYS_PER_YEAR + const annual_return = years > 0 && endValue > 0 && startValue > 0 + ? Math.pow(endValue / startValue, 1 / years) - 1 + : 0 + + // ── 逐期收益率(用于夏普/波动率) ──────────────────────────────────────── + const periodReturns: number[] = [] + for (let i = 1; i < n; i++) { + if (totals[i - 1] > 0) { + periodReturns.push(totals[i] / totals[i - 1] - 1) + } + } + + const meanPeriod = mean(periodReturns) + const stdPeriod = stddev(periodReturns, meanPeriod) + + // 年化波动率 = 日波动率 × √252 + const volatility = stdPeriod * Math.sqrt(TRADING_DAYS_PER_YEAR) + // 夏普 = 年化超额收益 / 年化波动(无风险利率按 0) + // 等价于 meanPeriod / stdPeriod × √252 + const sharpe = stdPeriod > 0 ? (meanPeriod / stdPeriod) * Math.sqrt(TRADING_DAYS_PER_YEAR) : 0 + + // ── 索提诺(仅用下行波动) ─────────────────────────────────────────────── + const downsideReturns = periodReturns.filter((r) => r < 0) + const downsideStd = downsideReturns.length > 0 + ? Math.sqrt(downsideReturns.reduce((s, r) => s + r * r, 0) / downsideReturns.length) + : 0 + const sortino = downsideStd > 0 + ? (meanPeriod / downsideStd) * Math.sqrt(TRADING_DAYS_PER_YEAR) + : 0 + + // ── 最大回撤 & 持续天数 ───────────────────────────────────────────────── + // 优先用后端已算好的 drawdown_pct(与图表一致),反推峰值&持续更准。 + // 若后端字段缺失,再退回从 totals 反推。 + let maxDrawdown = 0 + let maxDdDuration = 0 + + if (equity[0].drawdown_pct !== undefined) { + let curPeakIdx = 0 + for (let i = 0; i < n; i++) { + const dd = equity[i].drawdown_pct + if (dd > maxDrawdown) { + maxDrawdown = dd + maxDdDuration = i - curPeakIdx + } + // 触及新峰值:重置当前峰值点 + // 注意 drawdown_pct == 0 表示创新高 + if (dd === 0) curPeakIdx = i + } + } else { + // 退化路径:从 totals 反推 + let runningPeak = totals[0] + let curPeakIdx = 0 + for (let i = 0; i < n; i++) { + if (totals[i] > runningPeak) { + runningPeak = totals[i] + curPeakIdx = i + } + if (runningPeak > 0) { + const dd = runningPeak - totals[i] + const ddPct = dd / runningPeak + if (ddPct > maxDrawdown) { + maxDrawdown = ddPct + maxDdDuration = i - curPeakIdx + } + } + } + } + + // ── 卡玛比率 = 年化收益 / 最大回撤 ─────────────────────────────────────── + const calmar = maxDrawdown > 0 ? annual_return / maxDrawdown : (annual_return > 0 ? Infinity : 0) + + return { + total_return, + annual_return, + max_drawdown: maxDrawdown, + max_dd_duration: maxDdDuration, + sharpe, + sortino, + // 卡玛无穷大时(无回撤)封顶为一个大数,避免评级引擎 NaN + calmar: Number.isFinite(calmar) ? calmar : 999, + volatility, + n_points: n, + years, + } +} + +/** 求均值。空数组返回 0。 */ +function mean(xs: number[]): number { + if (xs.length === 0) return 0 + return xs.reduce((s, x) => s + x, 0) / xs.length +} + +/** 求样本标准差(n-1 分母)。空/单点返回 0。 */ +function stddev(xs: number[], m: number): number { + if (xs.length < 2) return 0 + const sumSq = xs.reduce((s, x) => s + (x - m) * (x - m), 0) + return Math.sqrt(sumSq / (xs.length - 1)) +} diff --git a/web-ui/src/grading/engine.ts b/web-ui/src/grading/engine.ts new file mode 100644 index 0000000..17223fa --- /dev/null +++ b/web-ui/src/grading/engine.ts @@ -0,0 +1,112 @@ +/** + * 评分引擎核心:插值、加权、否决。 + * + * 这一层是纯函数 + 零业务依赖,所有场景(单标的/组合/寻优)共用。 + */ + +import { GRADE_THRESHOLDS, type DimensionScore, type Grade, type GradeResult, type VetoHit } from './types' +import { THRESHOLDS, type DimensionKey } from './thresholds' + +/** + * 按锚点列表做线性插值,返回 0–100 的分数。 + * + * 锚点按 threshold 升序排列。值越界时取端点(不再外推)。 + * 锚点的 score 走向决定了「越大越好」还是「越小越好」——引擎不关心方向。 + * + * @example + * interpolate(THRESHOLDS.max_drawdown.anchors, 0.4165) // ≈ 30(回撤深) + * interpolate(THRESHOLDS.sharpe.anchors, 0.529) // ≈ 42 + */ +export function interpolate(anchors: readonly { threshold: number; score: number }[], value: number): number { + if (!Number.isFinite(value)) return 0 + if (anchors.length === 0) return 0 + + // 值小于最小锚点 → 取最低分 + if (value <= anchors[0].threshold) return anchors[0].score + // 值大于最大锚点 → 取最高分 + if (value >= anchors[anchors.length - 1].threshold) return anchors[anchors.length - 1].score + + // 找到 value 落在哪两个锚点之间,线性插值 + for (let i = 0; i < anchors.length - 1; i++) { + const a = anchors[i] + const b = anchors[i + 1] + if (value >= a.threshold && value <= b.threshold) { + if (a.threshold === b.threshold) return a.score + const ratio = (value - a.threshold) / (b.threshold - a.threshold) + return a.score + ratio * (b.score - a.score) + } + } + // 理论上不会走到这里 + return anchors[anchors.length - 1].score +} + +/** 构造一个维度的评分对象。 */ +export function scoreDimension(key: DimensionKey, raw: number, weight: number): DimensionScore { + const cfg = THRESHOLDS[key] + return { + key, + label: cfg.label, + raw, + score: interpolate(cfg.anchors, raw), + weight, + } +} + +/** 加权求和得到总分(0–100)。权重会在调用方归一化。 */ +export function weightedTotal(dimensions: DimensionScore[]): number { + const totalWeight = dimensions.reduce((s, d) => s + d.weight, 0) + if (totalWeight <= 0) return 0 + return dimensions.reduce((s, d) => s + d.score * d.weight, 0) / totalWeight +} + +/** 把分数映射到档位(不考虑否决)。 */ +export function scoreToGrade(score: number): Grade { + for (const { grade, minScore } of GRADE_THRESHOLDS) { + if (score >= minScore) return grade + } + return 'D' +} + +/** 档位排序值,便于比较「S > A > B > C > D」。 */ +const GRADE_ORDER: Grade[] = ['D', 'C', 'B', 'A', 'S'] +export function gradeRank(g: Grade): number { + return GRADE_ORDER.indexOf(g) +} + +/** 取两个档位中「更差」的那个(用于一票否决 cap)。 */ +export function worseGrade(a: Grade, b: Grade): Grade { + return gradeRank(a) <= gradeRank(b) ? a : b +} + +/** + * 根据原始分数、维度明细和否决规则,组装最终的 GradeResult。 + * + * @param scenario 评级场景 + * @param dimensions 各维度明细(权重已设定) + * @param vetoes 触发的否决规则(按优先级,引擎不重复计算) + * @param flags { insufficientSample, isLosing } 特殊标记 + */ +export function buildResult( + scenario: GradeResult['scenario'], + dimensions: DimensionScore[], + vetoes: VetoHit[], + flags: { insufficientSample: boolean; isLosing: boolean }, +): GradeResult { + const rawScore = weightedTotal(dimensions) + let grade = scoreToGrade(rawScore) + + // 应用所有否决规则:取最严格的 cap + for (const v of vetoes) { + grade = worseGrade(grade, v.cap) + } + + return { + grade, + score: Math.round(rawScore * 10) / 10, // 保留 1 位小数 + dimensions, + vetoes, + insufficientSample: flags.insufficientSample, + isLosing: flags.isLosing, + scenario, + } +} diff --git a/web-ui/src/grading/index.ts b/web-ui/src/grading/index.ts new file mode 100644 index 0000000..a276c31 --- /dev/null +++ b/web-ui/src/grading/index.ts @@ -0,0 +1,322 @@ +/** + * 评级系统统一入口。 + * + * 三个场景函数: + * gradePerformance(perf) — 单标的回测(完整 Performance,6 维度) + * gradePortfolio(result) — 组合回测(净值重算,5 维度) + * gradeGridPoint(point, totalTrades)— 参数寻优(4 字段子集,4 维度) + * + * 评级目的:让普通人一眼判断是否适合「经常参与」。 + * 低评级 = 长期套牢风险高,不建议参与(哪怕近期收益率好看)。 + * + * @see docs/superpowers/plans 评级系统设计文档 + */ + +import type { BacktestResult, EquityPoint, GridPointResult, Performance, PortfolioResult } from '../types' +import { buildResult, scoreDimension } from './engine' +import { computeCombinedMetrics } from './combinedMetrics' +import type { DimensionScore, GradeResult, VetoHit } from './types' + +// ════════════════════════════════════════════════════════════════════════════ +// 一票否决规则(所有场景共用) +// ════════════════════════════════════════════════════════════════════════════ + +export interface VetoContext { + /** 利润因子(< 1 表示系统实际亏钱) */ + profitFactor?: number | null + /** 总交易笔数(< 10 视为样本不足) */ + totalTrades?: number + /** 最大回撤(小数,> 0.6 几乎无法回本) */ + maxDrawdown?: number | null + /** 胜率(小数) */ + winRate?: number | null +} + +/** + * 应用一票否决规则。返回触发的否决列表 + 特殊标记。 + * + * 否决语义: + * - 「直接 D」类:触发后无论原始分多少,最终就是 D。 + * - 「最高 X」类:触发后最终档位不超过 X(可能仍然是 D/C,但不会更高)。 + */ +export function applyVetoes(ctx: VetoContext): { + vetoes: VetoHit[] + insufficientSample: boolean + isLosing: boolean +} { + const vetoes: VetoHit[] = [] + let insufficientSample = false + let isLosing = false + + // ── 「直接 D」类 ────────────────────────────────────────────────────────── + + // 系统亏损:利润因子 < 1,实际在亏钱 + if (ctx.profitFactor !== undefined && ctx.profitFactor !== null && ctx.profitFactor < 1) { + vetoes.push({ + key: 'losing_system', + reason: `利润因子 ${ctx.profitFactor.toFixed(2)} < 1,系统实际亏损`, + cap: 'D', + }) + isLosing = true + } + + // 样本不足(交易笔数 < 10):不再直接否决到 D。 + // 长线策略天然交易少(如 6 年 6 笔),但净值曲线(夏普/回撤/卡玛)依然可信—— + // 只有 win_rate / profit_factor 这两个依赖逐笔成交的维度不可信。 + // 处理方式改为:在 gradePerformance / gradeGridPoint 里把这两个维度权重降到 0, + // 重分配给净值类维度;同时通过 insufficientSample 标记让前端展示提示。 + if (ctx.totalTrades !== undefined && ctx.totalTrades < 10) { + insufficientSample = true + } + + // 深度套牢:最大回撤 > 60% + if (ctx.maxDrawdown !== undefined && ctx.maxDrawdown !== null && ctx.maxDrawdown > 0.6) { + vetoes.push({ + key: 'deep_drawdown', + reason: `最大回撤 ${(ctx.maxDrawdown * 100).toFixed(1)}% > 60%,深度套牢几乎无法回本`, + cap: 'D', + }) + } + + // ── 「最高 X」类(需在足够样本下才生效,避免噪音误杀) ───────────────────── + // 与 insufficientSample 同阈值:< 10 笔视为样本不足,>= 10 笔即让低胜率否决生效。 + // 之前的 30 笔阈值留出 10–29 笔的中间地带(既不算样本不足也不触发否决),逻辑有漏洞。 + const enoughTrades = ctx.totalTrades === undefined || ctx.totalTrades >= 10 + + // 胜率极低:win_rate < 25% 且样本充足 → 直接 D + if ( + enoughTrades && + ctx.winRate !== undefined && + ctx.winRate !== null && + ctx.winRate < 0.25 + ) { + vetoes.push({ + key: 'very_low_winrate', + reason: `胜率 ${(ctx.winRate * 100).toFixed(1)}% < 25% 且样本充足,几乎一直亏`, + cap: 'D', + }) + } + + // 高回撤:max_drawdown > 50% → 最高 B + if (ctx.maxDrawdown !== undefined && ctx.maxDrawdown !== null && ctx.maxDrawdown > 0.5) { + vetoes.push({ + key: 'high_drawdown', + reason: `最大回撤 ${(ctx.maxDrawdown * 100).toFixed(1)}% > 50%,套牢难回本`, + cap: 'B', + }) + } + + // 低胜率:win_rate < 30% 且样本充足 → 最高 C + if ( + enoughTrades && + ctx.winRate !== undefined && + ctx.winRate !== null && + ctx.winRate < 0.3 && + ctx.winRate >= 0.25 // 25% 以下已被上一条否决到 D + ) { + vetoes.push({ + key: 'low_winrate', + reason: `胜率 ${(ctx.winRate * 100).toFixed(1)}% < 30% 且样本充足,普通人拿不住`, + cap: 'C', + }) + } + + // 微利:利润因子 < 1.2 → 最高 B(系统勉强盈亏平衡) + if ( + ctx.profitFactor !== undefined && + ctx.profitFactor !== null && + ctx.profitFactor >= 1 && + ctx.profitFactor < 1.2 + ) { + vetoes.push({ + key: 'thin_edge', + reason: `利润因子 ${ctx.profitFactor.toFixed(2)} 接近 1,仅勉强盈亏平衡`, + cap: 'B', + }) + } + + return { vetoes, insufficientSample, isLosing } +} + +/** + * 当交易样本不足时,把依赖逐笔成交的维度(win_rate / profit_factor)权重降为 0, + * 按比例重分配给净值类维度(夏普/卡玛/回撤/波动率)。 + * + * 设计理由:交易笔数少只意味着「胜率/利润因子是噪音」,但夏普/卡玛/回撤 + * 是从净值曲线(通常几百到几千个点)算出来的,依然高度可信。 + * 把不可信维度降权而非整个评级否决,是统计上更合理的处理。 + * + * @param dimensions 当前维度列表(会被原地修改 weight) + * @param unreliableKeys 需要降权的维度 key(默认 win_rate / profit_factor) + * @returns 是否实际发生了降权 + */ +export function downweightUnreliableDimensions( + dimensions: DimensionScore[], + unreliableKeys: string[] = ['win_rate', 'profit_factor'], +): boolean { + // 收集需要降权的维度及其原权重总和 + const toDownweight = dimensions.filter((d) => unreliableKeys.includes(d.key)) + if (toDownweight.length === 0) return false + + const releasedWeight = toDownweight.reduce((s, d) => s + d.weight, 0) + if (releasedWeight <= 0) return false + + // 把权重清零 + for (const d of toDownweight) d.weight = 0 + + // 剩余可承接权重的维度(weight > 0 的) + const receivers = dimensions.filter((d) => d.weight > 0) + if (receivers.length === 0) return false + + const receiverTotal = receivers.reduce((s, d) => s + d.weight, 0) + if (receiverTotal <= 0) return false + + // 按现有权重比例分配释放出来的权重 + for (const d of receivers) { + d.weight += releasedWeight * (d.weight / receiverTotal) + } + + return true +} + +// ════════════════════════════════════════════════════════════════════════════ +// 场景 1:单标的回测评级(完整 Performance,6 维度) +// ════════════════════════════════════════════════════════════════════════════ + +/** + * 评级单标的回测结果。 + * + * 6 维度:卡玛(18%) + 最大回撤(17%) + 胜率(17%) + 利润因子(18%) + + * 夏普(15%) + 波动率(15%) + * + * 注意:total_return **不直接计入评分**(只通过卡玛/夏普间接体现)。 + * 这是产品诉求——「哪怕近期收益率高,长期风险大也该低评」。 + * + * 不再用 max_dd_duration 维度:后端该字段口径是「峰值跌到最深点」的 bar 数 + * (通常很短,京东方只有 1 天),与「套牢多久才回本」的产品直觉不符, + * 信号弱且容易被高波动策略误判为优质。波动率已足以反映持有颠簸程度。 + */ +export function gradePerformance(perf: Performance): GradeResult { + const dimensions: DimensionScore[] = [ + scoreDimension('calmar', perf.calmar, 0.18), + scoreDimension('max_drawdown', perf.max_drawdown, 0.17), + scoreDimension('win_rate', perf.win_rate, 0.17), + scoreDimension('profit_factor', perf.profit_factor, 0.18), + scoreDimension('sharpe', perf.sharpe, 0.15), + scoreDimension('volatility', perf.volatility, 0.15), + ] + + const { vetoes, insufficientSample, isLosing } = applyVetoes({ + profitFactor: perf.profit_factor, + totalTrades: perf.total_trades, + maxDrawdown: perf.max_drawdown, + winRate: perf.win_rate, + }) + + // 交易样本不足时:把 win_rate / profit_factor 权重降到 0, + // 重分配给净值类维度。降权后这些维度的单项分仍展示(信息透明), + // 但不再影响总分。详见 downweightUnreliableDimensions 注释。 + if (insufficientSample) { + downweightUnreliableDimensions(dimensions) + } + + return buildResult('single', dimensions, vetoes, { insufficientSample, isLosing }) +} + +// ════════════════════════════════════════════════════════════════════════════ +// 场景 2:组合回测评级(净值重算,5 维度) +// ════════════════════════════════════════════════════════════════════════════ + +/** + * 评级组合回测结果。 + * + * 组合级净值算不出胜率/利润因子,所以用 5 个净值可推导的维度: + * 卡玛(25%) + 最大回撤(22%) + 夏普(22%) + 索提诺(15%) + 波动率(16%) + * + * 否决规则中只有「深回撤」类能生效(无交易笔数/利润因子)。 + */ +export function gradePortfolio(result: PortfolioResult): GradeResult { + const equity: EquityPoint[] = result.combined_equity + const m = computeCombinedMetrics(equity) + + const dimensions: DimensionScore[] = [ + scoreDimension('calmar', m.calmar, 0.25), + scoreDimension('max_drawdown', m.max_drawdown, 0.22), + scoreDimension('sharpe', m.sharpe, 0.22), + scoreDimension('sortino', m.sortino, 0.15), + scoreDimension('volatility', m.volatility, 0.16), + ] + + // 组合级无逐笔交易统计,无法用 totalTrades 判断样本。改用净值点数: + // 至少 60 个交易日(≈3 个月)才视为统计有效。语义等价的 totalTrades 占位值: + // n_points >= 60 → 用 30(>= enoughTrades 阈值,所有否决规则可生效) + // n_points < 60 → 用 5(触发 insufficientSample 标记) + const PORTFOLIO_MIN_POINTS = 60 + const sampleProxyTrades = m.n_points >= PORTFOLIO_MIN_POINTS ? 30 : 5 + + const { vetoes, insufficientSample, isLosing } = applyVetoes({ + maxDrawdown: m.max_drawdown, + totalTrades: sampleProxyTrades, + }) + + return buildResult('portfolio', dimensions, vetoes, { insufficientSample, isLosing }) +} + +// ════════════════════════════════════════════════════════════════════════════ +// 场景 3:参数寻优评级(4 字段子集,4 维度降级版) +// ════════════════════════════════════════════════════════════════════════════ + +/** + * 评级单个寻优网格点(GridPointResult)。 + * + * 寻优结果只有 6 个字段(total_return/sharpe/max_drawdown/total_trades/ + * win_rate/profit_factor),缺卡玛/波动率/年化/avg_win/loss。 + * + * 降级到 4 维度(权重重分配): + * 夏普(30%) + 最大回撤(28%) + 胜率(22%) + 利润因子(20%) + * + * @param point 网格点结果 + * @param totalTradesOverride 可选,覆盖 point.total_trades(用于排名表统一基准) + */ +export function gradeGridPoint( + point: GridPointResult, + totalTradesOverride?: number, +): GradeResult { + const totalTrades = totalTradesOverride ?? point.total_trades + + const dimensions: DimensionScore[] = [ + scoreDimension('sharpe', point.sharpe ?? 0, 0.3), + scoreDimension('max_drawdown', point.max_drawdown ?? 1, 0.28), + scoreDimension('win_rate', point.win_rate ?? 0, 0.22), + scoreDimension('profit_factor', point.profit_factor ?? 0, 0.2), + ] + + const { vetoes, insufficientSample, isLosing } = applyVetoes({ + profitFactor: point.profit_factor, + totalTrades, + maxDrawdown: point.max_drawdown, + winRate: point.win_rate, + }) + + // 交易样本不足时同样降权 win_rate / profit_factor,重分配给夏普/回撤。 + if (insufficientSample) { + downweightUnreliableDimensions(dimensions) + } + + return buildResult('optimize', dimensions, vetoes, { insufficientSample, isLosing }) +} + +/** + * 从 BacktestResult 评级的便捷封装(自动识别单标的/组合)。 + * 主要给 BacktestView / OptimizeView 跳转后回测结果用。 + */ +export function gradeBacktestResult(result: BacktestResult): GradeResult { + return gradePerformance(result.performance) +} + +// ── 重新导出常用类型和工具,便于调用方一处 import ─────────────────────────── +export { GRADE_META, GRADE_THRESHOLDS } from './types' +export type { Grade, GradeResult, DimensionScore, VetoHit, GradeMeta } from './types' +export { worseGrade, scoreToGrade } from './engine' +export { computeCombinedMetrics } from './combinedMetrics' +export type { CombinedMetrics } from './combinedMetrics' diff --git a/web-ui/src/grading/thresholds.ts b/web-ui/src/grading/thresholds.ts new file mode 100644 index 0000000..f6f4ce8 --- /dev/null +++ b/web-ui/src/grading/thresholds.ts @@ -0,0 +1,170 @@ +/** + * 各评分维度的阈值映射表。 + * + * 每个维度用一组 (阈值, 分数) 锚点描述「值→分数」的对应关系。 + * 评分时做线性插值:值落在两个锚点之间时,按比例计算分数。 + * + * 设计原则(对应产品诉求): + * - 收益类指标(夏普/卡玛/利润因子/胜率):越高越好。 + * - 风险类指标(最大回撤/波动率/回撤持续):越低越好,但仍用「值↑ → 分数↓」统一表达。 + * 即:所有锚点都按「指标值从好到差」排列,分数从高到低。 + * + * 阈值集中在此文件,方便根据真实回测分布微调,无需动评分引擎。 + */ + +/** + * 锚点:一个 (指标原始值, 对应分数) 对。 + * - 对「越大越好」的指标(如夏普),threshold 升序排列,score 也升序。 + * - 对「越小越好」的指标(如回撤),threshold 升序,score 降序。 + * 引擎统一按「threshold 升序」处理,不关心方向,靠 score 走向体现好坏。 + */ +export interface Anchor { + threshold: number + score: number +} + +/** 单个维度的配置:标签 + 锚点列表。 */ +export interface DimensionConfig { + label: string + anchors: Anchor[] +} + +/** + * 「越大越好」维度的辅助构造器:传入 (最差阈值, 最差分) → (最好阈值, 最好分) 的若干档。 + * 这里直接返回锚点数组,调用方提供完整列表即可。 + */ +export const THRESHOLDS = { + // ── 风险调整收益类(越大越好)────────────────────────────────────────────── + + /** 卡玛比率 = 年化收益 / 最大回撤。直接反映「套牢回本难度」。 */ + calmar: { + label: '卡玛比率', + anchors: [ + { threshold: 0.0, score: 0 }, + { threshold: 0.3, score: 20 }, + { threshold: 0.5, score: 35 }, + { threshold: 0.8, score: 50 }, + { threshold: 1.0, score: 65 }, + { threshold: 1.5, score: 80 }, + { threshold: 2.0, score: 90 }, + { threshold: 3.0, score: 100 }, + ], + }, + + /** 夏普比率。A股长期 >1 算不错,>2 优秀。 */ + sharpe: { + label: '夏普比率', + anchors: [ + { threshold: 0.0, score: 10 }, + { threshold: 0.3, score: 25 }, + { threshold: 0.5, score: 40 }, + { threshold: 0.8, score: 55 }, + { threshold: 1.0, score: 68 }, + { threshold: 1.5, score: 82 }, + { threshold: 2.0, score: 92 }, + { threshold: 3.0, score: 100 }, + ], + }, + + /** 索提诺比率(仅用下行波动)。组合评级用,阈值比夏普略宽松。 */ + sortino: { + label: '索提诺比率', + anchors: [ + { threshold: 0.0, score: 10 }, + { threshold: 0.5, score: 30 }, + { threshold: 1.0, score: 50 }, + { threshold: 1.5, score: 65 }, + { threshold: 2.0, score: 78 }, + { threshold: 2.5, score: 88 }, + { threshold: 4.0, score: 100 }, + ], + }, + + // ── 风险类(越小越好:threshold 升序,score 降序)───────────────────────── + + /** 最大回撤(小数,0.4165 = 41.65%)。深回撤 = 套牢难回本。 */ + max_drawdown: { + label: '最大回撤', + anchors: [ + { threshold: 0.0, score: 100 }, + { threshold: 0.1, score: 88 }, + { threshold: 0.15, score: 78 }, + { threshold: 0.2, score: 68 }, + { threshold: 0.25, score: 58 }, + { threshold: 0.3, score: 48 }, + { threshold: 0.4, score: 30 }, + { threshold: 0.5, score: 15 }, + { threshold: 0.6, score: 0 }, + ], + }, + + /** 波动率(年化,小数)。持有过程的颠簸程度。 */ + volatility: { + label: '波动率', + anchors: [ + { threshold: 0.0, score: 100 }, + { threshold: 0.1, score: 85 }, + { threshold: 0.15, score: 75 }, + { threshold: 0.2, score: 62 }, + { threshold: 0.25, score: 50 }, + { threshold: 0.3, score: 38 }, + { threshold: 0.4, score: 22 }, + { threshold: 0.6, score: 0 }, + ], + }, + + /** 回撤持续天数。长期套牢的核心指标。 */ + max_dd_duration: { + label: '回撤持续', + anchors: [ + { threshold: 0, score: 100 }, + { threshold: 30, score: 80 }, + { threshold: 90, score: 62 }, + { threshold: 180, score: 45 }, + { threshold: 365, score: 28 }, + { threshold: 730, score: 10 }, + { threshold: 1095, score: 0 }, + ], + }, + + // ── 交易质量类 ──────────────────────────────────────────────────────────── + + /** 胜率(小数,0.3556 = 35.56%)。普通人拿不住低胜率品种。 */ + win_rate: { + label: '胜率', + anchors: [ + { threshold: 0.0, score: 0 }, + { threshold: 0.25, score: 12 }, + { threshold: 0.3, score: 22 }, + { threshold: 0.35, score: 32 }, + { threshold: 0.4, score: 45 }, + { threshold: 0.45, score: 58 }, + { threshold: 0.5, score: 70 }, + { threshold: 0.55, score: 82 }, + { threshold: 0.6, score: 92 }, + { threshold: 0.7, score: 100 }, + ], + }, + + /** + * 利润因子(profit_factor)= 总盈利 / 总亏损的绝对值。 + * < 1 表示系统实际亏钱;1.0–1.2 勉强盈亏平衡;> 2 算健康。 + */ + profit_factor: { + label: '利润因子', + anchors: [ + { threshold: 0.0, score: 0 }, + { threshold: 0.8, score: 10 }, + { threshold: 1.0, score: 25 }, + { threshold: 1.2, score: 42 }, + { threshold: 1.5, score: 60 }, + { threshold: 1.8, score: 75 }, + { threshold: 2.0, score: 84 }, + { threshold: 2.5, score: 92 }, + { threshold: 3.0, score: 100 }, + ], + }, +} as const + +/** 便捷类型:所有维度配置的映射。 */ +export type DimensionKey = keyof typeof THRESHOLDS diff --git a/web-ui/src/grading/types.ts b/web-ui/src/grading/types.ts new file mode 100644 index 0000000..21ae307 --- /dev/null +++ b/web-ui/src/grading/types.ts @@ -0,0 +1,112 @@ +/** + * 评级系统类型定义。 + * + * 评级目的:让普通人一眼判断这个品种/策略是否适合「经常参与投资」。 + * - 不只是看收益,更要看「套牢后能不能回本」「大部分时间是不是在亏」。 + * - 低评级 = 不建议普通人参与,哪怕近期收益率高,长期套牢风险也大。 + * + * 5 档:S(优秀)/ A(适合)/ B(谨慎)/ C(不建议经常参与)/ D(别碰)。 + */ + +/** 评级档位。D 包含「系统亏损」和「样本不足」两类特殊情况。 */ +export type Grade = 'S' | 'A' | 'B' | 'C' | 'D' + +/** 单个评分维度(如「最大回撤」「胜率」)。 */ +export interface DimensionScore { + /** 维度标识,如 'max_drawdown' / 'win_rate' */ + key: string + /** 中文名,如「最大回撤」 */ + label: string + /** 该维度原始值 */ + raw: number + /** 该维度在 0–100 的单项分(越接近 100 越好) */ + score: number + /** 该维度在总分中的权重(0–1,所有维度权重和应为 1) */ + weight: number +} + +/** 一票否决触发记录。 */ +export interface VetoHit { + /** 否决规则标识 */ + key: string + /** 触发原因(中文,可直接展示) */ + reason: string + /** 否决后的结果档位 */ + cap: Grade +} + +/** 评级结果。所有场景的评级函数都返回这个结构。 */ +export interface GradeResult { + /** 最终档位 */ + grade: Grade + /** 总分(0–100,否决后为否决后的分数) */ + score: number + /** 各维度明细,用于展示「为什么是这个评级」 */ + dimensions: DimensionScore[] + /** 触发的一票否决规则(空数组表示未触发) */ + vetoes: VetoHit[] + /** + * 是否为「交易样本不足」(笔数 < 10)。 + * 触发后:win_rate / profit_factor 维度权重降为 0,重分配给净值类维度。 + * 评级照常给出(不再否决到 D),但前端会展示「⚠ 交易样本有限」提示。 + */ + insufficientSample: boolean + /** 是否为「系统亏损」(profit_factor < 1,实际在亏钱) */ + isLosing: boolean + /** 评级使用的场景,便于前端展示差异化文案 */ + scenario: 'single' | 'portfolio' | 'optimize' +} + +/** 档位到展示元数据的映射(颜色、文案)。供 GradeBadge 使用。 */ +export interface GradeMeta { + grade: Grade + /** 主色 CSS 变量名(如 'var(--warn)')或直接颜色值 */ + color: string + /** 一句话含义 */ + hint: string + /** 是否适合普通人参与 */ + recommend: 'yes' | 'caution' | 'no' +} + +/** 档位元数据表。 */ +export const GRADE_META: Record = { + S: { + grade: 'S', + color: '#e0b341', // 金 + hint: '长期持有体验优秀,回撤浅、胜率稳', + recommend: 'yes', + }, + A: { + grade: 'A', + color: 'var(--down)', // 绿(A 股惯例绿即好) + hint: '适合经常参与,套牢后能较快回本', + recommend: 'yes', + }, + B: { + grade: 'B', + color: 'var(--accent)', // 蓝 + hint: '可参与但需择时,套牢回本有压力', + recommend: 'caution', + }, + C: { + grade: 'C', + color: 'var(--warn)', // 橙 + hint: '风险偏高,长期套牢风险大', + recommend: 'caution', + }, + D: { + grade: 'D', + color: 'var(--up)', // 红(A 股惯例红即危险) + hint: '持有体验差或系统亏损,不建议参与', + recommend: 'no', + }, +} + +/** 档位分数阈值(左闭右开:score >= 阈值即落入该档)。 */ +export const GRADE_THRESHOLDS: { grade: Grade; minScore: number }[] = [ + { grade: 'S', minScore: 88 }, + { grade: 'A', minScore: 73 }, + { grade: 'B', minScore: 58 }, + { grade: 'C', minScore: 43 }, + { grade: 'D', minScore: 0 }, +] diff --git a/web-ui/src/views/BacktestView.vue b/web-ui/src/views/BacktestView.vue index 89632cc..0d7d0d3 100644 --- a/web-ui/src/views/BacktestView.vue +++ b/web-ui/src/views/BacktestView.vue @@ -7,12 +7,14 @@ import { computed, nextTick, onMounted, ref } from 'vue' import { useRoute } from 'vue-router' import EquityChart from '../components/EquityChart.vue' +import GradeDetails from '../components/GradeDetails.vue' import KlineChart from '../components/KlineChart.vue' import MetricTable from '../components/MetricTable.vue' import StrategyPicker from '../components/StrategyPicker.vue' import SymbolPicker from '../components/SymbolPicker.vue' import TradeTable from '../components/TradeTable.vue' import { formatError, saveStrategy } from '../api' +import { gradePerformance } from '../grading' import type { Category, ExecutionMode } from '../types' import { useBacktestStore } from '../stores/backtest' @@ -111,6 +113,13 @@ const strategyLabel = computed( () => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value, ) +// 评级:基于完整 Performance,6 维度评分 + 一票否决。 +// total_return 不直接计入评分(只通过卡玛/夏普间接体现), +// 体现「哪怕近期收益率高,长期风险大也该低评」的产品诉求。 +const grade = computed(() => + store.result ? gradePerformance(store.result.performance) : null, +) + // 当前股票完整代码(市场:6位),从 SymbolPicker 同步来的 code 是纯数字, // 需要带上市场前缀。复用 SymbolPicker 内部已经算好的前缀更稳妥——这里简单按 // 交易所规则推断(6 位代码:6/9 开头 SH,其余 SZ;8/4 开头 BJ)。 @@ -260,6 +269,11 @@ async function onSave() { +
+

评级

+ +
+

绩效指标

diff --git a/web-ui/src/views/OptimizeView.vue b/web-ui/src/views/OptimizeView.vue index 9c75078..e6e5b9f 100644 --- a/web-ui/src/views/OptimizeView.vue +++ b/web-ui/src/views/OptimizeView.vue @@ -5,10 +5,13 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' +import GradeBadge from '../components/GradeBadge.vue' import OptimizeHeatmap from '../components/OptimizeHeatmap.vue' import OptimizeResultTable from '../components/OptimizeResultTable.vue' import ParamGridPicker from '../components/ParamGridPicker.vue' import SymbolPicker from '../components/SymbolPicker.vue' +import { gradeGridPoint } from '../grading' +import type { GradeResult } from '../grading' import type { Category, ExecutionMode } from '../types' import { useBacktestStore } from '../stores/backtest' @@ -125,6 +128,20 @@ function pct(v: number | null | undefined): string { function num(v: number | null | undefined, d = 2): string { return v !== null && v !== undefined && Number.isFinite(v) ? v.toFixed(d) : '-' } + +// 寻优评级:4 维度降级版(夏普30%/回撤28%/胜率22%/利润因子20%)。 +// 各网格点交易数独立判断「样本不足」否决。 +const bestGrade = computed(() => + store.optimizeResult?.best ? gradeGridPoint(store.optimizeResult.best) : null, +) +// 一键寻优全局最佳评级 +const bestAllGrade = computed(() => + store.optimizeAllResult?.best ? gradeGridPoint(store.optimizeAllResult.best) : null, +) +// 一键寻优排名表每行的评级(按需计算,避免大表全量计算) +const rankingGrades = computed(() => + (store.optimizeAllResult?.ranking ?? []).map((r) => gradeGridPoint(r)), +)