diff --git a/backend/database.py b/backend/database.py index fa58cf2..3453b62 100644 --- a/backend/database.py +++ b/backend/database.py @@ -37,6 +37,40 @@ CREATE TABLE IF NOT EXISTS cache ( value TEXT NOT NULL, expires_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS daily_core_stocks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT NOT NULL, + stock_code TEXT NOT NULL, + stock_name TEXT NOT NULL, + f3 REAL, + cover_count INTEGER, + rank INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + UNIQUE(trade_date, stock_code) +); + +CREATE TABLE IF NOT EXISTS daily_core_stock_themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT NOT NULL, + stock_code TEXT NOT NULL, + theme_code TEXT NOT NULL, + theme_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + UNIQUE(trade_date, stock_code, theme_code) +); + +CREATE TABLE IF NOT EXISTS daily_top_themes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trade_date TEXT NOT NULL, + theme_code TEXT NOT NULL, + theme_name TEXT NOT NULL, + bf3 REAL, + hot_rank INTEGER, + rank INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')), + UNIQUE(trade_date, theme_code) +); """ diff --git a/backend/main.py b/backend/main.py index e2598a3..dc11b50 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,4 @@ +import asyncio import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -6,7 +7,8 @@ from contextlib import asynccontextmanager from dotenv import load_dotenv from database import init_db -from routes import stock, collections, shares, sectors, themes +from routes import stock, collections, shares, sectors, themes, core_stocks +from services.daily_collector import collector_loop load_dotenv() @@ -14,7 +16,15 @@ load_dotenv() @asynccontextmanager async def lifespan(app: FastAPI): init_db() - yield + collector_task = asyncio.create_task(collector_loop()) + try: + yield + finally: + collector_task.cancel() + try: + await collector_task + except asyncio.CancelledError: + pass app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan) @@ -32,6 +42,7 @@ app.include_router(collections.router, prefix="/api/collections") app.include_router(shares.router, prefix="/api/share") app.include_router(sectors.router, prefix="/api/sectors") app.include_router(themes.router, prefix="/api/themes") +app.include_router(core_stocks.router, prefix="/api/core-stocks") # 生产模式:后端同时托管前端静态文件 # catch-all 路由在 API 路由之后注册,所以 API 优先级更高 diff --git a/backend/routes/core_stocks.py b/backend/routes/core_stocks.py new file mode 100644 index 0000000..a6564af --- /dev/null +++ b/backend/routes/core_stocks.py @@ -0,0 +1,104 @@ +"""核心股历史接口:活跃核心股 + 指定日核心股/题材前10""" + +from datetime import date as date_cls + +from fastapi import APIRouter, Query +from fastapi.responses import JSONResponse +from database import get_connection, dict_from_row + +router = APIRouter() + +_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"} + + +def _recent_trade_dates(conn, n: int = 10) -> list[str]: + """最近 n 个有数据的交易日(升序)""" + rows = conn.execute( + "SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?", + (n,), + ).fetchall() + return [r["trade_date"] for r in reversed(rows)] + + +@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵") +async def active_core_stocks(): + conn = get_connection() + try: + dates = _recent_trade_dates(conn, 10) + if not dates: + return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS) + + # 窗口内出现过且最近一次出现距今天数 <= 10 个交易日 + placeholders = ",".join("?" * len(dates)) + rows = conn.execute( + f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks + WHERE trade_date IN ({placeholders}) + ORDER BY trade_date DESC, rank ASC""", + dates, + ).fetchall() + + # 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜 + stock_days: dict[str, dict] = {} + for r in rows: + code = r["stock_code"] + s = stock_days.setdefault(code, { + "stockCode": code, + "stockName": r["stock_name"], + "coverCount": r["cover_count"], + "dailyGains": {}, + "appearCount": 0, + "lastAppear": None, + }) + s["dailyGains"][r["trade_date"]] = r["f3"] + s["appearCount"] += 1 + if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]: + s["lastAppear"] = r["trade_date"] + + stocks = list(stock_days.values()) + # 稳定排序:先按出现次数降序,再按最近上榜日降序 + stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True) + stocks.sort(key=lambda x: -x["appearCount"]) + + # daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径) + latest = dates[-1] if dates else None + for s in stocks: + if s.get("lastAppear") and latest: + try: + d1 = date_cls.fromisoformat(latest) + d2 = date_cls.fromisoformat(s["lastAppear"]) + s["daysSinceLastAppear"] = (d1 - d2).days + except ValueError: + s["daysSinceLastAppear"] = 0 + else: + s["daysSinceLastAppear"] = 0 + + return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS) + finally: + conn.close() + + +@router.get("/history", summary="指定交易日核心股(含所属题材)") +async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")): + conn = get_connection() + try: + rows = conn.execute( + "SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,) + ).fetchall() + # 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询 + themes_rows = conn.execute( + "SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?", + (date,), + ).fetchall() + themes_by_stock: dict[str, list] = {} + for t in themes_rows: + themes_by_stock.setdefault(t["stock_code"], []).append( + {"theme_code": t["theme_code"], "theme_name": t["theme_name"]} + ) + items = [] + for r in rows: + d = dict_from_row(r) + d["themes"] = themes_by_stock.get(d["stock_code"], []) + items.append(d) + return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS) + finally: + conn.close() diff --git a/backend/routes/themes.py b/backend/routes/themes.py index a2be720..31d6cc1 100644 --- a/backend/routes/themes.py +++ b/backend/routes/themes.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Query, HTTPException from fastapi.responses import JSONResponse +from database import get_connection, dict_from_row from services import themes router = APIRouter() @@ -38,6 +39,19 @@ async def theme_graph( return JSONResponse(result, headers=_NO_CACHE_HEADERS) +@router.get("/history", summary="指定交易日题材涨幅前10") +async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")): + conn = get_connection() + try: + rows = conn.execute( + "SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,) + ).fetchall() + items = [dict_from_row(r) for r in rows] + return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS) + finally: + conn.close() + + @router.get("/{theme_code}/detail", summary="题材详情") async def theme_detail(theme_code: str): data = await themes.fetch_theme_detail(theme_code) diff --git a/backend/services/daily_collector.py b/backend/services/daily_collector.py new file mode 100644 index 0000000..455a22e --- /dev/null +++ b/backend/services/daily_collector.py @@ -0,0 +1,130 @@ +"""每日热点数据采集:核心股前100 + 题材涨幅前10,收盘后自动入库 + +由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。 +""" + +import asyncio +import traceback +from datetime import datetime, time as dtime, timezone, timedelta +from typing import Optional + +from database import get_connection +from services.themes import fetch_theme_list + +_CST = timezone(timedelta(hours=8)) + +# 每天采集的后台任务:每 300 秒(5 分钟)检查一次 +CHECK_INTERVAL_SECONDS = 300 +COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集 +CORE_STOCK_LIMIT = 100 # 核心股前100 +TOP_THEME_LIMIT = 10 # 题材前10 + + +def _is_trading_day(d: datetime) -> bool: + """仅按工作日判断:周一至周五视为交易日,不处理法定节假日""" + return d.weekday() < 5 + + +def _has_collected(trade_date: str) -> bool: + """当日核心股是否已采集""" + conn = get_connection() + try: + row = conn.execute( + "SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1", + (trade_date,), + ).fetchone() + return row is not None + finally: + conn.close() + + +async def collect_daily(trade_date: str, dry_run: bool = False) -> dict: + """采集指定交易日数据并入库。 + + Args: + trade_date: YYYY-MM-DD + dry_run: True 只打印不写库(用于验证) + + Returns: + {"core_count": int, "theme_count": int, "skipped": bool} + """ + if _has_collected(trade_date): + print(f"[collector] {trade_date} 已采集,跳过") + return {"core_count": 0, "theme_count": 0, "skipped": True} + + # 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源) + themes = await fetch_theme_list(1, False) + if not themes: + print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过") + return {"core_count": 0, "theme_count": 0, "skipped": True} + + # 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股) + stock_map: dict[str, dict] = {} + theme_count: dict[str, int] = {} # securityCode -> 覆盖题材数 + for t in themes: + code = t.get("securityCode") + if not code: + continue + theme_count[code] = theme_count.get(code, 0) + 1 + if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0): + stock_map[code] = { + "stock_code": code, + "stock_name": t.get("securityName", ""), + "f3": t.get("f3"), + } + core_stocks = sorted( + stock_map.values(), key=lambda x: -(x["f3"] or 0) + )[:CORE_STOCK_LIMIT] + + # 3. 题材前10:bf3 降序 + top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT] + + if dry_run: + print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前10 {len(top_themes)} 只") + return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False} + + # 4. 入库(事务,UNIQUE 幂等) + conn = get_connection() + try: + for i, s in enumerate(core_stocks, start=1): + conn.execute( + "INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)", + (trade_date, s["stock_code"], s["stock_name"], s["f3"], theme_count.get(s["stock_code"], 0), i), + ) + # 核心股所属题材:从 themes 列表(含 securityCode/themeCode/themeName)中 + # 为每个核心股收集其全部所属题材,写入 daily_core_stock_themes + core_codes = {s["stock_code"] for s in core_stocks} + for t in themes: + if t.get("securityCode") in core_codes: + conn.execute( + "INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)", + (trade_date, t["securityCode"], t["themeCode"], t["themeName"]), + ) + for i, t in enumerate(top_themes, start=1): + conn.execute( + "INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)", + (trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i), + ) + conn.commit() + finally: + conn.close() + + print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前10 {len(top_themes)} 只") + return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False} + + +async def collector_loop(stop: Optional[asyncio.Event] = None) -> None: + """后台循环:每个交易日 15:00 后自动采集当日数据(幂等)""" + while True: + try: + now = datetime.now(_CST) + if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME: + trade_date = now.strftime("%Y-%m-%d") + if not _has_collected(trade_date): + await collect_daily(trade_date) + except Exception: + print("[collector] 采集异常:") + traceback.print_exc() + if stop is not None and stop.is_set(): + break + await asyncio.sleep(CHECK_INTERVAL_SECONDS) diff --git a/docs/superpowers/plans/2026-08-10-daily-core-stock-history.md b/docs/superpowers/plans/2026-08-10-daily-core-stock-history.md index 7214909..2e4332e 100644 --- a/docs/superpowers/plans/2026-08-10-daily-core-stock-history.md +++ b/docs/superpowers/plans/2026-08-10-daily-core-stock-history.md @@ -211,7 +211,7 @@ async def collector_loop(stop: Optional[asyncio.Event] = None) -> None: while True: try: now = datetime.now(_CST) - if _is_trading_day(now) and now.time() >= _COLLECT_AFTER_TIME: + if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME: trade_date = now.strftime("%Y-%m-%d") if not _has_collected(trade_date): await collect_daily(trade_date) @@ -219,7 +219,7 @@ async def collector_loop(stop: Optional[asyncio.Event] = None) -> None: print(f"[collector] 采集异常: {e}") if stop is not None and stop.is_set(): break - await asyncio.sleep(_CHECK_INTERVAL_SECONDS) + await asyncio.sleep(CHECK_INTERVAL_SECONDS) ``` > 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。 diff --git a/src/lib/core-stock-api.ts b/src/lib/core-stock-api.ts new file mode 100644 index 0000000..8cbba8b --- /dev/null +++ b/src/lib/core-stock-api.ts @@ -0,0 +1,59 @@ +// 核心股历史数据获取工具 +import { getApiBaseUrl } from "@/lib/api-client"; + +export interface ActiveCoreStock { + stockCode: string; + stockName: string; + coverCount: number | null; // 覆盖题材数 + dailyGains: Record; // 日期 -> 当日涨幅(可空) + appearCount: number; + lastAppear: string | null; + daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差 +} + +export interface ActiveCoreStocksResponse { + dates: string[]; + stocks: ActiveCoreStock[]; +} + +export interface CoreStockHistoryItem { + id: number; + trade_date: string; + stock_code: string; + stock_name: string; + f3: number | null; + cover_count: number | null; + rank: number; + themes: { theme_code: string; theme_name: string }[]; +} + +export interface CoreStockHistoryResponse { + date: string; + items: CoreStockHistoryItem[]; +} + +/** 获取活跃核心股 + 最近10日涨幅矩阵 */ +export async function fetchActiveCoreStocks(): Promise { + const baseUrl = getApiBaseUrl(); + try { + const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" }); + if (!resp.ok) return { dates: [], stocks: [] }; + return resp.json(); + } catch (err) { + console.error("[core-stock-api] 获取活跃核心股失败:", err); + return { dates: [], stocks: [] }; + } +} + +/** 获取指定交易日核心股(含所属题材) */ +export async function fetchCoreStockHistory(date: string): Promise { + const baseUrl = getApiBaseUrl(); + try { + const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" }); + if (!resp.ok) return null; + return resp.json(); + } catch (err) { + console.error("[core-stock-api] 获取核心股历史失败:", err); + return null; + } +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b99e2fa..c6718cb 100755 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ThemesRouteImport } from './routes/themes' import { Route as SectorsRouteImport } from './routes/sectors' import { Route as HotMapRouteImport } from './routes/hot-map' +import { Route as CoreStocksRouteImport } from './routes/core-stocks' import { Route as IndexRouteImport } from './routes/index' import { Route as ThemeCodeRouteImport } from './routes/theme.$code' import { Route as StockCodeRouteImport } from './routes/stock.$code' @@ -32,6 +33,11 @@ const HotMapRoute = HotMapRouteImport.update({ path: '/hot-map', getParentRoute: () => rootRouteImport, } as any) +const CoreStocksRoute = CoreStocksRouteImport.update({ + id: '/core-stocks', + path: '/core-stocks', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -55,6 +61,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute @@ -64,6 +71,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/': typeof IndexRoute + '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute @@ -74,6 +82,7 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/core-stocks': typeof CoreStocksRoute '/hot-map': typeof HotMapRoute '/sectors': typeof SectorsRoute '/themes': typeof ThemesRoute @@ -85,6 +94,7 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/core-stocks' | '/hot-map' | '/sectors' | '/themes' @@ -94,6 +104,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/' + | '/core-stocks' | '/hot-map' | '/sectors' | '/themes' @@ -103,6 +114,7 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/core-stocks' | '/hot-map' | '/sectors' | '/themes' @@ -113,6 +125,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CoreStocksRoute: typeof CoreStocksRoute HotMapRoute: typeof HotMapRoute SectorsRoute: typeof SectorsRoute ThemesRoute: typeof ThemesRoute @@ -144,6 +157,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HotMapRouteImport parentRoute: typeof rootRouteImport } + '/core-stocks': { + id: '/core-stocks' + path: '/core-stocks' + fullPath: '/core-stocks' + preLoaderRoute: typeof CoreStocksRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -177,6 +197,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CoreStocksRoute: CoreStocksRoute, HotMapRoute: HotMapRoute, SectorsRoute: SectorsRoute, ThemesRoute: ThemesRoute, diff --git a/src/routes/core-stocks.tsx b/src/routes/core-stocks.tsx new file mode 100644 index 0000000..570fc37 --- /dev/null +++ b/src/routes/core-stocks.tsx @@ -0,0 +1,113 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; +import { fetchActiveCoreStocks } from "@/lib/core-stock-api"; +import { ArrowLeft, RefreshCw, Flame } from "lucide-react"; + +export const Route = createFileRoute("/core-stocks")({ + component: CoreStocksPage, +}); + +/** 格式化涨幅,红涨绿跌 */ +function formatGain(v: number | null | undefined): string { + if (v == null) return "·"; + const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`; + return s; +} + +function CoreStocksPage() { + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: ["core-stocks", "active"], + queryFn: fetchActiveCoreStocks, + staleTime: 60_000, + retry: false, + }); + + const dates = data?.dates ?? []; + const stocks = data?.stocks ?? []; + + return ( +
+ {/* 顶栏 */} +
+
+
+ + + +

