7f93d6b0f5
- Introduced ActiveMoneyflowSource to fetch optional active-order flow, enhancing the sector radar's data capabilities. - Updated StockFactRecord and DailyAggregateRecord to include pct_change and active_buy_net_amount_yuan for improved financial insights. - Modified the build process to incorporate active moneyflow data without invalidating main rankings on failure. - Enhanced the HTTP API to return detailed sector history and metrics, including pct_change and active buy metrics for members. - Updated tests to validate the new functionality and ensure data integrity across various scenarios.
760 lines
23 KiB
TypeScript
760 lines
23 KiB
TypeScript
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`,
|
|
),
|
|
}
|
|
}),
|
|
}
|
|
}
|