Develop #5
@@ -9,6 +9,7 @@
|
|||||||
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
|
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
|
||||||
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
|
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
|
||||||
| [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 |
|
| [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 |
|
||||||
|
| [历史选股](./selection.md) | selection bounded context、目标交易日、qfq 读取和信号结果契约 |
|
||||||
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
|
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
|
||||||
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
|
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
|
||||||
| [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 |
|
| [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 |
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 历史选股代码规格
|
||||||
|
|
||||||
|
## Scenario: `zhixing_b1` 历史公式评估
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- 触发:新增 `modules/selection` bounded context,基于 PostgreSQL 已保存的 qfq
|
||||||
|
日线执行历史知行 B1 公式。
|
||||||
|
- 边界:selection 只读市场事实并返回领域评估结果;不负责市场数据同步、信号
|
||||||
|
持久化、批次调度、HTTP 路由或前端展示。
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- `MarketDataReader.load_history(ts_code: str, target_trade_date: date) -> StockHistory`
|
||||||
|
- `ZhixingB1Strategy.evaluate(history: StockHistory, target_trade_date: date) -> SelectionEvaluation`
|
||||||
|
- `EvaluateZhixingB1.execute(ts_code: str, target_trade_date: date) -> SelectionEvaluation`
|
||||||
|
- `SelectionSignal.identity -> tuple[str, date, str, str]`
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `StockHistory.bars` 必须是升序、去重的 qfq 行情,且不得包含目标交易日之后的
|
||||||
|
数据;`daily_basic` 按交易日保存同日可空指标。
|
||||||
|
- `SelectionBar` 使用有限的 `float | None` 表示 OHLCV;目标日的 open/high/low/
|
||||||
|
close/volume 任一缺失时不得生成信号。
|
||||||
|
- 策略名称固定为 `zhixing_b1`;7 个分类固定为
|
||||||
|
`zhixing_b1_oversold_turn`、`zhixing_b1_oversold_volume`、
|
||||||
|
`zhixing_b1_original_b1`、`zhixing_b1_extreme_volume`、
|
||||||
|
`zhixing_b1_pullback_white`、`zhixing_b1_pullback_super`、
|
||||||
|
`zhixing_b1_pullback_yellow`。
|
||||||
|
- 同一股票同一交易日可以返回多个分类;唯一身份是
|
||||||
|
`(ts_code, target_trade_date, strategy, category)`,返回顺序遵循
|
||||||
|
`ZHIXING_B1_SIGNAL_ORDER`。
|
||||||
|
- PostgreSQL reader 必须参数化查询 `source_adj = 'qfq'` 且
|
||||||
|
`trade_date <= target_trade_date`,左连接同日 `market_daily_basic`;不得回退到
|
||||||
|
CSV、Tushare 或当前最后一行。
|
||||||
|
- 评估状态区分 `selected`、`no_signal`、`insufficient_history`、
|
||||||
|
`missing_target_bar` 和 `data_error`。业务状态不是异常,数据库读取失败才映射为
|
||||||
|
`data_error`。
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 少于公式最小暖机长度 | 返回 `insufficient_history`,不返回信号 |
|
||||||
|
| 目标日没有 qfq bar 或 OHLCV 不完整 | 返回 `missing_target_bar` |
|
||||||
|
| 目标日数据完整但无分类命中 | 返回 `no_signal` |
|
||||||
|
| PostgreSQL 读取失败 | 返回 `data_error`,保留股票和目标日上下文 |
|
||||||
|
| rolling 窗口不足 | 只使用已到达的交易行;`EVERY` 等需要完整窗口的条件不命中 |
|
||||||
|
| 除零、NaN 或无穷中间值 | 转为 NaN/False,不得静默制造命中 |
|
||||||
|
|
||||||
|
### 5. Good / Base / Bad Cases
|
||||||
|
|
||||||
|
- Good:给定历史目标日,reader 只返回该日及之前的 qfq 行,策略返回可序列化详情
|
||||||
|
和全部命中分类。
|
||||||
|
- Base:同日多分类命中时每类都保留稳定身份;同日 `daily_basic` 缺失只影响实际
|
||||||
|
依赖该指标的条件。
|
||||||
|
- Bad:用当前最新市值覆盖历史 K 线、取 bars 最后一行代替显式目标日,或把 7 类
|
||||||
|
mask OR 成一个结果后丢失分类。
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- 指标单元测试:rolling 暖机、交易日 `REF`、窗口边界、除零、NaN、宽幅代码参数。
|
||||||
|
- 策略单元测试:目标日截断、暖机/缺失状态、7 个 mask 独立存在和同日多分类身份。
|
||||||
|
- reader 单元测试:参数化 SQL、qfq 过滤、目标日截断、升序映射和 left join 可空值。
|
||||||
|
- golden 测试:离线固定 fixture 可重复运行;不得在测试运行时导入旧项目或访问生产库。
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 会产生历史前视数据,并丢弃同日的其他分类。
|
||||||
|
target = history.bars[-1]
|
||||||
|
first = next(category for category in categories if masks[category].iloc[-1])
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 用显式交易日定位,并保留所有独立 mask 的命中。
|
||||||
|
target_index = frame.index[frame["trade_date"] == target_trade_date][0]
|
||||||
|
matched = tuple(category for category in signal_order if masks[category].iloc[target_index])
|
||||||
|
```
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ requires-python = ">=3.12,<3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.18.0",
|
"alembic>=1.18.0",
|
||||||
"fastapi>=0.141.1",
|
"fastapi>=0.141.1",
|
||||||
|
"numpy>=2.4.0",
|
||||||
|
"pandas>=2.3.3",
|
||||||
"psycopg[binary]>=3.3.2",
|
"psycopg[binary]>=3.3.2",
|
||||||
"pydantic-settings>=2.14.2",
|
"pydantic-settings>=2.14.2",
|
||||||
"sqlalchemy>=2.0.46",
|
"sqlalchemy>=2.0.46",
|
||||||
@@ -17,6 +19,7 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"httpx2>=2.9.1",
|
"httpx2>=2.9.1",
|
||||||
|
"pandas-stubs>=2.3.2.250926",
|
||||||
"pyright>=1.1.411",
|
"pyright>=1.1.411",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
|||||||
@@ -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,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,18 @@
|
|||||||
|
"""Ports that keep selection formulas independent from storage technology."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from .models import StockHistory
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataReaderError(RuntimeError):
|
||||||
|
"""A market-data adapter could not complete a read."""
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataReader(Protocol):
|
||||||
|
"""Read qfq history sufficient for one historical strategy evaluation."""
|
||||||
|
|
||||||
|
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
|
||||||
@@ -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."""
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
"""Read-only PostgreSQL adapter for selection history."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
from ....bootstrap.config import Settings
|
||||||
|
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
|
||||||
|
from ..domain.ports import MarketDataReaderError
|
||||||
|
|
||||||
|
|
||||||
|
class SelectionReaderError(MarketDataReaderError):
|
||||||
|
"""Database read failure with stock and target-date context."""
|
||||||
|
|
||||||
|
|
||||||
|
_HISTORY_QUERY = """
|
||||||
|
SELECT
|
||||||
|
bar.ts_code,
|
||||||
|
stock.name,
|
||||||
|
bar.trade_date,
|
||||||
|
bar.open,
|
||||||
|
bar.high,
|
||||||
|
bar.low,
|
||||||
|
bar.close,
|
||||||
|
bar.vol,
|
||||||
|
basic.turnover_rate,
|
||||||
|
basic.total_mv
|
||||||
|
FROM market_daily_bar AS bar
|
||||||
|
LEFT JOIN market_stock AS stock
|
||||||
|
ON stock.ts_code = bar.ts_code
|
||||||
|
LEFT JOIN market_daily_basic AS basic
|
||||||
|
ON basic.ts_code = bar.ts_code
|
||||||
|
AND basic.trade_date = bar.trade_date
|
||||||
|
WHERE bar.ts_code = %s
|
||||||
|
AND bar.source_adj = 'qfq'
|
||||||
|
AND bar.trade_date <= %s
|
||||||
|
ORDER BY bar.trade_date ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _as_date(value: object) -> date:
|
||||||
|
"""Convert a PostgreSQL date-like scalar to a date."""
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.date()
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value
|
||||||
|
return date.fromisoformat(str(value)[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: object) -> float | None:
|
||||||
|
"""Convert nullable PostgreSQL numerics to finite floats."""
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
number = Decimal(str(value))
|
||||||
|
except (InvalidOperation, ValueError) as exc:
|
||||||
|
raise ValueError(f"invalid market-data numeric value: {value!r}") from exc
|
||||||
|
if number.is_nan():
|
||||||
|
return None
|
||||||
|
if not number.is_finite():
|
||||||
|
raise ValueError(f"market-data numeric value must be finite: {value!r}")
|
||||||
|
return float(number)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresMarketDataReader:
|
||||||
|
"""Load qfq bars and same-day basic facts without writing market data."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings | str) -> None:
|
||||||
|
"""Create a reader from injected settings or a compatible URL string."""
|
||||||
|
|
||||||
|
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
|
||||||
|
|
||||||
|
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
|
||||||
|
"""Read all retained qfq rows through the explicit target date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ts_code: Tushare stock identifier.
|
||||||
|
target_trade_date: Historical date to which rows are truncated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A sorted ``StockHistory``. An empty history is a normal missing
|
||||||
|
target-data result and is interpreted by the application layer.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SelectionReaderError: If PostgreSQL cannot complete the read.
|
||||||
|
ValueError: If a returned date or numeric field is malformed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with psycopg.connect(self.database_url) as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
_HISTORY_QUERY,
|
||||||
|
(ts_code, target_trade_date),
|
||||||
|
).fetchall()
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
raise SelectionReaderError(
|
||||||
|
f"failed to load market history for {ts_code} at {target_trade_date.isoformat()}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
bars: dict[date, SelectionBar] = {}
|
||||||
|
daily_basic: dict[date, SelectionDailyBasic] = {}
|
||||||
|
name = ""
|
||||||
|
for raw_row in rows:
|
||||||
|
row = cast(tuple[object, ...], raw_row)
|
||||||
|
row_code, row_name, bar, basic = self._map_row(row, ts_code)
|
||||||
|
if row_code != ts_code:
|
||||||
|
raise ValueError(f"reader returned unexpected stock code: {row_code}")
|
||||||
|
name = row_name or name
|
||||||
|
if bar.trade_date <= target_trade_date:
|
||||||
|
bars[bar.trade_date] = bar
|
||||||
|
daily_basic[bar.trade_date] = basic
|
||||||
|
return StockHistory(
|
||||||
|
ts_code=ts_code,
|
||||||
|
name=name,
|
||||||
|
bars=tuple(bars[trade_date] for trade_date in sorted(bars)),
|
||||||
|
daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_row(
|
||||||
|
row: tuple[object, ...],
|
||||||
|
expected_code: str,
|
||||||
|
) -> tuple[str, str, SelectionBar, SelectionDailyBasic]:
|
||||||
|
"""Map the current query row, tolerating a legacy test row without name."""
|
||||||
|
|
||||||
|
if len(row) >= 10:
|
||||||
|
code, raw_name, raw_date = row[0], row[1], row[2]
|
||||||
|
values = row[3:]
|
||||||
|
elif len(row) >= 9:
|
||||||
|
code, raw_name, raw_date = row[0], "", row[1]
|
||||||
|
values = row[2:]
|
||||||
|
else:
|
||||||
|
raise ValueError("market history row has too few columns")
|
||||||
|
row_code = str(code or expected_code)
|
||||||
|
name = str(raw_name or "")
|
||||||
|
trade_date = _as_date(raw_date)
|
||||||
|
if len(values) < 7:
|
||||||
|
raise ValueError("market history row is missing OHLCV/basic columns")
|
||||||
|
bar = SelectionBar(
|
||||||
|
trade_date=trade_date,
|
||||||
|
open=_as_float(values[0]),
|
||||||
|
high=_as_float(values[1]),
|
||||||
|
low=_as_float(values[2]),
|
||||||
|
close=_as_float(values[3]),
|
||||||
|
volume=_as_float(values[4]),
|
||||||
|
)
|
||||||
|
basic = SelectionDailyBasic(
|
||||||
|
trade_date=trade_date,
|
||||||
|
turnover_rate=_as_float(values[5]),
|
||||||
|
total_mv=_as_float(values[6]),
|
||||||
|
)
|
||||||
|
return row_code, name, bar, basic
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Transport adapters for selection; intentionally empty in the first slice."""
|
||||||
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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"]
|
||||||
@@ -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,84 @@
|
|||||||
|
"""PostgreSQL reader contract tests using a fake connection."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
|
||||||
|
PostgresMarketDataReader,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
def __init__(self, rows: list[tuple[object, ...]]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
self.query: str | None = None
|
||||||
|
self.parameters: tuple[object, ...] | None = None
|
||||||
|
|
||||||
|
def __enter__(self) -> "FakeConnection":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def execute(self, query: str, parameters: tuple[object, ...]) -> "FakeResult":
|
||||||
|
self.query = query
|
||||||
|
self.parameters = parameters
|
||||||
|
return FakeResult(self.rows)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, rows: list[tuple[object, ...]]) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
def fetchall(self) -> list[tuple[object, ...]]:
|
||||||
|
return self.rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
connection = FakeConnection(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"000001.SZ",
|
||||||
|
"平安银行",
|
||||||
|
date(2024, 1, 2),
|
||||||
|
"10",
|
||||||
|
"11",
|
||||||
|
"9",
|
||||||
|
"10.5",
|
||||||
|
"1000",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"000001.SZ",
|
||||||
|
"平安银行",
|
||||||
|
date(2024, 1, 3),
|
||||||
|
"10.5",
|
||||||
|
"11",
|
||||||
|
"10",
|
||||||
|
"10.8",
|
||||||
|
"1200",
|
||||||
|
"1.2",
|
||||||
|
"100000",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def connect(database_url: str) -> FakeConnection:
|
||||||
|
assert database_url == "postgresql://test"
|
||||||
|
return connection
|
||||||
|
|
||||||
|
monkeypatch.setattr(psycopg, "connect", connect)
|
||||||
|
history = PostgresMarketDataReader("postgresql://test").load_history(
|
||||||
|
"000001.SZ", date(2024, 1, 3)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [bar.trade_date for bar in history.bars] == [date(2024, 1, 2), date(2024, 1, 3)]
|
||||||
|
assert history.daily_basic[date(2024, 1, 2)].turnover_rate is None
|
||||||
|
assert history.daily_basic[date(2024, 1, 3)].total_mv == 100000.0
|
||||||
|
assert connection.parameters == ("000001.SZ", date(2024, 1, 3))
|
||||||
|
assert "source_adj = 'qfq'" in cast(str, connection.query)
|
||||||
|
assert "trade_date <= %s" in cast(str, connection.query)
|
||||||
@@ -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
|
||||||
Generated
+18
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -865,6 +877,8 @@ source = { editable = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "numpy" },
|
||||||
|
{ name = "pandas" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
@@ -875,6 +889,7 @@ dependencies = [
|
|||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx2" },
|
{ name = "httpx2" },
|
||||||
|
{ name = "pandas-stubs" },
|
||||||
{ name = "pyright" },
|
{ name = "pyright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
@@ -885,6 +900,8 @@ dev = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.18.0" },
|
{ name = "alembic", specifier = ">=1.18.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.141.1" },
|
{ 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 = "psycopg", extras = ["binary"], specifier = ">=3.3.2" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
{ name = "sqlalchemy", specifier = ">=2.0.46" },
|
||||||
@@ -895,6 +912,7 @@ requires-dist = [
|
|||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "httpx2", specifier = ">=2.9.1" },
|
{ name = "httpx2", specifier = ">=2.9.1" },
|
||||||
|
{ name = "pandas-stubs", specifier = ">=2.3.2.250926" },
|
||||||
{ name = "pyright", specifier = ">=1.1.411" },
|
{ name = "pyright", specifier = ">=1.1.411" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user