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
@@ -0,0 +1,331 @@
import { lazy, Suspense, useState } from "react"
import { Button } from "@/shared/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from "@/shared/ui/dialog"
import { useSectorRadarDetail } from "../api/sector-radar.query"
import type {
RadarDetailResponse,
RadarMemberMetric,
RadarMetricKind,
RadarRankingRow,
RadarSectorQuery,
} from "../api/sector-radar.types"
import {
formatRadarValue,
radarMembersCsv,
radarValueTone,
} from "./radar-format"
import { RadarHistoryGrid } from "./radar-history"
const RadarRankChart = lazy(() =>
import("./radar-rank-chart").then((module) => ({
default: module.RadarRankChart,
})),
)
const metricLabels = {
swing: "波段资金率",
ratio: "单日流入率",
amount: "单日净额",
} as const
const memberLabels = {
pct_change: "涨跌幅",
net_amount_yuan: "主力净额",
active_buy_net_amount_yuan: "主买净额",
} as const
/** Keep the trigger inside the dialog primitive so Escape and close restore its focus. */
export function RadarDetailDialog({ row }: { row: RadarRankingRow }) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="font-medium text-foreground underline-offset-4 hover:text-primary hover:underline">
{row.sector_name}
</DialogTrigger>
<DialogContent className="max-h-[90dvh] grid-cols-1 overflow-y-auto sm:max-w-4xl">
<DialogTitle>{row.sector_name}</DialogTitle>
<DialogDescription>
{row.sector_type === "concept" ? "概念" : "行业"} · {row.sector_code}{" "}
· {row.trade_date}
</DialogDescription>
{open ? (
<RadarDetail
initialMetric={row.metric_kind}
query={{
sectorType: row.sector_type,
sectorCode: row.sector_code,
tradeDate: row.trade_date,
}}
/>
) : null}
</DialogContent>
</Dialog>
)
}
function RadarDetail({
query,
initialMetric,
}: {
query: RadarSectorQuery
initialMetric: RadarMetricKind
}) {
const detail = useSectorRadarDetail(query)
if (detail.isPending) return <p role="status">正在加载板块详情…</p>
if (detail.isError)
return (
<div role="alert">
板块详情加载失败。
<Button onClick={() => void detail.refetch()} variant="outline">
重试详情
</Button>
</div>
)
if (!detail.data || detail.data.status === "no_data")
return <p>暂无板块详情数据。</p>
return <RadarDetailBody data={detail.data} initialMetric={initialMetric} />
}
function RadarDetailBody({
data,
initialMetric,
}: {
data: RadarDetailResponse
initialMetric: RadarMetricKind
}) {
const [metric, setMetric] = useState<RadarMetricKind>(initialMetric)
const [memberMetric, setMemberMetric] =
useState<RadarMemberMetric>("pct_change")
const [memberSide, setMemberSide] = useState<"top" | "bottom">("top")
const [exportStatus, setExportStatus] = useState("")
const leaders = data.leaders[memberMetric]
async function copyMembers() {
try {
await navigator.clipboard.writeText(
data.members
.map((member) => `${member.ts_code}\t${member.name}`)
.join("\n"),
)
setExportStatus(`已复制 ${data.members.length} 只成员。`)
} catch {
setExportStatus("复制失败,请使用导出成员。")
}
}
function exportMembers() {
const url = URL.createObjectURL(
new Blob(["\ufeff", radarMembersCsv(data.members)], {
type: "text/csv;charset=utf-8",
}),
)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = `${data.sector_code}-${data.trade_date}-members.csv`
anchor.click()
URL.revokeObjectURL(url)
setExportStatus(`已导出 ${data.members.length} 只成员。`)
}
return (
<div className="min-w-0 space-y-5">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={!data.members.length}
onClick={() => void copyMembers()}
>
复制成员
</Button>
<Button
variant="outline"
size="sm"
disabled={!data.members.length}
onClick={exportMembers}
>
导出成员
</Button>
</div>
{exportStatus ? <p role="status">{exportStatus}</p> : null}
<div className="flex flex-wrap gap-x-6 gap-y-2 rounded-md bg-muted/50 p-3">
<p>实际交易日:{data.trade_date ?? "—"}</p>
<p>
当日涨跌幅:
<span className={radarValueTone(data.pct_change)}>
{formatRadarValue(data.pct_change, "percent")}
</span>
</p>
<p>
领涨股:
{data.leading_stock
? `${data.leading_stock.name ?? ""} ${data.leading_stock.ts_code}`
: "暂无数据"}
</p>
</div>
<section aria-label="三指标排名" className="space-y-3">
<div className="grid grid-cols-3 gap-2">
{(["swing", "ratio", "amount"] as const).map((key) => (
<button
type="button"
key={key}
onClick={() => setMetric(key)}
aria-pressed={metric === key}
className={`min-w-0 rounded-md border p-2 text-left break-words sm:p-3 ${metric === key ? "border-primary bg-primary/5" : "border-border"}`}
>
<span className="block text-xs text-muted-foreground">
{metricLabels[key]}
</span>
<span className="mt-1 block font-semibold">
{data.summary[key].missing ||
data.summary[key].rank_position === null
? "暂无排名"
: `第 ${data.summary[key].rank_position} 名 / ${data.summary[key].pool_size}`}
</span>
<span className={radarValueTone(data.summary[key].metric_value)}>
{formatRadarValue(
data.summary[key].metric_value,
key === "amount" ? "CNY_100M" : "ratio",
)}
</span>
</button>
))}
</div>
{data.history.points.some(
(point) =>
!point[metric].missing && point[metric].rank_position !== null,
) ? (
<Suspense fallback={<p role="status">正在加载排名曲线…</p>}>
<RadarRankChart
points={data.history.points}
metric={metric}
label={metricLabels[metric]}
/>
</Suspense>
) : (
<p className="rounded-md bg-muted/30 p-4 text-muted-foreground">
暂无{metricLabels[metric]}排名轨迹,当前历史记录均缺失。
</p>
)}
<RadarHistoryGrid history={data.history} metric={metric} />
</section>
<section aria-label="成分强弱" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="mr-auto font-medium">成分强弱</h3>
{(Object.keys(memberLabels) as RadarMemberMetric[]).map((key) => (
<Button
key={key}
variant={key === memberMetric ? "default" : "outline"}
size="sm"
aria-pressed={key === memberMetric}
onClick={() => setMemberMetric(key)}
>
{memberLabels[key]}
</Button>
))}
</div>
<div aria-label="成分排名方向" className="flex justify-end gap-1">
{(["top", "bottom"] as const).map((side) => (
<Button
key={side}
size="sm"
variant={memberSide === side ? "default" : "outline"}
aria-pressed={memberSide === side}
onClick={() => setMemberSide(side)}
>
{side === "top" ? "前 5" : "后 5"}
</Button>
))}
</div>
<div>
{[memberSide].map((side) => (
<div key={side} className="rounded-md border border-border p-3">
<h4 className="mb-2 font-medium">
{side === "top" ? "前 5 名" : "后 5 名"}
</h4>
{leaders[side].length ? (
<ol className="space-y-2">
{leaders[side].map((member) => (
<li
key={member.ts_code}
className="flex justify-between gap-3"
>
<span>
{member.name}{" "}
<span className="text-xs text-muted-foreground">
{member.ts_code}
</span>
</span>
<span
className={`tabular-nums ${radarValueTone(member[memberMetric])}`}
>
{formatRadarValue(
member[memberMetric],
memberMetric === "pct_change" ? "percent" : "yuan",
)}
</span>
</li>
))}
</ol>
) : (
<p className="text-muted-foreground">暂无有效数据</p>
)}
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
仅展示有该指标的成员,不足 5
只按实际数量显示。主买净额与主力净额为不同来源指标。
</p>
</section>
<section aria-label="相似板块" className="space-y-2">
<h3 className="font-medium">相似板块</h3>
<p className="text-xs text-muted-foreground">
本系统口径:同日可确认成员交集 / 并集(Jaccard 重合度)。
</p>
{data.similar_sectors.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{data.similar_sectors.map((sector) => (
<div
className="flex justify-between rounded-md border border-border p-3"
key={`${sector.sector_type}:${sector.sector_code}`}
>
<span>
{sector.sector_name}{" "}
<span className="text-xs text-muted-foreground">
{sector.sector_type === "concept" ? "概念" : "行业"}
</span>
</span>
<span>
{(sector.overlap_ratio * 100).toFixed(1)}%{" "}
<span className="text-xs text-muted-foreground">
({sector.intersection_count}/{sector.union_count})
</span>
</span>
</div>
))}
</div>
) : (
<p className="text-muted-foreground">暂无相似板块数据</p>
)}
</section>
<section aria-label="板块成员" className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="mr-auto font-medium">
板块成员({data.members.length})
</h3>
</div>
<div className="max-h-44 overflow-auto rounded-md border border-border p-3">
<p className="leading-7 text-muted-foreground">
{data.members.length
? data.members
.map((member) => `${member.name} (${member.ts_code})`)
.join("、")
: "暂无成员数据"}
</p>
</div>
</section>
</div>
)
}