Merge branch 'codex/chart' into develop
# Conflicts: # .trellis/workspace/yuxuanhui/index.md
This commit is contained in:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user