From 0dd81d709f7c87fe03f4bea764f4c828607326f6 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 29 Aug 2026 08:43:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(factor):=20tears=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E6=96=B9=E6=A1=88A=E5=8E=9F=E7=94=9F=E9=87=8D=E6=9E=84+?= =?UTF-8?q?=E3=80=8C=E5=8A=A0=E5=85=A5=E5=AF=B9=E6=AF=94=E3=80=8D=E5=81=9A?= =?UTF-8?q?=E5=AE=9E(=E7=94=A8=E6=88=B708-29=E6=8B=8D=E6=9D=BF)=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ①后端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绿 --- frontend/src/api/factor.ts | 34 + frontend/src/router/index.ts | 1 + frontend/src/stores/factorCompare.ts | 69 ++ frontend/src/views/Layout.vue | 4 + frontend/src/views/factor/Compare.vue | 668 ++++++++++++++++++ frontend/src/views/factor/CompareTray.vue | 154 ++++ frontend/src/views/factor/Leaderboard.vue | 52 +- .../src/views/factor/LeaderboardDetail.vue | 20 +- frontend/src/views/factor/Result.vue | 10 +- frontend/src/views/factor/TearsPanel.vue | 379 ++++++++++ sanguo_api/routes.py | 17 + sanguo_factor/analyzer.py | 21 + sanguo_factor/tears_data.py | 125 ++++ sanguo_orchestrator/runner.py | 3 +- tests/api/test_factor_routes.py | 33 +- tests/factor/test_analyzer.py | 96 +++ tests/factor/test_tears_data.py | 129 ++++ 17 files changed, 1806 insertions(+), 9 deletions(-) create mode 100644 frontend/src/stores/factorCompare.ts create mode 100644 frontend/src/views/factor/Compare.vue create mode 100644 frontend/src/views/factor/CompareTray.vue create mode 100644 frontend/src/views/factor/TearsPanel.vue create mode 100644 sanguo_factor/tears_data.py create mode 100644 tests/factor/test_tears_data.py diff --git a/frontend/src/api/factor.ts b/frontend/src/api/factor.ts index b5d2337..7dff52b 100644 --- a/frontend/src/api/factor.ts +++ b/frontend/src/api/factor.ts @@ -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 + quantile_annual: Record + ls_nav: number[] + ls_annual: number + ls_max_dd: number +} + +export interface TearsData { + factor: string + generated_at?: string + factor_autocorr: number | null + periods: Record +} + +export async function getTearsData(taskId: string, factor: string): Promise { + const { data } = await apiClient.get(`/task/${taskId}/tears/${factor}`) + return data +} + + // —— Factor Evaluation API —— export interface EvalRun { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bfa8fe1..d5d9cb8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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') }, diff --git a/frontend/src/stores/factorCompare.ts b/frontend/src/stores/factorCompare.ts new file mode 100644 index 0000000..0ac6d86 --- /dev/null +++ b/frontend/src/stores/factorCompare.ts @@ -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 })) + }, + }, +}) diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index d616761..55e9cea 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -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 { + + +
diff --git a/frontend/src/views/factor/Compare.vue b/frontend/src/views/factor/Compare.vue new file mode 100644 index 0000000..09cee3b --- /dev/null +++ b/frontend/src/views/factor/Compare.vue @@ -0,0 +1,668 @@ + + + + + diff --git a/frontend/src/views/factor/CompareTray.vue b/frontend/src/views/factor/CompareTray.vue new file mode 100644 index 0000000..b6d5cc7 --- /dev/null +++ b/frontend/src/views/factor/CompareTray.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/views/factor/Leaderboard.vue b/frontend/src/views/factor/Leaderboard.vue index 6793424..7313f05 100644 --- a/frontend/src/views/factor/Leaderboard.vue +++ b/frontend/src/views/factor/Leaderboard.vue @@ -1,9 +1,12 @@ + +