From 63c61ded376987ede1b81c983055ad7415299330 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 9 Sep 2026 07:13:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20M9-3=20K=E7=BA=BF=20Chart=20?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=88candlestick=20+=20=E9=87=8F=20+=20MA?= =?UTF-8?q?=20+=20Signal=E2=86=94Fill=20=E6=A0=87=E8=AE=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/StockChart/CandleChart.tsx:ECharts candlestick(红涨绿跌)+ 成交量副网格 + MA20/MA60 叠加 + 事件标记:成交 fills 实心三角、未成交信号空心三角(v3 §20.3); 组件只展示不计算;层隔离便于替换 TradingView Lightweight Charts - lib/types.ts:ChartResult/OHLC/Volume/Series/Marker DTO - tsc --noEmit 通过 --- .../web/components/StockChart/CandleChart.tsx | 213 ++++++++++++++++++ frontend/web/lib/types.ts | 40 ++++ 2 files changed, 253 insertions(+) create mode 100644 frontend/web/components/StockChart/CandleChart.tsx diff --git a/frontend/web/components/StockChart/CandleChart.tsx b/frontend/web/components/StockChart/CandleChart.tsx new file mode 100644 index 0000000..debf3d5 --- /dev/null +++ b/frontend/web/components/StockChart/CandleChart.tsx @@ -0,0 +1,213 @@ +"use client"; + +/** + * K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。 + * + * 标记语义(v3 §20.3 Signal↔Fill 严格区分): + * - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼ + * - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈 + * 组件只做展示,绝不自行计算(数据来自后端 Chart API)。 + * + * 基于 ECharts candlestick;组件层隔离,便于后续替换 TradingView Lightweight Charts。 + */ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts"; +import { GridComponent, TooltipComponent } from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { ChartMarker } from "@/lib/types"; + +echarts.use([ + CandlestickChart, + LineChart, + BarChart, + ScatterChart, + GridComponent, + TooltipComponent, + CanvasRenderer, +]); + +const C = { + faint: "#8b98b2", + line: "rgba(150,165,195,0.13)", + tooltipBg: "#141d30", + tooltipBorder: "rgba(150,165,195,0.28)", + up: "#e5484d", + down: "#2fb36b", + ma20: "#3fb6ff", + ma60: "#d4a72c", +}; + +export interface CandleDatum { + time: string; + open: number; + close: number; + low: number; + high: number; +} + +export function CandleChart({ + data, + volume, + indicators = {}, + fills = [], + signals = [], + height = 480, + name = "", +}: { + data: CandleDatum[]; + volume?: { time: string; value: number | null }[]; + indicators?: Record; + fills?: ChartMarker[]; + signals?: ChartMarker[]; + height?: number; + name?: string; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current || data.length === 0) return; + const chart = echarts.init(ref.current); + const dates = data.map((d) => d.time); + const vol = volume ?? []; + + // echarts option 结构较宽,series 用宽松类型(组件层实现细节) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const series: any[] = [ + { + name: "K线", + type: "candlestick", + data: data.map((d) => [d.open, d.close, d.low, d.high]), + itemStyle: { + color: C.up, // 阳线(收盘>开盘)红涨 + color0: C.down, + borderColor: C.up, + borderColor0: C.down, + }, + }, + ]; + for (const [name, pts] of Object.entries(indicators)) { + const vals = dates.map((t) => { + const p = pts.find((x) => x.time === t); + return p?.value ?? null; + }); + series.push({ + name, + type: "line", + data: vals, + smooth: false, + showSymbol: false, + lineStyle: { width: 1.2 }, + itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" }, + connectNulls: true, + }); + } + // 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mk = (m: ChartMarker, filled: boolean): any => { + const buy = m.kind.endsWith("buy"); + return { + value: [m.time, m.price ?? 0], + symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle", + symbolRotate: buy ? 0 : 180, + symbolSize: 13, + itemStyle: { + color: filled ? (buy ? C.up : C.down) : "transparent", + borderColor: buy ? C.up : C.down, + borderWidth: 1.6, + }, + tooltip: { formatter: m.text.join("
") }, + }; + }; + series.push( + { name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 }, + { name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 } + ); + + const hasVol = vol.length > 0; + const mainGridBottom = hasVol ? 58 : 14; + series.push({ + name: "成交量", + type: "bar", + xAxisIndex: 1, + yAxisIndex: 1, + data: dates.map((t) => { + const p = vol.find((x) => x.time === t); + return p?.value ?? 0; + }), + itemStyle: { color: "rgba(150,165,195,0.35)" }, + }); + + chart.setOption({ + backgroundColor: "transparent", + tooltip: { + trigger: "axis", + axisPointer: { type: "cross" }, + backgroundColor: C.tooltipBg, + borderColor: C.tooltipBorder, + textStyle: { color: "#e9eef7", fontSize: 12 }, + }, + axisPointer: { link: [{ xAxisIndex: "all" }] }, + grid: [ + { left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true }, + ...(hasVol + ? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }] + : []), + ], + xAxis: [ + { + type: "category", + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: C.line } }, + axisTick: { show: false }, + axisLabel: { color: C.faint, fontSize: 10 }, + splitLine: { show: false }, + }, + ...(hasVol + ? [{ + type: "category", + gridIndex: 1, + data: dates, + boundaryGap: true, + axisLine: { lineStyle: { color: C.line } }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false }, + }] + : []), + ], + yAxis: [ + { + type: "value", + scale: true, + splitNumber: 4, + axisLabel: { color: C.faint, fontSize: 10 }, + splitLine: { lineStyle: { color: C.line } }, + }, + ...(hasVol + ? [{ + type: "value", + gridIndex: 1, + scale: true, + axisLabel: { color: C.faint, fontSize: 9 }, + splitLine: { show: false }, + }] + : []), + ], + series, + legend: { + show: false, + }, + }); + const onResize = () => chart.resize(); + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + chart.dispose(); + }; + }, [data, volume, indicators, fills, signals, height]); + + if (data.length === 0) return
暂无 K 线数据
; + return
; +} diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index 0856f0f..9e5970d 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -186,3 +186,43 @@ export interface SignalRun { signal_id: string; result: SignalResult; } + +/* ---- Chart Service(v3 §20,与 domain/entities/chart.py 对应) ---- */ + +export interface ChartOHLC { + time: string; + open?: number | null; + high?: number | null; + low?: number | null; + close?: number | null; +} +export interface ChartVolumePoint { time: string; value?: number | null } +export interface ChartSeriesPoint { time: string; value?: number | null } +export interface ChartMarker { + time: string; + kind: string; // selection | signal_buy|signal_sell|signal_watch | fill_buy|fill_sell + symbol?: string; + price?: number | null; + score?: number | null; + text: string[]; + ref_id?: string | null; +} +export interface ChartResult { + metadata: { + symbol: string; + name?: string; + adjust_mode: string; + execution_price_basis?: string | null; + start?: string | null; + end?: string | null; + bar_count: number; + indicator_windows: number[]; + }; + bars: ChartOHLC[]; + volume: ChartVolumePoint[]; + indicators: Record; + selections: ChartMarker[]; + signals: ChartMarker[]; + fills: ChartMarker[]; + factor_values?: Record; +}