From 9c1a1eac232e8f51b6202b2efd3e7d80c6b724ca Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sun, 9 Aug 2026 09:34:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(selection):=20=E8=A1=A5=E5=85=85=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E6=89=A7=E8=A1=8C=E7=BB=93=E6=9E=9C=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/selection.md | 87 +++ .../check.jsonl | 10 + .../design.md | 187 +++++++ .../implement.jsonl | 17 + .../implement.md | 137 +++++ .../08-08-strategy-execution-results/prd.md | 83 +++ .../task.json | 26 + .../versions/0002_selection_results.py | 112 ++++ .../zhixing_server/interfaces/http/router.py | 2 + .../market_data/infrastructure/schema.py | 79 +++ .../modules/selection/application/run.py | 187 +++++++ .../modules/selection/domain/ports.py | 9 + .../modules/selection/domain/runs.py | 133 +++++ .../infrastructure/postgres_reader.py | 98 ++++ .../selection/infrastructure/postgres_runs.py | 414 ++++++++++++++ .../modules/selection/presentation/http.py | 259 +++++++++ .../integration/test_market_data_migration.py | 3 + zhixing-server/tests/test_selection_http.py | 207 +++++++ .../unit/selection/test_postgres_reader.py | 76 +++ .../unit/selection/test_postgres_runs.py | 251 +++++++++ .../tests/unit/selection/test_run.py | 228 ++++++++ zhixing-web/.prettierignore | 2 +- .../features/home/components/home-shell.tsx | 52 +- .../features/home/pages/home-page.test.tsx | 5 +- .../src/features/home/pages/home-page.tsx | 2 +- .../features/selection/api/selection.api.ts | 39 ++ .../features/selection/api/selection.query.ts | 79 +++ .../features/selection/api/selection.types.ts | 55 ++ .../pages/selection-results-page.test.tsx | 216 ++++++++ .../pages/selection-results-page.tsx | 516 ++++++++++++++++++ zhixing-web/src/routes/route-tree.tsx | 9 +- 31 files changed, 3566 insertions(+), 14 deletions(-) create mode 100644 .trellis/tasks/08-08-strategy-execution-results/check.jsonl create mode 100644 .trellis/tasks/08-08-strategy-execution-results/design.md create mode 100644 .trellis/tasks/08-08-strategy-execution-results/implement.jsonl create mode 100644 .trellis/tasks/08-08-strategy-execution-results/implement.md create mode 100644 .trellis/tasks/08-08-strategy-execution-results/prd.md create mode 100644 .trellis/tasks/08-08-strategy-execution-results/task.json create mode 100644 zhixing-server/migrations/versions/0002_selection_results.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/application/run.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/runs.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/presentation/http.py create mode 100644 zhixing-server/tests/test_selection_http.py create mode 100644 zhixing-server/tests/unit/selection/test_postgres_runs.py create mode 100644 zhixing-server/tests/unit/selection/test_run.py create mode 100644 zhixing-web/src/features/selection/api/selection.api.ts create mode 100644 zhixing-web/src/features/selection/api/selection.query.ts create mode 100644 zhixing-web/src/features/selection/api/selection.types.ts create mode 100644 zhixing-web/src/features/selection/pages/selection-results-page.test.tsx create mode 100644 zhixing-web/src/features/selection/pages/selection-results-page.tsx diff --git a/.trellis/spec/backend/selection.md b/.trellis/spec/backend/selection.md index 5eb2134..08f842f 100644 --- a/.trellis/spec/backend/selection.md +++ b/.trellis/spec/backend/selection.md @@ -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] 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) +``` diff --git a/.trellis/tasks/08-08-strategy-execution-results/check.jsonl b/.trellis/tasks/08-08-strategy-execution-results/check.jsonl new file mode 100644 index 0000000..747cbf6 --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/check.jsonl @@ -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":"检查策略执行是否只使用合格市场同步批次并携带覆盖率和失败信息。"} diff --git a/.trellis/tasks/08-08-strategy-execution-results/design.md b/.trellis/tasks/08-08-strategy-execution-results/design.md new file mode 100644 index 0000000..57ae24e --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/design.md @@ -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": "", + "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 和生产构建。 diff --git a/.trellis/tasks/08-08-strategy-execution-results/implement.jsonl b/.trellis/tasks/08-08-strategy-execution-results/implement.jsonl new file mode 100644 index 0000000..e376ebc --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/implement.jsonl @@ -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":"遵守策略批次关联市场同步批次、覆盖率和策略资格契约。"} diff --git a/.trellis/tasks/08-08-strategy-execution-results/implement.md b/.trellis/tasks/08-08-strategy-execution-results/implement.md new file mode 100644 index 0000000..e80d797 --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/implement.md @@ -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 层做批量读 + 取;本任务不通过放宽历史数据或减少股票池来掩盖性能问题。 diff --git a/.trellis/tasks/08-08-strategy-execution-results/prd.md b/.trellis/tasks/08-08-strategy-execution-results/prd.md new file mode 100644 index 0000000..9653213 --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/prd.md @@ -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 + +无。 diff --git a/.trellis/tasks/08-08-strategy-execution-results/task.json b/.trellis/tasks/08-08-strategy-execution-results/task.json new file mode 100644 index 0000000..ee3141d --- /dev/null +++ b/.trellis/tasks/08-08-strategy-execution-results/task.json @@ -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": {} +} \ No newline at end of file diff --git a/zhixing-server/migrations/versions/0002_selection_results.py b/zhixing-server/migrations/versions/0002_selection_results.py new file mode 100644 index 0000000..1e58354 --- /dev/null +++ b/zhixing-server/migrations/versions/0002_selection_results.py @@ -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") diff --git a/zhixing-server/src/zhixing_server/interfaces/http/router.py b/zhixing-server/src/zhixing_server/interfaces/http/router.py index 528f4b1..ee642ba 100644 --- a/zhixing-server/src/zhixing_server/interfaces/http/router.py +++ b/zhixing-server/src/zhixing_server/interfaces/http/router.py @@ -4,9 +4,11 @@ from fastapi import APIRouter 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.selection.presentation.http import selection_router api_v1_router = APIRouter(prefix="/api/v1") 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(selection_router, prefix="/selection", tags=["selection"]) __all__ = ["api_v1_router", "operational_router"] diff --git a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py index b0e924c..b5405f2 100644 --- a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py +++ b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py @@ -5,6 +5,8 @@ from sqlalchemy import ( Column, Date, DateTime, + ForeignKey, + Index, Integer, MetaData, Numeric, @@ -12,8 +14,10 @@ from sqlalchemy import ( String, Table, Text, + UniqueConstraint, func, ) +from sqlalchemy.dialects.postgresql import JSONB metadata = MetaData() @@ -114,3 +118,78 @@ market_sync_item = Table( Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), 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, +) diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/run.py b/zhixing-server/src/zhixing_server/modules/selection/application/run.py new file mode 100644 index 0000000..eb6b913 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/application/run.py @@ -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", +] diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py b/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py index 7af00bb..6366f8b 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py @@ -6,6 +6,7 @@ from datetime import date from typing import Protocol from .models import StockHistory +from .runs import SelectionExecutionSource, SelectionUniverseReader class MarketDataReaderError(RuntimeError): @@ -16,3 +17,11 @@ class MarketDataReader(Protocol): """Read qfq history sufficient for one historical strategy evaluation.""" def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ... + + +__all__ = [ + "MarketDataReader", + "MarketDataReaderError", + "SelectionExecutionSource", + "SelectionUniverseReader", +] diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py new file mode 100644 index 0000000..88eedb4 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py @@ -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: ... diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py index 7783e99..c46e4ba 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py @@ -11,12 +11,17 @@ import psycopg from ....bootstrap.config import Settings from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory from ..domain.ports import MarketDataReaderError +from ..domain.runs import SelectionExecutionSource, SelectionStock class SelectionReaderError(MarketDataReaderError): """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 = """ SELECT bar.ts_code, @@ -41,6 +46,41 @@ WHERE bar.ts_code = %s 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: """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)}, ) + 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 def _map_row( row: tuple[object, ...], diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py new file mode 100644 index 0000000..214ea5a --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py @@ -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] diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py new file mode 100644 index 0000000..81452db --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -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", +] diff --git a/zhixing-server/tests/integration/test_market_data_migration.py b/zhixing-server/tests/integration/test_market_data_migration.py index ae57f20..52e9c11 100644 --- a/zhixing-server/tests/integration/test_market_data_migration.py +++ b/zhixing-server/tests/integration/test_market_data_migration.py @@ -33,6 +33,9 @@ def test_postgres_migration_creates_market_data_contract( "market_daily_basic", "market_sync_batch", "market_sync_item", + "selection_run", + "selection_run_item", + "selection_signal", } <= tables finally: engine.dispose() diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py new file mode 100644 index 0000000..bfca991 --- /dev/null +++ b/zhixing-server/tests/test_selection_http.py @@ -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" diff --git a/zhixing-server/tests/unit/selection/test_postgres_reader.py b/zhixing-server/tests/unit/selection/test_postgres_reader.py index e456c32..ee7d509 100644 --- a/zhixing-server/tests/unit/selection/test_postgres_reader.py +++ b/zhixing-server/tests/unit/selection/test_postgres_reader.py @@ -1,6 +1,7 @@ """PostgreSQL reader contract tests using a fake connection.""" from datetime import date +from decimal import Decimal from typing import cast import psycopg @@ -8,6 +9,7 @@ import pytest from zhixing_server.modules.selection.infrastructure.postgres_reader import ( 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 "source_adj = 'qfq'" 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), + ) diff --git a/zhixing-server/tests/unit/selection/test_postgres_runs.py b/zhixing-server/tests/unit/selection/test_postgres_runs.py new file mode 100644 index 0000000..5fc7895 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_postgres_runs.py @@ -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], + ] diff --git a/zhixing-server/tests/unit/selection/test_run.py b/zhixing-server/tests/unit/selection/test_run.py new file mode 100644 index 0000000..461edec --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_run.py @@ -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 diff --git a/zhixing-web/.prettierignore b/zhixing-web/.prettierignore index 63d6da7..a46f1e4 100644 --- a/zhixing-web/.prettierignore +++ b/zhixing-web/.prettierignore @@ -2,4 +2,4 @@ coverage dist node_modules pnpm-lock.yaml - +.pnpm-store diff --git a/zhixing-web/src/features/home/components/home-shell.tsx b/zhixing-web/src/features/home/components/home-shell.tsx index 6ed18c9..64e9262 100644 --- a/zhixing-web/src/features/home/components/home-shell.tsx +++ b/zhixing-web/src/features/home/components/home-shell.tsx @@ -13,16 +13,30 @@ import { Separator } from "@/shared/ui/separator" interface HomeShellProps { children: React.ReactNode + activeSection?: "home" | "selection" } const navigation = [ - { label: "首页", icon: LayoutDashboard, active: true }, - { label: "行情数据", icon: BarChart3, active: false }, - { label: "同步任务", icon: ClipboardCheck, active: false }, - { label: "选股策略", icon: BrainCircuit, active: false }, + { + label: "首页", + icon: LayoutDashboard, + 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 (