merge: 每日核心股/题材历史 + 活跃核心股滚动表格功能
将 worktree-core-stock-history 分支合并回 main: - 每日采集核心股前100+所属题材、题材涨幅前10 存历史 - /api/core-stocks/active|history、/api/themes/history 接口 - /core-stocks 展示页(10日涨幅矩阵 + 题材数/最近上榜/上榜次数) - 后端 asyncio 定时采集 + 前端入口链接 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,40 @@ CREATE TABLE IF NOT EXISTS cache (
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
expires_at 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)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -6,7 +7,8 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -14,7 +16,15 @@ load_dotenv()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
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)
|
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(shares.router, prefix="/api/share")
|
||||||
app.include_router(sectors.router, prefix="/api/sectors")
|
app.include_router(sectors.router, prefix="/api/sectors")
|
||||||
app.include_router(themes.router, prefix="/api/themes")
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
|
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Query, HTTPException
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from database import get_connection, dict_from_row
|
||||||
from services import themes
|
from services import themes
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -38,6 +39,19 @@ async def theme_graph(
|
|||||||
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
|
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="题材详情")
|
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||||
async def theme_detail(theme_code: str):
|
async def theme_detail(theme_code: str):
|
||||||
data = await themes.fetch_theme_detail(theme_code)
|
data = await themes.fetch_theme_detail(theme_code)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -211,7 +211,7 @@ async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
now = datetime.now(_CST)
|
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")
|
trade_date = now.strftime("%Y-%m-%d")
|
||||||
if not _has_collected(trade_date):
|
if not _has_collected(trade_date):
|
||||||
await collect_daily(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}")
|
print(f"[collector] 采集异常: {e}")
|
||||||
if stop is not None and stop.is_set():
|
if stop is not None and stop.is_set():
|
||||||
break
|
break
|
||||||
await asyncio.sleep(_CHECK_INTERVAL_SECONDS)
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
```
|
```
|
||||||
|
|
||||||
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
|
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// 核心股历史数据获取工具
|
||||||
|
import { getApiBaseUrl } from "@/lib/api-client";
|
||||||
|
|
||||||
|
export interface ActiveCoreStock {
|
||||||
|
stockCode: string;
|
||||||
|
stockName: string;
|
||||||
|
coverCount: number | null; // 覆盖题材数
|
||||||
|
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
|
||||||
|
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<ActiveCoreStocksResponse> {
|
||||||
|
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<CoreStockHistoryResponse | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as ThemesRouteImport } from './routes/themes'
|
import { Route as ThemesRouteImport } from './routes/themes'
|
||||||
import { Route as SectorsRouteImport } from './routes/sectors'
|
import { Route as SectorsRouteImport } from './routes/sectors'
|
||||||
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 IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||||
@@ -32,6 +33,11 @@ const HotMapRoute = HotMapRouteImport.update({
|
|||||||
path: '/hot-map',
|
path: '/hot-map',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||||
|
id: '/core-stocks',
|
||||||
|
path: '/core-stocks',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -55,6 +61,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -64,6 +71,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -74,6 +82,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -85,6 +94,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -94,6 +104,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -103,6 +114,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -113,6 +125,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
CoreStocksRoute: typeof CoreStocksRoute
|
||||||
HotMapRoute: typeof HotMapRoute
|
HotMapRoute: typeof HotMapRoute
|
||||||
SectorsRoute: typeof SectorsRoute
|
SectorsRoute: typeof SectorsRoute
|
||||||
ThemesRoute: typeof ThemesRoute
|
ThemesRoute: typeof ThemesRoute
|
||||||
@@ -144,6 +157,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof HotMapRouteImport
|
preLoaderRoute: typeof HotMapRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/core-stocks': {
|
||||||
|
id: '/core-stocks'
|
||||||
|
path: '/core-stocks'
|
||||||
|
fullPath: '/core-stocks'
|
||||||
|
preLoaderRoute: typeof CoreStocksRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -177,6 +197,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
CoreStocksRoute: CoreStocksRoute,
|
||||||
HotMapRoute: HotMapRoute,
|
HotMapRoute: HotMapRoute,
|
||||||
SectorsRoute: SectorsRoute,
|
SectorsRoute: SectorsRoute,
|
||||||
ThemesRoute: ThemesRoute,
|
ThemesRoute: ThemesRoute,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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="/hot-map" 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 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||||
|
) : dates.length === 0 || stocks.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" title="覆盖题材数">题材数</th>
|
||||||
|
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap">最近上榜</th>
|
||||||
|
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stocks.map((s) => (
|
||||||
|
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||||
|
<span className="font-medium">{s.stockName}</span>
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
||||||
|
</td>
|
||||||
|
{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 (
|
||||||
|
<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 text-muted-foreground">
|
||||||
|
{s.coverCount ?? "·"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
||||||
|
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
|
||||||
|
</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" />
|
||||||
|
{s.appearCount}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -114,6 +114,13 @@ function HotMapPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/core-stocks"
|
||||||
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
核心股
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ function ThemesPage() {
|
|||||||
<Network className="h-3.5 w-3.5" />
|
<Network className="h-3.5 w-3.5" />
|
||||||
热点穿透
|
热点穿透
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/core-stocks"
|
||||||
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Flame 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