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