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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user