mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 22:44:22 +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:
@@ -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>
|
||||
Reference in New Issue
Block a user