diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/check.jsonl b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/check.jsonl new file mode 100644 index 0000000..ec28218 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/check.jsonl @@ -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 和详情接口缺口均已闭环"} diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/design.md b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/design.md new file mode 100644 index 0000000..c5ed359 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/design.md @@ -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 契约。案例图片行为测试必须断言详情中恰有一张 ``,其 `src` 由返回的 `case.id` 映射,案例名称和评分与同一响应一致;低于阈值也执行同一断言。 + +回滚可以按层撤销:删除新增详情接口与用例、恢复评分 HTTP 映射、移除 ECharts 组件/依赖和静态 JPG,再恢复原 grid class;没有数据库迁移或不可逆数据写入。 diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.jsonl b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.jsonl new file mode 100644 index 0000000..1eb2230 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.jsonl @@ -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":"现有数据能力、最佳案例图片和契约缺口"} diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.md b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.md new file mode 100644 index 0000000..b8ecd9d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/implement.md @@ -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 中新增穷举案例图片映射和展示组件;测试断言匹配和低于阈值时详情恰有一张 ``,其 `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”顺序撤销即可恢复旧行为。 diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/prd.md b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/prd.md new file mode 100644 index 0000000..f85321d --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/prd.md @@ -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。 diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/echarts.md b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/echarts.md new file mode 100644 index 0000000..f1fbed1 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/echarts.md @@ -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。 diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/repository-evidence.md b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/repository-evidence.md new file mode 100644 index 0000000..10be5ce --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/research/repository-evidence.md @@ -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` 映射,不需要数据库表、图片上传或后端文件服务。 diff --git a/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/task.json b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/task.json new file mode 100644 index 0000000..ad23a6f --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-01-stock-chart-display/task.json @@ -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": {} +} \ No newline at end of file diff --git a/.trellis/workspace/yuxuanhui/index.md b/.trellis/workspace/yuxuanhui/index.md index 5fdf7b8..4dae998 100644 --- a/.trellis/workspace/yuxuanhui/index.md +++ b/.trellis/workspace/yuxuanhui/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 14 +- **Total Sessions**: 15 - **Last Active**: 2026-09-01 @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 15 | 2026-09-01 | 选股模块技术图表迭代 | `9145fe4`, `feb8fd4` | `-` | | 14 | 2026-09-01 | 完成板块资金雷达双榜交互对齐 | `744cb76` | `develop` | | 13 | 2026-09-01 | 板块资金雷达滚动加载迭代 | `be31235`, `5f34be5` | `develop` | | 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` | @@ -51,4 +52,4 @@ - Sessions are appended to journal files - New journal file created when current exceeds 2000 lines -- Use `add_session.py` to record sessions \ No newline at end of file +- Use `add_session.py` to record sessions diff --git a/.trellis/workspace/yuxuanhui/journal-1.md b/.trellis/workspace/yuxuanhui/journal-1.md index 74958c5..4f42b41 100644 --- a/.trellis/workspace/yuxuanhui/journal-1.md +++ b/.trellis/workspace/yuxuanhui/journal-1.md @@ -322,6 +322,14 @@ ### Summary 将板块资金排名改为基于现有分页 API 的无限滚动列表,移除大块描述卡与底部分页器,保留异常和加载更多重试状态;补齐边界测试并通过完整前端检查与浏览器实测。 +## Session 13: 选股模块技术图表迭代 + +**Date**: 2026-09-01 +**Task**: 选股模块技术图表迭代 + +### Summary + +新增选股个股 qfq 日线/成交量/KDJ 图表详情接口;接入 ECharts 复合图、1:3 桌面布局和最佳匹配案例 JPG;补齐低阈值评分契约、前后端测试与浏览器验收。 ### Git Commits @@ -350,6 +358,8 @@ | Hash | Message | |------|---------| | `744cb76` | (see git log) | +| `9145fe4` | (see git log) | +| `feb8fd4` | (see git log) | ### Status diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/chart.py b/zhixing-server/src/zhixing_server/modules/selection/application/chart.py new file mode 100644 index 0000000..68c6e53 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/application/chart.py @@ -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", +] diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py index 40a5caf..b48a746 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -9,6 +9,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, s from pydantic import BaseModel, Field 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 ( RunZhixingB1, ) @@ -17,6 +22,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import ( PatternScore, ZhixingB1PatternScorer, ) +from zhixing_server.modules.selection.domain.ports import MarketDataReaderError from zhixing_server.modules.selection.domain.runs import ( SelectionRerunRequired, SelectionResultQuery, @@ -140,6 +146,30 @@ class SelectionStockResponse(BaseModel): 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]: """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: """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) +@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( "/runs", response_model=SelectionRunAcceptedResponse, @@ -387,13 +446,6 @@ def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreRespons return None if score.status == "failed": 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: return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整") 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( page: int, page_size: int, @@ -440,10 +517,12 @@ def _http_error(code: int, error_type: str, message: str) -> HTTPException: __all__ = [ + "SelectionChartResponse", "SelectionResultsResponse", "SelectionRunAcceptedResponse", "SelectionRunRequest", "SelectionStockResponse", + "get_selection_chart_service", "get_selection_service", "get_selection_postgres_pool", "selection_router", diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index c1df119..33f9326 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -10,6 +10,11 @@ from fastapi.testclient import TestClient import zhixing_server.modules.selection.presentation.http as selection_http from zhixing_server.bootstrap.app import create_app 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.domain.models import SelectionSignal, ZhixingB1Category from zhixing_server.modules.selection.domain.pattern_scoring import ( @@ -18,6 +23,7 @@ from zhixing_server.modules.selection.domain.pattern_scoring import ( PatternScore, PatternScoreBreakdown, ) +from zhixing_server.modules.selection.domain.ports import MarketDataReaderError from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, @@ -30,6 +36,7 @@ from zhixing_server.modules.selection.infrastructure.postgres_reader import ( SelectionMarketDataNotReady, ) from zhixing_server.modules.selection.presentation.http import ( + get_selection_chart_service, get_selection_postgres_pool, get_selection_service, ) @@ -97,6 +104,40 @@ class FakeSelectionService: 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: original_signal = SelectionSignal( 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.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) @@ -295,12 +341,21 @@ def test_query_returns_persisted_signal_details() -> None: ), { "status": "below_threshold", - "value": None, + "value": 42.5, "threshold": 60.0, "version": PATTERN_SCORING_VERSION, - "case": None, - "breakdown": None, - "reason": "未匹配到评分阈值以上案例", + "case": { + "id": "case_001", + "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 +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: service = FakeSelectionService(_run("run-http", "success")) diff --git a/zhixing-server/tests/unit/selection/test_chart.py b/zhixing-server/tests/unit/selection/test_chart.py new file mode 100644 index 0000000..8c56ffc --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_chart.py @@ -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)) diff --git a/zhixing-web/package.json b/zhixing-web/package.json index ad63873..ccd81eb 100644 --- a/zhixing-web/package.json +++ b/zhixing-web/package.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "echarts": "^6.1.0", "lucide-react": "^1.28.0", "react": "^19.2.8", "react-day-picker": "^10.0.1", diff --git a/zhixing-web/pnpm-lock.yaml b/zhixing-web/pnpm-lock.yaml index 8a62633..f6158cd 100644 --- a/zhixing-web/pnpm-lock.yaml +++ b/zhixing-web/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: date-fns: specifier: ^4.4.0 version: 4.4.0 + echarts: + specifier: ^6.1.0 + version: 6.1.0 lucide-react: specifier: ^1.28.0 version: 1.28.0(react@19.2.8) @@ -857,6 +860,9 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + electron-to-chromium@1.5.399: resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} @@ -1484,6 +1490,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1674,6 +1683,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + zustand@5.0.14: resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} engines: {node: '>=12.20.0'} @@ -2414,6 +2426,11 @@ snapshots: 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: {} enhanced-resolve@5.24.5: @@ -2958,6 +2975,8 @@ snapshots: dependencies: typescript: 6.0.3 + tslib@2.3.0: {} + tslib@2.8.1: optional: true @@ -3078,6 +3097,10 @@ snapshots: 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)): optionalDependencies: '@types/react': 19.2.18 diff --git a/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg b/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg new file mode 100644 index 0000000..e93d343 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_001_688799_华纳药厂_2025-05-12.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg b/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg new file mode 100644 index 0000000..069fbc7 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_002_600366_宁波韵升_2025-08-06.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg b/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg new file mode 100644 index 0000000..e0734e8 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_003_688321_微芯生物_2025-06-20.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg b/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg new file mode 100644 index 0000000..60992ca Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_004_600601_方正科技_2025-07-23.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg b/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg new file mode 100644 index 0000000..bd7ad4f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_006_002074_国轩高科_2025-08-04.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg b/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg new file mode 100644 index 0000000..3aa2e5f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_007_605378_野马电池_2025-08-01.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg b/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg new file mode 100644 index 0000000..7817c40 Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_008_600184_光电股份_2025-07-10.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg b/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg new file mode 100644 index 0000000..6ae80fe Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_009_301076_新瀚新材_2025-08-01.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg b/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg new file mode 100644 index 0000000..5b8a5ea Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_010_002940_昂利康_2025-07-11.jpg differ diff --git a/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg b/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg new file mode 100644 index 0000000..1b62d4f Binary files /dev/null and b/zhixing-web/public/patterns/b1-cases/case_011_000547_航天发展_2025-11-12.jpg differ diff --git a/zhixing-web/src/features/selection/api/selection.api.test.ts b/zhixing-web/src/features/selection/api/selection.api.test.ts index abd96d6..cae1a5e 100644 --- a/zhixing-web/src/features/selection/api/selection.api.test.ts +++ b/zhixing-web/src/features/selection/api/selection.api.test.ts @@ -4,7 +4,11 @@ const requestJson = vi.hoisted(() => vi.fn()) vi.mock("@/shared/api/request-json", () => ({ requestJson })) -import { getSelectionResults, getSelectionRun } from "./selection.api" +import { + getSelectionChart, + getSelectionResults, + getSelectionRun, +} from "./selection.api" describe("selection API adapters", () => { beforeEach(() => { @@ -47,4 +51,18 @@ describe("selection API adapters", () => { expect(params.has("category")).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 }) + }) }) diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index 121e4bb..5c667c3 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -1,6 +1,7 @@ import { requestJson } from "@/shared/api/request-json" import type { + SelectionChart, SelectionResults, SelectionResultsQuery, SelectionRunAccepted, @@ -8,6 +9,20 @@ import type { SelectionStrategy, } from "./selection.types" +export function getSelectionChart( + tsCode: string, + targetTradeDate: string, + signal?: AbortSignal, +) { + const params = new URLSearchParams({ + target_trade_date: targetTradeDate, + }) + return requestJson( + `/api/v1/selection/stocks/${encodeURIComponent(tsCode)}/chart?${params.toString()}`, + { signal }, + ) +} + export function getSelectionResults( strategy: SelectionStrategy, targetTradeDate?: string, diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 4667739..085900d 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -7,6 +7,7 @@ import { import { useEffect } from "react" import { + getSelectionChart, getSelectionResults, getSelectionRun, triggerSelectionRun, @@ -17,6 +18,12 @@ import type { SelectionStrategy, } from "./selection.types" +export const selectionChartQueryKey = ( + tsCode?: string, + targetTradeDate?: string, +) => + ["selection", "chart", tsCode ?? "none", targetTradeDate ?? "none"] as const + export const selectionResultsQueryKey = ( strategy: SelectionStrategy, 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( runId: string | null, resultQuery: SelectionResultsQuery = { page: 1, pageSize: 10 }, diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index d947ad6..6358aca 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -58,6 +58,26 @@ export interface SelectionStockResult { 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 { status: "matched" | "below_threshold" | "failed" value: number | null diff --git a/zhixing-web/src/features/selection/components/pattern-case-assets.ts b/zhixing-web/src/features/selection/components/pattern-case-assets.ts new file mode 100644 index 0000000..4e91782 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-assets.ts @@ -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 diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx new file mode 100644 index 0000000..8ff1958 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-image.test.tsx @@ -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() + + 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() + expect( + screen.getByRole("img", { name: "华纳药厂 B1 参考案例图" }), + ).toBeInTheDocument() + }) + + it("does not guess an asset for an unknown case id", () => { + render() + + 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() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + + view.rerender() + expect(screen.queryByRole("img")).not.toBeInTheDocument() + expect(screen.queryByText("案例图片暂不可用。")).not.toBeInTheDocument() + }) +}) diff --git a/zhixing-web/src/features/selection/components/pattern-case-image.tsx b/zhixing-web/src/features/selection/components/pattern-case-image.tsx new file mode 100644 index 0000000..e6d0370 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-case-image.tsx @@ -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(null) + const matchedCase = score?.status === "failed" ? null : score?.case + if (!matchedCase) return null + + const source = + patternCaseImageById[matchedCase.id as keyof typeof patternCaseImageById] + + return ( +
+
+

+ 最接近案例:{matchedCase.name} +

+ + {formatScore(score?.value)} 分 + +
+ {!source || failedSource === source ? ( +

+ 案例图片暂不可用。 +

+ ) : ( + {`${matchedCase.name} setFailedSource(source)} + src={source} + /> + )} +
+ ) +} + +function formatScore(value: number | null | undefined) { + return value === null || value === undefined ? "—" : value.toFixed(2) +} diff --git a/zhixing-web/src/features/selection/components/pattern-score.tsx b/zhixing-web/src/features/selection/components/pattern-score.tsx index a782510..e2fec77 100644 --- a/zhixing-web/src/features/selection/components/pattern-score.tsx +++ b/zhixing-web/src/features/selection/components/pattern-score.tsx @@ -18,13 +18,6 @@ export function PatternScoreSummary({ score }: PatternScoreProps) { if (score.status === "failed") { return 评分暂不可用 } - if (score.status === "below_threshold") { - return ( - - 未匹配到 {formatScore(score.threshold)} 分以上案例 - - ) - } return ( @@ -33,6 +26,9 @@ export function PatternScoreSummary({ score }: PatternScoreProps) { {score.case?.name ?? "最佳案例未知"} + {score.status === "below_threshold" ? ( + 低于阈值 + ) : null} ) } @@ -53,16 +49,6 @@ export function PatternScoreDetails({ score }: PatternScoreProps) { ) } - if (score.status === "below_threshold") { - return ( -
- 低于匹配阈值 -

- 未匹配到 {formatScore(score.threshold)} 分以上案例。 -

-
- ) - } return (
@@ -74,7 +60,9 @@ export function PatternScoreDetails({ score }: PatternScoreProps) { 最佳案例:{score.case?.name ?? "未知"}

- 已匹配 + + {score.status === "matched" ? "已匹配" : "低于匹配阈值"} +
{score.breakdown ? (
diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.test.ts b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts new file mode 100644 index 0000000..b869db6 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart-option.test.ts @@ -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]) + } + }) +}) diff --git a/zhixing-web/src/features/selection/components/selection-chart-option.ts b/zhixing-web/src/features/selection/components/selection-chart-option.ts new file mode 100644 index 0000000..daf8f81 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart-option.ts @@ -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, + } +} diff --git a/zhixing-web/src/features/selection/components/selection-chart.test.tsx b/zhixing-web/src/features/selection/components/selection-chart.test.tsx new file mode 100644 index 0000000..2559af4 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart.test.tsx @@ -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() + + 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( + , + ) + expect(chartMocks.setOption).toHaveBeenCalledTimes(2) + + view.unmount() + expect(chartMocks.disconnect).toHaveBeenCalledTimes(1) + expect(chartMocks.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/zhixing-web/src/features/selection/components/selection-chart.tsx b/zhixing-web/src/features/selection/components/selection-chart.tsx new file mode 100644 index 0000000..feb1694 --- /dev/null +++ b/zhixing-web/src/features/selection/components/selection-chart.tsx @@ -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(null) + const instanceRef = useRef(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 ( +
+ ) +} diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index 5b25772..cd23357 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -122,7 +122,7 @@ export function SelectionResultsWorkbench({ } return ( -
+
+ import("./selection-chart").then((module) => ({ + default: module.SelectionChart, + })), +) interface SignalDetailPanelProps { stock: SelectionStockResult | undefined } export function SignalDetailPanel({ stock }: SignalDetailPanelProps) { + const chartQuery = useSelectionChart(stock?.ts_code, stock?.target_trade_date) + if (!stock) { return (
+
+

+ 技术图表 +

+ {chartQuery.isPending ? ( +

正在加载行情图表…

+ ) : chartQuery.isError ? ( +

行情图表暂时不可用。

+ ) : chartQuery.data?.points.length ? ( + 正在加载图表组件…

+ } + > + +
+ ) : ( +

暂无可用行情数据。

+ )} +
+ + +

vi.fn()) const useSelectionResults = vi.fn() +const useSelectionChart = vi.fn() const useSelectionRun = vi.fn() const useTriggerSelectionRun = vi.fn() const mutate = vi.fn() @@ -23,6 +24,7 @@ const reset = vi.fn() vi.mock("@/features/selection/api/selection.query", () => ({ useSelectionResults: (...args: unknown[]) => useSelectionResults(...args), + useSelectionChart: (...args: unknown[]) => useSelectionChart(...args), useSelectionRun: (...args: unknown[]) => useSelectionRun(...args), useTriggerSelectionRun: () => useTriggerSelectionRun(), })) @@ -110,6 +112,11 @@ const selectedResult: SelectionResults = { describe("SelectionResultsPage", () => { beforeEach(() => { + useSelectionChart.mockReturnValue({ + data: undefined, + isError: false, + isPending: false, + }) useSelectionResults.mockReturnValue({ data: selectedResult, 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(within(detailPanel).getAllByText("J 值")).toHaveLength(2) 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<{ @@ -184,14 +202,23 @@ describe("SelectionResultsPage", () => { score: null, }, { - expected: "未匹配到 60.00 分以上案例。", + expected: "低于匹配阈值", score: { - breakdown: null, - case: null, - reason: "未匹配到评分阈值以上案例", + breakdown: { + kdj_state: 42, + price_shape: 44, + trend_structure: 40, + volume_pattern: 43, + }, + case: { + breakout_date: "2025-07-23", + id: "case_004", + name: "方正科技", + }, + reason: null, status: "below_threshold", threshold: 60, - value: null, + value: 42.5, version: "zhixing_b1_pattern_fastdtw_v1", }, }, @@ -225,9 +252,51 @@ describe("SelectionResultsPage", () => { const table = screen.getByRole("table") expect(within(table).getByText("原始 B1")).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() + + expect(screen.getByText("正在加载行情图表…")).toBeInTheDocument() + expect(useSelectionChart).toHaveBeenLastCalledWith( + "000001.SZ", + "2026-08-08", + ) + + useSelectionChart.mockReturnValue({ + data: undefined, + isError: true, + isPending: false, + }) + view.rerender() + expect(screen.getByText("行情图表暂时不可用。")).toBeInTheDocument() + }) + it("opens execution status details in a drawer and restores trigger focus", async () => { useSelectionResults.mockReturnValue({ data: {