mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 18:04:16 +08:00
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:
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
|
本文件记录 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
|
## [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 影响。
|
**修复多策略组合回测「最大回撤」严重虚高** —— 用户反馈: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 影响。
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普
|
|||||||
|
|
||||||
**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。
|
**回测可视化 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 文档。
|
装上就能跑。**Python API + CLI + Web API 三通道**,输出 JSON 天然喂给 AI Agent:Claude Code、OpenClaw、Hermes 直接吃。`easy-tdx serve` 一键起 REST 服务,浏览器打开就是交互式 API 文档。
|
||||||
|
|
||||||
**你不懂 TCP 协议?不用。**
|
**你不懂 TCP 协议?不用。**
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "easy-tdx"
|
name = "easy-tdx"
|
||||||
version = "1.17.13"
|
version = "1.17.14"
|
||||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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 绿,50–70 蓝,<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>
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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 },
|
||||||
|
]
|
||||||
@@ -7,12 +7,14 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import EquityChart from '../components/EquityChart.vue'
|
import EquityChart from '../components/EquityChart.vue'
|
||||||
|
import GradeDetails from '../components/GradeDetails.vue'
|
||||||
import KlineChart from '../components/KlineChart.vue'
|
import KlineChart from '../components/KlineChart.vue'
|
||||||
import MetricTable from '../components/MetricTable.vue'
|
import MetricTable from '../components/MetricTable.vue'
|
||||||
import StrategyPicker from '../components/StrategyPicker.vue'
|
import StrategyPicker from '../components/StrategyPicker.vue'
|
||||||
import SymbolPicker from '../components/SymbolPicker.vue'
|
import SymbolPicker from '../components/SymbolPicker.vue'
|
||||||
import TradeTable from '../components/TradeTable.vue'
|
import TradeTable from '../components/TradeTable.vue'
|
||||||
import { formatError, saveStrategy } from '../api'
|
import { formatError, saveStrategy } from '../api'
|
||||||
|
import { gradePerformance } from '../grading'
|
||||||
import type { Category, ExecutionMode } from '../types'
|
import type { Category, ExecutionMode } from '../types'
|
||||||
import { useBacktestStore } from '../stores/backtest'
|
import { useBacktestStore } from '../stores/backtest'
|
||||||
|
|
||||||
@@ -111,6 +113,13 @@ const strategyLabel = computed(
|
|||||||
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
|
() => 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 是纯数字,
|
// 当前股票完整代码(市场:6位),从 SymbolPicker 同步来的 code 是纯数字,
|
||||||
// 需要带上市场前缀。复用 SymbolPicker 内部已经算好的前缀更稳妥——这里简单按
|
// 需要带上市场前缀。复用 SymbolPicker 内部已经算好的前缀更稳妥——这里简单按
|
||||||
// 交易所规则推断(6 位代码:6/9 开头 SH,其余 SZ;8/4 开头 BJ)。
|
// 交易所规则推断(6 位代码:6/9 开头 SH,其余 SZ;8/4 开头 BJ)。
|
||||||
@@ -260,6 +269,11 @@ async function onSave() {
|
|||||||
<EquityChart :equity="store.result.equity_curve" />
|
<EquityChart :equity="store.result.equity_curve" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="grade" class="report-section">
|
||||||
|
<h3>评级</h3>
|
||||||
|
<GradeDetails :result="grade" expanded />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="report-section">
|
<section class="report-section">
|
||||||
<h3>绩效指标</h3>
|
<h3>绩效指标</h3>
|
||||||
<MetricTable :perf="store.result.performance" />
|
<MetricTable :perf="store.result.performance" />
|
||||||
|
|||||||
@@ -5,10 +5,13 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import GradeBadge from '../components/GradeBadge.vue'
|
||||||
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
|
import OptimizeHeatmap from '../components/OptimizeHeatmap.vue'
|
||||||
import OptimizeResultTable from '../components/OptimizeResultTable.vue'
|
import OptimizeResultTable from '../components/OptimizeResultTable.vue'
|
||||||
import ParamGridPicker from '../components/ParamGridPicker.vue'
|
import ParamGridPicker from '../components/ParamGridPicker.vue'
|
||||||
import SymbolPicker from '../components/SymbolPicker.vue'
|
import SymbolPicker from '../components/SymbolPicker.vue'
|
||||||
|
import { gradeGridPoint } from '../grading'
|
||||||
|
import type { GradeResult } from '../grading'
|
||||||
import type { Category, ExecutionMode } from '../types'
|
import type { Category, ExecutionMode } from '../types'
|
||||||
import { useBacktestStore } from '../stores/backtest'
|
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 {
|
function num(v: number | null | undefined, d = 2): string {
|
||||||
return v !== null && v !== undefined && Number.isFinite(v) ? v.toFixed(d) : '-'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -202,6 +219,7 @@ function num(v: number | null | undefined, d = 2): string {
|
|||||||
<section class="report-section">
|
<section class="report-section">
|
||||||
<h3>最优结果</h3>
|
<h3>最优结果</h3>
|
||||||
<div v-if="store.optimizeResult.best" class="best-summary">
|
<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-params">{{ JSON.stringify(store.optimizeResult.best.params) }}</span>
|
||||||
<span class="best-return pos">
|
<span class="best-return pos">
|
||||||
{{ (store.optimizeResult.best.total_return! * 100).toFixed(2) }}%
|
{{ (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">
|
<section class="report-section">
|
||||||
<h3>全局最佳</h3>
|
<h3>全局最佳</h3>
|
||||||
<div v-if="store.optimizeAllResult.best" class="best-summary">
|
<div v-if="store.optimizeAllResult.best" class="best-summary">
|
||||||
|
<GradeBadge v-if="bestAllGrade" :result="bestAllGrade" size="md" />
|
||||||
<span class="best-params">
|
<span class="best-params">
|
||||||
{{ store.optimizeAllResult.best.strategy_label }}
|
{{ store.optimizeAllResult.best.strategy_label }}
|
||||||
{{ JSON.stringify(store.optimizeAllResult.best.params) }}
|
{{ JSON.stringify(store.optimizeAllResult.best.params) }}
|
||||||
@@ -258,6 +277,7 @@ function num(v: number | null | undefined, d = 2): string {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
|
<th>评级</th>
|
||||||
<th>策略</th>
|
<th>策略</th>
|
||||||
<th>参数</th>
|
<th>参数</th>
|
||||||
<th class="num">总收益</th>
|
<th class="num">总收益</th>
|
||||||
@@ -275,6 +295,14 @@ function num(v: number | null | undefined, d = 2): string {
|
|||||||
:class="{ best: i === 0 }"
|
:class="{ best: i === 0 }"
|
||||||
>
|
>
|
||||||
<td class="rank">{{ i + 1 }}</td>
|
<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>{{ r.strategy_label }}</td>
|
||||||
<td class="params">{{ JSON.stringify(r.params) }}</td>
|
<td class="params">{{ JSON.stringify(r.params) }}</td>
|
||||||
<td class="num" :class="r.total_return !== null && r.total_return > 0 ? 'pos' : 'neg'">
|
<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;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
}
|
}
|
||||||
|
.grade-cell {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
|||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import EquityChart from '../components/EquityChart.vue'
|
import EquityChart from '../components/EquityChart.vue'
|
||||||
|
import GradeDetails from '../components/GradeDetails.vue'
|
||||||
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
|
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
|
||||||
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
|
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
|
||||||
import StocksPicker from '../components/StocksPicker.vue'
|
import StocksPicker from '../components/StocksPicker.vue'
|
||||||
import StrategyPicker from '../components/StrategyPicker.vue'
|
import StrategyPicker from '../components/StrategyPicker.vue'
|
||||||
import { formatError, saveStrategy } from '../api'
|
import { formatError, saveStrategy } from '../api'
|
||||||
|
import { gradePortfolio } from '../grading'
|
||||||
import type { Category, ExecutionMode } from '../types'
|
import type { Category, ExecutionMode } from '../types'
|
||||||
import { useBacktestStore } from '../stores/backtest'
|
import { useBacktestStore } from '../stores/backtest'
|
||||||
|
|
||||||
@@ -98,6 +100,12 @@ const strategyLabel = computed(
|
|||||||
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
|
() => 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() {
|
function openSaveForm() {
|
||||||
saveName.value = `${strategyLabel.value} · 组合${stocks.value.length}只`
|
saveName.value = `${strategyLabel.value} · 组合${stocks.value.length}只`
|
||||||
saveTags.value = ''
|
saveTags.value = ''
|
||||||
@@ -227,6 +235,11 @@ async function onSave() {
|
|||||||
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
|
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section v-if="grade" class="report-section">
|
||||||
|
<h3>组合评级</h3>
|
||||||
|
<GradeDetails :result="grade" expanded />
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="report-section">
|
<section class="report-section">
|
||||||
<h3>组合整体绩效</h3>
|
<h3>组合整体绩效</h3>
|
||||||
<div class="perf-summary">
|
<div class="perf-summary">
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "scripts/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user