feat(web): 因子详情页——表达式卡+6格指标+月度/累计IC双系列图+十分组柱状(echarts终端暗色)+排行榜页色值清理走token [nas]

This commit is contained in:
2026-08-25 00:40:26 +08:00
parent 52eb5177ac
commit 77e12f4fe0
3 changed files with 585 additions and 18 deletions
+3
View File
@@ -20,6 +20,9 @@
--cyan-dim: #00b8cc; /* 暗青(hover/次要激活) */
--cyan-soft: rgba(0, 229, 255, 0.14); /* 青色填充底 */
/* —— 次强调:紫(alpha158)—— */
--purple-dim: #b48cff; /* alpha158 类别色 */
/* —— 数据高亮:琥珀 —— */
--amber: #ffb000; /* 数据高亮/警告 */
--warn: #ffb000; /* 语义别名 */
+3 -3
View File
@@ -269,14 +269,14 @@ onMounted(async () => {
}
.btn.primary {
color: #04141a;
color: var(--text);
background: var(--brand);
border-color: var(--brand);
box-shadow: var(--glow-cyan);
}
.btn.primary:hover {
background: #33ecff;
background: var(--cyan-dim);
}
.stats {
@@ -572,7 +572,7 @@ td .fexpr {
}
.cat-alpha158 {
color: #b48cff;
color: var(--purple-dim);
background: rgba(180, 140, 255, 0.1);
border-color: rgba(180, 140, 255, 0.3);
}
+579 -15
View File
@@ -1,28 +1,313 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getEvalDetail, type EvalDetail } from '@/api/factor'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis, BRAND } from '@/utils/echartsDark'
import type { EChartsCoreOption } from 'echarts'
const route = useRoute()
const router = useRouter()
const factor = route.params.factor as string
const runId = route.query.run as string | undefined
const period = ref<'1' | '5' | '10'>('1')
const detail = ref<EvalDetail | null>(null)
const error = ref('')
const expandedExpr = ref(false)
// Chart refs
const monthlyChartEl = ref<HTMLDivElement>()
const decileChartEl = ref<HTMLDivElement>()
const { setOption: setMonthlyOption } = useChart(monthlyChartEl)
const { setOption: setDecileOption } = useChart(decileChartEl)
async function load() {
try {
error.value = ''
detail.value = await getEvalDetail(String(route.params.factor), route.query.run ? String(route.query.run) : undefined)
} catch (e) {
error.value = '因子不存在或无评估数据'
detail.value = null
}
}
function goBack() {
router.push('/factor/leaderboard')
router.back()
}
function goToNewFactor() {
router.push('/factor/new')
}
const currentMetrics = computed(() => {
if (!detail.value?.metrics) return null
const metrics = detail.value.metrics as Record<string, unknown>
return metrics[period.value] as Record<string, unknown> | null
})
function renderMonthlyChart() {
const metrics = currentMetrics.value
if (!metrics || !monthlyChartEl.value) return
const monthlyIc = metrics.monthly_ic as Array<{ month: string; ic: number }> | null
if (!monthlyIc || !monthlyIc.length) return
// Compute cumulative IC
let cumulative = 0
const cumulativeData = monthlyIc.map((p) => {
cumulative += p.ic
return cumulative
})
const months = monthlyIc.map((p) => p.month)
const icValues = monthlyIc.map((p) => p.ic)
const option: EChartsCoreOption = {
title: darkTitle('月度 IC 与累计 IC'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: {
type: 'category',
data: months,
...darkAxis(),
},
yAxis: {
type: 'value',
name: 'IC',
...darkAxis(),
},
series: [
{
type: 'bar',
name: '月度 IC',
data: icValues,
itemStyle: {
color: (params) => (params.data as number) >= 0 ? 'var(--up)' : 'var(--down)',
},
},
{
type: 'line',
name: '累计 IC',
data: cumulativeData,
showSymbol: false,
lineStyle: { color: BRAND, width: 1.8 },
itemStyle: { color: BRAND },
},
],
}
setMonthlyOption(option)
}
function renderDecileChart() {
const metrics = currentMetrics.value
if (!metrics || !decileChartEl.value) return
const deciles = metrics.deciles as (number | null)[] | null
if (!deciles || !deciles.length) return
const labels = ['D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9', 'D10']
const colors = deciles.map((val) => {
if (val === null) return 'var(--text-3)'
// Create gradient from --down (negative) to --up (positive)
// Normalize between -0.5% and 0.5% for color transition
const normalized = Math.max(-1, Math.min(1, val / 0.5))
if (normalized < 0) return 'var(--down)'
return 'var(--up)'
})
const option: EChartsCoreOption = {
title: darkTitle('十分组年化收益'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: {
type: 'category',
data: labels,
...darkAxis(),
},
yAxis: {
type: 'value',
name: '年化收益',
...darkAxis(),
},
series: [
{
type: 'bar',
name: '年化收益',
data: deciles.map((val, i) => ({
value: val,
itemStyle: { color: colors[i] },
})),
},
],
}
setDecileOption(option)
}
function renderCharts() {
renderMonthlyChart()
renderDecileChart()
}
function toggleExpr() {
expandedExpr.value = !expandedExpr.value
}
onMounted(load)
watch(
() => [detail.value, period.value],
async () => {
if (!detail.value) return
await nextTick()
renderCharts()
},
{ deep: true }
)
// Computed helpers
const conclusionLabel = computed(() => {
const map: Record<string, string> = {
effective: '有效',
watch: '观察',
eliminated: '淘汰',
}
if (!currentMetrics.value) return ''
const conclusion = (currentMetrics.value as Record<string, unknown>).conclusion as string | undefined
return conclusion ? map[conclusion] || conclusion : ''
})
const conclusionClass = computed(() => {
if (!currentMetrics.value) return ''
const conclusion = (currentMetrics.value as Record<string, unknown>).conclusion as string | undefined
return conclusion ? `st-${conclusion}` : ''
})
const conclusionColor = computed(() => {
const map: Record<string, string> = {
effective: 'var(--lamp-ok)',
watch: 'var(--lamp-warn)',
eliminated: 'var(--lamp-idle)',
}
if (!currentMetrics.value) return ''
const conclusion = (currentMetrics.value as Record<string, unknown>).conclusion as string | undefined
return conclusion ? map[conclusion] || '' : ''
})
const categoryClass = computed(() => {
if (!detail.value) return ''
const map: Record<string, string> = {
alpha101: 'cat-alpha101',
alpha158: 'cat-alpha158',
builtin: 'cat-builtin',
}
return map[detail.value.category] || 'cat-builtin'
})
// Formatting helpers
function num(val: number | null | undefined, digits = 3): string {
if (val == null) return '—'
return val.toFixed(digits)
}
function pct(val: number | null | undefined): string {
if (val == null) return '—'
const sign = val >= 0 ? '+' : ''
return `${sign}${(val * 100).toFixed(1)}%`
}
</script>
<template>
<div class="detail-page">
<a class="backlink" href="#" @click.prevent="goBack"> 返回排行榜</a>
<div class="page-head">
<div>
<h1 class="page-title">因子详情 · {{ factor }}</h1>
<p class="page-sub">批次 {{ runId || '最新' }}</p>
<div v-if="error" class="error-state">
<p>{{ error }}</p>
<a class="backlink" href="#" @click.prevent="goBack">返回排行榜</a>
</div>
<template v-else-if="detail">
<!-- Page Header -->
<div class="page-head">
<div>
<h1 class="page-title">因子详情 · {{ detail.factor }}</h1>
<p class="page-sub">批次 {{ route.query.run || '最新' }}</p>
</div>
</div>
</div>
<div class="placeholder">
<p>因子详情页待实现Task 11</p>
</div>
<!-- Factor Card -->
<div class="fcard">
<div class="hd">
<span class="name">{{ detail.factor }}</span>
<span :class="['cat-chip', categoryClass]">{{ detail.category }}</span>
<span v-if="conclusionLabel" :class="['st', conclusionClass]">
<span class="lamp" :style="{ background: conclusionColor, boxShadow: conclusionColor !== 'var(--lamp-idle)' ? `0 0 6px ${conclusionColor}` : 'none' }"></span>
{{ conclusionLabel }}
</span>
</div>
<div class="expr" :class="{ expanded: expandedExpr }">
<span class="expr-text">{{ detail.expression }}</span>
<button v-if="detail.expression && detail.expression.length > 100" class="expr-toggle" @click="toggleExpr">
{{ expandedExpr ? '收起' : '展开全文' }}
</button>
</div>
</div>
<!-- Metrics Strip -->
<div v-if="currentMetrics" class="metric-strip">
<div class="metric">
<div class="k">IC 均值 ({{ period }}D)</div>
<div class="v" :class="(currentMetrics.ic_mean as number) >= 0 ? 'pos' : 'neg'">{{ num(currentMetrics.ic_mean as number) }}</div>
</div>
<div class="metric">
<div class="k">ICIR</div>
<div class="v" :class="(currentMetrics.icir as number) >= 0 ? 'pos' : 'neg'">{{ num(currentMetrics.icir as number, 2) }}</div>
</div>
<div class="metric">
<div class="k">t </div>
<div class="v" :class="(currentMetrics.t_stat as number) >= 0 ? 'pos' : 'neg'">{{ num(currentMetrics.t_stat as number, 1) }}</div>
</div>
<div class="metric">
<div class="k">IC 胜率</div>
<div class="v" :class="(currentMetrics.win_rate as number) >= 0 ? 'pos' : 'neg'">{{ pct(currentMetrics.win_rate as number) }}</div>
</div>
<div class="metric">
<div class="k">多空年化</div>
<div class="v" :class="(currentMetrics.ls_annual as number) >= 0 ? 'pos' : 'neg'">{{ pct(currentMetrics.ls_annual as number) }}</div>
</div>
<div class="metric">
<div class="k">日均换手</div>
<div class="v" style="color: var(--text-2)">{{ pct(currentMetrics.turnover as number) }}</div>
<div class="d">参评 {{ currentMetrics.count }} </div>
</div>
</div>
<!-- Period Switcher -->
<div class="period-switch">
<button :class="{ on: period === '1' }" @click="period = '1'">1D</button>
<button :class="{ on: period === '5' }" @click="period = '5'">5D</button>
<button :class="{ on: period === '10' }" @click="period = '10'">10D</button>
</div>
<!-- Charts -->
<div class="charts">
<div class="chart-card">
<h3>月度 IC 与累计 IC</h3>
<div class="cs">=月度 RankIC · 线=累计 IC</div>
<div ref="monthlyChartEl" class="chart-box"></div>
</div>
<div class="chart-card">
<h3>十分组年化收益 ({{ period }}D)</h3>
<div class="cs">Q1=因子值最低组 · 单调性=区分度</div>
<div ref="decileChartEl" class="chart-box"></div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="btn primary" @click="goToNewFactor">查看完整 tears 报告</button>
<button class="btn">加入对比</button>
</div>
</template>
</div>
</template>
@@ -47,6 +332,15 @@ function goBack() {
text-underline-offset: 3px;
}
.error-state {
padding: 60px;
text-align: center;
color: var(--text-3);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
}
.page-head {
display: flex;
align-items: flex-end;
@@ -71,12 +365,282 @@ function goBack() {
letter-spacing: 0.02em;
}
.placeholder {
padding: 60px;
text-align: center;
color: var(--text-3);
.fcard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 16px 18px;
margin-bottom: var(--sp-3);
}
.fcard .hd {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.fcard .name {
font-family: var(--mono);
font-size: 17px;
font-weight: 700;
color: var(--brand);
letter-spacing: 0.03em;
}
.expr {
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
background: var(--panel);
border: 1px solid var(--border-2);
border-left: 2px solid var(--cyan-dim);
border-radius: var(--r-sm);
padding: 11px 14px;
color: var(--text-2);
overflow-x: auto;
white-space: pre;
position: relative;
}
.expr:not(.expanded) {
max-height: 80px;
overflow: hidden;
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
.expr.expanded {
mask-image: none;
-webkit-mask-image: none;
}
.expr-toggle {
position: absolute;
bottom: 8px;
right: 12px;
font-size: 10px;
color: var(--brand);
background: rgba(0, 229, 255, 0.1);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: var(--r-sm);
padding: 2px 8px;
cursor: pointer;
transition: all 0.15s var(--ease);
}
.expr-toggle:hover {
background: rgba(0, 229, 255, 0.2);
}
.metric-strip {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
margin-bottom: var(--sp-3);
}
@media (max-width: 1100px) {
.metric-strip {
grid-template-columns: repeat(3, 1fr);
}
}
.metric {
background: var(--bg-card);
padding: 11px 13px;
}
.metric .k {
color: var(--text-3);
font-family: var(--mono);
font-size: 9.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.metric .v {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 18px;
font-weight: 600;
margin-top: 2px;
}
.metric .d {
font-size: 10px;
margin-top: 2px;
color: var(--text-3);
font-family: var(--mono);
}
.period-switch {
display: flex;
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
margin-bottom: var(--sp-3);
}
.period-switch button {
font-family: var(--mono);
font-size: 11px;
padding: 4px 12px;
color: var(--text-2);
background: transparent;
border: none;
cursor: pointer;
}
.period-switch button.on {
background: rgba(0, 229, 255, 0.12);
color: var(--brand);
}
.charts {
display: grid;
grid-template-columns: 3fr 2fr;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
}
@media (max-width: 1100px) {
.charts {
grid-template-columns: 1fr;
}
}
.chart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 14px 16px;
}
.chart-card h3 {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.chart-card .cs {
color: var(--text-3);
font-family: var(--mono);
font-size: 10.5px;
margin-bottom: 10px;
}
.chart-box {
width: 100%;
height: 215px;
}
.actions {
display: flex;
gap: 10px;
}
.btn {
font-size: 12.5px;
font-weight: 600;
border-radius: var(--r-md);
padding: 7px 16px;
border: 1px solid var(--border);
color: var(--text-2);
background: var(--bg-card);
transition: all 0.15s var(--ease);
cursor: pointer;
}
.btn:hover {
color: var(--text);
border-color: var(--text-3);
}
.btn.primary {
color: var(--text);
background: var(--brand);
border-color: var(--brand);
box-shadow: var(--glow-cyan);
}
.btn.primary:hover {
background: var(--cyan-dim);
}
.cat-chip {
display: inline-block;
padding: 1px 8px;
border-radius: var(--r-sm);
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
line-height: 17px;
letter-spacing: 0.3px;
border: 1px solid transparent;
}
.cat-alpha101 {
color: var(--brand);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
}
.cat-alpha158 {
color: var(--purple-dim);
background: rgba(180, 140, 255, 0.1);
border-color: rgba(180, 140, 255, 0.3);
}
.cat-builtin {
color: var(--amber);
background: rgba(255, 176, 0, 0.1);
border-color: rgba(255, 176, 0, 0.3);
}
.st {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.08em;
}
.st-ok .lamp {
background: var(--lamp-ok);
box-shadow: 0 0 6px rgba(46, 230, 138, 0.7);
}
.st-watch .lamp {
background: var(--lamp-warn);
box-shadow: 0 0 6px rgba(255, 176, 0, 0.7);
}
.st-eliminated .lamp {
background: var(--lamp-idle);
box-shadow: none;
}
.lamp {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
</style>