Develop #5

Merged
sakibcc merged 6 commits from develop into main 2026-08-09 09:46:02 +08:00
69 changed files with 6071 additions and 29 deletions
+1
View File
@@ -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 及禁止模式 |
+170
View File
@@ -0,0 +1,170 @@
# 历史选股代码规格
## 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])
```
## Scenario: 持久化策略执行结果与 HTTP 重跑
### 1. Scope / Trigger
- Trigger:为已有历史选股公式增加每日批次持久化、HTTP 触发/查询和 Web 轮询时,沿用
`selection` bounded context;不要让查询请求重新计算公式。
- 触发入口是 `POST /api/v1/selection/runs`,结果读取入口是
`GET /api/v1/selection/runs/{run_id}` 和
`GET /api/v1/selection/results?strategy=...&target_trade_date=...`。
### 2. Signatures
- `SelectionUniverseReader.load_execution_source(strategy: str, target_trade_date: date) -> SelectionExecutionSource`
- `SelectionRunStore.prepare_run(strategy, target_trade_date, source, *, rerun: bool) -> SelectionRun`
- `POST /api/v1/selection/runs` 请求:
`{"strategy": "zhixing_b1", "target_trade_date": "YYYY-MM-DD", "rerun": false}`;成功返回
`202` 和 `{run_id, strategy, target_trade_date, status: "running"}`。
- `selection_run` 的业务唯一键是 `(strategy, target_trade_date)`;
`selection_run_item` 的唯一键是 `(run_id, ts_code)`;
`selection_signal` 的唯一键是 `(run_id, ts_code, category)`。
### 3. Contracts
- `load_execution_source` 必须先确认目标日存在 `strategy_eligible=true` 且已完成的
`success`/`partial_success` 市场同步批次,并且股票为 active 且目标日 qfq bar/basic
完整;来源批次 ID、目标数、实际参与数和 coverage 写入 run。
- 市场数据预检在删除旧结果之前执行。预检失败不得创建新 run,也不得破坏已有终态结果。
- 同一策略同一目标日的重跑在一个短事务内使用 advisory transaction lock,删除旧 run
(依赖子表 `ON DELETE CASCADE`)并创建唯一的新 `running` run;长时间的逐股计算在
事务外执行。
- 查询响应必须返回 run 状态、批次统计、coverage、失败股票和全部独立 signals;同一
股票同日的多 category 不能合并,并按 `ZHIXING_B1_SIGNAL_ORDER` 稳定排序。
- 前端仅在首次无结果时直接触发;已有终态结果或失败重试必须先确认,再传
`rerun=true`。运行中重复请求返回冲突,不能创建第二个当前 run。
### 4. Validation & Error Matrix
| 条件 | 行为 |
| --- | --- |
| 不支持的策略、非法日期或缺少合格市场数据 | `422`,错误码 `market_data_not_ready`(输入校验仍使用 FastAPI 默认 `422`) |
| 同策略同日已有 `running` run | `409`,错误码 `run_in_progress` |
| 已有终态 run 且 `rerun=false` | `409`,错误码 `rerun_confirmation_required` |
| PostgreSQL 读写失败 | `503`,错误码 `selection_storage_unavailable` |
| run ID 不存在 | `404`,错误码 `run_not_found` |
| 单只股票评估抛出异常 | 记录该 item 为 `data_error`,继续其他股票;批次最终为 `partial_success` 或 `failed` |
| 全股票评估完成但没有命中 | 批次为 `success`、`signal_count=0`;前端显示“没有命中信号”,不是“无数据” |
### 5. Good / Base / Bad Cases
- Good:目标日已有合格同步批次,首次 POST 返回 `202`,轮询最终结果保留同一股票的
两个独立 category;确认重跑后旧 signals 随旧 run 级联清除。
- Base:目标日没有结果时查询返回 `200 status=no_data`;查询不触发公式计算,用户可在
页面选择日期后发起首次执行。
- Bad:在市场数据预检前删除旧 run;把多个 category OR 成一条 signal;用浏览器长连接
等待全股票池计算;或把进程异常留下的 `running` 伪装成成功。
### 6. Tests Required
- Domain/application:断言 source 预检先于 `prepare_run`、首次执行、多分类落盘、单股异常
继续执行、全部 no-signal 和失败计数/终态聚合。
- PostgreSQL adapter:用 fake connection 断言 advisory lock、终态重跑删除后插入、运行中
冲突、JSONB details、级联删除契约和公式优先级排序。
- HTTP:用 `TestClient(create_app())` 断言 `202`、`409` 两类冲突、`422` 数据未就绪、
`GET` 无数据、run 轮询和终态 signals。
- Migration:在可用 PostgreSQL 中断言三张 selection 表、唯一键、索引、cascade 外键,
并验证 downgrade 顺序;无数据库时至少生成 offline upgrade/downgrade SQL。
- Frontend:断言加载、查询失败、无数据、执行中、失败、部分成功、无命中、多 category,
以及重跑/失败重试确认取消不发 POST、确认发送 `rerun=true`。
### 7. Wrong vs Correct
#### Wrong
```python
# 先清空旧结果,再去确认目标日输入是否可执行;预检失败会造成数据丢失。
store.delete_current(strategy, target_trade_date)
source = reader.load_execution_source(strategy, target_trade_date)
```
#### Correct
```python
# 先读取并验证来源快照,只有成功 claim 后才允许重跑清理事务。
source = reader.load_execution_source(strategy, target_trade_date)
run = store.prepare_run(strategy, target_trade_date, source, rerun=rerun)
```
@@ -0,0 +1,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 证据是否保留。"}
@@ -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 字段。
@@ -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 差异和有意行为变化迁移,不把旧项目运行时导入新项目。"}
@@ -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` 并进入实现阶段。
@@ -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`.
@@ -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` 选取少量普通代码、宽幅
代码和已命中日期作为固定样本。由于旧实现没有记录同日多分类命中,需另加人工
构造样本验证新任务要求的多信号结果。
@@ -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": {}
}
@@ -0,0 +1,10 @@
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"检查 HTTP 方法、状态码、Pydantic 响应和 /api/v1 路由组合是否符合项目契约。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"检查执行中冲突、重跑确认、失败状态和查询错误是否可观察且未吞异常。"}
{"file":".trellis/spec/backend/selection.md","reason":"检查目标交易日、qfq 输入、独立子信号和评估状态在批次持久化中没有被破坏。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行后端格式、lint、Pyright、pytest 和黑盒 HTTP 质量检查。"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"检查 Query key、轮询、mutation 和 AbortSignal 的实现方式。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"检查服务器状态没有错误复制到全局 store,运行中状态使用局部/query 状态。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"检查 API 类型和页面状态分支没有使用 any、无解释断言或重复响应形状。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、类型、测试和构建门禁。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查迁移、领域模型、HTTP JSON、前端类型与用户状态的端到端契约。"}
{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"检查策略执行是否只使用合格市场同步批次并携带覆盖率和失败信息。"}
@@ -0,0 +1,187 @@
# 策略执行结果持久化、HTTP 触发与查询设计
## 1. 设计目标
在现有 `selection` bounded context 上补齐一条可重跑的每日策略结果链路:用户在
Web 页面选择目标交易日并触发 `zhixing_b1`,HTTP 快速返回执行批次标识,服务端在
当前进程的异步批次中完成全股票池评估并持久化结果,页面轮询状态后展示信号明细。
一次重跑必须在同一数据库事务中清除指定“策略 + 目标交易日”的旧结果并创建新的
运行记录,避免旧信号和新信号混在一起。执行中的重复请求只返回冲突,不得并发清理
或重复计算同一批次。
本期不引入独立任务队列、定时器或其他策略;HTTP 是触发入口,FastAPI 进程内的
`BackgroundTasks` 是异步执行机制。
## 2. 上下文边界与模块分工
```text
zhixing-server/src/zhixing_server/modules/selection/
├── domain/
│ ├── models.py # 已有行情、信号和单股评估模型
│ ├── ports.py # 市场数据读取端口
│ └── runs.py # 批次状态、持久化读写端口和执行结果模型
├── application/
│ ├── evaluate.py # 已有单股评估用例
│ └── run.py # 全股票池批次编排、重跑和失败收敛
├── infrastructure/
│ ├── postgres_reader.py # 已有单股 qfq 历史读取,补充执行股票池读取
│ └── postgres_runs.py # selection 批次、item、signal 的 PostgreSQL 适配器
└── presentation/
└── http.py # Pydantic 请求/响应和 HTTP 依赖
```
- `selection.domain` 不依赖 FastAPI、Psycopg 或 PostgreSQL JSON 类型。
- `selection.application` 只依赖端口;批次执行负责逐股调用已有
`EvaluateZhixingB1`,不复制公式逻辑。
- `selection.infrastructure` 负责事务、锁、SQL、JSONB 序列化和市场同步批次关联。
- `selection.presentation.http` 只做边界校验、HTTP 状态映射和领域模型转换;由
`interfaces/http/router.py` 在 `/api/v1/selection` 下挂载。
- 前端新增 `features/selection` 垂直切片;页面可以依赖 `HomeShell` 和 shared UI,
`shared` 不反向依赖 selection。
## 3. 持久化模型与重跑事务
新增 Alembic migration `0002_selection_results`,同时更新
`modules/market_data/infrastructure/schema.py` 的 metadata。使用三张表:
### 3.1 `selection_run`
一行代表某个策略、目标交易日的一次当前执行尝试。
- `id`:UUID 字符串主键,作为异步轮询的 `run_id`。
- `strategy`、`target_trade_date`:业务身份;建立唯一约束,保证同一时点只有一条
当前尝试。
- `market_sync_batch_id`:引用产生输入数据的市场同步批次标识;跨上下文先保存
稳定 ID,不改变市场数据 bounded context 的写入所有权。
- `status`:`running`、`success`、`partial_success`、`failed`。
- `target_count`、`eligible_count`、`evaluated_count`、`selected_stock_count`、
`signal_count`、`failed_count`:批次汇总计数。
- `coverage`:从市场同步批次复制的覆盖率,使用 Numeric 保存精度。
- `error_type`、`error_message`:批次级失败上下文,可空且不保存 traceback 或凭据。
- `created_at`、`finished_at`:审计时间。
`selection_run_item` 以 `(run_id, ts_code)` 为主键,保存每只参与股票的名称、
评估状态、信号数量和可读原因。状态沿用领域评估状态:`selected`、`no_signal`、
`insufficient_history`、`missing_target_bar`、`data_error`。
`selection_signal` 以 `(run_id, ts_code, category)` 为主键,保存股票、目标日、
策略、子信号分类、qfq 收盘价和 JSONB `details`。同一股票同日的多个 category
分别落行,查询时按代码和公式优先级稳定排序。
### 3.2 首次执行、重跑和并发
`prepare_run(strategy, target_trade_date, rerun)` 在一个短事务中完成:
1. 使用按策略和日期派生的 PostgreSQL advisory transaction lock,串行化同一业务键。
2. 查询当前 `selection_run`。
3. `running` 时拒绝请求,返回 `409 run_in_progress`。
4. 已有终态且 `rerun=false` 时返回 `409 rerun_confirmation_required`;页面只有在
用户确认弹窗后才发送 `rerun=true`。
5. `rerun=true` 时删除旧 run(子表使用 `ON DELETE CASCADE`),再插入新的 `running`
run;删除与创建同事务提交。
6. 没有旧 run 时直接插入新的 `running` run。
事务提交后才注册 `BackgroundTasks`。后台执行异常会把 run 收敛为 `failed`;单只
股票异常记录到 `selection_run_item`,其余股票继续执行,最后根据失败数量和命中
结果写入 `success`、`partial_success` 或 `failed`。
进程在批次运行中崩溃会留下 `running` 状态;本期将其作为可见的执行中状态,并在
后续恢复机制中再增加超时接管。该限制必须在运维风险中保留,不能伪装成成功结果。
## 4. 执行数据流
1. HTTP 收到策略、目标交易日和 `rerun`,边界只允许当前支持的 `zhixing_b1`。
2. application 通过 selection 端口读取目标日最新的 `market_sync_batch`,只允许
`strategy_eligible=true` 的同步批次作为输入;没有可用批次则在任何清理/创建 run
事务之前返回 `422 market_data_not_ready`,不使用当前最新日期猜测目标日,也不破坏
已有的成功结果。
3. 读取当前 `market_stock.is_active=true` 且目标日同时拥有 bar/basic 的有效股票
集合;`target_count` 和 `coverage` 来自同步批次,`eligible_count` 来自实际输入。
4. 对每只股票调用已有 `PostgresMarketDataReader.load_history` 和
`EvaluateZhixingB1.execute`,将 item 状态和全部独立 signals 写入当前 run。
5. 完成后一次更新 run 汇总和 `finished_at`;查询端只读取已提交的持久化状态。
全股票池执行先使用现有“逐股票读取”的正确性优先方案,不在本任务引入并行化或
缓存;若性能不足,后续再以批量历史读取为单独设计。
## 5. HTTP 契约
### 5.1 触发
`POST /api/v1/selection/runs`
请求:
```json
{
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"rerun": false
}
```
成功返回 `202`:
```json
{
"run_id": "<uuid>",
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"status": "running"
}
```
错误状态至少包括:
- `409 run_in_progress`:同一策略和目标日已有运行中的批次;
- `409 rerun_confirmation_required`:已有终态结果但请求没有 `rerun=true`;
- `422`:策略、日期或市场数据资格不满足请求契约;
- `503`:无法创建批次或数据库不可用。
### 5.2 轮询与结果查询
- `GET /api/v1/selection/runs/{run_id}`:按 run ID 返回批次状态;运行中返回汇总,
终态追加 item 失败列表和 signals。
- `GET /api/v1/selection/results?strategy=zhixing_b1&target_trade_date=...`:按业务
键查询当前结果。目标日省略时取该策略最近一条当前 run;没有结果返回 `200` 的
`status=no_data`,不把“没有执行”伪装成 HTTP 异常。
稳定响应包含策略、目标日、run ID、状态、市场同步批次、计数、coverage、错误/失败
列表和 signal 明细。日期使用 ISO `date`,时间使用带时区的 ISO `datetime`。字段不
直接暴露数据库列名以外的内部异常信息。
## 6. 前端交互
- 新增 `/selection` 路由,启用 `HomeShell` 的“选股策略”导航。
- 页面提供目标交易日选择,默认查询最近持久化结果;策略下拉首期只显示“知行 B1”。
- 首次无结果时显示“执行策略”;已有成功、部分成功或失败结果时显示“重新执行/
重试执行”,点击先打开确认 Dialog,取消不调用 POST,确认才发送 `rerun=true`。
- POST 成功后保存 `run_id` 到组件局部状态,使用 React Query 轮询 run;运行中展示
状态和刷新提示,终态失效业务键查询并显示结果。
- 页面明确区分加载中、查询错误、无数据、执行中、执行失败、无命中、部分成功和
成功;signals 以每个 category 一行或可辨认的标签展示,同一股票的多分类不能合并
成一条无分类记录。
- API 类型、query key、mutation 和轮询逻辑全部位于 `features/selection/api/`,
页面不直接调用 `fetch`,不把服务器结果复制到 Zustand。
## 7. 兼容性与回滚
- 不修改 `market_stock`、行情事实表或现有同步批次的语义;只读取其
`strategy_eligible`、coverage 和目标日输入。
- migration downgrade 按 signals → items → runs 删除新表;删除 selection 结果
不影响市场数据。
- 如果异步机制或全市场性能不满足,保留已提交的迁移和领域契约,后续替换执行器;
不回退到即时查询或删除持久化结果。
- 进程崩溃遗留 `running` 和当前实现逐股票读取是已知风险,作为后续任务候选记录。
## 8. 验证策略
- domain/application:Fake reader/repository 覆盖首次执行、重跑先清空、运行中冲突、
全部状态聚合、多分类落盘和单股失败继续执行。
- infrastructure:fake psycopg connection 覆盖参数化查询、事务顺序、级联清理、
JSONB details、市场同步资格和稳定排序。
- HTTP:`TestClient(create_app())` 覆盖 `202`、`409`、`422`、无数据查询、轮询和
终态响应;后台执行依赖通过 FastAPI override 或 fake service 注入。
- frontend:页面测试覆盖初次执行、确认弹窗取消/确认、轮询状态、失败重试、无命中、
多分类和查询错误;运行格式、lint、类型、Vitest 和生产构建。
@@ -0,0 +1,17 @@
{"file":".trellis/spec/backend/index.md","reason":"确认后端 bounded context、HTTP 入口和开发前检查。"}
{"file":".trellis/spec/backend/directory-structure.md","reason":"按 selection 的 domain/application/infrastructure/presentation 边界组织持久化、执行和 HTTP 代码。"}
{"file":".trellis/spec/backend/http-api-contracts.md","reason":"实现 /api/v1 路由目录、Pydantic 响应模型、依赖注入和 TestClient 契约。"}
{"file":".trellis/spec/backend/error-handling.md","reason":"为重跑冲突、执行失败、查询失败和 FastAPI 边界建立稳定错误映射。"}
{"file":".trellis/spec/backend/selection.md","reason":"复用 zhixing_b1 的目标日、qfq、独立子信号和评估状态契约。"}
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵守 Ruff、Pyright、pytest、公开类型和 HTTP 测试门禁。"}
{"file":".trellis/spec/frontend/index.md","reason":"确认 React feature 垂直切片、同源 API 和前端质量检查。"}
{"file":".trellis/spec/frontend/directory-structure.md","reason":"将 selection API、页面、路由和组件放入正确的 feature 边界。"}
{"file":".trellis/spec/frontend/hook-guidelines.md","reason":"实现 React Query 查询、轮询和 mutation,不在页面直接 fetch。"}
{"file":".trellis/spec/frontend/state-management.md","reason":"将运行状态和结果留在 React Query/局部状态,不复制到 Zustand。"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"实现可访问的结果页面、表格状态和重跑确认 Dialog。"}
{"file":".trellis/spec/frontend/type-safety.md","reason":"保持后端响应类型、字面量状态和严格 TypeScript 一致。"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、测试和构建检查。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"同步数据库、领域、HTTP、前端 API 类型和页面状态的跨层契约。"}
{"file":"docs/adr/0001-bounded-context-first-modular-monolith.md","reason":"确认 selection 持久化与 HTTP 仍归属模块化单体的明确 bounded context。"}
{"file":"docs/adr/0003-postgresql-as-market-data-store.md","reason":"确认 PostgreSQL 是策略输入事实源,CSV 不承担运行时结果查询。"}
{"file":"docs/adr/0004-tushare-six-year-snapshot-sync.md","reason":"遵守策略批次关联市场同步批次、覆盖率和策略资格契约。"}
@@ -0,0 +1,137 @@
# 策略执行结果持久化与查询实施计划
## 实施原则
- 只修改 `zhixing-system`;保留用户已有的 `CONTEXT.md` 和 ADR 工作区变更。
- 先锁定数据库事务、批次状态和 HTTP 响应测试,再接入前端交互。
- 复用已有 `EvaluateZhixingB1`、`PostgresMarketDataReader` 和 shared UI,不复制公式
逻辑或网络 transport。
- 任何重跑都必须通过“策略 + 目标日”业务键清空旧结果;不要用插入多版本结果来
规避唯一约束。
- 后台任务只承担当前进程内异步执行;不得在本任务擅自引入任务队列、定时器或新的
外部服务。
## 1. 规划复核与数据库契约
- [x] 将 `prd.md`、`design.md`、本文件从头读一遍,确认产品决策、验收标准和实现
细节没有重复或冲突。
- [x] 阅读 `.trellis/spec/backend`、`.trellis/spec/frontend` 及 cross-layer guide,
确认 migration、HTTP、前端类型和测试边界。
- [x] 设计并实现 `0002_selection_results`:`selection_run`、`selection_run_item`、
`selection_signal`、唯一约束、状态索引、JSONB details 和安全 downgrade。
- [x] 更新 `modules/market_data/infrastructure/schema.py` metadata,使 Alembic
离线/在线上下文包含新表。
验证:
```bash
cd zhixing-server
uv run alembic check
uv run pytest tests/integration/test_market_data_migration.py
```
回滚点:migration 或 schema metadata 不一致时只回滚新 migration 和 selection 表,
不修改 `0001_market_data`。
## 2. 领域端口与 PostgreSQL 适配器
- [x] 在 selection domain 增加批次状态、股票 item、持久化查询模型和明确端口协议。
- [x] 为 `PostgresMarketDataReader` 增加读取当前有效执行股票集合的能力,使用目标日、
`market_sync_batch.strategy_eligible`、active stock 和 bar/basic 完整性约束。
- [x] 在清理旧结果前完成市场同步资格预检;目标日没有可用合格同步批次时返回
`market_data_not_ready`,保留已有结果不做破坏性变更。
- [x] 新增 selection PostgreSQL repository:短事务创建/删除/完成 run、记录 item 和
signal、按 run 或业务键读取;写入 details 时保持 JSON 可序列化。
- [x] 以 advisory transaction lock、终态检查和 `rerun` 参数实现首次执行、重复执行、
运行中冲突;清理和创建必须在同一事务。
验证:
```bash
cd zhixing-server
uv run pytest tests/unit/selection/test_postgres_runs.py
uv run pytest tests/unit/selection/test_postgres_reader.py
```
回滚点:若 SQL 适配器无法通过 fake connection 测试,保留纯领域端口和 migration,
先修适配器,不触碰已有市场数据写入代码。
## 3. 全股票池执行用例与异步服务
- [x] 实现 `RunZhixingB1`:准备 run、读取有效股票、逐只调用已有评估用例、保存 item
和全部信号、汇总并完成 run。
- [x] 为 selected/no_signal/insufficient_history/missing_target_bar/data_error 建立
明确计数和终态映射;单股失败不影响其他股票,批次级异常收敛为 failed。
- [x] 为后台执行提供可注入的 service/worker 入口,确保 HTTP 响应提交后才调度,异常
时更新持久化状态;避免把数据库连接对象跨请求/跨线程复用。
- [x] 为首次执行、重跑清理、重复运行冲突、失败重试和多分类信号编写应用测试。
验证:
```bash
cd zhixing-server
uv run pytest tests/unit/selection/test_run.py
uv run pytest tests/unit/selection/test_evaluate.py
```
## 4. HTTP 接口与后端契约测试
- [x] 在 `selection/presentation/http.py` 定义请求和响应 Pydantic 模型,稳定声明日期、
状态、计数、coverage、失败 item 和 signal details。
- [x] 实现 `POST /api/v1/selection/runs`,成功返回 `202 + run_id`;将
`run_in_progress`、`rerun_confirmation_required`、输入错误和数据库错误映射为稳定
HTTP 响应。
- [x] 实现 `GET /api/v1/selection/runs/{run_id}` 和按策略/日期查询结果的 GET 端点;
无当前结果返回 `status=no_data`,不要制造假的成功结果。
- [x] 在顶层 router 挂载 selection router,保持 `/api/v1` 唯一目录入口;依赖使用
`Settings` 注入并提供测试 override。
- [x] 用 `TestClient(create_app())` 覆盖真实路由、202、冲突、无数据、轮询和终态 JSON。
验证:
```bash
cd zhixing-server
uv run pytest tests/test_selection_http.py
```
## 5. 前端 feature 与交互
- [x] 新增 `features/selection/api/selection.types.ts`、`selection.api.ts`、
`selection.query.ts`,实现结果查询、run 查询/轮询和触发 mutation。
- [x] 新增 selection 页面及结果表/统计卡片/重跑确认 Dialog,覆盖初次执行、成功、
无命中、失败、部分成功、查询错误和运行中状态。
- [x] 为“选股策略”启用 `/selection` 路由和导航,保持 HomeShell 的布局及可访问性;
不让未实现的其他导航入口误显为可用。
- [x] 重跑或失败重试按钮只打开确认框;取消不调用 POST,确认后才传 `rerun=true`,
并在返回的 run 完成后刷新结果查询。
- [x] 同一股票多 category 用独立 badge/行展示,details 只显示后端稳定字段。
- [x] 更新页面测试和路由相关测试,使用 query hook mock,不依赖真实后端。
验证:
```bash
cd zhixing-web
pnpm format:check
pnpm lint
pnpm typecheck
pnpm test
pnpm build
```
## 6. 全量质量检查与交付门禁
- [x] 运行后端 Ruff、Pyright、pytest;运行前端格式、lint、typecheck、test、build。
- [x] 检查 `rg -n "except Exception|fetch\(|xg_composite"`,确认没有吞异常、页面直
接请求或旧策略运行时依赖。
- [x] 检查 migration downgrade、重复执行事务、run status 与前端轮询是否一致。
- [x] 使用 `task.py validate` 校验任务清单和上下文 manifests,再由 `trellis-check`
做最终规格/跨层检查。
## 风险与回滚点
- 新表/适配器失败:只回滚 `0002_selection_results` 和 selection 新代码,不撤销市场
数据 migration 或用户既有修改。
- 后台任务进程崩溃:当前 run 可能停在 `running`;必须在页面可见并记录为后续恢复
机制,不将其当作成功。
- 全股票池逐只读取过慢:保留结果契约和端口,后续在 reader/application 层做批量读
取;本任务不通过放宽历史数据或减少股票池来掩盖性能问题。
@@ -0,0 +1,83 @@
# 补充策略执行结果查询接口和前端页面
## Goal
为已完成的知行 B1 历史选股逻辑提供可使用的查询入口,让研究人员能够在 Web
页面选择目标交易日并查看策略命中的股票及子信号结果。
## Background and confirmed facts
- 上一个任务 `08-08-migrate-zhixing-b1` 已完成 `zhixing_b1` 策略领域逻辑、历史
qfq 行情读取适配器和单只股票评估用例。
- 当前领域入口是
`EvaluateZhixingB1.execute(ts_code, target_trade_date)`,返回
`SelectionEvaluation`,状态包括 `selected`、`no_signal`、`insufficient_history`、
`missing_target_bar` 和 `data_error`。
- `SelectionSignal` 已包含股票代码、名称、目标交易日、策略标识、七种独立子信号
分类、收盘价和可序列化详情;稳定身份为
`(ts_code, target_trade_date, strategy, category)`。
- 当前没有选股结果数据库表、结果持久化、全市场批量执行用例或 selection HTTP
路由;selection presentation 包仍为空。
- 后端 HTTP 通过 `/api/v1` 统一挂载,稳定响应使用 Pydantic 模型;前端按 feature
组织 API 类型、Query hook 和页面,并通过同源 `/api/v1/...` 请求后端。
- 当前前端首页侧栏的“选股策略”入口仍是禁用按钮,路由树只有 `/` 首页。
## Product decisions
- 结果按每日策略批次持久化;页面查询已保存的策略执行批次,不在查询请求中重新
计算策略。
- 策略执行通过 HTTP 触发,不新增 CLI 作为本任务的主要入口。
- 当用户重复执行或重试失败批次时,服务端必须先清空指定目标交易日、指定策略的
旧结果,再执行一次;前端在每次重执行前弹窗确认,确认后才发起 HTTP 请求。
- 执行接口需要能区分首次执行、已有结果的重复执行和执行中的冲突,不能因重复点击
产生重复信号或多个互相冲突的当前结果。
- HTTP 采用异步批次模式:`POST` 只创建/清空并启动批次,返回 `202` 和 `run_id`;
前端通过 `GET` 轮询批次状态,完成后展示持久化结果。全股票池计算不得要求浏览器
长时间保持原始执行请求。
## Requirements
- 策略执行结果必须按目标交易日和策略批次持久化,并可被后续 HTTP 查询读取。
- `zhixing_b1` 的多种独立子信号必须分别保存,不能因同一股票同日多分类而覆盖或
合并;结果身份继续遵循
`(ts_code, target_trade_date, strategy, category)`。
- 查询页面展示已保存批次的执行状态、目标交易日、参与数量、命中数量以及股票和
子信号明细;查询失败、执行失败和无命中必须有可区分的用户可见状态。
- 同一策略同一目标交易日重复执行必须具备幂等语义,不能产生重复信号或多个互相
冲突的“最新结果”;按用户确认的重跑规则,重跑前清空该日该策略旧结果。
- 结果应关联产生它所依赖的市场数据同步批次,并保留实际参与股票数和数据覆盖率,
以便判断结果是否完整。
- HTTP 应提供策略执行触发接口,支持指定目标交易日和策略;首次执行、重跑确认、
执行中冲突和失败重试应有明确响应语义。
- HTTP 响应使用稳定的 Pydantic 契约;前端使用 feature API 类型、React Query 和
独立的策略结果页面,不在页面中直接发起 `fetch`。
## Acceptance Criteria
- [ ] 已执行的 `zhixing_b1` 批次及其信号结果可以写入 PostgreSQL,并能通过稳定
唯一身份幂等重跑;重跑不会残留上一次执行的信号。
- [ ] HTTP 可以查询最新批次和/或指定目标交易日的持久化结果,响应包含批次状态、
覆盖率、命中统计和全部独立子信号明细。
- [ ] HTTP 可以触发指定目标交易日和策略的执行;已有结果重跑和失败重试遵循清空后
重算规则,并对执行中的重复请求返回可识别冲突。
- [ ] 前端“选股策略”入口可进入结果页面,能够查看加载中、无数据、执行失败、查询
失败、无命中和正常结果状态。
- [ ] 前端每次重跑/失败重试都会先展示确认弹窗;取消不会发起执行请求,确认后能显示
执行中状态并刷新持久化结果。
- [ ] 执行触发接口返回异步批次标识,前端可通过状态查询感知运行中、成功、无命中和
失败,并在结束后读取同一批次结果。
- [ ] 结果页面的股票明细能区分同一股票同日命中的多个子信号,并展示目标交易日、
股票代码/名称、收盘价及关键详情。
- [ ] 后端迁移、应用用例、HTTP 契约和前端页面测试覆盖成功、重复执行、无结果和
失败场景。
## Out of scope
- 不迁移其他选股策略,不改变 `zhixing_b1` 公式语义或市场数据同步规则。
- 不实现收益率、持仓、交易撮合或实盘交易能力;本任务展示的是选股信号结果。
- 不把执行调度扩展为独立任务队列或跨进程工作流;本期只提供 HTTP 触发和项目现有
运行边界内的异步批次执行机制。
## Open questions
无。
@@ -0,0 +1,26 @@
{
"id": "strategy-execution-results",
"name": "strategy-execution-results",
"title": "补充策略执行结果查询接口和前端页面",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "yuxuanhui",
"assignee": "yuxuanhui",
"createdAt": "2026-08-08",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -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,
+4 -3
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 2
- **Last Active**: 2026-08-07
- **Total Sessions**: 3
- **Last Active**: 2026-08-08
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~50 | Active |
| `journal-1.md` | ~71 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | 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` |
<!-- @@@/auto:session-history -->
+21
View File
@@ -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**
+10
View File
@@ -7,6 +7,16 @@
**历史可复现分析**:给定一个历史交易日,在当前目标股票池范围内重建该日的行情与估值状态;价格采用数据库中当前最新修订的前复权行情,不要求还原某次同步时的价格版本。
_Avoid_: 用当前市值回填历史、把当前目标股票池当作无幸存者偏差的长期回测宇宙
**选股策略**:一组针对明确目标交易日、依赖该日之前历史行情的选股规则;它产出可解释的选股信号,不等同于交易执行指令或收益承诺。
**知行 B1**:当前迁移中的知行选股策略名称;它按知行公式语义评估目标交易日,并包含七种可以独立命中的子信号。
**子信号**:选股公式中具有独立业务含义的命中分支;同一只股票在同一交易日可以同时命中多个子信号,不能因为存在合并条件而丢失分类信息。
**公式语义**:公式原文及已确认业务调整所表达的规则意图;当旧代码与公式意图冲突时,公式语义是迁移后的判断依据,旧代码只作为实现参考和差异证据。
**历史样本验证**:使用固定的历史行情样本和固定期望结果验证策略规则;它用于发现规则迁移偏差,不等同于回测收益评价。
## 市场数据
**股票主数据**:描述证券标识及其在市场中的基础状态,例如代码、名称、交易所和上市状态。
@@ -0,0 +1,31 @@
---
status: accepted
---
# 选股策略以公式语义为准并独立保留子信号
知行选股策略迁移时,以通达信公式原文和已确认的业务调整作为规则语义来源,
旧项目 Python 实现只作为迁移参考与差异证据。原因是旧实现存在无法历史重放的
数据行为,也会在多个 XG 条件同时命中时只保留第一个分类;直接追求旧输出兼容
会把实现缺陷固化到新系统。
新策略使用 `zhixing_b1` 作为业务标识,并把七种子信号作为独立分类。同一股票、
同一交易日可以产生多条不同分类的信号;每条信号的稳定身份由股票、交易日、
策略标识和子信号分类共同确定。
## Considered Options
- **完全兼容旧 Python 输出**:迁移成本较低,但会保留旧的首个分类截断和历史
数据前视风险。
- **只按公式返回一条合并信号**:实现简单,但会丢失子信号的解释性和后续统计
维度。
- **按公式语义计算并独立返回全部子信号**:需要增加分类结果和差异测试,但能
保持规则意图、解释能力和历史重放边界。
## Consequences
- golden 验证需要同时记录命中分类集合和有意的旧实现差异。
- 后续信号持久化可以使用 `(ts_code, trade_date, strategy, category)` 作为唯一
键,但本 ADR 不决定具体数据库表或 HTTP 契约。
- 其他策略迁移时仍需逐一确认其公式来源与历史依赖,不能自动套用旧策略的输出
兼容规则。
@@ -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")
+3
View File
@@ -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",
@@ -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"]
@@ -425,8 +425,7 @@ class SyncMarketData:
failure = self._failure("daily_basic", key, exc)
failures.append(failure)
logger.warning(
"market_data_sync_item_failed batch_id=%s stage=daily_basic item=%s "
"error_type=%s",
"market_data_sync_item_failed batch_id=%s stage=daily_basic item=%s error_type=%s",
batch_id,
key,
failure.error_type,
@@ -520,10 +519,16 @@ class SyncMarketData:
) -> None:
"""Log bounded, secret-free progress for a batch stage."""
if not force and total > _PROGRESS_LOG_INTERVAL and current not in {
1,
total,
} and current % _PROGRESS_LOG_INTERVAL != 0:
if (
not force
and total > _PROGRESS_LOG_INTERVAL
and current
not in {
1,
total,
}
and current % _PROGRESS_LOG_INTERVAL != 0
):
return
logger.info(
"market_data_sync_progress batch_id=%s stage=%s progress=%d/%d item=%s "
@@ -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,
)
@@ -0,0 +1 @@
"""Selection bounded context for reproducible historical strategy evaluation."""
@@ -0,0 +1 @@
"""Selection application use cases."""
@@ -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)
@@ -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",
]
@@ -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.
@@ -0,0 +1 @@
"""Pure selection domain models, indicators, ports, and strategies."""
@@ -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
@@ -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"
@@ -0,0 +1,27 @@
"""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
from .runs import SelectionExecutionSource, SelectionUniverseReader
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: ...
__all__ = [
"MarketDataReader",
"MarketDataReaderError",
"SelectionExecutionSource",
"SelectionUniverseReader",
]
@@ -0,0 +1,133 @@
"""Domain contracts for persisted historical selection runs."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from typing import Literal, Protocol
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus
@dataclass(frozen=True, slots=True)
class SelectionStock:
"""One eligible current stock that will be evaluated for a run."""
ts_code: str
name: str
@dataclass(frozen=True, slots=True)
class SelectionExecutionSource:
"""Market-data batch and eligible stock snapshot used by one run."""
market_sync_batch_id: str
target_trade_date: date
target_count: int
valid_count: int
coverage: Decimal
stocks: tuple[SelectionStock, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class SelectionRunItem:
"""Persistable per-stock evaluation state and its independent signals."""
ts_code: str
name: str
status: SelectionRunItemStatus
signal_count: int = 0
reason: str | None = None
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
@dataclass(frozen=True, slots=True)
class SelectionRun:
"""A current execution attempt and its materialized result rows."""
id: str
strategy: Literal["zhixing_b1"]
target_trade_date: date
market_sync_batch_id: str | None
status: SelectionRunStatus
target_count: int
eligible_count: int
evaluated_count: int
selected_stock_count: int
signal_count: int
failed_count: int
coverage: Decimal
error_type: str | None = None
error_message: str | None = None
created_at: datetime | None = None
finished_at: datetime | None = None
items: tuple[SelectionRunItem, ...] = field(default_factory=tuple)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
class SelectionRunError(RuntimeError):
"""Base class for expected selection-run persistence failures."""
class SelectionRunInProgress(SelectionRunError):
"""The requested strategy and date already have a running attempt."""
class SelectionRerunRequired(SelectionRunError):
"""A terminal result exists and an explicit rerun confirmation is missing."""
class SelectionRunStoreError(SelectionRunError):
"""The selection-run repository could not complete a database operation."""
class SelectionRunStore(Protocol):
"""Persistence port for current selection runs and their materialized rows."""
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun: ...
def record_item(self, run_id: str, item: SelectionRunItem) -> None: ...
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None: ...
def get_run(self, run_id: str) -> SelectionRun | None: ...
def get_latest_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None,
) -> SelectionRun | None: ...
class SelectionUniverseReader(Protocol):
"""Read a qualified market-data source snapshot for one strategy run."""
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource: ...
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
@@ -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
@@ -0,0 +1 @@
"""Selection infrastructure adapters."""
@@ -0,0 +1,256 @@
"""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
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,
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
"""
_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."""
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)},
)
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, ...],
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
@@ -0,0 +1,414 @@
"""PostgreSQL persistence adapter for selection execution runs."""
from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal, cast
from uuid import uuid4
import psycopg
from psycopg.types.json import Jsonb
from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunError,
SelectionRunInProgress,
SelectionRunItem,
SelectionRunStatus,
SelectionRunStore,
SelectionRunStoreError,
)
from ..domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
_SIGNAL_PRIORITY = {category: index for index, category in enumerate(ZHIXING_B1_SIGNAL_ORDER)}
class PostgresSelectionRunRepository(SelectionRunStore):
"""Persist one current result attempt per strategy and target date."""
def __init__(self, database_url: str) -> None:
"""Create the adapter with an injected PostgreSQL URL."""
self.database_url = database_url
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun:
"""Atomically claim the business key and create a running attempt.
The advisory transaction lock protects the small delete-and-create
window from duplicate HTTP requests. The long-running calculation is
intentionally performed after this transaction is released.
"""
run_id = str(uuid4())
key = f"selection:{strategy}:{target_trade_date.isoformat()}"
try:
with self._connection() as connection, connection.transaction():
connection.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (key,))
existing = connection.execute(
"""
SELECT id, status
FROM selection_run
WHERE strategy = %s AND target_trade_date = %s
FOR UPDATE
""",
(strategy, target_trade_date),
).fetchone()
if existing is not None:
existing_status = str(existing[1])
if existing_status == "running":
raise SelectionRunInProgress(
f"selection run is already running for {strategy} at "
f"{target_trade_date.isoformat()}"
)
if not rerun:
raise SelectionRerunRequired(
f"rerun confirmation is required for {strategy} at "
f"{target_trade_date.isoformat()}"
)
connection.execute(
"DELETE FROM selection_run WHERE strategy = %s AND target_trade_date = %s",
(strategy, target_trade_date),
)
connection.execute(
"""
INSERT INTO selection_run
(
id, strategy, target_trade_date, market_sync_batch_id,
status, target_count, eligible_count, coverage
)
VALUES (%s, %s, %s, %s, 'running', %s, %s, %s)
""",
(
run_id,
strategy,
target_trade_date,
source.market_sync_batch_id,
source.target_count,
len(source.stocks),
source.coverage,
),
)
except SelectionRunError:
raise
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to prepare selection run") from exc
return SelectionRun(
id=run_id,
strategy=strategy,
target_trade_date=target_trade_date,
market_sync_batch_id=source.market_sync_batch_id,
status="running",
target_count=source.target_count,
eligible_count=len(source.stocks),
evaluated_count=0,
selected_stock_count=0,
signal_count=0,
failed_count=0,
coverage=source.coverage,
)
def record_item(self, run_id: str, item: SelectionRunItem) -> None:
"""Upsert one stock outcome and all of its independent signal rows."""
try:
with self._connection() as connection, connection.transaction():
connection.execute(
"""
INSERT INTO selection_run_item
(run_id, ts_code, name, status, signal_count, reason)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (run_id, ts_code) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
signal_count = EXCLUDED.signal_count,
reason = EXCLUDED.reason
""",
(
run_id,
item.ts_code,
item.name,
item.status,
item.signal_count,
item.reason,
),
)
connection.execute(
"DELETE FROM selection_signal WHERE run_id = %s AND ts_code = %s",
(run_id, item.ts_code),
)
for signal in item.signals:
connection.execute(
"""
INSERT INTO selection_signal
(
run_id, ts_code, name, target_trade_date, strategy,
category, close, details
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (run_id, ts_code, category) DO UPDATE SET
name = EXCLUDED.name,
close = EXCLUDED.close,
details = EXCLUDED.details
""",
(
run_id,
signal.ts_code,
signal.name,
signal.target_trade_date,
signal.strategy,
signal.category.value,
signal.close,
Jsonb(dict(signal.details)),
),
)
except psycopg.Error as exc:
raise SelectionRunStoreError(
f"failed to persist selection item {item.ts_code}"
) from exc
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None:
"""Persist terminal counters and an optional safe batch error."""
try:
with self._connection() as connection, connection.transaction():
connection.execute(
"""
UPDATE selection_run
SET status = %s,
evaluated_count = %s,
selected_stock_count = %s,
signal_count = %s,
failed_count = %s,
error_type = %s,
error_message = %s,
finished_at = now()
WHERE id = %s
""",
(
status,
evaluated_count,
selected_stock_count,
signal_count,
failed_count,
error_type,
_safe_error(error_message),
run_id,
),
)
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to finish selection run {run_id}") from exc
def get_run(self, run_id: str) -> SelectionRun | None:
"""Read one run with its item failures and signal details."""
try:
with self._connection() as connection:
return self._load_run(connection, run_id)
except psycopg.Error as exc:
raise SelectionRunStoreError(f"failed to load selection run {run_id}") from exc
def get_latest_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date | None = None,
) -> SelectionRun | None:
"""Read the current run for a date or the latest date for a strategy."""
try:
with self._connection() as connection:
if target_trade_date is None:
row = connection.execute(
"""
SELECT id
FROM selection_run
WHERE strategy = %s
ORDER BY target_trade_date DESC, created_at DESC, id DESC
LIMIT 1
""",
(strategy,),
).fetchone()
else:
row = connection.execute(
"""
SELECT id
FROM selection_run
WHERE strategy = %s AND target_trade_date = %s
LIMIT 1
""",
(strategy, target_trade_date),
).fetchone()
return self._load_run(connection, str(row[0])) if row else None
except psycopg.Error as exc:
raise SelectionRunStoreError("failed to load latest selection run") from exc
@staticmethod
def _load_run(connection: Any, run_id: str) -> SelectionRun | None:
row = connection.execute(
"""
SELECT
id, strategy, target_trade_date, market_sync_batch_id, status,
target_count, eligible_count, evaluated_count, selected_stock_count,
signal_count, failed_count, coverage, error_type, error_message,
created_at, finished_at
FROM selection_run
WHERE id = %s
""",
(run_id,),
).fetchone()
if row is None:
return None
item_rows = connection.execute(
"""
SELECT ts_code, name, status, signal_count, reason
FROM selection_run_item
WHERE run_id = %s
ORDER BY ts_code
""",
(run_id,),
).fetchall()
signal_rows = connection.execute(
"""
SELECT
ts_code, name, target_trade_date, strategy, category, close, details
FROM selection_signal
WHERE run_id = %s
ORDER BY ts_code, category
""",
(run_id,),
).fetchall()
signals = tuple(
sorted(
(_signal_from_row(cast(tuple[object, ...], value)) for value in signal_rows),
key=lambda signal: (
signal.ts_code,
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
),
)
)
signals_by_stock: dict[str, list[SelectionSignal]] = defaultdict(list)
for signal in signals:
signals_by_stock[signal.ts_code].append(signal)
items = tuple(
SelectionRunItem(
ts_code=str(value[0]),
name=str(value[1] or ""),
status=cast(
Literal[
"selected",
"no_signal",
"insufficient_history",
"missing_target_bar",
"data_error",
],
str(value[2]),
),
signal_count=int(value[3] or 0),
reason=str(value[4]) if value[4] is not None else None,
signals=tuple(signals_by_stock.get(str(value[0]), ())),
)
for value in item_rows
)
return SelectionRun(
id=str(row[0]),
strategy=cast(Literal["zhixing_b1"], str(row[1])),
target_trade_date=_as_date(row[2]),
market_sync_batch_id=str(row[3]) if row[3] is not None else None,
status=cast(SelectionRunStatus, str(row[4])),
target_count=int(row[5]),
eligible_count=int(row[6]),
evaluated_count=int(row[7]),
selected_stock_count=int(row[8]),
signal_count=int(row[9]),
failed_count=int(row[10]),
coverage=Decimal(str(row[11])),
error_type=str(row[12]) if row[12] is not None else None,
error_message=str(row[13]) if row[13] is not None else None,
created_at=cast(datetime | None, row[14]),
finished_at=cast(datetime | None, row[15]),
items=items,
signals=signals,
)
@contextmanager
def _connection(self) -> Generator[Any, None, None]:
"""Translate psycopg failures without exposing driver details."""
try:
with psycopg.connect(self.database_url) as connection:
yield connection
except psycopg.Error as exc:
raise SelectionRunStoreError("selection database operation failed") from exc
def _signal_from_row(row: tuple[object, ...]) -> SelectionSignal:
"""Map a persisted signal row back to the domain signal model."""
return SelectionSignal(
ts_code=str(row[0]),
name=str(row[1] or ""),
target_trade_date=_as_date(row[2]),
strategy=cast(Literal["zhixing_b1"], str(row[3])),
category=ZhixingB1Category(str(row[4])),
close=float(str(row[5])),
details=_details(row[6]),
)
def _details(value: object) -> dict[str, float | str | None]:
"""Normalize JSONB details into the domain's scalar-only mapping."""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return {}
if not isinstance(value, Mapping):
return {}
values = cast(Mapping[object, object], value)
details: dict[str, float | str | None] = {}
for key, item in values.items():
if item is None or isinstance(item, str):
details[str(key)] = item
elif isinstance(item, (int, float)) and not isinstance(item, bool):
details[str(key)] = float(item)
return details
def _as_date(value: object) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
def _safe_error(message: str | None) -> str | None:
if message is None:
return None
return " ".join(message.split())[:500]
@@ -0,0 +1 @@
"""Transport adapters for selection; intentionally empty in the first slice."""
@@ -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",
]
@@ -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.
@@ -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。"
}
}
@@ -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
1 trade_date open high low close volume
2 2023-01-01 9.9600 10.1200 9.8800 10.0000 1000.00
3 2023-01-02 9.9800 10.1400 9.9000 10.0200 1030.00
4 2023-01-03 10.0000 10.1600 9.9200 10.0400 1060.00
5 2023-01-04 10.0200 10.1800 9.9400 10.0600 1090.00
6 2023-01-05 10.0400 10.2000 9.9600 10.0800 1120.00
7 2023-01-06 10.0600 10.2200 9.9800 10.1000 1150.00
8 2023-01-07 10.0800 10.2400 10.0000 10.1200 1180.00
9 2023-01-08 10.1000 10.2600 10.0200 10.1400 1000.00
10 2023-01-09 10.1200 10.2800 10.0400 10.1600 1030.00
11 2023-01-10 10.1400 10.3000 10.0600 10.1800 1060.00
12 2023-01-11 10.1600 10.3200 10.0800 10.2000 1090.00
13 2023-01-12 10.1800 10.3400 10.1000 10.2200 1120.00
14 2023-01-13 10.2000 10.3600 10.1200 10.2400 1150.00
15 2023-01-14 10.2200 10.3800 10.1400 10.2600 1180.00
16 2023-01-15 10.2400 10.4000 10.1600 10.2800 1000.00
17 2023-01-16 10.2600 10.4200 10.1800 10.3000 1030.00
18 2023-01-17 10.2800 10.4400 10.2000 10.3200 1060.00
19 2023-01-18 10.3000 10.4600 10.2200 10.3400 1090.00
20 2023-01-19 10.3200 10.4800 10.2400 10.3600 1120.00
21 2023-01-20 10.3400 10.5000 10.2600 10.3800 1150.00
22 2023-01-21 10.3600 10.5200 10.2800 10.4000 1180.00
23 2023-01-22 10.3800 10.5400 10.3000 10.4200 1000.00
24 2023-01-23 10.4000 10.5600 10.3200 10.4400 1030.00
25 2023-01-24 10.4200 10.5800 10.3400 10.4600 1060.00
26 2023-01-25 10.4400 10.6000 10.3600 10.4800 1090.00
27 2023-01-26 10.4600 10.6200 10.3800 10.5000 1120.00
28 2023-01-27 10.4800 10.6400 10.4000 10.5200 1150.00
29 2023-01-28 10.5000 10.6600 10.4200 10.5400 1180.00
30 2023-01-29 10.5200 10.6800 10.4400 10.5600 1000.00
31 2023-01-30 10.5400 10.7000 10.4600 10.5800 1030.00
32 2023-01-31 10.5600 10.7200 10.4800 10.6000 1060.00
33 2023-02-01 10.5800 10.7400 10.5000 10.6200 1090.00
34 2023-02-02 10.6000 10.7600 10.5200 10.6400 1120.00
35 2023-02-03 10.6200 10.7800 10.5400 10.6600 1150.00
36 2023-02-04 10.6400 10.8000 10.5600 10.6800 1180.00
37 2023-02-05 10.6600 10.8200 10.5800 10.7000 1000.00
38 2023-02-06 10.6800 10.8400 10.6000 10.7200 1030.00
39 2023-02-07 10.7000 10.8600 10.6200 10.7400 1060.00
40 2023-02-08 10.7200 10.8800 10.6400 10.7600 1090.00
41 2023-02-09 10.7400 10.9000 10.6600 10.7800 1120.00
42 2023-02-10 10.7600 10.9200 10.6800 10.8000 1150.00
43 2023-02-11 10.7800 10.9400 10.7000 10.8200 1180.00
44 2023-02-12 10.8000 10.9600 10.7200 10.8400 1000.00
45 2023-02-13 10.8200 10.9800 10.7400 10.8600 1030.00
46 2023-02-14 10.8400 11.0000 10.7600 10.8800 1060.00
47 2023-02-15 10.8600 11.0200 10.7800 10.9000 1090.00
48 2023-02-16 10.8800 11.0400 10.8000 10.9200 1120.00
49 2023-02-17 10.9000 11.0600 10.8200 10.9400 1150.00
50 2023-02-18 10.9200 11.0800 10.8400 10.9600 1180.00
51 2023-02-19 10.9400 11.1000 10.8600 10.9800 1000.00
52 2023-02-20 10.9600 11.1200 10.8800 11.0000 1030.00
53 2023-02-21 10.9800 11.1400 10.9000 11.0200 1060.00
54 2023-02-22 11.0000 11.1600 10.9200 11.0400 1090.00
55 2023-02-23 11.0200 11.1800 10.9400 11.0600 1120.00
56 2023-02-24 11.0400 11.2000 10.9600 11.0800 1150.00
57 2023-02-25 11.0600 11.2200 10.9800 11.1000 1180.00
58 2023-02-26 11.0800 11.2400 11.0000 11.1200 1000.00
59 2023-02-27 11.1000 11.2600 11.0200 11.1400 1030.00
60 2023-02-28 11.1200 11.2800 11.0400 11.1600 1060.00
61 2023-03-01 11.1400 11.3000 11.0600 11.1800 1090.00
62 2023-03-02 11.1600 11.3200 11.0800 11.2000 1120.00
63 2023-03-03 11.1800 11.3400 11.1000 11.2200 1150.00
64 2023-03-04 11.2000 11.3600 11.1200 11.2400 1180.00
65 2023-03-05 11.2200 11.3800 11.1400 11.2600 1000.00
66 2023-03-06 11.2400 11.4000 11.1600 11.2800 1030.00
67 2023-03-07 11.2600 11.4200 11.1800 11.3000 1060.00
68 2023-03-08 11.2800 11.4400 11.2000 11.3200 1090.00
69 2023-03-09 11.3000 11.4600 11.2200 11.3400 1120.00
70 2023-03-10 11.3200 11.4800 11.2400 11.3600 1150.00
71 2023-03-11 11.3400 11.5000 11.2600 11.3800 1180.00
72 2023-03-12 11.3600 11.5200 11.2800 11.4000 1000.00
73 2023-03-13 11.3800 11.5400 11.3000 11.4200 1030.00
74 2023-03-14 11.4000 11.5600 11.3200 11.4400 1060.00
75 2023-03-15 11.4200 11.5800 11.3400 11.4600 1090.00
76 2023-03-16 11.4400 11.6000 11.3600 11.4800 1120.00
77 2023-03-17 11.4600 11.6200 11.3800 11.5000 1150.00
78 2023-03-18 11.4800 11.6400 11.4000 11.5200 1180.00
79 2023-03-19 11.5000 11.6600 11.4200 11.5400 1000.00
80 2023-03-20 11.5200 11.6800 11.4400 11.5600 1030.00
81 2023-03-21 11.5400 11.7000 11.4600 11.5800 1060.00
82 2023-03-22 11.5600 11.7200 11.4800 11.6000 1090.00
83 2023-03-23 11.5800 11.7400 11.5000 11.6200 1120.00
84 2023-03-24 11.6000 11.7600 11.5200 11.6400 1150.00
85 2023-03-25 11.6200 11.7800 11.5400 11.6600 1180.00
86 2023-03-26 11.6400 11.8000 11.5600 11.6800 1000.00
87 2023-03-27 11.6600 11.8200 11.5800 11.7000 1030.00
88 2023-03-28 11.6800 11.8400 11.6000 11.7200 1060.00
89 2023-03-29 11.7000 11.8600 11.6200 11.7400 1090.00
90 2023-03-30 11.7200 11.8800 11.6400 11.7600 1120.00
91 2023-03-31 11.7400 11.9000 11.6600 11.7800 1150.00
92 2023-04-01 11.7600 11.9200 11.6800 11.8000 1180.00
93 2023-04-02 11.7800 11.9400 11.7000 11.8200 1000.00
94 2023-04-03 11.8000 11.9600 11.7200 11.8400 1030.00
95 2023-04-04 11.8200 11.9800 11.7400 11.8600 1060.00
96 2023-04-05 11.8400 12.0000 11.7600 11.8800 1090.00
97 2023-04-06 11.8600 12.0200 11.7800 11.9000 1120.00
98 2023-04-07 11.8800 12.0400 11.8000 11.9200 1150.00
99 2023-04-08 11.9000 12.0600 11.8200 11.9400 1180.00
100 2023-04-09 11.9200 12.0800 11.8400 11.9600 1000.00
101 2023-04-10 11.9400 12.1000 11.8600 11.9800 1030.00
102 2023-04-11 11.9600 12.1200 11.8800 12.0000 1060.00
103 2023-04-12 11.9800 12.1400 11.9000 12.0200 1090.00
104 2023-04-13 12.0000 12.1600 11.9200 12.0400 1120.00
105 2023-04-14 12.0200 12.1800 11.9400 12.0600 1150.00
106 2023-04-15 12.0400 12.2000 11.9600 12.0800 1180.00
107 2023-04-16 12.0600 12.2200 11.9800 12.1000 1000.00
108 2023-04-17 12.0800 12.2400 12.0000 12.1200 1030.00
109 2023-04-18 12.1000 12.2600 12.0200 12.1400 1060.00
110 2023-04-19 12.1200 12.2800 12.0400 12.1600 1090.00
111 2023-04-20 12.1400 12.3000 12.0600 12.1800 1120.00
112 2023-04-21 12.1600 12.3200 12.0800 12.2000 1150.00
113 2023-04-22 12.1800 12.3400 12.1000 12.2200 1180.00
114 2023-04-23 12.2000 12.3600 12.1200 12.2400 1000.00
115 2023-04-24 12.2200 12.3800 12.1400 12.2600 1030.00
116 2023-04-25 12.2400 12.4000 12.1600 12.2800 1060.00
@@ -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
1 trade_date open high low close volume
2 2023-01-01 19.9600 20.1200 19.8800 20.0000 1600.00
3 2023-01-02 19.9900 20.1500 19.9100 20.0300 1630.00
4 2023-01-03 20.0200 20.1800 19.9400 20.0600 1660.00
5 2023-01-04 20.0500 20.2100 19.9700 20.0900 1690.00
6 2023-01-05 20.0800 20.2400 20.0000 20.1200 1720.00
7 2023-01-06 20.1100 20.2700 20.0300 20.1500 1750.00
8 2023-01-07 20.1400 20.3000 20.0600 20.1800 1780.00
9 2023-01-08 20.1700 20.3300 20.0900 20.2100 1600.00
10 2023-01-09 20.2000 20.3600 20.1200 20.2400 1630.00
11 2023-01-10 20.2300 20.3900 20.1500 20.2700 1660.00
12 2023-01-11 20.2600 20.4200 20.1800 20.3000 1690.00
13 2023-01-12 20.2900 20.4500 20.2100 20.3300 1720.00
14 2023-01-13 20.3200 20.4800 20.2400 20.3600 1750.00
15 2023-01-14 20.3500 20.5100 20.2700 20.3900 1780.00
16 2023-01-15 20.3800 20.5400 20.3000 20.4200 1600.00
17 2023-01-16 20.4100 20.5700 20.3300 20.4500 1630.00
18 2023-01-17 20.4400 20.6000 20.3600 20.4800 1660.00
19 2023-01-18 20.4700 20.6300 20.3900 20.5100 1690.00
20 2023-01-19 20.5000 20.6600 20.4200 20.5400 1720.00
21 2023-01-20 20.5300 20.6900 20.4500 20.5700 1750.00
22 2023-01-21 20.5600 20.7200 20.4800 20.6000 1780.00
23 2023-01-22 20.5900 20.7500 20.5100 20.6300 1600.00
24 2023-01-23 20.6200 20.7800 20.5400 20.6600 1630.00
25 2023-01-24 20.6500 20.8100 20.5700 20.6900 1660.00
26 2023-01-25 20.6800 20.8400 20.6000 20.7200 1690.00
27 2023-01-26 20.7100 20.8700 20.6300 20.7500 1720.00
28 2023-01-27 20.7400 20.9000 20.6600 20.7800 1750.00
29 2023-01-28 20.7700 20.9300 20.6900 20.8100 1780.00
30 2023-01-29 20.8000 20.9600 20.7200 20.8400 1600.00
31 2023-01-30 20.8300 20.9900 20.7500 20.8700 1630.00
32 2023-01-31 20.8600 21.0200 20.7800 20.9000 1660.00
33 2023-02-01 20.8900 21.0500 20.8100 20.9300 1690.00
34 2023-02-02 20.9200 21.0800 20.8400 20.9600 1720.00
35 2023-02-03 20.9500 21.1100 20.8700 20.9900 1750.00
36 2023-02-04 20.9800 21.1400 20.9000 21.0200 1780.00
37 2023-02-05 21.0100 21.1700 20.9300 21.0500 1600.00
38 2023-02-06 21.0400 21.2000 20.9600 21.0800 1630.00
39 2023-02-07 21.0700 21.2300 20.9900 21.1100 1660.00
40 2023-02-08 21.1000 21.2600 21.0200 21.1400 1690.00
41 2023-02-09 21.1300 21.2900 21.0500 21.1700 1720.00
42 2023-02-10 21.1600 21.3200 21.0800 21.2000 1750.00
43 2023-02-11 21.1900 21.3500 21.1100 21.2300 1780.00
44 2023-02-12 21.2200 21.3800 21.1400 21.2600 1600.00
45 2023-02-13 21.2500 21.4100 21.1700 21.2900 1630.00
46 2023-02-14 21.2800 21.4400 21.2000 21.3200 1660.00
47 2023-02-15 21.3100 21.4700 21.2300 21.3500 1690.00
48 2023-02-16 21.3400 21.5000 21.2600 21.3800 1720.00
49 2023-02-17 21.3700 21.5300 21.2900 21.4100 1750.00
50 2023-02-18 21.4000 21.5600 21.3200 21.4400 1780.00
51 2023-02-19 21.4300 21.5900 21.3500 21.4700 1600.00
52 2023-02-20 21.4600 21.6200 21.3800 21.5000 1630.00
53 2023-02-21 21.4900 21.6500 21.4100 21.5300 1660.00
54 2023-02-22 21.5200 21.6800 21.4400 21.5600 1690.00
55 2023-02-23 21.5500 21.7100 21.4700 21.5900 1720.00
56 2023-02-24 21.5800 21.7400 21.5000 21.6200 1750.00
57 2023-02-25 21.6100 21.7700 21.5300 21.6500 1780.00
58 2023-02-26 21.6400 21.8000 21.5600 21.6800 1600.00
59 2023-02-27 21.6700 21.8300 21.5900 21.7100 1630.00
60 2023-02-28 21.7000 21.8600 21.6200 21.7400 1660.00
61 2023-03-01 21.7300 21.8900 21.6500 21.7700 1690.00
62 2023-03-02 21.7600 21.9200 21.6800 21.8000 1720.00
63 2023-03-03 21.7900 21.9500 21.7100 21.8300 1750.00
64 2023-03-04 21.8200 21.9800 21.7400 21.8600 1780.00
65 2023-03-05 21.8500 22.0100 21.7700 21.8900 1600.00
66 2023-03-06 21.8800 22.0400 21.8000 21.9200 1630.00
67 2023-03-07 21.9100 22.0700 21.8300 21.9500 1660.00
68 2023-03-08 21.9400 22.1000 21.8600 21.9800 1690.00
69 2023-03-09 21.9700 22.1300 21.8900 22.0100 1720.00
70 2023-03-10 22.0000 22.1600 21.9200 22.0400 1750.00
71 2023-03-11 22.0300 22.1900 21.9500 22.0700 1780.00
72 2023-03-12 22.0600 22.2200 21.9800 22.1000 1600.00
73 2023-03-13 22.0900 22.2500 22.0100 22.1300 1630.00
74 2023-03-14 22.1200 22.2800 22.0400 22.1600 1660.00
75 2023-03-15 22.1500 22.3100 22.0700 22.1900 1690.00
76 2023-03-16 22.1800 22.3400 22.1000 22.2200 1720.00
77 2023-03-17 22.2100 22.3700 22.1300 22.2500 1750.00
78 2023-03-18 22.2400 22.4000 22.1600 22.2800 1780.00
79 2023-03-19 22.2700 22.4300 22.1900 22.3100 1600.00
80 2023-03-20 22.3000 22.4600 22.2200 22.3400 1630.00
81 2023-03-21 22.3300 22.4900 22.2500 22.3700 1660.00
82 2023-03-22 22.3600 22.5200 22.2800 22.4000 1690.00
83 2023-03-23 22.3900 22.5500 22.3100 22.4300 1720.00
84 2023-03-24 22.4200 22.5800 22.3400 22.4600 1750.00
85 2023-03-25 22.4500 22.6100 22.3700 22.4900 1780.00
86 2023-03-26 22.4800 22.6400 22.4000 22.5200 1600.00
87 2023-03-27 22.5100 22.6700 22.4300 22.5500 1630.00
88 2023-03-28 22.5400 22.7000 22.4600 22.5800 1660.00
89 2023-03-29 22.5700 22.7300 22.4900 22.6100 1690.00
90 2023-03-30 22.6000 22.7600 22.5200 22.6400 1720.00
91 2023-03-31 22.6300 22.7900 22.5500 22.6700 1750.00
92 2023-04-01 22.6600 22.8200 22.5800 22.7000 1780.00
93 2023-04-02 22.6900 22.8500 22.6100 22.7300 1600.00
94 2023-04-03 22.7200 22.8800 22.6400 22.7600 1630.00
95 2023-04-04 22.7500 22.9100 22.6700 22.7900 1660.00
96 2023-04-05 22.7800 22.9400 22.7000 22.8200 1690.00
97 2023-04-06 22.8100 22.9700 22.7300 22.8500 1720.00
98 2023-04-07 22.8400 23.0000 22.7600 22.8800 1750.00
99 2023-04-08 22.8700 23.0300 22.7900 22.9100 1780.00
100 2023-04-09 22.9000 23.0600 22.8200 22.9400 1600.00
101 2023-04-10 22.9300 23.0900 22.8500 22.9700 1630.00
102 2023-04-11 22.9600 23.1200 22.8800 23.0000 1660.00
103 2023-04-12 22.9900 23.1500 22.9100 23.0300 1690.00
104 2023-04-13 23.0200 23.1800 22.9400 23.0600 1720.00
105 2023-04-14 23.0500 23.2100 22.9700 23.0900 1750.00
106 2023-04-15 23.0800 23.2400 23.0000 23.1200 1780.00
107 2023-04-16 23.1100 23.2700 23.0300 23.1500 1600.00
108 2023-04-17 23.1400 23.3000 23.0600 23.1800 1630.00
109 2023-04-18 23.1700 23.3300 23.0900 23.2100 1660.00
110 2023-04-19 23.2000 23.3600 23.1200 23.2400 1690.00
111 2023-04-20 23.2300 23.3900 23.1500 23.2700 1720.00
112 2023-04-21 23.2600 23.4200 23.1800 23.3000 1750.00
113 2023-04-22 23.2900 23.4500 23.2100 23.3300 1780.00
114 2023-04-23 23.3200 23.4800 23.2400 23.3600 1600.00
115 2023-04-24 23.3500 23.5100 23.2700 23.3900 1630.00
116 2023-04-25 23.3800 23.5400 23.3000 23.4200 1660.00
@@ -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()
@@ -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"]
+207
View File
@@ -0,0 +1,207 @@
"""HTTP contracts for triggering and querying persisted selection runs."""
from datetime import date
from decimal import Decimal
from fastapi.testclient import TestClient
from zhixing_server.bootstrap.app import create_app
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRun,
SelectionRunInProgress,
SelectionStock,
)
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
SelectionMarketDataNotReady,
)
from zhixing_server.modules.selection.presentation.http import get_selection_service
TARGET = date(2026, 8, 8)
class FakeSelectionService:
def __init__(self, run: SelectionRun | None = None) -> None:
self.run = run
self.executed = False
self.mode = "ok"
def prepare(
self,
strategy: str,
target_trade_date: date,
*,
rerun: bool,
) -> PreparedSelectionRun:
if self.mode == "in_progress":
raise SelectionRunInProgress("already running")
if self.mode == "rerun_required":
raise SelectionRerunRequired("confirm rerun")
if self.mode == "market_data_not_ready":
raise SelectionMarketDataNotReady("market data is not ready")
run = self.run or _run("run-http", "running")
return PreparedSelectionRun(
run=run,
source=SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=target_trade_date,
target_count=1,
valid_count=1,
coverage=Decimal("1"),
stocks=(SelectionStock("000001.SZ", "平安银行"),),
),
)
def execute(self, prepared: PreparedSelectionRun) -> None:
self.executed = True
def get_run(self, run_id: str) -> SelectionRun | None:
return self.run if self.run and self.run.id == run_id else None
def get_latest(
self,
strategy: str,
target_trade_date: date | None = None,
) -> SelectionRun | None:
if self.run is None:
return None
if target_trade_date is not None and self.run.target_trade_date != target_trade_date:
return None
return self.run
def _run(run_id: str, status: str) -> SelectionRun:
signal = SelectionSignal(
ts_code="000001.SZ",
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category.ORIGINAL_B1,
close=10.5,
details={"j": 12.0},
)
from zhixing_server.modules.selection.domain.runs import SelectionRunItem
return SelectionRun(
id=run_id,
strategy="zhixing_b1",
target_trade_date=TARGET,
market_sync_batch_id="market-run-1",
status=status, # type: ignore[arg-type]
target_count=1,
eligible_count=1,
evaluated_count=1,
selected_stock_count=1,
signal_count=1,
failed_count=0,
coverage=Decimal("1"),
items=(
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
),
signals=(signal,),
)
def _client(service: FakeSelectionService) -> TestClient:
app = create_app()
app.dependency_overrides[get_selection_service] = lambda: service
return TestClient(app)
def test_trigger_returns_accepted_run_and_schedules_execution() -> None:
service = FakeSelectionService()
response = _client(service).post(
"/api/v1/selection/runs",
json={
"strategy": "zhixing_b1",
"target_trade_date": "2026-08-08",
"rerun": False,
},
)
assert response.status_code == 202
assert response.json()["status"] == "running"
assert response.json()["target_trade_date"] == "2026-08-08"
assert service.executed is True
def test_trigger_requires_explicit_rerun_confirmation() -> None:
service = FakeSelectionService()
service.mode = "rerun_required"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "rerun_confirmation_required"
def test_trigger_rejects_a_duplicate_running_request() -> None:
service = FakeSelectionService()
service.mode = "in_progress"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 409
assert response.json()["detail"]["code"] == "run_in_progress"
def test_trigger_rejects_unqualified_market_data() -> None:
service = FakeSelectionService()
service.mode = "market_data_not_ready"
response = _client(service).post(
"/api/v1/selection/runs",
json={"strategy": "zhixing_b1", "target_trade_date": "2026-08-08"},
)
assert response.status_code == 422
assert response.json()["detail"]["code"] == "market_data_not_ready"
def test_query_returns_no_data_without_fabricating_a_result() -> None:
response = _client(FakeSelectionService()).get(
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
)
assert response.status_code == 200
assert response.json()["status"] == "no_data"
assert response.json()["signals"] == []
def test_query_returns_persisted_signal_details() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/results?strategy=zhixing_b1&target_trade_date=2026-08-08"
)
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "run-http"
assert body["signal_count"] == 1
assert body["signals"][0]["category"] == "zhixing_b1_original_b1"
assert body["signals"][0]["details"] == {"j": 12.0}
def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http"
)
assert response.status_code == 200
assert response.json()["status"] == "success"
assert response.json()["signals"][0]["category"] == "zhixing_b1_original_b1"
@@ -190,8 +190,7 @@ def test_sync_logs_progress_for_initialize_and_daily_update(
assert initialize.status == "success"
assert any("stage=daily_basic" in message for message in initialize_messages)
assert any(
"stage=bar" in message and "progress=1/1" in message
for message in initialize_messages
"stage=bar" in message and "progress=1/1" in message for message in initialize_messages
)
caplog.clear()
@@ -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"
@@ -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)
@@ -0,0 +1,160 @@
"""PostgreSQL reader contract tests using a fake connection."""
from datetime import date
from decimal import Decimal
from typing import cast
import psycopg
import pytest
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
SelectionMarketDataNotReady,
)
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)
class SourceConnection:
def __init__(self, source_row: tuple[object, ...] | None) -> None:
self.source_row = source_row
self.queries: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "SourceConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "SourceResult":
self.queries.append((query, parameters))
if "FROM market_sync_batch" in query:
return SourceResult(row=self.source_row)
return SourceResult(rows=[("000001.SZ", "平安银行")])
class SourceResult:
def __init__(
self,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_reader_loads_only_eligible_stocks_from_finished_market_batch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = SourceConnection(("market-run-1", 2, 2, Decimal("1")))
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
source = PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
assert source.market_sync_batch_id == "market-run-1"
assert source.target_count == 2
assert source.coverage == Decimal("1")
assert source.stocks[0].ts_code == "000001.SZ"
assert "strategy_eligible = true" in connection.queries[0][0]
assert "source_adj = 'qfq'" in connection.queries[1][0]
def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.MonkeyPatch) -> None:
connection = SourceConnection(None)
def connect(database_url: str) -> SourceConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
with pytest.raises(SelectionMarketDataNotReady):
PostgresMarketDataReader("postgresql://test").load_execution_source(
"zhixing_b1",
date(2026, 8, 8),
)
@@ -0,0 +1,251 @@
"""Persistence transaction tests for selection runs."""
from datetime import date
from decimal import Decimal
import psycopg
import pytest
from psycopg.types.json import Jsonb
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
SelectionRunInProgress,
SelectionRunItem,
SelectionStock,
)
from zhixing_server.modules.selection.domain.zhixing_b1 import ZHIXING_B1_SIGNAL_ORDER
from zhixing_server.modules.selection.infrastructure.postgres_runs import (
PostgresSelectionRunRepository,
)
TARGET = date(2026, 8, 8)
class FakeResult:
def __init__(self, row: tuple[object, ...] | None = None) -> None:
self.row = row
def fetchone(self) -> tuple[object, ...] | None:
return self.row
class FakeTransaction:
def __enter__(self) -> "FakeTransaction":
return self
def __exit__(self, *args: object) -> None:
return None
class FakeConnection:
def __init__(self, existing: tuple[object, ...] | None) -> None:
self.existing = existing
self.statements: list[tuple[str, tuple[object, ...]]] = []
def __enter__(self) -> "FakeConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def transaction(self) -> FakeTransaction:
return FakeTransaction()
def execute(self, query: str, parameters: tuple[object, ...]) -> FakeResult:
self.statements.append((query, parameters))
if "SELECT id, status" in query:
return FakeResult(self.existing)
return FakeResult()
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=1,
valid_count=1,
coverage=Decimal("1"),
stocks=(SelectionStock("000001.SZ", "平安银行"),),
)
def _repository(
monkeypatch: pytest.MonkeyPatch,
connection: FakeConnection,
) -> PostgresSelectionRunRepository:
def connect(database_url: str) -> FakeConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
return PostgresSelectionRunRepository("postgresql://test")
def test_prepare_claims_new_business_key(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=False,
)
assert run.status == "running"
assert run.target_trade_date == TARGET
assert any("INSERT INTO selection_run" in query for query, _ in connection.statements)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_requires_confirmation_for_terminal_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "success"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRerunRequired):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=False)
assert not any("DELETE FROM selection_run" in query for query, _ in connection.statements)
def test_prepare_rejects_duplicate_running_run(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "running"))
repository = _repository(monkeypatch, connection)
with pytest.raises(SelectionRunInProgress):
repository.prepare_run("zhixing_b1", TARGET, _source(), rerun=True)
def test_prepare_rerun_deletes_old_result_before_insert(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(("old-run", "failed"))
run = _repository(monkeypatch, connection).prepare_run(
"zhixing_b1",
TARGET,
_source(),
rerun=True,
)
statements = [query for query, _ in connection.statements]
assert "DELETE FROM selection_run WHERE strategy = %s AND target_trade_date = %s" in statements
assert run.id != "old-run"
def test_record_item_persists_independent_signals_as_jsonb(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(None)
repository = _repository(monkeypatch, connection)
signal = SelectionSignal(
ts_code="000001.SZ",
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category.ORIGINAL_B1,
close=10.5,
details={"j": 12.0},
)
repository.record_item(
"run-1",
SelectionRunItem(
ts_code="000001.SZ",
name="平安银行",
status="selected",
signal_count=1,
signals=(signal,),
),
)
signal_insert = next(
parameters
for query, parameters in connection.statements
if "INSERT INTO selection_signal" in query
)
assert isinstance(signal_insert[-1], Jsonb)
class LoadConnection:
def __enter__(self) -> "LoadConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
if "FROM selection_run\n" in query:
return LoadResult(
row=(
"run-1",
"zhixing_b1",
TARGET,
"market-run-1",
"success",
1,
1,
1,
1,
2,
0,
Decimal("1"),
None,
None,
None,
None,
)
)
if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
return LoadResult(
rows=[
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[-1].value,
Decimal("10.5"),
{},
),
(
"000001.SZ",
"平安银行",
TARGET,
"zhixing_b1",
ZHIXING_B1_SIGNAL_ORDER[0].value,
Decimal("10.5"),
{},
),
]
)
class LoadResult:
def __init__(
self,
*,
row: tuple[object, ...] | None = None,
rows: list[tuple[object, ...]] | None = None,
) -> None:
self.row = row
self.rows = rows or []
def fetchone(self) -> tuple[object, ...] | None:
return self.row
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_get_run_orders_signals_by_formula_priority(monkeypatch: pytest.MonkeyPatch) -> None:
connection = LoadConnection()
def connect(database_url: str) -> LoadConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run("run-1")
assert run is not None
assert [signal.category for signal in run.signals] == [
ZHIXING_B1_SIGNAL_ORDER[0],
ZHIXING_B1_SIGNAL_ORDER[-1],
]
@@ -0,0 +1,228 @@
"""Application tests for persisted whole-universe selection runs."""
from datetime import date
from decimal import Decimal
from typing import Literal
from zhixing_server.modules.selection.application.run import (
PreparedSelectionRun,
RunZhixingB1,
)
from zhixing_server.modules.selection.domain.models import (
SelectionEvaluation,
SelectionSignal,
StockHistory,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRun,
SelectionRunItem,
SelectionRunStatus,
SelectionStock,
)
TARGET = date(2026, 8, 8)
class FakeReader:
def __init__(self, source: SelectionExecutionSource) -> None:
self.source = source
def load_execution_source(
self,
strategy: str,
target_trade_date: date,
) -> SelectionExecutionSource:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
return self.source
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
raise AssertionError("the fake evaluator should be used")
class FakeStore:
def __init__(self) -> None:
self.items: list[SelectionRunItem] = []
self.finished: tuple[str, SelectionRunStatus, dict[str, object]] | None = None
def prepare_run(
self,
strategy: Literal["zhixing_b1"],
target_trade_date: date,
source: SelectionExecutionSource,
*,
rerun: bool,
) -> SelectionRun:
assert strategy == "zhixing_b1"
assert target_trade_date == TARGET
assert rerun is False
return SelectionRun(
id="run-1",
strategy="zhixing_b1",
target_trade_date=TARGET,
market_sync_batch_id=source.market_sync_batch_id,
status="running",
target_count=source.target_count,
eligible_count=len(source.stocks),
evaluated_count=0,
selected_stock_count=0,
signal_count=0,
failed_count=0,
coverage=source.coverage,
)
def record_item(self, run_id: str, item: SelectionRunItem) -> None:
assert run_id == "run-1"
self.items.append(item)
def finish_run(
self,
run_id: str,
status: SelectionRunStatus,
*,
evaluated_count: int,
selected_stock_count: int,
signal_count: int,
failed_count: int,
error_type: str | None = None,
error_message: str | None = None,
) -> None:
kwargs: dict[str, object] = {
"evaluated_count": evaluated_count,
"selected_stock_count": selected_stock_count,
"signal_count": signal_count,
"failed_count": failed_count,
}
if error_type is not None:
kwargs["error_type"] = error_type
if error_message is not None:
kwargs["error_message"] = error_message
self.finished = (run_id, status, kwargs)
def get_run(self, run_id: str):
return None
def get_latest_run(self, strategy: str, target_trade_date: date | None = None):
return None
class FakeEvaluator:
def __init__(self, results: dict[str, SelectionEvaluation]) -> None:
self.results = results
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
return self.results[ts_code]
class RaisingEvaluator:
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
if ts_code == "600000.SH":
raise RuntimeError("temporary evaluator failure")
return SelectionEvaluation(ts_code, target_trade_date, "no_signal")
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
target_trade_date=TARGET,
target_count=2,
valid_count=2,
coverage=Decimal("1"),
stocks=(
SelectionStock("000001.SZ", "平安银行"),
SelectionStock("600000.SH", "浦发银行"),
),
)
def _signal(ts_code: str, category: str) -> SelectionSignal:
from zhixing_server.modules.selection.domain.models import ZhixingB1Category
return SelectionSignal(
ts_code=ts_code,
name="平安银行",
target_trade_date=TARGET,
strategy="zhixing_b1",
category=ZhixingB1Category(category),
close=10.5,
details={"j": 12.0},
)
def test_prepare_captures_market_source_and_execute_persists_all_categories() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(
_signal("000001.SZ", "zhixing_b1_original_b1"),
_signal("000001.SZ", "zhixing_b1_pullback_white"),
),
),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"no_signal",
reason="no category matched",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
prepared = service.prepare("zhixing_b1", TARGET, rerun=False)
assert isinstance(prepared, PreparedSelectionRun)
service.execute(prepared)
assert [item.status for item in store.items] == ["selected", "no_signal"]
assert store.items[0].signal_count == 2
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2] == {
"evaluated_count": 2,
"selected_stock_count": 1,
"signal_count": 2,
"failed_count": 0,
}
def test_execute_marks_partial_success_when_one_stock_lacks_history() -> None:
source = _source()
store = FakeStore()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation("000001.SZ", TARGET, "no_signal"),
"600000.SH": SelectionEvaluation(
"600000.SH",
TARGET,
"insufficient_history",
reason="warm-up data is incomplete",
),
}
)
service = RunZhixingB1(FakeReader(source), store, evaluator)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["failed_count"] == 1
def test_execute_isolates_unexpected_single_stock_failure() -> None:
source = _source()
store = FakeStore()
service = RunZhixingB1(FakeReader(source), store, RaisingEvaluator())
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert [item.status for item in store.items] == ["no_signal", "data_error"]
assert store.items[1].reason == "temporary evaluator failure"
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "partial_success")
assert store.finished[2]["evaluated_count"] == 2
assert store.finished[2]["failed_count"] == 1
@@ -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
+18
View File
@@ -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" },
+1 -1
View File
@@ -2,4 +2,4 @@ coverage
dist
node_modules
pnpm-lock.yaml
.pnpm-store
@@ -34,7 +34,7 @@ export function FailedStocksDialog({
以下股票尚未在目标交易日同时具备完整的行情和估值数据。
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-80 pr-3">
<ScrollArea className="h-64 pr-3">
<div className="space-y-3">
{failures.map((failure) => (
<div
@@ -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 (
<div className="flex min-h-screen bg-muted/30 text-foreground">
<aside className="flex w-20 shrink-0 flex-col border-r bg-card px-3 py-5 md:w-64 md:px-5">
@@ -43,16 +57,34 @@ export function HomeShell({ children }: HomeShellProps) {
<nav aria-label="主导航" className="space-y-1">
{navigation.map((item) => {
const Icon = item.icon
const active = item.section === activeSection
const content = (
<>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
</>
)
if (item.path) {
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
render={<a href={item.path} />}
variant={active ? "secondary" : "ghost"}
aria-current={active ? "page" : undefined}
>
{content}
</Button>
)
}
return (
<Button
key={item.label}
className="w-full justify-start gap-3 px-2 md:px-3"
disabled={!item.active}
variant={item.active ? "secondary" : "ghost"}
aria-current={item.active ? "page" : undefined}
disabled
variant="ghost"
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden md:inline">{item.label}</span>
{content}
</Button>
)
})}
@@ -61,7 +61,10 @@ describe("HomePage", () => {
expect(screen.getByText("33.33%")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "行情数据" })).toBeDisabled()
expect(screen.getByRole("button", { name: "同步任务" })).toBeDisabled()
expect(screen.getByRole("button", { name: "选股策略" })).toBeDisabled()
expect(screen.getByRole("link", { name: "选股策略" })).toHaveAttribute(
"href",
"/selection",
)
})
it("opens and closes the accessible failed-stock dialog", () => {
@@ -22,7 +22,7 @@ export function HomePage() {
const update = overview.data?.latest_update
return (
<HomeShell>
<HomeShell activeSection="home">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">欢迎回到知行量化</p>
@@ -0,0 +1,39 @@
import { requestJson } from "@/shared/api/request-json"
import type {
SelectionResults,
SelectionRunAccepted,
SelectionRunRequest,
SelectionStrategy,
} from "./selection.types"
export function getSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
signal?: AbortSignal,
) {
const params = new URLSearchParams({ strategy })
if (targetTradeDate) params.set("target_trade_date", targetTradeDate)
return requestJson<SelectionResults>(
`/api/v1/selection/results?${params.toString()}`,
{ signal },
)
}
export function getSelectionRun(runId: string, signal?: AbortSignal) {
return requestJson<SelectionResults>(`/api/v1/selection/runs/${runId}`, {
signal,
})
}
export function triggerSelectionRun(
request: SelectionRunRequest,
signal?: AbortSignal,
) {
return requestJson<SelectionRunAccepted>("/api/v1/selection/runs", {
body: JSON.stringify(request),
headers: { "Content-Type": "application/json" },
method: "POST",
signal,
})
}
@@ -0,0 +1,79 @@
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
} from "@tanstack/react-query"
import { useEffect } from "react"
import {
getSelectionResults,
getSelectionRun,
triggerSelectionRun,
} from "./selection.api"
import type { SelectionRunRequest, SelectionStrategy } from "./selection.types"
export const selectionResultsQueryKey = (
strategy: SelectionStrategy,
targetTradeDate?: string,
) => ["selection", "results", strategy, targetTradeDate] as const
export const selectionRunQueryKey = (runId: string) =>
["selection", "runs", runId] as const
export function useSelectionResults(
strategy: SelectionStrategy,
targetTradeDate?: string,
) {
return useQuery({
queryFn: ({ signal }) =>
getSelectionResults(strategy, targetTradeDate, signal),
queryKey: selectionResultsQueryKey(strategy, targetTradeDate),
})
}
export function useSelectionRun(runId: string | null) {
const queryClient = useQueryClient()
const query = useQuery({
enabled: Boolean(runId),
queryFn: ({ signal }) => getSelectionRun(runId ?? "", signal),
queryKey: selectionRunQueryKey(runId ?? "none"),
refetchInterval: (query) =>
query.state.data?.status === "running" ? 1500 : false,
})
useEffect(() => {
const result = query.data
if (!runId || !result || result.status === "running") return
void invalidateSelectionResults(queryClient, result.strategy)
}, [query.data, queryClient, runId])
return query
}
export function useTriggerSelectionRun() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({
request,
signal,
}: {
request: SelectionRunRequest
signal?: AbortSignal
}) => triggerSelectionRun(request, signal),
onSuccess: (_accepted, { request }) => {
void invalidateSelectionResults(queryClient, request.strategy)
},
})
}
export function invalidateSelectionResults(
queryClient: QueryClient,
strategy: SelectionStrategy,
) {
return queryClient.invalidateQueries({
queryKey: ["selection", "results", strategy],
})
}
@@ -0,0 +1,55 @@
export type SelectionStrategy = "zhixing_b1"
export type SelectionRunStatus =
"no_data" | "running" | "success" | "partial_success" | "failed"
export interface SelectionRunRequest {
strategy: SelectionStrategy
target_trade_date: string
rerun: boolean
}
export interface SelectionRunAccepted {
run_id: string
strategy: SelectionStrategy
target_trade_date: string
status: "running"
}
export interface SelectionSignal {
ts_code: string
name: string
target_trade_date: string
strategy: SelectionStrategy
category: string
close: number
details: Record<string, number | string | null>
}
export interface SelectionFailure {
ts_code: string
name: string
status: string
reason: string | null
}
export interface SelectionResults {
strategy: SelectionStrategy
target_trade_date: string | null
run_id: string | null
market_sync_batch_id: string | null
status: SelectionRunStatus
target_count: number
eligible_count: number
evaluated_count: number
selected_stock_count: number
signal_count: number
failed_count: number
coverage: number
error_type: string | null
error_message: string | null
created_at: string | null
finished_at: string | null
failures: SelectionFailure[]
signals: SelectionSignal[]
}
@@ -0,0 +1,216 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { SelectionResults } from "../api/selection.types"
import { SelectionResultsPage } from "./selection-results-page"
const useSelectionResults = vi.fn()
const useSelectionRun = vi.fn()
const useTriggerSelectionRun = vi.fn()
const mutate = vi.fn()
const reset = vi.fn()
vi.mock("@/features/selection/api/selection.query", () => ({
useSelectionResults: (...args: unknown[]) => useSelectionResults(...args),
useSelectionRun: (...args: unknown[]) => useSelectionRun(...args),
useTriggerSelectionRun: () => useTriggerSelectionRun(),
}))
const selectedResult: SelectionResults = {
created_at: "2026-08-08T09:00:00+08:00",
coverage: 1,
eligible_count: 2,
evaluated_count: 2,
error_message: null,
error_type: null,
failed_count: 0,
failures: [],
finished_at: "2026-08-08T09:02:00+08:00",
market_sync_batch_id: "market-run-1",
run_id: "run-1",
selected_stock_count: 1,
signal_count: 2,
signals: [
{
category: "zhixing_b1_original_b1",
close: 10.5,
details: { j: 12, rsi: 20 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
{
category: "zhixing_b1_pullback_white",
close: 10.5,
details: { j: 12 },
name: "平安银行",
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
ts_code: "000001.SZ",
},
],
status: "success",
strategy: "zhixing_b1",
target_count: 2,
target_trade_date: "2026-08-08",
}
describe("SelectionResultsPage", () => {
beforeEach(() => {
useSelectionResults.mockReturnValue({
data: selectedResult,
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: undefined,
isError: false,
isPending: false,
})
useTriggerSelectionRun.mockReturnValue({
isError: false,
isPending: false,
mutate,
reset,
})
mutate.mockReset()
reset.mockReset()
})
it("renders persisted results and keeps multiple categories visible", () => {
render(<SelectionResultsPage />)
expect(
screen.getByRole("heading", { name: "选股策略" }),
).toBeInTheDocument()
expect(screen.getByText("知行 B1 执行结果")).toBeInTheDocument()
expect(screen.getByText("原始 B1")).toBeInTheDocument()
expect(screen.getByText("回踩白线 B")).toBeInTheDocument()
expect(screen.getAllByText("平安银行")).toHaveLength(2)
})
it("requires confirmation before a rerun and does not trigger on cancel", () => {
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
const dialog = screen.getByRole("dialog", { name: "确认重新执行策略?" })
expect(within(dialog).getByText(/清空 2026-08-08/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "取消" }))
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
expect(mutate).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "重新执行" }))
fireEvent.click(
within(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).getByRole("button", { name: "确认重新执行" }),
)
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: true,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("uses the same confirmation flow for a failed retry", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
failed_count: 1,
failures: [
{
name: "平安银行",
reason: "目标日数据读取失败",
status: "data_error",
ts_code: "000001.SZ",
},
],
selected_stock_count: 0,
signal_count: 0,
signals: [],
status: "failed",
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
fireEvent.click(screen.getByRole("button", { name: "重试执行" }))
expect(
screen.getByRole("dialog", { name: "确认重新执行策略?" }),
).toBeInTheDocument()
})
it("renders a no-data state and triggers an initial run after date selection", () => {
useSelectionResults.mockReturnValue({
data: {
...selectedResult,
run_id: null,
signals: [],
status: "no_data",
target_trade_date: null,
},
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("暂无策略结果")).toBeInTheDocument()
fireEvent.change(screen.getByLabelText("目标交易日"), {
target: { value: "2026-08-08" },
})
fireEvent.click(screen.getByRole("button", { name: "执行策略" }))
expect(mutate).toHaveBeenCalledWith(
{
request: {
rerun: false,
strategy: "zhixing_b1",
target_trade_date: "2026-08-08",
},
},
expect.any(Object),
)
})
it("continues polling a persisted running run after page reload", () => {
useSelectionResults.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
useSelectionRun.mockReturnValue({
data: { ...selectedResult, signals: [], status: "running" },
isError: false,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("策略执行中")).toBeInTheDocument()
expect(screen.getByRole("button", { name: "执行中" })).toBeDisabled()
expect(useSelectionRun).toHaveBeenCalledWith("run-1")
})
it("renders query failures explicitly", () => {
useSelectionResults.mockReturnValue({
data: undefined,
isError: true,
isPending: false,
})
render(<SelectionResultsPage />)
expect(screen.getByText("结果查询失败")).toBeInTheDocument()
})
})
@@ -0,0 +1,516 @@
import {
AlertTriangle,
CheckCircle2,
Play,
RefreshCw,
Search,
} from "lucide-react"
import { useState } from "react"
import {
useSelectionResults,
useSelectionRun,
useTriggerSelectionRun,
} from "@/features/selection/api/selection.query"
import type {
SelectionResults,
SelectionRunStatus,
SelectionSignal,
} from "@/features/selection/api/selection.types"
import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog"
import { Input } from "@/shared/ui/input"
import { Skeleton } from "@/shared/ui/skeleton"
import { HomeShell } from "@/features/home/components/home-shell"
const STRATEGY = "zhixing_b1" as const
const statusPresentation: Record<
Exclude<SelectionRunStatus, "no_data" | "running">,
{ label: string; variant: "default" | "destructive" | "outline" }
> = {
failed: { label: "执行失败", variant: "destructive" },
partial_success: { label: "部分成功", variant: "outline" },
success: { label: "执行完成", variant: "default" },
}
const categoryLabels: Record<string, string> = {
zhixing_b1_extreme_volume: "超卖超缩量 B",
zhixing_b1_original_b1: "原始 B1",
zhixing_b1_oversold_turn: "超卖缩量拐头 B",
zhixing_b1_oversold_volume: "超卖缩量 B",
zhixing_b1_pullback_super: "回踩超级 B",
zhixing_b1_pullback_white: "回踩白线 B",
zhixing_b1_pullback_yellow: "回踩黄线 B",
}
export function SelectionResultsPage() {
const [targetTradeDate, setTargetTradeDate] = useState<string | null>(null)
const [activeRunId, setActiveRunId] = useState<string | null>(null)
const [rerunDialogOpen, setRerunDialogOpen] = useState(false)
const results = useSelectionResults(STRATEGY, targetTradeDate || undefined)
const persistedRunningRunId =
results.data?.status === "running" ? results.data.run_id : null
const polledRunId = activeRunId ?? persistedRunningRunId
const activeRun = useSelectionRun(polledRunId)
const trigger = useTriggerSelectionRun()
const selectedTargetTradeDate =
targetTradeDate ?? results.data?.target_trade_date ?? ""
const selectedDateResult =
targetTradeDate &&
results.data?.target_trade_date !== selectedTargetTradeDate
? undefined
: results.data
const displayedResult = activeRun.data ?? selectedDateResult
const isRunning =
trigger.isPending ||
results.data?.status === "running" ||
activeRun.data?.status === "running" ||
(polledRunId !== null && activeRun.isPending)
const hasExistingResult = Boolean(
displayedResult?.run_id && displayedResult.status !== "no_data",
)
const hasQueryError =
activeRunId !== null ? activeRun.isError : results.isError
function handleExecute() {
if (!selectedTargetTradeDate || isRunning) return
if (hasExistingResult) {
setRerunDialogOpen(true)
return
}
startExecution(false)
}
function startExecution(rerun: boolean) {
if (!selectedTargetTradeDate) return
setRerunDialogOpen(false)
setActiveRunId(null)
trigger.mutate(
{
request: {
strategy: STRATEGY,
target_trade_date: selectedTargetTradeDate,
rerun,
},
},
{ onSuccess: (accepted) => setActiveRunId(accepted.run_id) },
)
}
function handleDateChange(value: string) {
setTargetTradeDate(value)
setActiveRunId(null)
trigger.reset()
}
return (
<HomeShell activeSection="selection">
<div className="space-y-8">
<section className="space-y-2">
<p className="text-sm font-medium text-primary">研究策略执行</p>
<h1 className="text-3xl font-semibold tracking-tight">选股策略</h1>
<p className="max-w-2xl text-muted-foreground">
查看知行 B1 在指定交易日的持久化执行结果,或重新执行当天策略。
</p>
</section>
<Card>
<CardHeader>
<CardTitle className="text-xl">执行条件</CardTitle>
<CardDescription>
策略只使用已完成且达到选股资格的市场数据同步批次。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 md:flex-row md:items-end">
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="target-trade-date"
>
目标交易日
<Input
id="target-trade-date"
onChange={(event) => handleDateChange(event.target.value)}
type="date"
value={selectedTargetTradeDate}
/>
</label>
<label
className="flex-1 space-y-2 text-sm font-medium"
htmlFor="strategy-name"
>
策略
<select
className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
disabled
id="strategy-name"
value={STRATEGY}
>
<option value={STRATEGY}>知行 B1</option>
</select>
</label>
<Button
disabled={!selectedTargetTradeDate || isRunning}
onClick={handleExecute}
>
{isRunning ? (
<RefreshCw aria-hidden="true" className="animate-spin" />
) : hasExistingResult ? (
<RefreshCw aria-hidden="true" />
) : (
<Play aria-hidden="true" />
)}
{isRunning
? "执行中"
: hasExistingResult
? displayedResult?.status === "failed"
? "重试执行"
: "重新执行"
: "执行策略"}
</Button>
</CardContent>
{trigger.isError ? (
<p className="px-6 pb-6 text-sm text-destructive" role="alert">
执行请求失败,请确认市场数据状态后重试。
</p>
) : null}
</Card>
{isRunning ? <RunningState /> : null}
{!isRunning &&
!hasQueryError &&
results.isPending &&
!displayedResult ? (
<LoadingState />
) : null}
{!isRunning && hasQueryError ? <QueryError /> : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "no_data" ? (
<NoDataState />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult?.status === "failed" ? (
<FailureState result={displayedResult} />
) : null}
{!isRunning &&
!hasQueryError &&
displayedResult &&
displayedResult.status !== "no_data" &&
displayedResult.status !== "failed" ? (
<ResultState result={displayedResult} />
) : null}
</div>
{rerunDialogOpen ? (
<Dialog open={rerunDialogOpen} onOpenChange={setRerunDialogOpen}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>确认重新执行策略?</DialogTitle>
<DialogDescription>
重新执行前会清空 {selectedTargetTradeDate} 的知行 B1
旧结果,再重新计算当前有效股票池。 旧结果清空后无法恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setRerunDialogOpen(false)}
variant="outline"
>
取消
</Button>
<Button
onClick={() => startExecution(true)}
variant="destructive"
>
确认重新执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : null}
</HomeShell>
)
}
function LoadingState() {
return (
<Card aria-label="正在加载策略执行结果">
<CardHeader className="gap-3">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72 max-w-full" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
)
}
function RunningState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<RefreshCw
aria-hidden="true"
className="size-5 animate-spin text-primary"
/>
策略执行中
</CardTitle>
<CardDescription>
正在逐只评估当前有效股票池,页面会自动刷新执行状态。
</CardDescription>
</CardHeader>
</Card>
)
}
function QueryError() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
结果查询失败
</CardTitle>
<CardDescription>
无法读取策略执行结果,请稍后刷新页面重试。
</CardDescription>
</CardHeader>
</Card>
)
}
function NoDataState() {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Search aria-hidden="true" className="size-5 text-primary" />
暂无策略结果
</CardTitle>
<CardDescription>
请选择目标交易日并执行知行 B1,完成后这里会显示持久化结果。
</CardDescription>
</CardHeader>
</Card>
)
}
function FailureState({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<AlertTriangle
aria-hidden="true"
className="size-5 text-destructive"
/>
策略执行失败
</CardTitle>
<CardDescription>
{result.error_message ??
"本次执行没有形成可用的策略结果,请确认后重试。"}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
目标交易日:
<span className="font-medium text-foreground">
{result.target_trade_date}
</span>
,失败股票:
<span className="font-medium text-foreground">
{result.failed_count}
</span>
</p>
</CardContent>
</Card>
)
}
function ResultState({ result }: { result: SelectionResults }) {
const presentation =
result.status === "success" ||
result.status === "partial_success" ||
result.status === "failed"
? statusPresentation[result.status]
: null
return (
<div className="space-y-6">
<Card>
<CardHeader className="gap-4 border-b md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-xl">
<CheckCircle2
aria-hidden="true"
className="size-5 text-primary"
/>
知行 B1 执行结果
</CardTitle>
<CardDescription>
目标交易日:{result.target_trade_date} · 市场同步批次:
{result.market_sync_batch_id}
</CardDescription>
</div>
{presentation ? (
<Badge variant={presentation.variant}>{presentation.label}</Badge>
) : null}
</CardHeader>
<CardContent className="grid gap-4 pt-6 sm:grid-cols-2 lg:grid-cols-5">
<Metric label="目标股票数" value={result.target_count} />
<Metric label="实际参与数" value={result.eligible_count} />
<Metric label="命中股票数" value={result.selected_stock_count} />
<Metric label="信号条数" value={result.signal_count} />
<Metric
label="数据覆盖率"
value={`${(result.coverage * 100).toFixed(2)}%`}
/>
</CardContent>
</Card>
{result.signal_count === 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-xl">本次没有命中信号</CardTitle>
<CardDescription>
目标交易日数据完整,但没有符合公式条件的股票。
</CardDescription>
</CardHeader>
</Card>
) : (
<SignalTable signals={result.signals} />
)}
{result.failures.length > 0 ? <FailureList result={result} /> : null}
</div>
)
}
function SignalTable({ signals }: { signals: SelectionSignal[] }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">命中信号明细</CardTitle>
<CardDescription>
同一股票同日的多个子信号分别保留,不合并分类。
</CardDescription>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full min-w-[720px] text-left text-sm">
<thead className="border-b text-muted-foreground">
<tr>
<th className="pb-3 pr-4 font-medium">股票</th>
<th className="pb-3 pr-4 font-medium">子信号</th>
<th className="pb-3 pr-4 font-medium">收盘价</th>
<th className="pb-3 font-medium">关键详情</th>
</tr>
</thead>
<tbody>
{signals.map((signal) => (
<tr
key={`${signal.ts_code}-${signal.category}`}
className="border-b last:border-0"
>
<td className="py-3 pr-4">
<p className="font-medium">{signal.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{signal.ts_code}
</p>
</td>
<td className="py-3 pr-4">
<Badge variant="outline">
{categoryLabels[signal.category] ?? signal.category}
</Badge>
</td>
<td className="py-3 pr-4 tabular-nums">
{signal.close.toFixed(2)}
</td>
<td className="py-3 text-muted-foreground">
{formatDetails(signal.details)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
}
function FailureList({ result }: { result: SelectionResults }) {
return (
<Card>
<CardHeader>
<CardTitle className="text-xl">未完成评估</CardTitle>
<CardDescription>这些股票未形成完整的策略评估结果。</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{result.failures.map((failure) => (
<div
key={`${failure.ts_code}-${failure.status}`}
className="rounded-lg border bg-muted/30 p-3"
>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<p className="font-medium">{failure.name}</p>
<p className="font-mono text-xs text-muted-foreground">
{failure.ts_code}
</p>
</div>
<span className="text-xs text-muted-foreground">
{failure.status}
</span>
</div>
{failure.reason ? (
<p className="mt-2 text-sm text-muted-foreground">
{failure.reason}
</p>
) : null}
</div>
))}
</CardContent>
</Card>
)
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-lg font-semibold tracking-tight">{value}</p>
</div>
)
}
function formatDetails(details: SelectionSignal["details"]) {
return Object.entries(details)
.map(([key, value]) => `${key}: ${value ?? "—"}`)
.join(" · ")
}
+8 -1
View File
@@ -1,6 +1,7 @@
import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"
import { HomePage } from "@/features/home/pages/home-page"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
const rootRoute = createRootRoute({
component: () => <Outlet />,
@@ -12,4 +13,10 @@ const indexRoute = createRoute({
component: HomePage,
})
export const routeTree = rootRoute.addChildren([indexRoute])
const selectionRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/selection",
component: SelectionResultsPage,
})
export const routeTree = rootRoute.addChildren([indexRoute, selectionRoute])
+1 -1
View File
@@ -51,7 +51,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-card p-4 text-sm text-card-foreground shadow-xl ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}