diff --git a/.gitignore b/.gitignore index e6b5d54..226a558 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ node_modules/ .pnpm-store/ dist/ coverage/ + +# Playwright CLI session artifacts +.playwright-cli/ 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 68c6e53..1c3ada8 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,7 @@ from typing import Literal, cast import pandas as pd -from ..domain.indicators import compute_kdj +from ..domain.indicators import compute_kdj, compute_zhixing_lines from ..domain.ports import MarketDataReader SELECTION_CHART_LIMIT = 250 @@ -21,7 +21,7 @@ class SelectionChartNotFound(LookupError): @dataclass(frozen=True, slots=True) 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 open: float | None @@ -32,6 +32,8 @@ class SelectionChartPoint: k: float | None d: float | None j: float | None + trend_white: float | None + trend_yellow: float | None @dataclass(frozen=True, slots=True) @@ -46,7 +48,7 @@ class SelectionChart: 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: """Inject the market-data reader owned by the selection context.""" @@ -54,7 +56,7 @@ class GetSelectionChart: self.reader = reader 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: ts_code: Tushare stock identifier selected by the user. @@ -83,6 +85,7 @@ class GetSelectionChart: } ) kdj = compute_kdj(frame) + white, yellow = compute_zhixing_lines(frame["close"]) start = max(0, len(bars) - SELECTION_CHART_LIMIT) points = tuple( SelectionChartPoint( @@ -95,6 +98,8 @@ class GetSelectionChart: k=_finite_or_none(kdj.iloc[index]["K"]), d=_finite_or_none(kdj.iloc[index]["D"]), 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) if index >= start 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 b48a746..c37de9d 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -147,7 +147,7 @@ class SelectionStockResponse(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 open: float | None @@ -158,6 +158,8 @@ class SelectionChartPointResponse(BaseModel): k: float | None d: float | None j: float | None + trend_white: float | None + trend_yellow: float | None class SelectionChartResponse(BaseModel): @@ -268,7 +270,7 @@ def get_selection_chart( target_trade_date: date, service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)], ) -> SelectionChartResponse: - """Return one stock's bounded qfq OHLCV and KDJ history.""" + """Return one stock's bounded qfq OHLCV, KDJ, and Zhixing-line history.""" try: chart = service.execute(ts_code, target_trade_date) @@ -482,6 +484,8 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse: k=point.k, d=point.d, j=point.j, + trend_white=point.trend_white, + trend_yellow=point.trend_yellow, ) for point in chart.points ], diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index 33f9326..57a8852 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -133,6 +133,8 @@ class FakeChartService: k=52.0, d=48.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, "d": 48.0, "j": 60.0, + "trend_white": 10.2, + "trend_yellow": 10.4, } ], } diff --git a/zhixing-server/tests/unit/selection/test_chart.py b/zhixing-server/tests/unit/selection/test_chart.py index 8c56ffc..8c3fb5a 100644 --- a/zhixing-server/tests/unit/selection/test_chart.py +++ b/zhixing-server/tests/unit/selection/test_chart.py @@ -9,7 +9,10 @@ from zhixing_server.modules.selection.application.chart import ( GetSelectionChart, 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 @@ -61,9 +64,12 @@ def test_chart_computes_on_full_history_before_returning_last_250_points() -> No } ) 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].d == pytest.approx(float(expected["D"])) 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: diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 0550081..ac600db 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -70,6 +70,8 @@ export interface SelectionChartPoint { k: number | null d: number | null j: number | null + trend_white: number | null + trend_yellow: 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 b869db6..9e06ec5 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 @@ -13,6 +13,8 @@ function point(index: number): SelectionChartPoint { low: index + 9, open: index + 10, 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, } } @@ -28,9 +30,11 @@ describe("selection chart option", () => { }> const series = option.series as Array<{ data: unknown[] + name: string xAxisIndex: number yAxisIndex: number }> + const legends = option.legend as Array<{ data: string[] }> expect(dataZoom).toHaveLength(2) expect(dataZoom[0]).toMatchObject({ @@ -39,13 +43,26 @@ describe("selection chart option", () => { xAxisIndex: [0, 1, 2], }) expect(dataZoom[1].xAxisIndex).toEqual([0, 1, 2]) - expect(series[0]).toMatchObject({ xAxisIndex: 0, yAxisIndex: 0 }) - expect(series[1]).toMatchObject({ xAxisIndex: 1, yAxisIndex: 1 }) - expect(series.slice(2)).toEqual( - expect.arrayContaining([ - expect.objectContaining({ xAxisIndex: 2, yAxisIndex: 2 }), - ]), - ) + expect(legends[0]?.data).toEqual(["知行白线", "知行黄线"]) + expect(series.find((item) => item.name === "日线")).toMatchObject({ + xAxisIndex: 0, + yAxisIndex: 0, + }) + 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]) }) @@ -57,20 +74,25 @@ describe("selection chart option", () => { high: null, j: null, k: null, + trend_white: null, + trend_yellow: null, volume: null, } const option = buildSelectionChartOption([incomplete]) const series = option.series as Array<{ connectNulls?: boolean data: unknown[] + name: string }> 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.every(Number.isNaN)).toBe(true) - expect(series[1].data[0]).toMatchObject({ value: null }) - for (const indicator of series.slice(2)) { - expect(indicator.connectNulls).toBe(false) + expect(volume?.data[0]).toMatchObject({ value: null }) + expect(lines).toHaveLength(5) + for (const indicator of lines) { expect(indicator.data).toEqual([null]) } }) 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 daf8f81..bd8f363 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,11 @@ import type { SelectionChartPoint } from "../api/selection.types" 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< | BarSeriesOption | CandlestickSeriesOption @@ -30,7 +35,7 @@ export type SelectionChartOption = ComposeOption< /** * 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. */ export function buildSelectionChartOption( @@ -63,11 +68,18 @@ export function buildSelectionChartOption( { bottom: "28%", height: "14%", left: 64, right: 24 }, { bottom: 48, height: "20%", left: 64, right: 24 }, ], - legend: { - data: ["K", "D", "J"], - right: 20, - top: "73%", - }, + legend: [ + { + data: zhixingLineSeries.map((line) => line.name), + right: 20, + top: 8, + }, + { + data: ["K", "D", "J"], + right: 20, + top: "73%", + }, + ], series: [ { data: points.map(toCandlestickValue), @@ -82,6 +94,21 @@ export function buildSelectionChartOption( xAxisIndex: 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) => ({ itemStyle: { 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 2559af4..24f4902 100644 --- a/zhixing-web/src/features/selection/components/selection-chart.test.tsx +++ b/zhixing-web/src/features/selection/components/selection-chart.test.tsx @@ -46,6 +46,8 @@ const chart: SelectionChartData = { low: 9, open: 10, trade_date: "2026-08-08", + trend_white: 10.2, + trend_yellow: 10.4, volume: 1000, }, ], @@ -88,7 +90,7 @@ describe("SelectionChart", () => { expect( screen.getByRole("img", { - name: "药明康德 日线、成交量和 KDJ 图表", + name: "药明康德 日线、知行黄白线、成交量和 KDJ 图表", }), ).toBeInTheDocument() expect(chartMocks.init).toHaveBeenCalledTimes(1) diff --git a/zhixing-web/src/features/selection/components/selection-chart.tsx b/zhixing-web/src/features/selection/components/selection-chart.tsx index feb1694..cddad85 100644 --- a/zhixing-web/src/features/selection/components/selection-chart.tsx +++ b/zhixing-web/src/features/selection/components/selection-chart.tsx @@ -61,7 +61,7 @@ export function SelectionChart({ chart }: SelectionChartProps) { return (