diff --git a/zhixing-web/package.json b/zhixing-web/package.json index ad63873..ccd81eb 100644 --- a/zhixing-web/package.json +++ b/zhixing-web/package.json @@ -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", diff --git a/zhixing-web/pnpm-lock.yaml b/zhixing-web/pnpm-lock.yaml index 8a62633..f6158cd 100644 --- a/zhixing-web/pnpm-lock.yaml +++ b/zhixing-web/pnpm-lock.yaml @@ -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 diff --git a/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg b/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg new file mode 100644 index 0000000..e93d343 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg b/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg new file mode 100644 index 0000000..069fbc7 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg b/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg new file mode 100644 index 0000000..e0734e8 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg b/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg new file mode 100644 index 0000000..60992ca Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg b/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg new file mode 100644 index 0000000..bd7ad4f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg b/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg new file mode 100644 index 0000000..3aa2e5f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg b/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg new file mode 100644 index 0000000..7817c40 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg b/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg new file mode 100644 index 0000000..6ae80fe Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg b/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg new file mode 100644 index 0000000..5b8a5ea Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg b/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg new file mode 100644 index 0000000..1b62d4f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg differ diff --git a/zhixing-web/src/features/selection/api/selection.api.test.ts b/zhixing-web/src/features/selection/api/selection.api.test.ts index abd96d6..cae1a5e 100644 --- a/zhixing-web/src/features/selection/api/selection.api.test.ts +++ b/zhixing-web/src/features/selection/api/selection.api.test.ts @@ -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 }) + }) }) diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index 121e4bb..5c667c3 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -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( + `/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`, + { signal }, + ) +} + export function getSelectionResults( strategy: SelectionStrategy, targetTradeDate?: string, diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 4667739..085900d 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -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 }, diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index d947ad6..6358aca 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -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 diff --git a/zhixing-web/src/features/selection/components/pattern-case-assets.ts b/zhixing-web/src/features/selection/components/pattern-case-assets.ts new file mode 100644 index 0000000..4e91782 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-assets.ts @@ -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 diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx new file mode 100644 index 0000000..8ff1958 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx @@ -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() + + 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() + expect( + screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }), + ).toBeInTheDocument() + }) + + it("does not guess an asset for an unknown case id", () => { + render() + + 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() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + + view.rerender() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument() + }) +}) diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.tsx new file mode 100644 index 0000000..e6d0370 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-image.tsx @@ -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(null) + const matchedCase = score?.status === "failed" ? null : score?.case + if (!matchedCase) return null + + const source = + patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById] + + return ( +
+
+

+ 最接近案例:{matchedCase.name} +

+ + {formatScore(score?.value)} 分 + +
+ {!source || failedSource === source ? ( +

+ 案例图片暂不可用。 +

+ ) : ( + {`${matchedCase.name} setFailedSource(source)} + src={source} + /> + )} +
+ ) +} + +function formatScore(value: number | null | undefined) { + return value === null || value === undefined ? "—" : value.toFixed(2) +} diff --git a/zhixing-web/src/features/selection/components/pattern-score.tsx b/zhixing-web/src/features/selection/components/pattern-score.tsx index a782510..e2fec77 100644 --- a/zhixing-web/src/features/selection/components/pattern-score.tsx +++ b/zhixing-web/src/features/selection/components/pattern-score.tsx @@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) { if (score.status === "failed") { return 评分暂不可用 } - if (score.status === "below_threshold") { - return ( - - 未匹配到 {formatScore(score.threshold)} 分以上案例 - - ) - } return ( @@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) { {score.case?.name ?? "最佳案例未知"} + {score.status === "below_threshold" ? ( + 低于阈值 + ) : null} ) } @@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) { ) } - if (score.status === "below_threshold") { - return ( -
- 低于匹配阈值 -

- 未匹配到 {formatScore(score.threshold)} 分以上案例。 -

-
- ) - } return (
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) { 最佳案例:{score.case?.name ?? "未知"}

- 已匹配 + + {score.status === "matched" ? "已匹配" : "低于匹配阈值"} +
{score.breakdown ? (
diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.test.ts b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts new file mode 100644 index 0000000..b869db6 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts @@ -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]) + } + }) +}) diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.ts b/zhixing-web/src/features/selection/components/selection-chart-option.ts new file mode 100644 index 0000000..daf8f81 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart-option.ts @@ -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, + } +} diff --git a/zhixing-web/src/features/selection/components/selection-chart.test.tsx b/zhixing-web/src/features/selection/components/selection-chart.test.tsx new file mode 100644 index 0000000..2559af4 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart.test.tsx @@ -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() + + 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( + , + ) + expect(chartMocks.setOption).toHaveBeenCalledTimes(2) + + view.unmount() + expect(chartMocks.disconnect).toHaveBeenCalledTimes(1) + expect(chartMocks.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/zhixing-web/src/features/selection/components/selection-chart.tsx b/zhixing-web/src/features/selection/components/selection-chart.tsx new file mode 100644 index 0000000..feb1694 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart.tsx @@ -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(null) + const instanceRef = useRef(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 ( +
+ ) +} diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index 5b25772..cd23357 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -122,7 +122,7 @@ export function SelectionResultsWorkbench({ } return ( -
+
+ 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 (
+
+

+ 技术图表 +

+ {chartQuery.isPending ? ( +

正在加载行情图表…

+ ) : chartQuery.isError ? ( +

行情图表暂时不可用。

+ ) : chartQuery.data?.points.length ? ( + 正在加载图表组件…

+ } + > + +
+ ) : ( +

暂无可用行情数据。

+ )} +
+ + +

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() + + expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument() + expect(useSelectionChart).toHaveBeenLastCalledWith( + "000001.SZ", + "2026-08-08", + ) + + useSelectionChart.mockReturnValue({ + data: undefined, + isError: true, + isPending: false, + }) + view.rerender() + expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument() + }) + it("opens execution status details in a drawer and restores trigger focus", async () => { useSelectionResults.mockReturnValue({ data: {