Files
sanguo_vnpy_v2/frontend/src/views/factor/Leaderboard.vue
T
claude_dev 362efe228a
CI/CD / test (push) Successful in 4s
CI/CD / nas-deploy (push) Successful in 1s
CI/CD / nas-verify (push) Successful in 13s
feat(frontend): 因子类别体系加composite合成类——chips+detail/picker样式+--magenta类别色 [nas]
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 12:18:18 +08:00

760 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getEvalRuns, getLeaderboard, type EvalRun, type LeaderboardRow, type LeaderboardTiles } from '@/api/factor'
import { useFactorCompareStore } from '@/stores/factorCompare'
const router = useRouter()
const compareStore = useFactorCompareStore()
const runs = ref<EvalRun[]>([])
const runId = ref<string>('')
const period = ref<'1' | '5' | '10'>('1')
const category = ref<string>('')
const search = ref('')
const sort = ref('abs_icir')
const loading = ref(false)
const tiles = ref<LeaderboardTiles | null>(null)
const rows = ref<LeaderboardRow[]>([])
const CATEGORY_CHIPS = [
{ key: '', label: '全部' },
{ key: 'alpha101', label: 'Alpha101' },
{ key: 'alpha158', label: 'Alpha158' },
{ key: 'builtin', label: '内置' },
{ key: 'fundamental', label: '财务基本面' },
{ key: 'composite', label: '合成因子' },
]
async function load() {
loading.value = true
try {
const data = await getLeaderboard({
...(runId.value ? { run_id: runId.value } : {}),
period: period.value,
...(category.value ? { category: category.value } : {}),
...(search.value ? { search: search.value } : {}),
sort: sort.value,
})
tiles.value = data.tiles
rows.value = data.rows
} finally {
loading.value = false
}
}
function pct(v: number | null): string {
return v == null ? '—' : `${(v * 100).toFixed(1)}%`
}
function num(v: number | null, digits = 3): string {
return v == null ? '—' : v.toFixed(digits)
}
function sparkPath(points: { ic: number }[]): string {
const pts = points.slice(-24)
if (!pts.length) return ''
const w = 88
const h = 20
const maxAbs = Math.max(0.02, ...pts.map((p) => Math.abs(p.ic)))
return pts
.map((p, i) => {
const x = (i / Math.max(pts.length - 1, 1)) * w
const y = h / 2 - (p.ic / maxAbs) * (h / 2 - 2)
return `${x},${y}`
})
.join(' ')
}
const CONCLUSION: Record<string, { label: string; color: string }> = {
effective: { label: '有效', color: 'var(--lamp-ok)' },
watch: { label: '观察', color: 'var(--lamp-warn)' },
eliminated: { label: '淘汰', color: 'var(--lamp-idle)' },
}
const selectedRun = computed(() => runs.value.find((r) => r.run_id === runId.value))
function goToDetail(row: LeaderboardRow) {
if (row.error) return
router.push(`/factor/leaderboard/${row.factor}?run=${runId.value}`)
}
function toggleCompare(row: LeaderboardRow) {
const ok = compareStore.toggle(row.factor, runId.value || undefined)
if (!ok) ElMessage.warning('对比托盘已满(6 个),先移除再添加')
}
function getCategoryClass(cat: string): string {
const map: Record<string, string> = {
alpha101: 'cat-alpha101',
alpha158: 'cat-alpha158',
builtin: 'cat-builtin',
fundamental: 'cat-fundamental',
composite: 'cat-composite',
}
return map[cat] || 'cat-builtin'
}
onMounted(async () => {
runs.value = await getEvalRuns()
if (runs.value.length) runId.value = runs.value[0].run_id
await load()
})
</script>
<template>
<div class="leaderboard-page">
<div class="page-head">
<div>
<h1 class="page-title">IC 排行榜</h1>
<p v-if="selectedRun" class="page-sub">
{{ selectedRun.label }} · {{ selectedRun.universe }} {{ selectedRun.symbols_count.toLocaleString() }} ·
{{ selectedRun.start }} ~ {{ selectedRun.end }}
</p>
</div>
<router-link to="/factor/batch" class="btn primary">发起新评估</router-link>
</div>
<div v-if="tiles" class="stats">
<div class="stat">
<div class="k">参评因子</div>
<div class="v cy">{{ tiles.factors_total }}</div>
<div class="d">alpha101 ×100 · alpha158 ×158</div>
</div>
<div class="stat ok">
<div class="k">有效因子</div>
<div class="v" :style="{ color: 'var(--lamp-ok)' }">{{ tiles.effective }}</div>
<div class="d">|IC| &gt; 0.02 ICIR &gt; 0.3</div>
</div>
<div class="stat warn">
<div class="k">观察中</div>
<div class="v" :style="{ color: 'var(--amber)' }">{{ tiles.watch }}</div>
<div class="d">指标边缘 · 待分年验证</div>
</div>
<div class="stat">
<div class="k">TOP1 多空年化</div>
<div class="v up">{{ pct(tiles.top_ls_annual) }}</div>
<div class="d">{{ tiles.top_ls_factor || '—' }} · 十组多空 · 扣费前</div>
</div>
</div>
<div class="filterbar">
<el-select v-model="runId" placeholder="选择批次" @change="load" class="run-select">
<el-option v-for="run in runs" :key="run.run_id" :label="`${run.label} · ${run.start}~${run.end}`" :value="run.run_id" />
</el-select>
<div class="chips">
<button v-for="chip in CATEGORY_CHIPS" :key="chip.key" class="chip-btn" :class="{ on: category === chip.key }" @click="category = chip.key; load()">
{{ chip.label }}
</button>
</div>
<el-input v-model="search" placeholder="搜索因子名…" class="search-input" @keyup.enter="load" />
<div class="seg">
<button :class="{ on: period === '1' }" @click="period = '1'; load()">1D</button>
<button :class="{ on: period === '5' }" @click="period = '5'; load()">5D</button>
<button :class="{ on: period === '10' }" @click="period = '10'; load()">10D</button>
</div>
</div>
<div v-loading="loading" class="tablewrap">
<div v-if="!loading && rows.length === 0" class="empty-state">
<p>暂无评估结果</p>
<router-link to="/factor/batch">去批量评估发起</router-link>
</div>
<div v-else class="tablescroll">
<table>
<thead>
<tr>
<th class="l">#</th>
<th class="l">因子</th>
<th class="l">类别</th>
<th>IC 均值</th>
<th>ICIR</th>
<th>t </th>
<th>胜率</th>
<th>多空年化</th>
<th>换手/</th>
<th>IC 趋势</th>
<th>结论</th>
<th class="l">对比</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.factor" :class="{ 'top3': row.rank <= 3, 'error-row': row.error }" @click="goToDetail(row)">
<td class="l rank">{{ row.rank }}</td>
<td class="l">
<span class="fname">{{ row.factor }}</span>
<span class="fexpr">{{ row.expression?.slice(0, 48) }}{{ row.expression?.length > 48 ? '…' : '' }}</span>
</td>
<td class="l">
<span :class="['cat-chip', getCategoryClass(row.category)]">{{ row.category }}</span>
</td>
<td>
<span v-if="row.ic_mean != null" class="icbar">
<span class="track">
<span class="fill" :class="row.ic_mean >= 0 ? 'pos' : 'neg'" :style="{ width: `${Math.abs(row.ic_mean) * 500}%` }"></span>
</span>
<span :class="row.ic_mean >= 0 ? 'pos' : 'neg'">{{ row.ic_mean.toFixed(3) }}</span>
</span>
<span v-else></span>
</td>
<td :class="row.icir != null ? (row.icir >= 0 ? 'pos' : 'neg') : ''">{{ num(row.icir, 2) }}</td>
<td :class="row.t_stat != null ? (row.t_stat >= 0 ? 'pos' : 'neg') : ''">{{ num(row.t_stat, 1) }}</td>
<td :class="row.win_rate != null ? (row.win_rate >= 0 ? 'pos' : 'neg') : ''">{{ pct(row.win_rate) }}</td>
<td :class="row.ls_annual != null ? (row.ls_annual >= 0 ? 'pos' : 'neg') : ''">{{ pct(row.ls_annual) }}</td>
<td>{{ pct(row.turnover) }}</td>
<td>
<svg v-if="row.monthly_ic?.length" width="88" height="20" class="sparkline">
<polyline :points="sparkPath(row.monthly_ic)" fill="none" stroke="var(--up)" stroke-width="1.5" />
</svg>
<span v-else></span>
</td>
<td>
<span v-if="row.error" class="st st-out" :title="row.error">
<span class="lamp"></span>错误
</span>
<span v-else class="st" :class="`st-${row.conclusion}`">
<span class="lamp"></span>{{ CONCLUSION[row.conclusion]?.label || row.conclusion }}
</span>
</td>
<td class="l">
<button
class="addbtn"
:class="{ on: compareStore.isSelected(row.factor) }"
:disabled="!!row.error"
:title="compareStore.isSelected(row.factor) ? '移出对比' : '加入对比'"
@click.stop="toggleCompare(row)"
>
{{ compareStore.isSelected(row.factor) ? '✓ 已选' : '+对比' }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="rows.length" class="tbl-foot">
<span>显示 {{ rows.length }} / {{ tiles?.factors_total || 0 }} · |IC| 降序</span>
<span>口径日频 RankIC · 红正绿负 · 多空=Q10Q1 等权 · 未做行业中性化</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.leaderboard-page {
padding: 22px 24px 60px;
min-width: 0;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: var(--sp-4);
flex-wrap: wrap;
}
.page-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
}
.page-sub {
color: var(--text-2);
font-size: 12px;
margin-top: 2px;
font-family: var(--mono);
letter-spacing: 0.02em;
}
.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;
text-decoration: none;
display: inline-block;
}
.btn:hover {
color: var(--text);
border-color: var(--text-3);
}
.btn.primary {
/* 对齐 el-button--primary 观感(新建模拟盘同款):青软底+荧光青字,
* 深底亮字对比清楚;青实底配冷白字对比不足(用户 08-30 反馈) */
color: var(--brand);
background: var(--cyan-soft);
border-color: rgba(0, 229, 255, 0.5);
font-weight: 600;
letter-spacing: 0.3px;
}
.btn.primary:hover {
color: var(--brand);
background: rgba(0, 229, 255, 0.22);
border-color: rgba(0, 229, 255, 0.65);
}
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
@media (max-width: 1100px) {
.stats {
grid-template-columns: repeat(2, 1fr);
}
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 13px 16px 11px;
position: relative;
overflow: hidden;
}
.stat::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--brand);
opacity: 0.55;
}
.stat.warn::before {
background: var(--amber);
}
.stat.ok::before {
background: var(--lamp-ok);
}
.stat .k {
color: var(--text-3);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.stat .v {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 23px;
font-weight: 600;
margin-top: 3px;
color: var(--text);
}
.stat .v.up {
color: var(--up);
}
.stat .v.cy {
color: var(--brand);
}
.stat .d {
font-size: 11px;
margin-top: 2px;
color: var(--text-3);
font-family: var(--mono);
}
.filterbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
padding: 9px 12px;
margin-bottom: var(--sp-3);
}
.run-select {
width: 200px;
}
.run-select :deep(.el-input__wrapper) {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-sm);
box-shadow: none;
}
.run-select :deep(.el-input__inner) {
font-family: var(--mono);
font-size: 11.5px;
color: var(--text-2);
}
.chips {
display: flex;
gap: 6px;
}
.chip-btn {
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
color: var(--text-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 2px 10px;
letter-spacing: 0.3px;
transition: all 0.15s var(--ease);
background: transparent;
cursor: pointer;
}
.chip-btn:hover {
color: var(--text);
border-color: var(--text-3);
}
.chip-btn.on {
color: var(--brand);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.3);
}
.search-input {
width: 170px;
}
.search-input :deep(.el-input__wrapper) {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-sm);
box-shadow: none;
}
.search-input :deep(.el-input__inner) {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
}
.seg {
display: flex;
border: 1px solid var(--border);
border-radius: var(--r-sm);
overflow: hidden;
margin-left: auto;
}
.seg button {
font-family: var(--mono);
font-size: 11px;
padding: 4px 12px;
color: var(--text-2);
background: transparent;
border: none;
cursor: pointer;
}
.seg button.on {
background: rgba(0, 229, 255, 0.12);
color: var(--brand);
}
.tablewrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
}
.tablescroll {
overflow-x: auto;
}
.empty-state {
padding: 40px;
text-align: center;
color: var(--text-3);
}
.empty-state a {
color: var(--brand);
text-decoration: none;
}
.empty-state a:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1050px;
}
thead th {
text-align: right;
font-family: var(--mono);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
color: var(--text-3);
text-transform: uppercase;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
padding: 8px 12px;
white-space: nowrap;
}
thead th.l,
tbody td.l {
text-align: left;
}
tbody td {
text-align: right;
padding: 7px 12px;
border-bottom: 1px solid var(--border-2);
font-family: var(--mono);
font-variant-numeric: tabular-nums;
font-size: 12px;
white-space: nowrap;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr {
cursor: pointer;
transition: background 0.12s;
}
tbody tr:hover {
background: var(--bg-hover);
}
tbody tr.error-row {
cursor: not-allowed;
opacity: 0.6;
}
tbody tr.error-row:hover {
background: transparent;
}
td .fname {
font-weight: 600;
color: var(--text);
}
td .fname:hover {
color: var(--brand);
}
td .fexpr {
color: var(--text-3);
font-size: 10.5px;
display: block;
margin-top: 0;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
}
.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);
}
.cat-fundamental {
color: var(--teal);
background: rgba(45, 212, 191, 0.1);
border-color: rgba(45, 212, 191, 0.3);
}
.cat-composite {
color: var(--magenta);
background: rgba(232, 121, 249, 0.1);
border-color: rgba(232, 121, 249, 0.3);
}
.pos {
color: var(--up);
}
.neg {
color: var(--down);
}
.icbar {
display: inline-flex;
align-items: center;
gap: 7px;
justify-content: flex-end;
}
.icbar .track {
width: 64px;
height: 4px;
border-radius: 2px;
background: var(--border);
position: relative;
overflow: hidden;
}
.icbar .fill {
position: absolute;
top: 0;
bottom: 0;
}
.icbar .fill.pos {
background: var(--up);
right: 0;
box-shadow: 0 0 5px rgba(255, 77, 94, 0.5);
}
.icbar .fill.neg {
background: var(--down);
left: 0;
box-shadow: 0 0 5px rgba(46, 230, 138, 0.5);
}
.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-out .lamp {
background: var(--lamp-idle);
box-shadow: none;
}
.lamp {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.rank {
color: var(--text-3);
font-size: 11px;
}
tr.top3 td.rank {
color: var(--brand);
font-weight: 700;
text-shadow: 0 0 10px rgba(0, 229, 255, 0.5);
}
.tbl-foot {
padding: 9px 14px;
color: var(--text-3);
font-family: var(--mono);
font-size: 10.5px;
letter-spacing: 0.04em;
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
border-top: 1px solid var(--border);
background: var(--panel);
}
.sparkline {
display: block;
}
.addbtn {
font-family: var(--mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.3px;
color: var(--text-2);
background: transparent;
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 2px 9px;
cursor: pointer;
transition: all 0.15s var(--ease);
}
.addbtn:hover:not(:disabled) {
color: var(--brand);
border-color: rgba(0, 229, 255, 0.4);
}
.addbtn.on {
color: var(--brand);
background: rgba(0, 229, 255, 0.1);
border-color: rgba(0, 229, 255, 0.35);
}
.addbtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>