核心股追踪

+
+ +
+
+ +
+

+ 活跃核心股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只 +

+ + {isLoading ? ( +
+ ) : dates.length === 0 || stocks.length === 0 ? ( +
+ 暂无数据,数据将在每日收盘后自动采集 +
+ ) : ( +
+ + + + + {dates.map((d) => ( + + ))} + + + + + + + {stocks.map((s) => ( + + + {dates.map((d) => { + const g = s.dailyGains[d]; + const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500"; + return ( + + ); + })} + + + + + ))} + +
股票 + {d.slice(5)} + 题材数最近上榜上榜
+ {s.stockName} + {s.stockCode} + + {formatGain(g)} + + {s.coverCount ?? "·"} + + {s.lastAppear ? s.lastAppear.slice(5) : "·"} + + + + {s.appearCount} + +
+
+ )} +
+
+ ); +} diff --git a/src/routes/hot-map.tsx b/src/routes/hot-map.tsx index bd0e6b3..c0596ce 100644 --- a/src/routes/hot-map.tsx +++ b/src/routes/hot-map.tsx @@ -114,6 +114,13 @@ function HotMapPage() { > + + + 核心股 +
diff --git a/src/routes/themes.tsx b/src/routes/themes.tsx index 3df85e4..6b2394a 100644 --- a/src/routes/themes.tsx +++ b/src/routes/themes.tsx @@ -71,6 +71,13 @@ function ThemesPage() { 热点穿透 + + + 核心股 +