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
@@ -5,6 +5,9 @@ const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import {
getSectorRadarHistory,
getSectorRadarDetail,
parseRadarDetailResponse,
getSectorRadarDates,
getSectorRadarRankings,
getStockSectorMembership,
@@ -69,11 +72,106 @@ const rankingPayload = {
],
}
const historyMetric = {
rank_position: 1,
rank_percentile: "100",
pool_size: 10,
metric_value: "0.125",
missing: false,
in_top: true,
in_bottom: false,
}
const historyPayload = {
status: "success",
requested_trade_date: "2026-08-28",
trade_date: "2026-08-28",
sector_type: "concept",
sector_code: "BK01",
sector_name: "机器人",
publication,
window_size: 30,
available_days: 1,
points: [
{
trade_date: "2026-08-28",
publication_id: "publication-1",
swing: historyMetric,
ratio: historyMetric,
amount: historyMetric,
},
],
}
const detailPayload = {
...historyPayload,
pct_change: "-1.25",
leading_stock: { ts_code: "000001.SZ", name: null },
summary: {
swing: historyMetric,
ratio: historyMetric,
amount: historyMetric,
},
history: historyPayload,
members: [
{
ts_code: "000001.SZ",
name: "测试成员",
pct_change: "-1.5",
net_amount_yuan: "120000000",
active_buy_net_amount_yuan: null,
},
],
leaders: {
pct_change: { top: [], bottom: [] },
net_amount_yuan: { top: [], bottom: [] },
active_buy_net_amount_yuan: { top: [], bottom: [] },
},
similar_sectors: [],
}
describe("sector radar API adapters", () => {
beforeEach(() => {
requestJson.mockReset()
})
it("reads persisted sector resources and preserves nullable independent metrics", async () => {
const signal = new AbortController().signal
const query = {
sectorType: "concept" as const,
sectorCode: "BK01",
tradeDate: "2026-08-28",
}
requestJson.mockResolvedValue(historyPayload)
expect(
(await getSectorRadarHistory(query, signal)).points[0]?.ratio
.metric_value,
).toBe(0.125)
expect(requestJson).toHaveBeenLastCalledWith(
"/api/v1/sector-radar/sectors/concept/BK01/history?trade_date=2026-08-28",
{ signal },
)
requestJson.mockResolvedValue(detailPayload)
const detail = await getSectorRadarDetail(query, signal)
expect(requestJson).toHaveBeenLastCalledWith(
"/api/v1/sector-radar/sectors/concept/BK01/detail?trade_date=2026-08-28",
{ signal },
)
expect(detail.pct_change).toBe(-1.25)
expect(detail.leading_stock?.name).toBeNull()
expect(detail.members[0]).toMatchObject({
net_amount_yuan: 120000000,
active_buy_net_amount_yuan: null,
})
})
it.each(["NaN", "Infinity", "0x10", "", true])(
"rejects malformed detail Decimal %s",
(value) => {
expect(() =>
parseRadarDetailResponse({ ...detailPayload, pct_change: value }),
).toThrow("detail.pct_change")
},
)
it("forwards AbortSignal and normalizes decimal publication fields", async () => {
const signal = new AbortController().signal
requestJson.mockResolvedValue({
@@ -237,6 +335,8 @@ describe("stock sector membership adapter", () => {
await expect(
getStockSectorMembership("000001.SZ", "2026-08-28"),
).rejects.toThrow("membership.industries[0].name must be a non-empty string")
).rejects.toThrow(
"membership.industries[0].name must be a non-empty string",
)
})
})
@@ -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`,
),
}
}),
}
}
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
const useQuery = vi.hoisted(() => vi.fn())
const useInfiniteQuery = vi.hoisted(() => vi.fn())
const api = vi.hoisted(() => ({
getSectorRadarHistory: vi.fn(),
getSectorRadarDetail: vi.fn(),
getSectorRadarDates: vi.fn(),
getSectorRadarRankings: vi.fn(),
}))
@@ -11,6 +13,9 @@ vi.mock("@tanstack/react-query", () => ({ useInfiniteQuery, useQuery }))
vi.mock("./sector-radar.api", () => api)
import {
sectorRadarSectorQueryKey,
useSectorRadarDetail,
useSectorRadarHistory,
sectorRadarDatesQueryKey,
sectorRadarRankingsQueryKey,
useSectorRadarDates,
@@ -36,6 +41,42 @@ describe("sector radar query hooks", () => {
useInfiniteQuery.mockImplementation((options) => options)
})
it("isolates sector resources by type, code and date and forwards cancellation", () => {
const sector = {
sectorType: "concept" as const,
sectorCode: "BK01",
tradeDate: "2026-08-28",
}
const signal = new AbortController().signal
useSectorRadarDetail(sector)
const options = useQuery.mock.calls.at(-1)?.[0]
expect(options.queryKey).toEqual([
"sectorRadar",
"detail",
"concept",
"BK01",
"2026-08-28",
])
expect(options.placeholderData).toBeUndefined()
options.queryFn({ signal })
expect(api.getSectorRadarDetail).toHaveBeenCalledWith(sector, signal)
expect(
sectorRadarSectorQueryKey("detail", {
...sector,
tradeDate: "2026-08-31",
}),
).not.toEqual(options.queryKey)
expect(
sectorRadarSectorQueryKey("detail", {
...sector,
sectorType: "industry",
}),
).not.toEqual(options.queryKey)
useSectorRadarHistory(sector)
useQuery.mock.calls.at(-1)?.[0].queryFn({ signal })
expect(api.getSectorRadarHistory).toHaveBeenCalledWith(sector, signal)
})
it("keeps filters in one ranking key without splitting cached pages", () => {
expect(sectorRadarDatesQueryKey).toEqual(["sectorRadar", "dates"])
expect(sectorRadarRankingsQueryKey(query)).toEqual([
@@ -4,10 +4,17 @@ import {
type InfiniteData,
} from "@tanstack/react-query"
import { getSectorRadarDates, getSectorRadarRankings, getStockSectorMembership } from "./sector-radar.api"
import {
getSectorRadarDates,
getSectorRadarRankings,
getStockSectorMembership,
getSectorRadarHistory,
getSectorRadarDetail,
} from "./sector-radar.api"
import type {
RadarRankingsQuery,
RadarRankingsResponse,
RadarSectorQuery,
} from "./sector-radar.types"
export const sectorRadarDatesQueryKey = ["sectorRadar", "dates"] as const
@@ -92,3 +99,31 @@ export function useSectorRadarRankings(query: RadarRankingsQuery) {
queryKey: sectorRadarRankingsQueryKey(query),
})
}
export const sectorRadarSectorQueryKey = (
resource: "history" | "detail",
query: RadarSectorQuery,
) =>
[
"sectorRadar",
resource,
query.sectorType,
query.sectorCode,
query.tradeDate,
] as const
/** Keep on-demand history isolated by sector type, code and requested date. */
export function useSectorRadarHistory(query: RadarSectorQuery) {
return useQuery({
queryKey: sectorRadarSectorQueryKey("history", query),
queryFn: ({ signal }) => getSectorRadarHistory(query, signal),
})
}
/** Mount only while the detail dialog is open; never reuse another date as placeholder data. */
export function useSectorRadarDetail(query: RadarSectorQuery) {
return useQuery({
queryKey: sectorRadarSectorQueryKey("detail", query),
queryFn: ({ signal }) => getSectorRadarDetail(query, signal),
})
}
@@ -77,6 +77,11 @@ export interface RadarRankingRow {
rank_percentile: number | null
rank_change_days: number
rank_change: number | null
pct_change: number | null
daily_net_amount_yuan: number | null
daily_ratio: number | null
on_list_count: number | null
history_available_days: number
}
export interface RadarRankingsResponse {
@@ -127,3 +132,68 @@ export interface StockSectorMembershipResponse {
concept_total: number
concept_limit: number
}
export interface RadarSectorQuery {
sectorType: SectorType
sectorCode: string
tradeDate: string
}
export interface RadarHistoryMetric {
rank_position: number | null
rank_percentile: number | null
pool_size: number
metric_value: number | null
missing: boolean
in_top: boolean
in_bottom: boolean
}
export type RadarMetricSummary = Record<RadarMetricKind, RadarHistoryMetric>
export interface RadarHistoryPoint extends RadarMetricSummary {
trade_date: string
publication_id: string | null
}
export interface RadarHistoryResponse {
status: "success" | "no_data"
requested_trade_date: string | null
trade_date: string | null
publication: RadarPublication | null
sector_type: SectorType
sector_code: string
sector_name: string | null
points: RadarHistoryPoint[]
window_size: number
available_days: number
}
export interface RadarMember {
ts_code: string
name: string
pct_change: number | null
net_amount_yuan: number | null
active_buy_net_amount_yuan: number | null
}
export type RadarMemberMetric =
"pct_change" | "net_amount_yuan" | "active_buy_net_amount_yuan"
export interface RadarDetailResponse extends Omit<
RadarHistoryResponse,
"points" | "window_size" | "available_days"
> {
pct_change: number | null
leading_stock: { ts_code: string; name: string | null } | null
summary: RadarMetricSummary
history: RadarHistoryResponse
members: RadarMember[]
leaders: Record<
RadarMemberMetric,
{ top: RadarMember[]; bottom: RadarMember[] }
>
similar_sectors: {
sector_type: SectorType
sector_code: string
sector_name: string
overlap_ratio: number
intersection_count: number
union_count: number
}[]
}