feat(factor): tears报告方案A原生重构+「加入对比」做实(用户08-29拍板) [vps]
CI/CD / test (push) Successful in 5s
CI/CD / nas-deploy (push) Successful in 8s
CI/CD / nas-verify (push) Successful in 8s

①后端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:
2026-08-29 08:43:38 +08:00
parent 7add8149af
commit 0dd81d709f
17 changed files with 1806 additions and 9 deletions
+34
View File
@@ -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 {
+1
View File
@@ -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') },
+69
View File
@@ -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 }))
},
},
})
+4
View File
@@ -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">
+668
View File
@@ -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>
+154
View File
@@ -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>
+51 -1
View File
@@ -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;
+5 -5
View File
@@ -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>
+379
View File
@@ -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: 'Q10Q1 多空净值', 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>
+17
View File
@@ -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:
+21
View File
@@ -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(独立 trytears 失败只标注,不覆盖上面的 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,
+125
View File
@@ -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
+2 -1
View File
@@ -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:
+32 -1
View File
@@ -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"
+96
View File
@@ -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.
+129
View File
@@ -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)