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
@@ -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>