"use client"; import { useEffect, useState } from "react"; import { apiGet } from "@/lib/api"; import { submitJob, waitJob } from "@/lib/jobs"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import { recentRange } from "@/lib/dates"; import { PageHeader, Card, Pill, Field, Btn, Banner, Progress, Signed, BacktestMetrics, MonthlyReturnsTable, UnimplementedNote, } from "@/components/ui"; import { LineChart } from "@/components/LineChart"; 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); const [running, setRunning] = useState(false); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { let alive = true; apiGet("/factors") .then((list) => { if (!alive) return; setFactors(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; } setRunning(true); setError(""); setJobId(""); setResult(null); try { 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 { job_id } = await submitJob(spec); setJobId(job_id); const out = await waitJob(job_id); if (out.status === "success" && out.result) { setResult(out.result); } else { setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); } } catch (e) { setError((e as Error).message); } finally { setRunning(false); setJobId(""); } } 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 ( ); })}
因子 方向 权重 简介
togglePick(f.name)} aria-label={`选择因子 ${f.name}`} /> {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)} /> {running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
{running ? (
任务 {jobId || "排队中…"} 后台执行中,完成后结果自动展示在本页。 } />
) : null} {error ?
{error}
: null}
{result ? : null} ); } function ResultView({ result, params, }: { result: BacktestResult; params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string }; }) { const s = result.summary; return ( <>
回测完成 Top {params.topN} {params.rebalance === "monthly" ? "月度调仓" : "周度调仓"} {params.excludeSt ? 剔除 ST : null} {params.start} ~ {params.end}
区间收益
期末 {s.final_equity.toLocaleString()}}> d.slice(5)} yFmt={(v) => v.toFixed(2)} /> 最大 {s.max_drawdown_pct.toFixed(2)}%}> d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
); }