feat(sector-radar): 完成前端排名工作台

This commit is contained in:
yuxuanhui
2026-08-29 20:14:42 +08:00
parent efc4c3d530
commit 8e96e64151
12 changed files with 1913 additions and 11 deletions
@@ -0,0 +1,163 @@
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")
})
})
@@ -0,0 +1,422 @@
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: readNullableNumberInRange(
record.rank_percentile,
0,
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 readNullableNumberInRange(
value: unknown,
min: number,
max: number,
path: string,
): number | null {
if (value === null) return null
const number = readFiniteNumber(value, path)
if (number < min || number > max) {
throw contractError(path, `must be between ${min} and ${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
}