Merge pull request 'Develop' (#22) from develop into main
Deploy Production / deploy (push) Successful in 33s

Reviewed-on: sakibcc/zhixing-system#22
This commit was merged in pull request #22.
This commit is contained in:
2026-09-01 13:48:46 +08:00
49 changed files with 2123 additions and 239 deletions
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "核对 feature 边界、URL 状态和质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "核对 Tab 语义、表格列对齐、滚动、状态提示和可访问性。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "核对双榜 query key、取消信号、分页停止条件和错误状态归属。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "核对测试覆盖以及 format、lint、typecheck、test、build 结果。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "确认未把原站未知的评分、涨跌幅或在榜天数伪造成已实现字段。"}
@@ -0,0 +1,39 @@
# 板块资金雷达双榜交互技术设计
## 范围与边界
本轮只修改 `zhixing-web` 的板块资金雷达页面、相关 query 组合和测试,不改变 FastAPI 参数、响应模型、数据库表或指标构建逻辑。后端现有 `GET /api/v1/sector-radar/rankings` 已支持 `side=top` 与 `side=bottom`,前端用两条独立无限查询组合双榜;两侧仍共享交易日、板块类型、指标视角、排名变化参数、搜索词和 `page_size`。
路由继续兼容既有 `side` 查询参数,但双榜页面不再用它决定可见榜单,也不提供“榜单范围”控件。旧链接无论携带 `side=all/top/bottom` 都进入同一双榜视图,避免为本轮视觉迭代扩大路由类型迁移范围。
## Tab 与工具栏
板块类型和指标视角改为 feature 内的语义化按钮 Tab,使用 `role="tablist"`、`role="tab"` 与 `aria-selected`,沿用现有 `updateSearch` 写回 URL,并在切换时重置 `page=1`。不新增共享 Tabs primitive或第三方依赖;交易日、排名变化指标和对比区间继续使用现有 Select,搜索继续使用 Input。
指标 Tab 顺序按目标截图组织为波段资金率、单日资金率、单日净额、排名变化。排名变化激活时,附属的“变化指标”和“对比区间”控件紧凑显示,不改变主 Tab 层级。
## 双查询与滚动数据流
页面从同一基础筛选分别构造 `side="top"` 和 `side="bottom"` 的 `RadarRankingsQuery`,调用两次现有 `useSectorRadarRankings`。现有 query key 已包含 `side` 且排除 `page`,因此两侧缓存和分页相互独立,无需修改后端契约。
每侧分别展平 `InfiniteData.pages`,按数组索引把强榜第 N 行和弱榜第 N 行配成一个视觉行。任一侧行数不足时对应单元格留空,另一侧继续正常展示。唯一滚动容器接近底部时,只为仍有下一页、未请求中且未处于加载更多错误的侧调用 `fetchNextPage`;请求级互斥覆盖同一滚动周期,避免连续事件重复取数。两侧都没有下一页时停止。
加载更多失败按侧记录并保留已加载行;提示区说明强榜或弱榜哪一侧失败,并只重试失败侧。首次请求、后台刷新、刷新失败、部分发布、无发布和搜索无匹配继续保持明确分支,不用一侧的下一页错误覆盖另一侧现有数据。
## 镜像表格结构
使用一张语义化 `<table>` 和显式列宽布局承载左右镜像双榜。第一层表头分为“资金进攻榜 TOP 10%”、当前指标和“BOTTOM 10% 资金撤离榜”;第二层列从左到右为:
`排名 / 板块 | 排名百分位 | 样本 | 资金覆盖率 | 质量 | 流入 | 流出 | 质量 | 资金覆盖率 | 样本 | 排名百分位 | 排名 / 板块`
右侧文本和列标题采用与阅读方向匹配的对齐方式,中间两列增加稳定分隔线。表头继续 sticky,表格保留足够 `min-width`,窄视口通过单一横向滚动容器查看,不压缩成多行或错位结构。
普通资金指标使用 `metric_value`:`CNY_100M` 以亿元格式化,`ratio` 以百分比格式化;强榜显示显式流入方向,弱榜显示显式流出方向,并使用现有主题中的红/绿语义和低饱和背景条增强对比。排名变化视角中间列改为“排名上升 / 排名下降”,值使用 `rank_change`,参考指标不伪装成独立流入/流出字段。
## 延期字段
涨跌幅、加权评分和在榜天数明确延期。当前 ranking row 只有单一 `metric_value`、排名、百分位、样本、覆盖率、质量与 1—5 日 `rank_change`;虽然数据源局部出现涨跌幅,聚合和排名表并未持久化该字段,加权评分公式也未公开,在榜天数没有历史轨迹契约。后续讨论必须先定义三项字段的业务语义、point-in-time 输入、公式、历史深度、异常处理与迁移兼容,不能在本轮 UI 中用现有字段代替或推算。
## 兼容、验证与回滚
本轮不改 HTTP 与数据库,可回滚为原单查询表格而不影响数据。验证包括 query 双实例参数、Tab 的 URL 更新、双榜配对、两侧独立续页、一侧结束或失败时另一侧继续、表头/行列数一致、正负格式化、可访问性、完整前端质量门禁和真实浏览器桌面布局检查。
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/frontend/index.md", "reason": "确认本轮属于 sector-radar feature,遵守前端分层、URL 状态与质量门禁。"}
{"file": ".trellis/spec/frontend/component-guidelines.md", "reason": "Tab、镜像表格、状态提示和滚动容器需遵守现有组件、Tailwind 与可访问性约定。"}
{"file": ".trellis/spec/frontend/hook-guidelines.md", "reason": "双榜继续由 TanStack Query 管理两侧独立无限查询、缓存和取消信号。"}
{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "实现需补齐用户可见行为测试并通过完整前端质量命令。"}
{"file": "docs/research/onechartlab-sector-capital-radar.md", "reason": "提供原站四种指标、TOP/BOTTOM 分层、公开字段和未知评分公式的证据边界。"}
@@ -0,0 +1,33 @@
# 板块资金雷达双榜交互执行计划
## 执行清单
- [ ] 更新页面筛选区,将板块类型和指标视角改为语义化 Tab;移除榜单范围 Select,保留交易日、搜索和排名变化附属控制,并验证 URL 状态重置。
- [ ] 从共享筛选构造强榜与弱榜两条无限查询,分别展平分页结果,组合双侧加载、刷新、错误和重试状态。
- [ ] 将单侧 `RadarTable` 重构为左右镜像双榜表格,建立稳定列宽、双层 sticky 表头、中间流入/流出(排名变化时为排名升降)以及双侧空位规则。
- [ ] 为净额、比例和排名变化实现方向明确的格式化与低饱和条形表达,保留质量、覆盖率和样本等知行已有字段,不实现延期字段。
- [ ] 更新页面行为测试,覆盖 Tab、双查询参数、行配对、双侧分页、一侧提前结束、一侧加载失败重试、格式化语义以及旧分页/榜单范围控件消失。
- [ ] 运行相关 Vitest、`pnpm format:check`、`pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build` 和 `git diff --check`,随后在真实浏览器中检查桌面布局、滚动加载和控制台。
## 重点文件
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.tsx`
- `zhixing-web/src/features/sector-radar/pages/sector-radar-page.test.tsx`
- 如双侧组合需要抽取 query helper,再最小修改 `zhixing-web/src/features/sector-radar/api/sector-radar.query.ts` 及其测试;不修改后端、迁移和共享 Pagination。
## 验证命令
```bash
cd zhixing-web
pnpm exec vitest run src/features/sector-radar/api/sector-radar.query.test.ts src/features/sector-radar/pages/sector-radar-page.test.tsx
pnpm check
pnpm build
cd ..
git diff --check
```
## 回滚点与风险
双榜由两条独立 HTTP 请求组成,不保证同一数据库事务快照;两条请求使用同一交易日与查询条件,且排名发布不可变时结果一致。若后续服务允许发布过程中变更,需要再引入 publication id 固定或后端双榜原子响应,本轮不扩大契约。
镜像表格列数多,桌面窄视口不可避免横向滚动;验收重点是列宽与表头稳定、文本不换行和单一滚动容器,而不是压缩所有列到任意宽度。
@@ -0,0 +1,47 @@
# 板块资金雷达双榜交互对齐
## Goal
把知行项目的板块资金雷达从“筛选器 + 单边普通表格”进一步对齐 OneChartLab 原站的核心浏览方式,让用户在同一屏内通过 Tab 切换板块类型和排序指标,并横向对照强榜与弱榜的流入、流出表现。
用户价值是减少筛选与翻页操作,在一个连续滚动榜单中直接比较资金进入端和撤离端,同时保持现有知行独立指标及数据质量边界。
## Background
- 当前页面已经支持服务端分页上的无限滚动,但仍以 Select 选择板块类型、指标视角和榜单范围,并只展示单侧榜单。
- 用户提供的目标截图显示:搜索框后使用“概念 / 行业”Tab,右侧使用指标 Tab;主体表格同时呈现左侧强榜和右侧弱榜,中间用“流入 / 流出”两列形成视觉对照。
- 本轮不使用 `lark-design-prototype`,以用户截图、现有项目视觉体系和实际数据契约为准。
## Requirements
- 将“概念板块 / 行业板块”从 Select 改为可直接点击的 Tab,并与 URL 状态、数据查询及刷新行为保持同步。
- 将指标视角改为 Tab 交互,至少覆盖现有的波段资金率、单日资金率、单日净额和排名变化;排名变化所需的指标与对比区间仍需有紧凑的附属控制。
- 强榜与弱榜必须在同一个连续滚动列表中成对展示,不再要求用户通过“榜单范围”筛选器切换。
- 表头结构、左右方向和列对齐参照目标截图:左侧为资金进攻榜,右侧为资金撤离榜,中间明确区分流入和流出;强榜从左向中间阅读,弱榜从中间向右阅读。
- 双榜外围只使用知行已有且可验证的排名百分位、样本、资金覆盖率和质量字段;左右按镜像顺序排列,并以固定列宽或等价布局保证表头和行数据对齐。
- 流入和流出数值使用方向明确、可比较的正负与色彩表达,并保持文本语义和可访问性,不只依赖颜色区分。
- 波段资金率、单日资金率和单日净额视角的中间列使用“流入 / 流出”;排名变化视角使用与排名升降一致的表头和数值语义,不把排名变化伪装成资金流量。
- 保留表头吸附、连续滚动加载、首次加载、后台刷新、部分发布、加载更多失败重试、空数据和致命错误等现有行为。
- 不新增与目标交互无关的大块说明卡、指标墙或辅助面板。
## Acceptance Criteria
- [ ] 用户可通过“概念 / 行业”Tab 切换板块类型,激活状态清晰且 URL 查询状态同步更新。
- [ ] 用户可通过指标 Tab 切换波段资金率、单日资金率、单日净额和排名变化,当前指标与中间流入/流出表头一致。
- [ ] 每个可见数据行同时展示一个强榜板块和一个弱榜板块,左右榜单分别保持各自排名顺序。
- [ ] 表头和数据列在桌面视口下稳定对齐,左右镜像列均展示排名/板块、排名百分位、样本、资金覆盖率和质量,中间流入/流出区域与两侧之间有明确边界,横向滚动时结构不塌陷。
- [ ] 中间指标值按当前视角正确格式化:净额以亿元显示,比例以百分比显示,排名变化以升降名次数显示;强弱方向有显式正负号或文字语义。
- [ ] 滚动接近底部时强榜与弱榜继续加载下一批,任一侧提前结束时另一侧仍可继续展示,直到两侧都加载完成。
- [ ] 加载更多失败时已加载的两侧数据不丢失,并提供可操作的重试入口。
- [ ] 不再显示“榜单范围”Select、底部分页器或大块纯描述卡。
- [ ] 相关前后端契约测试、前端行为测试、格式检查、lint、类型检查、完整测试和构建通过,并完成真实浏览器交互与布局检查。
## Out of Scope
- 复刻原站未在知行数据契约中存在的股票勾选、加权评分或自选功能。
- 更改板块指标公式、排序定义、数据构建任务或历史数据口径。
- 引入与本轮交互无关的新页面模块。
## Deferred Items
- 原站外围的“涨跌幅、加权评分、在榜天数”本轮不实现。当前知行排名持久化与 HTTP row 均没有这三个字段,其中加权评分的公开证据不足以确认公式;后续需分别讨论指标语义、输入时点、计算公式、持久化和历史兼容后再立项。
@@ -0,0 +1,26 @@
{
"id": "sector-radar-dual-ranking",
"name": "sector-radar-dual-ranking",
"title": "板块资金雷达双榜交互对齐",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,10 @@
{"file":".trellis/spec/backend/selection.md","reason":"核验 qfq、目标日截断、KDJ 和 selection 语义"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"核验新增 HTTP 响应与错误契约"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"核验 Ruff、Pyright、pytest 和后端代码边界"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"核验布局、状态、可访问性与组件边界"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"核验 React Query、AbortSignal 和同源请求"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"核验前端 API 类型与 strict TypeScript"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核验 Vitest 行为覆盖和完整前端门禁"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"核验跨层字段、状态和测试没有遗漏"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"核验 ECharts 三轴缩放、按需模块和生命周期"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"核验图片映射、below-threshold 和详情接口缺口均已闭环"}
@@ -0,0 +1,73 @@
# 选股模块图表展示技术设计
## 1. 边界与总体方案
本任务继续使用现有 `selection` bounded context,不新建业务上下文。分页结果接口继续只承载列表与目标日摘要;新增独立只读详情接口按 `ts_code + target_trade_date` 返回最多 250 个升序 qfq 日线点及对应 KDJ,避免每页股票都携带大数组。
前端 selection feature 用独立 React Query 缓存详情图表。从现有 `md` 双栏断点起,工作台统一使用左 `1fr`、右 `3fr`,删除更大断点的旧比例覆盖;移动端沿用现有记录卡和展开交互,不在本次增加移动端复合图表。右侧详情依次展示股票摘要、复合图表、最佳案例 JPG、评分分解和信号指标。
## 2. 后端数据流与契约
新增 application 用例 `GetSelectionChart`,依赖已有 `MarketDataReader`。用例接收 `ts_code`、`target_trade_date` 和固定上限 250,读取目标日前完整 qfq 历史,在完整历史上复用 `compute_kdj` 计算递归 K/D/J,再把 OHLCV 与指标对齐并截取末尾最多 250 条。这样返回量受控,同时 KDJ 初值与策略计算保持一致。
新增接口:
```text
GET /api/v1/selection/stocks/{ts_code}/chart?target_trade_date=YYYY-MM-DD
```
成功响应:
```json
{
"ts_code": "603259.SH",
"name": "药明康德",
"target_trade_date": "2026-08-31",
"source_adj": "qfq",
"points": [
{
"trade_date": "2026-08-31",
"open": 1.0,
"high": 1.0,
"low": 1.0,
"close": 1.0,
"volume": 1.0,
"k": 50.0,
"d": 50.0,
"j": 50.0
}
]
}
```
OHLCV 或 KDJ 不可用的点用 `null` 保留日期对齐,不制造数值。前端遇到 OHLC 不完整的日期时保留 category 但不绘制该蜡烛,volume/K/D/J 也保持空点,KDJ 禁止跨空点连线。没有任何目标日前行情时返回 `404 chart_data_not_found`;数据库读取失败返回 `503 selection_storage_unavailable`。接口继续使用 FastAPI Pydantic response model 和同源 `/api/v1` 路径。
## 3. 最佳案例评分与图片
不改变十案例评分算法、阈值、排序或持久化。调整 HTTP 映射:只要评分状态是 `matched` 或 `below_threshold` 且内部结果完整,都返回实际 `value`、`threshold`、`version`、`case` 和 `breakdown`;状态仍保留原值,因此 UI 可以区分“达到阈值”和“最接近但低于阈值”。评分失败返回 `status="failed"` 且无 case;评分未执行沿用现有 `score: null` 契约,前端明确显示“本次运行未执行图形评分”,两者均不展示案例图。契约测试锁定 `null` 的既有语义,避免把字段遗漏误当成功结果。
把用户提供的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/` 并保留原文件名。selection feature 维护穷举的 `case.id -> public URL` 映射;案例 ID 是稳定业务键,中文名只用于展示。若响应出现未知 case id,页面显示“案例图片暂不可用”,不猜测文件名。
## 4. 前端数据与组件
新增 `SelectionChart`、`SelectionChartPoint` 类型,API 函数通过 `requestJson` 调用详情接口并传递 `AbortSignal`,query key 包含股票代码和目标交易日。仅当存在选中股票时启用查询;切换股票时 React Query 取消或隔离旧请求,详情分别呈现加载、失败、空数据和成功状态。
新增 selection feature 内的复合图表组件,直接使用 `echarts/core`,按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom 和 Canvas renderer。三组 category x 轴共享相同交易日数组,candlestick、volume、K/D/J series 分别绑定三个 grid;inside 与 slider dataZoom 同时控制三组 x 轴。后端最多返回 250 点,前端通过 category `startValue`/`endValue` 精确选择末尾 120 点,不足 120 点时展示全部,并允许缩放查看返回的全部数据。
ECharts 初始化只发生在图表 DOM 挂载后;数据变化调用 `setOption`,容器变化通过 `ResizeObserver` 调用 `resize`,卸载时 `dispose`。option 构造与 API 数据转换保持为纯函数,避免在 jsdom 中依赖 Canvas 像素渲染。
最佳案例组件从 `score.case.id` 查找静态 JPG,使用语义标题、描述性 `alt`、固定 2:1 比例和 `loading="lazy"`。图像加载失败时保留案例名称和可读错误状态。
## 5. 兼容性与取舍
- 不把历史序列扩展到 `/selection/results`,避免分页和轮询响应膨胀。
- 不新增图片数据库、上传接口或动态生成逻辑;案例库固定,因此静态资源映射是最小充分方案。
- 不引入 React ECharts wrapper,减少 React 19 兼容面;只增加 `echarts` 一个运行时依赖。
- 移动端暂不加载大图表,维持现有详情展开行为;桌面端按需求提供完整详情。
- 不新增迁移。最佳案例字段已经持久化,图表读取复用现有 market data。
## 6. 风险、回滚与验证重点
主要风险是 KDJ 窗口被错误截断、股票切换显示上一只股票的数据、ECharts 容器未 resize/dispose、缺失行情点错误连线、未知案例 ID 产生错误图片,以及低于阈值的最佳案例仍被 HTTP 丢弃。测试分别锁定完整历史计算后截断、query key、加载/错误/空状态、三轴同步 option、缺失点处理、静态映射完整性和 below-threshold 契约。案例图片行为测试必须断言详情中恰有一张 `<img>`,其 `src` 由返回的 `case.id` 映射,案例名称和评分与同一响应一致;低于阈值也执行同一断言。
回滚可以按层撤销:删除新增详情接口与用例、恢复评分 HTTP 映射、移除 ECharts 组件/依赖和静态 JPG,再恢复原 grid class;没有数据库迁移或不可逆数据写入。
@@ -0,0 +1,14 @@
{"file":".trellis/spec/backend/index.md","reason":"后端 selection bounded context、HTTP 契约与质量入口"}
{"file":".trellis/spec/backend/selection.md","reason":"qfq 历史读取、目标日边界、KDJ 与选股结果契约"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"新增详情接口的 Pydantic、路由和同源 API 约束"}
{"file":".trellis/spec/backend/error-handling.md","reason":"图表空数据与存储失败的错误映射"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Python 类型、文档字符串与测试门禁"}
{"file":".trellis/spec/frontend/index.md","reason":"React feature 分层和质量入口"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"详情组件、Tailwind、页面状态与可访问性"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"requestJson、AbortSignal、React Query key 与状态归属"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"跨层响应类型与 strict TypeScript 约束"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"Vitest 行为测试和前端质量门禁"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"复用 reader、requestJson、query key 和 UI primitive"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"后端响应到前端 query/页面的完整契约链"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/echarts.md","reason":"ECharts 复合金融图、dataZoom 和生命周期方案"}
{"file":".trellis/tasks/09-01-stock-chart-display/research/repository-evidence.md","reason":"现有数据能力、最佳案例图片和契约缺口"}
@@ -0,0 +1,36 @@
# 选股模块图表展示实施计划
## 1. 后端图表详情用例与契约
- [x] 在 selection application 层新增 `GetSelectionChart` 及具名返回模型,复用 `MarketDataReader.load_history` 和 `compute_kdj`,在完整目标日前历史上计算后截取末尾最多 250 点。
- [x] 为用例补充单元测试,覆盖升序输出、目标日截断、超过 250 点、KDJ 对齐、空历史和可空值。
- [x] 在 selection HTTP 层新增 `GET /stocks/{ts_code}/chart` 的 Pydantic response model、依赖组合和错误映射。
- [x] 扩展 HTTP 测试,覆盖成功、404 空行情、503 存储错误、日期参数校验和完整响应字段。
## 2. 最佳案例契约与静态资源
- [x] 调整 `_pattern_score_response`,让 `below_threshold` 与 `matched` 一样返回真实 value/case/breakdown,同时保留状态与阈值语义;补充后端 HTTP 测试。
- [x] 将用户提供目录中的十张 JPG 复制到 `zhixing-web/public/patterns/b1-cases/`,保留文件名,并核对十个 case id 均有且仅有一个资源。
- [x] 在 selection feature 中新增穷举案例图片映射和展示组件;测试断言匹配和低于阈值时详情恰有一张 `<img>`,其 `src`、案例名称和分数来自同一个 `case.id`,并覆盖未知案例、图片加载失败、`score: null` 未评分和评分失败状态。
## 3. 前端详情数据链路
- [x] 在 `selection.types.ts` 定义图表响应类型,在 `selection.api.ts` 增加同源详情请求,在 `selection.query.ts` 增加包含股票代码和目标日的 query key/hook,并传递 `AbortSignal`。
- [x] 为 API URL 编码、查询参数和 query 启用条件补充窄测试。
- [x] 在工作台/详情组件中接入图表 query,保证选择切换不会显示旧股票数据,并呈现加载、错误、空数据和成功状态。
## 4. ECharts 复合图表与布局
- [x] 用 pnpm 添加 `echarts` 运行时依赖并更新 lockfile,不引入 React wrapper。
- [x] 新增 option 纯构造函数和图表组件;按需注册 candlestick、bar、line、grid、tooltip、legend、dataZoom、Canvas renderer,绑定三组 grid/axis/series。
- [x] 实现最多 250 点、用 category `startValue`/`endValue` 精确显示最后 120 点(不足时全量)的 inside + slider 同步缩放,以及 ResizeObserver resize 和卸载 dispose。
- [x] 用纯函数测试锁定 `[open, close, low, high]` 顺序、volume/KDJ 对齐、缺失 OHLC 不绘制蜡烛、空 KDJ 不跨点连线、三个 xAxis 索引和精确 120 点初始范围;页面行为测试断言可见状态与可访问名称。
- [x] 从 `md` 双栏断点起将 grid 统一改为左 `1fr`、右 `3fr`,移除 `lg` 的旧比例覆盖并保持移动端现有记录卡行为;在图表正下方展示最佳案例 JPG,再展示评分分解和关键指标。
## 5. 验证与回滚点
- [x] 运行后端窄测试:`uv run --directory zhixing-server pytest tests/unit/selection tests/test_selection_http.py -q`。
- [x] 运行前端窄测试:`pnpm --dir zhixing-web test -- selection`,并运行 `pnpm --dir zhixing-web typecheck`。
- [x] 运行完整质量门禁:`./dev.sh check`、`./dev.sh test`,再运行 `pnpm --dir zhixing-web build` 验证产物。
- [x] 使用本地页面核对 1:3 布局、药明康德/方正科技案例图、股票切换、缩放同步和详情滚动;当前数据库未用于验收,已使用确定性 Mock API 与真实浏览器核对。
- [x] 回滚前先确认无数据库迁移;按“HTTP/用例、前端 query/组件、静态资产与依赖、grid class”顺序撤销即可恢复旧行为。
@@ -0,0 +1,37 @@
# 选股模块图表展示迭代
## Goal
提升选股模块详情页的信息密度和技术分析能力,使用户可以在查看选股结果时直接核对股票的日线走势、成交量、KDJ 指标,并查看该股票最高相似度案例对应的参考图。
## Background
当前需求由用户明确提出,包含列表与详情页布局调整、详情页技术图表扩展,以及最佳匹配案例图片展示。当前桌面工作台实际是左宽右窄的两列布局,详情响应只有目标交易日标量,不包含历史序列。PostgreSQL 已保存按交易日升序的 qfq 日线 OHLCV,selection 领域已有 KDJ 计算,因此完整技术图表的数据基础已经具备,但需要新增只读详情契约。
仓库另有固定的十个图形匹配案例,评分器会把选中股票逐一与十个案例比较并保留最高分案例。对应的十张 `2800x1400` JPG 位于 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases`,文件中的 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 与当前代码案例定义一一对应。用户已明确:每只股票只展示最高相似度案例对应的一张 JPG;例如药明康德最高匹配方正科技时,展示方正科技 JPG。
## Requirements
- 选股模块从现有 `md` 桌面双栏断点起,左侧列表与右侧详情区域的宽度比例调整为 `1:3`,更小视口保持现有移动布局。
- 右侧详情区域增加一张完整技术图表,至少包含日线 K 线、成交量和 KDJ 三部分。
- 技术图表下方展示当前股票最高相似度案例对应的一张 JPG,并同时保留可读的案例名称和评分信息。
- 每个已完成评分的股票都应返回最高相似度分数和最佳案例,即使该分数低于业务匹配阈值;评分失败或未执行时不伪造案例图片。
- 可以引入与现有前端技术栈兼容的第三方图表库,但应控制新增依赖和改动范围。
- 图表行情必须使用目标交易日及之前的 qfq 日线,不能读取目标日之后的数据。
- 详情接口最多返回目标交易日前 250 个交易日;图表默认聚焦最近 120 个交易日,并允许用户缩放查看接口返回的全部数据。
## Acceptance Criteria
- [x] 从 `md` 双栏断点起,左侧列表和右侧详情区域呈现 `1:3` 的可用宽度比例,且不存在更大断点覆盖回旧比例。
- [x] 选中一只股票后,右侧能够展示与该股票对应的日线 K 线、成交量和 KDJ 指标,三部分时间轴或数据窗口保持一致。
- [x] 图表数据不超过目标交易日前 250 个交易日,首屏聚焦最近 120 个交易日;缩放操作同步作用于日线、成交量和 KDJ 三个区域。
- [x] 图表下方只展示当前股票最高相似度案例对应的一张 JPG;图片、案例名称和评分属于同一个最佳案例,药明康德匹配方正科技的场景会展示方正科技 JPG。
- [x] 低于阈值但评分成功的股票仍展示实际最高分、最佳案例及对应 JPG;未执行或失败时展示明确状态且不显示错误图片。
- [x] 页面在加载、空数据和接口失败时不会崩溃,并给出符合现有页面风格的状态反馈。
- [x] 相关前后端静态检查和自动化测试通过,且没有破坏现有选股列表与详情交互。
## Out of Scope
- 暂不改变选股算法、图形相似度评分规则或股票筛选逻辑。
- 暂不增加用户自定义技术指标、画线工具或交易下单能力。
- 暂不动态生成案例图片,也不展示最佳案例之外的其他九张 JPG。
@@ -0,0 +1,36 @@
# Apache ECharts 复合行情图研究
## 结论
本任务使用 `echarts` 本体并通过 `echarts/core` 按需注册,不引入 React 包装库。一个 ECharts 实例通过三个 `grid`、三组 category `xAxis`/value `yAxis`,以及分别绑定索引的 candlestick、bar、line series 组合日线、成交量和 KDJ。`inside` 与 `slider` 两个 `dataZoom` 同时控制三个 x 轴,使三个区域保持同一数据窗口。
前端组件在 DOM 挂载后调用 `echarts.init` 和 `setOption`,容器尺寸变化时调用 `resize`,卸载时调用 `dispose`。配置与数据转换应提取为纯函数,便于在 Vitest/jsdom 中验证 series、axis、数据顺序和初始缩放,而不依赖 Canvas 像素结果。
## 所需模块
- charts:`CandlestickChart`、`BarChart`、`LineChart`
- components:`GridComponent`、`TooltipComponent`、`LegendComponent`、`DataZoomComponent`
- renderer:`CanvasRenderer`
- 类型:使用 ECharts 提供的 `ComposeOption` 组合已注册组件的 option 类型
## 数据约定
- K 线数据顺序为 `[open, close, low, high]`。
- 日线、成交量、K、D、J 共用升序交易日 category 数据。
- 后端最多返回 250 点;前端用 category 的 `startValue` 和 `endValue` 精确选择最后 120 个点,不足 120 点时展示全部。
- 三组 series 通过 `xAxisIndex`/`yAxisIndex` 绑定各自 grid;两个 dataZoom 的 `xAxisIndex` 同时包含 `[0, 1, 2]`。
- OHLC 不完整的日期使用 ECharts 缺失数据占位,不绘制蜡烛;volume/K/D/J 的空值保持空点,KDJ series 设置 `connectNulls: false`,禁止跨缺失日期连线。
## 资料来源
2026-09-01 通过 Context7 查询官方 Apache ECharts 文档 `/apache/echarts-doc`。相关官方资料包括:
- `en/tutorial/data-zoom.md`:slider 与 inside dataZoom 组合,以及用 axis index 控制指定轴。
- `en/option/component/data-zoom.md`:一个 dataZoom 同时控制多个轴。
- `slides/arch-brief/asset/ec-demo/list-sample1.html`:candlestick、volume、多 grid、多 xAxis/yAxis 和 dataZoom 的金融图表示例。
- `en/tutorial/basic-concepts-overview.md`:`echarts.init`、option、多个 axis/series 与 `setOption` 的基本实例生命周期。
- `zh/tutorial/whats-new-in-echarts-v5.md`:按需引入时通过 `ComposeOption` 获得严格 TypeScript 类型。
## 本仓库约束
前端为 React 19、TypeScript strict、Vite 8;服务器状态应通过 TanStack Query 获取,API 走同源 `/api/v1`。图表属于 `selection` feature,不提升为 shared primitive。
@@ -0,0 +1,22 @@
# 选股详情图表仓库证据
## 当前页面与契约
- `zhixing-web/src/features/selection/components/selection-results-workbench.tsx` 当前桌面布局左宽右窄,目标应改为左 `1fr`、右 `3fr`。
- `zhixing-web/src/features/selection/components/signal-detail-panel.tsx` 当前只展示目标日价格、信号、评分和指标,没有历史图表或案例图片。
- `zhixing-web/src/features/selection/api/selection.types.ts` 与后端 `SelectionStockResponse` 只有目标日标量,没有历史 OHLCV/KDJ 序列。
- 现有前端依赖没有图表库。
## 行情与指标能力
- `PostgresMarketDataReader.load_history` 已按 `source_adj = 'qfq'`、`trade_date <= target_trade_date` 升序读取 OHLCV。
- `StockHistory`/`SelectionBar` 已描述存储无关的升序日线。
- `compute_kdj` 已实现与策略一致的 K、D、J 计算。本任务应在完整目标日前历史上计算后再截取末尾最多 250 点,避免只用返回窗口重新初始化 KDJ 状态。
- 现有 selection HTTP 只有 runs/results;复合图表需要独立的股票详情只读接口,避免把大序列塞进分页列表响应。
## 最佳案例图片
- 当前 `ZHIXING_B1_PATTERN_CASES` 是 `case_001`、`case_002`、`case_003`、`case_004`、`case_006` 至 `case_011` 共十个案例。
- 用户提供目录 `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/patterns/b1-cases` 含对应的十张 `2800x1400` JPG;文件名中的 case id、股票代码、名称与日期与当前代码定义一一对应。
- 评分结果已持久化最高案例。HTTP 对 `matched` 返回 `case.id/name/breakout_date`,但 `below_threshold` 当前丢弃实际 value/case/breakdown;本任务需要让所有成功评分状态保留这些真实结果。
- 图片作为本前端固定静态资产按 `case.id` 映射,不需要数据库表、图片上传或后端文件服务。
@@ -0,0 +1,26 @@
{
"id": "stock-chart-display",
"name": "stock-chart-display",
"title": "选股模块图表展示迭代",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-09-01",
"completedAt": "2026-09-01",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+4 -2
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 13 - **Total Sessions**: 15
- **Last Active**: 2026-09-01 - **Last Active**: 2026-09-01
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~335 | Active | | `journal-1.md` | ~356 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,6 +29,8 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 15 | 2026-09-01 | 选股模块技术图表迭代 | `9145fe4`, `feb8fd4` | `-` |
| 14 | 2026-09-01 | 完成板块资金雷达双榜交互对齐 | `744cb76` | `develop` |
| 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` | | 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` |
| 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` | | 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` |
| 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` | | 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` |
+31
View File
@@ -322,6 +322,14 @@
### Summary ### Summary
将板块资金排名改为基于现有分页 API 的无限滚动列表,移除大块描述卡与底部分页器,保留异常和加载更多重试状态;补齐边界测试并通过完整前端检查与浏览器实测。 将板块资金排名改为基于现有分页 API 的无限滚动列表,移除大块描述卡与底部分页器,保留异常和加载更多重试状态;补齐边界测试并通过完整前端检查与浏览器实测。
## Session 13: 选股模块技术图表迭代
**Date**: 2026-09-01
**Task**: 选股模块技术图表迭代
### Summary
新增选股个股 qfq 日线/成交量/KDJ 图表详情接口;接入 ECharts 复合图、1:3 桌面布局和最佳匹配案例 JPG;补齐低阈值评分契约、前后端测试与浏览器验收。
### Git Commits ### Git Commits
@@ -333,3 +341,26 @@
### Status ### Status
[OK] **Completed** [OK] **Completed**
## Session 14: 完成板块资金雷达双榜交互对齐
**Date**: 2026-09-01
**Task**: 完成板块资金雷达双榜交互对齐
**Branch**: `develop`
### Summary
实现概念/行业与指标 Tab、强弱榜双无限查询和镜像 12 列表格,补齐独立分页/失败重试、方向格式化与可访问性;通过前端 format、lint、typecheck、77 项测试和 build,并完成浏览器路由与 Tab 冒烟检查。
### Git Commits
| Hash | Message |
|------|---------|
| `744cb76` | (see git log) |
| `9145fe4` | (see git log) |
| `feb8fd4` | (see git log) |
### Status
[OK] **Completed**
@@ -0,0 +1,127 @@
"""Read-only application use case for one selected stock's chart series."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from math import isfinite
from typing import Literal, cast
import pandas as pd
from ..domain.indicators import compute_kdj
from ..domain.ports import MarketDataReader
SELECTION_CHART_LIMIT = 250
class SelectionChartNotFound(LookupError):
"""No qfq daily history exists through the requested target date."""
@dataclass(frozen=True, slots=True)
class SelectionChartPoint:
"""One date-aligned OHLCV and KDJ point in the public chart vocabulary."""
trade_date: date
open: float | None
high: float | None
low: float | None
close: float | None
volume: float | None
k: float | None
d: float | None
j: float | None
@dataclass(frozen=True, slots=True)
class SelectionChart:
"""A bounded ascending qfq chart series for one stock and target date."""
ts_code: str
name: str
target_trade_date: date
source_adj: Literal["qfq"]
points: tuple[SelectionChartPoint, ...]
class GetSelectionChart:
"""Load one qfq history, compute KDJ, then bound the response series."""
def __init__(self, reader: MarketDataReader) -> None:
"""Inject the market-data reader owned by the selection context."""
self.reader = reader
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart:
"""Return at most 250 points without changing KDJ's full-history state.
Args:
ts_code: Tushare stock identifier selected by the user.
target_trade_date: Inclusive historical boundary for the chart.
Returns:
An ascending qfq chart series aligned by trade date.
Raises:
SelectionChartNotFound: If no qfq bars exist through the target date.
MarketDataReaderError: If the injected reader cannot complete the read.
"""
history = self.reader.load_history(ts_code, target_trade_date)
bars = tuple(bar for bar in history.bars if bar.trade_date <= target_trade_date)
if not bars:
raise SelectionChartNotFound(
f"chart data not found for {ts_code} at {target_trade_date.isoformat()}"
)
frame = pd.DataFrame(
{
"low": [bar.low for bar in bars],
"high": [bar.high for bar in bars],
"close": [bar.close for bar in bars],
}
)
kdj = compute_kdj(frame)
start = max(0, len(bars) - SELECTION_CHART_LIMIT)
points = tuple(
SelectionChartPoint(
trade_date=bar.trade_date,
open=bar.open,
high=bar.high,
low=bar.low,
close=bar.close,
volume=bar.volume,
k=_finite_or_none(kdj.iloc[index]["K"]),
d=_finite_or_none(kdj.iloc[index]["D"]),
j=_finite_or_none(kdj.iloc[index]["J"]),
)
for index, bar in enumerate(bars)
if index >= start
)
return SelectionChart(
ts_code=history.ts_code,
name=history.name,
target_trade_date=target_trade_date,
source_adj="qfq",
points=points,
)
def _finite_or_none(value: object) -> float | None:
"""Convert one Pandas scalar to a finite JSON-safe float or ``None``."""
try:
number = float(cast(float, value))
except (TypeError, ValueError):
return None
return number if isfinite(number) else None
__all__ = [
"GetSelectionChart",
"SELECTION_CHART_LIMIT",
"SelectionChart",
"SelectionChartNotFound",
"SelectionChartPoint",
]
@@ -9,6 +9,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from zhixing_server.bootstrap.config import Settings, get_settings from zhixing_server.bootstrap.config import Settings, get_settings
from zhixing_server.modules.selection.application.chart import (
GetSelectionChart,
SelectionChart,
SelectionChartNotFound,
)
from zhixing_server.modules.selection.application.run import ( from zhixing_server.modules.selection.application.run import (
RunZhixingB1, RunZhixingB1,
) )
@@ -17,6 +22,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import (
PatternScore, PatternScore,
ZhixingB1PatternScorer, ZhixingB1PatternScorer,
) )
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery, SelectionResultQuery,
@@ -140,6 +146,30 @@ class SelectionStockResponse(BaseModel):
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
class SelectionChartPointResponse(BaseModel):
"""One date-aligned qfq OHLCV and KDJ point."""
trade_date: date
open: float | None
high: float | None
low: float | None
close: float | None
volume: float | None
k: float | None
d: float | None
j: float | None
class SelectionChartResponse(BaseModel):
"""A bounded ascending chart series for one selected stock."""
ts_code: str
name: str
target_trade_date: date
source_adj: Literal["qfq"]
points: list[SelectionChartPointResponse]
def _empty_stocks() -> list[SelectionStockResponse]: def _empty_stocks() -> list[SelectionStockResponse]:
"""Create a typed default stock-result list.""" """Create a typed default stock-result list."""
@@ -192,6 +222,15 @@ def get_selection_service(
) )
def get_selection_chart_service(
settings: Annotated[Settings, Depends(get_settings)],
) -> GetSelectionChart:
"""Build the read-only chart use case on the shared selection pool."""
pool = get_selection_postgres_pool(settings)
return GetSelectionChart(PostgresMarketDataReader(settings, pool=pool))
def get_selection_postgres_pool(settings: Settings) -> SelectionPostgresPool: def get_selection_postgres_pool(settings: Settings) -> SelectionPostgresPool:
"""Return the cached bounded pool shared by selection adapters.""" """Return the cached bounded pool shared by selection adapters."""
@@ -220,6 +259,26 @@ def _close_cached_selection_pools() -> None:
atexit.register(_close_cached_selection_pools) atexit.register(_close_cached_selection_pools)
@selection_router.get(
"/stocks/{ts_code}/chart",
response_model=SelectionChartResponse,
)
def get_selection_chart(
ts_code: str,
target_trade_date: date,
service: Annotated[GetSelectionChart, Depends(get_selection_chart_service)],
) -> SelectionChartResponse:
"""Return one stock's bounded qfq OHLCV and KDJ history."""
try:
chart = service.execute(ts_code, target_trade_date)
except SelectionChartNotFound as exc:
raise _http_error(404, "chart_data_not_found", str(exc)) from exc
except MarketDataReaderError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
return _chart_response(chart)
@selection_router.post( @selection_router.post(
"/runs", "/runs",
response_model=SelectionRunAcceptedResponse, response_model=SelectionRunAcceptedResponse,
@@ -387,13 +446,6 @@ def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreRespons
return None return None
if score.status == "failed": if score.status == "failed":
return SelectionPatternScoreResponse(status="failed", reason=score.reason) return SelectionPatternScoreResponse(status="failed", reason=score.reason)
if score.status == "below_threshold":
return SelectionPatternScoreResponse(
status="below_threshold",
threshold=score.threshold,
version=score.version,
reason="未匹配到评分阈值以上案例",
)
if score.case is None or score.breakdown is None: if score.case is None or score.breakdown is None:
return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整") return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整")
return SelectionPatternScoreResponse( return SelectionPatternScoreResponse(
@@ -411,6 +463,31 @@ def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreRespons
) )
def _chart_response(chart: SelectionChart) -> SelectionChartResponse:
"""Translate an application chart without leaking domain dataclasses."""
return SelectionChartResponse(
ts_code=chart.ts_code,
name=chart.name,
target_trade_date=chart.target_trade_date,
source_adj=chart.source_adj,
points=[
SelectionChartPointResponse(
trade_date=point.trade_date,
open=point.open,
high=point.high,
low=point.low,
close=point.close,
volume=point.volume,
k=point.k,
d=point.d,
j=point.j,
)
for point in chart.points
],
)
def _result_query( def _result_query(
page: int, page: int,
page_size: int, page_size: int,
@@ -440,10 +517,12 @@ def _http_error(code: int, error_type: str, message: str) -> HTTPException:
__all__ = [ __all__ = [
"SelectionChartResponse",
"SelectionResultsResponse", "SelectionResultsResponse",
"SelectionRunAcceptedResponse", "SelectionRunAcceptedResponse",
"SelectionRunRequest", "SelectionRunRequest",
"SelectionStockResponse", "SelectionStockResponse",
"get_selection_chart_service",
"get_selection_service", "get_selection_service",
"get_selection_postgres_pool", "get_selection_postgres_pool",
"selection_router", "selection_router",
+124 -5
View File
@@ -10,6 +10,11 @@ from fastapi.testclient import TestClient
import zhixing_server.modules.selection.presentation.http as selection_http import zhixing_server.modules.selection.presentation.http as selection_http
from zhixing_server.bootstrap.app import create_app from zhixing_server.bootstrap.app import create_app
from zhixing_server.bootstrap.config import Settings from zhixing_server.bootstrap.config import Settings
from zhixing_server.modules.selection.application.chart import (
SelectionChart,
SelectionChartNotFound,
SelectionChartPoint,
)
from zhixing_server.modules.selection.application.run import PreparedSelectionRun from zhixing_server.modules.selection.application.run import PreparedSelectionRun
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.pattern_scoring import ( from zhixing_server.modules.selection.domain.pattern_scoring import (
@@ -18,6 +23,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import (
PatternScore, PatternScore,
PatternScoreBreakdown, PatternScoreBreakdown,
) )
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
@@ -30,6 +36,7 @@ from zhixing_server.modules.selection.infrastructure.postgres_reader import (
SelectionMarketDataNotReady, SelectionMarketDataNotReady,
) )
from zhixing_server.modules.selection.presentation.http import ( from zhixing_server.modules.selection.presentation.http import (
get_selection_chart_service,
get_selection_postgres_pool, get_selection_postgres_pool,
get_selection_service, get_selection_service,
) )
@@ -97,6 +104,40 @@ class FakeSelectionService:
return self.run return self.run
class FakeChartService:
"""Return or fail one deterministic chart response."""
def __init__(self) -> None:
self.mode = "ok"
self.request: tuple[str, date] | None = None
def execute(self, ts_code: str, target_trade_date: date) -> SelectionChart:
self.request = (ts_code, target_trade_date)
if self.mode == "missing":
raise SelectionChartNotFound("chart unavailable")
if self.mode == "storage_error":
raise MarketDataReaderError("chart storage unavailable")
return SelectionChart(
ts_code=ts_code,
name="平安银行",
target_trade_date=target_trade_date,
source_adj="qfq",
points=(
SelectionChartPoint(
trade_date=target_trade_date,
open=10.0,
high=11.0,
low=9.5,
close=10.5,
volume=1000.0,
k=52.0,
d=48.0,
j=60.0,
),
),
)
def _run(run_id: str, status: str) -> SelectionRun: def _run(run_id: str, status: str) -> SelectionRun:
original_signal = SelectionSignal( original_signal = SelectionSignal(
ts_code="000001.SZ", ts_code="000001.SZ",
@@ -153,9 +194,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
) )
def _client(service: FakeSelectionService) -> TestClient: def _client(
service: FakeSelectionService,
chart_service: FakeChartService | None = None,
) -> TestClient:
app = create_app() app = create_app()
app.dependency_overrides[get_selection_service] = lambda: service app.dependency_overrides[get_selection_service] = lambda: service
if chart_service is not None:
app.dependency_overrides[get_selection_chart_service] = lambda: chart_service
return TestClient(app) return TestClient(app)
@@ -295,12 +341,21 @@ def test_query_returns_persisted_signal_details() -> None:
), ),
{ {
"status": "below_threshold", "status": "below_threshold",
"value": None, "value": 42.5,
"threshold": 60.0, "threshold": 60.0,
"version": PATTERN_SCORING_VERSION, "version": PATTERN_SCORING_VERSION,
"case": None, "case": {
"breakdown": None, "id": "case_001",
"reason": "未匹配到评分阈值以上案例", "name": "华纳药厂",
"breakout_date": "2025-05-12",
},
"breakdown": {
"trend_structure": 40.0,
"kdj_state": 42.0,
"volume_pattern": 43.0,
"price_shape": 44.0,
},
"reason": None,
}, },
), ),
( (
@@ -370,6 +425,70 @@ def test_query_rejects_invalid_page_size() -> None:
assert response.status_code == 422 assert response.status_code == 422
def test_chart_returns_bounded_qfq_contract() -> None:
chart_service = FakeChartService()
response = _client(FakeSelectionService(), chart_service).get(
"/api/v1/selection/stocks/000001.SZ/chart",
params={"target_trade_date": "2026-08-08"},
)
assert response.status_code == 200
assert chart_service.request == ("000001.SZ", TARGET)
assert response.json() == {
"ts_code": "000001.SZ",
"name": "平安银行",
"target_trade_date": "2026-08-08",
"source_adj": "qfq",
"points": [
{
"trade_date": "2026-08-08",
"open": 10.0,
"high": 11.0,
"low": 9.5,
"close": 10.5,
"volume": 1000.0,
"k": 52.0,
"d": 48.0,
"j": 60.0,
}
],
}
@pytest.mark.parametrize(
("mode", "status_code", "error_code"),
[
("missing", 404, "chart_data_not_found"),
("storage_error", 503, "selection_storage_unavailable"),
],
)
def test_chart_maps_application_errors(
mode: str,
status_code: int,
error_code: str,
) -> None:
chart_service = FakeChartService()
chart_service.mode = mode
response = _client(FakeSelectionService(), chart_service).get(
"/api/v1/selection/stocks/000001.SZ/chart",
params={"target_trade_date": "2026-08-08"},
)
assert response.status_code == status_code
assert response.json()["detail"]["code"] == error_code
def test_chart_requires_a_valid_target_date() -> None:
response = _client(FakeSelectionService(), FakeChartService()).get(
"/api/v1/selection/stocks/000001.SZ/chart",
params={"target_trade_date": "not-a-date"},
)
assert response.status_code == 422
def test_query_forwards_score_ascending_sort() -> None: def test_query_forwards_score_ascending_sort() -> None:
service = FakeSelectionService(_run("run-http", "success")) service = FakeSelectionService(_run("run-http", "success"))
@@ -0,0 +1,101 @@
"""Application tests for bounded qfq chart series."""
from datetime import date, timedelta
import pandas as pd
import pytest
from zhixing_server.modules.selection.application.chart import (
GetSelectionChart,
SelectionChartNotFound,
)
from zhixing_server.modules.selection.domain.indicators import compute_kdj
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
class FakeReader:
"""Return one deterministic history through the requested date."""
def __init__(self, history: StockHistory) -> None:
self.history = history
self.request: tuple[str, date] | None = None
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
self.request = (ts_code, target_trade_date)
return self.history
def _history(count: int) -> StockHistory:
start = date(2025, 1, 1)
bars = tuple(
SelectionBar(
trade_date=start + timedelta(days=index),
open=10.0 + index / 100,
high=11.0 + index / 100,
low=9.0 + index / 100,
close=10.5 + index / 100,
volume=1000.0 + index,
)
for index in range(count)
)
return StockHistory(ts_code="000001.SZ", name="平安银行", bars=bars)
def test_chart_computes_on_full_history_before_returning_last_250_points() -> None:
history = _history(260)
reader = FakeReader(history)
target = history.bars[-1].trade_date
chart = GetSelectionChart(reader).execute(history.ts_code, target)
assert reader.request == ("000001.SZ", target)
assert chart.source_adj == "qfq"
assert len(chart.points) == 250
assert chart.points[0].trade_date == history.bars[10].trade_date
assert chart.points[-1].trade_date == target
frame = pd.DataFrame(
{
"low": [bar.low for bar in history.bars],
"high": [bar.high for bar in history.bars],
"close": [bar.close for bar in history.bars],
}
)
expected = compute_kdj(frame).iloc[-1]
assert chart.points[-1].k == pytest.approx(float(expected["K"]))
assert chart.points[-1].d == pytest.approx(float(expected["D"]))
assert chart.points[-1].j == pytest.approx(float(expected["J"]))
def test_chart_filters_future_rows_and_preserves_nullable_points() -> None:
history = _history(12)
target = history.bars[-2].trade_date
incomplete = SelectionBar(
trade_date=history.bars[3].trade_date,
open=history.bars[3].open,
high=None,
low=history.bars[3].low,
close=None,
volume=None,
)
history = StockHistory(
ts_code=history.ts_code,
name=history.name,
bars=history.bars[:3] + (incomplete,) + history.bars[4:],
)
chart = GetSelectionChart(FakeReader(history)).execute(history.ts_code, target)
assert chart.points[-1].trade_date == target
assert all(point.trade_date <= target for point in chart.points)
assert chart.points[3].high is None
assert chart.points[3].volume is None
assert chart.points[3].k is None
assert chart.points[3].d is None
assert chart.points[3].j is None
def test_chart_rejects_empty_history() -> None:
reader = FakeReader(StockHistory(ts_code="000001.SZ", name="平安银行"))
with pytest.raises(SelectionChartNotFound, match="chart data not found"):
GetSelectionChart(reader).execute("000001.SZ", date(2026, 8, 8))
+1
View File
@@ -23,6 +23,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"echarts": "^6.1.0",
"lucide-react": "^1.28.0", "lucide-react": "^1.28.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-day-picker": "^10.0.1", "react-day-picker": "^10.0.1",
+23
View File
@@ -26,6 +26,9 @@ importers:
date-fns: date-fns:
specifier: ^4.4.0 specifier: ^4.4.0
version: 4.4.0 version: 4.4.0
echarts:
specifier: ^6.1.0
version: 6.1.0
lucide-react: lucide-react:
specifier: ^1.28.0 specifier: ^1.28.0
version: 1.28.0(react@19.2.8) version: 1.28.0(react@19.2.8)
@@ -857,6 +860,9 @@ packages:
dom-accessibility-api@0.6.3: dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
electron-to-chromium@1.5.399: electron-to-chromium@1.5.399:
resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==}
@@ -1484,6 +1490,9 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1674,6 +1683,9 @@ packages:
zod@4.4.3: zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
zustand@5.0.14: zustand@5.0.14:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -2414,6 +2426,11 @@ snapshots:
dom-accessibility-api@0.6.3: {} dom-accessibility-api@0.6.3: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
electron-to-chromium@1.5.399: {} electron-to-chromium@1.5.399: {}
enhanced-resolve@5.24.5: enhanced-resolve@5.24.5:
@@ -2958,6 +2975,8 @@ snapshots:
dependencies: dependencies:
typescript: 6.0.3 typescript: 6.0.3
tslib@2.3.0: {}
tslib@2.8.1: tslib@2.8.1:
optional: true optional: true
@@ -3078,6 +3097,10 @@ snapshots:
zod@4.4.3: {} zod@4.4.3: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
zustand@5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): zustand@5.0.14(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)):
optionalDependencies: optionalDependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

@@ -115,6 +115,18 @@ const rankingsResponse: RadarRankingsResponse = {
], ],
} }
const bottomRankingsResponse: RadarRankingsResponse = {
...rankingsResponse,
side: "bottom",
rows: rankingsResponse.rows.map((row, index) => ({
...row,
sector_code: `BOTTOM-${index + 1}`,
sector_name: index === 0 ? "新能源" : "医药商业",
metric_value: index === 0 ? -8.4 : null,
rank_position: index + 1,
})),
}
function rankingQueryResult(overrides: Record<string, unknown> = {}) { function rankingQueryResult(overrides: Record<string, unknown> = {}) {
return { return {
data: { pageParams: [1], pages: [rankingsResponse] }, data: { pageParams: [1], pages: [rankingsResponse] },
@@ -161,10 +173,10 @@ describe("SectorRadarPage", () => {
it("renders ranking rows without the description card or pagination", () => { it("renders ranking rows without the description card or pagination", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getByText("12.5 亿元")).toBeInTheDocument() expect(screen.getAllByText("+12.5 亿元")[0]).toBeInTheDocument()
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.getByText("样本有限")).toBeInTheDocument() expect(screen.getAllByText("样本有限")[0]).toBeInTheDocument()
expect(screen.getByText("95%")).toBeInTheDocument() expect(screen.getAllByText("95%")[0]).toBeInTheDocument()
expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument() expect(screen.queryByText("收盘后板块资金排名")).not.toBeInTheDocument()
expect( expect(
screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"), screen.queryByText("知行独立实现,非 OneChartLab 原站公式。"),
@@ -197,6 +209,38 @@ describe("SectorRadarPage", () => {
}) })
}) })
it("renders accessible tabs, writes tab state to the URL, and queries both sides", () => {
render(<SectorRadarPage />)
expect(
screen.getByRole("tablist", { name: "板块类型" }),
).toBeInTheDocument()
expect(
screen.getByRole("tablist", { name: "指标视角" }),
).toBeInTheDocument()
expect(screen.getByRole("tab", { name: "单日净额" })).toHaveAttribute(
"aria-selected",
"true",
)
expect(useSectorRadarRankings).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ side: "top", view: "amount" }),
)
expect(useSectorRadarRankings).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ side: "bottom", view: "amount" }),
)
fireEvent.click(screen.getByRole("tab", { name: "行业" }))
const searchCall = navigate.mock.calls[0]?.[0].search as (
previous: SectorRadarRouteSearch,
) => SectorRadarRouteSearch
expect(searchCall({ ...routeSearch, page: 4 })).toMatchObject({
page: 1,
sectorType: "industry",
})
})
it("loads the next page once when repeated scroll events reach the bottom", () => { it("loads the next page once when repeated scroll events reach the bottom", () => {
fetchNextPage.mockReturnValue(new Promise(() => undefined)) fetchNextPage.mockReturnValue(new Promise(() => undefined))
useSectorRadarRankings.mockReturnValue( useSectorRadarRankings.mockReturnValue(
@@ -230,7 +274,7 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块") expect(screen.getByRole("status")).toHaveTextContent("正在加载更多板块")
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument() expect(screen.queryByText("雷达数据正在刷新")).not.toBeInTheDocument()
}) })
@@ -257,9 +301,9 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.getByText("低空经济")).toBeInTheDocument() expect(screen.getAllByText("低空经济")[0]).toBeInTheDocument()
expect(screen.getByText("商业航天")).toBeInTheDocument() expect(screen.getAllByText("商业航天")[0]).toBeInTheDocument()
}) })
it("keeps loaded rows and offers retry when loading more fails", () => { it("keeps loaded rows and offers retry when loading more fails", () => {
@@ -273,15 +317,44 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.getByRole("alert")).toHaveTextContent( expect(screen.getByRole("alert")).toHaveTextContent(
"加载更多板块失败,已加载结果仍保留", "强榜加载更多失败,弱榜加载更多失败,已加载结果仍保留",
) )
expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument() expect(screen.queryByText("雷达数据刷新失败")).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重试加载更多" })) fireEvent.click(screen.getByRole("button", { name: "重试强榜" }))
expect(fetchNextPage).toHaveBeenCalledOnce() expect(fetchNextPage).toHaveBeenCalledOnce()
}) })
it("keeps the healthy side visible when only the other side fails to load more", () => {
const topFetchNextPage = vi.fn().mockResolvedValue(undefined)
const bottomFetchNextPage = vi.fn().mockResolvedValue(undefined)
useSectorRadarRankings.mockImplementation((query: { side: string }) =>
rankingQueryResult({
data: {
pageParams: [1],
pages: [
query.side === "bottom" ? bottomRankingsResponse : rankingsResponse,
],
},
fetchNextPage:
query.side === "bottom" ? bottomFetchNextPage : topFetchNextPage,
isFetchNextPageError: query.side === "bottom",
hasNextPage: true,
}),
)
render(<SectorRadarPage />)
expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.getByText("新能源")).toBeInTheDocument()
expect(screen.getByRole("alert")).toHaveTextContent("弱榜加载更多失败")
expect(screen.getByRole("button", { name: "重试弱榜" })).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "重试弱榜" }))
expect(bottomFetchNextPage).toHaveBeenCalledOnce()
expect(topFetchNextPage).not.toHaveBeenCalled()
})
it("shows rank changes without inventing missing history", () => { it("shows rank changes without inventing missing history", () => {
routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 } routeSearch = { ...routeSearch, view: "rank_change", rankChangeDays: 5 }
useSectorRadarRankings.mockReturnValue( useSectorRadarRankings.mockReturnValue(
@@ -307,10 +380,10 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect( expect(
screen.getByRole("columnheader", { name: "5 日排名变化" }), screen.getByRole("columnheader", { name: "排名上升" }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByText("+3")).toBeInTheDocument() expect(screen.getAllByText("+3 名")[0]).toBeInTheDocument()
expect(screen.getByText("暂无可比历史")).toBeInTheDocument() expect(screen.getAllByText("暂无可比历史")[0]).toBeInTheDocument()
}) })
it("warns when a partial attempt has not replaced last-good", () => { it("warns when a partial attempt has not replaced last-good", () => {
@@ -350,7 +423,7 @@ describe("SectorRadarPage", () => {
expect(screen.getByRole("status")).toHaveTextContent( expect(screen.getByRole("status")).toHaveTextContent(
"当前继续展示上一次已读取的有效结果", "当前继续展示上一次已读取的有效结果",
) )
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
}) })
it("keeps stale data visible when a background refresh fails", () => { it("keeps stale data visible when a background refresh fails", () => {
@@ -361,10 +434,38 @@ describe("SectorRadarPage", () => {
render(<SectorRadarPage />) render(<SectorRadarPage />)
expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败") expect(screen.getByRole("status")).toHaveTextContent("雷达数据刷新失败")
expect(screen.getByText("机器人")).toBeInTheDocument() expect(screen.getAllByText("机器人")[0]).toBeInTheDocument()
expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument() expect(screen.queryByText("板块资金雷达暂时不可用")).not.toBeInTheDocument()
}) })
it("shows a fatal error when one side has no data and the other has no response", () => {
useSectorRadarRankings.mockImplementation((query: { side: string }) =>
rankingQueryResult(
query.side === "top"
? {
data: {
pageParams: [1],
pages: [
{
...rankingsResponse,
side: "top",
status: "no_data",
publication: null,
rows: [],
total: 0,
},
],
},
}
: { data: undefined, isError: true },
),
)
render(<SectorRadarPage />)
expect(screen.getByText("板块资金雷达暂时不可用")).toBeInTheDocument()
})
it("renders loading, error with retry, and no-data states", () => { it("renders loading, error with retry, and no-data states", () => {
useSectorRadarDates.mockReturnValue({ useSectorRadarDates.mockReturnValue({
data: undefined, data: undefined,
@@ -408,7 +509,7 @@ describe("SectorRadarPage", () => {
rerender(<SectorRadarPage />) rerender(<SectorRadarPage />)
fireEvent.click(screen.getByRole("button", { name: "重试" })) fireEvent.click(screen.getByRole("button", { name: "重试" }))
expect(refetchDates).toHaveBeenCalledOnce() expect(refetchDates).toHaveBeenCalledOnce()
expect(refetchRankings).toHaveBeenCalledOnce() expect(refetchRankings).toHaveBeenCalledTimes(2)
useSectorRadarDates.mockReturnValue({ useSectorRadarDates.mockReturnValue({
data: { data: {
@@ -8,12 +8,14 @@ import {
useSectorRadarRankings, useSectorRadarRankings,
} from "@/features/sector-radar/api/sector-radar.query" } from "@/features/sector-radar/api/sector-radar.query"
import type { import type {
RadarMetricKind,
RadarMetricQuality, RadarMetricQuality,
RadarMetricUnit, RadarMetricUnit,
RadarPublication, RadarPublication,
RadarRankingRow, RadarRankingRow,
RadarRankingsQuery, RadarRankingsQuery,
RadarRankingsResponse, RadarRankingsResponse,
RadarView,
SectorRadarRouteSearch, SectorRadarRouteSearch,
} from "@/features/sector-radar/api/sector-radar.types" } from "@/features/sector-radar/api/sector-radar.types"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
@@ -37,29 +39,20 @@ import {
import { Skeleton } from "@/shared/ui/skeleton" import { Skeleton } from "@/shared/ui/skeleton"
const sectorTypeOptions = [ const sectorTypeOptions = [
{ label: "概念板块", value: "concept" }, { label: "概念", value: "concept" },
{ label: "行业板块", value: "industry" }, { label: "行业", value: "industry" },
] as const ] as const
const viewOptions = [ const viewOptions = [
{ label: "主力净额", value: "amount" }, { label: "波段资金率", value: "swing" },
{ label: "单日资金率", value: "ratio" }, { label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" }, { label: "单日净额", value: "amount" },
{ label: "排名变化", value: "rank_change" }, { label: "排名变化", value: "rank_change" },
] as const ] as const
const metricOptions = [ const metricOptions = [
{ label: "主力净额", value: "amount" }, { label: "主力净额", value: "amount" },
{ label: "单日资金率", value: "ratio" }, { label: "单日资金率", value: "ratio" },
{ label: "3—10 日波段资金率", value: "swing" }, { label: "波段资金率", value: "swing" },
] as const ] as const
const sideOptions = [
{ label: "全部板块", value: "all" },
{ label: "强榜", value: "top" },
{ label: "弱榜", value: "bottom" },
] as const
const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({ const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
label: `${value} 日变化`, label: `${value} 日变化`,
value: String(value), value: String(value),
@@ -68,41 +61,56 @@ const rankChangeDayOptions = [1, 2, 3, 4, 5].map((value) => ({
export function SectorRadarPage() { export function SectorRadarPage() {
const search = useSearch({ from: "/_workspace/sector-radar" }) const search = useSearch({ from: "/_workspace/sector-radar" })
const navigate = useNavigate({ from: "/sector-radar" }) const navigate = useNavigate({ from: "/sector-radar" })
const query: RadarRankingsQuery = { const baseQuery: Omit<RadarRankingsQuery, "side"> = {
tradeDate: search.tradeDate, tradeDate: search.tradeDate,
sectorType: search.sectorType, sectorType: search.sectorType,
view: search.view, view: search.view,
rankChangeMetric: search.rankChangeMetric, rankChangeMetric: search.rankChangeMetric,
rankChangeDays: search.rankChangeDays, rankChangeDays: search.rankChangeDays,
side: search.side,
search: search.search || undefined, search: search.search || undefined,
page: search.page, page: search.page,
pageSize: search.pageSize, pageSize: search.pageSize,
} }
const dates = useSectorRadarDates() const dates = useSectorRadarDates()
const rankings = useSectorRadarRankings(query) const topRankings = useSectorRadarRankings({ ...baseQuery, side: "top" })
const rankingPages = rankings.data?.pages ?? [] const bottomRankings = useSectorRadarRankings({
const firstRankingPage = rankingPages[0] ...baseQuery,
const rankingRows = rankingPages.flatMap((page) => page.rows) side: "bottom",
})
function updateSearch(next: Partial<SectorRadarRouteSearch>) { function updateSearch(next: Partial<SectorRadarRouteSearch>) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
} }
const loading =
const loading = dates.isPending || rankings.isPending dates.isPending || topRankings.isPending || bottomRankings.isPending
const refreshing = const refreshing =
!loading && !loading &&
(Boolean(dates.isFetching) || (Boolean(dates.isFetching) ||
(Boolean(rankings.isFetching) && !rankings.isFetchingNextPage)) (Boolean(topRankings.isFetching) && !topRankings.isFetchingNextPage) ||
(Boolean(bottomRankings.isFetching) &&
!bottomRankings.isFetchingNextPage))
const topHasData = Boolean(topRankings.data)
const bottomHasData = Boolean(bottomRankings.data)
const topFirstPage = topRankings.data?.pages[0]
const bottomFirstPage = bottomRankings.data?.pages[0]
const topHasSuccess = topFirstPage?.status === "success"
const bottomHasSuccess = bottomFirstPage?.status === "success"
const fatalError = const fatalError =
(dates.isError && !dates.data) || (rankings.isError && !rankings.data) Boolean(dates.isError && !dates.data) ||
(!topHasData && !bottomHasData) ||
(!topHasSuccess &&
!bottomHasSuccess &&
(topRankings.isError || bottomRankings.isError))
const refreshFailed = const refreshFailed =
!fatalError && !fatalError &&
(Boolean(dates.isError) || (Boolean(dates.isError) ||
(Boolean(rankings.isError) && !rankings.isFetchNextPageError)) (Boolean(topRankings.isError) && !topRankings.isFetchNextPageError) ||
(Boolean(bottomRankings.isError) && !bottomRankings.isFetchNextPageError))
const noData = const noData =
dates.data?.status === "no_data" || firstRankingPage?.status === "no_data" dates.data?.status === "no_data" ||
(topFirstPage?.status === "no_data" &&
bottomFirstPage?.status === "no_data")
const response = topFirstPage ?? bottomFirstPage
const publication = topFirstPage?.publication ?? bottomFirstPage?.publication
return ( return (
<PageLayout <PageLayout
actions={ actions={
@@ -120,35 +128,30 @@ export function SectorRadarPage() {
<RadarError <RadarError
onRetry={() => { onRetry={() => {
void dates.refetch() void dates.refetch()
void rankings.refetch() void topRankings.refetch()
void bottomRankings.refetch()
}} }}
/> />
) : null} ) : null}
{!loading && !fatalError && noData ? <RadarNoData /> : null} {!loading && !fatalError && noData ? <RadarNoData /> : null}
{!loading && {!loading && !fatalError && !noData && response && publication ? (
!fatalError &&
!noData &&
dates.data &&
firstRankingPage?.status === "success" &&
firstRankingPage.publication ? (
<> <>
{refreshFailed ? ( {refreshFailed ? (
<RadarRefreshFailureStatus /> <RadarRefreshFailureStatus />
) : refreshing ? ( ) : refreshing ? (
<RadarRefreshStatus /> <RadarRefreshStatus />
) : null} ) : null}
<RadarPublicationStatus {dates.data ? (
currentAttempt={dates.data.current_attempt} <RadarPublicationStatus
lastGood={dates.data.last_good} currentAttempt={dates.data.current_attempt}
publication={firstRankingPage.publication} lastGood={dates.data.last_good}
/> publication={publication}
/>
) : null}
<RadarTable <RadarTable
hasNextPage={Boolean(rankings.hasNextPage)} bottom={bottomRankings}
isFetchNextPageError={rankings.isFetchNextPageError} response={response}
isFetchingNextPage={rankings.isFetchingNextPage} top={topRankings}
onLoadMore={() => rankings.fetchNextPage()}
response={firstRankingPage}
rows={rankingRows}
/> />
</> </>
) : null} ) : null}
@@ -157,18 +160,19 @@ export function SectorRadarPage() {
) )
} }
interface RadarFiltersProps { function RadarFilters({
availableDates,
onChange,
search,
}: {
availableDates: string[] availableDates: string[]
onChange: (next: Partial<SectorRadarRouteSearch>) => void onChange: (next: Partial<SectorRadarRouteSearch>) => void
search: SectorRadarRouteSearch search: SectorRadarRouteSearch
} }) {
function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
const dateOptions = [ const dateOptions = [
{ label: "最近有效交易日", value: "latest" }, { label: "最近有效交易日", value: "latest" },
...availableDates.map((value) => ({ label: value, value })), ...availableDates.map((value) => ({ label: value, value })),
] ]
return ( return (
<div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12"> <div aria-label="板块资金雷达筛选" className="grid gap-2 lg:grid-cols-12">
<FilterSelect <FilterSelect
@@ -183,26 +187,24 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
}) })
} }
/> />
<FilterSelect <TabList
ariaLabel="板块类型"
className="lg:col-span-2" className="lg:col-span-2"
label="板块类型"
options={sectorTypeOptions} options={sectorTypeOptions}
value={search.sectorType} value={search.sectorType}
onValueChange={(value) => onChange={(value) =>
onChange({ onChange({
page: 1, page: 1,
sectorType: value as SectorRadarRouteSearch["sectorType"], sectorType: value as SectorRadarRouteSearch["sectorType"],
}) })
} }
/> />
<FilterSelect <TabList
className="lg:col-span-2" ariaLabel="指标视角"
label="指标视角" className="lg:col-span-4"
options={viewOptions} options={viewOptions}
value={search.view} value={search.view}
onValueChange={(value) => onChange={(value) => onChange({ page: 1, view: value as RadarView })}
onChange({ page: 1, view: value as SectorRadarRouteSearch["view"] })
}
/> />
{search.view === "rank_change" ? ( {search.view === "rank_change" ? (
<> <>
@@ -212,11 +214,7 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
options={metricOptions} options={metricOptions}
value={search.rankChangeMetric} value={search.rankChangeMetric}
onValueChange={(value) => onValueChange={(value) =>
onChange({ onChange({ page: 1, rankChangeMetric: value as RadarMetricKind })
page: 1,
rankChangeMetric:
value as SectorRadarRouteSearch["rankChangeMetric"],
})
} }
/> />
<FilterSelect <FilterSelect
@@ -230,15 +228,6 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
/> />
</> </>
) : null} ) : null}
<FilterSelect
className="lg:col-span-2"
label="榜单范围"
options={sideOptions}
value={search.side}
onValueChange={(value) =>
onChange({ page: 1, side: value as SectorRadarRouteSearch["side"] })
}
/>
<label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2"> <label className="grid gap-1 text-xs font-medium text-muted-foreground lg:col-span-2">
搜索板块 搜索板块
<Input <Input
@@ -257,6 +246,46 @@ function RadarFilters({ availableDates, onChange, search }: RadarFiltersProps) {
) )
} }
function TabList({
ariaLabel,
className,
onChange,
options,
value,
}: {
ariaLabel: string
className?: string
onChange: (value: string) => void
options: readonly { label: string; value: string }[]
value: string
}) {
return (
<div
className={`grid gap-1 text-xs font-medium text-muted-foreground ${className ?? ""}`}
>
<span>{ariaLabel}</span>
<div
aria-label={ariaLabel}
className="flex rounded-md border border-input bg-background p-0.5"
role="tablist"
>
{options.map((option) => (
<button
aria-selected={value === option.value}
className={`min-h-9 flex-1 rounded-sm px-2 text-xs transition-colors ${value === option.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"}`}
key={option.value}
onClick={() => onChange(option.value)}
role="tab"
type="button"
>
{option.label}
</button>
))}
</div>
</div>
)
}
function FilterSelect({ function FilterSelect({
className, className,
label, label,
@@ -312,9 +341,7 @@ function RadarPublicationStatus({
currentAttempt && currentAttempt &&
currentAttempt.status !== "success" && currentAttempt.status !== "success" &&
currentAttempt.publication_id !== lastGood?.publication_id currentAttempt.publication_id !== lastGood?.publication_id
if (!degradedAttempt) return null if (!degradedAttempt) return null
return ( return (
<div <div
role="status" role="status"
@@ -327,106 +354,145 @@ function RadarPublicationStatus({
) )
} }
type RankingQueryResult = ReturnType<typeof useSectorRadarRankings>
function RadarTable({ function RadarTable({
hasNextPage, top,
isFetchNextPageError, bottom,
isFetchingNextPage,
onLoadMore,
response, response,
rows,
}: { }: {
hasNextPage: boolean top: RankingQueryResult
isFetchNextPageError: boolean bottom: RankingQueryResult
isFetchingNextPage: boolean
onLoadMore: () => Promise<unknown>
response: RadarRankingsResponse response: RadarRankingsResponse
rows: RadarRankingRow[]
}) { }) {
const topRows = top.data?.pages.flatMap((page) => page.rows) ?? []
const bottomRows = bottom.data?.pages.flatMap((page) => page.rows) ?? []
const rowCount = Math.max(topRows.length, bottomRows.length)
const rankChangeView = response.view === "rank_change" const rankChangeView = response.view === "rank_change"
const loadMoreRequestPending = useRef(false) const loadMoreRequestPending = useRef({ top: false, bottom: false })
function requestLoadMore(query: RankingQueryResult) {
function requestLoadMore() { const side = query === top ? "top" : "bottom"
if (loadMoreRequestPending.current || isFetchingNextPage) return if (
loadMoreRequestPending.current[side] ||
loadMoreRequestPending.current = true query.isFetchingNextPage ||
const releaseRequest = () => { !query.hasNextPage ||
loadMoreRequestPending.current = false query.isFetchNextPageError
} )
void onLoadMore().then(releaseRequest, releaseRequest) return
loadMoreRequestPending.current[side] = true
void query.fetchNextPage().finally(() => {
loadMoreRequestPending.current[side] = false
})
} }
return ( return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card"> <section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card">
<div <div
aria-label="板块资金排名滚动区域" aria-label="板块资金排名滚动区域"
className="min-h-0 min-w-0 flex-1 overflow-auto" className="min-h-0 min-w-0 flex-1 overflow-auto"
onScroll={(event) => { onScroll={(event) => {
const container = event.currentTarget const c = event.currentTarget
const remaining = if (c.scrollHeight - c.scrollTop - c.clientHeight <= 160) {
container.scrollHeight - requestLoadMore(top)
container.scrollTop - requestLoadMore(bottom)
container.clientHeight
if (
remaining <= 160 &&
hasNextPage &&
!isFetchingNextPage &&
!isFetchNextPageError
) {
requestLoadMore()
} }
}} }}
role="region" role="region"
tabIndex={0} tabIndex={0}
> >
<table className="w-full min-w-[920px] border-collapse text-sm"> <table className="w-full min-w-[1280px] border-collapse text-sm">
<caption className="sr-only"> <caption className="sr-only">
{response.sector_type === "concept" ? "概念" : "行业"}板块资金排名 {response.sector_type === "concept" ? "概念" : "行业"}板块资金双榜
</caption> </caption>
<thead className="sticky top-0 z-10 bg-muted/95 text-left text-xs text-muted-foreground backdrop-blur"> <colgroup>
{Array.from({ length: 12 }, (_, i) => (
<col
key={i}
className={i === 5 || i === 6 ? "w-[120px]" : "w-[100px]"}
/>
))}
</colgroup>
<thead className="sticky top-0 z-10 bg-muted/95 text-xs text-muted-foreground backdrop-blur">
<tr> <tr>
<th className="px-3 py-2.5 font-medium" scope="col"> <th
名次 className="px-3 py-2 text-left font-medium"
colSpan={5}
scope="colgroup"
>
资金进攻榜 TOP 10%
</th> </th>
<th className="px-3 py-2.5 font-medium" scope="col"> <th
板块 className="border-x border-border px-3 py-2 text-center font-medium"
colSpan={2}
scope="colgroup"
>
{rankChangeView ? "排名变化" : response.definition.label}
</th> </th>
<th className="px-3 py-2.5 text-right font-medium" scope="col"> <th
{rankChangeView className="px-3 py-2 text-right font-medium"
? `${response.rank_change_days} 日排名变化` colSpan={5}
: response.definition.label} scope="colgroup"
>
BOTTOM 10% 资金撤离榜
</th> </th>
{rankChangeView ? ( </tr>
<th className="px-3 py-2.5 text-right font-medium" scope="col"> <tr>
参考指标值 <th className="px-3 py-2 text-left font-medium" scope="col">
</th> 排名 / 板块
) : null} </th>
<th className="px-3 py-2.5 text-right font-medium" scope="col"> <th className="px-3 py-2 text-right font-medium" scope="col">
排名百分位 排名百分位
</th> </th>
<th className="px-3 py-2.5 text-right font-medium" scope="col"> <th className="px-3 py-2 text-right font-medium" scope="col">
样本 样本
</th> </th>
<th className="px-3 py-2.5 text-right font-medium" scope="col"> <th className="px-3 py-2 text-right font-medium" scope="col">
资金覆盖率 资金覆盖率
</th> </th>
<th className="px-3 py-2.5 font-medium" scope="col"> <th className="px-3 py-2 text-left font-medium" scope="col">
质量 质量
</th> </th>
<th
className="border-l border-border px-3 py-2 text-right font-medium"
scope="col"
>
{rankChangeView ? "排名上升" : "流入"}
</th>
<th
className="border-r border-border px-3 py-2 text-left font-medium"
scope="col"
>
{rankChangeView ? "排名下降" : "流出"}
</th>
<th className="px-3 py-2 text-right font-medium" scope="col">
质量
</th>
<th className="px-3 py-2 text-right font-medium" scope="col">
资金覆盖率
</th>
<th className="px-3 py-2 text-right font-medium" scope="col">
样本
</th>
<th className="px-3 py-2 text-right font-medium" scope="col">
排名百分位
</th>
<th className="px-3 py-2 text-right font-medium" scope="col">
排名 / 板块
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border/60"> <tbody className="divide-y divide-border/60">
{rows.map((row) => ( {Array.from({ length: rowCount }, (_, i) => (
<RadarTableRow <RadarMirrorRow
key={`${row.sector_type}-${row.sector_code}`} key={i}
rankChangeView={rankChangeView} rankChangeView={rankChangeView}
row={row} top={topRows[i]}
bottom={bottomRows[i]}
/> />
))} ))}
{rows.length === 0 ? ( {rowCount === 0 ? (
<tr> <tr>
<td <td
className="px-3 py-12 text-center text-muted-foreground" className="px-3 py-12 text-center text-muted-foreground"
colSpan={rankChangeView ? 8 : 7} colSpan={12}
> >
没有符合当前筛选条件的板块。 没有符合当前筛选条件的板块。
</td> </td>
@@ -434,7 +500,7 @@ function RadarTable({
) : null} ) : null}
</tbody> </tbody>
</table> </table>
{isFetchingNextPage ? ( {top.isFetchingNextPage || bottom.isFetchingNextPage ? (
<div <div
aria-live="polite" aria-live="polite"
className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground" className="border-t border-border/60 px-3 py-2 text-center text-xs text-muted-foreground"
@@ -443,15 +509,43 @@ function RadarTable({
正在加载更多板块… 正在加载更多板块…
</div> </div>
) : null} ) : null}
{isFetchNextPageError ? ( {top.isFetchNextPageError || bottom.isFetchNextPageError ? (
<div <div
className="flex items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200" className="flex flex-wrap items-center justify-center gap-2 border-t border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
role="alert" role="alert"
> >
<span>加载更多板块失败,已加载结果仍保留。</span> <span>
<Button onClick={requestLoadMore} size="sm" variant="outline"> {top.isFetchNextPageError ? "强榜加载更多失败" : null}
重试加载更多 {top.isFetchNextPageError && bottom.isFetchNextPageError
</Button> ? ","
: null}
{bottom.isFetchNextPageError ? "弱榜加载更多失败" : null}
,已加载结果仍保留。
</span>
{top.isFetchNextPageError ? (
<Button
onClick={() => {
loadMoreRequestPending.current.top = false
void top.fetchNextPage()
}}
size="sm"
variant="outline"
>
重试强榜
</Button>
) : null}
{bottom.isFetchNextPageError ? (
<Button
onClick={() => {
loadMoreRequestPending.current.bottom = false
void bottom.fetchNextPage()
}}
size="sm"
variant="outline"
>
重试弱榜
</Button>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
@@ -459,34 +553,57 @@ function RadarTable({
) )
} }
function RadarTableRow({ function RadarMirrorRow({
top,
bottom,
rankChangeView, rankChangeView,
row,
}: { }: {
top?: RadarRankingRow
bottom?: RadarRankingRow
rankChangeView: boolean rankChangeView: boolean
row: RadarRankingRow
}) { }) {
return ( return (
<tr className="hover:bg-muted/40"> <tr className="hover:bg-muted/40">
<td className="px-3 py-2.5 font-semibold tabular-nums"> <RankCells row={top} align="left" />
<MetricCell direction="in" rankChangeView={rankChangeView} row={top} />
<MetricCell
direction="out"
rankChangeView={rankChangeView}
row={bottom}
/>
<RankCells row={bottom} align="right" />
</tr>
)
}
function RankCells({
row,
align,
}: {
row?: RadarRankingRow
align: "left" | "right"
}) {
if (!row)
return (
<>
<td className="px-3 py-2.5 text-muted-foreground">—</td>
<td />
<td />
<td />
<td />
</>
)
return (
<>
<td
className={`px-3 py-2.5 ${align === "right" ? "text-right" : "text-left"} font-semibold tabular-nums`}
>
{row.rank_position ?? "—"} {row.rank_position ?? "—"}
</td> <span className="mx-1 font-normal text-muted-foreground">/</span>
<td className="px-3 py-2.5"> <span className="font-medium text-foreground">{row.sector_name}</span>
<p className="font-medium text-foreground">{row.sector_name}</p> <span className="ml-1 text-xs font-normal text-muted-foreground">
<p className="mt-0.5 text-xs text-muted-foreground">
{row.sector_code} {row.sector_code}
</p> </span>
</td> </td>
<td className="px-3 py-2.5 text-right font-medium tabular-nums">
{rankChangeView
? formatRankChange(row.rank_change)
: formatMetricValue(row.metric_value, row.unit)}
</td>
{rankChangeView ? (
<td className="px-3 py-2.5 text-right tabular-nums text-muted-foreground">
{formatMetricValue(row.metric_value, row.unit)}
</td>
) : null}
<td className="px-3 py-2.5 text-right tabular-nums"> <td className="px-3 py-2.5 text-right tabular-nums">
{row.rank_percentile === null {row.rank_percentile === null
? "—" ? "—"
@@ -501,13 +618,64 @@ function RadarTableRow({
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<QualityBadge quality={row.quality} /> <QualityBadge quality={row.quality} />
</td> </td>
</tr> </>
) )
} }
function MetricCell({
row,
rankChangeView,
direction,
}: {
row?: RadarRankingRow
rankChangeView: boolean
direction: "in" | "out"
}) {
const value = row
? rankChangeView
? formatRankChange(row.rank_change)
: formatDirectionalMetric(row.metric_value, row.unit, direction)
: "—"
const positive =
row?.rank_change !== null &&
row?.rank_change !== undefined &&
row.rank_change > 0
const negative =
row?.rank_change !== null &&
row?.rank_change !== undefined &&
row.rank_change < 0
const borderClass = direction === "in" ? "border-l" : "border-r"
const toneClass =
positive || (direction === "in" && !rankChangeView)
? "text-red-600 dark:text-red-400 bg-red-500/10"
: negative || (direction === "out" && !rankChangeView)
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "text-foreground bg-muted/30"
return (
<td
className={`${borderClass} border-border px-3 py-2.5 ${direction === "in" ? "text-right" : "text-left"} font-medium tabular-nums ${toneClass}`}
>
{value}
</td>
)
}
function formatDirectionalMetric(
value: number | null,
unit: RadarMetricUnit,
direction: "in" | "out",
) {
if (value === null) return "—"
const absoluteValue = Math.abs(value)
const formatted =
unit === "CNY_100M"
? `${formatNumber(absoluteValue, 2)} 亿元`
: `${formatNumber(absoluteValue * 100, 2)}%`
return `${direction === "in" ? "+" : "−"}${formatted}`
}
function QualityBadge({ quality }: { quality: RadarMetricQuality }) { function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
if (quality === "available") return <Badge variant="outline">可用</Badge> if (quality === "available") return <Badge variant="outline">可用</Badge>
if (quality === "available_limited_sample") { if (quality === "available_limited_sample")
return ( return (
<Badge <Badge
className="border-amber-500/40 text-amber-700 dark:text-amber-200" className="border-amber-500/40 text-amber-700 dark:text-amber-200"
@@ -516,10 +684,8 @@ function QualityBadge({ quality }: { quality: RadarMetricQuality }) {
样本有限 样本有限
</Badge> </Badge>
) )
}
return <Badge variant="destructive">不可用</Badge> return <Badge variant="destructive">不可用</Badge>
} }
function RadarLoading() { function RadarLoading() {
return ( return (
<Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none"> <Card aria-label="正在加载板块资金雷达" className="flex-1 shadow-none">
@@ -537,7 +703,6 @@ function RadarLoading() {
</Card> </Card>
) )
} }
function RadarRefreshStatus() { function RadarRefreshStatus() {
return ( return (
<div <div
@@ -548,7 +713,6 @@ function RadarRefreshStatus() {
</div> </div>
) )
} }
function RadarRefreshFailureStatus() { function RadarRefreshFailureStatus() {
return ( return (
<div <div
@@ -559,7 +723,6 @@ function RadarRefreshFailureStatus() {
</div> </div>
) )
} }
function RadarError({ onRetry }: { onRetry: () => void }) { function RadarError({ onRetry }: { onRetry: () => void }) {
return ( return (
<Card className="shadow-none"> <Card className="shadow-none">
@@ -584,7 +747,6 @@ function RadarError({ onRetry }: { onRetry: () => void }) {
</Card> </Card>
) )
} }
function RadarNoData() { function RadarNoData() {
return ( return (
<Card className="shadow-none"> <Card className="shadow-none">
@@ -600,28 +762,18 @@ function RadarNoData() {
</Card> </Card>
) )
} }
function formatMetricValue(value: number | null, unit: RadarMetricUnit) {
if (value === null) return "—"
return unit === "CNY_100M"
? `${formatNumber(value, 2)} 亿元`
: `${formatNumber(value * 100, 2)}%`
}
function formatRankChange(value: number | null) { function formatRankChange(value: number | null) {
if (value === null) return "暂无可比历史" if (value === null) return "暂无可比历史"
if (value > 0) return `+${value}` if (value > 0) return `+${value} 名`
return String(value) if (value < 0) return `${value} 名`
return "0 名"
} }
function formatCoverage(value: number) { function formatCoverage(value: number) {
return `${formatNumber(value * 100, 1)}%` return `${formatNumber(value * 100, 1)}%`
} }
function formatNumber(value: number, maximumFractionDigits: number) { function formatNumber(value: number, maximumFractionDigits: number) {
return value.toLocaleString("zh-CN", { maximumFractionDigits }) return value.toLocaleString("zh-CN", { maximumFractionDigits })
} }
function attemptStatusLabel(status: RadarPublication["status"]) { function attemptStatusLabel(status: RadarPublication["status"]) {
if (status === "running") return "新一期仍在构建" if (status === "running") return "新一期仍在构建"
if (status === "partial") return "新一期数据不完整" if (status === "partial") return "新一期数据不完整"
@@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn())
vi.mock("@/shared/api/request-json", () => ({ requestJson })) vi.mock("@/shared/api/request-json", () => ({ requestJson }))
import { getSelectionResults, getSelectionRun } from "./selection.api" import {
getSelectionChart,
getSelectionResults,
getSelectionRun,
} from "./selection.api"
describe("selection API adapters", () => { describe("selection API adapters", () => {
beforeEach(() => { beforeEach(() => {
@@ -47,4 +51,18 @@ describe("selection API adapters", () => {
expect(params.has("category")).toBe(false) expect(params.has("category")).toBe(false)
expect(params.has("sort")).toBe(false) expect(params.has("sort")).toBe(false)
}) })
it("requests a stock chart with an encoded code and target date", async () => {
await getSelectionChart("600601.SH", "2026-08-08")
const [input, init] = requestJson.mock.calls[0] as [
string,
{ signal?: AbortSignal },
]
expect(input).toContain("/api/v1/selection/stocks/600601.SH/chart?")
expect(
new URL(input, "http://localhost").searchParams.get("target_trade_date"),
).toBe("2026-08-08")
expect(init).toEqual({ signal: undefined })
})
}) })
@@ -1,6 +1,7 @@
import { requestJson } from "@/shared/api/request-json" import { requestJson } from "@/shared/api/request-json"
import type { import type {
SelectionChart,
SelectionResults, SelectionResults,
SelectionResultsQuery, SelectionResultsQuery,
SelectionRunAccepted, SelectionRunAccepted,
@@ -8,6 +9,20 @@ import type {
SelectionStrategy, SelectionStrategy,
} from "./selection.types" } from "./selection.types"
export function getSelectionChart(
tsCode: string,
targetTradeDate: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({
target_trade_date: targetTradeDate,
})
return requestJson<SelectionChart>(
`/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`,
{ signal },
)
}
export function getSelectionResults( export function getSelectionResults(
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
@@ -7,6 +7,7 @@ import {
import { useEffect } from "react" import { useEffect } from "react"
import { import {
getSelectionChart,
getSelectionResults, getSelectionResults,
getSelectionRun, getSelectionRun,
triggerSelectionRun, triggerSelectionRun,
@@ -17,6 +18,12 @@ import type {
SelectionStrategy, SelectionStrategy,
} from "./selection.types" } from "./selection.types"
export const selectionChartQueryKey = (
tsCode?: string,
targetTradeDate?: string,
) =>
["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const
export const selectionResultsQueryKey = ( export const selectionResultsQueryKey = (
strategy: SelectionStrategy, strategy: SelectionStrategy,
targetTradeDate?: string, targetTradeDate?: string,
@@ -61,6 +68,15 @@ export function useSelectionResults(
}) })
} }
export function useSelectionChart(tsCode?: string, targetTradeDate?: string) {
return useQuery({
enabled: Boolean(tsCode && targetTradeDate),
queryFn: ({ signal }) =>
getSelectionChart(tsCode ?? "", targetTradeDate ?? "", signal),
queryKey: selectionChartQueryKey(tsCode, targetTradeDate),
})
}
export function useSelectionRun( export function useSelectionRun(
runId: string | null, runId: string | null,
resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 }, resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 },
@@ -58,6 +58,26 @@ export interface SelectionStockResult {
signals: SelectionSignal[] signals: SelectionSignal[]
} }
export interface SelectionChartPoint {
trade_date: string
open: number | null
high: number | null
low: number | null
close: number | null
volume: number | null
k: number | null
d: number | null
j: number | null
}
export interface SelectionChart {
ts_code: string
name: string
target_trade_date: string
source_adj: "qfq"
points: SelectionChartPoint[]
}
export interface SelectionPatternScore { export interface SelectionPatternScore {
status: "matched" | "below_threshold" | "failed" status: "matched" | "below_threshold" | "failed"
value: number | null value: number | null
@@ -0,0 +1,12 @@
export const patternCaseImageById = {
case_001: "/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
case_002: "/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg",
case_003: "/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg",
case_004: "/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
case_006: "/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg",
case_007: "/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg",
case_008: "/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg",
case_009: "/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg",
case_010: "/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg",
case_011: "/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg",
} as const
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import type { SelectionPatternScore } from "../api/selection.types"
import { PatternCaseImage } from "./pattern-case-image"
function score(
caseId = "case_004",
caseName = "方正科技",
): SelectionPatternScore {
return {
breakdown: {
kdj_state: 91,
price_shape: 92,
trend_structure: 86,
volume_pattern: 89,
},
case: {
breakout_date: "2025-07-23",
id: caseId,
name: caseName,
},
reason: null,
status: "matched",
threshold: 60,
value: 90.17,
version: "zhixing_b1_pattern_fastdtw_v1",
}
}
describe("PatternCaseImage", () => {
it("renders only the best case image and falls back after a load error", () => {
const view = render(<PatternCaseImage score={score()} />)
const image = screen.getByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(screen.getAllByRole("img")).toHaveLength(1)
expect(image).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
fireEvent.error(image)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
view.rerender(<PatternCaseImage score={score("case_001", "华纳药厂")} />)
expect(
screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }),
).toBeInTheDocument()
})
it("does not guess an asset for an unknown case id", () => {
render(<PatternCaseImage score={score("case_unknown", "未知案例")} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.getByText("案例图片暂不可用。")).toBeInTheDocument()
})
it("does not render an image for uncomputed or failed scores", () => {
const failed: SelectionPatternScore = {
breakdown: null,
case: null,
reason: "scoring unavailable",
status: "failed",
threshold: null,
value: null,
version: null,
}
const view = render(<PatternCaseImage score={null} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
view.rerender(<PatternCaseImage score={failed} />)
expect(screen.queryByRole("img")).not.toBeInTheDocument()
expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument()
})
})
@@ -0,0 +1,47 @@
import { useState } from "react"
import type { SelectionPatternScore } from "../api/selection.types"
import { patternCaseImageById } from "./pattern-case-assets"
interface PatternCaseImageProps {
score: SelectionPatternScore | null
}
export function PatternCaseImage({ score }: PatternCaseImageProps) {
const [failedSource, setFailedSource] = useState<string | null>(null)
const matchedCase = score?.status === "failed" ? null : score?.case
if (!matchedCase) return null
const source =
patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById]
return (
<section aria-labelledby="best-pattern-case" className="mt-4">
<div className="mb-2 flex items-baseline justify-between gap-3">
<h3 className="text-xs font-medium" id="best-pattern-case">
最接近案例:{matchedCase.name}
</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{formatScore(score?.value)} 分
</span>
</div>
{!source || failedSource === source ? (
<p className="rounded-md border border-border/60 p-3 text-xs text-muted-foreground">
案例图片暂不可用。
</p>
) : (
<img
alt={`${matchedCase.name} B1 参考案例图`}
className="aspect-2/1 w-full rounded-md border border-border/60 object-contain"
loading="lazy"
onError={() => setFailedSource(source)}
src={source}
/>
)}
</section>
)
}
function formatScore(value: number | null | undefined) {
return value === null || value === undefined ? "—" : value.toFixed(2)
}
@@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
if (score.status === "failed") { if (score.status === "failed") {
return <Badge variant="destructive">评分暂不可用</Badge> return <Badge variant="destructive">评分暂不可用</Badge>
} }
if (score.status === "below_threshold") {
return (
<Badge variant="outline">
未匹配到 {formatScore(score.threshold)} 分以上案例
</Badge>
)
}
return ( return (
<span className="space-y-0.5"> <span className="space-y-0.5">
<strong className="block tabular-nums text-primary"> <strong className="block tabular-nums text-primary">
@@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) {
<span className="block text-[10px] text-muted-foreground"> <span className="block text-[10px] text-muted-foreground">
{score.case?.name ?? "最佳案例未知"} {score.case?.name ?? "最佳案例未知"}
</span> </span>
{score.status === "below_threshold" ? (
<Badge variant="outline">低于阈值</Badge>
) : null}
</span> </span>
) )
} }
@@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
</div> </div>
) )
} }
if (score.status === "below_threshold") {
return (
<div className="space-y-1">
<Badge variant="outline">低于匹配阈值</Badge>
<p className="text-xs text-muted-foreground">
未匹配到 {formatScore(score.threshold)} 分以上案例。
</p>
</div>
)
}
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) {
最佳案例:{score.case?.name ?? "未知"} 最佳案例:{score.case?.name ?? "未知"}
</p> </p>
</div> </div>
<Badge variant="default">已匹配</Badge> <Badge variant={score.status === "matched" ? "default" : "outline"}>
{score.status === "matched" ? "已匹配" : "低于匹配阈值"}
</Badge>
</div> </div>
{score.breakdown ? ( {score.breakdown ? (
<dl className="grid grid-cols-2 gap-2 text-xs"> <dl className="grid grid-cols-2 gap-2 text-xs">
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest"
import type { SelectionChartPoint } from "../api/selection.types"
import { buildSelectionChartOption } from "./selection-chart-option"
function point(index: number): SelectionChartPoint {
return {
close: index + 10.5,
d: index + 20,
high: index + 11,
j: index + 30,
k: index + 10,
low: index + 9,
open: index + 10,
trade_date: `2026-${String(Math.floor(index / 28) + 1).padStart(2, "0")}-${String((index % 28) + 1).padStart(2, "0")}`,
volume: index + 1000,
}
}
describe("selection chart option", () => {
it("aligns all three grids and opens on exactly the last 120 points", () => {
const points = Array.from({ length: 250 }, (_, index) => point(index))
const option = buildSelectionChartOption(points)
const dataZoom = option.dataZoom as Array<{
endValue: string
startValue: string
xAxisIndex: number[]
}>
const series = option.series as Array<{
data: unknown[]
xAxisIndex: number
yAxisIndex: number
}>
expect(dataZoom).toHaveLength(2)
expect(dataZoom[0]).toMatchObject({
endValue: points[249].trade_date,
startValue: points[130].trade_date,
xAxisIndex: [0, 1, 2],
})
expect(dataZoom[1].xAxisIndex).toEqual([0, 1, 2])
expect(series[0]).toMatchObject({ xAxisIndex: 0, yAxisIndex: 0 })
expect(series[1]).toMatchObject({ xAxisIndex: 1, yAxisIndex: 1 })
expect(series.slice(2)).toEqual(
expect.arrayContaining([
expect.objectContaining({ xAxisIndex: 2, yAxisIndex: 2 }),
]),
)
expect(series[0].data[0]).toEqual([10, 10.5, 9, 11])
})
it("keeps missing candles and indicator values empty", () => {
const incomplete = {
...point(0),
close: null,
d: null,
high: null,
j: null,
k: null,
volume: null,
}
const option = buildSelectionChartOption([incomplete])
const series = option.series as Array<{
connectNulls?: boolean
data: unknown[]
}>
const candle = series[0].data[0] as number[]
expect(candle).toHaveLength(4)
expect(candle.every(Number.isNaN)).toBe(true)
expect(series[1].data[0]).toMatchObject({ value: null })
for (const indicator of series.slice(2)) {
expect(indicator.connectNulls).toBe(false)
expect(indicator.data).toEqual([null])
}
})
})
@@ -0,0 +1,182 @@
import type {
BarSeriesOption,
CandlestickSeriesOption,
LineSeriesOption,
} from "echarts/charts"
import type {
DataZoomComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
TooltipComponentOption,
} from "echarts/components"
import type { ComposeOption } from "echarts/core"
import type { SelectionChartPoint } from "../api/selection.types"
const DEFAULT_VISIBLE_POINTS = 120
export type SelectionChartOption = ComposeOption<
| BarSeriesOption
| CandlestickSeriesOption
| DataZoomComponentOption
| GridComponentOption
| LegendComponentOption
| LineSeriesOption
| TitleComponentOption
| TooltipComponentOption
>
/**
* Build the three-grid financial chart configuration from aligned API points.
*
* @param points Ascending qfq OHLCV and KDJ points from the selection API.
* @returns One ECharts option with synchronized axes and an exact 120-point window.
*/
export function buildSelectionChartOption(
points: SelectionChartPoint[],
): SelectionChartOption {
const dates = points.map((point) => point.trade_date)
const startIndex = Math.max(0, dates.length - DEFAULT_VISIBLE_POINTS)
const startValue = dates[startIndex]
const endValue = dates.at(-1)
const zoomRange = { endValue, startValue }
return {
animation: false,
dataZoom: [
{
...zoomRange,
type: "inside",
xAxisIndex: [0, 1, 2],
},
{
...zoomRange,
bottom: 4,
height: 20,
type: "slider",
xAxisIndex: [0, 1, 2],
},
],
grid: [
{ bottom: "46%", left: 64, right: 24, top: 36 },
{ bottom: "28%", height: "14%", left: 64, right: 24 },
{ bottom: 48, height: "20%", left: 64, right: 24 },
],
legend: {
data: ["K", "D", "J"],
right: 20,
top: "73%",
},
series: [
{
data: points.map(toCandlestickValue),
itemStyle: {
borderColor: "#dc2626",
borderColor0: "#16a34a",
color: "#dc2626",
color0: "#16a34a",
},
name: "日线",
type: "candlestick",
xAxisIndex: 0,
yAxisIndex: 0,
},
{
data: points.map((point) => ({
itemStyle: {
color:
point.open !== null &&
point.close !== null &&
point.close >= point.open
? "#dc2626"
: "#16a34a",
},
value: point.volume,
})),
name: "成交量",
type: "bar",
xAxisIndex: 1,
yAxisIndex: 1,
},
...(["k", "d", "j"] as const).map((key, index): LineSeriesOption => ({
connectNulls: false,
data: points.map((point) => point[key]),
name: key.toUpperCase(),
showSymbol: false,
smooth: false,
type: "line",
xAxisIndex: 2,
yAxisIndex: 2,
lineStyle: {
color: ["#2563eb", "#d97706", "#9333ea"][index],
width: 1.5,
},
})),
],
title: [
{ left: 12, text: "日线", textStyle: { fontSize: 12 }, top: 8 },
{ left: 72, text: "成交量", textStyle: { fontSize: 12 }, top: "54%" },
{ left: 72, text: "KDJ", textStyle: { fontSize: 12 }, top: "72%" },
],
tooltip: {
axisPointer: { type: "cross" },
trigger: "axis",
},
xAxis: [
buildCategoryAxis(dates, 0, false),
buildCategoryAxis(dates, 1, false),
buildCategoryAxis(dates, 2, true),
],
yAxis: [
buildValueAxis(0),
buildValueAxis(1),
buildValueAxis(2, { max: 120, min: -20 }),
],
}
}
function toCandlestickValue(point: SelectionChartPoint) {
if (
point.open === null ||
point.close === null ||
point.low === null ||
point.high === null
) {
return [Number.NaN, Number.NaN, Number.NaN, Number.NaN]
}
return [point.open, point.close, point.low, point.high]
}
function buildCategoryAxis(
dates: string[],
gridIndex: number,
showLabels: boolean,
) {
return {
axisLabel: { show: showLabels },
axisLine: { onZero: false },
boundaryGap: false,
data: dates,
gridIndex,
min: "dataMin",
max: "dataMax",
scale: true,
splitLine: { show: false },
type: "category" as const,
}
}
function buildValueAxis(
gridIndex: number,
bounds: { max?: number; min?: number } = {},
) {
return {
...bounds,
axisLabel: { fontSize: 10 },
gridIndex,
scale: true,
splitLine: { lineStyle: { opacity: 0.16 } },
type: "value" as const,
}
}
@@ -0,0 +1,110 @@
import { render, screen } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
const chartMocks = vi.hoisted(() => ({
disconnect: vi.fn(),
dispose: vi.fn(),
init: vi.fn(),
observe: vi.fn(),
resize: vi.fn(),
setOption: vi.fn(),
use: vi.fn(),
}))
vi.mock("echarts/charts", () => ({
BarChart: {},
CandlestickChart: {},
LineChart: {},
}))
vi.mock("echarts/components", () => ({
AxisPointerComponent: {},
DataZoomComponent: {},
GridComponent: {},
LegendComponent: {},
TitleComponent: {},
TooltipComponent: {},
}))
vi.mock("echarts/core", () => ({
init: chartMocks.init,
use: chartMocks.use,
}))
vi.mock("echarts/renderers", () => ({ CanvasRenderer: {} }))
import { SelectionChart } from "./selection-chart"
const chart: SelectionChartData = {
name: "药明康德",
points: [
{
close: 10.5,
d: 48,
high: 11,
j: 60,
k: 52,
low: 9,
open: 10,
trade_date: "2026-08-08",
volume: 1000,
},
],
source_adj: "qfq",
target_trade_date: "2026-08-08",
ts_code: "603259.SH",
}
describe("SelectionChart", () => {
beforeEach(() => {
chartMocks.init.mockReturnValue({
dispose: chartMocks.dispose,
resize: chartMocks.resize,
setOption: chartMocks.setOption,
})
vi.stubGlobal(
"ResizeObserver",
class {
constructor(private readonly callback: ResizeObserverCallback) {}
disconnect() {
chartMocks.disconnect()
}
observe() {
chartMocks.observe()
this.callback([], this as unknown as ResizeObserver)
}
},
)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it("initializes, resizes, updates and disposes one chart instance", () => {
const view = render(<SelectionChart chart={chart} />)
expect(
screen.getByRole("img", {
name: "药明康德 日线、成交量和 KDJ 图表",
}),
).toBeInTheDocument()
expect(chartMocks.init).toHaveBeenCalledTimes(1)
expect(chartMocks.observe).toHaveBeenCalledTimes(1)
expect(chartMocks.resize).toHaveBeenCalledTimes(1)
expect(chartMocks.setOption).toHaveBeenCalledTimes(1)
view.rerender(
<SelectionChart
chart={{ ...chart, points: [...chart.points, chart.points[0]] }}
/>,
)
expect(chartMocks.setOption).toHaveBeenCalledTimes(2)
view.unmount()
expect(chartMocks.disconnect).toHaveBeenCalledTimes(1)
expect(chartMocks.dispose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,70 @@
import { useEffect, useRef } from "react"
import { BarChart, CandlestickChart, LineChart } from "echarts/charts"
import {
AxisPointerComponent,
DataZoomComponent,
GridComponent,
LegendComponent,
TitleComponent,
TooltipComponent,
} from "echarts/components"
import * as echarts from "echarts/core"
import { CanvasRenderer } from "echarts/renderers"
import type { SelectionChart as SelectionChartData } from "../api/selection.types"
import { buildSelectionChartOption } from "./selection-chart-option"
echarts.use([
AxisPointerComponent,
BarChart,
CanvasRenderer,
CandlestickChart,
DataZoomComponent,
GridComponent,
LegendComponent,
LineChart,
TitleComponent,
TooltipComponent,
])
interface SelectionChartProps {
chart: SelectionChartData
}
export function SelectionChart({ chart }: SelectionChartProps) {
const containerRef = useRef<HTMLDivElement>(null)
const instanceRef = useRef<echarts.EChartsType | null>(null)
useEffect(() => {
const container = containerRef.current
if (!container) return
const instance = echarts.init(container, undefined, {
renderer: "canvas",
})
instanceRef.current = instance
const observer = new ResizeObserver(() => instance.resize())
observer.observe(container)
return () => {
observer.disconnect()
instance.dispose()
instanceRef.current = null
}
}, [])
useEffect(() => {
instanceRef.current?.setOption(buildSelectionChartOption(chart.points), {
notMerge: true,
})
}, [chart.points])
return (
<div
aria-label={`${chart.name} 日线、成交量和 KDJ 图表`}
className="h-[620px] min-h-[520px] w-full"
ref={containerRef}
role="img"
/>
)
}
@@ -122,7 +122,7 @@ export function SelectionResultsWorkbench({
} }
return ( return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_260px] md:overflow-hidden lg:grid-cols-[minmax(0,1.55fr)_minmax(280px,0.72fr)]"> <div className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-visible md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,3fr)] md:overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden"> <section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-visible rounded-md border border-border/80 bg-card md:overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5"> <div className="flex shrink-0 flex-wrap items-center gap-1.5 border-b border-border/60 p-2.5">
<Input <Input
@@ -1,4 +1,6 @@
import { lazy, Suspense } from "react"
import { Database, Search } from "lucide-react" import { Database, Search } from "lucide-react"
import { useSelectionChart } from "../api/selection.query"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card" import { Card } from "@/shared/ui/card"
@@ -7,12 +9,21 @@ import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails } from "./pattern-score" import { PatternScoreDetails } from "./pattern-score"
import { categoryToneClass, getCategoryLabel } from "./selection-presentation" import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
import { SignalDetails } from "./signal-details" import { SignalDetails } from "./signal-details"
import { PatternCaseImage } from "./pattern-case-image"
const SelectionChart = lazy(() =>
import("./selection-chart").then((module) => ({
default: module.SelectionChart,
})),
)
interface SignalDetailPanelProps { interface SignalDetailPanelProps {
stock: SelectionStockResult | undefined stock: SelectionStockResult | undefined
} }
export function SignalDetailPanel({ stock }: SignalDetailPanelProps) { export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date)
if (!stock) { if (!stock) {
return ( return (
<Card <Card
@@ -95,6 +106,32 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
<PatternScoreDetails score={stock.score} /> <PatternScoreDetails score={stock.score} />
</section> </section>
<section className="mt-4" aria-labelledby="technical-chart">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="technical-chart"
>
技术图表
</h3>
{chartQuery.isPending ? (
<p className="text-xs text-muted-foreground">正在加载行情图表…</p>
) : chartQuery.isError ? (
<p className="text-xs text-destructive">行情图表暂时不可用。</p>
) : chartQuery.data?.points.length ? (
<Suspense
fallback={
<p className="text-xs text-muted-foreground">正在加载图表组件…</p>
}
>
<SelectionChart chart={chartQuery.data} />
</Suspense>
) : (
<p className="text-xs text-muted-foreground">暂无可用行情数据。</p>
)}
</section>
<PatternCaseImage score={stock.score} />
<section className="mt-4" aria-labelledby="signal-detail-metrics"> <section className="mt-4" aria-labelledby="signal-detail-metrics">
<h3 <h3
className="mb-2 text-xs font-medium text-muted-foreground" className="mb-2 text-xs font-medium text-muted-foreground"
@@ -16,6 +16,7 @@ import { SelectionResultsPage } from "./selection-results-page"
const routerNavigate = vi.hoisted(() => vi.fn()) const routerNavigate = vi.hoisted(() => vi.fn())
const useSelectionResults = vi.fn() const useSelectionResults = vi.fn()
const useSelectionChart = vi.fn()
const useSelectionRun = vi.fn() const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn() const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn() const mutate = vi.fn()
@@ -23,6 +24,7 @@ const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({ vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args), useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionChart: (...args: unknown[]) => useSelectionChart(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args), useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(), useTriggerSelectionRun: () => useTriggerSelectionRun(),
})) }))
@@ -110,6 +112,11 @@ const selectedResult: SelectionResults = {
describe("SelectionResultsPage", () => { describe("SelectionResultsPage", () => {
beforeEach(() => { beforeEach(() => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: selectedResult, data: selectedResult,
isError: false, isError: false,
@@ -167,12 +174,23 @@ describe("SelectionResultsPage", () => {
}) })
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument() expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument()
expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument() expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument()
expect(
within(detailPanel).getByText("暂无可用行情数据。"),
).toBeInTheDocument()
expect( expect(
within(detailPanel).getByText("最佳案例:华纳药厂"), within(detailPanel).getByText("最佳案例:华纳药厂"),
).toBeInTheDocument() ).toBeInTheDocument()
expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument() expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument()
expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2) expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument() expect(within(detailPanel).getByText("RSI")).toBeInTheDocument()
const caseImages = within(detailPanel).getAllByRole("img", {
name: "华纳药厂 B1 参考案例图",
})
expect(caseImages).toHaveLength(1)
expect(caseImages[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg",
)
}) })
it.each<{ it.each<{
@@ -184,14 +202,23 @@ describe("SelectionResultsPage", () => {
score: null, score: null,
}, },
{ {
expected: "未匹配到 60.00 分以上案例。", expected: "低于匹配阈值",
score: { score: {
breakdown: null, breakdown: {
case: null, kdj_state: 42,
reason: "未匹配到评分阈值以上案例", price_shape: 44,
trend_structure: 40,
volume_pattern: 43,
},
case: {
breakout_date: "2025-07-23",
id: "case_004",
name: "方正科技",
},
reason: null,
status: "below_threshold", status: "below_threshold",
threshold: 60, threshold: 60,
value: null, value: 42.5,
version: "zhixing_b1_pattern_fastdtw_v1", version: "zhixing_b1_pattern_fastdtw_v1",
}, },
}, },
@@ -225,9 +252,51 @@ describe("SelectionResultsPage", () => {
const table = screen.getByRole("table") const table = screen.getByRole("table")
expect(within(table).getByText("原始 B1")).toBeInTheDocument() expect(within(table).getByText("原始 B1")).toBeInTheDocument()
expect(within(table).getByText("12")).toBeInTheDocument() expect(within(table).getByText("12")).toBeInTheDocument()
if (score?.status === "below_threshold") {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
const images = within(detailPanel).getAllByRole("img", {
name: "方正科技 B1 参考案例图",
})
expect(images).toHaveLength(1)
expect(images[0]).toHaveAttribute(
"src",
"/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg",
)
expect(within(detailPanel).getAllByText("42.50 分")).toHaveLength(2)
} else {
const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情",
})
expect(within(detailPanel).queryByRole("img")).not.toBeInTheDocument()
}
}, },
) )
it("shows chart loading and error states for the selected stock", () => {
useSelectionChart.mockReturnValue({
data: undefined,
isError: false,
isPending: true,
})
const view = render(<SelectionResultsPage />)
expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument()
expect(useSelectionChart).toHaveBeenLastCalledWith(
"000001.SZ",
"2026-08-08",
)
useSelectionChart.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
view.rerender(<SelectionResultsPage />)
expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument()
})
it("opens execution status details in a drawer and restores trigger focus", async () => { it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { data: {