feat(selection): show stock industry and concept boards in detail panel
Add point-in-time stock membership lookup to the sector radar module
(GET /sector-radar/stocks/{ts_code}/membership) reading the existing
dc_index/dc_member snapshots, and surface industries plus concept chips
in the selection signal detail panel with graceful no-data hiding.
This commit is contained in:
@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||
|
||||
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
|
||||
import {
|
||||
getSectorRadarDates,
|
||||
getSectorRadarRankings,
|
||||
getStockSectorMembership,
|
||||
} from "./sector-radar.api"
|
||||
|
||||
const publication = {
|
||||
publication_id: "publication-1",
|
||||
@@ -180,3 +184,59 @@ describe("sector radar API adapters", () => {
|
||||
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("stock sector membership adapter", () => {
|
||||
const membershipPayload = {
|
||||
status: "success",
|
||||
ts_code: "000001.SZ",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||
concepts: [
|
||||
{ code: "BK0001.DC", name: "机器人" },
|
||||
{ code: "BK0002.DC", name: "人工智能" },
|
||||
],
|
||||
concept_total: 2,
|
||||
concept_limit: 20,
|
||||
}
|
||||
|
||||
it("requests the snake-case endpoint and validates the payload", async () => {
|
||||
requestJson.mockResolvedValue(membershipPayload)
|
||||
|
||||
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
||||
|
||||
expect(requestJson).toHaveBeenCalledWith(
|
||||
"/api/v1/sector-radar/stocks/000001.SZ/membership?trade_date=2026-08-28",
|
||||
{ signal: undefined },
|
||||
)
|
||||
expect(result).toEqual(membershipPayload)
|
||||
})
|
||||
|
||||
it("keeps the no_data status with a null snapshot date", async () => {
|
||||
requestJson.mockResolvedValue({
|
||||
...membershipPayload,
|
||||
status: "no_data",
|
||||
trade_date: null,
|
||||
industries: [],
|
||||
concepts: [],
|
||||
concept_total: 0,
|
||||
})
|
||||
|
||||
const result = await getStockSectorMembership("000001.SZ", "2026-08-28")
|
||||
|
||||
expect(result.status).toBe("no_data")
|
||||
expect(result.trade_date).toBeNull()
|
||||
expect(result.industries).toEqual([])
|
||||
})
|
||||
|
||||
it("rejects a membership response with invalid sector references", async () => {
|
||||
requestJson.mockResolvedValue({
|
||||
...membershipPayload,
|
||||
industries: [{ code: "BK0901.DC", name: " " }],
|
||||
})
|
||||
|
||||
await expect(
|
||||
getStockSectorMembership("000001.SZ", "2026-08-28"),
|
||||
).rejects.toThrow("membership.industries[0].name must be a non-empty string")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type RadarRankingRow,
|
||||
type RadarRankingsQuery,
|
||||
type RadarRankingsResponse,
|
||||
type StockSectorMembershipResponse,
|
||||
type StockSectorRef,
|
||||
} from "./sector-radar.types"
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
@@ -145,6 +147,69 @@ export function parseRadarRankingsResponse(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one stock's industries and concepts from the last-good build snapshot.
|
||||
*
|
||||
* @param tsCode - Stock code such as `000001.SZ`.
|
||||
* @param tradeDate - Requested trade date; the backend answers with the
|
||||
* newest snapshot at or before it and reports that exact date.
|
||||
* @param signal - React Query cancellation signal forwarded to fetch.
|
||||
* @returns A validated membership response with grouped sector references.
|
||||
* @throws Error when the server response violates the stable HTTP contract.
|
||||
*/
|
||||
export async function getStockSectorMembership(
|
||||
tsCode: string,
|
||||
tradeDate: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const params = new URLSearchParams({ trade_date: tradeDate })
|
||||
const payload = await requestJson<unknown>(
|
||||
`/api/v1/sector-radar/stocks/${encodeURIComponent(tsCode)}/membership?${params.toString()}`,
|
||||
{ signal },
|
||||
)
|
||||
return parseStockSectorMembershipResponse(payload)
|
||||
}
|
||||
|
||||
/** Validate one stock-membership response at the feature boundary. */
|
||||
export function parseStockSectorMembershipResponse(
|
||||
value: unknown,
|
||||
): StockSectorMembershipResponse {
|
||||
const record = readRecord(value, "membership")
|
||||
return {
|
||||
status: readEnum(record.status, ["success", "no_data"], "membership.status"),
|
||||
ts_code: readNonEmptyString(record.ts_code, "membership.ts_code"),
|
||||
requested_trade_date: readDate(
|
||||
record.requested_trade_date,
|
||||
"membership.requested_trade_date",
|
||||
),
|
||||
trade_date: readNullableDate(record.trade_date, "membership.trade_date"),
|
||||
industries: readSectorRefs(record.industries, "membership.industries"),
|
||||
concepts: readSectorRefs(record.concepts, "membership.concepts"),
|
||||
concept_total: readIntegerInRange(
|
||||
record.concept_total,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
"membership.concept_total",
|
||||
),
|
||||
concept_limit: readIntegerInRange(
|
||||
record.concept_limit,
|
||||
1,
|
||||
100,
|
||||
"membership.concept_limit",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function readSectorRefs(value: unknown, path: string): StockSectorRef[] {
|
||||
return readArray(value, path).map((item, index) => {
|
||||
const ref = readRecord(item, `${path}[${index}]`)
|
||||
return {
|
||||
code: readNonEmptyString(ref.code, `${path}[${index}].code`),
|
||||
name: readNonEmptyString(ref.name, `${path}[${index}].name`),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readNullablePublication(
|
||||
value: unknown,
|
||||
path: string,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query"
|
||||
|
||||
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
|
||||
import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api"
|
||||
import type {
|
||||
RadarRankingsQuery,
|
||||
RadarRankingsResponse,
|
||||
@@ -12,6 +12,34 @@ import type {
|
||||
|
||||
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
||||
|
||||
export const stockSectorMembershipQueryKey = (
|
||||
tsCode?: string,
|
||||
tradeDate?: string,
|
||||
) =>
|
||||
[
|
||||
"sectorRadar",
|
||||
"stockMembership",
|
||||
tsCode ?? "none",
|
||||
tradeDate ?? "none",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Read one stock's point-in-time industries and concepts for a detail view.
|
||||
*
|
||||
* Disabled until both the stock code and target trade date are known; the
|
||||
* query stays cached per code-date pair so switching selections refetches
|
||||
* only unseen pairs.
|
||||
*/
|
||||
export function useStockSectorMembership(tsCode?: string, tradeDate?: string) {
|
||||
return useQuery({
|
||||
enabled: Boolean(tsCode && tradeDate),
|
||||
queryFn: ({ signal }) =>
|
||||
getStockSectorMembership(tsCode ?? "", tradeDate ?? "", signal),
|
||||
queryKey: stockSectorMembershipQueryKey(tsCode, tradeDate),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
|
||||
export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
|
||||
[
|
||||
"sectorRadar",
|
||||
|
||||
@@ -111,3 +111,19 @@ export interface RadarRankingsQuery {
|
||||
export interface SectorRadarRouteSearch extends RadarRankingsQuery {
|
||||
search: string
|
||||
}
|
||||
|
||||
export interface StockSectorRef {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StockSectorMembershipResponse {
|
||||
status: "success" | "no_data"
|
||||
ts_code: string
|
||||
requested_trade_date: string
|
||||
trade_date: string | null
|
||||
industries: StockSectorRef[]
|
||||
concepts: StockSectorRef[]
|
||||
concept_total: number
|
||||
concept_limit: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { SelectionStockResult } from "../api/selection.types"
|
||||
import { SignalDetailPanel } from "./signal-detail-panel"
|
||||
|
||||
const useSelectionChart = vi.fn()
|
||||
const useStockSectorMembership = vi.fn()
|
||||
|
||||
vi.mock("../api/selection.query", () => ({
|
||||
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||
useStockSectorMembership: (...args: unknown[]) =>
|
||||
useStockSectorMembership(...args),
|
||||
}))
|
||||
|
||||
vi.mock("./selection-chart", () => ({
|
||||
SelectionChart: () => <div>selection-chart</div>,
|
||||
}))
|
||||
|
||||
vi.mock("./pattern-case-image", () => ({
|
||||
PatternCaseImage: () => <div>pattern-case-image</div>,
|
||||
}))
|
||||
|
||||
const stock: SelectionStockResult = {
|
||||
close: 10.5,
|
||||
name: "平安银行",
|
||||
score: null,
|
||||
signals: [],
|
||||
strategy: "zhixing_b1",
|
||||
target_trade_date: "2026-08-28",
|
||||
ts_code: "000001.SZ",
|
||||
}
|
||||
|
||||
describe("SignalDetailPanel", () => {
|
||||
beforeEach(() => {
|
||||
useSelectionChart.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: true,
|
||||
})
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("shows the stock industry and concept boards from the membership query", () => {
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "success",
|
||||
ts_code: "000001.SZ",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
industries: [{ code: "BK0901.DC", name: "银行" }],
|
||||
concepts: [
|
||||
{ code: "BK0001.DC", name: "机器人" },
|
||||
{ code: "BK0002.DC", name: "人工智能" },
|
||||
],
|
||||
concept_total: 2,
|
||||
concept_limit: 20,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.getByText("行业:银行")).toBeInTheDocument()
|
||||
expect(screen.getByText("板块")).toBeInTheDocument()
|
||||
expect(screen.getByText("机器人")).toBeInTheDocument()
|
||||
expect(screen.getByText("人工智能")).toBeInTheDocument()
|
||||
expect(screen.queryByText(/等 \d+ 个/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("caps visible concepts and reports the total", () => {
|
||||
const concepts = Array.from({ length: 10 }, (_, index) => ({
|
||||
code: `BK${String(index + 1).padStart(4, "0")}.DC`,
|
||||
name: `板块${index + 1}`,
|
||||
}))
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "success",
|
||||
ts_code: "000001.SZ",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
industries: [],
|
||||
concepts,
|
||||
concept_total: 10,
|
||||
concept_limit: 20,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.getByText("板块1")).toBeInTheDocument()
|
||||
expect(screen.getByText("板块8")).toBeInTheDocument()
|
||||
expect(screen.queryByText("板块9")).not.toBeInTheDocument()
|
||||
expect(screen.getByText("等 10 个")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("hides the sector rows when the membership query returns no data", () => {
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: {
|
||||
status: "no_data",
|
||||
ts_code: "000001.SZ",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: null,
|
||||
industries: [],
|
||||
concepts: [],
|
||||
concept_total: 0,
|
||||
concept_limit: 20,
|
||||
},
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
render(<SignalDetailPanel stock={stock} />)
|
||||
|
||||
expect(screen.queryByText(/行业:/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("板块")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense } from "react"
|
||||
import { Search } from "lucide-react"
|
||||
import { useStockSectorMembership } from "@/features/sector-radar/api/sector-radar.query"
|
||||
import { useSelectionChart } from "../api/selection.query"
|
||||
|
||||
import { Card } from "@/shared/ui/card"
|
||||
@@ -18,6 +19,8 @@ const SelectionChart = lazy(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const displayedConceptCount = 8
|
||||
|
||||
interface SignalDetailPanelProps {
|
||||
stock: SelectionStockResult | undefined
|
||||
}
|
||||
@@ -28,6 +31,10 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
stock?.target_trade_date,
|
||||
stock?.strategy,
|
||||
)
|
||||
const membershipQuery = useStockSectorMembership(
|
||||
stock?.ts_code,
|
||||
stock?.target_trade_date,
|
||||
)
|
||||
|
||||
if (!stock) {
|
||||
return (
|
||||
@@ -63,6 +70,19 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
},
|
||||
]
|
||||
|
||||
const membership =
|
||||
membershipQuery.data?.status === "success" ? membershipQuery.data : undefined
|
||||
const industries = membership?.industries ?? []
|
||||
const concepts = membership?.concepts ?? []
|
||||
const extraConceptCount = Math.max(
|
||||
0,
|
||||
concepts.length - displayedConceptCount,
|
||||
)
|
||||
const sectorSnapshotTitle =
|
||||
membership?.trade_date != null
|
||||
? `板块成分截至 ${membership.trade_date}`
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label="当前股票详情"
|
||||
@@ -117,6 +137,33 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
|
||||
¥ {stock.close.toFixed(2)}
|
||||
</p>
|
||||
|
||||
{industries.length > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
行业:{industries.map((industry) => industry.name).join("、")}
|
||||
</p>
|
||||
)}
|
||||
{concepts.length > 0 && (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-1"
|
||||
title={sectorSnapshotTitle}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">板块</span>
|
||||
{concepts.slice(0, displayedConceptCount).map((concept) => (
|
||||
<span
|
||||
className="rounded bg-muted px-1.5 py-0.5 text-xs"
|
||||
key={concept.code}
|
||||
>
|
||||
{concept.name}
|
||||
</span>
|
||||
))}
|
||||
{extraConceptCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
等 {concepts.length} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="mt-3 divide-y divide-border/60 border-y border-border/60 text-xs">
|
||||
{metricRows.map((row) => (
|
||||
<div
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SelectionResultsPage } from "./selection-results-page"
|
||||
const routerNavigate = vi.hoisted(() => vi.fn())
|
||||
const useSelectionResults = vi.fn()
|
||||
const useSelectionChart = vi.fn()
|
||||
const useStockSectorMembership = vi.fn()
|
||||
const useSelectionRun = vi.fn()
|
||||
const useTriggerSelectionRun = vi.fn()
|
||||
const mutate = vi.fn()
|
||||
@@ -36,6 +37,11 @@ vi.mock("@/features/selection/api/selection.query", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||
useStockSectorMembership: (...args: unknown[]) =>
|
||||
useStockSectorMembership(...args),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useNavigate: () => routerNavigate,
|
||||
useSearch: () => ({
|
||||
@@ -157,6 +163,11 @@ describe("SelectionResultsPage", () => {
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useStockSectorMembership.mockReturnValue({
|
||||
data: undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
})
|
||||
useSelectionResults.mockReturnValue(selectionQueryResult(selectedResult))
|
||||
useSelectionRun.mockReturnValue(emptySelectionQuery())
|
||||
useTriggerSelectionRun.mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user