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 /** * 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("/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( `/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( `/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( 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( 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( 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`, ), } }), } }