From 3405250c0f95732c2acd6df97c6b68c3f70582f2 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 8 Aug 2026 19:41:18 +0800 Subject: [PATCH 1/6] chore(task): archive 00-bootstrap-guidelines --- .../{ => archive/2026-08}/00-bootstrap-guidelines/prd.md | 0 .../{ => archive/2026-08}/00-bootstrap-guidelines/task.json | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename .trellis/tasks/{ => archive/2026-08}/00-bootstrap-guidelines/prd.md (100%) rename .trellis/tasks/{ => archive/2026-08}/00-bootstrap-guidelines/task.json (92%) diff --git a/.trellis/tasks/00-bootstrap-guidelines/prd.md b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/prd.md similarity index 100% rename from .trellis/tasks/00-bootstrap-guidelines/prd.md rename to .trellis/tasks/archive/2026-08/00-bootstrap-guidelines/prd.md diff --git a/.trellis/tasks/00-bootstrap-guidelines/task.json b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json similarity index 92% rename from .trellis/tasks/00-bootstrap-guidelines/task.json rename to .trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json index 8b9328c..41f15fd 100644 --- a/.trellis/tasks/00-bootstrap-guidelines/task.json +++ b/.trellis/tasks/archive/2026-08/00-bootstrap-guidelines/task.json @@ -3,7 +3,7 @@ "name": "00-bootstrap-guidelines", "title": "Bootstrap Guidelines", "description": "Fill in project development guidelines for AI agents", - "status": "in_progress", + "status": "completed", "dev_type": "docs", "scope": null, "package": null, @@ -11,7 +11,7 @@ "creator": "yuxuanhui", "assignee": "yuxuanhui", "createdAt": "2026-08-04", - "completedAt": null, + "completedAt": "2026-08-08", "branch": null, "base_branch": null, "worktree_path": null, -- 2.52.0 From b295588fceb4fe073588572eab5756e245d637d6 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 8 Aug 2026 19:42:12 +0800 Subject: [PATCH 2/6] chore: record journal --- .trellis/workspace/yuxuanhui/index.md | 7 ++++--- .trellis/workspace/yuxuanhui/journal-1.md | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.trellis/workspace/yuxuanhui/index.md b/.trellis/workspace/yuxuanhui/index.md index 3653b8b..64a7764 100644 --- a/.trellis/workspace/yuxuanhui/index.md +++ b/.trellis/workspace/yuxuanhui/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 2 -- **Last Active**: 2026-08-07 +- **Total Sessions**: 3 +- **Last Active**: 2026-08-08 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~50 | Active | +| `journal-1.md` | ~71 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 3 | 2026-08-08 | 完成 00 Bootstrap Guidelines | `ee00641` | `develop` | | 2 | 2026-08-07 | 实现 Home 市场数据概览 | `b0e846d` | `develop` | | 1 | 2026-08-06 | 完成 TUSHARE PostgreSQL 同步任务 | `039a81f`, `11e8728` | `develop` | diff --git a/.trellis/workspace/yuxuanhui/journal-1.md b/.trellis/workspace/yuxuanhui/journal-1.md index 46dd15c..599148d 100644 --- a/.trellis/workspace/yuxuanhui/journal-1.md +++ b/.trellis/workspace/yuxuanhui/journal-1.md @@ -48,3 +48,24 @@ ### Status [OK] **Completed** + + +## Session 3: 完成 00 Bootstrap Guidelines + +**Date**: 2026-08-08 +**Task**: 完成 00 Bootstrap Guidelines +**Branch**: `develop` + +### Summary + +完成并归档 00-bootstrap-guidelines:后端、前端及跨层思考规范均已基于真实代码补齐并含代码示例。后端 20 项测试通过、1 项 PostgreSQL 集成测试因未设置 ZHIXING_TEST_DATABASE_URL 跳过;前端 10 项测试通过。完整 ./dev.sh check 受 HEAD 中两处既有 Ruff 格式问题阻断,未修改无关产品代码。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `ee00641` | (see git log) | + +### Status + +[OK] **Completed** -- 2.52.0 From 0c999fb828395f3532bd5ba004fae22b8ba16f27 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sat, 8 Aug 2026 19:52:46 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(web):=20=E8=B0=83=E6=95=B4=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=82=A1=E7=A5=A8=E5=BC=B9=E7=AA=97=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/home/components/failed-stocks-dialog.tsx | 2 +- zhixing-web/src/shared/ui/dialog.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/zhixing-web/src/features/home/components/failed-stocks-dialog.tsx b/zhixing-web/src/features/home/components/failed-stocks-dialog.tsx index 5e04983..87f933d 100644 --- a/zhixing-web/src/features/home/components/failed-stocks-dialog.tsx +++ b/zhixing-web/src/features/home/components/failed-stocks-dialog.tsx @@ -34,7 +34,7 @@ export function FailedStocksDialog({ 以下股票尚未在目标交易日同时具备完整的行情和估值数据。 - +
{failures.map((failure) => (
Date: Sat, 8 Aug 2026 22:41:45 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(selection):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E7=9F=A5=E8=A1=8CB1=E9=80=89=E8=82=A1=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/backend/index.md | 1 + .trellis/spec/backend/selection.md | 83 +++ .../08-08-migrate-zhixing-b1/check.jsonl | 9 + .../tasks/08-08-migrate-zhixing-b1/design.md | 217 +++++++ .../08-08-migrate-zhixing-b1/implement.jsonl | 10 + .../08-08-migrate-zhixing-b1/implement.md | 153 +++++ .../tasks/08-08-migrate-zhixing-b1/prd.md | 89 +++ .../research/legacy-zhixing-b1.md | 50 ++ .../tasks/08-08-migrate-zhixing-b1/task.json | 26 + zhixing-server/pyproject.toml | 3 + .../modules/selection/__init__.py | 1 + .../modules/selection/application/__init__.py | 1 + .../modules/selection/application/evaluate.py | 46 ++ .../modules/selection/domain/README.md | 6 + .../modules/selection/domain/__init__.py | 1 + .../modules/selection/domain/indicators.py | 230 +++++++ .../modules/selection/domain/models.py | 143 +++++ .../modules/selection/domain/ports.py | 18 + .../modules/selection/domain/zhixing_b1.py | 565 ++++++++++++++++++ .../selection/infrastructure/__init__.py | 1 + .../infrastructure/postgres_reader.py | 158 +++++ .../selection/presentation/__init__.py | 1 + .../fixtures/selection/zhixing_b1/README.md | 11 + .../fixtures/selection/zhixing_b1/golden.json | 31 + .../selection/zhixing_b1/ordinary.csv | 116 ++++ .../selection/zhixing_b1/wide_limit.csv | 116 ++++ .../integration/test_zhixing_b1_golden.py | 41 ++ .../tests/unit/selection/test_evaluate.py | 31 + .../tests/unit/selection/test_indicators.py | 69 +++ .../unit/selection/test_postgres_reader.py | 84 +++ .../tests/unit/selection/test_zhixing_b1.py | 94 +++ zhixing-server/uv.lock | 18 + 32 files changed, 2423 insertions(+) create mode 100644 .trellis/spec/backend/selection.md create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/check.jsonl create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/design.md create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/implement.jsonl create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/implement.md create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/prd.md create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md create mode 100644 .trellis/tasks/08-08-migrate-zhixing-b1/task.json create mode 100644 zhixing-server/src/zhixing_server/modules/selection/__init__.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/application/__init__.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/application/evaluate.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/README.md create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/__init__.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/indicators.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/models.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/ports.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/domain/zhixing_b1.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/infrastructure/__init__.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py create mode 100644 zhixing-server/src/zhixing_server/modules/selection/presentation/__init__.py create mode 100644 zhixing-server/tests/fixtures/selection/zhixing_b1/README.md create mode 100644 zhixing-server/tests/fixtures/selection/zhixing_b1/golden.json create mode 100644 zhixing-server/tests/fixtures/selection/zhixing_b1/ordinary.csv create mode 100644 zhixing-server/tests/fixtures/selection/zhixing_b1/wide_limit.csv create mode 100644 zhixing-server/tests/integration/test_zhixing_b1_golden.py create mode 100644 zhixing-server/tests/unit/selection/test_evaluate.py create mode 100644 zhixing-server/tests/unit/selection/test_indicators.py create mode 100644 zhixing-server/tests/unit/selection/test_postgres_reader.py create mode 100644 zhixing-server/tests/unit/selection/test_zhixing_b1.py diff --git a/.trellis/spec/backend/index.md b/.trellis/spec/backend/index.md index 7c454cc..f8cd15f 100644 --- a/.trellis/spec/backend/index.md +++ b/.trellis/spec/backend/index.md @@ -9,6 +9,7 @@ | [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 | | [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 | | [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 | +| [历史选股](./selection.md) | selection bounded context、目标交易日、qfq 读取和信号结果契约 | | [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 | | [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 | | [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 | diff --git a/.trellis/spec/backend/selection.md b/.trellis/spec/backend/selection.md new file mode 100644 index 0000000..5eb2134 --- /dev/null +++ b/.trellis/spec/backend/selection.md @@ -0,0 +1,83 @@ +# 历史选股代码规格 + +## Scenario: `zhixing_b1` 历史公式评估 + +### 1. Scope / Trigger + +- 触发:新增 `modules/selection` bounded context,基于 PostgreSQL 已保存的 qfq + 日线执行历史知行 B1 公式。 +- 边界:selection 只读市场事实并返回领域评估结果;不负责市场数据同步、信号 + 持久化、批次调度、HTTP 路由或前端展示。 + +### 2. Signatures + +- `MarketDataReader.load_history(ts_code: str, target_trade_date: date) -> StockHistory` +- `ZhixingB1Strategy.evaluate(history: StockHistory, target_trade_date: date) -> SelectionEvaluation` +- `EvaluateZhixingB1.execute(ts_code: str, target_trade_date: date) -> SelectionEvaluation` +- `SelectionSignal.identity -> tuple[str, date, str, str]` + +### 3. Contracts + +- `StockHistory.bars` 必须是升序、去重的 qfq 行情,且不得包含目标交易日之后的 + 数据;`daily_basic` 按交易日保存同日可空指标。 +- `SelectionBar` 使用有限的 `float | None` 表示 OHLCV;目标日的 open/high/low/ + close/volume 任一缺失时不得生成信号。 +- 策略名称固定为 `zhixing_b1`;7 个分类固定为 + `zhixing_b1_oversold_turn`、`zhixing_b1_oversold_volume`、 + `zhixing_b1_original_b1`、`zhixing_b1_extreme_volume`、 + `zhixing_b1_pullback_white`、`zhixing_b1_pullback_super`、 + `zhixing_b1_pullback_yellow`。 +- 同一股票同一交易日可以返回多个分类;唯一身份是 + `(ts_code, target_trade_date, strategy, category)`,返回顺序遵循 + `ZHIXING_B1_SIGNAL_ORDER`。 +- PostgreSQL reader 必须参数化查询 `source_adj = 'qfq'` 且 + `trade_date <= target_trade_date`,左连接同日 `market_daily_basic`;不得回退到 + CSV、Tushare 或当前最后一行。 +- 评估状态区分 `selected`、`no_signal`、`insufficient_history`、 + `missing_target_bar` 和 `data_error`。业务状态不是异常,数据库读取失败才映射为 + `data_error`。 + +### 4. Validation & Error Matrix + +| 条件 | 行为 | +| --- | --- | +| 少于公式最小暖机长度 | 返回 `insufficient_history`,不返回信号 | +| 目标日没有 qfq bar 或 OHLCV 不完整 | 返回 `missing_target_bar` | +| 目标日数据完整但无分类命中 | 返回 `no_signal` | +| PostgreSQL 读取失败 | 返回 `data_error`,保留股票和目标日上下文 | +| rolling 窗口不足 | 只使用已到达的交易行;`EVERY` 等需要完整窗口的条件不命中 | +| 除零、NaN 或无穷中间值 | 转为 NaN/False,不得静默制造命中 | + +### 5. Good / Base / Bad Cases + +- Good:给定历史目标日,reader 只返回该日及之前的 qfq 行,策略返回可序列化详情 + 和全部命中分类。 +- Base:同日多分类命中时每类都保留稳定身份;同日 `daily_basic` 缺失只影响实际 + 依赖该指标的条件。 +- Bad:用当前最新市值覆盖历史 K 线、取 bars 最后一行代替显式目标日,或把 7 类 + mask OR 成一个结果后丢失分类。 + +### 6. Tests Required + +- 指标单元测试:rolling 暖机、交易日 `REF`、窗口边界、除零、NaN、宽幅代码参数。 +- 策略单元测试:目标日截断、暖机/缺失状态、7 个 mask 独立存在和同日多分类身份。 +- reader 单元测试:参数化 SQL、qfq 过滤、目标日截断、升序映射和 left join 可空值。 +- golden 测试:离线固定 fixture 可重复运行;不得在测试运行时导入旧项目或访问生产库。 + +### 7. Wrong vs Correct + +#### Wrong + +```python +# 会产生历史前视数据,并丢弃同日的其他分类。 +target = history.bars[-1] +first = next(category for category in categories if masks[category].iloc[-1]) +``` + +#### Correct + +```python +# 用显式交易日定位,并保留所有独立 mask 的命中。 +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]) +``` diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/check.jsonl b/.trellis/tasks/08-08-migrate-zhixing-b1/check.jsonl new file mode 100644 index 0000000..8a9ca16 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/check.jsonl @@ -0,0 +1,9 @@ +{"file":".trellis/spec/backend/index.md","reason":"检查新增选股上下文是否遵循后端入口和模块边界。"} +{"file":".trellis/spec/backend/directory-structure.md","reason":"检查 domain、application、infrastructure、presentation 的依赖方向和目录职责。"} +{"file":".trellis/spec/backend/market-data-sync.md","reason":"检查 reader 是否只读 qfq 市场事实,并正确处理目标日、六年窗口和数据缺失。"} +{"file":".trellis/spec/backend/error-handling.md","reason":"检查数据错误是否可识别、未被吞掉,并与无信号状态区分。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行并核对 Ruff、Pyright、pytest 及直接依赖声明。"} +{"file":"docs/adr/0001-bounded-context-first-modular-monolith.md","reason":"检查没有跨上下文引入全局层或把业务规则放入 shared。"} +{"file":"docs/adr/0003-postgresql-as-market-data-store.md","reason":"检查没有把 CSV、旧项目最新市值或非 qfq 数据作为运行时事实。"} +{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"检查历史目标日、有效数据和同步资格约束没有被策略实现绕过。"} +{"file":".trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md","reason":"检查公式迁移、7 个分类、旧实现差异和 fixture 证据是否保留。"} diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/design.md b/.trellis/tasks/08-08-migrate-zhixing-b1/design.md new file mode 100644 index 0000000..5f6acba --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/design.md @@ -0,0 +1,217 @@ +# 知行 B1 选股策略迁移设计 + +## 1. 设计目标 + +在新项目中建立 `selection` bounded context,完成 `zhixing_b1` 的第一条公式级 +垂直切片:策略可以接收明确的目标交易日和历史行情,按通达信公式计算 7 个 +子信号,并返回可解释、可重复、可区分的多分类信号结果。 + +本任务不创建信号数据库表、HTTP API、前端页面或全市场批次调度。信号模型先 +提供稳定身份和后续持久化所需的契约。 + +## 2. 上下文与依赖方向 + +新增目录: + +```text +zhixing-server/src/zhixing_server/modules/selection/ +├── domain/ +│ ├── models.py # 行情输入、策略信号、评估结果 +│ ├── ports.py # 市场历史读取端口 +│ ├── indicators.py # TDX 风格滚动/递推指标原语 +│ ├── zhixing_b1.py # 知行 B1 公式及 7 个子信号 +│ └── __init__.py +├── application/ +│ ├── evaluate.py # 单股票、明确目标日的策略用例 +│ └── __init__.py +├── infrastructure/ +│ ├── postgres_reader.py # 只读 PostgreSQL 市场数据适配器 +│ └── __init__.py +└── presentation/ + └── __init__.py +``` + +- `selection.domain` 不导入 FastAPI、Psycopg 或 PostgreSQL 适配器。 +- `selection.domain.ports` 定义策略需要的最小 `MarketDataReader`,不复用旧项目 + 的 CSV repository,也不让策略直接拼 SQL。 +- `selection.infrastructure.postgres_reader` 只读 `market_stock`、 + `market_daily_bar` 和 `market_daily_basic`;市场数据写入仍归 `market_data` + bounded context 所有。 +- `selection.application` 负责把目标代码、目标交易日交给 reader 和纯领域策略, + 将数据缺失、无信号和基础设施错误区分开。 +- 暂不把 `selection` 接入顶层 HTTP router 或 FastAPI 应用组合,避免首期引入 + 未确定的产品 API 契约。 + +## 3. 领域模型 + +### 3.1 输入模型 + +定义面向策略的只读分析模型,不把 PostgreSQL 的 `Decimal`、Tushare 字段名或 +Pandas DataFrame 暴露给应用调用方: + +- `SelectionBar`:`trade_date`、`open`、`high`、`low`、`close`、`volume`,价格 + 和成交量在适配器边界转换为与旧项目一致的有限 `float`。 +- `SelectionDailyBasic`:`trade_date`、可选 `turnover_rate`、`total_mv` 等策略 + 可能使用的同日指标。 +- `StockHistory`:股票代码、名称、升序 bars、按日期索引的 daily basic;只包含 + `trade_date <= target_trade_date` 的记录。 + +`MarketDataReader.load_history(ts_code, target_trade_date)` 必须保证: + +1. bars 按交易日升序、去重,且只来自 `source_adj = 'qfq'`; +2. 不把目标日之后的数据泄露给策略; +3. 返回足够的历史 warm-up。首期直接返回数据库保留窗口内截至目标日的全部可用 + 行情,避免人为截断导致 EMA/KDJ 与旧实现不一致; +4. 目标日没有有效 bar 时返回可识别的缺失状态,而不是把更早日期伪装成目标日; +5. 同日 `daily_basic` 缺失保留为可观察的缺失值,只有公式实际需要的字段才影响 + 可选条件。 + +### 3.2 信号模型 + +定义 `ZhixingB1Category` 的 7 个稳定语义分类,外部值不再使用旧的 +`xg_composite` 前缀: + +- `zhixing_b1_oversold_turn` +- `zhixing_b1_oversold_volume` +- `zhixing_b1_original_b1` +- `zhixing_b1_extreme_volume` +- `zhixing_b1_pullback_white` +- `zhixing_b1_pullback_super` +- `zhixing_b1_pullback_yellow` + +`SelectionSignal` 至少包含: + +- `ts_code`、`name`、`target_trade_date`; +- `strategy = "zhixing_b1"`; +- 一个 `ZhixingB1Category`; +- qfq `close`; +- 可序列化的关键详情,如 J、RSI、知行白线/黄线、振幅、成交量比和命中的 + 公式标签。 + +稳定身份为 `(ts_code, target_trade_date, strategy, category)`。同一行数据可以 + 产生多个 category,返回顺序固定为公式文件中 7 个子信号的优先级顺序。 + +### 3.3 评估结果 + +用例返回带状态的 `SelectionEvaluation`,至少区分: + +- `selected`:至少一个子信号命中; +- `no_signal`:目标日数据完整但没有子信号命中; +- `insufficient_history`:少于公式要求的最小暖机长度; +- `missing_target_bar`:目标交易日没有 qfq 日线; +- `data_error`:市场数据适配器发生不可恢复的读取错误。 + +`no_signal`、`insufficient_history` 和 `missing_target_bar` 不是异常;数据库连接 +或 SQL 失败才转换为带上下文的基础设施错误。 + +## 4. 公式实现策略 + +### 4.1 计算层 + +为保持与旧实现及通达信公式的数值语义一致,首期使用直接依赖的 Pandas/NumPy +实现向量化指标。当前 `tushare` 已将 Pandas/NumPy 带入锁文件,但新代码直接 +使用它们,因此实施阶段将把 `pandas` 和 `numpy` 声明为后端直接依赖并更新 +`uv.lock`。 + +在 `selection.domain.indicators` 内实现或迁移以下原语,并用纯输入测试锁定边界: + +- `MA`、`EMA`、`LLV`、`HHV`、`SMA`、`REF`; +- `EXIST`、`EVERY`、`COUNT`、`HHVBARS`、`BARSLAST`、`CROSS`; +- TDX 风格 KDJ、RSI、知行白线/黄线; +- 板块宽幅判定及振幅区间/放宽系数; +- 大绿棒、缩量、异动、趋势、回踩和 BBI 派生条件。 + +旧项目的 `prepare_xg_indicators()` 可以作为迁移起点,但不得原样保留对旧项目 +`SignalCategory`、`zgnb` 包或旧 CSV 字段的导入。所有跨公式共享原语先归入 +`selection` 上下文,等第二个策略迁移时再根据真实复用情况决定是否上移到 +`shared`。 + +### 4.2 7 个子信号 + +将旧实现的 7 个 mask 逐一迁移为命名清晰的领域计算步骤,计算结果保留每个 +mask,而不是先 OR 成单一 `_存在B` 后只取第一项。最终组合逻辑为: + +```text +all_matches = [category for category in priority_order if category.mask(target_row)] +``` + +每个 mask 必须与通达信公式逐段对照;旧实现已经存在的 v1203 调整(例如上涨 +十字星的涨幅限制、原始 B1 的放宽缩量分支)作为有意语义保留,并在测试名或 +fixture 说明中标明。 + +### 4.3 缺失值和暖机 + +- 公式所需 rolling 窗口不足时遵循旧实现的窗口语义,不用当前行的未来数据补齐。 +- 目标日需要的 OHLCV 缺失时不生成信号。 +- 中间指标出现 NaN 时,比较型条件默认不命中;除零场景显式转为 NaN/False, + 不让异常被静默吞掉。 +- 不使用 `except Exception` 将单个子信号错误转成全局无信号;公式实现错误应 + 让测试或应用调用失败可见。 + +## 5. PostgreSQL 只读适配器 + +`PostgresMarketDataReader` 使用现有 `Settings.database_url`,通过参数化 SQL +读取: + +```sql +SELECT + bar.ts_code, bar.trade_date, bar.open, bar.high, bar.low, bar.close, + bar.vol, basic.turnover_rate, basic.total_mv +FROM market_daily_bar AS bar +LEFT JOIN market_daily_basic AS basic + ON basic.ts_code = bar.ts_code + AND basic.trade_date = bar.trade_date +WHERE bar.ts_code = %s + AND bar.source_adj = 'qfq' + AND bar.trade_date <= %s +ORDER BY bar.trade_date +``` + +适配器只负责查询、字段映射、排序和缺失状态;不写数据库、不回退到 CSV、不 +调用 Tushare。查询整个六年保留窗口是首期的正确性优先选择,后续全市场运行 +若证明有性能压力再引入可配置 warm-up 窗口和批量读取。 + +## 6. 验证策略 + +### 6.1 公式单元测试 + +为每个原语和 7 个 mask 提供边界案例,至少包含: + +- rolling 窗口刚好不足、刚好满足和超过; +- `high == low`、前收为零、成交量为零、NaN/None; +- 30/68/普通代码的幅度参数; +- 大绿棒在 15 日前/后、当前最大量切换; +- 上涨十字星涨幅小于 4% 与超过 4%; +- 同一行同时满足多个 mask。 + +### 6.2 历史 golden + +从旧项目现有 `data/raw` 中选择少量公开行情样本,抽取为新项目测试 fixture, +不让测试运行时依赖旧项目目录。固定 fixture 包含: + +- 升序 qfq OHLCV CSV; +- 目标交易日和股票代码; +- 旧实现/人工确认的命中分类集合; +- 关键详情允许小数误差的期望值。 + +golden 只验证固定样本,不把旧实现当成新实现的运行时依赖。对于旧实现当前 +只保留第一个子信号的行为,golden 记录“新实现返回全部命中分类”的有意差异。 + +### 6.3 端口和适配器测试 + +- Fake reader 测试应用用例只传入目标日前数据,并区分无信号、目标日缺失和 + 基础设施错误。 +- PostgreSQL reader 使用 mock/fake connection 验证参数化查询、qfq 过滤、升序 + 映射和基本指标左连接;不访问真实网络。 +- 如使用 PostgreSQL 集成测试,沿用 `ZHIXING_TEST_DATABASE_URL` marker,且 + 不把它作为普通单测必需条件。 + +## 7. 兼容性、回滚与后续演进 + +- 不修改旧项目目录和旧 SQLite 数据;迁移结果通过 `zhixing_b1` 新身份区分。 +- 首期不创建数据库迁移,因此回滚只需移除新 selection 模块和直接依赖,不影响 + 已有 market-data 表及同步任务。 +- 后续增加策略时,优先复用真实验证后确认的 `selection.domain.indicators`; + 不预先建立跨上下文的全局指标工具箱。 +- 后续实现信号持久化时,可直接使用稳定身份 `(ts_code, date, strategy, + category)` 建立唯一键;本任务不提前锁定表结构或 HTTP 字段。 diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/implement.jsonl b/.trellis/tasks/08-08-migrate-zhixing-b1/implement.jsonl new file mode 100644 index 0000000..8509d33 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/implement.jsonl @@ -0,0 +1,10 @@ +{"file":".trellis/spec/backend/index.md","reason":"实现 selection bounded context 前确认后端分层、开发前检查与质量入口。"} +{"file":".trellis/spec/backend/directory-structure.md","reason":"按 domain/application/infrastructure/presentation 边界创建选股上下文,并保持导入方向。"} +{"file":".trellis/spec/backend/market-data-sync.md","reason":"读取 market_daily_bar 和 market_daily_basic 时保留 qfq、六年窗口、目标日新鲜度和数据事实源契约。"} +{"file":".trellis/spec/backend/error-handling.md","reason":"区分无信号、目标数据缺失和数据库读取失败,避免把异常静默成空结果。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循 Python 3.12、严格类型、Ruff、Pyright 和 pytest 质量要求。"} +{"file":"docs/adr/0001-bounded-context-first-modular-monolith.md","reason":"确认选股能力应作为明确 bounded context 演进,而不是创建全局 service 或 utils。"} +{"file":"docs/adr/0003-postgresql-as-market-data-store.md","reason":"确认 PostgreSQL 是策略事实源,价格使用 qfq,当前股票池和历史分析语义不被策略迁移改变。"} +{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"确认策略只消费有效目标日数据、六年窗口和覆盖率资格,不改同步契约。"} +{"file":"CONTEXT.md","reason":"使用现有市场数据和分析语义,补充 zhixing_b1、子信号与公式语义的项目术语。"} +{"file":".trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md","reason":"实现时按已核对的旧公式、v1203 差异和有意行为变化迁移,不把旧项目运行时导入新项目。"} diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/implement.md b/.trellis/tasks/08-08-migrate-zhixing-b1/implement.md new file mode 100644 index 0000000..98a68d4 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/implement.md @@ -0,0 +1,153 @@ +# 知行 B1 选股策略实施计划 + +## 实施原则 + +- 只修改 `zhixing-system`;旧项目只读,不回写、不重命名、不提交旧项目数据。 +- 先写行为测试,再补最小实现;每一步保持 `uv run pytest` 可定位失败范围。 +- `zhixing_b1` 是新策略身份;不要把 `xg_composite` 作为新模块的对外名称。 +- 公式语义优先于旧 Python 的偶然行为;每个有意差异都要在 fixture 或测试名中 + 留下证据。 +- 不启动 HTTP、前端、信号持久化或全市场批次编排;它们属于后续任务。 + +## 1. 依赖与模块骨架 + +- 在 `zhixing-server/pyproject.toml` 声明直接依赖 `pandas` 和 `numpy`,执行 + `uv lock`,确认锁文件与 Python 3.12 环境一致。 +- 创建 `modules/selection/{domain,application,infrastructure,presentation}` + 包和上下文 README,保持 domain 不导入 FastAPI/Psycopg。 +- 先新增领域模型、端口和评估状态类型,再接入基础设施。 + +验证: + +```bash +cd zhixing-server +uv lock --check +uv run python -c "import numpy, pandas; print(numpy.__version__, pandas.__version__)" +``` + +回滚点:依赖或包骨架若无法通过 Ruff/Pyright,先撤销骨架,不触碰 +`modules/market_data`。 + +## 2. 迁移公式原语 + +- 从旧项目 `shared/indicators.py`、`kdj.py`、`rsi.py`、`zhixing.py` 和 + `market_type.py` 提取必要实现到 `selection/domain/indicators.py`。 +- 先覆盖 `MA/EMA/LLV/HHV/SMA/REF/EXIST/EVERY/COUNT/HHVBARS/BARSLAST/CROSS`, + 再实现 KDJ、RSI、知行线和板块幅度参数。 +- 每个公共函数增加完整类型、参数/返回值/边界说明;NaN、除零和不足窗口行为 + 用测试锁定。 +- 不引入 Numba、SciPy 或其他旧项目专用依赖;首期 Pandas/NumPy 足够保持公式 + 计算的向量化和数值接近。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/unit/selection/test_indicators.py +``` + +## 3. 实现 `ZhixingB1Strategy` + +- 将旧 `prepare_xg_indicators()` 拆成可读的领域计算步骤:基础线、振幅、KDJ/RSI、 + 缩量、大绿棒、异动、趋势、距离/回踩、7 个子信号。 +- 策略类固定 `name = "zhixing_b1"`,公开入口显式接收 `StockHistory` 和 + `target_trade_date`。 +- 对目标交易日定位使用日期索引,不使用 DataFrame 最后一行猜测目标日期。 +- 保留 7 个 mask 的全部命中,按公式顺序产生多个 `SelectionSignal`,不可使用 + 旧代码中的 `break`。 +- 详情只写可序列化、与目标行相关的关键指标;不要保存整张 DataFrame。 +- 对目标日缺失、历史不足、无信号分别返回评估状态;公式计算异常向上暴露, + 不静默转换为空结果。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/unit/selection/test_zhixing_b1.py +``` + +回滚点:若结果数量明显偏离 golden,保留公式原语测试和差异报告,回滚策略 +编排层,不回退到旧 `xg_composite` 命名。 + +## 4. 建立市场数据读取端口与 PostgreSQL 适配器 + +- 在 `selection/domain/ports.py` 定义只读 `MarketDataReader`。 +- 在 `selection/infrastructure/postgres_reader.py` 实现参数化查询,读取 qfq + `market_daily_bar`,左连接同日 `market_daily_basic`,按日期升序映射为 + `StockHistory`。 +- 从 `Settings` 注入连接串;不直接读取环境变量,不调用 Tushare,不回退 CSV。 +- 使用数据库事实表的 `source_adj = 'qfq'` 过滤,拒绝目标日之后的行。 +- 连接失败转换为带股票和目标日上下文的基础设施错误;目标日无 bar 属于可识别 + 的业务状态。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/unit/selection/test_postgres_reader.py +``` + +若增加 PostgreSQL 集成覆盖: + +```bash +cd zhixing-server +uv run pytest -m integration tests/integration/test_selection_reader.py +``` + +回滚点:只读适配器失败时删除 selection 适配器即可;不得修改已有市场数据表、 +同步事务或 Alembic migration。 + +## 5. 应用用例与 Fake reader + +- 实现 `EvaluateZhixingB1`,输入 `ts_code`、`target_trade_date` 和 reader,输出 + `SelectionEvaluation`。 +- 用例只负责读取、调用领域策略和映射错误;不负责全市场循环、保存信号或 HTTP + 响应。 +- 提供 Fake reader 测试目标日期截断、缺失目标行、历史不足、无信号、选中多分类 + 和基础设施错误。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/unit/selection/test_evaluate.py +``` + +## 6. 固定 fixture 与 golden 对比 + +- 从旧项目现有 `data/raw` 中选取少量股票和目标日期,抽取最小 OHLCV CSV,放入 + `zhixing-server/tests/fixtures/selection/zhixing_b1/`。 +- 将旧实现或人工确认结果固化为 JSON,包含目标日期、命中分类集合和关键详情的 + 容差范围;测试运行时不导入旧项目。 +- 至少包含普通代码和宽幅代码,并加入一个人工构造的同日多信号样本。 +- 明确记录旧实现“只保留第一个分类”与新实现“保留全部分类”的差异。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/integration/test_zhixing_b1_golden.py +``` + +## 7. 完整质量检查与规划复核 + +实现结束后运行: + +```bash +cd zhixing-server +uv run ruff format --check . +uv run ruff check . +uv run pyright +uv run pytest +``` + +并检查: + +- `rg -n "xg_composite|zgnb\." src/zhixing_server/modules/selection tests` 只在 + 迁移说明或兼容性测试中出现,不成为新领域运行时依赖; +- 旧项目工作区没有被修改; +- 没有新增 HTTP 路由、前端文件、信号表 migration 或调度入口; +- golden、单元测试和端口测试都能在无网络、无生产数据库条件下运行。 + +完成 planning 后,先向用户展示 `prd.md`、`design.md` 和本文件摘要;只有用户 +明确批准最新 planning summary,才能执行 `task.py start` 并进入实现阶段。 diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/prd.md b/.trellis/tasks/08-08-migrate-zhixing-b1/prd.md new file mode 100644 index 0000000..8dcd7c9 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/prd.md @@ -0,0 +1,89 @@ +# 迁移知行B1选股策略 + +## Goal + +在新项目中迁移旧项目的 `xg_composite` 选股逻辑,并将新策略名称统一为 +`zhixing_b1`,让系统能够基于 PostgreSQL 中的历史市场数据执行可复现的知行 +B1 选股。 + +首期采用“公式级垂直切片”:从通达信公式、指标计算、7 个子信号、信号结果、 +市场数据读取到历史验证用例打通一条完整链路;不在本任务内铺开其他策略或完整 +前端产品能力。 + +## Background and confirmed facts + +- 旧项目的 `xg_composite` 对应通达信选股公式,包含 7 个子信号;实现位于 + `zgnb-project/src/zgnb/domain/strategy/xg_composite.py`,公式原文位于 + `zgnb-project/docs/references/formulas/tongdaxin_xuangu_formula.txt`。 +- 旧项目的共享指标实现位于 `zgnb-project/src/zgnb/shared/xg_indicators.py`, + 其中包含知行线、BBI、KDJ、RSI、振幅、趋势、回踩和 7 个子信号条件。 +- 旧项目目前只返回第一个命中的 XG 子信号;本任务以公式/业务意图为准,允许 + 同一股票同一交易日产生多条不同分类的信号。 +- 新项目已确定 PostgreSQL 为市场数据事实源、价格使用 qfq 日线、股票池为当前 + 沪深非 ST A 股,并保留 6 年数据窗口。 +- 新项目当前已有 `market_daily_bar`、`market_daily_basic` 和市场数据领域端口, + 但还没有策略读取端口、选股信号模型或策略 API。 + +## Requirements + +### R1. 迁移策略身份 + +- 新策略的业务标识为 `zhixing_b1`。 +- 领域逻辑不得继续依赖旧项目的 `xg_composite` 命名作为对外策略身份。 +- 7 个子信号保留独立分类,并可在同一股票同一交易日同时出现。 + +### R2. 公式语义 + +- 以通达信公式和已确认的 v1203 业务调整为主要依据,旧 Python 实现作为迁移 + 参考和差异线索。 +- 不复制旧流程中将最新市值写入全部历史 K 线、按当前日期查询历史 B1 信号等 + 不能支持历史重放的行为。 +- 对公式中涉及的代码板块、涨跌幅放宽系数、振幅区间、缩量、大绿棒、趋势、 + 回踩和 7 个子信号条件建立可测试的实现。 + +### R3. 市场数据读取 + +- 策略使用市场数据领域端口读取目标交易日之前的足够 warm-up 日线数据。 +- OHLCV 指标使用 `market_daily_bar` 的 qfq 价格与成交量;换手率等估值/交易 + 条件使用同一交易日的 `market_daily_basic` 快照。 +- 策略计算不得直接依赖 PostgreSQL、Pandas SQL 查询或具体 HTTP 层实现。 +- 历史选股必须显式使用 `target_trade_date`,不得隐式退化为“当前最后一行”。 + +### R4. 信号结果 + +- 信号至少包含股票、交易日、策略标识、子信号分类、收盘价和可解释的关键指标 + 详情。 +- 信号提供由“股票、交易日、策略标识、子信号分类”组成的稳定身份;不同子信号 + 分类不得被合并丢失,为后续持久化提供幂等依据。 + +### R5. 验证 + +- 为共享指标和每个子信号建立边界条件测试,覆盖缺数据、暖机期、除零和板块 + 参数差异。 +- 提供固定历史样本的策略级验证,能够判断新实现是否符合公式语义,并明确记录 + 与旧实现的有意差异。 +- 验证不得访问真实 Tushare、生产数据库或依赖实时网络。 + +## Out of scope + +- `bowl_rebound`、`b1`、`b1b2`、`brick_chart` 的迁移。 +- 完整选股批次编排、自动调度、前端页面和图表生成。 +- 选股信号 PostgreSQL 表、信号持久化实现和 HTTP API。 +- 实盘交易、回测收益评价和策略参数优化。 +- 为解决本任务而改变既有市场数据同步的股票池、qfq 或六年保留契约。 + +## Acceptance Criteria + +- [ ] 新项目存在名为 `zhixing_b1` 的领域策略,并能在显式目标交易日上运行。 +- [ ] 7 个子信号均有独立分类;同一股票同日多信号不会互相覆盖,并具有稳定身份。 +- [ ] 策略只通过市场数据端口获得 qfq 日线和同日交易指标,不直接耦合存储实现。 +- [ ] 公式关键分支、暖机边界、缺失值和除零场景均有自动化测试。 +- [ ] 固定历史样本验证可重复运行,并以固定 golden 结果对比新旧实现或公式差异。 +- [ ] 关键公式分支同时有人工确认案例和单元测试,golden 对比范围保持为少量固定样本。 +- [ ] 未实现其他策略、前端页面或实时数据能力,且不改变现有市场数据同步契约。 + +## Notes + +- Keep `prd.md` focused on requirements, constraints, and acceptance criteria. +- Lightweight tasks can remain PRD-only. +- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`. diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md b/.trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md new file mode 100644 index 0000000..893a912 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/research/legacy-zhixing-b1.md @@ -0,0 +1,50 @@ +# 旧项目知行 B1 逻辑研究 + +## 来源 + +- 公式原文:`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/docs/references/formulas/tongdaxin_xuangu_formula.txt` +- 旧策略入口:`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/strategy/xg_composite.py` +- 旧指标实现:`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/shared/xg_indicators.py` +- 旧数据字段转换:`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/infrastructure/data_source/tushare_adapter.py` + +## 已确认逻辑 + +`xg_composite` 先计算趋势白线、大哥黄线、BBI、短期/长期振荡器、KDJ、RSI、 +振幅区间、缩量、大绿棒、异动、趋势和回踩条件,再执行 7 个子信号 mask: + +1. 超卖缩量拐头 B +2. 超卖缩量 B +3. 原始 B1 +4. 超卖超缩量 B +5. 回踩白线 B +6. 回踩超级 B +7. 回踩黄线 B + +旧实现将 7 个 mask OR 为 `_存在B`,在 `select()` 中按优先级找到第一个匹配后 +`break`。新任务明确改为返回全部命中分类。 + +## 迁移时必须保留的语义 + +- 数据升序;`REF` 使用前一交易日,不能用自然日偏移。 +- `趋势白线 = EMA(EMA(C, 10), 10)`。 +- `大哥黄线 = (MA(C,14)+MA(C,28)+MA(C,57)+MA(C,114))/4`。 +- `短期` 使用 3 日最低价和 3 日最高收盘价,`长期` 使用 21 日窗口。 +- 宽幅代码为 `68`、`30`、`4`、`8`、`9` 开头;普通代码如果最近 200 行内出现 + 超过 15% 的上涨,也使用宽幅参数。 +- v1203 调整包含上涨十字星涨幅上限和原始 B1 的适当缩量分支。 + +## 旧实现与新任务的有意差异 + +- 新策略标识为 `zhixing_b1`,不再使用 `xg_composite` 作为运行时名称。 +- 新分类值使用 `zhixing_b1_*` 前缀,不依赖旧 `SignalCategory.XG_*`。 +- 新实现保留同一股票同一交易日的全部命中分类。 +- 新实现目标日由显式 `target_trade_date` 决定,不取数据最后一行作为隐式目标。 +- 新实现只使用新项目 PostgreSQL 的 qfq 日线;旧项目 CSV 中的 `market_cap` 是 + 初始化时取到的最新市值复制值,不能作为历史事实。 +- 新测试运行时不导入旧项目;旧项目只用于生成固定 fixture 和 golden 期望。 + +## 现有 fixture 线索 + +旧项目已有完整 CSV 和 SQLite 信号结果,可从 `data/raw` 选取少量普通代码、宽幅 +代码和已命中日期作为固定样本。由于旧实现没有记录同日多分类命中,需另加人工 +构造样本验证新任务要求的多信号结果。 diff --git a/.trellis/tasks/08-08-migrate-zhixing-b1/task.json b/.trellis/tasks/08-08-migrate-zhixing-b1/task.json new file mode 100644 index 0000000..2fcc151 --- /dev/null +++ b/.trellis/tasks/08-08-migrate-zhixing-b1/task.json @@ -0,0 +1,26 @@ +{ + "id": "migrate-zhixing-b1", + "name": "migrate-zhixing-b1", + "title": "迁移知行B1选股策略", + "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/pyproject.toml b/zhixing-server/pyproject.toml index 8df7d40..894ebb2 100644 --- a/zhixing-server/pyproject.toml +++ b/zhixing-server/pyproject.toml @@ -7,6 +7,8 @@ requires-python = ">=3.12,<3.13" dependencies = [ "alembic>=1.18.0", "fastapi>=0.141.1", + "numpy>=2.4.0", + "pandas>=2.3.3", "psycopg[binary]>=3.3.2", "pydantic-settings>=2.14.2", "sqlalchemy>=2.0.46", @@ -17,6 +19,7 @@ dependencies = [ [dependency-groups] dev = [ "httpx2>=2.9.1", + "pandas-stubs>=2.3.2.250926", "pyright>=1.1.411", "pytest>=9.1.1", "pytest-cov>=7.1.0", diff --git a/zhixing-server/src/zhixing_server/modules/selection/__init__.py b/zhixing-server/src/zhixing_server/modules/selection/__init__.py new file mode 100644 index 0000000..a7d4824 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/__init__.py @@ -0,0 +1 @@ +"""Selection bounded context for reproducible historical strategy evaluation.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/__init__.py b/zhixing-server/src/zhixing_server/modules/selection/application/__init__.py new file mode 100644 index 0000000..6db86d8 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/application/__init__.py @@ -0,0 +1 @@ +"""Selection application use cases.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/evaluate.py b/zhixing-server/src/zhixing_server/modules/selection/application/evaluate.py new file mode 100644 index 0000000..429d7ef --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/application/evaluate.py @@ -0,0 +1,46 @@ +"""Application use case for one-stock historical Zhixing B1 evaluation.""" + +from __future__ import annotations + +from datetime import date + +from ..domain.models import SelectionEvaluation, StockHistory +from ..domain.ports import MarketDataReader, MarketDataReaderError +from ..domain.zhixing_b1 import ZhixingB1Strategy + + +class EvaluateZhixingB1: + """Read one history, evaluate the pure strategy, and map read failures.""" + + def __init__( + self, + reader: MarketDataReader, + strategy: ZhixingB1Strategy | None = None, + ) -> None: + """Inject the market-data port and optionally a strategy instance.""" + + self.reader = reader + self.strategy = strategy or ZhixingB1Strategy() + + def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation: + """Evaluate ``ts_code`` on the exact requested trading date.""" + + try: + history = self.reader.load_history(ts_code, target_trade_date) + except MarketDataReaderError as exc: + return SelectionEvaluation( + ts_code=ts_code, + target_trade_date=target_trade_date, + status="data_error", + reason=str(exc), + ) + return self.strategy.evaluate(history, target_trade_date) + + def execute_history( + self, + history: StockHistory, + target_trade_date: date, + ) -> SelectionEvaluation: + """Evaluate an already loaded history for deterministic unit tests.""" + + return self.strategy.evaluate(history, target_trade_date) diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/README.md b/zhixing-server/src/zhixing_server/modules/selection/domain/README.md new file mode 100644 index 0000000..67c2b28 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/README.md @@ -0,0 +1,6 @@ +# Selection bounded context + +`selection` owns formula semantics and historical evaluation models for +`zhixing_b1`. Its domain imports only Pandas/NumPy and its own models/ports; +PostgreSQL remains behind `infrastructure/postgres_reader.py`. This first slice +does not expose HTTP routes or write signal records. diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/__init__.py b/zhixing-server/src/zhixing_server/modules/selection/domain/__init__.py new file mode 100644 index 0000000..ac6e775 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/__init__.py @@ -0,0 +1 @@ +"""Pure selection domain models, indicators, ports, and strategies.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/indicators.py b/zhixing-server/src/zhixing_server/modules/selection/domain/indicators.py new file mode 100644 index 0000000..1239f8d --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/indicators.py @@ -0,0 +1,230 @@ +"""TDX-style indicator primitives used by the Zhixing B1 formula. + +All inputs are ascending by trading date. Rolling functions intentionally +use available observations for the early rows, while ``EVERY`` keeps its +full-window requirement. This matches the legacy formula's warm-up behavior +without using future rows. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import numpy as np +import pandas as pd + + +def _check_window(window: int) -> None: + """Validate a positive TDX lookback window.""" + + if window < 1: + raise ValueError("window must be positive") + + +def MA(series: pd.Series, window: int) -> pd.Series: + """Return a simple moving average with available-row warm-up.""" + + _check_window(window) + return series.rolling(window=window, min_periods=1).mean() + + +def EMA(series: pd.Series, window: int) -> pd.Series: + """Return an adjust-false exponential moving average.""" + + _check_window(window) + return series.ewm(span=window, adjust=False, min_periods=1).mean() + + +def LLV(series: pd.Series, window: int) -> pd.Series: + """Return the lowest value in the trailing window.""" + + _check_window(window) + return series.rolling(window=window, min_periods=1).min() + + +def HHV(series: pd.Series, window: int) -> pd.Series: + """Return the highest value in the trailing window.""" + + _check_window(window) + return series.rolling(window=window, min_periods=1).max() + + +def SMA(series: pd.Series, window: int, weight: int = 1) -> pd.Series: + """Return TDX ``SMA(X,N,M)`` using its recursive weighted average.""" + + _check_window(window) + if weight < 0 or weight > window: + raise ValueError("weight must be between zero and window") + return series.ewm(alpha=weight / window, adjust=False, min_periods=1).mean() + + +def REF(series: pd.Series, periods: int) -> pd.Series: + """Return the value ``periods`` trading rows ago.""" + + if periods < 0: + raise ValueError("periods must not be negative") + return series.shift(periods) + + +def EXIST(condition: pd.Series, window: int) -> pd.Series: + """Return whether a condition occurred at least once in the window.""" + + _check_window(window) + values = condition.fillna(False).astype(bool).astype(float) + return values.rolling(window=window, min_periods=1).max().astype(bool) + + +def EVERY(condition: pd.Series, window: int) -> pd.Series: + """Return whether every row in a complete trailing window is true.""" + + _check_window(window) + values = condition.fillna(False).astype(bool).astype(float) + return values.rolling(window=window, min_periods=window).min().fillna(0).astype(bool) + + +def COUNT(condition: pd.Series, window: int) -> pd.Series: + """Count true rows in the trailing window.""" + + _check_window(window) + values = condition.fillna(False).astype(bool).astype(float) + return values.rolling(window=window, min_periods=1).sum() + + +def HHVBARS(series: pd.Series, window: int) -> pd.Series: + """Return periods since the most recent trailing maximum.""" + + _check_window(window) + values = series.to_numpy(dtype=float) + result = np.full(len(values), np.nan, dtype=float) + for index in range(len(values)): + start = max(0, index - window + 1) + trailing = values[start : index + 1] + finite = np.isfinite(trailing) + if not finite.any(): + continue + maximum = np.nanmax(trailing) + latest = np.flatnonzero(finite & (trailing == maximum))[-1] + result[index] = len(trailing) - 1 - int(latest) + return pd.Series(result, index=series.index, dtype=float) + + +def BARSLAST(condition: pd.Series) -> pd.Series: + """Return periods since the most recent true row, or NaN before one.""" + + values = condition.fillna(False).astype(bool).to_numpy() + result = np.full(len(values), np.nan, dtype=float) + last_true = -1 + for index, matched in enumerate(values): + if matched: + last_true = index + if last_true >= 0: + result[index] = index - last_true + return pd.Series(result, index=condition.index, dtype=float) + + +def CROSS(left: pd.Series, right: pd.Series) -> pd.Series: + """Return rows where ``left`` crosses from below to at-or-above right.""" + + previous_left = REF(left, 1) + previous_right = REF(right, 1) + return ( + previous_left.notna() + & previous_right.notna() + & left.notna() + & right.notna() + & (previous_left < previous_right) + & (left >= right) + ) + + +def compute_kdj(frame: pd.DataFrame, window: int = 9) -> pd.DataFrame: + """Compute ascending-data K, D and J values. + + A zero high-low range is represented as NaN. K and D carry their prior + state across such a row, while J remains NaN there, preventing a flat or + incomplete bar from becoming an oversold signal. + """ + + _check_window(window) + if frame.empty: + return pd.DataFrame(index=frame.index, data={"K": [], "D": [], "J": []}) + low = LLV(frame["low"], window) + high = HHV(frame["high"], window) + denominator = high - low + rsv = ((frame["close"] - low) / denominator.replace(0, np.nan) * 100).to_numpy(float) + k = np.full(len(rsv), np.nan, dtype=float) + d = np.full(len(rsv), np.nan, dtype=float) + previous_k = 50.0 + previous_d = 50.0 + for index, value in enumerate(rsv): + if np.isfinite(value): + previous_k = (2.0 * previous_k + value) / 3.0 + previous_d = (2.0 * previous_d + previous_k) / 3.0 + k[index] = previous_k + d[index] = previous_d + j = 3.0 * k - 2.0 * d + return pd.DataFrame(index=frame.index, data={"K": k, "D": d, "J": j}) + + +def compute_rsi(close: pd.Series, window: int = 3) -> pd.Series: + """Compute TDX RSI from close prices, preserving zero-denominator NaN.""" + + _check_window(window) + previous = REF(close, 1) + change = close - previous + gain = change.clip(lower=0) + absolute_change = change.abs() + denominator = SMA(absolute_change, window, 1) + return SMA(gain, window, 1).div(denominator.replace(0, np.nan)).mul(100) + + +def compute_zhixing_lines(close: pd.Series) -> tuple[pd.Series, pd.Series]: + """Return the formula's trend white line and 4-MA yellow line.""" + + white = EMA(EMA(close, 10), 10) + yellow = (MA(close, 14) + MA(close, 28) + MA(close, 57) + MA(close, 114)) / 4 + return white, yellow + + +def is_wide_limit(code: str) -> bool: + """Return whether a code belongs to the 20-percent-limit prefixes.""" + + return code.startswith(("68", "30", "4", "8", "9")) + + +def compute_amplitude_params(code: str, close: pd.Series | pd.DataFrame) -> tuple[float, float]: + """Return ``(daily_range_limit, change_relaxation)`` for one history. + + Ordinary stocks are widened when a more-than-15-percent historical move + appears in the available trailing 200 trading rows. The function accepts + either a close series or a frame containing ``close`` for test and caller + convenience. + """ + + values = close["close"] if isinstance(close, pd.DataFrame) else close + wide = is_wide_limit(code) + if not wide and not values.empty: + ratio = values / REF(values, 1) + wide = bool(EXIST(ratio > 1.15, min(200, len(values))).iloc[-1]) + return (8.0, 0.9) if wide else (5.0, 1.0) + + +def finite_or_none(value: object) -> float | None: + """Convert one numeric scalar to a JSON-safe float or ``None``.""" + + if value is None: + return None + number = float(str(value)) + return number if np.isfinite(number) else None + + +def serializable_metrics(values: Sequence[tuple[str, object]]) -> dict[str, float | str | None]: + """Convert target-row metrics into a JSON-safe details mapping.""" + + result: dict[str, float | str | None] = {} + for key, value in values: + if isinstance(value, str) or value is None: + result[key] = value + else: + result[key] = finite_or_none(value) + return result diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/models.py b/zhixing-server/src/zhixing_server/modules/selection/domain/models.py new file mode 100644 index 0000000..92bd7c6 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/models.py @@ -0,0 +1,143 @@ +"""Stable, storage-independent models used by the selection domain.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import date +from enum import StrEnum +from math import isfinite +from typing import Literal + + +def _validate_number(value: float | None, field_name: str) -> None: + """Reject infinities while allowing ``None`` for incomplete source rows.""" + + if value is not None and not isfinite(value): + raise ValueError(f"{field_name} must be finite or None") + + +@dataclass(frozen=True, slots=True) +class SelectionBar: + """One qfq daily OHLCV row in the strategy's numeric vocabulary. + + Source nulls are retained as ``None`` so a missing target price cannot be + silently converted into a signal. The PostgreSQL adapter performs the + Decimal-to-float conversion at this boundary. + """ + + trade_date: date + open: float | None + high: float | None + low: float | None + close: float | None + volume: float | None + + def __post_init__(self) -> None: + """Validate that source numbers are finite when present.""" + + for field_name in ("open", "high", "low", "close", "volume"): + _validate_number(getattr(self, field_name), field_name) + + @property + def vol(self) -> float | None: + """Return the database-compatible alias for ``volume``.""" + + return self.volume + + +@dataclass(frozen=True, slots=True) +class SelectionDailyBasic: + """Same-day optional valuation and liquidity facts.""" + + trade_date: date + turnover_rate: float | None = None + total_mv: float | None = None + + def __post_init__(self) -> None: + """Validate optional numerical facts without inventing missing data.""" + + _validate_number(self.turnover_rate, "turnover_rate") + _validate_number(self.total_mv, "total_mv") + + +@dataclass(frozen=True, slots=True) +class StockHistory: + """A stock's ascending qfq bars and date-indexed daily-basic facts.""" + + ts_code: str + name: str + bars: tuple[SelectionBar, ...] = field(default_factory=tuple) + daily_basic: Mapping[date, SelectionDailyBasic] = field( + default_factory=lambda: dict[date, SelectionDailyBasic]() + ) + + @property + def daily_basics(self) -> Mapping[date, SelectionDailyBasic]: + """Return the plural alias used by some callers.""" + + return self.daily_basic + + +class ZhixingB1Category(StrEnum): + """The seven independent, persistence-ready B1 sub-signal categories.""" + + OVERSOLD_TURN = "zhixing_b1_oversold_turn" + OVERSOLD_VOLUME = "zhixing_b1_oversold_volume" + ORIGINAL_B1 = "zhixing_b1_original_b1" + EXTREME_VOLUME = "zhixing_b1_extreme_volume" + PULLBACK_WHITE = "zhixing_b1_pullback_white" + PULLBACK_SUPER = "zhixing_b1_pullback_super" + PULLBACK_YELLOW = "zhixing_b1_pullback_yellow" + + +@dataclass(frozen=True, slots=True) +class SelectionSignal: + """One explainable B1 hit with a stable identity.""" + + ts_code: str + name: str + target_trade_date: date + strategy: Literal["zhixing_b1"] + category: ZhixingB1Category + close: float + details: Mapping[str, float | str | None] = field( + default_factory=lambda: dict[str, float | str | None]() + ) + + @property + def identity(self) -> tuple[str, date, str, str]: + """Return the future persistence key for this signal.""" + + return ( + self.ts_code, + self.target_trade_date, + self.strategy, + self.category.value, + ) + + +SelectionEvaluationStatus = Literal[ + "selected", + "no_signal", + "insufficient_history", + "missing_target_bar", + "data_error", +] + + +@dataclass(frozen=True, slots=True) +class SelectionEvaluation: + """Result of evaluating one stock on one explicit trade date.""" + + ts_code: str + target_trade_date: date + status: SelectionEvaluationStatus + signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) + reason: str | None = None + + @property + def selected(self) -> bool: + """Return whether at least one independent sub-signal matched.""" + + return self.status == "selected" diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py b/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py new file mode 100644 index 0000000..7af00bb --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/ports.py @@ -0,0 +1,18 @@ +"""Ports that keep selection formulas independent from storage technology.""" + +from __future__ import annotations + +from datetime import date +from typing import Protocol + +from .models import StockHistory + + +class MarketDataReaderError(RuntimeError): + """A market-data adapter could not complete a read.""" + + +class MarketDataReader(Protocol): + """Read qfq history sufficient for one historical strategy evaluation.""" + + def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ... diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/zhixing_b1.py b/zhixing-server/src/zhixing_server/modules/selection/domain/zhixing_b1.py new file mode 100644 index 0000000..2f45ee0 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/zhixing_b1.py @@ -0,0 +1,565 @@ +"""Formula-level implementation of the seven Zhixing B1 sub-signals.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import date + +import numpy as np +import pandas as pd + +from .indicators import ( + BARSLAST, + COUNT, + CROSS, + EVERY, + HHV, + LLV, + MA, + REF, + compute_amplitude_params, + compute_kdj, + compute_rsi, + compute_zhixing_lines, + serializable_metrics, +) +from .models import ( + SelectionEvaluation, + SelectionSignal, + StockHistory, + ZhixingB1Category, +) + +ZHIXING_B1_SIGNAL_ORDER: tuple[ZhixingB1Category, ...] = ( + ZhixingB1Category.OVERSOLD_TURN, + ZhixingB1Category.OVERSOLD_VOLUME, + ZhixingB1Category.ORIGINAL_B1, + ZhixingB1Category.EXTREME_VOLUME, + ZhixingB1Category.PULLBACK_WHITE, + ZhixingB1Category.PULLBACK_SUPER, + ZhixingB1Category.PULLBACK_YELLOW, +) + +_SIGNAL_LABELS: Mapping[ZhixingB1Category, str] = { + ZhixingB1Category.OVERSOLD_TURN: "超卖缩量拐头B", + ZhixingB1Category.OVERSOLD_VOLUME: "超卖缩量B", + ZhixingB1Category.ORIGINAL_B1: "原始B1", + ZhixingB1Category.EXTREME_VOLUME: "超卖超缩量B", + ZhixingB1Category.PULLBACK_WHITE: "回踩白线B", + ZhixingB1Category.PULLBACK_SUPER: "回踩超级B", + ZhixingB1Category.PULLBACK_YELLOW: "回踩黄线B", +} + +MINIMUM_HISTORY = 114 + + +def _not_big_green_bar( + volume: np.ndarray, + open_price: np.ndarray, + close: np.ndarray, + previous_close: np.ndarray, + window: int = 40, +) -> tuple[pd.Series, pd.Series]: + """Return ``(not_big_green, big_green_far)`` for every trading row.""" + + not_big_green = np.ones(len(volume), dtype=bool) + big_green_far = np.zeros(len(volume), dtype=bool) + for index in range(len(volume)): + start = max(0, index - window + 1) + trailing = volume[start : index + 1] + finite = np.isfinite(trailing) + if not finite.any(): + not_big_green[index] = False + continue + maximum = np.nanmax(trailing) + local_positions = np.flatnonzero(finite & (trailing == maximum)) + maximum_index = start + int(local_positions[-1]) + periods_ago = index - maximum_index + is_not_bearish = ( + close[maximum_index] >= previous_close[maximum_index] + or close[maximum_index] >= open_price[maximum_index] + ) + not_big_green[index] = is_not_bearish + big_green_far[index] = not is_not_bearish and periods_ago >= 15 + index = pd.RangeIndex(len(volume)) + return ( + pd.Series(not_big_green, index=index), + pd.Series(big_green_far, index=index), + ) + + +def _safe_percentage(numerator: pd.Series, denominator: pd.Series) -> pd.Series: + """Divide into percentages while making zero denominators explicit NaN.""" + + return numerator.div(denominator.replace(0, np.nan)).mul(100) + + +def prepare_zhixing_b1_indicators(frame: pd.DataFrame, code: str) -> pd.DataFrame: + """Prepare all formula intermediates for ascending OHLCV rows. + + Args: + frame: DataFrame with ``open``, ``high``, ``low``, ``close`` and + ``volume`` columns, ordered from old to new. + code: Tushare-style stock code used for width-limit parameters. + + Returns: + A copy containing named, testable intermediate formula values. + + Raises: + KeyError: If an OHLCV column is absent. + """ + + result = frame.copy() + close = result["close"].astype(float) + high = result["high"].astype(float) + low = result["low"].astype(float) + open_price = result["open"].astype(float) + volume = result["volume"].astype(float) + + white, yellow = compute_zhixing_lines(close) + result["trend_white"] = white + result["trend_yellow"] = yellow + result["bbi"] = (MA(close, 3) + MA(close, 6) + MA(close, 12) + MA(close, 24)) / 4 + + short_low = LLV(low, 3) + short_high = HHV(close, 3) + long_low = LLV(low, 21) + long_high = HHV(close, 21) + result["short_oscillator"] = _safe_percentage(close - short_low, short_high - short_low) + result["long_oscillator"] = _safe_percentage(close - long_low, long_high - long_low) + + kdj = compute_kdj(result, window=9) + result[["k", "d", "j"]] = kdj[["K", "D", "J"]] + result["rsi"] = compute_rsi(close, window=3) + + amplitude_range, relaxation = compute_amplitude_params(code, close) + result["amplitude_range"] = amplitude_range + result["relaxation"] = relaxation + result["daily_amplitude"] = _safe_percentage(high - low, low) + previous_close = REF(close, 1) + result["daily_change"] = _safe_percentage((close - previous_close).abs(), previous_close) + result["daily_change"] = result["daily_change"] * relaxation + result["up_cross"] = (close > previous_close) & ( + _safe_percentage((close - open_price).abs(), open_price) * relaxation < 1.8 + ) + + highest_volume_20 = HHV(volume, 20) + highest_volume_30 = HHV(volume, 30) + highest_volume_50 = HHV(volume, 50) + result["low_volume"] = (volume < highest_volume_20 * 0.416) | (volume < highest_volume_50 / 3) + result["pullback_low_volume"] = (volume < highest_volume_20 * 0.45) | ( + volume < highest_volume_50 / 3 + ) + result["moderate_low_volume"] = (volume < highest_volume_20 * 0.618) | ( + volume < highest_volume_50 / 3 + ) + result["extreme_low_volume"] = (volume < highest_volume_30 / 4) | ( + volume < highest_volume_50 / 6 + ) + + not_big_green, big_green_far = _not_big_green_bar( + volume.to_numpy(float), + open_price.to_numpy(float), + close.to_numpy(float), + previous_close.fillna(close).to_numpy(float), + ) + result["not_big_green"] = not_big_green.to_numpy() + result["big_green_far"] = big_green_far.to_numpy() + + recent_low = LLV(low, 20) + recent_high = HHV(high, 20) + distant_low = LLV(low, 50) + distant_high = HHV(high, 50) + result["recent_amplitude"] = _safe_percentage(recent_high - recent_low, recent_low) + result["distant_amplitude"] = _safe_percentage(distant_high - distant_low, distant_low) + result["super_change"] = result["recent_amplitude"] >= 60 + + short = result["short_oscillator"] + long = result["long_oscillator"] + result["single_pin"] = (short <= 20) & (long >= 75) | ((long - short) >= 70) + result["treasure_bowl"] = ( + (COUNT(long >= 75, 8) >= 6) & (COUNT(short <= 70, 7) >= 4) & (COUNT(short <= 50, 8) >= 1) + ) + result["double_trident"] = ( + EVERY(long >= 75, 8) & (COUNT(short <= 50, 6) >= 2) & (COUNT(short <= 20, 7) >= 1) + ) + result["red_fat_green_thin"] = (COUNT(close >= open_price, 15) > 7) | ( + COUNT(close > previous_close, 11) > 5 + ) + result["wash_change"] = ( + (COUNT(result["single_pin"], 10) >= 2) | result["treasure_bowl"] | result["double_trident"] + ) + result["recent_change"] = (result["recent_amplitude"] >= 15) | ( + _safe_percentage(HHV(high, 12) - LLV(low, 14), LLV(low, 14)) >= 11 + ) + result["distant_change"] = result["distant_amplitude"] >= 30 + + result["uptrend"] = (white >= yellow) & ( + (close >= yellow) | ((close > yellow * 0.975) & (close > open_price)) + ) + result["strong_trend"] = ( + EVERY(yellow >= REF(yellow, 1) * 0.999, 13) + & (white >= REF(white, 1)) + & EVERY(white > yellow, 20) + & EVERY(white >= REF(white, 1), 11) + & result["red_fat_green_thin"] + ) + result["super_bull"] = ( + ( + EVERY(result["bbi"] >= REF(result["bbi"], 1) * 0.999, 20) + | (COUNT(result["bbi"] >= REF(result["bbi"], 1), 25) >= 23) + ) + & ((result["recent_amplitude"] >= 30) | (result["distant_amplitude"] > 80)) + & (BARSLAST(CROSS(close, yellow)) > 12) + ) + + result["white_distance"] = _safe_percentage((close - white).abs(), close) + result["low_white_distance"] = _safe_percentage((low - white).abs(), white) + result["bbi_distance"] = _safe_percentage((close - result["bbi"]).abs(), close) + result["low_bbi_distance"] = _safe_percentage((low - result["bbi"]).abs(), result["bbi"]) + result["yellow_distance"] = _safe_percentage((close - yellow).abs(), yellow) + result["white_pullback"] = ( + ((close >= white) & (result["white_distance"] <= 2)) + | ((close < white) & (result["white_distance"] < 0.8)) + | ( + (close >= result["bbi"]) + & (result["bbi_distance"] < 2.5) + & (result["low_bbi_distance"] < 1) + & (result["white_distance"] <= 3) + & (result["daily_change"] < 1) + & (close > previous_close) + ) + ) + result["white_support"] = (close >= white) & (result["white_distance"] < 1.5) + result["strong_pullback"] = ( + ((result["low_white_distance"] < 1) | (result["low_bbi_distance"] < 0.5)) + & (close > white) + & (result["white_distance"] <= 3.5) + ) + result["yellow_pullback"] = ( + (close >= yellow) + & ( + (result["yellow_distance"] <= 1.5) + | ((result["yellow_distance"] <= 2) & (result["daily_change"] < 1)) + ) + ) | ((close < yellow) & (result["yellow_distance"] <= 0.8)) + return result + + +def compute_signal_masks(frame: pd.DataFrame) -> dict[ZhixingB1Category, pd.Series]: + """Return all seven independent signal masks for prepared indicators. + + The function deliberately returns every mask separately. Callers must + not collapse them into one mask before constructing signals. + """ + + required = { + "uptrend", + "rsi", + "j", + "amplitude_range", + "daily_amplitude", + "daily_change", + "up_cross", + "not_big_green", + "big_green_far", + "recent_change", + "distant_change", + "wash_change", + "trend_white", + "trend_yellow", + "low_volume", + "moderate_low_volume", + "extreme_low_volume", + "recent_amplitude", + "distant_amplitude", + "super_change", + "strong_trend", + "super_bull", + "white_distance", + "bbi_distance", + "yellow_distance", + "white_pullback", + "white_support", + "strong_pullback", + "yellow_pullback", + "low_white_distance", + "low_bbi_distance", + "bbi", + "open", + "close", + "low", + "volume", + } + missing = sorted(required.difference(frame.columns)) + if missing: + raise ValueError(f"prepared indicators missing columns: {', '.join(missing)}") + + rsi = frame["rsi"] + j = frame["j"] + rsi_j = rsi + j + previous_rsi = REF(rsi, 1) + previous_j = REF(j, 1) + previous_volume = REF(frame["volume"], 1) + change_trigger = frame["recent_change"] | frame["distant_change"] | frame["wash_change"] + not_green = frame["not_big_green"] | frame["big_green_far"] + daily_range = frame["daily_amplitude"] + daily_change = frame["daily_change"] + close = frame["close"] + open_price = frame["open"] + + oversold_turn = ( + frame["uptrend"] + & ((rsi - 15) >= previous_rsi) + & ((previous_rsi < 20) | (previous_j < 14)) + & (daily_range < frame["amplitude_range"] + 0.5) + & ((daily_change < 2.3) | (frame["up_cross"] & (daily_change < 4))) + & not_green + & change_trigger + & (close >= frame["trend_yellow"]) + ) + oversold_volume = ( + frame["uptrend"] + & ((j < 14) | (rsi < 23)) + & ((rsi_j < 55) | (j == LLV(j, 20))) + & (daily_range < frame["amplitude_range"]) + & ((daily_change < 2.5) | frame["up_cross"]) + & not_green + & (frame["low_volume"] | (frame["moderate_low_volume"] & (daily_change < 1))) + & change_trigger + ) + original_b1 = ( + (frame["trend_white"] > frame["trend_yellow"]) + & (close >= frame["trend_yellow"] * 0.99) + & (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1)) + & ((j < 13) | (rsi < 21)) + & (rsi_j < LLV(rsi_j, 15) * 1.5) + & frame["moderate_low_volume"] + & not_green + & ( + (_safe_percentage((close - open_price).abs(), open_price) < 1.5) + | frame["extreme_low_volume"] + | ( + frame["moderate_low_volume"] + & (frame["volume"] < LLV(frame["volume"], 20) * 1.1) + & (j == LLV(j, 20)) + ) + | ( + frame["moderate_low_volume"] + & ( + (frame["white_distance"] < 1.8) + | (frame["bbi_distance"] < 1.5) + | (frame["yellow_distance"] < 2.8) + ) + ) + ) + & change_trigger + ) + extreme_volume = ( + frame["uptrend"] + & ((j < 14) | (rsi < 23)) + & (rsi_j < 60) + & (frame["distant_amplitude"] >= 45) + & ( + (daily_range < frame["amplitude_range"]) + | ( + frame["super_change"] + & (daily_range < frame["amplitude_range"] + 3.2) + & (close > open_price) + & (close > frame["trend_white"]) + ) + ) + & ( + ( + (close < open_price) + & (frame["volume"] < previous_volume) + & (close >= frame["trend_yellow"]) + ) + | (close >= open_price) + ) + & ((daily_change < 2) | frame["up_cross"]) + & not_green + & frame["extreme_low_volume"] + & change_trigger + ) + pullback_white = ( + frame["strong_trend"] + & ((j < 30) | (rsi < 40) | frame["wash_change"]) + & (rsi_j < 70) + & ( + (daily_range < frame["amplitude_range"] + 0.5) + | (frame["white_distance"] < 1) + | (frame["bbi_distance"] < 1) + ) + & frame["white_pullback"] + & ((daily_change < 2) | ((daily_change < 5) & frame["white_support"])) + & not_green + & frame["pullback_low_volume"] + & change_trigger + & (frame["low"] <= REF(close, 1)) + ) + pullback_super = ( + frame["super_bull"] + & ((j < 35) | (rsi < 45) | frame["wash_change"]) + & (rsi_j < 80) + & (rsi_j == LLV(rsi_j, 25)) + & (daily_range < frame["amplitude_range"] + 1) + & ((daily_change < 2.5) | (frame["white_distance"] < 2)) + & frame["strong_pullback"] + & not_green + & change_trigger + & frame["moderate_low_volume"] + ) + pullback_yellow = ( + (frame["trend_white"] >= frame["trend_yellow"]) + & (close >= frame["trend_yellow"] * 0.975) + & ((j < 13) | (rsi < 18)) + & frame["yellow_pullback"] + & not_green + & ( + frame["low_volume"] + | (frame["moderate_low_volume"] & ((j == LLV(j, 20)) | (rsi == LLV(rsi, 14)))) + ) + & (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1) * 0.997) + & (MA(close, 60) >= REF(MA(close, 60), 1)) + & (frame["recent_amplitude"] >= 11.9) + & (frame["distant_amplitude"] >= 19.5) + ) + return { + ZhixingB1Category.OVERSOLD_TURN: oversold_turn.fillna(False).astype(bool), + ZhixingB1Category.OVERSOLD_VOLUME: oversold_volume.fillna(False).astype(bool), + ZhixingB1Category.ORIGINAL_B1: original_b1.fillna(False).astype(bool), + ZhixingB1Category.EXTREME_VOLUME: extreme_volume.fillna(False).astype(bool), + ZhixingB1Category.PULLBACK_WHITE: pullback_white.fillna(False).astype(bool), + ZhixingB1Category.PULLBACK_SUPER: pullback_super.fillna(False).astype(bool), + ZhixingB1Category.PULLBACK_YELLOW: pullback_yellow.fillna(False).astype(bool), + } + + +@dataclass(frozen=True, slots=True) +class ZhixingB1Strategy: + """Evaluate all seven B1 sub-signals for a specified historical date.""" + + name: str = "zhixing_b1" + + def evaluate(self, history: StockHistory, target_trade_date: date) -> SelectionEvaluation: + """Return selected, no-signal, warm-up, or missing-target state. + + Only bars through ``target_trade_date`` are passed into the formulas; + future rows supplied by a reader cannot affect the historical result. + """ + + bars_by_date = {bar.trade_date: bar for bar in history.bars} + target_bar = bars_by_date.get(target_trade_date) + if target_bar is None or any( + value is None + for value in ( + target_bar.open, + target_bar.high, + target_bar.low, + target_bar.close, + target_bar.volume, + ) + ): + return SelectionEvaluation( + history.ts_code, + target_trade_date, + "missing_target_bar", + reason="target trade date has no complete qfq daily bar", + ) + + selected_bars = tuple( + sorted( + (bar for bar in bars_by_date.values() if bar.trade_date <= target_trade_date), + key=lambda bar: bar.trade_date, + ) + ) + if len(selected_bars) < MINIMUM_HISTORY: + return SelectionEvaluation( + history.ts_code, + target_trade_date, + "insufficient_history", + reason=f"need at least {MINIMUM_HISTORY} ascending bars before evaluation", + ) + + frame = pd.DataFrame( + { + "trade_date": [bar.trade_date for bar in selected_bars], + "open": [bar.open for bar in selected_bars], + "high": [bar.high for bar in selected_bars], + "low": [bar.low for bar in selected_bars], + "close": [bar.close for bar in selected_bars], + "volume": [bar.volume for bar in selected_bars], + } + ) + if bool(frame.isna().to_numpy().any()): + target_index = frame.index[frame["trade_date"] == target_trade_date] + target_incomplete = False + if not target_index.empty: + target_incomplete = bool( + frame.loc[target_index[0], ["open", "high", "low", "close", "volume"]] + .isna() + .to_numpy() + .any() + ) + if target_index.empty or target_incomplete: + return SelectionEvaluation( + history.ts_code, + target_trade_date, + "missing_target_bar", + reason="target trade date has incomplete qfq OHLCV values", + ) + + prepared = prepare_zhixing_b1_indicators(frame, history.ts_code) + masks = compute_signal_masks(prepared) + target_index = int(prepared.index[prepared["trade_date"] == target_trade_date][0]) + matched = tuple( + category + for category in ZHIXING_B1_SIGNAL_ORDER + if bool(masks[category].iloc[target_index]) + ) + if not matched: + return SelectionEvaluation( + history.ts_code, + target_trade_date, + "no_signal", + reason="no Zhixing B1 sub-signal matched the target row", + ) + + row = prepared.iloc[target_index] + details = serializable_metrics( + ( + ("sub_signal", ";".join(_SIGNAL_LABELS[category] for category in matched)), + ("j", row["j"]), + ("rsi", row["rsi"]), + ("trend_white", row["trend_white"]), + ("trend_yellow", row["trend_yellow"]), + ("daily_amplitude", row["daily_amplitude"]), + ("daily_change", row["daily_change"]), + ("volume", row["volume"]), + ) + ) + signals = tuple( + SelectionSignal( + ts_code=history.ts_code, + name=history.name, + target_trade_date=target_trade_date, + strategy="zhixing_b1", + category=category, + close=float(row["close"]), + details=details, + ) + for category in matched + ) + return SelectionEvaluation( + history.ts_code, + target_trade_date, + "selected", + signals=signals, + ) + + def select(self, history: StockHistory, target_trade_date: date) -> tuple[SelectionSignal, ...]: + """Return only signals for callers that do not need evaluation status.""" + + return self.evaluate(history, target_trade_date).signals diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/__init__.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/__init__.py new file mode 100644 index 0000000..38d8176 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Selection infrastructure adapters.""" 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 new file mode 100644 index 0000000..7783e99 --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py @@ -0,0 +1,158 @@ +"""Read-only PostgreSQL adapter for selection history.""" + +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal, InvalidOperation +from typing import cast + +import psycopg + +from ....bootstrap.config import Settings +from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory +from ..domain.ports import MarketDataReaderError + + +class SelectionReaderError(MarketDataReaderError): + """Database read failure with stock and target-date context.""" + + +_HISTORY_QUERY = """ +SELECT + bar.ts_code, + stock.name, + bar.trade_date, + bar.open, + bar.high, + bar.low, + bar.close, + bar.vol, + basic.turnover_rate, + basic.total_mv +FROM market_daily_bar AS bar +LEFT JOIN market_stock AS stock + ON stock.ts_code = bar.ts_code +LEFT JOIN market_daily_basic AS basic + ON basic.ts_code = bar.ts_code + AND basic.trade_date = bar.trade_date +WHERE bar.ts_code = %s + AND bar.source_adj = 'qfq' + AND bar.trade_date <= %s +ORDER BY bar.trade_date ASC +""" + + +def _as_date(value: object) -> date: + """Convert a PostgreSQL date-like scalar to a date.""" + + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return date.fromisoformat(str(value)[:10]) + + +def _as_float(value: object) -> float | None: + """Convert nullable PostgreSQL numerics to finite floats.""" + + if value is None: + return None + try: + number = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise ValueError(f"invalid market-data numeric value: {value!r}") from exc + if number.is_nan(): + return None + if not number.is_finite(): + raise ValueError(f"market-data numeric value must be finite: {value!r}") + return float(number) + + +class PostgresMarketDataReader: + """Load qfq bars and same-day basic facts without writing market data.""" + + def __init__(self, settings: Settings | str) -> None: + """Create a reader from injected settings or a compatible URL string.""" + + self.database_url = settings.database_url if isinstance(settings, Settings) else settings + + def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: + """Read all retained qfq rows through the explicit target date. + + Args: + ts_code: Tushare stock identifier. + target_trade_date: Historical date to which rows are truncated. + + Returns: + A sorted ``StockHistory``. An empty history is a normal missing + target-data result and is interpreted by the application layer. + + Raises: + SelectionReaderError: If PostgreSQL cannot complete the read. + ValueError: If a returned date or numeric field is malformed. + """ + + try: + with psycopg.connect(self.database_url) as connection: + rows = connection.execute( + _HISTORY_QUERY, + (ts_code, target_trade_date), + ).fetchall() + except psycopg.Error as exc: + raise SelectionReaderError( + f"failed to load market history for {ts_code} at {target_trade_date.isoformat()}" + ) from exc + + bars: dict[date, SelectionBar] = {} + daily_basic: dict[date, SelectionDailyBasic] = {} + name = "" + for raw_row in rows: + row = cast(tuple[object, ...], raw_row) + row_code, row_name, bar, basic = self._map_row(row, ts_code) + if row_code != ts_code: + raise ValueError(f"reader returned unexpected stock code: {row_code}") + name = row_name or name + if bar.trade_date <= target_trade_date: + bars[bar.trade_date] = bar + daily_basic[bar.trade_date] = basic + return StockHistory( + ts_code=ts_code, + name=name, + bars=tuple(bars[trade_date] for trade_date in sorted(bars)), + daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)}, + ) + + @staticmethod + def _map_row( + row: tuple[object, ...], + expected_code: str, + ) -> tuple[str, str, SelectionBar, SelectionDailyBasic]: + """Map the current query row, tolerating a legacy test row without name.""" + + if len(row) >= 10: + code, raw_name, raw_date = row[0], row[1], row[2] + values = row[3:] + elif len(row) >= 9: + code, raw_name, raw_date = row[0], "", row[1] + values = row[2:] + else: + raise ValueError("market history row has too few columns") + row_code = str(code or expected_code) + name = str(raw_name or "") + trade_date = _as_date(raw_date) + if len(values) < 7: + raise ValueError("market history row is missing OHLCV/basic columns") + bar = SelectionBar( + trade_date=trade_date, + open=_as_float(values[0]), + high=_as_float(values[1]), + low=_as_float(values[2]), + close=_as_float(values[3]), + volume=_as_float(values[4]), + ) + basic = SelectionDailyBasic( + trade_date=trade_date, + turnover_rate=_as_float(values[5]), + total_mv=_as_float(values[6]), + ) + return row_code, name, bar, basic diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/__init__.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/__init__.py new file mode 100644 index 0000000..e03160f --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/__init__.py @@ -0,0 +1 @@ +"""Transport adapters for selection; intentionally empty in the first slice.""" diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/README.md b/zhixing-server/tests/fixtures/selection/zhixing_b1/README.md new file mode 100644 index 0000000..36e92c3 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/README.md @@ -0,0 +1,11 @@ +# Zhixing B1 fixed fixtures + +These are small, deterministic artificial qfq OHLCV histories used to verify +ordinary and wide-limit amplitude parameters without importing the legacy +project at test time. The rows are calendar-spaced only to keep the fixture +readable; the formula treats them as ascending trading observations. + +`multi_signal.json` documents the independent-mask orchestration case. The +unit test forces all seven masks on one prepared target row, which is +intentional: the legacy result set did not contain a trustworthy historical +same-day multi-hit sample, while the new contract requires retaining all hits. diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/golden.json b/zhixing-server/tests/fixtures/selection/zhixing_b1/golden.json new file mode 100644 index 0000000..fe9023c --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/golden.json @@ -0,0 +1,31 @@ +{ + "ordinary": { + "file": "ordinary.csv", + "ts_code": "000001.SZ", + "target_trade_date": "2023-04-25", + "status": "no_signal", + "categories": [], + "note": "人工上升序列,验证普通代码使用 5% 振幅区间;不代表历史推荐结果。" + }, + "wide_limit": { + "file": "wide_limit.csv", + "ts_code": "300001.SZ", + "target_trade_date": "2023-04-25", + "status": "no_signal", + "categories": [], + "note": "人工上升序列,验证 30 开头代码使用 8% 振幅区间;不代表历史推荐结果。" + }, + "multi_signal": { + "status": "selected", + "categories": [ + "zhixing_b1_oversold_turn", + "zhixing_b1_oversold_volume", + "zhixing_b1_original_b1", + "zhixing_b1_extreme_volume", + "zhixing_b1_pullback_white", + "zhixing_b1_pullback_super", + "zhixing_b1_pullback_yellow" + ], + "note": "人工构造的同日多命中契约;测试通过独立 mask 注入验证不 break。" + } +} diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/ordinary.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/ordinary.csv new file mode 100644 index 0000000..0eb4417 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/ordinary.csv @@ -0,0 +1,116 @@ +trade_date,open,high,low,close,volume +2023-01-01,9.9600,10.1200,9.8800,10.0000,1000.00 +2023-01-02,9.9800,10.1400,9.9000,10.0200,1030.00 +2023-01-03,10.0000,10.1600,9.9200,10.0400,1060.00 +2023-01-04,10.0200,10.1800,9.9400,10.0600,1090.00 +2023-01-05,10.0400,10.2000,9.9600,10.0800,1120.00 +2023-01-06,10.0600,10.2200,9.9800,10.1000,1150.00 +2023-01-07,10.0800,10.2400,10.0000,10.1200,1180.00 +2023-01-08,10.1000,10.2600,10.0200,10.1400,1000.00 +2023-01-09,10.1200,10.2800,10.0400,10.1600,1030.00 +2023-01-10,10.1400,10.3000,10.0600,10.1800,1060.00 +2023-01-11,10.1600,10.3200,10.0800,10.2000,1090.00 +2023-01-12,10.1800,10.3400,10.1000,10.2200,1120.00 +2023-01-13,10.2000,10.3600,10.1200,10.2400,1150.00 +2023-01-14,10.2200,10.3800,10.1400,10.2600,1180.00 +2023-01-15,10.2400,10.4000,10.1600,10.2800,1000.00 +2023-01-16,10.2600,10.4200,10.1800,10.3000,1030.00 +2023-01-17,10.2800,10.4400,10.2000,10.3200,1060.00 +2023-01-18,10.3000,10.4600,10.2200,10.3400,1090.00 +2023-01-19,10.3200,10.4800,10.2400,10.3600,1120.00 +2023-01-20,10.3400,10.5000,10.2600,10.3800,1150.00 +2023-01-21,10.3600,10.5200,10.2800,10.4000,1180.00 +2023-01-22,10.3800,10.5400,10.3000,10.4200,1000.00 +2023-01-23,10.4000,10.5600,10.3200,10.4400,1030.00 +2023-01-24,10.4200,10.5800,10.3400,10.4600,1060.00 +2023-01-25,10.4400,10.6000,10.3600,10.4800,1090.00 +2023-01-26,10.4600,10.6200,10.3800,10.5000,1120.00 +2023-01-27,10.4800,10.6400,10.4000,10.5200,1150.00 +2023-01-28,10.5000,10.6600,10.4200,10.5400,1180.00 +2023-01-29,10.5200,10.6800,10.4400,10.5600,1000.00 +2023-01-30,10.5400,10.7000,10.4600,10.5800,1030.00 +2023-01-31,10.5600,10.7200,10.4800,10.6000,1060.00 +2023-02-01,10.5800,10.7400,10.5000,10.6200,1090.00 +2023-02-02,10.6000,10.7600,10.5200,10.6400,1120.00 +2023-02-03,10.6200,10.7800,10.5400,10.6600,1150.00 +2023-02-04,10.6400,10.8000,10.5600,10.6800,1180.00 +2023-02-05,10.6600,10.8200,10.5800,10.7000,1000.00 +2023-02-06,10.6800,10.8400,10.6000,10.7200,1030.00 +2023-02-07,10.7000,10.8600,10.6200,10.7400,1060.00 +2023-02-08,10.7200,10.8800,10.6400,10.7600,1090.00 +2023-02-09,10.7400,10.9000,10.6600,10.7800,1120.00 +2023-02-10,10.7600,10.9200,10.6800,10.8000,1150.00 +2023-02-11,10.7800,10.9400,10.7000,10.8200,1180.00 +2023-02-12,10.8000,10.9600,10.7200,10.8400,1000.00 +2023-02-13,10.8200,10.9800,10.7400,10.8600,1030.00 +2023-02-14,10.8400,11.0000,10.7600,10.8800,1060.00 +2023-02-15,10.8600,11.0200,10.7800,10.9000,1090.00 +2023-02-16,10.8800,11.0400,10.8000,10.9200,1120.00 +2023-02-17,10.9000,11.0600,10.8200,10.9400,1150.00 +2023-02-18,10.9200,11.0800,10.8400,10.9600,1180.00 +2023-02-19,10.9400,11.1000,10.8600,10.9800,1000.00 +2023-02-20,10.9600,11.1200,10.8800,11.0000,1030.00 +2023-02-21,10.9800,11.1400,10.9000,11.0200,1060.00 +2023-02-22,11.0000,11.1600,10.9200,11.0400,1090.00 +2023-02-23,11.0200,11.1800,10.9400,11.0600,1120.00 +2023-02-24,11.0400,11.2000,10.9600,11.0800,1150.00 +2023-02-25,11.0600,11.2200,10.9800,11.1000,1180.00 +2023-02-26,11.0800,11.2400,11.0000,11.1200,1000.00 +2023-02-27,11.1000,11.2600,11.0200,11.1400,1030.00 +2023-02-28,11.1200,11.2800,11.0400,11.1600,1060.00 +2023-03-01,11.1400,11.3000,11.0600,11.1800,1090.00 +2023-03-02,11.1600,11.3200,11.0800,11.2000,1120.00 +2023-03-03,11.1800,11.3400,11.1000,11.2200,1150.00 +2023-03-04,11.2000,11.3600,11.1200,11.2400,1180.00 +2023-03-05,11.2200,11.3800,11.1400,11.2600,1000.00 +2023-03-06,11.2400,11.4000,11.1600,11.2800,1030.00 +2023-03-07,11.2600,11.4200,11.1800,11.3000,1060.00 +2023-03-08,11.2800,11.4400,11.2000,11.3200,1090.00 +2023-03-09,11.3000,11.4600,11.2200,11.3400,1120.00 +2023-03-10,11.3200,11.4800,11.2400,11.3600,1150.00 +2023-03-11,11.3400,11.5000,11.2600,11.3800,1180.00 +2023-03-12,11.3600,11.5200,11.2800,11.4000,1000.00 +2023-03-13,11.3800,11.5400,11.3000,11.4200,1030.00 +2023-03-14,11.4000,11.5600,11.3200,11.4400,1060.00 +2023-03-15,11.4200,11.5800,11.3400,11.4600,1090.00 +2023-03-16,11.4400,11.6000,11.3600,11.4800,1120.00 +2023-03-17,11.4600,11.6200,11.3800,11.5000,1150.00 +2023-03-18,11.4800,11.6400,11.4000,11.5200,1180.00 +2023-03-19,11.5000,11.6600,11.4200,11.5400,1000.00 +2023-03-20,11.5200,11.6800,11.4400,11.5600,1030.00 +2023-03-21,11.5400,11.7000,11.4600,11.5800,1060.00 +2023-03-22,11.5600,11.7200,11.4800,11.6000,1090.00 +2023-03-23,11.5800,11.7400,11.5000,11.6200,1120.00 +2023-03-24,11.6000,11.7600,11.5200,11.6400,1150.00 +2023-03-25,11.6200,11.7800,11.5400,11.6600,1180.00 +2023-03-26,11.6400,11.8000,11.5600,11.6800,1000.00 +2023-03-27,11.6600,11.8200,11.5800,11.7000,1030.00 +2023-03-28,11.6800,11.8400,11.6000,11.7200,1060.00 +2023-03-29,11.7000,11.8600,11.6200,11.7400,1090.00 +2023-03-30,11.7200,11.8800,11.6400,11.7600,1120.00 +2023-03-31,11.7400,11.9000,11.6600,11.7800,1150.00 +2023-04-01,11.7600,11.9200,11.6800,11.8000,1180.00 +2023-04-02,11.7800,11.9400,11.7000,11.8200,1000.00 +2023-04-03,11.8000,11.9600,11.7200,11.8400,1030.00 +2023-04-04,11.8200,11.9800,11.7400,11.8600,1060.00 +2023-04-05,11.8400,12.0000,11.7600,11.8800,1090.00 +2023-04-06,11.8600,12.0200,11.7800,11.9000,1120.00 +2023-04-07,11.8800,12.0400,11.8000,11.9200,1150.00 +2023-04-08,11.9000,12.0600,11.8200,11.9400,1180.00 +2023-04-09,11.9200,12.0800,11.8400,11.9600,1000.00 +2023-04-10,11.9400,12.1000,11.8600,11.9800,1030.00 +2023-04-11,11.9600,12.1200,11.8800,12.0000,1060.00 +2023-04-12,11.9800,12.1400,11.9000,12.0200,1090.00 +2023-04-13,12.0000,12.1600,11.9200,12.0400,1120.00 +2023-04-14,12.0200,12.1800,11.9400,12.0600,1150.00 +2023-04-15,12.0400,12.2000,11.9600,12.0800,1180.00 +2023-04-16,12.0600,12.2200,11.9800,12.1000,1000.00 +2023-04-17,12.0800,12.2400,12.0000,12.1200,1030.00 +2023-04-18,12.1000,12.2600,12.0200,12.1400,1060.00 +2023-04-19,12.1200,12.2800,12.0400,12.1600,1090.00 +2023-04-20,12.1400,12.3000,12.0600,12.1800,1120.00 +2023-04-21,12.1600,12.3200,12.0800,12.2000,1150.00 +2023-04-22,12.1800,12.3400,12.1000,12.2200,1180.00 +2023-04-23,12.2000,12.3600,12.1200,12.2400,1000.00 +2023-04-24,12.2200,12.3800,12.1400,12.2600,1030.00 +2023-04-25,12.2400,12.4000,12.1600,12.2800,1060.00 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/wide_limit.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/wide_limit.csv new file mode 100644 index 0000000..3bd662c --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/wide_limit.csv @@ -0,0 +1,116 @@ +trade_date,open,high,low,close,volume +2023-01-01,19.9600,20.1200,19.8800,20.0000,1600.00 +2023-01-02,19.9900,20.1500,19.9100,20.0300,1630.00 +2023-01-03,20.0200,20.1800,19.9400,20.0600,1660.00 +2023-01-04,20.0500,20.2100,19.9700,20.0900,1690.00 +2023-01-05,20.0800,20.2400,20.0000,20.1200,1720.00 +2023-01-06,20.1100,20.2700,20.0300,20.1500,1750.00 +2023-01-07,20.1400,20.3000,20.0600,20.1800,1780.00 +2023-01-08,20.1700,20.3300,20.0900,20.2100,1600.00 +2023-01-09,20.2000,20.3600,20.1200,20.2400,1630.00 +2023-01-10,20.2300,20.3900,20.1500,20.2700,1660.00 +2023-01-11,20.2600,20.4200,20.1800,20.3000,1690.00 +2023-01-12,20.2900,20.4500,20.2100,20.3300,1720.00 +2023-01-13,20.3200,20.4800,20.2400,20.3600,1750.00 +2023-01-14,20.3500,20.5100,20.2700,20.3900,1780.00 +2023-01-15,20.3800,20.5400,20.3000,20.4200,1600.00 +2023-01-16,20.4100,20.5700,20.3300,20.4500,1630.00 +2023-01-17,20.4400,20.6000,20.3600,20.4800,1660.00 +2023-01-18,20.4700,20.6300,20.3900,20.5100,1690.00 +2023-01-19,20.5000,20.6600,20.4200,20.5400,1720.00 +2023-01-20,20.5300,20.6900,20.4500,20.5700,1750.00 +2023-01-21,20.5600,20.7200,20.4800,20.6000,1780.00 +2023-01-22,20.5900,20.7500,20.5100,20.6300,1600.00 +2023-01-23,20.6200,20.7800,20.5400,20.6600,1630.00 +2023-01-24,20.6500,20.8100,20.5700,20.6900,1660.00 +2023-01-25,20.6800,20.8400,20.6000,20.7200,1690.00 +2023-01-26,20.7100,20.8700,20.6300,20.7500,1720.00 +2023-01-27,20.7400,20.9000,20.6600,20.7800,1750.00 +2023-01-28,20.7700,20.9300,20.6900,20.8100,1780.00 +2023-01-29,20.8000,20.9600,20.7200,20.8400,1600.00 +2023-01-30,20.8300,20.9900,20.7500,20.8700,1630.00 +2023-01-31,20.8600,21.0200,20.7800,20.9000,1660.00 +2023-02-01,20.8900,21.0500,20.8100,20.9300,1690.00 +2023-02-02,20.9200,21.0800,20.8400,20.9600,1720.00 +2023-02-03,20.9500,21.1100,20.8700,20.9900,1750.00 +2023-02-04,20.9800,21.1400,20.9000,21.0200,1780.00 +2023-02-05,21.0100,21.1700,20.9300,21.0500,1600.00 +2023-02-06,21.0400,21.2000,20.9600,21.0800,1630.00 +2023-02-07,21.0700,21.2300,20.9900,21.1100,1660.00 +2023-02-08,21.1000,21.2600,21.0200,21.1400,1690.00 +2023-02-09,21.1300,21.2900,21.0500,21.1700,1720.00 +2023-02-10,21.1600,21.3200,21.0800,21.2000,1750.00 +2023-02-11,21.1900,21.3500,21.1100,21.2300,1780.00 +2023-02-12,21.2200,21.3800,21.1400,21.2600,1600.00 +2023-02-13,21.2500,21.4100,21.1700,21.2900,1630.00 +2023-02-14,21.2800,21.4400,21.2000,21.3200,1660.00 +2023-02-15,21.3100,21.4700,21.2300,21.3500,1690.00 +2023-02-16,21.3400,21.5000,21.2600,21.3800,1720.00 +2023-02-17,21.3700,21.5300,21.2900,21.4100,1750.00 +2023-02-18,21.4000,21.5600,21.3200,21.4400,1780.00 +2023-02-19,21.4300,21.5900,21.3500,21.4700,1600.00 +2023-02-20,21.4600,21.6200,21.3800,21.5000,1630.00 +2023-02-21,21.4900,21.6500,21.4100,21.5300,1660.00 +2023-02-22,21.5200,21.6800,21.4400,21.5600,1690.00 +2023-02-23,21.5500,21.7100,21.4700,21.5900,1720.00 +2023-02-24,21.5800,21.7400,21.5000,21.6200,1750.00 +2023-02-25,21.6100,21.7700,21.5300,21.6500,1780.00 +2023-02-26,21.6400,21.8000,21.5600,21.6800,1600.00 +2023-02-27,21.6700,21.8300,21.5900,21.7100,1630.00 +2023-02-28,21.7000,21.8600,21.6200,21.7400,1660.00 +2023-03-01,21.7300,21.8900,21.6500,21.7700,1690.00 +2023-03-02,21.7600,21.9200,21.6800,21.8000,1720.00 +2023-03-03,21.7900,21.9500,21.7100,21.8300,1750.00 +2023-03-04,21.8200,21.9800,21.7400,21.8600,1780.00 +2023-03-05,21.8500,22.0100,21.7700,21.8900,1600.00 +2023-03-06,21.8800,22.0400,21.8000,21.9200,1630.00 +2023-03-07,21.9100,22.0700,21.8300,21.9500,1660.00 +2023-03-08,21.9400,22.1000,21.8600,21.9800,1690.00 +2023-03-09,21.9700,22.1300,21.8900,22.0100,1720.00 +2023-03-10,22.0000,22.1600,21.9200,22.0400,1750.00 +2023-03-11,22.0300,22.1900,21.9500,22.0700,1780.00 +2023-03-12,22.0600,22.2200,21.9800,22.1000,1600.00 +2023-03-13,22.0900,22.2500,22.0100,22.1300,1630.00 +2023-03-14,22.1200,22.2800,22.0400,22.1600,1660.00 +2023-03-15,22.1500,22.3100,22.0700,22.1900,1690.00 +2023-03-16,22.1800,22.3400,22.1000,22.2200,1720.00 +2023-03-17,22.2100,22.3700,22.1300,22.2500,1750.00 +2023-03-18,22.2400,22.4000,22.1600,22.2800,1780.00 +2023-03-19,22.2700,22.4300,22.1900,22.3100,1600.00 +2023-03-20,22.3000,22.4600,22.2200,22.3400,1630.00 +2023-03-21,22.3300,22.4900,22.2500,22.3700,1660.00 +2023-03-22,22.3600,22.5200,22.2800,22.4000,1690.00 +2023-03-23,22.3900,22.5500,22.3100,22.4300,1720.00 +2023-03-24,22.4200,22.5800,22.3400,22.4600,1750.00 +2023-03-25,22.4500,22.6100,22.3700,22.4900,1780.00 +2023-03-26,22.4800,22.6400,22.4000,22.5200,1600.00 +2023-03-27,22.5100,22.6700,22.4300,22.5500,1630.00 +2023-03-28,22.5400,22.7000,22.4600,22.5800,1660.00 +2023-03-29,22.5700,22.7300,22.4900,22.6100,1690.00 +2023-03-30,22.6000,22.7600,22.5200,22.6400,1720.00 +2023-03-31,22.6300,22.7900,22.5500,22.6700,1750.00 +2023-04-01,22.6600,22.8200,22.5800,22.7000,1780.00 +2023-04-02,22.6900,22.8500,22.6100,22.7300,1600.00 +2023-04-03,22.7200,22.8800,22.6400,22.7600,1630.00 +2023-04-04,22.7500,22.9100,22.6700,22.7900,1660.00 +2023-04-05,22.7800,22.9400,22.7000,22.8200,1690.00 +2023-04-06,22.8100,22.9700,22.7300,22.8500,1720.00 +2023-04-07,22.8400,23.0000,22.7600,22.8800,1750.00 +2023-04-08,22.8700,23.0300,22.7900,22.9100,1780.00 +2023-04-09,22.9000,23.0600,22.8200,22.9400,1600.00 +2023-04-10,22.9300,23.0900,22.8500,22.9700,1630.00 +2023-04-11,22.9600,23.1200,22.8800,23.0000,1660.00 +2023-04-12,22.9900,23.1500,22.9100,23.0300,1690.00 +2023-04-13,23.0200,23.1800,22.9400,23.0600,1720.00 +2023-04-14,23.0500,23.2100,22.9700,23.0900,1750.00 +2023-04-15,23.0800,23.2400,23.0000,23.1200,1780.00 +2023-04-16,23.1100,23.2700,23.0300,23.1500,1600.00 +2023-04-17,23.1400,23.3000,23.0600,23.1800,1630.00 +2023-04-18,23.1700,23.3300,23.0900,23.2100,1660.00 +2023-04-19,23.2000,23.3600,23.1200,23.2400,1690.00 +2023-04-20,23.2300,23.3900,23.1500,23.2700,1720.00 +2023-04-21,23.2600,23.4200,23.1800,23.3000,1750.00 +2023-04-22,23.2900,23.4500,23.2100,23.3300,1780.00 +2023-04-23,23.3200,23.4800,23.2400,23.3600,1600.00 +2023-04-24,23.3500,23.5100,23.2700,23.3900,1630.00 +2023-04-25,23.3800,23.5400,23.3000,23.4200,1660.00 diff --git a/zhixing-server/tests/integration/test_zhixing_b1_golden.py b/zhixing-server/tests/integration/test_zhixing_b1_golden.py new file mode 100644 index 0000000..714c063 --- /dev/null +++ b/zhixing-server/tests/integration/test_zhixing_b1_golden.py @@ -0,0 +1,41 @@ +"""Offline golden checks for fixed, non-legacy selection fixtures.""" + +import csv +import json +from datetime import date +from pathlib import Path + +from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory +from zhixing_server.modules.selection.domain.zhixing_b1 import ZhixingB1Strategy + +FIXTURE_ROOT = Path(__file__).parents[1] / "fixtures" / "selection" / "zhixing_b1" + + +def _read_history(path: Path, ts_code: str) -> StockHistory: + with path.open(newline="") as file: + bars = tuple( + SelectionBar( + trade_date=date.fromisoformat(row["trade_date"]), + open=float(row["open"]), + high=float(row["high"]), + low=float(row["low"]), + close=float(row["close"]), + volume=float(row["volume"]), + ) + for row in csv.DictReader(file) + ) + return StockHistory(ts_code=ts_code, name="fixture", bars=bars) + + +def test_fixed_ordinary_and_wide_limit_goldens_are_reproducible() -> None: + with (FIXTURE_ROOT / "golden.json").open() as file: + golden = json.load(file) + + strategy = ZhixingB1Strategy() + for key in ("ordinary", "wide_limit"): + expected = golden[key] + history = _read_history(FIXTURE_ROOT / expected["file"], expected["ts_code"]) + target = date.fromisoformat(expected["target_trade_date"]) + result = strategy.evaluate(history, target) + assert result.status == expected["status"] + assert [signal.category.value for signal in result.signals] == expected["categories"] diff --git a/zhixing-server/tests/unit/selection/test_evaluate.py b/zhixing-server/tests/unit/selection/test_evaluate.py new file mode 100644 index 0000000..1f4e612 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_evaluate.py @@ -0,0 +1,31 @@ +"""Application-level state and port mapping tests.""" + +from datetime import date + +from zhixing_server.modules.selection.application.evaluate import EvaluateZhixingB1 +from zhixing_server.modules.selection.domain.models import StockHistory +from zhixing_server.modules.selection.domain.ports import MarketDataReaderError + +TARGET = date(2024, 1, 2) + + +class EmptyReader: + def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: + return StockHistory(ts_code=ts_code, name="", bars=()) + + +class FailingReader: + def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: + raise MarketDataReaderError(f"database unavailable for {ts_code}") + + +def test_evaluate_maps_reader_error_to_data_error() -> None: + result = EvaluateZhixingB1(FailingReader()).execute("000001.SZ", TARGET) + assert result.status == "data_error" + assert result.signals == () + assert "000001.SZ" in (result.reason or "") + + +def test_evaluate_distinguishes_missing_target_from_reader_error() -> None: + result = EvaluateZhixingB1(EmptyReader()).execute("000001.SZ", TARGET) + assert result.status == "missing_target_bar" diff --git a/zhixing-server/tests/unit/selection/test_indicators.py b/zhixing-server/tests/unit/selection/test_indicators.py new file mode 100644 index 0000000..1f904a1 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_indicators.py @@ -0,0 +1,69 @@ +"""Boundary tests for TDX-style selection indicators.""" + +import numpy as np +import pandas as pd +import pytest + +from zhixing_server.modules.selection.domain.indicators import ( + BARSLAST, + COUNT, + CROSS, + EVERY, + HHVBARS, + MA, + REF, + compute_amplitude_params, + compute_kdj, + compute_rsi, +) + + +def test_rolling_primitives_use_trading_rows_and_keep_every_warmup() -> None: + values = pd.Series([1.0, 2.0, 3.0, 2.0]) + + assert MA(values, 3).tolist() == [1.0, 1.5, 2.0, 7 / 3] + assert REF(values, 1).isna().iloc[0] + assert EVERY(pd.Series([True, True, True]), 3).tolist() == [False, False, True] + assert COUNT(pd.Series([True, False, True]), 2).tolist() == [1.0, 1.0, 1.0] + + +def test_hhvbars_and_barslast_are_stable_for_ties_and_missing_prefix() -> None: + values = pd.Series([1.0, 3.0, 3.0, 2.0]) + assert HHVBARS(values, 3).tolist() == [0.0, 0.0, 0.0, 1.0] + bars_last = BARSLAST(pd.Series([False, True, False, True])) + assert np.isnan(bars_last.iloc[0]) + assert bars_last.iloc[1:].tolist() == [0.0, 1.0, 0.0] + + +def test_cross_does_not_match_without_a_previous_complete_row() -> None: + assert CROSS(pd.Series([1.0, 3.0, 2.0]), pd.Series([2.0, 2.0, 2.0])).tolist() == [ + False, + True, + False, + ] + + +def test_zero_range_and_zero_rsi_denominator_do_not_create_finite_signals() -> None: + frame = pd.DataFrame( + { + "low": [10.0, 10.0, 10.0], + "high": [10.0, 10.0, 10.0], + "close": [10.0, 10.0, 10.0], + } + ) + kdj = compute_kdj(frame, 3) + rsi = compute_rsi(frame["close"], 3) + assert kdj["J"].isna().all() + assert rsi.isna().all() + + +def test_amplitude_parameters_cover_wide_prefix_and_historical_wide_move() -> None: + close = pd.Series([10.0, 10.0, 11.6, 11.0]) + assert compute_amplitude_params("688001", close) == (8.0, 0.9) + assert compute_amplitude_params("000001", close) == (8.0, 0.9) + assert compute_amplitude_params("000001", pd.Series([10.0, 10.1])) == (5.0, 1.0) + + +def test_invalid_indicator_windows_fail_loudly() -> None: + with pytest.raises(ValueError): + MA(pd.Series([1.0]), 0) diff --git a/zhixing-server/tests/unit/selection/test_postgres_reader.py b/zhixing-server/tests/unit/selection/test_postgres_reader.py new file mode 100644 index 0000000..e456c32 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_postgres_reader.py @@ -0,0 +1,84 @@ +"""PostgreSQL reader contract tests using a fake connection.""" + +from datetime import date +from typing import cast + +import psycopg +import pytest + +from zhixing_server.modules.selection.infrastructure.postgres_reader import ( + PostgresMarketDataReader, +) + + +class FakeConnection: + def __init__(self, rows: list[tuple[object, ...]]) -> None: + self.rows = rows + self.query: str | None = None + self.parameters: tuple[object, ...] | None = None + + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, *args: object) -> None: + return None + + def execute(self, query: str, parameters: tuple[object, ...]) -> "FakeResult": + self.query = query + self.parameters = parameters + return FakeResult(self.rows) + + +class FakeResult: + def __init__(self, rows: list[tuple[object, ...]]) -> None: + self.rows = rows + + def fetchall(self) -> list[tuple[object, ...]]: + return self.rows + + +def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.MonkeyPatch) -> None: + connection = FakeConnection( + [ + ( + "000001.SZ", + "平安银行", + date(2024, 1, 2), + "10", + "11", + "9", + "10.5", + "1000", + None, + None, + ), + ( + "000001.SZ", + "平安银行", + date(2024, 1, 3), + "10.5", + "11", + "10", + "10.8", + "1200", + "1.2", + "100000", + ), + ] + ) + + def connect(database_url: str) -> FakeConnection: + assert database_url == "postgresql://test" + return connection + + monkeypatch.setattr(psycopg, "connect", connect) + history = PostgresMarketDataReader("postgresql://test").load_history( + "000001.SZ", date(2024, 1, 3) + ) + + assert [bar.trade_date for bar in history.bars] == [date(2024, 1, 2), date(2024, 1, 3)] + assert history.daily_basic[date(2024, 1, 2)].turnover_rate is None + assert history.daily_basic[date(2024, 1, 3)].total_mv == 100000.0 + 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) diff --git a/zhixing-server/tests/unit/selection/test_zhixing_b1.py b/zhixing-server/tests/unit/selection/test_zhixing_b1.py new file mode 100644 index 0000000..3e6c518 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_zhixing_b1.py @@ -0,0 +1,94 @@ +"""Behavior tests for explicit-date Zhixing B1 evaluation.""" + +from datetime import date, timedelta + +import pandas as pd +import pytest + +from zhixing_server.modules.selection.domain import zhixing_b1 +from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory +from zhixing_server.modules.selection.domain.zhixing_b1 import ( + MINIMUM_HISTORY, + ZHIXING_B1_SIGNAL_ORDER, + ZhixingB1Strategy, + compute_signal_masks, + prepare_zhixing_b1_indicators, +) + + +def make_history(count: int = MINIMUM_HISTORY, code: str = "000001.SZ") -> StockHistory: + bars = tuple( + SelectionBar( + trade_date=date(2020, 1, 1) + timedelta(days=index), + open=10.0 + index * 0.02, + high=10.2 + index * 0.02, + low=9.9 + index * 0.02, + close=10.1 + index * 0.02, + volume=1000.0 + (index % 7) * 30, + ) + for index in range(count) + ) + return StockHistory(ts_code=code, name="测试股票", bars=bars) + + +def test_strategy_has_seven_stable_categories_and_prepared_masks() -> None: + history = make_history() + frame = pd.DataFrame( + { + "open": [bar.open for bar in history.bars], + "high": [bar.high for bar in history.bars], + "low": [bar.low for bar in history.bars], + "close": [bar.close for bar in history.bars], + "volume": [bar.volume for bar in history.bars], + } + ) + prepared = prepare_zhixing_b1_indicators(frame, history.ts_code) + masks = compute_signal_masks(prepared) + + assert tuple(masks) == ZHIXING_B1_SIGNAL_ORDER + assert all(mask.dtype == bool for mask in masks.values()) + assert all(len(mask) == len(history.bars) for mask in masks.values()) + + +def test_strategy_explicit_target_ignores_future_rows() -> None: + history = make_history() + target = history.bars[-1].trade_date + future = SelectionBar( + trade_date=target + timedelta(days=1), + open=1.0, + high=100.0, + low=0.5, + close=99.0, + volume=1_000_000.0, + ) + with_future = StockHistory(history.ts_code, history.name, history.bars + (future,)) + + strategy = ZhixingB1Strategy() + assert strategy.evaluate(with_future, target) == strategy.evaluate(history, target) + + +def test_strategy_returns_missing_and_warmup_states() -> None: + strategy = ZhixingB1Strategy() + history = make_history(MINIMUM_HISTORY - 1) + target = history.bars[-1].trade_date + assert strategy.evaluate(history, target).status == "insufficient_history" + assert strategy.evaluate(history, target + timedelta(days=1)).status == "missing_target_bar" + + +def test_strategy_keeps_all_same_day_subsignals_in_priority_order( + monkeypatch: pytest.MonkeyPatch, +) -> None: + history = make_history() + target = history.bars[-1].trade_date + + def all_masks(frame: pd.DataFrame) -> dict[zhixing_b1.ZhixingB1Category, pd.Series]: + return { + category: pd.Series(True, index=frame.index) for category in ZHIXING_B1_SIGNAL_ORDER + } + + monkeypatch.setattr(zhixing_b1, "compute_signal_masks", all_masks) + result = ZhixingB1Strategy().evaluate(history, target) + + assert result.status == "selected" + assert tuple(signal.category for signal in result.signals) == ZHIXING_B1_SIGNAL_ORDER + assert len({signal.identity for signal in result.signals}) == 7 diff --git a/zhixing-server/uv.lock b/zhixing-server/uv.lock index 907193a..ec0d629 100644 --- a/zhixing-server/uv.lock +++ b/zhixing-server/uv.lock @@ -374,6 +374,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, ] +[[package]] +name = "pandas-stubs" +version = "3.0.5.260730" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/d2/dea4a3a56b7b5f69c5fbca9f14625fcf28e1a39a657e9833d4a10bcac593/pandas_stubs-3.0.5.260730.tar.gz", hash = "sha256:f70a232c57d93a5a2c81f8a53953e10891a5374bc92652277deb325e2e4d0ff3", size = 114631, upload-time = "2026-07-30T14:31:42.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/c2/959caec5c46f484b5f8bb6def4b0cf7a45ba6acda26f12b75142d98cc5ae/pandas_stubs-3.0.5.260730-py3-none-any.whl", hash = "sha256:60e90e3e1eda6937e337e243cbe6217e151c11137cd7eddf832af537c7310bfd", size = 174807, upload-time = "2026-07-30T14:31:41.17Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -865,6 +877,8 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "fastapi" }, + { name = "numpy" }, + { name = "pandas" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, @@ -875,6 +889,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "httpx2" }, + { name = "pandas-stubs" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -885,6 +900,8 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.18.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "numpy", specifier = ">=2.4.0" }, + { name = "pandas", specifier = ">=2.3.3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.2" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, { name = "sqlalchemy", specifier = ">=2.0.46" }, @@ -895,6 +912,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "httpx2", specifier = ">=2.9.1" }, + { name = "pandas-stubs", specifier = ">=2.3.2.250926" }, { name = "pyright", specifier = ">=1.1.411" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, -- 2.52.0 From 9c1a1eac232e8f51b6202b2efd3e7d80c6b724ca Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Sun, 9 Aug 2026 09:34:46 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(selection):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E6=89=A7=E8=A1=8C=E7=BB=93=E6=9E=9C=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=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 (