feat(web): M8.6a 交易信号页(/signals)+ 导航与类型
- app/signals/page.tsx:因子+买卖排名阈值+时点+白名单 → BUY/WATCH/SELL 表格 (类型 Pill 着色、得分/价格/触发理由)+ 最近信号记录回看 - app-shell 导航加入「交易信号」;lib/types.ts 增加 Signal* 类型与 universe.symbols - 真实 MySQL 端到端:/api/signals 返回可解释信号(跌破 MA60 的 SELL 警示等); /signals 页面 200;tsc --noEmit 通过
This commit is contained in:
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/** 交易信号(v2 §15):基于选股评分排名 + 趋势规则生成 BUY / WATCH / SELL,
|
||||||
|
* 每条带理由(为什么);支持当前/历史 as_of,历史记录点击回看。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiGet, apiPost } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
FactorMeta,
|
||||||
|
SelectionQuery,
|
||||||
|
SignalMeta,
|
||||||
|
SignalResult,
|
||||||
|
SignalRun,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
Card,
|
||||||
|
Pill,
|
||||||
|
Field,
|
||||||
|
Btn,
|
||||||
|
Banner,
|
||||||
|
Empty,
|
||||||
|
SkeletonLines,
|
||||||
|
} from "@/components/ui";
|
||||||
|
|
||||||
|
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(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}>{f.name}</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 className="mono"><b>{e.symbol}</b></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const GROUPS: { title: string; items: NavItem[] }[] = [
|
|||||||
{ href: "/", label: "总览", icon: "grid" },
|
{ href: "/", label: "总览", icon: "grid" },
|
||||||
{ href: "/stocks", label: "股票池", icon: "candles" },
|
{ href: "/stocks", label: "股票池", icon: "candles" },
|
||||||
{ href: "/selection", label: "股票筛选", icon: "target" },
|
{ href: "/selection", label: "股票筛选", icon: "target" },
|
||||||
|
{ href: "/signals", label: "交易信号", icon: "scale" },
|
||||||
{ href: "/factors", label: "因子研究", icon: "flask" },
|
{ href: "/factors", label: "因子研究", icon: "flask" },
|
||||||
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
|
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
|
||||||
{ href: "/backtest", label: "选股回测", icon: "gauge" },
|
{ href: "/backtest", label: "选股回测", icon: "gauge" },
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ export interface FactorMeta {
|
|||||||
|
|
||||||
export interface ResearchSpec {
|
export interface ResearchSpec {
|
||||||
type: "factor_test" | "backtest";
|
type: "factor_test" | "backtest";
|
||||||
universe: { exclude_st?: boolean; min_listing_days?: number };
|
universe: {
|
||||||
|
exclude_st?: boolean;
|
||||||
|
min_listing_days?: number;
|
||||||
|
symbols?: string[];
|
||||||
|
};
|
||||||
factors: { name: string; weight: number }[];
|
factors: { name: string; weight: number }[];
|
||||||
selection: { top_n: number };
|
selection: { top_n: number };
|
||||||
rebalance: "weekly" | "monthly";
|
rebalance: "weekly" | "monthly";
|
||||||
@@ -84,7 +88,11 @@ export interface SelectionCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectionQuery {
|
export interface SelectionQuery {
|
||||||
universe: { exclude_st?: boolean; min_listing_days?: number };
|
universe: {
|
||||||
|
exclude_st?: boolean;
|
||||||
|
min_listing_days?: number;
|
||||||
|
symbols?: string[];
|
||||||
|
};
|
||||||
as_of?: string | null; // null/省略 = 最近交易日
|
as_of?: string | null; // null/省略 = 最近交易日
|
||||||
method: "score" | "condition";
|
method: "score" | "condition";
|
||||||
factors: { name: string; weight: number }[];
|
factors: { name: string; weight: number }[];
|
||||||
@@ -126,3 +134,46 @@ export interface SelectionRun {
|
|||||||
result: SelectionResult;
|
result: SelectionResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ---- 交易信号(v2 §15,与 domain/entities/signal.py 对应) ---- */
|
||||||
|
|
||||||
|
export interface SignalRules {
|
||||||
|
buy_rank_threshold?: number;
|
||||||
|
buy_require_trend?: boolean;
|
||||||
|
buy_require_momentum?: boolean;
|
||||||
|
trend_ma?: number;
|
||||||
|
sell_rank_threshold?: number;
|
||||||
|
sell_on_trend_break?: boolean;
|
||||||
|
max_output_rank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalEvent {
|
||||||
|
symbol: string;
|
||||||
|
signal_date: string;
|
||||||
|
signal_type: "BUY" | "WATCH" | "SELL";
|
||||||
|
score?: number | null;
|
||||||
|
price?: number | null;
|
||||||
|
trigger_reason: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalResult {
|
||||||
|
as_of_date: string;
|
||||||
|
rules: SignalRules;
|
||||||
|
statistics: { universe_size: number; buy: number; watch: number; sell: number };
|
||||||
|
events: SignalEvent[];
|
||||||
|
config_snapshot: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalMeta {
|
||||||
|
id: string;
|
||||||
|
as_of: string;
|
||||||
|
buy: number;
|
||||||
|
watch: number;
|
||||||
|
sell: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignalRun {
|
||||||
|
signal_id: string;
|
||||||
|
result: SignalResult;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user