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:
@@ -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``."""
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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="平安银行"))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user