feat(selection): add Zhixing line indicators to selection chart and update related components

This commit is contained in:
yuxuanhui
2026-09-01 22:57:58 +08:00
parent 3da90f7df8
commit f41ad0915e
10 changed files with 100 additions and 25 deletions
+3
View File
@@ -20,3 +20,6 @@ node_modules/
.pnpm-store/ .pnpm-store/
dist/ dist/
coverage/ coverage/
# Playwright CLI session artifacts
.playwright-cli/
@@ -9,7 +9,7 @@ from typing import Literal, cast
import pandas as pd import pandas as pd
from ..domain.indicators import compute_kdj from ..domain.indicators import compute_kdj, compute_zhixing_lines
from ..domain.ports import MarketDataReader from ..domain.ports import MarketDataReader
SELECTION_CHART_LIMIT = 250 SELECTION_CHART_LIMIT = 250
@@ -21,7 +21,7 @@ class SelectionChartNotFound(LookupError):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SelectionChartPoint: class SelectionChartPoint:
"""One date-aligned OHLCV and KDJ point in the public chart vocabulary.""" """One date-aligned OHLCV, KDJ, and Zhixing-line point."""
trade_date: date trade_date: date
open: float | None open: float | None
@@ -32,6 +32,8 @@ class SelectionChartPoint:
k: float | None k: float | None
d: float | None d: float | None
j: float | None j: float | None
trend_white: float | None
trend_yellow: float | None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -46,7 +48,7 @@ class SelectionChart:
class GetSelectionChart: class GetSelectionChart:
"""Load one qfq history, compute KDJ, then bound the response series.""" """Load one qfq history, compute KDJ and Zhixing lines, then bound the series."""
def __init__(self, reader: MarketDataReader) -> None: def __init__(self, reader: MarketDataReader) -> None:
"""Inject the market-data reader owned by the selection context.""" """Inject the market-data reader owned by the selection context."""
@@ -54,7 +56,7 @@ 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) -> SelectionChart:
"""Return at most 250 points without changing KDJ's full-history 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.
@@ -83,6 +85,7 @@ class GetSelectionChart:
} }
) )
kdj = compute_kdj(frame) kdj = compute_kdj(frame)
white, yellow = compute_zhixing_lines(frame["close"])
start = max(0, len(bars) - SELECTION_CHART_LIMIT) start = max(0, len(bars) - SELECTION_CHART_LIMIT)
points = tuple( points = tuple(
SelectionChartPoint( SelectionChartPoint(
@@ -95,6 +98,8 @@ class GetSelectionChart:
k=_finite_or_none(kdj.iloc[index]["K"]), k=_finite_or_none(kdj.iloc[index]["K"]),
d=_finite_or_none(kdj.iloc[index]["D"]), d=_finite_or_none(kdj.iloc[index]["D"]),
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_yellow=_finite_or_none(yellow.iloc[index]),
) )
for index, bar in enumerate(bars) for index, bar in enumerate(bars)
if index >= start if index >= start
@@ -147,7 +147,7 @@ class SelectionStockResponse(BaseModel):
class SelectionChartPointResponse(BaseModel): class SelectionChartPointResponse(BaseModel):
"""One date-aligned qfq OHLCV and KDJ point.""" """One date-aligned qfq OHLCV, KDJ, and Zhixing-line point."""
trade_date: date trade_date: date
open: float | None open: float | None
@@ -158,6 +158,8 @@ class SelectionChartPointResponse(BaseModel):
k: float | None k: float | None
d: float | None d: float | None
j: float | None j: float | None
trend_white: float | None
trend_yellow: float | None
class SelectionChartResponse(BaseModel): class SelectionChartResponse(BaseModel):
@@ -268,7 +270,7 @@ def get_selection_chart(
target_trade_date: date, target_trade_date: date,
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)], service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
) -> SelectionChartResponse: ) -> SelectionChartResponse:
"""Return one stock's bounded qfq OHLCV and KDJ 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)
@@ -482,6 +484,8 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
k=point.k, k=point.k,
d=point.d, d=point.d,
j=point.j, j=point.j,
trend_white=point.trend_white,
trend_yellow=point.trend_yellow,
) )
for point in chart.points for point in chart.points
], ],
@@ -133,6 +133,8 @@ class FakeChartService:
k=52.0, k=52.0,
d=48.0, d=48.0,
j=60.0, j=60.0,
trend_white=10.2,
trend_yellow=10.4,
), ),
), ),
) )
@@ -451,6 +453,8 @@ def test_chart_returns_bounded_qfq_contract() -> None:
"k": 52.0, "k": 52.0,
"d": 48.0, "d": 48.0,
"j": 60.0, "j": 60.0,
"trend_white": 10.2,
"trend_yellow": 10.4,
} }
], ],
} }
@@ -9,7 +9,10 @@ from zhixing_server.modules.selection.application.chart import (
GetSelectionChart, GetSelectionChart,
SelectionChartNotFound, SelectionChartNotFound,
) )
from zhixing_server.modules.selection.domain.indicators import compute_kdj from zhixing_server.modules.selection.domain.indicators import (
compute_kdj,
compute_zhixing_lines,
)
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
@@ -61,9 +64,12 @@ def test_chart_computes_on_full_history_before_returning_last_250_points() -> No
} }
) )
expected = compute_kdj(frame).iloc[-1] expected = compute_kdj(frame).iloc[-1]
expected_white, expected_yellow = compute_zhixing_lines(frame["close"])
assert chart.points[-1].k == pytest.approx(float(expected["K"])) assert chart.points[-1].k == pytest.approx(float(expected["K"]))
assert chart.points[-1].d == pytest.approx(float(expected["D"])) assert chart.points[-1].d == pytest.approx(float(expected["D"]))
assert chart.points[-1].j == pytest.approx(float(expected["J"])) assert chart.points[-1].j == pytest.approx(float(expected["J"]))
assert chart.points[-1].trend_white == pytest.approx(float(expected_white.iloc[-1]))
assert chart.points[-1].trend_yellow == pytest.approx(float(expected_yellow.iloc[-1]))
def test_chart_filters_future_rows_and_preserves_nullable_points() -> None: def test_chart_filters_future_rows_and_preserves_nullable_points() -> None:
@@ -70,6 +70,8 @@ export interface SelectionChartPoint {
k: number | null k: number | null
d: number | null d: number | null
j: number | null j: number | null
trend_white: number | null
trend_yellow: number | null
} }
export interface SelectionChart { export interface SelectionChart {
@@ -13,6 +13,8 @@ function point(index: number): SelectionChartPoint {
low: index + 9, low: index + 9,
open: index + 10, open: index + 10,
trade_date: `2026-${String(Math.floor(index / 28) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}`, trade_date: `2026-${String(Math.floor(index / 28) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}`,
trend_white: index + 10.2,
trend_yellow: index + 10.4,
volume: index + 1000, volume: index + 1000,
} }
} }
@@ -28,9 +30,11 @@ describe("selection chart option", () => {
}> }>
const series = option.series as Array<{ const series = option.series as Array<{
data: unknown[] data: unknown[]
name: string
xAxisIndex: number xAxisIndex: number
yAxisIndex: number yAxisIndex: number
}> }>
const legends = option.legend as Array<{ data: string[] }>
expect(dataZoom).toHaveLength(2) expect(dataZoom).toHaveLength(2)
expect(dataZoom[0]).toMatchObject({ expect(dataZoom[0]).toMatchObject({
@@ -39,13 +43,26 @@ describe("selection chart option", () => {
xAxisIndex: [0, 1, 2], xAxisIndex: [0, 1, 2],
}) })
expect(dataZoom[1].xAxisIndex).toEqual([0, 1, 2]) expect(dataZoom[1].xAxisIndex).toEqual([0, 1, 2])
expect(series[0]).toMatchObject({ xAxisIndex: 0, yAxisIndex: 0 }) expect(legends[0]?.data).toEqual(["知行白线", "知行黄线"])
expect(series[1]).toMatchObject({ xAxisIndex: 1, yAxisIndex: 1 }) expect(series.find((item) => item.name === "日线")).toMatchObject({
expect(series.slice(2)).toEqual( xAxisIndex: 0,
expect.arrayContaining([ yAxisIndex: 0,
expect.objectContaining({ xAxisIndex: 2, yAxisIndex: 2 }), })
]), expect(series.find((item) => item.name === "知行白线")).toMatchObject({
) data: points.map((item) => item.trend_white),
xAxisIndex: 0,
yAxisIndex: 0,
})
expect(series.find((item) => item.name === "知行黄线")).toMatchObject({
data: points.map((item) => item.trend_yellow),
xAxisIndex: 0,
yAxisIndex: 0,
})
expect(series.find((item) => item.name === "成交量")).toMatchObject({
xAxisIndex: 1,
yAxisIndex: 1,
})
expect(series.filter((item) => item.xAxisIndex === 2)).toHaveLength(3)
expect(series[0].data[0]).toEqual([10, 10.5, 9, 11]) expect(series[0].data[0]).toEqual([10, 10.5, 9, 11])
}) })
@@ -57,20 +74,25 @@ describe("selection chart option", () => {
high: null, high: null,
j: null, j: null,
k: null, k: null,
trend_white: null,
trend_yellow: null,
volume: null, volume: null,
} }
const option = buildSelectionChartOption([incomplete]) const option = buildSelectionChartOption([incomplete])
const series = option.series as Array<{ const series = option.series as Array<{
connectNulls?: boolean connectNulls?: boolean
data: unknown[] data: unknown[]
name: string
}> }>
const candle = series[0].data[0] as number[] const candle = series[0].data[0] as number[]
const volume = series.find((item) => item.name === "成交量")
const lines = series.filter((item) => item.connectNulls === false)
expect(candle).toHaveLength(4) expect(candle).toHaveLength(4)
expect(candle.every(Number.isNaN)).toBe(true) expect(candle.every(Number.isNaN)).toBe(true)
expect(series[1].data[0]).toMatchObject({ value: null }) expect(volume?.data[0]).toMatchObject({ value: null })
for (const indicator of series.slice(2)) { expect(lines).toHaveLength(5)
expect(indicator.connectNulls).toBe(false) for (const indicator of lines) {
expect(indicator.data).toEqual([null]) expect(indicator.data).toEqual([null])
} }
}) })
@@ -16,6 +16,11 @@ import type { SelectionChartPoint } from "../api/selection.types"
const DEFAULT_VISIBLE_POINTS = 120 const DEFAULT_VISIBLE_POINTS = 120
const zhixingLineSeries = [
{ color: "#a8a8a8", key: "trend_white", name: "知行白线" },
{ color: "#eab308", key: "trend_yellow", name: "知行黄线" },
] as const
export type SelectionChartOption = ComposeOption< export type SelectionChartOption = ComposeOption<
| BarSeriesOption | BarSeriesOption
| CandlestickSeriesOption | CandlestickSeriesOption
@@ -30,7 +35,7 @@ export type SelectionChartOption = ComposeOption<
/** /**
* Build the three-grid financial chart configuration from aligned API points. * Build the three-grid financial chart configuration from aligned API points.
* *
* @param points Ascending qfq OHLCV and KDJ points from the selection API. * @param points Ascending qfq OHLCV, KDJ, and Zhixing-line points from the API.
* @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(
@@ -63,11 +68,18 @@ export function buildSelectionChartOption(
{ bottom: "28%", height: "14%", left: 64, right: 24 }, { bottom: "28%", height: "14%", left: 64, right: 24 },
{ bottom: 48, height: "20%", left: 64, right: 24 }, { bottom: 48, height: "20%", left: 64, right: 24 },
], ],
legend: { legend: [
{
data: zhixingLineSeries.map((line) => line.name),
right: 20,
top: 8,
},
{
data: ["K", "D", "J"], data: ["K", "D", "J"],
right: 20, right: 20,
top: "73%", top: "73%",
}, },
],
series: [ series: [
{ {
data: points.map(toCandlestickValue), data: points.map(toCandlestickValue),
@@ -82,6 +94,21 @@ export function buildSelectionChartOption(
xAxisIndex: 0, xAxisIndex: 0,
yAxisIndex: 0, yAxisIndex: 0,
}, },
...zhixingLineSeries.map((line): LineSeriesOption => ({
connectNulls: false,
data: points.map((point) => point[line.key]),
itemStyle: { color: line.color },
lineStyle: {
color: line.color,
width: 1.5,
},
name: line.name,
showSymbol: false,
smooth: false,
type: "line",
xAxisIndex: 0,
yAxisIndex: 0,
})),
{ {
data: points.map((point) => ({ data: points.map((point) => ({
itemStyle: { itemStyle: {
@@ -46,6 +46,8 @@ const chart: SelectionChartData = {
low: 9, low: 9,
open: 10, open: 10,
trade_date: "2026-08-08", trade_date: "2026-08-08",
trend_white: 10.2,
trend_yellow: 10.4,
volume: 1000, volume: 1000,
}, },
], ],
@@ -88,7 +90,7 @@ describe("SelectionChart", () => {
expect( expect(
screen.getByRole("img", { screen.getByRole("img", {
name: "药明康德 日线、成交量和 KDJ 图表", name: "药明康德 日线、知行黄白线、成交量和 KDJ 图表",
}), }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(chartMocks.init).toHaveBeenCalledTimes(1) expect(chartMocks.init).toHaveBeenCalledTimes(1)
@@ -61,7 +61,7 @@ export function SelectionChart({ chart }: SelectionChartProps) {
return ( return (
<div <div
aria-label={`${chart.name} 日线、成交量和 KDJ 图表`} aria-label={`${chart.name} 日线、知行黄白线、成交量和 KDJ 图表`}
className="h-[620px] min-h-[520px] w-full" className="h-[620px] min-h-[520px] w-full"
ref={containerRef} ref={containerRef}
role="img" role="img"