From 0a205ffbf31db4e611fdacefad0a9dc295c7067f Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sat, 5 Sep 2026 04:09:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=98=E9=9D=A2=E6=B4=9E=E5=AF=9F?= =?UTF-8?q?=E7=AC=AC=E5=9B=9B=E6=89=B9=20=E2=80=94=20=E6=9D=BF=E5=9D=97?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=80=A7=E7=83=AD=E5=8A=9B=E5=9B=BE=20+=20?= =?UTF-8?q?=E5=BC=82=E5=8A=A8=E9=9B=B7=E8=BE=BE=20+=20=E9=87=8F=E8=83=BD?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 相关性 /hotspots 页内新增「相关性」视图:/board-mac/hotspot-correlation 复用热点历史矩阵缓存,对窗口内活跃板块(每日前 per_day 名并集,按上榜次数 取前 N)两两算日涨跌幅 Pearson 相关;红=同涨同跌(抱团)、绿=跷跷板(轮动), ECharts 热力图 + 双向色阶 visualMap;无缓存时透传 building 状态 - 异动雷达 /radar:沪深异动流时间线(封板/炸板/大笔买入/逼近涨停…), 每分钟异动密度柱 + 类型筛选 chips(带计数),行点击直达个股弹窗,15s 轮询 - 量能仪表盘并入市场情绪页:两市(上证+深成 5 分钟线)累计成交额曲线 vs 近 5 日同期均值(虚线),标题给出偏离百分比——放量/缩量一眼可辨 - 热点缓存判定重构为 _hotspot_history_or_build 公共入口,correlation 与 hotspot 共用同一构建状态机;新增相关性矩阵回归单测 --- docs/market-insights-roadmap.md | 6 +- src/easy_tdx/web/routers/board_mac.py | 125 ++++++-- tests/unit/test_board_mac_hotspot.py | 52 ++++ web-ui/src/App.vue | 1 + web-ui/src/api.ts | 18 ++ web-ui/src/components/HotspotCorrelation.vue | 154 ++++++++++ web-ui/src/router.ts | 3 + web-ui/src/types.ts | 13 + web-ui/src/views/HotspotView.vue | 15 +- web-ui/src/views/RadarView.vue | 306 +++++++++++++++++++ web-ui/src/views/SentimentView.vue | 115 ++++++- 11 files changed, 782 insertions(+), 26 deletions(-) create mode 100644 web-ui/src/components/HotspotCorrelation.vue create mode 100644 web-ui/src/views/RadarView.vue diff --git a/docs/market-insights-roadmap.md b/docs/market-insights-roadmap.md index 0aa9fb2..5a6d5f0 100644 --- a/docs/market-insights-roadmap.md +++ b/docs/market-insights-roadmap.md @@ -11,9 +11,9 @@ | ① | **市场情绪时间线** `/sentiment` | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ✅ 第三批 | | ⑨ | **市场宽度分时** `/sentiment` | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ✅ 第三批(随①) | | ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 后续批次 | -| ⑤ | **板块相关性热力图** | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ⏳ 第四批 | -| ③ | **异动雷达时间线** | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ⏳ 第四批 | -| ⑧ | **量能仪表盘** | 放量/缩量(两市成交额 vs 5日均量带) | 指数分钟线现成 | ⏳ 第四批 | +| ⑤ | **板块相关性热力图** `/hotspots` | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ✅ 第四批(热点滚动页内「相关性」视图) | +| ③ | **异动雷达时间线** `/radar` | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ✅ 第四批 | +| ⑧ | **量能仪表盘** `/sentiment` | 放量/缩量(两市累计成交 vs 5日同期均值) | 指数 5 分钟线现成 | ✅ 第四批(并入市场情绪页) | | ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ⏳ 收尾(必须做) | ## 批次 diff --git a/src/easy_tdx/web/routers/board_mac.py b/src/easy_tdx/web/routers/board_mac.py index d405f6e..f6f837f 100644 --- a/src/easy_tdx/web/routers/board_mac.py +++ b/src/easy_tdx/web/routers/board_mac.py @@ -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]: diff --git a/tests/unit/test_board_mac_hotspot.py b/tests/unit/test_board_mac_hotspot.py index 5d2df4b..4e8ed7e 100644 --- a/tests/unit/test_board_mac_hotspot.py +++ b/tests/unit/test_board_mac_hotspot.py @@ -327,3 +327,55 @@ def test_hotspot_missing_kline_board_excluded(): data, _ = _wait_ready(client, fake) assert data["total_boards"] == 2 assert all(r["code"] != "881101" for r in data["rows"]) + + +def test_hotspot_correlation_matrix_ready(): + """缓存就绪:相关矩阵直接可算,完全同向的两板块相关系数 = 1。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from easy_tdx.web.routers import board_mac + + board_mac._hotspot_history_cache["HY"] = ( + "2030-01-01", + { + "axis": ["2026-08-10", "2026-08-11", "2026-08-12"], + "pct": { + "881100": {"2026-08-10": 5.0, "2026-08-11": 3.0, "2026-08-12": 1.0}, + "881200": {"2026-08-10": 4.0, "2026-08-11": 2.0, "2026-08-12": 0.0}, + }, + "names": {"881100": "甲板块", "881200": "乙板块"}, + }, + ) + fake = _FakeHotspotMacClient() + try: + with TestClient(_hotspot_app(fake)) as client: + resp = client.get( + "/api/v1/board-mac/hotspot-correlation", + params={"board_type": "HY", "days": 5, "per_day": 2}, + ) + finally: + board_mac._hotspot_history_cache.clear() + + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["status"] == "ready" + assert [b["code"] for b in data["boards"]] == ["881100", "881200"] + assert data["matrix"][0][0] == 1.0 + assert data["matrix"][0][1] == pytest.approx(1.0, abs=0.01) # 完全线性同向 + assert data["matrix"][1][0] == data["matrix"][0][1] + + +def test_hotspot_correlation_building_passthrough(): + """无缓存:与 hotspot 相同的 building 状态透传,前端轮询即可。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeHotspotMacClient() + with TestClient(_hotspot_app(fake)) as client: + resp = client.get("/api/v1/board-mac/hotspot-correlation", params={"board_type": "HY"}) + assert resp.status_code == 200 + body = resp.json()["data"] + assert body["status"] in ("building", "error", "ready") # 单机假客户端极快时可能已完成 + if body["status"] == "building": + assert 0.0 <= body["progress"] <= 1.0 diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index 5a87b62..3363098 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -32,6 +32,7 @@ const sseLabel: Record = { 大盘日历 涨停生态 市场情绪 + 异动雷达 自选行情 期货持仓排名 diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index b746589..901d9c2 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -12,6 +12,7 @@ import type { CcpmProductsResponse, CcpmRankResponse, DataFrameResponse, + HotspotCorrelationResp, HotspotResp, LimitUpEcologyResp, LimitUpHistoryRow, @@ -847,6 +848,23 @@ export async function fetchLimitUpEcology(): Promise { return body.data } +/** 热点板块相关性矩阵(复用热点历史缓存;未构建时返回 building/error)。 */ +export async function fetchHotspotCorrelation( + boardType: string, + days: number, + perDay = 5, +): Promise { + const params = new URLSearchParams({ + board_type: boardType, + days: String(days), + per_day: String(perDay), + }) + const resp = await fetch(`${BASE}/board-mac/hotspot-correlation?${params}`) + if (!resp.ok) await throwError(resp) + const body = (await resp.json()) as { data: HotspotCorrelationResp } + return body.data +} + /** 当日情绪分钟曲线(采样器逐分钟落库;date=0 表示尚无采样)。 */ export async function fetchSentimentToday(): Promise { const resp = await fetch(`${BASE}/market/sentiment/today`) diff --git a/web-ui/src/components/HotspotCorrelation.vue b/web-ui/src/components/HotspotCorrelation.vue new file mode 100644 index 0000000..b620076 --- /dev/null +++ b/web-ui/src/components/HotspotCorrelation.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts index 6ed591f..fdc6a5a 100644 --- a/web-ui/src/router.ts +++ b/web-ui/src/router.ts @@ -12,6 +12,7 @@ import LlmHistoryView from './views/LlmHistoryView.vue' import LlmSettingsView from './views/LlmSettingsView.vue' import OptimizeView from './views/OptimizeView.vue' import PortfolioView from './views/PortfolioView.vue' +import RadarView from './views/RadarView.vue' import SentimentView from './views/SentimentView.vue' import ServerSettingsView from './views/ServerSettingsView.vue' import SignalRadarView from './views/SignalRadarView.vue' @@ -38,6 +39,8 @@ const routes = [ { path: '/limitup', name: 'limitup', component: LimitUpView }, // 市场情绪(宽度分时 + 涨停温度计;采样器盘中逐分钟积累) { path: '/sentiment', name: 'sentiment', component: SentimentView }, + // 异动雷达(沪深异动流时间线:封板/炸板/大笔买入…) + { path: '/radar', name: 'radar', component: RadarView }, { path: '/backtest', name: 'backtest', component: BacktestView }, { path: '/portfolio', name: 'portfolio', component: PortfolioView }, { path: '/optimize', name: 'optimize', component: OptimizeView }, diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index cae5dc5..cd40c0f 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -687,6 +687,19 @@ export interface LimitUpEcologyResp { blown: LimitUpEntry[] } +// ── 热点板块相关性(GET /api/v1/board-mac/hotspot-correlation) ────────────── + +export interface HotspotCorrelationResp { + status: 'ready' | 'building' | 'error' + progress?: number + error?: string + /** 入阵板块(按上榜次数降序),matrix 行列与之对齐 */ + boards?: Array<{ code: string; name: string; days_in: number }> + /** Pearson 相关系数矩阵(-1~1,null = 样本不足) */ + matrix?: Array> + days?: number +} + // ── 市场情绪(/market/sentiment/*,盘中逐分钟采样 + vipdoc 涨停史回补) ───── export interface SentimentSample { diff --git a/web-ui/src/views/HotspotView.vue b/web-ui/src/views/HotspotView.vue index 241eae1..548ea62 100644 --- a/web-ui/src/views/HotspotView.vue +++ b/web-ui/src/views/HotspotView.vue @@ -6,6 +6,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { fetchBoardHotspot, formatError } from '../api' import BoardDialog from '../components/BoardDialog.vue' +import HotspotCorrelation from '../components/HotspotCorrelation.vue' import HotspotMatrix from '../components/HotspotMatrix.vue' import HotspotStatStrip from '../components/HotspotStatStrip.vue' import type { HotspotResp, HotspotRow } from '../types' @@ -20,6 +21,7 @@ type SortKey = 'days_in' | 'sum_pct' | 'first_date' const boardType = ref<'HY' | 'GN' | 'FG'>(props.boardType ?? 'HY') const days = ref(20) const mode = ref<'top' | 'bottom'>('top') +const viewMode = ref<'matrix' | 'corr'>('matrix') watch( () => props.boardType, @@ -227,6 +229,10 @@ function openBoard(r: HotspotRow) { + + + +