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:
yuxuanhui
2026-09-05 13:41:25 +08:00
parent 79476252b1
commit 3b50e75ef8
13 changed files with 249 additions and 36 deletions
@@ -9,7 +9,9 @@ from typing import Literal, cast
import pandas as pd import pandas as pd
from ..domain.gold_brick import prepare_gold_brick_indicators
from ..domain.indicators import compute_kdj, compute_zhixing_lines from ..domain.indicators import compute_kdj, compute_zhixing_lines
from ..domain.models import SelectionBar
from ..domain.ports import MarketDataReader from ..domain.ports import MarketDataReader
SELECTION_CHART_LIMIT = 250 SELECTION_CHART_LIMIT = 250
@@ -21,7 +23,7 @@ class SelectionChartNotFound(LookupError):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SelectionChartPoint: class SelectionChartPoint:
"""One date-aligned OHLCV, KDJ, and Zhixing-line point.""" """One date-aligned OHLCV, KDJ, Zhixing-line, and gold-brick point."""
trade_date: date trade_date: date
open: float | None open: float | None
@@ -34,6 +36,7 @@ class SelectionChartPoint:
j: float | None j: float | None
trend_white: float | None trend_white: float | None
trend_yellow: float | None trend_yellow: float | None
brick_chart: float | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -55,12 +58,18 @@ class GetSelectionChart:
self.reader = reader self.reader = reader
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart: def execute(
self,
ts_code: str,
target_trade_date: date,
include_brick_chart: bool = False,
) -> SelectionChart:
"""Return at most 250 points without changing full-history indicator state. """Return at most 250 points without changing full-history indicator state.
Args: Args:
ts_code: Tushare stock identifier selected by the user. ts_code: Tushare stock identifier selected by the user.
target_trade_date: Inclusive historical boundary for the chart. target_trade_date: Inclusive historical boundary for the chart.
include_brick_chart: Compute the gold-brick series for the request.
Returns: Returns:
An ascending qfq chart series aligned by trade date. An ascending qfq chart series aligned by trade date.
@@ -86,6 +95,7 @@ class GetSelectionChart:
) )
kdj = compute_kdj(frame) kdj = compute_kdj(frame)
white, yellow = compute_zhixing_lines(frame["close"]) white, yellow = compute_zhixing_lines(frame["close"])
brick_chart = self._compute_brick_chart(ts_code, bars) if include_brick_chart else None
start = max(0, len(bars) - SELECTION_CHART_LIMIT) start = max(0, len(bars) - SELECTION_CHART_LIMIT)
points = tuple( points = tuple(
SelectionChartPoint( SelectionChartPoint(
@@ -100,6 +110,9 @@ class GetSelectionChart:
j=_finite_or_none(kdj.iloc[index]["J"]), j=_finite_or_none(kdj.iloc[index]["J"]),
trend_white=_finite_or_none(white.iloc[index]), trend_white=_finite_or_none(white.iloc[index]),
trend_yellow=_finite_or_none(yellow.iloc[index]), trend_yellow=_finite_or_none(yellow.iloc[index]),
brick_chart=(
None if brick_chart is None else _finite_or_none(brick_chart.iloc[index])
),
) )
for index, bar in enumerate(bars) for index, bar in enumerate(bars)
if index >= start if index >= start
@@ -112,6 +125,25 @@ class GetSelectionChart:
points=points, points=points,
) )
def _compute_brick_chart(
self,
ts_code: str,
bars: tuple[SelectionBar, ...],
) -> pd.Series:
"""Run the gold-brick formula on full history for stable warmup values."""
frame = pd.DataFrame(
{
"trade_date": [bar.trade_date for bar in bars],
"open": [bar.open for bar in bars],
"high": [bar.high for bar in bars],
"low": [bar.low for bar in bars],
"close": [bar.close for bar in bars],
"volume": [bar.volume for bar in bars],
}
)
return prepare_gold_brick_indicators(frame, ts_code)["brick_chart"]
def _finite_or_none(value: object) -> float | None: def _finite_or_none(value: object) -> float | None:
"""Convert one Pandas scalar to a finite JSON-safe float or ``None``.""" """Convert one Pandas scalar to a finite JSON-safe float or ``None``."""
@@ -153,7 +153,7 @@ class SelectionStockResponse(BaseModel):
class SelectionChartPointResponse(BaseModel): class SelectionChartPointResponse(BaseModel):
"""One date-aligned qfq OHLCV, KDJ, and Zhixing-line point.""" """One date-aligned qfq OHLCV, KDJ, Zhixing-line, and gold-brick point."""
trade_date: date trade_date: date
open: float | None open: float | None
@@ -166,6 +166,7 @@ class SelectionChartPointResponse(BaseModel):
j: float | None j: float | None
trend_white: float | None trend_white: float | None
trend_yellow: float | None trend_yellow: float | None
brick_chart: float | None = None
class SelectionChartResponse(BaseModel): class SelectionChartResponse(BaseModel):
@@ -276,11 +277,16 @@ def get_selection_chart(
ts_code: str, ts_code: str,
target_trade_date: date, target_trade_date: date,
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)], service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
strategy: StrategyValue = "zhixing_b1",
) -> SelectionChartResponse: ) -> SelectionChartResponse:
"""Return one stock's bounded qfq OHLCV, KDJ, and Zhixing-line history.""" """Return one stock's bounded qfq OHLCV, KDJ, and Zhixing-line history."""
try: try:
chart = service.execute(ts_code, target_trade_date) chart = service.execute(
ts_code,
target_trade_date,
include_brick_chart=strategy == "gold_brick",
)
except SelectionChartNotFound as exc: except SelectionChartNotFound as exc:
raise _http_error(404, "chart_data_not_found", str(exc)) from exc raise _http_error(404, "chart_data_not_found", str(exc)) from exc
except MarketDataReaderError as exc: except MarketDataReaderError as exc:
@@ -503,6 +509,7 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
j=point.j, j=point.j,
trend_white=point.trend_white, trend_white=point.trend_white,
trend_yellow=point.trend_yellow, trend_yellow=point.trend_yellow,
brick_chart=point.brick_chart,
) )
for point in chart.points for point in chart.points
], ],
+24 -4
View File
@@ -109,10 +109,15 @@ class FakeChartService:
def __init__(self) -> None: def __init__(self) -> None:
self.mode = "ok" self.mode = "ok"
self.request: tuple[str, date] | None = None self.request: tuple[str, date, bool] | None = None
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart: def execute(
self.request = (ts_code, target_trade_date) self,
ts_code: str,
target_trade_date: date,
include_brick_chart: bool = False,
) -> SelectionChart:
self.request = (ts_code, target_trade_date, include_brick_chart)
if self.mode == "missing": if self.mode == "missing":
raise SelectionChartNotFound("chart unavailable") raise SelectionChartNotFound("chart unavailable")
if self.mode == "storage_error": if self.mode == "storage_error":
@@ -135,6 +140,7 @@ class FakeChartService:
j=60.0, j=60.0,
trend_white=10.2, trend_white=10.2,
trend_yellow=10.4, trend_yellow=10.4,
brick_chart=5.0 if include_brick_chart else None,
), ),
), ),
) )
@@ -436,7 +442,7 @@ def test_chart_returns_bounded_qfq_contract() -> None:
) )
assert response.status_code == 200 assert response.status_code == 200
assert chart_service.request == ("000001.SZ", TARGET) assert chart_service.request == ("000001.SZ", TARGET, False)
assert response.json() == { assert response.json() == {
"ts_code": "000001.SZ", "ts_code": "000001.SZ",
"name": "平安银行", "name": "平安银行",
@@ -455,11 +461,25 @@ def test_chart_returns_bounded_qfq_contract() -> None:
"j": 60.0, "j": 60.0,
"trend_white": 10.2, "trend_white": 10.2,
"trend_yellow": 10.4, "trend_yellow": 10.4,
"brick_chart": None,
} }
], ],
} }
def test_chart_requests_brick_series_for_gold_brick_strategy() -> None:
chart_service = FakeChartService()
response = _client(FakeSelectionService(), chart_service).get(
"/api/v1/selection/stocks/000001.SZ/chart",
params={"target_trade_date": "2026-08-08", "strategy": "gold_brick"},
)
assert response.status_code == 200
assert chart_service.request == ("000001.SZ", TARGET, True)
assert response.json()["points"][0]["brick_chart"] == 5.0
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mode", "status_code", "error_code"), ("mode", "status_code", "error_code"),
[ [
@@ -9,6 +9,9 @@ from zhixing_server.modules.selection.application.chart import (
GetSelectionChart, GetSelectionChart,
SelectionChartNotFound, SelectionChartNotFound,
) )
from zhixing_server.modules.selection.domain.gold_brick import (
prepare_gold_brick_indicators,
)
from zhixing_server.modules.selection.domain.indicators import ( from zhixing_server.modules.selection.domain.indicators import (
compute_kdj, compute_kdj,
compute_zhixing_lines, compute_zhixing_lines,
@@ -100,6 +103,36 @@ def test_chart_filters_future_rows_and_preserves_nullable_points() -> None:
assert chart.points[3].j is None assert chart.points[3].j is None
def test_chart_skips_brick_series_by_default_and_computes_on_request() -> None:
history = _history(260)
reader = FakeReader(history)
target = history.bars[-1].trade_date
default_chart = GetSelectionChart(reader).execute(history.ts_code, target)
assert all(point.brick_chart is None for point in default_chart.points)
brick_chart = GetSelectionChart(reader).execute(
history.ts_code,
target,
include_brick_chart=True,
)
frame = pd.DataFrame(
{
"trade_date": [bar.trade_date for bar in history.bars],
"open": [bar.open for bar in history.bars],
"high": [bar.high for bar in history.bars],
"low": [bar.low for bar in history.bars],
"close": [bar.close for bar in history.bars],
"volume": [bar.volume for bar in history.bars],
}
)
expected = prepare_gold_brick_indicators(frame, history.ts_code)["brick_chart"]
assert brick_chart.points[-1].brick_chart == pytest.approx(float(expected.iloc[-1]))
assert brick_chart.points[0].trade_date == history.bars[10].trade_date
assert brick_chart.points[0].brick_chart == pytest.approx(float(expected.iloc[10]))
def test_chart_rejects_empty_history() -> None: def test_chart_rejects_empty_history() -> None:
reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行")) reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行"))
@@ -13,11 +13,13 @@ import {
export function getSelectionChart( export function getSelectionChart(
tsCode: string, tsCode: string,
targetTradeDate: string, targetTradeDate: string,
strategy?: SelectionStrategy,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
const params = new URLSearchParams({ const params = new URLSearchParams({
target_trade_date: targetTradeDate, target_trade_date: targetTradeDate,
}) })
if (strategy) params.set("strategy", strategy)
return requestJson<SelectionChart>( return requestJson<SelectionChart>(
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`, `/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
{ signal }, { signal },
@@ -31,8 +31,15 @@ const defaultListQuery: SelectionResultsListQuery = {
export const selectionChartQueryKey = ( export const selectionChartQueryKey = (
tsCode?: string, tsCode?: string,
targetTradeDate?: 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 = ( export const selectionResultsQueryKey = (
strategy: SelectionStrategy, 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({ return useQuery({
enabled: Boolean(tsCode && targetTradeDate), enabled: Boolean(tsCode && targetTradeDate),
queryFn: ({ signal }) => queryFn: ({ signal }) =>
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal), getSelectionChart(tsCode ?? "", targetTradeDate ?? "", strategy, signal),
queryKey: selectionChartQueryKey(tsCode, targetTradeDate), queryKey: selectionChartQueryKey(tsCode, targetTradeDate, strategy),
}) })
} }
@@ -75,6 +75,7 @@ export interface SelectionChartPoint {
j: number | null j: number | null
trend_white: number | null trend_white: number | null
trend_yellow: number | null trend_yellow: number | null
brick_chart: number | null
} }
export interface SelectionChart { export interface SelectionChart {
@@ -5,6 +5,7 @@ import { buildSelectionChartOption } from "./selection-chart-option"
function point(index: number): SelectionChartPoint { function point(index: number): SelectionChartPoint {
return { return {
brick_chart: null,
close: index + 10.5, close: index + 10.5,
d: index + 20, d: index + 20,
high: index + 11, high: index + 11,
@@ -106,4 +107,51 @@ describe("selection chart option", () => {
expect(indicator.data).toEqual([null]) 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 DEFAULT_VISIBLE_POINTS = 120
const BRICK_UP_COLOR = "#dc2626"
const BRICK_DOWN_COLOR = "#16a34a"
const zhixingLineSeries = [ const zhixingLineSeries = [
{ color: "#a8a8a8", key: "trend_white", name: "知行白线" }, { color: "#a8a8a8", key: "trend_white", name: "知行白线" },
{ color: "#eab308", key: "trend_yellow", 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. * @returns One ECharts option with synchronized axes and an exact 120-point window.
*/ */
export function buildSelectionChartOption( export function buildSelectionChartOption(
@@ -46,6 +52,10 @@ export function buildSelectionChartOption(
const startValue = dates[startIndex] const startValue = dates[startIndex]
const endValue = dates.at(-1) const endValue = dates.at(-1)
const zoomRange = { endValue, startValue } const zoomRange = { endValue, startValue }
const showBrickChart = points.some(
(point) => point.brick_chart !== null && point.brick_chart !== undefined,
)
const axisCount = showBrickChart ? 4 : 3
return { return {
animation: false, animation: false,
@@ -53,21 +63,28 @@ export function buildSelectionChartOption(
{ {
...zoomRange, ...zoomRange,
type: "inside", type: "inside",
xAxisIndex: [0, 1, 2], xAxisIndex: [...Array(axisCount).keys()],
}, },
{ {
...zoomRange, ...zoomRange,
bottom: 4, bottom: 4,
height: 20, height: 20,
type: "slider", type: "slider",
xAxisIndex: [0, 1, 2], xAxisIndex: [...Array(axisCount).keys()],
}, },
], ],
grid: [ grid: showBrickChart
{ bottom: "46%", left: 64, right: 24, top: 36 }, ? [
{ bottom: "28%", height: "14%", left: 64, right: 24 }, { bottom: "52%", left: 64, right: 24, top: 36 },
{ bottom: 48, height: "20%", left: 64, right: 24 }, { 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: [ legend: [
{ {
data: zhixingLineSeries.map((line) => line.name), data: zhixingLineSeries.map((line) => line.name),
@@ -75,9 +92,9 @@ export function buildSelectionChartOption(
top: 8, top: 8,
}, },
{ {
data: ["J"], data: showBrickChart ? ["金砖", "J"] : ["J"],
right: 20, right: 20,
top: "73%", top: showBrickChart ? "77%" : "73%",
}, },
], ],
series: [ series: [
@@ -126,6 +143,7 @@ export function buildSelectionChartOption(
xAxisIndex: 1, xAxisIndex: 1,
yAxisIndex: 1, yAxisIndex: 1,
}, },
...(showBrickChart ? [buildBrickSeries(points)] : []),
{ {
connectNulls: false, connectNulls: false,
data: points.map((point) => point.j), data: points.map((point) => point.j),
@@ -137,32 +155,65 @@ export function buildSelectionChartOption(
showSymbol: false, showSymbol: false,
smooth: false, smooth: false,
type: "line", type: "line",
xAxisIndex: 2, xAxisIndex: axisCount - 1,
yAxisIndex: 2, yAxisIndex: axisCount - 1,
}, },
], ],
title: [ title: showBrickChart
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 }, ? [
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" }, { left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
{ left: 72, text: "J", textStyle: { fontSize: 12 }, top: "72%" }, { 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: { tooltip: {
axisPointer: { type: "cross" }, axisPointer: { type: "cross" },
trigger: "axis", trigger: "axis",
}, },
xAxis: [ xAxis: [
buildCategoryAxis(dates, 0, false), ...Array.from({ length: axisCount - 1 }, (_, gridIndex) =>
buildCategoryAxis(dates, 1, false), buildCategoryAxis(dates, gridIndex, false),
buildCategoryAxis(dates, 2, true), ),
buildCategoryAxis(dates, axisCount - 1, true),
], ],
yAxis: [ yAxis: [
buildValueAxis(0), buildValueAxis(0),
buildValueAxis(1), 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) { function toCandlestickValue(point: SelectionChartPoint) {
if ( if (
point.open === null || point.open === null ||
@@ -38,6 +38,7 @@ const chart: SelectionChartData = {
name: "药明康德", name: "药明康德",
points: [ points: [
{ {
brick_chart: null,
close: 10.5, close: 10.5,
d: 48, d: 48,
high: 11, high: 11,
@@ -64,7 +64,9 @@ export function SelectionChart({ chart, className }: SelectionChartProps) {
return ( return (
<div <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)} className={cn("h-full min-h-70 w-full", className)}
ref={containerRef} ref={containerRef}
role="img" role="img"
@@ -23,7 +23,11 @@ interface SignalDetailPanelProps {
} }
export function SignalDetailPanel({ stock }: 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) { if (!stock) {
return ( return (
@@ -324,6 +324,7 @@ describe("SelectionResultsPage", () => {
expect(useSelectionChart).toHaveBeenLastCalledWith( expect(useSelectionChart).toHaveBeenLastCalledWith(
"000001.SZ", "000001.SZ",
"2026-08-08", "2026-08-08",
"zhixing_b1",
) )
useSelectionChart.mockReturnValue({ useSelectionChart.mockReturnValue({