Files
qlib/frontend/web/app/signals/page.tsx
T
Simon f13b34c59e feat(web): 字段库页 + 界面单位换算 + 因子目录参数化 + 选股策略条件表单
字段库与单位:
- `/fields`:字段库管理页(中文名/说明可改、可停用;kind 是引擎事实不可改);
  单位只在字段自己的阶梯里选(总市值 = 万元/亿元),越界 422 原样展示。
- `lib/units.ts`:界面单位 ⇄ 基准单位换算集中一处,条件输入按界面单位回显、
  提交前换回基准单位(引擎只认基准单位,库里存的也永远是基准单位)。
- `SelectionStrategyForm` 取代 `StrategyParamsForm`:一个策略只定义「怎么选」
  (股票池 + 因子 + 过滤条件),条件字段来自字段库接口而不是前端硬编码枚举。

因子参数化(名字即身份,界面不许藏):
- `/factors` 新增「参数」列与展开行:精确引擎键、每个参数的允许范围、来源、依赖列
  (依赖列标明「引擎事实,不可改」);说明文案从「不可修改」改为
  「改参数 = 新建参数化因子 = 新身份,旧因子/既有策略不变义」。
- 「新建参数化因子」卡片:模板下拉 + 受控窗口(min/max)+ 方向枚举下拉,
  实时预览规范键/中文名/渲染公式;后端 422 的原文原样展示,不静默截断。
- 因子下拉显示中文名(含参数)与短键,**value 一律是完整引擎键**;
  停用的因子从各页候选里消失(/strategies、/selection、/signals、/factors/compose),
  既有策略/归档仍按名字解析;`/factors/compose` 顺手修了勾选框点击目标过小。
- `/fields` 加提示:字段库只列内置因子名,参数化因子在 /factors 管理,
  选股策略条件下拉里会一并出现。

tsc --noEmit 0 error;verify_ui_alignment 8 页 160 项全过(另跑 3 个选因子页 51/54)。
2026-10-01 16:38:29 +08:00

219 lines
7.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>
{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>
);
}