接着 /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,复查无残留)。
527 lines
21 KiB
TypeScript
527 lines
21 KiB
TypeScript
"use client";
|
||
|
||
/** 股票筛选(选股系统 M6,v2 §14/§21.1):
|
||
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
|
||
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
|
||
*/
|
||
import Link from "next/link";
|
||
import { useEffect, useState } from "react";
|
||
import { apiGet, apiPost } from "@/lib/api";
|
||
import { SymbolLink } from "@/lib/symbols";
|
||
import { useJobRunner } from "@/lib/jobs";
|
||
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
|
||
import type {
|
||
FactorMeta,
|
||
SelectionCondition,
|
||
SelectionMeta,
|
||
SelectionQuery,
|
||
SelectionResult,
|
||
SelectionRun,
|
||
} from "@/lib/types";
|
||
import {
|
||
PageHeader,
|
||
Card,
|
||
Pill,
|
||
Field,
|
||
Btn,
|
||
Banner,
|
||
Empty,
|
||
SkeletonLines,
|
||
} from "@/components/ui";
|
||
import { JobProgress } from "@/components/JobProgress";
|
||
|
||
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
|
||
{ value: "static.industry", label: "行业 industry", kind: "str" },
|
||
{ value: "static.market", label: "市场 market", kind: "str" },
|
||
{ value: "momentum_20", label: "动量 20 日", kind: "num" },
|
||
{ value: "momentum_60", label: "动量 60 日", kind: "num" },
|
||
{ value: "close", label: "收盘价 close", kind: "num" },
|
||
{ value: "close_ma60_ref", label: "收盘 > MA60", kind: "ref" },
|
||
{ value: "volume_ratio_5_60", label: "量比", kind: "num" },
|
||
{ value: "fundamental.roe", label: "ROE(%)", kind: "num" },
|
||
{ value: "fundamental.eps", label: "EPS", kind: "num" },
|
||
];
|
||
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
|
||
|
||
/**
|
||
* 读取后端 payload 里的 name(若存在)。
|
||
*
|
||
* 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有
|
||
* 声明 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;
|
||
}
|
||
|
||
function emptyCondition(): SelectionCondition {
|
||
return { field: "static.industry", op: "eq", value: "白酒" };
|
||
}
|
||
|
||
export default function SelectionPage() {
|
||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||
const [mode, setMode] = useState<"score" | "condition">("score");
|
||
const [asOf, setAsOf] = useState(""); // 空 = 最近交易日
|
||
const [excludeSt, setExcludeSt] = useState(true);
|
||
const [topN, setTopN] = useState(20);
|
||
const [factorRows, setFactorRows] = useState([{ name: "momentum_60", weight: 1 }]);
|
||
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
|
||
const [result, setResult] = useState<SelectionResult | null>(null);
|
||
const [selectionId, setSelectionId] = useState("");
|
||
/**
|
||
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
|
||
*
|
||
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
|
||
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
|
||
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
|
||
*/
|
||
const [savedSelectionId, setSavedSelectionId] = useState("");
|
||
/**
|
||
* 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
|
||
* 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
|
||
*/
|
||
const [running, setRunning] = useState(false);
|
||
/**
|
||
* 异步选股(POST /selections/jobs)的作业反馈统一交给 `useJobRunner`:
|
||
* 提交瞬间就有「正在提交作业…」,之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
|
||
* 此前只有一个 asyncBusy 布尔值,用户点了「异步(全市场)」只看到按钮变灰,
|
||
* 不知道到底提交成功没、作业号是多少(正是「点了没反应」的投诉点)。
|
||
*/
|
||
const job = useJobRunner<SelectionResult>("异步选股(全市场)");
|
||
/** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
|
||
const asyncActive =
|
||
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
|
||
/** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
|
||
const [error, setError] = useState("");
|
||
const [history, setHistory] = useState<SelectionMeta[] | 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<SelectionMeta[]>("/selections?limit=8")
|
||
.then((rows) => alive && setHistory(rows))
|
||
.catch(() => alive && setHistory([]));
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, []);
|
||
|
||
function buildQuery(): SelectionQuery {
|
||
if (mode === "score") {
|
||
return {
|
||
universe: { exclude_st: excludeSt, min_listing_days: 0 },
|
||
as_of: asOf || null,
|
||
method: "score",
|
||
factors: factorRows.filter((r) => r.name),
|
||
conditions: [],
|
||
top_n: topN,
|
||
};
|
||
}
|
||
return {
|
||
universe: { exclude_st: excludeSt, min_listing_days: 0 },
|
||
as_of: asOf || null,
|
||
method: "condition",
|
||
factors: [],
|
||
conditions: conds.map((c) => ({
|
||
...c,
|
||
...(c.field === "close_ma60_ref"
|
||
? { field: "close", op: c.op, ref: "ma60", value: null }
|
||
: {}),
|
||
})),
|
||
top_n: null,
|
||
};
|
||
}
|
||
|
||
async function run() {
|
||
setRunning(true);
|
||
setError("");
|
||
setResult(null);
|
||
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
|
||
setSavedSelectionId("");
|
||
try {
|
||
const run = await apiPost<SelectionRun>("/selections", buildQuery());
|
||
setSelectionId(run.selection_id);
|
||
setSavedSelectionId(run.selection_id);
|
||
setResult(run.result);
|
||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
}
|
||
|
||
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
|
||
async function runAsync() {
|
||
setError("");
|
||
setResult(null);
|
||
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
|
||
setSavedSelectionId("");
|
||
// 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
|
||
// 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
|
||
let submittedId = "";
|
||
const out = await job.run(async () => {
|
||
const r = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
|
||
submittedId = r.job_id;
|
||
return r;
|
||
});
|
||
if (out.status === "success" && out.result) {
|
||
setSelectionId(submittedId);
|
||
setResult(out.result);
|
||
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
|
||
}
|
||
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
|
||
}
|
||
|
||
async function openHistory(id: string) {
|
||
setError("");
|
||
try {
|
||
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
|
||
setSelectionId(id);
|
||
setSavedSelectionId(id);
|
||
setResult(detail);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="股票筛选"
|
||
sub="以因子评分 TopN 或结构化条件筛选股票,支持当前 / 历史时点;每次选股落库可复现、可解释(为什么选它)。"
|
||
actions={
|
||
selectionId ? (
|
||
<Pill tone="violet" icon="check">
|
||
{selectionId}
|
||
</Pill>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
<Card title="选股条件" icon="filter">
|
||
<div className="form-grid">
|
||
<Field label="方式">
|
||
<select
|
||
className="input"
|
||
value={mode}
|
||
onChange={(e) => setMode(e.target.value as "score" | "condition")}
|
||
>
|
||
<option value="score">因子评分(TopN)</option>
|
||
<option value="condition">条件筛选(AND)</option>
|
||
</select>
|
||
</Field>
|
||
<Field label="选股时点 as_of" hint="留空 = 最近可用交易日">
|
||
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
|
||
</Field>
|
||
<Field label="标的范围" hint="ST 按当时名称判定">
|
||
<label className="check">
|
||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||
剔除 ST
|
||
</label>
|
||
</Field>
|
||
</div>
|
||
|
||
{mode === "score" ? (
|
||
<>
|
||
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
|
||
评分因子与权重
|
||
</div>
|
||
{/* 与回测页同一套「表头 + 栅格行」:列宽由 CSS 决定,不再用 flex 拉伸
|
||
(flex:1 会把权重框拉到几百像素宽,与一位小数的内容完全不匹配) */}
|
||
<div className="factor-rows">
|
||
<div className="factor-rows__head" aria-hidden="true">
|
||
<span>因子</span>
|
||
<span>权重</span>
|
||
<span />
|
||
</div>
|
||
{factorRows.map((r, i) => (
|
||
<div className="factor-row" key={i}>
|
||
<select
|
||
className="input"
|
||
aria-label={`第 ${i + 1} 个因子的名称`}
|
||
value={r.name}
|
||
onChange={(e) => {
|
||
const next = [...factorRows];
|
||
next[i] = { ...next[i], name: e.target.value };
|
||
setFactorRows(next);
|
||
}}
|
||
>
|
||
{factors.map((f) => (
|
||
<option key={f.name} value={f.name}>{factorOptionLabel(f)}</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
className="input mono"
|
||
type="number"
|
||
inputMode="decimal"
|
||
step="0.1"
|
||
min={0}
|
||
aria-label={`第 ${i + 1} 个因子的权重`}
|
||
value={r.weight}
|
||
onChange={(e) => {
|
||
const next = [...factorRows];
|
||
next[i] = { ...next[i], weight: Number(e.target.value) };
|
||
setFactorRows(next);
|
||
}}
|
||
/>
|
||
<Btn
|
||
icon="x"
|
||
disabled={factorRows.length <= 1}
|
||
aria-label={`删除第 ${i + 1} 个因子`}
|
||
title="删除该因子"
|
||
onClick={() => setFactorRows(factorRows.filter((_, j) => j !== i))}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="row" style={{ gap: 10, marginTop: 10, alignItems: "flex-end" }}>
|
||
<Btn
|
||
icon="layers"
|
||
size="sm"
|
||
onClick={() => setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])}
|
||
>
|
||
添加因子
|
||
</Btn>
|
||
<Field label="Top N" hint="按综合分排序取前 N 只">
|
||
<input
|
||
className="input"
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={1}
|
||
value={topN}
|
||
onChange={(e) => setTopN(Number(e.target.value))}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
|
||
条件(全部满足才入选)
|
||
</div>
|
||
<div className="cond-rows">
|
||
<div className="cond-rows__head" aria-hidden="true">
|
||
<span>字段</span>
|
||
<span>比较</span>
|
||
<span>取值</span>
|
||
<span />
|
||
</div>
|
||
{conds.map((c, i) => {
|
||
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
|
||
const isRef = c.field === "close" && c.ref === "ma60";
|
||
return (
|
||
<div className="cond-row" key={i}>
|
||
<select
|
||
className="input"
|
||
aria-label={`第 ${i + 1} 个条件的字段`}
|
||
value={isRef ? "close_ma60_ref" : c.field}
|
||
onChange={(e) => {
|
||
const next = [...conds];
|
||
const v = e.target.value;
|
||
next[i] = v === "close_ma60_ref"
|
||
? { field: "close", op: "gt", ref: "ma60", value: null }
|
||
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
|
||
setConds(next);
|
||
}}
|
||
>
|
||
{FIELD_OPTIONS.map((f) => (
|
||
<option key={f.value} value={f.value}>{f.label}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="input"
|
||
aria-label={`第 ${i + 1} 个条件的比较符`}
|
||
value={c.op}
|
||
onChange={(e) => {
|
||
const next = [...conds];
|
||
next[i] = { ...next[i], op: e.target.value as SelectionCondition["op"] };
|
||
setConds(next);
|
||
}}
|
||
>
|
||
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
{!isRef ? (
|
||
<input
|
||
className="input mono"
|
||
aria-label={`第 ${i + 1} 个条件的取值`}
|
||
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
|
||
value={String(c.value ?? "")}
|
||
onChange={(e) => {
|
||
const next = [...conds];
|
||
const raw = e.target.value;
|
||
const val = c.op === "in"
|
||
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
|
||
: (meta?.kind === "num" && !c.field.startsWith("static.")
|
||
? Number(raw)
|
||
: raw);
|
||
next[i] = { ...next[i], value: val as never };
|
||
setConds(next);
|
||
}}
|
||
/>
|
||
) : (
|
||
/* 占位保持第四列(操作列)不串位 */
|
||
<span />
|
||
)}
|
||
<Btn
|
||
icon="x"
|
||
disabled={conds.length <= 1}
|
||
aria-label={`删除第 ${i + 1} 个条件`}
|
||
title="删除该条件"
|
||
onClick={() => setConds(conds.filter((_, j) => j !== i))}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div style={{ marginTop: 10 }}>
|
||
<Btn icon="layers" size="sm" onClick={() => setConds([...conds, emptyCondition()])}>
|
||
添加条件
|
||
</Btn>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ marginTop: 14 }}>
|
||
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncActive} onClick={run}>
|
||
{running ? "筛选中…" : "执行选股"}
|
||
</Btn>
|
||
<Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
|
||
{asyncActive ? "后台执行中…" : "异步(全市场)"}
|
||
</Btn>
|
||
</div>
|
||
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
|
||
<JobProgress
|
||
state={job.state}
|
||
onCancel={job.cancel}
|
||
onDismiss={job.reset}
|
||
anchorId="selection-job-progress"
|
||
kind="selection"
|
||
/>
|
||
{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} selectionId={savedSelectionId} /> : 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} · ${m.method} · ${m.selected} 只`}
|
||
>
|
||
<b className="mono">{m.as_of}</b>
|
||
{m.method} · {m.selected} 只
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) {
|
||
const s = result.statistics;
|
||
if (result.candidates.length === 0) {
|
||
return (
|
||
<Card>
|
||
<Empty
|
||
icon="filter"
|
||
title="无符合条件/可评分的股票"
|
||
hint={`范围 ${s.universe_size} 只,可评分 ${s.evaluated} 只。可放宽条件或选择更早的 as_of。`}
|
||
/>
|
||
</Card>
|
||
);
|
||
}
|
||
return (
|
||
<Card
|
||
icon="target"
|
||
title={`选股结果 · ${result.as_of_date} · ${result.method}`}
|
||
tools={
|
||
<>
|
||
<Pill>范围 {s.universe_size}</Pill>
|
||
<Pill>评分 {s.evaluated}</Pill>
|
||
<Pill tone="pos">选出 {s.selected}</Pill>
|
||
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
|
||
{selectionId ? (
|
||
<Link
|
||
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
|
||
className="btn btn--sm"
|
||
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
|
||
>
|
||
<span>按此条件回测</span>
|
||
</Link>
|
||
) : null}
|
||
</>
|
||
}
|
||
>
|
||
{selectionId ? (
|
||
<div className="hint" style={{ marginBottom: 10 }}>
|
||
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
|
||
</div>
|
||
) : null}
|
||
<div className="table-wrap">
|
||
<table className="tbl">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>代码 / 名称</th>
|
||
<th>得分</th>
|
||
<th>因子值</th>
|
||
<th>入选理由</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{result.candidates.map((c) => (
|
||
<tr key={c.symbol}>
|
||
<td className="mono dim">{c.rank}</td>
|
||
<td>
|
||
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
|
||
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
|
||
</td>
|
||
<td className="mono">{c.score.toFixed(4)}</td>
|
||
<td style={{ fontSize: 12 }}>
|
||
{Object.entries(c.factor_values)
|
||
.map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(4) : v}`)
|
||
.join(" ")}
|
||
</td>
|
||
<td>
|
||
<div className="chips">
|
||
{c.selection_reason.map((r) => (
|
||
<span className="chip" key={r}>{r.slice(0, 40)}</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{result.unimplemented.length ? (
|
||
<div style={{ marginTop: 10 }}>
|
||
<Banner tone="info">{result.unimplemented.join(";")}</Banner>
|
||
</div>
|
||
) : null}
|
||
</Card>
|
||
);
|
||
}
|