feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities. - Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights. - Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure. - Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members. - Updated tests to validate the new functionality and ensure data integrity across various scenarios.
This commit is contained in:
@@ -5,6 +5,9 @@ const requestJson = vi.hoisted(() => vi.fn())
|
||||
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
|
||||
|
||||
import {
|
||||
getSectorRadarHistory,
|
||||
getSectorRadarDetail,
|
||||
parseRadarDetailResponse,
|
||||
getSectorRadarDates,
|
||||
getSectorRadarRankings,
|
||||
getStockSectorMembership,
|
||||
@@ -69,11 +72,106 @@ const rankingPayload = {
|
||||
],
|
||||
}
|
||||
|
||||
const historyMetric = {
|
||||
rank_position: 1,
|
||||
rank_percentile: "100",
|
||||
pool_size: 10,
|
||||
metric_value: "0.125",
|
||||
missing: false,
|
||||
in_top: true,
|
||||
in_bottom: false,
|
||||
}
|
||||
const historyPayload = {
|
||||
status: "success",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
sector_type: "concept",
|
||||
sector_code: "BK01",
|
||||
sector_name: "机器人",
|
||||
publication,
|
||||
window_size: 30,
|
||||
available_days: 1,
|
||||
points: [
|
||||
{
|
||||
trade_date: "2026-08-28",
|
||||
publication_id: "publication-1",
|
||||
swing: historyMetric,
|
||||
ratio: historyMetric,
|
||||
amount: historyMetric,
|
||||
},
|
||||
],
|
||||
}
|
||||
const detailPayload = {
|
||||
...historyPayload,
|
||||
pct_change: "-1.25",
|
||||
leading_stock: { ts_code: "000001.SZ", name: null },
|
||||
summary: {
|
||||
swing: historyMetric,
|
||||
ratio: historyMetric,
|
||||
amount: historyMetric,
|
||||
},
|
||||
history: historyPayload,
|
||||
members: [
|
||||
{
|
||||
ts_code: "000001.SZ",
|
||||
name: "测试成员",
|
||||
pct_change: "-1.5",
|
||||
net_amount_yuan: "120000000",
|
||||
active_buy_net_amount_yuan: null,
|
||||
},
|
||||
],
|
||||
leaders: {
|
||||
pct_change: { top: [], bottom: [] },
|
||||
net_amount_yuan: { top: [], bottom: [] },
|
||||
active_buy_net_amount_yuan: { top: [], bottom: [] },
|
||||
},
|
||||
similar_sectors: [],
|
||||
}
|
||||
|
||||
describe("sector radar API adapters", () => {
|
||||
beforeEach(() => {
|
||||
requestJson.mockReset()
|
||||
})
|
||||
|
||||
it("reads persisted sector resources and preserves nullable independent metrics", async () => {
|
||||
const signal = new AbortController().signal
|
||||
const query = {
|
||||
sectorType: "concept" as const,
|
||||
sectorCode: "BK01",
|
||||
tradeDate: "2026-08-28",
|
||||
}
|
||||
requestJson.mockResolvedValue(historyPayload)
|
||||
expect(
|
||||
(await getSectorRadarHistory(query, signal)).points[0]?.ratio
|
||||
.metric_value,
|
||||
).toBe(0.125)
|
||||
expect(requestJson).toHaveBeenLastCalledWith(
|
||||
"/api/v1/sector-radar/sectors/concept/BK01/history?trade_date=2026-08-28",
|
||||
{ signal },
|
||||
)
|
||||
requestJson.mockResolvedValue(detailPayload)
|
||||
const detail = await getSectorRadarDetail(query, signal)
|
||||
expect(requestJson).toHaveBeenLastCalledWith(
|
||||
"/api/v1/sector-radar/sectors/concept/BK01/detail?trade_date=2026-08-28",
|
||||
{ signal },
|
||||
)
|
||||
expect(detail.pct_change).toBe(-1.25)
|
||||
expect(detail.leading_stock?.name).toBeNull()
|
||||
expect(detail.members[0]).toMatchObject({
|
||||
net_amount_yuan: 120000000,
|
||||
active_buy_net_amount_yuan: null,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(["NaN", "Infinity", "0x10", "", true])(
|
||||
"rejects malformed detail Decimal %s",
|
||||
(value) => {
|
||||
expect(() =>
|
||||
parseRadarDetailResponse({ ...detailPayload, pct_change: value }),
|
||||
).toThrow("detail.pct_change")
|
||||
},
|
||||
)
|
||||
|
||||
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
|
||||
const signal = new AbortController().signal
|
||||
requestJson.mockResolvedValue({
|
||||
@@ -237,6 +335,8 @@ describe("stock sector membership adapter", () => {
|
||||
|
||||
await expect(
|
||||
getStockSectorMembership("000001.SZ", "2026-08-28"),
|
||||
).rejects.toThrow("membership.industries[0].name must be a non-empty string")
|
||||
).rejects.toThrow(
|
||||
"membership.industries[0].name must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
radarViews,
|
||||
sectorTypes,
|
||||
type RadarDatesResponse,
|
||||
type RadarSectorQuery,
|
||||
type RadarHistoryResponse,
|
||||
type RadarHistoryMetric,
|
||||
type RadarDetailResponse,
|
||||
type RadarMember,
|
||||
type RadarMetricDefinition,
|
||||
type RadarPublication,
|
||||
type RadarRankingRow,
|
||||
@@ -176,7 +181,11 @@ export function parseStockSectorMembershipResponse(
|
||||
): StockSectorMembershipResponse {
|
||||
const record = readRecord(value, "membership")
|
||||
return {
|
||||
status: readEnum(record.status, ["success", "no_data"], "membership.status"),
|
||||
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,
|
||||
@@ -309,6 +318,33 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
|
||||
record.metric_value,
|
||||
`${path}.metric_value`,
|
||||
),
|
||||
pct_change: readNullableFiniteNumber(
|
||||
record.pct_change ?? null,
|
||||
`${path}.pct_change`,
|
||||
),
|
||||
daily_net_amount_yuan: readNullableFiniteNumber(
|
||||
record.daily_net_amount_yuan ?? null,
|
||||
`${path}.daily_net_amount_yuan`,
|
||||
),
|
||||
daily_ratio: readNullableFiniteNumber(
|
||||
record.daily_ratio ?? null,
|
||||
`${path}.daily_ratio`,
|
||||
),
|
||||
on_list_count:
|
||||
record.on_list_count == null
|
||||
? null
|
||||
: readIntegerInRange(
|
||||
record.on_list_count,
|
||||
0,
|
||||
30,
|
||||
`${path}.on_list_count`,
|
||||
),
|
||||
history_available_days: readIntegerInRange(
|
||||
record.history_available_days ?? 0,
|
||||
0,
|
||||
30,
|
||||
`${path}.history_available_days`,
|
||||
),
|
||||
quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`),
|
||||
member_count: readIntegerInRange(
|
||||
record.member_count,
|
||||
@@ -427,7 +463,8 @@ function readFiniteNumber(value: unknown, path: string): number {
|
||||
const number =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim().length > 0
|
||||
: typeof value === "string" &&
|
||||
/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())
|
||||
? Number(value)
|
||||
: Number.NaN
|
||||
if (!Number.isFinite(number)) throw contractError(path, "must be finite")
|
||||
@@ -483,3 +520,240 @@ function readNullablePositiveNumber(
|
||||
function contractError(path: string, reason: string): Error {
|
||||
return new Error(`Invalid sector radar response: ${path} ${reason}`)
|
||||
}
|
||||
|
||||
/** Fetch persisted history for a sector/date; forward cancellation and reject malformed decimals. */
|
||||
export async function getSectorRadarHistory(
|
||||
query: RadarSectorQuery,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const payload = await requestJson<unknown>(
|
||||
sectorResourceUrl(query, "history"),
|
||||
{ signal },
|
||||
)
|
||||
return parseRadarHistoryResponse(payload)
|
||||
}
|
||||
|
||||
/** Fetch persisted detail on demand; no supplier calls originate from the browser. */
|
||||
export async function getSectorRadarDetail(
|
||||
query: RadarSectorQuery,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const payload = await requestJson<unknown>(
|
||||
sectorResourceUrl(query, "detail"),
|
||||
{ signal },
|
||||
)
|
||||
return parseRadarDetailResponse(payload)
|
||||
}
|
||||
|
||||
function sectorResourceUrl(
|
||||
query: RadarSectorQuery,
|
||||
resource: "history" | "detail",
|
||||
) {
|
||||
const params = new URLSearchParams({ trade_date: query.tradeDate })
|
||||
return `/api/v1/sector-radar/sectors/${query.sectorType}/${encodeURIComponent(query.sectorCode)}/${resource}?${params}`
|
||||
}
|
||||
|
||||
function readSectorIdentity(record: JsonRecord, path: string) {
|
||||
return {
|
||||
status: readEnum(record.status, ["success", "no_data"], `${path}.status`),
|
||||
requested_trade_date: readNullableDate(
|
||||
record.requested_trade_date,
|
||||
`${path}.requested_trade_date`,
|
||||
),
|
||||
trade_date: readNullableDate(record.trade_date, `${path}.trade_date`),
|
||||
publication: readNullablePublication(
|
||||
record.publication,
|
||||
`${path}.publication`,
|
||||
),
|
||||
sector_type: readEnum(
|
||||
record.sector_type,
|
||||
sectorTypes,
|
||||
`${path}.sector_type`,
|
||||
),
|
||||
sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`),
|
||||
sector_name: readNullableString(record.sector_name, `${path}.sector_name`),
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize the finite Decimal fields while preserving missing ranks as null. */
|
||||
export function parseRadarHistoryResponse(
|
||||
value: unknown,
|
||||
): RadarHistoryResponse {
|
||||
const record = readRecord(value, "history")
|
||||
return {
|
||||
...readSectorIdentity(record, "history"),
|
||||
window_size: readIntegerInRange(
|
||||
record.window_size,
|
||||
1,
|
||||
30,
|
||||
"history.window_size",
|
||||
),
|
||||
available_days: readIntegerInRange(
|
||||
record.available_days,
|
||||
0,
|
||||
30,
|
||||
"history.available_days",
|
||||
),
|
||||
points: readArray(record.points, "history.points").map((value, index) => {
|
||||
const path = `history.points[${index}]`
|
||||
const point = readRecord(value, path)
|
||||
return {
|
||||
trade_date: readDate(point.trade_date, `${path}.trade_date`),
|
||||
publication_id: readNullableString(
|
||||
point.publication_id,
|
||||
`${path}.publication_id`,
|
||||
),
|
||||
...readMetricSummary(point, path),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function readMetricSummary(record: JsonRecord, path: string) {
|
||||
return {
|
||||
amount: readHistoryMetric(record.amount, `${path}.amount`),
|
||||
ratio: readHistoryMetric(record.ratio, `${path}.ratio`),
|
||||
swing: readHistoryMetric(record.swing, `${path}.swing`),
|
||||
}
|
||||
}
|
||||
|
||||
function readHistoryMetric(value: unknown, path: string): RadarHistoryMetric {
|
||||
const record = readRecord(value, path)
|
||||
return {
|
||||
rank_position: readNullableInteger(
|
||||
record.rank_position,
|
||||
1,
|
||||
`${path}.rank_position`,
|
||||
),
|
||||
rank_percentile: readNullablePositiveNumber(
|
||||
record.rank_percentile,
|
||||
100,
|
||||
`${path}.rank_percentile`,
|
||||
),
|
||||
pool_size: readIntegerInRange(
|
||||
record.pool_size,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
`${path}.pool_size`,
|
||||
),
|
||||
metric_value: readNullableFiniteNumber(
|
||||
record.metric_value,
|
||||
`${path}.metric_value`,
|
||||
),
|
||||
missing: readBoolean(record.missing, `${path}.missing`),
|
||||
in_top: readBoolean(record.in_top, `${path}.in_top`),
|
||||
in_bottom: readBoolean(record.in_bottom, `${path}.in_bottom`),
|
||||
}
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw contractError(path, "must be a boolean")
|
||||
return value
|
||||
}
|
||||
|
||||
function readMembers(value: unknown, path: string): RadarMember[] {
|
||||
return readArray(value, path).map((value, index) => {
|
||||
const itemPath = `${path}[${index}]`
|
||||
const record = readRecord(value, itemPath)
|
||||
return {
|
||||
ts_code: readNonEmptyString(record.ts_code, `${itemPath}.ts_code`),
|
||||
name: readNonEmptyString(record.name, `${itemPath}.name`),
|
||||
pct_change: readNullableFiniteNumber(
|
||||
record.pct_change,
|
||||
`${itemPath}.pct_change`,
|
||||
),
|
||||
net_amount_yuan: readNullableFiniteNumber(
|
||||
record.net_amount_yuan,
|
||||
`${itemPath}.net_amount_yuan`,
|
||||
),
|
||||
active_buy_net_amount_yuan: readNullableFiniteNumber(
|
||||
record.active_buy_net_amount_yuan,
|
||||
`${itemPath}.active_buy_net_amount_yuan`,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Validate the full detail payload, including independent member metrics and overlaps. */
|
||||
export function parseRadarDetailResponse(value: unknown): RadarDetailResponse {
|
||||
const record = readRecord(value, "detail")
|
||||
const leading =
|
||||
record.leading_stock === null
|
||||
? null
|
||||
: readRecord(record.leading_stock, "detail.leading_stock")
|
||||
const leaders = readRecord(record.leaders, "detail.leaders")
|
||||
function readLeaders(key: string) {
|
||||
const path = `detail.leaders.${key}`
|
||||
const group = readRecord(leaders[key], path)
|
||||
return {
|
||||
top: readMembers(group.top, `${path}.top`),
|
||||
bottom: readMembers(group.bottom, `${path}.bottom`),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...readSectorIdentity(record, "detail"),
|
||||
pct_change: readNullableFiniteNumber(
|
||||
record.pct_change,
|
||||
"detail.pct_change",
|
||||
),
|
||||
leading_stock:
|
||||
leading === null
|
||||
? null
|
||||
: {
|
||||
ts_code: readNonEmptyString(
|
||||
leading.ts_code,
|
||||
"detail.leading_stock.ts_code",
|
||||
),
|
||||
name: readNullableString(leading.name, "detail.leading_stock.name"),
|
||||
},
|
||||
summary: readMetricSummary(
|
||||
readRecord(record.summary, "detail.summary"),
|
||||
"detail.summary",
|
||||
),
|
||||
history: parseRadarHistoryResponse(record.history),
|
||||
members: readMembers(record.members, "detail.members"),
|
||||
leaders: {
|
||||
pct_change: readLeaders("pct_change"),
|
||||
net_amount_yuan: readLeaders("net_amount_yuan"),
|
||||
active_buy_net_amount_yuan: readLeaders("active_buy_net_amount_yuan"),
|
||||
},
|
||||
similar_sectors: readArray(
|
||||
record.similar_sectors,
|
||||
"detail.similar_sectors",
|
||||
).map((value, index) => {
|
||||
const path = `detail.similar_sectors[${index}]`
|
||||
const sector = readRecord(value, path)
|
||||
return {
|
||||
sector_type: readEnum(
|
||||
sector.sector_type,
|
||||
sectorTypes,
|
||||
`${path}.sector_type`,
|
||||
),
|
||||
sector_code: readNonEmptyString(
|
||||
sector.sector_code,
|
||||
`${path}.sector_code`,
|
||||
),
|
||||
sector_name: readNonEmptyString(
|
||||
sector.sector_name,
|
||||
`${path}.sector_name`,
|
||||
),
|
||||
overlap_ratio: readFraction(
|
||||
sector.overlap_ratio,
|
||||
`${path}.overlap_ratio`,
|
||||
),
|
||||
intersection_count: readIntegerInRange(
|
||||
sector.intersection_count,
|
||||
0,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
`${path}.intersection_count`,
|
||||
),
|
||||
union_count: readIntegerInRange(
|
||||
sector.union_count,
|
||||
1,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
`${path}.union_count`,
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
const useQuery = vi.hoisted(() => vi.fn())
|
||||
const useInfiniteQuery = vi.hoisted(() => vi.fn())
|
||||
const api = vi.hoisted(() => ({
|
||||
getSectorRadarHistory: vi.fn(),
|
||||
getSectorRadarDetail: vi.fn(),
|
||||
getSectorRadarDates: vi.fn(),
|
||||
getSectorRadarRankings: vi.fn(),
|
||||
}))
|
||||
@@ -11,6 +13,9 @@ vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
|
||||
vi.mock("./sector-radar.api", () => api)
|
||||
|
||||
import {
|
||||
sectorRadarSectorQueryKey,
|
||||
useSectorRadarDetail,
|
||||
useSectorRadarHistory,
|
||||
sectorRadarDatesQueryKey,
|
||||
sectorRadarRankingsQueryKey,
|
||||
useSectorRadarDates,
|
||||
@@ -36,6 +41,42 @@ describe("sector radar query hooks", () => {
|
||||
useInfiniteQuery.mockImplementation((options) => options)
|
||||
})
|
||||
|
||||
it("isolates sector resources by type, code and date and forwards cancellation", () => {
|
||||
const sector = {
|
||||
sectorType: "concept" as const,
|
||||
sectorCode: "BK01",
|
||||
tradeDate: "2026-08-28",
|
||||
}
|
||||
const signal = new AbortController().signal
|
||||
useSectorRadarDetail(sector)
|
||||
const options = useQuery.mock.calls.at(-1)?.[0]
|
||||
expect(options.queryKey).toEqual([
|
||||
"sectorRadar",
|
||||
"detail",
|
||||
"concept",
|
||||
"BK01",
|
||||
"2026-08-28",
|
||||
])
|
||||
expect(options.placeholderData).toBeUndefined()
|
||||
options.queryFn({ signal })
|
||||
expect(api.getSectorRadarDetail).toHaveBeenCalledWith(sector, signal)
|
||||
expect(
|
||||
sectorRadarSectorQueryKey("detail", {
|
||||
...sector,
|
||||
tradeDate: "2026-08-31",
|
||||
}),
|
||||
).not.toEqual(options.queryKey)
|
||||
expect(
|
||||
sectorRadarSectorQueryKey("detail", {
|
||||
...sector,
|
||||
sectorType: "industry",
|
||||
}),
|
||||
).not.toEqual(options.queryKey)
|
||||
useSectorRadarHistory(sector)
|
||||
useQuery.mock.calls.at(-1)?.[0].queryFn({ signal })
|
||||
expect(api.getSectorRadarHistory).toHaveBeenCalledWith(sector, signal)
|
||||
})
|
||||
|
||||
it("keeps filters in one ranking key without splitting cached pages", () => {
|
||||
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
|
||||
expect(sectorRadarRankingsQueryKey(query)).toEqual([
|
||||
|
||||
@@ -4,10 +4,17 @@ import {
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query"
|
||||
|
||||
import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api"
|
||||
import {
|
||||
getSectorRadarDates,
|
||||
getSectorRadarRankings,
|
||||
getStockSectorMembership,
|
||||
getSectorRadarHistory,
|
||||
getSectorRadarDetail,
|
||||
} from "./sector-radar.api"
|
||||
import type {
|
||||
RadarRankingsQuery,
|
||||
RadarRankingsResponse,
|
||||
RadarSectorQuery,
|
||||
} from "./sector-radar.types"
|
||||
|
||||
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
|
||||
@@ -92,3 +99,31 @@ export function useSectorRadarRankings(query: RadarRankingsQuery) {
|
||||
queryKey: sectorRadarRankingsQueryKey(query),
|
||||
})
|
||||
}
|
||||
|
||||
export const sectorRadarSectorQueryKey = (
|
||||
resource: "history" | "detail",
|
||||
query: RadarSectorQuery,
|
||||
) =>
|
||||
[
|
||||
"sectorRadar",
|
||||
resource,
|
||||
query.sectorType,
|
||||
query.sectorCode,
|
||||
query.tradeDate,
|
||||
] as const
|
||||
|
||||
/** Keep on-demand history isolated by sector type, code and requested date. */
|
||||
export function useSectorRadarHistory(query: RadarSectorQuery) {
|
||||
return useQuery({
|
||||
queryKey: sectorRadarSectorQueryKey("history", query),
|
||||
queryFn: ({ signal }) => getSectorRadarHistory(query, signal),
|
||||
})
|
||||
}
|
||||
|
||||
/** Mount only while the detail dialog is open; never reuse another date as placeholder data. */
|
||||
export function useSectorRadarDetail(query: RadarSectorQuery) {
|
||||
return useQuery({
|
||||
queryKey: sectorRadarSectorQueryKey("detail", query),
|
||||
queryFn: ({ signal }) => getSectorRadarDetail(query, signal),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ export interface RadarRankingRow {
|
||||
rank_percentile: number | null
|
||||
rank_change_days: number
|
||||
rank_change: number | null
|
||||
pct_change: number | null
|
||||
daily_net_amount_yuan: number | null
|
||||
daily_ratio: number | null
|
||||
on_list_count: number | null
|
||||
history_available_days: number
|
||||
}
|
||||
|
||||
export interface RadarRankingsResponse {
|
||||
@@ -127,3 +132,68 @@ export interface StockSectorMembershipResponse {
|
||||
concept_total: number
|
||||
concept_limit: number
|
||||
}
|
||||
|
||||
export interface RadarSectorQuery {
|
||||
sectorType: SectorType
|
||||
sectorCode: string
|
||||
tradeDate: string
|
||||
}
|
||||
|
||||
export interface RadarHistoryMetric {
|
||||
rank_position: number | null
|
||||
rank_percentile: number | null
|
||||
pool_size: number
|
||||
metric_value: number | null
|
||||
missing: boolean
|
||||
in_top: boolean
|
||||
in_bottom: boolean
|
||||
}
|
||||
|
||||
export type RadarMetricSummary = Record<RadarMetricKind, RadarHistoryMetric>
|
||||
export interface RadarHistoryPoint extends RadarMetricSummary {
|
||||
trade_date: string
|
||||
publication_id: string | null
|
||||
}
|
||||
export interface RadarHistoryResponse {
|
||||
status: "success" | "no_data"
|
||||
requested_trade_date: string | null
|
||||
trade_date: string | null
|
||||
publication: RadarPublication | null
|
||||
sector_type: SectorType
|
||||
sector_code: string
|
||||
sector_name: string | null
|
||||
points: RadarHistoryPoint[]
|
||||
window_size: number
|
||||
available_days: number
|
||||
}
|
||||
export interface RadarMember {
|
||||
ts_code: string
|
||||
name: string
|
||||
pct_change: number | null
|
||||
net_amount_yuan: number | null
|
||||
active_buy_net_amount_yuan: number | null
|
||||
}
|
||||
export type RadarMemberMetric =
|
||||
"pct_change" | "net_amount_yuan" | "active_buy_net_amount_yuan"
|
||||
export interface RadarDetailResponse extends Omit<
|
||||
RadarHistoryResponse,
|
||||
"points" | "window_size" | "available_days"
|
||||
> {
|
||||
pct_change: number | null
|
||||
leading_stock: { ts_code: string; name: string | null } | null
|
||||
summary: RadarMetricSummary
|
||||
history: RadarHistoryResponse
|
||||
members: RadarMember[]
|
||||
leaders: Record<
|
||||
RadarMemberMetric,
|
||||
{ top: RadarMember[]; bottom: RadarMember[] }
|
||||
>
|
||||
similar_sectors: {
|
||||
sector_type: SectorType
|
||||
sector_code: string
|
||||
sector_name: string
|
||||
overlap_ratio: number
|
||||
intersection_count: number
|
||||
union_count: number
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { lazy, Suspense, useState } from "react"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { useSectorRadarDetail } from "../api/sector-radar.query"
|
||||
import type {
|
||||
RadarDetailResponse,
|
||||
RadarMemberMetric,
|
||||
RadarMetricKind,
|
||||
RadarRankingRow,
|
||||
RadarSectorQuery,
|
||||
} from "../api/sector-radar.types"
|
||||
import {
|
||||
formatRadarValue,
|
||||
radarMembersCsv,
|
||||
radarValueTone,
|
||||
} from "./radar-format"
|
||||
import { RadarHistoryGrid } from "./radar-history"
|
||||
const RadarRankChart = lazy(() =>
|
||||
import("./radar-rank-chart").then((module) => ({
|
||||
default: module.RadarRankChart,
|
||||
})),
|
||||
)
|
||||
|
||||
const metricLabels = {
|
||||
swing: "波段资金率",
|
||||
ratio: "单日流入率",
|
||||
amount: "单日净额",
|
||||
} as const
|
||||
const memberLabels = {
|
||||
pct_change: "涨跌幅",
|
||||
net_amount_yuan: "主力净额",
|
||||
active_buy_net_amount_yuan: "主买净额",
|
||||
} as const
|
||||
|
||||
/** Keep the trigger inside the dialog primitive so Escape and close restore its focus. */
|
||||
export function RadarDetailDialog({ row }: { row: RadarRankingRow }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline">
|
||||
{row.sector_name}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
|
||||
<DialogTitle>{row.sector_name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
|
||||
· {row.trade_date}
|
||||
</DialogDescription>
|
||||
{open ? (
|
||||
<RadarDetail
|
||||
initialMetric={row.metric_kind}
|
||||
query={{
|
||||
sectorType: row.sector_type,
|
||||
sectorCode: row.sector_code,
|
||||
tradeDate: row.trade_date,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function RadarDetail({
|
||||
query,
|
||||
initialMetric,
|
||||
}: {
|
||||
query: RadarSectorQuery
|
||||
initialMetric: RadarMetricKind
|
||||
}) {
|
||||
const detail = useSectorRadarDetail(query)
|
||||
if (detail.isPending) return <p role="status">正在加载板块详情…</p>
|
||||
if (detail.isError)
|
||||
return (
|
||||
<div role="alert">
|
||||
板块详情加载失败。
|
||||
<Button onClick={() => void detail.refetch()} variant="outline">
|
||||
重试详情
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
if (!detail.data || detail.data.status === "no_data")
|
||||
return <p>暂无板块详情数据。</p>
|
||||
return <RadarDetailBody data={detail.data} initialMetric={initialMetric} />
|
||||
}
|
||||
|
||||
function RadarDetailBody({
|
||||
data,
|
||||
initialMetric,
|
||||
}: {
|
||||
data: RadarDetailResponse
|
||||
initialMetric: RadarMetricKind
|
||||
}) {
|
||||
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
|
||||
const [memberMetric, setMemberMetric] =
|
||||
useState<RadarMemberMetric>("pct_change")
|
||||
const [memberSide, setMemberSide] = useState<"top" | "bottom">("top")
|
||||
const [exportStatus, setExportStatus] = useState("")
|
||||
const leaders = data.leaders[memberMetric]
|
||||
async function copyMembers() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
data.members
|
||||
.map((member) => `${member.ts_code}\t${member.name}`)
|
||||
.join("\n"),
|
||||
)
|
||||
setExportStatus(`已复制 ${data.members.length} 只成员。`)
|
||||
} catch {
|
||||
setExportStatus("复制失败,请使用导出成员。")
|
||||
}
|
||||
}
|
||||
function exportMembers() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob(["\ufeff", radarMembersCsv(data.members)], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
}),
|
||||
)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = `${data.sector_code}-${data.trade_date}-members.csv`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
setExportStatus(`已导出 ${data.members.length} 只成员。`)
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.members.length}
|
||||
onClick={() => void copyMembers()}
|
||||
>
|
||||
复制成员
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data.members.length}
|
||||
onClick={exportMembers}
|
||||
>
|
||||
导出成员
|
||||
</Button>
|
||||
</div>
|
||||
{exportStatus ? <p role="status">{exportStatus}</p> : null}
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/50 p-3">
|
||||
<p>实际交易日:{data.trade_date ?? "—"}</p>
|
||||
<p>
|
||||
当日涨跌幅:
|
||||
<span className={radarValueTone(data.pct_change)}>
|
||||
{formatRadarValue(data.pct_change, "percent")}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
领涨股:
|
||||
{data.leading_stock
|
||||
? `${data.leading_stock.name ?? ""} ${data.leading_stock.ts_code}`
|
||||
: "暂无数据"}
|
||||
</p>
|
||||
</div>
|
||||
<section aria-label="三指标排名" className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["swing", "ratio", "amount"] as const).map((key) => (
|
||||
<button
|
||||
type="button"
|
||||
key={key}
|
||||
onClick={() => setMetric(key)}
|
||||
aria-pressed={metric === key}
|
||||
className={`min-w-0 rounded-md border p-2 text-left break-words sm:p-3 ${metric === key ? "border-primary bg-primary/5" : "border-border"}`}
|
||||
>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{metricLabels[key]}
|
||||
</span>
|
||||
<span className="mt-1 block font-semibold">
|
||||
{data.summary[key].missing ||
|
||||
data.summary[key].rank_position === null
|
||||
? "暂无排名"
|
||||
: `第 ${data.summary[key].rank_position} 名 / ${data.summary[key].pool_size}`}
|
||||
</span>
|
||||
<span className={radarValueTone(data.summary[key].metric_value)}>
|
||||
{formatRadarValue(
|
||||
data.summary[key].metric_value,
|
||||
key === "amount" ? "CNY_100M" : "ratio",
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{data.history.points.some(
|
||||
(point) =>
|
||||
!point[metric].missing && point[metric].rank_position !== null,
|
||||
) ? (
|
||||
<Suspense fallback={<p role="status">正在加载排名曲线…</p>}>
|
||||
<RadarRankChart
|
||||
points={data.history.points}
|
||||
metric={metric}
|
||||
label={metricLabels[metric]}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<p className="rounded-md bg-muted/30 p-4 text-muted-foreground">
|
||||
暂无{metricLabels[metric]}排名轨迹,当前历史记录均缺失。
|
||||
</p>
|
||||
)}
|
||||
<RadarHistoryGrid history={data.history} metric={metric} />
|
||||
</section>
|
||||
<section aria-label="成分强弱" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="mr-auto font-medium">成分强弱</h3>
|
||||
{(Object.keys(memberLabels) as RadarMemberMetric[]).map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={key === memberMetric ? "default" : "outline"}
|
||||
size="sm"
|
||||
aria-pressed={key === memberMetric}
|
||||
onClick={() => setMemberMetric(key)}
|
||||
>
|
||||
{memberLabels[key]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div aria-label="成分排名方向" className="flex justify-end gap-1">
|
||||
{(["top", "bottom"] as const).map((side) => (
|
||||
<Button
|
||||
key={side}
|
||||
size="sm"
|
||||
variant={memberSide === side ? "default" : "outline"}
|
||||
aria-pressed={memberSide === side}
|
||||
onClick={() => setMemberSide(side)}
|
||||
>
|
||||
{side === "top" ? "前 5" : "后 5"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{[memberSide].map((side) => (
|
||||
<div key={side} className="rounded-md border border-border p-3">
|
||||
<h4 className="mb-2 font-medium">
|
||||
{side === "top" ? "前 5 名" : "后 5 名"}
|
||||
</h4>
|
||||
{leaders[side].length ? (
|
||||
<ol className="space-y-2">
|
||||
{leaders[side].map((member) => (
|
||||
<li
|
||||
key={member.ts_code}
|
||||
className="flex justify-between gap-3"
|
||||
>
|
||||
<span>
|
||||
{member.name}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{member.ts_code}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`tabular-nums ${radarValueTone(member[memberMetric])}`}
|
||||
>
|
||||
{formatRadarValue(
|
||||
member[memberMetric],
|
||||
memberMetric === "pct_change" ? "percent" : "yuan",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无有效数据</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
仅展示有该指标的成员,不足 5
|
||||
只按实际数量显示。主买净额与主力净额为不同来源指标。
|
||||
</p>
|
||||
</section>
|
||||
<section aria-label="相似板块" className="space-y-2">
|
||||
<h3 className="font-medium">相似板块</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
本系统口径:同日可确认成员交集 / 并集(Jaccard 重合度)。
|
||||
</p>
|
||||
{data.similar_sectors.length ? (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{data.similar_sectors.map((sector) => (
|
||||
<div
|
||||
className="flex justify-between rounded-md border border-border p-3"
|
||||
key={`${sector.sector_type}:${sector.sector_code}`}
|
||||
>
|
||||
<span>
|
||||
{sector.sector_name}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sector.sector_type === "concept" ? "概念" : "行业"}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{(sector.overlap_ratio * 100).toFixed(1)}%{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({sector.intersection_count}/{sector.union_count})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">暂无相似板块数据</p>
|
||||
)}
|
||||
</section>
|
||||
<section aria-label="板块成员" className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="mr-auto font-medium">
|
||||
板块成员({data.members.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="max-h-44 overflow-auto rounded-md border border-border p-3">
|
||||
<p className="leading-7 text-muted-foreground">
|
||||
{data.members.length
|
||||
? data.members
|
||||
.map((member) => `${member.name} (${member.ts_code})`)
|
||||
.join("、")
|
||||
: "暂无成员数据"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import type {
|
||||
RadarDetailResponse,
|
||||
RadarRankingRow,
|
||||
} from "../api/sector-radar.types"
|
||||
import { RadarDetailDialog } from "./radar-detail-dialog"
|
||||
import { RadarHistoryGrid } from "./radar-history"
|
||||
import {
|
||||
formatRadarValue,
|
||||
radarMembersCsv,
|
||||
radarValueTone,
|
||||
} from "./radar-format"
|
||||
import { buildRadarRankChartOption } from "./radar-rank-chart-option"
|
||||
|
||||
const detailQuery = vi.hoisted(() => vi.fn())
|
||||
vi.mock("../api/sector-radar.query", () => ({
|
||||
useSectorRadarDetail: detailQuery,
|
||||
}))
|
||||
vi.mock("./radar-rank-chart", () => ({
|
||||
RadarRankChart: ({ label }: { label: string }) => (
|
||||
<div role="img" aria-label={label} />
|
||||
),
|
||||
}))
|
||||
const metric = {
|
||||
rank_position: 2,
|
||||
rank_percentile: 90,
|
||||
pool_size: 20,
|
||||
metric_value: 0.02,
|
||||
missing: false,
|
||||
in_top: true,
|
||||
in_bottom: false,
|
||||
}
|
||||
const absent = {
|
||||
...metric,
|
||||
rank_position: null,
|
||||
metric_value: null,
|
||||
missing: true,
|
||||
in_top: false,
|
||||
}
|
||||
const member = {
|
||||
ts_code: "000001.SZ",
|
||||
name: "测试成员",
|
||||
pct_change: -1.2,
|
||||
net_amount_yuan: 2e8,
|
||||
active_buy_net_amount_yuan: null,
|
||||
}
|
||||
const data: RadarDetailResponse = {
|
||||
status: "success",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
publication: null,
|
||||
sector_type: "concept",
|
||||
sector_code: "BK01",
|
||||
sector_name: "机器人",
|
||||
pct_change: -2,
|
||||
leading_stock: { ts_code: "000001.SZ", name: null },
|
||||
summary: {
|
||||
swing: metric,
|
||||
ratio: metric,
|
||||
amount: { ...metric, metric_value: 2 },
|
||||
},
|
||||
history: {
|
||||
status: "success",
|
||||
requested_trade_date: "2026-08-28",
|
||||
trade_date: "2026-08-28",
|
||||
publication: null,
|
||||
sector_type: "concept",
|
||||
sector_code: "BK01",
|
||||
sector_name: "机器人",
|
||||
window_size: 30,
|
||||
available_days: 1,
|
||||
points: [
|
||||
{
|
||||
trade_date: "2026-08-28",
|
||||
publication_id: "p1",
|
||||
swing: metric,
|
||||
ratio: metric,
|
||||
amount: metric,
|
||||
},
|
||||
{
|
||||
trade_date: "2026-08-27",
|
||||
publication_id: null,
|
||||
swing: absent,
|
||||
ratio: absent,
|
||||
amount: absent,
|
||||
},
|
||||
],
|
||||
},
|
||||
members: [member],
|
||||
leaders: {
|
||||
pct_change: { top: [member], bottom: [member] },
|
||||
net_amount_yuan: { top: [member], bottom: [member] },
|
||||
active_buy_net_amount_yuan: { top: [], bottom: [] },
|
||||
},
|
||||
similar_sectors: [],
|
||||
}
|
||||
const row: RadarRankingRow = {
|
||||
trade_date: "2026-08-28",
|
||||
sector_type: "concept",
|
||||
sector_code: "BK01",
|
||||
sector_name: "机器人",
|
||||
metric_kind: "ratio",
|
||||
metric_version: "v1",
|
||||
implementation_kind: "independent",
|
||||
unit: "ratio",
|
||||
metric_value: 0.02,
|
||||
quality: "available",
|
||||
member_count: 1,
|
||||
valid_sample_count: 1,
|
||||
membership_coverage: 1,
|
||||
moneyflow_coverage: 1,
|
||||
rank_position: 2,
|
||||
rank_percentile: 90,
|
||||
rank_change_days: 1,
|
||||
rank_change: null,
|
||||
pct_change: -2,
|
||||
daily_net_amount_yuan: 2e8,
|
||||
daily_ratio: 0.02,
|
||||
on_list_count: 1,
|
||||
history_available_days: 1,
|
||||
}
|
||||
|
||||
describe("radar detail interactions", () => {
|
||||
beforeEach(() => {
|
||||
detailQuery.mockReset()
|
||||
detailQuery.mockReturnValue({ data, isPending: false, isError: false })
|
||||
})
|
||||
it("loads only after opening, switches both metrics and copies only returned members", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(<RadarDetailDialog row={row} />)
|
||||
expect(detailQuery).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
expect(detailQuery).toHaveBeenCalledWith({
|
||||
sectorType: "concept",
|
||||
sectorCode: "BK01",
|
||||
tradeDate: "2026-08-28",
|
||||
})
|
||||
expect(within(dialog).getByText("当日涨跌幅:")).toHaveTextContent("-2%")
|
||||
expect(within(dialog).getByText(/领涨股:/)).toHaveTextContent("000001.SZ")
|
||||
expect(
|
||||
await within(dialog).findByRole("img", { name: "单日流入率" }),
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /单日净额/ }))
|
||||
expect(
|
||||
within(dialog).getByRole("img", { name: "单日净额" }),
|
||||
).toBeInTheDocument()
|
||||
const strength = within(dialog).getByRole("region", { name: "成分强弱" })
|
||||
expect(within(strength).getAllByRole("listitem")).toHaveLength(1)
|
||||
fireEvent.click(within(strength).getByRole("button", { name: "后 5" }))
|
||||
expect(
|
||||
within(strength).getByRole("heading", { name: "后 5 名" }),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(strength).getByRole("button", { name: "后 5" }),
|
||||
).toHaveAttribute("aria-pressed", "true")
|
||||
fireEvent.click(within(strength).getByRole("button", { name: "主买净额" }))
|
||||
expect(within(strength).getAllByText("暂无有效数据")).toHaveLength(1)
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "复制成员" }))
|
||||
await waitFor(() =>
|
||||
expect(writeText).toHaveBeenCalledWith("000001.SZ\t测试成员"),
|
||||
)
|
||||
expect(await screen.findByText("已复制 1 只成员。")).toBeInTheDocument()
|
||||
})
|
||||
it("starts with the clicked ranking metric and labels entirely missing histories", async () => {
|
||||
detailQuery.mockReturnValue({
|
||||
data: {
|
||||
...data,
|
||||
history: {
|
||||
...data.history,
|
||||
points: data.history.points.map((point) => ({
|
||||
...point,
|
||||
amount: absent,
|
||||
})),
|
||||
},
|
||||
},
|
||||
isPending: false,
|
||||
isError: false,
|
||||
})
|
||||
render(<RadarDetailDialog row={{ ...row, metric_kind: "amount" }} />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
||||
const dialog = await screen.findByRole("dialog")
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: /单日净额/ }),
|
||||
).toHaveAttribute("aria-pressed", "true")
|
||||
expect(
|
||||
within(dialog).getByText("暂无单日净额排名轨迹,当前历史记录均缺失。"),
|
||||
).toBeInTheDocument()
|
||||
expect(within(dialog).queryByRole("img")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("exports the API member CSV and releases its object URL", async () => {
|
||||
const create = vi.fn().mockReturnValue("blob:members")
|
||||
const revoke = vi.fn()
|
||||
Object.defineProperty(URL, "createObjectURL", {
|
||||
configurable: true,
|
||||
value: create,
|
||||
})
|
||||
Object.defineProperty(URL, "revokeObjectURL", {
|
||||
configurable: true,
|
||||
value: revoke,
|
||||
})
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => undefined)
|
||||
render(<RadarDetailDialog row={row} />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
||||
await screen.findByRole("dialog")
|
||||
fireEvent.click(screen.getByRole("button", { name: "导出成员" }))
|
||||
expect(create).toHaveBeenCalledWith(expect.any(Blob))
|
||||
const blob = create.mock.calls[0]?.[0] as Blob
|
||||
const text = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.readAsText(blob)
|
||||
})
|
||||
expect(text).toContain('"000001.SZ","测试成员","-1.2","200000000",""')
|
||||
expect(click.mock.instances[0]).toHaveAttribute(
|
||||
"download",
|
||||
"BK01-2026-08-28-members.csv",
|
||||
)
|
||||
expect(revoke).toHaveBeenCalledWith("blob:members")
|
||||
click.mockRestore()
|
||||
})
|
||||
|
||||
it("closes with Escape and restores focus to the trigger", async () => {
|
||||
render(<RadarDetailDialog row={row} />)
|
||||
const trigger = screen.getByRole("button", { name: "机器人" })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
await screen.findByRole("dialog")
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, {
|
||||
key: "Escape",
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
|
||||
)
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
it("reports pending, error and unavailable detail without inventing values", async () => {
|
||||
detailQuery.mockReturnValue({ isPending: true })
|
||||
const { rerender } = render(<RadarDetailDialog row={row} />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "机器人" }))
|
||||
expect(await screen.findByRole("status")).toHaveTextContent(
|
||||
"正在加载板块详情",
|
||||
)
|
||||
const retry = vi.fn()
|
||||
detailQuery.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: true,
|
||||
refetch: retry,
|
||||
})
|
||||
rerender(<RadarDetailDialog row={row} />)
|
||||
fireEvent.click(screen.getByRole("button", { name: "重试详情" }))
|
||||
expect(retry).toHaveBeenCalledOnce()
|
||||
detailQuery.mockReturnValue({
|
||||
data: { status: "no_data" },
|
||||
isPending: false,
|
||||
isError: false,
|
||||
})
|
||||
rerender(<RadarDetailDialog row={row} />)
|
||||
expect(screen.getByText("暂无板块详情数据。")).toBeInTheDocument()
|
||||
})
|
||||
it("keeps newest-first history, missing cells, and oldest-first disconnected chart ranks", () => {
|
||||
render(
|
||||
<RadarHistoryGrid history={data.history} metric="ratio" side="top" />,
|
||||
)
|
||||
expect(screen.getByText(/历史不足 30 日/)).toBeInTheDocument()
|
||||
expect(screen.getByText("缺失")).toBeInTheDocument()
|
||||
expect(
|
||||
screen
|
||||
.getByText("08-28")
|
||||
.compareDocumentPosition(screen.getByText("08-27")) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
const option = buildRadarRankChartOption(data.history.points, "ratio")
|
||||
expect(option.xAxis.data).toEqual(["2026-08-27", "2026-08-28"])
|
||||
expect(option.series[0]?.data).toEqual([null, 2])
|
||||
expect(option.series[0]?.connectNulls).toBe(false)
|
||||
expect(option.yAxis.inverse).toBe(true)
|
||||
})
|
||||
it("preserves signed amounts and escapes supplier strings in local CSV", () => {
|
||||
expect(formatRadarValue(-2e8, "yuan")).toBe("-2 亿元")
|
||||
expect(formatRadarValue(null, "percent")).toBe("—")
|
||||
expect(radarValueTone(-1)).toContain("emerald")
|
||||
const csv = radarMembersCsv([{ ...member, name: '=SUM(1,2)"\nhello' }])
|
||||
expect(csv).toContain('"\'=SUM(1,2)""\nhello"')
|
||||
expect(csv).toContain('"-1.2","200000000",""')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RadarMember } from "../api/sector-radar.types"
|
||||
|
||||
/** Format signed values without changing their sign to match a ranking side. */
|
||||
export function formatRadarValue(
|
||||
value: number | null | undefined,
|
||||
unit: "percent" | "yuan" | "ratio" | "CNY_100M",
|
||||
) {
|
||||
if (value == null || !Number.isFinite(value)) return "—"
|
||||
const scaled =
|
||||
unit === "yuan" ? value / 1e8 : unit === "ratio" ? value * 100 : value
|
||||
const suffix = unit === "yuan" || unit === "CNY_100M" ? " 亿元" : "%"
|
||||
return `${scaled > 0 ? "+" : ""}${scaled.toLocaleString("zh-CN", { maximumFractionDigits: 2 })}${suffix}`
|
||||
}
|
||||
|
||||
export function radarValueTone(value: number | null | undefined) {
|
||||
return value == null || !Number.isFinite(value) || value === 0
|
||||
? "text-muted-foreground"
|
||||
: value > 0
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
/** Serialize only API members; quote every field and neutralize spreadsheet formula prefixes. */
|
||||
export function radarMembersCsv(members: RadarMember[]) {
|
||||
function cell(value: string | number | null) {
|
||||
const text = value === null ? "" : String(value)
|
||||
// Untrusted supplier names/codes must never execute as spreadsheet formulas.
|
||||
const safe =
|
||||
typeof value === "string" && /^[\s]*[=+@\-\t\r]/.test(text)
|
||||
? `'${text}`
|
||||
: text
|
||||
return `"${safe.replaceAll('"', '""')}"`
|
||||
}
|
||||
return [
|
||||
["股票代码", "股票名称", "涨跌幅(%)", "主力净额(元)", "主买净额(元)"],
|
||||
...members.map((member) => [
|
||||
member.ts_code,
|
||||
member.name,
|
||||
member.pct_change,
|
||||
member.net_amount_yuan,
|
||||
member.active_buy_net_amount_yuan,
|
||||
]),
|
||||
]
|
||||
.map((row) => row.map(cell).join(","))
|
||||
.join("\r\n")
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useSectorRadarHistory } from "../api/sector-radar.query"
|
||||
import type {
|
||||
RadarHistoryResponse,
|
||||
RadarMetricKind,
|
||||
RadarSectorQuery,
|
||||
} from "../api/sector-radar.types"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
export function RadarHistory({
|
||||
query,
|
||||
metric,
|
||||
side,
|
||||
}: {
|
||||
query: RadarSectorQuery
|
||||
metric: RadarMetricKind
|
||||
side: "top" | "bottom"
|
||||
}) {
|
||||
const history = useSectorRadarHistory(query)
|
||||
if (history.isPending) return <p role="status">正在加载在榜历史…</p>
|
||||
if (history.isError)
|
||||
return (
|
||||
<div role="alert">
|
||||
在榜历史加载失败。
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void history.refetch()}
|
||||
>
|
||||
重试历史
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
if (!history.data || history.data.status === "no_data")
|
||||
return <p>暂无在榜历史数据。</p>
|
||||
return <RadarHistoryGrid history={history.data} metric={metric} side={side} />
|
||||
}
|
||||
|
||||
/** Render newest first and trust each day's pool-based membership flags, preserving missing days. */
|
||||
export function RadarHistoryGrid({
|
||||
history,
|
||||
metric,
|
||||
side,
|
||||
}: {
|
||||
history: RadarHistoryResponse
|
||||
metric: RadarMetricKind
|
||||
side?: "top" | "bottom"
|
||||
}) {
|
||||
const points = [...history.points].sort((a, b) =>
|
||||
b.trade_date.localeCompare(a.trade_date),
|
||||
)
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
近 {history.window_size} 个交易日,已有 {history.available_days} 日发布
|
||||
{history.available_days < history.window_size
|
||||
? "(历史不足 30 日)"
|
||||
: ""}
|
||||
。
|
||||
{points.length
|
||||
? `覆盖 ${points.at(-1)?.trade_date} 至 ${points[0]?.trade_date}。`
|
||||
: ""}
|
||||
在榜为进入对应榜单的累计次数,并非连续天数。
|
||||
</p>
|
||||
<div className="grid grid-cols-5 gap-1 sm:grid-cols-10">
|
||||
{points.map((point) => {
|
||||
const value = point[metric]
|
||||
const inList =
|
||||
side === "top"
|
||||
? value.in_top
|
||||
: side === "bottom"
|
||||
? value.in_bottom
|
||||
: value.in_top || value.in_bottom
|
||||
return (
|
||||
<div
|
||||
key={point.trade_date}
|
||||
className={`rounded-md border p-1.5 text-center text-xs ${!value.missing && inList ? "border-primary/40 bg-primary/10" : "border-border bg-muted/30"}`}
|
||||
title={`${point.trade_date} · 排名池 ${value.pool_size} · ${value.missing ? "缺失" : inList ? "在榜" : "未在榜"}`}
|
||||
>
|
||||
<div className="text-muted-foreground">
|
||||
{point.trade_date.slice(5)}
|
||||
</div>
|
||||
<div className="mt-1 font-medium tabular-nums">
|
||||
{value.missing || value.rank_position === null
|
||||
? "缺失"
|
||||
: `第 ${value.rank_position} 名`}
|
||||
</div>
|
||||
<span className="sr-only">
|
||||
{point.trade_date} 排名池 {value.pool_size}{" "}
|
||||
{value.missing ? "缺失" : inList ? "在榜" : "未在榜"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {
|
||||
RadarHistoryPoint,
|
||||
RadarMetricKind,
|
||||
} from "../api/sector-radar.types"
|
||||
|
||||
/** Produce a chronological ranking series; missing observations break the line and rank 1 stays on top. */
|
||||
export function buildRadarRankChartOption(
|
||||
points: RadarHistoryPoint[],
|
||||
metric: RadarMetricKind,
|
||||
) {
|
||||
const ordered = [...points].sort((a, b) =>
|
||||
a.trade_date.localeCompare(b.trade_date),
|
||||
)
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 48, right: 20, top: 24, bottom: 36 },
|
||||
tooltip: {
|
||||
trigger: "axis" as const,
|
||||
renderMode: "richText" as const,
|
||||
valueFormatter: (value: unknown) =>
|
||||
value == null ? "缺失" : `第 ${String(value)} 名`,
|
||||
},
|
||||
xAxis: {
|
||||
type: "category" as const,
|
||||
data: ordered.map((point) => point.trade_date),
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
inverse: true,
|
||||
min: 1,
|
||||
max: Math.max(2, ...ordered.map((point) => point[metric].pool_size)),
|
||||
minInterval: 1,
|
||||
name: "排名",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "排名",
|
||||
type: "line" as const,
|
||||
connectNulls: false,
|
||||
showSymbol: true,
|
||||
showAllSymbol: true,
|
||||
data: ordered.map((point) =>
|
||||
point[metric].missing ? null : point[metric].rank_position,
|
||||
),
|
||||
lineStyle: { color: "#2563eb" },
|
||||
itemStyle: { color: "#2563eb" },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { LineChart } from "echarts/charts"
|
||||
import { GridComponent, TooltipComponent } from "echarts/components"
|
||||
import * as echarts from "echarts/core"
|
||||
import { CanvasRenderer } from "echarts/renderers"
|
||||
import type {
|
||||
RadarHistoryPoint,
|
||||
RadarMetricKind,
|
||||
} from "../api/sector-radar.types"
|
||||
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer])
|
||||
|
||||
import { buildRadarRankChartOption } from "./radar-rank-chart-option"
|
||||
|
||||
export function RadarRankChart({
|
||||
points,
|
||||
metric,
|
||||
label,
|
||||
}: {
|
||||
points: RadarHistoryPoint[]
|
||||
metric: RadarMetricKind
|
||||
label: string
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const chart = echarts.init(containerRef.current, undefined, {
|
||||
renderer: "canvas",
|
||||
})
|
||||
chart.setOption(buildRadarRankChartOption(points, metric))
|
||||
const observer = new ResizeObserver(() => chart.resize())
|
||||
observer.observe(containerRef.current)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
chart.dispose()
|
||||
}
|
||||
}, [points, metric])
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="img"
|
||||
aria-label={`${label}近30交易日排名曲线,排名1在上方,缺失处断线`}
|
||||
className="h-64 w-full"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SectorRadarPage } from "./sector-radar-page"
|
||||
const navigate = vi.hoisted(() => vi.fn())
|
||||
const useSectorRadarDates = vi.fn()
|
||||
const useSectorRadarRankings = vi.fn()
|
||||
const useSectorRadarHistory = vi.fn()
|
||||
const refetchDates = vi.fn()
|
||||
const refetchRankings = vi.fn()
|
||||
const fetchNextPage = vi.fn()
|
||||
@@ -24,6 +25,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}))
|
||||
|
||||
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
|
||||
useSectorRadarHistory: (...args: unknown[]) => useSectorRadarHistory(...args),
|
||||
useSectorRadarDates: () => useSectorRadarDates(),
|
||||
useSectorRadarRankings: (...args: unknown[]) =>
|
||||
useSectorRadarRankings(...args),
|
||||
@@ -91,6 +93,11 @@ const rankingsResponse: RadarRankingsResponse = {
|
||||
rank_percentile: 100,
|
||||
rank_change_days: 1,
|
||||
rank_change: 3,
|
||||
pct_change: -1.25,
|
||||
daily_net_amount_yuan: 1250000000,
|
||||
daily_ratio: -0.02,
|
||||
on_list_count: 3,
|
||||
history_available_days: 10,
|
||||
},
|
||||
{
|
||||
trade_date: "2026-08-28",
|
||||
@@ -111,6 +118,11 @@ const rankingsResponse: RadarRankingsResponse = {
|
||||
rank_percentile: null,
|
||||
rank_change_days: 1,
|
||||
rank_change: null,
|
||||
pct_change: null,
|
||||
daily_net_amount_yuan: null,
|
||||
daily_ratio: null,
|
||||
on_list_count: null,
|
||||
history_available_days: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -163,6 +175,11 @@ describe("SectorRadarPage", () => {
|
||||
refetch: refetchDates,
|
||||
})
|
||||
useSectorRadarRankings.mockReturnValue(rankingQueryResult())
|
||||
useSectorRadarHistory.mockReturnValue({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
data: { status: "no_data" },
|
||||
})
|
||||
navigate.mockReset()
|
||||
refetchDates.mockReset()
|
||||
refetchRankings.mockReset()
|
||||
@@ -175,14 +192,21 @@ describe("SectorRadarPage", () => {
|
||||
|
||||
expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("19/20")[0]).toBeInTheDocument()
|
||||
expect(screen.getAllByText("100%")[0]).toBeInTheDocument()
|
||||
expect(screen.queryByText("19/20")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("BK0001.DC")).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole("columnheader", { name: "排名百分位" }),
|
||||
screen.queryByRole("columnheader", { name: "排名百分位" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "样本" }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole("columnheader", { name: "涨跌幅" }),
|
||||
).toHaveLength(2)
|
||||
expect(screen.getAllByRole("columnheader", { name: "样本" })).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(
|
||||
screen.getAllByRole("columnheader", { name: "单日流入率" }),
|
||||
).toHaveLength(2)
|
||||
expect(screen.getAllByText("-2%")).toHaveLength(2)
|
||||
expect(
|
||||
screen.queryByRole("columnheader", { name: "资金覆盖率" }),
|
||||
).not.toBeInTheDocument()
|
||||
@@ -206,6 +230,66 @@ describe("SectorRadarPage", () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("mirrors ratio columns and expands only the selected sector history, resetting on date change", () => {
|
||||
routeSearch = { ...routeSearch, view: "ratio" }
|
||||
const ratioPage = {
|
||||
...rankingsResponse,
|
||||
view: "ratio",
|
||||
rows: rankingsResponse.rows.map((row) => ({
|
||||
...row,
|
||||
unit: "ratio",
|
||||
metric_value: -0.01,
|
||||
})),
|
||||
}
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
rankingQueryResult({ data: { pages: [ratioPage], pageParams: [1] } }),
|
||||
)
|
||||
const { rerender } = render(<SectorRadarPage />)
|
||||
expect(screen.getAllByRole("columnheader", { name: "在榜" })).toHaveLength(
|
||||
2,
|
||||
)
|
||||
expect(screen.getAllByText("-1%")).toHaveLength(4)
|
||||
const trigger = screen.getAllByRole("button", {
|
||||
name: "机器人展开在榜历史",
|
||||
})[0]!
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
expect(useSectorRadarHistory).toHaveBeenLastCalledWith({
|
||||
sectorType: "concept",
|
||||
sectorCode: "BK0001.DC",
|
||||
tradeDate: "2026-08-28",
|
||||
})
|
||||
expect(screen.getByText("暂无在榜历史数据。")).toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument()
|
||||
fireEvent.click(trigger)
|
||||
useSectorRadarRankings.mockReturnValue(
|
||||
rankingQueryResult({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
...ratioPage,
|
||||
publication: {
|
||||
...successPublication,
|
||||
publication_id: "next-day",
|
||||
},
|
||||
rows: ratioPage.rows.map((row) => ({
|
||||
...row,
|
||||
trade_date: "2026-08-31",
|
||||
})),
|
||||
},
|
||||
],
|
||||
pageParams: [1],
|
||||
},
|
||||
}),
|
||||
)
|
||||
rerender(<SectorRadarPage />)
|
||||
expect(screen.queryByText("暂无在榜历史数据。")).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByRole("button", { name: "机器人展开在榜历史" })[0],
|
||||
).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
it("resets filters to the first batch in router search state", () => {
|
||||
routeSearch = { ...routeSearch, page: 3 }
|
||||
render(<SectorRadarPage />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
|
||||
import { useRef } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router"
|
||||
|
||||
import { PageLayout } from "@/app/layout/page-layout"
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/features/sector-radar/api/sector-radar.query"
|
||||
import type {
|
||||
RadarMetricKind,
|
||||
RadarMetricUnit,
|
||||
RadarPublication,
|
||||
RadarRankingRow,
|
||||
RadarRankingsQuery,
|
||||
@@ -36,19 +35,23 @@ import {
|
||||
} from "@/shared/ui/select"
|
||||
import { Skeleton } from "@/shared/ui/skeleton"
|
||||
|
||||
import { RadarDetailDialog } from "../components/radar-detail-dialog"
|
||||
import { RadarHistory } from "../components/radar-history"
|
||||
import { formatRadarValue, radarValueTone } from "../components/radar-format"
|
||||
|
||||
const sectorTypeOptions = [
|
||||
{ label: "概念", value: "concept" },
|
||||
{ label: "行业", value: "industry" },
|
||||
] as const
|
||||
const viewOptions = [
|
||||
{ label: "波段资金率", value: "swing" },
|
||||
{ label: "单日资金率", value: "ratio" },
|
||||
{ label: "单日流入率", value: "ratio" },
|
||||
{ label: "单日净额", value: "amount" },
|
||||
{ label: "排名变化", value: "rank_change" },
|
||||
] as const
|
||||
const metricOptions = [
|
||||
{ label: "主力净额", value: "amount" },
|
||||
{ label: "单日资金率", value: "ratio" },
|
||||
{ label: "单日流入率", value: "ratio" },
|
||||
{ label: "波段资金率", value: "swing" },
|
||||
] as const
|
||||
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
|
||||
@@ -366,6 +369,8 @@ function RadarTable({
|
||||
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
|
||||
const rowCount = Math.max(topRows.length, bottomRows.length)
|
||||
const rankChangeView = response.view === "rank_change"
|
||||
const dailyView = response.view === "amount" || response.view === "ratio"
|
||||
const columns = dailyView && response.view === "ratio" ? 10 : 8
|
||||
const loadMoreRequestPending = useRef({ top: false, bottom: false })
|
||||
function requestLoadMore(query: RankingQueryResult) {
|
||||
const side = query === top ? "top" : "bottom"
|
||||
@@ -401,7 +406,7 @@ function RadarTable({
|
||||
{response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
|
||||
</caption>
|
||||
<colgroup>
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
{Array.from({ length: columns }, (_, i) => (
|
||||
<col
|
||||
key={i}
|
||||
className={i === 3 || i === 4 ? "w-[120px]" : "w-[100px]"}
|
||||
@@ -412,7 +417,7 @@ function RadarTable({
|
||||
<tr>
|
||||
<th
|
||||
className="px-3 py-1 text-left font-medium"
|
||||
colSpan={3}
|
||||
colSpan={(columns - 2) / 2}
|
||||
scope="colgroup"
|
||||
>
|
||||
资金进攻榜 TOP 10%
|
||||
@@ -426,49 +431,89 @@ function RadarTable({
|
||||
</th>
|
||||
<th
|
||||
className="px-3 py-1 text-right font-medium"
|
||||
colSpan={3}
|
||||
colSpan={(columns - 2) / 2}
|
||||
scope="colgroup"
|
||||
>
|
||||
BOTTOM 10% 资金撤离榜
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="px-3 py-1 text-left font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th
|
||||
className="border-l border-border px-3 py-1 text-right font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名上升" : "流入"}
|
||||
</th>
|
||||
<th
|
||||
className="border-r border-border px-3 py-1 text-left font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名下降" : "流出"}
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
</tr>
|
||||
{dailyView ? (
|
||||
<tr>
|
||||
{(response.view === "ratio"
|
||||
? [
|
||||
"排名 / 板块",
|
||||
"涨跌幅",
|
||||
"净额",
|
||||
"在榜",
|
||||
"流入率",
|
||||
"流入率",
|
||||
"在榜",
|
||||
"净额",
|
||||
"涨跌幅",
|
||||
"排名 / 板块",
|
||||
]
|
||||
: [
|
||||
"排名 / 板块",
|
||||
"涨跌幅",
|
||||
"单日流入率",
|
||||
"净额",
|
||||
"净额",
|
||||
"单日流入率",
|
||||
"涨跌幅",
|
||||
"排名 / 板块",
|
||||
]
|
||||
).map((label, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
className="px-3 py-1 text-right font-medium"
|
||||
>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
<tr>
|
||||
<th className="px-3 py-1 text-left font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th
|
||||
className="border-l border-border px-3 py-1 text-right font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名上升" : "流入"}
|
||||
</th>
|
||||
<th
|
||||
className="border-r border-border px-3 py-1 text-left font-medium"
|
||||
scope="col"
|
||||
>
|
||||
{rankChangeView ? "排名下降" : "流出"}
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
样本
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名百分位
|
||||
</th>
|
||||
<th className="px-3 py-1 text-right font-medium" scope="col">
|
||||
排名 / 板块
|
||||
</th>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{Array.from({ length: rowCount }, (_, i) => (
|
||||
<RadarMirrorRow
|
||||
key={i}
|
||||
key={`${response.publication?.publication_id}:${response.view}:${topRows[i]?.sector_code}:${bottomRows[i]?.sector_code}`}
|
||||
view={response.view}
|
||||
rankChangeView={rankChangeView}
|
||||
top={topRows[i]}
|
||||
bottom={bottomRows[i]}
|
||||
@@ -478,7 +523,7 @@ function RadarTable({
|
||||
<tr>
|
||||
<td
|
||||
className="px-3 py-8 text-center text-muted-foreground"
|
||||
colSpan={8}
|
||||
colSpan={columns}
|
||||
>
|
||||
没有符合当前筛选条件的板块。
|
||||
</td>
|
||||
@@ -543,11 +588,52 @@ function RadarMirrorRow({
|
||||
top,
|
||||
bottom,
|
||||
rankChangeView,
|
||||
view,
|
||||
}: {
|
||||
view: RadarView
|
||||
top?: RadarRankingRow
|
||||
bottom?: RadarRankingRow
|
||||
rankChangeView: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<"top" | "bottom" | null>(null)
|
||||
if (view === "ratio" || view === "amount")
|
||||
return (
|
||||
<>
|
||||
<tr className="hover:bg-muted/40">
|
||||
<DailyCells
|
||||
row={top}
|
||||
side="top"
|
||||
view={view}
|
||||
expanded={expanded === "top"}
|
||||
onToggle={() => setExpanded(expanded === "top" ? null : "top")}
|
||||
/>
|
||||
<DailyCells
|
||||
row={bottom}
|
||||
side="bottom"
|
||||
view={view}
|
||||
expanded={expanded === "bottom"}
|
||||
onToggle={() =>
|
||||
setExpanded(expanded === "bottom" ? null : "bottom")
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
{expanded && (expanded === "top" ? top : bottom) ? (
|
||||
<tr>
|
||||
<td colSpan={view === "ratio" ? 10 : 8} className="bg-muted/20 p-3">
|
||||
<RadarHistory
|
||||
query={{
|
||||
sectorType: (expanded === "top" ? top : bottom)!.sector_type,
|
||||
sectorCode: (expanded === "top" ? top : bottom)!.sector_code,
|
||||
tradeDate: (expanded === "top" ? top : bottom)!.trade_date,
|
||||
}}
|
||||
metric={view}
|
||||
side={expanded}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<tr className="hover:bg-muted/40">
|
||||
<RankCells row={top} align="left" />
|
||||
@@ -561,6 +647,76 @@ function RadarMirrorRow({
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
/** Mirror daily fields without borrowing values from the opposite sector. */
|
||||
function DailyCells({
|
||||
row,
|
||||
side,
|
||||
view,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
row?: RadarRankingRow
|
||||
side: "top" | "bottom"
|
||||
view: "ratio" | "amount"
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
if (!row)
|
||||
return (
|
||||
<td
|
||||
colSpan={view === "ratio" ? 5 : 4}
|
||||
className="p-3 text-center text-muted-foreground"
|
||||
>
|
||||
—
|
||||
</td>
|
||||
)
|
||||
const fields = [
|
||||
<td key="name" className="px-3 py-2 whitespace-nowrap">
|
||||
<span className="mr-2 text-muted-foreground tabular-nums">
|
||||
{row.rank_position ?? "—"}
|
||||
</span>
|
||||
<RadarDetailDialog row={row} />
|
||||
</td>,
|
||||
<td
|
||||
key="pct"
|
||||
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(row.pct_change)}`}
|
||||
>
|
||||
{formatRadarValue(row.pct_change, "percent")}
|
||||
</td>,
|
||||
<td
|
||||
key="extra"
|
||||
className={`px-3 py-2 text-right tabular-nums ${radarValueTone(view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio)}`}
|
||||
>
|
||||
{formatRadarValue(
|
||||
view === "ratio" ? row.daily_net_amount_yuan : row.daily_ratio,
|
||||
view === "ratio" ? "yuan" : "ratio",
|
||||
)}
|
||||
</td>,
|
||||
...(view === "ratio"
|
||||
? [
|
||||
<td key="history" className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${row.sector_name}${expanded ? "收起" : "展开"}在榜历史`}
|
||||
onClick={onToggle}
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
{row.on_list_count == null ? "—" : `${row.on_list_count} 次`}
|
||||
</button>
|
||||
</td>,
|
||||
]
|
||||
: []),
|
||||
<td
|
||||
key="metric"
|
||||
className={`border-x border-border bg-muted/30 px-3 py-2 text-right font-medium tabular-nums ${radarValueTone(row.metric_value)}`}
|
||||
>
|
||||
{formatRadarValue(row.metric_value, row.unit)}
|
||||
</td>,
|
||||
]
|
||||
return <>{side === "top" ? fields : fields.reverse()}</>
|
||||
}
|
||||
|
||||
function RankCells({
|
||||
row,
|
||||
align,
|
||||
@@ -626,23 +782,10 @@ function MetricCell({
|
||||
const value = row
|
||||
? rankChangeView
|
||||
? formatRankChange(row.rank_change)
|
||||
: formatDirectionalMetric(row.metric_value, row.unit, direction)
|
||||
: formatRadarValue(row.metric_value, row.unit)
|
||||
: "—"
|
||||
const positive =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change > 0
|
||||
const negative =
|
||||
row?.rank_change !== null &&
|
||||
row?.rank_change !== undefined &&
|
||||
row.rank_change < 0
|
||||
const borderClass = direction === "in" ? "border-l" : "border-r"
|
||||
const toneClass =
|
||||
positive || (direction === "in" && !rankChangeView)
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10"
|
||||
: negative || (direction === "out" && !rankChangeView)
|
||||
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "text-foreground bg-muted/30"
|
||||
const toneClass = `${radarValueTone(rankChangeView ? row?.rank_change : row?.metric_value)} bg-muted/30`
|
||||
return (
|
||||
<td
|
||||
className={`${borderClass} border-border px-3 py-1.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
|
||||
@@ -652,20 +795,6 @@ function MetricCell({
|
||||
)
|
||||
}
|
||||
|
||||
function formatDirectionalMetric(
|
||||
value: number | null,
|
||||
unit: RadarMetricUnit,
|
||||
direction: "in" | "out",
|
||||
) {
|
||||
if (value === null) return "—"
|
||||
const absoluteValue = Math.abs(value)
|
||||
const formatted =
|
||||
unit === "CNY_100M"
|
||||
? `${formatNumber(absoluteValue, 2)} 亿元`
|
||||
: `${formatNumber(absoluteValue * 100, 2)}%`
|
||||
return `${direction === "in" ? "+" : "−"}${formatted}`
|
||||
}
|
||||
|
||||
function RadarLoading() {
|
||||
return (
|
||||
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
|
||||
|
||||
Reference in New Issue
Block a user