"use client"; import { useEffect, useState } from "react"; import { apiGet } from "@/lib/api"; import { submitJob, useJobRunner } from "@/lib/jobs"; import { factorLabel, pickableFactors } from "@/lib/factors"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import { recentRange } from "@/lib/dates"; import { PageHeader, Card, Pill, Field, Btn, Banner, Signed, } from "@/components/ui"; import { BacktestResultView } from "@/components/BacktestResultView"; import { JobProgress } from "@/components/JobProgress"; import { Icon } from "@/components/icons"; interface Pick { name: string; weight: number; } export default function ComposePage() { const [factors, setFactors] = useState([]); const [picks, setPicks] = useState([]); const [topN, setTopN] = useState(10); const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly"); const [excludeSt, setExcludeSt] = useState(true); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [result, setResult] = useState(null); /** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */ const [archiveId, setArchiveId] = useState(null); /** * 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」, * 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。 * 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30), * 用户既不知道提交成功没、也不知道跑到哪一步。 */ const job = useJobRunner("因子组合回测"); const active = job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running"; /** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */ const [error, setError] = useState(""); useEffect(() => { let alive = true; apiGet("/factors") .then((list) => { if (!alive) return; // 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中) setFactors(pickableFactors(list)); setPicks([ { name: "momentum_60", weight: 1.5 }, { name: "volatility_60", weight: 1.0 }, ]); }) .catch((e: Error) => alive && setError(e.message)); const { start: s, end: e } = recentRange(); setStart(s); setEnd(e); return () => { alive = false; }; }, []); function togglePick(name: string) { setPicks((prev) => prev.some((p) => p.name === name) ? prev.filter((p) => p.name !== name) : [...prev, { name, weight: 1 }], ); } function setWeight(name: string, weight: number) { setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p))); } function removePick(name: string) { setPicks((prev) => prev.filter((p) => p.name !== name)); } async function run() { const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0); if (valid.length === 0) { setError("请选择至少一个因子并设置大于 0 的权重"); return; } setError(""); setResult(null); setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果 const spec: ResearchSpec = { type: "backtest", universe: { exclude_st: excludeSt, min_listing_days: 0 }, factors: valid.map((p) => ({ name: p.name, weight: p.weight })), selection: { top_n: topN }, rebalance, period: [start, end], }; const out = await job.run(() => submitJob(spec), { label: `因子组合回测(${valid.length} 个因子)`, }); if (out.status === "success" && out.result) { setResult(out.result); // 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」 setArchiveId(out.experimentId ?? null); } // 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、 // 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败 } return ( <> {picks.length} 个因子 } />
  1. 选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。
  2. 每个交易日:每个因子在全体股票横截面上做 z-score 标准化(当日缺失因子值的股票不参与该因子得分)。
  3. 综合得分 = Σ(权重 × 标准化后因子得分),得分越高越优先。
  4. 每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。
{factors.map((f) => { const pick = picks.find((p) => p.name === f.name); return ( ); })}
因子 方向 权重 简介
{/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小 (本项目 UI 自检要求 ≥28px,且与同排输入框等高) */} {factorLabel(f)} {/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */} {f.name} {f.direction === "higher_is_better" ? "高为好" : "低为好"} setWeight(f.name, Number(e.target.value))} aria-label={`${f.name} 权重`} /> {f.brief ?? f.description}
{picks.length > 0 ? (
{picks.map((p) => ( {p.name} 权重 {p.weight} ))}
) : (
尚未选择因子 —— 在上方勾选并设置权重。
)}
setTopN(Number(e.target.value))} /> setStart(e.target.value)} /> setEnd(e.target.value)} /> {active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
{/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */} {error ?
{error}
: null}
{result ? ( ) : null} ); } function ResultView({ result, params, archiveId, }: { result: BacktestResult; params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string }; archiveId?: string | null; }) { const s = result.summary; return ( <>
回测完成 Top {params.topN} {params.rebalance === "monthly" ? "月度调仓" : "周度调仓"} {params.excludeSt ? 剔除 ST : null} {params.start} ~ {params.end} {params.picks.length} 个因子
区间收益
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里, 两处各写一份迟早出现「同一个结果两套图」的口径漂移 */} ); }