feat(sector-radar): 完成前端排名工作台
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user