Files
zhixing-system/zhixing-web/src/features/selection/components/selection-chart-option.ts
T

298 lines
8.1 KiB
TypeScript
Raw Normal View History

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,
}
}