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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user