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
+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>