feat(selection): 集成 B1 FastDTW 图形评分
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user