feat(factor): 进度页心跳判活+因子列表默认折叠(用户08-30反馈两项) [vps]
痛点:因子分析分钟级运行,进度条停着看不出进行中还是死掉;240因子罗列乱。
①跨进程心跳链路:analyzer._report_progress 写 {output_dir}/{tid}.progress
(stage/detail/ts;IO故障静默——锦上添花不能伤主流程),埋点=行情加载i/N逐只
+因子特征+逐因子i/M+tears✓+完成;runner._factor_worker 透传 task_id
(默认空串兼容旧调用);GET /task/{id} 合并 _read_factor_progress(factor_
前缀才读,age=距上次活动秒数)。
②Progress.vue终端风重构:心跳区=呼吸灯(绿≤30s/琥珀≤180s静默期/红更久,
prefers-reduced-motion停动画)+「Xs前·detail」+运行时长秒表+步骤%大数字;
因子步骤=行情加载→因子计算→逐因子分析→完成(心跳stage驱动);回测/优化
保持原5步推导+进度条;useTask/TaskStatus 透传 progress。
③FactorPicker默认折叠:groups max-height 128px两行预览+渐隐底边+
「展开全部N个因子▾」按钮,搜索时自动展开。
+5测试(_report_progress写/静默/心跳读roundtrip/非factor/损坏JSON),323绿+build绿
This commit is contained in:
@@ -19,6 +19,13 @@ export interface TaskStatus {
|
||||
task_id: string
|
||||
status: string
|
||||
stage: string
|
||||
/** 因子分析心跳(仅 factor_ 任务运行中有值) */
|
||||
progress?: {
|
||||
stage: string
|
||||
detail: string
|
||||
ts: number
|
||||
age: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface EquityPoint {
|
||||
|
||||
@@ -4,6 +4,14 @@ import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'unknown'
|
||||
|
||||
export interface TaskProgress {
|
||||
stage: string
|
||||
detail: string
|
||||
ts: number
|
||||
/** 距上次心跳秒数(后端算) */
|
||||
age: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a task's status + stage via polling (2s) and WebSocket (real-time).
|
||||
* Auto-stops on unmount.
|
||||
@@ -11,6 +19,7 @@ export type TaskState = 'pending' | 'running' | 'done' | 'failed' | 'unknown'
|
||||
export function useTask(taskId: string) {
|
||||
const status = ref<TaskState>('unknown')
|
||||
const stage = ref('')
|
||||
const progress = ref<TaskProgress | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
@@ -19,6 +28,7 @@ export function useTask(taskId: string) {
|
||||
const s = await getStatus(taskId)
|
||||
status.value = s.status as TaskState
|
||||
stage.value = s.stage
|
||||
progress.value = (s as { progress?: TaskProgress | null }).progress ?? null
|
||||
} catch {
|
||||
/* transient — keep last known state */
|
||||
}
|
||||
@@ -51,5 +61,5 @@ export function useTask(taskId: string) {
|
||||
if (ws) ws.close()
|
||||
})
|
||||
|
||||
return { status, stage, start }
|
||||
return { status, stage, progress, start }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 任务进度页(终端风重构,2026-08-30 用户反馈:因子分析分钟级运行,
|
||||
* 进度条停着看不出是进行中还是死掉)。
|
||||
*
|
||||
* 因子任务核心=心跳:worker 跨进程写 progress 文件(stage/detail/ts),
|
||||
* useTask 轮询透传 → 本页显示「最近活动 Xs 前 · detail」+ 运行时长,
|
||||
* age 分档(绿/琥珀)告知活着 vs 长任务静默期。回测/优化走原 5 步推导。
|
||||
*/
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
@@ -7,12 +15,16 @@ import { getLog } from '@/api/backtest'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const taskId = String(route.params.id)
|
||||
const isFactor = computed(() => route.path.startsWith('/factor'))
|
||||
const isFactor = computed(() => route.path.startsWith('/factor') || taskId.startsWith('factor_'))
|
||||
const isOptimize = computed(() => taskId.startsWith('opt_'))
|
||||
|
||||
const { status, stage, start } = useTask(taskId)
|
||||
const { status, stage, progress, start } = useTask(taskId)
|
||||
|
||||
// 同时拉日志(3s)
|
||||
// —— 运行时长(本地秒表,页面挂载起) ——
|
||||
const elapsed = ref(0)
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// —— 日志(3s) ——
|
||||
const logText = ref('')
|
||||
const logBox = ref<HTMLElement | null>(null)
|
||||
let logTimer: ReturnType<typeof setInterval> | null = null
|
||||
@@ -31,9 +43,11 @@ onMounted(() => {
|
||||
start()
|
||||
void pullLog()
|
||||
logTimer = setInterval(pullLog, 3000)
|
||||
tickTimer = setInterval(() => { elapsed.value++ }, 1000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (logTimer) clearInterval(logTimer)
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
})
|
||||
|
||||
watch(status, (s) => {
|
||||
@@ -48,9 +62,9 @@ watch(status, (s) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 步骤推导
|
||||
// —— 通用(回测/优化)步骤推导 ——
|
||||
interface Step { key: string; label: string }
|
||||
const steps: Step[] = [
|
||||
const btSteps: Step[] = [
|
||||
{ key: 'submit', label: '任务提交' },
|
||||
{ key: 'data', label: '数据加载' },
|
||||
{ key: 'run', label: '回测执行' },
|
||||
@@ -58,22 +72,18 @@ const steps: Step[] = [
|
||||
{ key: 'done', label: '完成' },
|
||||
]
|
||||
|
||||
function stepState(idx: number): 'done' | 'active' | 'pending' {
|
||||
function btStepState(idx: number): 'done' | 'active' | 'pending' {
|
||||
const s = status.value
|
||||
if (s === 'done') return 'done'
|
||||
if (s === 'failed') {
|
||||
// 失败:当前进行到的步骤标 active(红),之前标 done
|
||||
return idx < currentIdx.value ? 'done' : idx === currentIdx.value ? 'active' : 'pending'
|
||||
}
|
||||
if (idx < currentIdx.value) return 'done'
|
||||
if (idx === currentIdx.value) return 'active'
|
||||
if (idx < btCurrentIdx.value) return 'done'
|
||||
if (idx === btCurrentIdx.value) return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const currentIdx = computed(() => {
|
||||
const btCurrentIdx = computed(() => {
|
||||
const st = (stage.value || '').toLowerCase()
|
||||
const s = status.value
|
||||
if (s === 'done') return steps.length - 1
|
||||
if (s === 'done') return btSteps.length - 1
|
||||
if (s === 'pending' || s === 'unknown') return 0
|
||||
if (st.includes('metric') || st.includes('指标')) return 3
|
||||
if (st.includes('run') || st.includes('backtest') || st.includes('回测')) return 2
|
||||
@@ -81,72 +91,158 @@ const currentIdx = computed(() => {
|
||||
return 1
|
||||
})
|
||||
|
||||
const pct = computed(() => {
|
||||
if (status.value === 'done') return 100
|
||||
if (status.value === 'failed') return Math.round((currentIdx.value / (steps.length - 1)) * 100)
|
||||
return Math.round((currentIdx.value / (steps.length - 1)) * 100)
|
||||
const btPct = computed(() => Math.round((btCurrentIdx.value / (btSteps.length - 1)) * 100))
|
||||
|
||||
// —— 因子任务步骤(由心跳 stage 驱动) ——
|
||||
const fxSteps: Step[] = [
|
||||
{ key: 'data', label: '行情加载' },
|
||||
{ key: 'compute', label: '因子计算' },
|
||||
{ key: 'analyze', label: '逐因子分析' },
|
||||
{ key: 'done', label: '完成' },
|
||||
]
|
||||
|
||||
const fxCurrentIdx = computed(() => {
|
||||
const s = status.value
|
||||
if (s === 'done') return fxSteps.length - 1
|
||||
const ps = progress.value?.stage
|
||||
if (ps === 'done') return 3
|
||||
if (ps === 'analyze') return 2
|
||||
if (ps === 'compute') return 1
|
||||
if (ps === 'data') return 0
|
||||
return 0 // 无心跳(排队/刚起步)
|
||||
})
|
||||
|
||||
function fxStepState(idx: number): 'done' | 'active' | 'pending' {
|
||||
if (status.value === 'done') return 'done'
|
||||
if (idx < fxCurrentIdx.value) return 'done'
|
||||
if (idx === fxCurrentIdx.value) return 'active'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const fxPct = computed(() => Math.round((fxCurrentIdx.value / (fxSteps.length - 1)) * 100))
|
||||
|
||||
// —— 心跳分档 ——
|
||||
const heartbeat = computed((): { level: 'ok' | 'slow' | 'stale' | 'none'; text: string } => {
|
||||
if (status.value === 'done') return { level: 'ok', text: '已完成' }
|
||||
if (status.value === 'failed') return { level: 'stale', text: '失败' }
|
||||
if (status.value === 'pending') return { level: 'none', text: '排队中,等待 worker 接活' }
|
||||
const p = progress.value
|
||||
if (!p) return { level: 'none', text: '等待首个心跳(分析进程启动中)…' }
|
||||
const a = p.age
|
||||
if (a <= 30) return { level: 'ok', text: `${p.age}s 前 · ${p.detail}` }
|
||||
if (a <= 180) return { level: 'slow', text: `${p.age}s 前 · ${p.detail}(大窗口因子计算静默期,仍在运行)` }
|
||||
return { level: 'stale', text: `${p.age}s 无新进展 · ${p.detail}——仍在运行,超长可考虑缩小标的池/区间` }
|
||||
})
|
||||
|
||||
function fmtElapsed(sec: number): string {
|
||||
if (sec < 60) return `${sec}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
if (m < 60) return `${m}m${sec % 60}s`
|
||||
return `${Math.floor(m / 60)}h${m % 60}m`
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = { running: '运行中', done: '完成', failed: '失败', pending: '排队中', unknown: '初始化' }
|
||||
const statusChipClass: Record<string, string> = { running: 'st-running', done: 'st-done', failed: 'st-failed', pending: 'st-pending', unknown: 'st-pending' }
|
||||
|
||||
const pageTitle = computed(() => (isFactor.value ? '因子分析' : isOptimize.value ? '参数优化' : '回测'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page progress-page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h2 class="page-title">{{ isFactor ? '因子分析' : isOptimize ? '参数优化' : '回测' }}进行中</h2>
|
||||
<h2 class="page-title">{{ pageTitle }}进行中</h2>
|
||||
<p class="page-subtitle">任务 ID:<span class="mono">{{ taskId }}</span></p>
|
||||
</div>
|
||||
<span class="chip" :class="statusChipClass[status]">{{ statusLabel[status] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<el-progress
|
||||
:percentage="pct"
|
||||
:status="status === 'failed' ? 'exception' : status === 'done' ? 'success' : undefined"
|
||||
:stroke-width="10"
|
||||
/>
|
||||
<div class="stage-text muted">当前阶段:{{ stage || '—' }}</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 步骤时间线 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">执行步骤</span></template>
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="(s, i) in steps" :key="s.key"
|
||||
class="tl-node"
|
||||
:class="[stepState(i), { failed: status === 'failed' && i === currentIdx }]"
|
||||
:style="{ flex: i === steps.length - 1 ? '0 0 auto' : '1' }"
|
||||
>
|
||||
<div class="tl-dot">
|
||||
<span v-if="stepState(i) === 'done'">✓</span>
|
||||
<span v-else>{{ i + 1 }}</span>
|
||||
</div>
|
||||
<div class="tl-label">{{ s.label }}</div>
|
||||
<div v-if="i < steps.length - 1" class="tl-bar" />
|
||||
<!-- 心跳区(因子任务:判活核心) -->
|
||||
<div v-if="isFactor" class="pane heartbeat" :class="`hb-${heartbeat.level}`">
|
||||
<div class="beacon" :class="{ stopped: status === 'done' || status === 'failed' }" />
|
||||
<div class="hb-main">
|
||||
<div class="hb-text" :class="`t-${heartbeat.level}`">{{ heartbeat.text }}</div>
|
||||
<div class="hb-sub">
|
||||
已运行 <span class="mono">{{ fmtElapsed(elapsed) }}</span>
|
||||
<span v-if="progress" class="dot">·</span>
|
||||
<span v-if="progress" class="mono">step {{ progress.stage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="hb-pct mono">{{ fxPct }}<span class="pct-sign">%</span></div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤时间线 -->
|
||||
<div class="pane">
|
||||
<div class="ph"><span class="pt">{{ isFactor ? '分析步骤' : '执行步骤' }}</span><span class="tag">{{ isFactor ? '由 worker 心跳驱动' : '按阶段推导' }}</span></div>
|
||||
<div class="pb">
|
||||
<div class="timeline">
|
||||
<div
|
||||
v-for="(s, i) in (isFactor ? fxSteps : btSteps)" :key="s.key"
|
||||
class="tl-node"
|
||||
:class="[isFactor ? fxStepState(i) : btStepState(i), { failed: status === 'failed' && i === (isFactor ? fxCurrentIdx : btCurrentIdx) }]"
|
||||
:style="{ flex: i === (isFactor ? fxSteps : btSteps).length - 1 ? '0 0 auto' : '1' }"
|
||||
>
|
||||
<div class="tl-dot">
|
||||
<span v-if="(isFactor ? fxStepState(i) : btStepState(i)) === 'done'">✓</span>
|
||||
<span v-else>{{ i + 1 }}</span>
|
||||
</div>
|
||||
<div class="tl-label">{{ s.label }}</div>
|
||||
<div v-if="i < (isFactor ? fxSteps : btSteps).length - 1" class="tl-bar" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isFactor" class="pct-bar"><div class="pct-fill" :style="{ width: btPct + '%' }" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<el-card class="blk" shadow="never">
|
||||
<template #header><span class="section-title">运行日志 <em class="muted">(每 3s 刷新)</em></span></template>
|
||||
<div ref="logBox" class="log-box">
|
||||
<pre v-if="logText">{{ logText }}</pre>
|
||||
<span v-else class="muted">暂无日志输出…</span>
|
||||
<div class="pane">
|
||||
<div class="ph"><span class="pt">运行日志</span><span class="tag">每 3s 刷新</span></div>
|
||||
<div class="pb">
|
||||
<div ref="logBox" class="log-box">
|
||||
<pre v-if="logText">{{ logText }}</pre>
|
||||
<span v-else class="empty">暂无日志输出…</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.blk { border: 1px solid var(--border-2); }
|
||||
.stage-text { margin-top: 10px; }
|
||||
.progress-page { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.pane { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-lg); min-width: 0; }
|
||||
.pane .ph { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border-2); flex-wrap: wrap; }
|
||||
.pane .pt { font-size: 12.5px; font-weight: 700; letter-spacing: .06em; }
|
||||
.pane .tag { font-family: var(--mono); font-size: 9.5px; color: var(--text-3); letter-spacing: .1em; }
|
||||
.pane .pb { padding: 14px; }
|
||||
|
||||
/* ===== 心跳区 ===== */
|
||||
.heartbeat { display: flex; align-items: center; gap: 16px; padding: 16px 18px; }
|
||||
.beacon { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.hb-ok .beacon { background: var(--lamp-ok); animation: breathe 1.6s ease-in-out infinite; }
|
||||
.hb-slow .beacon { background: var(--lamp-warn); animation: breathe 3s ease-in-out infinite; }
|
||||
.hb-stale .beacon { background: var(--lamp-crit); animation: breathe 3s ease-in-out infinite; }
|
||||
.hb-none .beacon { background: var(--lamp-idle); }
|
||||
.beacon.stopped { animation: none; }
|
||||
.hb-ok .beacon { box-shadow: 0 0 10px rgba(46, 230, 138, .5); }
|
||||
.hb-slow .beacon, .hb-stale .beacon { box-shadow: 0 0 10px rgba(255, 176, 0, .4); }
|
||||
@media (prefers-reduced-motion: reduce) { .beacon { animation: none; } }
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: .35; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.hb-main { flex: 1; min-width: 0; }
|
||||
.hb-text { font-family: var(--mono); font-size: 13px; }
|
||||
.t-ok { color: var(--down); }
|
||||
.t-slow { color: var(--amber); }
|
||||
.t-stale { color: var(--danger); }
|
||||
.t-none { color: var(--text-2); }
|
||||
.hb-sub { margin-top: 4px; font-family: var(--mono); font-size: 10.5px; color: var(--text-3); }
|
||||
.hb-sub .dot { margin: 0 6px; }
|
||||
.hb-pct { font-size: 30px; font-weight: 700; color: var(--brand); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
.hb-pct .pct-sign { font-size: 14px; color: var(--text-3); }
|
||||
|
||||
/* ===== 时间线 ===== */
|
||||
.timeline { display: flex; align-items: flex-start; }
|
||||
.tl-node { display: flex; flex-direction: column; align-items: center; position: relative; padding: 0 8px; }
|
||||
.tl-dot {
|
||||
@@ -157,33 +253,22 @@ const statusChipClass: Record<string, string> = { running: 'st-running', done: '
|
||||
border: 1px solid var(--border-2);
|
||||
z-index: 1;
|
||||
}
|
||||
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); }
|
||||
.tl-label { margin-top: 8px; font-size: 12px; color: var(--text-3); white-space: nowrap; }
|
||||
.tl-bar { position: absolute; top: 13px; left: 50%; width: 100%; height: 2px; background: var(--border-2); z-index: 0; }
|
||||
|
||||
.tl-node.done .tl-dot { background: var(--down); color: #fff; border-color: var(--down); }
|
||||
.tl-node.done .tl-label { color: var(--text); }
|
||||
.tl-node.done + .tl-node .tl-bar,
|
||||
.tl-node.done .tl-bar { background: var(--down); }
|
||||
.tl-node.active .tl-dot { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.tl-node.done + .tl-node .tl-bar { background: var(--down); }
|
||||
.tl-node.active .tl-dot { background: var(--brand); color: #032027; border-color: var(--brand); }
|
||||
.tl-node.active .tl-label { color: var(--brand); }
|
||||
.tl-node.active.failed .tl-dot { background: var(--danger); border-color: var(--danger); }
|
||||
.tl-node.active.failed .tl-dot { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.tl-node.active.failed .tl-label { color: var(--danger); }
|
||||
|
||||
.log-box {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r-sm);
|
||||
padding: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
.log-box pre {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-2);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.pct-bar { margin-top: 14px; height: 4px; background: var(--border-2); border-radius: var(--r-sm); overflow: hidden; }
|
||||
.pct-fill { height: 100%; background: var(--brand); transition: width .4s ease; }
|
||||
|
||||
/* ===== 日志 ===== */
|
||||
.log-box { background: var(--bg); border: 1px solid var(--border-2); border-radius: var(--r-sm); padding: 12px; max-height: 360px; overflow: auto; }
|
||||
.log-box pre { margin: 0; font-family: var(--mono); font-size: 12px; line-height: 1.6; color: var(--text-2); white-space: pre-wrap; word-break: break-all; }
|
||||
.empty { color: var(--text-3); font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -11,6 +11,16 @@ const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>()
|
||||
|
||||
const q = ref('')
|
||||
const picked = ref<Set<string>>(new Set(props.modelValue))
|
||||
// 默认收起(1-2 行预览);搜索或点「展开全部」后放开
|
||||
const collapsed = ref(true)
|
||||
|
||||
function toggleCollapse(): void {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
function onSearch(): void {
|
||||
if (q.value.trim()) collapsed.value = false
|
||||
}
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map = new Map<string, FactorItem[]>()
|
||||
@@ -68,11 +78,11 @@ defineExpose({ hydrate })
|
||||
<template>
|
||||
<div class="fp">
|
||||
<div class="searchrow">
|
||||
<input v-model="q" type="text" spellcheck="false" placeholder="搜索因子名 / 类别,如 alpha16、KMID、alpha158 …">
|
||||
<input v-model="q" type="text" spellcheck="false" placeholder="搜索因子名 / 类别,如 alpha16、KMID、alpha158 …" @input="onSearch">
|
||||
<span class="count">显示 {{ visibleCount() }} / {{ factors.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="groups">
|
||||
<div class="groups" :class="{ collapsed }">
|
||||
<div v-for="g in grouped" :key="g.cat" class="grp">
|
||||
<div class="gh">
|
||||
<span class="cat-chip" :class="`cat-${g.cat}`">{{ g.cat }}</span>
|
||||
@@ -87,6 +97,9 @@ defineExpose({ hydrate })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="foldbtn" @click="toggleCollapse">
|
||||
{{ collapsed ? `展开全部 ${factors.length} 个因子 ▾` : '收起因子列表 ▴' }}
|
||||
</button>
|
||||
|
||||
<div class="picked">
|
||||
<span class="pk">已选 {{ picked.size }}</span>
|
||||
@@ -109,6 +122,19 @@ defineExpose({ hydrate })
|
||||
.searchrow input:focus { outline: 1px solid rgba(0,229,255,.5); }
|
||||
.count { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
|
||||
.groups { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
/* 默认收起:两行分组预览(头部+两行 chips ≈ 128px);渐隐底边提示可展开 */
|
||||
.groups.collapsed { max-height: 128px; overflow: hidden; position: relative; }
|
||||
.groups.collapsed::after {
|
||||
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 34px;
|
||||
background: linear-gradient(transparent, var(--bg-card));
|
||||
pointer-events: none;
|
||||
}
|
||||
.foldbtn {
|
||||
font-family: var(--mono); font-size: 10.5px; padding: 4px 12px; margin: 8px 0 0;
|
||||
border-radius: var(--r-sm); border: 1px dashed var(--border); background: transparent;
|
||||
color: var(--text-2); cursor: pointer; transition: all .12s ease;
|
||||
}
|
||||
.foldbtn:hover { color: var(--brand); border-color: rgba(0, 229, 255, .4); }
|
||||
@media (max-width: 980px) { .groups { grid-template-columns: 1fr; } }
|
||||
.grp .gh { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.grp .gc { font-family: var(--mono); font-size: 10px; color: var(--text-3); }
|
||||
|
||||
@@ -190,9 +190,34 @@ def get_status(task_id: str):
|
||||
"status": s.value if hasattr(s, "value") else str(s),
|
||||
"stage": stage or "",
|
||||
"error_msg": task.error_msg if (task and isinstance(task.error_msg, str)) else None,
|
||||
# 因子分析心跳:worker 跨进程写 {output_dir}/{tid}.progress(stage/detail/ts),
|
||||
# 此处读出并算 age(距上次活动秒数)——前端显示"Xs 前 · detail"判活
|
||||
"progress": _read_factor_progress(task_id),
|
||||
}
|
||||
|
||||
|
||||
_FACTOR_PROGRESS_DIR = "/tmp/factor" # 因子分析 output_dir(analyzer 心跳文件所在,测试可 patch)
|
||||
|
||||
|
||||
def _read_factor_progress(task_id: str) -> dict | None:
|
||||
"""读因子任务进度心跳文件(不存在/损坏返 None,不影响 status 主链路)."""
|
||||
if not task_id.startswith("factor_"):
|
||||
return None
|
||||
try:
|
||||
import json as _json
|
||||
import time as _time
|
||||
|
||||
p = os.path.join(_FACTOR_PROGRESS_DIR, f"{task_id}.progress")
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
with open(p, encoding="utf-8") as f:
|
||||
d = _json.load(f)
|
||||
d["age"] = round(_time.time() - float(d.get("ts") or 0), 1)
|
||||
return d
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/task/{task_id}/result", dependencies=[Depends(verify_token)])
|
||||
def get_result(task_id: str):
|
||||
"""Get task result"""
|
||||
|
||||
@@ -60,6 +60,22 @@ class FactorReport:
|
||||
end: str = ""
|
||||
|
||||
|
||||
def _report_progress(task_id: str, output_dir: str, stage: str, detail: str) -> None:
|
||||
"""跨进程进度心跳:worker 写 {output_dir}/{task_id}.progress,
|
||||
GET /task/{id} 读它合并返回(前端显示"最近活动 Xs 前 · detail")。
|
||||
失败静默——进度是可观测性锦上添花,不能影响分析主流程。"""
|
||||
if not task_id:
|
||||
return
|
||||
try:
|
||||
import json as _j
|
||||
import time as _t
|
||||
p = os.path.join(output_dir, f"{task_id}.progress")
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
f.write(_j.dumps({"stage": stage, "detail": detail, "ts": _t.time()}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_factor_analysis(
|
||||
symbols: list[str],
|
||||
factor_names: list[str],
|
||||
@@ -67,7 +83,8 @@ def run_factor_analysis(
|
||||
end: str,
|
||||
cfg,
|
||||
output_dir: str,
|
||||
periods: tuple = (1, 5, 10)
|
||||
periods: tuple = (1, 5, 10),
|
||||
task_id: str = "",
|
||||
) -> FactorReport:
|
||||
"""
|
||||
Run factor analysis using AlphaLabSession and alphalens.
|
||||
@@ -131,7 +148,10 @@ def run_factor_analysis(
|
||||
|
||||
# Create AlphaLab session and load symbols
|
||||
session = AlphaLabSession(lab_path=output_dir)
|
||||
session.load_symbols(symbols, start, end, cfg)
|
||||
# 逐只加载 + 心跳(分钟级任务的可观测性:行情加载 i/N)
|
||||
for _i, _sym in enumerate(symbols, 1):
|
||||
_report_progress(task_id, output_dir, "data", f"行情加载 {_i}/{len(symbols)}: {_sym}")
|
||||
session.load_symbols([_sym], start, end, cfg)
|
||||
|
||||
# Calculate period split (simple deterministic split)
|
||||
from datetime import datetime
|
||||
@@ -146,6 +166,7 @@ def run_factor_analysis(
|
||||
test_period = (mid_point.strftime("%Y-%m-%d"), end)
|
||||
|
||||
# Compute factors using AlphaLabSession
|
||||
_report_progress(task_id, output_dir, "compute", f"计算因子特征: {', '.join(factor_names)}")
|
||||
factor_df = session.compute_factors(factor_names, train_period, valid_period, test_period)
|
||||
|
||||
# Load close prices separately for tears computation
|
||||
@@ -186,7 +207,8 @@ def run_factor_analysis(
|
||||
tears_paths = {}
|
||||
|
||||
# Process each factor
|
||||
for factor_name in factor_names:
|
||||
for _fi, factor_name in enumerate(factor_names, 1):
|
||||
_report_progress(task_id, output_dir, "analyze", f"因子 {_fi}/{len(factor_names)}: {factor_name}")
|
||||
try:
|
||||
# Convert polars DataFrame to pandas for alphalens
|
||||
factor_pd = factor_df.to_pandas()
|
||||
@@ -311,6 +333,7 @@ def run_factor_analysis(
|
||||
with open(tears_path, "w", encoding="utf-8") as _tf:
|
||||
_json.dump(tears, _tf, ensure_ascii=False)
|
||||
tears_paths[factor_name] = tears_path
|
||||
_report_progress(task_id, output_dir, "analyze", f"因子 {_fi}/{len(factor_names)}: {factor_name} tears ✓")
|
||||
except Exception as tears_json_e:
|
||||
ic_summary[factor_name]["tears_json_error"] = (
|
||||
f"{type(tears_json_e).__name__}: {tears_json_e}"
|
||||
@@ -394,6 +417,7 @@ def run_factor_analysis(
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
|
||||
_report_progress(task_id, output_dir, "done", "分析完成")
|
||||
return FactorReport(
|
||||
factor_names=factor_names,
|
||||
output_dir=output_dir,
|
||||
|
||||
@@ -128,7 +128,7 @@ class Orchestrator:
|
||||
spec = self._pending[task_id]
|
||||
fut: Future = self.pool.submit_work(
|
||||
task_id, _factor_worker, spec["symbols"], spec["factor_names"],
|
||||
spec["start"], spec["end"], spec["cfg"], spec["output_dir"]
|
||||
spec["start"], spec["end"], spec["cfg"], spec["output_dir"], task_id
|
||||
)
|
||||
|
||||
task = self.pool.get_task(task_id)
|
||||
@@ -382,10 +382,10 @@ def _opt_worker(strategy_class, symbol: str, grid: dict, start: str, end: str, c
|
||||
return run_cta_optimization(strategy_class, symbol, grid, start, end, cfg, db_path, task_id=task_id)
|
||||
|
||||
|
||||
def _factor_worker(symbols: list, factor_names: list, start: str, end: str, cfg, output_dir: str) -> any:
|
||||
def _factor_worker(symbols: list, factor_names: list, start: str, end: str, cfg, output_dir: str, task_id: str = "") -> any:
|
||||
"""Worker for factor analysis (lazy import, spawn-friendly)"""
|
||||
from sanguo_factor.analyzer import run_factor_analysis
|
||||
return run_factor_analysis(symbols, factor_names, start, end, cfg, output_dir)
|
||||
return run_factor_analysis(symbols, factor_names, start, end, cfg, output_dir, task_id=task_id)
|
||||
|
||||
|
||||
def _portfolio_worker(spec: dict) -> any:
|
||||
|
||||
@@ -130,3 +130,30 @@ def test_universe_pool_and_search(client, token, monkeypatch):
|
||||
assert r2.status_code == 200 and r2.json() == []
|
||||
r3 = client.get("/api/v1/factor/universe/search", params={"q": "茅台"}, headers=h)
|
||||
assert r3.status_code == 200 and len(r3.json()) == 1
|
||||
|
||||
|
||||
# —— 因子进度心跳(_read_factor_progress) ——
|
||||
|
||||
def test_read_factor_progress_roundtrip(tmp_path, monkeypatch):
|
||||
import json, time
|
||||
from sanguo_api import routes as rt
|
||||
|
||||
p = tmp_path / "factor_abc.progress"
|
||||
p.write_text(json.dumps({"stage": "analyze", "detail": "因子 1/2", "ts": time.time() - 5}),
|
||||
encoding="utf-8")
|
||||
monkeypatch.setattr(rt, "_FACTOR_PROGRESS_DIR", str(tmp_path))
|
||||
d = rt._read_factor_progress("factor_abc")
|
||||
assert d is not None and d["stage"] == "analyze" and 4 <= d["age"] <= 15
|
||||
|
||||
|
||||
def test_read_factor_progress_bad_input(tmp_path, monkeypatch):
|
||||
from sanguo_api import routes as rt
|
||||
|
||||
# 非 factor 任务恒 None
|
||||
assert rt._read_factor_progress("cta_123") is None
|
||||
monkeypatch.setattr(rt, "_FACTOR_PROGRESS_DIR", str(tmp_path))
|
||||
# 文件不存在 → None
|
||||
assert rt._read_factor_progress("factor_missing") is None
|
||||
# 损坏 JSON → None(不影响 status 主链路)
|
||||
(tmp_path / "factor_bad.progress").write_text("{not json", encoding="utf-8")
|
||||
assert rt._read_factor_progress("factor_bad") is None
|
||||
|
||||
@@ -376,3 +376,28 @@ def test_run_factor_analysis_ic_extraction_fails_gracefully(tmp_path):
|
||||
assert "error" in report.ic_summary["ma5"]["ic"]
|
||||
# Status and report should still be present
|
||||
assert "status" in report.ic_summary["ma5"]
|
||||
|
||||
|
||||
# —— 跨进程进度心跳(2026-08-30:progress 页判活) ——
|
||||
|
||||
def test_report_progress_writes_file(tmp_path):
|
||||
"""task_id 空=不写;正常写 JSON(stage/detail/ts)."""
|
||||
import json
|
||||
from sanguo_factor.analyzer import _report_progress
|
||||
|
||||
_report_progress("", str(tmp_path), "data", "不应写入")
|
||||
assert not (tmp_path / ".progress").exists()
|
||||
|
||||
_report_progress("factor_ab12", str(tmp_path), "analyze", "因子 2/5: kmid")
|
||||
d = json.loads((tmp_path / "factor_ab12.progress").read_text(encoding="utf-8"))
|
||||
assert d["stage"] == "analyze"
|
||||
assert d["detail"] == "因子 2/5: kmid"
|
||||
assert d["ts"] > 0
|
||||
|
||||
|
||||
def test_report_progress_never_raises(tmp_path, monkeypatch):
|
||||
"""进度是锦上添花:IO 故障必须静默(不影响分析主流程)."""
|
||||
from sanguo_factor.analyzer import _report_progress
|
||||
|
||||
monkeypatch.setattr("builtins.open", lambda *a, **k: (_ for _ in ()).throw(OSError("boom")))
|
||||
_report_progress("factor_x", str(tmp_path), "data", "x") # 不抛即过
|
||||
|
||||
Reference in New Issue
Block a user