接着 /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,复查无残留)。
234 lines
8.6 KiB
TypeScript
234 lines
8.6 KiB
TypeScript
"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>
|
||
);
|
||
}
|