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,
|
value TEXT NOT NULL,
|
||||||
expires_at TEXT NOT NULL
|
expires_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_core_stocks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
stock_name TEXT NOT NULL,
|
||||||
|
f3 REAL,
|
||||||
|
cover_count INTEGER,
|
||||||
|
rank INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
UNIQUE(trade_date, stock_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
theme_code TEXT NOT NULL,
|
||||||
|
theme_name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
UNIQUE(trade_date, stock_code, theme_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trade_date TEXT NOT NULL,
|
||||||
|
theme_code TEXT NOT NULL,
|
||||||
|
theme_name TEXT NOT NULL,
|
||||||
|
bf3 REAL,
|
||||||
|
hot_rank INTEGER,
|
||||||
|
rank INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||||
|
UNIQUE(trade_date, theme_code)
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@@ -6,7 +7,8 @@ from contextlib import asynccontextmanager
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from database import init_db
|
from database import init_db
|
||||||
from routes import stock, collections, shares, sectors, themes
|
from routes import stock, collections, shares, sectors, themes, core_stocks
|
||||||
|
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -14,7 +16,18 @@ load_dotenv()
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
collector_task = asyncio.create_task(collector_loop())
|
||||||
|
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)
|
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(shares.router, prefix="/api/share")
|
||||||
app.include_router(sectors.router, prefix="/api/sectors")
|
app.include_router(sectors.router, prefix="/api/sectors")
|
||||||
app.include_router(themes.router, prefix="/api/themes")
|
app.include_router(themes.router, prefix="/api/themes")
|
||||||
|
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||||
|
|
||||||
# 生产模式:后端同时托管前端静态文件
|
# 生产模式:后端同时托管前端静态文件
|
||||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||||
|
|||||||
@@ -0,0 +1,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 import APIRouter, Query, HTTPException
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from database import get_connection, dict_from_row
|
||||||
from services import themes
|
from services import themes
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -38,6 +39,52 @@ async def theme_graph(
|
|||||||
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
|
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="题材详情")
|
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||||
async def theme_detail(theme_code: str):
|
async def theme_detail(theme_code: str):
|
||||||
data = await themes.fetch_theme_detail(theme_code)
|
data = await themes.fetch_theme_detail(theme_code)
|
||||||
|
|||||||
@@ -56,3 +56,13 @@ def clean_expired():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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_INDEX = "rn-themeIndex"
|
||||||
_APP_KEY_DETAIL = "rn-themeDetail"
|
_APP_KEY_DETAIL = "rn-themeDetail"
|
||||||
|
|
||||||
|
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
|
||||||
|
# 补齐真实 Chrome 头 + client="web" 后正常返回
|
||||||
_HEADERS = {
|
_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",
|
"Accept": "application/json, text/plain, */*",
|
||||||
"Origin": "https://emrnweb.eastmoney.com",
|
|
||||||
"Referer": "https://emrnweb.eastmoney.com/",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
"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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||||
|
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||||
|
_LIST_CACHE_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
def _list_ttl_seconds() -> int:
|
def _list_ttl_seconds() -> int:
|
||||||
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
|
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||||
return 0 if _is_trading_time() else _next_open_delta_seconds()
|
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 {
|
return {
|
||||||
"args": args or {},
|
"args": args or {},
|
||||||
"appKey": app_key,
|
"appKey": app_key,
|
||||||
"client": "iOS",
|
"client": "web", # 生产实测 iOS client 会 403,web + 完整浏览器头正常
|
||||||
"clientVersion": "8.3",
|
"clientVersion": "8.3",
|
||||||
"clientType": "cfw",
|
"clientType": "cfw",
|
||||||
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
|
"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=降序
|
asc: True=升序, False=降序
|
||||||
"""
|
"""
|
||||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||||
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
|
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||||
if not _is_trading_time():
|
cached = get_cache(cache_key)
|
||||||
cached = get_cache(cache_key)
|
if cached is not None:
|
||||||
if cached is not None:
|
return json.loads(cached)
|
||||||
return json.loads(cached)
|
|
||||||
|
|
||||||
sort = 1 if asc else -1
|
sort = 1 if asc else -1
|
||||||
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
# hotRank 数值越小越热,"热度降序(最热在前)" 需反转为接口升序
|
||||||
@@ -359,7 +374,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"themes": [
|
"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()
|
for t in theme_map.values()
|
||||||
],
|
],
|
||||||
"stocks": stocks,
|
"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:
|
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:
|
Args:
|
||||||
sort_field: 题材排序 1=涨幅, 4=热度
|
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}"
|
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||||
|
|
||||||
data, expires_at = _get_graph_cache(cache_key)
|
data, expires_at = _get_graph_cache(cache_key)
|
||||||
if data is not None:
|
if data is not None and expires_at and expires_at > time.time():
|
||||||
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
|
# 缓存新鲜 → 直接返回
|
||||||
if not (expires_at and expires_at > time.time()):
|
|
||||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
|
||||||
return _trim_graph_result(data, limit)
|
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())
|
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
data, expires_at = _get_graph_cache(cache_key)
|
data, expires_at = _get_graph_cache(cache_key)
|
||||||
if data is not None:
|
if data is not None and expires_at and expires_at > time.time():
|
||||||
# 等待锁期间已被其他请求写入
|
# 等待锁期间已被其他请求刷新
|
||||||
if not (expires_at and expires_at > time.time()):
|
|
||||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
|
||||||
return _trim_graph_result(data, limit)
|
return _trim_graph_result(data, limit)
|
||||||
data = await _build_theme_graph(sort_field, top_n)
|
data = await _build_theme_graph(sort_field, top_n)
|
||||||
_set_graph_cache(cache_key, data)
|
_set_graph_cache(cache_key, data)
|
||||||
return _trim_graph_result(data, limit)
|
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 {
|
export interface GraphTheme {
|
||||||
themeCode: string;
|
themeCode: string;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
stockCount: number; // 题材内股票数
|
stockCount: number; // 题材内股票数
|
||||||
|
bf3: number | null; // 题材涨幅
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphStock {
|
export interface GraphStock {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as ThemesRouteImport } from './routes/themes'
|
import { Route as ThemesRouteImport } from './routes/themes'
|
||||||
import { Route as SectorsRouteImport } from './routes/sectors'
|
import { Route as SectorsRouteImport } from './routes/sectors'
|
||||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||||
|
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||||
@@ -32,6 +33,11 @@ const HotMapRoute = HotMapRouteImport.update({
|
|||||||
path: '/hot-map',
|
path: '/hot-map',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||||
|
id: '/core-stocks',
|
||||||
|
path: '/core-stocks',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -55,6 +61,7 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -64,6 +71,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -74,6 +82,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
'/core-stocks': typeof CoreStocksRoute
|
||||||
'/hot-map': typeof HotMapRoute
|
'/hot-map': typeof HotMapRoute
|
||||||
'/sectors': typeof SectorsRoute
|
'/sectors': typeof SectorsRoute
|
||||||
'/themes': typeof ThemesRoute
|
'/themes': typeof ThemesRoute
|
||||||
@@ -85,6 +94,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -94,6 +104,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -103,6 +114,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/core-stocks'
|
||||||
| '/hot-map'
|
| '/hot-map'
|
||||||
| '/sectors'
|
| '/sectors'
|
||||||
| '/themes'
|
| '/themes'
|
||||||
@@ -113,6 +125,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
|
CoreStocksRoute: typeof CoreStocksRoute
|
||||||
HotMapRoute: typeof HotMapRoute
|
HotMapRoute: typeof HotMapRoute
|
||||||
SectorsRoute: typeof SectorsRoute
|
SectorsRoute: typeof SectorsRoute
|
||||||
ThemesRoute: typeof ThemesRoute
|
ThemesRoute: typeof ThemesRoute
|
||||||
@@ -144,6 +157,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof HotMapRouteImport
|
preLoaderRoute: typeof HotMapRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/core-stocks': {
|
||||||
|
id: '/core-stocks'
|
||||||
|
path: '/core-stocks'
|
||||||
|
fullPath: '/core-stocks'
|
||||||
|
preLoaderRoute: typeof CoreStocksRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/': {
|
'/': {
|
||||||
id: '/'
|
id: '/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@@ -177,6 +197,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
|
CoreStocksRoute: CoreStocksRoute,
|
||||||
HotMapRoute: HotMapRoute,
|
HotMapRoute: HotMapRoute,
|
||||||
SectorsRoute: SectorsRoute,
|
SectorsRoute: SectorsRoute,
|
||||||
ThemesRoute: ThemesRoute,
|
ThemesRoute: ThemesRoute,
|
||||||
|
|||||||
@@ -0,0 +1,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 MAX_STOCK_NODES = 800;
|
||||||
|
|
||||||
|
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
|
||||||
|
const THEME_MIN_RADIUS = 6;
|
||||||
|
const THEME_MAX_RADIUS = 30;
|
||||||
|
|
||||||
/* 视图切换:关系图 / 核心股列表 */
|
/* 视图切换:关系图 / 核心股列表 */
|
||||||
type ViewMode = "graph" | "list";
|
type ViewMode = "graph" | "list";
|
||||||
|
|
||||||
@@ -114,6 +118,13 @@ function HotMapPage() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/core-stocks"
|
||||||
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
热点股
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
|
|||||||
f100?: string;
|
f100?: string;
|
||||||
themeCodes?: string[];
|
themeCodes?: string[];
|
||||||
code?: string;
|
code?: string;
|
||||||
|
// theme 专属
|
||||||
|
bf3?: number | null; // 题材涨幅
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
||||||
@@ -351,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
|
|||||||
ctx.globalAlpha = 1;
|
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(
|
function drawStockNodes(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
nodes: SimNode[],
|
nodes: SimNode[],
|
||||||
activeSet: Set<string> | null,
|
activeSet: Set<string> | null,
|
||||||
activeId: string | null,
|
activeId: string | null,
|
||||||
|
time: number,
|
||||||
) {
|
) {
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.type !== "stock") continue;
|
if (n.type !== "stock") continue;
|
||||||
@@ -364,30 +385,59 @@ function drawStockNodes(
|
|||||||
const isActive = activeId === n.id;
|
const isActive = activeId === n.id;
|
||||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||||
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
|
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 x = n.x ?? 0;
|
||||||
const y = n.y ?? 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.beginPath();
|
||||||
ctx.arc(x, y, r, 0, TAU);
|
ctx.arc(x, y, glowR, 0, TAU);
|
||||||
ctx.fillStyle = fill;
|
ctx.fillStyle = glow;
|
||||||
ctx.globalAlpha = 0.35 * alpha;
|
ctx.globalAlpha = 1;
|
||||||
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.fill();
|
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) {
|
if (isActive) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, n.radius + 3, 0, TAU);
|
ctx.arc(x, y, pr + 3, 0, TAU);
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha;
|
||||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
@@ -397,7 +447,7 @@ function drawStockNodes(
|
|||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 题材节点:蓝色圆点 */
|
/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
|
||||||
function drawThemeNodes(
|
function drawThemeNodes(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
nodes: SimNode[],
|
nodes: SimNode[],
|
||||||
@@ -409,14 +459,32 @@ function drawThemeNodes(
|
|||||||
const isActive = activeId === n.id;
|
const isActive = activeId === n.id;
|
||||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||||
const r = isActive ? n.radius + 3 : n.radius;
|
const r = isActive ? n.radius + 3 : n.radius;
|
||||||
|
const isPos = (n.bf3 ?? 0) >= 0;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
|
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
|
||||||
ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6";
|
// 正涨幅:蓝色实心;负涨幅:蓝色空心
|
||||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
ctx.strokeStyle = "#3b82f6";
|
||||||
ctx.fill();
|
ctx.lineWidth = isPos ? 1.5 : 2;
|
||||||
ctx.strokeStyle = "#1d4ed8";
|
if (isPos) {
|
||||||
ctx.lineWidth = 1.5;
|
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();
|
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;
|
ctx.globalAlpha = 1;
|
||||||
}
|
}
|
||||||
@@ -453,7 +521,8 @@ function drawThemeLabels(
|
|||||||
if (n.type !== "theme") continue;
|
if (n.type !== "theme") continue;
|
||||||
if (!showAll && activeId !== n.id) continue;
|
if (!showAll && activeId !== n.id) continue;
|
||||||
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
|
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 simRunningRef = useRef(false);
|
||||||
const fitOnceRef = useRef(false);
|
const fitOnceRef = useRef(false);
|
||||||
const rafRef = useRef(0);
|
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 pointersRef = useRef(new Map<number, { x: number; y: number }>());
|
||||||
const gestureRef = useRef<Gesture>({ kind: "none" });
|
const gestureRef = useRef<Gesture>({ kind: "none" });
|
||||||
|
|
||||||
@@ -511,19 +582,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
|||||||
if (activeSet) {
|
if (activeSet) {
|
||||||
// 高亮态:全量重绘(邻居亮、非邻居淡出)
|
// 高亮态:全量重绘(邻居亮、非邻居淡出)
|
||||||
drawEdges(ctx, edges, activeSet);
|
drawEdges(ctx, edges, activeSet);
|
||||||
drawStockNodes(ctx, nodes, activeSet, activeId);
|
drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
|
||||||
drawThemeNodes(ctx, nodes, activeSet, activeId);
|
drawThemeNodes(ctx, nodes, activeSet, activeId);
|
||||||
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
|
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
|
||||||
} else {
|
} else {
|
||||||
const b = boundsRef.current;
|
const b = boundsRef.current;
|
||||||
const sc = staticCanvasRef.current;
|
const sc = staticCanvasRef.current;
|
||||||
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
|
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);
|
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
|
||||||
|
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||||
} else {
|
} else {
|
||||||
// 模拟期或大比例放大:直接全量绘制(保证清晰)
|
// 模拟期或大比例放大:直接全量绘制(保证清晰)
|
||||||
drawEdges(ctx, edges, null);
|
drawEdges(ctx, edges, null);
|
||||||
drawStockNodes(ctx, nodes, null, null);
|
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||||
drawThemeNodes(ctx, nodes, null, null);
|
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.setTransform(sw / w, 0, 0, sh / h, 0, 0);
|
||||||
sctx.translate(-minX2, -minY2);
|
sctx.translate(-minX2, -minY2);
|
||||||
drawEdges(sctx, edges, null);
|
drawEdges(sctx, edges, null);
|
||||||
drawStockNodes(sctx, nodes, null, null);
|
// 股票节点不在静态层:需常驻重绘以支持心跳/光晕动画
|
||||||
drawThemeNodes(sctx, nodes, null, null);
|
drawThemeNodes(sctx, nodes, null, null);
|
||||||
staticReadyRef.current = true;
|
staticReadyRef.current = true;
|
||||||
boundsRef.current = { minX: minX2, minY: minY2, w, h };
|
boundsRef.current = { minX: minX2, minY: minY2, w, h };
|
||||||
@@ -627,6 +699,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
|||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [requestRender]);
|
}, [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 重建) */
|
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
|
||||||
const { nodes, edges, stockById } = useMemo(() => {
|
const { nodes, edges, stockById } = useMemo(() => {
|
||||||
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
|
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,
|
f62: s.f62,
|
||||||
f100: s.f100,
|
f100: s.f100,
|
||||||
themeCodes: s.themeCodes,
|
themeCodes: s.themeCodes,
|
||||||
radius: Math.min(18, 4 + s.coverCount * 1.3),
|
// 基础半径来自覆盖数,涨幅绝对值作增量:涨得猛/跌得深球更大
|
||||||
}));
|
radius: Math.min(20, 3 + s.coverCount * 1.2 + (Math.abs(s.f3 ?? 0) / 10) * 4),
|
||||||
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,
|
|
||||||
}));
|
}));
|
||||||
|
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥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 edgeList: SimEdge[] = buildEdgesFromStocks(kept);
|
||||||
|
|
||||||
const stockByIdMap = new Map<string, GraphStock>();
|
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>
|
<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>
|
<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>
|
<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">
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
代码 {node.code} · 覆盖 {node.coverCount} 只股票
|
代码 {node.code} · 覆盖 {node.coverCount} 只股票
|
||||||
</p>
|
</p>
|
||||||
@@ -1104,7 +1207,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1124,8 +1231,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-1 border-t border-border/40">
|
<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 className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
|
||||||
<span>题材节点</span>
|
<span>题材 · 涨幅为正</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-24
@@ -1,7 +1,14 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { getStockBoard } from "@/lib/stock-api";
|
||||||
import { formatMoney } from "@/lib/utils";
|
import { formatMoney } from "@/lib/utils";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: false,
|
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 isLoading = detailQ.isLoading || stocksQ.isLoading;
|
||||||
const isError = detailQ.isError || stocksQ.isError;
|
const isError = detailQ.isError || stocksQ.isError;
|
||||||
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
|
|||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
detailQ.refetch();
|
detailQ.refetch();
|
||||||
stocksQ.refetch();
|
stocksQ.refetch();
|
||||||
|
quoteQ.refetch();
|
||||||
|
setNewsPage(1);
|
||||||
|
newsQ.refetch();
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseInfo = detail?.baseInfo;
|
const baseInfo = detail?.baseInfo;
|
||||||
const hotEvent = detail?.hotEvent;
|
const hotEvent = detail?.hotEvent;
|
||||||
const eventHistory = detail?.eventHistory ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
|
|||||||
flat={statistic?.f106}
|
flat={statistic?.f106}
|
||||||
fex5={statistic?.fex5}
|
fex5={statistic?.fex5}
|
||||||
total={total}
|
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>
|
<div>
|
||||||
@@ -181,6 +221,9 @@ function StatBar({
|
|||||||
flat,
|
flat,
|
||||||
fex5,
|
fex5,
|
||||||
total,
|
total,
|
||||||
|
strength,
|
||||||
|
hotValue,
|
||||||
|
hotValueUpLimit,
|
||||||
}: {
|
}: {
|
||||||
f3: number | null | undefined;
|
f3: number | null | undefined;
|
||||||
up: number | null | undefined;
|
up: number | null | undefined;
|
||||||
@@ -188,8 +231,13 @@ function StatBar({
|
|||||||
flat: number | null | undefined;
|
flat: number | null | undefined;
|
||||||
fex5: number | null | undefined;
|
fex5: number | null | undefined;
|
||||||
total: number;
|
total: number;
|
||||||
|
strength: number | null;
|
||||||
|
hotValue: number;
|
||||||
|
hotValueUpLimit: number;
|
||||||
}) {
|
}) {
|
||||||
const isPos = (f3 ?? 0) >= 0;
|
const isPos = (f3 ?? 0) >= 0;
|
||||||
|
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||||||
|
const showQuote = strength != null || hotValueUpLimit > 0;
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-3">
|
<CardContent className="p-3">
|
||||||
@@ -218,24 +266,49 @@ function StatBar({
|
|||||||
平盘 {flat} 只
|
平盘 {flat} 只
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
相关新闻(可折叠)
|
相关新闻(分页加载)
|
||||||
============================================================ */
|
============================================================ */
|
||||||
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
|
function NewsList({
|
||||||
const [expanded, setExpanded] = useState(false);
|
items,
|
||||||
const shown = expanded ? items : items.slice(0, 2);
|
total,
|
||||||
|
loadingMore,
|
||||||
const fmtTime = (ts: number | null) => {
|
onLoadMore,
|
||||||
if (!ts) return "";
|
}: {
|
||||||
const d = new Date(ts);
|
items: ThemeNewsItem[];
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
total: number;
|
||||||
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
loadingMore: boolean;
|
||||||
};
|
onLoadMore: () => void;
|
||||||
|
}) {
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
|||||||
<div className="flex items-center gap-1.5 mb-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<Newspaper className="h-4 w-4 text-primary" />
|
<Newspaper className="h-4 w-4 text-primary" />
|
||||||
<h2 className="text-sm font-semibold">相关新闻</h2>
|
<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>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{shown.map((n, idx) => (
|
{items.map((n, idx) => (
|
||||||
<div key={idx} className="space-y-0.5">
|
<div key={idx} className="space-y-0.5">
|
||||||
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{n.newsMediaName}
|
{n.newsMediaName}
|
||||||
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||||
|
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{items.length > 2 && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={onLoadMore}
|
||||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
disabled={loadingMore}
|
||||||
|
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
{loadingMore ? "加载中…" : "加载更多"}
|
||||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ function ThemesPage() {
|
|||||||
<Network className="h-3.5 w-3.5" />
|
<Network className="h-3.5 w-3.5" />
|
||||||
热点穿透
|
热点穿透
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/core-stocks"
|
||||||
|
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Flame className="h-3.5 w-3.5" />
|
||||||
|
热点股
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user