feat(web): M9-3 K线 Chart 组件(candlestick + 量 + MA + Signal↔Fill 标记)
- 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 通过
This commit is contained in:
@@ -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<string, { time: string; value: number | null }[]>;
|
||||||
|
fills?: ChartMarker[];
|
||||||
|
signals?: ChartMarker[];
|
||||||
|
height?: number;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(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("<br/>") },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
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 <div className="hint">暂无 K 线数据</div>;
|
||||||
|
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
|
||||||
|
}
|
||||||
@@ -186,3 +186,43 @@ export interface SignalRun {
|
|||||||
signal_id: string;
|
signal_id: string;
|
||||||
result: SignalResult;
|
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<string, ChartSeriesPoint[]>;
|
||||||
|
selections: ChartMarker[];
|
||||||
|
signals: ChartMarker[];
|
||||||
|
fills: ChartMarker[];
|
||||||
|
factor_values?: Record<string, ChartSeriesPoint[]>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user