feat(factor): 进度页心跳判活+因子列表默认折叠(用户08-30反馈两项) [vps]
CI/CD / test (push) Successful in 4s
CI/CD / nas-deploy (push) Successful in 11s
CI/CD / nas-verify (push) Successful in 7s

痛点:因子分析分钟级运行,进度条停着看不出进行中还是死掉;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:
2026-08-30 10:17:19 +08:00
parent 557479b848
commit 8f4f564733
9 changed files with 315 additions and 86 deletions
+7
View File
@@ -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 {
+11 -1
View File
@@ -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 }
}
+162 -77
View File
@@ -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>
+28 -2
View File
@@ -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); }
+25
View File
@@ -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"""
+27 -3
View File
@@ -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,
+3 -3
View File
@@ -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:
+27
View File
@@ -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
+25
View File
@@ -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") # 不抛即过