Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0dbeef3fd | ||
|
|
eb66ab02d0 | ||
|
|
7fe8074e22 | ||
|
|
f98a9255a5 | ||
|
|
776ab55fc3 | ||
|
|
dfcce3abaf | ||
|
|
d473bbad6b | ||
|
|
c4a0c50ed0 | ||
|
|
0a32bfad78 | ||
|
|
69535bc782 | ||
|
|
254deb6a80 | ||
|
|
b29ca8413c | ||
|
|
c610faa4ad | ||
|
|
f93ffd381b | ||
|
|
1d3ec3194c | ||
|
|
b4d48180b7 | ||
|
|
6d3c470b7b | ||
|
|
23644a9c77 | ||
|
|
1c434a208a | ||
|
|
036a87bac9 | ||
|
|
258fc9184d | ||
|
|
ac985a2140 | ||
|
|
a7cff0930d | ||
|
|
5e7f0c4f13 | ||
|
|
77f6e36c13 | ||
|
|
cd56f6c158 | ||
|
|
c58bd990fd | ||
|
|
6b12b04cab | ||
|
|
ffabf3d396 | ||
|
|
f5ba61e3db | ||
|
|
526b182051 | ||
|
|
14c31e3f06 | ||
|
|
aed3eea739 | ||
|
|
5ffa5c07b2 | ||
|
|
84d237de1f | ||
|
|
f354da108d | ||
|
|
8687bda331 | ||
|
|
75c7ff3670 | ||
|
|
84a01fb7b1 | ||
|
|
ce131b2867 |
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec)
|
||||
#
|
||||
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
|
||||
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
|
||||
#
|
||||
# 用法(容器内):
|
||||
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
|
||||
# ./auvops.sh clean-expired # 只清理已过期的缓存
|
||||
# ./auvops.sh cache-count # 查看缓存条数
|
||||
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
|
||||
# ./auvops.sh sh # 进入交互式 shell
|
||||
# ./auvops.sh help # 查看帮助
|
||||
#
|
||||
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
|
||||
#
|
||||
# 示例:
|
||||
# ./auvops.sh cache-clear
|
||||
# ./auvops.sh recollect 2026-08-10
|
||||
# ./auvops.sh cache-count
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 容器内 backend 根:脚本所在目录(/app)
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
|
||||
PY="${PY:-python}"
|
||||
"${PY}" -c "import services" >/dev/null 2>&1 || {
|
||||
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
|
||||
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
|
||||
# <python 代码>
|
||||
# EOF
|
||||
py_env() {
|
||||
local env_args=()
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
env_args+=("${1%%=*}=${1#*=}"); shift
|
||||
done
|
||||
env "${env_args[@]}" "${PY}" -
|
||||
}
|
||||
|
||||
# ---- 子命令实现 ----
|
||||
|
||||
# 清空全部缓存(当日数据全新)
|
||||
cmd_cache_clear() {
|
||||
echo "① 清空全部缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clear_all
|
||||
clear_all()
|
||||
print(" cache 表已清空")
|
||||
EOF
|
||||
echo "✅ 缓存已清空"
|
||||
}
|
||||
|
||||
# 只清理已过期缓存
|
||||
cmd_clean_expired() {
|
||||
echo "① 清理过期缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
print(" 过期缓存已清理")
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 查看缓存条数
|
||||
cmd_cache_count() {
|
||||
"${PY}" - <<'EOF'
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
||||
print(f"缓存条数: {n}")
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
}
|
||||
|
||||
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
|
||||
cmd_recollect() {
|
||||
local date="${1:-$(date +%F)}"
|
||||
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
|
||||
echo "================================================"
|
||||
|
||||
echo "① 删除 ${date} 旧数据"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import os
|
||||
from database import get_connection
|
||||
date = os.environ["TARGET_DATE"]
|
||||
conn = get_connection()
|
||||
try:
|
||||
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
|
||||
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
|
||||
print(f" {tbl}: 删除 {cur.rowcount} 行")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
|
||||
echo "② 用新选股逻辑重采 ${date}"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import asyncio
|
||||
import os
|
||||
from services.daily_collector import collect_daily
|
||||
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
|
||||
print(" 结果:", result)
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 进入交互式 shell
|
||||
cmd_sh() {
|
||||
"${PY}" || true
|
||||
}
|
||||
|
||||
# 帮助
|
||||
usage() {
|
||||
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
|
||||
}
|
||||
|
||||
# ---- 入口 ----
|
||||
main() {
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
local cmd="$1"; shift
|
||||
case "${cmd}" in
|
||||
cache-clear|cc) cmd_cache_clear "$@" ;;
|
||||
clean-expired) cmd_clean_expired "$@" ;;
|
||||
cache-count) cmd_cache_count "$@" ;;
|
||||
recollect) cmd_recollect "$@" ;;
|
||||
sh|shell|python) cmd_sh "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) echo "❌ 未知命令: ${cmd}(./auvops.sh help 查看用法)" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -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)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+16
-2
@@ -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, cache_cleanup_loop
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -14,7 +16,18 @@ load_dotenv()
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
yield
|
||||
collector_task = asyncio.create_task(collector_loop())
|
||||
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
t.cancel()
|
||||
for t in (collector_task, cache_cleanup_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
|
||||
@@ -32,6 +45,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 优先级更高
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
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"])
|
||||
|
||||
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
dates,
|
||||
).fetchall()
|
||||
themes_by_stock: dict[str, dict[str, dict]] = {}
|
||||
for t in themes_rows:
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
|
||||
# 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.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
from services import themes
|
||||
|
||||
router = APIRouter()
|
||||
@@ -38,6 +39,52 @@ async def theme_graph(
|
||||
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日题材涨幅前20")
|
||||
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}/news", summary="题材相关新闻(分页)")
|
||||
async def theme_news(
|
||||
theme_code: str,
|
||||
page_num: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
|
||||
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
|
||||
):
|
||||
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
|
||||
async def theme_quote(theme_code: str):
|
||||
result = await themes.fetch_theme_quote(theme_code)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||
async def theme_detail(theme_code: str):
|
||||
data = await themes.fetch_theme_detail(theme_code)
|
||||
|
||||
@@ -56,3 +56,13 @@ def clean_expired():
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_all():
|
||||
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM cache")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 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.cache import clear_all
|
||||
from services.themes import fetch_theme_list, _build_theme_graph
|
||||
|
||||
_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 = 20 # 题材涨幅前20
|
||||
HOTMAP_TOP_N = 50 # 热点穿透采样题材数:涨幅榜+热度榜各取前50,合并(与热点穿透页一致)
|
||||
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
|
||||
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
|
||||
|
||||
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
|
||||
CLEANUP_TIME = dtime(9, 31)
|
||||
|
||||
|
||||
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:
|
||||
"""采集指定交易日数据并入库。
|
||||
|
||||
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
|
||||
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
|
||||
|
||||
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}
|
||||
|
||||
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
|
||||
lead_themes: dict[str, list[tuple[str, str]]] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
|
||||
|
||||
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
|
||||
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
|
||||
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
|
||||
hotmap_core: list[dict] = []
|
||||
graph_theme_name: dict[str, str] = {}
|
||||
try:
|
||||
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
|
||||
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
|
||||
hotmap_core = [
|
||||
s for s in graph.get("stocks", [])
|
||||
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
|
||||
][:HOTMAP_CORE_LIMIT]
|
||||
except Exception as e:
|
||||
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
|
||||
|
||||
# 3. 题材领涨股涨幅前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"),
|
||||
}
|
||||
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
|
||||
|
||||
# 4. 题材涨幅前20:bf3 降序
|
||||
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:TOP_THEME_LIMIT]
|
||||
|
||||
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
|
||||
core_stocks: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
|
||||
|
||||
for s in hotmap_core:
|
||||
code = s["securityCode"]
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s.get("securityName", ""),
|
||||
"f3": s.get("f3"),
|
||||
"cover_count": s.get("coverCount", 0),
|
||||
})
|
||||
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
|
||||
for tc in s.get("themeCodes", []):
|
||||
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
for s in f3_core:
|
||||
code = s["stock_code"]
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s["stock_name"],
|
||||
"f3": s["f3"],
|
||||
"cover_count": theme_count.get(code, 0),
|
||||
})
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
if dry_run:
|
||||
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
# 6. 入库(事务,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"], s["cover_count"], i),
|
||||
)
|
||||
for code, tc, tn in theme_pairs:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
|
||||
(trade_date, code, tc, tn),
|
||||
)
|
||||
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)} 只,题材前20 {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)
|
||||
|
||||
|
||||
def _next_cleanup_dt(now: datetime) -> datetime:
|
||||
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
|
||||
for days in range(0, 8):
|
||||
d = (now + timedelta(days=days)).date()
|
||||
if d.weekday() >= 5: # 跳过周末
|
||||
continue
|
||||
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
|
||||
if dt > now:
|
||||
return dt
|
||||
return now + timedelta(days=1) # 兜底:理论不可达
|
||||
|
||||
|
||||
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
|
||||
|
||||
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
next_dt = _next_cleanup_dt(now)
|
||||
delay = max(0, int((next_dt - now).total_seconds()))
|
||||
if stop is not None:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass # 到点
|
||||
if stop.is_set():
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
clear_all()
|
||||
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
print("[cache-cleanup] 清理异常:")
|
||||
traceback.print_exc()
|
||||
await asyncio.sleep(60) # 出错 1 分钟后再试
|
||||
+95
-25
@@ -30,12 +30,23 @@ _PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
|
||||
_APP_KEY_INDEX = "rn-themeIndex"
|
||||
_APP_KEY_DETAIL = "rn-themeDetail"
|
||||
|
||||
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
|
||||
# 补齐真实 Chrome 头 + client="web" 后正常返回
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Origin": "https://emrnweb.eastmoney.com",
|
||||
"Referer": "https://emrnweb.eastmoney.com/",
|
||||
"Accept": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"DNT": "1",
|
||||
"Origin": "https://emrnweb.eastmoney.com",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": "https://emrnweb.eastmoney.com/",
|
||||
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
# ---- 交易时段感知缓存 ----
|
||||
@@ -77,9 +88,14 @@ def _next_open_delta_seconds() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||
_LIST_CACHE_SECONDS = 120
|
||||
|
||||
|
||||
def _list_ttl_seconds() -> int:
|
||||
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
|
||||
return 0 if _is_trading_time() else _next_open_delta_seconds()
|
||||
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||
|
||||
|
||||
# ---- 请求封装 ----
|
||||
@@ -89,7 +105,7 @@ def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -
|
||||
return {
|
||||
"args": args or {},
|
||||
"appKey": app_key,
|
||||
"client": "iOS",
|
||||
"client": "web", # 生产实测 iOS client 会 403,web + 完整浏览器头正常
|
||||
"clientVersion": "8.3",
|
||||
"clientType": "cfw",
|
||||
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
|
||||
@@ -148,11 +164,10 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
|
||||
asc: True=升序, False=降序
|
||||
"""
|
||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
|
||||
if not _is_trading_time():
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
sort = 1 if asc else -1
|
||||
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
||||
@@ -359,7 +374,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
||||
|
||||
return {
|
||||
"themes": [
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]}
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")}
|
||||
for t in theme_map.values()
|
||||
],
|
||||
"stocks": stocks,
|
||||
@@ -399,10 +414,11 @@ async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: async
|
||||
|
||||
|
||||
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
|
||||
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate)
|
||||
"""获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate)
|
||||
|
||||
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
|
||||
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
|
||||
缓存新鲜 → 直接返回;
|
||||
非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
|
||||
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度
|
||||
@@ -412,21 +428,75 @@ async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1
|
||||
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 缓存新鲜 → 直接返回
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 无缓存:同步构建(并发下加锁去重)
|
||||
# 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
|
||||
if data is not None and not _is_trading_time():
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 交易时段过期 / 无缓存:同步重建,加锁去重
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 等待锁期间已被其他请求写入
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 等待锁期间已被其他请求刷新
|
||||
return _trim_graph_result(data, limit)
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
|
||||
# ---- 题材相关新闻(分页) ----
|
||||
|
||||
_NEWS_PAGE_SIZE = 10
|
||||
|
||||
|
||||
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
|
||||
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
|
||||
|
||||
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
|
||||
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
|
||||
"""
|
||||
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getThemeRelatedNews",
|
||||
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
|
||||
# ---- 单题材实时行情(强度/热度/涨幅) ----
|
||||
|
||||
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
|
||||
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
|
||||
cache_key = f"theme_quote:{theme_code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getSingleThemeQuote",
|
||||
{"themeCode": theme_code},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
|
||||
|
||||
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py` 的 `fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
|
||||
|
||||
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
|
||||
|
||||
**参考现有代码:**
|
||||
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
|
||||
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
|
||||
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
|
||||
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
|
||||
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
|
||||
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
|
||||
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
|
||||
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
|
||||
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
|
||||
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
|
||||
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
|
||||
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
|
||||
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
|
||||
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 数据库 — 追加 3 张表
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
|
||||
|
||||
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
|
||||
|
||||
在 `backend/database.py` 的 `SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
|
||||
|
||||
```python
|
||||
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)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行验证建表**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
|
||||
```
|
||||
|
||||
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/services/daily_collector.py`
|
||||
|
||||
- [ ] **Step 1: 新建采集服务**
|
||||
|
||||
```python
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
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))
|
||||
|
||||
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
|
||||
CORE_STOCK_LIMIT = 100 # 核心股前100
|
||||
TOP_THEME_LIMIT = 20 # 题材涨幅前20
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
|
||||
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] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
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. 题材涨幅前20: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)} 只,题材前20 {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, rank) VALUES (?,?,?,?,?)",
|
||||
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
|
||||
)
|
||||
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)} 只,题材前20 {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 as e:
|
||||
print(f"[collector] 采集异常: {e}")
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
```
|
||||
|
||||
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
|
||||
|
||||
- [ ] **Step 2: 修正变量名并运行验证脚本**
|
||||
|
||||
在 `collect_daily` 中 `[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`。
|
||||
|
||||
验证(dry_run 不写库):
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=True)
|
||||
print(r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
|
||||
|
||||
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
|
||||
print(r)
|
||||
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
|
||||
print(r2)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 第一次入库,第二次 `skipped: True`。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 历史接口 `backend/routes/core_stocks.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/routes/core_stocks.py`
|
||||
|
||||
- [ ] **Step 1: 新建路由**
|
||||
|
||||
```python
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
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 FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
|
||||
from collections import OrderedDict
|
||||
stock_days: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"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["appearCount"], -(x.get("lastAppear") or "")))
|
||||
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()
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict_from_row(r)
|
||||
themes = conn.execute(
|
||||
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
|
||||
(date, d["stock_code"]),
|
||||
).fetchall()
|
||||
d["themes"] = [dict(t) for t in themes]
|
||||
items.append(d)
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from routes.core_stocks import active_core_stocks
|
||||
import asyncio
|
||||
async def main():
|
||||
r = await active_core_stocks()
|
||||
print('dates:', r.body[:200] if hasattr(r,'body') else r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
|
||||
|
||||
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
|
||||
print('最近日期:', dates)
|
||||
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
|
||||
print('总记录:', rows['n'])
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py`
|
||||
|
||||
- [ ] **Step 1: 修改 main.py**
|
||||
|
||||
在 `from routes import ...` 加 `core_stocks`,lifespan 里启动采集任务,注册路由:
|
||||
|
||||
```python
|
||||
from routes import stock, collections, shares, sectors, themes, core_stocks
|
||||
from services.daily_collector import collector_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
task = asyncio.create_task(collector_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
```
|
||||
|
||||
并在 `app.include_router` 区加:
|
||||
|
||||
```python
|
||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||
```
|
||||
|
||||
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
|
||||
|
||||
- [ ] **Step 2: 语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
|
||||
```
|
||||
|
||||
Expected: `语法 OK`
|
||||
|
||||
- [ ] **Step 3: 启动服务冒烟测试**
|
||||
|
||||
```bash
|
||||
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/core-stock-api.ts`
|
||||
|
||||
- [ ] **Step 1: 新建 API 客户端**
|
||||
|
||||
```typescript
|
||||
// 核心股历史数据获取工具
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface ActiveCoreStock {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
|
||||
appearCount: number;
|
||||
lastAppear: string | null;
|
||||
}
|
||||
|
||||
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();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** 获取指定交易日核心股(含所属题材) */
|
||||
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 展示页 `src/routes/core-stocks.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/core-stocks.tsx`
|
||||
|
||||
- [ ] **Step 1: 新建展示页**
|
||||
|
||||
```tsx
|
||||
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">
|
||||
<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 ? (
|
||||
<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 className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th 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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 入口链接 + 验证 + 清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/themes.tsx`
|
||||
- Modify: `src/routes/hot-map.tsx`
|
||||
- Delete(临时): `backend/verify_collector.py`
|
||||
|
||||
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
|
||||
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
|
||||
|
||||
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
|
||||
|
||||
- [ ] **Step 3: 前端构建验证**
|
||||
|
||||
```bash
|
||||
cd /Users/cjun/Code/github/auv && pnpm build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx` 的 `createFileRoute` 路径与 `to` 一致。
|
||||
|
||||
- [ ] **Step 4: 清理临时验证文件**
|
||||
|
||||
```bash
|
||||
rm -f backend/verify_collector.py
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/themes.tsx src/routes/hot-map.tsx
|
||||
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检结果
|
||||
|
||||
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
|
||||
- **无占位符:** 每步含完整代码与命令 ✓
|
||||
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
|
||||
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
|
||||
|
||||
## 注意
|
||||
|
||||
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
|
||||
|
||||
日期:2026-08-10
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
|
||||
|
||||
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
|
||||
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
|
||||
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
|
||||
|
||||
## 2. 已确认的决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
|
||||
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
|
||||
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
|
||||
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
|
||||
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
|
||||
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
|
||||
|
||||
## 3. 数据模型(新增 3 张表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
|
||||
stock_code TEXT NOT NULL, -- 股票代码
|
||||
stock_name TEXT NOT NULL, -- 股票名称
|
||||
f3 REAL, -- 当日涨幅%
|
||||
cover_count INTEGER, -- 覆盖题材数(采样)
|
||||
rank INTEGER, -- 当日涨幅排名 1-100
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE 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 DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE 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, -- 当日板块涨幅排名 1-20
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
### 4.1 触发机制(方案 A)
|
||||
|
||||
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
|
||||
- 协程循环(如每 5 分钟检查一次):
|
||||
- 是否**交易日**(周一至周五,非节假日)。
|
||||
- 是否**收盘后**(北京时间 > 15:00)。
|
||||
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
|
||||
- 条件满足 → 触发采集。
|
||||
|
||||
### 4.2 采集逻辑
|
||||
|
||||
1. **核心股前 100**:调用 `fetch_theme_list(1, False)` 或 `fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
|
||||
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`。
|
||||
3. **题材前 20**:从 `fetch_theme_list(1, False)` 取 `bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`。
|
||||
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
|
||||
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
|
||||
|
||||
### 4.3 补全(折中方案)
|
||||
|
||||
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
|
||||
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
|
||||
|
||||
## 5. 新接口(`backend/routes/`)
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
|
||||
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
|
||||
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
|
||||
|
||||
### active 接口返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"dates": ["2026-08-03", "...", "2026-08-10"],
|
||||
"stocks": [
|
||||
{
|
||||
"stockCode": "601606",
|
||||
"stockName": "长城军工",
|
||||
"coverCount": 9,
|
||||
"lastAppear": "2026-08-10",
|
||||
"daysSinceLastAppear": 0,
|
||||
"appearCount": 5,
|
||||
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
|
||||
- `stocks`:10 日窗口内出现过的活跃核心股。
|
||||
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
|
||||
|
||||
## 6. 新展示页(前端 `/core-stocks`)
|
||||
|
||||
### 6.1 页面结构
|
||||
|
||||
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
|
||||
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
|
||||
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
|
||||
- 列:最近 10 个 A 股交易日,最右为最新。
|
||||
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
|
||||
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
|
||||
|
||||
### 6.2 数据获取
|
||||
|
||||
- 前端 `useQuery` 调 `GET /api/core-stocks/active`。
|
||||
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
|
||||
|
||||
### 6.3 路由
|
||||
|
||||
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`。
|
||||
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
|
||||
|
||||
## 7. 错误处理与边界
|
||||
|
||||
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
|
||||
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
|
||||
- **核心股不足 100**:存实际数量,不补齐。
|
||||
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`。
|
||||
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
|
||||
|
||||
## 8. 测试
|
||||
|
||||
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
|
||||
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
|
||||
- 前端页面:空态、有数据态、10 日窗口滚动。
|
||||
|
||||
## 9. 范围外(YAGNI)
|
||||
|
||||
- 不做法定的深度交易日历(节假日调休)。
|
||||
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
|
||||
- 不做板块成分股的每日存储。
|
||||
@@ -0,0 +1,60 @@
|
||||
// 核心股历史数据获取工具
|
||||
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; // 最近上榜距窗口最新交易日的自然日差
|
||||
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -159,12 +159,82 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 题材相关新闻(分页) ── */
|
||||
|
||||
export interface ThemeNewsItem {
|
||||
newsCode: string;
|
||||
newsTitle: string;
|
||||
newsMediaName: string;
|
||||
showDateTime: number | null;
|
||||
showDateTimeFormat: string | null;
|
||||
commentCount: number;
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export interface ThemeNewsResponse {
|
||||
total: number;
|
||||
maxEuTime: string;
|
||||
list: ThemeNewsItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
|
||||
*/
|
||||
export async function fetchThemeNews(
|
||||
themeCode: string,
|
||||
pageNum: number = 1,
|
||||
pageSize: number = 10,
|
||||
maxEuTime: string = "",
|
||||
): Promise<ThemeNewsResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材新闻失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 单题材实时行情(强度/热度/涨幅) ── */
|
||||
|
||||
export interface ThemeQuote {
|
||||
strengthValue: number | null;
|
||||
hotValueUpLimit: number;
|
||||
hotValue: number;
|
||||
f3: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单题材实时行情
|
||||
*/
|
||||
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材行情失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 热点穿透:题材-股票 网状关系图 ── */
|
||||
|
||||
export interface GraphTheme {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
stockCount: number; // 题材内股票数
|
||||
bf3: number | null; // 题材涨幅
|
||||
}
|
||||
|
||||
export interface GraphStock {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
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) => (
|
||||
<Fragment key={s.stockCode}>
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<Link
|
||||
to="/stock/$code"
|
||||
params={{ code: s.stockCode }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{s.stockName}
|
||||
</Link>
|
||||
<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>
|
||||
<tr className="border-b bg-muted/20">
|
||||
<td colSpan={dates.length + 4} className="px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground mb-1">所属题材</div>
|
||||
{s.themes && s.themes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.themes.map((t) => (
|
||||
<Link
|
||||
key={t.theme_code}
|
||||
to="/theme/$code"
|
||||
params={{ code: t.theme_code }}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
{t.theme_name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无题材数据</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+152
-40
@@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [
|
||||
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
|
||||
const MAX_STOCK_NODES = 800;
|
||||
|
||||
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
|
||||
const THEME_MIN_RADIUS = 6;
|
||||
const THEME_MAX_RADIUS = 30;
|
||||
|
||||
/* 视图切换:关系图 / 核心股列表 */
|
||||
type ViewMode = "graph" | "list";
|
||||
|
||||
@@ -114,6 +118,13 @@ function HotMapPage() {
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</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>
|
||||
|
||||
@@ -265,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
|
||||
f100?: string;
|
||||
themeCodes?: string[];
|
||||
code?: string;
|
||||
// theme 专属
|
||||
bf3?: number | null; // 题材涨幅
|
||||
}
|
||||
|
||||
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
||||
@@ -351,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
|
||||
/* 心跳周期 ms;0~1 相位,0 最小 / 0.5 最大 */
|
||||
const PULSE_PERIOD = 900;
|
||||
const PULSE_AMPLITUDE = 0.22; // 心跳半径脉动幅度(相对半径)
|
||||
|
||||
/**
|
||||
* 股票节点:覆盖数分级颜色 + 心跳(≥5题材) + 涨跌光晕 + 描边 + 内实心
|
||||
* time(ms) 由持续动画循环驱动;非动画源(t=0)时心跳相位取 0 的静止态。
|
||||
*/
|
||||
function drawStockNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: SimNode[],
|
||||
activeSet: Set<string> | null,
|
||||
activeId: string | null,
|
||||
time: number,
|
||||
) {
|
||||
for (const n of nodes) {
|
||||
if (n.type !== "stock") continue;
|
||||
@@ -364,30 +385,59 @@ function drawStockNodes(
|
||||
const isActive = activeId === n.id;
|
||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||||
const x = n.x ?? 0;
|
||||
const y = n.y ?? 0;
|
||||
// 外圈淡填充
|
||||
const isPos = (n.f3 ?? 0) >= 0;
|
||||
|
||||
// 心跳:覆盖≥5 的核心股按正弦脉动半径(相位在 0 时静止,避免动画源缺省导致闪烁)
|
||||
const heartbeat = cover >= 5 ? 0.5 + 0.5 * Math.sin((time / PULSE_PERIOD) * TAU - Math.PI / 2) : 0;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const pr = r * (1 + heartbeat * PULSE_AMPLITUDE);
|
||||
|
||||
// 涨跌光晕:所有股票统一强度(不随覆盖数 alpha 衰减),随心跳脉动,正=红 / 负=绿
|
||||
const glowColor = isPos ? "rgba(239,68,68," : "rgba(34,197,94,"; // 红/绿
|
||||
const glowR = pr * 1.9;
|
||||
const glow = ctx.createRadialGradient(x, y, pr * 0.2, x, y, glowR);
|
||||
glow.addColorStop(0, `${glowColor}${dim ? 0.06 : 0.5})`);
|
||||
glow.addColorStop(1, `${glowColor}0)`);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, TAU);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.globalAlpha = 0.35 * alpha;
|
||||
ctx.fill();
|
||||
// 描边
|
||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.stroke();
|
||||
// 内实心
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, n.radius, 0, TAU);
|
||||
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
|
||||
ctx.arc(x, y, glowR, 0, TAU);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fill();
|
||||
|
||||
if (isPos) {
|
||||
// 涨:外圈淡填充 + 分级描边 + 内实心(覆盖数越多越红)
|
||||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.globalAlpha = 0.35 * alpha;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
|
||||
ctx.fill();
|
||||
} else {
|
||||
// 跌:绿色空心球(仅描边,内部淡绿留白)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.strokeStyle = "#22c55e";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#22c55e";
|
||||
ctx.globalAlpha = dim ? 0.02 : 0.1;
|
||||
ctx.fill();
|
||||
}
|
||||
// 选中外环
|
||||
if (isActive) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, n.radius + 3, 0, TAU);
|
||||
ctx.arc(x, y, pr + 3, 0, TAU);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||
ctx.lineWidth = 1;
|
||||
@@ -397,7 +447,7 @@ function drawStockNodes(
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** 题材节点:蓝色圆点 */
|
||||
/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
|
||||
function drawThemeNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: SimNode[],
|
||||
@@ -409,14 +459,32 @@ function drawThemeNodes(
|
||||
const isActive = activeId === n.id;
|
||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const isPos = (n.bf3 ?? 0) >= 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
|
||||
ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6";
|
||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#1d4ed8";
|
||||
ctx.lineWidth = 1.5;
|
||||
// 正涨幅:蓝色实心;负涨幅:蓝色空心
|
||||
ctx.strokeStyle = "#3b82f6";
|
||||
ctx.lineWidth = isPos ? 1.5 : 2;
|
||||
if (isPos) {
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||||
ctx.fill();
|
||||
} else {
|
||||
// 空心:仅描边,内部留白
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.globalAlpha = dim ? 0.05 : 0.15;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = dim ? 0.15 : 0.6;
|
||||
}
|
||||
ctx.stroke();
|
||||
// 选中外环
|
||||
if (isActive) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x ?? 0, n.y ?? 0, r + 3, 0, TAU);
|
||||
ctx.strokeStyle = "#60a5fa";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -453,7 +521,8 @@ function drawThemeLabels(
|
||||
if (n.type !== "theme") continue;
|
||||
if (!showAll && activeId !== n.id) continue;
|
||||
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
|
||||
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, "#1e40af");
|
||||
const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
|
||||
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +555,8 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
const simRunningRef = useRef(false);
|
||||
const fitOnceRef = useRef(false);
|
||||
const rafRef = useRef(0);
|
||||
const timeRef = useRef(0); // 动画时钟(ms),心跳/光晕脉动用
|
||||
const pulseStartRef = useRef(0); // 持续动画起始时间戳(ms)
|
||||
const pointersRef = useRef(new Map<number, { x: number; y: number }>());
|
||||
const gestureRef = useRef<Gesture>({ kind: "none" });
|
||||
|
||||
@@ -511,19 +582,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
if (activeSet) {
|
||||
// 高亮态:全量重绘(邻居亮、非邻居淡出)
|
||||
drawEdges(ctx, edges, activeSet);
|
||||
drawStockNodes(ctx, nodes, activeSet, activeId);
|
||||
drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
|
||||
drawThemeNodes(ctx, nodes, activeSet, activeId);
|
||||
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
|
||||
} else {
|
||||
const b = boundsRef.current;
|
||||
const sc = staticCanvasRef.current;
|
||||
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
|
||||
// 静止态:drawImage 静态层 + 动态层
|
||||
// 静止态:静态层(边+题材)+ 动态层重绘股票(心跳/光晕动画)
|
||||
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
|
||||
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||
} else {
|
||||
// 模拟期或大比例放大:直接全量绘制(保证清晰)
|
||||
drawEdges(ctx, edges, null);
|
||||
drawStockNodes(ctx, nodes, null, null);
|
||||
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||
drawThemeNodes(ctx, nodes, null, null);
|
||||
}
|
||||
}
|
||||
@@ -570,7 +642,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
|
||||
sctx.translate(-minX2, -minY2);
|
||||
drawEdges(sctx, edges, null);
|
||||
drawStockNodes(sctx, nodes, null, null);
|
||||
// 股票节点不在静态层:需常驻重绘以支持心跳/光晕动画
|
||||
drawThemeNodes(sctx, nodes, null, null);
|
||||
staticReadyRef.current = true;
|
||||
boundsRef.current = { minX: minX2, minY: minY2, w, h };
|
||||
@@ -627,6 +699,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
return () => ro.disconnect();
|
||||
}, [requestRender]);
|
||||
|
||||
/* 持续动画循环:心跳 + 光晕脉动。力收敛后常驻 rAF,仅更新时间与触发重绘 */
|
||||
useEffect(() => {
|
||||
pulseStartRef.current = performance.now();
|
||||
let anim = 0;
|
||||
const loop = () => {
|
||||
timeRef.current = performance.now() - pulseStartRef.current;
|
||||
requestRender();
|
||||
anim = requestAnimationFrame(loop);
|
||||
};
|
||||
anim = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(anim);
|
||||
}, [requestRender]);
|
||||
|
||||
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
|
||||
const { nodes, edges, stockById } = useMemo(() => {
|
||||
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
|
||||
@@ -645,16 +730,24 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
f62: s.f62,
|
||||
f100: s.f100,
|
||||
themeCodes: s.themeCodes,
|
||||
radius: Math.min(18, 4 + s.coverCount * 1.3),
|
||||
}));
|
||||
const themeNodes: SimNode[] = graph.themes.map((t) => ({
|
||||
id: `t:${t.themeCode}`,
|
||||
type: "theme" as const,
|
||||
name: t.themeName,
|
||||
code: t.themeCode,
|
||||
coverCount: t.stockCount,
|
||||
radius: 10,
|
||||
// 基础半径来自覆盖数,涨幅绝对值作增量:涨得猛/跌得深球更大
|
||||
radius: Math.min(20, 3 + s.coverCount * 1.2 + (Math.abs(s.f3 ?? 0) / 10) * 4),
|
||||
}));
|
||||
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
|
||||
const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
|
||||
const themeNodes: SimNode[] = graph.themes.map((t) => {
|
||||
const bf3 = t.bf3 ?? 0;
|
||||
return {
|
||||
id: `t:${t.themeCode}`,
|
||||
type: "theme" as const,
|
||||
name: t.themeName,
|
||||
code: t.themeCode,
|
||||
coverCount: t.stockCount,
|
||||
bf3: t.bf3,
|
||||
// 平方根映射:同一个小涨幅区间内球径差异更大(线性映射被极端涨幅拉平均)
|
||||
radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
|
||||
};
|
||||
});
|
||||
const edgeList: SimEdge[] = buildEdgesFromStocks(kept);
|
||||
|
||||
const stockByIdMap = new Map<string, GraphStock>();
|
||||
@@ -1000,6 +1093,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold">{node.name}</p>
|
||||
{node.bf3 != null && (
|
||||
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
|
||||
</>
|
||||
)}
|
||||
@@ -1058,6 +1156,11 @@ function BottomSheet({
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-sm">{node.name}</p>
|
||||
{node.bf3 != null && (
|
||||
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
代码 {node.code} · 覆盖 {node.coverCount} 只股票
|
||||
</p>
|
||||
@@ -1104,7 +1207,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
|
||||
题材
|
||||
题材涨
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 8, height: 8 }} />
|
||||
题材跌
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1124,8 +1231,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-border/40">
|
||||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
|
||||
<span>题材节点</span>
|
||||
<span>题材 · 涨幅为正</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 10, height: 10 }} />
|
||||
<span>题材 · 涨幅为负</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+99
-24
@@ -1,7 +1,14 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
|
||||
import {
|
||||
fetchThemeDetail,
|
||||
fetchThemeNews,
|
||||
fetchThemeQuote,
|
||||
fetchThemeStocks,
|
||||
type ThemeStock,
|
||||
type ThemeNewsItem,
|
||||
} from "@/lib/theme-api";
|
||||
import { getStockBoard } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
|
||||
const [newsPage, setNewsPage] = useState(1);
|
||||
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
|
||||
const newsQ = useQuery({
|
||||
queryKey: ["themeNews", code, newsPage],
|
||||
queryFn: () => fetchThemeNews(code, newsPage, 10),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const quoteQ = useQuery({
|
||||
queryKey: ["themeQuote", code],
|
||||
queryFn: () => fetchThemeQuote(code),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 分页追加:首页重置列表,翻页拼接
|
||||
useEffect(() => {
|
||||
if (!newsQ.data?.list) return;
|
||||
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
|
||||
}, [newsQ.data, newsPage]);
|
||||
|
||||
const isLoading = detailQ.isLoading || stocksQ.isLoading;
|
||||
const isError = detailQ.isError || stocksQ.isError;
|
||||
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
|
||||
const refresh = () => {
|
||||
detailQ.refetch();
|
||||
stocksQ.refetch();
|
||||
quoteQ.refetch();
|
||||
setNewsPage(1);
|
||||
newsQ.refetch();
|
||||
};
|
||||
|
||||
const baseInfo = detail?.baseInfo;
|
||||
const hotEvent = detail?.hotEvent;
|
||||
const eventHistory = detail?.eventHistory ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
|
||||
flat={statistic?.f106}
|
||||
fex5={statistic?.fex5}
|
||||
total={total}
|
||||
strength={quoteQ.data?.strengthValue ?? null}
|
||||
hotValue={quoteQ.data?.hotValue ?? 0}
|
||||
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||||
/>
|
||||
|
||||
{/* ── 相关新闻(可折叠) ── */}
|
||||
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
|
||||
{/* ── 相关新闻(分页加载) ── */}
|
||||
{newsItems.length > 0 && (
|
||||
<NewsList
|
||||
items={newsItems}
|
||||
total={newsQ.data?.total ?? 0}
|
||||
loadingMore={newsQ.isFetching && newsPage > 1}
|
||||
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 相关股票 ── */}
|
||||
<div>
|
||||
@@ -181,6 +221,9 @@ function StatBar({
|
||||
flat,
|
||||
fex5,
|
||||
total,
|
||||
strength,
|
||||
hotValue,
|
||||
hotValueUpLimit,
|
||||
}: {
|
||||
f3: number | null | undefined;
|
||||
up: number | null | undefined;
|
||||
@@ -188,8 +231,13 @@ function StatBar({
|
||||
flat: number | null | undefined;
|
||||
fex5: number | null | undefined;
|
||||
total: number;
|
||||
strength: number | null;
|
||||
hotValue: number;
|
||||
hotValueUpLimit: number;
|
||||
}) {
|
||||
const isPos = (f3 ?? 0) >= 0;
|
||||
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||||
const showQuote = strength != null || hotValueUpLimit > 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
@@ -218,24 +266,49 @@ function StatBar({
|
||||
平盘 {flat} 只
|
||||
</p>
|
||||
)}
|
||||
{/* 强度 + 热度(来自单题材实时行情接口) */}
|
||||
{showQuote && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
{strength != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
强度
|
||||
<b className="font-bold text-foreground tabular-nums">{strength}</b>
|
||||
</span>
|
||||
)}
|
||||
{hotValueUpLimit > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame className="h-3 w-3 text-orange-500" />
|
||||
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||
/>
|
||||
</span>
|
||||
热度 {hotValue}/{hotValueUpLimit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
相关新闻(可折叠)
|
||||
相关新闻(分页加载)
|
||||
============================================================ */
|
||||
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? items : items.slice(0, 2);
|
||||
|
||||
const fmtTime = (ts: number | null) => {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
function NewsList({
|
||||
items,
|
||||
total,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
items: ThemeNewsItem[];
|
||||
total: number;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const hasMore = items.length < total;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Newspaper className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">相关新闻</h2>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">共 {total} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{shown.map((n, idx) => (
|
||||
{items.map((n, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{n.newsMediaName}
|
||||
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
||||
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 2 && (
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||
>
|
||||
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{loadingMore ? "加载中…" : "加载更多"}
|
||||
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -71,6 +71,13 @@ function ThemesPage() {
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
热点穿透
|
||||
</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
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user