Files
qlib/frontend/web/app/signals/page.tsx
T
Simon e58367af27 feat(web): 因子研究/因子组合/股票筛选接入统一作业反馈;阶段圆点按作业类型区分
接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套
`useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈):

- **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与
  按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条,
  阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子,
  不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条
  只能显示最后一个作业,前几个失败不能丢)。
- **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` /
  「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误
  (同一失败不在两处各说一遍)。
- **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections`
  没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。
- **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段),
  改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、
  出错显示后端原文」。
- **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的
  `STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰
  (`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。
  现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总],
  selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段),
  未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。

验证(真实浏览器 CDP,读数原文已记录):
- /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、
  圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举);
  成功态给「去对比 / 打开归档」。
- /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」;
  另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。
- /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。
- /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。
- 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。
2026-10-01 18:37:25 +08:00

234 lines
8.6 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";
/** 交易信号(v2 §15):基于选股评分排名 + 趋势规则生成 BUY / WATCH / SELL,
* 每条带理由(为什么);支持当前/历史 as_of,历史记录点击回看。
*/
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type {
FactorMeta,
SelectionQuery,
SignalMeta,
SignalResult,
SignalRun,
} from "@/lib/types";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Empty,
SkeletonLines,
} from "@/components/ui";
/**
* 读取后端 payload 里的 name(若存在)。
*
* 为什么用宽松读取而非直接 `e.name`:`lib/types.ts` 的 SignalEvent 目前没有声明 name
* 字段(该文件由其他改动维护);后端可能已返回更权威的中文名。取不到就交给 SymbolLink
* 渲染占位「—」,不编造名称(AGENT §24)。
*/
function payloadName(row: unknown): string | undefined {
if (row && typeof row === "object" && "name" in row) {
const n = (row as { name?: unknown }).name;
if (typeof n === "string" && n.trim()) return n;
}
return undefined;
}
export default function SignalsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [factor, setFactor] = useState("momentum_60");
const [asOf, setAsOf] = useState("");
const [symbols, setSymbols] = useState("600519.SH,000001.SZ,300750.SZ,601318.SH,000858.SZ");
const [buyRank, setBuyRank] = useState(2);
const [sellRank, setSellRank] = useState(5);
const [result, setResult] = useState<SignalResult | null>(null);
const [signalId, setSignalId] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [history, setHistory] = useState<SignalMeta[] | null>(null);
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
// 停用/算不出来的因子不摆进候选(停用仍可被既有策略解析,只是不该再被选中)
.then((list) => alive && list.length > 0 && setFactors(pickableFactors(list)))
.catch((e: Error) => alive && setError(e.message));
apiGet<SignalMeta[]>("/signals?limit=8").then(setHistory).catch(() => alive && setHistory([]));
return () => {
alive = false;
};
}, []);
function buildQuery(): SelectionQuery {
return {
universe: { exclude_st: true, min_listing_days: 0, symbols: symbols.split(/[,,]/).map((s) => s.trim()).filter(Boolean) },
as_of: asOf || null,
method: "score",
factors: [{ name: factor, weight: 1 }],
conditions: [],
top_n: sellRank + 10,
};
}
async function run() {
setRunning(true);
setError("");
setResult(null);
try {
const run = await apiPost<SignalRun>("/signals", {
query: buildQuery(),
rules: { buy_rank_threshold: buyRank, sell_rank_threshold: sellRank, max_output_rank: 60 },
});
setSignalId(run.signal_id);
setResult(run.result);
apiGet<SignalMeta[]>("/signals?limit=8").then(setHistory).catch(() => undefined);
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
}
}
async function openHistory(id: string) {
try {
const detail = await apiGet<SignalResult>(`/signals/${id}`);
setSignalId(id);
setResult(detail);
} catch (e) {
setError((e as Error).message);
}
}
return (
<>
<PageHeader
title="交易信号"
sub="基于选股综合分排名与趋势规则输出 BUY / WATCH / SELL,每条带触发理由;与回测共用同一评分引擎。"
actions={signalId ? <Pill tone="violet" icon="check">{signalId}</Pill> : null}
/>
<Card title="信号参数" icon="scale">
<div className="form-grid">
<Field label="评分因子">
<select className="input" value={factor} onChange={(e) => setFactor(e.target.value)}>
{factors.map((f) => (
<option key={f.name} value={f.name}>{factorOptionLabel(f)}</option>
))}
</select>
</Field>
<Field label="买入排名阈值 (≤)">
<input className="input" type="number" min={1} value={buyRank} onChange={(e) => setBuyRank(Number(e.target.value))} />
</Field>
<Field label="卖出/警示排名阈值 (>)">
<input className="input" type="number" min={1} value={sellRank} onChange={(e) => setSellRank(Number(e.target.value))} />
</Field>
<Field label="时点 as_of" hint="留空 = 最近交易日">
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
</Field>
<Field label="股票白名单" hint="逗号分隔(≤60)">
<input className="input" value={symbols} onChange={(e) => setSymbols(e.target.value)} />
</Field>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
{running ? "生成中…" : "生成信号"}
</Btn>
</div>
{/*
为什么这里没有 JobProgress:`POST /signals` 是**同步**接口(backend/app/api/signals.py
只有 POST "",没有 /signals/jobs),拿不到 job_id,也就无从轮询阶段 —— 套 useJobRunner
会去 GET /jobs/{signal_id} 撞 404,而合成一个假的 JobRunState 更是「看似有数据的假象」。
所以这里如实说清三件事:同步连接、没有作业号/阶段/取消、出错时显示后端原文。
*/}
{running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
同步请求已发出:请求期间浏览器一直连着后端(这条路径不是后台作业,不能关页离开)。
因为是同步接口,<b>没有作业号与执行阶段,也没有取消按钮</b>。
该请求通常在数秒内返回;若后端报错或连接中断,下方会显示后端原文。
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
{result ? <ResultView result={result} /> : null}
{history && history.length > 0 ? (
<Card title="最近信号记录" icon="archive">
<div className="chips">
{history.map((m) => (
<button key={m.id} className="chip" style={{ cursor: "pointer" }} onClick={() => openHistory(m.id)} title={m.id}>
<b className="mono">{m.as_of}</b>
买 {m.buy} / 观 {m.watch} / 卖 {m.sell}
</button>
))}
</div>
</Card>
) : null}
</>
);
}
function ResultView({ result }: { result: SignalResult }) {
const st = result.statistics;
return (
<Card
icon="scale"
title={`信号 · ${result.as_of_date}`}
tools={
<>
<Pill tone="pos">BUY {st.buy}</Pill>
<Pill>WATCH {st.watch}</Pill>
<Pill tone="neg">SELL {st.sell}</Pill>
</>
}
>
{result.events.length === 0 ? (
<Empty icon="scale" title="无信号输出" hint="放宽排名阈值或检查白名单数据。" />
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>类型</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>价格</th>
<th>触发理由</th>
</tr>
</thead>
<tbody>
{result.events.map((e) => (
<tr key={e.symbol}>
<td>
<Pill tone={e.signal_type === "BUY" ? "pos" : e.signal_type === "SELL" ? "neg" : undefined}>
{e.signal_type}
</Pill>
</td>
<td><SymbolLink symbol={e.symbol} name={payloadName(e)} /></td>
<td className="mono">{e.score?.toFixed(4)}</td>
<td className="mono">{e.price ?? "-"}</td>
<td style={{ fontSize: 12 }}>
<div className="chips">
{e.trigger_reason.map((r) => (
<span className="chip" key={r}>{r}</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
);
}