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 9525dfc..1967cfb 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/application/chart.py +++ b/zhixing-server/src/zhixing_server/modules/selection/application/chart.py @@ -37,6 +37,7 @@ class SelectionChartPoint: trend_white: float | None trend_yellow: float | None brick_chart: float | None = None + brick_strong_red: bool | None = None @dataclass(frozen=True, slots=True) @@ -95,7 +96,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 + brick = self._compute_brick_indicators(ts_code, bars) if include_brick_chart else None start = max(0, len(bars) - SELECTION_CHART_LIMIT) points = tuple( SelectionChartPoint( @@ -111,7 +112,10 @@ class GetSelectionChart: 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]) + None if brick is None else _finite_or_none(brick["brick_chart"].iloc[index]) + ), + brick_strong_red=( + None if brick is None else _strong_red_or_none(brick["strong_red"].iloc[index]) ), ) for index, bar in enumerate(bars) @@ -125,11 +129,11 @@ class GetSelectionChart: points=points, ) - def _compute_brick_chart( + def _compute_brick_indicators( self, ts_code: str, bars: tuple[SelectionBar, ...], - ) -> pd.Series: + ) -> pd.DataFrame: """Run the gold-brick formula on full history for stable warmup values.""" frame = pd.DataFrame( @@ -142,7 +146,16 @@ class GetSelectionChart: "volume": [bar.volume for bar in bars], } ) - return prepare_gold_brick_indicators(frame, ts_code)["brick_chart"] + prepared = prepare_gold_brick_indicators(frame, ts_code) + return prepared[["brick_chart", "strong_red"]] + + +def _strong_red_or_none(value: object) -> bool | None: + """Convert one Pandas scalar to a JSON-safe brick flag or ``None``.""" + + if value is None or pd.isna(value): + return None + return bool(value) def _finite_or_none(value: object) -> float | 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 4fe3a8c..2c8c7ad 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -167,6 +167,7 @@ class SelectionChartPointResponse(BaseModel): trend_white: float | None trend_yellow: float | None brick_chart: float | None = None + brick_strong_red: bool | None = None class SelectionChartResponse(BaseModel): @@ -510,6 +511,7 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse: trend_white=point.trend_white, trend_yellow=point.trend_yellow, brick_chart=point.brick_chart, + brick_strong_red=point.brick_strong_red, ) for point in chart.points ], diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index d7ea4e5..60ab841 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -141,6 +141,7 @@ class FakeChartService: trend_white=10.2, trend_yellow=10.4, brick_chart=5.0 if include_brick_chart else None, + brick_strong_red=True if include_brick_chart else None, ), ), ) @@ -462,6 +463,7 @@ def test_chart_returns_bounded_qfq_contract() -> None: "trend_white": 10.2, "trend_yellow": 10.4, "brick_chart": None, + "brick_strong_red": None, } ], } @@ -478,6 +480,7 @@ def test_chart_requests_brick_series_for_gold_brick_strategy() -> None: assert response.status_code == 200 assert chart_service.request == ("000001.SZ", TARGET, True) assert response.json()["points"][0]["brick_chart"] == 5.0 + assert response.json()["points"][0]["brick_strong_red"] is True @pytest.mark.parametrize( diff --git a/zhixing-server/tests/unit/selection/test_chart.py b/zhixing-server/tests/unit/selection/test_chart.py index d335657..db567c5 100644 --- a/zhixing-server/tests/unit/selection/test_chart.py +++ b/zhixing-server/tests/unit/selection/test_chart.py @@ -127,10 +127,15 @@ def test_chart_skips_brick_series_by_default_and_computes_on_request() -> None: "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])) + prepared = prepare_gold_brick_indicators(frame, history.ts_code) + assert brick_chart.points[-1].brick_chart == pytest.approx( + float(prepared["brick_chart"].iloc[-1]) + ) + assert brick_chart.points[-1].brick_strong_red is bool(prepared["strong_red"].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])) + assert brick_chart.points[0].brick_chart == pytest.approx( + float(prepared["brick_chart"].iloc[10]) + ) def test_chart_rejects_empty_history() -> None: diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 76f9273..abac089 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -76,6 +76,7 @@ export interface SelectionChartPoint { trend_white: number | null trend_yellow: number | null brick_chart: number | null + brick_strong_red: boolean | 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 dc6ae01..3d2ff41 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 @@ -6,6 +6,7 @@ import { buildSelectionChartOption } from "./selection-chart-option" function point(index: number): SelectionChartPoint { return { brick_chart: null, + brick_strong_red: null, close: index + 10.5, d: index + 20, high: index + 11, @@ -116,8 +117,9 @@ describe("selection chart option", () => { const option = buildSelectionChartOption(brickPoints) const grids = option.grid as Array> const series = option.series as Array<{ - data: Array<{ itemStyle: { color: string }; value: number }> + data: Array<{ itemStyle: Record; value: number } | null> name: string + stack?: string xAxisIndex: number yAxisIndex: number }> @@ -141,17 +143,44 @@ describe("selection chart option", () => { expect(valueAxes).toHaveLength(4) const brick = series.find((item) => item.name === "金砖") - expect(brick).toMatchObject({ xAxisIndex: 2, yAxisIndex: 2 }) + expect(brick).toMatchObject({ + stack: "brick", + xAxisIndex: 2, + yAxisIndex: 2, + }) + expect(brick?.data[0]).toBeNull() expect(brick?.data[1]).toMatchObject({ - itemStyle: { color: "#dc2626" }, + itemStyle: { + borderColor: "#dc2626", + color: "transparent", + }, value: 1, }) expect(brick?.data[2]).toMatchObject({ itemStyle: { color: "#16a34a" }, - value: 0, + value: 1, }) const jLine = series.find((item) => item.name === "J") expect(jLine).toMatchObject({ xAxisIndex: 3, yAxisIndex: 3 }) }) + + it("draws strong-red bricks as solid blocks", () => { + const brickPoints = Array.from({ length: 3 }, (_, index) => ({ + ...point(index), + brick_chart: index, + brick_strong_red: index === 2, + })) + const option = buildSelectionChartOption(brickPoints) + const series = option.series as Array<{ + data: Array<{ itemStyle: Record; value: number } | null> + name: string + }> + + const brick = series.find((item) => item.name === "金砖") + expect(brick?.data[2]).toMatchObject({ + itemStyle: { color: "#dc2626" }, + value: 1, + }) + }) }) 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 1a9df60..f91114f 100644 --- a/zhixing-web/src/features/selection/components/selection-chart-option.ts +++ b/zhixing-web/src/features/selection/components/selection-chart-option.ts @@ -143,7 +143,7 @@ export function buildSelectionChartOption( xAxisIndex: 1, yAxisIndex: 1, }, - ...(showBrickChart ? [buildBrickSeries(points)] : []), + ...(showBrickChart ? buildBrickSeries(points) : []), { connectNulls: false, data: points.map((point) => point.j), @@ -192,26 +192,63 @@ export function buildSelectionChartOption( } } -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, - } +/** + * 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) { 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 bd953e5..2183af9 100644 --- a/zhixing-web/src/features/selection/components/selection-chart.test.tsx +++ b/zhixing-web/src/features/selection/components/selection-chart.test.tsx @@ -39,6 +39,7 @@ const chart: SelectionChartData = { points: [ { brick_chart: null, + brick_strong_red: null, close: 10.5, d: 48, high: 11,