Merge pull request 'fix(selection): render gold brick chart as TDX brick blocks' from develop into main
Deploy Production / deploy (push) Successful in 29s

This commit is contained in:
yuxuanhui
2026-09-05 14:58:05 +08:00
8 changed files with 124 additions and 33 deletions
@@ -37,6 +37,7 @@ class SelectionChartPoint:
trend_white: float | None trend_white: float | None
trend_yellow: float | None trend_yellow: float | None
brick_chart: float | None = None brick_chart: float | None = None
brick_strong_red: bool | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -95,7 +96,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 brick = self._compute_brick_indicators(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(
@@ -111,7 +112,10 @@ class GetSelectionChart:
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=( 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) for index, bar in enumerate(bars)
@@ -125,11 +129,11 @@ class GetSelectionChart:
points=points, points=points,
) )
def _compute_brick_chart( def _compute_brick_indicators(
self, self,
ts_code: str, ts_code: str,
bars: tuple[SelectionBar, ...], bars: tuple[SelectionBar, ...],
) -> pd.Series: ) -> pd.DataFrame:
"""Run the gold-brick formula on full history for stable warmup values.""" """Run the gold-brick formula on full history for stable warmup values."""
frame = pd.DataFrame( frame = pd.DataFrame(
@@ -142,7 +146,16 @@ class GetSelectionChart:
"volume": [bar.volume for bar in bars], "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: def _finite_or_none(value: object) -> float | None:
@@ -167,6 +167,7 @@ class SelectionChartPointResponse(BaseModel):
trend_white: float | None trend_white: float | None
trend_yellow: float | None trend_yellow: float | None
brick_chart: float | None = None brick_chart: float | None = None
brick_strong_red: bool | None = None
class SelectionChartResponse(BaseModel): class SelectionChartResponse(BaseModel):
@@ -510,6 +511,7 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
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, brick_chart=point.brick_chart,
brick_strong_red=point.brick_strong_red,
) )
for point in chart.points for point in chart.points
], ],
@@ -141,6 +141,7 @@ class FakeChartService:
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, 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_white": 10.2,
"trend_yellow": 10.4, "trend_yellow": 10.4,
"brick_chart": None, "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 response.status_code == 200
assert chart_service.request == ("000001.SZ", TARGET, True) assert chart_service.request == ("000001.SZ", TARGET, True)
assert response.json()["points"][0]["brick_chart"] == 5.0 assert response.json()["points"][0]["brick_chart"] == 5.0
assert response.json()["points"][0]["brick_strong_red"] is True
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -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], "volume": [bar.volume for bar in history.bars],
} }
) )
expected = prepare_gold_brick_indicators(frame, history.ts_code)["brick_chart"] prepared = prepare_gold_brick_indicators(frame, history.ts_code)
assert brick_chart.points[-1].brick_chart == pytest.approx(float(expected.iloc[-1])) 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].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: def test_chart_rejects_empty_history() -> None:
@@ -76,6 +76,7 @@ export interface SelectionChartPoint {
trend_white: number | null trend_white: number | null
trend_yellow: number | null trend_yellow: number | null
brick_chart: number | null brick_chart: number | null
brick_strong_red: boolean | null
} }
export interface SelectionChart { export interface SelectionChart {
@@ -6,6 +6,7 @@ import { buildSelectionChartOption } from "./selection-chart-option"
function point(index: number): SelectionChartPoint { function point(index: number): SelectionChartPoint {
return { return {
brick_chart: null, brick_chart: null,
brick_strong_red: null,
close: index + 10.5, close: index + 10.5,
d: index + 20, d: index + 20,
high: index + 11, high: index + 11,
@@ -116,8 +117,9 @@ describe("selection chart option", () => {
const option = buildSelectionChartOption(brickPoints) const option = buildSelectionChartOption(brickPoints)
const grids = option.grid as Array<Record<string, unknown>> const grids = option.grid as Array<Record<string, unknown>>
const series = option.series as Array<{ const series = option.series as Array<{
data: Array<{ itemStyle: { color: string }; value: number }> data: Array<{ itemStyle: Record<string, unknown>; value: number } | null>
name: string name: string
stack?: string
xAxisIndex: number xAxisIndex: number
yAxisIndex: number yAxisIndex: number
}> }>
@@ -141,17 +143,44 @@ describe("selection chart option", () => {
expect(valueAxes).toHaveLength(4) expect(valueAxes).toHaveLength(4)
const brick = series.find((item) => item.name === "金砖") 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({ expect(brick?.data[1]).toMatchObject({
itemStyle: { color: "#dc2626" }, itemStyle: {
borderColor: "#dc2626",
color: "transparent",
},
value: 1, value: 1,
}) })
expect(brick?.data[2]).toMatchObject({ expect(brick?.data[2]).toMatchObject({
itemStyle: { color: "#16a34a" }, itemStyle: { color: "#16a34a" },
value: 0, value: 1,
}) })
const jLine = series.find((item) => item.name === "J") const jLine = series.find((item) => item.name === "J")
expect(jLine).toMatchObject({ xAxisIndex: 3, yAxisIndex: 3 }) 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<string, unknown>; value: number } | null>
name: string
}>
const brick = series.find((item) => item.name === "金砖")
expect(brick?.data[2]).toMatchObject({
itemStyle: { color: "#dc2626" },
value: 1,
})
})
}) })
@@ -143,7 +143,7 @@ export function buildSelectionChartOption(
xAxisIndex: 1, xAxisIndex: 1,
yAxisIndex: 1, yAxisIndex: 1,
}, },
...(showBrickChart ? [buildBrickSeries(points)] : []), ...(showBrickChart ? buildBrickSeries(points) : []),
{ {
connectNulls: false, connectNulls: false,
data: points.map((point) => point.j), data: points.map((point) => point.j),
@@ -192,26 +192,63 @@ export function buildSelectionChartOption(
} }
} }
function buildBrickSeries(points: SelectionChartPoint[]): BarSeriesOption { /**
return { * Render the TDX-style brick blocks: each brick spans between yesterday's and
data: points.map((point, index) => { * 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] const previous = points[index - 1]
const rising = if (previous?.brick_chart == null || point.brick_chart == null) {
previous?.brick_chart != null && return 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,
} }
}), return {
name: "金砖", 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", type: "bar",
xAxisIndex: 2, xAxisIndex: 2,
yAxisIndex: 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) { function toCandlestickValue(point: SelectionChartPoint) {
@@ -39,6 +39,7 @@ const chart: SelectionChartData = {
points: [ points: [
{ {
brick_chart: null, brick_chart: null,
brick_strong_red: null,
close: 10.5, close: 10.5,
d: 48, d: 48,
high: 11, high: 11,