423 lines
12 KiB
TypeScript
423 lines
12 KiB
TypeScript
|
|
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}`)
|
||
|
|
}
|