diff --git a/docs/board-overview-design.md b/docs/board-overview-design.md new file mode 100644 index 0000000..9592c79 --- /dev/null +++ b/docs/board-overview-design.md @@ -0,0 +1,273 @@ +# 行业总览 / 概念总览 页面设计(v1 草案) + +> 目标:在 WebUI 左侧导航新增「行业总览」「概念总览」两个栏目,提供全部行业/概念板块的行情总览、 +> 实用统计与板块异动监控;点击板块复用首页 `BoardDialog` 弹窗(左:分时/日K,右:成分股列表)。 + +--- + +## 1. 现状盘点(设计依据,全部为已验证的现成能力) + +### 1.1 后端可复用接口(`src/easy_tdx/web/routers/board_mac.py`,前缀 `/api/v1`) + +| 端点 | 能给什么 | 成本 | +|---|---|---| +| `GET /board-mac/list?board_type=HY\|HY2\|GN\|FG\|DQ&sort_column=…` | 全量板块列表:`market, code(881xxx/885xxx), name, price, pre_close, sort_value, 领涨股6字段(symbol_*)`。行业(HY)约 86 个、概念(GN)约 270–500 个;每页 150,自动翻页。排序键支持 `SPEED / CHANGE_3D / 5D / 10D / 20D / 60D / YTD` | 低(1–2 页/次) | +| `GET /board-mac/members?board_symbol=881001&count=120&sort_type=CHANGE_PCT` | 板块成分股 + 全量报价(自动 80/页翻页),弹窗已在用 | 低 | +| `GET /board-mac/summary?board_symbol=…` | `member_count, amount, vol, main_net_amount, main_net_3d/5d, up_count, down_count, members` | 高(每板块一次,概念 300+ 不可全量) | +| `GET /board-mac/ranking?board_type=…&sort_by=main_net_amount&top_n=10` | 资金/成交/涨跌幅榜(内部逐板块 summary) | 中,必须限 `top_n` | +| `GET /board-mac/change-ranking?board_type=…&days=20` | N 日区间涨幅榜(走板块指数日K) | 低 | +| `GET /market/stat` | 全市场上涨/下跌/平盘/涨停/跌停家数(880005/880001/880006) | 低 | +| `GET /minute` `/bars`(SH+88xxxx) | 板块指数分时/日K,弹窗已在用;客户端已内置换机容错 | 低 | +| `GET /market/session` | 交易时段判定,用于启停自动刷新 | 低 | + +**口径注意(Issue #53)**:`sort_column=CHANGE_PCT` 时 `sort_value` 恒为 0;当日涨跌幅必须由 +`price / pre_close - 1` 自行计算。其余排序键的 `sort_value` 即该指标值。 + +### 1.2 前端可复用资产(`web-ui/src/`) + +| 资产 | 位置 | 复用方式 | +|---|---|---| +| `BoardDialog.vue` 板块弹窗 | components | **原样复用**:头部 SSE 实时报价+加/移自选;左侧分时(`IntradayChart`)/日K(`StockKline`) tab;右侧成分股涨跌榜(用户所称"下拉框"),点成分股可再叠 `StockDialog` | +| `StockDialog.vue` 个股弹窗 | components | 原样复用(成分股点击穿透) | +| 自选股体系 | stores + `watchlist.py` | 已支持板块(WatchlistView 板块行开 BoardDialog),板块行可直接加"加自选"星标 | +| ECharts 封装 | `echarts-setup.ts` | 已注册 Bar/Heatmap 等;红涨绿跌 `UP_COLOR='#ef4146'` / `DOWN_COLOR='#18a058'` | +| API 封装 | `api.ts`(`BASE='/api/v1'` + 统一错误解析) | 新增 2–3 个 fetch 函数 | +| 导航/路由 | `App.vue:26-43` + `router.ts:21-40` | 「行情」分组下加两个 RouterLink + 两条路由 | + +**结论:后端仅缺一个"多指标合并"端点,前端为本需求的全部工作量主体。** + +--- + +## 2. 总体设计 + +``` +路由与导航(行情分组) + /industries 行业总览 ─┐ + /concepts 概念总览 ─┴─ 共用 BoardOverviewView.vue,路由 props 区分 board_type + 行业页额外提供 HY(一级)/HY2(二级) 切换 +``` + +新组件(建议 4 个,均放 `components/`): + +- `BoardStatStrip.vue` — 顶部统计条(板块广度 + 全市场涨跌家数 + 涨跌幅分布直方图) +- `BoardTiles.vue` — 热力图模式(CSS Grid 色块,不用 ECharts,500 块内 DOM 足够快) +- `BoardRankRail.vue` — 右侧栏:涨幅/跌幅/涨速(异动) 榜 + 翻红翻绿时间线 + 资金榜(懒加载) +- `BoardOverviewView.vue` — 主视图:统计条 + [热力图|表格] 切换 + 右栏 + 弹窗编排 + +主表格直接内嵌在 View 中(与 DashboardView 同风格),不再抽组件。 + +--- + +## 3. 功能设计 + +### 3.1 页面信息架构(自上而下) + +1. **统计条**:一眼判断今天板块层面强弱 +2. **工具行**:视图切换 / 排序 / 搜索 / 自动刷新开关 +3. **主区**:热力图(默认)或 全量表 +4. **右栏**:榜单 + 异动(热力图模式下承担"看榜"职责,表格模式下可折叠) + +### 3.2 统计条(BoardStatStrip) + +数据源:1 次 `/board-mac/list`(默认排序)+ 1 次 `/market/stat`,全部前端聚合。 + +| 指标 | 计算 | 说明 | +|---|---|---| +| 板块总数 / 上涨 / 下跌 / 平盘 | 按 `change_pct` 正负统计本类型板块 | 如「86 个板块 · 52▲ / 30▼ / 4—」 | +| 板块涨幅中位数 | median(change_pct) | 比均值抗极值 | +| 全市场涨停/跌停家数 | `/market/stat`(×10 还原) | 判断赚钱效应 | +| 涨幅分布直方图 | change_pct 分桶(±1% 一档,截断 ±5%) 小柱图 | ECharts Bar,点击桶可过滤主区(P2) | + +### 3.3 主区一:热力图模式(默认) + +- **布局**:CSS Grid 自适应列(tile 最小宽 ~104px),按当前排序降序排列。 +- **着色**:红涨绿跌,透明度随 `|change_pct|` 分 5 档增强(复用 UP/DOWN_COLOR)。 +- **tile 内容**:板块名(超长省略)+ 当日涨跌幅;≥140px 宽度时追加领涨股名+其涨幅。 +- **交互**:hover 显示 tooltip(代码/价格/涨跌幅/领涨股);**单击打开 BoardDialog**; + 右键或 tile 上的 ★ 加自选(P2)。 +- **概念页适配**:500 tile 时顶部加搜索框联动高亮/过滤,tile 缩小至 ~88px。 + +> 为什么不用 ECharts heatmap/treemap:treemap 未注册、treemap 按市值定容缺少廉价数据源 +> (逐板块 summary 不可行),等宽色块已满足"扫一眼谁强谁弱",且交互实现最简单。 + +### 3.4 主区二:表格模式(全量、可排序) + +列定义(行业页): + +| 列 | 来源 | 备注 | +|---|---|---| +| 名称 / 代码 | list | 点击行 → BoardDialog | +| 最新价 | list.price | | +| **涨跌幅** | price/pre_close | 默认排序列,红绿色阶背景 | +| 涨速 | list(sort_column=SPEED).sort_value | 盘中异动核心 | +| 3日 / 5日 / 20日 / YTD | list(对应 sort_column).sort_value | 见 3.7 合并策略 | +| 轮动标签 | 前端规则(3.6) | 「反弹 / 走强 / 回调 / 补跌」 | +| 领涨股(+涨幅) | list.symbol_* | 点领涨股 → StockDialog(stopPropagation) | +| 主力净额 | `/board-mac/ranking` 合并(懒加载,见 3.7) | 可空 | +| ★ | 自选 | 复用 watchlist API(P2) | + +表格排序纯前端(数据已全量在内存),不再发请求。行业 86 行、概念 ≤500 行,无需虚拟滚动。 + +### 3.5 右栏:榜单 + 板块异动(BoardRankRail) + +1. **涨幅榜 Top10 / 跌幅榜 Top10**:前端内存排序,随刷新同步。 +2. **涨速榜 Top10**("板块异动"主入口):`sort_column=SPEED` 的 `sort_value`, + `|speed| ≥ 0.5%/5min` 的条目加闪烁高亮;点击直达 BoardDialog。 +3. **翻红/翻绿时间线**:前端对相邻两次快照做 diff,`change_pct` 由负转正记「翻红」、 + 正转负记「翻绿」,按时间倒序展示(保留最近 30 条)。这是最低成本的"板块轮动监控"。 +4. **主力资金榜 Top10**(懒加载 tab):首次展开才调 `/board-mac/ranking?sort_by=main_net_amount&top_n=10`, + 每 5 分钟刷新一次(接口较贵)。 +5. **轮动信号卡**(P2):超跌反弹聚集度(当日↑ 且 20日↓ 的板块数)等汇总提示。 + +### 3.6 轮动标签规则(表格列 + tile 角标) + +前端基于已合并的多周期涨幅做简单规则标注(阈值可调,初版取 1%): + +| 标签 | 条件 | 含义 | +|---|---|---| +| 超跌反弹 | 当日 > +1% 且 20日 < -3% | 前期弱势,今日异动 | +| 趋势走强 | 当日>0 且 3日>0 且 5日>0 | 多周期共振向上 | +| 高位回调 | 当日 < -1% 且 20日 > +5% | 强势板块补跌 | +| 趋势走弱 | 当日<0 且 3日<0 且 5日<0 | 多周期共振向下 | + +规则透明、可解释,不引入额外请求。 + +### 3.7 数据获取与刷新策略 + +**合并请求(关键设计)**:页面一次刷新需要 6 种排序的 list(当日、涨速、3日、5日、20日、YTD)。 +前端直连将产生 6–12 个 MAC 请求/次。因此新增一个后端聚合端点: + +``` +GET /api/v1/board-mac/overview?board_type=HY&metrics=speed,3d,5d,20d,ytd +→ { + board_type: "HY", + ts: 1725400000, + rows: [{ + market: 1, code: "881106", name: "种植业", + price: 1039.93, pre_close: 1031.20, change_pct: 0.846, // 服务端算好 + speed: 0.32, chg_3d: 2.1, chg_5d: -0.8, chg_20d: 6.3, chg_ytd: 14.2, + leader_code: "600xxx", leader_name: "xxx", leader_change_pct: 10.02 + }, ...] + } +``` + +- 服务端并发拉取各 sort_column 的 list 后按 code 归并,任一指标缺失置 null(不阻塞整体)。 +- **服务端缓存 15s**(TTL),多人/多页共享,保护 MAC 服务器。 +- 前端 30s 轮询(仅交易时段,`/market/session` 判定),手动 ⟳ 随时可用;页面不可见时暂停 + (`document.visibilitychange`)。 +- 降级:`/board-mac/overview` 不可用时前端回退为直连 6 次 `/board-mac/list` 并归并(同口径函数复用)。 + +**懒加载项**:主力资金榜(首次展开)、板块弹窗内容(点击时,BoardDialog 自理)。 + +### 3.8 行业页 vs 概念页差异 + +| 维度 | 行业总览 (`HY`) | 概念总览 (`GN`) | +|---|---|---| +| 板块数量级 | ~86 | ~270–500 | +| 二级切换 | 一级(HY) / 二级(HY2) toggle | 无 | +| 搜索框 | 有(按名/代码) | **显著位置**,支持拼音/关键词过滤 | +| 热力图 tile | 104px | 88px | +| 其余(统计条/榜单/异动/弹窗/刷新) | 完全一致 | 完全一致 | + +同一组件路由 props 驱动,后续 `FG`(风格)/`DQ`(地域) 仅需在 App.vue 加入口 + 传 props。 + +### 3.9 板块弹窗(点击板块) + +**原样复用 `BoardDialog.vue`**(与首页体验一致): + +- 头部:板块名 + SSE 实时报价 + 加/移自选 + 关闭 +- 左侧:分时(`/minute`,SH+881xxx/885xxx)/ 日K(`/bars`,MA/MACD/KDJ 等指标前端本地算) +- 右侧 330px 成分股涨跌榜:`/board-mac/members`,支持点击成分股叠加 `StockDialog`(五档盘口+K线) + +增量增强(P2,可选):弹窗右侧顶部补一行成分股广度(`up_count/down_count`,来自 summary,单板块成本可接受)。 + +--- + +## 4. UI 布局设计 + +### 4.1 线框 + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ 行业总览 86个板块 52▲ 30▼ 4— │ 中位 +0.84% │ 涨停23 跌停5 │ ▂▄▆█▆▄▂ 分布图 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ [热力图|表格] 排序[涨跌幅▾] [搜索____] HY/HY2 ⟳30s[ON] 上次刷新 14:32:05 │ +├───────────────────────────────────────────────┬────────────────────────────┤ +│ │ 涨幅榜 Top10 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌───────┐ │ 1 种植业 +3.21% │ +│ │种植业 │ │渔业 │ │煤炭开采│ │... │ │ 2 ... │ +│ │ +3.21% │ │ +2.88% │ │ +2.10% │ │ │ │ 跌幅榜 Top10 │ +│ │领涨 xx │ │ │ │ │ │ │ │ 异动(涨速) Top10 ⚡闪烁 │ +│ └────────┘ └────────┘ └────────┘ └───────┘ │ … │ +│ ┌────────┐ ┌────────┐ ... │ ────────────────────── │ +│ │概念名… │ │ │ │ 翻红/翻绿 时间线 │ +│ └────────┘ └────────┘ │ 14:31 翻红 生物疫苗 │ +│ │ 14:28 翻绿 房地产开发 │ +│ (表格模式:3.4 节列定义,同区域替换) │ [资金榜 Top10 ▸懒加载] │ +├───────────────────────────────────────────────┴────────────────────────────┤ +│ 单击板块 → BoardDialog 弹窗(左 分时/日K | 右 成分股涨跌榜 → StockDialog) │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 交互与视觉细则 + +- **配色**:沿用全局主题变量与 `UP_COLOR/DOWN_COLOR`;色阶透明度 5 档:0–0.5% / 0.5–1 / 1–2 / 2–3 / >3%。 +- **弹窗层级**:与首页一致(BoardDialog teleport to body,1280px 遮罩弹窗);成分股→StockDialog 叠加。 +- **空/错误态**:接口失败 → 顶部错误条(api.ts 统一 `{error,detail}` 解析)+ 重试按钮; + 非交易时段显示"休市中,显示最近收盘数据"徽标。 +- **响应式**:右栏 ≥1280px 常驻,<1280px 折叠为顶部横向 chips;主区 tile 自动换列。 +- **localStorage 偏好**:视图模式 / 排序列 / 自动刷新开关(P2)。 + +--- + +## 5. 改动清单(实施落点) + +### 后端(仅 1 个新端点) + +| 文件 | 改动 | +|---|---| +| `src/easy_tdx/web/routers/board_mac.py` | 新增 `GET /board-mac/overview`:并发聚合多 sort_column 的 `MacClient.get_board_list`,计算 change_pct,按 code 归并;模块级 TTL 缓存(15s) | +| `tests/unit/test_board_mac_overview.py` | 单测:归并正确性 / 缺失指标置 null / 缓存命中(mock AsyncMacClient) | + +### 前端 + +| 文件 | 改动 | +|---|---| +| `web-ui/src/App.vue` | 行情分组 +2 RouterLink(行业总览 `/industries`、概念总览 `/concepts`) | +| `web-ui/src/router.ts` | 两条路由 → `BoardOverviewView`,`props: { boardType: 'HY' \| 'GN' }` | +| `web-ui/src/types.ts` | `BoardOverviewRow / BoardOverviewResp / BoardRankRow` 类型 | +| `web-ui/src/api.ts` | `fetchBoardOverview(boardType, metrics)`;`fetchBoardRanking` 薄封装(复用现有错误处理) | +| `web-ui/src/views/BoardOverviewView.vue` | 主视图(统计条编排 / 工具行 / 热力图⇄表格 / 右栏 / 弹窗编排 / 轮询与 diff) | +| `web-ui/src/components/BoardStatStrip.vue` | 统计条 + 分布直方图 | +| `web-ui/src/components/BoardTiles.vue` | 热力图模式 | +| `web-ui/src/components/BoardRankRail.vue` | 榜单 + 翻红翻绿时间线 + 资金懒加载 | + +--- + +## 6. 边界与风险 + +1. **MAC 服务器可用性**:`get_board_list` 依赖 MAC host(`get_mac_hosts`);客户端已有故障转移, + overview 端点失败时前端展示错误条并可回退直连。 +2. **板块指数 K 线缺数据**:部分服务器不给 88xxxx 日K——仅影响 `change-ranking`(本设计未依赖它做主指标, + 多周期涨幅全部来自 board list),弹窗日K沿用现有换机容错。 +3. **概念数量大**:500 个 tile / 行的渲染无压力;`/board-mac/ranking`(逐板块 summary)只允许 top_n 懒加载。 +4. **盘中口径**:当日涨跌幅一律 `price/pre_close-1`,禁止使用 CHANGE_PCT 排序的 sort_value(恒 0,Issue #53)。 +5. **刷新风暴**:TTL 缓存 + visibilitychange 暂停 + 30s 间隔,三重保护。 + +## 7. 分期计划 + +| 期 | 内容 | 预估 | +|---|---|---| +| **P1(MVP)** | 导航+路由、BoardOverviewView(热力图+表格+搜索)、统计条、右栏涨幅/跌幅/涨速榜、翻红翻绿时间线、30s 自动刷新、BoardDialog 复用、`/board-mac/overview` 端点+缓存+单测 | 1.5–2 天 | +| **P2** | 主力资金榜懒加载、轮动标签、tile ★加自选、直方图点击过滤、localStorage 偏好、HY2 切换打磨、休市徽标 | 1 天 | +| **P3(远期)** | FG/DQ 入口、板块间对比(多选叠加K线)、"点击板块过滤全市场个股"联动、板块轮动 AI 解读 | 另立项 | + +## 8. 验收清单(P1) + +- [ ] 左侧导航出现两个新栏目,路由/高亮/刷新兜底均正常 +- [ ] 行业页展示全部 ~86 个行业(HY,且可切 HY2),概念页展示全部概念(≈300+),数量与 `/board-mac/list` 原始返回一致 +- [ ] 当日涨跌幅与通达信客户端同口径(price/pre_close),排序、搜索、色阶正确 +- [ ] 单击板块 tile/行 → BoardDialog:分时、日K、成分股榜、成分股→StockDialog 全链路可用 +- [ ] 交易时段 30s 自动刷新且网络面板只有 1 个 overview 请求;休市自动停止 +- [ ] 涨速榜闪烁、翻红/翻绿时间线在盘中可见事件产生 +- [ ] 断开 MAC 服务器时出现错误条 + 重试,页面不白屏 diff --git a/src/easy_tdx/web/routers/board_mac.py b/src/easy_tdx/web/routers/board_mac.py index 9ff2c29..846cd0a 100644 --- a/src/easy_tdx/web/routers/board_mac.py +++ b/src/easy_tdx/web/routers/board_mac.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import time from typing import Any from fastapi import APIRouter, Depends, Query @@ -18,6 +20,24 @@ from easy_tdx.web.schemas import DataFrameResponse, DictResponse router = APIRouter(tags=["board-mac"]) +# overview 端点:metrics 参数名 → 返回行字段名(值来自对应排序键的 sort_value) +_OVERVIEW_METRIC_FIELDS: dict[str, str] = { + "SPEED": "speed", + "CHANGE_3D": "chg_3d", + "CHANGE_5D": "chg_5d", + "CHANGE_10D": "chg_10d", + "CHANGE_20D": "chg_20d", + "CHANGE_60D": "chg_60d", + "YTD": "chg_ytd", +} +_OVERVIEW_TTL = 15.0 +# (board_type, metrics) -> (monotonic 截止时间, payload)。无锁:并发重复拉取 +# 无害(AsyncMacClient 连接内本就串行),省去跨事件循环的锁生命周期问题。 +_overview_cache: dict[tuple[str, tuple[str, ...]], tuple[float, dict[str, Any]]] = {} + +# 可在单测中 monkeypatch 以控制 TTL 判定 +_now = time.monotonic + def _df_resp(df: Any) -> DataFrameResponse: return DataFrameResponse.from_dataframe(df) @@ -127,3 +147,84 @@ async def board_change_ranking( ascending=ascending, ) return _df_resp(df) + + +@router.get("/board-mac/overview", response_model=DictResponse) +async def board_overview( + board_type: str = Query("HY", description="板块类型: HY/HY2/GN/FG/DQ"), + metrics: str = Query( + "SPEED,CHANGE_3D,CHANGE_5D,CHANGE_20D,YTD", + description=( + "附加指标(逗号分隔,取自各排序键的 sort_value): " + "SPEED/CHANGE_3D/CHANGE_5D/CHANGE_10D/CHANGE_20D/CHANGE_60D/YTD" + ), + ), + count: int = Query(2000, ge=1, le=20000), + client: Any = Depends(get_mac_client), +) -> DictResponse: + """板块总览:一次返回全部板块的当日涨跌幅 + 领涨股 + 多周期指标。 + + 以默认(涨跌幅)排序的板块列表为基表归并各 metrics 排序列的 sort_value, + 避免前端直连 N 次 ``/board-mac/list``。结果服务端缓存 15s。 + 当日涨跌幅按 price/pre_close-1 计算(CHANGE_PCT 的 sort_value 恒 0)。 + """ + bt = board_type_from_str(board_type) + sort_names = [m.strip().upper() for m in metrics.split(",") if m.strip()] + invalid = [m for m in sort_names if m not in _OVERVIEW_METRIC_FIELDS] + if invalid: + valid = ", ".join(_OVERVIEW_METRIC_FIELDS) + raise ValueError(f"无效指标 '{','.join(invalid)}',可选值: {valid}") + + cache_key = (bt.name, tuple(sort_names)) + cached = _overview_cache.get(cache_key) + if cached is not None and _now() < cached[0]: + return DictResponse.from_dict(cached[1]) + + results = await asyncio.gather( + client.get_board_list(board_type=bt, count=count), + *( + client.get_board_list(board_type=bt, count=count, sort_column=board_sort_from_str(name)) + for name in sort_names + ), + ) + base_df, metric_dfs = results[0], list(results[1:]) + + metric_values: dict[str, dict[str, float]] = {} + for name, df in zip(sort_names, metric_dfs): + field = _OVERVIEW_METRIC_FIELDS[name] + col: dict[str, float] = {} + if df is not None and not df.empty: + for code, value in zip(df["code"], df["sort_value"]): + col[str(code)] = float(value) + metric_values[field] = col + + rows: list[dict[str, Any]] = [] + if base_df is not None and not base_df.empty: + for record in base_df.to_dict(orient="records"): + price = float(record["price"]) + pre_close = float(record["pre_close"]) + sym_price = float(record.get("symbol_price") or 0.0) + sym_pre_close = float(record.get("symbol_pre_close") or 0.0) + row: dict[str, Any] = { + "market": int(record["market"]), + "code": str(record["code"]), + "name": str(record["name"]), + "price": price, + "pre_close": pre_close, + "change_pct": round((price / pre_close - 1) * 100, 3) if pre_close else None, + "leader_code": str(record.get("symbol_code") or ""), + "leader_name": str(record.get("symbol_name") or ""), + "leader_change_pct": ( + round((sym_price / sym_pre_close - 1) * 100, 3) if sym_pre_close else None + ), + } + for field, values in metric_values.items(): + row[field] = values.get(str(record["code"])) + # 未请求的指标字段补 null,保证行结构稳定(前端类型固定) + for field in _OVERVIEW_METRIC_FIELDS.values(): + row.setdefault(field, None) + rows.append(row) + + 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) diff --git a/tests/unit/test_board_mac_overview.py b/tests/unit/test_board_mac_overview.py new file mode 100644 index 0000000..9dd433e --- /dev/null +++ b/tests/unit/test_board_mac_overview.py @@ -0,0 +1,221 @@ +"""/board-mac/overview 聚合端点单测(离线,mock MAC 客户端)。 + +覆盖:多排序键归并、当日涨跌幅口径(price/pre_close-1)、缺失指标置 null、 +TTL 缓存命中、无效指标 400、空列表。 +""" + +from __future__ import annotations + +import pytest + + +def _board_df(rows: list[dict]) -> object: + import pandas as pd + + return pd.DataFrame(rows) + + +def _board_row( + code: str, + name: str, + price: float, + pre_close: float, + sort_value: float = 0.0, + leader: tuple[str, str, float, float] | None = None, +) -> dict: + leader = leader or ("600000", "领涨股", price * 1.05, price) + return { + "market": 1, + "code": code, + "name": name, + "price": price, + "sort_value": sort_value, + "pre_close": pre_close, + "symbol_market": 1, + "symbol_code": leader[0], + "symbol_name": leader[1], + "symbol_price": leader[2], + "symbol_pre_close": leader[3], + } + + +class _FakeOverviewMacClient: + """按 BoardSortColumn 名称返回预置 DataFrame 的替身客户端。""" + + def __init__(self, frames: dict[str, object]): + import pandas as pd + + self._frames = frames + self._empty = pd.DataFrame() + self.calls: list[str] = [] + + async def get_board_list(self, board_type=None, count=10000, sort_column=None): + name = getattr(sort_column, "name", None) or "CHANGE_PCT" # 与真客户端默认一致 + self.calls.append(name) + return self._frames.get(name, self._empty) + + +def _overview_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(): + from easy_tdx.web.routers import board_mac + + board_mac._overview_cache.clear() + yield + board_mac._overview_cache.clear() + + +def _get_overview(client, board_type="HY", metrics="SPEED,CHANGE_20D"): + return client.get( + "/api/v1/board-mac/overview", + params={"board_type": board_type, "metrics": metrics}, + ) + + +def test_overview_merge_and_change_pct(): + """基表 + 各排序键归并;涨跌幅按 price/pre_close-1 计算。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + frames = { + "CHANGE_PCT": _board_df( + [ + _board_row( + "881106", "种植业", 1039.93, 1031.20, leader=("600100", "A股票", 11.0, 10.0) + ), + _board_row( + "881101", "煤炭开采", 2200.0, 2244.0, leader=("600200", "B股票", 9.5, 10.0) + ), + ] + ), + "SPEED": _board_df( + [ + _board_row("881106", "种植业", 1039.93, 1031.20, sort_value=0.52), + _board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-0.11), + ] + ), + "CHANGE_20D": _board_df( + [ + _board_row("881106", "种植业", 1039.93, 1031.20, sort_value=6.3), + _board_row("881101", "煤炭开采", 2200.0, 2244.0, sort_value=-2.4), + ] + ), + } + fake = _FakeOverviewMacClient(frames) + with TestClient(_overview_app(fake)) as client: + resp = _get_overview(client) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["board_type"] == "HY" + assert data["count"] == 2 + + rows = {r["code"]: r for r in data["rows"]} + hy = rows["881106"] + # 1039.93/1031.20-1 = +0.8465% + assert hy["change_pct"] == pytest.approx(0.846, abs=0.01) + assert hy["speed"] == 0.52 + assert hy["chg_20d"] == 6.3 + assert hy["chg_5d"] is None # 未请求的指标置 null + assert hy["leader_name"] == "A股票" + assert hy["leader_change_pct"] == pytest.approx(10.0, abs=0.01) + + mt = rows["881101"] + assert mt["change_pct"] == pytest.approx(-1.961, abs=0.01) + assert mt["leader_change_pct"] == pytest.approx(-5.0, abs=0.01) + + # 基表(涨跌幅排序) + SPEED + CHANGE_20D 共 3 次调用 + assert sorted(fake.calls) == ["CHANGE_20D", "CHANGE_PCT", "SPEED"] + + +def test_overview_cache_hit_within_ttl(): + """TTL 内命中缓存,不再触发 MAC 调用;时间推进后重新拉取。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + from easy_tdx.web.routers import board_mac + + fake = _FakeOverviewMacClient( + {"CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)])} + ) + clock = {"t": 100.0} + board_mac._now = lambda: clock["t"] # type: ignore[assignment] + try: + with TestClient(_overview_app(fake)) as client: + _get_overview(client) + _get_overview(client) + assert fake.calls.count("CHANGE_PCT") == 1 + + clock["t"] += board_mac._OVERVIEW_TTL + 1 + with TestClient(_overview_app(fake)) as client: + _get_overview(client) + assert fake.calls.count("CHANGE_PCT") == 2 + finally: + board_mac._now = board_mac.time.monotonic # type: ignore[assignment] + + +def test_overview_cache_key_separates_board_type(): + """不同 board_type 的缓存相互独立。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + frames = { + "CHANGE_PCT": _board_df([_board_row("881001", "软件服务", 5000.0, 4900.0)]), + "GN": None, + } + fake = _FakeOverviewMacClient(frames) + with TestClient(_overview_app(fake)) as client: + _get_overview(client, board_type="HY") + _get_overview(client, board_type="GN") + assert fake.calls.count("CHANGE_PCT") == 2 + + +def test_overview_invalid_metric_returns_400(): + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeOverviewMacClient({}) + with TestClient(_overview_app(fake)) as client: + resp = _get_overview(client, metrics="SPEED,NOT_A_METRIC") + assert resp.status_code == 400 + assert "NOT_A_METRIC" in resp.json()["detail"] + + +def test_overview_empty_base_list(): + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + fake = _FakeOverviewMacClient({}) + with TestClient(_overview_app(fake)) as client: + resp = _get_overview(client) + assert resp.status_code == 200 + data = resp.json()["data"] + assert data["count"] == 0 + assert data["rows"] == [] + + +def test_overview_zero_pre_close_change_pct_null(): + """pre_close 为 0(无行情)时涨跌幅为 null 而非异常/除零。""" + pytest.importorskip("fastapi") + from fastapi.testclient import TestClient + + frames = {"CHANGE_PCT": _board_df([_board_row("881999", "空数据板块", 0.0, 0.0)])} + fake = _FakeOverviewMacClient(frames) + with TestClient(_overview_app(fake)) as client: + resp = _get_overview(client) + assert resp.status_code == 200 + row = resp.json()["data"]["rows"][0] + assert row["change_pct"] is None + assert row["leader_change_pct"] is None diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index bf1baef..246368b 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -26,6 +26,8 @@ const sseLabel: Record = {