diff --git a/frontend/web/app/stocks/[symbol]/page.tsx b/frontend/web/app/stocks/[symbol]/page.tsx new file mode 100644 index 0000000..6453055 --- /dev/null +++ b/frontend/web/app/stocks/[symbol]/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +/** Stock Research Page(v3 §20.4):单只股票的统一研究视图。 + * K 线(chart API,可切换不复权/前复权显示)+ 成交量 + MA + + * 该股成交(fills)与未成交信号(signals)标记 + 最近选股/信号历史(点击看原因)。 + * 全部数据来自 Chart API —— 前端不重算。 + */ +import { useEffect, useMemo, useState } from "react"; +import { useParams } from "next/navigation"; +import { apiGet } from "@/lib/api"; +import type { ChartMarker, ChartResult, Stock } from "@/lib/types"; +import { + PageHeader, + Card, + Pill, + Btn, + Banner, + Empty, + SkeletonLines, +} from "@/components/ui"; +import { CandleChart, type CandleDatum } from "@/components/StockChart/CandleChart"; + +export default function StockResearchPage() { + const params = useParams<{ symbol: string }>(); + const symbol = (params.symbol ?? "").toUpperCase(); + const [stock, setStock] = useState(null); + const [chart, setChart] = useState(null); + const [signalMarkers, setSignalMarkers] = useState([]); + const [selectionMarkers, setSelectionMarkers] = useState([]); + const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(""); + Promise.all([ + apiGet(`/stocks/${symbol}`), + apiGet(`/stocks/${symbol}/chart?adjust=${adjust}`), + apiGet(`/stocks/${symbol}/signals`), + apiGet(`/stocks/${symbol}/selections`), + ]) + .then(([st, ch, sig, sel]) => { + if (!alive) return; + setStock(st); + setChart(ch); + setSignalMarkers(sig); + setSelectionMarkers(sel); + }) + .catch((e: Error) => alive && setError(e.message)) + .finally(() => alive && setLoading(false)); + return () => { + alive = false; + }; + }, [symbol, adjust]); + + const candles: CandleDatum[] = useMemo( + () => + (chart?.bars ?? []) + .filter((b) => b.open != null && b.close != null && b.low != null && b.high != null) + .map((b) => ({ + time: b.time, + open: b.open as number, + close: b.close as number, + low: b.low as number, + high: b.high as number, + })), + [chart] + ); + + return ( + <> + + {(["none", "qfq", "hfq"] as const).map((m) => ( + setAdjust(m)} + > + {m === "none" ? "不复权" : m === "qfq" ? "前复权" : "后复权"} + + ))} + + } + /> + + {error ? {error} : null} + {loading && !chart ? ( + + ) : ( + <> + + 共 {chart?.metadata.bar_count ?? 0} 根 + {chart?.metadata.execution_price_basis ? ( + 回测执行价口径 {chart.metadata.execution_price_basis} + ) : null} + + } + > + {candles.length === 0 ? ( + + ) : ( + + )} + + + {chart && (chart.signals.length || chart.fills.length) ? ( + + 成交 {chart.fills.length} + 未成交信号 {chart.signals.length} + + } + > +
+ {[...chart.signals, ...chart.fills].map((m, i) => ( + + {m.time} + {m.kind} + {m.price ? ` @${m.price}` : ""} + + ))} +
+
+ ) : null} + +
+ + {selectionMarkers.length === 0 ? ( + + ) : ( +
+ {selectionMarkers.slice(0, 12).map((m, i) => ( + + {m.time} + {m.text[0]} + + ))} +
+ )} +
+ + {signalMarkers.length === 0 ? ( + + ) : ( +
+ {signalMarkers.slice(0, 12).map((m, i) => ( + + {m.time} + {m.kind} + {m.price ? ` @${m.price}` : ""} + + ))} +
+ )} +
+
+ + )} + + ); +} diff --git a/frontend/web/app/stocks/page.tsx b/frontend/web/app/stocks/page.tsx index a7b2873..24ce5f7 100644 --- a/frontend/web/app/stocks/page.tsx +++ b/frontend/web/app/stocks/page.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { useCallback, useEffect, useRef, useState } from "react"; import { apiGet } from "@/lib/api"; import type { Stock } from "@/lib/types"; @@ -129,7 +130,7 @@ export default function StocksPage() { {stocks.map((s) => ( - {s.symbol} + {s.symbol} {s.name} {marketOf(s.symbol)} diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index 9e5970d..e968981 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -5,6 +5,8 @@ export interface Stock { name: string; industry?: string | null; area?: string | null; + market?: string | null; + exchange?: string | null; list_date: string; delist_date?: string | null; status: string;