release: v1.17.14 — Web UI 数据评级系统(S/A/B/C/D 五档,三入口覆盖)

纯前端 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 等场景
This commit is contained in:
Justin Gu
2026-07-05 01:21:18 +08:00
parent 66c49d1a8d
commit cf843cebad
16 changed files with 1667 additions and 2 deletions
+22
View File
@@ -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 影响。
+2
View File
@@ -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 AgentClaude Code、OpenClaw、Hermes 直接吃。`easy-tdx serve` 一键起 REST 服务,浏览器打开就是交互式 API 文档。
**你不懂 TCP 协议?不用。**
+1 -1
View File
@@ -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"
+50
View File
@@ -0,0 +1,50 @@
/**
* 把评级测试入口打包成单文件 JS,再用 node:test 跑。
*
* 项目未引入 vitest/jest,临时用 rolldownvite 自带依赖)打包,
* 避免 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)
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
// 评级徽章:圆形大字母(S/A/B/C/D+ 颜色 + tooltip。
// 用在 BacktestView/PortfolioView/OptimizeView 的报告顶部,让人一眼看到「适不适合参与」。
//
// 设计:徽章本身用档位主色,悬停展开评分明细 + 否决原因。
// 不适合展示太长的文本——详细信息交给同页的 GradeDetails 组件。
import { computed } from 'vue'
import { GRADE_META, type GradeResult } from '../grading'
const props = withDefaults(
defineProps<{
/** 评级结果 */
result: GradeResult
/** 尺寸:sm 用于表格内紧凑展示,md/lg 用于报告顶部 */
size?: 'sm' | 'md' | 'lg'
/** 是否展示分数(如 "B 65.3")。表格内通常关闭。 */
showScore?: boolean
}>(),
{ size: 'md', showScore: true },
)
const meta = computed(() => GRADE_META[props.result.grade])
// tooltip 文案:档位含义 + 分数 + 触发的否决原因
const tooltip = computed(() => {
const lines: string[] = [`${meta.value.grade} 档 · ${meta.value.hint}`]
lines.push(`综合评分 ${props.result.score}`)
if (props.result.insufficientSample) lines.push('⚠ 样本不足')
if (props.result.isLosing) lines.push('⚠ 系统亏损')
for (const v of props.result.vetoes) {
lines.push(`${v.reason}`)
}
return lines.join('\n')
})
const badgeClass = computed(() => [
'grade-badge',
`size-${props.size}`,
`grade-${props.result.grade}`,
])
</script>
<template>
<span class="badge-wrapper">
<span
:class="badgeClass"
:style="{ '--grade-color': meta.color }"
:title="tooltip"
role="img"
:aria-label="`评级 ${result.grade}${meta.hint}`"
>
<span class="grade-letter">{{ result.grade }}</span>
<span v-if="showScore" class="grade-score">{{ result.score.toFixed(0) }}</span>
</span>
<span
v-if="result.insufficientSample"
class="sample-warn"
title="交易笔数 < 10,胜率/利润因子已降权(不参与总分),评级基于净值类指标"
>
交易样本有限
</span>
</span>
</template>
<style scoped>
.badge-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sample-warn {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius);
background: rgba(240, 160, 32, 0.12);
border: 1px solid rgba(240, 160, 32, 0.45);
color: var(--warn);
font-weight: 500;
white-space: nowrap;
cursor: help;
}
.grade-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--grade-color) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--grade-color) 55%, transparent);
color: var(--grade-color);
font-weight: 700;
line-height: 1;
user-select: none;
cursor: help;
white-space: nowrap;
}
.grade-letter {
font-size: 1em;
letter-spacing: 0.5px;
}
.grade-score {
font-size: 0.85em;
opacity: 0.85;
font-family: var(--font-mono);
font-weight: 600;
}
/* 尺寸 */
.size-sm {
font-size: 11px;
padding: 1px 7px;
}
.size-md {
font-size: 13px;
padding: 3px 10px;
}
.size-lg {
font-size: 16px;
padding: 6px 14px;
}
.size-lg .grade-letter {
font-size: 1.15em;
}
/* 各档位无需额外样式,--grade-color 已通过 style 绑定 */
</style>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
// 评级详情:徽章 + 维度得分条 + 否决原因。
// 与 GradeBadge 互补:GradeBadge 是「徽章快照」,本组件展示「为什么是这个评级」。
// 放在 MetricTable 旁边,让用户理解评分依据。
import { computed } from 'vue'
import GradeBadge from './GradeBadge.vue'
import { GRADE_META, type DimensionScore, type GradeResult } from '../grading'
const props = defineProps<{
result: GradeResult
/** 是否默认展开维度明细(紧凑场景可关闭,仅徽章 + 总分) */
expanded?: boolean
}>()
const meta = computed(() => GRADE_META[props.result.grade])
// 维度得分条颜色:>=70 绿,5070 蓝,<50 橙/红
function barColor(score: number): string {
if (score >= 70) return 'var(--down)' // 绿(A 股惯例绿即好)
if (score >= 50) return 'var(--accent)' // 蓝
if (score >= 30) return 'var(--warn)' // 橙
return 'var(--up)' // 红
}
// 把维度按权重降序展示
const sortedDimensions = computed<DimensionScore[]>(() =>
[...props.result.dimensions].sort((a, b) => b.weight - a.weight),
)
</script>
<template>
<div class="grade-details" :class="`scenario-${result.scenario}`">
<div class="grade-header">
<GradeBadge :result="result" size="lg" />
<div class="grade-hint">{{ meta.hint }}</div>
</div>
<!-- 特殊标记 -->
<div v-if="result.insufficientSample || result.isLosing" class="flags">
<span v-if="result.isLosing" class="flag flag-losing"> 系统亏损</span>
<span v-if="result.insufficientSample" class="flag flag-insufficient">
交易样本有限< 10 胜率/利润因子已降权评级基于净值类指标
</span>
</div>
<!-- 否决原因 -->
<ul v-if="result.vetoes.length" class="vetoes">
<li v-for="v in result.vetoes" :key="v.key" class="veto-item">
<span class="veto-cap">{{ v.cap }}</span>
<span class="veto-reason">{{ v.reason }}</span>
</li>
</ul>
<!-- 维度明细 -->
<div v-if="expanded" class="dimensions">
<div v-for="d in sortedDimensions" :key="d.key" class="dim-row">
<div class="dim-header">
<span class="dim-label">{{ d.label }}</span>
<span class="dim-weight">权重 {{ (d.weight * 100).toFixed(0) }}%</span>
<span class="dim-score">{{ d.score.toFixed(1) }}</span>
</div>
<div class="dim-bar-track">
<div
class="dim-bar-fill"
:style="{ width: `${Math.min(100, Math.max(0, d.score))}%`, background: barColor(d.score) }"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.grade-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.grade-header {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.grade-hint {
font-size: 13px;
color: var(--text-muted);
}
.flags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.flag {
font-size: 12px;
padding: 3px 9px;
border-radius: var(--radius);
font-weight: 600;
}
.flag-losing {
background: rgba(239, 65, 70, 0.15);
color: var(--up);
border: 1px solid var(--up);
}
.flag-insufficient {
background: rgba(240, 160, 32, 0.15);
color: var(--warn);
border: 1px solid var(--warn);
}
.vetoes {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: rgba(239, 65, 70, 0.06);
border: 1px solid rgba(239, 65, 70, 0.25);
border-radius: var(--radius);
}
.veto-item {
display: flex;
gap: 10px;
align-items: baseline;
font-size: 12px;
}
.veto-cap {
flex-shrink: 0;
font-weight: 700;
color: var(--up);
width: 16px;
}
.veto-reason {
color: var(--text-muted);
}
.dimensions {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.dim-row {
display: flex;
flex-direction: column;
gap: 3px;
}
.dim-header {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
}
.dim-label {
flex: 1;
color: var(--text);
}
.dim-weight {
color: var(--text-dim);
font-size: 11px;
}
.dim-score {
font-family: var(--font-mono);
font-weight: 600;
color: var(--text-muted);
min-width: 36px;
text-align: right;
}
.dim-bar-track {
height: 4px;
background: var(--bg);
border-radius: 2px;
overflow: hidden;
}
.dim-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
}
</style>
+319
View File
@@ -0,0 +1,319 @@
/**
* 评级系统自检脚本。
*
* 项目未引入 vitest,采用 Node 内置 test runnernode:test)跑。
* 评级逻辑是纯函数 + 零 DOM 依赖,可直接 import ESM TypeScriptNode 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 → 550.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)
})
+180
View File
@@ -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))
}
+112
View File
@@ -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,
}
}
+322
View File
@@ -0,0 +1,322 @@
/**
* 评级系统统一入口。
*
* 三个场景函数:
* gradePerformance(perf) — 单标的回测(完整 Performance6 维度)
* 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:单标的回测评级(完整 Performance6 维度)
// ════════════════════════════════════════════════════════════════════════════
/**
* 评级单标的回测结果。
*
* 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'
+170
View File
@@ -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
+112
View File
@@ -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<Grade, GradeMeta> = {
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 },
]
+14
View File
@@ -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() {
<EquityChart :equity="store.result.equity_curve" />
</section>
<section v-if="grade" class="report-section">
<h3>评级</h3>
<GradeDetails :result="grade" expanded />
</section>
<section class="report-section">
<h3>绩效指标</h3>
<MetricTable :perf="store.result.performance" />
+31
View File
@@ -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<GradeResult | null>(() =>
store.optimizeResult?.best ? gradeGridPoint(store.optimizeResult.best) : null,
)
// 一键寻优全局最佳评级
const bestAllGrade = computed<GradeResult | null>(() =>
store.optimizeAllResult?.best ? gradeGridPoint(store.optimizeAllResult.best) : null,
)
// 一键寻优排名表每行的评级(按需计算,避免大表全量计算)
const rankingGrades = computed<GradeResult[]>(() =>
(store.optimizeAllResult?.ranking ?? []).map((r) => gradeGridPoint(r)),
)
</script>
<template>
@@ -202,6 +219,7 @@ function num(v: number | null | undefined, d = 2): string {
<section class="report-section">
<h3>最优结果</h3>
<div v-if="store.optimizeResult.best" class="best-summary">
<GradeBadge v-if="bestGrade" :result="bestGrade" size="md" />
<span class="best-params">{{ JSON.stringify(store.optimizeResult.best.params) }}</span>
<span class="best-return pos">
{{ (store.optimizeResult.best.total_return! * 100).toFixed(2) }}%
@@ -233,6 +251,7 @@ function num(v: number | null | undefined, d = 2): string {
<section class="report-section">
<h3>全局最佳</h3>
<div v-if="store.optimizeAllResult.best" class="best-summary">
<GradeBadge v-if="bestAllGrade" :result="bestAllGrade" size="md" />
<span class="best-params">
{{ store.optimizeAllResult.best.strategy_label }}
{{ JSON.stringify(store.optimizeAllResult.best.params) }}
@@ -258,6 +277,7 @@ function num(v: number | null | undefined, d = 2): string {
<thead>
<tr>
<th>#</th>
<th>评级</th>
<th>策略</th>
<th>参数</th>
<th class="num">总收益</th>
@@ -275,6 +295,14 @@ function num(v: number | null | undefined, d = 2): string {
:class="{ best: i === 0 }"
>
<td class="rank">{{ i + 1 }}</td>
<td class="grade-cell">
<GradeBadge
v-if="rankingGrades[i]"
:result="rankingGrades[i]"
size="sm"
:show-score="false"
/>
</td>
<td>{{ r.strategy_label }}</td>
<td class="params">{{ JSON.stringify(r.params) }}</td>
<td class="num" :class="r.total_return !== null && r.total_return > 0 ? 'pos' : 'neg'">
@@ -438,4 +466,7 @@ function num(v: number | null | undefined, d = 2): string {
font-size: 11px;
padding: 2px 8px;
}
.grade-cell {
width: 56px;
}
</style>
+13
View File
@@ -5,11 +5,13 @@ 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 PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
import StocksPicker from '../components/StocksPicker.vue'
import StrategyPicker from '../components/StrategyPicker.vue'
import { formatError, saveStrategy } from '../api'
import { gradePortfolio } from '../grading'
import type { Category, ExecutionMode } from '../types'
import { useBacktestStore } from '../stores/backtest'
@@ -98,6 +100,12 @@ const strategyLabel = computed(
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
)
// 组合评级:从 combined_equity 重算夏普/卡玛/波动率等(组合级净值算不出胜率/利润因子),
// 用 5 维度评分。净值点数过少(< 60 个交易日)视为样本不足。
const grade = computed(() =>
store.portfolioResult ? gradePortfolio(store.portfolioResult) : null,
)
function openSaveForm() {
saveName.value = `${strategyLabel.value} · 组合${stocks.value.length}`
saveTags.value = ''
@@ -227,6 +235,11 @@ async function onSave() {
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
</div>
<section v-if="grade" class="report-section">
<h3>组合评级</h3>
<GradeDetails :result="grade" expanded />
</section>
<section class="report-section">
<h3>组合整体绩效</h3>
<div class="perf-summary">
+2 -1
View File
@@ -11,5 +11,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "scripts/**"]
}