163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史"""
|
||
|
||
from fastapi import APIRouter, Query, HTTPException
|
||
from fastapi.responses import JSONResponse
|
||
from database import get_connection, dict_from_row
|
||
from services import themes
|
||
|
||
router = APIRouter()
|
||
|
||
# 上游反代/CDN 可能按 path 缓存,显式禁止缓存
|
||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||
|
||
|
||
@router.get("", summary="题材列表")
|
||
async def theme_list(
|
||
sort_field: int = Query(1, ge=1, le=5, description="排序字段:1=涨幅 3=强度 4=热度排名 5=成交额"),
|
||
asc: bool = Query(False, description="True=升序, False=降序"),
|
||
):
|
||
if sort_field not in (1, 3, 4, 5):
|
||
raise HTTPException(status_code=400, detail="排序字段仅支持 1/3/4/5")
|
||
|
||
data = await themes.fetch_theme_list(sort_field, asc)
|
||
return JSONResponse(
|
||
{"data": data, "count": len(data), "sort_field": sort_field, "asc": asc},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
|
||
|
||
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
|
||
async def theme_graph(
|
||
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
|
||
top: int = Query(30, ge=1, le=200, description="题材数量"),
|
||
limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"),
|
||
):
|
||
if sort_field not in (1, 4):
|
||
raise HTTPException(status_code=400, detail="排序字段仅支持 1(涨幅)/4(热度)")
|
||
|
||
result = await themes.fetch_theme_graph(sort_field, top, limit)
|
||
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("/active", summary="活跃题材 + 最近10日涨幅矩阵")
|
||
async def active_themes():
|
||
conn = get_connection()
|
||
try:
|
||
# 最近 10 个有数据的交易日(升序)
|
||
rows = conn.execute(
|
||
"SELECT DISTINCT trade_date FROM daily_top_themes ORDER BY trade_date DESC LIMIT 10"
|
||
).fetchall()
|
||
dates = [r["trade_date"] for r in reversed(rows)]
|
||
if not dates:
|
||
return JSONResponse({"dates": [], "themes": []}, headers=_NO_CACHE_HEADERS)
|
||
|
||
# 窗口内全部题材上榜记录,按天分组排列(当日 rank 即排名)
|
||
placeholders = ",".join("?" * len(dates))
|
||
rows = conn.execute(
|
||
f"""SELECT trade_date, theme_code, theme_name, bf3, rank FROM daily_top_themes
|
||
WHERE trade_date IN ({placeholders})
|
||
ORDER BY trade_date DESC, rank ASC""",
|
||
dates,
|
||
).fetchall()
|
||
|
||
# 按题材聚合:每日涨幅矩阵 + 上榜次数 + 最近上榜日 + 最佳排名
|
||
theme_map: dict[str, dict] = {}
|
||
for r in rows:
|
||
code = r["theme_code"]
|
||
t = theme_map.setdefault(code, {
|
||
"themeCode": code,
|
||
"themeName": r["theme_name"],
|
||
"dailyGains": {},
|
||
"appearCount": 0,
|
||
"lastAppear": None,
|
||
"bestRank": None,
|
||
})
|
||
t["dailyGains"][r["trade_date"]] = r["bf3"]
|
||
t["appearCount"] += 1
|
||
if t["lastAppear"] is None or r["trade_date"] > t["lastAppear"]:
|
||
t["lastAppear"] = r["trade_date"]
|
||
if t["bestRank"] is None or (r["rank"] or 0) < t["bestRank"]:
|
||
t["bestRank"] = r["rank"] or 0
|
||
|
||
themes_list = list(theme_map.values())
|
||
# 稳定排序:先按上榜次数降序,再按最佳排名升序
|
||
themes_list.sort(key=lambda x: x["bestRank"] if x["bestRank"] is not None else 10**9)
|
||
themes_list.sort(key=lambda x: -x["appearCount"])
|
||
return JSONResponse({"dates": dates, "themes": themes_list}, headers=_NO_CACHE_HEADERS)
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
||
async def theme_news(
|
||
theme_code: str,
|
||
page_num: int = Query(1, ge=1, description="页码"),
|
||
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
|
||
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
|
||
):
|
||
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
|
||
if result is None:
|
||
return JSONResponse(
|
||
{"data": None, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
return JSONResponse(
|
||
{"data": result, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
|
||
|
||
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
|
||
async def theme_quote(theme_code: str):
|
||
result = await themes.fetch_theme_quote(theme_code)
|
||
if result is None:
|
||
return JSONResponse(
|
||
{"data": None, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
return JSONResponse(
|
||
{"data": result, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
|
||
|
||
@router.get("/{theme_code}/detail", summary="题材详情")
|
||
async def theme_detail(theme_code: str):
|
||
data = await themes.fetch_theme_detail(theme_code)
|
||
if not data:
|
||
return JSONResponse(
|
||
{"data": None, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
return JSONResponse(
|
||
{"data": data, "theme_code": theme_code},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|
||
|
||
|
||
@router.get("/{theme_code}/stocks", summary="题材相关股票(全部)")
|
||
async def theme_stocks(theme_code: str):
|
||
result = await themes.fetch_theme_stocks(theme_code)
|
||
return JSONResponse(
|
||
{
|
||
"data": result.get("stockList", []),
|
||
"statistic": result.get("statistic", {}),
|
||
"total": result.get("total", 0),
|
||
"theme_code": theme_code,
|
||
},
|
||
headers=_NO_CACHE_HEADERS,
|
||
)
|