Files
qlib/frontend/web/app/factors/compose/page.tsx
T
Simon 48a97c2a12 feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

341 lines
12 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 { 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,
Progress,
Signed,
} from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
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);
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState<string | 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(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;
}
setRunning(true);
setError("");
setJobId("");
setResult(null);
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
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);
setArchiveId(out.experimentId ?? null);
} 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>
{/* 勾选框要用 .check--cell 包一层:裸 16px 的 checkbox 点击目标太小
(本项目 UI 自检要求 ≥28px,且与同排输入框等高) */}
<label className="check check--cell">
<input
type="checkbox"
checked={!!pick}
onChange={() => togglePick(f.name)}
aria-label={`选择因子 ${f.name}`}
/>
</label>
</td>
<td className="cell-strong">
{factorLabel(f)}
{/* 名字即身份:中文名旁边的引擎键必须看得见(含参数,如 momentum(window=90,…)) */}
<span className="hint mono cell-hint" style={{ fontWeight: 400, overflowWrap: "anywhere" }}>
{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 }}
archiveId={archiveId}
/>
) : 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 (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<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>
<Pill>{params.picks.length} 个因子</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div>
</div>
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
<BacktestResultView
result={result}
name="因子组合"
archive={archiveId ? { id: archiveId } : undefined}
/>
</>
);
}