feat(factor): tears报告方案A原生重构+「加入对比」做实(用户08-29拍板) [vps]
①后端tears序列化:sanguo_factor/tears_data.py新模块,alphalens已算分层序列(日度IC/月度聚合/十分组累计净值/多空Q10−Q1净值+最大回撤/去重叠年化/因子秩自相关)在分析时序列化为{factor}_tears.json,与tearsheet同源;FactorReport加tears_paths,GET /task/{id}/tears/{factor}端点(token header),_persist_factor同步落DB
②前端tears页:TearsPanel.vue按设计稿tab①——指标条7格+月度IC柱(红正绿负)/累计IC线双轴+月度IC热力图(年×月,CSS格)+分组累计净值Q1/Q5/Q10+多空净值(琥珀+面积+○最大回撤标注)+十分组年化(±5%虚线)+IC衰减(1/5/10D),1/5/10D全页联动;Result.vue的iframe→原生渲染;旧任务404自动回退iframe旧alphalens报告
③加入对比(设计稿tab②纯前端):factorCompare store(localStorage持久化,2~6个)+排行榜行内「+对比/✓已选」列+详情页死按钮做实(选中青色态)+全局底部托盘CompareTray(chips可删/清空/对比N因子→)+对比页Compare.vue(指标并排·行最优青色高亮/累计IC叠加多线/月度IC序列Pearson相关性矩阵前端算/十分组小倍数SVG)
测试:tears_data纯函数5+全链真实alphalens6(合成因子IC>0/分层单调/JSON可序列化)+analyzer写盘/容错2+端点401/404/200共3;factor+api+orchestrator 317全绿;npm run build绿
This commit is contained in:
@@ -34,6 +34,40 @@ export function reportUrl(taskId: string, factor: string): string {
|
||||
return `/api/v1/task/${taskId}/report/${factor}?token=${encodeURIComponent(auth.token ?? '')}`
|
||||
}
|
||||
|
||||
// —— Tears 分层序列(方案A:分析时序列化,前端 ECharts 暗色渲染) ——
|
||||
|
||||
export interface TearsPeriodData {
|
||||
count: number
|
||||
ic_mean: number | null
|
||||
ic_std: number | null
|
||||
icir: number | null
|
||||
t_stat: number | null
|
||||
win_rate: number | null
|
||||
ic_dates: string[]
|
||||
ic_values: number[]
|
||||
monthly_ic: MonthlyIcPoint[]
|
||||
quantile_keys: string[]
|
||||
nav_dates: string[]
|
||||
quantile_nav: Record<string, number[]>
|
||||
quantile_annual: Record<string, number>
|
||||
ls_nav: number[]
|
||||
ls_annual: number
|
||||
ls_max_dd: number
|
||||
}
|
||||
|
||||
export interface TearsData {
|
||||
factor: string
|
||||
generated_at?: string
|
||||
factor_autocorr: number | null
|
||||
periods: Record<string, TearsPeriodData>
|
||||
}
|
||||
|
||||
export async function getTearsData(taskId: string, factor: string): Promise<TearsData> {
|
||||
const { data } = await apiClient.get<TearsData>(`/task/${taskId}/tears/${factor}`)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
// —— Factor Evaluation API ——
|
||||
|
||||
export interface EvalRun {
|
||||
|
||||
@@ -24,6 +24,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'backtest/history', name: 'bt-history', component: () => import('@/views/backtest/History.vue') },
|
||||
{ path: 'factor/leaderboard', name: 'fc-leaderboard', component: () => import('@/views/factor/Leaderboard.vue') },
|
||||
{ path: 'factor/leaderboard/:factor', name: 'fc-leaderboard-detail', component: () => import('@/views/factor/LeaderboardDetail.vue') },
|
||||
{ path: 'factor/compare', name: 'fc-compare', component: () => import('@/views/factor/Compare.vue') },
|
||||
{ path: 'factor/batch', name: 'fc-batch', component: () => import('@/views/factor/BatchEval.vue') },
|
||||
{ path: 'factor/new', name: 'fc-new', component: () => import('@/views/factor/New.vue') },
|
||||
{ path: 'factor/progress/:id', name: 'fc-progress', component: () => import('@/views/backtest/Progress.vue') },
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 因子对比托盘(设计稿②):排行榜行内/详情页加入,2~6 个起对比,localStorage 持久化防刷新丢
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const LS_KEY = 'factorCompare.v1'
|
||||
const MAX = 6
|
||||
|
||||
// 线色板(设计稿 FCOL 同款):按加入顺序分配,托盘 chips 与对比页各图共用
|
||||
const PALETTE = ['#00e5ff', '#b48cff', '#ffb000', '#5f8fd9', '#d98fd9', '#2ee68a']
|
||||
|
||||
function loadPersisted(): { factors: string[]; runId: string } {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY)
|
||||
if (!raw) return { factors: [], runId: '' }
|
||||
const parsed = JSON.parse(raw) as { factors?: unknown; runId?: unknown }
|
||||
const factors = Array.isArray(parsed.factors)
|
||||
? parsed.factors.filter((f): f is string => typeof f === 'string').slice(0, MAX)
|
||||
: []
|
||||
return { factors, runId: typeof parsed.runId === 'string' ? parsed.runId : '' }
|
||||
} catch {
|
||||
return { factors: [], runId: '' }
|
||||
}
|
||||
}
|
||||
|
||||
interface FactorCompareState {
|
||||
factors: string[]
|
||||
runId: string
|
||||
}
|
||||
|
||||
export const useFactorCompareStore = defineStore('factorCompare', {
|
||||
state: (): FactorCompareState => loadPersisted(),
|
||||
getters: {
|
||||
count: (state): number => state.factors.length,
|
||||
canCompare: (state): boolean => state.factors.length >= 2,
|
||||
isFull: (state): boolean => state.factors.length >= MAX,
|
||||
},
|
||||
actions: {
|
||||
isSelected(factor: string): boolean {
|
||||
return this.factors.includes(factor)
|
||||
},
|
||||
/** 加入/移除;满了返回 false(调用方提示) */
|
||||
toggle(factor: string, runId?: string): boolean {
|
||||
if (this.factors.includes(factor)) {
|
||||
this.factors = this.factors.filter((f) => f !== factor)
|
||||
} else {
|
||||
if (this.factors.length >= MAX) return false
|
||||
this.factors = [...this.factors, factor]
|
||||
if (runId) this.runId = runId
|
||||
}
|
||||
this.persist()
|
||||
return true
|
||||
},
|
||||
remove(factor: string): void {
|
||||
this.factors = this.factors.filter((f) => f !== factor)
|
||||
this.persist()
|
||||
},
|
||||
clear(): void {
|
||||
this.factors = []
|
||||
this.runId = ''
|
||||
this.persist()
|
||||
},
|
||||
colorOf(factor: string): string {
|
||||
const i = this.factors.indexOf(factor)
|
||||
return i >= 0 ? PALETTE[i % PALETTE.length] : PALETTE[0]
|
||||
},
|
||||
persist(): void {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({ factors: this.factors, runId: this.runId }))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import CompareTray from '@/views/factor/CompareTray.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -234,6 +235,9 @@ function onLogout(): void {
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
<!-- 因子对比托盘(设计稿②):全局底部,加入≥2 个因子后浮现 -->
|
||||
<CompareTray />
|
||||
|
||||
<!-- Cmd+K 命令面板 -->
|
||||
<div v-if="cmdOpen" class="cmd-overlay" @click.self="closeCmd">
|
||||
<div class="cmd-box term-corners">
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
<script setup lang="ts">
|
||||
// 因子对比页(设计稿②,用户 2026-08-29 拍板做实):纯前端——现有 eval/detail ×N,
|
||||
// 相关性=月度 IC 序列 Pearson 前端算,零后端改动。
|
||||
// 视觉蓝本 docs/factor_research/factor-tears-compare-mockup.html tab②。
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { getEvalDetail, type MonthlyIcPoint } from '@/api/factor'
|
||||
import { useFactorCompareStore } from '@/stores/factorCompare'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTooltip, darkAxis, UP, DOWN, TEXT_DIM } from '@/utils/echartsDark'
|
||||
|
||||
interface PeriodMetrics {
|
||||
count?: number
|
||||
ic_mean: number | null
|
||||
ic_std: number | null
|
||||
icir: number | null
|
||||
t_stat: number | null
|
||||
win_rate: number | null
|
||||
monthly_ic: MonthlyIcPoint[] | null
|
||||
ls_annual: number | null
|
||||
deciles: (number | null)[] | null
|
||||
conclusion?: string
|
||||
}
|
||||
|
||||
interface FactorMetrics {
|
||||
factor: string
|
||||
category: string
|
||||
turnover: number | null
|
||||
periods: Record<string, PeriodMetrics>
|
||||
}
|
||||
|
||||
const store = useFactorCompareStore()
|
||||
const period = ref<'1' | '5' | '10'>('5')
|
||||
|
||||
const loading = ref(false)
|
||||
const loadErr = ref('')
|
||||
const factors = ref<FactorMetrics[]>([])
|
||||
const failed = ref<string[]>([])
|
||||
|
||||
const overlayEl = ref<HTMLDivElement>()
|
||||
const overlay = useChart(overlayEl)
|
||||
|
||||
function mOf(f: FactorMetrics): PeriodMetrics | null {
|
||||
return f.periods?.[period.value] ?? null
|
||||
}
|
||||
|
||||
function pct(v: number | null | undefined, digits = 1): string {
|
||||
return typeof v === 'number' ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(digits)}%` : '—'
|
||||
}
|
||||
function num(v: number | null | undefined, digits = 3): string {
|
||||
return typeof v === 'number' ? v.toFixed(digits) : '—'
|
||||
}
|
||||
function sgnCls(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number' || v === 0) return ''
|
||||
return v > 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
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)' },
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!store.canCompare) return
|
||||
loading.value = true
|
||||
loadErr.value = ''
|
||||
failed.value = []
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
store.factors.map(async (name) => {
|
||||
try {
|
||||
const d = await getEvalDetail(name, store.runId || undefined)
|
||||
const metrics = d.metrics as Record<string, unknown>
|
||||
const periods: Record<string, PeriodMetrics> = {}
|
||||
for (const p of ['1', '5', '10']) {
|
||||
const m = metrics[p] as PeriodMetrics | undefined
|
||||
if (m) periods[p] = m
|
||||
}
|
||||
const fm: FactorMetrics = {
|
||||
factor: d.factor,
|
||||
category: d.category,
|
||||
turnover: typeof metrics.turnover === 'number' ? metrics.turnover : null,
|
||||
periods,
|
||||
}
|
||||
return fm
|
||||
} catch {
|
||||
failed.value = [...failed.value, name]
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
factors.value = results.filter((f): f is FactorMetrics => f !== null)
|
||||
await nextTick()
|
||||
renderAll()
|
||||
} catch {
|
||||
loadErr.value = '对比数据加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// —— 指标并排表(行最优青色高亮;换手=min 最优,其余=max;t 值按 |t|) ——
|
||||
|
||||
interface MetricRow {
|
||||
k: string
|
||||
get: (f: FactorMetrics) => number | null
|
||||
fmt: (v: number | null) => string
|
||||
cls: (v: number | null) => string
|
||||
best: 'max' | 'min' | 'none'
|
||||
}
|
||||
|
||||
const metricRows: MetricRow[] = [
|
||||
{ k: 'IC 均值', get: (f) => mOf(f)?.ic_mean ?? null, fmt: (v) => num(v), cls: sgnCls, best: 'max' },
|
||||
{ k: 'ICIR', get: (f) => mOf(f)?.icir ?? null, fmt: (v) => num(v, 2), cls: sgnCls, best: 'max' },
|
||||
{ k: 't 值', get: (f) => Math.abs(mOf(f)?.t_stat ?? 0) || null, fmt: (v) => num(v, 1), cls: () => '', best: 'max' },
|
||||
{ k: 'IC 胜率', get: (f) => mOf(f)?.win_rate ?? null, fmt: (v) => pct(v), cls: () => '', best: 'max' },
|
||||
{ k: '多空年化', get: (f) => mOf(f)?.ls_annual ?? null, fmt: (v) => pct(v), cls: sgnCls, best: 'max' },
|
||||
{ k: '日均换手', get: (f) => f.turnover, fmt: (v) => pct(v), cls: () => '', best: 'min' },
|
||||
]
|
||||
|
||||
function bestIdx(row: MetricRow): number {
|
||||
if (row.best === 'none' || !factors.value.length) return -1
|
||||
let bi = -1
|
||||
let bv: number | null = null
|
||||
factors.value.forEach((f, i) => {
|
||||
const v = row.get(f)
|
||||
if (v == null) return
|
||||
if (bv == null || (row.best === 'min' ? v < bv : v > bv)) { bv = v; bi = i }
|
||||
})
|
||||
return bi
|
||||
}
|
||||
|
||||
/** t 值行展示原始值(含符号),排序键是 |t|(metricRows.get 已取 abs) */
|
||||
function tStatDisplay(f: FactorMetrics): number | null {
|
||||
return mOf(f)?.t_stat ?? null
|
||||
}
|
||||
|
||||
// —— 月度 IC Pearson(公共月份对齐) ——
|
||||
|
||||
function pearsonAligned(a: MonthlyIcPoint[], b: MonthlyIcPoint[]): number | null {
|
||||
const mb = new Map(b.map((p) => [p.month, p.ic]))
|
||||
const xs: number[] = []
|
||||
const ys: number[] = []
|
||||
for (const p of a) {
|
||||
const y = mb.get(p.month)
|
||||
if (typeof y === 'number') { xs.push(p.ic); ys.push(y) }
|
||||
}
|
||||
const n = xs.length
|
||||
if (n < 3) return null
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (let i = 0; i < n; i++) { sx += xs[i]; sy += ys[i] }
|
||||
const mx = sx / n
|
||||
const my = sy / n
|
||||
let cov = 0
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dx = xs[i] - mx
|
||||
const dy = ys[i] - my
|
||||
cov += dx * dy
|
||||
vx += dx * dx
|
||||
vy += dy * dy
|
||||
}
|
||||
if (vx <= 0 || vy <= 0) return null
|
||||
return cov / Math.sqrt(vx * vy)
|
||||
}
|
||||
|
||||
const corrMatrix = computed<(number | null)[][]>(() =>
|
||||
factors.value.map((a) =>
|
||||
factors.value.map((b) => {
|
||||
if (a.factor === b.factor) return 1
|
||||
const ma = mOf(a)?.monthly_ic
|
||||
const mb = mOf(b)?.monthly_ic
|
||||
if (!ma?.length || !mb?.length) return null
|
||||
return pearsonAligned(ma, mb)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
function corrCellStyle(v: number | null): Record<string, string> {
|
||||
if (v == null) return { background: 'var(--panel)', color: 'var(--text-3)' }
|
||||
const a = Math.min(1, Math.abs(v))
|
||||
return {
|
||||
background: `rgba(0,229,255,${(0.04 + a * 0.4).toFixed(2)})`,
|
||||
color: a > 0.75 ? '#05080d' : 'var(--text-2)',
|
||||
}
|
||||
}
|
||||
|
||||
// —— 累计 IC 叠加 ——
|
||||
|
||||
function renderOverlay(): void {
|
||||
const fs = factors.value
|
||||
if (!fs.length) return
|
||||
// 月份并集排序(各因子月度覆盖可能有差),缺失月份断点 null
|
||||
const monthSet = new Set<string>()
|
||||
fs.forEach((f) => (mOf(f)?.monthly_ic ?? []).forEach((p) => monthSet.add(p.month)))
|
||||
const months = [...monthSet].sort()
|
||||
const cumSeries = fs.map((f) => {
|
||||
const mi = mOf(f)?.monthly_ic ?? []
|
||||
const byMonth = new Map(mi.map((p) => [p.month, p.ic]))
|
||||
let c = 0
|
||||
const vals = months.map((m) => {
|
||||
const v = byMonth.get(m)
|
||||
if (typeof v !== 'number') return null
|
||||
c += v
|
||||
return Math.round(c * 1e4) / 1e4
|
||||
})
|
||||
return { name: f.factor, vals }
|
||||
})
|
||||
overlay.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? v.toFixed(2) : '—') },
|
||||
legend: { top: 4, right: 8, textStyle: { color: TEXT_DIM, fontSize: 11 }, itemWidth: 14, itemHeight: 9 },
|
||||
grid: { left: 48, right: 24, top: 34, bottom: 30 },
|
||||
xAxis: { type: 'category', data: months, ...darkAxis() },
|
||||
yAxis: { type: 'value', ...darkAxis() },
|
||||
series: cumSeries.map((s) => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: s.vals,
|
||||
symbol: 'none',
|
||||
connectNulls: false,
|
||||
lineStyle: { color: store.colorOf(s.name), width: 1.8 },
|
||||
itemStyle: { color: store.colorOf(s.name) },
|
||||
emphasis: { focus: 'series' },
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// —— 十分组小倍数(SVG,同设计稿 mini 卡) ——
|
||||
|
||||
function decilesSvg(vals: (number | null)[]): string {
|
||||
const W = 300
|
||||
const H = 128
|
||||
const pl = 8
|
||||
const pr = 8
|
||||
const pt = 16
|
||||
const pb = 16
|
||||
const ok = vals.filter((v): v is number => v != null)
|
||||
const mx = Math.max(0.02, ...ok.map(Math.abs)) * 1.18
|
||||
const midY = pt + (H - pt - pb) / 2
|
||||
const half = (H - pt - pb) / 2
|
||||
const span = W - pl - pr
|
||||
let s = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto" role="img">`
|
||||
s += `<line x1="${pl}" x2="${W - pr}" y1="${midY}" y2="${midY}" stroke="var(--border)"/>`
|
||||
vals.forEach((v, i) => {
|
||||
const cx = pl + (i + 0.5) * span / 10
|
||||
const bw = span / 10 * 0.55
|
||||
if (v != null) {
|
||||
const y = midY - (v / mx) * half
|
||||
s += `<rect x="${(cx - bw / 2).toFixed(1)}" y="${Math.min(y, midY).toFixed(1)}" width="${bw.toFixed(1)}" height="${Math.abs(y - midY).toFixed(1)}" fill="${v >= 0 ? UP : DOWN}"/>`
|
||||
s += `<text x="${cx}" y="${v >= 0 ? y - 3 : y + 10}" fill="${v >= 0 ? UP : DOWN}" font-size="8" text-anchor="middle">${(v >= 0 ? '+' : '') + (v * 100).toFixed(1)}%</text>`
|
||||
}
|
||||
s += `<text x="${cx}" y="${H - 4}" fill="var(--text-3)" font-size="8" text-anchor="middle">D${i + 1}</text>`
|
||||
})
|
||||
return s + '</svg>'
|
||||
}
|
||||
|
||||
const minis = computed(() =>
|
||||
factors.value.map((f) => ({
|
||||
factor: f.factor,
|
||||
color: store.colorOf(f.factor),
|
||||
icir: mOf(f)?.icir ?? null,
|
||||
svg: decilesSvg(mOf(f)?.deciles ?? []),
|
||||
}))
|
||||
)
|
||||
|
||||
function renderAll(): void {
|
||||
renderOverlay()
|
||||
}
|
||||
|
||||
watch(period, async () => { await nextTick(); renderAll() })
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compare-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1 class="page-title">因子对比</h1>
|
||||
<p class="page-sub">
|
||||
{{ factors.length }} 因子 · {{ period }}D 口径 · {{ store.runId || '最新批次' }}
|
||||
<span v-if="failed.length" class="fail-note">· {{ failed.length }} 个加载失败已略过</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-right">
|
||||
<div class="seg">
|
||||
<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>
|
||||
<router-link to="/factor/leaderboard" class="btn">返回排行榜</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.canCompare" class="empty-state">
|
||||
<p>对比至少需要 2 个因子——去排行榜行内「+对比」添加</p>
|
||||
<router-link to="/factor/leaderboard">前往排行榜</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 因子 chips -->
|
||||
<div class="chip-row">
|
||||
<span
|
||||
v-for="f in factors"
|
||||
:key="f.factor"
|
||||
class="tchip mono"
|
||||
:style="{ borderColor: store.colorOf(f.factor) + '66' }"
|
||||
>
|
||||
<span :style="{ color: store.colorOf(f.factor) }">●</span>{{ f.factor }}
|
||||
<span class="cat">{{ f.category }}</span>
|
||||
</span>
|
||||
<span class="hint-inline">在排行榜行内 + 或详情页「加入对比」增删</span>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<div v-if="loadErr" class="empty-state"><p>{{ loadErr }}</p></div>
|
||||
|
||||
<template v-else-if="factors.length">
|
||||
<!-- 指标并排 -->
|
||||
<div class="chart-card main-card">
|
||||
<h3>指标并排<span class="h3-tag">行最优青色高亮</span></h3>
|
||||
<div class="tblwrap">
|
||||
<table class="cmp-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rk">指标({{ period }}D)</th>
|
||||
<th v-for="f in factors" :key="f.factor" class="mcol" :style="{ color: store.colorOf(f.factor) }">
|
||||
{{ f.factor }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="rk">结论</td>
|
||||
<td v-for="f in factors" :key="f.factor">
|
||||
<span class="st"><span class="lamp" :style="{ background: CONCLUSION[mOf(f)?.conclusion ?? 'eliminated']?.color }"></span>{{ CONCLUSION[mOf(f)?.conclusion ?? 'eliminated']?.label ?? '—' }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="row in metricRows" :key="row.k">
|
||||
<td class="rk">{{ row.k }}<template v-if="row.k === 't 值' || row.k === '日均换手'"><span class="rk-sub">{{ row.k === 't 值' ? '按|t|最优' : '越低越好' }}</span></template></td>
|
||||
<td
|
||||
v-for="(f, i) in factors"
|
||||
:key="f.factor"
|
||||
:class="{ best: i === bestIdx(row) }"
|
||||
>
|
||||
<span
|
||||
v-if="row.k === 't 值'"
|
||||
class="mono val"
|
||||
:class="sgnCls(tStatDisplay(f))"
|
||||
>{{ num(tStatDisplay(f), 1) }}</span>
|
||||
<span v-else class="mono val" :class="row.cls(row.get(f))">{{ row.fmt(row.get(f)) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="chart-card">
|
||||
<h3>累计 IC 叠加</h3>
|
||||
<div class="cs">各因子累计 RankIC 曲线 · 斜率=预测力</div>
|
||||
<div ref="overlayEl" class="cbody" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>IC 序列相关性<span class="h3-tag">月度 IC Pearson</span></h3>
|
||||
<div class="cs">高相关=同质因子,为去重铺路</div>
|
||||
<div class="cmx" :style="{ gridTemplateColumns: `72px repeat(${factors.length}, 1fr)` }">
|
||||
<div></div>
|
||||
<div v-for="f in factors" :key="'h-' + f.factor" class="cl mono" :style="{ color: store.colorOf(f.factor) }">{{ f.factor }}</div>
|
||||
<template v-for="(rowF, i) in factors" :key="'r-' + rowF.factor">
|
||||
<div class="cl mono" :style="{ color: store.colorOf(rowF.factor) }">{{ rowF.factor }}</div>
|
||||
<div
|
||||
v-for="(c, j) in corrMatrix[i]"
|
||||
:key="j"
|
||||
class="cc mono"
|
||||
:style="corrCellStyle(i === j ? 1 : c)"
|
||||
>{{ i === j ? '—' : c == null ? '·' : c.toFixed(2) }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 十分组小倍数 -->
|
||||
<div class="minis-grid">
|
||||
<div v-for="m in minis" :key="m.factor" class="chart-card mini">
|
||||
<h3 class="mono" :style="{ color: m.color }">{{ m.factor }}<span class="h3c">{{ period }}D · ICIR {{ num(m.icir, 2) }}</span></h3>
|
||||
<div v-html="m.svg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="foot-note">
|
||||
口径:日频 RankIC · 批次 {{ store.runId || 'latest' }} · 相关性=月度 IC 公共月份 Pearson · 换手为全周期口径
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compare-page {
|
||||
padding: 22px 24px 80px;
|
||||
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;
|
||||
}
|
||||
|
||||
.fail-note { color: var(--amber); }
|
||||
|
||||
.head-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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); }
|
||||
|
||||
.empty-state {
|
||||
padding: 60px;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
|
||||
.empty-state a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.tchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
.tchip .cat {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.hint-inline { font-size: 10.5px; color: var(--text-3); }
|
||||
|
||||
.chart-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-md);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.chart-card h3 {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.h3-tag {
|
||||
font-size: 10px;
|
||||
color: var(--text-3);
|
||||
margin-left: 8px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cs { font-size: 11px; color: var(--text-3); margin: 4px 0 8px; }
|
||||
.cbody { width: 100%; height: 250px; }
|
||||
|
||||
.main-card { margin-bottom: var(--sp-3); }
|
||||
|
||||
.tblwrap { overflow-x: auto; margin-top: 8px; }
|
||||
|
||||
.cmp-tbl { border-collapse: collapse; width: 100%; }
|
||||
|
||||
.cmp-tbl th {
|
||||
text-align: right;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 6px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmp-tbl td {
|
||||
text-align: right;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmp-tbl tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.cmp-tbl .rk {
|
||||
text-align: left !important;
|
||||
color: var(--text-3);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.rk-sub {
|
||||
display: block;
|
||||
font-size: 9.5px;
|
||||
color: var(--text-3);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.cmp-tbl .best {
|
||||
background: var(--cyan-soft);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 229, 255, 0.35);
|
||||
}
|
||||
|
||||
.val.up { color: var(--up); }
|
||||
.val.down { color: var(--down); }
|
||||
|
||||
.st {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.lamp {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
/* 相关性矩阵(青色 alpha 格,同设计稿) */
|
||||
.cmx {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cmx .cl {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmx .cc {
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11.5px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.minis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.mini .h3c {
|
||||
font-size: 10px;
|
||||
margin-left: 8px;
|
||||
color: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.foot-note {
|
||||
color: var(--text-3);
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
// 对比托盘(设计稿②):排行榜/详情页加入的因子沉在这里,2~6 个可发起对比。
|
||||
// 挂 Layout 底部,对比页自身不显示(页面内已有 chips)。
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useFactorCompareStore } from '@/stores/factorCompare'
|
||||
|
||||
const store = useFactorCompareStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const visible = computed(() => store.count > 0 && route.name !== 'fc-compare')
|
||||
|
||||
function goCompare(): void {
|
||||
router.push('/factor/compare')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="tray">
|
||||
<div v-if="visible" class="compare-tray">
|
||||
<span class="tray-label mono">对比托盘</span>
|
||||
<div class="chips">
|
||||
<span
|
||||
v-for="f in store.factors"
|
||||
:key="f"
|
||||
class="tchip mono"
|
||||
:style="{ borderColor: store.colorOf(f) + '66' }"
|
||||
>
|
||||
<span :style="{ color: store.colorOf(f) }">●</span>{{ f }}
|
||||
<span class="x" title="移除" @click="store.remove(f)">✕</span>
|
||||
</span>
|
||||
<span class="hint-inline">{{ store.count }} / 6 · 至少 2 个</span>
|
||||
</div>
|
||||
<button class="go-btn" :disabled="!store.canCompare" @click="goCompare">
|
||||
对比 {{ store.count }} 因子 →
|
||||
</button>
|
||||
<button class="clear-btn" title="清空" @click="store.clear()">清空</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compare-tray {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 18px;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: var(--bg-overlay);
|
||||
border: 1px solid rgba(0, 229, 255, 0.28);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow), var(--glow-cyan);
|
||||
padding: 9px 12px 9px 14px;
|
||||
}
|
||||
|
||||
.tray-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tchip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 2px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tchip .x {
|
||||
cursor: pointer;
|
||||
color: var(--text-3);
|
||||
font-size: 10px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.tchip .x:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.hint-inline {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.go-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
background: var(--cyan-soft);
|
||||
border: 1px solid rgba(0, 229, 255, 0.35);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s var(--ease);
|
||||
}
|
||||
|
||||
.go-btn:hover:not(:disabled) {
|
||||
background: rgba(0, 229, 255, 0.2);
|
||||
}
|
||||
|
||||
.go-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
font-size: 11px;
|
||||
color: var(--text-3);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
color: var(--text-2);
|
||||
border-color: var(--text-3);
|
||||
}
|
||||
|
||||
.tray-enter-active,
|
||||
.tray-leave-active {
|
||||
transition: all 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.tray-enter-from,
|
||||
.tray-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
<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')
|
||||
@@ -74,6 +77,11 @@ function goToDetail(row: LeaderboardRow) {
|
||||
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',
|
||||
@@ -167,6 +175,7 @@ onMounted(async () => {
|
||||
<th>换手/日</th>
|
||||
<th>IC 趋势</th>
|
||||
<th>结论</th>
|
||||
<th class="l">对比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -207,6 +216,17 @@ onMounted(async () => {
|
||||
<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>
|
||||
@@ -480,7 +500,7 @@ onMounted(async () => {
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
min-width: 1050px;
|
||||
}
|
||||
|
||||
thead th {
|
||||
@@ -685,4 +705,34 @@ tr.top3 td.rank {
|
||||
.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>
|
||||
|
||||
@@ -4,10 +4,12 @@ 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 { useFactorCompareStore } from '@/stores/factorCompare'
|
||||
import type { EChartsCoreOption } from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const compareStore = useFactorCompareStore()
|
||||
const period = ref<'1' | '5' | '10'>('1')
|
||||
const detail = ref<EvalDetail | null>(null)
|
||||
const error = ref('')
|
||||
@@ -38,6 +40,13 @@ function goToNewFactor() {
|
||||
router.push({ path: '/factor/new', query: { factor: detail.value?.factor || '' } })
|
||||
}
|
||||
|
||||
const inCompare = computed(() => detail.value ? compareStore.isSelected(detail.value.factor) : false)
|
||||
|
||||
function toggleCompare() {
|
||||
if (!detail.value) return
|
||||
compareStore.toggle(detail.value.factor, route.query.run ? String(route.query.run) : undefined)
|
||||
}
|
||||
|
||||
const currentMetrics = computed(() => {
|
||||
if (!detail.value?.metrics) return null
|
||||
const metrics = detail.value.metrics as Record<string, unknown>
|
||||
@@ -306,7 +315,9 @@ function pct(val: number | null | undefined): string {
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="btn primary" @click="goToNewFactor">查看完整 tears 报告</button>
|
||||
<button class="btn">加入对比</button>
|
||||
<button class="btn" :class="{ chosen: inCompare }" @click="toggleCompare">
|
||||
{{ inCompare ? '✓ 已加入对比' : '加入对比' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -575,6 +586,13 @@ function pct(val: number | null | undefined): string {
|
||||
background: var(--cyan-dim);
|
||||
}
|
||||
|
||||
/* 已加入对比的选中态(青色终端风) */
|
||||
.btn.chosen {
|
||||
color: var(--brand);
|
||||
border-color: rgba(0, 229, 255, 0.35);
|
||||
background: var(--cyan-soft);
|
||||
}
|
||||
|
||||
.cat-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getIcSummary, reportUrl } from '@/api/factor'
|
||||
import { getIcSummary } from '@/api/factor'
|
||||
import TearsPanel from './TearsPanel.vue'
|
||||
|
||||
interface IcStats {
|
||||
mean?: number
|
||||
@@ -124,12 +125,12 @@ onMounted(async () => {
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- tears 报告 -->
|
||||
<!-- tears 报告(方案A:原生 ECharts 暗色渲染,旧任务自动回退 iframe alphalens 报告) -->
|
||||
<el-card class="blk" shadow="never" v-if="factors.length">
|
||||
<template #header><span class="section-title">分层 tears 报告</span></template>
|
||||
<el-tabs v-model="activeReport" type="card">
|
||||
<el-tab-pane v-for="f in factors" :key="f" :label="f" :name="f">
|
||||
<iframe :src="reportUrl(taskId, f)" class="report-frame" />
|
||||
<el-tab-pane v-for="f in factors" :key="f" :label="f" :name="f" lazy>
|
||||
<TearsPanel :task-id="taskId" :factor="f" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
@@ -145,5 +146,4 @@ onMounted(async () => {
|
||||
.m-value { margin-top: 6px; font-size: 20px; font-weight: 700; color: var(--text); }
|
||||
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.report-frame { width: 100%; height: 620px; border: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
// tears 分层序列原生渲染(方案A,用户 2026-08-29 拍板)——替代 iframe 内嵌
|
||||
// alphalens 浅色 HTML:同一批序列(月度/累计IC、十分组净值、多空净值)ECharts 暗色化。
|
||||
// 视觉蓝本 docs/factor_research/factor-tears-compare-mockup.html tab①。
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import type { AxiosError } from 'axios'
|
||||
import { getTearsData, reportUrl } from '@/api/factor'
|
||||
import type { TearsData, TearsPeriodData } from '@/api/factor'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import { darkTooltip, darkAxis, UP, DOWN, BRAND, AMBER, TEXT_DIM, AXIS, SPLIT } from '@/utils/echartsDark'
|
||||
|
||||
const props = defineProps<{ taskId: string; factor: string }>()
|
||||
|
||||
const PERIODS = ['1D', '5D', '10D'] as const
|
||||
const loading = ref(true)
|
||||
const err = ref('')
|
||||
// 旧任务(本功能上线前分析)无 tears JSON → 回退 iframe 旧 alphalens 报告
|
||||
const stale = ref(false)
|
||||
const data = ref<TearsData | null>(null)
|
||||
const period = ref<(typeof PERIODS)[number]>('1D')
|
||||
|
||||
const d = computed<TearsPeriodData | null>(() => data.value?.periods?.[period.value] ?? null)
|
||||
|
||||
const icEl = ref<HTMLDivElement>()
|
||||
const qnavEl = ref<HTMLDivElement>()
|
||||
const lsEl = ref<HTMLDivElement>()
|
||||
const decEl = ref<HTMLDivElement>()
|
||||
const decayEl = ref<HTMLDivElement>()
|
||||
const ic = useChart(icEl)
|
||||
const qnav = useChart(qnavEl)
|
||||
const ls = useChart(lsEl)
|
||||
const dec = useChart(decEl)
|
||||
const decay = useChart(decayEl)
|
||||
|
||||
function fmt(v: number | null | undefined, digits: number): string {
|
||||
return typeof v === 'number' ? v.toFixed(digits) : '—'
|
||||
}
|
||||
|
||||
// darkAxis() 返回 Record<string, unknown>,axisLabel 单取是 unknown → 提一次供各处 spread
|
||||
const MONO_LABEL = darkAxis().axisLabel as Record<string, unknown>
|
||||
function pct(v: number | null | undefined, digits = 1): string {
|
||||
return typeof v === 'number' ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(digits)}%` : '—'
|
||||
}
|
||||
function sgn(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number' || v === 0) return ''
|
||||
return v > 0 ? 'up' : 'down'
|
||||
}
|
||||
|
||||
// 指标条 7 格(设计稿 metric-strip)
|
||||
const strip = computed(() => {
|
||||
const p = d.value
|
||||
if (!p) return []
|
||||
return [
|
||||
{ k: `IC 均值 (${period.value})`, v: fmt(p.ic_mean, 3), cls: sgn(p.ic_mean) },
|
||||
{ k: 'ICIR', v: fmt(p.icir, 2), cls: sgn(p.icir) },
|
||||
{ k: 't 值', v: fmt(p.t_stat, 1), cls: sgn(p.t_stat) },
|
||||
{ k: 'IC 胜率', v: fmt(p.win_rate != null ? p.win_rate * 100 : null, 1) + '%', cls: '' },
|
||||
{ k: '多空年化', v: pct(p.ls_annual), cls: sgn(p.ls_annual) },
|
||||
{ k: '多空最大回撤', v: pct(p.ls_max_dd), cls: 'down' },
|
||||
{ k: '因子自相关', v: fmt(data.value?.factor_autocorr ?? null, 2), cls: '' },
|
||||
]
|
||||
})
|
||||
|
||||
// —— 月度 IC 热力图(CSS grid,红正绿负,alpha 随 |IC|) ——
|
||||
|
||||
const heatRows = computed<{ year: string; cells: (number | null)[] }[]>(() => {
|
||||
const mi = d.value?.monthly_ic ?? []
|
||||
const byYear = new Map<string, (number | null)[]>()
|
||||
for (const p of mi) {
|
||||
const y = p.month.slice(0, 4)
|
||||
let cells = byYear.get(y)
|
||||
if (!cells) { cells = Array<number | null>(12).fill(null); byYear.set(y, cells) }
|
||||
cells[Number(p.month.slice(5)) - 1] = p.ic
|
||||
}
|
||||
return [...byYear.entries()].map(([year, cells]) => ({ year, cells }))
|
||||
})
|
||||
|
||||
function heatCell(v: number | null): Record<string, string> {
|
||||
if (v == null) return { background: 'var(--panel)', color: 'var(--text-3)' }
|
||||
const a = Math.min(1, Math.abs(v) / 0.16)
|
||||
const rgb = v >= 0 ? '255,77,94' : '46,230,138'
|
||||
return {
|
||||
background: `rgba(${rgb},${(0.08 + a * 0.72).toFixed(2)})`,
|
||||
color: a > 0.55 ? '#05080d' : 'var(--text-2)',
|
||||
}
|
||||
}
|
||||
function heatText(v: number | null): string {
|
||||
if (v == null) return ''
|
||||
const r = Math.round(v * 100) / 100
|
||||
if (r === 0) return '0'
|
||||
return r > 0 ? r.toFixed(2).replace(/^0/, '') : r.toFixed(2).replace(/^-0/, '-')
|
||||
}
|
||||
|
||||
// —— 图表渲染 ——
|
||||
|
||||
function renderIc(): void {
|
||||
const p = d.value
|
||||
if (!p || !p.monthly_ic.length) return
|
||||
const mi = p.monthly_ic
|
||||
const months = mi.map((x) => x.month.slice(2))
|
||||
let c = 0
|
||||
const cum = mi.map((x) => (c += x.ic, Math.round(c * 1e4) / 1e4))
|
||||
const mx = Math.max(0.12, ...mi.map((x) => Math.abs(x.ic))) * 1.15
|
||||
ic.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? v.toFixed(3) : String(v)) },
|
||||
legend: { top: 4, right: 8, textStyle: { color: TEXT_DIM, fontSize: 11 }, itemWidth: 14, itemHeight: 9 },
|
||||
grid: { left: 44, right: 52, top: 34, bottom: 28 },
|
||||
xAxis: { type: 'category', data: months, ...darkAxis() },
|
||||
yAxis: [
|
||||
{ type: 'value', min: -mx, max: mx, ...darkAxis(), axisLabel: { ...MONO_LABEL, formatter: (v: number) => v.toFixed(2) } },
|
||||
{ type: 'value', max: (v: { max: number }) => v.max * 1.05, ...darkAxis(), axisLabel: { color: BRAND, fontSize: 11, formatter: (v: number) => v.toFixed(1) }, splitLine: { show: false } },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '月度IC', type: 'bar',
|
||||
data: mi.map((x) => ({ value: x.ic, itemStyle: { color: x.ic >= 0 ? UP : DOWN } })),
|
||||
},
|
||||
{
|
||||
name: '累计IC', type: 'line', yAxisIndex: 1, data: cum, symbol: 'none',
|
||||
lineStyle: { color: BRAND, width: 1.8 },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function renderQNav(): void {
|
||||
const p = d.value
|
||||
if (!p || !p.nav_dates.length) return
|
||||
const seriesDef = [
|
||||
{ q: '10', name: 'Q10 最高组', color: UP },
|
||||
{ q: '5', name: 'Q5', color: TEXT_DIM },
|
||||
{ q: '1', name: 'Q1 最低组', color: DOWN },
|
||||
].filter((s) => p.quantile_nav[s.q]?.length)
|
||||
qnav.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? (v * 100).toFixed(1) + '%' : String(v)) },
|
||||
legend: { top: 4, right: 8, textStyle: { color: TEXT_DIM, fontSize: 11 }, itemWidth: 14, itemHeight: 9 },
|
||||
grid: { left: 52, right: 20, top: 34, bottom: 30 },
|
||||
xAxis: { type: 'time', ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, ...darkAxis(), axisLabel: { ...MONO_LABEL, formatter: (v: number) => (v * 100).toFixed(0) + '%' } },
|
||||
series: seriesDef.map((s) => ({
|
||||
name: s.name, type: 'line', data: p.nav_dates.map((dt, i) => [dt, p.quantile_nav[s.q][i]]),
|
||||
symbol: 'none', lineStyle: { color: s.color, width: 1.8 },
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
function renderLs(): void {
|
||||
const p = d.value
|
||||
if (!p || !p.ls_nav.length) return
|
||||
const nav = p.ls_nav
|
||||
// 最大回撤点(前端从净值序列推,与后端 ls_max_dd 同法):谷底 + 对应峰值起点
|
||||
let peak = -Infinity
|
||||
let peakIdx = 0
|
||||
let ddIdx = 0
|
||||
let ddVal = 0
|
||||
let peakAtDd = 0
|
||||
nav.forEach((v, i) => {
|
||||
if (v > peak) { peak = v; peakIdx = i }
|
||||
const dd = v / peak - 1
|
||||
if (dd < ddVal) { ddVal = dd; ddIdx = i; peakAtDd = peakIdx }
|
||||
})
|
||||
ls.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? (v * 100).toFixed(1) + '%' : String(v)) },
|
||||
grid: { left: 52, right: 20, top: 30, bottom: 30 },
|
||||
xAxis: { type: 'time', ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, ...darkAxis(), axisLabel: { ...MONO_LABEL, formatter: (v: number) => (v * 100).toFixed(0) + '%' } },
|
||||
series: [{
|
||||
name: 'Q10−Q1 多空净值', type: 'line',
|
||||
data: p.nav_dates.map((dt, i) => [dt, nav[i]]),
|
||||
symbol: 'none', lineStyle: { color: AMBER, width: 1.8 },
|
||||
areaStyle: { color: 'rgba(255,176,0,0.10)' },
|
||||
markPoint: {
|
||||
symbol: 'circle', symbolSize: 10,
|
||||
itemStyle: { color: 'transparent', borderColor: DOWN, borderWidth: 1.5 },
|
||||
label: {
|
||||
show: true, position: 'top', color: DOWN, fontSize: 10,
|
||||
formatter: `最大回撤 ${(ddVal * 100).toFixed(1)}%\n${p.nav_dates[peakAtDd]?.slice(0, 7) ?? ''}~${p.nav_dates[ddIdx]?.slice(0, 7) ?? ''}`,
|
||||
},
|
||||
data: ddVal < 0 ? [{ coord: [p.nav_dates[ddIdx], nav[ddIdx]] }] : [],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function renderDec(): void {
|
||||
const p = d.value
|
||||
if (!p || !p.quantile_keys.length) return
|
||||
const ks = p.quantile_keys
|
||||
dec.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : String(v)) },
|
||||
grid: { left: 48, right: 16, top: 30, bottom: 28 },
|
||||
xAxis: { type: 'category', data: ks.map((k) => `D${k}`), ...darkAxis() },
|
||||
yAxis: { type: 'value', ...darkAxis(), axisLabel: { ...MONO_LABEL, formatter: (v: number) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(0)}%` } },
|
||||
series: [{
|
||||
type: 'bar', barWidth: '55%',
|
||||
data: ks.map((k) => {
|
||||
const v = p.quantile_annual[k] ?? 0
|
||||
return { value: v, itemStyle: { color: v >= 0 ? UP : DOWN } }
|
||||
}),
|
||||
label: {
|
||||
show: true, position: 'top', color: TEXT_DIM, fontSize: 10,
|
||||
formatter: (o: { value: number }) => `${o.value >= 0 ? '+' : ''}${(o.value * 100).toFixed(1)}%`,
|
||||
},
|
||||
markLine: {
|
||||
silent: true, symbol: 'none',
|
||||
lineStyle: { color: AXIS, type: 'dashed' },
|
||||
label: { color: TEXT_DIM, fontSize: 10, formatter: (o: { value: number }) => `${o.value > 0 ? '+' : ''}${o.value * 100}%` },
|
||||
data: [{ yAxis: 0.05 }, { yAxis: -0.05 }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function renderDecay(): void {
|
||||
const root = data.value
|
||||
if (!root) return
|
||||
const items = PERIODS
|
||||
.map((p) => ({ p, v: root.periods[p]?.ic_mean }))
|
||||
.filter((x): x is { p: (typeof PERIODS)[number]; v: number } => typeof x.v === 'number')
|
||||
if (!items.length) return
|
||||
decay.setOption({
|
||||
tooltip: { ...darkTooltip(), valueFormatter: (v: number) => (typeof v === 'number' ? v.toFixed(4) : String(v)) },
|
||||
grid: { left: 44, right: 16, top: 30, bottom: 28 },
|
||||
xAxis: { type: 'category', data: items.map((x) => x.p), ...darkAxis() },
|
||||
yAxis: { type: 'value', ...darkAxis(), axisLabel: { ...MONO_LABEL, formatter: (v: number) => v.toFixed(2) } },
|
||||
series: [{
|
||||
type: 'bar', barWidth: '40%',
|
||||
data: items.map((x) => ({ value: x.v, itemStyle: { color: 'rgba(0,229,255,0.7)' } })),
|
||||
label: { show: true, position: 'top', color: BRAND, fontSize: 10, formatter: (o: { value: number }) => o.value.toFixed(3) },
|
||||
markLine: {
|
||||
silent: true, symbol: 'none', lineStyle: { color: SPLIT },
|
||||
label: { show: false }, data: [{ yAxis: 0 }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
function renderAll(): void {
|
||||
renderIc()
|
||||
renderQNav()
|
||||
renderLs()
|
||||
renderDec()
|
||||
renderDecay()
|
||||
}
|
||||
|
||||
watch(period, () => nextTick(renderAll))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await getTearsData(props.taskId, props.factor)
|
||||
if (!data.value.periods || !Object.keys(data.value.periods).length) stale.value = true
|
||||
else await nextTick(renderAll)
|
||||
} catch (e) {
|
||||
if ((e as AxiosError)?.response?.status === 404) stale.value = true
|
||||
else err.value = 'tears 数据加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="tears-panel">
|
||||
<template v-if="stale">
|
||||
<div class="stale-note">
|
||||
该任务在旧版本下分析,没有分层序列数据——已回退到原 alphalens 报告,重新提交分析即可获得新版 tears。
|
||||
</div>
|
||||
<iframe :src="reportUrl(props.taskId, props.factor)" class="report-frame" />
|
||||
</template>
|
||||
<div v-else-if="err" class="stale-note">{{ err }}</div>
|
||||
<template v-else-if="d">
|
||||
<div class="tears-head">
|
||||
<span class="factor-name mono">{{ props.factor }}</span>
|
||||
<span v-if="data?.generated_at" class="gen">生成于 {{ data.generated_at.replace('T', ' ') }}</span>
|
||||
<div class="seg">
|
||||
<button v-for="p in PERIODS" :key="p" :class="{ on: p === period }" @click="period = p">{{ p }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-strip">
|
||||
<div v-for="m in strip" :key="m.k" class="metric">
|
||||
<div class="k">{{ m.k }}</div>
|
||||
<div class="v mono" :class="m.cls">{{ m.v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div class="chart-card">
|
||||
<h3>月度 IC 与累计 IC</h3>
|
||||
<div class="cs">柱=月度 RankIC(红正绿负) · 线=累计 IC(右轴)</div>
|
||||
<div ref="icEl" class="cbody" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>月度 IC 热力图</h3>
|
||||
<div class="cs">红=正预测力 · 绿=负 · 看月度稳定性与失效期</div>
|
||||
<div class="heat">
|
||||
<div class="heat-row head">
|
||||
<span class="hl" />
|
||||
<span v-for="m in 12" :key="m" class="hm">{{ m }}</span>
|
||||
</div>
|
||||
<div v-for="row in heatRows" :key="row.year" class="heat-row">
|
||||
<span class="hl mono">{{ row.year }}</span>
|
||||
<span v-for="(v, i) in row.cells" :key="i" class="hc mono" :style="heatCell(v)">{{ heatText(v) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>分组累计净值 Q1 / Q5 / Q10</h3>
|
||||
<div class="cs">按因子值十分组回测净值 · 单调分层=因子有效</div>
|
||||
<div ref="qnavEl" class="cbody" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>多空净值 Q10 − Q1</h3>
|
||||
<div class="cs">做多最高组做空最低组 · ○=最大回撤点</div>
|
||||
<div ref="lsEl" class="cbody" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>十分组年化收益 ({{ period }})</h3>
|
||||
<div class="cs">D1=因子值最低组 · 单调性=区分度</div>
|
||||
<div ref="decEl" class="cbody" />
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>IC 衰减</h3>
|
||||
<div class="cs">持有期拉长后的 IC 均值 · 衰减快=适合短持有</div>
|
||||
<div ref="decayEl" class="cbody" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tears-panel { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.tears-head { display: flex; align-items: center; gap: 12px; }
|
||||
.factor-name { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.gen { font-size: 11px; color: var(--text-3); }
|
||||
.seg { margin-left: auto; display: flex; border: 1px solid var(--border); border-radius: var(--r-sm); overflow: hidden; }
|
||||
.seg button {
|
||||
background: transparent; border: 0; color: var(--text-2);
|
||||
font-family: var(--mono); font-size: 12px; padding: 4px 14px; cursor: pointer;
|
||||
}
|
||||
.seg button + button { border-left: 1px solid var(--border); }
|
||||
.seg button.on { background: var(--cyan-soft); color: var(--cyan); }
|
||||
|
||||
.metric-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 10px; }
|
||||
.metric { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 10px 12px; }
|
||||
.metric .k { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
.metric .v { margin-top: 5px; font-size: 17px; font-weight: 700; color: var(--text); }
|
||||
.metric .v.up { color: var(--up); }
|
||||
.metric .v.down { color: var(--down); }
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.chart-card { background: var(--bg-card); border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 12px 14px; }
|
||||
.chart-card h3 { margin: 0; font-size: 12.5px; font-weight: 600; letter-spacing: 0.04em; color: var(--text); }
|
||||
.cs { font-size: 11px; color: var(--text-3); margin: 4px 0 8px; }
|
||||
.cbody { width: 100%; height: 240px; }
|
||||
|
||||
/* 月度 IC 热力图(年×月格子,同设计稿) */
|
||||
.heat { display: flex; flex-direction: column; gap: 3px; padding-top: 4px; }
|
||||
.heat-row { display: grid; grid-template-columns: 38px repeat(12, 1fr); gap: 3px; align-items: stretch; }
|
||||
.heat-row.head .hm { font-size: 10px; color: var(--text-3); text-align: center; }
|
||||
.hl { font-size: 11px; color: var(--text-3); display: flex; align-items: center; justify-content: flex-end; padding-right: 8px; }
|
||||
.hc {
|
||||
height: 26px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10.5px; border-radius: 2px;
|
||||
}
|
||||
|
||||
.stale-note {
|
||||
font-size: 12.5px; color: var(--text-2); background: var(--bg-card);
|
||||
border: 1px solid var(--border-2); border-radius: var(--r-md); padding: 10px 14px;
|
||||
}
|
||||
.report-frame { width: 100%; height: 620px; border: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid2 { grid-template-columns: 1fr; }
|
||||
.metric-strip { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
</style>
|
||||
@@ -351,6 +351,23 @@ def factor_report(task_id: str, factor: str, token: str = Query(...)):
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/tears/{factor}", dependencies=[Depends(verify_token)])
|
||||
def factor_tears_json(task_id: str, factor: str):
|
||||
"""Tears 分层序列 JSON(方案A):前端 ECharts 暗色 tears 页数据源。
|
||||
|
||||
序列在分析时由 sanguo_factor.tears_data 序列化落盘;历史任务(该代码
|
||||
上线前跑的)没有 tears_paths → 404,重跑一次分析即可生成。
|
||||
"""
|
||||
r = get_orchestrator().get_raw_result(task_id)
|
||||
if r is None:
|
||||
raise HTTPException(status_code=404, detail="result not ready")
|
||||
paths = getattr(r, "tears_paths", {}) or {}
|
||||
path = paths.get(factor)
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail=f"tears data for {factor} not found (re-run analysis)")
|
||||
return FileResponse(path, media_type="application/json")
|
||||
|
||||
|
||||
# ===== History + Optimization endpoints (S3) =====
|
||||
|
||||
def _to_bj(ts: str | None) -> str:
|
||||
|
||||
@@ -54,6 +54,7 @@ class FactorReport:
|
||||
output_dir: str
|
||||
ic_summary: dict = field(default_factory=dict)
|
||||
report_paths: dict = field(default_factory=dict)
|
||||
tears_paths: dict = field(default_factory=dict)
|
||||
symbols: list[str] = field(default_factory=list)
|
||||
start: str = ""
|
||||
end: str = ""
|
||||
@@ -182,6 +183,7 @@ def run_factor_analysis(
|
||||
# Initialize IC summary and report paths
|
||||
ic_summary = {}
|
||||
report_paths = {}
|
||||
tears_paths = {}
|
||||
|
||||
# Process each factor
|
||||
for factor_name in factor_names:
|
||||
@@ -292,6 +294,24 @@ def run_factor_analysis(
|
||||
"ic": ic_data,
|
||||
}
|
||||
|
||||
# tears JSON 序列化(方案A,独立 try:失败只标注不吞 IC 成功)——
|
||||
# 分层序列 → ECharts 数据源,前端暗色 tears 页替代 iframe 浅色报告
|
||||
try:
|
||||
import json as _json
|
||||
from datetime import datetime as _dt
|
||||
from .tears_data import build_tears_data
|
||||
tears = build_tears_data(merged_data, periods)
|
||||
tears["factor"] = factor_name
|
||||
tears["generated_at"] = _dt.now().isoformat(timespec="seconds")
|
||||
tears_path = os.path.join(output_dir, f"{factor_name}_tears.json")
|
||||
with open(tears_path, "w", encoding="utf-8") as _tf:
|
||||
_json.dump(tears, _tf, ensure_ascii=False)
|
||||
tears_paths[factor_name] = tears_path
|
||||
except Exception as tears_json_e:
|
||||
ic_summary[factor_name]["tears_json_error"] = (
|
||||
f"{type(tears_json_e).__name__}: {tears_json_e}"
|
||||
)
|
||||
|
||||
# Generate tears sheet(独立 try:tears 失败只标注,不覆盖上面的 IC 成功)
|
||||
#
|
||||
# alphalens-reloaded 的 create_full_tear_sheet 内部,每个 tear sheet
|
||||
@@ -375,6 +395,7 @@ def run_factor_analysis(
|
||||
output_dir=output_dir,
|
||||
ic_summary=ic_summary,
|
||||
report_paths=report_paths,
|
||||
tears_paths=tears_paths,
|
||||
symbols=symbols,
|
||||
start=start,
|
||||
end=end,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tears 分层序列序列化(方案A tears 端点数据源).
|
||||
|
||||
create_full_tear_sheet 的 matplotlib 图,本质是"已算好的序列被画成图"——
|
||||
本模块把同一批 alphalens performance 序列(日度IC / 分组日度收益 / 因子秩自相关)
|
||||
直接序列化成前端 ECharts 可渲染的 dict,替代 iframe 内嵌浅色 HTML 报告
|
||||
(用户 2026-08-29 拍板方案A:原生重构)。
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
# 与 analyzer.py 同款幂等补丁:alphalens-reloaded 的 demean_forward_returns 用
|
||||
# groupby.transform(lambda) 在 pandas2 崩,独立 import 本模块(单测路径)时
|
||||
# analyzer 可能未加载 → 补丁不在 → mean_return_by_quantile 崩。重复 patch 无害。
|
||||
try:
|
||||
import alphalens.utils as _al_utils
|
||||
|
||||
def _demean_forward_returns_pandas2(factor_data, grouper=None):
|
||||
factor_data = factor_data.copy()
|
||||
if not grouper:
|
||||
grouper = factor_data.index.get_level_values("date")
|
||||
cols = _al_utils.get_forward_returns_columns(factor_data.columns)
|
||||
means = factor_data.groupby(grouper)[cols].transform("mean")
|
||||
factor_data[cols] = factor_data[cols] - means
|
||||
return factor_data
|
||||
|
||||
_al_utils.demean_forward_returns = _demean_forward_returns_pandas2
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
_TRADING_DAYS = 252
|
||||
|
||||
|
||||
def _nav(daily: pd.Series) -> pd.Series:
|
||||
"""日度收益(可含重叠窗口)→ 累计净值,首值 1;NaN 日视为空仓(0 收益)."""
|
||||
return (1.0 + daily.fillna(0.0)).cumprod()
|
||||
|
||||
|
||||
def _max_drawdown(nav: pd.Series) -> float:
|
||||
"""净值序列最大回撤(≤0)."""
|
||||
v = (nav / nav.cummax() - 1.0).min()
|
||||
return float(v) if pd.notna(v) else 0.0
|
||||
|
||||
|
||||
def _annualized(daily: pd.Series, period: int) -> float:
|
||||
"""日均 × 252 / period:线性去重叠年化,1/5/10D 三周期可比(1D≈eval 链路口径)."""
|
||||
s = daily.dropna()
|
||||
if s.empty:
|
||||
return 0.0
|
||||
return float(s.mean() * _TRADING_DAYS / period)
|
||||
|
||||
|
||||
def _monthly_ic(ic: pd.Series) -> list[dict]:
|
||||
"""日度 IC 按月聚合(月度柱/热力图数据),口径同 sanguo_factor.metrics.monthly_ic."""
|
||||
s = ic.dropna()
|
||||
if s.empty:
|
||||
return []
|
||||
g = s.groupby(s.index.to_period("M")).mean()
|
||||
return [{"month": t.strftime("%Y-%m"), "ic": round(float(v), 6)} for t, v in g.items()]
|
||||
|
||||
|
||||
def build_tears_data(merged_data, periods: tuple = (1, 5, 10)) -> dict:
|
||||
"""alphalens factor_data → tears 序列 dict.
|
||||
|
||||
每序列自带日期轴(ic_dates/nav_dates);IC/分组收益均调 alphalens 原函数,
|
||||
与 tearsheet 同源。quantile_nav 给全 10 组,前端按需画 Q1/Q5/Q10。
|
||||
"""
|
||||
from alphalens.performance import (
|
||||
factor_information_coefficient,
|
||||
factor_rank_autocorrelation,
|
||||
mean_return_by_quantile,
|
||||
)
|
||||
|
||||
ic_df = factor_information_coefficient(merged_data)
|
||||
qr_by_date, _ = mean_return_by_quantile(merged_data, by_date=True, demeaned=True)
|
||||
|
||||
# 因子秩自相关(1D,指标条一项):失败不致命 → None
|
||||
try:
|
||||
ac = factor_rank_autocorrelation(merged_data, period=1).dropna()
|
||||
factor_autocorr = round(float(ac.mean()), 4) if len(ac) else None
|
||||
except Exception:
|
||||
factor_autocorr = None
|
||||
|
||||
out: dict = {"factor_autocorr": factor_autocorr, "periods": {}}
|
||||
for p in periods:
|
||||
# 列名兼容:alphalens-reloaded 生成 "1D",老版纯数字 "1"(同 analyzer 口径)
|
||||
col = next((c for c in ic_df.columns if c in (f"{p}D", str(p))), None)
|
||||
if col is None:
|
||||
continue
|
||||
|
||||
ic = ic_df[col].dropna()
|
||||
qd = qr_by_date[col].unstack("factor_quantile") # date × quantile
|
||||
qkeys = sorted(int(q) for q in qd.columns)
|
||||
|
||||
quantile_nav: dict = {}
|
||||
quantile_annual: dict = {}
|
||||
for q in qkeys:
|
||||
daily_q = qd[q]
|
||||
quantile_nav[str(q)] = [round(float(v), 6) for v in _nav(daily_q)]
|
||||
quantile_annual[str(q)] = round(_annualized(daily_q, p), 4)
|
||||
|
||||
# 多空 = 最高分位 − 最低分位(每日,demeaned 超额口径同 tearsheet)
|
||||
ls_daily = qd[qkeys[-1]] - qd[qkeys[0]]
|
||||
ls_nav = _nav(ls_daily)
|
||||
|
||||
n = len(ic)
|
||||
m = float(ic.mean()) if n else 0.0
|
||||
sd = float(ic.std()) if n > 1 else 0.0
|
||||
out["periods"][f"{p}D"] = {
|
||||
"count": int(n),
|
||||
"ic_mean": round(m, 6) if n else None,
|
||||
"ic_std": round(sd, 6) if n > 1 else None,
|
||||
"icir": round(m / sd, 4) if sd > 0 else None,
|
||||
"t_stat": round(m / (sd / n ** 0.5), 4) if sd > 0 and n > 1 else None,
|
||||
"win_rate": round(float((ic > 0).mean()), 4) if n else None,
|
||||
"ic_dates": [d.strftime("%Y-%m-%d") for d in ic.index],
|
||||
"ic_values": [round(float(v), 6) for v in ic],
|
||||
"monthly_ic": _monthly_ic(ic),
|
||||
"quantile_keys": [str(q) for q in qkeys],
|
||||
"nav_dates": [d.strftime("%Y-%m-%d") for d in qd.index],
|
||||
"quantile_nav": quantile_nav,
|
||||
"quantile_annual": quantile_annual,
|
||||
"ls_nav": [round(float(v), 6) for v in ls_nav],
|
||||
"ls_annual": round(_annualized(ls_daily, p), 4),
|
||||
"ls_max_dd": round(_max_drawdown(ls_nav), 4),
|
||||
}
|
||||
return out
|
||||
@@ -296,7 +296,8 @@ class Orchestrator:
|
||||
params={"factor_names": fr.factor_names},
|
||||
start=getattr(fr, "start", "") or "",
|
||||
end=getattr(fr, "end", "") or "",
|
||||
statistics={"ic_summary": fr.ic_summary, "report_paths": fr.report_paths},
|
||||
statistics={"ic_summary": fr.ic_summary, "report_paths": fr.report_paths,
|
||||
"tears_paths": getattr(fr, "tears_paths", None) or {}},
|
||||
equity_curve=None, trades=None,
|
||||
), db_path=self.db_path)
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,9 +10,10 @@ from sanguo_api.auth import hash_password
|
||||
class FakeReport:
|
||||
"""Stand-in for FactorReport."""
|
||||
|
||||
def __init__(self, ic_summary: dict, report_paths: dict):
|
||||
def __init__(self, ic_summary: dict, report_paths: dict, tears_paths: dict | None = None):
|
||||
self.ic_summary = ic_summary
|
||||
self.report_paths = report_paths
|
||||
self.tears_paths = tears_paths or {}
|
||||
|
||||
|
||||
class FakeOrch:
|
||||
@@ -71,3 +72,33 @@ def test_report_bad_token_401(client):
|
||||
def test_report_file_absent_404(client, token):
|
||||
r = client.get(f"/api/v1/task/t/report/ma5?token={token}")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# —— tears JSON 端点(方案A) ——
|
||||
|
||||
def test_tears_json_no_token_401(client):
|
||||
r = client.get("/api/v1/task/t/tears/ma5")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_tears_json_absent_404(client, token):
|
||||
"""fixture 的 FakeReport 无 tears_paths → 404(历史任务语义)."""
|
||||
r = client.get("/api/v1/task/t/tears/ma5", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_tears_json_served(client, token, tmp_path):
|
||||
"""tears_paths 指向真实 JSON 文件 → 200 + application/json."""
|
||||
import json
|
||||
p = tmp_path / "ma5_tears.json"
|
||||
p.write_text(json.dumps({"factor": "ma5", "periods": {"1D": {"ic_mean": 0.06}}}),
|
||||
encoding="utf-8")
|
||||
set_orchestrator(FakeOrch(FakeReport(
|
||||
ic_summary={"ma5": {"status": "success", "ic": {}}},
|
||||
report_paths={"ma5": "/tmp/__definitely_absent_ma5.html"},
|
||||
tears_paths={"ma5": str(p)},
|
||||
)))
|
||||
r = client.get("/api/v1/task/t/tears/ma5", headers={"Authorization": f"Bearer {token}"})
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/json")
|
||||
assert r.json()["factor"] == "ma5"
|
||||
|
||||
@@ -232,6 +232,102 @@ def test_run_factor_analysis_extracts_ic_values(tmp_path):
|
||||
assert abs(ic_data["1D"]["mean"] - 0.05) < 0.01 # Allow small rounding errors
|
||||
|
||||
|
||||
def test_run_factor_analysis_writes_tears_json(tmp_path):
|
||||
"""tears JSON(方案A)写盘 + FactorReport.tears_paths 记录 + factor/generated_at 补齐."""
|
||||
import pytest
|
||||
pytest.importorskip("polars")
|
||||
pytest.importorskip("alphalens")
|
||||
|
||||
import json
|
||||
import pandas as pd
|
||||
from sanguo_factor.analyzer import run_factor_analysis
|
||||
|
||||
dates = pd.date_range("2024-01-01", periods=6, freq="D", tz="Asia/Shanghai")
|
||||
mock_factor_data = pd.DataFrame(index=pd.MultiIndex.from_product(
|
||||
[dates, ["AAPL"]], names=["datetime", "asset"]))
|
||||
mock_factor_data["factor"] = [0.5] * 6
|
||||
mock_factor_data["1D"] = [0.01] * 6
|
||||
mock_factor_data["5D"] = [0.05] * 6
|
||||
mock_factor_data["10D"] = [0.10] * 6
|
||||
|
||||
mock_pl_df = MagicMock()
|
||||
mock_pl_df.to_pandas.return_value = pd.DataFrame({
|
||||
"datetime": [d.isoformat() for d in dates],
|
||||
"vt_symbol": ["AAPL"] * 6,
|
||||
"ma5": [0.5] * 6,
|
||||
})
|
||||
|
||||
with patch("sanguo_factor.analyzer.AlphaLabSession") as MS, \
|
||||
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as MC, \
|
||||
patch("sanguo_factor.analyzer.create_full_tear_sheet"), \
|
||||
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
|
||||
patch("sanguo_factor.tears_data.build_tears_data",
|
||||
return_value={"factor_autocorr": 0.9, "periods": {"1D": {"ic_mean": 0.1}}}) as MB, \
|
||||
patch("sanguo_data.datareader.read_db_daily",
|
||||
return_value=_fake_bars("AAPL", range(1, 7))):
|
||||
MS.return_value.compute_factors.return_value = mock_pl_df
|
||||
MC.return_value = mock_factor_data
|
||||
MIC.return_value = pd.DataFrame(
|
||||
{"1D": [0.05, 0.04, 0.06, 0.05, 0.04, 0.06]}, index=dates)
|
||||
|
||||
report = run_factor_analysis(
|
||||
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
|
||||
cfg=MagicMock(), output_dir=str(tmp_path)
|
||||
)
|
||||
MB.assert_called_once()
|
||||
assert "ma5" in report.tears_paths
|
||||
assert report.tears_paths["ma5"].endswith("ma5_tears.json")
|
||||
assert os.path.exists(report.tears_paths["ma5"])
|
||||
with open(report.tears_paths["ma5"], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert data["factor"] == "ma5"
|
||||
assert data["periods"]["1D"]["ic_mean"] == 0.1
|
||||
assert data["generated_at"]
|
||||
|
||||
|
||||
def test_run_factor_analysis_tears_json_error_not_fatal(tmp_path):
|
||||
"""build_tears_data 抛错 → 只标注 tears_json_error,IC 结果保留."""
|
||||
import pytest
|
||||
pytest.importorskip("polars")
|
||||
pytest.importorskip("alphalens")
|
||||
|
||||
import pandas as pd
|
||||
from sanguo_factor.analyzer import run_factor_analysis
|
||||
|
||||
dates = pd.date_range("2024-01-01", periods=4, freq="D", tz="Asia/Shanghai")
|
||||
mock_factor_data = pd.DataFrame(index=pd.MultiIndex.from_product(
|
||||
[dates, ["AAPL"]], names=["datetime", "asset"]))
|
||||
for c in ("factor", "1D", "5D", "10D"):
|
||||
mock_factor_data[c] = [0.5] * 4
|
||||
|
||||
mock_pl_df = MagicMock()
|
||||
mock_pl_df.to_pandas.return_value = pd.DataFrame({
|
||||
"datetime": [d.isoformat() for d in dates],
|
||||
"vt_symbol": ["AAPL"] * 4,
|
||||
"ma5": [0.5] * 4,
|
||||
})
|
||||
|
||||
with patch("sanguo_factor.analyzer.AlphaLabSession") as MS, \
|
||||
patch("sanguo_factor.analyzer.get_clean_factor_and_forward_returns") as MC, \
|
||||
patch("sanguo_factor.analyzer.create_full_tear_sheet"), \
|
||||
patch("sanguo_factor.analyzer.factor_information_coefficient") as MIC, \
|
||||
patch("sanguo_factor.tears_data.build_tears_data",
|
||||
side_effect=RuntimeError("boom")), \
|
||||
patch("sanguo_data.datareader.read_db_daily",
|
||||
return_value=_fake_bars("AAPL", range(1, 5))):
|
||||
MS.return_value.compute_factors.return_value = mock_pl_df
|
||||
MC.return_value = mock_factor_data
|
||||
MIC.return_value = pd.DataFrame({"1D": [0.05, 0.04, 0.06, 0.05]}, index=dates)
|
||||
|
||||
report = run_factor_analysis(
|
||||
["AAPL"], ["ma5"], "2024-01-01", "2024-01-10",
|
||||
cfg=MagicMock(), output_dir=str(tmp_path)
|
||||
)
|
||||
assert report.ic_summary["ma5"]["status"] == "success"
|
||||
assert "tears_json_error" in report.ic_summary["ma5"]
|
||||
assert report.tears_paths == {}
|
||||
|
||||
|
||||
def test_run_factor_analysis_ic_extraction_fails_gracefully(tmp_path):
|
||||
"""Test that IC extraction failures don't crash the pipeline.
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Test tears_data - 分层序列序列化(方案A tears 端点数据源)."""
|
||||
import sys
|
||||
import os
|
||||
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
|
||||
if _VNPY_SRC not in sys.path:
|
||||
sys.path.insert(0, _VNPY_SRC)
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
# —— 纯函数(不依赖 alphalens,本地必跑) ——
|
||||
|
||||
def test_nav_compound():
|
||||
from sanguo_factor.tears_data import _nav
|
||||
nav = _nav(pd.Series([0.1, -0.2, 0.1]))
|
||||
assert list(np.round(nav.values, 6)) == [1.1, 0.88, 0.968]
|
||||
|
||||
|
||||
def test_nav_nan_day_is_flat():
|
||||
from sanguo_factor.tears_data import _nav
|
||||
nav = _nav(pd.Series([np.nan, 0.1]))
|
||||
assert nav.iloc[0] == 1.0
|
||||
assert abs(nav.iloc[1] - 1.1) < 1e-9
|
||||
|
||||
|
||||
def test_max_drawdown():
|
||||
from sanguo_factor.tears_data import _max_drawdown, _nav
|
||||
nav = _nav(pd.Series([0.1, -0.2, 0.1]))
|
||||
dd = _max_drawdown(nav)
|
||||
assert dd <= 0
|
||||
assert abs(dd - (0.88 / 1.1 - 1.0)) < 1e-9
|
||||
|
||||
|
||||
def test_annualized_deoverlaps_period():
|
||||
from sanguo_factor.tears_data import _annualized
|
||||
s = pd.Series([0.001] * 252) # 日均 0.001
|
||||
assert abs(_annualized(s, 1) - 0.252) < 1e-9
|
||||
# 同收益按 5D 重叠口径 → 年化除以 5(去重叠,三周期可比)
|
||||
assert abs(_annualized(s, 5) - 0.0504) < 1e-9
|
||||
|
||||
|
||||
def test_monthly_ic_groups_by_month():
|
||||
from sanguo_factor.tears_data import _monthly_ic
|
||||
idx = pd.to_datetime(["2024-01-05", "2024-01-20", "2024-02-01"])
|
||||
g = _monthly_ic(pd.Series([0.1, 0.3, 0.2], index=idx))
|
||||
assert [x["month"] for x in g] == ["2024-01", "2024-02"]
|
||||
assert abs(g[0]["ic"] - 0.2) < 1e-9
|
||||
assert g[1]["ic"] == 0.2
|
||||
|
||||
|
||||
# —— build_tears_data 全链(真实 alphalens 函数) ——
|
||||
|
||||
def _merged_data(n_days: int = 40, n_assets: int = 20, seed: int = 7) -> pd.DataFrame:
|
||||
"""合成 alphalens factor_data:因子与前瞻收益秩正相关(rank IC 显著为正)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
dates = pd.date_range("2024-01-02", periods=n_days, freq="B", name="date")
|
||||
assets = [f"S{i:03d}" for i in range(n_assets)]
|
||||
idx = pd.MultiIndex.from_product([dates, assets], names=["date", "asset"])
|
||||
factor = rng.normal(size=len(idx))
|
||||
df = pd.DataFrame({"factor": factor}, index=idx)
|
||||
# 横截面 rank(0~1)驱动前瞻收益 → IC>0;1/5/10D 噪声递减信号不变
|
||||
rk = pd.Series(factor, index=idx).groupby(level="date").rank(pct=True).values
|
||||
base = (rk - 0.5) * 0.04
|
||||
for p in (1, 5, 10):
|
||||
df[f"{p}D"] = base + rng.normal(0, 0.01, size=len(idx))
|
||||
df["factor_quantile"] = (
|
||||
pd.Series(factor, index=idx).groupby(level="date")
|
||||
.transform(lambda x: pd.qcut(x, 10, labels=False) + 1)
|
||||
.astype(int)
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def built():
|
||||
pytest.importorskip("alphalens")
|
||||
from sanguo_factor.tears_data import build_tears_data
|
||||
return build_tears_data(_merged_data())
|
||||
|
||||
|
||||
def test_build_tears_data_period_keys(built):
|
||||
assert set(built["periods"]) == {"1D", "5D", "10D"}
|
||||
|
||||
|
||||
def test_build_tears_data_ic_positive(built):
|
||||
"""合成因子与收益正相关 → 三周期 IC 均值/ICIR 为正,胜率过半."""
|
||||
for p in ("1D", "5D", "10D"):
|
||||
d = built["periods"][p]
|
||||
assert d["ic_mean"] > 0, p
|
||||
assert d["icir"] > 0, p
|
||||
assert d["t_stat"] > 2, p
|
||||
assert d["win_rate"] > 0.5, p
|
||||
|
||||
|
||||
def test_build_tears_data_series_shapes(built):
|
||||
d1 = built["periods"]["1D"]
|
||||
n_nav = len(d1["nav_dates"])
|
||||
assert n_nav == 40
|
||||
assert d1["quantile_keys"] == [str(i) for i in range(1, 11)]
|
||||
for q in d1["quantile_keys"]:
|
||||
assert len(d1["quantile_nav"][q]) == n_nav, q
|
||||
assert len(d1["ls_nav"]) == n_nav
|
||||
assert len(d1["ic_dates"]) == d1["count"]
|
||||
assert len(d1["ic_dates"]) == len(d1["ic_values"])
|
||||
# 净值恒正(首值 = 1+首日收益,同 alphalens cum_returns 口径)
|
||||
assert all(v > 0 for v in d1["quantile_nav"]["1"])
|
||||
assert all(v > 0 for v in d1["ls_nav"])
|
||||
|
||||
|
||||
def test_build_tears_data_quantile_monotonic(built):
|
||||
"""信号由 rank 驱动 → Q10 年化 > Q1 年化,多空年化为正."""
|
||||
d1 = built["periods"]["1D"]
|
||||
assert d1["quantile_annual"]["10"] > d1["quantile_annual"]["1"]
|
||||
assert d1["ls_annual"] > 0
|
||||
assert d1["ls_max_dd"] <= 0
|
||||
|
||||
|
||||
def test_build_tears_data_monthly_ic_present(built):
|
||||
d1 = built["periods"]["1D"]
|
||||
assert d1["monthly_ic"], "monthly_ic 不应为空(40 个交易日 ≥ 1 个月)"
|
||||
assert "month" in d1["monthly_ic"][0] and "ic" in d1["monthly_ic"][0]
|
||||
|
||||
|
||||
def test_build_tears_data_json_serializable(built):
|
||||
"""端点要 FileResponse 这个 dict → 必须整棵 json 可序列化."""
|
||||
import json
|
||||
json.dumps(built)
|
||||
Reference in New Issue
Block a user