feat: 盘面洞察三栏目 — 热点滚动/大盘日历/涨停生态,移除龙头池

- 热点滚动 /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
This commit is contained in:
Justin Gu
2026-09-05 02:43:02 +08:00
parent ef9b34b88d
commit e419f911b7
19 changed files with 3471 additions and 224 deletions
+293
View File
@@ -0,0 +1,293 @@
# 市场热点滚动 页面设计(v1 草案)
> 目标:新增「热点滚动」栏目(`/hotspots`),让用户直观看到**一段时间内市场热点如何形成、
> 谁在持续领涨、谁在领跌、热点之间如何轮动**。核心表达是「交易日 × 板块」的红涨绿跌热点矩阵,
> 辅以轮动排名曲线。配色遵循 A 股惯例:红涨绿跌(`UP_COLOR=#ef4146` / `DOWN_COLOR=#18a058`)。
>
> **P1 已实施**(后端端点 + 热点矩阵 + 统计卡 + 领涨/领跌 + 后台构建进度,单测
> `tests/unit/test_board_mac_hotspot.py`);轮动曲线等见 §8 分期。
---
## 0. 可行性结论(先回答"能不能做")
**可行,且不需要任何新数据源。** 需求的本质数据是一张
「最近 N 个交易日 × 全部板块」的**每日涨跌幅矩阵**,而这条数据路径全部是已验证的现成能力:
| 现成能力 | 位置 | 在本需求中的角色 |
|---|---|---|
| 板块指数日 K881xxx/885xxxSH 市场) | `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一次 ≈ **49s**(仅首次,当日缓存)
- 概念 GN ≈ 300–500 板块 → ≈ **2050s**(仅首次,当日缓存)
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 | buildingbuilding 时只有 progress
"progress": 1.0, // building 时的构建进度 01
"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, /* datesnull=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 默认排序(12 页,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 chartECharts Liney 轴反转)
- `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 档
00.5 / 0.51 / 12 / 23 / >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 Linex = 交易日,y = 当日名次(**y 轴反转,1 在顶部**,范围 1per_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 Liney 反转) |
| `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. 分期计划
| 期 | 内容 | 预估 |
|---|---|---|
| **P1MVP** | `/board-mac/hotspot` 端点 + 缓存 + 后台构建 + 单测;热点矩阵(领涨/领跌、10/20/30日、行业/概念)+ 统计卡 + BoardDialog 复用 + 构建进度条 | 22.5 天 |
| **P2** | 轮动曲线 bump chart、磁盘持久化历史矩阵(重启不重拉)、只看≥2 次/搜索/localStorage 偏好、HY2 切换 | 1 天 |
| **P3(远期)** | 概念→个股联动(点热点格子看当日成分股贡献)、热点轮动 AI 解读、自定义窗口与多窗口对比 | 另立项 |
## 9. 验收清单(P1
- [ ] 左侧导航出现「热点滚动」,路由/高亮正常
- [ ] 行业页首次进入出现构建进度条,完成后矩阵展示完整 20 列(列数=交易日数)× 上榜板块并集
- [ ] 矩阵配色红涨绿跌且色阶随幅度增强;每日前三名徽标可辨识;hover tooltip 数值与当日排名正确
- [ ] 领涨/领跌切换后行集合、排名语义、统计卡文案整体镜像
- [ ] 盘中今日列 60s 滚动且休市去重;历史列当日不重复拉取(网络面板仅 1 个 hotspot 轮询请求)
- [ ] 单击板块 → BoardDialog 全链路(分时/日K/成分股)
- [ ] 概念页首次构建期间其他页面(行情看板等)请求不受阻塞
- [ ] 断开 MAC 服务器时错误条 + 重试,页面不白屏
+31
View File
@@ -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/当日缓存与后台构建,单连接串行约束下不占请求线程。
+247
View File
@@ -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 日期 YYYYMMDDvipdoc 新鲜度)
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
+280 -1
View File
@@ -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):交易日 × 板块 每日涨跌矩阵 + 每日排名
#
# 两段式数据合成:
# - 历史矩阵:逐板块拉板块指数日Kget_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/bottomgot {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
+38 -12
View File
@@ -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)
+329
View File
@@ -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"])
+166
View File
@@ -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 # 第二次命中缓存
+3 -1
View File
@@ -28,8 +28,10 @@ const sseLabel: Record<string, string> = {
<RouterLink to="/" exact-active-class="active">市场看板</RouterLink>
<RouterLink to="/industries" active-class="active">行业总览</RouterLink>
<RouterLink to="/concepts" active-class="active">概念总览</RouterLink>
<RouterLink to="/hotspots" active-class="active">热点滚动</RouterLink>
<RouterLink to="/calendar" active-class="active">大盘日历</RouterLink>
<RouterLink to="/limitup" active-class="active">涨停生态</RouterLink>
<RouterLink to="/watchlist" active-class="active">自选行情</RouterLink>
<RouterLink to="/leaders" active-class="active">龙头池</RouterLink>
<RouterLink to="/ccpm" active-class="active">期货持仓排名</RouterLink>
<div class="nav-group">分析</div>
<RouterLink to="/backtest" active-class="active">单标的回测</RouterLink>
+31 -6
View File
@@ -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<HotspotResp> {
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<Record<string, unknown>[]> {
const params = new URLSearchParams({ market, count: String(count) })
@@ -810,12 +834,13 @@ export async function clearLlmHistory(): Promise<number> {
return (await resp.json()).deleted as number
}
/** 核心龙头池(159 只)。 */
export async function fetchCoreLeaders(): Promise<CoreLeaderRow[]> {
const resp = await fetch(`${BASE}/market/core-leaders`)
/** 涨停生态(连板天梯/炸板/跌停,本地 vipdoc 离线回算,服务端缓存 60s)。
* data_date 为 vipdoc 数据日期;name 字段需前端经 fetchSymbolName 补齐。 */
export async function fetchLimitUpEcology(): Promise<LimitUpEcologyResp> {
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
}
/** 中金所成交持仓排名:品种列表(含科普元数据)。 */
+256
View File
@@ -0,0 +1,256 @@
<script setup lang="ts">
// 热点矩阵:交易日 × 板块 的红涨绿跌色阶网格(纯 DOM 表格,sticky 双向表头)。
// 当日进入前 per_day 的格子带名次徽标(①~⑩,前3加粗描边);今日列高亮;
// hover 出数值 tooltip,单击行头/格子打开板块弹窗。数据已由父组件排序过滤。
import { fmtPctSigned } from '../format'
import type { HotspotRow } from '../types'
const props = defineProps<{
dates: string[]
rows: HotspotRow[]
perDay: number
/** 今日实时列下标,null = 无今日列 */
todayIndex: number | null
mode: 'top' | 'bottom'
}>()
const emit = defineEmits<{ select: [row: HotspotRow] }>()
// 名次徽标:①②③④⑤⑥⑦⑧⑨⑩(perDay ≤ 10)
const RANK_CHARS = '①②③④⑤⑥⑦⑧⑨⑩'
function rankChar(rank: number | null): string {
if (rank === null || rank < 1 || rank > 10) return ''
return RANK_CHARS[rank - 1] ?? ''
}
/** 涨跌幅 → 背景色(A股红涨绿跌,|pct| 分 5 档增强;与 BoardTiles 同规) */
function cellStyle(pct: number | null): Record<string, string> {
if (pct === null || pct === undefined) {
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' }
}
function isTopDay(rank: number | null): boolean {
return rank !== null && rank <= props.perDay
}
function shortDate(d: string): string {
return d.slice(5) // MM-DD
}
function cellTitle(row: HotspotRow, i: number): string {
const pct = row.pct[i]
const rank = row.rank[i]
const parts = [`${props.dates[i]} ${row.name}`]
parts.push(pct === null ? '无数据' : `${fmtPctSigned(pct)}`)
if (rank !== null) {
const label = props.mode === 'top' ? '涨幅' : '跌幅'
parts.push(`当日${label}${rank}`)
}
return parts.join(' · ')
}
function rowTitle(row: HotspotRow): string {
return `${row.name} (${row.code})\n上榜 ${row.days_in} 天 · 当前连榜 ${row.streak}`
}
</script>
<template>
<div class="matrix-wrap">
<table class="hs-table qtable">
<thead>
<tr>
<th class="sticky-name head-name">板块</th>
<th
v-for="(d, i) in dates"
:key="d"
class="date-col"
:class="{ today: todayIndex === i }"
>
{{ shortDate(d) }}<span v-if="todayIndex === i" class="today-tag"></span>
</th>
<th class="sum-col">上榜</th>
<th class="sum-col">连榜</th>
<th class="sum-col">累计</th>
<th class="sum-col">首榜</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.code">
<th class="sticky-name name" :title="rowTitle(row)" @click="emit('select', row)">
<span class="n">{{ row.name }}</span>
<span class="c dim">{{ row.code }}</span>
</th>
<td
v-for="(d, i) in dates"
:key="d"
class="cell-td"
:class="{ today: todayIndex === i }"
>
<div
class="cell"
:class="{ top3: isTopDay(row.rank[i]) && row.rank[i]! <= 3 }"
:style="cellStyle(row.pct[i])"
:title="cellTitle(row, i)"
@click="emit('select', row)"
>
<span class="pct mono">{{ fmtPctSigned(row.pct[i]) }}</span>
<span v-if="isTopDay(row.rank[i])" class="rk">{{ rankChar(row.rank[i]) }}</span>
</div>
</td>
<td class="sum-col strong">{{ row.days_in }}</td>
<td class="sum-col" :class="{ hot: row.streak >= 3 }">{{ row.streak }}</td>
<td
class="sum-col mono strong"
:class="row.sum_pct === null ? 'flat' : row.sum_pct > 0 ? 'up' : 'down'"
>
{{ fmtPctSigned(row.sum_pct) }}
</td>
<td class="sum-col dim">{{ row.first_date ? shortDate(row.first_date) : '-' }}</td>
</tr>
</tbody>
</table>
<div v-if="rows.length === 0" class="matrix-empty">窗口内暂无上榜板块</div>
</div>
</template>
<style scoped>
.matrix-wrap {
overflow: auto;
max-height: calc(100vh - 320px);
min-height: 240px;
}
.hs-table {
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
}
.hs-table th,
.hs-table td {
padding: 0;
border-bottom: 1px solid var(--border);
text-align: center;
white-space: nowrap;
}
/* 列头(sticky 顶) */
.hs-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-panel);
padding: 6px 4px;
color: var(--text-dim);
font-weight: 500;
border-bottom: 1px solid var(--border);
}
/* 行头板块名(sticky 左) */
.hs-table .sticky-name {
position: sticky;
left: 0;
z-index: 1;
background: var(--bg-panel);
}
.hs-table thead .sticky-name {
z-index: 3;
}
.hs-table .head-name {
text-align: left;
min-width: 100px;
}
.hs-table .name {
text-align: left;
cursor: pointer;
padding: 0 8px;
min-width: 100px;
max-width: 124px;
}
.hs-table .name:hover {
color: var(--accent);
}
.hs-table .name .n {
display: block;
font-weight: 600;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
}
.hs-table .name .c {
display: block;
font-size: 10px;
font-family: var(--font-mono);
}
.date-col {
min-width: 53px;
}
/* 单元格 */
.cell-td {
padding: 0 !important;
}
.cell {
position: relative;
min-width: 53px;
height: 34px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1.1;
cursor: pointer;
overflow: hidden;
}
.cell:hover {
filter: brightness(1.3);
}
.cell .pct {
font-size: 10.5px;
}
.cell .rk {
font-size: 10px;
opacity: 0.95;
}
/* 当日第一名加粗描边 */
.cell.top3 {
box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.65);
}
/* 今日列高亮 */
.date-col.today {
color: var(--accent);
font-weight: 700;
}
.cell-td.today {
border-left: 1px solid var(--accent);
}
.today-tag {
margin-left: 2px;
font-size: 9px;
background: var(--accent);
color: #fff;
border-radius: 2px;
padding: 0 2px;
}
/* 行尾汇总列 */
.sum-col {
min-width: 46px;
padding: 0 7px !important;
font-family: var(--font-mono);
}
.sum-col.strong {
font-weight: 700;
}
.sum-col.hot {
color: var(--up);
}
.matrix-empty {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
</style>
+257
View File
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 热点窗口统计卡:窗口领涨(跌)王 / 持续热点 / 新面孔 / 一日游。
// 全部由矩阵行前端派生,零额外请求;领跌模式切换弱势词汇。
// 数量不为 0 的卡片可点击:王卡直达板块弹窗,其余展开成员板块列表( chips 可再点弹窗)。
import { computed, ref } from 'vue'
import { fmtPctSigned } from '../format'
import type { HotspotRow } from '../types'
const props = defineProps<{
rows: HotspotRow[]
dates: string[]
mode: 'top' | 'bottom'
}>()
const emit = defineEmits<{ select: [row: HotspotRow] }>()
const king = computed(() => {
const withSum = props.rows.filter((r) => r.sum_pct !== null)
if (withSum.length === 0) return null
return [...withSum].sort((a, b) =>
props.mode === 'top'
? (b.sum_pct ?? 0) - (a.sum_pct ?? 0)
: (a.sum_pct ?? 0) - (b.sum_pct ?? 0),
)[0]
})
/** 持续热点阈值:上榜 ≥ max(3, ⌈窗口/4⌉) 天(真实数据下前 5 名轮动快,÷3 会常年为 0) */
const persistentThreshold = computed(() => Math.max(3, Math.ceil(props.dates.length / 4)))
const persistent = computed(() => props.rows.filter((r) => r.days_in >= persistentThreshold.value))
/** 新面孔:首次上榜落在最近 5 个交易日 */
const fresh = computed(() => {
if (props.dates.length === 0) return []
const cutoff = props.dates.length - 5
return props.rows.filter((r) => {
if (!r.first_date) return false
return props.dates.indexOf(r.first_date) >= cutoff
})
})
/** 一日游:整个窗口仅上榜 1 天(脉冲行情,数量越多轮动越快) */
const flash = computed(() => props.rows.filter((r) => r.days_in === 1))
const kingLabel = computed(() => (props.mode === 'top' ? '窗口领涨王' : '窗口领跌王'))
const persistentLabel = computed(() => (props.mode === 'top' ? '持续热点' : '持续弱势'))
const freshLabel = computed(() => (props.mode === 'top' ? '新面孔' : '新杀跌'))
// ── 卡片点击:展开成员板块列表 ───────────────────────────────────────────────
type ExpandKey = 'persistent' | 'fresh' | 'flash'
const expanded = ref<ExpandKey | null>(null)
const EXPAND_META: Record<ExpandKey, { label: () => string; list: () => HotspotRow[] }> = {
persistent: { label: () => `${persistentLabel.value}(上榜 ≥ ${persistentThreshold.value} 天)`, list: () => persistent.value },
fresh: { label: () => `${freshLabel.value}(近 5 个交易日首次上榜)`, list: () => fresh.value },
flash: { label: () => '一日游(仅上榜 1 天)', list: () => flash.value },
}
function toggle(key: ExpandKey) {
expanded.value = expanded.value === key ? null : key
}
const expandedTitle = computed(() =>
expanded.value ? EXPAND_META[expanded.value].label() : '',
)
const expandedList = computed(() =>
expanded.value ? EXPAND_META[expanded.value].list().sort((a, b) =>
props.mode === 'top'
? (b.sum_pct ?? 0) - (a.sum_pct ?? 0)
: (a.sum_pct ?? 0) - (b.sum_pct ?? 0),
) : [],
)
function firstDateShort(r: HotspotRow): string {
return r.first_date ? r.first_date.slice(5) : ''
}
</script>
<template>
<div class="stat-strip-wrap">
<div class="stat-strip">
<div
class="stat-card card king"
:class="mode"
title="点击查看板块详情"
@click="king && emit('select', king)"
>
<div class="stat-title">{{ kingLabel }}</div>
<template v-if="king">
<div class="stat-main">{{ king.name }}</div>
<div class="stat-sub mono" :class="king.sum_pct !== null && king.sum_pct < 0 ? 'down' : 'up'">
{{ fmtPctSigned(king.sum_pct) }}
<span class="dim">· 上榜 {{ king.days_in }} </span>
</div>
</template>
<div v-else class="stat-main dim"></div>
</div>
<div
class="stat-card card"
:class="{ clickable: persistent.length > 0, on: expanded === 'persistent' }"
@click="toggle('persistent')"
>
<div class="stat-title">{{ persistentLabel }}</div>
<div class="stat-main">
{{ persistent.length }} <span class="unit"></span>
<span v-if="persistent.length > 0" class="expand-hint">{{ expanded === 'persistent' ? '收起 ' : '展开 ' }}</span>
</div>
<div class="stat-sub dim">上榜 {{ persistentThreshold }} </div>
</div>
<div
class="stat-card card"
:class="{ clickable: fresh.length > 0, on: expanded === 'fresh' }"
@click="toggle('fresh')"
>
<div class="stat-title">{{ freshLabel }}</div>
<div class="stat-main">
{{ fresh.length }} <span class="unit"></span>
<span v-if="fresh.length > 0" class="expand-hint">{{ expanded === 'fresh' ? '收起 ' : '展开 ' }}</span>
</div>
<div class="stat-sub dim"> 5 个交易日首次上榜</div>
</div>
<div
class="stat-card card"
:class="{ clickable: flash.length > 0, on: expanded === 'flash' }"
@click="toggle('flash')"
>
<div class="stat-title">一日游</div>
<div class="stat-main">
{{ flash.length }} <span class="unit"></span>
<span v-if="flash.length > 0" class="expand-hint">{{ expanded === 'flash' ? '收起 ' : '展开 ' }}</span>
</div>
<div class="stat-sub dim">仅上榜 1 · 越多轮动越快</div>
</div>
</div>
<!-- 成员板块展开面板 -->
<div v-if="expanded && expandedList.length > 0" class="member-panel card">
<div class="mp-title">{{ expandedTitle }} · {{ expandedList.length }} 个板块单击查看详情</div>
<div class="mp-chips">
<button
v-for="r in expandedList"
:key="r.code"
class="member-chip"
:title="`${r.name} (${r.code})\n上榜 ${r.days_in} 天 · 累计 ${fmtPctSigned(r.sum_pct)}${r.first_date ? ` · 首榜 ${firstDateShort(r)}` : ''}`"
@click.stop="emit('select', r)"
>
<span class="mc-name">{{ r.name }}</span>
<span class="mono mc-pct" :class="r.sum_pct === null ? 'flat' : r.sum_pct > 0 ? 'up' : 'down'">
{{ fmtPctSigned(r.sum_pct) }}
</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.stat-strip-wrap {
display: flex;
flex-direction: column;
gap: 10px;
}
.stat-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat-card {
padding: 10px 14px;
}
.stat-card.clickable {
cursor: pointer;
transition: border-color 0.1s;
}
.stat-card.clickable:hover {
border-color: var(--accent);
}
.stat-card.on {
border-color: var(--accent);
}
.stat-card.king {
cursor: pointer;
}
.stat-card.king:hover {
border-color: var(--accent);
}
.stat-title {
font-size: 11.5px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-main {
font-size: 17px;
font-weight: 700;
}
.stat-main.up {
color: var(--up);
}
.stat-main.down {
color: var(--down);
}
.unit {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.expand-hint {
font-size: 11px;
font-weight: 400;
color: var(--accent);
margin-left: 6px;
}
.stat-sub {
font-size: 11.5px;
margin-top: 2px;
}
.member-panel {
padding: 10px 14px;
}
.mp-title {
font-size: 11.5px;
color: var(--text-muted);
margin-bottom: 8px;
}
.mp-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.member-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 12px;
border-radius: 999px;
background: var(--bg-elevated);
border: 1px solid var(--border);
cursor: pointer;
}
.member-chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.mc-name {
font-weight: 600;
}
.mc-pct {
font-size: 11.5px;
}
@media (max-width: 1024px) {
.stat-strip {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
// 风险提示组件:prominent=true 用于语义重点页(龙头池/AI 解读历史等),
// 风险提示组件:prominent=true 用于语义重点页(AI 解读历史等),
// 默认紧凑样式。文案可通过默认插槽整体替换。
withDefaults(defineProps<{ prominent?: boolean }>(), { prominent: false })
</script>
+10
View File
@@ -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<string, string> {
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' }
}
+11 -3
View File
@@ -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 路径当页面访问)回看板,不再渲染空白
+75 -7
View File
@@ -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<number | null>
/** 对齐 dates 的当日全类型排名(top: 1=涨幅最大;bottom: 1=跌幅最大) */
rank: Array<number | null>
/** 上榜天数(进入每日前 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/*) ─────────────────────────────────
/** 品种元数据(含给新手的科普文案)。 */
-193
View File
@@ -1,193 +0,0 @@
<script setup lang="ts">
// 核心龙头池页:159 只核心龙头(东财全行业龙头名单,screen universe="core"
// 的同一份数据资产)。支持搜索过滤,点击行弹个股详情。
import { computed, onMounted, ref } from 'vue'
import { fetchCoreLeaders, formatError } from '../api'
import type { CoreLeaderRow } from '../types'
import RiskDisclaimer from '../components/RiskDisclaimer.vue'
import StockDialog from '../components/StockDialog.vue'
const leaders = ref<CoreLeaderRow[]>([])
const loading = ref(false)
const error = ref('')
const keyword = ref('')
const dialog = ref<{ market: string; code: string; name: string } | null>(null)
onMounted(load)
async function load() {
loading.value = true
error.value = ''
try {
leaders.value = await fetchCoreLeaders()
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
const filtered = computed(() => {
const k = keyword.value.trim().toLowerCase()
if (!k) return leaders.value
return leaders.value.filter(
(r) => r.code.includes(k) || r.name.toLowerCase().includes(k),
)
})
function openDialog(row: CoreLeaderRow) {
dialog.value = { market: row.market, code: row.code, name: row.name }
}
</script>
<template>
<div class="leaders-view">
<div class="toolbar">
<h2>核心龙头池 <span class="dim title-sub">{{ leaders.length }} · 东财全行业龙头名单</span></h2>
<input
v-model="keyword"
class="search"
type="text"
placeholder="搜代码 / 名称…"
spellcheck="false"
/>
</div>
<p class="hint">
<strong>这份名单是什么</strong>按东方财富公开的"全行业龙头股名单"整理的
<strong>选股扫描范围</strong>即离线扫描的 <code>universe="core"</code> 股票池
<code>easy-tdx screen scan --universe core</code>
<code>/market/strength?universe=core</code> 均按此过滤四组分层全球第一 / 国内第一 /
科技细分 / 行业冠军<strong>名单仅描述"这些公司在其行业内规模/市占率领先"这一客观事实
不代表任何买入价值判断</strong>龙头同样可能高估滞涨或衰退点击行查看个股详情
</p>
<RiskDisclaimer prominent>
<strong> 风险提示与免责声明</strong>
<p>
本页面展示的"核心龙头池"仅为<strong>策略扫描的股票范围筛选清单</strong>
<strong>不构成任何形式的个股推荐买入建议或投资顾问服务</strong>名单基于第三方公开
资料整理可能存在滞后遗漏或错误"行业龙头"是对历史经营地位的描述
不预示未来股价表现本工具及作者不对任何人依据本名单作出的投资行为及损失承担责任
投资有风险入市需谨慎据此操作风险自负
</p>
</RiskDisclaimer>
<div v-if="error" class="error-banner"> {{ error }}</div>
<div v-else-if="loading" class="empty">加载中</div>
<div v-else class="grid">
<div
v-for="r in filtered"
:key="r.market + r.code"
class="cell"
@click="openDialog(r)"
>
<span class="c-code mono dim">{{ r.code }}</span>
<span class="c-name">{{ r.name }}</span>
<span class="c-mkt mono dim">{{ r.market }}</span>
</div>
</div>
<div v-if="!loading && !error && !filtered.length" class="empty">无匹配结果</div>
<StockDialog
v-if="dialog"
:market="dialog.market"
:code="dialog.code"
:name="dialog.name"
@close="dialog = null"
/>
</div>
</template>
<style scoped>
.leaders-view {
height: 100%;
overflow-y: auto;
padding: 14px 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.toolbar h2 {
font-size: 16px;
}
.title-sub {
font-weight: 400;
font-size: 12px;
}
.search {
width: 200px;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
}
.search:focus {
outline: none;
border-color: var(--accent);
}
.hint {
margin: 8px 0 12px;
font-size: 12px;
color: var(--text-dim);
line-height: 1.7;
}
.hint code {
font-family: var(--font-mono);
font-size: 11px;
}
.error-banner {
padding: 8px 12px;
background: rgba(244, 67, 54, 0.1);
border-radius: var(--radius);
font-size: 12px;
color: var(--red, #f44336);
}
.empty {
color: var(--text-dim);
padding: 40px;
text-align: center;
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 6px;
}
.cell {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 12.5px;
cursor: pointer;
}
.cell:hover {
border-color: var(--accent);
}
.cell:hover .c-name {
color: var(--accent);
}
.c-code {
font-size: 11px;
flex-shrink: 0;
}
.c-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-mkt {
font-size: 10px;
flex-shrink: 0;
}
.dim {
color: var(--text-dim);
}
</style>
+517
View File
@@ -0,0 +1,517 @@
<script setup lang="ts">
// 市场热点滚动(/hotspots):交易日 × 板块涨跌矩阵,直观展示热点形成/持续/轮动/领跌。
// 首次构建走后端后台任务(1s 轮询进度),完成后当日缓存;盘中 60s 轮询仅滚动今日列。
// 单击板块复用 BoardDialog。
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { fetchBoardHotspot, formatError } from '../api'
import BoardDialog from '../components/BoardDialog.vue'
import HotspotMatrix from '../components/HotspotMatrix.vue'
import HotspotStatStrip from '../components/HotspotStatStrip.vue'
import type { HotspotResp, HotspotRow } from '../types'
// boardType 可由路由 props 注入(/styles → FG 风格轮动),页内仍可自由切换
const props = defineProps<{ boardType?: 'HY' | 'GN' | 'FG' }>()
const PER_DAY = 5 // 每日前 5 名入选(与后端默认一致)
type SortKey = 'days_in' | 'sum_pct' | 'first_date'
const boardType = ref<'HY' | 'GN' | 'FG'>(props.boardType ?? 'HY')
const days = ref<number>(20)
const mode = ref<'top' | 'bottom'>('top')
watch(
() => props.boardType,
(t) => {
if (t && t !== boardType.value) setType(t)
},
)
const typeLabels: Record<'HY' | 'GN' | 'FG', string> = { HY: '行业', GN: '概念', FG: '风格' }
const dayOptions: Array<{ v: number; label: string }> = [
{ v: 1, label: '今日' },
{ v: 10, label: '近10日' },
{ v: 20, label: '近20日' },
{ v: 30, label: '近30日' },
]
// ── 数据状态机:loading → building(进度) → ready / error ─────────────────────
const resp = ref<HotspotResp | null>(null)
const buildingProgress = ref<number | null>(null) // null = 非构建中
const buildError = ref('')
const loading = ref(false)
const lastRefresh = ref('')
let buildTimer = 0
function stopBuildPoll() {
if (buildTimer) {
window.clearInterval(buildTimer)
buildTimer = 0
}
}
async function load(retry = false) {
try {
const r = await fetchBoardHotspot(boardType.value, days.value, mode.value, PER_DAY, retry)
if (r.status === 'building') {
buildError.value = ''
resp.value = null
loading.value = false
buildingProgress.value = r.progress ?? 0
if (!buildTimer) buildTimer = window.setInterval(pollBuild, 1000)
return
}
stopBuildPoll()
buildingProgress.value = null
if (r.status === 'error') {
buildError.value = r.error || '热点矩阵构建失败'
return
}
buildError.value = ''
resp.value = r
loading.value = false
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
stopBuildPoll()
buildingProgress.value = null
buildError.value = formatError(e)
}
}
function pollBuild() {
if (!document.hidden) load()
}
const buildPct = computed(() => Math.round((buildingProgress.value ?? 0) * 100))
function setType(t: 'HY' | 'GN' | 'FG') {
if (boardType.value === t) return
boardType.value = t
resetAndLoad()
}
function setDays(d: number) {
if (days.value === d) return
days.value = d
resetAndLoad()
}
function setMode(m: 'top' | 'bottom') {
if (mode.value === m) return
mode.value = m
resetAndLoad()
}
function resetAndLoad() {
stopBuildPoll()
resp.value = null
buildingProgress.value = null
buildError.value = ''
loading.value = true
load()
}
// ── 行排序 / 过滤(数据已全量在内存,纯前端) ─────────────────────────────────
const search = ref('')
const onlyMulti = ref(false)
const sortKey = ref<SortKey>('days_in')
const dates = computed(() => resp.value?.dates ?? [])
const perDay = computed(() => resp.value?.per_day ?? PER_DAY)
const todayIndex = computed(() => resp.value?.today_index ?? null)
const displayRows = computed<HotspotRow[]>(() => {
let out = resp.value?.rows ?? []
const q = search.value.trim().toLowerCase()
if (q) out = out.filter((r) => r.name.toLowerCase().includes(q) || r.code.includes(q))
if (onlyMulti.value) out = out.filter((r) => r.days_in >= 2)
return [...out].sort((a, b) => {
switch (sortKey.value) {
case 'sum_pct':
return (b.sum_pct ?? -Infinity) - (a.sum_pct ?? -Infinity)
case 'first_date':
return (b.first_date ?? '').localeCompare(a.first_date ?? '') // 新热点在前
default:
return b.days_in - a.days_in || (b.sum_pct ?? 0) - (a.sum_pct ?? 0)
}
})
})
// ── 轮询调度(同行业总览:交易时段门控 + 页面隐藏暂停) ───────────────────────
let refreshTimer = 0
let sessionTimer = 0
const sessionGated = ref(localStorage.getItem('hotspot.sessionGated') !== '0')
function onSessionToggle() {
localStorage.setItem('hotspot.sessionGated', sessionGated.value ? '1' : '0')
}
function isTradeSession(now = new Date()): boolean {
const day = now.getDay()
if (day === 0 || day === 6) return false
const m = now.getHours() * 60 + now.getMinutes()
return (m >= 555 && m <= 690) || (m >= 780 && m <= 905)
}
const inSession = ref(isTradeSession())
const autoPaused = computed(() => sessionGated.value && !inSession.value)
const sessionLabel = computed(() => {
if (!sessionGated.value) return '全天候模式'
return inSession.value ? '交易中' : '休市 · 自动刷新已暂停'
})
function tick() {
inSession.value = isTradeSession()
if (autoPaused.value || document.hidden) return
if (resp.value) load() // 历史列服务端当日缓存,仅今日列滚动,请求很快
}
onMounted(() => {
load()
refreshTimer = window.setInterval(tick, 60_000)
sessionTimer = window.setInterval(() => {
inSession.value = isTradeSession()
}, 60_000)
})
onBeforeUnmount(() => {
stopBuildPoll()
window.clearInterval(refreshTimer)
window.clearInterval(sessionTimer)
})
// ── 弹窗(板块详情) ─────────────────────────────────────────────────────────
const boardDialog = ref<{ code: string; name: string } | null>(null)
function openBoard(r: HotspotRow) {
boardDialog.value = { code: r.code, name: r.name }
}
</script>
<template>
<div class="hotspot-view">
<div class="view-head">
<h2>热点滚动</h2>
<span class="dim head-sub">板块热点的形成 · 持续 · 轮动</span>
<span v-if="resp?.total_boards" class="dim head-sub">参与板块 {{ resp.total_boards }}</span>
<span v-if="resp?.session === 'live'" class="live-badge">今日列 · 实时</span>
</div>
<!-- 工具行 -->
<div class="toolbar">
<span class="seg">
<button
v-for="t in (['HY', 'GN', 'FG'] as const)"
:key="t"
:class="{ on: boardType === t }"
@click="setType(t)"
>
{{ typeLabels[t] }}
</button>
</span>
<span class="seg">
<button
v-for="o in dayOptions"
:key="o.v"
:class="{ on: days === o.v }"
:title="o.v === 1 ? '今日领涨/领跌前5名' : undefined"
@click="setDays(o.v)"
>
{{ o.label }}
</button>
</span>
<span class="seg">
<button :class="{ on: mode === 'top' }" @click="setMode('top')">领涨</button>
<button :class="{ on: mode === 'bottom' }" @click="setMode('bottom')">领跌</button>
</span>
<label class="tb-label">排序
<select v-model="sortKey">
<option value="days_in">上榜次数</option>
<option value="sum_pct">累计涨跌</option>
<option value="first_date">最新上榜</option>
</select>
</label>
<label class="tb-label multi-toggle">
<input v-model="onlyMulti" type="checkbox" />
只看上榜2
</label>
<input v-model="search" class="search" type="text" placeholder="搜索板块名/代码" />
<span class="tb-spacer"></span>
<span class="dot" :class="{ live: !autoPaused }"></span>
<span class="session-label" :class="{ paused: autoPaused }">{{ sessionLabel }}</span>
<label class="session-toggle">
<input v-model="sessionGated" type="checkbox" @change="onSessionToggle" />
仅交易时段刷新
</label>
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
<button class="manual-refresh" @click="load()"> 刷新</button>
</div>
<!-- 构建中 / 失败 -->
<div v-if="buildingProgress !== null" class="building card">
<div class="build-text">正在构建板块日K矩阵 {{ buildPct }}%</div>
<div class="build-bar">
<div class="build-fill" :style="{ width: buildPct + '%' }"></div>
</div>
<div class="dim build-hint">
首次构建需逐板块拉取日K{{ boardType === 'GN' ? '概念板块数量多,' : '' }}约需数十秒
完成后当日缓存秒级刷新
</div> </div>
<div v-else-if="buildError" class="err card">
构建失败{{ buildError }}
<button @click="load(true)">重试</button>
</div>
<div v-else-if="loading" class="loading">加载中</div>
<!-- 主区统计卡 + 图例 + 矩阵 -->
<template v-else-if="resp">
<HotspotStatStrip :rows="resp.rows ?? []" :dates="dates" :mode="mode" @select="openBoard" />
<div class="legend">
<span class="lg-title">色阶</span>
<i class="sw u1"></i><i class="sw u2"></i><i class="sw u3"></i>
<span></span>
<i class="sw d1"></i><i class="sw d2"></i><i class="sw d3"></i>
<span>越深幅度越大</span>
<span class="lg-sep">|</span>
<span><b class="lg-rank">~</b> 当日前 {{ perDay }} {{ mode === 'top' ? '最强' : '最弱' }}</span>
<span>描边 = 当日前 3 </span>
<span class="lg-sep">|</span>
<span>单击板块看分时/日K/成分股</span>
</div>
<div class="matrix-card card">
<HotspotMatrix
:dates="dates"
:rows="displayRows"
:per-day="perDay"
:today-index="todayIndex"
:mode="mode"
@select="openBoard"
/>
</div>
</template>
<BoardDialog
v-if="boardDialog"
:code="boardDialog.code"
:name="boardDialog.name"
@close="boardDialog = null"
/>
</div>
</template>
<style scoped>
.hotspot-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.view-head,
.toolbar,
.legend,
.building,
.err,
.loading {
flex-shrink: 0;
}
.view-head {
display: flex;
align-items: center;
gap: 8px;
}
.view-head h2 {
font-size: 17px;
font-weight: 700;
}
.head-sub {
font-size: 12px;
}
.live-badge {
font-size: 11px;
color: var(--up);
border: 1px solid var(--up);
border-radius: 3px;
padding: 0 6px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
}
.seg button {
border-radius: 0;
font-size: 12px;
padding: 4px 12px;
}
.seg button:first-child {
border-radius: var(--radius) 0 0 var(--radius);
}
.seg button:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
margin-left: -1px;
}
.seg button.on {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.12);
position: relative;
z-index: 1;
}
.tb-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 0;
font-size: 12px;
color: var(--text-muted);
}
.tb-label select {
width: auto;
padding: 4px 8px;
font-size: 12px;
}
.multi-toggle input {
width: auto;
}
.search {
width: 170px;
padding: 4px 10px;
font-size: 12px;
}
.tb-spacer {
flex: 1;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-dim);
}
.dot.live {
background: var(--up);
box-shadow: 0 0 4px var(--up);
}
.session-label {
font-size: 11.5px;
color: var(--text-muted);
}
.session-label.paused {
color: var(--warn);
}
.session-toggle {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 0;
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
}
.session-toggle input {
width: auto;
}
.refresh-ts {
font-family: var(--font-mono);
font-size: 11.5px;
}
.manual-refresh {
font-size: 12px;
padding: 4px 10px;
}
.building {
padding: 18px 20px;
}
.build-text {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.build-bar {
height: 8px;
background: var(--bg-elevated);
border-radius: 4px;
overflow: hidden;
}
.build-fill {
height: 100%;
background: var(--accent);
border-radius: 4px;
transition: width 0.4s ease;
}
.build-hint {
font-size: 11.5px;
margin-top: 8px;
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.legend {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11.5px;
color: var(--text-muted);
}
.lg-title {
color: var(--text-dim);
}
.sw {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin: 0 1px;
vertical-align: -2px;
}
.sw.u1 {
background: rgba(239, 65, 70, 0.26);
}
.sw.u2 {
background: rgba(239, 65, 70, 0.5);
}
.sw.u3 {
background: rgba(239, 65, 70, 0.82);
}
.sw.d1 {
background: rgba(24, 160, 88, 0.26);
}
.sw.d2 {
background: rgba(24, 160, 88, 0.5);
}
.sw.d3 {
background: rgba(24, 160, 88, 0.82);
}
.lg-sep {
color: var(--border);
}
.lg-rank {
color: var(--text);
}
.matrix-card {
padding: 8px;
}
.matrix-card :deep(.matrix-wrap) {
max-height: calc(100vh - 380px);
}
</style>
+505
View File
@@ -0,0 +1,505 @@
<script setup lang="ts">
// 大盘日历(/calendar):指数全年每日涨跌红绿日历热力图(GitHub 贡献图风格)。
// 数据 = /bars/index 指数日K(上证/深成/创业板),前端算逐日涨跌幅、按年渲染 12 个月块。
import { computed, onMounted, ref, watch } from 'vue'
import { fetchIndexBars, formatError } from '../api'
import { fmt2, fmtAmount, fmtPctSigned, pctCellStyle } from '../format'
import type { Bar } from '../types'
const INDICES = [
{ market: 'SH', code: '000001', name: '上证指数' },
{ market: 'SZ', code: '399001', name: '深证成指' },
{ market: 'SZ', code: '399006', name: '创业板指' },
] as const
type DailyPoint = { date: string; pct: number | null; close: number; amount: number }
const activeIdx = ref(0)
const barsByIndex = new Map<number, Bar[]>()
const loading = ref(false)
const error = ref('')
const bars = ref<Bar[]>([])
const lastUpdate = ref('')
async function loadIndex(idx: number) {
if (barsByIndex.has(idx)) {
bars.value = barsByIndex.get(idx)!
return
}
loading.value = true
error.value = ''
try {
const meta = INDICES[idx]
const data = await fetchIndexBars(meta.market, meta.code, 550) // ≈2.2 年
barsByIndex.set(idx, data)
bars.value = data
if (data.length === 0) error.value = `${meta.name} 日K返回空`
lastUpdate.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
function onIndexChange(idx: number) {
activeIdx.value = idx
loadIndex(idx)
}
/** 全序列逐日涨跌幅(首日无前收 → null;跨年由上一年末收盘提供基准) */
const daily = computed<DailyPoint[]>(() => {
const out: DailyPoint[] = []
let prev: number | null = null
for (const b of bars.value) {
const close = b.close
out.push({
date: b.datetime.slice(0, 10),
pct: prev !== null && prev > 0 ? (close / prev - 1) * 100 : null,
close,
amount: Number(b.amount ?? 0),
})
prev = close
}
return out
})
const years = computed(() => {
const set = new Set<number>()
for (const p of daily.value) set.add(Number(p.date.slice(0, 4)))
return [...set].sort((a, b) => b - a) // 新年份在前
})
const activeYear = ref<number>(new Date().getFullYear())
watch(years, (ys) => {
if (ys.length > 0 && !ys.includes(activeYear.value)) activeYear.value = ys[0]
})
const yearPoints = computed(() =>
daily.value.filter((p) => Number(p.date.slice(0, 4)) === activeYear.value),
)
/** 年内统计:红绿天数、最大连涨/连跌 */
const yearStats = computed(() => {
const pts = yearPoints.value
if (pts.length === 0) return null
const up = pts.filter((p) => (p.pct ?? 0) > 0).length
const down = pts.filter((p) => (p.pct ?? 0) < 0).length
let maxUpStreak = 0
let maxDownStreak = 0
let cu = 0
let cd = 0
for (const p of pts) {
if ((p.pct ?? 0) > 0) cu += 1
else cu = 0
if ((p.pct ?? 0) < 0) cd += 1
else cd = 0
maxUpStreak = Math.max(maxUpStreak, cu)
maxDownStreak = Math.max(maxDownStreak, cd)
}
return { up, down, maxUpStreak, maxDownStreak }
})
/** 年涨幅:上年末收盘为基准(取全序列中年内首日的前一根) */
const yearPct = computed(() => {
const firstPos = daily.value.findIndex(
(p) => Number(p.date.slice(0, 4)) === activeYear.value,
)
if (firstPos <= 0) return null
const base = daily.value[firstPos - 1].close
const lastPt = daily.value[firstPos + yearPoints.value.length - 1]
if (!lastPt || base <= 0) return null
return (lastPt.close / base - 1) * 100
})
/** 按月分组的渲染模型:12 个月块,各含交易日格与月涨幅(上月末收盘为基准) */
const months = computed(() => {
const byMonth: DailyPoint[][] = Array.from({ length: 12 }, () => [])
for (const p of yearPoints.value) byMonth[Number(p.date.slice(5, 7)) - 1].push(p)
return byMonth.map((pts, i) => {
let pct: number | null = null
if (pts.length > 0) {
const pos = daily.value.findIndex((p) => p.date === pts[0].date)
if (pos > 0) pct = (pts[pts.length - 1].close / daily.value[pos - 1].close - 1) * 100
}
return { month: i + 1, days: pts, pct }
})
})
// ── 方框大小编码成交额:年内四分位分 4 档(相对口径,跨指数/年份自归一) ─────
const SIZE_STEPS = [18, 22, 26, 30]
const amountTiers = computed(() => {
const sorted = yearPoints.value
.filter((p) => p.amount > 0)
.map((p) => p.amount)
.sort((a, b) => a - b)
const map = new Map<string, number>()
const q = (t: number) => (sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(t * sorted.length))] : 0)
const cuts = [q(0.25), q(0.5), q(0.75)]
for (const p of yearPoints.value) {
const tier =
p.amount <= 0 || sorted.length === 0
? 0
: p.amount <= cuts[0]
? 0
: p.amount <= cuts[1]
? 1
: p.amount <= cuts[2]
? 2
: 3
map.set(p.date, tier)
}
return map
})
function daySize(p: DailyPoint): number {
return SIZE_STEPS[amountTiers.value.get(p.date) ?? 0]
}
// ── 浮动 tooltip:跟随鼠标展示当日涨跌幅 / 收盘 / 成交额 ────────────────────
const tip = ref<{ x: number; y: number; lines: Array<{ t: string; cls?: string }> } | null>(null)
function showTip(e: MouseEvent, p: DailyPoint) {
tip.value = {
x: e.clientX,
y: e.clientY,
lines: [
{ t: p.date },
{ t: `收盘 ${fmt2(p.close)}` },
{ t: fmtPctSigned(p.pct), cls: (p.pct ?? 0) > 0 ? 'up' : (p.pct ?? 0) < 0 ? 'down' : 'flat' },
{ t: `成交 ${fmtAmount(p.amount)}` },
],
}
}
function hideTip() {
tip.value = null
}
function cellTitle(p: DailyPoint): string {
return `${p.date}\n收 ${fmt2(p.close)} · ${fmtPctSigned(p.pct)}`
}
onMounted(() => loadIndex(activeIdx.value))
</script>
<template>
<div class="calendar-view">
<div class="view-head">
<h2>大盘日历</h2>
<span class="dim head-sub">全年红绿一眼扫完 · 红涨绿跌</span>
<span v-if="lastUpdate" class="dim head-sub">更新于 {{ lastUpdate }}</span>
</div>
<div class="toolbar">
<span class="seg">
<button
v-for="(m, i) in INDICES"
:key="m.code"
:class="{ on: activeIdx === i }"
@click="onIndexChange(i)"
>
{{ m.name }}
</button>
</span>
<span class="seg">
<button v-for="y in years" :key="y" :class="{ on: activeYear === y }" @click="activeYear = y">
{{ y }}
</button>
</span>
<span class="tb-spacer"></span>
<div class="legend">
<span class="lg-title">色阶</span>
<i class="sw u1"></i><i class="sw u2"></i><i class="sw u3"></i>
<span></span>
<i class="sw d1"></i><i class="sw d2"></i><i class="sw d3"></i>
<span>越深幅度越大</span>
<span class="lg-sep">|</span>
<span>框越大 = 当日成交额越高年内相对分档</span>
<span class="lg-sep">|</span>
<span>悬停看当日详情</span>
</div>
</div>
<div v-if="error" class="err card">
加载失败{{ error }}
<button @click="onIndexChange(activeIdx)">重试</button>
</div>
<div v-else-if="loading" class="loading">指数日K加载中</div>
<template v-else>
<!-- 年度统计条 -->
<div class="stat-strip">
<div class="stat-card card">
<div class="stat-title">{{ activeYear }}年涨幅</div>
<div class="stat-main mono" :class="yearPct === null ? 'flat' : yearPct > 0 ? 'up' : 'down'">
{{ fmtPctSigned(yearPct) }}
</div>
</div>
<div class="stat-card card">
<div class="stat-title">上涨 / 下跌天数</div>
<div class="stat-main">
<span class="up">{{ yearStats?.up ?? '-' }}</span>
<span class="dim"> / </span>
<span class="down">{{ yearStats?.down ?? '-' }}</span>
</div>
</div>
<div class="stat-card card">
<div class="stat-title">最长连涨</div>
<div class="stat-main">{{ yearStats?.maxUpStreak ?? '-' }} <span class="unit"></span></div>
</div>
<div class="stat-card card">
<div class="stat-title">最长连跌</div>
<div class="stat-main">{{ yearStats?.maxDownStreak ?? '-' }} <span class="unit"></span></div>
</div>
</div>
<!-- 12 个月块日历 -->
<div class="cal-grid">
<div v-for="m in months" :key="m.month" class="month-block card">
<div class="m-head">
<span class="m-name">{{ m.month }}</span>
<span
v-if="m.pct !== null"
class="m-pct mono"
:class="m.pct > 0 ? 'up' : m.pct < 0 ? 'down' : 'flat'"
>
{{ fmtPctSigned(m.pct) }}
</span>
</div>
<div class="m-days">
<div
v-for="p in m.days"
:key="p.date"
class="day-cell mono"
:style="{ ...pctCellStyle(p.pct), width: daySize(p) + 'px', height: daySize(p) + 'px' }"
:title="cellTitle(p)"
@mouseenter="showTip($event, p)"
@mousemove="showTip($event, p)"
@mouseleave="hideTip"
>
{{ Number(p.date.slice(8, 10)) }}
</div>
</div>
</div>
</div>
<!-- 浮动 tooltip -->
<div
v-if="tip"
class="float-tip"
:style="{ left: tip.x + 14 + 'px', top: tip.y + 14 + 'px' }"
>
<div v-for="(l, i) in tip.lines" :key="i" class="ft-line" :class="l.cls">{{ l.t }}</div>
</div>
</template>
</div>
</template>
<style scoped>
.calendar-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.view-head,
.toolbar,
.stat-strip,
.err,
.loading {
flex-shrink: 0;
}
.view-head {
display: flex;
align-items: center;
gap: 8px;
}
.view-head h2 {
font-size: 17px;
font-weight: 700;
}
.head-sub {
font-size: 12px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
}
.seg button {
border-radius: 0;
font-size: 12px;
padding: 4px 12px;
}
.seg button:first-child {
border-radius: var(--radius) 0 0 var(--radius);
}
.seg button:last-child {
border-radius: 0 var(--radius) var(--radius) 0;
margin-left: -1px;
}
.seg button.on {
border-color: var(--accent);
color: var(--accent);
background: rgba(74, 158, 255, 0.12);
position: relative;
z-index: 1;
}
.tb-spacer {
flex: 1;
}
.legend {
display: flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
color: var(--text-muted);
}
.lg-title {
color: var(--text-dim);
}
.sw {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
margin: 0 1px;
}
.sw.u1 {
background: rgba(239, 65, 70, 0.26);
}
.sw.u2 {
background: rgba(239, 65, 70, 0.5);
}
.sw.u3 {
background: rgba(239, 65, 70, 0.82);
}
.sw.d1 {
background: rgba(24, 160, 88, 0.26);
}
.sw.d2 {
background: rgba(24, 160, 88, 0.5);
}
.sw.d3 {
background: rgba(24, 160, 88, 0.82);
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.stat-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat-card {
padding: 10px 14px;
}
.stat-title {
font-size: 11.5px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-main {
font-size: 17px;
font-weight: 700;
}
.unit {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
padding-bottom: 20px;
}
.month-block {
padding: 10px 12px;
}
.m-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.m-name {
font-size: 12.5px;
font-weight: 700;
}
.m-pct {
font-size: 12px;
}
.m-days {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
min-height: 30px;
}
.day-cell {
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9.5px;
cursor: default;
transition: width 0.15s, height 0.15s;
}
.day-cell:hover {
filter: brightness(1.3);
outline: 1px solid var(--accent);
}
/* 浮动 tooltip(跟随鼠标;fixed 定位不受滚动容器裁剪) */
.float-tip {
position: fixed;
z-index: 1000;
pointer-events: none;
background: rgba(26, 29, 38, 0.96);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 7px 10px;
font-size: 11.5px;
line-height: 1.5;
color: var(--text);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
white-space: nowrap;
}
.ft-line.up {
color: var(--up);
font-weight: 700;
}
.ft-line.down {
color: var(--down);
font-weight: 700;
}
.ft-line.flat {
color: var(--text-muted);
}
@media (max-width: 1024px) {
.stat-strip {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
// 涨停生态(/limitup):连板天梯 / 首板 / 炸板 / 跌停,本地 vipdoc 日线离线回算。
// 数据日期取决于本机通达信客户端(页头明示);股票名称经 fetchSymbolName 懒加载补齐;
// 单击任意股票打开 StockDialog。120s 轮询 + 手动刷新。
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchLimitUpEcology, fetchSymbolName, formatError } from '../api'
import StockDialog from '../components/StockDialog.vue'
import type { LimitUpEntry } from '../types'
const resp = ref<Awaited<ReturnType<typeof fetchLimitUpEcology>> | null>(null)
const loading = ref(false)
const error = ref('')
const lastRefresh = ref('')
async function load() {
loading.value = resp.value === null
error.value = ''
try {
resp.value = await fetchLimitUpEcology()
lastRefresh.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
fillNames(allEntries.value)
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
// ── 名称懒加载(vipdoc 无名称,逐只经 MAC symbol-info 补齐,并发 8) ──────────
const names = ref<Record<string, string>>({})
function nameKey(e: LimitUpEntry): string {
return `${e.market}${e.code}`
}
function nameOf(e: LimitUpEntry): string {
return names.value[nameKey(e)] || e.code
}
async function fillNames(list: LimitUpEntry[]) {
const todo = list.filter((e) => names.value[nameKey(e)] === undefined)
let i = 0
const workers = Array.from({ length: 8 }, async () => {
while (i < todo.length) {
const e = todo[i++]
try {
names.value[nameKey(e)] = await fetchSymbolName(e.market, e.code)
} catch {
names.value[nameKey(e)] = ''
}
}
})
await Promise.all(workers)
}
// ── 派生视图模型 ──────────────────────────────────────────────────────────────
const allEntries = computed<LimitUpEntry[]>(() =>
resp.value ? [...resp.value.limit_up, ...resp.value.blown, ...resp.value.limit_down] : [],
)
const summary = computed(() => resp.value?.summary ?? null)
const dataDate = computed(() => {
const d = resp.value?.data_date ?? 0
if (!d) return ''
return `${String(d).slice(0, 4)}-${String(d).slice(4, 6)}-${String(d).slice(6, 8)}`
})
/** 连板天梯:streak ≥ 2 的分组(高度降序) */
const ladder = computed(() => {
const groups = new Map<number, LimitUpEntry[]>()
for (const e of resp.value?.limit_up ?? []) {
if (e.streak < 2) continue
const arr = groups.get(e.streak) ?? []
arr.push(e)
groups.set(e.streak, arr)
}
return [...groups.entries()].sort((a, b) => b[0] - a[0])
})
const firstBoard = computed<LimitUpEntry[]>(() =>
(resp.value?.limit_up ?? []).filter((e) => e.streak === 1),
)
const dataStale = computed(() => {
if (!resp.value?.data_date) return false
const d = resp.value.data_date
const today = new Date()
const todayInt = today.getFullYear() * 10000 + (today.getMonth() + 1) * 100 + today.getDate()
return d < todayInt
})
// ── 轮询与弹窗 ────────────────────────────────────────────────────────────────
let timer = 0
function isTradeSession(now = new Date()): boolean {
const day = now.getDay()
if (day === 0 || day === 6) return false
const m = now.getHours() * 60 + now.getMinutes()
return (m >= 555 && m <= 690) || (m >= 780 && m <= 905)
}
function tick() {
if (document.hidden || !isTradeSession()) return
load()
}
onMounted(() => {
load()
timer = window.setInterval(tick, 120_000)
})
onBeforeUnmount(() => window.clearInterval(timer))
const stockDialog = ref<{ market: string; code: string; name?: string } | null>(null)
function openStock(e: LimitUpEntry) {
stockDialog.value = { market: e.market, code: e.code, name: nameOf(e) }
}
function pctClass(pct: number): string {
return pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat'
}
</script>
<template>
<div class="limitup-view">
<div class="view-head">
<h2>涨停生态</h2>
<span v-if="dataDate" class="dim head-sub">数据日期 {{ dataDate }}</span>
<span v-if="dataStale" class="stale-badge">数据非今日 · 通达信客户端下载数据后更新</span>
<span class="tb-spacer"></span>
<span v-if="lastRefresh" class="dim refresh-ts">{{ lastRefresh }}</span>
<button class="manual-refresh" @click="load"> 刷新</button>
</div>
<div v-if="error" class="err card">
加载失败{{ error }}
<button @click="load">重试</button>
</div>
<div v-else-if="loading" class="loading">扫描本地 vipdoc 日线全市场约需数秒</div>
<template v-else-if="resp">
<div v-if="resp.total === 0" class="err card">
未检测到本地通达信 vipdoc 日线数据请确认本机已安装通达信且
<code> vipdoc/{sh,sz}/lday/*.day </code> 存在自动检测失败时可在 CLI 侧指定路径
</div>
<template v-else>
<!-- 统计卡条 -->
<div class="stat-strip">
<div class="stat-card card">
<div class="stat-title">涨停</div>
<div class="stat-main up">{{ summary?.limit_up_count ?? '-' }}</div>
</div>
<div class="stat-card card">
<div class="stat-title">跌停</div>
<div class="stat-main down">{{ summary?.limit_down_count ?? '-' }}</div>
</div>
<div class="stat-card card">
<div class="stat-title">炸板</div>
<div class="stat-main">{{ summary?.blown_count ?? '-' }}</div>
<div class="stat-sub dim">
炸板率 {{ summary?.blown_rate === null || summary?.blown_rate === undefined ? '-' : summary.blown_rate + '%' }}
</div>
</div>
<div class="stat-card card">
<div class="stat-title">最高连板</div>
<div class="stat-main up">{{ summary?.max_streak ?? '-' }} <span class="unit"></span></div>
<div class="stat-sub dim">
首板 {{ summary?.first_board ?? '-' }} · 二板 {{ summary?.second_board ?? '-' }} · 3板以上 {{ summary?.plus3 ?? '-' }}
</div>
</div>
</div>
<!-- 连板天梯 -->
<div v-if="ladder.length > 0" class="section">
<div class="sec-title">连板天梯</div>
<div v-for="[height, entries] in ladder" :key="height" class="ladder-row card">
<div class="l-height" :class="{ high: height >= 5 }">{{ height }}</div>
<div class="l-chips">
<button v-for="e in entries" :key="e.code" class="chip-s" @click="openStock(e)">
<span class="c-name">{{ nameOf(e) }}<span v-if="e.st" class="st-mark">ST?</span></span>
<span class="mono c-pct" :class="pctClass(e.pct)">{{ e.pct.toFixed(1) }}%</span>
</button>
</div>
</div>
</div>
<!-- 首板 -->
<div class="section">
<div class="sec-title">首板{{ firstBoard.length }}</div>
<div class="card flat-card">
<div class="l-chips">
<button v-for="e in firstBoard" :key="e.code" class="chip-s" @click="openStock(e)">
<span class="c-name">{{ nameOf(e) }}<span v-if="e.st" class="st-mark">ST?</span></span>
<span class="mono c-pct" :class="pctClass(e.pct)">{{ e.pct.toFixed(1) }}%</span>
</button>
<span v-if="firstBoard.length === 0" class="dim">今日无首板</span>
</div>
</div>
</div>
<!-- 炸板 / 跌停 -->
<div class="two-col">
<div class="section">
<div class="sec-title">炸板{{ resp.blown.length }}· 曾触涨停未封住</div>
<div class="card flat-card">
<div class="l-chips">
<button v-for="e in resp.blown" :key="e.code" class="chip-s blown" @click="openStock(e)">
<span class="c-name">{{ nameOf(e) }}</span>
<span class="mono c-pct" :class="pctClass(e.pct)">{{ e.pct.toFixed(1) }}%</span>
</button>
<span v-if="resp.blown.length === 0" class="dim"></span>
</div>
</div>
</div>
<div class="section">
<div class="sec-title">跌停{{ resp.limit_down.length }}</div>
<div class="card flat-card">
<div class="l-chips">
<button v-for="e in resp.limit_down" :key="e.code" class="chip-s downed" @click="openStock(e)">
<span class="c-name">{{ nameOf(e) }}</span>
<span class="mono c-pct down">{{ e.pct.toFixed(1) }}%</span>
</button>
<span v-if="resp.limit_down.length === 0" class="dim"></span>
</div>
</div>
</div>
</div>
</template>
</template>
<StockDialog
v-if="stockDialog"
:market="stockDialog.market"
:code="stockDialog.code"
:name="stockDialog.name"
@close="stockDialog = null"
/>
</div>
</template>
<style scoped>
.limitup-view {
height: 100%;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.view-head,
.stat-strip,
.err,
.loading,
.section {
flex-shrink: 0;
}
.view-head {
display: flex;
align-items: center;
gap: 8px;
}
.view-head h2 {
font-size: 17px;
font-weight: 700;
}
.head-sub {
font-size: 12px;
}
.stale-badge {
font-size: 11px;
color: var(--warn);
border: 1px solid var(--warn);
border-radius: 3px;
padding: 0 6px;
}
.tb-spacer {
flex: 1;
}
.refresh-ts {
font-family: var(--font-mono);
font-size: 11.5px;
}
.manual-refresh {
font-size: 12px;
padding: 4px 10px;
}
.err {
color: var(--up);
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.err code {
color: var(--text-muted);
}
.loading {
padding: 40px 0;
text-align: center;
color: var(--text-dim);
}
.stat-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.stat-card {
padding: 10px 14px;
}
.stat-title {
font-size: 11.5px;
color: var(--text-muted);
margin-bottom: 4px;
}
.stat-main {
font-size: 17px;
font-weight: 700;
}
.unit {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.stat-sub {
font-size: 11.5px;
margin-top: 2px;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
}
.sec-title {
font-size: 12.5px;
font-weight: 600;
color: var(--text-muted);
}
.ladder-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
}
.l-height {
flex-shrink: 0;
width: 52px;
text-align: center;
font-weight: 700;
font-size: 14px;
color: var(--up);
border: 1px solid var(--up);
border-radius: var(--radius);
padding: 3px 0;
}
.l-height.high {
color: #fff;
background: var(--up);
}
.l-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip-s {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 12px;
border-radius: 999px;
background: rgba(239, 65, 70, 0.08);
border: 1px solid var(--border);
cursor: pointer;
}
.chip-s:hover {
border-color: var(--up);
}
.chip-s.blown {
background: rgba(255, 165, 0, 0.08);
}
.chip-s.blown:hover {
border-color: var(--warn);
}
.chip-s.downed {
background: rgba(24, 160, 88, 0.08);
}
.chip-s.downed:hover {
border-color: var(--down);
}
.c-name {
font-weight: 600;
}
.st-mark {
font-size: 9.5px;
color: var(--warn);
margin-left: 3px;
}
.c-pct {
font-size: 11.5px;
}
.flat-card {
padding: 10px 12px;
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
@media (max-width: 1024px) {
.stat-strip,
.two-col {
grid-template-columns: 1fr;
}
}
</style>