Merge branch 'codex/chart' into develop

# Conflicts:
#	.trellis/workspace/yuxuanhui/index.md
This commit is contained in:
yuxuanhui
2026-09-01 13:44:30 +08:00
41 changed files with 1493 additions and 39 deletions
+1
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"echarts": "^6.1.0",
"lucide-react": "^1.28.0",
"react": "^19.2.8",
"react-day-picker": "^10.0.1",
+23
View File
@@ -26,6 +26,9 @@ importers:
date-fns:
specifier: ^4.4.0
version: 4.4.0
echarts:
specifier: ^6.1.0
version: 6.1.0
lucide-react:
specifier: ^1.28.0
version: 1.28.0(react@19.2.8)
@@ -857,6 +860,9 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
electron-to-chromium@1.5.399:
resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==}
@@ -1484,6 +1490,9 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1674,6 +1683,9 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zustand@5.0.14:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'}
@@ -2414,6 +2426,11 @@ snapshots:
dom-accessibility-api@0.6.3: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
electron-to-chromium@1.5.399: {}
enhanced-resolve@5.24.5:
@@ -2958,6 +2975,8 @@ snapshots:
dependencies:
typescript: 6.0.3
tslib@2.3.0: {}
tslib@2.8.1:
optional: true
@@ -3078,6 +3097,10 @@ snapshots:
zod@4.4.3: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zustand@5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
optionalDependencies:
'@types/react': 19.2.18
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSelectionResults, getSelectionRun } from "./selection.api"
import {
getSelectionChart,
getSelectionResults,
getSelectionRun,
} from "./selection.api"
describe("selection API adapters", () => {
beforeEach(() => {
@@ -47,4 +51,18 @@ describe("selection API adapters", () => {
expect(params.has("category")).toBe(false)
expect(params.has("sort")).toBe(false)
})
it("requests a stock chart with an encoded code and target date", async () => {
await getSelectionChart("600601.SH", "2026-08-08")
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
expect(input).toContain("/api/v1/selection/stocks/600601.SH/chart?")
expect(
new URL(input, "http://localhost").searchParams.get("target_trade_date"),
).toBe("2026-08-08")
expect(init).toEqual({ signal: undefined })
})
})
@@ -1,6 +1,7 @@
import { requestJson } from "@/shared/api/request-json"
import type {
SelectionChart,
SelectionResults,
SelectionResultsQuery,
SelectionRunAccepted,
@@ -8,6 +9,20 @@ import type {
SelectionStrategy,
} from "./selection.types"
export function getSelectionChart(
tsCode: string,
targetTradeDate: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
target_trade_date: targetTradeDate,
})
return requestJson<SelectionChart>(
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
{ signal },
)
}
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
@@ -7,6 +7,7 @@ import {
import { useEffect } from "react"
import {
getSelectionChart,
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
@@ -17,6 +18,12 @@ import type {
SelectionStrategy,
} from "./selection.types"
export const selectionChartQueryKey = (
tsCode?: string,
targetTradeDate?: string,
) =>
["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
@@ -61,6 +68,15 @@ export function useSelectionResults(
})
}
export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
return useQuery({
enabled: Boolean(tsCode && targetTradeDate),
queryFn: ({ signal }) =>
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal),
queryKey: selectionChartQueryKey(tsCode, targetTradeDate),
})
}
export function useSelectionRun(
runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
@@ -58,6 +58,26 @@ export interface SelectionStockResult {
signals: SelectionSignal[]
}
export interface SelectionChartPoint {
trade_date: string
open: number | null
high: number | null
low: number | null
close: number | null
volume: number | null
k: number | null
d: number | null
j: number | null
}
export interface SelectionChart {
ts_code: string
name: string
target_trade_date: string
source_adj: "qfq"
points: SelectionChartPoint[]
}
export interface SelectionPatternScore {
status: "matched" | "below_threshold" | "failed"
value: number | null
@@ -0,0 +1,12 @@
export const patternCaseImageById = {
case_001: "/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
case_002: "/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg",
case_003: "/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg",
case_004: "/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
case_006: "/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg",
case_007: "/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg",
case_008: "/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg",
case_009: "/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg",
case_010: "/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg",
case_011: "/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg",
} as const
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { SelectionPatternScore } from "../api/selection.types"
import { PatternCaseImage } from "./pattern-case-image"
function score(
caseId = "case_004",
caseName = "方正科技",
): SelectionPatternScore {
return {
breakdown: {
kdj_state: 91,
price_shape: 92,
trend_structure: 86,
volume_pattern: 89,
},
case: {
breakout_date: "2025-07-23",
id: caseId,
name: caseName,
},
reason: null,
status: "matched",
threshold: 60,
value: 90.17,
version: "zhixing_b1_pattern_fastdtw_v1",
}
}
describe("PatternCaseImage", () => {
it("renders only the best case image and falls back after a load error", () => {
const view = render(<PatternCaseImage score={score()} />)
const image = screen.getByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(screen.getAllByRole("img")).toHaveLength(1)
expect(image).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
fireEvent.error(image)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
expect(
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
).toBeInTheDocument()
})
it("does not guess an asset for an unknown case id", () => {
render(<PatternCaseImage score={score("case_unknown", "未知案例")} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
})
it("does not render an image for uncomputed or failed scores", () => {
const failed: SelectionPatternScore = {
breakdown: null,
case: null,
reason: "scoring unavailable",
status: "failed",
threshold: null,
value: null,
version: null,
}
const view = render(<PatternCaseImage score={null} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
view.rerender(<PatternCaseImage score={failed} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,47 @@
import { useState } from "react"
import type { SelectionPatternScore } from "../api/selection.types"
import { patternCaseImageById } from "./pattern-case-assets"
interface PatternCaseImageProps {
score: SelectionPatternScore | null
}
export function PatternCaseImage({ score }: PatternCaseImageProps) {
const [failedSource, setFailedSource] = useState<string | null>(null)
const matchedCase = score?.status === "failed" ? null : score?.case
if (!matchedCase) return null
const source =
patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
return (
<section aria-labelledby="best-pattern-case" className="mt-4">
<div className="mb-2 flex items-baseline justify-between gap-3">
<h3 className="text-xs font-medium" id="best-pattern-case">
最接近案例:{matchedCase.name}
</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{formatScore(score?.value)} 分
</span>
</div>
{!source || failedSource === source ? (
<p className="rounded-md border border-border/60 p-3 text-xs text-muted-foreground">
案例图片暂不可用。
</p>
) : (
<img
alt={`${matchedCase.name} B1 参考案例图`}
className="aspect-2/1 w-full rounded-md border border-border/60 object-contain"
loading="lazy"
onError={() => setFailedSource(source)}
src={source}
/>
)}
</section>
)
}
function formatScore(value: number | null | undefined) {
return value === null || value === undefined ? "—" : value.toFixed(2)
}
@@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
if (score.status === "failed") {
return <Badge variant="destructive">评分暂不可用</Badge>
}
if (score.status === "below_threshold") {
return (
<Badge variant="outline">
未匹配到 {formatScore(score.threshold)} 分以上案例
</Badge>
)
}
return (
<span className="space-y-0.5">
<strong className="block tabular-nums text-primary">
@@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
<span className="block text-[10px] text-muted-foreground">
{score.case?.name ?? "最佳案例未知"}
</span>
{score.status === "below_threshold" ? (
<Badge variant="outline">低于阈值</Badge>
) : null}
</span>
)
}
@@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
</div>
)
}
if (score.status === "below_threshold") {
return (
<div className="space-y-1">
<Badge variant="outline">低于匹配阈值</Badge>
<p className="text-xs text-muted-foreground">
未匹配到 {formatScore(score.threshold)} 分以上案例。
</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
最佳案例:{score.case?.name ?? "未知"}
</p>
</div>
<Badge variant="default">已匹配</Badge>
<Badge variant={score.status === "matched" ? "default" : "outline"}>
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
</Badge>
</div>
{score.breakdown ? (
<dl className="grid grid-cols-2 gap-2 text-xs">
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest"
import type { SelectionChartPoint } from "../api/selection.types"
import { buildSelectionChartOption } from "./selection-chart-option"
function point(index: number): SelectionChartPoint {
return {
close: index + 10.5,
d: index + 20,
high: index + 11,
j: index + 30,
k: index + 10,
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")}`,
volume: index + 1000,
}
}
describe("selection chart option", () => {
it("aligns all three grids and opens on exactly the last 120 points", () => {
const points = Array.from({ length: 250 }, (_, index) => point(index))
const option = buildSelectionChartOption(points)
const dataZoom = option.dataZoom as Array<{
endValue: string
startValue: string
xAxisIndex: number[]
}>
const series = option.series as Array<{
data: unknown[]
xAxisIndex: number
yAxisIndex: number
}>
expect(dataZoom).toHaveLength(2)
expect(dataZoom[0]).toMatchObject({
endValue: points[249].trade_date,
startValue: points[130].trade_date,
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(series[0].data[0]).toEqual([10, 10.5, 9, 11])
})
it("keeps missing candles and indicator values empty", () => {
const incomplete = {
...point(0),
close: null,
d: null,
high: null,
j: null,
k: null,
volume: null,
}
const option = buildSelectionChartOption([incomplete])
const series = option.series as Array<{
connectNulls?: boolean
data: unknown[]
}>
const candle = series[0].data[0] as number[]
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(indicator.data).toEqual([null])
}
})
})
@@ -0,0 +1,182 @@
import type {
BarSeriesOption,
CandlestickSeriesOption,
LineSeriesOption,
} from "echarts/charts"
import type {
DataZoomComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
TooltipComponentOption,
} from "echarts/components"
import type { ComposeOption } from "echarts/core"
import type { SelectionChartPoint } from "../api/selection.types"
const DEFAULT_VISIBLE_POINTS = 120
export type SelectionChartOption = ComposeOption<
| BarSeriesOption
| CandlestickSeriesOption
| DataZoomComponentOption
| GridComponentOption
| LegendComponentOption
| LineSeriesOption
| TitleComponentOption
| TooltipComponentOption
>
/**
* Build the three-grid financial chart configuration from aligned API points.
*
* @param points Ascending qfq OHLCV and KDJ points from the selection API.
* @returns One ECharts option with synchronized axes and an exact 120-point window.
*/
export function buildSelectionChartOption(
points: SelectionChartPoint[],
): SelectionChartOption {
const dates = points.map((point) => point.trade_date)
const startIndex = Math.max(0, dates.length - DEFAULT_VISIBLE_POINTS)
const startValue = dates[startIndex]
const endValue = dates.at(-1)
const zoomRange = { endValue, startValue }
return {
animation: false,
dataZoom: [
{
...zoomRange,
type: "inside",
xAxisIndex: [0, 1, 2],
},
{
...zoomRange,
bottom: 4,
height: 20,
type: "slider",
xAxisIndex: [0, 1, 2],
},
],
grid: [
{ bottom: "46%", left: 64, right: 24, top: 36 },
{ 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%",
},
series: [
{
data: points.map(toCandlestickValue),
itemStyle: {
borderColor: "#dc2626",
borderColor0: "#16a34a",
color: "#dc2626",
color0: "#16a34a",
},
name: "日线",
type: "candlestick",
xAxisIndex: 0,
yAxisIndex: 0,
},
{
data: points.map((point) => ({
itemStyle: {
color:
point.open !== null &&
point.close !== null &&
point.close >= point.open
? "#dc2626"
: "#16a34a",
},
value: point.volume,
})),
name: "成交量",
type: "bar",
xAxisIndex: 1,
yAxisIndex: 1,
},
...(["k", "d", "j"] as const).map((key, index): LineSeriesOption => ({
connectNulls: false,
data: points.map((point) => point[key]),
name: key.toUpperCase(),
showSymbol: false,
smooth: false,
type: "line",
xAxisIndex: 2,
yAxisIndex: 2,
lineStyle: {
color: ["#2563eb", "#d97706", "#9333ea"][index],
width: 1.5,
},
})),
],
title: [
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
{ left: 72, text: "KDJ", textStyle: { fontSize: 12 }, top: "72%" },
],
tooltip: {
axisPointer: { type: "cross" },
trigger: "axis",
},
xAxis: [
buildCategoryAxis(dates, 0, false),
buildCategoryAxis(dates, 1, false),
buildCategoryAxis(dates, 2, true),
],
yAxis: [
buildValueAxis(0),
buildValueAxis(1),
buildValueAxis(2, { max: 120, min: -20 }),
],
}
}
function toCandlestickValue(point: SelectionChartPoint) {
if (
point.open === null ||
point.close === null ||
point.low === null ||
point.high === null
) {
return [Number.NaN, Number.NaN, Number.NaN, Number.NaN]
}
return [point.open, point.close, point.low, point.high]
}
function buildCategoryAxis(
dates: string[],
gridIndex: number,
showLabels: boolean,
) {
return {
axisLabel: { show: showLabels },
axisLine: { onZero: false },
boundaryGap: false,
data: dates,
gridIndex,
min: "dataMin",
max: "dataMax",
scale: true,
splitLine: { show: false },
type: "category" as const,
}
}
function buildValueAxis(
gridIndex: number,
bounds: { max?: number; min?: number } = {},
) {
return {
...bounds,
axisLabel: { fontSize: 10 },
gridIndex,
scale: true,
splitLine: { lineStyle: { opacity: 0.16 } },
type: "value" as const,
}
}
@@ -0,0 +1,110 @@
import { render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
const chartMocks = vi.hoisted(() => ({
disconnect: vi.fn(),
dispose: vi.fn(),
init: vi.fn(),
observe: vi.fn(),
resize: vi.fn(),
setOption: vi.fn(),
use: vi.fn(),
}))
vi.mock("echarts/charts", () => ({
BarChart: {},
CandlestickChart: {},
LineChart: {},
}))
vi.mock("echarts/components", () => ({
AxisPointerComponent: {},
DataZoomComponent: {},
GridComponent: {},
LegendComponent: {},
TitleComponent: {},
TooltipComponent: {},
}))
vi.mock("echarts/core", () => ({
init: chartMocks.init,
use: chartMocks.use,
}))
vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} }))
import { SelectionChart } from "./selection-chart"
const chart: SelectionChartData = {
name: "药明康德",
points: [
{
close: 10.5,
d: 48,
high: 11,
j: 60,
k: 52,
low: 9,
open: 10,
trade_date: "2026-08-08",
volume: 1000,
},
],
source_adj: "qfq",
target_trade_date: "2026-08-08",
ts_code: "603259.SH",
}
describe("SelectionChart", () => {
beforeEach(() => {
chartMocks.init.mockReturnValue({
dispose: chartMocks.dispose,
resize: chartMocks.resize,
setOption: chartMocks.setOption,
})
vi.stubGlobal(
"ResizeObserver",
class {
constructor(private readonly callback: ResizeObserverCallback) {}
disconnect() {
chartMocks.disconnect()
}
observe() {
chartMocks.observe()
this.callback([], this as unknown as ResizeObserver)
}
},
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it("initializes, resizes, updates and disposes one chart instance", () => {
const view = render(<SelectionChart chart={chart} />)
expect(
screen.getByRole("img", {
name: "药明康德 日线、成交量和 KDJ 图表",
}),
).toBeInTheDocument()
expect(chartMocks.init).toHaveBeenCalledTimes(1)
expect(chartMocks.observe).toHaveBeenCalledTimes(1)
expect(chartMocks.resize).toHaveBeenCalledTimes(1)
expect(chartMocks.setOption).toHaveBeenCalledTimes(1)
view.rerender(
<SelectionChart
chart={{ ...chart, points: [...chart.points, chart.points[0]] }}
/>,
)
expect(chartMocks.setOption).toHaveBeenCalledTimes(2)
view.unmount()
expect(chartMocks.disconnect).toHaveBeenCalledTimes(1)
expect(chartMocks.dispose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react"
import { BarChart, CandlestickChart, LineChart } from "echarts/charts"
import {
AxisPointerComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from "echarts/components"
import * as echarts from "echarts/core"
import { CanvasRenderer } from "echarts/renderers"
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
import { buildSelectionChartOption } from "./selection-chart-option"
echarts.use([
AxisPointerComponent,
BarChart,
CanvasRenderer,
CandlestickChart,
DataZoomComponent,
GridComponent,
LegendComponent,
LineChart,
TitleComponent,
TooltipComponent,
])
interface SelectionChartProps {
chart: SelectionChartData
}
export function SelectionChart({ chart }: SelectionChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const instanceRef = useRef<echarts.EChartsType | null>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const instance = echarts.init(container, undefined, {
renderer: "canvas",
})
instanceRef.current = instance
const observer = new ResizeObserver(() => instance.resize())
observer.observe(container)
return () => {
observer.disconnect()
instance.dispose()
instanceRef.current = null
}
}, [])
useEffect(() => {
instanceRef.current?.setOption(buildSelectionChartOption(chart.points), {
notMerge: true,
})
}, [chart.points])
return (
<div
aria-label={`${chart.name} 日线、成交量和 KDJ 图表`}
className="h-[620px] min-h-[520px] w-full"
ref={containerRef}
role="img"
/>
)
}
@@ -122,7 +122,7 @@ export function SelectionResultsWorkbench({
}
return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]">
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)] md:overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
<Input
@@ -1,4 +1,6 @@
import { lazy, Suspense } from "react"
import { Database, Search } from "lucide-react"
import { useSelectionChart } from "../api/selection.query"
import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card"
@@ -7,12 +9,21 @@ import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails } from "./pattern-score"
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
import { SignalDetails } from "./signal-details"
import { PatternCaseImage } from "./pattern-case-image"
const SelectionChart = lazy(() =>
import("./selection-chart").then((module) => ({
default: module.SelectionChart,
})),
)
interface SignalDetailPanelProps {
stock: SelectionStockResult | undefined
}
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
if (!stock) {
return (
<Card
@@ -95,6 +106,32 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
<PatternScoreDetails score={stock.score} />
</section>
<section className="mt-4" aria-labelledby="technical-chart">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="technical-chart"
>
技术图表
</h3>
{chartQuery.isPending ? (
<p className="text-xs text-muted-foreground">正在加载行情图表…</p>
) : chartQuery.isError ? (
<p className="text-xs text-destructive">行情图表暂时不可用。</p>
) : chartQuery.data?.points.length ? (
<Suspense
fallback={
<p className="text-xs text-muted-foreground">正在加载图表组件…</p>
}
>
<SelectionChart chart={chartQuery.data} />
</Suspense>
) : (
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
)}
</section>
<PatternCaseImage score={stock.score} />
<section className="mt-4" aria-labelledby="signal-detail-metrics">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn()
const useSelectionChart = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
@@ -23,6 +24,7 @@ const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
@@ -110,6 +112,11 @@ const selectedResult: SelectionResults = {
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
@@ -167,12 +174,23 @@ describe("SelectionResultsPage", () => {
})
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
expect(
within(detailPanel).getByText("暂无可用行情数据。"),
).toBeInTheDocument()
expect(
within(detailPanel).getByText("最佳案例:华纳药厂"),
).toBeInTheDocument()
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
const caseImages = within(detailPanel).getAllByRole("img", {
name: "华纳药厂 B1 参考案例图",
})
expect(caseImages).toHaveLength(1)
expect(caseImages[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
)
})
it.each<{
@@ -184,14 +202,23 @@ describe("SelectionResultsPage", () => {
score: null,
},
{
expected: "未匹配到 60.00 分以上案例。",
expected: "低于匹配阈值",
score: {
breakdown: null,
case: null,
reason: "未匹配到评分阈值以上案例",
breakdown: {
kdj_state: 42,
price_shape: 44,
trend_structure: 40,
volume_pattern: 43,
},
case: {
breakout_date: "2025-07-23",
id: "case_004",
name: "方正科技",
},
reason: null,
status: "below_threshold",
threshold: 60,
value: null,
value: 42.5,
version: "zhixing_b1_pattern_fastdtw_v1",
},
},
@@ -225,9 +252,51 @@ describe("SelectionResultsPage", () => {
const table = screen.getByRole("table")
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("12")).toBeInTheDocument()
if (score?.status === "below_threshold") {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
const images = within(detailPanel).getAllByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
expect(within(detailPanel).getAllByText("42.50 分")).toHaveLength(2)
} else {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).queryByRole("img")).not.toBeInTheDocument()
}
},
)
it("shows chart loading and error states for the selected stock", () => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: true,
})
const view = render(<SelectionResultsPage />)
expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument()
expect(useSelectionChart).toHaveBeenLastCalledWith(
"000001.SZ",
"2026-08-08",
)
useSelectionChart.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
view.rerender(<SelectionResultsPage />)
expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument()
})
it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({
data: {