Files
zhixing-system/zhixing-web/src/features/sector-radar/api/sector-radar.api.ts
T

486 lines
14 KiB
TypeScript
Raw Normal View History

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,
type StockSectorMembershipResponse,
type StockSectorRef,
} 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),
),
}
}
/**
* Fetch one stock's industries and concepts from the last-good build snapshot.
*
* @param tsCode - Stock code such as `000001.SZ`.
* @param tradeDate - Requested trade date; the backend answers with the
* newest snapshot at or before it and reports that exact date.
* @param signal - React Query cancellation signal forwarded to fetch.
* @returns A validated membership response with grouped sector references.
* @throws Error when the server response violates the stable HTTP contract.
*/
export async function getStockSectorMembership(
tsCode: string,
tradeDate: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({ trade_date: tradeDate })
const payload = await requestJson<unknown>(
`/api/v1/sector-radar/stocks/${encodeURIComponent(tsCode)}/membership?${params.toString()}`,
{ signal },
)
return parseStockSectorMembershipResponse(payload)
}
/** Validate one stock-membership response at the feature boundary. */
export function parseStockSectorMembershipResponse(
value: unknown,
): StockSectorMembershipResponse {
const record = readRecord(value, "membership")
return {
status: readEnum(record.status, ["success", "no_data"], "membership.status"),
ts_code: readNonEmptyString(record.ts_code, "membership.ts_code"),
requested_trade_date: readDate(
record.requested_trade_date,
"membership.requested_trade_date",
),
trade_date: readNullableDate(record.trade_date, "membership.trade_date"),
industries: readSectorRefs(record.industries, "membership.industries"),
concepts: readSectorRefs(record.concepts, "membership.concepts"),
concept_total: readIntegerInRange(
record.concept_total,
0,
Number.MAX_SAFE_INTEGER,
"membership.concept_total",
),
concept_limit: readIntegerInRange(
record.concept_limit,
1,
100,
"membership.concept_limit",
),
}
}
function readSectorRefs(value: unknown, path: string): StockSectorRef[] {
return readArray(value, path).map((item, index) => {
const ref = readRecord(item, `${path}[${index}]`)
return {
code: readNonEmptyString(ref.code, `${path}[${index}].code`),
name: readNonEmptyString(ref.name, `${path}[${index}].name`),
}
})
}
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}`)
}