import type { BarSeriesOption, CandlestickSeriesOption, LineSeriesOption, } from "echarts/charts" import type { DataZoomComponentOption, GridComponentOption, LegendComponentOption, TitleComponentOption, TooltipComponentOption, } from "echarts/components" import type { ComposeOption } from "echarts/core" import type { SelectionChartPoint } from "../api/selection.types" const DEFAULT_VISIBLE_POINTS = 120 const BRICK_UP_COLOR = "#dc2626" const BRICK_DOWN_COLOR = "#16a34a" const zhixingLineSeries = [ { color: "#a8a8a8", key: "trend_white", name: "知行白线" }, { color: "#eab308", key: "trend_yellow", name: "知行黄线" }, ] as const export type SelectionChartOption = ComposeOption< | BarSeriesOption | CandlestickSeriesOption | DataZoomComponentOption | GridComponentOption | LegendComponentOption | LineSeriesOption | TitleComponentOption | TooltipComponentOption > /** * Build the financial chart configuration from aligned API points. * * When any point carries gold-brick values, a brick grid is inserted below * the volume grid; otherwise the original three-grid layout is kept. * * @param points Ascending qfq OHLCV, J, Zhixing-line, and brick points. * @returns One ECharts option with synchronized axes and an exact 120-point window. */ export function buildSelectionChartOption( points: SelectionChartPoint[], ): SelectionChartOption { const dates = points.map((point) => point.trade_date) const startIndex = Math.max(0, dates.length - DEFAULT_VISIBLE_POINTS) const startValue = dates[startIndex] const endValue = dates.at(-1) const zoomRange = { endValue, startValue } const showBrickChart = points.some( (point) => point.brick_chart !== null && point.brick_chart !== undefined, ) const axisCount = showBrickChart ? 4 : 3 return { animation: false, dataZoom: [ { ...zoomRange, type: "inside", xAxisIndex: [...Array(axisCount).keys()], }, { ...zoomRange, bottom: 4, height: 20, type: "slider", xAxisIndex: [...Array(axisCount).keys()], }, ], grid: showBrickChart ? [ { bottom: "52%", left: 64, right: 24, top: 36 }, { bottom: "37%", height: "12%", left: 64, right: 24 }, { bottom: "24%", height: "10%", left: 64, right: 24 }, { bottom: 48, height: "13%", left: 64, right: 24 }, ] : [ { bottom: "46%", left: 64, right: 24, top: 36 }, { bottom: "28%", height: "14%", left: 64, right: 24 }, { bottom: 48, height: "20%", left: 64, right: 24 }, ], legend: [ { data: zhixingLineSeries.map((line) => line.name), right: 20, top: 8, }, { data: showBrickChart ? ["金砖", "J"] : ["J"], right: 20, top: showBrickChart ? "77%" : "73%", }, ], series: [ { data: points.map(toCandlestickValue), itemStyle: { borderColor: "#dc2626", borderColor0: "#16a34a", color: "#dc2626", color0: "#16a34a", }, name: "日线", type: "candlestick", xAxisIndex: 0, yAxisIndex: 0, }, ...zhixingLineSeries.map((line): LineSeriesOption => ({ connectNulls: false, data: points.map((point) => point[line.key]), itemStyle: { color: line.color }, lineStyle: { color: line.color, width: 1.5, }, name: line.name, showSymbol: false, smooth: false, type: "line", xAxisIndex: 0, yAxisIndex: 0, })), { data: points.map((point) => ({ itemStyle: { color: point.open !== null && point.close !== null && point.close >= point.open ? "#dc2626" : "#16a34a", }, value: point.volume, })), name: "成交量", type: "bar", xAxisIndex: 1, yAxisIndex: 1, }, ...(showBrickChart ? buildBrickSeries(points) : []), { connectNulls: false, data: points.map((point) => point.j), lineStyle: { color: "#9333ea", width: 1.5, }, name: "J", showSymbol: false, smooth: false, type: "line", xAxisIndex: axisCount - 1, yAxisIndex: axisCount - 1, }, ], title: showBrickChart ? [ { left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 }, { left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "50%" }, { left: 72, text: "金砖", textStyle: { fontSize: 12 }, top: "65%" }, { left: 72, text: "J", textStyle: { fontSize: 12 }, top: "77%" }, ] : [ { left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 }, { left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" }, { left: 72, text: "J", textStyle: { fontSize: 12 }, top: "72%" }, ], tooltip: { axisPointer: { type: "cross" }, trigger: "axis", }, xAxis: [ ...Array.from({ length: axisCount - 1 }, (_, gridIndex) => buildCategoryAxis(dates, gridIndex, false), ), buildCategoryAxis(dates, axisCount - 1, true), ], yAxis: [ buildValueAxis(0), buildValueAxis(1), ...(showBrickChart ? [buildValueAxis(2)] : []), showBrickChart ? buildValueAxis(3, { max: 120, min: -20 }) : buildValueAxis(2, { max: 120, min: -20 }), ], } } /** * Render the TDX-style brick blocks: each brick spans between yesterday's and * today's brick value — rising bricks are hollow red (solid when strong red), * falling bricks are solid green, matching the STICKLINE sub-chart formula. * * The range is stacked from an invisible base series so each bar starts at * min(prev, cur) instead of zero. */ function buildBrickSeries(points: SelectionChartPoint[]): BarSeriesOption[] { const bricks = points.map((point, index) => { const previous = points[index - 1] if (previous?.brick_chart == null || point.brick_chart == null) { return null } return { base: Math.min(previous.brick_chart, point.brick_chart), height: Math.abs(point.brick_chart - previous.brick_chart), rising: point.brick_chart > previous.brick_chart, strongRed: point.brick_strong_red === true, } }) return [ { barWidth: "72%", data: bricks.map((brick) => (brick ? brick.base : null)), itemStyle: { color: "transparent" }, silent: true, stack: "brick", tooltip: { show: false }, type: "bar", xAxisIndex: 2, yAxisIndex: 2, }, { barWidth: "72%", data: bricks.map((brick) => { if (!brick) return null return { itemStyle: brick.rising ? brick.strongRed ? { color: BRICK_UP_COLOR } : { borderColor: BRICK_UP_COLOR, borderWidth: 1, color: "transparent", } : { color: BRICK_DOWN_COLOR }, value: brick.height, } }), name: "金砖", stack: "brick", type: "bar", xAxisIndex: 2, yAxisIndex: 2, }, ] } function toCandlestickValue(point: SelectionChartPoint) { if ( point.open === null || point.close === null || point.low === null || point.high === null ) { return [Number.NaN, Number.NaN, Number.NaN, Number.NaN] } return [point.open, point.close, point.low, point.high] } function buildCategoryAxis( dates: string[], gridIndex: number, showLabels: boolean, ) { return { axisLabel: { show: showLabels }, axisLine: { onZero: false }, boundaryGap: false, data: dates, gridIndex, min: "dataMin", max: "dataMax", scale: true, splitLine: { show: false }, type: "category" as const, } } function buildValueAxis( gridIndex: number, bounds: { max?: number; min?: number } = {}, ) { return { ...bounds, axisLabel: { fontSize: 10 }, gridIndex, scale: true, splitLine: { lineStyle: { opacity: 0.16 } }, type: "value" as const, } }