"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史""" 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, )