feat: 新增热点穿透网状关系图 + 移动端优化

- 新增 /hot-map 热点穿透页:d3-force 力导向网状图展示题材-股票 M:N 关系
  - 股票节点面积/颜色按覆盖题材数分级,穿透核心股高亮
  - 图/核心股列表双视图切换,列表覆盖题材>2 公司降序展示
  - 悬停信息卡、缩放平移、点击跳转股票/题材详情
- 数据采样改为涨幅+热度双榜合并(各 Top50 去重=82 题材),修复覆盖数低估
  (有研新材覆盖数 2→10,核心股 66→1124 家)
- 移动端适配:触屏 tap 选中底部浮层、标签缩放阈值防重叠、自动 fit、紧凑图例
- 主页/题材列表增加热点穿透入口

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-06 20:34:14 +08:00
co-authored by Claude
parent 45836f4a30
commit e28d1a2fd0
9 changed files with 1209 additions and 206 deletions
+12
View File
@@ -25,6 +25,18 @@ async def theme_list(
)
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
async def theme_graph(
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
top: int = Query(30, ge=1, le=60, description="题材数量"),
):
if sort_field not in (1, 4):
raise HTTPException(status_code=400, detail="排序字段仅支持 1(涨幅)/4(热度)")
result = await themes.fetch_theme_graph(sort_field, top)
return JSONResponse(result, 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)
+100
View File
@@ -221,3 +221,103 @@ async def fetch_theme_stocks(theme_code: str) -> dict:
if ttl > 0:
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl)
return result
# ---- 热点穿透:题材-股票 网状关系图数据 ----
# 合并采样:涨幅榜 Top N + 热度榜 Top N 去重合并,
# 避免单一榜单导致股票覆盖题材数被低估(如有研新材覆盖 10+ 题材,仅涨幅榜只能采到 2 个)。
_GRAPH_MERGE_TOP = 50
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30) -> dict:
"""构建题材-股票网状关系图数据(热点穿透)
合并采样涨幅榜 + 热度榜 Top N 题材(去重),并发拉取每题材全部股票,
统计每股覆盖的题材数(M:N 关系),返回前端可直接渲染的图结构。
Args:
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
top_n: 每个榜单的题材数量(1-60)
Returns:
{
"themes": [{themeCode, themeName, bf3, hotValue, strengthValue, stockCount}],
"stocks": [{securityCode, securityName, coverCount, f3, f2, f62, f100, themeCodes[]}],
"edges": [{themeCode, securityCode}],
"stats": {themeCount, stockCount, coreCount, maxCover}
}
"""
cache_key = f"theme_graph:{sort_field}:{top_n}"
cached = get_cache(cache_key)
if cached is not None:
return json.loads(cached)
# 1. 合并采样涨幅榜 + 热度榜(当前榜优先在前,另一榜补充),按 themeCode 去重
primary_list = await fetch_theme_list(sort_field, asc=False)
other_field = 4 if sort_field == 1 else 1
other_list = await fetch_theme_list(other_field, asc=False)
merged: dict[str, dict] = {}
for t in primary_list[:top_n]:
merged.setdefault(t["themeCode"], t)
for t in other_list[:top_n]:
merged.setdefault(t["themeCode"], t)
themes = list(merged.values())
if not themes:
return {"themes": [], "stocks": [], "edges": [], "stats": {}}
# 2. 并发拉取每题材股票(限流保护)
sem = asyncio.Semaphore(5)
async def _fetch_with_limit(code: str):
async with sem:
return await fetch_theme_stocks(code)
results = await asyncio.gather(*[_fetch_with_limit(t["themeCode"]) for t in themes])
# 3. 构建图数据
theme_map = {t["themeCode"]: t for t in themes}
stock_map: dict[str, dict] = {} # securityCode -> stock dict
edges: list[dict] = []
for t, res in zip(themes, results):
stock_list = res.get("stockList", [])
theme_map[t["themeCode"]]["stockCount"] = len(stock_list)
for s in stock_list:
code = s["securityCode"]
edges.append({"themeCode": t["themeCode"], "securityCode": code})
if code not in stock_map:
stock_map[code] = {
"securityCode": code,
"securityName": s.get("securityName", ""),
"coverCount": 0,
"f3": s.get("f3"),
"f2": s.get("f2"),
"f62": s.get("f62"),
"f100": s.get("f100", ""),
"themeCodes": [],
}
stock_map[code]["coverCount"] += 1
stock_map[code]["themeCodes"].append(t["themeCode"])
# 4. 排序:覆盖题材数越多(穿透越强)排越前
stocks = sorted(stock_map.values(), key=lambda x: (-x["coverCount"], -(x.get("f3") or 0)))
stats = {
"themeCount": len(themes),
"stockCount": len(stocks),
"coreCount": sum(1 for s in stocks if s["coverCount"] >= 2),
"maxCover": max((s["coverCount"] for s in stocks), default=1),
}
result = {
"themes": list(theme_map.values()),
"stocks": stocks,
"edges": edges,
"stats": stats,
}
ttl = _dynamic_ttl()
if ttl > 0:
set_cache(cache_key, json.dumps(result, ensure_ascii=False), ttl_hours=ttl)
return result