feat: 题材热点历史页展示最近10个交易日题材涨幅矩阵
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
"""题材数据路由:题材列表、题材详情、题材相关股票"""
|
"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, HTTPException
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -52,6 +52,55 @@ async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active", summary="活跃题材 + 最近10日涨幅矩阵")
|
||||||
|
async def active_themes():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
# 最近 10 个有数据的交易日(升序)
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT DISTINCT trade_date FROM daily_top_themes ORDER BY trade_date DESC LIMIT 10"
|
||||||
|
).fetchall()
|
||||||
|
dates = [r["trade_date"] for r in reversed(rows)]
|
||||||
|
if not dates:
|
||||||
|
return JSONResponse({"dates": [], "themes": []}, headers=_NO_CACHE_HEADERS)
|
||||||
|
|
||||||
|
# 窗口内全部题材上榜记录,按天分组排列(当日 rank 即排名)
|
||||||
|
placeholders = ",".join("?" * len(dates))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT trade_date, theme_code, theme_name, bf3, rank FROM daily_top_themes
|
||||||
|
WHERE trade_date IN ({placeholders})
|
||||||
|
ORDER BY trade_date DESC, rank ASC""",
|
||||||
|
dates,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 按题材聚合:每日涨幅矩阵 + 上榜次数 + 最近上榜日 + 最佳排名
|
||||||
|
theme_map: dict[str, dict] = {}
|
||||||
|
for r in rows:
|
||||||
|
code = r["theme_code"]
|
||||||
|
t = theme_map.setdefault(code, {
|
||||||
|
"themeCode": code,
|
||||||
|
"themeName": r["theme_name"],
|
||||||
|
"dailyGains": {},
|
||||||
|
"appearCount": 0,
|
||||||
|
"lastAppear": None,
|
||||||
|
"bestRank": None,
|
||||||
|
})
|
||||||
|
t["dailyGains"][r["trade_date"]] = r["bf3"]
|
||||||
|
t["appearCount"] += 1
|
||||||
|
if t["lastAppear"] is None or r["trade_date"] > t["lastAppear"]:
|
||||||
|
t["lastAppear"] = r["trade_date"]
|
||||||
|
if t["bestRank"] is None or (r["rank"] or 0) < t["bestRank"]:
|
||||||
|
t["bestRank"] = r["rank"] or 0
|
||||||
|
|
||||||
|
themes_list = list(theme_map.values())
|
||||||
|
# 稳定排序:先按上榜次数降序,再按最佳排名升序
|
||||||
|
themes_list.sort(key=lambda x: x["bestRank"] if x["bestRank"] is not None else 10**9)
|
||||||
|
themes_list.sort(key=lambda x: -x["appearCount"])
|
||||||
|
return JSONResponse({"dates": dates, "themes": themes_list}, headers=_NO_CACHE_HEADERS)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
||||||
async def theme_news(
|
async def theme_news(
|
||||||
theme_code: str,
|
theme_code: str,
|
||||||
|
|||||||
@@ -3,6 +3,35 @@ import { getApiBaseUrl } from "@/lib/api-client";
|
|||||||
|
|
||||||
/* ── 题材列表 ── */
|
/* ── 题材列表 ── */
|
||||||
|
|
||||||
|
export interface ActiveTheme {
|
||||||
|
themeCode: string;
|
||||||
|
themeName: string;
|
||||||
|
dailyGains: Record<string, number | null>; // trade_date -> 当日题材涨幅 bf3(可空)
|
||||||
|
appearCount: number; // 窗口内上榜次数
|
||||||
|
lastAppear: string | null; // 最近上榜交易日
|
||||||
|
bestRank: number | null; // 窗口内最佳排名(rank 最小值)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveThemesResponse {
|
||||||
|
dates: string[];
|
||||||
|
themes: ActiveTheme[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最近 10 个交易日的活跃题材涨幅矩阵
|
||||||
|
*/
|
||||||
|
export async function fetchActiveThemes(): Promise<ActiveThemesResponse> {
|
||||||
|
const baseUrl = getApiBaseUrl();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${baseUrl}/api/themes/active`, { method: "GET", cache: "no-store" });
|
||||||
|
if (!resp.ok) return { dates: [], themes: [] };
|
||||||
|
return resp.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[theme-api] 获取活跃题材历史失败:", err);
|
||||||
|
return { dates: [], themes: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThemeItem {
|
export interface ThemeItem {
|
||||||
themeCode: string;
|
themeCode: string;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ThemesRouteImport } from './routes/themes'
|
import { Route as ThemesRouteImport } from './routes/themes'
|
||||||
|
import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
|
||||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||||
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
@@ -22,6 +23,11 @@ const ThemesRoute = ThemesRouteImport.update({
|
|||||||
path: '/themes',
|
path: '/themes',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ThemeHistoryRoute = ThemeHistoryRouteImport.update({
|
||||||
|
id: '/theme-history',
|
||||||
|
path: '/theme-history',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const HotMapRoute = HotMapRouteImport.update({
|
const HotMapRoute = HotMapRouteImport.update({
|
||||||
id: '/hot-map',
|
id: '/hot-map',
|
||||||
path: '/hot-map',
|
path: '/hot-map',
|
||||||
@@ -57,6 +63,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
@@ -66,6 +73,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
@@ -76,6 +84,7 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/core-stocks': typeof CoreStocksRoute
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
|
'/theme-history': typeof ThemeHistoryRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
'/share/$code': typeof ShareCodeRoute
|
'/share/$code': typeof ShareCodeRoute
|
||||||
'/stock/$code': typeof StockCodeRoute
|
'/stock/$code': typeof StockCodeRoute
|
||||||
@@ -87,6 +96,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
| '/share/$code'
|
| '/share/$code'
|
||||||
| '/stock/$code'
|
| '/stock/$code'
|
||||||
@@ -96,6 +106,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
| '/share/$code'
|
| '/share/$code'
|
||||||
| '/stock/$code'
|
| '/stock/$code'
|
||||||
@@ -105,6 +116,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/core-stocks'
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
|
| '/theme-history'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
| '/share/$code'
|
| '/share/$code'
|
||||||
| '/stock/$code'
|
| '/stock/$code'
|
||||||
@@ -115,6 +127,7 @@ export interface RootRouteChildren {
|
|||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
CoreStocksRoute: typeof CoreStocksRoute
|
CoreStocksRoute: typeof CoreStocksRoute
|
||||||
HotMapRoute: typeof HotMapRoute
|
HotMapRoute: typeof HotMapRoute
|
||||||
|
ThemeHistoryRoute: typeof ThemeHistoryRoute
|
||||||
ThemesRoute: typeof ThemesRoute
|
ThemesRoute: typeof ThemesRoute
|
||||||
ShareCodeRoute: typeof ShareCodeRoute
|
ShareCodeRoute: typeof ShareCodeRoute
|
||||||
StockCodeRoute: typeof StockCodeRoute
|
StockCodeRoute: typeof StockCodeRoute
|
||||||
@@ -130,6 +143,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ThemesRouteImport
|
preLoaderRoute: typeof ThemesRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/theme-history': {
|
||||||
|
id: '/theme-history'
|
||||||
|
path: '/theme-history'
|
||||||
|
fullPath: '/theme-history'
|
||||||
|
preLoaderRoute: typeof ThemeHistoryRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/hot-map': {
|
'/hot-map': {
|
||||||
id: '/hot-map'
|
id: '/hot-map'
|
||||||
path: '/hot-map'
|
path: '/hot-map'
|
||||||
@@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
CoreStocksRoute: CoreStocksRoute,
|
CoreStocksRoute: CoreStocksRoute,
|
||||||
HotMapRoute: HotMapRoute,
|
HotMapRoute: HotMapRoute,
|
||||||
|
ThemeHistoryRoute: ThemeHistoryRoute,
|
||||||
ThemesRoute: ThemesRoute,
|
ThemesRoute: ThemesRoute,
|
||||||
ShareCodeRoute: ShareCodeRoute,
|
ShareCodeRoute: ShareCodeRoute,
|
||||||
StockCodeRoute: StockCodeRoute,
|
StockCodeRoute: StockCodeRoute,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchActiveThemes } from "@/lib/theme-api";
|
||||||
|
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/theme-history")({
|
||||||
|
component: ThemeHistoryPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 格式化涨幅,红涨绿跌 */
|
||||||
|
function formatGain(v: number | null | undefined): string {
|
||||||
|
if (v == null) return "·";
|
||||||
|
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeHistoryPage() {
|
||||||
|
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||||
|
queryKey: ["themes", "active"],
|
||||||
|
queryFn: fetchActiveThemes,
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dates = data?.dates ?? [];
|
||||||
|
const themes = data?.themes ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* 顶栏 */}
|
||||||
|
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link to="/themes" className="hover:opacity-70 transition-opacity" aria-label="返回">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-base font-semibold">题材热点历史</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="刷新"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
活跃题材(最近 10 个交易日内上榜涨幅前20)· 按上榜次数排序 · 共 {themes.length} 个
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||||
|
) : dates.length === 0 || themes.length === 0 ? (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-16">
|
||||||
|
暂无数据,数据将在每日收盘后自动采集
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">题材</th>
|
||||||
|
{dates.map((d) => (
|
||||||
|
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||||
|
{d.slice(5)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{themes.map((t) => (
|
||||||
|
<tr key={t.themeCode} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
to="/theme/$code"
|
||||||
|
params={{ code: t.themeCode }}
|
||||||
|
className="font-medium hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t.themeName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
{dates.map((d) => {
|
||||||
|
const g = t.dailyGains[d];
|
||||||
|
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||||
|
return (
|
||||||
|
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||||
|
{formatGain(g)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||||
|
<Flame className="h-3 w-3" />
|
||||||
|
{t.appearCount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
TrendingDown,
|
TrendingDown,
|
||||||
Flame,
|
Flame,
|
||||||
Network,
|
Network,
|
||||||
|
History,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/themes")({
|
export const Route = createFileRoute("/themes")({
|
||||||
@@ -78,6 +79,13 @@ function ThemesPage() {
|
|||||||
<Flame className="h-3.5 w-3.5" />
|
<Flame className="h-3.5 w-3.5" />
|
||||||
热点股
|
热点股
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/theme-history"
|
||||||
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
历史
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user