feat(selection): 集成 B1 FastDTW 图形评分

This commit is contained in:
yuxuanhui
2026-08-31 16:14:16 +08:00
parent 86762c0d9a
commit 6ce291e242
51 changed files with 2917 additions and 41 deletions
@@ -17,6 +17,7 @@ describe("selection API adapters", () => {
page: 2,
pageSize: 15,
search: "平安银行",
sort: "score_desc",
})
const [input, init] = requestJson.mock.calls[0] as [
@@ -30,6 +31,7 @@ describe("selection API adapters", () => {
expect(params.get("page_size")).toBe("15")
expect(params.get("search")).toBe("平安银行")
expect(params.get("category")).toBe("pullback")
expect(params.get("sort")).toBe("score_desc")
expect(init).toEqual({ signal: undefined })
})
@@ -43,5 +45,6 @@ describe("selection API adapters", () => {
expect(params.get("page_size")).toBe("5")
expect(params.has("search")).toBe(false)
expect(params.has("category")).toBe(false)
expect(params.has("sort")).toBe(false)
})
})
@@ -44,6 +44,7 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
})
if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category)
if (query.sort) params.set("sort", query.sort)
return params
}
@@ -31,6 +31,7 @@ export const selectionResultsQueryKey = (
query.pageSize,
query.search ?? "",
query.category ?? "all",
query.sort ?? "code",
] as const
export const selectionRunQueryKey = (
@@ -45,6 +46,7 @@ export const selectionRunQueryKey = (
query.pageSize,
query.search ?? "",
query.category ?? "all",
query.sort ?? "code",
] as const
export function useSelectionResults(
@@ -10,11 +10,16 @@ export const selectionCategoryFilters = [
"original",
] as const
export type SelectionSort = "code" | "score_desc" | "score_asc"
export const selectionSorts = ["code", "score_desc", "score_asc"] as const
export interface SelectionResultsQuery {
page: number
pageSize: number
search?: string
category?: Exclude<SelectionCategoryFilter, "all">
sort?: SelectionSort
}
export type SelectionRunStatus =
@@ -49,9 +54,33 @@ export interface SelectionStockResult {
target_trade_date: string
strategy: SelectionStrategy
close: number
score: SelectionPatternScore | null
signals: SelectionSignal[]
}
export interface SelectionPatternScore {
status: "matched" | "below_threshold" | "failed"
value: number | null
threshold: number | null
version: string | null
case: SelectionPatternCase | null
breakdown: SelectionPatternBreakdown | null
reason: string | null
}
export interface SelectionPatternCase {
id: string
name: string
breakout_date: string
}
export interface SelectionPatternBreakdown {
trend_structure: number
kdj_state: number
volume_pattern: number
price_shape: number
}
export interface SelectionFailure {
ts_code: string
name: string
@@ -0,0 +1,99 @@
import { Badge } from "@/shared/ui/badge"
import type { SelectionPatternScore } from "../api/selection.types"
interface PatternScoreProps {
score: SelectionPatternScore | null
}
const breakdownLabels = {
trend_structure: "趋势",
kdj_state: "KDJ",
volume_pattern: "量能",
price_shape: "价格形态",
} as const
export function PatternScoreSummary({ score }: PatternScoreProps) {
if (!score) return <span className="text-muted-foreground">未评分</span>
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">
{formatScore(score.value)} 分
</strong>
<span className="block text-[10px] text-muted-foreground">
{score.case?.name ?? "最佳案例未知"}
</span>
</span>
)
}
export function PatternScoreDetails({ score }: PatternScoreProps) {
if (!score) {
return (
<p className="text-xs text-muted-foreground">本次运行未执行图形评分。</p>
)
}
if (score.status === "failed") {
return (
<div className="space-y-1">
<Badge variant="destructive">评分暂不可用</Badge>
<p className="text-xs text-muted-foreground">
{score.reason || "案例库或评分计算暂时不可用。"}
</p>
</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">
<div>
<p className="text-2xl font-semibold tabular-nums">
{formatScore(score.value)} 分
</p>
<p className="text-xs text-muted-foreground">
最佳案例:{score.case?.name ?? "未知"}
</p>
</div>
<Badge variant="default">已匹配</Badge>
</div>
{score.breakdown ? (
<dl className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(breakdownLabels).map(([key, label]) => (
<div key={key} className="rounded-md border border-border/60 p-2">
<dt className="text-muted-foreground">{label}</dt>
<dd className="mt-1 font-semibold tabular-nums">
{formatScore(
score.breakdown?.[key as keyof typeof breakdownLabels],
)}
</dd>
</div>
))}
</dl>
) : null}
</div>
)
}
function formatScore(value: number | null | undefined) {
return value === null || value === undefined ? "—" : value.toFixed(2)
}
@@ -14,6 +14,7 @@ import {
import type {
SelectionResults,
SelectionSort,
SelectionStockResult,
} from "../api/selection.types"
import { ExecutionStatusTrigger } from "./execution-status-trigger"
@@ -28,6 +29,14 @@ import {
import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const
const SCORE_SORT_OPTIONS: ReadonlyArray<{
label: string
value: SelectionSort
}> = [
{ label: "按股票代码", value: "code" },
{ label: "评分从高到低", value: "score_desc" },
{ label: "评分从低到高", value: "score_asc" },
]
interface SelectionResultsWorkbenchProps {
drawerOpen: boolean
@@ -46,6 +55,7 @@ export function SelectionResultsWorkbench({
const navigate = useNavigate({ from: "/selection" })
const query = search.search ?? ""
const category = search.category ?? "all"
const sort = search.sort ?? "code"
const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(),
@@ -84,6 +94,7 @@ export function SelectionResultsWorkbench({
pageSize?: number
search?: string
category?: SignalCategoryFilter
sort?: SelectionSort
}) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
@@ -96,6 +107,10 @@ export function SelectionResultsWorkbench({
updateSearch({ category: value, page: 1 })
}
function handleSortChange(value: SelectionSort) {
updateSearch({ page: 1, sort: value })
}
function handleToggleExpanded(stock: SelectionStockResult) {
const key = getStockKey(stock)
setExpandedKeys((previous) => {
@@ -143,6 +158,31 @@ export function SelectionResultsWorkbench({
</SelectGroup>
</SelectContent>
</Select>
<Select
items={SCORE_SORT_OPTIONS}
onValueChange={(value) => {
if (typeof value === "string") {
handleSortChange(value as SelectionSort)
}
}}
value={sort}
>
<SelectTrigger
aria-label="排序命中股票"
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
>
<SelectValue placeholder="排序命中股票" />
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false}>
<SelectGroup>
{SCORE_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
筛选结果 {stocksTotal} 只
</span>
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card"
import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails } from "./pattern-score"
import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
import { SignalDetails } from "./signal-details"
@@ -84,6 +85,16 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
</div>
</dl>
<section className="mt-4" aria-labelledby="pattern-score-details">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="pattern-score-details"
>
图形相似度评分
</h3>
<PatternScoreDetails score={stock.score} />
</section>
<section className="mt-4" aria-labelledby="signal-detail-metrics">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
import {
categoryToneClass,
getCategoryLabel,
@@ -57,6 +58,9 @@ export function SignalRecordList({
¥ {stock.close.toFixed(2)}
</span>
</button>
<div className="text-xs">
<PatternScoreSummary score={stock.score} />
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap gap-1">
{stock.signals.map((signal) => (
@@ -95,6 +99,12 @@ export function SignalRecordList({
</div>
{expanded ? (
<div className="space-y-3">
<section className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
图形相似度评分
</p>
<PatternScoreDetails score={stock.score} />
</section>
{stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
@@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge"
import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreSummary } from "./pattern-score"
import {
categoryToneClass,
getCategoryLabel,
@@ -38,6 +39,7 @@ export function SignalTable({
<tr className="border-b border-border/70">
<th className="h-9 px-3 font-medium">股票</th>
<th className="h-9 px-3 font-medium">信号类型</th>
<th className="h-9 px-3 text-right font-medium">图形评分</th>
<th className="h-9 px-3 text-right font-medium">J 值</th>
<th className="h-9 px-3 text-right font-medium">收盘价</th>
</tr>
@@ -75,6 +77,9 @@ export function SignalTable({
))}
</div>
</td>
<td className="px-3 text-right align-middle">
<PatternScoreSummary score={stock.score} />
</td>
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
{stock.signals.map((signal) => (
<span key={signal.category} className="block">
@@ -92,7 +97,7 @@ export function SignalTable({
<tr>
<td
className="h-32 px-3 text-center text-sm text-muted-foreground"
colSpan={4}
colSpan={5}
>
没有符合当前筛选条件的信号。
</td>
@@ -7,7 +7,10 @@ import {
} from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionResults } from "../api/selection.types"
import type {
SelectionPatternScore,
SelectionResults,
} from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
@@ -26,7 +29,13 @@ vi.mock("@/features/selection/api/selection.query", () => ({
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }),
useSearch: () => ({
page: 1,
pageSize: 5,
search: "",
category: "all",
sort: "code",
}),
}))
const selectedResult: SelectionResults = {
@@ -50,6 +59,24 @@ const selectedResult: SelectionResults = {
{
close: 10.5,
name: "平安银行",
score: {
breakdown: {
kdj_state: 83,
price_shape: 90.1,
trend_structure: 71.2,
volume_pattern: 88,
},
case: {
breakout_date: "2025-05-12",
id: "case_001",
name: "华纳药厂",
},
reason: null,
status: "matched",
threshold: 60,
value: 86.4,
version: "zhixing_b1_pattern_fastdtw_v1",
},
signals: [
{
category: "zhixing_b1_original_b1",
@@ -117,6 +144,9 @@ describe("SelectionResultsPage", () => {
expect(
within(table).getByRole("columnheader", { name: "J 值" }),
).toBeInTheDocument()
expect(
within(table).getByRole("columnheader", { name: "图形评分" }),
).toBeInTheDocument()
expect(
within(table).queryByRole("columnheader", { name: "关键详情" }),
).not.toBeInTheDocument()
@@ -125,15 +155,75 @@ describe("SelectionResultsPage", () => {
expect(within(table).getByText("12")).toBeInTheDocument()
expect(within(table).getByText("13")).toBeInTheDocument()
expect(within(table).getByText("平安银行")).toBeInTheDocument()
expect(within(table).getByText("86.40 分")).toBeInTheDocument()
expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
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()
})
it.each<{
expected: string
score: SelectionPatternScore | null
}>([
{
expected: "本次运行未执行图形评分。",
score: null,
},
{
expected: "未匹配到 60.00 分以上案例。",
score: {
breakdown: null,
case: null,
reason: "未匹配到评分阈值以上案例",
status: "below_threshold",
threshold: 60,
value: null,
version: "zhixing_b1_pattern_fastdtw_v1",
},
},
{
expected: "评分暂不可用",
score: {
breakdown: null,
case: null,
reason: "FastDTW unavailable",
status: "failed",
threshold: null,
value: null,
version: null,
},
},
])(
"keeps selection signals visible when score state changes",
({ expected, score }) => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
stocks: [{ ...selectedResult.stocks[0], score }],
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getAllByText(expected).length).toBeGreaterThan(0)
const table = screen.getByRole("table")
expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("12")).toBeInTheDocument()
},
)
it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({
data: {
@@ -328,6 +418,42 @@ describe("SelectionResultsPage", () => {
})
})
it("requests database-backed score sorting and resets the page", async () => {
render(<SelectionResultsPage />)
const sortTrigger = screen.getByRole("combobox", {
name: "排序命中股票",
})
fireEvent.click(sortTrigger)
const option = await screen.findByRole("option", { name: "评分从高到低" })
fireEvent.pointerDown(option, { pointerType: "mouse" })
fireEvent.click(option)
const lastCall = routerNavigate.mock.calls.at(-1)
const searchUpdate = lastCall?.[0].search as (previous: {
category: string
page: number
pageSize: number
search: string
sort: string
}) => Record<string, unknown>
expect(
searchUpdate({
category: "all",
page: 3,
pageSize: 5,
search: "",
sort: "code",
}),
).toEqual({
category: "all",
page: 1,
pageSize: 5,
search: "",
sort: "score_desc",
})
})
it("filters signals, updates the detail panel, and expands mobile details", () => {
const extraStock = {
...selectedResult.stocks[0],
@@ -536,6 +662,7 @@ describe("SelectionResultsPage", () => {
expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1,
pageSize: 5,
sort: "code",
})
})
@@ -61,6 +61,7 @@ export function SelectionResultsPage() {
pageSize: search.pageSize,
...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}),
sort: search.sort,
}
const results = useSelectionResults(
+9 -1
View File
@@ -5,7 +5,9 @@ import { ComponentsPreviewPage } from "@/features/components/pages/components-pr
import { HomePage } from "@/features/home/pages/home-page"
import {
selectionCategoryFilters,
selectionSorts,
type SelectionCategoryFilter,
type SelectionSort,
} from "@/features/selection/api/selection.types"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
import { SyncPage } from "@/features/sync/pages/sync-page"
@@ -44,7 +46,13 @@ const selectionRoute = createRoute({
)
? (rawCategory as SelectionCategoryFilter)
: "all"
return { page, pageSize, search: searchValue, category }
const rawSort = typeof search.sort === "string" ? search.sort : "code"
const sort: SelectionSort = selectionSorts.includes(
rawSort as SelectionSort,
)
? (rawSort as SelectionSort)
: "code"
return { page, pageSize, search: searchValue, category, sort }
},
component: SelectionResultsPage,
})