2026-09-01 13:37:19 +08:00
|
|
|
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
|
|
|
|
|
|
2026-09-01 22:57:58 +08:00
|
|
|
const zhixingLineSeries = [
|
|
|
|
|
{ color: "#a8a8a8", key: "trend_white", name: "知行白线" },
|
|
|
|
|
{ color: "#eab308", key: "trend_yellow", name: "知行黄线" },
|
|
|
|
|
] as const
|
|
|
|
|
|
2026-09-01 13:37:19 +08:00
|
|
|
export type SelectionChartOption = ComposeOption<
|
|
|
|
|
| BarSeriesOption
|
|
|
|
|
| CandlestickSeriesOption
|
|
|
|
|
| DataZoomComponentOption
|
|
|
|
|
| GridComponentOption
|
|
|
|
|
| LegendComponentOption
|
|
|
|
|
| LineSeriesOption
|
|
|
|
|
| TitleComponentOption
|
|
|
|
|
| TooltipComponentOption
|
|
|
|
|
>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the three-grid financial chart configuration from aligned API points.
|
|
|
|
|
*
|
2026-09-01 22:57:58 +08:00
|
|
|
* @param points Ascending qfq OHLCV, KDJ, and Zhixing-line points from the API.
|
2026-09-01 13:37:19 +08:00
|
|
|
* @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 }
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
animation: false,
|
|
|
|
|
dataZoom: [
|
|
|
|
|
{
|
|
|
|
|
...zoomRange,
|
|
|
|
|
type: "inside",
|
|
|
|
|
xAxisIndex: [0, 1, 2],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
...zoomRange,
|
|
|
|
|
bottom: 4,
|
|
|
|
|
height: 20,
|
|
|
|
|
type: "slider",
|
|
|
|
|
xAxisIndex: [0, 1, 2],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
grid: [
|
|
|
|
|
{ bottom: "46%", left: 64, right: 24, top: 36 },
|
|
|
|
|
{ bottom: "28%", height: "14%", left: 64, right: 24 },
|
|
|
|
|
{ bottom: 48, height: "20%", left: 64, right: 24 },
|
|
|
|
|
],
|
2026-09-01 22:57:58 +08:00
|
|
|
legend: [
|
|
|
|
|
{
|
|
|
|
|
data: zhixingLineSeries.map((line) => line.name),
|
|
|
|
|
right: 20,
|
|
|
|
|
top: 8,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
data: ["K", "D", "J"],
|
|
|
|
|
right: 20,
|
|
|
|
|
top: "73%",
|
|
|
|
|
},
|
|
|
|
|
],
|
2026-09-01 13:37:19 +08:00
|
|
|
series: [
|
|
|
|
|
{
|
|
|
|
|
data: points.map(toCandlestickValue),
|
|
|
|
|
itemStyle: {
|
|
|
|
|
borderColor: "#dc2626",
|
|
|
|
|
borderColor0: "#16a34a",
|
|
|
|
|
color: "#dc2626",
|
|
|
|
|
color0: "#16a34a",
|
|
|
|
|
},
|
|
|
|
|
name: "日线",
|
|
|
|
|
type: "candlestick",
|
|
|
|
|
xAxisIndex: 0,
|
|
|
|
|
yAxisIndex: 0,
|
|
|
|
|
},
|
2026-09-01 22:57:58 +08:00
|
|
|
...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,
|
|
|
|
|
})),
|
2026-09-01 13:37:19 +08:00
|
|
|
{
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
...(["k", "d", "j"] as const).map((key, index): LineSeriesOption => ({
|
|
|
|
|
connectNulls: false,
|
|
|
|
|
data: points.map((point) => point[key]),
|
|
|
|
|
name: key.toUpperCase(),
|
|
|
|
|
showSymbol: false,
|
|
|
|
|
smooth: false,
|
|
|
|
|
type: "line",
|
|
|
|
|
xAxisIndex: 2,
|
|
|
|
|
yAxisIndex: 2,
|
|
|
|
|
lineStyle: {
|
|
|
|
|
color: ["#2563eb", "#d97706", "#9333ea"][index],
|
|
|
|
|
width: 1.5,
|
|
|
|
|
},
|
|
|
|
|
})),
|
|
|
|
|
],
|
|
|
|
|
title: [
|
|
|
|
|
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
|
|
|
|
|
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
|
|
|
|
|
{ left: 72, text: "KDJ", textStyle: { fontSize: 12 }, top: "72%" },
|
|
|
|
|
],
|
|
|
|
|
tooltip: {
|
|
|
|
|
axisPointer: { type: "cross" },
|
|
|
|
|
trigger: "axis",
|
|
|
|
|
},
|
|
|
|
|
xAxis: [
|
|
|
|
|
buildCategoryAxis(dates, 0, false),
|
|
|
|
|
buildCategoryAxis(dates, 1, false),
|
|
|
|
|
buildCategoryAxis(dates, 2, true),
|
|
|
|
|
],
|
|
|
|
|
yAxis: [
|
|
|
|
|
buildValueAxis(0),
|
|
|
|
|
buildValueAxis(1),
|
|
|
|
|
buildValueAxis(2, { max: 120, min: -20 }),
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|