Files
qlib/frontend/web/app/stocks/[symbol]/page.tsx
T
Simon 03fb463216 feat(web): K线图库对比 —— TradingView Lightweight Charts 实现 + 双库切换
- 按确认方案「两套实现并存对比」:
  - npm 依赖 lightweight-charts@4.2.3(v3 §21 推荐库)
  - components/StockChart/chartTypes.ts:共享 props 接口
  - CandleChart.tsx(ECharts 版,重构复用共享类型)+ CandleChartLW.tsx
    (Lightweight 版:蜡烛+成交量副刻度+MA+成交实心箭头/未成交信号圆点 markers)
  - StockChart.tsx wrapper;个股页顶部「ECharts / Lightweight」切换(默认 Lightweight)
- /stocks/600519.SH 冒烟 200;tsc --noEmit 通过
2026-09-09 07:21:07 +08:00

194 lines
6.9 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 type { CandleDatum } from "@/components/StockChart/chartTypes";
import { StockChart, type ChartLibrary } from "@/components/StockChart/StockChart";
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 [chartLib, setChartLib] = useState<ChartLibrary>("lightweight");
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 }}>
{(["echarts", "lightweight"] as const).map((l) => (
<Btn key={l} variant={chartLib === l ? "primary" : undefined} onClick={() => setChartLib(l)}>
{l === "echarts" ? "ECharts" : "Lightweight"}
</Btn>
))}
{(["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="该区间/口径没有行情(可能停牌或未同步)。" />
) : (
<StockChart
library={chartLib}
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>
</>
)}
</>
);
}