Merge branch 'codex/chart' into develop
# Conflicts: # .trellis/workspace/yuxuanhui/index.md
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 14
|
- **Total Sessions**: 15
|
||||||
- **Last Active**: 2026-09-01
|
- **Last Active**: 2026-09-01
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
<!-- @@@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` |
|
| 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` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -350,6 +358,8 @@
|
|||||||
| Hash | Message |
|
| Hash | Message |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `744cb76` | (see git log) |
|
| `744cb76` | (see git log) |
|
||||||
|
| `9145fe4` | (see git log) |
|
||||||
|
| `feb8fd4` | (see git log) |
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 225 KiB |
@@ -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: {
|
||||||
|
|||||||