feat(selection): add Zhixing line indicators to selection chart and update related components
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user