From e419f911b7b00423cea722080882d9bab8e0be06 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sat, 5 Sep 2026 02:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9B=98=E9=9D=A2=E6=B4=9E=E5=AF=9F?= =?UTF-8?q?=E4=B8=89=E6=A0=8F=E7=9B=AE=20=E2=80=94=20=E7=83=AD=E7=82=B9?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8/=E5=A4=A7=E7=9B=98=E6=97=A5=E5=8E=86/?= =?UTF-8?q?=E6=B6=A8=E5=81=9C=E7=94=9F=E6=80=81=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E9=BE=99=E5=A4=B4=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 热点滚动 /hotspots:交易日×板块涨跌矩阵(行业/概念/风格FG、领涨领跌镜像、今日~30日窗口), 每日名次徽标+连板/累计/首榜统计,统计卡可点击展开成员板块;后端 /board-mac/hotspot 两段式数据(历史日K矩阵当日缓存 + 今日实时列,周末 pre_close 未滚动去重), 单连接约束下后台构建+进度轮询;风格轮动 /styles 为同视图 FG 路由别名 - 大盘日历 /calendar:指数全年红绿热力图,方框大小编码成交额(年内四分位), 悬停浮框显示收盘/涨跌幅/成交额 - 涨停生态 /limitup:本地 vipdoc 日线离线回算连板天梯/首板二板分布/炸板率/跌停; 仅统计最后一根 bar 等于全市场最新交易日的股票(防停牌/退市/未下载陈旧文件污染), 主板 5% ST 判定带低价护栏;名称前端经 symbol-info 懒加载补齐 - 移除龙头池栏目及 /market/core-leaders 端点(screen 模块 universe=core 保留) - 设计文档:docs/hotspot-rolling-design.md、docs/market-insights-roadmap.md --- docs/hotspot-rolling-design.md | 293 ++++++++++++ docs/market-insights-roadmap.md | 31 ++ src/easy_tdx/screen/limitup.py | 247 ++++++++++ src/easy_tdx/web/routers/board_mac.py | 281 ++++++++++- src/easy_tdx/web/routers/market.py | 50 +- tests/unit/test_board_mac_hotspot.py | 329 +++++++++++++ tests/unit/test_limitup_ecology.py | 166 +++++++ web-ui/src/App.vue | 4 +- web-ui/src/api.ts | 37 +- web-ui/src/components/HotspotMatrix.vue | 256 ++++++++++ web-ui/src/components/HotspotStatStrip.vue | 257 ++++++++++ web-ui/src/components/RiskDisclaimer.vue | 2 +- web-ui/src/format.ts | 10 + web-ui/src/router.ts | 14 +- web-ui/src/types.ts | 82 +++- web-ui/src/views/CoreLeadersView.vue | 193 -------- web-ui/src/views/HotspotView.vue | 517 +++++++++++++++++++++ web-ui/src/views/IndexCalendarView.vue | 505 ++++++++++++++++++++ web-ui/src/views/LimitUpView.vue | 421 +++++++++++++++++ 19 files changed, 3471 insertions(+), 224 deletions(-) create mode 100644 docs/hotspot-rolling-design.md create mode 100644 docs/market-insights-roadmap.md create mode 100644 src/easy_tdx/screen/limitup.py create mode 100644 tests/unit/test_board_mac_hotspot.py create mode 100644 tests/unit/test_limitup_ecology.py create mode 100644 web-ui/src/components/HotspotMatrix.vue create mode 100644 web-ui/src/components/HotspotStatStrip.vue delete mode 100644 web-ui/src/views/CoreLeadersView.vue create mode 100644 web-ui/src/views/HotspotView.vue create mode 100644 web-ui/src/views/IndexCalendarView.vue create mode 100644 web-ui/src/views/LimitUpView.vue diff --git a/docs/hotspot-rolling-design.md b/docs/hotspot-rolling-design.md new file mode 100644 index 0000000..f246ca6 --- /dev/null +++ b/docs/hotspot-rolling-design.md @@ -0,0 +1,293 @@ +# 市场热点滚动 页面设计(v1 草案) + +> 目标:新增「热点滚动」栏目(`/hotspots`),让用户直观看到**一段时间内市场热点如何形成、 +> 谁在持续领涨、谁在领跌、热点之间如何轮动**。核心表达是「交易日 × 板块」的红涨绿跌热点矩阵, +> 辅以轮动排名曲线。配色遵循 A 股惯例:红涨绿跌(`UP_COLOR=#ef4146` / `DOWN_COLOR=#18a058`)。 +> +> **P1 已实施**(后端端点 + 热点矩阵 + 统计卡 + 领涨/领跌 + 后台构建进度,单测 +> `tests/unit/test_board_mac_hotspot.py`);轮动曲线等见 §8 分期。 + +--- + +## 0. 可行性结论(先回答"能不能做") + +**可行,且不需要任何新数据源。** 需求的本质数据是一张 +「最近 N 个交易日 × 全部板块」的**每日涨跌幅矩阵**,而这条数据路径全部是已验证的现成能力: + +| 现成能力 | 位置 | 在本需求中的角色 | +|---|---|---| +| 板块指数日 K(881xxx/885xxx,SH 市场) | `MacClient.get_stock_kline`;`/bars` 已被 BoardDialog 用于板块日K;`get_board_change_ranking`(client.py:963)内部就是逐板块拉日K再计算 | **矩阵的原始数据**:每板块一根日K序列 → 逐日 `close/pre_close-1` | +| 全量板块列表(code/name/market) | `get_board_list`(`/board-mac/list`) | 矩阵的行全集 + 当日实时涨跌(price/pre_close) | +| 当日实时口径 | `/board-mac/overview` 同款算法(15s 缓存) | 矩阵的**今日列**(实时滚动),历史列不变 | +| ECharts Line / 主题色 | `echarts-setup.ts`(LineChart 已注册,红涨绿跌常量已定义) | 轮动排名曲线(bump chart)零新增依赖 | +| BoardDialog / session 门控轮询 | `BoardOverviewView.vue:160-202` 模式 | 点击穿透 + 盘中自动刷新复用 | + +**唯一缺口**:一个把「逐板块日K → 日期×板块涨跌矩阵 → 每日排名 → 入选行集合」聚合起来的后端端点。 +注意**不能**循环调用现成的 `/board-mac/change-ranking?days=1&target_date=D` 来拼 N 天—— +该端点每次调用都会重新串行拉取全部板块日K(client.py:1008 起逐板块 `await`),N 天就是 N 倍成本; +正确做法是**一遍拉取、一次建满矩阵**(下文 §2)。 + +--- + +## 1. 关键约束:MAC 客户端是单连接串行 + +`AsyncMacClient` 全程只有一条 `AsyncTdxConnection`(client.py:1250),所有命令在同一 TCP 连接上排队。 +这决定了两个设计决策: + +1. **成本预算**(按单请求 30–100ms 估算): + - 行业 HY ≈ 86 板块 → 全量日K一次 ≈ **4–9s**(仅首次,当日缓存) + - 概念 GN ≈ 300–500 板块 → ≈ **20–50s**(仅首次,当日缓存) +2. **构建必须放后台任务**:若在请求线程里同步建矩阵,构建期间会**占住共享连接**, + 拖死同服务器的其他所有页面请求。因此首次构建走后台任务 + 进度轮询(§2.3)。 + +--- + +## 2. 数据端点设计(后端唯一新增) + +### 2.1 端点 + +``` +GET /api/v1/board-mac/hotspot?board_type=HY|HY2|GN&days=20&mode=top&per_day=5 +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `board_type` | `HY` | 板块类型 | +| `days` | `20` | 窗口交易日数(1–60;1=仅今日,前端「今日」档) | +| `mode` | `top` | `top`=领涨(每日最强入选)/ `bottom`=领跌(每日最弱入选) | +| `per_day` | `5` | 每日入选名次阈值(2–10) | +| `retry` | `false` | 上次构建失败后强制重建(不带此参数时 error 状态稳定返回,轮询不会冲掉错误信息) | + +### 2.2 响应 + +```jsonc +{ + "status": "ready", // ready | building(building 时只有 progress) + "progress": 1.0, // building 时的构建进度 0–1 + "board_type": "HY", "days": 20, "mode": "top", "per_day": 5, + "generated_at": 1725400000, + "session": "live", // live=今日列为盘中实时 + "dates": ["2026-08-11", "...", "2026-09-05"], // 交易日轴,最后一格=今日 + "today_index": 19, + "total_boards": 86, // 参与排名的板块总数 + "rows": [ // 行集合 = 窗口内「每日入选」板块的并集 + { + "code": "881106", "name": "存储器", + "pct": [3.2, -1.1, null, /* …对齐 dates,null=当日无K线 */], + "rank": [1, null, 23, /* …当日全类型排名,mode=top 时 1=涨幅第一 */], + "days_in": 12, // 上榜天数(进入每日前 per_day 的天数) + "streak": 3, // 当前连续上榜天数(截至最后一列) + "best_rank": 1, + "sum_pct": 42.1, // 窗口累计涨跌(%) + "first_date": "2026-08-11" // 窗口内首次上榜日 → 热点"形成"时点 + } + ] +} +``` + +payload 规模:60 行 × 60 列 × 2 数组 ≈ 每响应几十 KB,无压力。 + +### 2.3 服务端算法与缓存(两段式,对应"历史不变、今日滚动") + +``` +第一段:历史矩阵(当日不可变) + 1. get_board_list(board_type) → 全量板块 + 2. 逐板块 get_stock_kline(DAILY, count=days+12, adjust=NONE) + (asyncio.gather + Semaphore(8);连接本身串行,信号量只做秩序与背压) + 3. 交易日轴 = 数据最全板块的最后 days 个交易日 + 4. pct[d] = close[d]/close[d-1]-1(窗口首日的前收由多拉的缓冲 bar 提供) + 5. 每日对全类型板块排名 → rank 矩阵 + 6. 行集合 = ∪(每日 mode 方向前 per_day 名);补齐 days_in/streak/sum_pct/first_date + ↳ 缓存:`_hotspot_history_cache[(board_type,)] = (日历日, 满60日矩阵)`, + **当日全天有效**(历史 K 线收盘后不可变),跨日首个请求重建;days 只做切片不进缓存键 + +第二段:今日列(滚动) + 7. get_board_list 默认排序(1–2 页,15s TTL,与 overview 同口径)取 price/pre_close + 8. 盘中:作为最后一列与实时排名合并;若今日列日期 == 历史轴最后一日(休市/周末)则不重复追加 + ↳ 今日列随每次请求现算(廉价),`session` 字段告知前端是否 live + +构建调度: + 首次请求某 board_type 且无当日缓存 → 启动 asyncio 后台任务建矩阵, + 立即返回 {"status":"building","progress":...};前端 1s 轮询直至 ready。 + 进度 = 已完成板块数/总数。构建期间该端点不占请求线程,其他页面不受阻。 +``` + +--- + +## 3. 页面总体设计 + +``` +路由(行情分组) + /hotspots 热点滚动 → HotspotView.vue +``` + +新组件 3 个(`components/`): + +- `HotspotMatrix.vue` — **热点矩阵**(核心视图):交易日×板块 DOM 网格,红涨绿跌色阶 + 每日名次徽标 +- `HotspotBump.vue` — **轮动曲线**:每日名次随时间流动的 bump chart(ECharts Line,y 轴反转) +- `HotspotStatStrip.vue` — 窗口统计卡条(领涨王/持续热点/新面孔/一日游) + +视图 `HotspotView.vue`:工具行 + 统计条 + [矩阵|曲线] 切换 + 轮询与弹窗编排。 + +### 3.1 为什么主视图选"矩阵"而不是河流图/堆叠面积 + +- 用户要的是"**谁**在哪几天领涨/领跌 + **怎么轮动**"——矩阵同时给出实体(板块名)、 + 时间(列)、强度(色阶)、名次(徽标)四个维度,扫一眼即可读出 + "红色竖带从 A 列流向 B 列"这种轮动直觉; +- ThemeRiver/堆叠面积图好看但读不出精确值与名次,且需新注册 ECharts 图表类型; +- 本仓库已有先例:板块总览的 BoardTiles 用 CSS Grid 而非 ECharts heatmap—— + 矩阵沿用 DOM 网格,天然支持 sticky 行头/列头、单击弹窗、行内汇总列。 + +--- + +## 4. 功能设计 + +### 4.1 工具行 + +``` +[行业|概念] [近10日|近20日|近30日] [领涨|领跌] [热点矩阵|轮动曲线] ⟳60s[ON] 构建于 14:32:05 +``` + +- **领涨/领跌**切换 = `mode` 参数,整套视图(行集合、排名、统计卡、配色重心)随之镜像; +- 首次构建/切换板块类型时显示进度条("正在构建板块日K矩阵 42/86…"); +- 盘中 60s 轮询仅刷新**今日列**(历史列服务端已缓存,响应本身很快); + session 门控 + visibilitychange 暂停,复用 BoardOverviewView 模式。 + +### 4.2 统计卡条(HotspotStatStrip,前端由 rows 派生,零额外请求) + +卡片可交互:**领涨(跌)王卡单击直达该板块 BoardDialog;其余卡片数量不为 0 时可点击, +展开成员板块 chips 面板(名称 + 窗口累计,chips 单击再开弹窗)**。 + +| 卡片 | 计算 | 领跌模式 | +|---|---|---| +| 窗口领涨王 | `sum_pct` 最大的行 + 累计涨幅 | 窗口领跌王(`sum_pct` 最小) | +| 持续热点 | `days_in ≥ max(3, ⌈days/4⌉)` 的板块数(持续性热点的证据;真实数据下 ÷3 会常年为 0) | 持续弱势 | +| 新面孔 | `first_date` 落在最近 5 个交易日的板块数(**热点正在形成**) | 新杀跌 | +| 一日游 | `days_in == 1` 的板块数(脉冲行情占比,越高说明轮动越快) | 同义 | + +### 4.3 热点矩阵(核心,HotspotMatrix) + +**布局**(横向可滚动,左侧行头 sticky): + +``` +│ 板块 │ 08-11 08-12 08-13 … 09-04 ┃ 09-05·今 │ 上榜 连榜 累计 首榜 │ +│───────────┼───────────────────────────╋──────────┼───────────────────────┤ +│ 存储器 │ Ⓐ1 Ⓐ2 · … Ⓐ3 ┃ Ⓐ2 │ 12 3 +42.1% 08-11 │ +│ CPO │ Ⓐ4 · Ⓐ1 … · ┃ Ⓐ1 │ 9 1 +31.5% 08-12 │ +│ 房地产开发│ · · · … Ⓐ5 ┃ · │ 2 0 +6.2% 09-04 │ +│ …(行集合,默认按 上榜次数↓ 排序) │ +``` + +- **单元格着色**:`pct>0` 红 / `pct<0` 绿,透明度随 `|pct|` 分 5 档 + (0–0.5 / 0.5–1 / 1–2 / 2–3 / >3% → 0.15/0.30/0.50/0.70/0.95),与 BoardTiles 同规; + `null`(无K线)留空。**红涨绿跌,与全站一致。** +- **上榜徽标**:当日进入前 per_day 的格子加 `①②③④⑤` 名次徽标(当日前三名加粗描边)—— + 一行里徽标的出现/消失/位移就是该热点的形成—持续—衰退曲线; +- **今日列**:竖线分隔 + 列头「今日·实时」徽标,盘中数值随轮询滚动; +- **行尾汇总**:上榜天数 / 当前连榜 / 窗口累计 / 首次上榜(点击列头排序; + 「首榜」降序 = 新热点在前,直接回答"热点如何形成"); +- **交互**:hover 出 tooltip(日期/板块/涨跌幅/当日第 N 名);单击格子或行头 → `BoardDialog` + (左分时/日K、右成分股,原样复用);行内筛选「只看上榜≥2 次」+ 搜索框(概念页必备); +- 行集合上限:候选行按 `days_in` 截断至 60 行,页脚注明"仅展示窗口内上榜板块"。 + +### 4.4 轮动曲线(HotspotBump,第二视图) + +- ECharts Line,x = 交易日,y = 当日名次(**y 轴反转,1 在顶部**,范围 1–per_day); +- 每个板块一条折线,仅在「上榜日」有点(未上榜断线)——线的爬升/俯冲/交叉即轮动全景; +- 取 `days_in` 前 12 的板块入图(可读性上限),图例可点选隐藏; +- tooltip axisPointer 显示当日全部入选板块及名次;红涨绿跌仅用于线的 Current #1 标注, + 线本身用分类色板(名次图的颜色语义是"板块身份"而非涨跌,避免与矩阵冲突); +- 数据零新增:直接消费矩阵响应的 `rank` 数组。 + +### 4.5 领跌模式的镜像语义 + +`mode=bottom` 时:行集合 = 每日跌幅前 per_day 的并集;`rank` 1 = 当日跌幅最大; +统计卡换弱势词汇(领跌王/持续弱势/新杀跌);矩阵主色自然偏绿(数据决定,无需特殊处理)。 +用户问题里"谁又在领跌"由此获得与领涨完全对等的一等公民视图。 + +--- + +## 5. UI 布局线框 + +``` +┌────────────────────────────────────────────────────────────────────────────────┐ +│ 热点滚动 [行业|概念] [近10|20|30日] [领涨|领跌] [矩阵|曲线] ⟳60s[ON] 14:32:05 │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ 窗口领涨王 │ │ 持续热点 │ │ 新面孔 │ │ 一日游 │ ← HotspotStatStrip │ +│ │ 存储器 │ │ 5 个 │ │ 3 个 │ │ 7 个 │ │ +│ │ +42.1% │ │ 上榜≥7天 │ │ 近5日首上榜 │ │ 仅上榜1天 │ │ +│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │ +├────────────────────────────────────────────────────────────────────────────────┤ +│ 行排序[上榜次数▾] [只看上榜≥2次☐] [搜索____] 图例 ▉红=涨 ▉绿=跌 ①=当日第N名 │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ 板块 │ 08-11 08-12 … ┃ 今日 │ 上榜 连榜 累计 首榜 │ │ │ +│ │ 存储器 │ ①1 ②2 … ┃ ②2 │ 12 3 +42% 08-11 │ ← 红绿矩阵 │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ (轮动曲线 tab:y 反转名次折线,1 在顶,每板块一条线) │ +└────────────────────────────────────────────────────────────────────────────────┘ + 单击板块/格子 → BoardDialog(分时/日K + 成分股 → StockDialog) +``` + +视觉细则: + +- 配色只用既有变量:涨 `#ef4146`、跌 `#18a058`、主题背景/文字沿用全局;不做新色板; +- 徽标 `①` 用行内圆点+数字,红底白字(领涨)/ 绿底白字(领跌); +- 矩阵列头 sticky(横向滚动时日期可见)、行头 sticky(纵向滚动时板块名可见); +- 空态/错误态:顶部错误条 + 重试(复用 api.ts 统一解析);构建中进度条;休市时今日列标"收盘"。 + +--- + +## 6. 改动清单 + +### 后端(1 个端点 + 缓存 + 单测) + +| 文件 | 改动 | +|---|---| +| `src/easy_tdx/web/routers/board_mac.py` | 新增 `GET /board-mac/hotspot`:两段式算法(§2.3)+ 后台构建任务 + 当日缓存;模块级 `_hotspot_history_cache` | +| `tests/unit/test_board_mac_hotspot.py` | 单测(mock AsyncMacClient):矩阵计算口径(首日前收)/ 交易日轴 / mode=top·bottom 行集合与排名 / 今日列合并与休市去重 / 缓存跨日失效 / building→ready 状态机 | + +### 前端 + +| 文件 | 改动 | +|---|---| +| `web-ui/src/App.vue` | 行情分组 +1 RouterLink(热点滚动) | +| `web-ui/src/router.ts` | `/hotspots` → `HotspotView` | +| `web-ui/src/types.ts` | `HotspotResp / HotspotRow` | +| `web-ui/src/api.ts` | `fetchBoardHotspot(boardType, days, mode, perDay)` | +| `web-ui/src/views/HotspotView.vue` | 主视图(工具行/统计条/视图切换/轮询/弹窗编排/构建进度轮询) | +| `web-ui/src/components/HotspotMatrix.vue` | 热点矩阵(sticky 双表头、色阶、徽标、tooltip、排序筛选) | +| `web-ui/src/components/HotspotBump.vue` | 轮动曲线(ECharts Line,y 反转) | +| `web-ui/src/components/HotspotStatStrip.vue` | 窗口统计卡 | + +--- + +## 7. 边界与风险 + +1. **单连接串行 × 概念板块量大**:GN 首次构建 20–50s——后台任务 + 进度条 + 当日缓存兜底; + 默认板块类型为行业(首屏 4–9s 可接受)。文档与页面均提示"概念首次构建较慢"。 +2. **部分服务器不给 88xxxx 日K**:该板块 pct 记 null、不参与当日排名;行头仍展示(有历史的日子照常着色)。 +3. **新板块/长假期**:窗口首日的前收取自缓冲 bar(多拉 12 根);缓冲不足时首日 pct 置 null。 +4. **休市/周末**:历史轴止于最近交易日;今日列与历史末列去重(§2.3 第 8 步),不出现重复列。 +5. **盘中口径**:今日列 = price/pre_close-1(与 overview 同口径,规避 Issue #53 的 CHANGE_PCT 恒 0); + 历史列 = 日K close 比值。两段口径在收盘后自然收敛一致。 +6. **缓存正确性**:历史矩阵按日历日失效(跨日首个请求重建),不存在隔夜脏数据; + `days` 只切片不进缓存键,切窗口零成本。 + +## 8. 分期计划 + +| 期 | 内容 | 预估 | +|---|---|---| +| **P1(MVP)** | `/board-mac/hotspot` 端点 + 缓存 + 后台构建 + 单测;热点矩阵(领涨/领跌、10/20/30日、行业/概念)+ 统计卡 + BoardDialog 复用 + 构建进度条 | 2–2.5 天 | +| **P2** | 轮动曲线 bump chart、磁盘持久化历史矩阵(重启不重拉)、只看≥2 次/搜索/localStorage 偏好、HY2 切换 | 1 天 | +| **P3(远期)** | 概念→个股联动(点热点格子看当日成分股贡献)、热点轮动 AI 解读、自定义窗口与多窗口对比 | 另立项 | + +## 9. 验收清单(P1) + +- [ ] 左侧导航出现「热点滚动」,路由/高亮正常 +- [ ] 行业页首次进入出现构建进度条,完成后矩阵展示完整 20 列(列数=交易日数)× 上榜板块并集 +- [ ] 矩阵配色红涨绿跌且色阶随幅度增强;每日前三名徽标可辨识;hover tooltip 数值与当日排名正确 +- [ ] 领涨/领跌切换后行集合、排名语义、统计卡文案整体镜像 +- [ ] 盘中今日列 60s 滚动且休市去重;历史列当日不重复拉取(网络面板仅 1 个 hotspot 轮询请求) +- [ ] 单击板块 → BoardDialog 全链路(分时/日K/成分股) +- [ ] 概念页首次构建期间其他页面(行情看板等)请求不受阻塞 +- [ ] 断开 MAC 服务器时错误条 + 重试,页面不白屏 diff --git a/docs/market-insights-roadmap.md b/docs/market-insights-roadmap.md new file mode 100644 index 0000000..b7399b6 --- /dev/null +++ b/docs/market-insights-roadmap.md @@ -0,0 +1,31 @@ +# 盘面洞察栏目系列 · 路线图 + +> 目标:延续「热点滚动」的思路,做一系列帮助直观理解盘面的栏目。 +> 排序原则:先复用现有基建的快赢,再做需要采样器/新计算的大件,AI 汇总收尾。 + +| # | 栏目 | 回答的问题 | 数据基础 | 状态 | +|---|---|---|---|---| +| ④ | **风格轮动** `/styles` | 今天是大票还是小票、高股息还是成长 | 热点滚动基建 × FG 风格板块,纯复用 | ✅ 第一批(后并入热点滚动页内「风格」档,独立导航已移除) | +| ⑦ | **大盘日历** `/calendar` | 全年情绪一眼扫完(红绿日历热力图) | 指数日K(`/bars/index`)现成 | ✅ 第一批(含悬停浮框 + 成交额编码方框大小) | +| ② | **涨停生态 / 连板天梯** `/limitup` | 连板高度、首板/二板分布、炸板率、跌停 | 本地 vipdoc .day 文件(strength 扫描器同款读取器),close==涨停价 连续天数可回算 | ✅ 第二批 | +| ① | **市场情绪时间线** | 情绪处于冰点/回暖/高潮/退潮 | 涨跌家数、涨停跌停数逐分钟采样(新采样器 + sqlite) | ⏳ 第三批 | +| ⑨ | **市场宽度分时** | 指数新高但上涨家数背离的顶部信号 | 依赖 ① 的采样器 | ⏳ 第三批(随①) | +| ⑥ | **板块资金日历** | 哪天钱涌向了哪个板块 | board summary 主力净额逐日采样 | ⏳ 第三批(随①) | +| ⑤ | **板块相关性热力图** | 哪些板块同涨同跌(抱团 vs 分散) | 热点滚动已缓存的 60 日涨跌矩阵求两两相关 | ⏳ 第四批 | +| ③ | **异动雷达时间线** | 异动密度骤增 = 盘面转折点 | `/mac/unusual` 现成,纯前端 | ⏳ 第四批 | +| ⑧ | **量能仪表盘** | 放量/缩量(两市成交额 vs 5日均量带) | 指数分钟线现成 | ⏳ 第四批 | +| ⑩ | **AI 盘面早报/复盘** | 把以上所有数据"自动读"给你听 | LLM 管道 + ai-history 归档现成 | ⏳ 收尾(必须做) | + +## 批次 + +- **第一批(本轮)**:④ + ⑦ —— 零后端改动,纯前端复用。 +- **第二批**:② 涨停生态 —— 新端点,读本地 .day 文件回算连板/炸板,无历史包袱。 +- **第三批**:① 情绪采样器(每分钟落 sqlite)+ ⑨ 宽度分时 + ⑥ 资金日历。 +- **第四批**:⑤ 相关性热力图、③ 异动雷达、⑧ 量能仪表盘。 +- **收尾**:⑩ AI 复盘 —— 输入 = 热点矩阵 Top + 情绪曲线 + 连板梯 + 量能,收盘后自动生成归档。 + +## 设计约定(继承热点滚动) + +- 配色:红涨绿跌 `#ef4146` / `#18a058`,幅度 5 档透明度。 +- 交互:单击穿透 BoardDialog / StockDialog;交易时段门控轮询;页面隐藏暂停。 +- 后端:新端点一律带 TTL/当日缓存与后台构建,单连接串行约束下不占请求线程。 diff --git a/src/easy_tdx/screen/limitup.py b/src/easy_tdx/screen/limitup.py new file mode 100644 index 0000000..621dfb3 --- /dev/null +++ b/src/easy_tdx/screen/limitup.py @@ -0,0 +1,247 @@ +"""涨停生态计算(本地 vipdoc .day 文件,离线快速回算连板/炸板/跌停)。 + +设计要点: + +- **数据源**:``vipdoc/{sh,sz}/lday/*.day``(与 strength 扫描器同款读取器 + :func:`easy_tdx.offline.daily_bar.read_daily_bars`),不依赖网络;数据新鲜度 + 取决于本机通达信客户端的数据日期,因此结果必须携带 ``data_date`` 供前端明示。 +- **涨停判定**:收盘价 == 涨停价(前收 × 涨幅上限,四舍五入到分)。 + 涨幅上限按代码段近似:主板(60/00) 10%、创业板(30)/科创板(68) 20%。 + .day 文件无证券名称,无法识别 ST——对主板额外按 5% 判定并标记 ``st=True`` + (常规股票恰收在 +5.00% 整的误报率极低,前端展示名称后可自辨)。 +- **炸板**:当日 high 触及涨停价但收盘未封住(close < 涨停价)。 +- **连板高度(streak)**:截至最新一根 bar 的连续涨停天数(按 bar 连续计, + 停牌跳日不中断,与通行口径一致)。 +- 纯函数 + 文件遍历分离,便于用合成 .day 文件做单测。 +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from pathlib import Path + +from easy_tdx.offline.daily_bar import _detect_security_type, read_daily_bars +from easy_tdx.offline.paths import resolve_vipdoc + +_A_STOCK_TYPES = frozenset({"SH_A_STOCK", "SZ_A_STOCK"}) + +__all__ = ["LimitUpEntry", "LimitUpEcology", "compute_limitup_ecology"] + + +def _round_price(x: float) -> float: + """四舍五入到分(Python round 是银行家舍入,交易所是四舍五入,不能混用)。""" + return math.floor(x * 100 + 0.5) / 100 + + +def _limit_ratio(code: str) -> float: + """涨幅上限:创业板/科创板 20%,其余主板 10%(ST 由调用侧按 5% 二次判定)。""" + if code.startswith(("30", "68")): + return 0.20 + return 0.10 + + +@dataclass +class LimitUpEntry: + """单只涨停/跌停/炸板股票的回算结果。""" + + code: str + market: str # SH / SZ + pct: float # 最新日涨跌幅(%,按 close/prev_close-1) + streak: int = 0 # 连续涨停/跌停天数(截至最新 bar) + st: bool = False # 主板 5% 判定(疑似 ST) + blown: bool = False # 炸板(曾触及涨停未封住) + + +@dataclass +class LimitUpEcology: + """全市场涨停生态快照。""" + + data_date: int # 全市场最新 bar 日期 YYYYMMDD(vipdoc 新鲜度) + total: int # 参与统计的股票数 + limit_up: list[LimitUpEntry] = field(default_factory=list) + limit_down: list[LimitUpEntry] = field(default_factory=list) + blown: list[LimitUpEntry] = field(default_factory=list) # 炸板(曾涨停未封住) + + def summary(self) -> dict[str, object]: + heights = [e.streak for e in self.limit_up] + touched = len(self.limit_up) + len(self.blown) + return { + "data_date": self.data_date, + "total": self.total, + "limit_up_count": len(self.limit_up), + "limit_down_count": len(self.limit_down), + "blown_count": len(self.blown), + # 炸板率 = 炸板 / (封住 + 炸板),无分母时为 None + "blown_rate": round(len(self.blown) / touched * 100, 1) if touched else None, + "max_streak": max(heights) if heights else 0, + "first_board": sum(1 for h in heights if h == 1), + "second_board": sum(1 for h in heights if h == 2), + "plus3": sum(1 for h in heights if h >= 3), + } + + +def _entry_from_closes( + closes: list[float], + last_high: float, + market: str, + code: str, +) -> LimitUpEntry | None: + """从收盘价序列判定最新交易日的涨停/跌停/炸板与连板高度。 + + Args: + closes: 最近若干根 bar 的收盘价(时间升序,最后一根 = 数据日)。 + last_high: 数据日的最高价(炸板判定用)。 + market: SH / SZ。 + code: 6 位代码。 + """ + if len(closes) < 2: + return None + prev = closes[-2] + if prev <= 0: + return None + + pct = (closes[-1] / prev - 1.0) * 100.0 + entry = LimitUpEntry(code=code, market=market, pct=round(pct, 2)) + + up_ratio = _limit_ratio(code) + limit_up_price = _round_price(prev * (1 + up_ratio)) + # 主板 5%:疑似 ST 涨停。低价股(< 3 元)最小报价单位 0.01 占比过大, + # +5% 整的巧合概率骤增,跳过 ST 判定(宁可漏报不误报)。 + st_applicable = up_ratio == 0.10 and prev >= 3.0 + st_price = _round_price(prev * 1.05) if st_applicable else None + limit_down_price = _round_price(prev * (1 - up_ratio)) + st_down_price = _round_price(prev * 0.95) if st_applicable else None + + def _eq(a: float, b: float) -> bool: + return abs(a - b) < 1e-4 + + def _is_up(i: int) -> bool: + """第 i 根是否涨停(用第 i-1 根收盘作前收)。""" + if i < 1: + return False + p = closes[i - 1] + c = closes[i] + if _eq(c, _round_price(p * (1 + up_ratio))): + return True + return st_applicable and _eq(c, _round_price(p * 1.05)) + + # 连板高度(截至最后一根) + streak = 0 + i = len(closes) - 1 + while i >= 1 and _is_up(i): + streak += 1 + i -= 1 + entry.streak = streak + entry.st = bool(streak > 0 and st_price is not None and _eq(closes[-1], st_price)) + + if streak > 0: + entry.blown = False + return entry + + # 未封住的场合:炸板(high 触及涨停价)或跌停 + if _eq(last_high, limit_up_price): + entry.blown = True + return entry + + if _eq(closes[-1], limit_down_price) or ( + st_down_price is not None and _eq(closes[-1], st_down_price) + ): + down_streak = 0 + j = len(closes) - 1 + while j >= 1: + p = closes[j - 1] + c = closes[j] + hit = _eq(c, _round_price(p * (1 - up_ratio))) + if not hit and st_applicable: + hit = _eq(c, _round_price(p * 0.95)) + if not hit: + break + down_streak += 1 + j -= 1 + entry.streak = down_streak + return entry + return None + + +def compute_limitup_ecology( + vipdoc_path: str | Path | None = None, + *, + max_files: int = 20000, +) -> LimitUpEcology: + """扫描全市场 .day 文件,回算最新交易日的涨停生态。 + + Args: + vipdoc_path: vipdoc 目录,None 则自动检测。 + max_files: 文件数上限(防意外巨量文件拖死扫描)。 + + Returns: + :class:`LimitUpEcology`;vipdoc 不可用时 total=0。 + """ + eco = LimitUpEcology(data_date=0, total=0) + try: + vipdoc = resolve_vipdoc(vipdoc_path) + except Exception: # noqa: BLE001 — 路径不存在/自动检测失败:按空数据处理 + return eco + if not vipdoc.is_dir(): + return eco + + files: list[tuple[Path, str, str]] = [] + for exchange in ("sz", "sh"): + lday_dir = vipdoc / exchange / "lday" + if not lday_dir.is_dir(): + continue + for filepath in sorted(lday_dir.glob("*.day")): + if _detect_security_type(filepath.name) not in _A_STOCK_TYPES: + continue + code = filepath.name.lower()[2:8] + files.append((filepath, exchange.upper(), code)) + if len(files) >= max_files: + break + if len(files) >= max_files: + break + eco.total = len(files) + + # 一遍读取,仅保留尾部收盘/最高价;随后按"最后一根日期 == 全市场最新交易日" + # 过滤——vipdoc 里大量文件因停牌/退市/未下载而停在历史日期,若不过滤会把 + # 多年前的"涨停"当成今天的(真实教训:退市前仙股文件冒出 5 连板)。 + _TAIL = 13 # 连板判定最多回看 12 根 + 判定用前收 + scanned: list[tuple[int, str, str, list[float], list[float]]] = [] + for filepath, market, code in files: + try: + bars = read_daily_bars(filepath) + except Exception: # noqa: BLE001 — 单文件损坏不阻塞整体 + continue + if len(bars) < 2: + continue + tail = bars[-_TAIL:] + last_date = bars[-1].year * 10000 + bars[-1].month * 100 + bars[-1].day + scanned.append( + ( + last_date, + market, + code, + [b.close for b in tail], + [b.high for b in tail], + ) + ) + if last_date > eco.data_date: + eco.data_date = last_date + + for last_date, market, code, closes, highs in scanned: + if last_date != eco.data_date: + continue # 数据不新鲜(停牌/退市/未下载),不参与今日生态 + entry = _entry_from_closes(closes, highs[-1], market, code) + if entry is None: + continue + if entry.blown: + eco.blown.append(entry) + elif entry.pct > 0 and entry.streak > 0: + eco.limit_up.append(entry) + elif entry.pct < 0 and entry.streak > 0: + eco.limit_down.append(entry) + + eco.limit_up.sort(key=lambda e: (-e.streak, -e.pct)) + eco.limit_down.sort(key=lambda e: (-e.streak, e.pct)) + eco.blown.sort(key=lambda e: -e.pct) + return eco diff --git a/src/easy_tdx/web/routers/board_mac.py b/src/easy_tdx/web/routers/board_mac.py index 846cd0a..d405f6e 100644 --- a/src/easy_tdx/web/routers/board_mac.py +++ b/src/easy_tdx/web/routers/board_mac.py @@ -1,13 +1,17 @@ -"""板块分析路由:板块列表、成分、归属、摘要、涨幅排名、N日涨幅。""" +"""板块分析路由:板块列表、成分、归属、摘要、涨幅排名、N日涨幅、热点滚动。""" from __future__ import annotations import asyncio +import logging import time +from datetime import datetime from typing import Any +import pandas as pd from fastapi import APIRouter, Depends, Query +from easy_tdx.mac.enums import Adjust, Period from easy_tdx.web.convert import ( board_sort_from_str, board_type_from_str, @@ -18,6 +22,8 @@ from easy_tdx.web.convert import ( from easy_tdx.web.deps import get_mac_client from easy_tdx.web.schemas import DataFrameResponse, DictResponse +_logger = logging.getLogger(__name__) + router = APIRouter(tags=["board-mac"]) # overview 端点:metrics 参数名 → 返回行字段名(值来自对应排序键的 sort_value) @@ -228,3 +234,276 @@ async def board_overview( payload = {"board_type": bt.name, "ts": int(time.time()), "count": len(rows), "rows": rows} _overview_cache[cache_key] = (_now() + _OVERVIEW_TTL, payload) return DictResponse.from_dict(payload) + + +# --------------------------------------------------------------------------- +# 热点滚动(/board-mac/hotspot):交易日 × 板块 每日涨跌矩阵 + 每日排名 +# +# 两段式数据合成: +# - 历史矩阵:逐板块拉板块指数日K(get_stock_kline),close 逐日环比得涨跌幅, +# 收盘后不可变 → 按日历日缓存全天有效;days 参数只做切片,不进缓存键。 +# - 今日列:实时报价 price/pre_close-1(与 overview 同口径);全市场无一移动 +# (盘前/休市/节假日)则不追加今日列,避免出现全 0 的假列。 +# +# AsyncMacClient 是单连接串行,概念板块(~500 个)首次构建需数十秒: +# 构建放 asyncio 后台任务 + 进度轮询,避免占住请求线程并拖死同连接的其他页面。 +# --------------------------------------------------------------------------- + +# 历史矩阵最大窗口(days 参数在其内切片)与多拉的缓冲 bar(窗口首日前收 + 节假日) +_HOTSPOT_MAX_DAYS = 60 +_HOTSPOT_FETCH_BUFFER = 12 +_HOTSPOT_KLINE_COUNT = _HOTSPOT_MAX_DAYS + _HOTSPOT_FETCH_BUFFER +_HOTSPOT_MAX_ROWS = 60 # 返回行数上限(行集合按上榜次数截断) +_HOTSPOT_KLINE_CONCURRENCY = 8 # 单连接实际串行,信号量只做秩序与背压 + +# board_type 名 -> (日历日, {axis: 日期轴, pct: {code: {日期: 涨跌幅}}, names: {code: 名称}}) +_hotspot_history_cache: dict[str, tuple[str, dict[str, Any]]] = {} +# board_type 名 -> 构建状态 {"status": "building"|"ready"|"error", "progress", "task", "error"} +_hotspot_builds: dict[str, dict[str, Any]] = {} + + +def _today_str() -> str: + """当日日历日(缓存失效键;单测可 monkeypatch)。""" + return datetime.now().strftime("%Y-%m-%d") + + +async def _hotspot_build(board_key: str, bt: Any, client: Any) -> None: + """后台构建板块历史日度涨跌矩阵,结果写入 _hotspot_history_cache。""" + state = _hotspot_builds[board_key] + try: + boards_df = await client.get_board_list(board_type=bt, count=5000) + if boards_df is None or boards_df.empty: + raise ValueError("板块列表为空,无法构建热点矩阵") + entries = [ + (str(rec["code"]), int(rec.get("market") or 1)) + for rec in boards_df.to_dict(orient="records") + ] + names = { + str(rec["code"]): str(rec.get("name") or rec["code"]) + for rec in boards_df.to_dict(orient="records") + } + total = len(entries) + sem = asyncio.Semaphore(_HOTSPOT_KLINE_CONCURRENCY) + done = 0 + + async def fetch_one(code: str, market: int) -> tuple[str, pd.DataFrame | None]: + nonlocal done + async with sem: + try: + df = await client.get_stock_kline( + market=market, + code=code, + period=Period.DAILY, + count=_HOTSPOT_KLINE_COUNT, + adjust=Adjust.NONE, + ) + except Exception: # noqa: BLE001 — 单板块缺K线不阻塞整体 + df = None + done += 1 + state["progress"] = round(done / total, 4) + return code, df + + fetched = await asyncio.gather(*(fetch_one(code, market) for code, market in entries)) + + pct_map: dict[str, dict[str, float]] = {} + for code, df in fetched: + if df is None or df.empty or len(df) < 2 or "datetime" not in df.columns: + continue + kline = df.sort_values("datetime") + dates = pd.to_datetime(kline["datetime"]).dt.strftime("%Y-%m-%d").reset_index(drop=True) + close = pd.to_numeric(kline["close"], errors="coerce").reset_index(drop=True) + pct = (close / close.shift(1) - 1.0) * 100.0 + series: dict[str, float] = {} + for d, p in zip(dates.iloc[1:], pct.iloc[1:]): # 首根无前收,跳过 + if pd.notna(p): + series[str(d)] = round(float(p), 3) + if series: + pct_map[code] = series + if not pct_map: + raise ValueError("全部板块日K获取失败,无法构建热点矩阵") + + # 交易日轴 = 数据最全板块的日期序列(全市场板块共享交易日历) + axis = sorted(max(pct_map.values(), key=len).keys()) + _hotspot_history_cache[board_key] = ( + _today_str(), + {"axis": axis, "pct": pct_map, "names": names}, + ) + state["status"] = "ready" + state["progress"] = 1.0 + except Exception as exc: # noqa: BLE001 — 构建失败转可轮询的 error 状态,不抛出 + state["status"] = "error" + state["error"] = str(exc) + _logger.warning("热点矩阵构建失败 (%s): %s", board_key, exc) + + +@router.get("/board-mac/hotspot", response_model=DictResponse) +async def board_hotspot( + board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"), + days: int = Query(20, ge=1, le=_HOTSPOT_MAX_DAYS, description="窗口交易日数(1=仅今日)"), + mode: str = Query("top", description="top=领涨(每日最强入选) / bottom=领跌(每日最弱入选)"), + per_day: int = Query(5, ge=2, le=10, description="每日入选名次阈值"), + retry: bool = Query(False, description="上次构建失败后强制重建"), + client: Any = Depends(get_mac_client), +) -> DictResponse: + """市场热点滚动:交易日 × 板块 每日涨跌矩阵 + 当日排名。 + + 首次请求某板块类型时启动后台构建,返回 ``{"status": "building", "progress": 0~1}``, + 前端 ~1s 轮询直至 ``ready``。构建失败返回 ``{"status": "error", "error": ...}`` + 并保持稳定(轮询不会自动重建,避免错误被冲掉);带 ``retry=1`` 再次请求即重建。 + ``session`` 为 ``live`` 表示最后一列是盘中实时值。 + + 行集合 = 窗口内「每日 mode 方向前 per_day 名」板块的并集(按上榜次数截断至 + ``_HOTSPOT_MAX_ROWS`` 行)。``rank`` 为当日全类型排名:mode=top 时 1=涨幅最大, + mode=bottom 时 1=跌幅最大。``sum_pct`` 为窗口内逐日复利累计。 + """ + mode_norm = mode.strip().lower() + if mode_norm not in ("top", "bottom"): + raise ValueError(f"mode 仅支持 top/bottom,got {mode}") + + 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] + axis_all: list[str] = history["axis"] + pct_map: dict[str, dict[str, float]] = history["pct"] + names: dict[str, str] = dict(history["names"]) + + # 窗口切片:剔除今日(今日列一律来自实时报价,避免日K盘中未完成 bar 混入) + today = _today_str() + window = [d for d in axis_all if d != today][-days:] + + # 今日列:实时报价(1–2 页,廉价)。全市场无一移动(盘前/休市)则不追加 + live_df = await client.get_board_list(board_type=bt, count=5000) + live_change: dict[str, float] = {} + any_moved = False + if live_df is not None and not live_df.empty: + for rec in live_df.to_dict(orient="records"): + code = str(rec["code"]) + if rec.get("name"): + names[code] = str(rec["name"]) + price = float(rec.get("price") or 0.0) + pre = float(rec.get("pre_close") or 0.0) + if price > 0 and pre > 0: + chg = round((price / pre - 1.0) * 100.0, 3) + live_change[code] = chg + if abs(chg) > 1e-9: + any_moved = True + col_pct: list[dict[str, float]] = [ + {code: m[d] for code, m in pct_map.items() if d in m} for d in window + ] + # 周末/节假日隔夜:TDX 的 pre_close 尚未滚动,实时涨跌会与历史末列几乎完全 + # 重合(都是上一交易日的涨幅)——重合度过高则不追加,避免出现重复的假今日列。 + # 交易日盘中实时值与昨日收盘涨幅必然大面积偏离,不受此判定影响。 + if any_moved and window: + last_col = col_pct[-1] + same = diff = 0 + for code, chg in live_change.items(): + prev = last_col.get(code) + if prev is None: + continue + if abs(chg - prev) <= 0.05: + same += 1 + else: + diff += 1 + append_live = (same + diff) > 0 and diff / (same + diff) >= 0.5 + else: + append_live = False + if append_live: + col_pct.append(live_change) + + dates = window + ([today] if append_live else []) + + # 每列全类型排名(mode 方向;1 = 最强/最弱) + col_rank: list[dict[str, int]] = [] + for col in col_pct: + ordered = sorted(col.items(), key=lambda kv: kv[1], reverse=(mode_norm == "top")) + col_rank.append({code: i + 1 for i, (code, _) in enumerate(ordered)}) + + # 行集合 = 每日前 per_day 名的并集;行内元数据在完整窗口(含今日列)上统计 + in_top: list[set[str]] = [{c for c, r in rank.items() if r <= per_day} for rank in col_rank] + candidates: set[str] = set().union(*in_top) if in_top else set() + + rows_out: list[dict[str, Any]] = [] + for code in candidates: + pct_arr = [col.get(code) for col in col_pct] + rank_arr = [rank.get(code) for rank in col_rank] + top_flags = [r is not None and r <= per_day for r in rank_arr] + best: int | None = None + comp = 1.0 + has_data = False + for p, r in zip(pct_arr, rank_arr): + if p is not None: + has_data = True + comp *= 1.0 + p / 100.0 + if r is not None and (best is None or r < best): + best = r + first_date = next((dates[i] for i, f in enumerate(top_flags) if f), None) + rows_out.append( + { + "code": code, + "name": names.get(code, code), + "pct": pct_arr, + "rank": rank_arr, + "days_in": sum(top_flags), + "streak": _trailing_streak(top_flags), + "best_rank": best, + "sum_pct": round((comp - 1.0) * 100.0, 2) if has_data else None, + "first_date": first_date, + } + ) + rows_out.sort( + key=lambda r: ( + -r["days_in"], + -(r["sum_pct"] or 0.0), + r["best_rank"] if r["best_rank"] else 9999, + ) + ) + rows_out = rows_out[:_HOTSPOT_MAX_ROWS] + + from easy_tdx.realtime.session import is_trading_time + + payload: dict[str, Any] = { + "status": "ready", + "board_type": bt.name, + "days": days, + "mode": mode_norm, + "per_day": per_day, + "generated_at": int(time.time()), + "session": "live" if (append_live and is_trading_time()) else "closed", + "dates": dates, + "today_index": (len(dates) - 1) if append_live else None, + "total_boards": len(pct_map), + "rows": rows_out, + } + return DictResponse.from_dict(payload) + + +def _trailing_streak(flags: list[bool]) -> int: + """从末尾向前数连续 True(末位为 False 时对齐"当前连榜"语义返 0)。""" + if not flags or not flags[-1]: + return 0 + n = 0 + for f in reversed(flags): + if not f: + break + n += 1 + return n diff --git a/src/easy_tdx/web/routers/market.py b/src/easy_tdx/web/routers/market.py index d537918..d29b420 100644 --- a/src/easy_tdx/web/routers/market.py +++ b/src/easy_tdx/web/routers/market.py @@ -1,7 +1,10 @@ -"""市场信息路由:证券列表、实时行情、市场统计、资金流向。""" +"""市场信息路由:证券列表、实时行情、市场统计、资金流向、涨停生态。""" from __future__ import annotations +import asyncio +import time +from dataclasses import asdict from typing import Any from fastapi import APIRouter, Depends, Query @@ -11,11 +14,16 @@ from easy_tdx.web.deps import get_client from easy_tdx.web.schemas import ( CountResponse, DataFrameResponse, + DictResponse, QuoteRequest, ) router = APIRouter(tags=["market"]) +# 涨停生态结果缓存(vipdoc 盘中随通达信客户端落盘更新,60s 足够新鲜) +_limitup_cache: tuple[float, dict[str, Any]] | None = None +_LIMITUP_TTL = 60.0 + def _df_response(df: Any) -> DataFrameResponse: """将 DataFrame 转为 API 响应。""" @@ -88,20 +96,38 @@ async def market_session() -> dict[str, Any]: return session_info() -@router.get("/market/core-leaders", response_model=DataFrameResponse) -async def core_leaders() -> DataFrameResponse: - """核心龙头池(159 只,按东方财富全行业龙头名单整理)。 +@router.get("/limitup-ecology", response_model=DictResponse) +async def limitup_ecology( + vipdoc: str | None = Query(None, description="离线数据目录(默认自动检测)"), +) -> DictResponse: + """涨停生态:连板天梯 / 首板二板分布 / 炸板 / 跌停(本地 vipdoc 日线离线回算)。 - 数据资产供前端展示/导出;扫描场景走 ``universe="core"``(screen scan - 与 /market/strength 均支持)。 + 结果的 ``data_date`` 为 vipdoc 数据日期——数据新鲜度取决于本机通达信客户端 + 的盘后下载/盘中落盘,前端必须明示该日期。全市场扫描约需数秒,结果缓存 60s。 + 涨停判定按代码段:主板 10%(含 5% 疑似 ST 标记)、创业板/科创板 20%; + .day 文件无名称,name 由前端经批量报价补齐。 """ - from easy_tdx.screen.universe import CORE_LEADERS + global _limitup_cache + now = time.monotonic() + if _limitup_cache is not None and now - _limitup_cache[0] < _LIMITUP_TTL: + return DictResponse.from_dict(_limitup_cache[1]) - rows = [ - {"code": code, "name": name, "market": "SH" if code.startswith(("6", "9")) else "SZ"} - for code, name in CORE_LEADERS.items() - ] - return DataFrameResponse(data=rows, count=len(rows)) + def _scan() -> dict[str, Any]: + from easy_tdx.screen.limitup import compute_limitup_ecology + + eco = compute_limitup_ecology(vipdoc) + return { + "data_date": eco.data_date, + "total": eco.total, + "summary": eco.summary(), + "limit_up": [asdict(e) for e in eco.limit_up], + "limit_down": [asdict(e) for e in eco.limit_down], + "blown": [asdict(e) for e in eco.blown], + } + + payload = await asyncio.to_thread(_scan) + _limitup_cache = (now, payload) + return DictResponse.from_dict(payload) @router.get("/fund-flow", response_model=DataFrameResponse) diff --git a/tests/unit/test_board_mac_hotspot.py b/tests/unit/test_board_mac_hotspot.py new file mode 100644 index 0000000..5d2df4b --- /dev/null +++ b/tests/unit/test_board_mac_hotspot.py @@ -0,0 +1,329 @@ +"""/board-mac/hotspot 热点滚动端点单测(离线,mock MAC 客户端)。 + +覆盖:后台构建状态机(building→ready / error 稳定 + retry 重建)、涨跌矩阵口径 +(close 逐日环比)、每日排名与行集合并集(top/bottom 镜像)、行元数据 +(days_in/streak/best_rank/复利 sum_pct/first_date)、今日列实时合并与 +休市(全市场未移动)去重、当日缓存复用(不重拉日K)、无效 mode 400。 +""" + +from __future__ import annotations + +import time + +import pandas as pd +import pytest + +# ── 测试数据:21 个交易日(首根为窗口前锚点),三个板块涨跌幅恒定 ────────────── + +_DATES = [d.strftime("%Y-%m-%d") for d in pd.bdate_range("2026-07-31", periods=21)] +# 轴 = 首根之后的 20 个交易日 +_AXIS = _DATES[1:] + + +def _kline_df(start_close: float, daily: float) -> pd.DataFrame: + closes = [start_close * (daily**i) for i in range(len(_DATES))] + return pd.DataFrame({"datetime": pd.to_datetime(_DATES), "close": closes}) + + +_BOARDS = [ + {"market": 1, "code": "881106", "name": "存储器", "price": 0.0, "pre_close": 0.0}, + {"market": 1, "code": "881105", "name": "CPO", "price": 0.0, "pre_close": 0.0}, + {"market": 1, "code": "881101", "name": "房地产开发", "price": 0.0, "pre_close": 0.0}, +] + + +class _FakeHotspotMacClient: + """按 code 返回恒定日涨跌幅 K 线的替身客户端。 + + 存储器 +5%/日、CPO +2%/日、房地产开发 -1%/日;实时报价由 live_prices + 提供(price/pre_close),缺省全部未移动(休市口径)。 + """ + + def __init__(self, live_prices: dict[str, tuple[float, float]] | None = None): + self.klines = { + "881106": _kline_df(100.0, 1.05), + "881105": _kline_df(200.0, 1.02), + "881101": _kline_df(300.0, 0.99), + } + self.live_prices = live_prices or {} + self.kline_calls = 0 + self.list_calls = 0 + + async def get_board_list(self, board_type=None, count=5000, sort_column=None): + self.list_calls += 1 + rows = [] + for b in _BOARDS: + row = dict(b) + price, pre = self.live_prices.get(b["code"], (0.0, 0.0)) + row["price"], row["pre_close"] = price, pre + rows.append(row) + return pd.DataFrame(rows) + + async def get_stock_kline( + self, market=1, code="", period=None, start=0, count=800, times=1, adjust=None, **_ + ): + self.kline_calls += 1 + return self.klines.get(code, pd.DataFrame()) + + +def _hotspot_app(mac_client): + from fastapi import FastAPI + + from easy_tdx.web.errors import register_exception_handlers + from easy_tdx.web.routers import board_mac + + app = FastAPI() + register_exception_handlers(app) + app.include_router(board_mac.router, prefix="/api/v1") + app.state.tdx_client = object() + app.state.mac_client = mac_client + return app + + +@pytest.fixture(autouse=True) +def _clean_cache(monkeypatch): + from easy_tdx.web.routers import board_mac + + board_mac._hotspot_history_cache.clear() + board_mac._hotspot_builds.clear() + # 默认把"今天"钉在远期:不在 K 线轴内且实时报价未移动 → 不追加今日列 + monkeypatch.setattr(board_mac, "_today_str", lambda: "2030-01-01") + yield + board_mac._hotspot_history_cache.clear() + board_mac._hotspot_builds.clear() + + +def _get(client, client_obj, **params): + query = {"board_type": "HY", "days": 10, "per_day": 2, **params} + resp = client.get("/api/v1/board-mac/hotspot", params=query) + assert resp.status_code == 200, resp.text + return resp.json()["data"], client_obj + + +def _wait_ready(client, client_obj, timeout=10.0, **params): + """轮询直至构建结束,返回最终 payload。""" + deadline = time.time() + timeout + data = None + while time.time() < deadline: + data, client_obj = _get(client, client_obj, **params) + if data["status"] != "building": + return data, client_obj + time.sleep(0.02) + raise AssertionError(f"热点矩阵构建超时: {data}") + + +def test_hotspot_build_matrix_and_metadata(): + """building→ready;矩阵口径、每日排名、行集合并集、行元数据全量校验。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeHotspotMacClient() + with TestClient(_hotspot_app(fake)) as client: + data, fake = _wait_ready(client, fake) + + assert data["status"] == "ready" + assert data["dates"] == _AXIS[-10:] + assert data["today_index"] is None # 实时未移动 → 不追加今日列 + assert data["total_boards"] == 3 + + rows = {r["code"]: r for r in data["rows"]} + # 每日 +5%/+2% 恒定 → 前 2 名恒为存储器、CPO;房地产开发从不上榜 + assert set(rows) == {"881106", "881105"} + + mem = rows["881106"] + assert mem["pct"] == [5.0] * 10 + assert mem["rank"] == [1] * 10 + assert mem["days_in"] == 10 + assert mem["streak"] == 10 + assert mem["best_rank"] == 1 + assert mem["first_date"] == _AXIS[-10] + assert mem["sum_pct"] == pytest.approx(((1.05**10) - 1) * 100, abs=0.01) + + cpo = rows["881105"] + assert cpo["rank"] == [2] * 10 + assert cpo["sum_pct"] == pytest.approx(((1.02**10) - 1) * 100, abs=0.01) + + +def test_hotspot_mode_bottom_mirrors_selection(): + """mode=bottom:每日最弱入选,排名语义镜像(1=跌幅最大)。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeHotspotMacClient() + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake, mode="bottom") + + rows = {r["code"]: r for r in data["rows"]} + # 跌幅最深(-1%/日)与次深(+2%/日弱于 +5%)入选 + assert set(rows) == {"881101", "881105"} + assert rows["881101"]["rank"] == [1] * 10 + assert rows["881101"]["days_in"] == 10 + assert rows["881101"]["sum_pct"] == pytest.approx(((0.99**10) - 1) * 100, abs=0.01) + assert rows["881105"]["rank"] == [2] * 10 + + +def test_hotspot_live_today_column_merged(): + """实时报价有移动 → 追加今日列:日期=今天、涨跌=price/pre_close、计入排名与连榜。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from easy_tdx.web.routers import board_mac + + today = "2026-08-31" + board_mac._today_str = lambda: today # type: ignore[assignment] + # 存储器 +3%、CPO 大跌 -3%(跌出当日前2)、地产 -0.5%(挤进当日前2) + pre_a = 100.0 * (1.05**20) + pre_b = 200.0 * (1.02**20) + pre_c = 300.0 * (0.99**20) + live = { + "881106": (round(pre_a * 1.03, 4), round(pre_a, 4)), + "881105": (round(pre_b * 0.97, 4), round(pre_b, 4)), + "881101": (round(pre_c * 0.995, 4), round(pre_c, 4)), + } + fake = _FakeHotspotMacClient(live_prices=live) + try: + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake) + finally: + board_mac._today_str = lambda: "2030-01-01" # type: ignore[assignment] + + assert data["dates"][-1] == today + assert data["today_index"] == len(data["dates"]) - 1 + assert len(data["dates"]) == 11 + + rows = {r["code"]: r for r in data["rows"]} + mem = rows["881106"] + assert mem["pct"][-1] == 3.0 + assert mem["rank"][-1] == 1 # +3% 强于地产 -0.5% 与 CPO -3% + assert mem["days_in"] == 11 # 窗口 10 日 + 今日列 + assert mem["streak"] == 11 + assert mem["sum_pct"] == pytest.approx(((1.05**10) * 1.03 - 1) * 100, abs=0.01) + + # CPO 今日大跌跌出前2 → 今日列计入排名但断连 + cpo = rows["881105"] + assert cpo["pct"][-1] == -3.0 + assert cpo["rank"][-1] == 3 + assert cpo["days_in"] == 10 + assert cpo["streak"] == 0 + + # 房地产开发仅今日上榜 → 进入行集合,首榜=今日 + estate = rows["881101"] + assert estate["pct"][-1] == -0.5 + assert estate["rank"][-1] == 2 + assert estate["days_in"] == 1 + assert estate["first_date"] == today + + +def test_hotspot_market_idle_no_live_column(): + """全市场无一移动(盘前/休市)→ 不追加全 0 假列。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + # live_prices 为空 → 全部 price=pre_close=0 → any_moved=False + fake = _FakeHotspotMacClient() + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake) + assert data["today_index"] is None + assert data["dates"] == _AXIS[-10:] + assert data["session"] == "closed" + + +def test_hotspot_weekend_duplicate_live_suppressed(): + """周末隔夜 pre_close 未滚动:实时涨跌与历史末列重合 → 不追加重复的假今日列。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from easy_tdx.web.routers import board_mac + + board_mac._today_str = lambda: "2026-08-29" # type: ignore[assignment] # 周六,不在轴内 + # price=最后一根 close、pre_close=前一根 close → 实时涨跌 == 历史末列(最后一个交易日的涨幅) + live = { + "881106": (100.0 * (1.05**20), 100.0 * (1.05**19)), + "881105": (200.0 * (1.02**20), 200.0 * (1.02**19)), + "881101": (300.0 * (0.99**20), 300.0 * (0.99**19)), + } + fake = _FakeHotspotMacClient(live_prices=live) + try: + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake) + finally: + board_mac._today_str = lambda: "2030-01-01" # type: ignore[assignment] + + assert data["today_index"] is None + assert data["dates"] == _AXIS[-10:] # 仍是 10 列窗口,无 08-29 重复列 + + +def test_hotspot_history_cache_reused(): + """当日缓存复用:二次请求不重拉日K,仅刷新实时列表。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeHotspotMacClient() + with TestClient(_hotspot_app(fake)) as client: + _wait_ready(client, fake) + kline_calls_after_build = fake.kline_calls + list_calls_after_build = fake.list_calls + data, _ = _get(client, fake) + assert data["status"] == "ready" + assert fake.kline_calls == kline_calls_after_build # 日K零重复拉取 + assert fake.list_calls == list_calls_after_build + 1 # 实时列每次现取 + + +def test_hotspot_error_stable_until_retry(): + """全部板块日K失败 → error 状态稳定(轮询不冲掉错误),retry=1 触发重建。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + class _EmptyKlineClient(_FakeHotspotMacClient): + async def get_stock_kline(self, **_): # noqa: D102 — 全部返回空 + self.kline_calls += 1 + return pd.DataFrame() + + fake = _EmptyKlineClient() + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake) + assert data["status"] == "error" + assert "日K" in data["error"] + + # 不带 retry 的再次请求:错误稳定(不再重拉日K) + fake2 = fake + with TestClient(_hotspot_app(fake2)) as client: + data, _ = _get(client, fake2) + assert data["status"] == "error" + + # retry=1 → 重新构建(仍失败,但状态机走 building) + with TestClient(_hotspot_app(fake2)) as client: + resp = client.get( + "/api/v1/board-mac/hotspot", + params={"board_type": "HY", "days": 10, "per_day": 2, "retry": "true"}, + ) + assert resp.status_code == 200 + # 任务刚启动:building 或(极快完成后的)error 均合法 + assert resp.json()["data"]["status"] in ("building", "error") + + +def test_hotspot_invalid_mode_returns_400(): + 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", + params={"board_type": "HY", "mode": "sideways"}, + ) + assert resp.status_code == 400 + assert "mode" in resp.json()["detail"] + + +def test_hotspot_missing_kline_board_excluded(): + """个别板块无日K:不参与排名,其余板块矩阵不受影响。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeHotspotMacClient() + del fake.klines["881101"] # 房地产开发缺日K + with TestClient(_hotspot_app(fake)) as client: + data, _ = _wait_ready(client, fake) + assert data["total_boards"] == 2 + assert all(r["code"] != "881101" for r in data["rows"]) diff --git a/tests/unit/test_limitup_ecology.py b/tests/unit/test_limitup_ecology.py new file mode 100644 index 0000000..6010ea8 --- /dev/null +++ b/tests/unit/test_limitup_ecology.py @@ -0,0 +1,166 @@ +"""涨停生态(screen.limitup + /limitup-ecology 端点)单测。 + +用合成 .day 二进制文件验证:涨停/连板/炸板/跌停判定、20cm 创业板、 +主板 5% 疑似 ST、汇总统计与排序;端点侧验证 DictResponse 包装与 60s 缓存。 +""" + +from __future__ import annotations + +import pytest + +from easy_tdx.offline.daily_bar import _DAILY_FMT + + +def _day(date: int, open_: float, high: float, low: float, close: float) -> bytes: + """按 .day 真实格式打包一根日线(价格 ×100 存 uint,成交额 f32)。""" + return _DAILY_FMT.pack( + date, + round(open_ * 100), + round(high * 100), + round(low * 100), + round(close * 100), + 5_000_000.0, + 1_000_000, + 0, + ) + + +def _write_stock( + vipdoc, + exchange: str, + code: str, + closes: list[float], + highs: list[float] | None = None, + dates: list[int] | None = None, +) -> None: + """写一只股票的 .day 文件;closes 逐日收盘,highs 缺省=每日收盘。""" + lday = vipdoc / exchange / "lday" + lday.mkdir(parents=True, exist_ok=True) + highs = highs or closes + dates = dates or [20260801 + i for i in range(len(closes))] + data = b"".join(_day(d, c - 0.05, h, c - 0.10, c) for d, c, h in zip(dates, closes, highs)) + (lday / f"{exchange}{code}.day").write_bytes(data) + + +@pytest.fixture +def vipdoc(tmp_path): + """合成市场(全部股票最后 bar 对齐 20260804,模拟真实"同一交易日")。""" + last4 = [20260801, 20260802, 20260803, 20260804] + # 主板 3 连板:10.00 → 11.00 → 12.10 → 13.31(每根恰为 round(prev×1.1, 2)) + _write_stock(tmp_path, "sh", "600100", [10.00, 11.00, 12.10, 13.31]) + # 创业板 2 连板(20cm):20.00 → 24.00 → 28.80(首根铺垫同价) + _write_stock(tmp_path, "sz", "300200", [20.00, 20.00, 24.00, 28.80], dates=last4) + # 主板 5%(疑似 ST,前收 ≥3 才启用 ST 判定):10.00 → 10.50 + _write_stock(tmp_path, "sh", "600300", [10.00, 10.00, 10.00, 10.50], dates=last4) + # 炸板:前收 10.00,最高触 11.00,收 10.80(离开 5% 价位避免歧义) + _write_stock( + tmp_path, + "sh", + "600400", + [10.00, 10.00, 10.00, 10.80], + highs=[10.20, 10.20, 10.50, 11.00], + dates=last4, + ) + # 跌停:10.00 → 9.00 + _write_stock(tmp_path, "sz", "000500", [10.00, 10.00, 10.00, 9.00], dates=last4) + # 平盘(无事件) + _write_stock(tmp_path, "sh", "600600", [10.00, 10.00, 10.00, 10.20], dates=last4) + # 陈旧文件:数据停在 20260703,当年的"3连板"不得进入今日生态 + _write_stock( + tmp_path, + "sh", + "600700", + [10.00, 11.00, 12.10], + dates=[20260701, 20260702, 20260703], + ) + # 低价 ST 护栏:前收 2.00(<3)恰收 +5%(2.10)不算涨停 + _write_stock(tmp_path, "sh", "600800", [2.00, 2.00, 2.00, 2.10], dates=last4) + return tmp_path + + +def test_limitup_core_detection(vipdoc): + from easy_tdx.screen.limitup import compute_limitup_ecology + + eco = compute_limitup_ecology(vipdoc) + assert eco.data_date == 20260804 + assert eco.total == 8 + + up = {e.code: e for e in eco.limit_up} + assert set(up) == {"600100", "300200", "600300"} # 600700 陈旧排除、600800 低价护栏 + + board3 = up["600100"] + assert board3.streak == 3 + assert board3.market == "SH" + assert board3.pct == pytest.approx(10.0, abs=0.01) + assert board3.st is False + + cyb = up["300200"] + assert cyb.streak == 2 # 20cm 创业板 + assert cyb.pct == pytest.approx(20.0, abs=0.01) + + assert up["600300"].streak == 1 + assert up["600300"].st is True # 主板 5% → 疑似 ST + + # 连板天梯排序:高度降序 + assert [e.streak for e in eco.limit_up] == [3, 2, 1] + + # 炸板与跌停 + assert [e.code for e in eco.blown] == ["600400"] + assert eco.blown[0].pct == pytest.approx(8.0, abs=0.01) + assert [e.code for e in eco.limit_down] == ["000500"] + assert eco.limit_down[0].streak == 1 + + s = eco.summary() + assert s["limit_up_count"] == 3 + assert s["blown_count"] == 1 + assert s["limit_down_count"] == 1 + assert s["max_streak"] == 3 + assert s["first_board"] == 1 # 仅 600300 首板;600100 三板、300200 二板 + assert s["blown_rate"] == 25.0 # 3 封住 + 1 炸板 + + +def test_limitup_empty_vipdoc(tmp_path): + from easy_tdx.screen.limitup import compute_limitup_ecology + + eco = compute_limitup_ecology(tmp_path / "nonexistent") + assert eco.total == 0 + assert eco.data_date == 0 + assert eco.summary()["limit_up_count"] == 0 + + +def test_limitup_endpoint_and_cache(vipdoc, monkeypatch): + """端点返回 DictResponse 包装;60s 内命中缓存(扫描只跑一次)。""" + pytest.importorskip("fastapi") + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from easy_tdx.screen import limitup as limitup_mod + from easy_tdx.web.errors import register_exception_handlers + from easy_tdx.web.routers import market as market_mod + + calls = {"n": 0} + real = limitup_mod.compute_limitup_ecology + + def counting(*a, **kw): + calls["n"] += 1 + return real(*a, **kw) + + monkeypatch.setattr(limitup_mod, "compute_limitup_ecology", counting) + + app = FastAPI() + register_exception_handlers(app) + app.include_router(market_mod.router, prefix="/api/v1") + app.state.tdx_client = object() + + with TestClient(app) as client: + r1 = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)}) + assert r1.status_code == 200 + d1 = r1.json()["data"] + assert d1["summary"]["limit_up_count"] == 3 + assert d1["limit_up"][0]["code"] == "600100" + + r2 = client.get("/api/v1/limitup-ecology", params={"vipdoc": str(vipdoc)}) + assert r2.status_code == 200 + assert r2.json()["data"] == d1 + + assert calls["n"] == 1 # 第二次命中缓存 diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index 246368b..bed2504 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -28,8 +28,10 @@ const sseLabel: Record = { 市场看板 行业总览 概念总览 + 热点滚动 + 大盘日历 + 涨停生态 自选行情 - 龙头池 期货持仓排名 单标的回测 diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index 2806876..9f72bac 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -9,10 +9,11 @@ import type { BoardOverviewResp, BoardRow, Category, - CoreLeaderRow, CcpmProductsResponse, CcpmRankResponse, DataFrameResponse, + HotspotResp, + LimitUpEcologyResp, LlmChatResponse, LlmChatContext, LlmHistoryResponse, @@ -582,6 +583,29 @@ export async function fetchBoardOverview( return body.data } +/** 市场热点滚动(交易日×板块涨跌矩阵 + 每日排名)。 + * 首次请求某板块类型时后端后台构建,返回 status=building(附 progress), + * 前端 ~1s 轮询直至 ready;构建失败 status=error 稳定返回,retry=true 重建。 */ +export async function fetchBoardHotspot( + boardType: string, + days: number, + mode: 'top' | 'bottom', + perDay = 5, + retry = false, +): Promise { + const params = new URLSearchParams({ + board_type: boardType, + days: String(days), + mode, + per_day: String(perDay), + }) + if (retry) params.set('retry', 'true') + const resp = await fetch(`${BASE}/board-mac/hotspot?${params}`) + if (!resp.ok) await throwError(resp) + const body = (await resp.json()) as { data: HotspotResp } + return body.data +} + /** 市场异动流(火箭发射/大笔买入/封涨停板/打开跌停板/快速反弹等)。 */ export async function fetchUnusual(market: 'SH' | 'SZ', count = 50): Promise[]> { const params = new URLSearchParams({ market, count: String(count) }) @@ -810,12 +834,13 @@ export async function clearLlmHistory(): Promise { return (await resp.json()).deleted as number } -/** 核心龙头池(159 只)。 */ -export async function fetchCoreLeaders(): Promise { - const resp = await fetch(`${BASE}/market/core-leaders`) +/** 涨停生态(连板天梯/炸板/跌停,本地 vipdoc 离线回算,服务端缓存 60s)。 + * data_date 为 vipdoc 数据日期;name 字段需前端经 fetchSymbolName 补齐。 */ +export async function fetchLimitUpEcology(): Promise { + const resp = await fetch(`${BASE}/limitup-ecology`) if (!resp.ok) await throwError(resp) - const body = (await resp.json()) as DataFrameResponse - return body.data as unknown as CoreLeaderRow[] + const body = (await resp.json()) as { data: LimitUpEcologyResp } + return body.data } /** 中金所成交持仓排名:品种列表(含科普元数据)。 */ diff --git a/web-ui/src/components/HotspotMatrix.vue b/web-ui/src/components/HotspotMatrix.vue new file mode 100644 index 0000000..5356045 --- /dev/null +++ b/web-ui/src/components/HotspotMatrix.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/web-ui/src/components/HotspotStatStrip.vue b/web-ui/src/components/HotspotStatStrip.vue new file mode 100644 index 0000000..04c0dc4 --- /dev/null +++ b/web-ui/src/components/HotspotStatStrip.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/web-ui/src/components/RiskDisclaimer.vue b/web-ui/src/components/RiskDisclaimer.vue index fb55e60..fd39600 100644 --- a/web-ui/src/components/RiskDisclaimer.vue +++ b/web-ui/src/components/RiskDisclaimer.vue @@ -1,5 +1,5 @@ diff --git a/web-ui/src/format.ts b/web-ui/src/format.ts index 9da9363..f3e2830 100644 --- a/web-ui/src/format.ts +++ b/web-ui/src/format.ts @@ -41,3 +41,13 @@ export function dirClass(v: number | null | undefined): string { if (v === null || v === undefined || !Number.isFinite(v) || v === 0) return 'flat' return v > 0 ? 'up' : 'down' } + +/** 涨跌幅 → 红涨绿跌背景样式(|pct| 分 5 档透明度,与板块热力图/热点矩阵同规)。 */ +export function pctCellStyle(pct: number | null | undefined): Record { + if (pct === null || pct === undefined || !Number.isFinite(pct)) return { background: 'transparent' } + if (pct === 0) return { background: 'var(--bg-elevated)', color: 'var(--text-muted)' } + const mag = Math.abs(pct) + const tier = mag > 3 ? 0.82 : mag > 2 ? 0.62 : mag > 1 ? 0.42 : mag > 0.5 ? 0.26 : 0.14 + const base = pct > 0 ? '239, 65, 70' : '24, 160, 88' // var(--up) / var(--down) 的 rgb + return { background: `rgba(${base}, ${tier})`, color: '#fff' } +} diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts index 5782ed8..d3d94d7 100644 --- a/web-ui/src/router.ts +++ b/web-ui/src/router.ts @@ -4,8 +4,10 @@ import BacktestView from './views/BacktestView.vue' import BoardOverviewView from './views/BoardOverviewView.vue' import CcpmView from './views/CcpmView.vue' import CompareView from './views/CompareView.vue' -import CoreLeadersView from './views/CoreLeadersView.vue' import DashboardView from './views/DashboardView.vue' +import HotspotView from './views/HotspotView.vue' +import IndexCalendarView from './views/IndexCalendarView.vue' +import LimitUpView from './views/LimitUpView.vue' import LlmHistoryView from './views/LlmHistoryView.vue' import LlmSettingsView from './views/LlmSettingsView.vue' import OptimizeView from './views/OptimizeView.vue' @@ -24,7 +26,15 @@ const routes = [ // 行业/概念总览(同一视图组件,路由 props 区分板块类型) { path: '/industries', name: 'industries', component: BoardOverviewView, props: { boardType: 'HY' } }, { path: '/concepts', name: 'concepts', component: BoardOverviewView, props: { boardType: 'GN' } }, + // 市场热点滚动(交易日×板块涨跌矩阵:热点形成/持续/轮动/领跌) + { path: '/hotspots', name: 'hotspots', component: HotspotView }, + // 风格轮动(同一热点视图 × FG 风格板块:大/小盘、高股息/成长…) + { path: '/styles', name: 'styles', component: HotspotView, props: { boardType: 'FG' } }, + // 大盘日历(指数全年红绿热力图) + { path: '/calendar', name: 'calendar', component: IndexCalendarView }, { path: '/watchlist', name: 'watchlist', component: WatchlistView }, + // 涨停生态(连板天梯/炸板/跌停,本地 vipdoc 离线回算) + { path: '/limitup', name: 'limitup', component: LimitUpView }, { path: '/backtest', name: 'backtest', component: BacktestView }, { path: '/portfolio', name: 'portfolio', component: PortfolioView }, { path: '/optimize', name: 'optimize', component: OptimizeView }, @@ -35,8 +45,6 @@ const routes = [ { path: '/llm', name: 'llm', component: LlmSettingsView }, // AI 解读历史(每次「直接解读」自动归档) { path: '/ai-history', name: 'ai-history', component: LlmHistoryView }, - // 核心龙头池(universe=core 的 159 只名单) - { path: '/leaders', name: 'leaders', component: CoreLeadersView }, // 中金所成交持仓排名(独立数据源,每日收盘后发布) { path: '/ccpm', name: 'ccpm', component: CcpmView }, // 兜底:未注册路径(如把 API 路径当页面访问)回看板,不再渲染空白 diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index e32c3b2..8dbef04 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -612,6 +612,81 @@ export interface BoardFlipEvent { change_pct: number } +// ── 市场热点滚动(GET /api/v1/board-mac/hotspot,交易日×板块涨跌矩阵) ────── + +/** 热点滚动行:窗口内至少一次进入「每日前 per_day」的板块。 */ +export interface HotspotRow { + code: string + name: string + /** 对齐 dates 的每日涨跌幅(%),null = 当日无数据 */ + pct: Array + /** 对齐 dates 的当日全类型排名(top: 1=涨幅最大;bottom: 1=跌幅最大) */ + rank: Array + /** 上榜天数(进入每日前 per_day) */ + days_in: number + /** 截至最后一列的连续上榜天数 */ + streak: number + best_rank: number | null + /** 窗口内逐日复利累计涨跌(%) */ + sum_pct: number | null + /** 窗口内首次上榜日期(YYYY-MM-DD) */ + first_date: string | null +} + +export interface HotspotResp { + /** ready=有数据;building=后台构建中(progress 0~1);error=构建失败(error 字段) */ + status: 'ready' | 'building' | 'error' + progress?: number + error?: string + board_type?: string + days?: number + mode?: 'top' | 'bottom' + per_day?: number + /** 数据生成时刻(epoch 秒) */ + generated_at?: number + /** live = 最后一列为盘中实时值 */ + session?: 'live' | 'closed' + /** 交易日轴(升序),最后一格可能为今日实时列 */ + dates?: string[] + today_index?: number | null + total_boards?: number + rows?: HotspotRow[] +} + +// ── 涨停生态(GET /api/v1/limitup-ecology,本地 vipdoc 离线回算) ──────────── + +/** 单只涨停/跌停/炸板股票(vipdoc 回算口径;name 由前端批量补齐)。 */ +export interface LimitUpEntry { + code: string + market: string // SH / SZ + pct: number // 最新交易日涨跌幅(%) + /** 连续涨停/跌停天数(截至数据日) */ + streak: number + /** 主板按 5% 判定(疑似 ST) */ + st: boolean + /** 炸板:曾触及涨停未封住 */ + blown: boolean +} + +export interface LimitUpEcologyResp { + /** vipdoc 数据日期 YYYYMMDD —— 新鲜度取决于本机通达信客户端 */ + data_date: number + total: number + summary: { + limit_up_count: number + limit_down_count: number + blown_count: number + blown_rate: number | null + max_streak: number + first_board: number + second_board: number + plus3: number + } + limit_up: LimitUpEntry[] + limit_down: LimitUpEntry[] + blown: LimitUpEntry[] +} + // ── Walk-Forward 样本外验证(v1.27 POST /backtest/wf/run/async)────────────── export interface WalkForwardWindow { @@ -823,13 +898,6 @@ export interface LlmHistoryResponse { count: number } -/** 核心龙头池条目(GET /api/v1/market/core-leaders)。 */ -export interface CoreLeaderRow { - code: string - name: string - market: string -} - // ── 中金所成交持仓排名(GET /api/v1/ccpm/*) ───────────────────────────────── /** 品种元数据(含给新手的科普文案)。 */ diff --git a/web-ui/src/views/CoreLeadersView.vue b/web-ui/src/views/CoreLeadersView.vue deleted file mode 100644 index 43cd3da..0000000 --- a/web-ui/src/views/CoreLeadersView.vue +++ /dev/null @@ -1,193 +0,0 @@ - - - - - diff --git a/web-ui/src/views/HotspotView.vue b/web-ui/src/views/HotspotView.vue new file mode 100644 index 0000000..241eae1 --- /dev/null +++ b/web-ui/src/views/HotspotView.vue @@ -0,0 +1,517 @@ + + + + + diff --git a/web-ui/src/views/IndexCalendarView.vue b/web-ui/src/views/IndexCalendarView.vue new file mode 100644 index 0000000..baeb1ad --- /dev/null +++ b/web-ui/src/views/IndexCalendarView.vue @@ -0,0 +1,505 @@ + + + + + diff --git a/web-ui/src/views/LimitUpView.vue b/web-ui/src/views/LimitUpView.vue new file mode 100644 index 0000000..7f6137b --- /dev/null +++ b/web-ui/src/views/LimitUpView.vue @@ -0,0 +1,421 @@ + + + + +