"use client"; import { useEffect, useState } from "react"; import { apiGet } from "@/lib/api"; import { submitJob, waitJob } from "@/lib/jobs"; import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types"; import { recentRange } from "@/lib/dates"; import { PageHeader, Card, Metric, Pill, Field, Btn, Banner, Progress, Empty, SkeletonLines, } from "@/components/ui"; export default function FactorsPage() { const [factors, setFactors] = useState([]); const [loading, setLoading] = useState(true); const [checked, setChecked] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [reports, setReports] = useState>({}); const [running, setRunning] = useState(false); const [processed, setProcessed] = useState(0); const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { let alive = true; apiGet("/factors") .then((list) => { if (!alive) return; setFactors(list); const picks = ["momentum_60", "volatility_60"].filter((n) => list.some((f) => f.name === n), ); setChecked(new Set(picks)); }) .catch((e: Error) => setError(e.message)) .finally(() => alive && setLoading(false)); const { start: s, end: e } = recentRange(); setStart(s); setEnd(e); return () => { alive = false; }; }, []); function toggleCheck(name: string) { setChecked((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } function toggleExpand(name: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } async function run() { const names = factors.map((f) => f.name).filter((n) => checked.has(n)); if (names.length === 0) { setError("请至少选择一个因子"); return; } setRunning(true); setError(""); setJobId(""); setReports({}); setProcessed(0); try { let index = 0; for (const name of names) { index += 1; setCurrent({ idx: index, total: names.length, name }); const spec: ResearchSpec = { type: "factor_test", universe: { exclude_st: true, min_listing_days: 0 }, factors: [{ name, weight: 1 }], selection: { top_n: 10 }, rebalance: "monthly", period: [start, end], }; const { job_id } = await submitJob(spec); setJobId(job_id); const out = await waitJob(job_id); if (out.status === "success" && out.result) { setReports((prev) => ({ ...prev, [name]: out.result! })); } else { const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`; setError((prev) => (prev ? `${prev}\n${msg}` : msg)); } setProcessed(index); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); setCurrent(null); setJobId(""); } } const selectedCount = checked.size; const reportNames = Object.keys(reports); const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100); return ( <> {reportNames.length}/{selectedCount} 个已出报告} /> {error && !running ? (
{error}
) : null} 已选 {selectedCount} 个} flush > {loading ? (
) : factors.length === 0 ? ( ) : ( <>
{factors.map((f) => ( toggleCheck(f.name)} onToggleExpand={() => toggleExpand(f.name)} /> ))}
因子 回看 方向 简介(用法 / 何时有效)
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
)}
执行中 : undefined}>
setStart(e.target.value)} /> setEnd(e.target.value)} /> {running ? `运行中 ${processed}/${selectedCount}` : selectedCount === 0 ? "请先勾选因子" : `运行因子测试(${selectedCount} 个)`}
{running ? (
正在运行:{current?.name}({current?.idx}/{current?.total})· 后台任务{" "} {jobId || "排队中…"} } />
) : null}
{reportNames.length === 0 && !running && !error ? ( ) : null} {reportNames.map((name) => { const meta = factors.find((f) => f.name === name); return ( 已完成 } > ); })} ); } function FactorRow(props: { factor: FactorMeta; checked: boolean; expanded: boolean; onToggleCheck: () => void; onToggleExpand: () => void; }) { const { factor: f } = props; return ( <> e.stopPropagation()}> {f.name} {f.lookback} 日 {f.direction === "higher_is_better" ? "高为好" : "低为好"} {f.brief ?? f.description} {props.expanded ? (
{f.name} · {f.description}
公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency}; 方向: {f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
{f.brief ?
{f.brief}
: null}
) : null} ); } function ReportView({ report }: { report: FactorTestReport }) { const qs = report.quantile_returns ?? []; const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct))); return (
= 0 ? "pos" : "neg"} /> = 0 ? "pos" : "neg"} /> = 1 ? "pos" : "plain"} sub="越大越稳定" /> = 50 ? "pos" : "warn"} />
{qs.length > 0 ? (
分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益)
{qs.map((q) => { const v = q.return_pct; const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72)); return (
{v >= 0 ? "+" : ""}{v.toFixed(2)}%
= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} /> Q{q.quantile + 1}
); })}
) : null}
读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。 单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
); }