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:
yuxuanhui
2026-09-05 16:23:50 +08:00
parent 50cac3575a
commit 41a9b4eb9a
14 changed files with 825 additions and 5 deletions
@@ -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
}