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:
yuxuanhui
2026-09-06 16:06:17 +08:00
parent 68282f5d46
commit 7f93d6b0f5
48 changed files with 4883 additions and 95 deletions
@@ -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">