feat(selection): 补充策略执行结果查询链路
This commit is contained in:
@@ -13,16 +13,30 @@ import { Separator } from "@/shared/ui/separator"
|
||||
|
||||
interface HomeShellProps {
|
||||
children: React.ReactNode
|
||||
activeSection?: "home" | "selection"
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ label: "首页", icon: LayoutDashboard, active: true },
|
||||
{ label: "行情数据", icon: BarChart3, active: false },
|
||||
{ label: "同步任务", icon: ClipboardCheck, active: false },
|
||||
{ label: "选股策略", icon: BrainCircuit, active: false },
|
||||
{
|
||||
label: "首页",
|
||||
icon: LayoutDashboard,
|
||||
section: "home" as const,
|
||||
path: "/" as const,
|
||||
},
|
||||
{ label: "行情数据", icon: BarChart3, section: null, path: null },
|
||||
{ label: "同步任务", icon: ClipboardCheck, section: null, path: null },
|
||||
{
|
||||
label: "选股策略",
|
||||
icon: BrainCircuit,
|
||||
section: "selection" as const,
|
||||
path: "/selection" as const,
|
||||
},
|
||||
]
|
||||
|
||||
export function HomeShell({ children }: HomeShellProps) {
|
||||
export function HomeShell({
|
||||
activeSection = "home",
|
||||
children,
|
||||
}: HomeShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-muted/30 text-foreground">
|
||||
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
|
||||
@@ -43,16 +57,34 @@ export function HomeShell({ children }: HomeShellProps) {
|
||||
<nav aria-label="主导航" className="space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = item.section === activeSection
|
||||
const content = (
|
||||
<>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
</>
|
||||
)
|
||||
if (item.path) {
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
render={<a href={item.path} />}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={item.label}
|
||||
className="w-full justify-start gap-3 px-2 md:px-3"
|
||||
disabled={!item.active}
|
||||
variant={item.active ? "secondary" : "ghost"}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
disabled
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="hidden md:inline">{item.label}</span>
|
||||
{content}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -61,7 +61,10 @@ describe("HomePage", () => {
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
|
||||
expect(screen.getByRole("button", { name: "选股策略" })).toBeDisabled()
|
||||
expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
|
||||
"href",
|
||||
"/selection",
|
||||
)
|
||||
})
|
||||
|
||||
it("opens and closes the accessible failed-stock dialog", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function HomePage() {
|
||||
const update = overview.data?.latest_update
|
||||
|
||||
return (
|
||||
<HomeShell>
|
||||
<HomeShell activeSection="home">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { requestJson } from "@/shared/api/request-json"
|
||||
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionRunAccepted,
|
||||
SelectionRunRequest,
|
||||
SelectionStrategy,
|
||||
} from "./selection.types"
|
||||
|
||||
export function getSelectionResults(
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({ strategy })
|
||||
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
|
||||
return requestJson<SelectionResults>(
|
||||
`/api/v1/selection/results?${params.toString()}`,
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
export function getSelectionRun(runId: string, signal?: AbortSignal) {
|
||||
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, {
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export function triggerSelectionRun(
|
||||
request: SelectionRunRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestJson<SelectionRunAccepted>("/api/v1/selection/runs", {
|
||||
body: JSON.stringify(request),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
signal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import {
|
||||
getSelectionResults,
|
||||
getSelectionRun,
|
||||
triggerSelectionRun,
|
||||
} from "./selection.api"
|
||||
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types"
|
||||
|
||||
export const selectionResultsQueryKey = (
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
) => ["selection", "results", strategy, targetTradeDate] as const
|
||||
|
||||
export const selectionRunQueryKey = (runId: string) =>
|
||||
["selection", "runs", runId] as const
|
||||
|
||||
export function useSelectionResults(
|
||||
strategy: SelectionStrategy,
|
||||
targetTradeDate?: string,
|
||||
) {
|
||||
return useQuery({
|
||||
queryFn: ({ signal }) =>
|
||||
getSelectionResults(strategy, targetTradeDate, signal),
|
||||
queryKey: selectionResultsQueryKey(strategy, targetTradeDate),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSelectionRun(runId: string | null) {
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery({
|
||||
enabled: Boolean(runId),
|
||||
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal),
|
||||
queryKey: selectionRunQueryKey(runId ?? "none"),
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.status === "running" ? 1500 : false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const result = query.data
|
||||
if (!runId || !result || result.status === "running") return
|
||||
|
||||
void invalidateSelectionResults(queryClient, result.strategy)
|
||||
}, [query.data, queryClient, runId])
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function useTriggerSelectionRun() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
request,
|
||||
signal,
|
||||
}: {
|
||||
request: SelectionRunRequest
|
||||
signal?: AbortSignal
|
||||
}) => triggerSelectionRun(request, signal),
|
||||
onSuccess: (_accepted, { request }) => {
|
||||
void invalidateSelectionResults(queryClient, request.strategy)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function invalidateSelectionResults(
|
||||
queryClient: QueryClient,
|
||||
strategy: SelectionStrategy,
|
||||
) {
|
||||
return queryClient.invalidateQueries({
|
||||
queryKey: ["selection", "results", strategy],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type SelectionStrategy = "zhixing_b1"
|
||||
|
||||
export type SelectionRunStatus =
|
||||
"no_data" | "running" | "success" | "partial_success" | "failed"
|
||||
|
||||
export interface SelectionRunRequest {
|
||||
strategy: SelectionStrategy
|
||||
target_trade_date: string
|
||||
rerun: boolean
|
||||
}
|
||||
|
||||
export interface SelectionRunAccepted {
|
||||
run_id: string
|
||||
strategy: SelectionStrategy
|
||||
target_trade_date: string
|
||||
status: "running"
|
||||
}
|
||||
|
||||
export interface SelectionSignal {
|
||||
ts_code: string
|
||||
name: string
|
||||
target_trade_date: string
|
||||
strategy: SelectionStrategy
|
||||
category: string
|
||||
close: number
|
||||
details: Record<string, number | string | null>
|
||||
}
|
||||
|
||||
export interface SelectionFailure {
|
||||
ts_code: string
|
||||
name: string
|
||||
status: string
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export interface SelectionResults {
|
||||
strategy: SelectionStrategy
|
||||
target_trade_date: string | null
|
||||
run_id: string | null
|
||||
market_sync_batch_id: string | null
|
||||
status: SelectionRunStatus
|
||||
target_count: number
|
||||
eligible_count: number
|
||||
evaluated_count: number
|
||||
selected_stock_count: number
|
||||
signal_count: number
|
||||
failed_count: number
|
||||
coverage: number
|
||||
error_type: string | null
|
||||
error_message: string | null
|
||||
created_at: string | null
|
||||
finished_at: string | null
|
||||
failures: SelectionFailure[]
|
||||
signals: SelectionSignal[]
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { SelectionResults } from "../api/selection.types"
|
||||
|
||||
import { SelectionResultsPage } from "./selection-results-page"
|
||||
|
||||
const useSelectionResults = vi.fn()
|
||||
const useSelectionRun = vi.fn()
|
||||
const useTriggerSelectionRun = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
const reset = vi.fn()
|
||||
|
||||
vi.mock("@/features/selection/api/selection.query", () => ({
|
||||
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
|
||||
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
|
||||
useTriggerSelectionRun: () => useTriggerSelectionRun(),
|
||||
}))
|
||||
|
||||
const selectedResult: SelectionResults = {
|
||||
created_at: "2026-08-08T09:00:00+08:00",
|
||||
coverage: 1,
|
||||
eligible_count: 2,
|
||||
evaluated_count: 2,
|
||||
error_message: null,
|
||||
error_type: null,
|
||||
failed_count: 0,
|
||||
failures: [],
|
||||
finished_at: "2026-08-08T09:02:00+08:00",
|
||||
market_sync_batch_id: "market-run-1",
|
||||
run_id: "run-1",
|
||||
selected_stock_count: 1,
|
||||
signal_count: 2,
|
||||
signals: [
|
||||
{
|
||||
category: "zhixing_b1_original_b1",
|
||||
close: 10.5,
|
||||
details: { j: 12, rsi: 20 },
|
||||
name: "平安银行",
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
ts_code: "000001.SZ",
|
||||
},
|
||||
{
|
||||
category: "zhixing_b1_pullback_white",
|
||||
close: 10.5,
|
||||
details: { j: 12 },
|
||||
name: "平安银行",
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
ts_code: "000001.SZ",
|
||||
},
|
||||
],
|
||||
status: "success",
|
||||
strategy: "zhixing_b1",
|
||||
target_count: 2,
|
||||
target_trade_date: "2026-08-08",
|
||||
}
|
||||
|
||||
describe("SelectionResultsPage", () => {
|
||||
beforeEach(() => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: selectedResult,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionRun.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useTriggerSelectionRun.mockReturnValue({
|
||||
isError: false,
|
||||
isPending: false,
|
||||
mutate,
|
||||
reset,
|
||||
})
|
||||
mutate.mockReset()
|
||||
reset.mockReset()
|
||||
})
|
||||
|
||||
it("renders persisted results and keeps multiple categories visible", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "选股策略" }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
|
||||
expect(screen.getByText("原始 B1")).toBeInTheDocument()
|
||||
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
|
||||
expect(screen.getAllByText("平安银行")).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("requires confirmation before a rerun and does not trigger on cancel", () => {
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
|
||||
const dialog = screen.getByRole("dialog", { name: "确认重新执行策略?" })
|
||||
expect(within(dialog).getByText(/清空 2026-08-08/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "取消" }))
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
|
||||
).getByRole("button", { name: "确认重新执行" }),
|
||||
)
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
{
|
||||
request: {
|
||||
rerun: true,
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the same confirmation flow for a failed retry", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
failed_count: 1,
|
||||
failures: [
|
||||
{
|
||||
name: "平安银行",
|
||||
reason: "目标日数据读取失败",
|
||||
status: "data_error",
|
||||
ts_code: "000001.SZ",
|
||||
},
|
||||
],
|
||||
selected_stock_count: 0,
|
||||
signal_count: 0,
|
||||
signals: [],
|
||||
status: "failed",
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试执行" }))
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders a no-data state and triggers an initial run after date selection", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: {
|
||||
...selectedResult,
|
||||
run_id: null,
|
||||
signals: [],
|
||||
status: "no_data",
|
||||
target_trade_date: null,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
|
||||
fireEvent.change(screen.getByLabelText("目标交易日"), {
|
||||
target: { value: "2026-08-08" },
|
||||
})
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
{
|
||||
request: {
|
||||
rerun: false,
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-08",
|
||||
},
|
||||
},
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it("continues polling a persisted running run after page reload", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionRun.mockReturnValue({
|
||||
data: { ...selectedResult, signals: [], status: "running" },
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("策略执行中")).toBeInTheDocument()
|
||||
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
|
||||
expect(useSelectionRun).toHaveBeenCalledWith("run-1")
|
||||
})
|
||||
|
||||
it("renders query failures explicitly", () => {
|
||||
useSelectionResults.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SelectionResultsPage />)
|
||||
|
||||
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,516 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
useSelectionResults,
|
||||
useSelectionRun,
|
||||
useTriggerSelectionRun,
|
||||
} from "@/features/selection/api/selection.query"
|
||||
import type {
|
||||
SelectionResults,
|
||||
SelectionRunStatus,
|
||||
SelectionSignal,
|
||||
} from "@/features/selection/api/selection.types"
|
||||
import { Badge } from "@/shared/ui/badge"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { HomeShell } from "@/features/home/components/home-shell"
|
||||
|
||||
const STRATEGY = "zhixing_b1" as const
|
||||
|
||||
const statusPresentation: Record<
|
||||
Exclude<SelectionRunStatus, "no_data" | "running">,
|
||||
{ label: string; variant: "default" | "destructive" | "outline" }
|
||||
> = {
|
||||
failed: { label: "执行失败", variant: "destructive" },
|
||||
partial_success: { label: "部分成功", variant: "outline" },
|
||||
success: { label: "执行完成", variant: "default" },
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
zhixing_b1_extreme_volume: "超卖超缩量 B",
|
||||
zhixing_b1_original_b1: "原始 B1",
|
||||
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
|
||||
zhixing_b1_oversold_volume: "超卖缩量 B",
|
||||
zhixing_b1_pullback_super: "回踩超级 B",
|
||||
zhixing_b1_pullback_white: "回踩白线 B",
|
||||
zhixing_b1_pullback_yellow: "回踩黄线 B",
|
||||
}
|
||||
|
||||
export function SelectionResultsPage() {
|
||||
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null)
|
||||
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
|
||||
|
||||
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined)
|
||||
const persistedRunningRunId =
|
||||
results.data?.status === "running" ? results.data.run_id : null
|
||||
const polledRunId = activeRunId ?? persistedRunningRunId
|
||||
const activeRun = useSelectionRun(polledRunId)
|
||||
const trigger = useTriggerSelectionRun()
|
||||
|
||||
const selectedTargetTradeDate =
|
||||
targetTradeDate ?? results.data?.target_trade_date ?? ""
|
||||
|
||||
const selectedDateResult =
|
||||
targetTradeDate &&
|
||||
results.data?.target_trade_date !== selectedTargetTradeDate
|
||||
? undefined
|
||||
: results.data
|
||||
const displayedResult = activeRun.data ?? selectedDateResult
|
||||
const isRunning =
|
||||
trigger.isPending ||
|
||||
results.data?.status === "running" ||
|
||||
activeRun.data?.status === "running" ||
|
||||
(polledRunId !== null && activeRun.isPending)
|
||||
const hasExistingResult = Boolean(
|
||||
displayedResult?.run_id && displayedResult.status !== "no_data",
|
||||
)
|
||||
const hasQueryError =
|
||||
activeRunId !== null ? activeRun.isError : results.isError
|
||||
|
||||
function handleExecute() {
|
||||
if (!selectedTargetTradeDate || isRunning) return
|
||||
if (hasExistingResult) {
|
||||
setRerunDialogOpen(true)
|
||||
return
|
||||
}
|
||||
startExecution(false)
|
||||
}
|
||||
|
||||
function startExecution(rerun: boolean) {
|
||||
if (!selectedTargetTradeDate) return
|
||||
setRerunDialogOpen(false)
|
||||
setActiveRunId(null)
|
||||
trigger.mutate(
|
||||
{
|
||||
request: {
|
||||
strategy: STRATEGY,
|
||||
target_trade_date: selectedTargetTradeDate,
|
||||
rerun,
|
||||
},
|
||||
},
|
||||
{ onSuccess: (accepted) => setActiveRunId(accepted.run_id) },
|
||||
)
|
||||
}
|
||||
|
||||
function handleDateChange(value: string) {
|
||||
setTargetTradeDate(value)
|
||||
setActiveRunId(null)
|
||||
trigger.reset()
|
||||
}
|
||||
|
||||
return (
|
||||
<HomeShell activeSection="selection">
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-primary">研究策略执行</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
|
||||
<p className="max-w-2xl text-muted-foreground">
|
||||
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">执行条件</CardTitle>
|
||||
<CardDescription>
|
||||
策略只使用已完成且达到选股资格的市场数据同步批次。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="target-trade-date"
|
||||
>
|
||||
目标交易日
|
||||
<Input
|
||||
id="target-trade-date"
|
||||
onChange={(event) => handleDateChange(event.target.value)}
|
||||
type="date"
|
||||
value={selectedTargetTradeDate}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
className="flex-1 space-y-2 text-sm font-medium"
|
||||
htmlFor="strategy-name"
|
||||
>
|
||||
策略
|
||||
<select
|
||||
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
disabled
|
||||
id="strategy-name"
|
||||
value={STRATEGY}
|
||||
>
|
||||
<option value={STRATEGY}>知行 B1</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={!selectedTargetTradeDate || isRunning}
|
||||
onClick={handleExecute}
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw aria-hidden="true" className="animate-spin" />
|
||||
) : hasExistingResult ? (
|
||||
<RefreshCw aria-hidden="true" />
|
||||
) : (
|
||||
<Play aria-hidden="true" />
|
||||
)}
|
||||
{isRunning
|
||||
? "执行中"
|
||||
: hasExistingResult
|
||||
? displayedResult?.status === "failed"
|
||||
? "重试执行"
|
||||
: "重新执行"
|
||||
: "执行策略"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
{trigger.isError ? (
|
||||
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
|
||||
执行请求失败,请确认市场数据状态后重试。
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{isRunning ? <RunningState /> : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
results.isPending &&
|
||||
!displayedResult ? (
|
||||
<LoadingState />
|
||||
) : null}
|
||||
{!isRunning && hasQueryError ? <QueryError /> : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult?.status === "no_data" ? (
|
||||
<NoDataState />
|
||||
) : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult?.status === "failed" ? (
|
||||
<FailureState result={displayedResult} />
|
||||
) : null}
|
||||
{!isRunning &&
|
||||
!hasQueryError &&
|
||||
displayedResult &&
|
||||
displayedResult.status !== "no_data" &&
|
||||
displayedResult.status !== "failed" ? (
|
||||
<ResultState result={displayedResult} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{rerunDialogOpen ? (
|
||||
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认重新执行策略?</DialogTitle>
|
||||
<DialogDescription>
|
||||
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
|
||||
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setRerunDialogOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => startExecution(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
确认重新执行
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</HomeShell>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<Card aria-label="正在加载策略执行结果">
|
||||
<CardHeader className="gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-72 max-w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RunningState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className="size-5 animate-spin text-primary"
|
||||
/>
|
||||
策略执行中
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
正在逐只评估当前有效股票池,页面会自动刷新执行状态。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryError() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="size-5 text-destructive"
|
||||
/>
|
||||
结果查询失败
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
无法读取策略执行结果,请稍后刷新页面重试。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function NoDataState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<Search aria-hidden="true" className="size-5 text-primary" />
|
||||
暂无策略结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
请选择目标交易日并执行知行 B1,完成后这里会显示持久化结果。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FailureState({ result }: { result: SelectionResults }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="size-5 text-destructive"
|
||||
/>
|
||||
策略执行失败
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{result.error_message ??
|
||||
"本次执行没有形成可用的策略结果,请确认后重试。"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
目标交易日:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.target_trade_date}
|
||||
</span>
|
||||
,失败股票:
|
||||
<span className="font-medium text-foreground">
|
||||
{result.failed_count}
|
||||
</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultState({ result }: { result: SelectionResults }) {
|
||||
const presentation =
|
||||
result.status === "success" ||
|
||||
result.status === "partial_success" ||
|
||||
result.status === "failed"
|
||||
? statusPresentation[result.status]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<CheckCircle2
|
||||
aria-hidden="true"
|
||||
className="size-5 text-primary"
|
||||
/>
|
||||
知行 B1 执行结果
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日:{result.target_trade_date} · 市场同步批次:
|
||||
{result.market_sync_batch_id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{presentation ? (
|
||||
<Badge variant={presentation.variant}>{presentation.label}</Badge>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric label="目标股票数" value={result.target_count} />
|
||||
<Metric label="实际参与数" value={result.eligible_count} />
|
||||
<Metric label="命中股票数" value={result.selected_stock_count} />
|
||||
<Metric label="信号条数" value={result.signal_count} />
|
||||
<Metric
|
||||
label="数据覆盖率"
|
||||
value={`${(result.coverage * 100).toFixed(2)}%`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{result.signal_count === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
|
||||
<CardDescription>
|
||||
目标交易日数据完整,但没有符合公式条件的股票。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
<SignalTable signals={result.signals} />
|
||||
)}
|
||||
|
||||
{result.failures.length > 0 ? <FailureList result={result} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">命中信号明细</CardTitle>
|
||||
<CardDescription>
|
||||
同一股票同日的多个子信号分别保留,不合并分类。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="border-b text-muted-foreground">
|
||||
<tr>
|
||||
<th className="pb-3 pr-4 font-medium">股票</th>
|
||||
<th className="pb-3 pr-4 font-medium">子信号</th>
|
||||
<th className="pb-3 pr-4 font-medium">收盘价</th>
|
||||
<th className="pb-3 font-medium">关键详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.map((signal) => (
|
||||
<tr
|
||||
key={`${signal.ts_code}-${signal.category}`}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<p className="font-medium">{signal.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{signal.ts_code}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant="outline">
|
||||
{categoryLabels[signal.category] ?? signal.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4 tabular-nums">
|
||||
{signal.close.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">
|
||||
{formatDetails(signal.details)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FailureList({ result }: { result: SelectionResults }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">未完成评估</CardTitle>
|
||||
<CardDescription>这些股票未形成完整的策略评估结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{result.failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.ts_code}-${failure.status}`}
|
||||
className="rounded-lg border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-medium">{failure.name}</p>
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{failure.ts_code}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{failure.status}
|
||||
</span>
|
||||
</div>
|
||||
{failure.reason ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{failure.reason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDetails(details: SelectionSignal["details"]) {
|
||||
return Object.entries(details)
|
||||
.map(([key, value]) => `${key}: ${value ?? "—"}`)
|
||||
.join(" · ")
|
||||
}
|
||||
Reference in New Issue
Block a user