diff --git a/frontend/web/app/signals/page.tsx b/frontend/web/app/signals/page.tsx new file mode 100644 index 0000000..960e9ed --- /dev/null +++ b/frontend/web/app/signals/page.tsx @@ -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([]); + 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(null); + const [signalId, setSignalId] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [history, setHistory] = useState(null); + + useEffect(() => { + let alive = true; + apiGet("/factors") + .then((list) => alive && list.length > 0 && setFactors(list)) + .catch((e: Error) => alive && setError(e.message)); + apiGet("/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("/signals", { + query: buildQuery(), + rules: { buy_rank_threshold: buyRank, sell_rank_threshold: sellRank, max_output_rank: 60 }, + }); + setSignalId(run.signal_id); + setResult(run.result); + apiGet("/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(`/signals/${id}`); + setSignalId(id); + setResult(detail); + } catch (e) { + setError((e as Error).message); + } + } + + return ( + <> + {signalId} : null} + /> + + +
+ + + + + setBuyRank(Number(e.target.value))} /> + + + setSellRank(Number(e.target.value))} /> + + + setAsOf(e.target.value)} /> + + + setSymbols(e.target.value)} /> + + + {running ? "生成中…" : "生成信号"} + +
+ {error ?
{error}
: null} +
+ + {!result && !running && history === null ? : null} + + {result ? : null} + + {history && history.length > 0 ? ( + +
+ {history.map((m) => ( + + ))} +
+
+ ) : null} + + ); +} + +function ResultView({ result }: { result: SignalResult }) { + const st = result.statistics; + return ( + + BUY {st.buy} + WATCH {st.watch} + SELL {st.sell} + + } + > + {result.events.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {result.events.map((e) => ( + + + + + + + + ))} + +
类型代码得分价格触发理由
+ + {e.signal_type} + + {e.symbol}{e.score?.toFixed(4)}{e.price ?? "-"} +
+ {e.trigger_reason.map((r) => ( + {r} + ))} +
+
+
+ )} +
+ ); +} diff --git a/frontend/web/components/app-shell.tsx b/frontend/web/components/app-shell.tsx index 383d3b4..840499c 100644 --- a/frontend/web/components/app-shell.tsx +++ b/frontend/web/components/app-shell.tsx @@ -19,6 +19,7 @@ const GROUPS: { title: string; items: NavItem[] }[] = [ { href: "/", label: "总览", icon: "grid" }, { href: "/stocks", label: "股票池", icon: "candles" }, { href: "/selection", label: "股票筛选", icon: "target" }, + { href: "/signals", label: "交易信号", icon: "scale" }, { href: "/factors", label: "因子研究", icon: "flask" }, { href: "/factors/compose", label: "因子组合", icon: "layers" }, { href: "/backtest", label: "选股回测", icon: "gauge" }, diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index cc1ea25..6afba02 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -22,7 +22,11 @@ export interface FactorMeta { export interface ResearchSpec { 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 }[]; selection: { top_n: number }; rebalance: "weekly" | "monthly"; @@ -84,7 +88,11 @@ export interface SelectionCondition { } 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/省略 = 最近交易日 method: "score" | "condition"; factors: { name: string; weight: number }[]; @@ -126,3 +134,46 @@ export interface SelectionRun { 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; +} + +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; +}