Develop #5

Merged
sakibcc merged 6 commits from develop into main 2026-08-09 09:46:02 +08:00
31 changed files with 3566 additions and 14 deletions
Showing only changes of commit 9c1a1eac23 - Show all commits
+87
View File
@@ -81,3 +81,90 @@ first = next(category for category in categories if masks[category].iloc[-1])
target_index = frame.index[frame["trade_date"] == target_trade_date][0] target_index = frame.index[frame["trade_date"] == target_trade_date][0]
matched = tuple(category for category in signal_order if masks[category].iloc[target_index]) matched = tuple(category for category in signal_order if masks[category].iloc[target_index])
``` ```
## Scenario: 持久化策略执行结果与 HTTP 重跑
### 1. Scope / Trigger
- Trigger:为已有历史选股公式增加每日批次持久化、HTTP 触发/查询和 Web 轮询时,沿用
`selection` bounded context;不要让查询请求重新计算公式。
- 触发入口是 `POST /api/v1/selection/runs`,结果读取入口是
`GET /api/v1/selection/runs/{run_id}` 和
`GET /api/v1/selection/results?strategy=...&target_trade_date=...`。
### 2. Signatures
- `SelectionUniverseReader.load_execution_source(strategy: str, target_trade_date: date) -> SelectionExecutionSource`
- `SelectionRunStore.prepare_run(strategy, target_trade_date, source, *, rerun: bool) -> SelectionRun`
- `POST /api/v1/selection/runs` 请求:
`{"strategy": "zhixing_b1", "target_trade_date": "YYYY-MM-DD", "rerun": false}`;成功返回
`202` 和 `{run_id, strategy, target_trade_date, status: "running"}`。
- `selection_run` 的业务唯一键是 `(strategy, target_trade_date)`;
`selection_run_item` 的唯一键是 `(run_id, ts_code)`;
`selection_signal` 的唯一键是 `(run_id, ts_code, category)`。
### 3. Contracts
- `load_execution_source` 必须先确认目标日存在 `strategy_eligible=true` 且已完成的
`success`/`partial_success` 市场同步批次,并且股票为 active 且目标日 qfq bar/basic
完整;来源批次 ID、目标数、实际参与数和 coverage 写入 run。
- 市场数据预检在删除旧结果之前执行。预检失败不得创建新 run,也不得破坏已有终态结果。
- 同一策略同一目标日的重跑在一个短事务内使用 advisory transaction lock,删除旧 run
(依赖子表 `ON DELETE CASCADE`)并创建唯一的新 `running` run;长时间的逐股计算在
事务外执行。
- 查询响应必须返回 run 状态、批次统计、coverage、失败股票和全部独立 signals;同一
股票同日的多 category 不能合并,并按 `ZHIXING_B1_SIGNAL_ORDER` 稳定排序。
- 前端仅在首次无结果时直接触发;已有终态结果或失败重试必须先确认,再传
`rerun=true`。运行中重复请求返回冲突,不能创建第二个当前 run。
### 4. Validation & Error Matrix
| 条件 | 行为 |
| --- | --- |
| 不支持的策略、非法日期或缺少合格市场数据 | `422`,错误码 `market_data_not_ready`(输入校验仍使用 FastAPI 默认 `422`) |
| 同策略同日已有 `running` run | `409`,错误码 `run_in_progress` |
| 已有终态 run 且 `rerun=false` | `409`,错误码 `rerun_confirmation_required` |
| PostgreSQL 读写失败 | `503`,错误码 `selection_storage_unavailable` |
| run ID 不存在 | `404`,错误码 `run_not_found` |
| 单只股票评估抛出异常 | 记录该 item 为 `data_error`,继续其他股票;批次最终为 `partial_success` 或 `failed` |
| 全股票评估完成但没有命中 | 批次为 `success`、`signal_count=0`;前端显示“没有命中信号”,不是“无数据” |
### 5. Good / Base / Bad Cases
- Good:目标日已有合格同步批次,首次 POST 返回 `202`,轮询最终结果保留同一股票的
两个独立 category;确认重跑后旧 signals 随旧 run 级联清除。
- Base:目标日没有结果时查询返回 `200 status=no_data`;查询不触发公式计算,用户可在
页面选择日期后发起首次执行。
- Bad:在市场数据预检前删除旧 run;把多个 category OR 成一条 signal;用浏览器长连接
等待全股票池计算;或把进程异常留下的 `running` 伪装成成功。
### 6. Tests Required
- Domain/application:断言 source 预检先于 `prepare_run`、首次执行、多分类落盘、单股异常
继续执行、全部 no-signal 和失败计数/终态聚合。
- PostgreSQL adapter:用 fake connection 断言 advisory lock、终态重跑删除后插入、运行中
冲突、JSONB details、级联删除契约和公式优先级排序。
- HTTP:用 `TestClient(create_app())` 断言 `202`、`409` 两类冲突、`422` 数据未就绪、
`GET` 无数据、run 轮询和终态 signals。
- Migration:在可用 PostgreSQL 中断言三张 selection 表、唯一键、索引、cascade 外键,
并验证 downgrade 顺序;无数据库时至少生成 offline upgrade/downgrade SQL。
- Frontend:断言加载、查询失败、无数据、执行中、失败、部分成功、无命中、多 category,
以及重跑/失败重试确认取消不发 POST、确认发送 `rerun=true`。
### 7. Wrong vs Correct
#### Wrong
```python
# 先清空旧结果,再去确认目标日输入是否可执行;预检失败会造成数据丢失。
store.delete_current(strategy, target_trade_date)
source = reader.load_execution_source(strategy, target_trade_date)
```
#### Correct
```python
# 先读取并验证来源快照,只有成功 claim 后才允许重跑清理事务。
source = reader.load_execution_source(strategy, target_trade_date)
run = store.prepare_run(strategy, target_trade_date, source, rerun=rerun)
```
@@ -0,0 +1,10 @@
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"检查 HTTP 方法、状态码、Pydantic 响应和 /api/v1 路由组合是否符合项目契约。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"检查执行中冲突、重跑确认、失败状态和查询错误是否可观察且未吞异常。"}
{"file":".trellis/spec/backend/selection.md","reason":"检查目标交易日、qfq 输入、独立子信号和评估状态在批次持久化中没有被破坏。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行后端格式、lint、Pyright、pytest 和黑盒 HTTP 质量检查。"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"检查 Query key、轮询、mutation 和 AbortSignal 的实现方式。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"检查服务器状态没有错误复制到全局 store,运行中状态使用局部/query 状态。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"检查 API 类型和页面状态分支没有使用 any、无解释断言或重复响应形状。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、类型、测试和构建门禁。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查迁移、领域模型、HTTP JSON、前端类型与用户状态的端到端契约。"}
{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"检查策略执行是否只使用合格市场同步批次并携带覆盖率和失败信息。"}
@@ -0,0 +1,187 @@
# 策略执行结果持久化、HTTP 触发与查询设计
## 1. 设计目标
在现有 `selection` bounded context 上补齐一条可重跑的每日策略结果链路:用户在
Web 页面选择目标交易日并触发 `zhixing_b1`,HTTP 快速返回执行批次标识,服务端在
当前进程的异步批次中完成全股票池评估并持久化结果,页面轮询状态后展示信号明细。
一次重跑必须在同一数据库事务中清除指定“策略 + 目标交易日”的旧结果并创建新的
运行记录,避免旧信号和新信号混在一起。执行中的重复请求只返回冲突,不得并发清理
或重复计算同一批次。
本期不引入独立任务队列、定时器或其他策略;HTTP 是触发入口,FastAPI 进程内的
`BackgroundTasks` 是异步执行机制。
## 2. 上下文边界与模块分工
```text
zhixing-server/src/zhixing_server/modules/selection/
├── domain/
│ ├── models.py # 已有行情、信号和单股评估模型
│ ├── ports.py # 市场数据读取端口
│ └── runs.py # 批次状态、持久化读写端口和执行结果模型
├── application/
│ ├── evaluate.py # 已有单股评估用例
│ └── run.py # 全股票池批次编排、重跑和失败收敛
├── infrastructure/
│ ├── postgres_reader.py # 已有单股 qfq 历史读取,补充执行股票池读取
│ └── postgres_runs.py # selection 批次、item、signal 的 PostgreSQL 适配器
└── presentation/
└── http.py # Pydantic 请求/响应和 HTTP 依赖
```
- `selection.domain` 不依赖 FastAPI、Psycopg 或 PostgreSQL JSON 类型。
- `selection.application` 只依赖端口;批次执行负责逐股调用已有
`EvaluateZhixingB1`,不复制公式逻辑。
- `selection.infrastructure` 负责事务、锁、SQL、JSONB 序列化和市场同步批次关联。
- `selection.presentation.http` 只做边界校验、HTTP 状态映射和领域模型转换;由
`interfaces/http/router.py` 在 `/api/v1/selection` 下挂载。
- 前端新增 `features/selection` 垂直切片;页面可以依赖 `HomeShell` 和 shared UI,
`shared` 不反向依赖 selection。
## 3. 持久化模型与重跑事务
新增 Alembic migration `0002_selection_results`,同时更新
`modules/market_data/infrastructure/schema.py` 的 metadata。使用三张表:
### 3.1 `selection_run`
一行代表某个策略、目标交易日的一次当前执行尝试。
- `id`:UUID 字符串主键,作为异步轮询的 `run_id`。
- `strategy`、`target_trade_date`:业务身份;建立唯一约束,保证同一时点只有一条
当前尝试。
- `market_sync_batch_id`:引用产生输入数据的市场同步批次标识;跨上下文先保存
稳定 ID,不改变市场数据 bounded context 的写入所有权。
- `status`:`running`、`success`、`partial_success`、`failed`。
- `target_count`、`eligible_count`、`evaluated_count`、`selected_stock_count`、
`signal_count`、`failed_count`:批次汇总计数。
- `coverage`:从市场同步批次复制的覆盖率,使用 Numeric 保存精度。
- `error_type`、`error_message`:批次级失败上下文,可空且不保存 traceback 或凭据。
- `created_at`、`finished_at`:审计时间。
`selection_run_item` 以 `(run_id, ts_code)` 为主键,保存每只参与股票的名称、
评估状态、信号数量和可读原因。状态沿用领域评估状态:`selected`、`no_signal`、
`insufficient_history`、`missing_target_bar`、`data_error`。
`selection_signal` 以 `(run_id, ts_code, category)` 为主键,保存股票、目标日、
策略、子信号分类、qfq 收盘价和 JSONB `details`。同一股票同日的多个 category
分别落行,查询时按代码和公式优先级稳定排序。
### 3.2 首次执行、重跑和并发
`prepare_run(strategy, target_trade_date, rerun)` 在一个短事务中完成:
1. 使用按策略和日期派生的 PostgreSQL advisory transaction lock,串行化同一业务键。
2. 查询当前 `selection_run`。
3. `running` 时拒绝请求,返回 `409 run_in_progress`。
4. 已有终态且 `rerun=false` 时返回 `409 rerun_confirmation_required`;页面只有在
用户确认弹窗后才发送 `rerun=true`。
5. `rerun=true` 时删除旧 run(子表使用 `ON DELETE CASCADE`),再插入新的 `running`
run;删除与创建同事务提交。
6. 没有旧 run 时直接插入新的 `running` run。
事务提交后才注册 `BackgroundTasks`。后台执行异常会把 run 收敛为 `failed`;单只
股票异常记录到 `selection_run_item`,其余股票继续执行,最后根据失败数量和命中
结果写入 `success`、`partial_success` 或 `failed`。
进程在批次运行中崩溃会留下 `running` 状态;本期将其作为可见的执行中状态,并在
后续恢复机制中再增加超时接管。该限制必须在运维风险中保留,不能伪装成成功结果。
## 4. 执行数据流
1. HTTP 收到策略、目标交易日和 `rerun`,边界只允许当前支持的 `zhixing_b1`。
2. application 通过 selection 端口读取目标日最新的 `market_sync_batch`,只允许
`strategy_eligible=true` 的同步批次作为输入;没有可用批次则在任何清理/创建 run
事务之前返回 `422 market_data_not_ready`,不使用当前最新日期猜测目标日,也不破坏
已有的成功结果。
3. 读取当前 `market_stock.is_active=true` 且目标日同时拥有 bar/basic 的有效股票
集合;`target_count` 和 `coverage` 来自同步批次,`eligible_count` 来自实际输入。
4. 对每只股票调用已有 `PostgresMarketDataReader.load_history` 和
`EvaluateZhixingB1.execute`,将 item 状态和全部独立 signals 写入当前 run。
5. 完成后一次更新 run 汇总和 `finished_at`;查询端只读取已提交的持久化状态。
全股票池执行先使用现有“逐股票读取”的正确性优先方案,不在本任务引入并行化或
缓存;若性能不足,后续再以批量历史读取为单独设计。
## 5. HTTP 契约
### 5.1 触发
`POST /api/v1/selection/runs`
请求:
```json
{
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"rerun": false
}
```
成功返回 `202`:
```json
{
"run_id": "<uuid>",
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"status": "running"
}
```
错误状态至少包括:
- `409 run_in_progress`:同一策略和目标日已有运行中的批次;
- `409 rerun_confirmation_required`:已有终态结果但请求没有 `rerun=true`;
- `422`:策略、日期或市场数据资格不满足请求契约;
- `503`:无法创建批次或数据库不可用。
### 5.2 轮询与结果查询
- `GET /api/v1/selection/runs/{run_id}`:按 run ID 返回批次状态;运行中返回汇总,
终态追加 item 失败列表和 signals。
- `GET /api/v1/selection/results?strategy=zhixing_b1&target_trade_date=...`:按业务
键查询当前结果。目标日省略时取该策略最近一条当前 run;没有结果返回 `200` 的
`status=no_data`,不把“没有执行”伪装成 HTTP 异常。
稳定响应包含策略、目标日、run ID、状态、市场同步批次、计数、coverage、错误/失败
列表和 signal 明细。日期使用 ISO `date`,时间使用带时区的 ISO `datetime`。字段不
直接暴露数据库列名以外的内部异常信息。
## 6. 前端交互
- 新增 `/selection` 路由,启用 `HomeShell` 的“选股策略”导航。
- 页面提供目标交易日选择,默认查询最近持久化结果;策略下拉首期只显示“知行 B1”。
- 首次无结果时显示“执行策略”;已有成功、部分成功或失败结果时显示“重新执行/
重试执行”,点击先打开确认 Dialog,取消不调用 POST,确认才发送 `rerun=true`。
- POST 成功后保存 `run_id` 到组件局部状态,使用 React Query 轮询 run;运行中展示
状态和刷新提示,终态失效业务键查询并显示结果。
- 页面明确区分加载中、查询错误、无数据、执行中、执行失败、无命中、部分成功和
成功;signals 以每个 category 一行或可辨认的标签展示,同一股票的多分类不能合并
成一条无分类记录。
- API 类型、query key、mutation 和轮询逻辑全部位于 `features/selection/api/`,
页面不直接调用 `fetch`,不把服务器结果复制到 Zustand。
## 7. 兼容性与回滚
- 不修改 `market_stock`、行情事实表或现有同步批次的语义;只读取其
`strategy_eligible`、coverage 和目标日输入。
- migration downgrade 按 signals → items → runs 删除新表;删除 selection 结果
不影响市场数据。
- 如果异步机制或全市场性能不满足,保留已提交的迁移和领域契约,后续替换执行器;
不回退到即时查询或删除持久化结果。
- 进程崩溃遗留 `running` 和当前实现逐股票读取是已知风险,作为后续任务候选记录。
## 8. 验证策略
- domain/application:Fake reader/repository 覆盖首次执行、重跑先清空、运行中冲突、
全部状态聚合、多分类落盘和单股失败继续执行。
- infrastructure:fake psycopg connection 覆盖参数化查询、事务顺序、级联清理、
JSONB details、市场同步资格和稳定排序。
- HTTP:`TestClient(create_app())` 覆盖 `202`、`409`、`422`、无数据查询、轮询和
终态响应;后台执行依赖通过 FastAPI override 或 fake service 注入。
- frontend:页面测试覆盖初次执行、确认弹窗取消/确认、轮询状态、失败重试、无命中、
多分类和查询错误;运行格式、lint、类型、Vitest 和生产构建。
@@ -0,0 +1,17 @@
{"file":".trellis/spec/backend/index.md","reason":"确认后端 bounded context、HTTP 入口和开发前检查。"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"按 selection 的 domain/application/infrastructure/presentation 边界组织持久化、执行和 HTTP 代码。"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"实现 /api/v1 路由目录、Pydantic 响应模型、依赖注入和 TestClient 契约。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"为重跑冲突、执行失败、查询失败和 FastAPI 边界建立稳定错误映射。"}
{"file":".trellis/spec/backend/selection.md","reason":"复用 zhixing_b1 的目标日、qfq、独立子信号和评估状态契约。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵守 Ruff、Pyright、pytest、公开类型和 HTTP 测试门禁。"}
{"file":".trellis/spec/frontend/index.md","reason":"确认 React feature 垂直切片、同源 API 和前端质量检查。"}
{"file":".trellis/spec/frontend/directory-structure.md","reason":"将 selection API、页面、路由和组件放入正确的 feature 边界。"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"实现 React Query 查询、轮询和 mutation,不在页面直接 fetch。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"将运行状态和结果留在 React Query/局部状态,不复制到 Zustand。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"实现可访问的结果页面、表格状态和重跑确认 Dialog。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"保持后端响应类型、字面量状态和严格 TypeScript 一致。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、测试和构建检查。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"同步数据库、领域、HTTP、前端 API 类型和页面状态的跨层契约。"}
{"file":"docs/adr/0001-bounded-context-first-modular-monolith.md","reason":"确认 selection 持久化与 HTTP 仍归属模块化单体的明确 bounded context。"}
{"file":"docs/adr/0003-postgresql-as-market-data-store.md","reason":"确认 PostgreSQL 是策略输入事实源,CSV 不承担运行时结果查询。"}
{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"遵守策略批次关联市场同步批次、覆盖率和策略资格契约。"}
@@ -0,0 +1,137 @@
# 策略执行结果持久化与查询实施计划
## 实施原则
- 只修改 `zhixing-system`;保留用户已有的 `CONTEXT.md` 和 ADR 工作区变更。
- 先锁定数据库事务、批次状态和 HTTP 响应测试,再接入前端交互。
- 复用已有 `EvaluateZhixingB1`、`PostgresMarketDataReader` 和 shared UI,不复制公式
逻辑或网络 transport。
- 任何重跑都必须通过“策略 + 目标日”业务键清空旧结果;不要用插入多版本结果来
规避唯一约束。
- 后台任务只承担当前进程内异步执行;不得在本任务擅自引入任务队列、定时器或新的
外部服务。
## 1. 规划复核与数据库契约
- [x] 将 `prd.md`、`design.md`、本文件从头读一遍,确认产品决策、验收标准和实现
细节没有重复或冲突。
- [x] 阅读 `.trellis/spec/backend`、`.trellis/spec/frontend` 及 cross-layer guide,
确认 migration、HTTP、前端类型和测试边界。
- [x] 设计并实现 `0002_selection_results`:`selection_run`、`selection_run_item`、
`selection_signal`、唯一约束、状态索引、JSONB details 和安全 downgrade。
- [x] 更新 `modules/market_data/infrastructure/schema.py` metadata,使 Alembic
离线/在线上下文包含新表。
验证:
```bash
cd zhixing-server
uv run alembic check
uv run pytest tests/integration/test_market_data_migration.py
```
回滚点:migration 或 schema metadata 不一致时只回滚新 migration 和 selection 表,
不修改 `0001_market_data`。
## 2. 领域端口与 PostgreSQL 适配器
- [x] 在 selection domain 增加批次状态、股票 item、持久化查询模型和明确端口协议。
- [x] 为 `PostgresMarketDataReader` 增加读取当前有效执行股票集合的能力,使用目标日、
`market_sync_batch.strategy_eligible`、active stock 和 bar/basic 完整性约束。
- [x] 在清理旧结果前完成市场同步资格预检;目标日没有可用合格同步批次时返回
`market_data_not_ready`,保留已有结果不做破坏性变更。
- [x] 新增 selection PostgreSQL repository:短事务创建/删除/完成 run、记录 item 和
signal、按 run 或业务键读取;写入 details 时保持 JSON 可序列化。
- [x] 以 advisory transaction lock、终态检查和 `rerun` 参数实现首次执行、重复执行、
运行中冲突;清理和创建必须在同一事务。
验证:
```bash
cd zhixing-server
uv run pytest tests/unit/selection/test_postgres_runs.py
uv run pytest tests/unit/selection/test_postgres_reader.py
```
回滚点:若 SQL 适配器无法通过 fake connection 测试,保留纯领域端口和 migration,
先修适配器,不触碰已有市场数据写入代码。
## 3. 全股票池执行用例与异步服务
- [x] 实现 `RunZhixingB1`:准备 run、读取有效股票、逐只调用已有评估用例、保存 item
和全部信号、汇总并完成 run。
- [x] 为 selected/no_signal/insufficient_history/missing_target_bar/data_error 建立
明确计数和终态映射;单股失败不影响其他股票,批次级异常收敛为 failed。
- [x] 为后台执行提供可注入的 service/worker 入口,确保 HTTP 响应提交后才调度,异常
时更新持久化状态;避免把数据库连接对象跨请求/跨线程复用。
- [x] 为首次执行、重跑清理、重复运行冲突、失败重试和多分类信号编写应用测试。
验证:
```bash
cd zhixing-server
uv run pytest tests/unit/selection/test_run.py
uv run pytest tests/unit/selection/test_evaluate.py
```
## 4. HTTP 接口与后端契约测试
- [x] 在 `selection/presentation/http.py` 定义请求和响应 Pydantic 模型,稳定声明日期、
状态、计数、coverage、失败 item 和 signal details。
- [x] 实现 `POST /api/v1/selection/runs`,成功返回 `202 + run_id`;将
`run_in_progress`、`rerun_confirmation_required`、输入错误和数据库错误映射为稳定
HTTP 响应。
- [x] 实现 `GET /api/v1/selection/runs/{run_id}` 和按策略/日期查询结果的 GET 端点;
无当前结果返回 `status=no_data`,不要制造假的成功结果。
- [x] 在顶层 router 挂载 selection router,保持 `/api/v1` 唯一目录入口;依赖使用
`Settings` 注入并提供测试 override。
- [x] 用 `TestClient(create_app())` 覆盖真实路由、202、冲突、无数据、轮询和终态 JSON。
验证:
```bash
cd zhixing-server
uv run pytest tests/test_selection_http.py
```
## 5. 前端 feature 与交互
- [x] 新增 `features/selection/api/selection.types.ts`、`selection.api.ts`、
`selection.query.ts`,实现结果查询、run 查询/轮询和触发 mutation。
- [x] 新增 selection 页面及结果表/统计卡片/重跑确认 Dialog,覆盖初次执行、成功、
无命中、失败、部分成功、查询错误和运行中状态。
- [x] 为“选股策略”启用 `/selection` 路由和导航,保持 HomeShell 的布局及可访问性;
不让未实现的其他导航入口误显为可用。
- [x] 重跑或失败重试按钮只打开确认框;取消不调用 POST,确认后才传 `rerun=true`,
并在返回的 run 完成后刷新结果查询。
- [x] 同一股票多 category 用独立 badge/行展示,details 只显示后端稳定字段。
- [x] 更新页面测试和路由相关测试,使用 query hook mock,不依赖真实后端。
验证:
```bash
cd zhixing-web
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
## 6. 全量质量检查与交付门禁
- [x] 运行后端 Ruff、Pyright、pytest;运行前端格式、lint、typecheck、test、build。
- [x] 检查 `rg -n "except Exception|fetch\(|xg_composite"`,确认没有吞异常、页面直
接请求或旧策略运行时依赖。
- [x] 检查 migration downgrade、重复执行事务、run status 与前端轮询是否一致。
- [x] 使用 `task.py validate` 校验任务清单和上下文 manifests,再由 `trellis-check`
做最终规格/跨层检查。
## 风险与回滚点
- 新表/适配器失败:只回滚 `0002_selection_results` 和 selection 新代码,不撤销市场
数据 migration 或用户既有修改。
- 后台任务进程崩溃:当前 run 可能停在 `running`;必须在页面可见并记录为后续恢复
机制,不将其当作成功。
- 全股票池逐只读取过慢:保留结果契约和端口,后续在 reader/application 层做批量读
取;本任务不通过放宽历史数据或减少股票池来掩盖性能问题。
@@ -0,0 +1,83 @@
# 补充策略执行结果查询接口和前端页面
## Goal
为已完成的知行 B1 历史选股逻辑提供可使用的查询入口,让研究人员能够在 Web
页面选择目标交易日并查看策略命中的股票及子信号结果。
## Background and confirmed facts
- 上一个任务 `08-08-migrate-zhixing-b1` 已完成 `zhixing_b1` 策略领域逻辑、历史
qfq 行情读取适配器和单只股票评估用例。
- 当前领域入口是
`EvaluateZhixingB1.execute(ts_code, target_trade_date)`,返回
`SelectionEvaluation`,状态包括 `selected`、`no_signal`、`insufficient_history`、
`missing_target_bar` 和 `data_error`。
- `SelectionSignal` 已包含股票代码、名称、目标交易日、策略标识、七种独立子信号
分类、收盘价和可序列化详情;稳定身份为
`(ts_code, target_trade_date, strategy, category)`。
- 当前没有选股结果数据库表、结果持久化、全市场批量执行用例或 selection HTTP
路由;selection presentation 包仍为空。
- 后端 HTTP 通过 `/api/v1` 统一挂载,稳定响应使用 Pydantic 模型;前端按 feature
组织 API 类型、Query hook 和页面,并通过同源 `/api/v1/...` 请求后端。
- 当前前端首页侧栏的“选股策略”入口仍是禁用按钮,路由树只有 `/` 首页。
## Product decisions
- 结果按每日策略批次持久化;页面查询已保存的策略执行批次,不在查询请求中重新
计算策略。
- 策略执行通过 HTTP 触发,不新增 CLI 作为本任务的主要入口。
- 当用户重复执行或重试失败批次时,服务端必须先清空指定目标交易日、指定策略的
旧结果,再执行一次;前端在每次重执行前弹窗确认,确认后才发起 HTTP 请求。
- 执行接口需要能区分首次执行、已有结果的重复执行和执行中的冲突,不能因重复点击
产生重复信号或多个互相冲突的当前结果。
- HTTP 采用异步批次模式:`POST` 只创建/清空并启动批次,返回 `202` 和 `run_id`;
前端通过 `GET` 轮询批次状态,完成后展示持久化结果。全股票池计算不得要求浏览器
长时间保持原始执行请求。
## Requirements
- 策略执行结果必须按目标交易日和策略批次持久化,并可被后续 HTTP 查询读取。
- `zhixing_b1` 的多种独立子信号必须分别保存,不能因同一股票同日多分类而覆盖或
合并;结果身份继续遵循
`(ts_code, target_trade_date, strategy, category)`。
- 查询页面展示已保存批次的执行状态、目标交易日、参与数量、命中数量以及股票和
子信号明细;查询失败、执行失败和无命中必须有可区分的用户可见状态。
- 同一策略同一目标交易日重复执行必须具备幂等语义,不能产生重复信号或多个互相
冲突的“最新结果”;按用户确认的重跑规则,重跑前清空该日该策略旧结果。
- 结果应关联产生它所依赖的市场数据同步批次,并保留实际参与股票数和数据覆盖率,
以便判断结果是否完整。
- HTTP 应提供策略执行触发接口,支持指定目标交易日和策略;首次执行、重跑确认、
执行中冲突和失败重试应有明确响应语义。
- HTTP 响应使用稳定的 Pydantic 契约;前端使用 feature API 类型、React Query 和
独立的策略结果页面,不在页面中直接发起 `fetch`。
## Acceptance Criteria
- [ ] 已执行的 `zhixing_b1` 批次及其信号结果可以写入 PostgreSQL,并能通过稳定
唯一身份幂等重跑;重跑不会残留上一次执行的信号。
- [ ] HTTP 可以查询最新批次和/或指定目标交易日的持久化结果,响应包含批次状态、
覆盖率、命中统计和全部独立子信号明细。
- [ ] HTTP 可以触发指定目标交易日和策略的执行;已有结果重跑和失败重试遵循清空后
重算规则,并对执行中的重复请求返回可识别冲突。
- [ ] 前端“选股策略”入口可进入结果页面,能够查看加载中、无数据、执行失败、查询
失败、无命中和正常结果状态。
- [ ] 前端每次重跑/失败重试都会先展示确认弹窗;取消不会发起执行请求,确认后能显示
执行中状态并刷新持久化结果。
- [ ] 执行触发接口返回异步批次标识,前端可通过状态查询感知运行中、成功、无命中和
失败,并在结束后读取同一批次结果。
- [ ] 结果页面的股票明细能区分同一股票同日命中的多个子信号,并展示目标交易日、
股票代码/名称、收盘价及关键详情。
- [ ] 后端迁移、应用用例、HTTP 契约和前端页面测试覆盖成功、重复执行、无结果和
失败场景。
## Out of scope
- 不迁移其他选股策略,不改变 `zhixing_b1` 公式语义或市场数据同步规则。
- 不实现收益率、持仓、交易撮合或实盘交易能力;本任务展示的是选股信号结果。
- 不把执行调度扩展为独立任务队列或跨进程工作流;本期只提供 HTTP 触发和项目现有
运行边界内的异步批次执行机制。
## Open questions
无。
@@ -0,0 +1,26 @@
{
"id": "strategy-execution-results",
"name": "strategy-execution-results",
"title": "补充策略执行结果查询接口和前端页面",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-08",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,112 @@
"""Create persisted selection run and signal result tables."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0002_selection_results"
down_revision: str | None = "0001_market_data"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create the current selection-run result model."""
op.create_table(
"selection_run",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("strategy", sa.String(64), nullable=False),
sa.Column("target_trade_date", sa.Date(), nullable=False),
sa.Column("market_sync_batch_id", sa.String(36), nullable=False),
sa.Column("status", sa.String(24), nullable=False),
sa.Column("target_count", sa.Integer(), nullable=False),
sa.Column("eligible_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("evaluated_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("selected_stock_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("signal_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("failed_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("coverage", sa.Numeric(8, 6), nullable=False, server_default="0"),
sa.Column("error_type", sa.String(64)),
sa.Column("error_message", sa.Text()),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("finished_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint(
"strategy",
"target_trade_date",
name="uq_selection_run_strategy_date",
),
)
op.create_index(
"ix_selection_run_status_date",
"selection_run",
["strategy", "status", "target_trade_date"],
)
op.create_table(
"selection_run_item",
sa.Column(
"run_id",
sa.String(36),
sa.ForeignKey("selection_run.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("ts_code", sa.String(12), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("status", sa.String(32), nullable=False),
sa.Column("signal_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("reason", sa.Text()),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("run_id", "ts_code"),
)
op.create_index("ix_selection_run_item_status", "selection_run_item", ["run_id", "status"])
op.create_table(
"selection_signal",
sa.Column(
"run_id",
sa.String(36),
sa.ForeignKey("selection_run.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("ts_code", sa.String(12), nullable=False),
sa.Column("name", sa.String(128), nullable=False),
sa.Column("target_trade_date", sa.Date(), nullable=False),
sa.Column("strategy", sa.String(64), nullable=False),
sa.Column("category", sa.String(64), nullable=False),
sa.Column("close", sa.Numeric(20, 6), nullable=False),
sa.Column("details", JSONB, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("run_id", "ts_code", "category"),
)
op.create_index(
"ix_selection_signal_strategy_date",
"selection_signal",
["strategy", "target_trade_date", "ts_code"],
)
def downgrade() -> None:
"""Drop selection result tables in dependency-safe order."""
op.drop_index("ix_selection_signal_strategy_date", table_name="selection_signal")
op.drop_table("selection_signal")
op.drop_index("ix_selection_run_item_status", table_name="selection_run_item")
op.drop_table("selection_run_item")
op.drop_index("ix_selection_run_status_date", table_name="selection_run")
op.drop_table("selection_run")
@@ -4,9 +4,11 @@ from fastapi import APIRouter
from zhixing_server.interfaces.http.system import operational_router, system_router from zhixing_server.interfaces.http.system import operational_router, system_router
from zhixing_server.modules.market_data.presentation.home import home_router from zhixing_server.modules.market_data.presentation.home import home_router
from zhixing_server.modules.selection.presentation.http import selection_router
api_v1_router = APIRouter(prefix="/api/v1") api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(system_router, prefix="/system", tags=["system"]) api_v1_router.include_router(system_router, prefix="/system", tags=["system"])
api_v1_router.include_router(home_router, prefix="/home", tags=["home"]) api_v1_router.include_router(home_router, prefix="/home", tags=["home"])
api_v1_router.include_router(selection_router, prefix="/selection", tags=["selection"])
__all__ = ["api_v1_router", "operational_router"] __all__ = ["api_v1_router", "operational_router"]
@@ -5,6 +5,8 @@ from sqlalchemy import (
Column, Column,
Date, Date,
DateTime, DateTime,
ForeignKey,
Index,
Integer, Integer,
MetaData, MetaData,
Numeric, Numeric,
@@ -12,8 +14,10 @@ from sqlalchemy import (
String, String,
Table, Table,
Text, Text,
UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB
metadata = MetaData() metadata = MetaData()
@@ -114,3 +118,78 @@ market_sync_item = Table(
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("batch_id", "item_kind", "item_key"), PrimaryKeyConstraint("batch_id", "item_kind", "item_key"),
) )
selection_run = Table(
"selection_run",
metadata,
Column("id", String(36), primary_key=True),
Column("strategy", String(64), nullable=False),
Column("target_trade_date", Date, nullable=False),
Column("market_sync_batch_id", String(36), nullable=False),
Column("status", String(24), nullable=False),
Column("target_count", Integer, nullable=False),
Column("eligible_count", Integer, nullable=False, server_default="0"),
Column("evaluated_count", Integer, nullable=False, server_default="0"),
Column("selected_stock_count", Integer, nullable=False, server_default="0"),
Column("signal_count", Integer, nullable=False, server_default="0"),
Column("failed_count", Integer, nullable=False, server_default="0"),
Column("coverage", Numeric(8, 6), nullable=False, server_default="0"),
Column("error_type", String(64)),
Column("error_message", Text),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("finished_at", DateTime(timezone=True)),
UniqueConstraint("strategy", "target_trade_date", name="uq_selection_run_strategy_date"),
)
selection_run_item = Table(
"selection_run_item",
metadata,
Column(
"run_id", String(36), ForeignKey("selection_run.id", ondelete="CASCADE"), nullable=False
),
Column("ts_code", String(12), nullable=False),
Column("name", String(128), nullable=False),
Column("status", String(32), nullable=False),
Column("signal_count", Integer, nullable=False, server_default="0"),
Column("reason", Text),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("run_id", "ts_code"),
)
selection_signal = Table(
"selection_signal",
metadata,
Column(
"run_id", String(36), ForeignKey("selection_run.id", ondelete="CASCADE"), nullable=False
),
Column("ts_code", String(12), nullable=False),
Column("name", String(128), nullable=False),
Column("target_trade_date", Date, nullable=False),
Column("strategy", String(64), nullable=False),
Column("category", String(64), nullable=False),
Column("close", Numeric(20, 6), nullable=False),
Column("details", JSONB, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("run_id", "ts_code", "category"),
)
# Keep the declarative metadata aligned with the indexes created by the
# Alembic revisions. Alembic uses this object for both offline inspection
# and future autogeneration, so omitting these indexes would make the schema
# appear drifted even though the migration creates them.
Index("ix_market_daily_bar_trade_date", market_daily_bar.c.trade_date)
Index("ix_market_daily_basic_trade_date", market_daily_basic.c.trade_date)
Index("ix_market_sync_item_status", market_sync_item.c.batch_id, market_sync_item.c.status)
Index(
"ix_selection_run_status_date",
selection_run.c.strategy,
selection_run.c.status,
selection_run.c.target_trade_date,
)
Index("ix_selection_run_item_status", selection_run_item.c.run_id, selection_run_item.c.status)
Index(
"ix_selection_signal_strategy_date",
selection_signal.c.strategy,
selection_signal.c.target_trade_date,
selection_signal.c.ts_code,
)
@@ -0,0 +1,187 @@
"""Application orchestration for persisted whole-universe B1 runs."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from typing import Literal, Protocol
from ..domain.models import SelectionEvaluation
from ..domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunInProgress,
SelectionRunItem,
SelectionRunStatus,
SelectionRunStore,
SelectionUniverseReader,
)
from .evaluate import EvaluateZhixingB1
logger = logging.getLogger(__name__)
StrategyName = Literal["zhixing_b1"]
_FAILURE_STATUSES = {"insufficient_history", "missing_target_bar", "data_error"}
class SelectionEvaluator(Protocol):
"""Minimal single-stock evaluator required by the batch orchestrator."""
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation: ...
@dataclass(frozen=True, slots=True)
class PreparedSelectionRun:
"""A claimed run and its immutable market-data source snapshot."""
run: SelectionRun
source: SelectionExecutionSource
class RunZhixingB1:
"""Prepare, execute, and query persisted Zhixing B1 result batches."""
def __init__(
self,
reader: SelectionUniverseReader,
store: SelectionRunStore,
evaluator: SelectionEvaluator | None = None,
) -> None:
"""Inject storage ports and optionally a test evaluator."""
self.reader = reader
self.store = store
self.evaluator = evaluator or EvaluateZhixingB1(reader)
def prepare(
self,
strategy: StrategyName,
target_trade_date: date,
*,
rerun: bool,
) -> PreparedSelectionRun:
"""Validate source eligibility before claiming the rerunnable key."""
source = self.reader.load_execution_source(strategy, target_trade_date)
run = self.store.prepare_run(
strategy,
target_trade_date,
source,
rerun=rerun,
)
return PreparedSelectionRun(run=run, source=source)
def execute(self, prepared: PreparedSelectionRun) -> None:
"""Evaluate every eligible stock and converge the persisted run status.
This method is the boundary used by FastAPI's in-process background
task. An unexpected batch-level error is recorded before the worker
returns so the UI never mistakes a lost worker exception for success.
"""
evaluated_count = 0
selected_stock_count = 0
signal_count = 0
failed_count = 0
try:
for stock in prepared.source.stocks:
try:
evaluation = self.evaluator.execute(
stock.ts_code,
prepared.source.target_trade_date,
)
except Exception as exc: # noqa: BLE001 - isolate one stock from the batch
logger.exception(
"selection_item_failed run_id=%s ts_code=%s",
prepared.run.id,
stock.ts_code,
)
evaluation = SelectionEvaluation(
ts_code=stock.ts_code,
target_trade_date=prepared.source.target_trade_date,
status="data_error",
reason=_safe_item_error(exc),
)
item = _to_item(stock.ts_code, stock.name, evaluation)
self.store.record_item(prepared.run.id, item)
evaluated_count += 1
selected_stock_count += evaluation.status == "selected"
signal_count += len(evaluation.signals)
failed_count += evaluation.status in _FAILURE_STATUSES
status = _run_status(evaluated_count, failed_count)
self.store.finish_run(
prepared.run.id,
status,
evaluated_count=evaluated_count,
selected_stock_count=selected_stock_count,
signal_count=signal_count,
failed_count=failed_count,
)
except Exception as exc: # noqa: BLE001 - worker boundary must persist failure state
logger.exception("selection_run_failed run_id=%s", prepared.run.id)
try:
self.store.finish_run(
prepared.run.id,
"failed",
evaluated_count=evaluated_count,
selected_stock_count=selected_stock_count,
signal_count=signal_count,
failed_count=max(failed_count, 1),
error_type="batch_error",
error_message=str(exc),
)
except Exception: # noqa: BLE001 - preserve the original worker failure
logger.exception("selection_run_failure_persist_failed run_id=%s", prepared.run.id)
def get_run(self, run_id: str) -> SelectionRun | None:
"""Read one persisted run for polling."""
return self.store.get_run(run_id)
def get_latest(
self,
strategy: StrategyName,
target_trade_date: date | None = None,
) -> SelectionRun | None:
"""Read the current result by date or the latest result for a strategy."""
return self.store.get_latest_run(strategy, target_trade_date)
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
"""Translate a single-stock domain result into a stored item."""
return SelectionRunItem(
ts_code=ts_code,
name=name or (evaluation.signals[0].name if evaluation.signals else ""),
status=evaluation.status,
signal_count=len(evaluation.signals),
reason=evaluation.reason,
signals=evaluation.signals,
)
def _run_status(evaluated_count: int, failed_count: int) -> SelectionRunStatus:
"""Map per-stock outcomes into a visible batch status."""
if failed_count == 0:
return "success"
if evaluated_count == 0 or failed_count >= evaluated_count:
return "failed"
return "partial_success"
def _safe_item_error(error: Exception) -> str:
"""Keep per-stock failure context readable without persisting tracebacks."""
return " ".join(str(error).split())[:500] or error.__class__.__name__
__all__ = [
"PreparedSelectionRun",
"RunZhixingB1",
"SelectionRerunRequired",
"SelectionRunInProgress",
]
@@ -6,6 +6,7 @@ from datetime import date
from typing import Protocol from typing import Protocol
from .models import StockHistory from .models import StockHistory
from .runs import SelectionExecutionSource, SelectionUniverseReader
class MarketDataReaderError(RuntimeError): class MarketDataReaderError(RuntimeError):
@@ -16,3 +17,11 @@ class MarketDataReader(Protocol):
"""Read qfq history sufficient for one historical strategy evaluation.""" """Read qfq history sufficient for one historical strategy evaluation."""
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ... def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
__all__ = [
"MarketDataReader",
"MarketDataReaderError",
"SelectionExecutionSource",
"SelectionUniverseReader",
]
@@ -0,0 +1,133 @@
"""Domain contracts for persisted historical selection runs."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Literal, Protocol
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus
@dataclass(frozen=True, slots=True)
class SelectionStock:
"""One eligible current stock that will be evaluated for a run."""
ts_code: str
name: str
@dataclass(frozen=True, slots=True)
class SelectionExecutionSource:
"""Market-data batch and eligible stock snapshot used by one run."""
market_sync_batch_id: str
target_trade_date: date
target_count: int
valid_count: int
coverage: Decimal
stocks: tuple[SelectionStock, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class SelectionRunItem:
"""Persistable per-stock evaluation state and its independent signals."""
ts_code: str
name: str
status: SelectionRunItemStatus
signal_count: int = 0
reason: str | None = None
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class SelectionRun:
"""A current execution attempt and its materialized result rows."""
id: str
strategy: Literal["zhixing_b1"]
target_trade_date: date
market_sync_batch_id: str | None
status: SelectionRunStatus
target_count: int
eligible_count: int
evaluated_count: int
selected_stock_count: int
signal_count: int
failed_count: int
coverage: Decimal
error_type: str | None = None
error_message: str | None = None
created_at: datetime | None = None
finished_at: datetime | None = None
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
class SelectionRunError(RuntimeError):
"""Base class for expected selection-run persistence failures."""
class SelectionRunInProgress(SelectionRunError):
"""The requested strategy and date already have a running attempt."""
class SelectionRerunRequired(SelectionRunError):
"""A terminal result exists and an explicit rerun confirmation is missing."""
class SelectionRunStoreError(SelectionRunError):
"""The selection-run repository could not complete a database operation."""
class SelectionRunStore(Protocol):
"""Persistence port for current selection runs and their materialized rows."""
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun: ...
def record_item(self, run_id: str, item: SelectionRunItem) -> None: ...
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None: ...
def get_run(self, run_id: str) -> SelectionRun | None: ...
def get_latest_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None,
) -> SelectionRun | None: ...
class SelectionUniverseReader(Protocol):
"""Read a qualified market-data source snapshot for one strategy run."""
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource: ...
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
@@ -11,12 +11,17 @@ import psycopg
from ....bootstrap.config import Settings from ....bootstrap.config import Settings
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
from ..domain.ports import MarketDataReaderError from ..domain.ports import MarketDataReaderError
from ..domain.runs import SelectionExecutionSource, SelectionStock
class SelectionReaderError(MarketDataReaderError): class SelectionReaderError(MarketDataReaderError):
"""Database read failure with stock and target-date context.""" """Database read failure with stock and target-date context."""
class SelectionMarketDataNotReady(MarketDataReaderError):
"""The requested date has no market-data batch eligible for selection."""
_HISTORY_QUERY = """ _HISTORY_QUERY = """
SELECT SELECT
bar.ts_code, bar.ts_code,
@@ -41,6 +46,41 @@ WHERE bar.ts_code = %s
ORDER BY bar.trade_date ASC ORDER BY bar.trade_date ASC
""" """
_SOURCE_QUERY = """
SELECT id, target_count, valid_count, coverage
FROM market_sync_batch
WHERE target_trade_date = %s
AND strategy_eligible = true
AND status IN ('success', 'partial_success')
ORDER BY finished_at DESC NULLS LAST, created_at DESC, id DESC
LIMIT 1
"""
_ELIGIBLE_STOCKS_QUERY = """
SELECT stock.ts_code, stock.name
FROM market_stock AS stock
WHERE stock.is_active = true
AND EXISTS (
SELECT 1
FROM market_daily_bar AS bar
WHERE bar.ts_code = stock.ts_code
AND bar.trade_date = %s
AND bar.source_adj = 'qfq'
AND bar.open IS NOT NULL
AND bar.high IS NOT NULL
AND bar.low IS NOT NULL
AND bar.close IS NOT NULL
AND bar.vol IS NOT NULL
)
AND EXISTS (
SELECT 1
FROM market_daily_basic AS basic
WHERE basic.ts_code = stock.ts_code
AND basic.trade_date = %s
)
ORDER BY stock.ts_code
"""
def _as_date(value: object) -> date: def _as_date(value: object) -> date:
"""Convert a PostgreSQL date-like scalar to a date.""" """Convert a PostgreSQL date-like scalar to a date."""
@@ -122,6 +162,64 @@ class PostgresMarketDataReader:
daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)}, daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)},
) )
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource:
"""Load the qualified market-data snapshot for a strategy run.
Args:
strategy: Supported strategy identity. The current reader accepts
``zhixing_b1`` and keeps the parameter explicit for future
strategy-specific eligibility rules.
target_trade_date: Historical trading date to evaluate.
Returns:
The eligible stock snapshot and its source synchronization facts.
Raises:
SelectionMarketDataNotReady: If no eligible synchronization batch
or complete active stock exists for the requested date.
SelectionReaderError: If PostgreSQL cannot complete the read.
"""
if strategy != "zhixing_b1":
raise SelectionMarketDataNotReady(f"unsupported selection strategy: {strategy}")
try:
with psycopg.connect(self.database_url) as connection:
source_row = connection.execute(_SOURCE_QUERY, (target_trade_date,)).fetchone()
if source_row is None:
raise SelectionMarketDataNotReady(
f"market data is not strategy-eligible for {target_trade_date.isoformat()}"
)
stock_rows = connection.execute(
_ELIGIBLE_STOCKS_QUERY,
(target_trade_date, target_trade_date),
).fetchall()
except SelectionMarketDataNotReady:
raise
except psycopg.Error as exc:
raise SelectionReaderError(
f"failed to load selection source at {target_trade_date.isoformat()}"
) from exc
stocks = tuple(
SelectionStock(ts_code=str(row[0]), name=str(row[1] or "")) for row in stock_rows
)
if not stocks:
raise SelectionMarketDataNotReady(
f"no eligible stocks have complete market data for {target_trade_date.isoformat()}"
)
return SelectionExecutionSource(
market_sync_batch_id=str(source_row[0]),
target_trade_date=target_trade_date,
target_count=int(source_row[1]),
valid_count=int(source_row[2]),
coverage=Decimal(str(source_row[3])),
stocks=stocks,
)
@staticmethod @staticmethod
def _map_row( def _map_row(
row: tuple[object, ...], row: tuple[object, ...],
@@ -0,0 +1,414 @@
"""PostgreSQL persistence adapter for selection execution runs."""
from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal, cast
from uuid import uuid4
import psycopg
from psycopg.types.json import Jsonb
from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunError,
SelectionRunInProgress,
SelectionRunItem,
SelectionRunStatus,
SelectionRunStore,
SelectionRunStoreError,
)
from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)}
class PostgresSelectionRunRepository(SelectionRunStore):
"""Persist one current result attempt per strategy and target date."""
def __init__(self, database_url: str) -> None:
"""Create the adapter with an injected PostgreSQL URL."""
self.database_url = database_url
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun:
"""Atomically claim the business key and create a running attempt.
The advisory transaction lock protects the small delete-and-create
window from duplicate HTTP requests. The long-running calculation is
intentionally performed after this transaction is released.
"""
run_id = str(uuid4())
key = f"selection:{strategy}:{target_trade_date.isoformat()}"
try:
with self._connection() as connection, connection.transaction():
connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (key,))
existing = connection.execute(
"""
SELECT id, status
FROM selection_run
WHERE strategy = %s AND target_trade_date = %s
FOR UPDATE
""",
(strategy, target_trade_date),
).fetchone()
if existing is not None:
existing_status = str(existing[1])
if existing_status == "running":
raise SelectionRunInProgress(
f"selection run is already running for {strategy} at "
f"{target_trade_date.isoformat()}"
)
if not rerun:
raise SelectionRerunRequired(
f"rerun confirmation is required for {strategy} at "
f"{target_trade_date.isoformat()}"
)
connection.execute(
"DELETE FROM selection_run WHERE strategy = %s AND target_trade_date = %s",
(strategy, target_trade_date),
)
connection.execute(
"""
INSERT INTO selection_run
(
id, strategy, target_trade_date, market_sync_batch_id,
status, target_count, eligible_count, coverage
)
VALUES (%s, %s, %s, %s, 'running', %s, %s, %s)
""",
(
run_id,
strategy,
target_trade_date,
source.market_sync_batch_id,
source.target_count,
len(source.stocks),
source.coverage,
),
)
except SelectionRunError:
raise
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to prepare selection run") from exc
return SelectionRun(
id=run_id,
strategy=strategy,
target_trade_date=target_trade_date,
market_sync_batch_id=source.market_sync_batch_id,
status="running",
target_count=source.target_count,
eligible_count=len(source.stocks),
evaluated_count=0,
selected_stock_count=0,
signal_count=0,
failed_count=0,
coverage=source.coverage,
)
def record_item(self, run_id: str, item: SelectionRunItem) -> None:
"""Upsert one stock outcome and all of its independent signal rows."""
try:
with self._connection() as connection, connection.transaction():
connection.execute(
"""
INSERT INTO selection_run_item
(run_id, ts_code, name, status, signal_count, reason)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (run_id, ts_code) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
signal_count = EXCLUDED.signal_count,
reason = EXCLUDED.reason
""",
(
run_id,
item.ts_code,
item.name,
item.status,
item.signal_count,
item.reason,
),
)
connection.execute(
"DELETE FROM selection_signal WHERE run_id = %s AND ts_code = %s",
(run_id, item.ts_code),
)
for signal in item.signals:
connection.execute(
"""
INSERT INTO selection_signal
(
run_id, ts_code, name, target_trade_date, strategy,
category, close, details
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (run_id, ts_code, category) DO UPDATE SET
name = EXCLUDED.name,
close = EXCLUDED.close,
details = EXCLUDED.details
""",
(
run_id,
signal.ts_code,
signal.name,
signal.target_trade_date,
signal.strategy,
signal.category.value,
signal.close,
Jsonb(dict(signal.details)),
),
)
except psycopg.Error as exc:
raise SelectionRunStoreError(
f"failed to persist selection item {item.ts_code}"
) from exc
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None:
"""Persist terminal counters and an optional safe batch error."""
try:
with self._connection() as connection, connection.transaction():
connection.execute(
"""
UPDATE selection_run
SET status = %s,
evaluated_count = %s,
selected_stock_count = %s,
signal_count = %s,
failed_count = %s,
error_type = %s,
error_message = %s,
finished_at = now()
WHERE id = %s
""",
(
status,
evaluated_count,
selected_stock_count,
signal_count,
failed_count,
error_type,
_safe_error(error_message),
run_id,
),
)
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc
def get_run(self, run_id: str) -> SelectionRun | None:
"""Read one run with its item failures and signal details."""
try:
with self._connection() as connection:
return self._load_run(connection, run_id)
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
def get_latest_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None,
) -> SelectionRun | None:
"""Read the current run for a date or the latest date for a strategy."""
try:
with self._connection() as connection:
if target_trade_date is None:
row = connection.execute(
"""
SELECT id
FROM selection_run
WHERE strategy = %s
ORDER BY target_trade_date DESC, created_at DESC, id DESC
LIMIT 1
""",
(strategy,),
).fetchone()
else:
row = connection.execute(
"""
SELECT id
FROM selection_run
WHERE strategy = %s AND target_trade_date = %s
LIMIT 1
""",
(strategy, target_trade_date),
).fetchone()
return self._load_run(connection, str(row[0])) if row else None
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc
@staticmethod
def _load_run(connection: Any, run_id: str) -> SelectionRun | None:
row = connection.execute(
"""
SELECT
id, strategy, target_trade_date, market_sync_batch_id, status,
target_count, eligible_count, evaluated_count, selected_stock_count,
signal_count, failed_count, coverage, error_type, error_message,
created_at, finished_at
FROM selection_run
WHERE id = %s
""",
(run_id,),
).fetchone()
if row is None:
return None
item_rows = connection.execute(
"""
SELECT ts_code, name, status, signal_count, reason
FROM selection_run_item
WHERE run_id = %s
ORDER BY ts_code
""",
(run_id,),
).fetchall()
signal_rows = connection.execute(
"""
SELECT
ts_code, name, target_trade_date, strategy, category, close, details
FROM selection_signal
WHERE run_id = %s
ORDER BY ts_code, category
""",
(run_id,),
).fetchall()
signals = tuple(
sorted(
(_signal_from_row(cast(tuple[object, ...], value)) for value in signal_rows),
key=lambda signal: (
signal.ts_code,
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
),
)
)
signals_by_stock: dict[str, list[SelectionSignal]] = defaultdict(list)
for signal in signals:
signals_by_stock[signal.ts_code].append(signal)
items = tuple(
SelectionRunItem(
ts_code=str(value[0]),
name=str(value[1] or ""),
status=cast(
Literal[
"selected",
"no_signal",
"insufficient_history",
"missing_target_bar",
"data_error",
],
str(value[2]),
),
signal_count=int(value[3] or 0),
reason=str(value[4]) if value[4] is not None else None,
signals=tuple(signals_by_stock.get(str(value[0]), ())),
)
for value in item_rows
)
return SelectionRun(
id=str(row[0]),
strategy=cast(Literal["zhixing_b1"], str(row[1])),
target_trade_date=_as_date(row[2]),
market_sync_batch_id=str(row[3]) if row[3] is not None else None,
status=cast(SelectionRunStatus, str(row[4])),
target_count=int(row[5]),
eligible_count=int(row[6]),
evaluated_count=int(row[7]),
selected_stock_count=int(row[8]),
signal_count=int(row[9]),
failed_count=int(row[10]),
coverage=Decimal(str(row[11])),
error_type=str(row[12]) if row[12] is not None else None,
error_message=str(row[13]) if row[13] is not None else None,
created_at=cast(datetime | None, row[14]),
finished_at=cast(datetime | None, row[15]),
items=items,
signals=signals,
)
@contextmanager
def _connection(self) -> Generator[Any, None, None]:
"""Translate psycopg failures without exposing driver details."""
try:
with psycopg.connect(self.database_url) as connection:
yield connection
except psycopg.Error as exc:
raise SelectionRunStoreError("selection database operation failed") from exc
def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal:
"""Map a persisted signal row back to the domain signal model."""
return SelectionSignal(
ts_code=str(row[0]),
name=str(row[1] or ""),
target_trade_date=_as_date(row[2]),
strategy=cast(Literal["zhixing_b1"], str(row[3])),
category=ZhixingB1Category(str(row[4])),
close=float(str(row[5])),
details=_details(row[6]),
)
def _details(value: object) -> dict[str, float | str | None]:
"""Normalize JSONB details into the domain's scalar-only mapping."""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return {}
if not isinstance(value, Mapping):
return {}
values = cast(Mapping[object, object], value)
details: dict[str, float | str | None] = {}
for key, item in values.items():
if item is None or isinstance(item, str):
details[str(key)] = item
elif isinstance(item, (int, float)) and not isinstance(item, bool):
details[str(key)] = float(item)
return details
def _as_date(value: object) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
def _safe_error(message: str | None) -> str | None:
if message is None:
return None
return " ".join(message.split())[:500]
@@ -0,0 +1,259 @@
"""HTTP presentation for persisted strategy execution results."""
from datetime import date, datetime
from typing import Annotated, Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
from pydantic import BaseModel, Field
from zhixing_server.bootstrap.config import Settings, get_settings
from zhixing_server.modules.selection.application.run import (
RunZhixingB1,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired,
SelectionRun,
SelectionRunInProgress,
SelectionRunStoreError,
)
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
SelectionMarketDataNotReady,
SelectionReaderError,
)
from zhixing_server.modules.selection.infrastructure.postgres_runs import (
PostgresSelectionRunRepository,
)
selection_router = APIRouter()
StrategyValue = Literal["zhixing_b1"]
SelectionStatusValue = Literal[
"no_data",
"running",
"success",
"partial_success",
"failed",
]
class SelectionRunRequest(BaseModel):
"""Input contract for one initial run or explicit rerun."""
strategy: StrategyValue
target_trade_date: date
rerun: bool = False
class SelectionRunAcceptedResponse(BaseModel):
"""Small response returned before the background evaluation completes."""
run_id: str
strategy: StrategyValue
target_trade_date: date
status: Literal["running"]
class SelectionSignalResponse(BaseModel):
"""One persisted independent sub-signal in the public result contract."""
ts_code: str
name: str
target_trade_date: date
strategy: StrategyValue
category: str
close: float
details: dict[str, float | str | None]
class SelectionFailureResponse(BaseModel):
"""One stock that could not produce a complete evaluation."""
ts_code: str
name: str
status: str
reason: str | None
def _empty_failures() -> list[SelectionFailureResponse]:
"""Create a typed default list for Pydantic's strict checker."""
return []
def _empty_signals() -> list[SelectionSignalResponse]:
"""Create a typed default list for Pydantic's strict checker."""
return []
class SelectionResultsResponse(BaseModel):
"""Batch summary and materialized signals consumed by the Web feature."""
strategy: StrategyValue
target_trade_date: date | None
run_id: str | None
market_sync_batch_id: str | None
status: SelectionStatusValue
target_count: int = Field(default=0, ge=0)
eligible_count: int = Field(default=0, ge=0)
evaluated_count: int = Field(default=0, ge=0)
selected_stock_count: int = Field(default=0, ge=0)
signal_count: int = Field(default=0, ge=0)
failed_count: int = Field(default=0, ge=0)
coverage: float = Field(default=0, ge=0, le=1)
error_type: str | None = None
error_message: str | None = None
created_at: datetime | None = None
finished_at: datetime | None = None
failures: list[SelectionFailureResponse] = Field(default_factory=_empty_failures)
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
def get_selection_service(
settings: Annotated[Settings, Depends(get_settings)],
) -> RunZhixingB1:
"""Build one request-scoped selection application service."""
reader = PostgresMarketDataReader(settings)
store = PostgresSelectionRunRepository(settings.database_url)
return RunZhixingB1(reader, store)
@selection_router.post(
"/runs",
response_model=SelectionRunAcceptedResponse,
status_code=status.HTTP_202_ACCEPTED,
)
def trigger_selection_run(
request: SelectionRunRequest,
background_tasks: BackgroundTasks,
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
) -> SelectionRunAcceptedResponse:
"""Claim a run and schedule its whole-universe evaluation."""
try:
prepared = service.prepare(
request.strategy,
request.target_trade_date,
rerun=request.rerun,
)
except SelectionRunInProgress as exc:
raise _http_error(409, "run_in_progress", str(exc)) from exc
except SelectionRerunRequired as exc:
raise _http_error(409, "rerun_confirmation_required", str(exc)) from exc
except SelectionMarketDataNotReady as exc:
raise _http_error(422, "market_data_not_ready", str(exc)) from exc
except (SelectionReaderError, SelectionRunStoreError) as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
background_tasks.add_task(service.execute, prepared)
return SelectionRunAcceptedResponse(
run_id=prepared.run.id,
strategy=prepared.run.strategy,
target_trade_date=prepared.run.target_trade_date,
status="running",
)
@selection_router.get("/runs/{run_id}", response_model=SelectionResultsResponse)
def get_selection_run(
run_id: str,
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
) -> SelectionResultsResponse:
"""Return one run for asynchronous polling."""
try:
run = service.get_run(run_id)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None:
raise _http_error(404, "run_not_found", f"selection run not found: {run_id}")
return _run_response(run)
@selection_router.get("/results", response_model=SelectionResultsResponse)
def get_selection_results(
service: Annotated[RunZhixingB1, Depends(get_selection_service)],
strategy: StrategyValue = "zhixing_b1",
target_trade_date: date | None = None,
) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date."""
try:
run = service.get_latest(strategy, target_trade_date)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
if run is None:
return SelectionResultsResponse(
strategy=strategy,
target_trade_date=target_trade_date,
run_id=None,
market_sync_batch_id=None,
status="no_data",
coverage=0,
)
return _run_response(run)
def _run_response(run: SelectionRun) -> SelectionResultsResponse:
"""Translate a domain run without exposing storage-specific fields."""
return SelectionResultsResponse(
strategy=run.strategy,
target_trade_date=run.target_trade_date,
run_id=run.id,
market_sync_batch_id=run.market_sync_batch_id,
status=run.status,
target_count=run.target_count,
eligible_count=run.eligible_count,
evaluated_count=run.evaluated_count,
selected_stock_count=run.selected_stock_count,
signal_count=run.signal_count,
failed_count=run.failed_count,
coverage=float(run.coverage),
error_type=run.error_type,
error_message=run.error_message,
created_at=run.created_at,
finished_at=run.finished_at,
failures=[
SelectionFailureResponse(
ts_code=item.ts_code,
name=item.name,
status=item.status,
reason=item.reason,
)
for item in run.items
if item.status in {"insufficient_history", "missing_target_bar", "data_error"}
],
signals=[
SelectionSignalResponse(
ts_code=signal.ts_code,
name=signal.name,
target_trade_date=signal.target_trade_date,
strategy=signal.strategy,
category=signal.category.value,
close=signal.close,
details=dict(signal.details),
)
for signal in run.signals
],
)
def _http_error(code: int, error_type: str, message: str) -> HTTPException:
"""Create the project's explicit, safe error envelope."""
return HTTPException(
status_code=code,
detail={"code": error_type, "message": message},
)
__all__ = [
"SelectionResultsResponse",
"SelectionRunAcceptedResponse",
"SelectionRunRequest",
"get_selection_service",
"selection_router",
]
@@ -33,6 +33,9 @@ def test_postgres_migration_creates_market_data_contract(
"market_daily_basic", "market_daily_basic",
"market_sync_batch", "market_sync_batch",
"market_sync_item", "market_sync_item",
"selection_run",
"selection_run_item",
"selection_signal",
} <= tables } <= tables
finally: finally:
engine.dispose() engine.dispose()
+207
View File
@@ -0,0 +1,207 @@
"""HTTP contracts for triggering and querying persisted selection runs."""
from datetime import date
from decimal import Decimal
from fastapi.testclient import TestClient
from zhixing_server.bootstrap.app import create_app
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.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunInProgress,
SelectionStock,
)
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
SelectionMarketDataNotReady,
)
from zhixing_server.modules.selection.presentation.http import get_selection_service
TARGET = date(2026, 8, 8)
class FakeSelectionService:
def __init__(self, run: SelectionRun | None = None) -> None:
self.run = run
self.executed = False
self.mode = "ok"
def prepare(
self,
strategy: str,
target_trade_date: date,
*,
rerun: bool,
) -> PreparedSelectionRun:
if self.mode == "in_progress":
raise SelectionRunInProgress("already running")
if self.mode == "rerun_required":
raise SelectionRerunRequired("confirm rerun")
if self.mode == "market_data_not_ready":
raise SelectionMarketDataNotReady("market data is not ready")
run = self.run or _run("run-http", "running")
return PreparedSelectionRun(
run=run,
source=SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=target_trade_date,
target_count=1,
valid_count=1,
coverage=Decimal("1"),
stocks=(SelectionStock("000001.SZ", "平安银行"),),
),
)
def execute(self, prepared: PreparedSelectionRun) -> None:
self.executed = True
def get_run(self, run_id: str) -> SelectionRun | None:
return self.run if self.run and self.run.id == run_id else None
def get_latest(
self,
strategy: str,
target_trade_date: date | None = None,
) -> SelectionRun | None:
if self.run is None:
return None
if target_trade_date is not None and self.run.target_trade_date != target_trade_date:
return None
return self.run
def _run(run_id: str, status: str) -> SelectionRun:
signal = SelectionSignal(
ts_code="000001.SZ",
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category.ORIGINAL_B1,
close=10.5,
details={"j": 12.0},
)
from zhixing_server.modules.selection.domain.runs import SelectionRunItem
return SelectionRun(
id=run_id,
strategy="zhixing_b1",
target_trade_date=TARGET,
market_sync_batch_id="market-run-1",
status=status, # type: ignore[arg-type]
target_count=1,
eligible_count=1,
evaluated_count=1,
selected_stock_count=1,
signal_count=1,
failed_count=0,
coverage=Decimal("1"),
items=(
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
),
signals=(signal,),
)
def _client(service: FakeSelectionService) -> TestClient:
app = create_app()
app.dependency_overrides[get_selection_service] = lambda: service
return TestClient(app)
def test_trigger_returns_accepted_run_and_schedules_execution() -> None:
service = FakeSelectionService()
response = _client(service).post(
"/api/v1/selection/runs",
json={
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"rerun": False,
},
)
assert response.status_code == 202
assert response.json()["status"] == "running"
assert response.json()["target_trade_date"] == "2026-08-08"
assert service.executed is True
def test_trigger_requires_explicit_rerun_confirmation() -> None:
service = FakeSelectionService()
service.mode = "rerun_required"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "rerun_confirmation_required"
def test_trigger_rejects_a_duplicate_running_request() -> None:
service = FakeSelectionService()
service.mode = "in_progress"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "run_in_progress"
def test_trigger_rejects_unqualified_market_data() -> None:
service = FakeSelectionService()
service.mode = "market_data_not_ready"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 422
assert response.json()["detail"]["code"] == "market_data_not_ready"
def test_query_returns_no_data_without_fabricating_a_result() -> None:
response = _client(FakeSelectionService()).get(
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
)
assert response.status_code == 200
assert response.json()["status"] == "no_data"
assert response.json()["signals"] == []
def test_query_returns_persisted_signal_details() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
)
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "run-http"
assert body["signal_count"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1"
assert body["signals"][0]["details"] == {"j": 12.0}
def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http"
)
assert response.status_code == 200
assert response.json()["status"] == "success"
assert response.json()["signals"][0]["category"] == "zhixing_b1_original_b1"
@@ -1,6 +1,7 @@
"""PostgreSQL reader contract tests using a fake connection.""" """PostgreSQL reader contract tests using a fake connection."""
from datetime import date from datetime import date
from decimal import Decimal
from typing import cast from typing import cast
import psycopg import psycopg
@@ -8,6 +9,7 @@ import pytest
from zhixing_server.modules.selection.infrastructure.postgres_reader import ( from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader, PostgresMarketDataReader,
SelectionMarketDataNotReady,
) )
@@ -82,3 +84,77 @@ def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.Monk
assert connection.parameters == ("000001.SZ", date(2024, 1, 3)) assert connection.parameters == ("000001.SZ", date(2024, 1, 3))
assert "source_adj = 'qfq'" in cast(str, connection.query) assert "source_adj = 'qfq'" in cast(str, connection.query)
assert "trade_date <= %s" in cast(str, connection.query) assert "trade_date <= %s" in cast(str, connection.query)
class SourceConnection:
def __init__(self, source_row: tuple[object, ...] | None) -> None:
self.source_row = source_row
self.queries: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "SourceConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "SourceResult":
self.queries.append((query, parameters))
if "FROM market_sync_batch" in query:
return SourceResult(row=self.source_row)
return SourceResult(rows=[("000001.SZ", "平安银行")])
class SourceResult:
def __init__(
self,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_reader_loads_only_eligible_stocks_from_finished_market_batch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = SourceConnection(("market-run-1", 2, 2, Decimal("1")))
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
source = PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
assert source.market_sync_batch_id == "market-run-1"
assert source.target_count == 2
assert source.coverage == Decimal("1")
assert source.stocks[0].ts_code == "000001.SZ"
assert "strategy_eligible = true" in connection.queries[0][0]
assert "source_adj = 'qfq'" in connection.queries[1][0]
def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.MonkeyPatch) -> None:
connection = SourceConnection(None)
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
with pytest.raises(SelectionMarketDataNotReady):
PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
@@ -0,0 +1,251 @@
"""Persistence transaction tests for selection runs."""
from datetime import date
from decimal import Decimal
import psycopg
import pytest
from psycopg.types.json import Jsonb
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRunInProgress,
SelectionRunItem,
SelectionStock,
)
from zhixing_server.modules.selection.domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
from zhixing_server.modules.selection.infrastructure.postgres_runs import (
PostgresSelectionRunRepository,
)
TARGET = date(2026, 8, 8)
class FakeResult:
def __init__(self, row: tuple[object, ...] | None = None) -> None:
self.row = row
def fetchone(self) -> tuple[object, ...] | None:
return self.row
class FakeTransaction:
def __enter__(self) -> "FakeTransaction":
return self
def __exit__(self, *args: object) -> None:
return None
class FakeConnection:
def __init__(self, existing: tuple[object, ...] | None) -> None:
self.existing = existing
self.statements: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "FakeConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def transaction(self) -> FakeTransaction:
return FakeTransaction()
def execute(self, query: str, parameters: tuple[object, ...]) -> FakeResult:
self.statements.append((query, parameters))
if "SELECT id, status" in query:
return FakeResult(self.existing)
return FakeResult()
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=1,
valid_count=1,
coverage=Decimal("1"),
stocks=(SelectionStock("000001.SZ", "平安银行"),),
)
def _repository(
monkeypatch: pytest.MonkeyPatch,
connection: FakeConnection,
) -> PostgresSelectionRunRepository:
def connect(database_url: str) -> FakeConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
return PostgresSelectionRunRepository("postgresql://test")
def test_prepare_claims_new_business_key(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=False,
)
assert run.status == "running"
assert run.target_trade_date == TARGET
assert any("INSERT INTO selection_run" in query for query, _ in connection.statements)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_requires_confirmation_for_terminal_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "success"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRerunRequired):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=False)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_rejects_duplicate_running_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "running"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRunInProgress):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=True)
def test_prepare_rerun_deletes_old_result_before_insert(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "failed"))
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=True,
)
statements = [query for query, _ in connection.statements]
assert "DELETE FROM selection_run WHERE strategy = %s AND target_trade_date = %s" in statements
assert run.id != "old-run"
def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
repository = _repository(monkeypatch, connection)
signal = SelectionSignal(
ts_code="000001.SZ",
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category.ORIGINAL_B1,
close=10.5,
details={"j": 12.0},
)
repository.record_item(
"run-1",
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
)
signal_insert = next(
parameters
for query, parameters in connection.statements
if "INSERT INTO selection_signal" in query
)
assert isinstance(signal_insert[-1], Jsonb)
class LoadConnection:
def __enter__(self) -> "LoadConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
if "FROM selection_run\n" in query:
return LoadResult(
row=(
"run-1",
"zhixing_b1",
TARGET,
"market-run-1",
"success",
1,
1,
1,
1,
2,
0,
Decimal("1"),
None,
None,
None,
None,
)
)
if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
return LoadResult(
rows=[
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[-1].value,
Decimal("10.5"),
{},
),
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[0].value,
Decimal("10.5"),
{},
),
]
)
class LoadResult:
def __init__(
self,
*,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPatch) -> None:
connection = LoadConnection()
def connect(database_url: str) -> LoadConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1")
assert run is not None
assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0],
ZHIXING_B1_SIGNAL_ORDER[-1],
]
@@ -0,0 +1,228 @@
"""Application tests for persisted whole-universe selection runs."""
from datetime import date
from decimal import Decimal
from typing import Literal
from zhixing_server.modules.selection.application.run import (
PreparedSelectionRun,
RunZhixingB1,
)
from zhixing_server.modules.selection.domain.models import (
SelectionEvaluation,
SelectionSignal,
StockHistory,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRun,
SelectionRunItem,
SelectionRunStatus,
SelectionStock,
)
TARGET = date(2026, 8, 8)
class FakeReader:
def __init__(self, source: SelectionExecutionSource) -> None:
self.source = source
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
return self.source
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
raise AssertionError("the fake evaluator should be used")
class FakeStore:
def __init__(self) -> None:
self.items: list[SelectionRunItem] = []
self.finished: tuple[str, SelectionRunStatus, dict[str, object]] | None = None
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
assert rerun is False
return SelectionRun(
id="run-1",
strategy="zhixing_b1",
target_trade_date=TARGET,
market_sync_batch_id=source.market_sync_batch_id,
status="running",
target_count=source.target_count,
eligible_count=len(source.stocks),
evaluated_count=0,
selected_stock_count=0,
signal_count=0,
failed_count=0,
coverage=source.coverage,
)
def record_item(self, run_id: str, item: SelectionRunItem) -> None:
assert run_id == "run-1"
self.items.append(item)
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None:
kwargs: dict[str, object] = {
"evaluated_count": evaluated_count,
"selected_stock_count": selected_stock_count,
"signal_count": signal_count,
"failed_count": failed_count,
}
if error_type is not None:
kwargs["error_type"] = error_type
if error_message is not None:
kwargs["error_message"] = error_message
self.finished = (run_id, status, kwargs)
def get_run(self, run_id: str):
return None
def get_latest_run(self, strategy: str, target_trade_date: date | None = None):
return None
class FakeEvaluator:
def __init__(self, results: dict[str, SelectionEvaluation]) -> None:
self.results = results
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
return self.results[ts_code]
class RaisingEvaluator:
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
if ts_code == "600000.SH":
raise RuntimeError("temporary evaluator failure")
return SelectionEvaluation(ts_code, target_trade_date, "no_signal")
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=2,
valid_count=2,
coverage=Decimal("1"),
stocks=(
SelectionStock("000001.SZ", "平安银行"),
SelectionStock("600000.SH", "浦发银行"),
),
)
def _signal(ts_code: str, category: str) -> SelectionSignal:
from zhixing_server.modules.selection.domain.models import ZhixingB1Category
return SelectionSignal(
ts_code=ts_code,
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category(category),
close=10.5,
details={"j": 12.0},
)
def test_prepare_captures_market_source_and_execute_persists_all_categories() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(
_signal("000001.SZ", "zhixing_b1_original_b1"),
_signal("000001.SZ", "zhixing_b1_pullback_white"),
),
),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"no_signal",
reason="no category matched",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
prepared = service.prepare("zhixing_b1", TARGET, rerun=False)
assert isinstance(prepared, PreparedSelectionRun)
service.execute(prepared)
assert [item.status for item in store.items] == ["selected", "no_signal"]
assert store.items[0].signal_count == 2
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2] == {
"evaluated_count": 2,
"selected_stock_count": 1,
"signal_count": 2,
"failed_count": 0,
}
def test_execute_marks_partial_success_when_one_stock_lacks_history() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation("000001.SZ", TARGET, "no_signal"),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"insufficient_history",
reason="warm-up data is incomplete",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["failed_count"] == 1
def test_execute_isolates_unexpected_single_stock_failure() -> None:
source = _source()
store = FakeStore()
service = RunZhixingB1(FakeReader(source), store, RaisingEvaluator())
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert [item.status for item in store.items] == ["no_signal", "data_error"]
assert store.items[1].reason == "temporary evaluator failure"
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["evaluated_count"] == 2
assert store.finished[2]["failed_count"] == 1
+1 -1
View File
@@ -2,4 +2,4 @@ coverage
dist dist
node_modules node_modules
pnpm-lock.yaml pnpm-lock.yaml
.pnpm-store
@@ -13,16 +13,30 @@ import { Separator } from "@/shared/ui/separator"
interface HomeShellProps { interface HomeShellProps {
children: React.ReactNode children: React.ReactNode
activeSection?: "home" | "selection"
} }
const navigation = [ const navigation = [
{ label: "首页", icon: LayoutDashboard, active: true }, {
{ label: "行情数据", icon: BarChart3, active: false }, label: "首页",
{ label: "同步任务", icon: ClipboardCheck, active: false }, icon: LayoutDashboard,
{ label: "选股策略", icon: BrainCircuit, active: false }, section: "home" as const,
path: "/" as const,
},
{ label: "行情数据", icon: BarChart3, section: null, path: null },
{ label: "同步任务", icon: ClipboardCheck, section: null, path: null },
{
label: "选股策略",
icon: BrainCircuit,
section: "selection" as const,
path: "/selection" as const,
},
] ]
export function HomeShell({ children }: HomeShellProps) { export function HomeShell({
activeSection = "home",
children,
}: HomeShellProps) {
return ( return (
<div className="flex min-h-screen bg-muted/30 text-foreground"> <div className="flex min-h-screen bg-muted/30 text-foreground">
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5"> <aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
@@ -43,16 +57,34 @@ export function HomeShell({ children }: HomeShellProps) {
<nav aria-label="主导航" className="space-y-1"> <nav aria-label="主导航" className="space-y-1">
{navigation.map((item) => { {navigation.map((item) => {
const Icon = item.icon const Icon = item.icon
const active = item.section === activeSection
const content = (
<>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
</>
)
if (item.path) {
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
render={<a href={item.path} />}
variant={active ? "secondary" : "ghost"}
aria-current={active ? "page" : undefined}
>
{content}
</Button>
)
}
return ( return (
<Button <Button
key={item.label} key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3" className="w-full justify-start gap-3 px-2 md:px-3"
disabled={!item.active} disabled
variant={item.active ? "secondary" : "ghost"} variant="ghost"
aria-current={item.active ? "page" : undefined}
> >
<Icon className="size-4 shrink-0" aria-hidden="true" /> {content}
<span className="hidden md:inline">{item.label}</span>
</Button> </Button>
) )
})} })}
@@ -61,7 +61,10 @@ describe("HomePage", () => {
expect(screen.getByText("33.33%")).toBeInTheDocument() expect(screen.getByText("33.33%")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled() expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled() expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
expect(screen.getByRole("button", { name: "选股策略" })).toBeDisabled() expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
"href",
"/selection",
)
}) })
it("opens and closes the accessible failed-stock dialog", () => { it("opens and closes the accessible failed-stock dialog", () => {
@@ -22,7 +22,7 @@ export function HomePage() {
const update = overview.data?.latest_update const update = overview.data?.latest_update
return ( return (
<HomeShell> <HomeShell activeSection="home">
<div className="space-y-8"> <div className="space-y-8">
<section className="space-y-2"> <section className="space-y-2">
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p> <p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
@@ -0,0 +1,39 @@
import { requestJson } from "@/shared/api/request-json"
import type {
SelectionResults,
SelectionRunAccepted,
SelectionRunRequest,
SelectionStrategy,
} from "./selection.types"
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({ strategy })
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`,
{ signal },
)
}
export function getSelectionRun(runId: string, signal?: AbortSignal) {
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, {
signal,
})
}
export function triggerSelectionRun(
request: SelectionRunRequest,
signal?: AbortSignal,
) {
return requestJson<SelectionRunAccepted>("/api/v1/selection/runs", {
body: JSON.stringify(request),
headers: { "Content-Type": "application/json" },
method: "POST",
signal,
})
}
@@ -0,0 +1,79 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { useEffect } from "react"
import {
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
} from "./selection.api"
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types"
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
) => ["selection", "results", strategy, targetTradeDate] as const
export const selectionRunQueryKey = (runId: string) =>
["selection", "runs", runId] as const
export function useSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
) {
return useQuery({
queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, signal),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate),
})
}
export function useSelectionRun(runId: string | null) {
const queryClient = useQueryClient()
const query = useQuery({
enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal),
queryKey: selectionRunQueryKey(runId ?? "none"),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false,
})
useEffect(() => {
const result = query.data
if (!runId || !result || result.status === "running") return
void invalidateSelectionResults(queryClient, result.strategy)
}, [query.data, queryClient, runId])
return query
}
export function useTriggerSelectionRun() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
request,
signal,
}: {
request: SelectionRunRequest
signal?: AbortSignal
}) => triggerSelectionRun(request, signal),
onSuccess: (_accepted, { request }) => {
void invalidateSelectionResults(queryClient, request.strategy)
},
})
}
export function invalidateSelectionResults(
queryClient: QueryClient,
strategy: SelectionStrategy,
) {
return queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy],
})
}
@@ -0,0 +1,55 @@
export type SelectionStrategy = "zhixing_b1"
export type SelectionRunStatus =
"no_data" | "running" | "success" | "partial_success" | "failed"
export interface SelectionRunRequest {
strategy: SelectionStrategy
target_trade_date: string
rerun: boolean
}
export interface SelectionRunAccepted {
run_id: string
strategy: SelectionStrategy
target_trade_date: string
status: "running"
}
export interface SelectionSignal {
ts_code: string
name: string
target_trade_date: string
strategy: SelectionStrategy
category: string
close: number
details: Record<string, number | string | null>
}
export interface SelectionFailure {
ts_code: string
name: string
status: string
reason: string | null
}
export interface SelectionResults {
strategy: SelectionStrategy
target_trade_date: string | null
run_id: string | null
market_sync_batch_id: string | null
status: SelectionRunStatus
target_count: number
eligible_count: number
evaluated_count: number
selected_stock_count: number
signal_count: number
failed_count: number
coverage: number
error_type: string | null
error_message: string | null
created_at: string | null
finished_at: string | null
failures: SelectionFailure[]
signals: SelectionSignal[]
}
@@ -0,0 +1,216 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionResults } from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00",
coverage: 1,
eligible_count: 2,
evaluated_count: 2,
error_message: null,
error_type: null,
failed_count: 0,
failures: [],
finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1",
run_id: "run-1",
selected_stock_count: 1,
signal_count: 2,
signals: [
{
category: "zhixing_b1_original_b1",
close: 10.5,
details: { j: 12, rsi: 20 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
{
category: "zhixing_b1_pullback_white",
close: 10.5,
details: { j: 12 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
status: "success",
strategy: "zhixing_b1",
target_count: 2,
target_trade_date: "2026-08-08",
}
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
mutate,
reset,
})
mutate.mockReset()
reset.mockReset()
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(
screen.getByRole("heading", { name: "选股策略" }),
).toBeInTheDocument()
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
expect(screen.getByText("原始 B1")).toBeInTheDocument()
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
expect(screen.getAllByText("平安银行")).toHaveLength(2)
})
it("requires confirmation before a rerun and does not trigger on cancel", () => {
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
const dialog = screen.getByRole("dialog", { name: "确认重新执行策略?" })
expect(within(dialog).getByText(/清空 2026-08-08/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "取消" }))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
fireEvent.click(
within(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).getByRole("button", { name: "确认重新执行" }),
)
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: true,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("uses the same confirmation flow for a failed retry", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 1,
failures: [
{
name: "平安银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "000001.SZ",
},
],
selected_stock_count: 0,
signal_count: 0,
signals: [],
status: "failed",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重试执行" }))
expect(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).toBeInTheDocument()
})
it("renders a no-data state and triggers an initial run after date selection", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
run_id: null,
signals: [],
status: "no_data",
target_trade_date: null,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
fireEvent.change(screen.getByLabelText("目标交易日"), {
target: { value: "2026-08-08" },
})
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: false,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1")
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
})
})
@@ -0,0 +1,516 @@
import {
AlertTriangle,
CheckCircle2,
Play,
RefreshCw,
Search,
} from "lucide-react"
import { useState } from "react"
import {
useSelectionResults,
useSelectionRun,
useTriggerSelectionRun,
} from "@/features/selection/api/selection.query"
import type {
SelectionResults,
SelectionRunStatus,
SelectionSignal,
} from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import { Skeleton } from "@/shared/ui/skeleton"
import { HomeShell } from "@/features/home/components/home-shell"
const STRATEGY = "zhixing_b1" as const
const statusPresentation: Record<
Exclude<SelectionRunStatus, "no_data" | "running">,
{ label: string; variant: "default" | "destructive" | "outline" }
> = {
failed: { label: "执行失败", variant: "destructive" },
partial_success: { label: "部分成功", variant: "outline" },
success: { label: "执行完成", variant: "default" },
}
const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
zhixing_b1_oversold_volume: "超卖缩量 B",
zhixing_b1_pullback_super: "回踩超级 B",
zhixing_b1_pullback_white: "回踩白线 B",
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined)
const persistedRunningRunId =
results.data?.status === "running" ? results.data.run_id : null
const polledRunId = activeRunId ?? persistedRunningRunId
const activeRun = useSelectionRun(polledRunId)
const trigger = useTriggerSelectionRun()
const selectedTargetTradeDate =
targetTradeDate ?? results.data?.target_trade_date ?? ""
const selectedDateResult =
targetTradeDate &&
results.data?.target_trade_date !== selectedTargetTradeDate
? undefined
: results.data
const displayedResult = activeRun.data ?? selectedDateResult
const isRunning =
trigger.isPending ||
results.data?.status === "running" ||
activeRun.data?.status === "running" ||
(polledRunId !== null && activeRun.isPending)
const hasExistingResult = Boolean(
displayedResult?.run_id && displayedResult.status !== "no_data",
)
const hasQueryError =
activeRunId !== null ? activeRun.isError : results.isError
function handleExecute() {
if (!selectedTargetTradeDate || isRunning) return
if (hasExistingResult) {
setRerunDialogOpen(true)
return
}
startExecution(false)
}
function startExecution(rerun: boolean) {
if (!selectedTargetTradeDate) return
setRerunDialogOpen(false)
setActiveRunId(null)
trigger.mutate(
{
request: {
strategy: STRATEGY,
target_trade_date: selectedTargetTradeDate,
rerun,
},
},
{ onSuccess: (accepted) => setActiveRunId(accepted.run_id) },
)
}
function handleDateChange(value: string) {
setTargetTradeDate(value)
setActiveRunId(null)
trigger.reset()
}
return (
<HomeShell activeSection="selection">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">研究策略执行</p>
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
<p className="max-w-2xl text-muted-foreground">
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
</p>
</section>
<Card>
<CardHeader>
<CardTitle className="text-xl">执行条件</CardTitle>
<CardDescription>
策略只使用已完成且达到选股资格的市场数据同步批次。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="target-trade-date"
>
目标交易日
<Input
id="target-trade-date"
onChange={(event) => handleDateChange(event.target.value)}
type="date"
value={selectedTargetTradeDate}
/>
</label>
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="strategy-name"
>
策略
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
disabled
id="strategy-name"
value={STRATEGY}
>
<option value={STRATEGY}>知行 B1</option>
</select>
</label>
<Button
disabled={!selectedTargetTradeDate || isRunning}
onClick={handleExecute}
>
{isRunning ? (
<RefreshCw aria-hidden="true" className="animate-spin" />
) : hasExistingResult ? (
<RefreshCw aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
{isRunning
? "执行中"
: hasExistingResult
? displayedResult?.status === "failed"
? "重试执行"
: "重新执行"
: "执行策略"}
</Button>
</CardContent>
{trigger.isError ? (
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
执行请求失败,请确认市场数据状态后重试。
</p>
) : null}
</Card>
{isRunning ? <RunningState /> : null}
{!isRunning &&
!hasQueryError &&
results.isPending &&
!displayedResult ? (
<LoadingState />
) : null}
{!isRunning && hasQueryError ? <QueryError /> : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "no_data" ? (
<NoDataState />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "failed" ? (
<FailureState result={displayedResult} />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "failed" ? (
<ResultState result={displayedResult} />
) : null}
</div>
{rerunDialogOpen ? (
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认重新执行策略?</DialogTitle>
<DialogDescription>
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setRerunDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
onClick={() => startExecution(true)}
variant="destructive"
>
确认重新执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</HomeShell>
)
}
function LoadingState() {
return (
<Card aria-label="正在加载策略执行结果">
<CardHeader className="gap-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
)
}
function RunningState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<RefreshCw
aria-hidden="true"
className="size-5 animate-spin text-primary"
/>
策略执行中
</CardTitle>
<CardDescription>
正在逐只评估当前有效股票池,页面会自动刷新执行状态。
</CardDescription>
</CardHeader>
</Card>
)
}
function QueryError() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
结果查询失败
</CardTitle>
<CardDescription>
无法读取策略执行结果,请稍后刷新页面重试。
</CardDescription>
</CardHeader>
</Card>
)
}
function NoDataState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Search aria-hidden="true" className="size-5 text-primary" />
暂无策略结果
</CardTitle>
<CardDescription>
请选择目标交易日并执行知行 B1,完成后这里会显示持久化结果。
</CardDescription>
</CardHeader>
</Card>
)
}
function FailureState({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
策略执行失败
</CardTitle>
<CardDescription>
{result.error_message ??
"本次执行没有形成可用的策略结果,请确认后重试。"}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
目标交易日:
<span className="font-medium text-foreground">
{result.target_trade_date}
</span>
,失败股票:
<span className="font-medium text-foreground">
{result.failed_count}
</span>
</p>
</CardContent>
</Card>
)
}
function ResultState({ result }: { result: SelectionResults }) {
const presentation =
result.status === "success" ||
result.status === "partial_success" ||
result.status === "failed"
? statusPresentation[result.status]
: null
return (
<div className="space-y-6">
<Card>
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<CheckCircle2
aria-hidden="true"
className="size-5 text-primary"
/>
知行 B1 执行结果
</CardTitle>
<CardDescription>
目标交易日:{result.target_trade_date} · 市场同步批次:
{result.market_sync_batch_id}
</CardDescription>
</div>
{presentation ? (
<Badge variant={presentation.variant}>{presentation.label}</Badge>
) : null}
</CardHeader>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="目标股票数" value={result.target_count} />
<Metric label="实际参与数" value={result.eligible_count} />
<Metric label="命中股票数" value={result.selected_stock_count} />
<Metric label="信号条数" value={result.signal_count} />
<Metric
label="数据覆盖率"
value={`${(result.coverage * 100).toFixed(2)}%`}
/>
</CardContent>
</Card>
{result.signal_count === 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</CardHeader>
</Card>
) : (
<SignalTable signals={result.signals} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">命中信号明细</CardTitle>
<CardDescription>
同一股票同日的多个子信号分别保留,不合并分类。
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pb-3 pr-4 font-medium">股票</th>
<th className="pb-3 pr-4 font-medium">子信号</th>
<th className="pb-3 pr-4 font-medium">收盘价</th>
<th className="pb-3 font-medium">关键详情</th>
</tr>
</thead>
<tbody>
{signals.map((signal) => (
<tr
key={`${signal.ts_code}-${signal.category}`}
className="border-b last:border-0"
>
<td className="py-3 pr-4">
<p className="font-medium">{signal.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{signal.ts_code}
</p>
</td>
<td className="py-3 pr-4">
<Badge variant="outline">
{categoryLabels[signal.category] ?? signal.category}
</Badge>
</td>
<td className="py-3 pr-4 tabular-nums">
{signal.close.toFixed(2)}
</td>
<td className="py-3 text-muted-foreground">
{formatDetails(signal.details)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
}
function FailureList({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">未完成评估</CardTitle>
<CardDescription>这些股票未形成完整的策略评估结果。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{result.failures.map((failure) => (
<div
key={`${failure.ts_code}-${failure.status}`}
className="rounded-lg border bg-muted/30 p-3"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<p className="font-medium">{failure.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{failure.ts_code}
</p>
</div>
<span className="text-xs text-muted-foreground">
{failure.status}
</span>
</div>
{failure.reason ? (
<p className="mt-2 text-sm text-muted-foreground">
{failure.reason}
</p>
) : null}
</div>
))}
</CardContent>
</Card>
)
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p>
</div>
)
}
function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
+8 -1
View File
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router" import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { HomePage } from "@/features/home/pages/home-page" import { HomePage } from "@/features/home/pages/home-page"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({
component: () => <Outlet />, component: () => <Outlet />,
@@ -12,4 +13,10 @@ const indexRoute = createRoute({
component: HomePage, component: HomePage,
}) })
export const routeTree = rootRoute.addChildren([indexRoute]) const selectionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/selection",
component: SelectionResultsPage,
})
export const routeTree = rootRoute.addChildren([indexRoute, selectionRoute])