diff --git a/frontend/web/app/stocks/[symbol]/page.tsx b/frontend/web/app/stocks/[symbol]/page.tsx index 6453055..4109a61 100644 --- a/frontend/web/app/stocks/[symbol]/page.tsx +++ b/frontend/web/app/stocks/[symbol]/page.tsx @@ -18,7 +18,8 @@ import { Empty, SkeletonLines, } from "@/components/ui"; -import { CandleChart, type CandleDatum } from "@/components/StockChart/CandleChart"; +import type { CandleDatum } from "@/components/StockChart/chartTypes"; +import { StockChart, type ChartLibrary } from "@/components/StockChart/StockChart"; export default function StockResearchPage() { const params = useParams<{ symbol: string }>(); @@ -28,6 +29,7 @@ export default function StockResearchPage() { const [signalMarkers, setSignalMarkers] = useState([]); const [selectionMarkers, setSelectionMarkers] = useState([]); const [adjust, setAdjust] = useState<"none" | "qfq" | "hfq">("none"); + const [chartLib, setChartLib] = useState("lightweight"); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); @@ -80,6 +82,11 @@ export default function StockResearchPage() { } actions={
+ {(["echarts", "lightweight"] as const).map((l) => ( + setChartLib(l)}> + {l === "echarts" ? "ECharts" : "Lightweight"} + + ))} {(["none", "qfq", "hfq"] as const).map((m) => ( ) : ( - ; - fills?: ChartMarker[]; - signals?: ChartMarker[]; - height?: number; - name?: string; -}) { +}: CandleChartProps) { const ref = useRef(null); useEffect(() => { diff --git a/frontend/web/components/StockChart/CandleChartLW.tsx b/frontend/web/components/StockChart/CandleChartLW.tsx new file mode 100644 index 0000000..d0bd42c --- /dev/null +++ b/frontend/web/components/StockChart/CandleChartLW.tsx @@ -0,0 +1,154 @@ +"use client"; + +/** + * K 线图 —— TradingView Lightweight Charts 实现(v3 §21 推荐库)。 + * + * 与 ECharts 版(CandleChart.tsx)共用同一 props 接口(chartTypes.ts), + * 供「两套实现并存对比」,组件层可整体替换。 + * 标记语义(v3 §20.3):成交 fills = 实心箭头;未成交信号 = 空心圆点。 + * 组件只展示,不重算。 + */ +import { useEffect, useRef } from "react"; +import { + createChart, + ColorType, + CrosshairMode, + type IChartApi, + type ISeriesApi, + type SeriesMarker, + type Time, +} from "lightweight-charts"; +import type { CandleChartProps } from "./chartTypes"; + +const C = { + faint: "#8b98b2", + grid: "rgba(150,165,195,0.09)", + up: "#e5484d", + down: "#2fb36b", + ma20: "#3fb6ff", + ma60: "#d4a72c", + text: "#e9eef7", + markerBuy: "#3ddc97", + markerSell: "#ff5a5a", + markerSig: "#b98bff", +}; + +function toTime(t: string): Time { + return t as Time; // 'YYYY-MM-DD'(v4 支持字符串 business day) +} + +export function CandleChartLW({ + data, + volume = [], + indicators = {}, + fills = [], + signals = [], + height = 480, + name = "", +}: CandleChartProps) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || data.length === 0) return; + const chart: IChartApi = createChart(ref.current, { + autoSize: true, + height, + layout: { + background: { type: ColorType.Solid, color: "transparent" }, + textColor: C.text, + fontSize: 11, + }, + grid: { + vertLines: { color: C.grid }, + horzLines: { color: C.grid }, + }, + rightPriceScale: { borderColor: C.grid }, + timeScale: { borderColor: C.grid, timeVisible: false }, + crosshair: { mode: CrosshairMode.Normal }, + }); + + const candle: ISeriesApi<"Candlestick"> = chart.addCandlestickSeries({ + upColor: C.up, + downColor: C.down, + borderUpColor: C.up, + borderDownColor: C.down, + wickUpColor: C.up, + wickDownColor: C.down, + }); + candle.setData( + data.map((d) => ({ + time: toTime(d.time), + open: d.open, + high: d.high, + low: d.low, + close: d.close, + })) + ); + + // 成交量(独立价格刻度,置于底部 20%) + const vol: ISeriesApi<"Histogram"> = chart.addHistogramSeries({ + priceFormat: { type: "volume" }, + priceScaleId: "vol", + lastValueVisible: false, + priceLineVisible: false, + }); + chart.priceScale("vol").applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } }); + vol.setData( + volume.map((v) => ({ time: toTime(v.time), value: v.value ?? 0, color: C.grid })) + ); + + // MA 指标线 + for (const [name, pts] of Object.entries(indicators)) { + const line = chart.addLineSeries({ + color: name === "ma60" ? C.ma60 : C.ma20, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }); + line.setData( + pts + .filter((p) => p.value != null) + .map((p) => ({ time: toTime(p.time), value: p.value as number })) + ); + } + + // 事件标记:成交=实心箭头;未成交信号=圆点 + const markers: SeriesMarker