feat(selection): 迁移知行B1选股策略

This commit is contained in:
yuxuanhui
2026-08-08 22:41:45 +08:00
parent 0c999fb828
commit e9d06df5de
32 changed files with 2423 additions and 0 deletions
+1
View File
@@ -9,6 +9,7 @@
| [目录与模块边界](./directory-structure.md) | 包结构、bounded context 和导入边界 |
| [配置与运行时](./configuration-and-runtime.md) | `Settings`、应用工厂和部署环境 |
| [市场数据同步](./market-data-sync.md) | Tushare qfq、PostgreSQL、CSV 快照和一次性 Job 契约 |
| [历史选股](./selection.md) | selection bounded context、目标交易日、qfq 读取和信号结果契约 |
| [HTTP 契约](./http-api-contracts.md) | 路由组合、响应模型和同源 API 路径 |
| [错误处理](./error-handling.md) | 当前 FastAPI 错误行为及跨层错误传递 |
| [质量与测试](./quality-guidelines.md) | Ruff、Pyright、pytest 及禁止模式 |
+83
View File
@@ -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": {}
}
+3
View File
@@ -7,6 +7,8 @@ requires-python = ">=3.12,<3.13"
dependencies = [
"alembic>=1.18.0",
"fastapi>=0.141.1",
"numpy>=2.4.0",
"pandas>=2.3.3",
"psycopg[binary]>=3.3.2",
"pydantic-settings>=2.14.2",
"sqlalchemy>=2.0.46",
@@ -17,6 +19,7 @@ dependencies = [
[dependency-groups]
dev = [
"httpx2>=2.9.1",
"pandas-stubs>=2.3.2.250926",
"pyright>=1.1.411",
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
@@ -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."""
@@ -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
1 trade_date open high low close volume
2 2023-01-01 9.9600 10.1200 9.8800 10.0000 1000.00
3 2023-01-02 9.9800 10.1400 9.9000 10.0200 1030.00
4 2023-01-03 10.0000 10.1600 9.9200 10.0400 1060.00
5 2023-01-04 10.0200 10.1800 9.9400 10.0600 1090.00
6 2023-01-05 10.0400 10.2000 9.9600 10.0800 1120.00
7 2023-01-06 10.0600 10.2200 9.9800 10.1000 1150.00
8 2023-01-07 10.0800 10.2400 10.0000 10.1200 1180.00
9 2023-01-08 10.1000 10.2600 10.0200 10.1400 1000.00
10 2023-01-09 10.1200 10.2800 10.0400 10.1600 1030.00
11 2023-01-10 10.1400 10.3000 10.0600 10.1800 1060.00
12 2023-01-11 10.1600 10.3200 10.0800 10.2000 1090.00
13 2023-01-12 10.1800 10.3400 10.1000 10.2200 1120.00
14 2023-01-13 10.2000 10.3600 10.1200 10.2400 1150.00
15 2023-01-14 10.2200 10.3800 10.1400 10.2600 1180.00
16 2023-01-15 10.2400 10.4000 10.1600 10.2800 1000.00
17 2023-01-16 10.2600 10.4200 10.1800 10.3000 1030.00
18 2023-01-17 10.2800 10.4400 10.2000 10.3200 1060.00
19 2023-01-18 10.3000 10.4600 10.2200 10.3400 1090.00
20 2023-01-19 10.3200 10.4800 10.2400 10.3600 1120.00
21 2023-01-20 10.3400 10.5000 10.2600 10.3800 1150.00
22 2023-01-21 10.3600 10.5200 10.2800 10.4000 1180.00
23 2023-01-22 10.3800 10.5400 10.3000 10.4200 1000.00
24 2023-01-23 10.4000 10.5600 10.3200 10.4400 1030.00
25 2023-01-24 10.4200 10.5800 10.3400 10.4600 1060.00
26 2023-01-25 10.4400 10.6000 10.3600 10.4800 1090.00
27 2023-01-26 10.4600 10.6200 10.3800 10.5000 1120.00
28 2023-01-27 10.4800 10.6400 10.4000 10.5200 1150.00
29 2023-01-28 10.5000 10.6600 10.4200 10.5400 1180.00
30 2023-01-29 10.5200 10.6800 10.4400 10.5600 1000.00
31 2023-01-30 10.5400 10.7000 10.4600 10.5800 1030.00
32 2023-01-31 10.5600 10.7200 10.4800 10.6000 1060.00
33 2023-02-01 10.5800 10.7400 10.5000 10.6200 1090.00
34 2023-02-02 10.6000 10.7600 10.5200 10.6400 1120.00
35 2023-02-03 10.6200 10.7800 10.5400 10.6600 1150.00
36 2023-02-04 10.6400 10.8000 10.5600 10.6800 1180.00
37 2023-02-05 10.6600 10.8200 10.5800 10.7000 1000.00
38 2023-02-06 10.6800 10.8400 10.6000 10.7200 1030.00
39 2023-02-07 10.7000 10.8600 10.6200 10.7400 1060.00
40 2023-02-08 10.7200 10.8800 10.6400 10.7600 1090.00
41 2023-02-09 10.7400 10.9000 10.6600 10.7800 1120.00
42 2023-02-10 10.7600 10.9200 10.6800 10.8000 1150.00
43 2023-02-11 10.7800 10.9400 10.7000 10.8200 1180.00
44 2023-02-12 10.8000 10.9600 10.7200 10.8400 1000.00
45 2023-02-13 10.8200 10.9800 10.7400 10.8600 1030.00
46 2023-02-14 10.8400 11.0000 10.7600 10.8800 1060.00
47 2023-02-15 10.8600 11.0200 10.7800 10.9000 1090.00
48 2023-02-16 10.8800 11.0400 10.8000 10.9200 1120.00
49 2023-02-17 10.9000 11.0600 10.8200 10.9400 1150.00
50 2023-02-18 10.9200 11.0800 10.8400 10.9600 1180.00
51 2023-02-19 10.9400 11.1000 10.8600 10.9800 1000.00
52 2023-02-20 10.9600 11.1200 10.8800 11.0000 1030.00
53 2023-02-21 10.9800 11.1400 10.9000 11.0200 1060.00
54 2023-02-22 11.0000 11.1600 10.9200 11.0400 1090.00
55 2023-02-23 11.0200 11.1800 10.9400 11.0600 1120.00
56 2023-02-24 11.0400 11.2000 10.9600 11.0800 1150.00
57 2023-02-25 11.0600 11.2200 10.9800 11.1000 1180.00
58 2023-02-26 11.0800 11.2400 11.0000 11.1200 1000.00
59 2023-02-27 11.1000 11.2600 11.0200 11.1400 1030.00
60 2023-02-28 11.1200 11.2800 11.0400 11.1600 1060.00
61 2023-03-01 11.1400 11.3000 11.0600 11.1800 1090.00
62 2023-03-02 11.1600 11.3200 11.0800 11.2000 1120.00
63 2023-03-03 11.1800 11.3400 11.1000 11.2200 1150.00
64 2023-03-04 11.2000 11.3600 11.1200 11.2400 1180.00
65 2023-03-05 11.2200 11.3800 11.1400 11.2600 1000.00
66 2023-03-06 11.2400 11.4000 11.1600 11.2800 1030.00
67 2023-03-07 11.2600 11.4200 11.1800 11.3000 1060.00
68 2023-03-08 11.2800 11.4400 11.2000 11.3200 1090.00
69 2023-03-09 11.3000 11.4600 11.2200 11.3400 1120.00
70 2023-03-10 11.3200 11.4800 11.2400 11.3600 1150.00
71 2023-03-11 11.3400 11.5000 11.2600 11.3800 1180.00
72 2023-03-12 11.3600 11.5200 11.2800 11.4000 1000.00
73 2023-03-13 11.3800 11.5400 11.3000 11.4200 1030.00
74 2023-03-14 11.4000 11.5600 11.3200 11.4400 1060.00
75 2023-03-15 11.4200 11.5800 11.3400 11.4600 1090.00
76 2023-03-16 11.4400 11.6000 11.3600 11.4800 1120.00
77 2023-03-17 11.4600 11.6200 11.3800 11.5000 1150.00
78 2023-03-18 11.4800 11.6400 11.4000 11.5200 1180.00
79 2023-03-19 11.5000 11.6600 11.4200 11.5400 1000.00
80 2023-03-20 11.5200 11.6800 11.4400 11.5600 1030.00
81 2023-03-21 11.5400 11.7000 11.4600 11.5800 1060.00
82 2023-03-22 11.5600 11.7200 11.4800 11.6000 1090.00
83 2023-03-23 11.5800 11.7400 11.5000 11.6200 1120.00
84 2023-03-24 11.6000 11.7600 11.5200 11.6400 1150.00
85 2023-03-25 11.6200 11.7800 11.5400 11.6600 1180.00
86 2023-03-26 11.6400 11.8000 11.5600 11.6800 1000.00
87 2023-03-27 11.6600 11.8200 11.5800 11.7000 1030.00
88 2023-03-28 11.6800 11.8400 11.6000 11.7200 1060.00
89 2023-03-29 11.7000 11.8600 11.6200 11.7400 1090.00
90 2023-03-30 11.7200 11.8800 11.6400 11.7600 1120.00
91 2023-03-31 11.7400 11.9000 11.6600 11.7800 1150.00
92 2023-04-01 11.7600 11.9200 11.6800 11.8000 1180.00
93 2023-04-02 11.7800 11.9400 11.7000 11.8200 1000.00
94 2023-04-03 11.8000 11.9600 11.7200 11.8400 1030.00
95 2023-04-04 11.8200 11.9800 11.7400 11.8600 1060.00
96 2023-04-05 11.8400 12.0000 11.7600 11.8800 1090.00
97 2023-04-06 11.8600 12.0200 11.7800 11.9000 1120.00
98 2023-04-07 11.8800 12.0400 11.8000 11.9200 1150.00
99 2023-04-08 11.9000 12.0600 11.8200 11.9400 1180.00
100 2023-04-09 11.9200 12.0800 11.8400 11.9600 1000.00
101 2023-04-10 11.9400 12.1000 11.8600 11.9800 1030.00
102 2023-04-11 11.9600 12.1200 11.8800 12.0000 1060.00
103 2023-04-12 11.9800 12.1400 11.9000 12.0200 1090.00
104 2023-04-13 12.0000 12.1600 11.9200 12.0400 1120.00
105 2023-04-14 12.0200 12.1800 11.9400 12.0600 1150.00
106 2023-04-15 12.0400 12.2000 11.9600 12.0800 1180.00
107 2023-04-16 12.0600 12.2200 11.9800 12.1000 1000.00
108 2023-04-17 12.0800 12.2400 12.0000 12.1200 1030.00
109 2023-04-18 12.1000 12.2600 12.0200 12.1400 1060.00
110 2023-04-19 12.1200 12.2800 12.0400 12.1600 1090.00
111 2023-04-20 12.1400 12.3000 12.0600 12.1800 1120.00
112 2023-04-21 12.1600 12.3200 12.0800 12.2000 1150.00
113 2023-04-22 12.1800 12.3400 12.1000 12.2200 1180.00
114 2023-04-23 12.2000 12.3600 12.1200 12.2400 1000.00
115 2023-04-24 12.2200 12.3800 12.1400 12.2600 1030.00
116 2023-04-25 12.2400 12.4000 12.1600 12.2800 1060.00
@@ -0,0 +1,116 @@
trade_date,open,high,low,close,volume
2023-01-01,19.9600,20.1200,19.8800,20.0000,1600.00
2023-01-02,19.9900,20.1500,19.9100,20.0300,1630.00
2023-01-03,20.0200,20.1800,19.9400,20.0600,1660.00
2023-01-04,20.0500,20.2100,19.9700,20.0900,1690.00
2023-01-05,20.0800,20.2400,20.0000,20.1200,1720.00
2023-01-06,20.1100,20.2700,20.0300,20.1500,1750.00
2023-01-07,20.1400,20.3000,20.0600,20.1800,1780.00
2023-01-08,20.1700,20.3300,20.0900,20.2100,1600.00
2023-01-09,20.2000,20.3600,20.1200,20.2400,1630.00
2023-01-10,20.2300,20.3900,20.1500,20.2700,1660.00
2023-01-11,20.2600,20.4200,20.1800,20.3000,1690.00
2023-01-12,20.2900,20.4500,20.2100,20.3300,1720.00
2023-01-13,20.3200,20.4800,20.2400,20.3600,1750.00
2023-01-14,20.3500,20.5100,20.2700,20.3900,1780.00
2023-01-15,20.3800,20.5400,20.3000,20.4200,1600.00
2023-01-16,20.4100,20.5700,20.3300,20.4500,1630.00
2023-01-17,20.4400,20.6000,20.3600,20.4800,1660.00
2023-01-18,20.4700,20.6300,20.3900,20.5100,1690.00
2023-01-19,20.5000,20.6600,20.4200,20.5400,1720.00
2023-01-20,20.5300,20.6900,20.4500,20.5700,1750.00
2023-01-21,20.5600,20.7200,20.4800,20.6000,1780.00
2023-01-22,20.5900,20.7500,20.5100,20.6300,1600.00
2023-01-23,20.6200,20.7800,20.5400,20.6600,1630.00
2023-01-24,20.6500,20.8100,20.5700,20.6900,1660.00
2023-01-25,20.6800,20.8400,20.6000,20.7200,1690.00
2023-01-26,20.7100,20.8700,20.6300,20.7500,1720.00
2023-01-27,20.7400,20.9000,20.6600,20.7800,1750.00
2023-01-28,20.7700,20.9300,20.6900,20.8100,1780.00
2023-01-29,20.8000,20.9600,20.7200,20.8400,1600.00
2023-01-30,20.8300,20.9900,20.7500,20.8700,1630.00
2023-01-31,20.8600,21.0200,20.7800,20.9000,1660.00
2023-02-01,20.8900,21.0500,20.8100,20.9300,1690.00
2023-02-02,20.9200,21.0800,20.8400,20.9600,1720.00
2023-02-03,20.9500,21.1100,20.8700,20.9900,1750.00
2023-02-04,20.9800,21.1400,20.9000,21.0200,1780.00
2023-02-05,21.0100,21.1700,20.9300,21.0500,1600.00
2023-02-06,21.0400,21.2000,20.9600,21.0800,1630.00
2023-02-07,21.0700,21.2300,20.9900,21.1100,1660.00
2023-02-08,21.1000,21.2600,21.0200,21.1400,1690.00
2023-02-09,21.1300,21.2900,21.0500,21.1700,1720.00
2023-02-10,21.1600,21.3200,21.0800,21.2000,1750.00
2023-02-11,21.1900,21.3500,21.1100,21.2300,1780.00
2023-02-12,21.2200,21.3800,21.1400,21.2600,1600.00
2023-02-13,21.2500,21.4100,21.1700,21.2900,1630.00
2023-02-14,21.2800,21.4400,21.2000,21.3200,1660.00
2023-02-15,21.3100,21.4700,21.2300,21.3500,1690.00
2023-02-16,21.3400,21.5000,21.2600,21.3800,1720.00
2023-02-17,21.3700,21.5300,21.2900,21.4100,1750.00
2023-02-18,21.4000,21.5600,21.3200,21.4400,1780.00
2023-02-19,21.4300,21.5900,21.3500,21.4700,1600.00
2023-02-20,21.4600,21.6200,21.3800,21.5000,1630.00
2023-02-21,21.4900,21.6500,21.4100,21.5300,1660.00
2023-02-22,21.5200,21.6800,21.4400,21.5600,1690.00
2023-02-23,21.5500,21.7100,21.4700,21.5900,1720.00
2023-02-24,21.5800,21.7400,21.5000,21.6200,1750.00
2023-02-25,21.6100,21.7700,21.5300,21.6500,1780.00
2023-02-26,21.6400,21.8000,21.5600,21.6800,1600.00
2023-02-27,21.6700,21.8300,21.5900,21.7100,1630.00
2023-02-28,21.7000,21.8600,21.6200,21.7400,1660.00
2023-03-01,21.7300,21.8900,21.6500,21.7700,1690.00
2023-03-02,21.7600,21.9200,21.6800,21.8000,1720.00
2023-03-03,21.7900,21.9500,21.7100,21.8300,1750.00
2023-03-04,21.8200,21.9800,21.7400,21.8600,1780.00
2023-03-05,21.8500,22.0100,21.7700,21.8900,1600.00
2023-03-06,21.8800,22.0400,21.8000,21.9200,1630.00
2023-03-07,21.9100,22.0700,21.8300,21.9500,1660.00
2023-03-08,21.9400,22.1000,21.8600,21.9800,1690.00
2023-03-09,21.9700,22.1300,21.8900,22.0100,1720.00
2023-03-10,22.0000,22.1600,21.9200,22.0400,1750.00
2023-03-11,22.0300,22.1900,21.9500,22.0700,1780.00
2023-03-12,22.0600,22.2200,21.9800,22.1000,1600.00
2023-03-13,22.0900,22.2500,22.0100,22.1300,1630.00
2023-03-14,22.1200,22.2800,22.0400,22.1600,1660.00
2023-03-15,22.1500,22.3100,22.0700,22.1900,1690.00
2023-03-16,22.1800,22.3400,22.1000,22.2200,1720.00
2023-03-17,22.2100,22.3700,22.1300,22.2500,1750.00
2023-03-18,22.2400,22.4000,22.1600,22.2800,1780.00
2023-03-19,22.2700,22.4300,22.1900,22.3100,1600.00
2023-03-20,22.3000,22.4600,22.2200,22.3400,1630.00
2023-03-21,22.3300,22.4900,22.2500,22.3700,1660.00
2023-03-22,22.3600,22.5200,22.2800,22.4000,1690.00
2023-03-23,22.3900,22.5500,22.3100,22.4300,1720.00
2023-03-24,22.4200,22.5800,22.3400,22.4600,1750.00
2023-03-25,22.4500,22.6100,22.3700,22.4900,1780.00
2023-03-26,22.4800,22.6400,22.4000,22.5200,1600.00
2023-03-27,22.5100,22.6700,22.4300,22.5500,1630.00
2023-03-28,22.5400,22.7000,22.4600,22.5800,1660.00
2023-03-29,22.5700,22.7300,22.4900,22.6100,1690.00
2023-03-30,22.6000,22.7600,22.5200,22.6400,1720.00
2023-03-31,22.6300,22.7900,22.5500,22.6700,1750.00
2023-04-01,22.6600,22.8200,22.5800,22.7000,1780.00
2023-04-02,22.6900,22.8500,22.6100,22.7300,1600.00
2023-04-03,22.7200,22.8800,22.6400,22.7600,1630.00
2023-04-04,22.7500,22.9100,22.6700,22.7900,1660.00
2023-04-05,22.7800,22.9400,22.7000,22.8200,1690.00
2023-04-06,22.8100,22.9700,22.7300,22.8500,1720.00
2023-04-07,22.8400,23.0000,22.7600,22.8800,1750.00
2023-04-08,22.8700,23.0300,22.7900,22.9100,1780.00
2023-04-09,22.9000,23.0600,22.8200,22.9400,1600.00
2023-04-10,22.9300,23.0900,22.8500,22.9700,1630.00
2023-04-11,22.9600,23.1200,22.8800,23.0000,1660.00
2023-04-12,22.9900,23.1500,22.9100,23.0300,1690.00
2023-04-13,23.0200,23.1800,22.9400,23.0600,1720.00
2023-04-14,23.0500,23.2100,22.9700,23.0900,1750.00
2023-04-15,23.0800,23.2400,23.0000,23.1200,1780.00
2023-04-16,23.1100,23.2700,23.0300,23.1500,1600.00
2023-04-17,23.1400,23.3000,23.0600,23.1800,1630.00
2023-04-18,23.1700,23.3300,23.0900,23.2100,1660.00
2023-04-19,23.2000,23.3600,23.1200,23.2400,1690.00
2023-04-20,23.2300,23.3900,23.1500,23.2700,1720.00
2023-04-21,23.2600,23.4200,23.1800,23.3000,1750.00
2023-04-22,23.2900,23.4500,23.2100,23.3300,1780.00
2023-04-23,23.3200,23.4800,23.2400,23.3600,1600.00
2023-04-24,23.3500,23.5100,23.2700,23.3900,1630.00
2023-04-25,23.3800,23.5400,23.3000,23.4200,1660.00
1 trade_date open high low close volume
2 2023-01-01 19.9600 20.1200 19.8800 20.0000 1600.00
3 2023-01-02 19.9900 20.1500 19.9100 20.0300 1630.00
4 2023-01-03 20.0200 20.1800 19.9400 20.0600 1660.00
5 2023-01-04 20.0500 20.2100 19.9700 20.0900 1690.00
6 2023-01-05 20.0800 20.2400 20.0000 20.1200 1720.00
7 2023-01-06 20.1100 20.2700 20.0300 20.1500 1750.00
8 2023-01-07 20.1400 20.3000 20.0600 20.1800 1780.00
9 2023-01-08 20.1700 20.3300 20.0900 20.2100 1600.00
10 2023-01-09 20.2000 20.3600 20.1200 20.2400 1630.00
11 2023-01-10 20.2300 20.3900 20.1500 20.2700 1660.00
12 2023-01-11 20.2600 20.4200 20.1800 20.3000 1690.00
13 2023-01-12 20.2900 20.4500 20.2100 20.3300 1720.00
14 2023-01-13 20.3200 20.4800 20.2400 20.3600 1750.00
15 2023-01-14 20.3500 20.5100 20.2700 20.3900 1780.00
16 2023-01-15 20.3800 20.5400 20.3000 20.4200 1600.00
17 2023-01-16 20.4100 20.5700 20.3300 20.4500 1630.00
18 2023-01-17 20.4400 20.6000 20.3600 20.4800 1660.00
19 2023-01-18 20.4700 20.6300 20.3900 20.5100 1690.00
20 2023-01-19 20.5000 20.6600 20.4200 20.5400 1720.00
21 2023-01-20 20.5300 20.6900 20.4500 20.5700 1750.00
22 2023-01-21 20.5600 20.7200 20.4800 20.6000 1780.00
23 2023-01-22 20.5900 20.7500 20.5100 20.6300 1600.00
24 2023-01-23 20.6200 20.7800 20.5400 20.6600 1630.00
25 2023-01-24 20.6500 20.8100 20.5700 20.6900 1660.00
26 2023-01-25 20.6800 20.8400 20.6000 20.7200 1690.00
27 2023-01-26 20.7100 20.8700 20.6300 20.7500 1720.00
28 2023-01-27 20.7400 20.9000 20.6600 20.7800 1750.00
29 2023-01-28 20.7700 20.9300 20.6900 20.8100 1780.00
30 2023-01-29 20.8000 20.9600 20.7200 20.8400 1600.00
31 2023-01-30 20.8300 20.9900 20.7500 20.8700 1630.00
32 2023-01-31 20.8600 21.0200 20.7800 20.9000 1660.00
33 2023-02-01 20.8900 21.0500 20.8100 20.9300 1690.00
34 2023-02-02 20.9200 21.0800 20.8400 20.9600 1720.00
35 2023-02-03 20.9500 21.1100 20.8700 20.9900 1750.00
36 2023-02-04 20.9800 21.1400 20.9000 21.0200 1780.00
37 2023-02-05 21.0100 21.1700 20.9300 21.0500 1600.00
38 2023-02-06 21.0400 21.2000 20.9600 21.0800 1630.00
39 2023-02-07 21.0700 21.2300 20.9900 21.1100 1660.00
40 2023-02-08 21.1000 21.2600 21.0200 21.1400 1690.00
41 2023-02-09 21.1300 21.2900 21.0500 21.1700 1720.00
42 2023-02-10 21.1600 21.3200 21.0800 21.2000 1750.00
43 2023-02-11 21.1900 21.3500 21.1100 21.2300 1780.00
44 2023-02-12 21.2200 21.3800 21.1400 21.2600 1600.00
45 2023-02-13 21.2500 21.4100 21.1700 21.2900 1630.00
46 2023-02-14 21.2800 21.4400 21.2000 21.3200 1660.00
47 2023-02-15 21.3100 21.4700 21.2300 21.3500 1690.00
48 2023-02-16 21.3400 21.5000 21.2600 21.3800 1720.00
49 2023-02-17 21.3700 21.5300 21.2900 21.4100 1750.00
50 2023-02-18 21.4000 21.5600 21.3200 21.4400 1780.00
51 2023-02-19 21.4300 21.5900 21.3500 21.4700 1600.00
52 2023-02-20 21.4600 21.6200 21.3800 21.5000 1630.00
53 2023-02-21 21.4900 21.6500 21.4100 21.5300 1660.00
54 2023-02-22 21.5200 21.6800 21.4400 21.5600 1690.00
55 2023-02-23 21.5500 21.7100 21.4700 21.5900 1720.00
56 2023-02-24 21.5800 21.7400 21.5000 21.6200 1750.00
57 2023-02-25 21.6100 21.7700 21.5300 21.6500 1780.00
58 2023-02-26 21.6400 21.8000 21.5600 21.6800 1600.00
59 2023-02-27 21.6700 21.8300 21.5900 21.7100 1630.00
60 2023-02-28 21.7000 21.8600 21.6200 21.7400 1660.00
61 2023-03-01 21.7300 21.8900 21.6500 21.7700 1690.00
62 2023-03-02 21.7600 21.9200 21.6800 21.8000 1720.00
63 2023-03-03 21.7900 21.9500 21.7100 21.8300 1750.00
64 2023-03-04 21.8200 21.9800 21.7400 21.8600 1780.00
65 2023-03-05 21.8500 22.0100 21.7700 21.8900 1600.00
66 2023-03-06 21.8800 22.0400 21.8000 21.9200 1630.00
67 2023-03-07 21.9100 22.0700 21.8300 21.9500 1660.00
68 2023-03-08 21.9400 22.1000 21.8600 21.9800 1690.00
69 2023-03-09 21.9700 22.1300 21.8900 22.0100 1720.00
70 2023-03-10 22.0000 22.1600 21.9200 22.0400 1750.00
71 2023-03-11 22.0300 22.1900 21.9500 22.0700 1780.00
72 2023-03-12 22.0600 22.2200 21.9800 22.1000 1600.00
73 2023-03-13 22.0900 22.2500 22.0100 22.1300 1630.00
74 2023-03-14 22.1200 22.2800 22.0400 22.1600 1660.00
75 2023-03-15 22.1500 22.3100 22.0700 22.1900 1690.00
76 2023-03-16 22.1800 22.3400 22.1000 22.2200 1720.00
77 2023-03-17 22.2100 22.3700 22.1300 22.2500 1750.00
78 2023-03-18 22.2400 22.4000 22.1600 22.2800 1780.00
79 2023-03-19 22.2700 22.4300 22.1900 22.3100 1600.00
80 2023-03-20 22.3000 22.4600 22.2200 22.3400 1630.00
81 2023-03-21 22.3300 22.4900 22.2500 22.3700 1660.00
82 2023-03-22 22.3600 22.5200 22.2800 22.4000 1690.00
83 2023-03-23 22.3900 22.5500 22.3100 22.4300 1720.00
84 2023-03-24 22.4200 22.5800 22.3400 22.4600 1750.00
85 2023-03-25 22.4500 22.6100 22.3700 22.4900 1780.00
86 2023-03-26 22.4800 22.6400 22.4000 22.5200 1600.00
87 2023-03-27 22.5100 22.6700 22.4300 22.5500 1630.00
88 2023-03-28 22.5400 22.7000 22.4600 22.5800 1660.00
89 2023-03-29 22.5700 22.7300 22.4900 22.6100 1690.00
90 2023-03-30 22.6000 22.7600 22.5200 22.6400 1720.00
91 2023-03-31 22.6300 22.7900 22.5500 22.6700 1750.00
92 2023-04-01 22.6600 22.8200 22.5800 22.7000 1780.00
93 2023-04-02 22.6900 22.8500 22.6100 22.7300 1600.00
94 2023-04-03 22.7200 22.8800 22.6400 22.7600 1630.00
95 2023-04-04 22.7500 22.9100 22.6700 22.7900 1660.00
96 2023-04-05 22.7800 22.9400 22.7000 22.8200 1690.00
97 2023-04-06 22.8100 22.9700 22.7300 22.8500 1720.00
98 2023-04-07 22.8400 23.0000 22.7600 22.8800 1750.00
99 2023-04-08 22.8700 23.0300 22.7900 22.9100 1780.00
100 2023-04-09 22.9000 23.0600 22.8200 22.9400 1600.00
101 2023-04-10 22.9300 23.0900 22.8500 22.9700 1630.00
102 2023-04-11 22.9600 23.1200 22.8800 23.0000 1660.00
103 2023-04-12 22.9900 23.1500 22.9100 23.0300 1690.00
104 2023-04-13 23.0200 23.1800 22.9400 23.0600 1720.00
105 2023-04-14 23.0500 23.2100 22.9700 23.0900 1750.00
106 2023-04-15 23.0800 23.2400 23.0000 23.1200 1780.00
107 2023-04-16 23.1100 23.2700 23.0300 23.1500 1600.00
108 2023-04-17 23.1400 23.3000 23.0600 23.1800 1630.00
109 2023-04-18 23.1700 23.3300 23.0900 23.2100 1660.00
110 2023-04-19 23.2000 23.3600 23.1200 23.2400 1690.00
111 2023-04-20 23.2300 23.3900 23.1500 23.2700 1720.00
112 2023-04-21 23.2600 23.4200 23.1800 23.3000 1750.00
113 2023-04-22 23.2900 23.4500 23.2100 23.3300 1780.00
114 2023-04-23 23.3200 23.4800 23.2400 23.3600 1600.00
115 2023-04-24 23.3500 23.5100 23.2700 23.3900 1630.00
116 2023-04-25 23.3800 23.5400 23.3000 23.4200 1660.00
@@ -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
+18
View File
@@ -374,6 +374,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260730"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/d2/dea4a3a56b7b5f69c5fbca9f14625fcf28e1a39a657e9833d4a10bcac593/pandas_stubs-3.0.5.260730.tar.gz", hash = "sha256:f70a232c57d93a5a2c81f8a53953e10891a5374bc92652277deb325e2e4d0ff3", size = 114631, upload-time = "2026-07-30T14:31:42.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/c2/959caec5c46f484b5f8bb6def4b0cf7a45ba6acda26f12b75142d98cc5ae/pandas_stubs-3.0.5.260730-py3-none-any.whl", hash = "sha256:60e90e3e1eda6937e337e243cbe6217e151c11137cd7eddf832af537c7310bfd", size = 174807, upload-time = "2026-07-30T14:31:41.17Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -865,6 +877,8 @@ source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
@@ -875,6 +889,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx2" },
{ name = "pandas-stubs" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -885,6 +900,8 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.18.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "numpy", specifier = ">=2.4.0" },
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.2" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "sqlalchemy", specifier = ">=2.0.46" },
@@ -895,6 +912,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx2", specifier = ">=2.9.1" },
{ name = "pandas-stubs", specifier = ">=2.3.2.250926" },
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.1.0" },