feat: 盘面洞察第四批 — 板块相关性热力图 + 异动雷达 + 量能仪表盘

- 相关性 /hotspots 页内新增「相关性」视图:/board-mac/hotspot-correlation
  复用热点历史矩阵缓存,对窗口内活跃板块(每日前 per_day 名并集,按上榜次数
  取前 N)两两算日涨跌幅 Pearson 相关;红=同涨同跌(抱团)、绿=跷跷板(轮动),
  ECharts 热力图 + 双向色阶 visualMap;无缓存时透传 building 状态
- 异动雷达 /radar:沪深异动流时间线(封板/炸板/大笔买入/逼近涨停…),
  每分钟异动密度柱 + 类型筛选 chips(带计数),行点击直达个股弹窗,15s 轮询
- 量能仪表盘并入市场情绪页:两市(上证+深成 5 分钟线)累计成交额曲线
  vs 近 5 日同期均值(虚线),标题给出偏离百分比——放量/缩量一眼可辨
- 热点缓存判定重构为 _hotspot_history_or_build 公共入口,correlation 与
  hotspot 共用同一构建状态机;新增相关性矩阵回归单测
This commit is contained in:
Justin Gu
2026-09-05 04:09:03 +08:00
parent 37c732fb30
commit 0a205ffbf3
11 changed files with 782 additions and 26 deletions
+105 -20
View File
@@ -336,6 +336,39 @@ async def _hotspot_build(board_key: str, bt: Any, client: Any) -> None:
_logger.warning("热点矩阵构建失败 (%s): %s", board_key, exc)
def _hotspot_history_or_build(
key: str,
bt: Any,
client: Any,
*,
retry: bool = False,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""热点历史缓存的公共入口。
缓存就绪返回 ``(history, None)``;否则触发/汇报后台构建,返回
``(None, building_or_error_payload)``。error 状态保持稳定不自动重建,
保证失败原因能被前端读到(``retry=1`` 才重建)。
"""
cached = _hotspot_history_cache.get(key)
if cached is not None and cached[0] == _today_str():
return cached[1], None
state = _hotspot_builds.get(key)
running = state is not None and state.get("task") is not None and not state["task"].done()
# 需要新建:无状态 / 上次成功但缓存已过期 / 显式重试
if not running and (retry or state is None or state.get("status") == "ready"):
state = {"status": "building", "progress": 0.0, "task": None, "error": ""}
_hotspot_builds[key] = state
state["task"] = asyncio.create_task(_hotspot_build(key, bt, client))
running = True
if running:
return None, {"status": "building", "progress": state.get("progress", 0.0)}
return None, {
"status": "error",
"error": state.get("error") or "热点矩阵构建失败",
"progress": 1.0,
}
@router.get("/board-mac/hotspot", response_model=DictResponse)
async def board_hotspot(
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
@@ -363,26 +396,9 @@ async def board_hotspot(
bt = board_type_from_str(board_type)
key = bt.name
cached = _hotspot_history_cache.get(key)
if cached is None or cached[0] != _today_str():
state = _hotspot_builds.get(key)
running = state is not None and state.get("task") is not None and not state["task"].done()
# 需要新建:无状态 / 上次成功但缓存已过期 / 显式重试。
# error 状态保持稳定不自动重建,保证失败原因能被前端读到。
if not running and (retry or state is None or state.get("status") == "ready"):
state = {"status": "building", "progress": 0.0, "task": None, "error": ""}
_hotspot_builds[key] = state
state["task"] = asyncio.create_task(_hotspot_build(key, bt, client))
running = True
if running:
return DictResponse.from_dict(
{"status": "building", "progress": state.get("progress", 0.0)}
)
return DictResponse.from_dict(
{"status": "error", "error": state.get("error") or "热点矩阵构建失败", "progress": 1.0}
)
history = cached[1]
history, build_payload = _hotspot_history_or_build(key, bt, client, retry=retry)
if build_payload is not None:
return DictResponse.from_dict(build_payload)
axis_all: list[str] = history["axis"]
pct_map: dict[str, dict[str, float]] = history["pct"]
names: dict[str, str] = dict(history["names"])
@@ -497,6 +513,75 @@ async def board_hotspot(
return DictResponse.from_dict(payload)
@router.get("/board-mac/hotspot-correlation", response_model=DictResponse)
async def board_hotspot_correlation(
board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"),
days: int = Query(20, ge=5, le=_HOTSPOT_MAX_DAYS, description="相关性窗口交易日数"),
per_day: int = Query(5, ge=2, le=10, description="每日入选名次阈值(行集合口径)"),
top: int = Query(15, ge=5, le=25, description="入阵板块数上限(按上榜次数取前 N"),
client: Any = Depends(get_mac_client),
) -> DictResponse:
"""热点板块相关性矩阵:窗口内活跃板块两两日涨跌幅的 Pearson 相关系数。
行集合与 ``/board-mac/hotspot`` 同口径(每日 mode=top 前 per_day 名的并集,
不含今日实时列),按上榜次数取前 ``top`` 个板块入阵。复用热点历史矩阵缓存
(无缓存时返回与 hotspot 相同的 building/error 状态,前端先拉 hotspot 即可)。
相关系数 >0(红)= 同涨同跌,<0(绿)= 跷跷板。
"""
bt = board_type_from_str(board_type)
key = bt.name
history, build_payload = _hotspot_history_or_build(key, bt, client)
if build_payload is not None:
return DictResponse.from_dict(build_payload)
axis_all: list[str] = history["axis"]
pct_map: dict[str, dict[str, float]] = history["pct"]
names: dict[str, str] = dict(history["names"])
# 仅用已完成交易日(不含今日),与热点矩阵的历史段对齐
window = [d for d in axis_all if d != _today_str()][-days:]
col_pct: list[dict[str, float]] = [
{c: m[d] for c, m in pct_map.items() if d in m} for d in window
]
in_top: list[set[str]] = [
set(sorted(col, key=lambda c: col[c], reverse=True)[:per_day]) for col in col_pct
]
days_in: dict[str, int] = {}
for s in in_top:
for c in s:
days_in[c] = days_in.get(c, 0) + 1
chosen = sorted(days_in, key=lambda c: -days_in[c])[:top]
if len(chosen) < 2:
return DictResponse.from_dict(
{"status": "ready", "boards": [], "matrix": [], "days": len(window)}
)
frame = pd.DataFrame({c: pct_map[c] for c in chosen}).T # 板块 × 交易日,缺失为 NaN
corr = frame.T.corr(min_periods=max(3, len(window) // 2))
boards = [
{"code": c, "name": names.get(c, c), "days_in": days_in[c]} for c in chosen
]
matrix: list[list[float | None]] = [
[
None if pd.isna(corr.loc[a, b]) else round(float(corr.loc[a, b]), 2)
for b in chosen
]
for a in chosen
]
return DictResponse.from_dict(
{
"status": "ready",
"board_type": bt.name,
"days": days,
"boards": boards,
"matrix": matrix,
}
)
def _trailing_streak(flags: list[bool]) -> int:
"""从末尾向前数连续 True(末位为 False 时对齐"当前连榜"语义返 0)。"""
if not flags or not flags[-1]: