feat(selection): show gold brick chart below volume in stock detail panel
Add an optional brick_chart series to the selection chart API, computed from the shared gold-brick formula when strategy=gold_brick, and render a fourth grid with red/green brick bars between the volume and J grids.
This commit is contained in:
@@ -13,11 +13,13 @@ import {
|
||||
export function getSelectionChart(
|
||||
tsCode: string,
|
||||
targetTradeDate: string,
|
||||
strategy?: SelectionStrategy,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
target_trade_date: targetTradeDate,
|
||||
})
|
||||
if (strategy) params.set("strategy", strategy)
|
||||
return requestJson<SelectionChart>(
|
||||
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
|
||||
{ signal },
|
||||
|
||||
@@ -31,8 +31,15 @@ const defaultListQuery: SelectionResultsListQuery = {
|
||||
export const selectionChartQueryKey = (
|
||||
tsCode?: string,
|
||||
targetTradeDate?: string,
|
||||
strategy?: SelectionStrategy,
|
||||
) =>
|
||||
["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
|
||||
[
|
||||
"selection",
|
||||
"chart",
|
||||
tsCode ?? "none",
|
||||
targetTradeDate ?? "none",
|
||||
strategy ?? "none",
|
||||
] as const
|
||||
|
||||
export const selectionResultsQueryKey = (
|
||||
strategy: SelectionStrategy,
|
||||
@@ -100,12 +107,16 @@ export function useSelectionResults(
|
||||
})
|
||||
}
|
||||
|
||||
export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
|
||||
export function useSelectionChart(
|
||||
tsCode?: string,
|
||||
targetTradeDate?: string,
|
||||
strategy?: SelectionStrategy,
|
||||
) {
|
||||
return useQuery({
|
||||
enabled: Boolean(tsCode && targetTradeDate),
|
||||
queryFn: ({ signal }) =>
|
||||
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal),
|
||||
queryKey: selectionChartQueryKey(tsCode, targetTradeDate),
|
||||
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", strategy, signal),
|
||||
queryKey: selectionChartQueryKey(tsCode, targetTradeDate, strategy),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface SelectionChartPoint {
|
||||
j: number | null
|
||||
trend_white: number | null
|
||||
trend_yellow: number | null
|
||||
brick_chart: number | null
|
||||
}
|
||||
|
||||
export interface SelectionChart {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buildSelectionChartOption } from "./selection-chart-option"
|
||||
|
||||
function point(index: number): SelectionChartPoint {
|
||||
return {
|
||||
brick_chart: null,
|
||||
close: index + 10.5,
|
||||
d: index + 20,
|
||||
high: index + 11,
|
||||
@@ -106,4 +107,51 @@ describe("selection chart option", () => {
|
||||
expect(indicator.data).toEqual([null])
|
||||
}
|
||||
})
|
||||
|
||||
it("inserts a brick grid below volume when brick values exist", () => {
|
||||
const brickPoints = Array.from({ length: 250 }, (_, index) => ({
|
||||
...point(index),
|
||||
brick_chart: index % 2,
|
||||
}))
|
||||
const option = buildSelectionChartOption(brickPoints)
|
||||
const grids = option.grid as Array<Record<string, unknown>>
|
||||
const series = option.series as Array<{
|
||||
data: Array<{ itemStyle: { color: string }; value: number }>
|
||||
name: string
|
||||
xAxisIndex: number
|
||||
yAxisIndex: number
|
||||
}>
|
||||
const legends = option.legend as Array<{ data: string[] }>
|
||||
const titles = option.title as Array<{ text: string; top: unknown }>
|
||||
const dataZoom = option.dataZoom as Array<{ xAxisIndex: number[] }>
|
||||
const valueAxes = option.yAxis as Array<Record<string, unknown>>
|
||||
|
||||
expect(grids).toHaveLength(4)
|
||||
expect(grids[1]).toMatchObject({ bottom: "37%" })
|
||||
expect(grids[2]).toMatchObject({ bottom: "24%" })
|
||||
expect(grids[3]).toMatchObject({ bottom: 48 })
|
||||
expect(dataZoom[0].xAxisIndex).toEqual([0, 1, 2, 3])
|
||||
expect(legends[1]?.data).toEqual(["金砖", "J"])
|
||||
expect(titles.map((item) => item.text)).toEqual([
|
||||
"日线",
|
||||
"成交量",
|
||||
"金砖",
|
||||
"J",
|
||||
])
|
||||
expect(valueAxes).toHaveLength(4)
|
||||
|
||||
const brick = series.find((item) => item.name === "金砖")
|
||||
expect(brick).toMatchObject({ xAxisIndex: 2, yAxisIndex: 2 })
|
||||
expect(brick?.data[1]).toMatchObject({
|
||||
itemStyle: { color: "#dc2626" },
|
||||
value: 1,
|
||||
})
|
||||
expect(brick?.data[2]).toMatchObject({
|
||||
itemStyle: { color: "#16a34a" },
|
||||
value: 0,
|
||||
})
|
||||
|
||||
const jLine = series.find((item) => item.name === "J")
|
||||
expect(jLine).toMatchObject({ xAxisIndex: 3, yAxisIndex: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,9 @@ 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: "知行黄线" },
|
||||
@@ -33,9 +36,12 @@ export type SelectionChartOption = ComposeOption<
|
||||
>
|
||||
|
||||
/**
|
||||
* Build the three-grid financial chart configuration from aligned API points.
|
||||
* Build the financial chart configuration from aligned API points.
|
||||
*
|
||||
* @param points Ascending qfq OHLCV, J, and Zhixing-line points from the API.
|
||||
* 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(
|
||||
@@ -46,6 +52,10 @@ export function buildSelectionChartOption(
|
||||
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,
|
||||
@@ -53,21 +63,28 @@ export function buildSelectionChartOption(
|
||||
{
|
||||
...zoomRange,
|
||||
type: "inside",
|
||||
xAxisIndex: [0, 1, 2],
|
||||
xAxisIndex: [...Array(axisCount).keys()],
|
||||
},
|
||||
{
|
||||
...zoomRange,
|
||||
bottom: 4,
|
||||
height: 20,
|
||||
type: "slider",
|
||||
xAxisIndex: [0, 1, 2],
|
||||
xAxisIndex: [...Array(axisCount).keys()],
|
||||
},
|
||||
],
|
||||
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 },
|
||||
],
|
||||
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),
|
||||
@@ -75,9 +92,9 @@ export function buildSelectionChartOption(
|
||||
top: 8,
|
||||
},
|
||||
{
|
||||
data: ["J"],
|
||||
data: showBrickChart ? ["金砖", "J"] : ["J"],
|
||||
right: 20,
|
||||
top: "73%",
|
||||
top: showBrickChart ? "77%" : "73%",
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -126,6 +143,7 @@ export function buildSelectionChartOption(
|
||||
xAxisIndex: 1,
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
...(showBrickChart ? [buildBrickSeries(points)] : []),
|
||||
{
|
||||
connectNulls: false,
|
||||
data: points.map((point) => point.j),
|
||||
@@ -137,32 +155,65 @@ export function buildSelectionChartOption(
|
||||
showSymbol: false,
|
||||
smooth: false,
|
||||
type: "line",
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
xAxisIndex: axisCount - 1,
|
||||
yAxisIndex: axisCount - 1,
|
||||
},
|
||||
],
|
||||
title: [
|
||||
{ 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%" },
|
||||
],
|
||||
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: [
|
||||
buildCategoryAxis(dates, 0, false),
|
||||
buildCategoryAxis(dates, 1, false),
|
||||
buildCategoryAxis(dates, 2, true),
|
||||
...Array.from({ length: axisCount - 1 }, (_, gridIndex) =>
|
||||
buildCategoryAxis(dates, gridIndex, false),
|
||||
),
|
||||
buildCategoryAxis(dates, axisCount - 1, true),
|
||||
],
|
||||
yAxis: [
|
||||
buildValueAxis(0),
|
||||
buildValueAxis(1),
|
||||
buildValueAxis(2, { max: 120, min: -20 }),
|
||||
...(showBrickChart ? [buildValueAxis(2)] : []),
|
||||
showBrickChart
|
||||
? buildValueAxis(3, { max: 120, min: -20 })
|
||||
: buildValueAxis(2, { max: 120, min: -20 }),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildBrickSeries(points: SelectionChartPoint[]): BarSeriesOption {
|
||||
return {
|
||||
data: points.map((point, index) => {
|
||||
const previous = points[index - 1]
|
||||
const rising =
|
||||
previous?.brick_chart != null &&
|
||||
point.brick_chart != null &&
|
||||
point.brick_chart > previous.brick_chart
|
||||
return {
|
||||
itemStyle: {
|
||||
color: rising ? BRICK_UP_COLOR : BRICK_DOWN_COLOR,
|
||||
},
|
||||
value: point.brick_chart ?? 0,
|
||||
}
|
||||
}),
|
||||
name: "金砖",
|
||||
type: "bar",
|
||||
xAxisIndex: 2,
|
||||
yAxisIndex: 2,
|
||||
}
|
||||
}
|
||||
|
||||
function toCandlestickValue(point: SelectionChartPoint) {
|
||||
if (
|
||||
point.open === null ||
|
||||
|
||||
@@ -38,6 +38,7 @@ const chart: SelectionChartData = {
|
||||
name: "药明康德",
|
||||
points: [
|
||||
{
|
||||
brick_chart: null,
|
||||
close: 10.5,
|
||||
d: 48,
|
||||
high: 11,
|
||||
|
||||
@@ -64,7 +64,9 @@ export function SelectionChart({ chart, className }: SelectionChartProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`${chart.name} 日线、知行黄白线、成交量和 J 线图表`}
|
||||
aria-label={`${chart.name} 日线、知行黄白线、成交量${
|
||||
chart.points.some((point) => point.brick_chart != null) ? "、金砖" : ""
|
||||
}和 J 线图表`}
|
||||
className={cn("h-full min-h-70 w-full", className)}
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
|
||||
@@ -23,7 +23,11 @@ interface SignalDetailPanelProps {
|
||||
}
|
||||
|
||||
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
|
||||
const chartQuery = useSelectionChart(
|
||||
stock?.ts_code,
|
||||
stock?.target_trade_date,
|
||||
stock?.strategy,
|
||||
)
|
||||
|
||||
if (!stock) {
|
||||
return (
|
||||
|
||||
@@ -324,6 +324,7 @@ describe("SelectionResultsPage", () => {
|
||||
expect(useSelectionChart).toHaveBeenLastCalledWith(
|
||||
"000001.SZ",
|
||||
"2026-08-08",
|
||||
"zhixing_b1",
|
||||
)
|
||||
|
||||
useSelectionChart.mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user