Files
qlib/frontend/web/app/factors/compose/page.tsx
T
Simon 24f98e90c6 feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)
- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等
- 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题
- 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐
- 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标
- LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
2026-09-08 21:48:20 +08:00

330 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<FactorMeta[]>([]);
const [picks, setPicks] = useState<Pick[]>([]);
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<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
const [error, setError] = useState("");
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/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<BacktestResult>(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 (
<>
<PageHeader
title="因子组合"
sub="多因子横截面 z-score 标准化后按权重叠加成综合分,每期取 Top N 等权持有。"
actions={
<Pill tone="accent" icon="layers">
{picks.length} 个因子
</Pill>
}
/>
<Card title="组合方式(如何合成一个分数)" icon="scale">
<ol className="steplist">
<li>选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。</li>
<li>
每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b>(当日缺失因子值的股票不参与该因子得分)。
</li>
<li>
综合得分 = <b>Σ(权重 × 标准化后因子得分)</b>,得分越高越优先。
</li>
<li>每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。</li>
</ol>
</Card>
<Card
title="选择因子并设置权重"
icon="target"
sub="权重可为小数(如 1.5 = 更侧重该因子)"
flush
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 46 }}></th>
<th>因子</th>
<th>方向</th>
<th style={{ width: 120 }}>权重</th>
<th>简介</th>
</tr>
</thead>
<tbody>
{factors.map((f) => {
const pick = picks.find((p) => p.name === f.name);
return (
<tr key={f.name} className={pick ? "row-active" : undefined}>
<td>
<input
type="checkbox"
checked={!!pick}
onChange={() => togglePick(f.name)}
aria-label={`选择因子 ${f.name}`}
/>
</td>
<td className="cell-strong">
<span className="mono">{f.name}</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
</td>
<td>
<input
type="number"
className="input"
step="0.1"
min="0.1"
style={{ width: 92 }}
value={pick?.weight ?? 1}
disabled={!pick}
onChange={(e) => setWeight(f.name, Number(e.target.value))}
aria-label={`${f.name} 权重`}
/>
</td>
<td className="hint">{f.brief ?? f.description}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
<Card title="回测条件" icon="gauge">
{picks.length > 0 ? (
<div className="chips" style={{ marginBottom: 14 }}>
{picks.map((p) => (
<span className="chip" key={p.name}>
<b className="mono">{p.name}</b>
权重 {p.weight}
<button
type="button"
className="chip__x"
aria-label={`移除 ${p.name}`}
onClick={() => removePick(p.name)}
>
<Icon name="x" size={12} />
</button>
</span>
))}
</div>
) : (
<div className="hint" style={{ marginBottom: 14 }}>
尚未选择因子 —— 在上方勾选并设置权重。
</div>
)}
<div className="form-grid">
<Field label="Top N">
<input
type="number"
className="input"
min={1}
max={200}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</Field>
<Field label="调仓频率">
<select
className="input"
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || picks.length === 0}
onClick={run}
>
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={30}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
</>
}
/>
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : 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 (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Pill tone="violet" icon="target">Top {params.topN}</Pill>
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<UnimplementedNote items={result.unimplemented} />
</>
);
}