Files
qlib/frontend/web/app/stocks/[symbol]/page.tsx
T
Simon e1a0a8aa38 feat(web): M9-4 Stock Research Page /stocks/[symbol](v3 §20.4)
- 个股统一研究页:K 线(chart API,none/qfq/hfq 显示切换)+ 量 + MA +
  成交 fills/未成交信号标记 + 该股历史选股命中与信号记录(点击看原因/价格)
- 股票池每行代码可点击进入本页
- 数据全部来自 Chart API(前端不重算);类型补齐 Stock.market/exchange
- 冒烟:/stocks/600519.SH 200,MySQL 上 qfq K 线返回正常;tsc --noEmit 通过
2026-09-09 07:14:44 +08:00

186 lines
6.5 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";
/** 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<Stock | null>(null);
const [chart, setChart] = useState<ChartResult | null>(null);
const [signalMarkers, setSignalMarkers] = useState<ChartMarker[]>([]);
const [selectionMarkers, setSelectionMarkers] = useState<ChartMarker[]>([]);
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<Stock>(`/stocks/${symbol}`),
apiGet<ChartResult>(`/stocks/${symbol}/chart?adjust=${adjust}`),
apiGet<ChartMarker[]>(`/stocks/${symbol}/signals`),
apiGet<ChartMarker[]>(`/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 (
<>
<PageHeader
title={stock ? `${stock.name} ${stock.symbol}` : symbol}
sub={
stock
? `行业 ${stock.industry ?? "-"} · 市场 ${stock.market ?? "-"} · 上市 ${stock.list_date} · 状态 ${stock.status}`
: "个股研究视图(K 线 / 信号 / 成交)"
}
actions={
<div className="row" style={{ gap: 6 }}>
{(["none", "qfq", "hfq"] as const).map((m) => (
<Btn
key={m}
variant={adjust === m ? "primary" : undefined}
onClick={() => setAdjust(m)}
>
{m === "none" ? "不复权" : m === "qfq" ? "前复权" : "后复权"}
</Btn>
))}
</div>
}
/>
{error ? <Banner tone="error">{error}</Banner> : null}
{loading && !chart ? (
<SkeletonLines n={8} />
) : (
<>
<Card
icon="chartLine"
title={`K 线 · ${chart?.metadata.adjust_mode ?? "-"} · ${chart?.metadata.name ?? ""}`}
tools={
<>
<Pill>共 {chart?.metadata.bar_count ?? 0} 根</Pill>
{chart?.metadata.execution_price_basis ? (
<Pill tone="violet">回测执行价口径 {chart.metadata.execution_price_basis}</Pill>
) : null}
</>
}
>
{candles.length === 0 ? (
<Empty icon="chartLine" title="无 K 线数据" hint="该区间/口径没有行情(可能停牌或未同步)。" />
) : (
<CandleChart
data={candles}
volume={chart?.volume ?? []}
indicators={chart?.indicators ?? {}}
fills={chart?.fills ?? []}
signals={chart?.signals ?? []}
name={symbol}
/>
)}
</Card>
{chart && (chart.signals.length || chart.fills.length) ? (
<Card
icon="scale"
title={`回测成交与信号 · ${symbol}`}
tools={
<>
<Pill tone="pos">成交 {chart.fills.length}</Pill>
<Pill>未成交信号 {chart.signals.length}</Pill>
</>
}
>
<div className="chips">
{[...chart.signals, ...chart.fills].map((m, i) => (
<span className="chip" key={`${m.kind}-${m.time}-${i}`} title={m.text.join(";")}>
<b className="mono">{m.time}</b>
{m.kind}
{m.price ? ` @${m.price}` : ""}
</span>
))}
</div>
</Card>
) : null}
<div className="chart-grid">
<Card icon="spark" title="该股历史选股命中">
{selectionMarkers.length === 0 ? (
<Empty icon="spark" title="暂无选股记录" hint="该股尚未进入任何落库选股结果。" />
) : (
<div className="chips">
{selectionMarkers.slice(0, 12).map((m, i) => (
<span className="chip" key={`sel-${m.time}-${i}`} title={m.text.join(";")}>
<b className="mono">{m.time}</b>
{m.text[0]}
</span>
))}
</div>
)}
</Card>
<Card icon="alert" title="该股历史信号">
{signalMarkers.length === 0 ? (
<Empty icon="alert" title="暂无信号记录" />
) : (
<div className="chips">
{signalMarkers.slice(0, 12).map((m, i) => (
<span className="chip" key={`sig-${m.time}-${i}`} title={m.text.join(";")}>
<b className="mono">{m.time}</b>
{m.kind}
{m.price ? ` @${m.price}` : ""}
</span>
))}
</div>
)}
</Card>
</div>
</>
)}
</>
);
}