Merge branch 'develop' into codex/point

This commit is contained in:
yuxuanhui
2026-08-31 16:14:35 +08:00
86 changed files with 13384 additions and 217 deletions
@@ -0,0 +1,182 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
const publication = {
publication_id: "publication-1",
target_trade_date: "2026-08-28",
status: "success",
source_version: "tushare-pro-v1",
universe_version: "eastmoney-dc-v1",
metric_versions: ["zhixing_amount_net_bn_v1"],
input_hash: "a".repeat(64),
coverage: "0.99",
started_at: "2026-08-28T08:00:00Z",
finished_at: "2026-08-28T08:05:00Z",
error_summary: null,
}
const rankingPayload = {
status: "success",
requested_trade_date: "2026-08-28",
sector_type: "concept",
view: "rank_change",
rank_change_metric: "amount",
rank_change_days: 5,
side: "top",
search: "机器人",
publication,
definition: {
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
label: "主力净流入(知行独立实现)",
unit: "CNY_100M",
implementation_kind: "independent",
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
},
page: 2,
page_size: 10,
total: 1,
rows: [
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0001.DC",
sector_name: "机器人",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: "12.5",
quality: "available",
member_count: 20,
valid_sample_count: 19,
membership_coverage: "1",
moneyflow_coverage: "0.95",
rank_position: 1,
rank_percentile: "100",
rank_change_days: 5,
rank_change: 3,
},
],
}
describe("sector radar API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
const signal = new AbortController().signal
requestJson.mockResolvedValue({
status: "success",
available_dates: ["2026-08-28"],
current_attempt: publication,
last_good: publication,
})
const result = await getSectorRadarDates(signal)
expect(requestJson).toHaveBeenCalledWith("/api/v1/sector-radar/dates", {
signal,
})
expect(result.last_good?.coverage).toBe(0.99)
})
it("maps URL-backed filters to the ranking HTTP contract", async () => {
const signal = new AbortController().signal
requestJson.mockResolvedValue(rankingPayload)
const result = await getSectorRadarRankings(
{
tradeDate: "2026-08-28",
sectorType: "concept",
view: "rank_change",
rankChangeMetric: "amount",
rankChangeDays: 5,
side: "top",
search: " 机器人 ",
page: 2,
pageSize: 10,
},
signal,
)
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
const params = new URL(input, "http://localhost").searchParams
expect(input).toContain("/api/v1/sector-radar/rankings?")
expect(params.get("trade_date")).toBe("2026-08-28")
expect(params.get("sector_type")).toBe("concept")
expect(params.get("view")).toBe("rank_change")
expect(params.get("rank_change_metric")).toBe("amount")
expect(params.get("rank_change_days")).toBe("5")
expect(params.get("side")).toBe("top")
expect(params.get("search")).toBe("机器人")
expect(params.get("page")).toBe("2")
expect(params.get("page_size")).toBe("10")
expect(init).toEqual({ signal })
expect(result.rows[0]?.metric_value).toBe(12.5)
expect(result.rows[0]?.rank_percentile).toBe(100)
})
it("rejects unknown stable enum values at the feature boundary", async () => {
requestJson.mockResolvedValue({ ...rankingPayload, view: "private_score" })
await expect(
getSectorRadarRankings({
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
page: 1,
pageSize: 20,
}),
).rejects.toThrow("Invalid sector radar response: rankings.view")
})
it("rejects non-finite decimal strings instead of rendering them", async () => {
requestJson.mockResolvedValue({
...rankingPayload,
rows: [{ ...rankingPayload.rows[0], metric_value: "NaN" }],
})
await expect(
getSectorRadarRankings({
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
page: 1,
pageSize: 20,
}),
).rejects.toThrow("rankings.rows[0].metric_value must be finite")
})
it("rejects a zero rank percentile that the domain cannot produce", async () => {
requestJson.mockResolvedValue({
...rankingPayload,
rows: [{ ...rankingPayload.rows[0], rank_percentile: "0" }],
})
await expect(
getSectorRadarRankings({
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
page: 1,
pageSize: 20,
}),
).rejects.toThrow("rankings.rows[0].rank_percentile must be greater than 0")
})
})
@@ -0,0 +1,420 @@
import { requestJson } from "@/shared/api/request-json"
import {
radarMetricKinds,
radarMetricQualities,
radarMetricUnits,
radarPublicationStatuses,
radarRankSides,
radarViews,
sectorTypes,
type RadarDatesResponse,
type RadarMetricDefinition,
type RadarPublication,
type RadarRankingRow,
type RadarRankingsQuery,
type RadarRankingsResponse,
} from "./sector-radar.types"
type JsonRecord = Record<string, unknown>
/**
* Fetch and validate the available sector-radar publication dates.
*
* @param signal - React Query cancellation signal forwarded to fetch.
* @returns A normalized response whose decimal fields are finite numbers.
* @throws Error when the server response violates the stable HTTP contract.
*/
export async function getSectorRadarDates(signal?: AbortSignal) {
const payload = await requestJson<unknown>("/api/v1/sector-radar/dates", {
signal,
})
return parseRadarDatesResponse(payload)
}
/**
* Fetch one persisted ranking page using the backend's snake-case query names.
*
* @param query - URL-backed ranking filters owned by the sector-radar feature.
* @param signal - React Query cancellation signal forwarded to fetch.
* @returns A validated and normalized ranking page.
* @throws Error when the server response violates the stable HTTP contract.
*/
export async function getSectorRadarRankings(
query: RadarRankingsQuery,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
sector_type: query.sectorType,
view: query.view,
rank_change_metric: query.rankChangeMetric,
rank_change_days: String(query.rankChangeDays),
side: query.side,
page: String(query.page),
page_size: String(query.pageSize),
})
if (query.tradeDate) params.set("trade_date", query.tradeDate)
if (query.search?.trim()) params.set("search", query.search.trim())
const payload = await requestJson<unknown>(
`/api/v1/sector-radar/rankings?${params.toString()}`,
{ signal },
)
return parseRadarRankingsResponse(payload)
}
/**
* Validate the dates response at the feature boundary.
*
* The shared transport intentionally does not own feature schemas. Decimal
* strings emitted by Pydantic are converted here so the page never handles
* mixed string/number arithmetic.
*/
export function parseRadarDatesResponse(value: unknown): RadarDatesResponse {
const record = readRecord(value, "dates")
return {
status: readEnum(record.status, ["success", "no_data"], "dates.status"),
available_dates: readArray(
record.available_dates,
"dates.available_dates",
).map((item, index) => readDate(item, `dates.available_dates[${index}]`)),
current_attempt: readNullablePublication(
record.current_attempt,
"dates.current_attempt",
),
last_good: readNullablePublication(record.last_good, "dates.last_good"),
}
}
/** Validate and normalize one ranking response from the same-repository API. */
export function parseRadarRankingsResponse(
value: unknown,
): RadarRankingsResponse {
const record = readRecord(value, "rankings")
return {
status: readEnum(record.status, ["success", "no_data"], "rankings.status"),
requested_trade_date: readNullableDate(
record.requested_trade_date,
"rankings.requested_trade_date",
),
sector_type: readEnum(
record.sector_type,
sectorTypes,
"rankings.sector_type",
),
view: readEnum(record.view, radarViews, "rankings.view"),
rank_change_metric: readEnum(
record.rank_change_metric,
radarMetricKinds,
"rankings.rank_change_metric",
),
rank_change_days: readIntegerInRange(
record.rank_change_days,
1,
5,
"rankings.rank_change_days",
),
side: readEnum(record.side, radarRankSides, "rankings.side"),
search: readNullableString(record.search, "rankings.search"),
publication: readNullablePublication(
record.publication,
"rankings.publication",
),
definition: readMetricDefinition(record.definition),
page: readIntegerInRange(
record.page,
1,
Number.MAX_SAFE_INTEGER,
"rankings.page",
),
page_size: readIntegerInRange(
record.page_size,
1,
100,
"rankings.page_size",
),
total: readIntegerInRange(
record.total,
0,
Number.MAX_SAFE_INTEGER,
"rankings.total",
),
rows: readArray(record.rows, "rankings.rows").map((item, index) =>
readRankingRow(item, index),
),
}
}
function readNullablePublication(
value: unknown,
path: string,
): RadarPublication | null {
if (value === null) return null
const record = readRecord(value, path)
return {
publication_id: readNonEmptyString(
record.publication_id,
`${path}.publication_id`,
),
target_trade_date: readDate(
record.target_trade_date,
`${path}.target_trade_date`,
),
status: readEnum(record.status, radarPublicationStatuses, `${path}.status`),
source_version: readNonEmptyString(
record.source_version,
`${path}.source_version`,
),
universe_version: readNonEmptyString(
record.universe_version,
`${path}.universe_version`,
),
metric_versions: readArray(
record.metric_versions,
`${path}.metric_versions`,
).map((item, index) =>
readNonEmptyString(item, `${path}.metric_versions[${index}]`),
),
input_hash: readNullableString(record.input_hash, `${path}.input_hash`),
coverage: readFraction(record.coverage, `${path}.coverage`),
started_at: readDateTime(record.started_at, `${path}.started_at`),
finished_at: readNullableDateTime(
record.finished_at,
`${path}.finished_at`,
),
error_summary: readNullableString(
record.error_summary,
`${path}.error_summary`,
),
}
}
function readMetricDefinition(value: unknown): RadarMetricDefinition {
const path = "rankings.definition"
const record = readRecord(value, path)
return {
metric_kind: readEnum(
record.metric_kind,
radarMetricKinds,
`${path}.metric_kind`,
),
metric_version: readNonEmptyString(
record.metric_version,
`${path}.metric_version`,
),
label: readNonEmptyString(record.label, `${path}.label`),
unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`),
implementation_kind: readEnum(
record.implementation_kind,
["independent"],
`${path}.implementation_kind`,
),
disclaimer: readNonEmptyString(record.disclaimer, `${path}.disclaimer`),
}
}
function readRankingRow(value: unknown, index: number): RadarRankingRow {
const path = `rankings.rows[${index}]`
const record = readRecord(value, path)
return {
trade_date: readDate(record.trade_date, `${path}.trade_date`),
sector_type: readEnum(
record.sector_type,
sectorTypes,
`${path}.sector_type`,
),
sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`),
sector_name: readNonEmptyString(record.sector_name, `${path}.sector_name`),
metric_kind: readEnum(
record.metric_kind,
radarMetricKinds,
`${path}.metric_kind`,
),
metric_version: readNonEmptyString(
record.metric_version,
`${path}.metric_version`,
),
implementation_kind: readEnum(
record.implementation_kind,
["independent"],
`${path}.implementation_kind`,
),
unit: readEnum(record.unit, radarMetricUnits, `${path}.unit`),
metric_value: readNullableFiniteNumber(
record.metric_value,
`${path}.metric_value`,
),
quality: readEnum(record.quality, radarMetricQualities, `${path}.quality`),
member_count: readIntegerInRange(
record.member_count,
0,
Number.MAX_SAFE_INTEGER,
`${path}.member_count`,
),
valid_sample_count: readIntegerInRange(
record.valid_sample_count,
0,
Number.MAX_SAFE_INTEGER,
`${path}.valid_sample_count`,
),
membership_coverage: readFraction(
record.membership_coverage,
`${path}.membership_coverage`,
),
moneyflow_coverage: readFraction(
record.moneyflow_coverage,
`${path}.moneyflow_coverage`,
),
rank_position: readNullableInteger(
record.rank_position,
1,
`${path}.rank_position`,
),
rank_percentile: readNullablePositiveNumber(
record.rank_percentile,
100,
`${path}.rank_percentile`,
),
rank_change_days: readIntegerInRange(
record.rank_change_days,
1,
5,
`${path}.rank_change_days`,
),
rank_change: readNullableInteger(
record.rank_change,
Number.MIN_SAFE_INTEGER,
`${path}.rank_change`,
),
}
}
function readRecord(value: unknown, path: string): JsonRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw contractError(path, "must be an object")
}
return value as JsonRecord
}
function readArray(value: unknown, path: string): unknown[] {
if (!Array.isArray(value)) throw contractError(path, "must be an array")
return value
}
function readEnum<const Values extends readonly string[]>(
value: unknown,
allowed: Values,
path: string,
): Values[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw contractError(path, `must be one of ${allowed.join(", ")}`)
}
return value as Values[number]
}
function readNonEmptyString(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw contractError(path, "must be a non-empty string")
}
return value
}
function readNullableString(value: unknown, path: string): string | null {
if (value === null) return null
if (typeof value !== "string")
throw contractError(path, "must be a string or null")
return value
}
function readDate(value: unknown, path: string): string {
const text = readNonEmptyString(value, path)
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
if (!match) throw contractError(path, "must be an ISO trade date")
const parsed = new Date(`${text}T00:00:00Z`)
if (
Number.isNaN(parsed.getTime()) ||
parsed.getUTCFullYear() !== Number(match[1]) ||
parsed.getUTCMonth() + 1 !== Number(match[2]) ||
parsed.getUTCDate() !== Number(match[3])
) {
throw contractError(path, "must be a valid trade date")
}
return text
}
function readNullableDate(value: unknown, path: string): string | null {
return value === null ? null : readDate(value, path)
}
function readDateTime(value: unknown, path: string): string {
const text = readNonEmptyString(value, path)
if (!Number.isFinite(Date.parse(text))) {
throw contractError(path, "must be an ISO date-time")
}
return text
}
function readNullableDateTime(value: unknown, path: string): string | null {
return value === null ? null : readDateTime(value, path)
}
function readFiniteNumber(value: unknown, path: string): number {
const number =
typeof value === "number"
? value
: typeof value === "string" && value.trim().length > 0
? Number(value)
: Number.NaN
if (!Number.isFinite(number)) throw contractError(path, "must be finite")
return number
}
function readNullableFiniteNumber(value: unknown, path: string): number | null {
return value === null ? null : readFiniteNumber(value, path)
}
function readFraction(value: unknown, path: string): number {
const number = readFiniteNumber(value, path)
if (number < 0 || number > 1)
throw contractError(path, "must be between 0 and 1")
return number
}
function readIntegerInRange(
value: unknown,
min: number,
max: number,
path: string,
): number {
const number = readFiniteNumber(value, path)
if (!Number.isInteger(number) || number < min || number > max) {
throw contractError(path, `must be an integer between ${min} and ${max}`)
}
return number
}
function readNullableInteger(
value: unknown,
min: number,
path: string,
): number | null {
if (value === null) return null
return readIntegerInRange(value, min, Number.MAX_SAFE_INTEGER, path)
}
function readNullablePositiveNumber(
value: unknown,
max: number,
path: string,
): number | null {
if (value === null) return null
const number = readFiniteNumber(value, path)
if (number <= 0 || number > max) {
throw contractError(path, `must be greater than 0 and at most ${max}`)
}
return number
}
function contractError(path: string, reason: string): Error {
return new Error(`Invalid sector radar response: ${path} ${reason}`)
}
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({
getSectorRadarDates: vi.fn(),
getSectorRadarRankings: vi.fn(),
}))
vi.mock("@tanstack/react-query", () => ({ useQuery }))
vi.mock("./sector-radar.api", () => api)
import {
sectorRadarDatesQueryKey,
sectorRadarRankingsQueryKey,
useSectorRadarDates,
useSectorRadarRankings,
} from "./sector-radar.query"
const query = {
tradeDate: "2026-08-28",
sectorType: "industry" as const,
view: "rank_change" as const,
rankChangeMetric: "ratio" as const,
rankChangeDays: 3,
side: "bottom" as const,
search: "银行",
page: 2,
pageSize: 50,
}
describe("sector radar query hooks", () => {
beforeEach(() => {
vi.clearAllMocks()
useQuery.mockImplementation((options) => options)
})
it("keeps every server-affecting filter in the ranking query key", () => {
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
expect(sectorRadarRankingsQueryKey(query)).toEqual([
"sectorRadar",
"rankings",
"2026-08-28",
"industry",
"rank_change",
"ratio",
3,
"bottom",
"银行",
2,
50,
])
})
it("forwards React Query cancellation signals to both adapters", async () => {
useSectorRadarDates()
useSectorRadarRankings(query)
const datesOptions = useQuery.mock.calls[0]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
}
const rankingsOptions = useQuery.mock.calls[1]?.[0] as {
queryFn: (context: { signal: AbortSignal }) => unknown
}
const signal = new AbortController().signal
await datesOptions.queryFn({ signal })
await rankingsOptions.queryFn({ signal })
expect(api.getSectorRadarDates).toHaveBeenCalledWith(signal)
expect(api.getSectorRadarRankings).toHaveBeenCalledWith(query, signal)
})
})
@@ -0,0 +1,35 @@
import { useQuery } from "@tanstack/react-query"
import { getSectorRadarDates, getSectorRadarRankings } from "./sector-radar.api"
import type { RadarRankingsQuery } from "./sector-radar.types"
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
export const sectorRadarRankingsQueryKey = (query: RadarRankingsQuery) =>
[
"sectorRadar",
"rankings",
query.tradeDate ?? "latest",
query.sectorType,
query.view,
query.rankChangeMetric,
query.rankChangeDays,
query.side,
query.search ?? "",
query.page,
query.pageSize,
] as const
export function useSectorRadarDates() {
return useQuery({
queryFn: ({ signal }) => getSectorRadarDates(signal),
queryKey: sectorRadarDatesQueryKey,
})
}
export function useSectorRadarRankings(query: RadarRankingsQuery) {
return useQuery({
queryFn: ({ signal }) => getSectorRadarRankings(query, signal),
queryKey: sectorRadarRankingsQueryKey(query),
})
}
@@ -0,0 +1,113 @@
export const sectorTypes = ["concept", "industry"] as const
export type SectorType = (typeof sectorTypes)[number]
export const radarViews = ["amount", "ratio", "swing", "rank_change"] as const
export type RadarView = (typeof radarViews)[number]
export const radarMetricKinds = ["amount", "ratio", "swing"] as const
export type RadarMetricKind = (typeof radarMetricKinds)[number]
export const radarRankSides = ["top", "bottom", "all"] as const
export type RadarRankSide = (typeof radarRankSides)[number]
export const radarPublicationStatuses = [
"running",
"success",
"partial",
"failed",
] as const
export type RadarPublicationStatus = (typeof radarPublicationStatuses)[number]
export const radarMetricQualities = [
"available",
"available_limited_sample",
"unavailable",
] as const
export type RadarMetricQuality = (typeof radarMetricQualities)[number]
export const radarMetricUnits = ["CNY_100M", "ratio"] as const
export type RadarMetricUnit = (typeof radarMetricUnits)[number]
export interface RadarPublication {
publication_id: string
target_trade_date: string
status: RadarPublicationStatus
source_version: string
universe_version: string
metric_versions: string[]
input_hash: string | null
coverage: number
started_at: string
finished_at: string | null
error_summary: string | null
}
export interface RadarDatesResponse {
status: "success" | "no_data"
available_dates: string[]
current_attempt: RadarPublication | null
last_good: RadarPublication | null
}
export interface RadarMetricDefinition {
metric_kind: RadarMetricKind
metric_version: string
label: string
unit: RadarMetricUnit
implementation_kind: "independent"
disclaimer: string
}
export interface RadarRankingRow {
trade_date: string
sector_type: SectorType
sector_code: string
sector_name: string
metric_kind: RadarMetricKind
metric_version: string
implementation_kind: "independent"
unit: RadarMetricUnit
metric_value: number | null
quality: RadarMetricQuality
member_count: number
valid_sample_count: number
membership_coverage: number
moneyflow_coverage: number
rank_position: number | null
rank_percentile: number | null
rank_change_days: number
rank_change: number | null
}
export interface RadarRankingsResponse {
status: "success" | "no_data"
requested_trade_date: string | null
sector_type: SectorType
view: RadarView
rank_change_metric: RadarMetricKind
rank_change_days: number
side: RadarRankSide
search: string | null
publication: RadarPublication | null
definition: RadarMetricDefinition
page: number
page_size: number
total: number
rows: RadarRankingRow[]
}
export interface RadarRankingsQuery {
tradeDate?: string
sectorType: SectorType
view: RadarView
rankChangeMetric: RadarMetricKind
rankChangeDays: number
side: RadarRankSide
search?: string
page: number
pageSize: number
}
export interface SectorRadarRouteSearch extends RadarRankingsQuery {
search: string
}
@@ -0,0 +1,330 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type {
RadarDatesResponse,
RadarRankingsResponse,
SectorRadarRouteSearch,
} from "../api/sector-radar.types"
import { SectorRadarPage } from "./sector-radar-page"
const navigate = vi.hoisted(() => vi.fn())
const useSectorRadarDates = vi.fn()
const useSectorRadarRankings = vi.fn()
const refetchDates = vi.fn()
const refetchRankings = vi.fn()
let routeSearch: SectorRadarRouteSearch
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigate,
useSearch: () => routeSearch,
}))
vi.mock("@/features/sector-radar/api/sector-radar.query", () => ({
useSectorRadarDates: () => useSectorRadarDates(),
useSectorRadarRankings: (...args: unknown[]) =>
useSectorRadarRankings(...args),
}))
const successPublication = {
publication_id: "publication-success",
target_trade_date: "2026-08-28",
status: "success" as const,
source_version: "tushare-pro-v1",
universe_version: "eastmoney-dc-v1",
metric_versions: ["zhixing_amount_net_bn_v1"],
input_hash: "a".repeat(64),
coverage: 0.99,
started_at: "2026-08-28T08:00:00Z",
finished_at: "2026-08-28T08:05:00Z",
error_summary: null,
}
const datesResponse: RadarDatesResponse = {
status: "success",
available_dates: ["2026-08-28", "2026-08-27"],
current_attempt: successPublication,
last_good: successPublication,
}
const rankingsResponse: RadarRankingsResponse = {
status: "success",
requested_trade_date: null,
sector_type: "concept",
view: "amount",
rank_change_metric: "amount",
rank_change_days: 1,
side: "all",
search: null,
publication: successPublication,
definition: {
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
label: "主力净流入(知行独立实现)",
unit: "CNY_100M",
implementation_kind: "independent",
disclaimer: "知行独立实现,非 OneChartLab 原站公式。",
},
page: 1,
page_size: 20,
total: 2,
rows: [
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0001.DC",
sector_name: "机器人",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: 12.5,
quality: "available",
member_count: 20,
valid_sample_count: 19,
membership_coverage: 1,
moneyflow_coverage: 0.95,
rank_position: 1,
rank_percentile: 100,
rank_change_days: 1,
rank_change: 3,
},
{
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK0002.DC",
sector_name: "低空经济",
metric_kind: "amount",
metric_version: "zhixing_amount_net_bn_v1",
implementation_kind: "independent",
unit: "CNY_100M",
metric_value: null,
quality: "available_limited_sample",
member_count: 4,
valid_sample_count: 3,
membership_coverage: 1,
moneyflow_coverage: 0.75,
rank_position: null,
rank_percentile: null,
rank_change_days: 1,
rank_change: null,
},
],
}
describe("SectorRadarPage", () => {
beforeEach(() => {
routeSearch = {
tradeDate: undefined,
sectorType: "concept",
view: "amount",
rankChangeMetric: "amount",
rankChangeDays: 1,
side: "all",
search: "",
page: 1,
pageSize: 20,
}
useSectorRadarDates.mockReturnValue({
data: datesResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
navigate.mockReset()
refetchDates.mockReset()
refetchRankings.mockReset()
})
it("renders independent metric provenance, units, quality, and ranking rows", () => {
render(<SectorRadarPage />)
expect(screen.getAllByText("知行独立实现").length).toBeGreaterThan(0)
expect(screen.getByText("zhixing_amount_net_bn_v1")).toBeInTheDocument()
expect(screen.getByText("12.5 亿元")).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument()
})
it("stores search and pagination changes in router search state", () => {
render(<SectorRadarPage />)
fireEvent.change(screen.getByRole("searchbox", { name: "搜索板块" }), {
target: { value: "机器人" },
})
fireEvent.click(screen.getByRole("button", { name: "第 1 页" }))
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
) => SectorRadarRouteSearch
expect(searchCall({ ...routeSearch, page: 3 })).toMatchObject({
page: 1,
search: "机器人",
})
})
it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
view: "rank_change",
rank_change_days: 5,
rows: rankingsResponse.rows.map((row, index) => ({
...row,
rank_change_days: 5,
rank_change: index === 0 ? 3 : null,
})),
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(
screen.getByRole("columnheader", { name: "5 日排名变化" }),
).toBeInTheDocument()
expect(screen.getByText("+3")).toBeInTheDocument()
expect(screen.getByText("暂无可比历史")).toBeInTheDocument()
})
it("warns when a partial attempt has not replaced last-good", () => {
useSectorRadarDates.mockReturnValue({
data: {
...datesResponse,
current_attempt: {
...successPublication,
publication_id: "publication-partial",
target_trade_date: "2026-08-29",
status: "partial",
coverage: 0.8,
input_hash: null,
},
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("新一期数据不完整")
expect(screen.getByRole("status")).toHaveTextContent(
"当前仍展示最近有效发布 2026-08-28",
)
})
it("keeps the last response visible while React Query refreshes it", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: false,
isFetching: true,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent(
"当前继续展示上一次已读取的有效结果",
)
expect(screen.getByText("机器人")).toBeInTheDocument()
})
it("keeps stale data visible when a background refresh fails", () => {
useSectorRadarRankings.mockReturnValue({
data: rankingsResponse,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
expect(screen.getByText("机器人")).toBeInTheDocument()
expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument()
})
it("renders loading, error with retry, and no-data states", () => {
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: false,
isFetching: true,
isPending: true,
refetch: refetchRankings,
})
const { rerender } = render(<SectorRadarPage />)
expect(screen.getByLabelText("正在加载板块资金雷达")).toBeInTheDocument()
useSectorRadarDates.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: undefined,
isError: true,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(refetchDates).toHaveBeenCalledOnce()
expect(refetchRankings).toHaveBeenCalledOnce()
useSectorRadarDates.mockReturnValue({
data: {
status: "no_data",
available_dates: [],
current_attempt: null,
last_good: null,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchDates,
})
useSectorRadarRankings.mockReturnValue({
data: {
...rankingsResponse,
status: "no_data",
publication: null,
rows: [],
total: 0,
},
isError: false,
isFetching: false,
isPending: false,
refetch: refetchRankings,
})
rerender(<SectorRadarPage />)
expect(screen.getByText("暂无板块资金发布")).toBeInTheDocument()
})
})
@@ -0,0 +1,648 @@
import { AlertTriangle, Database, RefreshCw } from "lucide-react"
import { useEffect } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { PageLayout } from "@/app/layout/page-layout"
import {
useSectorRadarDates,
useSectorRadarRankings,
} from "@/features/sector-radar/api/sector-radar.query"
import type {
RadarMetricQuality,
RadarMetricUnit,
RadarPublication,
RadarRankingRow,
RadarRankingsQuery,
SectorRadarRouteSearch,
} from "@/features/sector-radar/api/sector-radar.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import { Input } from "@/shared/ui/input"
import { Pagination } from "@/shared/ui/pagination"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select"
import { Skeleton } from "@/shared/ui/skeleton"
const PAGE_SIZE_OPTIONS = [10, 20, 50] as const
const sectorTypeOptions = [
{ label: "概念板块", value: "concept" },
{ label: "行业板块", value: "industry" },
] as const
const viewOptions = [
{ label: "主力净额", value: "amount" },
{ label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" },
{ label: "排名变化", value: "rank_change" },
] as const
const metricOptions = [
{ label: "主力净额", value: "amount" },
{ label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" },
] as const
const sideOptions = [
{ label: "全部板块", value: "all" },
{ label: "强榜", value: "top" },
{ label: "弱榜", value: "bottom" },
] as const
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
label: `${value} 日变化`,
value: String(value),
}))
export function SectorRadarPage() {
const search = useSearch({ from: "/_workspace/sector-radar" })
const navigate = useNavigate({ from: "/sector-radar" })
const query: RadarRankingsQuery = {
tradeDate: search.tradeDate,
sectorType: search.sectorType,
view: search.view,
rankChangeMetric: search.rankChangeMetric,
rankChangeDays: search.rankChangeDays,
side: search.side,
search: search.search || undefined,
page: search.page,
pageSize: search.pageSize,
}
const dates = useSectorRadarDates()
const rankings = useSectorRadarRankings(query)
const total = rankings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / search.pageSize))
useEffect(() => {
if (rankings.data?.status === "success" && search.page > pageCount) {
void navigate({
search: (previous) => ({ ...previous, page: pageCount }),
})
}
}, [navigate, pageCount, rankings.data?.status, search.page])
function updateSearch(next: Partial<SectorRadarRouteSearch>) {
void navigate({ search: (previous) => ({ ...previous, ...next }) })
}
const loading = dates.isPending || rankings.isPending
const refreshing =
!loading && (Boolean(dates.isFetching) || Boolean(rankings.isFetching))
const fatalError =
(dates.isError && !dates.data) || (rankings.isError && !rankings.data)
const refreshFailed =
!fatalError && (Boolean(dates.isError) || Boolean(rankings.isError))
const noData =
dates.data?.status === "no_data" || rankings.data?.status === "no_data"
return (
<PageLayout
actions={
<RadarFilters
availableDates={dates.data?.available_dates ?? []}
onChange={updateSearch}
search={search}
/>
}
mode="bounded-workspace"
>
<div className="mx-auto flex min-h-0 w-full max-w-7xl flex-1 flex-col gap-3 md:overflow-hidden">
{loading ? <RadarLoading /> : null}
{!loading && fatalError ? (
<RadarError
onRetry={() => {
void dates.refetch()
void rankings.refetch()
}}
/>
) : null}
{!loading && !fatalError && noData ? <RadarNoData /> : null}
{!loading &&
!fatalError &&
!noData &&
dates.data &&
rankings.data?.status === "success" &&
rankings.data.publication ? (
<>
{refreshFailed ? (
<RadarRefreshFailureStatus />
) : refreshing ? (
<RadarRefreshStatus />
) : null}
<RadarStatusSummary
currentAttempt={dates.data.current_attempt}
lastGood={dates.data.last_good}
publication={rankings.data.publication}
disclaimer={rankings.data.definition.disclaimer}
metricVersion={rankings.data.definition.metric_version}
/>
<RadarTable
onPageChange={(page) => updateSearch({ page })}
onPageSizeChange={(pageSize) =>
updateSearch({ page: 1, pageSize })
}
page={Math.min(search.page, pageCount)}
pageSize={search.pageSize}
response={rankings.data}
/>
</>
) : null}
</div>
</PageLayout>
)
}
interface RadarFiltersProps {
availableDates: string[]
onChange: (next: Partial<SectorRadarRouteSearch>) => void
search: SectorRadarRouteSearch
}
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
const dateOptions = [
{ label: "最近有效交易日", value: "latest" },
...availableDates.map((value) => ({ label: value, value })),
]
return (
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
<FilterSelect
className="lg:col-span-2"
label="交易日"
options={dateOptions}
value={search.tradeDate ?? "latest"}
onValueChange={(value) =>
onChange({
page: 1,
tradeDate: value === "latest" ? undefined : value,
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="板块类型"
options={sectorTypeOptions}
value={search.sectorType}
onValueChange={(value) =>
onChange({
page: 1,
sectorType: value as SectorRadarRouteSearch["sectorType"],
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="指标视角"
options={viewOptions}
value={search.view}
onValueChange={(value) =>
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
}
/>
{search.view === "rank_change" ? (
<>
<FilterSelect
className="lg:col-span-2"
label="变化指标"
options={metricOptions}
value={search.rankChangeMetric}
onValueChange={(value) =>
onChange({
page: 1,
rankChangeMetric:
value as SectorRadarRouteSearch["rankChangeMetric"],
})
}
/>
<FilterSelect
className="lg:col-span-2"
label="对比区间"
options={rankChangeDayOptions}
value={String(search.rankChangeDays)}
onValueChange={(value) =>
onChange({ page: 1, rankChangeDays: Number(value) })
}
/>
</>
) : null}
<FilterSelect
className="lg:col-span-2"
label="榜单范围"
options={sideOptions}
value={search.side}
onValueChange={(value) =>
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
}
/>
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
搜索板块
<Input
aria-label="搜索板块"
className="bg-background"
maxLength={100}
onChange={(event) =>
onChange({ page: 1, search: event.target.value })
}
placeholder="名称或代码"
type="search"
value={search.search}
/>
</label>
</div>
)
}
function FilterSelect({
className,
label,
onValueChange,
options,
value,
}: {
className?: string
label: string
onValueChange: (value: string) => void
options: readonly { label: string; value: string }[]
value: string
}) {
return (
<label
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
>
{label}
<Select
items={options}
onValueChange={(next) => {
if (typeof next === "string") onValueChange(next)
}}
value={value}
>
<SelectTrigger aria-label={label} className="bg-background">
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false}>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</label>
)
}
function RadarStatusSummary({
currentAttempt,
disclaimer,
lastGood,
metricVersion,
publication,
}: {
currentAttempt: RadarPublication | null
disclaimer: string
lastGood: RadarPublication | null
metricVersion: string
publication: RadarPublication
}) {
const degradedAttempt =
currentAttempt &&
currentAttempt.status !== "success" &&
currentAttempt.publication_id !== lastGood?.publication_id
return (
<Card className="shrink-0 shadow-none">
<CardHeader className="gap-3 p-4 pb-3">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<CardTitle className="text-base">收盘后板块资金排名</CardTitle>
<CardDescription className="mt-1">{disclaimer}</CardDescription>
</div>
<Badge variant="outline">知行独立实现</Badge>
<Badge variant="outline">{metricVersion}</Badge>
</div>
{degradedAttempt ? (
<div
role="status"
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
{attemptStatusLabel(currentAttempt.status)}:目标交易日{" "}
{currentAttempt.target_trade_date}
,当前仍展示最近有效发布{" "}
{lastGood?.target_trade_date ?? publication.target_trade_date}。
</div>
) : null}
</CardHeader>
<CardContent className="grid gap-3 p-4 pt-0 text-xs sm:grid-cols-2 lg:grid-cols-5">
<SummaryItem label="数据交易日" value={publication.target_trade_date} />
<SummaryItem
label="发布完成"
value={formatDateTime(publication.finished_at)}
/>
<SummaryItem
label="全局覆盖率"
value={formatCoverage(publication.coverage)}
/>
<SummaryItem label="事实来源" value={publication.source_version} />
<SummaryItem
label="板块范围版本"
value={publication.universe_version}
/>
</CardContent>
</Card>
)
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-muted-foreground">{label}</p>
<p className="mt-1 break-all font-medium text-foreground tabular-nums">
{value}
</p>
</div>
)
}
function RadarTable({
onPageChange,
onPageSizeChange,
page,
pageSize,
response,
}: {
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
page: number
pageSize: number
response: Exclude<
ReturnType<typeof useSectorRadarRankings>["data"],
undefined
>
}) {
const rankChangeView = response.view === "rank_change"
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
<table className="w-full min-w-[920px] border-collapse text-sm">
<caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名
</caption>
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur">
<tr>
<th className="px-3 py-2.5 font-medium" scope="col">
名次
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
板块
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
{rankChangeView
? `${response.rank_change_days} 日排名变化`
: response.definition.label}
</th>
{rankChangeView ? (
<th className="px-3 py-2.5 text-right font-medium" scope="col">
参考指标值
</th>
) : null}
<th className="px-3 py-2.5 text-right font-medium" scope="col">
排名百分位
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
样本
</th>
<th className="px-3 py-2.5 text-right font-medium" scope="col">
资金覆盖率
</th>
<th className="px-3 py-2.5 font-medium" scope="col">
质量
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{response.rows.map((row) => (
<RadarTableRow
key={`${row.sector_type}-${row.sector_code}`}
rankChangeView={rankChangeView}
row={row}
/>
))}
{response.rows.length === 0 ? (
<tr>
<td
className="px-3 py-12 text-center text-muted-foreground"
colSpan={rankChangeView ? 8 : 7}
>
没有符合当前筛选条件的板块。
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<Pagination
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
page={page}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
total={response.total}
/>
</section>
)
}
function RadarTableRow({
rankChangeView,
row,
}: {
rankChangeView: boolean
row: RadarRankingRow
}) {
return (
<tr className="hover:bg-muted/40">
<td className="px-3 py-2.5 font-semibold tabular-nums">
{row.rank_position ?? "—"}
</td>
<td className="px-3 py-2.5">
<p className="font-medium text-foreground">{row.sector_name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{row.sector_code}
</p>
</td>
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
{rankChangeView
? formatRankChange(row.rank_change)
: formatMetricValue(row.metric_value, row.unit)}
</td>
{rankChangeView ? (
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
{formatMetricValue(row.metric_value, row.unit)}
</td>
) : null}
<td className="px-3 py-2.5 text-right tabular-nums">
{row.rank_percentile === null
? "—"
: `${formatNumber(row.rank_percentile, 2)}%`}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{row.valid_sample_count}/{row.member_count}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{formatCoverage(row.moneyflow_coverage)}
</td>
<td className="px-3 py-2.5">
<QualityBadge quality={row.quality} />
</td>
</tr>
)
}
function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
if (quality === "available") return <Badge variant="outline">可用</Badge>
if (quality === "available_limited_sample") {
return (
<Badge
className="border-amber-500/40 text-amber-700 dark:text-amber-200"
variant="outline"
>
样本有限
</Badge>
)
}
return <Badge variant="destructive">不可用</Badge>
}
function RadarLoading() {
return (
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
<CardHeader className="gap-3">
<CardTitle className="text-base">正在加载板块资金雷达</CardTitle>
<CardDescription>正在读取最近有效发布和板块排名。</CardDescription>
<Skeleton className="h-5 w-52" />
<Skeleton className="h-4 w-80 max-w-full" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 7 }, (_, index) => (
<Skeleton className="h-10 w-full" key={index} />
))}
</CardContent>
</Card>
)
}
function RadarRefreshStatus() {
return (
<div
role="status"
className="shrink-0 rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-muted-foreground"
>
雷达数据正在刷新,当前继续展示上一次已读取的有效结果。
</div>
)
}
function RadarRefreshFailureStatus() {
return (
<div
role="status"
className="shrink-0 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-200"
>
雷达数据刷新失败,当前继续展示上一次已读取的有效结果。请稍后重试。
</div>
)
}
function RadarError({ onRetry }: { onRetry: () => void }) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<AlertTriangle
className="size-5 text-destructive"
aria-hidden="true"
/>
板块资金雷达暂时不可用
</CardTitle>
<CardDescription>
请求或响应校验失败,可能是服务、网络或接口契约暂时异常。
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={onRetry} variant="outline">
<RefreshCw aria-hidden="true" />
重试
</Button>
</CardContent>
</Card>
)
}
function RadarNoData() {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Database className="size-5 text-primary" aria-hidden="true" />
暂无板块资金发布
</CardTitle>
<CardDescription>
完成第一次收盘后构建并通过完整性门槛后,这里会显示最近有效排名。
</CardDescription>
</CardHeader>
</Card>
)
}
function formatMetricValue(value: number | null, unit: RadarMetricUnit) {
if (value === null) return "—"
return unit === "CNY_100M"
? `${formatNumber(value, 2)} 亿元`
: `${formatNumber(value * 100, 2)}%`
}
function formatRankChange(value: number | null) {
if (value === null) return "暂无可比历史"
if (value > 0) return `+${value}`
return String(value)
}
function formatCoverage(value: number) {
return `${formatNumber(value * 100, 1)}%`
}
function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits })
}
function formatDateTime(value: string | null) {
if (!value) return "—"
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Asia/Shanghai",
}).format(new Date(value))
}
function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整"
if (status === "failed") return "新一期构建失败"
return "新一期已发布"
}