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

760 lines
23 KiB
TypeScript
Raw Normal View History

import { requestJson } from "@/shared/api/request-json"
import {
radarMetricKinds,
radarMetricQualities,
radarMetricUnits,
radarPublicationStatuses,
radarRankSides,
radarViews,
sectorTypes,
type RadarDatesResponse,
type RadarSectorQuery,
type RadarHistoryResponse,
type RadarHistoryMetric,
type RadarDetailResponse,
type RadarMember,
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`,
),
pct_change: readNullableFiniteNumber(
record.pct_change ?? null,
`${path}.pct_change`,
),
daily_net_amount_yuan: readNullableFiniteNumber(
record.daily_net_amount_yuan ?? null,
`${path}.daily_net_amount_yuan`,
),
daily_ratio: readNullableFiniteNumber(
record.daily_ratio ?? null,
`${path}.daily_ratio`,
),
on_list_count:
record.on_list_count == null
? null
: readIntegerInRange(
record.on_list_count,
0,
30,
`${path}.on_list_count`,
),
history_available_days: readIntegerInRange(
record.history_available_days ?? 0,
0,
30,
`${path}.history_available_days`,
),
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" &&
/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())
? 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}`)
}
/** Fetch persisted history for a sector/date; forward cancellation and reject malformed decimals. */
export async function getSectorRadarHistory(
query: RadarSectorQuery,
signal?: AbortSignal,
) {
const payload = await requestJson<unknown>(
sectorResourceUrl(query, "history"),
{ signal },
)
return parseRadarHistoryResponse(payload)
}
/** Fetch persisted detail on demand; no supplier calls originate from the browser. */
export async function getSectorRadarDetail(
query: RadarSectorQuery,
signal?: AbortSignal,
) {
const payload = await requestJson<unknown>(
sectorResourceUrl(query, "detail"),
{ signal },
)
return parseRadarDetailResponse(payload)
}
function sectorResourceUrl(
query: RadarSectorQuery,
resource: "history" | "detail",
) {
const params = new URLSearchParams({ trade_date: query.tradeDate })
return `/api/v1/sector-radar/sectors/${query.sectorType}/${encodeURIComponent(query.sectorCode)}/${resource}?${params}`
}
function readSectorIdentity(record: JsonRecord, path: string) {
return {
status: readEnum(record.status, ["success", "no_data"], `${path}.status`),
requested_trade_date: readNullableDate(
record.requested_trade_date,
`${path}.requested_trade_date`,
),
trade_date: readNullableDate(record.trade_date, `${path}.trade_date`),
publication: readNullablePublication(
record.publication,
`${path}.publication`,
),
sector_type: readEnum(
record.sector_type,
sectorTypes,
`${path}.sector_type`,
),
sector_code: readNonEmptyString(record.sector_code, `${path}.sector_code`),
sector_name: readNullableString(record.sector_name, `${path}.sector_name`),
}
}
/** Normalize the finite Decimal fields while preserving missing ranks as null. */
export function parseRadarHistoryResponse(
value: unknown,
): RadarHistoryResponse {
const record = readRecord(value, "history")
return {
...readSectorIdentity(record, "history"),
window_size: readIntegerInRange(
record.window_size,
1,
30,
"history.window_size",
),
available_days: readIntegerInRange(
record.available_days,
0,
30,
"history.available_days",
),
points: readArray(record.points, "history.points").map((value, index) => {
const path = `history.points[${index}]`
const point = readRecord(value, path)
return {
trade_date: readDate(point.trade_date, `${path}.trade_date`),
publication_id: readNullableString(
point.publication_id,
`${path}.publication_id`,
),
...readMetricSummary(point, path),
}
}),
}
}
function readMetricSummary(record: JsonRecord, path: string) {
return {
amount: readHistoryMetric(record.amount, `${path}.amount`),
ratio: readHistoryMetric(record.ratio, `${path}.ratio`),
swing: readHistoryMetric(record.swing, `${path}.swing`),
}
}
function readHistoryMetric(value: unknown, path: string): RadarHistoryMetric {
const record = readRecord(value, path)
return {
rank_position: readNullableInteger(
record.rank_position,
1,
`${path}.rank_position`,
),
rank_percentile: readNullablePositiveNumber(
record.rank_percentile,
100,
`${path}.rank_percentile`,
),
pool_size: readIntegerInRange(
record.pool_size,
0,
Number.MAX_SAFE_INTEGER,
`${path}.pool_size`,
),
metric_value: readNullableFiniteNumber(
record.metric_value,
`${path}.metric_value`,
),
missing: readBoolean(record.missing, `${path}.missing`),
in_top: readBoolean(record.in_top, `${path}.in_top`),
in_bottom: readBoolean(record.in_bottom, `${path}.in_bottom`),
}
}
function readBoolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw contractError(path, "must be a boolean")
return value
}
function readMembers(value: unknown, path: string): RadarMember[] {
return readArray(value, path).map((value, index) => {
const itemPath = `${path}[${index}]`
const record = readRecord(value, itemPath)
return {
ts_code: readNonEmptyString(record.ts_code, `${itemPath}.ts_code`),
name: readNonEmptyString(record.name, `${itemPath}.name`),
pct_change: readNullableFiniteNumber(
record.pct_change,
`${itemPath}.pct_change`,
),
net_amount_yuan: readNullableFiniteNumber(
record.net_amount_yuan,
`${itemPath}.net_amount_yuan`,
),
active_buy_net_amount_yuan: readNullableFiniteNumber(
record.active_buy_net_amount_yuan,
`${itemPath}.active_buy_net_amount_yuan`,
),
}
})
}
/** Validate the full detail payload, including independent member metrics and overlaps. */
export function parseRadarDetailResponse(value: unknown): RadarDetailResponse {
const record = readRecord(value, "detail")
const leading =
record.leading_stock === null
? null
: readRecord(record.leading_stock, "detail.leading_stock")
const leaders = readRecord(record.leaders, "detail.leaders")
function readLeaders(key: string) {
const path = `detail.leaders.${key}`
const group = readRecord(leaders[key], path)
return {
top: readMembers(group.top, `${path}.top`),
bottom: readMembers(group.bottom, `${path}.bottom`),
}
}
return {
...readSectorIdentity(record, "detail"),
pct_change: readNullableFiniteNumber(
record.pct_change,
"detail.pct_change",
),
leading_stock:
leading === null
? null
: {
ts_code: readNonEmptyString(
leading.ts_code,
"detail.leading_stock.ts_code",
),
name: readNullableString(leading.name, "detail.leading_stock.name"),
},
summary: readMetricSummary(
readRecord(record.summary, "detail.summary"),
"detail.summary",
),
history: parseRadarHistoryResponse(record.history),
members: readMembers(record.members, "detail.members"),
leaders: {
pct_change: readLeaders("pct_change"),
net_amount_yuan: readLeaders("net_amount_yuan"),
active_buy_net_amount_yuan: readLeaders("active_buy_net_amount_yuan"),
},
similar_sectors: readArray(
record.similar_sectors,
"detail.similar_sectors",
).map((value, index) => {
const path = `detail.similar_sectors[${index}]`
const sector = readRecord(value, path)
return {
sector_type: readEnum(
sector.sector_type,
sectorTypes,
`${path}.sector_type`,
),
sector_code: readNonEmptyString(
sector.sector_code,
`${path}.sector_code`,
),
sector_name: readNonEmptyString(
sector.sector_name,
`${path}.sector_name`,
),
overlap_ratio: readFraction(
sector.overlap_ratio,
`${path}.overlap_ratio`,
),
intersection_count: readIntegerInRange(
sector.intersection_count,
0,
Number.MAX_SAFE_INTEGER,
`${path}.intersection_count`,
),
union_count: readIntegerInRange(
sector.union_count,
1,
Number.MAX_SAFE_INTEGER,
`${path}.union_count`,
),
}
}),
}
}