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:
yuxuanhui
2026-09-06 16:06:17 +08:00
parent 68282f5d46
commit 7f93d6b0f5
48 changed files with 4883 additions and 95 deletions
@@ -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`,
),
}
}),
}
}