From 3b50e75ef8b25e3f0dac275ef006486a24ec81bc Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 5 Sep 2026 13:41:25 +0800 Subject: [PATCH] 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. --- .../modules/selection/application/chart.py | 36 ++++++- .../modules/selection/presentation/http.py | 11 ++- zhixing-server/tests/test_selection_http.py | 28 +++++- .../tests/unit/selection/test_chart.py | 33 +++++++ .../features/selection/api/selection.api.ts | 2 + .../features/selection/api/selection.query.ts | 19 +++- .../features/selection/api/selection.types.ts | 1 + .../components/selection-chart-option.test.ts | 48 ++++++++++ .../components/selection-chart-option.ts | 95 ++++++++++++++----- .../components/selection-chart.test.tsx | 1 + .../selection/components/selection-chart.tsx | 4 +- .../components/signal-detail-panel.tsx | 6 +- .../pages/selection-results-page.test.tsx | 1 + 13 files changed, 249 insertions(+), 36 deletions(-) diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/chart.py b/zhixing-server/src/zhixing_server/modules/selection/application/chart.py index 1c3ada8..9525dfc 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/application/chart.py +++ b/zhixing-server/src/zhixing_server/modules/selection/application/chart.py @@ -9,7 +9,9 @@ from typing import Literal, cast import pandas as pd +from ..domain.gold_brick import prepare_gold_brick_indicators from ..domain.indicators import compute_kdj, compute_zhixing_lines +from ..domain.models import SelectionBar from ..domain.ports import MarketDataReader SELECTION_CHART_LIMIT = 250 @@ -21,7 +23,7 @@ class SelectionChartNotFound(LookupError): @dataclass(frozen=True, slots=True) 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 open: float | None @@ -34,6 +36,7 @@ class SelectionChartPoint: j: float | None trend_white: float | None trend_yellow: float | None + brick_chart: float | None = None @dataclass(frozen=True, slots=True) @@ -55,12 +58,18 @@ class GetSelectionChart: 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. Args: ts_code: Tushare stock identifier selected by the user. target_trade_date: Inclusive historical boundary for the chart. + include_brick_chart: Compute the gold-brick series for the request. Returns: An ascending qfq chart series aligned by trade date. @@ -86,6 +95,7 @@ class GetSelectionChart: ) kdj = compute_kdj(frame) 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) points = tuple( SelectionChartPoint( @@ -100,6 +110,9 @@ class GetSelectionChart: j=_finite_or_none(kdj.iloc[index]["J"]), trend_white=_finite_or_none(white.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) if index >= start @@ -112,6 +125,25 @@ class GetSelectionChart: 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: """Convert one Pandas scalar to a finite JSON-safe float or ``None``.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py index 736112a..4fe3a8c 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -153,7 +153,7 @@ class SelectionStockResponse(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 open: float | None @@ -166,6 +166,7 @@ class SelectionChartPointResponse(BaseModel): j: float | None trend_white: float | None trend_yellow: float | None + brick_chart: float | None = None class SelectionChartResponse(BaseModel): @@ -276,11 +277,16 @@ def get_selection_chart( ts_code: str, target_trade_date: date, service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)], + strategy: StrategyValue = "zhixing_b1", ) -> SelectionChartResponse: """Return one stock's bounded qfq OHLCV, KDJ, and Zhixing-line history.""" 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: raise _http_error(404, "chart_data_not_found", str(exc)) from exc except MarketDataReaderError as exc: @@ -503,6 +509,7 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse: j=point.j, trend_white=point.trend_white, trend_yellow=point.trend_yellow, + brick_chart=point.brick_chart, ) for point in chart.points ], diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index 57a8852..d7ea4e5 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -109,10 +109,15 @@ class FakeChartService: def __init__(self) -> None: 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: - self.request = (ts_code, target_trade_date) + def execute( + 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": raise SelectionChartNotFound("chart unavailable") if self.mode == "storage_error": @@ -135,6 +140,7 @@ class FakeChartService: j=60.0, trend_white=10.2, 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 chart_service.request == ("000001.SZ", TARGET) + assert chart_service.request == ("000001.SZ", TARGET, False) assert response.json() == { "ts_code": "000001.SZ", "name": "平安银行", @@ -455,11 +461,25 @@ def test_chart_returns_bounded_qfq_contract() -> None: "j": 60.0, "trend_white": 10.2, "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( ("mode", "status_code", "error_code"), [ diff --git a/zhixing-server/tests/unit/selection/test_chart.py b/zhixing-server/tests/unit/selection/test_chart.py index 8c3fb5a..d335657 100644 --- a/zhixing-server/tests/unit/selection/test_chart.py +++ b/zhixing-server/tests/unit/selection/test_chart.py @@ -9,6 +9,9 @@ from zhixing_server.modules.selection.application.chart import ( GetSelectionChart, SelectionChartNotFound, ) +from zhixing_server.modules.selection.domain.gold_brick import ( + prepare_gold_brick_indicators, +) from zhixing_server.modules.selection.domain.indicators import ( compute_kdj, 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 +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: reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行")) diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index 37ecce7..cae3579 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -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( `/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`, { signal }, diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 7e13540..3dfdb91 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -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), }) } diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 07b56ea..76f9273 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -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 { diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.test.ts b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts index 225cb9c..dc6ae01 100644 --- a/zhixing-web/src/features/selection/components/selection-chart-option.test.ts +++ b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts @@ -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> + 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> + + 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 }) + }) }) diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.ts b/zhixing-web/src/features/selection/components/selection-chart-option.ts index b168184..1a9df60 100644 --- a/zhixing-web/src/features/selection/components/selection-chart-option.ts +++ b/zhixing-web/src/features/selection/components/selection-chart-option.ts @@ -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 || diff --git a/zhixing-web/src/features/selection/components/selection-chart.test.tsx b/zhixing-web/src/features/selection/components/selection-chart.test.tsx index 54c695c..bd953e5 100644 --- a/zhixing-web/src/features/selection/components/selection-chart.test.tsx +++ b/zhixing-web/src/features/selection/components/selection-chart.test.tsx @@ -38,6 +38,7 @@ const chart: SelectionChartData = { name: "药明康德", points: [ { + brick_chart: null, close: 10.5, d: 48, high: 11, diff --git a/zhixing-web/src/features/selection/components/selection-chart.tsx b/zhixing-web/src/features/selection/components/selection-chart.tsx index 1cb4e53..b45744a 100644 --- a/zhixing-web/src/features/selection/components/selection-chart.tsx +++ b/zhixing-web/src/features/selection/components/selection-chart.tsx @@ -64,7 +64,9 @@ export function SelectionChart({ chart, className }: SelectionChartProps) { return (
point.brick_chart != null) ? "、金砖" : "" + }和 J 线图表`} className={cn("h-full min-h-70 w-full", className)} ref={containerRef} role="img" diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx index bd9f34d..e772dc8 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx @@ -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 ( diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index 9070184..74350b5 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -324,6 +324,7 @@ describe("SelectionResultsPage", () => { expect(useSelectionChart).toHaveBeenLastCalledWith( "000001.SZ", "2026-08-08", + "zhixing_b1", ) useSelectionChart.mockReturnValue({