feat(sector_radar): enhance sector radar functionality with active moneyflow and detailed metrics
- 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.
This commit is contained in:
@@ -9,6 +9,11 @@ import {
|
||||
radarViews,
|
||||
sectorTypes,
|
||||
type RadarDatesResponse,
|
||||
type RadarSectorQuery,
|
||||
type RadarHistoryResponse,
|
||||
type RadarHistoryMetric,
|
||||
type RadarDetailResponse,
|
||||
type RadarMember,
|
||||
type RadarMetricDefinition,
|
||||
type RadarPublication,
|
||||
type RadarRankingRow,
|
||||
@@ -176,7 +181,11 @@ export function parseStockSectorMembershipResponse(
|
||||
): StockSectorMembershipResponse {
|
||||
const record = readRecord(value, "membership")
|
||||
return {
|
||||
status: readEnum(record.status, ["success", "no_data"], "membership.status"),
|
||||
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,
|
||||
@@ -309,6 +318,33 @@ function readRankingRow(value: unknown, index: number): RadarRankingRow {
|
||||
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,
|
||||
@@ -427,7 +463,8 @@ function readFiniteNumber(value: unknown, path: string): number {
|
||||
const number =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim().length > 0
|
||||
: 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")
|
||||
@@ -483,3 +520,240 @@ function readNullablePositiveNumber(
|
||||
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`,
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user