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
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..domain.gold_brick import prepare_gold_brick_indicators
|
||||||
from ..domain.indicators import compute_kdj, compute_zhixing_lines
|
from ..domain.indicators import compute_kdj, compute_zhixing_lines
|
||||||
|
from ..domain.models import SelectionBar
|
||||||
from ..domain.ports import MarketDataReader
|
from ..domain.ports import MarketDataReader
|
||||||
|
|
||||||
SELECTION_CHART_LIMIT = 250
|
SELECTION_CHART_LIMIT = 250
|
||||||
@@ -21,7 +23,7 @@ class SelectionChartNotFound(LookupError):
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SelectionChartPoint:
|
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
|
trade_date: date
|
||||||
open: float | None
|
open: float | None
|
||||||
@@ -34,6 +36,7 @@ class SelectionChartPoint:
|
|||||||
j: float | None
|
j: float | None
|
||||||
trend_white: float | None
|
trend_white: float | None
|
||||||
trend_yellow: float | None
|
trend_yellow: float | None
|
||||||
|
brick_chart: float | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -55,12 +58,18 @@ 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,
|
||||||
|
include_brick_chart: bool = False,
|
||||||
|
) -> SelectionChart:
|
||||||
"""Return at most 250 points without changing full-history indicator 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.
|
||||||
target_trade_date: Inclusive historical boundary for the chart.
|
target_trade_date: Inclusive historical boundary for the chart.
|
||||||
|
include_brick_chart: Compute the gold-brick series for the request.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
An ascending qfq chart series aligned by trade date.
|
An ascending qfq chart series aligned by trade date.
|
||||||
@@ -86,6 +95,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
|
||||||
start = max(0, len(bars) - SELECTION_CHART_LIMIT)
|
start = max(0, len(bars) - SELECTION_CHART_LIMIT)
|
||||||
points = tuple(
|
points = tuple(
|
||||||
SelectionChartPoint(
|
SelectionChartPoint(
|
||||||
@@ -100,6 +110,9 @@ class GetSelectionChart:
|
|||||||
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_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=(
|
||||||
|
None if brick_chart is None else _finite_or_none(brick_chart.iloc[index])
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for index, bar in enumerate(bars)
|
for index, bar in enumerate(bars)
|
||||||
if index >= start
|
if index >= start
|
||||||
@@ -112,6 +125,25 @@ class GetSelectionChart:
|
|||||||
points=points,
|
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:
|
def _finite_or_none(value: object) -> float | None:
|
||||||
"""Convert one Pandas scalar to a finite JSON-safe float or ``None``."""
|
"""Convert one Pandas scalar to a finite JSON-safe float or ``None``."""
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ class SelectionStockResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SelectionChartPointResponse(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
|
trade_date: date
|
||||||
open: float | None
|
open: float | None
|
||||||
@@ -166,6 +166,7 @@ class SelectionChartPointResponse(BaseModel):
|
|||||||
j: float | None
|
j: float | None
|
||||||
trend_white: float | None
|
trend_white: float | None
|
||||||
trend_yellow: float | None
|
trend_yellow: float | None
|
||||||
|
brick_chart: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class SelectionChartResponse(BaseModel):
|
class SelectionChartResponse(BaseModel):
|
||||||
@@ -276,11 +277,16 @@ def get_selection_chart(
|
|||||||
ts_code: str,
|
ts_code: str,
|
||||||
target_trade_date: date,
|
target_trade_date: date,
|
||||||
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
|
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
|
||||||
|
strategy: StrategyValue = "zhixing_b1",
|
||||||
) -> SelectionChartResponse:
|
) -> SelectionChartResponse:
|
||||||
"""Return one stock's bounded qfq OHLCV, KDJ, and Zhixing-line 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,
|
||||||
|
include_brick_chart=strategy == "gold_brick",
|
||||||
|
)
|
||||||
except SelectionChartNotFound as exc:
|
except SelectionChartNotFound as exc:
|
||||||
raise _http_error(404, "chart_data_not_found", str(exc)) from exc
|
raise _http_error(404, "chart_data_not_found", str(exc)) from exc
|
||||||
except MarketDataReaderError as exc:
|
except MarketDataReaderError as exc:
|
||||||
@@ -503,6 +509,7 @@ def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
|
|||||||
j=point.j,
|
j=point.j,
|
||||||
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,
|
||||||
)
|
)
|
||||||
for point in chart.points
|
for point in chart.points
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -109,10 +109,15 @@ class FakeChartService:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.mode = "ok"
|
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:
|
def execute(
|
||||||
self.request = (ts_code, target_trade_date)
|
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":
|
if self.mode == "missing":
|
||||||
raise SelectionChartNotFound("chart unavailable")
|
raise SelectionChartNotFound("chart unavailable")
|
||||||
if self.mode == "storage_error":
|
if self.mode == "storage_error":
|
||||||
@@ -135,6 +140,7 @@ class FakeChartService:
|
|||||||
j=60.0,
|
j=60.0,
|
||||||
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -436,7 +442,7 @@ def test_chart_returns_bounded_qfq_contract() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert chart_service.request == ("000001.SZ", TARGET)
|
assert chart_service.request == ("000001.SZ", TARGET, False)
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"ts_code": "000001.SZ",
|
"ts_code": "000001.SZ",
|
||||||
"name": "平安银行",
|
"name": "平安银行",
|
||||||
@@ -455,11 +461,25 @@ def test_chart_returns_bounded_qfq_contract() -> None:
|
|||||||
"j": 60.0,
|
"j": 60.0,
|
||||||
"trend_white": 10.2,
|
"trend_white": 10.2,
|
||||||
"trend_yellow": 10.4,
|
"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(
|
@pytest.mark.parametrize(
|
||||||
("mode", "status_code", "error_code"),
|
("mode", "status_code", "error_code"),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from zhixing_server.modules.selection.application.chart import (
|
|||||||
GetSelectionChart,
|
GetSelectionChart,
|
||||||
SelectionChartNotFound,
|
SelectionChartNotFound,
|
||||||
)
|
)
|
||||||
|
from zhixing_server.modules.selection.domain.gold_brick import (
|
||||||
|
prepare_gold_brick_indicators,
|
||||||
|
)
|
||||||
from zhixing_server.modules.selection.domain.indicators import (
|
from zhixing_server.modules.selection.domain.indicators import (
|
||||||
compute_kdj,
|
compute_kdj,
|
||||||
compute_zhixing_lines,
|
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
|
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:
|
def test_chart_rejects_empty_history() -> None:
|
||||||
reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行"))
|
reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行"))
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
export function getSelectionChart(
|
export function getSelectionChart(
|
||||||
tsCode: string,
|
tsCode: string,
|
||||||
targetTradeDate: string,
|
targetTradeDate: string,
|
||||||
|
strategy?: SelectionStrategy,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
target_trade_date: targetTradeDate,
|
target_trade_date: targetTradeDate,
|
||||||
})
|
})
|
||||||
|
if (strategy) params.set("strategy", strategy)
|
||||||
return requestJson<SelectionChart>(
|
return requestJson<SelectionChart>(
|
||||||
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
|
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
|
||||||
{ signal },
|
{ signal },
|
||||||
|
|||||||
@@ -31,8 +31,15 @@ const defaultListQuery: SelectionResultsListQuery = {
|
|||||||
export const selectionChartQueryKey = (
|
export const selectionChartQueryKey = (
|
||||||
tsCode?: string,
|
tsCode?: string,
|
||||||
targetTradeDate?: string,
|
targetTradeDate?: string,
|
||||||
|
strategy?: SelectionStrategy,
|
||||||
) =>
|
) =>
|
||||||
["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
|
[
|
||||||
|
"selection",
|
||||||
|
"chart",
|
||||||
|
tsCode ?? "none",
|
||||||
|
targetTradeDate ?? "none",
|
||||||
|
strategy ?? "none",
|
||||||
|
] as const
|
||||||
|
|
||||||
export const selectionResultsQueryKey = (
|
export const selectionResultsQueryKey = (
|
||||||
strategy: SelectionStrategy,
|
strategy: SelectionStrategy,
|
||||||
@@ -100,12 +107,16 @@ export function useSelectionResults(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
|
export function useSelectionChart(
|
||||||
|
tsCode?: string,
|
||||||
|
targetTradeDate?: string,
|
||||||
|
strategy?: SelectionStrategy,
|
||||||
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled: Boolean(tsCode && targetTradeDate),
|
enabled: Boolean(tsCode && targetTradeDate),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal),
|
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", strategy, signal),
|
||||||
queryKey: selectionChartQueryKey(tsCode, targetTradeDate),
|
queryKey: selectionChartQueryKey(tsCode, targetTradeDate, strategy),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface SelectionChartPoint {
|
|||||||
j: number | null
|
j: number | null
|
||||||
trend_white: number | null
|
trend_white: number | null
|
||||||
trend_yellow: number | null
|
trend_yellow: number | null
|
||||||
|
brick_chart: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SelectionChart {
|
export interface SelectionChart {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { buildSelectionChartOption } from "./selection-chart-option"
|
|||||||
|
|
||||||
function point(index: number): SelectionChartPoint {
|
function point(index: number): SelectionChartPoint {
|
||||||
return {
|
return {
|
||||||
|
brick_chart: null,
|
||||||
close: index + 10.5,
|
close: index + 10.5,
|
||||||
d: index + 20,
|
d: index + 20,
|
||||||
high: index + 11,
|
high: index + 11,
|
||||||
@@ -106,4 +107,51 @@ describe("selection chart option", () => {
|
|||||||
expect(indicator.data).toEqual([null])
|
expect(indicator.data).toEqual([null])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("inserts a brick grid below volume when brick values exist", () => {
|
||||||
|
const brickPoints = Array.from({ length: 250 }, (_, index) => ({
|
||||||
|
...point(index),
|
||||||
|
brick_chart: index % 2,
|
||||||
|
}))
|
||||||
|
const option = buildSelectionChartOption(brickPoints)
|
||||||
|
const grids = option.grid as Array<Record<string, unknown>>
|
||||||
|
const series = option.series as Array<{
|
||||||
|
data: Array<{ itemStyle: { color: string }; value: number }>
|
||||||
|
name: string
|
||||||
|
xAxisIndex: number
|
||||||
|
yAxisIndex: number
|
||||||
|
}>
|
||||||
|
const legends = option.legend as Array<{ data: string[] }>
|
||||||
|
const titles = option.title as Array<{ text: string; top: unknown }>
|
||||||
|
const dataZoom = option.dataZoom as Array<{ xAxisIndex: number[] }>
|
||||||
|
const valueAxes = option.yAxis as Array<Record<string, unknown>>
|
||||||
|
|
||||||
|
expect(grids).toHaveLength(4)
|
||||||
|
expect(grids[1]).toMatchObject({ bottom: "37%" })
|
||||||
|
expect(grids[2]).toMatchObject({ bottom: "24%" })
|
||||||
|
expect(grids[3]).toMatchObject({ bottom: 48 })
|
||||||
|
expect(dataZoom[0].xAxisIndex).toEqual([0, 1, 2, 3])
|
||||||
|
expect(legends[1]?.data).toEqual(["金砖", "J"])
|
||||||
|
expect(titles.map((item) => item.text)).toEqual([
|
||||||
|
"日线",
|
||||||
|
"成交量",
|
||||||
|
"金砖",
|
||||||
|
"J",
|
||||||
|
])
|
||||||
|
expect(valueAxes).toHaveLength(4)
|
||||||
|
|
||||||
|
const brick = series.find((item) => item.name === "金砖")
|
||||||
|
expect(brick).toMatchObject({ xAxisIndex: 2, yAxisIndex: 2 })
|
||||||
|
expect(brick?.data[1]).toMatchObject({
|
||||||
|
itemStyle: { color: "#dc2626" },
|
||||||
|
value: 1,
|
||||||
|
})
|
||||||
|
expect(brick?.data[2]).toMatchObject({
|
||||||
|
itemStyle: { color: "#16a34a" },
|
||||||
|
value: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const jLine = series.find((item) => item.name === "J")
|
||||||
|
expect(jLine).toMatchObject({ xAxisIndex: 3, yAxisIndex: 3 })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import type { SelectionChartPoint } from "../api/selection.types"
|
|||||||
|
|
||||||
const DEFAULT_VISIBLE_POINTS = 120
|
const DEFAULT_VISIBLE_POINTS = 120
|
||||||
|
|
||||||
|
const BRICK_UP_COLOR = "#dc2626"
|
||||||
|
const BRICK_DOWN_COLOR = "#16a34a"
|
||||||
|
|
||||||
const zhixingLineSeries = [
|
const zhixingLineSeries = [
|
||||||
{ color: "#a8a8a8", key: "trend_white", name: "知行白线" },
|
{ color: "#a8a8a8", key: "trend_white", name: "知行白线" },
|
||||||
{ color: "#eab308", key: "trend_yellow", name: "知行黄线" },
|
{ color: "#eab308", key: "trend_yellow", name: "知行黄线" },
|
||||||
@@ -33,9 +36,12 @@ export type SelectionChartOption = ComposeOption<
|
|||||||
>
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the three-grid financial chart configuration from aligned API points.
|
* Build the financial chart configuration from aligned API points.
|
||||||
*
|
*
|
||||||
* @param points Ascending qfq OHLCV, J, and Zhixing-line points from the API.
|
* When any point carries gold-brick values, a brick grid is inserted below
|
||||||
|
* the volume grid; otherwise the original three-grid layout is kept.
|
||||||
|
*
|
||||||
|
* @param points Ascending qfq OHLCV, J, Zhixing-line, and brick points.
|
||||||
* @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(
|
||||||
@@ -46,6 +52,10 @@ export function buildSelectionChartOption(
|
|||||||
const startValue = dates[startIndex]
|
const startValue = dates[startIndex]
|
||||||
const endValue = dates.at(-1)
|
const endValue = dates.at(-1)
|
||||||
const zoomRange = { endValue, startValue }
|
const zoomRange = { endValue, startValue }
|
||||||
|
const showBrickChart = points.some(
|
||||||
|
(point) => point.brick_chart !== null && point.brick_chart !== undefined,
|
||||||
|
)
|
||||||
|
const axisCount = showBrickChart ? 4 : 3
|
||||||
|
|
||||||
return {
|
return {
|
||||||
animation: false,
|
animation: false,
|
||||||
@@ -53,17 +63,24 @@ export function buildSelectionChartOption(
|
|||||||
{
|
{
|
||||||
...zoomRange,
|
...zoomRange,
|
||||||
type: "inside",
|
type: "inside",
|
||||||
xAxisIndex: [0, 1, 2],
|
xAxisIndex: [...Array(axisCount).keys()],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...zoomRange,
|
...zoomRange,
|
||||||
bottom: 4,
|
bottom: 4,
|
||||||
height: 20,
|
height: 20,
|
||||||
type: "slider",
|
type: "slider",
|
||||||
xAxisIndex: [0, 1, 2],
|
xAxisIndex: [...Array(axisCount).keys()],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
grid: [
|
grid: showBrickChart
|
||||||
|
? [
|
||||||
|
{ bottom: "52%", left: 64, right: 24, top: 36 },
|
||||||
|
{ bottom: "37%", height: "12%", left: 64, right: 24 },
|
||||||
|
{ bottom: "24%", height: "10%", left: 64, right: 24 },
|
||||||
|
{ bottom: 48, height: "13%", left: 64, right: 24 },
|
||||||
|
]
|
||||||
|
: [
|
||||||
{ bottom: "46%", left: 64, right: 24, top: 36 },
|
{ bottom: "46%", left: 64, right: 24, top: 36 },
|
||||||
{ 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 },
|
||||||
@@ -75,9 +92,9 @@ export function buildSelectionChartOption(
|
|||||||
top: 8,
|
top: 8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
data: ["J"],
|
data: showBrickChart ? ["金砖", "J"] : ["J"],
|
||||||
right: 20,
|
right: 20,
|
||||||
top: "73%",
|
top: showBrickChart ? "77%" : "73%",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
@@ -126,6 +143,7 @@ export function buildSelectionChartOption(
|
|||||||
xAxisIndex: 1,
|
xAxisIndex: 1,
|
||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
},
|
},
|
||||||
|
...(showBrickChart ? [buildBrickSeries(points)] : []),
|
||||||
{
|
{
|
||||||
connectNulls: false,
|
connectNulls: false,
|
||||||
data: points.map((point) => point.j),
|
data: points.map((point) => point.j),
|
||||||
@@ -137,11 +155,18 @@ export function buildSelectionChartOption(
|
|||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
smooth: false,
|
smooth: false,
|
||||||
type: "line",
|
type: "line",
|
||||||
xAxisIndex: 2,
|
xAxisIndex: axisCount - 1,
|
||||||
yAxisIndex: 2,
|
yAxisIndex: axisCount - 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
title: [
|
title: showBrickChart
|
||||||
|
? [
|
||||||
|
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
|
||||||
|
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "50%" },
|
||||||
|
{ left: 72, text: "金砖", textStyle: { fontSize: 12 }, top: "65%" },
|
||||||
|
{ left: 72, text: "J", textStyle: { fontSize: 12 }, top: "77%" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
|
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
|
||||||
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
|
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
|
||||||
{ left: 72, text: "J", textStyle: { fontSize: 12 }, top: "72%" },
|
{ left: 72, text: "J", textStyle: { fontSize: 12 }, top: "72%" },
|
||||||
@@ -151,18 +176,44 @@ export function buildSelectionChartOption(
|
|||||||
trigger: "axis",
|
trigger: "axis",
|
||||||
},
|
},
|
||||||
xAxis: [
|
xAxis: [
|
||||||
buildCategoryAxis(dates, 0, false),
|
...Array.from({ length: axisCount - 1 }, (_, gridIndex) =>
|
||||||
buildCategoryAxis(dates, 1, false),
|
buildCategoryAxis(dates, gridIndex, false),
|
||||||
buildCategoryAxis(dates, 2, true),
|
),
|
||||||
|
buildCategoryAxis(dates, axisCount - 1, true),
|
||||||
],
|
],
|
||||||
yAxis: [
|
yAxis: [
|
||||||
buildValueAxis(0),
|
buildValueAxis(0),
|
||||||
buildValueAxis(1),
|
buildValueAxis(1),
|
||||||
buildValueAxis(2, { max: 120, min: -20 }),
|
...(showBrickChart ? [buildValueAxis(2)] : []),
|
||||||
|
showBrickChart
|
||||||
|
? buildValueAxis(3, { max: 120, min: -20 })
|
||||||
|
: buildValueAxis(2, { max: 120, min: -20 }),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildBrickSeries(points: SelectionChartPoint[]): BarSeriesOption {
|
||||||
|
return {
|
||||||
|
data: points.map((point, index) => {
|
||||||
|
const previous = points[index - 1]
|
||||||
|
const rising =
|
||||||
|
previous?.brick_chart != 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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
name: "金砖",
|
||||||
|
type: "bar",
|
||||||
|
xAxisIndex: 2,
|
||||||
|
yAxisIndex: 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toCandlestickValue(point: SelectionChartPoint) {
|
function toCandlestickValue(point: SelectionChartPoint) {
|
||||||
if (
|
if (
|
||||||
point.open === null ||
|
point.open === null ||
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const chart: SelectionChartData = {
|
|||||||
name: "药明康德",
|
name: "药明康德",
|
||||||
points: [
|
points: [
|
||||||
{
|
{
|
||||||
|
brick_chart: null,
|
||||||
close: 10.5,
|
close: 10.5,
|
||||||
d: 48,
|
d: 48,
|
||||||
high: 11,
|
high: 11,
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ export function SelectionChart({ chart, className }: SelectionChartProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label={`${chart.name} 日线、知行黄白线、成交量和 J 线图表`}
|
aria-label={`${chart.name} 日线、知行黄白线、成交量${
|
||||||
|
chart.points.some((point) => point.brick_chart != null) ? "、金砖" : ""
|
||||||
|
}和 J 线图表`}
|
||||||
className={cn("h-full min-h-70 w-full", className)}
|
className={cn("h-full min-h-70 w-full", className)}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
role="img"
|
role="img"
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ interface SignalDetailPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||||
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
|
const chartQuery = useSelectionChart(
|
||||||
|
stock?.ts_code,
|
||||||
|
stock?.target_trade_date,
|
||||||
|
stock?.strategy,
|
||||||
|
)
|
||||||
|
|
||||||
if (!stock) {
|
if (!stock) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ describe("SelectionResultsPage", () => {
|
|||||||
expect(useSelectionChart).toHaveBeenLastCalledWith(
|
expect(useSelectionChart).toHaveBeenLastCalledWith(
|
||||||
"000001.SZ",
|
"000001.SZ",
|
||||||
"2026-08-08",
|
"2026-08-08",
|
||||||
|
"zhixing_b1",
|
||||||
)
|
)
|
||||||
|
|
||||||
useSelectionChart.mockReturnValue({
|
useSelectionChart.mockReturnValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user