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