diff --git a/.env.example b/.env.example index 5a170bc..a7f86fe 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,5 @@ ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS=0.2 ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522 ZHIXING_SELECTION_MAX_WORKERS=4 ZHIXING_SELECTION_BATCH_SIZE=200 +ZHIXING_SELECTION_PATTERN_SCORING_ENABLED=true API_UPSTREAM=http://server:8000 diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/check.jsonl b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/check.jsonl new file mode 100644 index 0000000..70fd971 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/check.jsonl @@ -0,0 +1,8 @@ +{"file":".trellis/spec/backend/selection.md","reason":"复核七个子信号、历史截断、批次状态和重跑语义未被评分改变。"} +{"file":".trellis/spec/backend/http-api-contracts.md","reason":"复核新增评分响应与后端 HTTP 测试。"} +{"file":".trellis/spec/backend/error-handling.md","reason":"复核评分失败隔离、去敏原因和选股失败语义。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"执行后端格式、lint、strict type-check 与全量测试。"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"复核评分 TypeScript 契约无 any 或不安全断言。"} +{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"执行前端格式、lint、类型、测试和构建门禁。"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查数据库到 UI 的评分字段与状态全链路一致。"} +{"file":".trellis/tasks/08-29-integrate-b1-scoring/research/scoring-analysis.md","reason":"核对实际代码权重、十案例、阈值、缓存风险和 parity 目标。"} diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/design.md b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/design.md new file mode 100644 index 0000000..4d0c5ad --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/design.md @@ -0,0 +1,162 @@ +# 知行 B1 图形相似度评分集成设计 + +## 目标与边界 + +在不改变知行 B1 七个子信号公式、命中状态和稳定身份的前提下,为每只已命中的股票计算一次 0–100 完美图形相似度。评分使用原项目实际代码中的十个案例、25 日窗口、四维特征、权重、容忍参数和 60 分阈值,并修正为真正生效的 FastDTW 曲线对齐,在选股结果页展示匹配案例与分项。 + +本设计不包含图片生成、视觉模型、1–5 主观评分、`PASS/WATCH/FAIL`、自动交易、评分独立重跑、多评分器并存或跨策略通用评分平台。评分只属于 `selection` bounded context。 + +## 当前与目标数据流 + +当前执行链: + +```text +POST selection run + -> load qfq histories in batches + -> evaluate zhixing_b1 masks + -> SelectionRunItem + category signals + -> PostgreSQL + -> GET results + -> stocks[].signals[] +``` + +目标执行链: + +```text +POST selection run + -> load the ten versioned case windows once for this run + -> build an immutable in-memory case library + -> load candidate qfq histories in existing batches + -> evaluate zhixing_b1 masks + -> if selected: score the stock once against all cases + -> SelectionRunItem(score) + unchanged category signals + -> PostgreSQL + -> GET results with stocks[].score + stocks[].signals[] +``` + +评分在公式评估之后执行。`no_signal`、`insufficient_history`、`missing_target_bar` 和 `data_error` 不运行评分;评分异常只影响该股票的评分状态,不改变 `SelectionRunItem.status`、signals 或批次的选股成功状态。 + +## 领域模型与模块边界 + +在 `modules/selection/domain/` 增加纯领域评分模块,负责案例定义、特征提取、四维匹配和结果值对象。该模块只依赖 NumPy/Pandas 与显式注入的评分配置,不导入 FastAPI、PostgreSQL 或 infrastructure。 + +建议领域类型包括: + +- `PatternCaseDefinition`:案例 ID、名称、规范化 `ts_code`、突破日和窗口长度。 +- `PatternFeatures`:趋势、KDJ、量能和价格形态四组不可变特征。 +- `PatternScoreBreakdown`:四个 0–100 有限分项。 +- `PatternScore`:状态、原始总分、阈值、最佳案例、breakdown、版本和安全原因。 +- `ZhixingB1PatternScorer`:对一个 `StockHistory` 与不可变案例库执行确定性评分。 + +评分状态与选股状态分离,使用 `not_executed`、`matched`、`below_threshold` 和 `failed`。`matched` 表示最高分大于等于 60;`below_threshold` 表示计算成功但原 pipeline 不会 enrichment;`failed` 表示评分实际执行但输入、案例库或算法失败。选股失败仍只使用已有 evaluation status。 + +应用层增加评分用例或端口,由 `RunZhixingB1` 注入。每次 run 开始时加载一次案例库,每批复用已有候选 `StockHistory`,只给 `selected` 股票评分。相同股票命中的多个 category 共享一个股票级评分,不重复计算。 + +infrastructure 负责从 PostgreSQL 读取十个案例在各自 `breakout_date` 之前的 qfq 行情。查询必须参数化、升序、严格 `< breakout_date`,每个案例取最后 25 条。生产运行不访问旧项目 CSV、旧缓存或 Tushare。 + +## 算法兼容契约 + +版本一使用固定标识 `zhixing_b1_pattern_fastdtw_v1`。以下任何变化都必须升级版本:案例集合或突破日、窗口长度、特征公式、权重、容忍参数、FastDTW 半径或距离函数、阈值或非有限值处理。 + +版本一保留原运行代码的事实值: + +| 项目 | 契约 | +| --- | --- | +| 案例数 | 10,保持缺少 `case_005` 的既有定义 | +| 候选/案例窗口 | 25 个升序交易日;案例不包含突破日 | +| 分项 | `trend_structure`、`kdj_state`、`volume_pattern`、`price_shape` | +| 权重 | 0.10、0.20、0.25、0.45 | +| 总分 | `round(weighted_sum * 100, 2)` | +| 阈值 | 60.0,比较使用 `>=` | +| 曲线距离 | 真正生效的 FastDTW;一维曲线使用标量欧氏距离,显式 `radius=1` | +| 最佳案例 | 十个案例中总分最高者;稳定同分时按案例定义顺序 | + +原文档中的 30% 趋势/25% 价格权重和 YAML 中未生效的动态权重不进入 v1。实现应把实际生效常量集中在版本化配置中,不能继续保留“配置看似可改但运行时忽略”的状态。 + +实施门禁已验证原代码的 `fastdtw(one_dimensional_curve, ..., dist=scipy.spatial.distance.euclidean)` 稳定抛出 `AxisError`,随后由 `_shape()` 回退 `_simple_dtw`。用户明确选择修正为真正生效的 FastDTW,因为允许局部时间对齐更符合评分要求。实现使用适配一维标量的欧氏距离并显式固定 `radius=1`,不依赖 SciPy 的向量函数;这会改变旧历史分数和阈值命中集合,因此必须使用新的 `zhixing_b1_pattern_fastdtw_v1` 版本,并以新 golden 锁定结果。 + +所有领域输出必须是有限数。对原 25 日窗口造成的非有限中间特征,通过 compatibility helper 复现旧 matcher 的最终比较结果,但不允许 `NaN`/`Infinity` 进入 dataclass、JSONB 或 HTTP。固定 fixture 必须覆盖该路径;没有证据证明兼容时,评分返回 `failed`,不伪造分数。 + +## 案例库构建与一致性 + +只迁移十条案例定义,不迁移原 `data/cache/b1_pattern_library_cache.json`。该缓存未被 Git 跟踪、没有失效协议且已与行情漂移,不能作为部署事实源。 + +每次 selection run 从 PostgreSQL 构建一次小型内存案例库,读取规模约为 250 行,避免跨 run 的磁盘缓存失效问题。十个案例必须全部成功、各有 25 条有效 qfq OHLCV,才将案例库标记为 ready;缺任一案例时本 run 的评分统一不可用,但选股照常执行。这个原子完整性检查是对旧项目“静默使用部分案例库”的有意收紧,避免同一个版本标识对应不同分母和结果。 + +案例特征使用数据库中当前最新修订的 qfq,符合现有历史分析语义。已落盘评分不会因后续 qfq 修订自动改变;用户显式重跑后允许得到基于最新修订数据的新分数。`market_sync_batch_id`、评分版本和案例定义共同提供解释上下文。 + +固定案例是评分模板,而不是目标交易日当时可知的市场事实。历史日期可能使用后来定义的案例,因此该分数解释为“使用 `zhixing_b1_pattern_fastdtw_v1` 模板对历史候选做相似度评价”,不能解释为无前视偏差的历史交易信号。 + +## 持久化设计 + +评分是每股一次的结果,存入 `selection_run_item`,不复制到 `selection_signal.details`。新增列建议为: + +- `score_status VARCHAR(32) NOT NULL DEFAULT 'not_executed'` +- `score_value NUMERIC(5,2) NULL` +- `score_threshold NUMERIC(5,2) NULL` +- `score_version VARCHAR(64) NULL` +- `match_case_id VARCHAR(32) NULL` +- `match_case_name VARCHAR(128) NULL` +- `match_case_breakout_date DATE NULL` +- `match_breakdown JSONB NULL` +- `score_reason TEXT NULL` + +约束保证总分和四个分项位于 0–100,`matched` 必须具备完整分数、案例、breakdown、阈值和版本;`below_threshold` 可以保留内部原始分数用于审计,但 HTTP 默认只表达“未达到 60”而不把它当作匹配结果;`failed` 不保存数值或案例,只保存去敏后的有限长度原因。旧 run 通过默认 `not_executed` 与空字段保持兼容。 + +增加 `(run_id, score_value DESC, ts_code)` 索引,为数据库级评分排序提供稳定分页。重跑仍删除旧 `selection_run` 并依赖级联清除 item/signal;不新增独立评分表,也不双写 signal details。 + +若未来需要同股多评分器、多个评分版本同时存在或评分独立重跑,再把 item 上的单份结果迁移到 `(run_id, ts_code, scorer, version)` 的独立表;当前需求不提前引入该复杂度。 + +## HTTP 与前端契约 + +`SelectionStockResponse` 增加可空的股票级 `score`: + +```json +{ + "status": "matched", + "value": 86.4, + "threshold": 60.0, + "version": "zhixing_b1_pattern_fastdtw_v1", + "case": { + "id": "case_001", + "name": "华纳药厂", + "breakout_date": "2025-05-12" + }, + "breakdown": { + "trend_structure": 71.2, + "kdj_state": 83.0, + "volume_pattern": 88.0, + "price_shape": 90.1 + }, + "reason": null +} +``` + +旧 run、未执行评分或字段全空时返回 `score: null`。评分失败返回 `status: failed` 与安全原因,但现有 signals 仍完整显示;不得把评分失败放入顶层 `failures[]`,该列表继续只表示选股评估失败。 + +结果查询增加可选 `sort=code|score_desc|score_asc`,默认 `code` 保持当前行为。排序和分页必须在 PostgreSQL 完成,稳定次级键为 `ts_code`;前端不能只排序当前页。评分筛选、只导出高分代码和独立排名暂不纳入 MVP。 + +前端在每只股票卡片/行的股票级区域展示总分、最佳案例和四个分项,七个 signal 继续展示各自原有 details。`below_threshold` 显示“未匹配到 60 分以上案例”,`failed` 显示“评分暂不可用”,两者都不能遮挡选股信号。页面提供按评分升降序的可访问控件,并保留默认代码排序。 + +## 失败、性能与并发 + +评分复用已加载的候选历史,只额外读取一次十个案例窗口。复杂度约为 `命中股票数 × 10 × 25` 的特征比较,且只对 selected 股票执行;不得为每个 category 或每个候选单独查询案例数据。 + +案例库初始化失败是 run 级评分不可用,不是选股批次失败。单股评分异常只将该股 `score_status` 置为 `failed`,其他股票继续。边界日志只记录 run ID、股票代码、评分版本和异常类型,不输出数据库连接、凭据或原始异常对象。 + +现有 FastAPI 进程内 background task 仍是执行边界;本任务不引入队列。实现必须测量新增评分耗时并写入结构化 run 日志,确认没有显著放大现有批次时长或连接池使用。 + +## 发布与回滚 + +迁移为向后兼容的可空列与索引。增加 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 配置,默认启用;紧急情况下可关闭评分,选股链恢复原行为,新 run 的 score 为 `not_executed`。 + +发布顺序为先执行数据库 upgrade,再发布同时理解新列的后端,最后发布前端。旧前端会忽略新增 JSON 字段;新前端对 `score: null` 安全降级。回滚应用时保留新增列不会影响旧代码,只有确认不再需要已保存评分时才执行 destructive downgrade。 + +## 主要风险与控制 + +- 原项目没有数值 golden:先冻结最小旧数据 fixture 和期望值,再实现迁移。 +- 原缓存漂移:不迁移缓存,每次 run 从 PostgreSQL 构建完整案例库。 +- 25 日窗口与 114 日指标产生非有限中间值:兼容 helper + 有限值断言 + golden 覆盖。 +- FastDTW 语义:使用标量欧氏距离与固定 `radius=1`,通过新 golden 锁定;不得把旧 `_simple_dtw` 期望值当作兼容目标,也不能在异常时静默退回另一种算法。 +- 同股多 category:评分只存 item 并在股票级响应展示,signal 身份和详情不变。 +- 历史模板前视解释:在 UI/文档中明确分数是当前版本模板相似度,不是历史收益承诺。 diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.jsonl b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.jsonl new file mode 100644 index 0000000..2261fce --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.jsonl @@ -0,0 +1,12 @@ +{"file":".trellis/spec/backend/index.md","reason":"后端规格入口与开发前检查。"} +{"file":".trellis/spec/backend/directory-structure.md","reason":"保持 selection bounded context 的 domain/application/infrastructure/presentation 边界。"} +{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"评分开关必须通过 Settings 与 ZHIXING_ 配置注入。"} +{"file":".trellis/spec/backend/selection.md","reason":"保护 B1 目标交易日、qfq、七子信号、批次持久化和重跑契约。"} +{"file":".trellis/spec/backend/http-api-contracts.md","reason":"新增 stocks[].score 时同步稳定 Pydantic 与同源 API 契约。"} +{"file":".trellis/spec/backend/error-handling.md","reason":"评分失败需隔离并在边界安全表达,不能吞掉选股错误。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"Python 3.12、Ruff、Pyright strict 与 pytest 实施要求。"} +{"file":".trellis/spec/frontend/index.md","reason":"前端 selection feature 与跨层字段变更入口。"} +{"file":".trellis/spec/frontend/type-safety.md","reason":"为评分响应定义严格 TypeScript 类型并同步 API 契约。"} +{"file":".trellis/spec/frontend/component-guidelines.md","reason":"在现有股票结果 UI 中以可访问方式展示评分状态和分项。"} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"评分字段贯穿后端、API、query、类型和页面测试。"} +{"file":".trellis/tasks/08-29-integrate-b1-scoring/research/scoring-analysis.md","reason":"原评分算法、案例资产、缓存风险与当前集成接缝的源码证据。"} diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.md b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.md new file mode 100644 index 0000000..ca7a4c0 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/implement.md @@ -0,0 +1,105 @@ +# 知行 B1 图形相似度评分实施计划 + +## 实施前门禁 + +- [ ] 用户明确批准本次最终规划摘要;批准前不运行 `task.py start`,不修改产品代码。 +- [ ] 使用 `trellis-before-dev` 加载 backend、frontend 与跨层规格。 +- [ ] 确认当前工作区只包含用户已有修改和本任务规划文件,记录不可覆盖的改动。 +- [x] 在 Python 3.12 下点验原 FastDTW 调用:依赖可安装/导入,但一维曲线配合 SciPy 欧氏距离稳定抛出 `AxisError`,原实现实际回退 `_simple_dtw`。 +- [x] 用户确认版本一采用真正生效的 FastDTW,接受与旧 `_simple_dtw` 分数不兼容;版本固定为 `zhixing_b1_pattern_fastdtw_v1`、标量欧氏距离、`radius=1`。 + +## 1. 冻结兼容基线 + +- [ ] 从原项目十个案例 CSV 中提取严格早于 breakout date 的最小 25 日窗口,并选取代表性的候选窗口,写入 `zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/`;不复制完整生产数据。 +- [ ] 使用原项目实际特征、权重和容忍参数,以及修正后的 FastDTW 路径离线生成期望的案例特征、四个分项、最佳案例和总分 JSON;测试运行时不导入原项目。 +- [ ] fixture 覆盖最高分大于等于 60、低于 60、同分稳定顺序、窗口不足、空案例、非有限中间特征和十案例完整性。 +- [ ] 记录原实现中被保留的行为及有意收紧的行为:实际权重优先于过时文档;完整案例库失败时不使用部分库;持久化和 HTTP 禁止非有限值。 + +回滚点:如果无法生成稳定的有限期望值,停止实现并回到规划,不猜测算法结果。 + +## 2. 实现纯领域评分 + +- [ ] 在 `modules/selection/domain/` 增加版本化案例定义、评分值对象、特征提取器、经确认的 DTW matcher 与 `ZhixingB1PatternScorer`;公开类型写完整 docstring、参数、返回值、异常与设计原因。 +- [ ] 迁移十个案例、25 日窗口、四维特征、`0.10/0.20/0.25/0.45` 权重、原容忍参数、60 分阈值和稳定 best-match 规则。 +- [ ] 集中实现有限值兼容 helper,保证领域对象从不包含 `NaN` 或 `Infinity`。 +- [ ] 增加领域单元/golden 测试,证明固定输入与旧实现期望一致且多次运行确定。 +- [ ] 更新 `pyproject.toml` 与 `uv.lock`,只引入实际运行所需依赖。 + +验证: + +```bash +cd zhixing-server +uv run pytest tests/unit/selection -q +uv run pyright +uv run ruff check . +``` + +## 3. 构建 PostgreSQL 案例库适配器 + +- [ ] 定义 selection application/domain 所需的 case history port,不让领域层依赖 psycopg。 +- [ ] 在 selection infrastructure 中实现参数化批量查询:规范化 `ts_code`、`source_adj='qfq'`、严格 `< breakout_date`、升序、每案例最后 25 行。 +- [ ] 每个 run 只读取和构建一次完整案例库;验证十个案例各有 25 条有效 OHLCV,禁止静默部分成功。 +- [ ] 用 fake connection 测试 SQL 参数、日期边界、排序、代码映射、缺失案例和数据库错误转换;有测试库时补 PostgreSQL 集成测试。 + +回滚点:案例库 adapter 独立合入前不得改变现有 selection run 结果。 + +## 4. 接入选股应用编排 + +- [ ] 给 `RunZhixingB1` 注入 scorer/case-library loader;在 run 开始时准备库,在已有 evaluator 返回 `selected` 后复用对应 `StockHistory` 评分一次。 +- [ ] 扩展 `SelectionRunItem` 承载股票级 score;保留 signals、`signal_count`、选股 status 和 reason 的原语义。 +- [ ] 评分 `failed` 或 `below_threshold` 不进入现有失败计数,不改变 run 的 `success/partial_success/failed` 聚合。 +- [ ] 单元测试 selected/no-signal/评估失败/案例库失败/单股评分失败/同股七 category 只评分一次/批次继续执行。 +- [ ] 增加 feature flag,并通过 `Settings`、依赖注入和 Compose 环境变量统一配置;业务代码不直接读取环境。 + +## 5. 扩展数据库与仓储 + +- [ ] 新建 Alembic migration,为 `selection_run_item` 增加评分状态、数值、版本、案例、breakdown、原因及排序索引;同时更新声明式 schema。 +- [ ] 增加数据库 check constraints,拒绝越界或不完整 matched 结果;旧行安全回填 `not_executed`。 +- [ ] 更新 batch upsert、run loader、重跑级联和查询对象,保持 item 与 signal 同事务落盘。 +- [ ] 增加 `code|score_desc|score_asc` 的白名单排序,数据库分页使用 `score_value` 与 `ts_code` 稳定排序;不得拼接用户原始 SQL。 +- [ ] 仓储测试覆盖 round-trip breakdown、旧行空 score、排序分页、category 过滤仍返回全部 signals、重跑清理和 migration upgrade/downgrade SQL。 + +回滚点:迁移为 additive;应用回滚时保留列。执行 downgrade 前必须确认已保存评分允许删除。 + +## 6. 扩展 HTTP 与前端 + +- [ ] 后端增加具名 Pydantic score/case/breakdown 响应模型,在 `stocks[].score` 返回股票级结果;`failures[]` 继续只表示选股评估失败。 +- [ ] HTTP 测试覆盖 matched、below-threshold、failed、旧 run `score: null`、多 category、三种排序和分页稳定性。 +- [ ] 同步更新 `selection.types.ts`、API query 参数和 React Query key,保持同源 `/api/v1` 请求。 +- [ ] 在 selection workbench 的股票级区域展示总分、案例、分项、低于阈值与评分失败状态;signals 原详情不变。 +- [ ] 增加可访问的评分排序控件,默认仍为代码排序;测试用户可见文本、控件行为和分页请求参数。 + +## 7. 全量验证与发布检查 + +- [ ] 后端执行格式、lint、strict type-check、全量测试、migration offline SQL;设置 `ZHIXING_TEST_DATABASE_URL` 时执行 PostgreSQL 集成测试。 +- [ ] 前端执行格式、lint、type-check、测试和 build。 +- [ ] 根目录执行完整门禁,并记录实际结果,不能用计划命令冒充已验证。 +- [ ] 用固定 run fixture 或本地测试库核对:选中股票与七个 signals 在开关前后完全一致,只有股票级评分字段新增。 +- [ ] 核对一次 run 只读取一次案例库、每股只评分一次、没有按 category 重复计算;记录评分耗时和数据库查询数。 +- [ ] 验证关闭 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 后旧流程仍成功、HTTP 安全返回空 score。 + +```bash +cd zhixing-server +uv run ruff format --check . +uv run ruff check . +uv run pyright +uv run pytest +uv run alembic upgrade head --sql +uv run alembic downgrade -1 --sql + +cd ../zhixing-web +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test +pnpm build + +cd .. +./dev.sh check +./dev.sh test +``` + +## 交付与后续 + +- [ ] 交付时报告算法版本、案例完整性、数值 parity、测试结果、性能数据和是否运行真实 PostgreSQL 集成测试。 +- [ ] 将“评分筛选/代码导出”“独立评分重跑”“多评分器/版本并存”“1–5 主观视觉评分”保留为独立后续需求,不在本任务顺带实现。 diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/prd.md b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/prd.md new file mode 100644 index 0000000..047f068 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/prd.md @@ -0,0 +1,50 @@ +# 知行 B1 集成原项目评分 + +## Goal + +在不改变知行 B1 选股语义的前提下,复用原项目的案例、特征、权重和阈值,并修正曲线距离为真正生效的 FastDTW,为每只 B1 命中股票提供可解释、可持久化、可验证的 0–100 最佳案例匹配结果。 + +## Background + +当前系统已具备 `POST /api/v1/selection/runs`、后台批量评估、PostgreSQL 结果持久化、结果查询与前端轮询展示;策略固定为 `zhixing_b1`,按显式目标交易日读取 qfq OHLCV,并独立保留七种子信号(`.trellis/spec/backend/selection.md:12-49,85-152`,`docs/adr/0005-selection-formula-semantics-and-independent-subsignals.md:7-23`)。评分尚未接入。 + +用户已明确本任务只迁移 Python 可执行的 0–100 图形相似度评分,不迁移 prompt 中依赖图片和大模型的 1–5 主观视觉评分。 + +实施门禁发现原源码的一维 FastDTW 调用实际抛错并回退 `_simple_dtw`;用户进一步确认版本一直接修正为真正生效的 FastDTW,因为允许局部时间对齐更符合评分要求。新分数使用独立版本,不承诺兼容旧 `_simple_dtw` 历史结果。 + +原可执行评分在候选信号产生后运行,对候选最近 25 个交易日与十个固定案例比较趋势结构、KDJ、量能和价格形态,实际权重为 `0.10/0.20/0.25/0.45`,总分为加权和乘以 100;每股只保留最高分案例,达到 `60.0` 才 enrichment(`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/config.py:8-38`,`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/matcher.py:19-128`,`/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/application/pipeline.py:168-220`)。原文档权重与运行代码不一致,YAML 动态权重也未真正注入,迁移以实际运行代码为兼容基线。 + +原案例行情与缓存均未被 Git 跟踪,缓存没有版本或失效校验且已与当前行情漂移;原项目也没有评分数值 golden 测试。完整证据记录在 `research/scoring-analysis.md`。 + +## Requirements + +- R1:评分必须是 B1 命中后的 enrichment,不参与七个 mask 的判断,不改变股票是否选中、同股多 category、signals 顺序或 `(ts_code, target_trade_date, strategy, category)` 稳定身份。 +- R2:版本一固定使用原运行代码的十个案例、25 日升序窗口、四维特征、`0.10/0.20/0.25/0.45` 权重、容忍参数、最佳案例规则和 `>= 60.0` 阈值;曲线距离使用真正生效、显式半径的 FastDTW,版本标识为 `zhixing_b1_pattern_fastdtw_v1`。算法、案例、FastDTW 半径或阈值变化必须升级评分版本。 +- R3:案例定义属于代码中的版本化业务规则;案例特征在每次 run 中从 PostgreSQL 最新 qfq 行情完整构建,严格使用突破日前最后 25 个交易日,不依赖旧项目、本地 CSV、Tushare 或旧磁盘缓存。 +- R4:每个 run 只加载一次完整案例库,每只 `selected` 股票只评分一次;同股七个 category 共享股票级评分,不能复制成 category 级规则。 +- R5:评分结果存入 `selection_run_item`,与选股 evaluation status 分离;结果包含状态、有限的 0–100 总分、60 分阈值、评分版本、最佳案例、四个有限分项和安全原因。旧 run 保持可读。 +- R6:案例库缺失、单股评分异常、低于阈值或关闭评分都不得使选股失败,也不得进入现有选股失败计数;状态必须能区分 `not_executed`、`matched`、`below_threshold` 和 `failed`。 +- R7:HTTP 在 `stocks[].score` 返回可空的股票级评分,现有 `stocks[].signals[]` 与 `failures[]` 语义不变;后端支持稳定的代码、评分升序和评分降序数据库分页。 +- R8:前端在股票级区域展示匹配分数、案例、四个分项以及低于阈值/评分失败状态,并提供评分排序;任何评分状态都不能遮挡已命中的 signals。 +- R9:所有持久化和 HTTP 数值必须有限;原 25 日窗口产生的非有限中间特征必须通过离线兼容 fixture 锁定最终行为,不能把 `NaN` 或 `Infinity` 写入数据库或响应。 +- R10:提供 `ZHIXING_SELECTION_PATTERN_SCORING_ENABLED` 运行开关;关闭后选股链维持原行为,新结果不产生评分。 + +## Acceptance Criteria + +- [ ] 离线 fixture 不依赖原项目或网络,数值 golden 覆盖十案例最佳匹配、四分项、总分、阈值边界、稳定同分、窗口不足和非有限中间值,并锁定修正后 FastDTW 版本一的确定结果。 +- [ ] 对同一固定 B1 run,开启和关闭评分得到完全相同的选中股票、七个 category、signal details 和选股批次状态,差异只在股票级评分字段。 +- [ ] 一只同时命中多个 category 的股票只调用一次 scorer,只保存和返回一个 `stocks[].score`,全部 signals 仍按既有顺序返回。 +- [ ] 十个案例均存在时,最高分 `>= 60` 的股票返回完整 matched score、案例、版本和四个分项;低于 60 时返回明确的 below-threshold 状态而不伪装成匹配。 +- [ ] 任一案例缺失或单股评分抛错时,选股继续并保留 signals;评分返回 failed/不可用状态,现有 `failed_count` 与 `failures[]` 不增加。 +- [ ] 旧 run 和关闭评分产生的 run 可由新后端与前端安全读取,`score` 为空或 not-executed,不影响原页面功能。 +- [ ] `code`、`score_desc` 和 `score_asc` 排序在 PostgreSQL 分页前执行,并以 `ts_code` 作为稳定次级键;前端不会只重排当前页。 +- [ ] 一次 run 只读取一次案例库且不按股票/category 重复查询;验证记录包含评分耗时和查询/调用次数。 +- [ ] Alembic upgrade/downgrade SQL、后端 Ruff/Pyright/pytest、前端 format/lint/typecheck/test/build 和根目录门禁全部通过;未配置 PostgreSQL 测试库时明确报告跳过项。 + +## Out of Scope + +- prompt 中的 1–5 主观视觉评分、图片生成、视觉模型调用和 `PASS/WATCH/FAIL`。 +- 改写知行 B1 公式、合并七个子信号、让评分反向决定是否入选或用于自动交易。 +- 评分独立重跑、同股多评分器或多版本并存、通用评分平台。 +- 评分阈值筛选、只导出高分股票代码和独立排名页面;MVP 只提供结果展示与排序。 +- 兼容原项目实际回退的 `_simple_dtw` 历史分数;FastDTW v1 是用户明确选择的新评分版本。 diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/research/scoring-analysis.md b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/research/scoring-analysis.md new file mode 100644 index 0000000..d486720 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/research/scoring-analysis.md @@ -0,0 +1,52 @@ +# 原项目 B1 图形相似度评分调研 + +## 结论 + +本任务迁移的是原项目 Python 已执行并持久化的 0–100 B1 完美图形相似度评分,不包含 `prompt/b1.md` 定义的 1–5 主观视觉评分。相似度评分属于 B1 命中后的 enrichment,不参与七个子信号的命中判断。 + +原执行链为 `SelectionPipeline._enrich_with_pattern_match()` 调用 `B1PatternLibrary.find_b1_best_match()`,对每只候选股票计算一次结果,再把同一结果写入该股票的信号详情。关键源码位于: + +- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/application/pipeline.py:168-220` +- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/library.py:22-101` +- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/feature_extractor.py:22-154` +- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/matcher.py:19-128` +- `/Users/yuxuanhui/bcc-github/quant-project/zgnb/zgnb-project/src/zgnb/domain/pattern/config.py:8-38` + +## 算法事实 + +候选与案例都取最近 25 个升序交易日,提取四组特征:趋势结构、KDJ 状态、量能形态和价格形态。四组实际代码权重分别为 `0.10`、`0.20`、`0.25` 和 `0.45`,总分为分项相似度加权和乘以 100,保留两位小数。文档中 `0.30/0.20/0.25/0.25` 的权重与当前运行代码不一致,不能作为迁移基线。 + +价格曲线源码先尝试 `fastdtw` 和 SciPy 欧氏距离,异常时回退 `_simple_dtw`;原项目把 `fastdtw>=0.3.4` 与 `scipy>=1.10.0` 声明为正式依赖。实施门禁在 Python 3.12 上用相同的一维数组调用点验,`scipy.spatial.distance.euclidean` 接收到标量后稳定抛出 `AxisError: axis -1 is out of bounds for array of dimension 0`,因此原 `_shape()` 实际捕获异常并使用 `_simple_dtw`。这说明旧项目落地运行结果的曲线分数来自 simple-DTW fallback,而不是 FastDTW 成功路径。匹配十个固定案例后只保留最高分案例,最高分达到 `60.0` 才向外提供 `similarity_score`、`match_case` 和四个分项。 + +案例窗口严格使用 `breakout_date` 之前的数据,不包含突破日。十个案例为 `688799.SH`、`600366.SH`、`688321.SH`、`600601.SH`、`002074.SZ`、`605378.SH`、`600184.SH`、`301076.SZ`、`002940.SZ` 和 `000547.SZ`;原编号缺少 `case_005`,迁移时保持既有十条定义,不自行补案例。 + +## 案例资产与兼容风险 + +原项目 `data/raw/` 行情和 `data/cache/b1_pattern_library_cache.json` 都被 `.gitignore` 排除,不属于可部署资产。缓存没有算法版本、案例定义哈希、行情修订或完整性校验;本机缓存与当前 CSV 重算结果已有八个案例发生差异。因此新系统不能复制该缓存作为事实源,应迁移案例定义并从 PostgreSQL 最新 qfq 行情构建案例特征。 + +原特征提取器先截取 25 行,再计算最长 114 日均线,导致部分趋势字段为非有限值。迁移必须通过固定 fixture 锁定原 matcher 对这些中间值的最终有限分数行为,禁止把 `NaN` 写入 PostgreSQL 或 HTTP。若无法得到有限、确定的结果,应将评分标记为失败,但不得改变选股结果。 + +原项目没有案例特征、窗口截断或评分数值 golden 测试,只测试了字段透传与排序。新系统必须把从旧 CSV 提取的最小窗口和离线期望结果纳入测试 fixture;测试运行时不得依赖原项目、本机缓存、Tushare 或生产数据库。 + +## FastDTW 决策 + +用户确认版本一不兼容旧 `_simple_dtw` fallback,而是直接修正为真正生效的 FastDTW,因为允许局部时间轴对齐更符合业务期望。新版本使用一维标量欧氏距离、显式 `radius=1` 和版本标识 `zhixing_b1_pattern_fastdtw_v1`;不得在 FastDTW 异常时静默切回 simple-DTW。原项目的十案例、特征、权重、容忍参数和 60 分阈值继续复用,数值 golden 以修正后的新算法为准。 + +## 当前系统接缝 + +当前 B1 执行链为 HTTP 创建 run、批量读取 `StockHistory`、并发评估、写入 `selection_run_item` 与 `selection_signal`、查询并按股票聚合到 `stocks[].signals[]`。评分是每股一次的结果,最合适的持久化位置是 `selection_run_item`,而不是每条 `selection_signal.details`。 + +关键依据: + +- `.trellis/spec/backend/selection.md` +- `zhixing-server/src/zhixing_server/modules/selection/application/run.py:91-159` +- `zhixing-server/src/zhixing_server/modules/selection/domain/runs.py:19-58` +- `zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py:184-229,355-454` +- `zhixing-server/src/zhixing_server/modules/selection/presentation/http.py:97-137,273-323` +- `zhixing-web/src/features/selection/api/selection.types.ts:36-83` + +当前结果以股票为分页实体,一股可以拥有多个 category。把 score 复制到 signal details 会造成重复与 category 语义混淆,也不利于数据库级排序。为 `selection_run_item` 增加可空、版本化的评分列能复用现有主键、批量 upsert、重跑级联与股票聚合读取。 + +## 已验证基线 + +调研阶段后端全量测试基线为 `83 passed, 2 skipped`,两个跳过项需要 `ZHIXING_TEST_DATABASE_URL`;B1 相关单元、golden 与 HTTP 测试为 `42 passed`。原项目运行点验因环境导入名不匹配失败,过程中临时产生的 `.venv` 与 `uv.lock` 已移至系统废纸篓,没有保留对原项目的改动。 diff --git a/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/task.json b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/task.json new file mode 100644 index 0000000..4219465 --- /dev/null +++ b/.trellis/tasks/archive/2026-08/08-29-integrate-b1-scoring/task.json @@ -0,0 +1,26 @@ +{ + "id": "integrate-b1-scoring", + "name": "integrate-b1-scoring", + "title": "知行 B1 集成原项目评分", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "yuxuanhui", + "assignee": "yuxuanhui", + "createdAt": "2026-08-29", + "completedAt": "2026-08-31", + "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/.trellis/workspace/yuxuanhui/index.md b/.trellis/workspace/yuxuanhui/index.md index 2a6c12c..7e1de22 100644 --- a/.trellis/workspace/yuxuanhui/index.md +++ b/.trellis/workspace/yuxuanhui/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 11 +- **Total Sessions**: 12 - **Last Active**: 2026-08-31 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~291 | Active | +| `journal-1.md` | ~313 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 12 | 2026-08-31 | 集成知行 B1 FastDTW 图形评分 | `6ce291e`, `5800661` | `codex/point` | | 11 | 2026-08-31 | 资金雷达当前上市股票资金流补拉 | `2ffd016` | `codex/sector-radar-listed-moneyflow-recovery` | | 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` | | 9 | 2026-08-12 | 完成选股执行性能优化 | `8963c06` | `develop` | diff --git a/.trellis/workspace/yuxuanhui/journal-1.md b/.trellis/workspace/yuxuanhui/journal-1.md index 90a3451..22599b3 100644 --- a/.trellis/workspace/yuxuanhui/journal-1.md +++ b/.trellis/workspace/yuxuanhui/journal-1.md @@ -289,3 +289,25 @@ ### Status [OK] **Completed** + + +## Session 12: 集成知行 B1 FastDTW 图形评分 + +**Date**: 2026-08-31 +**Task**: 集成知行 B1 FastDTW 图形评分 +**Branch**: `codex/point` + +### Summary + +实现并验证股票级 FastDTW 图形评分,合入最新 develop 后将评分迁移顺延为单一 0007 head,完成后端、前端和根目录全量门禁。 + +### Git Commits + +| Hash | Message | +|------|---------| +| `6ce291e` | (see git log) | +| `5800661` | (see git log) | + +### Status + +[OK] **Completed** diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ba70033..dc3cc47 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -43,6 +43,7 @@ services: ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} + ZHIXING_SELECTION_PATTERN_SCORING_ENABLED: ${ZHIXING_SELECTION_PATTERN_SCORING_ENABLED:-true} ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-} init: true ports: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6557ec0..39f58a6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -25,6 +25,7 @@ services: ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} + ZHIXING_SELECTION_PATTERN_SCORING_ENABLED: ${ZHIXING_SELECTION_PATTERN_SCORING_ENABLED:-true} ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-} init: true expose: diff --git a/zhixing-server/migrations/versions/0007_selection_pattern_scoring.py b/zhixing-server/migrations/versions/0007_selection_pattern_scoring.py new file mode 100644 index 0000000..a8e889a --- /dev/null +++ b/zhixing-server/migrations/versions/0007_selection_pattern_scoring.py @@ -0,0 +1,142 @@ +"""Add stock-level versioned B1 pattern scoring results.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0007_selection_pattern_scoring" +down_revision: str | None = "0006_membership_unknown" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_PATTERN_BREAKDOWN_CHECK = """ +match_breakdown IS NULL OR ( + jsonb_typeof(match_breakdown) = 'object' + AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number' + THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number' + THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number' + THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number' + THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100 + ELSE false END +) +""" + + +def upgrade() -> None: + """Add nullable scoring data while keeping existing runs readable.""" + + op.add_column( + "selection_run_item", + sa.Column( + "score_status", + sa.String(32), + nullable=False, + server_default="not_executed", + ), + ) + op.add_column("selection_run_item", sa.Column("score_value", sa.Numeric(5, 2))) + op.add_column("selection_run_item", sa.Column("score_threshold", sa.Numeric(5, 2))) + op.add_column("selection_run_item", sa.Column("score_version", sa.String(64))) + op.add_column("selection_run_item", sa.Column("match_case_id", sa.String(32))) + op.add_column("selection_run_item", sa.Column("match_case_name", sa.String(128))) + op.add_column("selection_run_item", sa.Column("match_case_breakout_date", sa.Date())) + op.add_column( + "selection_run_item", + sa.Column("match_breakdown", postgresql.JSONB(astext_type=sa.Text())), + ) + op.add_column("selection_run_item", sa.Column("score_reason", sa.Text())) + op.create_check_constraint( + "ck_selection_run_item_score_status", + "selection_run_item", + "score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')", + ) + op.create_check_constraint( + "ck_selection_run_item_score_value_range", + "selection_run_item", + "score_value IS NULL OR score_value BETWEEN 0 AND 100", + ) + op.create_check_constraint( + "ck_selection_run_item_score_threshold_range", + "selection_run_item", + "score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100", + ) + op.create_check_constraint( + "ck_selection_run_item_breakdown_range", + "selection_run_item", + _PATTERN_BREAKDOWN_CHECK, + ) + op.create_check_constraint( + "ck_selection_run_item_score_shape", + "selection_run_item", + "(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL " + "AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL " + "AND match_case_breakout_date IS NULL AND match_breakdown IS NULL " + "AND score_reason IS NULL) " + "OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL " + "AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL " + "AND match_case_breakout_date IS NULL AND match_breakdown IS NULL " + "AND score_reason IS NOT NULL) " + "OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL " + "AND score_threshold IS NOT NULL AND score_version IS NOT NULL " + "AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL " + "AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL " + "AND score_reason IS NULL " + "AND ((score_status = 'matched' AND score_value >= score_threshold) " + "OR (score_status = 'below_threshold' AND score_value < score_threshold)))", + ) + op.create_index( + "ix_selection_run_item_score", + "selection_run_item", + ["run_id", sa.text("score_value DESC"), "ts_code"], + ) + + +def downgrade() -> None: + """Remove only the additive stock-level scoring contract.""" + + op.drop_index("ix_selection_run_item_score", table_name="selection_run_item") + op.drop_constraint( + "ck_selection_run_item_score_shape", + "selection_run_item", + type_="check", + ) + op.drop_constraint( + "ck_selection_run_item_score_threshold_range", + "selection_run_item", + type_="check", + ) + op.drop_constraint( + "ck_selection_run_item_breakdown_range", + "selection_run_item", + type_="check", + ) + op.drop_constraint( + "ck_selection_run_item_score_value_range", + "selection_run_item", + type_="check", + ) + op.drop_constraint( + "ck_selection_run_item_score_status", + "selection_run_item", + type_="check", + ) + for column in ( + "score_reason", + "match_breakdown", + "match_case_breakout_date", + "match_case_name", + "match_case_id", + "score_version", + "score_threshold", + "score_value", + "score_status", + ): + op.drop_column("selection_run_item", column) diff --git a/zhixing-server/pyproject.toml b/zhixing-server/pyproject.toml index 84ccb87..7bcfb65 100644 --- a/zhixing-server/pyproject.toml +++ b/zhixing-server/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12,<3.13" dependencies = [ "alembic>=1.18.0", "fastapi>=0.141.1", + "fastdtw>=0.3.4", "numpy>=2.4.0", "pandas>=2.3.3", "psycopg[binary,pool]>=3.3.2", diff --git a/zhixing-server/src/zhixing_server/bootstrap/config.py b/zhixing-server/src/zhixing_server/bootstrap/config.py index 5374a4b..1a3ef6c 100644 --- a/zhixing-server/src/zhixing_server/bootstrap/config.py +++ b/zhixing-server/src/zhixing_server/bootstrap/config.py @@ -31,6 +31,7 @@ class Settings(BaseSettings): sector_radar_advisory_lock_key: int = 7_380_522 selection_max_workers: int = Field(default=4, ge=1) selection_batch_size: int = Field(default=200, ge=1) + selection_pattern_scoring_enabled: bool = True model_config = SettingsConfigDict( env_file=".env", diff --git a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py index c2aab64..c559920 100644 --- a/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py +++ b/zhixing-server/src/zhixing_server/modules/market_data/infrastructure/schema.py @@ -2,6 +2,7 @@ from sqlalchemy import ( Boolean, + CheckConstraint, Column, Date, DateTime, @@ -22,6 +23,24 @@ from sqlalchemy.dialects.postgresql import JSONB metadata = MetaData() +_PATTERN_BREAKDOWN_CHECK = """ +match_breakdown IS NULL OR ( + jsonb_typeof(match_breakdown) = 'object' + AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number' + THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number' + THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number' + THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100 + ELSE false END + AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number' + THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100 + ELSE false END +) +""" + market_stock = Table( "market_stock", metadata, @@ -153,8 +172,51 @@ selection_run_item = Table( Column("status", String(32), nullable=False), Column("signal_count", Integer, nullable=False, server_default="0"), Column("reason", Text), + Column("score_status", String(32), nullable=False, server_default="not_executed"), + Column("score_value", Numeric(5, 2)), + Column("score_threshold", Numeric(5, 2)), + Column("score_version", String(64)), + Column("match_case_id", String(32)), + Column("match_case_name", String(128)), + Column("match_case_breakout_date", Date), + Column("match_breakdown", JSONB), + Column("score_reason", Text), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), PrimaryKeyConstraint("run_id", "ts_code"), + CheckConstraint( + "score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')", + name="ck_selection_run_item_score_status", + ), + CheckConstraint( + "score_value IS NULL OR score_value BETWEEN 0 AND 100", + name="ck_selection_run_item_score_value_range", + ), + CheckConstraint( + "score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100", + name="ck_selection_run_item_score_threshold_range", + ), + CheckConstraint( + _PATTERN_BREAKDOWN_CHECK, + name="ck_selection_run_item_breakdown_range", + ), + CheckConstraint( + "(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL " + "AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL " + "AND match_case_breakout_date IS NULL AND match_breakdown IS NULL " + "AND score_reason IS NULL) " + "OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL " + "AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL " + "AND match_case_breakout_date IS NULL AND match_breakdown IS NULL " + "AND score_reason IS NOT NULL) " + "OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL " + "AND score_threshold IS NOT NULL AND score_version IS NOT NULL " + "AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL " + "AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL " + "AND score_reason IS NULL " + "AND ((score_status = 'matched' AND score_value >= score_threshold) " + "OR (score_status = 'below_threshold' AND score_value < score_threshold)))", + name="ck_selection_run_item_score_shape", + ), ) selection_signal = Table( @@ -223,6 +285,12 @@ Index( selection_run.c.target_trade_date, ) Index("ix_selection_run_item_status", selection_run_item.c.run_id, selection_run_item.c.status) +Index( + "ix_selection_run_item_score", + selection_run_item.c.run_id, + selection_run_item.c.score_value.desc(), + selection_run_item.c.ts_code, +) Index( "ix_selection_signal_strategy_date", selection_signal.c.strategy, diff --git a/zhixing-server/src/zhixing_server/modules/selection/application/run.py b/zhixing-server/src/zhixing_server/modules/selection/application/run.py index 8f87e02..971d996 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/application/run.py +++ b/zhixing-server/src/zhixing_server/modules/selection/application/run.py @@ -11,6 +11,12 @@ from datetime import date from typing import Literal, Protocol, cast from ..domain.models import SelectionEvaluation, StockHistory +from ..domain.pattern_scoring import ( + PatternCase, + PatternCaseLibraryLoader, + PatternScore, + PatternScorer, +) from ..domain.runs import ( BatchSelectionRunStore, BatchSelectionUniverseReader, @@ -54,7 +60,10 @@ class RunZhixingB1: reader: SelectionUniverseReader, store: SelectionRunStore, evaluator: SelectionEvaluator | None = None, + pattern_case_loader: PatternCaseLibraryLoader | None = None, + pattern_scorer: PatternScorer | None = None, *, + pattern_scoring_enabled: bool = False, max_workers: int = 4, batch_size: int = 200, ) -> None: @@ -67,6 +76,9 @@ class RunZhixingB1: self.reader = reader self.store = store self.evaluator = evaluator or EvaluateZhixingB1(reader) + self.pattern_case_loader = pattern_case_loader + self.pattern_scorer = pattern_scorer + self.pattern_scoring_enabled = pattern_scoring_enabled self.max_workers = max_workers self.batch_size = batch_size @@ -106,7 +118,9 @@ class RunZhixingB1: read_seconds = 0.0 evaluate_seconds = 0.0 persist_seconds = 0.0 + scoring_seconds = 0.0 try: + pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id) with ThreadPoolExecutor(max_workers=self.max_workers) as executor: for batch_stocks in _chunks(stocks, self.batch_size): read_started = time.perf_counter() @@ -120,24 +134,39 @@ class RunZhixingB1: ) evaluate_started = time.perf_counter() + evaluations = tuple( + executor.map( + self._evaluate_stock, + batch_stocks, + histories, + [prepared.source.target_trade_date] * len(batch_stocks), + ) + ) + evaluate_seconds += time.perf_counter() - evaluate_started + + scoring_started = time.perf_counter() items = tuple( _to_item( stock.ts_code, stock.name, evaluation, - ) - for stock, evaluation in zip( - batch_stocks, - executor.map( - self._evaluate_stock, - batch_stocks, - histories, - [prepared.source.target_trade_date] * len(batch_stocks), + pattern_score=self._score_stock( + prepared.run.id, + stock, + history, + evaluation, + pattern_cases, + pattern_library_error, ), + ) + for stock, history, evaluation in zip( + batch_stocks, + histories, + evaluations, strict=True, ) ) - evaluate_seconds += time.perf_counter() - evaluate_started + scoring_seconds += time.perf_counter() - scoring_started evaluated_count += len(items) selected_stock_count += sum(item.status == "selected" for item in items) @@ -184,7 +213,7 @@ class RunZhixingB1: logger.info( "selection_run_summary run_id=%s stock_count=%d history_rows=%d " "batch_count=%d worker_count=%d read_seconds=%.3f " - "evaluate_seconds=%.3f persist_seconds=%.3f", + "evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f", prepared.run.id, len(stocks), history_rows, @@ -192,9 +221,64 @@ class RunZhixingB1: self.max_workers, read_seconds, evaluate_seconds, + scoring_seconds, persist_seconds, ) + def _prepare_pattern_cases( + self, + run_id: str, + ) -> tuple[tuple[PatternCase, ...] | None, str | None]: + """Load the complete case library once without failing selection.""" + + if not self.pattern_scoring_enabled: + return None, None + if self.pattern_case_loader is None or self.pattern_scorer is None: + reason = "pattern scoring is enabled but not configured" + logger.error("selection_pattern_library_failed run_id=%s reason=%s", run_id, reason) + return None, reason + try: + return self.pattern_case_loader.load(), None + except Exception as exc: # noqa: BLE001 - scoring enrichment must not fail selection + reason = _safe_item_error(exc) + logger.warning( + "selection_pattern_library_failed run_id=%s error_type=%s reason=%s", + run_id, + exc.__class__.__name__, + reason, + ) + return None, reason + + def _score_stock( + self, + run_id: str, + stock: SelectionStock, + history: StockHistory | None, + evaluation: SelectionEvaluation, + cases: tuple[PatternCase, ...] | None, + library_error: str | None, + ) -> PatternScore: + """Score one selected stock once and isolate enrichment failures.""" + + if not self.pattern_scoring_enabled or evaluation.status != "selected": + return PatternScore() + if library_error is not None: + return PatternScore.failed(library_error) + if history is None or cases is None or self.pattern_scorer is None: + return PatternScore.failed("pattern scoring history or case library is unavailable") + try: + return self.pattern_scorer.score(history, cases) + except Exception as exc: # noqa: BLE001 - one score must not fail the selection run + reason = _safe_item_error(exc) + logger.warning( + "selection_pattern_score_failed run_id=%s ts_code=%s error_type=%s reason=%s", + run_id, + stock.ts_code, + exc.__class__.__name__, + reason, + ) + return PatternScore.failed(reason) + def _load_histories( self, stocks: Sequence[SelectionStock], @@ -287,7 +371,13 @@ class RunZhixingB1: return self.store.get_latest_run(strategy, target_trade_date, query=query) -def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem: +def _to_item( + ts_code: str, + name: str, + evaluation: SelectionEvaluation, + *, + pattern_score: PatternScore | None = None, +) -> SelectionRunItem: """Translate a single-stock domain result into a stored item.""" return SelectionRunItem( @@ -296,6 +386,7 @@ def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> Select status=evaluation.status, signal_count=len(evaluation.signals), reason=evaluation.reason, + pattern_score=pattern_score or PatternScore(), signals=evaluation.signals, ) diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/pattern_scoring.py b/zhixing-server/src/zhixing_server/modules/selection/domain/pattern_scoring.py new file mode 100644 index 0000000..1b7d66c --- /dev/null +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/pattern_scoring.py @@ -0,0 +1,572 @@ +"""Versioned Zhixing B1 pattern-similarity scoring. + +The module deliberately keeps the algorithm and its ten case definitions in +one bounded-context-owned contract. Infrastructure supplies qfq histories; +the scorer performs no I/O and never falls back to a different DTW algorithm. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import date +from math import isfinite +from numbers import Real +from typing import Literal, Protocol, cast + +import numpy as np +import pandas as pd +from fastdtw import fastdtw # type: ignore[reportMissingTypeStubs] + +from .models import SelectionBar, StockHistory + +PATTERN_SCORING_VERSION = "zhixing_b1_pattern_fastdtw_v1" +PATTERN_LOOKBACK_DAYS = 25 +PATTERN_SCORE_THRESHOLD = 60.0 +PATTERN_FASTDTW_RADIUS = 1 +PATTERN_WEIGHTS = { + "trend_structure": 0.10, + "kdj_state": 0.20, + "volume_pattern": 0.25, + "price_shape": 0.45, +} +PATTERN_TOLERANCES = { + "trend_ratio": 0.10, + "price_bias": 10.0, + "trend_spread": 10.0, + "j_value": 30.0, + "drawdown": 15.0, +} + +PatternScoreStatus = Literal["not_executed", "matched", "below_threshold", "failed"] + + +@dataclass(frozen=True, slots=True) +class PatternCaseDefinition: + """A versioned pattern template and its exclusive breakout boundary.""" + + id: str + name: str + ts_code: str + breakout_date: date + lookback_days: int = PATTERN_LOOKBACK_DAYS + + +ZHIXING_B1_PATTERN_CASES: tuple[PatternCaseDefinition, ...] = ( + PatternCaseDefinition("case_001", "华纳药厂", "688799.SH", date(2025, 5, 12)), + PatternCaseDefinition("case_002", "宁波韵升", "600366.SH", date(2025, 8, 6)), + PatternCaseDefinition("case_003", "微芯生物", "688321.SH", date(2025, 6, 20)), + PatternCaseDefinition("case_004", "方正科技", "600601.SH", date(2025, 7, 23)), + PatternCaseDefinition("case_006", "国轩高科", "002074.SZ", date(2025, 8, 4)), + PatternCaseDefinition("case_007", "野马电池", "605378.SH", date(2025, 8, 1)), + PatternCaseDefinition("case_008", "光电股份", "600184.SH", date(2025, 7, 10)), + PatternCaseDefinition("case_009", "新瀚新材", "301076.SZ", date(2025, 8, 1)), + PatternCaseDefinition("case_010", "昂利康", "002940.SZ", date(2025, 7, 11)), + PatternCaseDefinition("case_011", "航天发展", "000547.SZ", date(2025, 11, 12)), +) + + +@dataclass(frozen=True, slots=True) +class PatternFeatures: + """Immutable, finite-or-null features used by the matcher.""" + + trend_structure: Mapping[str, float | bool | None] + kdj_state: Mapping[str, float | bool | str | None] + volume_pattern: Mapping[str, float | bool | str | int | None] + price_shape: Mapping[str, float | str | int | tuple[float, ...] | None] + + +@dataclass(frozen=True, slots=True) +class PatternCase: + """One complete case history with precomputed immutable features.""" + + definition: PatternCaseDefinition + history: StockHistory + features: PatternFeatures + + +@dataclass(frozen=True, slots=True) +class PatternScoreBreakdown: + """Finite 0-100 scores for the four versioned pattern dimensions.""" + + trend_structure: float + kdj_state: float + volume_pattern: float + price_shape: float + + def __post_init__(self) -> None: + """Reject non-finite or out-of-range values before persistence.""" + + for name in ("trend_structure", "kdj_state", "volume_pattern", "price_shape"): + _validate_score(getattr(self, name), name) + + def as_dict(self) -> dict[str, float]: + """Return the JSONB/HTTP field names without exposing dataclass internals.""" + + return { + "trend_structure": self.trend_structure, + "kdj_state": self.kdj_state, + "volume_pattern": self.volume_pattern, + "price_shape": self.price_shape, + } + + +@dataclass(frozen=True, slots=True) +class PatternScore: + """One stock-level scoring outcome independent of selection status.""" + + status: PatternScoreStatus = "not_executed" + value: float | None = None + threshold: float | None = None + version: str | None = None + case: PatternCaseDefinition | None = None + breakdown: PatternScoreBreakdown | None = None + reason: str | None = None + + def __post_init__(self) -> None: + """Enforce complete successful results and value-free failures.""" + + if self.status in {"matched", "below_threshold"}: + if ( + self.value is None + or self.threshold is None + or self.version is None + or self.case is None + or self.breakdown is None + ): + raise ValueError("computed pattern score requires complete match context") + _validate_score(self.value, "value") + _validate_score(self.threshold, "threshold") + if (self.value >= self.threshold) != (self.status == "matched"): + raise ValueError("pattern score status must agree with threshold") + elif any( + value is not None + for value in (self.value, self.threshold, self.version, self.case, self.breakdown) + ): + raise ValueError("uncomputed pattern score cannot carry match values") + if self.status == "failed" and not (self.reason and self.reason.strip()): + raise ValueError("failed pattern score requires a safe reason") + if self.status == "not_executed" and self.reason is not None: + raise ValueError("not-executed pattern score cannot carry a reason") + + @classmethod + def failed(cls, reason: str) -> PatternScore: + """Create a safe failure without retaining raw exception details.""" + + normalized = " ".join(reason.split())[:500] or "pattern scoring failed" + return cls(status="failed", reason=normalized) + + +class PatternCaseLibraryError(RuntimeError): + """The immutable ten-case library could not be loaded completely.""" + + +class PatternScoringError(RuntimeError): + """A candidate could not be scored under the versioned algorithm.""" + + +class PatternCaseLibraryLoader(Protocol): + """Load the complete versioned case library once for a selection run.""" + + def load(self) -> tuple[PatternCase, ...]: ... + + +class PatternScorer(Protocol): + """Score one selected stock against an already prepared case library.""" + + def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore: ... + + +class PatternFeatureExtractor: + """Reproduce the legacy 25-row feature formulas with finite outputs.""" + + def extract(self, history: StockHistory) -> PatternFeatures: + """Extract features from the latest 25 ascending, complete OHLCV rows. + + Raises: + PatternScoringError: If the history does not contain exactly the + required complete window or dates are not strictly ascending. + """ + + bars = history.bars[-PATTERN_LOOKBACK_DAYS:] + _validate_window(bars, history.ts_code) + frame = pd.DataFrame( + { + "open": [bar.open for bar in bars], + "high": [bar.high for bar in bars], + "low": [bar.low for bar in bars], + "close": [bar.close for bar in bars], + "volume": [bar.volume for bar in bars], + }, + dtype=float, + ) + white = frame["close"].ewm(span=10, adjust=False).mean() + white = white.ewm(span=10, adjust=False).mean() + yellow = ( + frame["close"].rolling(14, min_periods=14).mean() + + frame["close"].rolling(28, min_periods=28).mean() + + frame["close"].rolling(57, min_periods=57).mean() + + frame["close"].rolling(114, min_periods=114).mean() + ) / 4.0 + frame["short_term_trend"] = white + frame["bull_bear_line"] = yellow + frame = _legacy_kdj(frame) + return PatternFeatures( + trend_structure=_trend_features(frame), + kdj_state=_kdj_features(frame), + volume_pattern=_volume_features(frame), + price_shape=_price_features(frame), + ) + + +class ZhixingB1PatternScorer: + """Select the stable best case using working scalar FastDTW radius one.""" + + def __init__(self, extractor: PatternFeatureExtractor | None = None) -> None: + """Inject an extractor for deterministic unit tests.""" + + self.extractor = extractor or PatternFeatureExtractor() + + def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore: + """Score one selected history once against all ten ordered cases. + + Raises: + PatternScoringError: If the library is incomplete or FastDTW + cannot produce a finite distance. No alternative algorithm is + used when FastDTW fails. + """ + + if tuple(case.definition for case in cases) != ZHIXING_B1_PATTERN_CASES: + raise PatternScoringError("pattern case library is incomplete or out of order") + candidate = self.extractor.extract(history) + best: tuple[float, PatternCase, PatternScoreBreakdown] | None = None + for case in cases: + breakdown = _match(candidate, case.features) + value = round( + sum( + breakdown.as_dict()[name] / 100.0 * weight + for name, weight in PATTERN_WEIGHTS.items() + ) + * 100.0, + 2, + ) + _validate_score(value, "value") + if best is None or value > best[0]: + best = (value, case, breakdown) + if best is None: + raise PatternScoringError("pattern case library is empty") + value, case, breakdown = best + return PatternScore( + status="matched" if value >= PATTERN_SCORE_THRESHOLD else "below_threshold", + value=value, + threshold=PATTERN_SCORE_THRESHOLD, + version=PATTERN_SCORING_VERSION, + case=case.definition, + breakdown=breakdown, + ) + + +def build_pattern_case( + definition: PatternCaseDefinition, + history: StockHistory, + extractor: PatternFeatureExtractor | None = None, +) -> PatternCase: + """Validate and precompute one versioned case for run-wide reuse.""" + + if history.ts_code != definition.ts_code: + raise PatternCaseLibraryError(f"case {definition.id} code does not match definition") + if len(history.bars) != definition.lookback_days: + raise PatternCaseLibraryError( + f"case {definition.id} requires {definition.lookback_days} complete rows" + ) + try: + features = (extractor or PatternFeatureExtractor()).extract(history) + except PatternScoringError as exc: + raise PatternCaseLibraryError(f"case {definition.id} history is invalid") from exc + return PatternCase(definition=definition, history=history, features=features) + + +def _match(candidate: PatternFeatures, case: PatternFeatures) -> PatternScoreBreakdown: + return PatternScoreBreakdown( + trend_structure=round(_trend_similarity(candidate, case) * 100.0, 2), + kdj_state=round(_kdj_similarity(candidate, case) * 100.0, 2), + volume_pattern=round(_volume_similarity(candidate, case) * 100.0, 2), + price_shape=round(_price_similarity(candidate, case) * 100.0, 2), + ) + + +def _trend_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float: + c, s = candidate.trend_structure, case.trend_structure + values = [ + _difference_similarity(c.get("short_vs_bullbear"), s.get("short_vs_bullbear"), 0.10), + _slope_similarity(c.get("short_slope"), s.get("short_slope")), + 1.0 if c.get("is_in_bowl") == s.get("is_in_bowl") else 0.2, + _difference_similarity(c.get("price_vs_short_pct"), s.get("price_vs_short_pct"), 10.0), + _difference_similarity(c.get("trend_spread_pct"), s.get("trend_spread_pct"), 10.0), + _difference_similarity(c.get("price_bias_pct"), s.get("price_bias_pct"), 10.0), + ] + return float(np.mean(values)) + + +def _kdj_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float: + c, s = candidate.kdj_state, case.kdj_state + values = [ + 1.0 if c.get("j_position") == s.get("j_position") else 0.4, + _difference_similarity(c.get("j_value"), s.get("j_value"), 30.0), + 1.0 if c.get("k_cross_d") == s.get("k_cross_d") else 0.6, + 1.0 if c.get("j_rebound") == s.get("j_rebound") else 0.7, + ] + return float(np.mean(values)) + + +def _volume_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float: + c, s = candidate.volume_pattern, case.volume_pattern + values = [ + _difference_similarity(c.get("avg_volume_ratio"), s.get("avg_volume_ratio"), 1.5), + 1.0 if c.get("shrink_then_expand") == s.get("shrink_then_expand") else 0.5, + 1.0 if c.get("volume_trend") == s.get("volume_trend") else 0.6, + _difference_similarity(c.get("max_volume_ratio"), s.get("max_volume_ratio"), 3.0), + ] + return float(np.mean(values)) + + +def _price_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float: + c, s = candidate.price_shape, case.price_shape + candidate_curve = cast(tuple[float, ...], c.get("normalized_curve")) + case_curve = cast(tuple[float, ...], s.get("normalized_curve")) + distance, _path = _fastdtw()( # radius and scalar metric are versioned behavior + candidate_curve, + case_curve, + radius=PATTERN_FASTDTW_RADIUS, + dist=_scalar_euclidean, + ) + if not isfinite(float(distance)): + raise PatternScoringError("FastDTW returned a non-finite distance") + values = [ + max(0.0, 1.0 - float(distance) / max(len(candidate_curve), len(case_curve))), + _difference_similarity(c.get("max_drawdown"), s.get("max_drawdown"), 15.0), + _difference_similarity(c.get("breakout_strength"), s.get("breakout_strength"), 5.0), + 1.0 if c.get("overall_trend") == s.get("overall_trend") else 0.5, + _difference_similarity(c.get("consolidation_days"), s.get("consolidation_days"), 10.0), + ] + return float(np.mean(values)) + + +def _fastdtw() -> Callable[..., tuple[float, list[tuple[int, int]]]]: + """Give the untyped extension one narrow, checked call signature.""" + + return cast(Callable[..., tuple[float, list[tuple[int, int]]]], fastdtw) + + +def _scalar_euclidean(left: float, right: float) -> float: + """Return Euclidean distance for scalar one-dimensional curve points.""" + + return abs(float(left) - float(right)) + + +def _difference_similarity(left: object, right: object, tolerance: float) -> float: + left_number = _finite_float(left) + right_number = _finite_float(right) + if left_number is None or right_number is None: + return 0.0 + return max(0.0, 1.0 - abs(left_number - right_number) / tolerance) + + +def _slope_similarity(left: object, right: object) -> float: + left_number = _finite_float(left) + right_number = _finite_float(right) + if left_number is None or right_number is None: + return 0.0 + difference = abs(left_number - right_number) + if (left_number > 0) == (right_number > 0): + return max(0.7, 1.0 - difference / 10.0) + return max(0.0, 0.3 - difference / 20.0) + + +def _trend_features(frame: pd.DataFrame) -> dict[str, float | bool | None]: + latest = frame.iloc[-1] + short = float(latest["short_term_trend"]) + bullbear = float(latest["bull_bear_line"]) + short_previous = float(frame["short_term_trend"].iloc[-5]) + bullbear_previous = float(frame["bull_bear_line"].iloc[-5]) + close = float(latest["close"]) + average = (short + bullbear) / 2.0 + return { + "short_vs_bullbear": _finite_round(short / bullbear if bullbear else 1.0, 4), + "short_slope": _finite_round( + (short / short_previous - 1.0) * 100.0 if short_previous else 0.0, + 4, + ), + "bullbear_slope": _finite_round( + (bullbear / bullbear_previous - 1.0) * 100.0 if bullbear_previous else 0.0, + 4, + ), + "price_vs_short_pct": _finite_round((close - short) / short * 100.0 if short else 0.0, 4), + "price_vs_bullbear_pct": _finite_round( + (close - bullbear) / bullbear * 100.0 if bullbear else 0.0, + 4, + ), + "is_in_bowl": bool(short > close > bullbear), + "trend_spread_pct": _finite_round( + (short - bullbear) / bullbear * 100.0 if bullbear else 0.0, + 4, + ), + "price_bias_pct": _finite_round((close - average) / average * 100.0 if average else 0.0, 4), + } + + +def _kdj_features(frame: pd.DataFrame) -> dict[str, float | bool | str | None]: + latest = frame.iloc[-1] + j_values = frame["J"].to_numpy(dtype=float) + recent = j_values[-5:] + j_trend = float(np.polyfit(np.arange(5), recent, 1)[0]) if np.isfinite(recent).all() else 0.0 + previous = frame.iloc[-2] + j_value = float(latest["J"]) if pd.notna(latest["J"]) else 50.0 + return { + "j_value": _finite_round(j_value, 2), + "j_trend": _finite_round(j_trend, 4), + "j_min_lookback": _finite_round(float(frame["J"].min()), 2), + "k_cross_d": bool(previous["K"] < previous["D"] and latest["K"] > latest["D"]), + "j_position": "低位" if j_value <= 20 else ("高位" if j_value >= 80 else "中位"), + "j_rebound": bool(j_values[-1] > j_values[-3]), + } + + +def _volume_features(frame: pd.DataFrame) -> dict[str, float | bool | str | int | None]: + volumes = frame["volume"].to_numpy(dtype=float) + recent_average = float(np.mean(volumes[-10:])) + before_average = float(np.mean(volumes[-20:-10])) + average_ratio = recent_average / before_average if before_average > 0 else 1.0 + ratios = [ + volumes[index] / volumes[index - 1] for index in range(1, 20) if volumes[index - 1] > 0 + ] + midpoint = len(volumes) // 2 + early, late = float(np.mean(volumes[:midpoint])), float(np.mean(volumes[midpoint:])) + shrink_expand = bool(late > early * 1.3 and early < float(np.mean(volumes)) * 0.9) + key_count = sum( + 1 + for index in range(1, len(frame)) + if frame["volume"].iloc[index] > frame["volume"].iloc[index - 1] * 2 + and frame["close"].iloc[index] > frame["open"].iloc[index] + ) + slope = float(np.polyfit(np.arange(len(volumes)), volumes, 1)[0]) + slope_pct = slope / float(np.mean(volumes)) * 100.0 if float(np.mean(volumes)) > 0 else 0.0 + trend = ( + "持续放量" + if slope_pct > 5 + else "持续缩量" + if slope_pct < -5 + else "缩量后放量" + if shrink_expand + else "量能平稳" + ) + return { + "avg_volume_ratio": _finite_round(average_ratio, 2), + "max_volume_ratio": _finite_round(max(ratios, default=1.0), 2), + "volume_trend": trend, + "key_candles_count": key_count, + "shrink_then_expand": shrink_expand, + } + + +def _price_features(frame: pd.DataFrame) -> dict[str, float | str | int | tuple[float, ...] | None]: + closes = frame["close"].to_numpy(dtype=float) + minimum, maximum = float(closes.min()), float(closes.max()) + normalized = ( + tuple(float(value) for value in (closes - minimum) / (maximum - minimum)) + if maximum > minimum + else (0.0,) * len(closes) + ) + peak = np.maximum.accumulate(closes) + max_drawdown = float(((peak - closes) / peak).max()) * 100.0 + breakout = (closes[-1] / closes[-2] - 1.0) * 100.0 + returns = np.diff(closes) / closes[:-1] + volatility = float(np.std(returns)) * 100.0 + consolidation, current = 0, 0 + for index in range(len(frame) - 5): + window = closes[index : index + 5] + if window.max() > 0 and (window.max() - window.min()) / window.max() < 0.05: + current += 1 + consolidation = max(consolidation, current) + else: + current = 0 + trend = ( + "上升" + if closes[-1] > closes[0] * 1.05 + else "下降" + if closes[-1] < closes[0] * 0.95 + else "震荡" + ) + return { + "consolidation_days": consolidation, + "max_drawdown": _finite_round(max_drawdown, 2), + "breakout_strength": _finite_round(breakout, 2), + "normalized_curve": normalized, + "volatility": _finite_round(volatility, 4), + "overall_trend": trend, + } + + +def _legacy_kdj(frame: pd.DataFrame) -> pd.DataFrame: + low = frame["low"].rolling(window=9, min_periods=1).min() + high = frame["high"].rolling(window=9, min_periods=1).max() + rsv = ((frame["close"] - low) / (high - low + 1e-9) * 100.0).to_numpy(dtype=float) + k = np.empty(len(rsv), dtype=float) + d = np.empty(len(rsv), dtype=float) + k[0] = d[0] = 50.0 + for index in range(1, len(rsv)): + k[index] = 2.0 / 3.0 * k[index - 1] + 1.0 / 3.0 * rsv[index] + d[index] = 2.0 / 3.0 * d[index - 1] + 1.0 / 3.0 * k[index] + return frame.assign(K=k, D=d, J=3.0 * k - 2.0 * d) + + +def _validate_window(bars: Sequence[SelectionBar], ts_code: str) -> None: + if len(bars) != PATTERN_LOOKBACK_DAYS: + raise PatternScoringError(f"{ts_code} requires {PATTERN_LOOKBACK_DAYS} complete rows") + if any( + left.trade_date >= right.trade_date for left, right in zip(bars, bars[1:], strict=False) + ): + raise PatternScoringError(f"{ts_code} pattern rows must be strictly ascending") + if any( + value is None + for bar in bars + for value in (bar.open, bar.high, bar.low, bar.close, bar.volume) + ): + raise PatternScoringError(f"{ts_code} pattern rows require complete OHLCV") + + +def _finite_float(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, Real): + return None + number = float(value) + return number if isfinite(number) else None + + +def _finite_round(value: float, digits: int) -> float | None: + return round(float(value), digits) if isfinite(float(value)) else None + + +def _validate_score(value: float, name: str) -> None: + if not isfinite(value) or value < 0 or value > 100: + raise ValueError(f"{name} must be finite and between 0 and 100") + + +__all__ = [ + "PATTERN_FASTDTW_RADIUS", + "PATTERN_LOOKBACK_DAYS", + "PATTERN_SCORE_THRESHOLD", + "PATTERN_SCORING_VERSION", + "PatternCase", + "PatternCaseDefinition", + "PatternCaseLibraryError", + "PatternCaseLibraryLoader", + "PatternFeatureExtractor", + "PatternFeatures", + "PatternScore", + "PatternScoreBreakdown", + "PatternScorer", + "PatternScoringError", + "ZHIXING_B1_PATTERN_CASES", + "ZhixingB1PatternScorer", + "build_pattern_case", +] diff --git a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py index 9ccf519..8ca5779 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/domain/runs.py @@ -9,10 +9,12 @@ from decimal import Decimal from typing import Literal, Protocol from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory +from .pattern_scoring import PatternScore SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunItemStatus = SelectionEvaluationStatus SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"] +SelectionResultSort = Literal["code", "score_desc", "score_asc"] @dataclass(frozen=True, slots=True) @@ -23,6 +25,7 @@ class SelectionResultQuery: page_size: int = 10 search: str | None = None category: SelectionSignalCategoryFilter | None = None + sort: SelectionResultSort = "code" @dataclass(frozen=True, slots=True) @@ -54,6 +57,7 @@ class SelectionRunItem: status: SelectionRunItemStatus signal_count: int = 0 reason: str | None = None + pattern_score: PatternScore = field(default_factory=PatternScore) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py index c7123c1..951aac2 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py @@ -12,6 +12,12 @@ import psycopg from ....bootstrap.config import Settings from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory +from ..domain.pattern_scoring import ( + ZHIXING_B1_PATTERN_CASES, + PatternCase, + PatternCaseLibraryError, + build_pattern_case, +) from ..domain.ports import MarketDataReaderError from ..domain.runs import SelectionExecutionSource, SelectionStock from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool @@ -103,6 +109,38 @@ WHERE stock.is_active = true ORDER BY stock.ts_code """ +_PATTERN_CASES_QUERY = """ +WITH case_definition AS ( + SELECT * + FROM unnest(%s::text[], %s::text[], %s::date[], %s::integer[]) + AS definition(case_id, ts_code, breakout_date, lookback_days) +), ranked AS ( + SELECT + definition.case_id, + bar.ts_code, + bar.trade_date, + bar.open, + bar.high, + bar.low, + bar.close, + bar.vol, + row_number() OVER ( + PARTITION BY definition.case_id + ORDER BY bar.trade_date DESC + ) AS recency_rank, + definition.lookback_days + FROM case_definition AS definition + JOIN market_daily_bar AS bar + ON bar.ts_code = definition.ts_code + AND bar.source_adj = 'qfq' + AND bar.trade_date < definition.breakout_date +) +SELECT case_id, ts_code, trade_date, open, high, low, close, vol +FROM ranked +WHERE recency_rank <= lookback_days +ORDER BY case_id ASC, trade_date ASC +""" + def _as_date(value: object) -> date: """Convert a PostgreSQL date-like scalar to a date.""" @@ -386,3 +424,106 @@ class PostgresMarketDataReader: raise except Exception as exc: # noqa: BLE001 - normalize pool/driver failures raise SelectionReaderError("selection database operation failed") from exc + + +class PostgresPatternCaseLibraryLoader: + """Build the complete versioned FastDTW case library from PostgreSQL qfq bars.""" + + def __init__( + self, + settings: Settings | str, + *, + pool: SelectionPostgresPool | SelectionConnectionPool | None = None, + ) -> None: + """Create a loader sharing the process selection connection pool.""" + + self.database_url = settings.database_url if isinstance(settings, Settings) else settings + if isinstance(pool, SelectionPostgresPool): + self.pool: SelectionPostgresPool | None = pool + elif pool is not None: + self.pool = SelectionPostgresPool(self.database_url, max_connections=1, pool=pool) + else: + self.pool = None + + def load(self) -> tuple[PatternCase, ...]: + """Load all ten exclusive pre-breakout windows exactly once. + + Returns: + Ordered, feature-precomputed cases matching the versioned definitions. + + Raises: + PatternCaseLibraryError: If the query fails or any case lacks a + complete finite 25-row qfq window. + """ + + definitions = ZHIXING_B1_PATTERN_CASES + parameters = ( + [definition.id for definition in definitions], + [definition.ts_code for definition in definitions], + [definition.breakout_date for definition in definitions], + [definition.lookback_days for definition in definitions], + ) + try: + with self._connection() as connection: + rows = connection.execute(_PATTERN_CASES_QUERY, parameters).fetchall() + rows_by_case: dict[str, list[tuple[object, ...]]] = { + definition.id: [] for definition in definitions + } + for raw_row in rows: + row = cast(tuple[object, ...], raw_row) + case_id = str(row[0]) + if case_id not in rows_by_case: + raise PatternCaseLibraryError(f"unexpected pattern case row: {case_id}") + rows_by_case[case_id].append(row) + + cases: list[PatternCase] = [] + for definition in definitions: + case_rows = rows_by_case[definition.id] + if len(case_rows) != definition.lookback_days: + raise PatternCaseLibraryError( + f"case {definition.id} requires {definition.lookback_days} qfq rows" + ) + bars = tuple( + SelectionBar( + trade_date=_as_date(row[2]), + open=_as_float(row[3]), + high=_as_float(row[4]), + low=_as_float(row[5]), + close=_as_float(row[6]), + volume=_as_float(row[7]), + ) + for row in case_rows + ) + if any(bar.trade_date >= definition.breakout_date for bar in bars): + raise PatternCaseLibraryError( + f"case {definition.id} contains a non-exclusive breakout row" + ) + history = StockHistory( + ts_code=definition.ts_code, + name=definition.name, + bars=bars, + ) + cases.append(build_pattern_case(definition, history)) + return tuple(cases) + except PatternCaseLibraryError: + raise + except Exception as exc: # noqa: BLE001 - redact database details at the port boundary + raise PatternCaseLibraryError( + "failed to load the complete pattern case library" + ) from exc + + @contextmanager + def _connection(self) -> Generator[Any, None, None]: + """Borrow a shared connection without exposing driver failures.""" + + try: + if self.pool is None: + with psycopg.connect(self.database_url) as connection: + yield connection + else: + with self.pool.connection() as connection: + yield connection + except PatternCaseLibraryError: + raise + except Exception as exc: # noqa: BLE001 - normalize driver/pool errors + raise PatternCaseLibraryError("pattern case database operation failed") from exc diff --git a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py index 71f9825..1bc0f7d 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py +++ b/zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py @@ -15,6 +15,11 @@ import psycopg from psycopg.types.json import Jsonb from ..domain.models import SelectionSignal, ZhixingB1Category +from ..domain.pattern_scoring import ( + ZHIXING_B1_PATTERN_CASES, + PatternScore, + PatternScoreBreakdown, +) from ..domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, @@ -44,17 +49,37 @@ _SIGNAL_ORDER_SQL = ( ) + f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END" ) +_PATTERN_CASES_BY_ID = {definition.id: definition for definition in ZHIXING_B1_PATTERN_CASES} +_STOCK_ORDER_SQL = { + "code": "item.ts_code ASC", + "score_desc": "item.score_value DESC NULLS LAST, item.ts_code ASC", + "score_asc": "item.score_value ASC NULLS LAST, item.ts_code ASC", +} _ITEM_UPSERT = """ INSERT INTO selection_run_item - (run_id, ts_code, name, status, signal_count, reason) -VALUES (%s, %s, %s, %s, %s, %s) + ( + run_id, ts_code, name, status, signal_count, reason, + score_status, score_value, score_threshold, score_version, + match_case_id, match_case_name, match_case_breakout_date, + match_breakdown, score_reason + ) +VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON CONFLICT (run_id, ts_code) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, signal_count = EXCLUDED.signal_count, - reason = EXCLUDED.reason + reason = EXCLUDED.reason, + score_status = EXCLUDED.score_status, + score_value = EXCLUDED.score_value, + score_threshold = EXCLUDED.score_threshold, + score_version = EXCLUDED.score_version, + match_case_id = EXCLUDED.match_case_id, + match_case_name = EXCLUDED.match_case_name, + match_case_breakout_date = EXCLUDED.match_case_breakout_date, + match_breakdown = EXCLUDED.match_breakdown, + score_reason = EXCLUDED.score_reason """ _SIGNAL_UPSERT = """ INSERT INTO selection_signal @@ -200,6 +225,19 @@ class PostgresSelectionRunRepository(SelectionRunStore): item.status, item.signal_count, item.reason, + item.pattern_score.status, + item.pattern_score.value, + item.pattern_score.threshold, + item.pattern_score.version, + item.pattern_score.case.id if item.pattern_score.case else None, + item.pattern_score.case.name if item.pattern_score.case else None, + item.pattern_score.case.breakout_date if item.pattern_score.case else None, + ( + Jsonb(item.pattern_score.breakdown.as_dict()) + if item.pattern_score.breakdown + else None + ), + item.pattern_score.reason, ) for item in items ) @@ -354,7 +392,11 @@ class PostgresSelectionRunRepository(SelectionRunStore): return None item_rows = connection.execute( """ - SELECT ts_code, name, status, signal_count, reason + SELECT + ts_code, name, status, signal_count, reason, + score_status, score_value, score_threshold, score_version, + match_case_id, match_case_name, match_case_breakout_date, + match_breakdown, score_reason FROM selection_run_item WHERE run_id = %s ORDER BY ts_code @@ -363,7 +405,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): ).fetchall() stock_filter, stock_parameters = _stock_filter(query, run_id) stock_total_row = connection.execute( - f"SELECT COUNT(DISTINCT ts_code) FROM selection_signal WHERE {stock_filter}", + f"SELECT COUNT(*) FROM selection_run_item AS item WHERE {stock_filter}", tuple(stock_parameters), ).fetchone() stock_total = int(stock_total_row[0] or 0) if stock_total_row else 0 @@ -372,10 +414,10 @@ class PostgresSelectionRunRepository(SelectionRunStore): list[tuple[object, ...]], connection.execute( f""" - SELECT DISTINCT ts_code - FROM selection_signal + SELECT item.ts_code + FROM selection_run_item AS item WHERE {stock_filter} - ORDER BY ts_code + ORDER BY {_STOCK_ORDER_SQL[query.sort]} LIMIT %s OFFSET %s """, tuple((*stock_parameters, query.page_size, offset)), @@ -403,7 +445,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): sorted( (_signal_from_row(value) for value in signal_rows), key=lambda signal: ( - signal.ts_code, + stock_codes.index(signal.ts_code), _SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)), ), ) @@ -427,6 +469,7 @@ class PostgresSelectionRunRepository(SelectionRunStore): ), signal_count=int(value[3] or 0), reason=str(value[4]) if value[4] is not None else None, + pattern_score=_pattern_score_from_row(value[5:14]), signals=tuple(signals_by_stock.get(str(value[0]), ())), ) for value in item_rows @@ -509,18 +552,76 @@ def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[o can present all independently persisted categories together. """ - clauses = ["run_id = %s"] + clauses = ["item.run_id = %s", "item.status = 'selected'", "item.signal_count > 0"] parameters: list[object] = [run_id] if query.search: pattern = f"%{_escape_like(query.search)}%" - clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')") + clauses.append("(item.name ILIKE %s ESCAPE '\\' OR item.ts_code ILIKE %s ESCAPE '\\')") parameters.extend((pattern, pattern)) if query.category: - clauses.append("category LIKE %s") + clauses.append( + "EXISTS (" + "SELECT 1 FROM selection_signal AS signal " + "WHERE signal.run_id = item.run_id " + "AND signal.ts_code = item.ts_code " + "AND signal.category LIKE %s" + ")" + ) parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%") return " AND ".join(clauses), parameters +def _pattern_score_from_row(row: Sequence[object]) -> PatternScore: + """Reconstruct a validated stock-level score from nullable item columns.""" + + if len(row) < 9: + return PatternScore() + status = str(row[0] or "not_executed") + if status == "not_executed": + return PatternScore() + if status == "failed": + return PatternScore.failed(str(row[8] or "pattern scoring failed")) + if status not in {"matched", "below_threshold"}: + return PatternScore.failed("persisted pattern score status is invalid") + definition = _PATTERN_CASES_BY_ID.get(str(row[4])) + breakdown = _pattern_breakdown(row[7]) + if definition is None or breakdown is None: + return PatternScore.failed("persisted pattern score is incomplete") + try: + return PatternScore( + status=cast(Literal["matched", "below_threshold"], status), + value=float(str(row[1])), + threshold=float(str(row[2])), + version=str(row[3]), + case=definition, + breakdown=breakdown, + ) + except (TypeError, ValueError): + return PatternScore.failed("persisted pattern score is invalid") + + +def _pattern_breakdown(value: object) -> PatternScoreBreakdown | None: + """Parse the four finite JSONB score dimensions.""" + + if isinstance(value, str): + try: + value = json.loads(value) + except json.JSONDecodeError: + return None + if not isinstance(value, Mapping): + return None + values = cast(Mapping[object, object], value) + try: + return PatternScoreBreakdown( + trend_structure=float(str(values["trend_structure"])), + kdj_state=float(str(values["kdj_state"])), + volume_pattern=float(str(values["volume_pattern"])), + price_shape=float(str(values["price_shape"])), + ) + except (KeyError, TypeError, ValueError): + return None + + def _escape_like(value: str) -> str: """Escape user wildcards before placing text inside a SQL LIKE pattern.""" diff --git a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py index af51df5..40a5caf 100644 --- a/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py +++ b/zhixing-server/src/zhixing_server/modules/selection/presentation/http.py @@ -13,6 +13,10 @@ from zhixing_server.modules.selection.application.run import ( RunZhixingB1, ) from zhixing_server.modules.selection.domain.models import SelectionSignal +from zhixing_server.modules.selection.domain.pattern_scoring import ( + PatternScore, + ZhixingB1PatternScorer, +) from zhixing_server.modules.selection.domain.runs import ( SelectionRerunRequired, SelectionResultQuery, @@ -23,6 +27,7 @@ from zhixing_server.modules.selection.domain.runs import ( from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool from zhixing_server.modules.selection.infrastructure.postgres_reader import ( PostgresMarketDataReader, + PostgresPatternCaseLibraryLoader, SelectionMarketDataNotReady, SelectionReaderError, ) @@ -82,6 +87,35 @@ class SelectionFailureResponse(BaseModel): reason: str | None +class SelectionPatternCaseResponse(BaseModel): + """The best matching versioned case for one computed score.""" + + id: str + name: str + breakout_date: date + + +class SelectionPatternBreakdownResponse(BaseModel): + """The four finite 0-100 similarity dimensions.""" + + trend_structure: float = Field(ge=0, le=100) + kdj_state: float = Field(ge=0, le=100) + volume_pattern: float = Field(ge=0, le=100) + price_shape: float = Field(ge=0, le=100) + + +class SelectionPatternScoreResponse(BaseModel): + """A stock-level enrichment independent of selection evaluation status.""" + + status: Literal["matched", "below_threshold", "failed"] + value: float | None = Field(default=None, ge=0, le=100) + threshold: float | None = Field(default=None, ge=0, le=100) + version: str | None = None + case: SelectionPatternCaseResponse | None = None + breakdown: SelectionPatternBreakdownResponse | None = None + reason: str | None = None + + def _empty_failures() -> list[SelectionFailureResponse]: """Create a typed default list for Pydantic's strict checker.""" @@ -102,6 +136,7 @@ class SelectionStockResponse(BaseModel): target_trade_date: date strategy: StrategyValue close: float + score: SelectionPatternScoreResponse | None = None signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) @@ -144,10 +179,14 @@ def get_selection_service( pool = get_selection_postgres_pool(settings) reader = PostgresMarketDataReader(settings, pool=pool) + pattern_case_loader = PostgresPatternCaseLibraryLoader(settings, pool=pool) store = PostgresSelectionRunRepository(settings.database_url, pool=pool) return RunZhixingB1( reader, store, + pattern_case_loader=pattern_case_loader, + pattern_scorer=ZhixingB1PatternScorer(), + pattern_scoring_enabled=settings.selection_pattern_scoring_enabled, max_workers=settings.selection_max_workers, batch_size=settings.selection_batch_size, ) @@ -225,11 +264,12 @@ def get_selection_run( page_size: Annotated[int, Query(ge=1, le=100)] = 10, search: Annotated[str | None, Query(max_length=100)] = None, category: Literal["pullback", "oversold", "original"] | None = None, + sort: Literal["code", "score_desc", "score_asc"] = "code", ) -> SelectionResultsResponse: """Return one run for asynchronous polling.""" try: - query = _result_query(page, page_size, search, category) + query = _result_query(page, page_size, search, category, sort) run = service.get_run(run_id, query=query) except SelectionRunStoreError as exc: raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc @@ -247,11 +287,12 @@ def get_selection_results( page_size: Annotated[int, Query(ge=1, le=100)] = 10, search: Annotated[str | None, Query(max_length=100)] = None, category: Literal["pullback", "oversold", "original"] | None = None, + sort: Literal["code", "score_desc", "score_asc"] = "code", ) -> SelectionResultsResponse: """Return the current persisted result for a strategy and optional date.""" try: - query = _result_query(page, page_size, search, category) + query = _result_query(page, page_size, search, category, sort) run = service.get_latest(strategy, target_trade_date, query=query) except SelectionRunStoreError as exc: raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc @@ -276,6 +317,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio signals_by_stock: dict[str, list[SelectionSignalResponse]] = {} for signal in run.signals: signals_by_stock.setdefault(signal.ts_code, []).append(_signal_response(signal)) + items_by_stock = {item.ts_code: item for item in run.items} return SelectionResultsResponse( strategy=run.strategy, @@ -316,6 +358,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio target_trade_date=signals[0].target_trade_date, strategy=signals[0].strategy, close=signals[0].close, + score=_pattern_score_response(items_by_stock[signals[0].ts_code].pattern_score), signals=signals, ) for signals in signals_by_stock.values() @@ -337,11 +380,43 @@ def _signal_response(signal: SelectionSignal) -> SelectionSignalResponse: ) +def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreResponse | None: + """Hide not-executed scores and expose validated computed/failure states.""" + + if score.status == "not_executed": + return None + if score.status == "failed": + return SelectionPatternScoreResponse(status="failed", reason=score.reason) + if score.status == "below_threshold": + return SelectionPatternScoreResponse( + status="below_threshold", + threshold=score.threshold, + version=score.version, + reason="未匹配到评分阈值以上案例", + ) + if score.case is None or score.breakdown is None: + return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整") + return SelectionPatternScoreResponse( + status=score.status, + value=score.value, + threshold=score.threshold, + version=score.version, + case=SelectionPatternCaseResponse( + id=score.case.id, + name=score.case.name, + breakout_date=score.case.breakout_date, + ), + breakdown=SelectionPatternBreakdownResponse(**score.breakdown.as_dict()), + reason=score.reason, + ) + + def _result_query( page: int, page_size: int, search: str | None, category: Literal["pullback", "oversold", "original"] | None, + sort: Literal["code", "score_desc", "score_asc"], ) -> SelectionResultQuery: """Normalize HTTP query values before handing them to the selection port.""" @@ -351,6 +426,7 @@ def _result_query( page_size=page_size, search=normalized_search or None, category=category, + sort=sort, ) diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/README.md b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/README.md new file mode 100644 index 0000000..77dcc0c --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/README.md @@ -0,0 +1,5 @@ +# FastDTW v1 离线基线 + +十个 CSV 仅保留原项目固定案例突破日前最后 25 个升序交易日,测试运行不读取原项目、网络或数据库。`golden.json` 使用修正后可工作的 FastDTW、标量欧氏距离与 `radius=1` 离线生成;它有意不兼容原项目实际执行的 simple-DTW fallback。 + +25 行窗口不足以产生 114 日多空线。领域 extractor 将这些旧公式产生的非有限中间值显式转换为 `None`,matcher 按旧比较的最终效果记为零相似度,保证 dataclass、JSONB 和 HTTP 不包含 `NaN`/`Infinity`。案例库现在要求十例各 25 行完整 OHLCV,不再静默接受部分案例。 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_001.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_001.csv new file mode 100644 index 0000000..8352114 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_001.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-04-01,27.93,29.03,27.8,28.94,27218.84,5612754000 +2025-04-02,28.9,29.18,28.69,28.94,12933.01,5612754000 +2025-04-03,28.71,29.07,28.54,28.73,11232.75,5612754000 +2025-04-07,27.93,27.93,23.19,24.02,37675.08,5612754000 +2025-04-08,24.03,25.03,24.03,24.86,16676.65,5612754000 +2025-04-09,24.49,24.87,23.12,24.73,14468.45,5612754000 +2025-04-10,24.96,25.53,24.89,25.1,11065.87,5612754000 +2025-04-11,25.02,25.91,24.7,25.67,11201.14,5612754000 +2025-04-14,25.75,26.88,25.75,26.29,14566.38,5612754000 +2025-04-15,26.41,27.19,26.09,26.17,10132.02,5612754000 +2025-04-16,26.04,26.71,25.88,26.38,13525.38,5612754000 +2025-04-17,26.11,28.69,26.04,28.39,42729.26,5612754000 +2025-04-18,28.83,29.54,27.91,28.51,48214.96,5612754000 +2025-04-21,28.83,31.25,27.94,30.52,96121.97,5612754000 +2025-04-22,30.52,35.02,30.52,32.77,148408.79,5612754000 +2025-04-23,32.43,33.71,30.92,32.34,56626.78,5612754000 +2025-04-24,32.44,34.67,32.44,34.18,48618.11,5612754000 +2025-04-25,34.18,34.55,30.24,30.67,75477.69,5612754000 +2025-04-28,30.93,32.63,30.24,31.15,62031.09,5612754000 +2025-04-29,31.8,32.54,30.97,31.39,34211.51,5612754000 +2025-04-30,32.02,32.02,29.92,30.19,48359.44,5612754000 +2025-05-06,30.24,30.62,29.2,29.5,36216.67,5612754000 +2025-05-07,29.67,30.34,29.18,29.54,26316.22,5612754000 +2025-05-08,29.54,29.94,29.26,29.82,20883.91,5612754000 +2025-05-09,29.82,30.44,29.32,29.44,16659.71,5612754000 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_002.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_002.csv new file mode 100644 index 0000000..6d31bff --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_002.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-07-02,10.88,11.1,10.57,10.67,1287084.86,12045490456 +2025-07-03,10.62,10.9,10.54,10.82,991702.0,12045490456 +2025-07-04,10.83,10.88,10.4,10.45,855100.82,12045490456 +2025-07-07,10.28,11.26,10.28,10.98,1216456.82,12045490456 +2025-07-08,10.89,11.52,10.81,11.15,1558398.35,12045490456 +2025-07-09,11.18,11.23,10.79,10.84,1033990.38,12045490456 +2025-07-10,11.27,11.86,10.93,11.64,2056513.24,12045490456 +2025-07-11,11.87,12.46,11.53,12.13,2320402.62,12045490456 +2025-07-14,12.19,12.46,11.57,11.62,1491417.84,12045490456 +2025-07-15,11.58,12.78,11.58,12.2,2460847.71,12045490456 +2025-07-16,11.98,12.04,11.27,11.32,1938934.42,12045490456 +2025-07-17,11.08,11.49,10.97,11.4,1018625.42,12045490456 +2025-07-18,11.34,12.14,11.32,11.68,1574602.28,12045490456 +2025-07-21,11.6,11.98,11.57,11.8,1226347.09,12045490456 +2025-07-22,11.68,12.0,11.47,11.56,985223.02,12045490456 +2025-07-23,11.46,11.73,11.2,11.5,751845.98,12045490456 +2025-07-24,11.42,12.4,11.39,12.27,1884541.46,12045490456 +2025-07-25,12.22,13.06,12.12,12.61,1848357.03,12045490456 +2025-07-28,12.91,12.97,12.61,12.69,1106575.15,12045490456 +2025-07-29,12.41,12.64,12.28,12.4,794365.97,12045490456 +2025-07-30,12.38,12.44,11.83,12.09,880349.27,12045490456 +2025-07-31,11.97,12.13,11.75,11.81,547576.88,12045490456 +2025-08-01,11.8,11.8,11.54,11.58,448552.57,12045490456 +2025-08-04,11.6,11.68,11.51,11.63,404376.06,12045490456 +2025-08-05,11.8,11.89,11.63,11.68,518346.76,12045490456 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_003.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_003.csv new file mode 100644 index 0000000..2528abb --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_003.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-05-15,17.33,17.42,17.1,17.25,20642.05,11720226798 +2025-05-16,17.18,17.73,17.17,17.43,35369.48,11720226798 +2025-05-19,17.48,17.48,17.08,17.25,25489.78,11720226798 +2025-05-20,17.36,17.74,17.32,17.53,36708.2,11720226798 +2025-05-21,17.72,18.22,17.47,17.72,41463.24,11720226798 +2025-05-22,17.62,17.81,17.37,17.58,40314.58,11720226798 +2025-05-23,17.52,17.97,17.47,17.51,46281.21,11720226798 +2025-05-26,17.63,17.63,17.05,17.09,38830.29,11720226798 +2025-05-27,17.17,17.32,17.0,17.16,42731.45,11720226798 +2025-05-28,17.17,18.4,17.08,18.21,123423.01,11720226798 +2025-05-29,18.44,20.16,18.36,19.65,194317.79,11720226798 +2025-05-30,19.74,19.96,19.39,19.76,132173.99,11720226798 +2025-06-03,19.86,22.94,19.85,22.36,290301.1,11720226798 +2025-06-04,22.17,22.76,21.58,22.54,199596.74,11720226798 +2025-06-05,22.54,23.42,21.96,23.31,231289.99,11720226798 +2025-06-06,23.01,23.11,21.66,22.86,233436.91,11720226798 +2025-06-09,22.76,24.44,22.76,23.71,261851.09,11720226798 +2025-06-10,23.69,23.82,22.66,22.81,190046.45,11720226798 +2025-06-11,22.89,23.06,22.32,22.37,116651.04,11720226798 +2025-06-12,22.64,24.05,22.18,23.28,190460.15,11720226798 +2025-06-13,23.16,23.64,22.72,22.88,106830.32,11720226798 +2025-06-16,22.88,23.32,22.51,22.75,70989.1,11720226798 +2025-06-17,23.23,23.41,21.97,22.18,139623.83,11720226798 +2025-06-18,21.85,22.29,21.61,22.22,100081.97,11720226798 +2025-06-19,22.22,22.49,21.31,21.44,76485.29,11720226798 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_004.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_004.csv new file mode 100644 index 0000000..b65f39b --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_004.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-18,4.65,4.86,4.6,4.83,2051281.76,39275697251 +2025-06-19,4.79,4.98,4.75,4.78,1715941.43,39275697251 +2025-06-20,4.77,4.81,4.63,4.65,1051952.17,39275697251 +2025-06-23,4.6,4.75,4.57,4.7,934722.87,39275697251 +2025-06-24,4.72,4.81,4.7,4.78,945714.0,39275697251 +2025-06-25,4.8,4.85,4.73,4.81,1124786.7,39275697251 +2025-06-26,4.86,5.05,4.83,4.94,2459293.2,39275697251 +2025-06-27,4.94,5.42,4.85,5.27,4029657.48,39275697251 +2025-06-30,5.25,5.43,5.25,5.34,2441261.2,39275697251 +2025-07-01,5.31,5.38,5.24,5.3,1702111.13,39275697251 +2025-07-02,5.26,5.28,5.05,5.08,1565861.38,39275697251 +2025-07-03,5.08,5.59,5.08,5.59,4250014.47,39275697251 +2025-07-04,5.6,5.74,5.52,5.6,4529145.33,39275697251 +2025-07-07,5.5,5.79,5.49,5.58,2463078.1,39275697251 +2025-07-08,5.55,5.95,5.53,5.78,3665165.9,39275697251 +2025-07-09,5.75,5.82,5.65,5.69,2274246.96,39275697251 +2025-07-10,5.67,5.76,5.51,5.58,2005171.32,39275697251 +2025-07-11,5.57,5.58,5.39,5.5,1839462.11,39275697251 +2025-07-14,5.51,5.55,5.42,5.44,1238426.57,39275697251 +2025-07-15,5.45,5.6,5.4,5.47,2322143.38,39275697251 +2025-07-16,5.29,5.47,5.29,5.36,1945350.4,39275697251 +2025-07-17,5.33,5.57,5.3,5.48,2190584.97,39275697251 +2025-07-18,5.47,5.65,5.45,5.5,2020531.6,39275697251 +2025-07-21,5.52,5.66,5.43,5.48,1384268.25,39275697251 +2025-07-22,5.45,5.55,5.35,5.37,1735870.73,39275697251 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_006.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_006.csv new file mode 100644 index 0000000..90c6f70 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_006.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-30,32.11,33.05,31.61,32.23,1421653.88,51810407315 +2025-07-01,31.82,32.18,30.63,31.51,1313625.56,51810407315 +2025-07-02,31.51,31.57,30.79,30.9,615208.98,51810407315 +2025-07-03,31.16,31.21,30.49,30.96,810657.45,51810407315 +2025-07-04,30.65,30.93,29.93,30.43,799767.87,51810407315 +2025-07-07,30.43,30.67,30.09,30.22,482072.12,51810407315 +2025-07-08,30.11,30.34,29.97,30.12,621125.66,51810407315 +2025-07-09,30.19,30.86,29.73,29.83,1103713.14,51810407315 +2025-07-10,29.58,30.08,29.46,29.7,591122.59,51810407315 +2025-07-11,29.61,30.49,29.52,30.06,833099.95,51810407315 +2025-07-14,30.07,30.42,29.69,29.86,504302.93,51810407315 +2025-07-15,29.75,30.23,29.0,29.19,737141.8,51810407315 +2025-07-16,29.18,29.45,29.01,29.21,367657.7,51810407315 +2025-07-17,29.2,29.94,28.85,29.79,688212.84,51810407315 +2025-07-18,30.09,31.56,29.9,31.0,1211206.13,51810407315 +2025-07-21,30.98,31.37,30.36,31.07,772026.09,51810407315 +2025-07-22,30.78,31.45,30.43,30.8,785708.74,51810407315 +2025-07-23,30.56,30.57,29.89,29.92,703169.55,51810407315 +2025-07-24,29.84,30.46,29.77,30.31,543627.27,51810407315 +2025-07-25,30.36,31.13,30.36,30.45,619316.45,51810407315 +2025-07-28,30.43,31.24,30.18,31.04,702169.14,51810407315 +2025-07-29,30.79,31.15,30.33,30.69,542088.91,51810407315 +2025-07-30,30.84,30.85,29.24,29.48,761650.21,51810407315 +2025-07-31,29.34,30.01,28.94,29.12,470283.8,51810407315 +2025-08-01,28.99,29.22,28.64,28.67,386407.75,51810407315 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_007.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_007.csv new file mode 100644 index 0000000..dcb0632 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_007.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-27,19.98,22.09,19.61,22.09,276542.2,3567378360 +2025-06-30,21.86,24.19,21.46,23.37,328614.33,3567378360 +2025-07-01,22.39,22.77,21.46,21.46,246739.02,3567378360 +2025-07-02,20.91,21.41,20.4,20.88,152920.45,3567378360 +2025-07-03,20.81,22.49,20.72,22.05,226316.34,3567378360 +2025-07-04,21.4,21.76,20.75,20.76,157410.92,3567378360 +2025-07-07,20.47,21.17,20.32,21.0,89066.04,3567378360 +2025-07-08,21.01,21.11,20.67,20.9,81440.83,3567378360 +2025-07-09,20.91,21.39,20.42,20.51,94518.29,3567378360 +2025-07-10,20.51,20.51,19.94,20.32,82854.2,3567378360 +2025-07-11,20.42,20.6,20.12,20.41,63631.7,3567378360 +2025-07-14,20.57,20.93,20.51,20.6,71670.49,3567378360 +2025-07-15,20.45,20.78,20.13,20.47,70849.72,3567378360 +2025-07-16,20.6,20.86,20.33,20.47,68310.79,3567378360 +2025-07-17,20.26,20.6,19.92,20.51,62354.29,3567378360 +2025-07-18,20.48,20.79,20.36,20.49,62896.87,3567378360 +2025-07-21,20.36,20.97,20.12,20.52,68576.12,3567378360 +2025-07-22,20.4,21.25,20.34,20.96,129095.1,3567378360 +2025-07-23,20.84,20.88,20.09,20.17,96276.56,3567378360 +2025-07-24,20.16,20.35,20.08,20.19,45888.62,3567378360 +2025-07-25,20.21,20.21,19.97,20.08,38465.12,3567378360 +2025-07-28,20.09,20.55,20.06,20.4,51218.04,3567378360 +2025-07-29,20.34,20.54,19.79,19.93,61055.53,3567378360 +2025-07-30,19.81,20.21,19.2,19.86,79996.39,3567378360 +2025-07-31,19.66,19.99,19.37,19.48,43501.6,3567378360 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_008.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_008.csv new file mode 100644 index 0000000..8dd9f7d --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_008.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-05,13.86,13.93,13.61,13.82,76839.0,9399394575 +2025-06-06,13.83,14.01,13.66,13.7,69401.0,9399394575 +2025-06-09,13.69,13.87,13.64,13.76,75300.24,9399394575 +2025-06-10,13.7,13.74,12.99,13.18,174574.3,9399394575 +2025-06-11,13.2,13.34,13.11,13.3,61388.02,9399394575 +2025-06-12,13.26,13.35,13.14,13.21,46718.0,9399394575 +2025-06-13,13.17,13.52,13.17,13.38,164443.0,9399394575 +2025-06-16,13.48,13.75,13.17,13.68,140522.0,9399394575 +2025-06-17,13.63,14.09,13.62,13.97,143405.8,9399394575 +2025-06-18,13.98,14.72,13.89,14.72,275552.83,9399394575 +2025-06-19,14.48,14.48,13.72,14.15,252934.0,9399394575 +2025-06-20,14.15,14.16,13.75,13.8,127924.0,9399394575 +2025-06-23,14.01,14.33,13.9,14.33,160493.0,9399394575 +2025-06-24,14.19,14.87,13.84,14.54,252237.43,9399394575 +2025-06-25,14.78,16.0,14.71,16.0,600588.02,9399394575 +2025-06-26,16.0,17.6,15.98,16.63,846170.51,9399394575 +2025-06-27,16.56,17.27,16.3,16.42,651687.06,9399394575 +2025-06-30,16.58,17.57,16.58,17.54,612607.43,9399394575 +2025-07-01,17.28,17.9,16.88,17.24,468426.25,9399394575 +2025-07-02,17.18,17.18,16.42,16.61,337259.72,9399394575 +2025-07-03,16.62,16.84,16.37,16.46,199869.31,9399394575 +2025-07-04,16.37,16.45,16.04,16.1,180557.04,9399394575 +2025-07-07,16.03,16.32,15.86,16.12,142471.31,9399394575 +2025-07-08,15.98,16.16,15.91,16.07,122700.83,9399394575 +2025-07-09,16.08,16.45,15.94,15.99,230184.09,9399394575 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_009.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_009.csv new file mode 100644 index 0000000..98b8864 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_009.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-27,20.43,20.56,20.04,20.27,54029.3,4707136785 +2025-06-30,20.22,20.5,20.18,20.44,45101.98,4707136785 +2025-07-01,20.43,20.56,20.05,20.3,46657.31,4707136785 +2025-07-02,20.28,20.28,19.76,20.03,40227.67,4707136785 +2025-07-03,20.1,20.18,19.73,19.89,29471.22,4707136785 +2025-07-04,20.02,20.02,19.43,19.62,32150.52,4707136785 +2025-07-07,19.61,19.85,19.4,19.78,24560.45,4707136785 +2025-07-08,19.71,21.16,19.71,20.73,110928.39,4707136785 +2025-07-09,21.04,21.22,20.32,20.47,80045.04,4707136785 +2025-07-10,20.25,20.51,19.88,20.01,62948.74,4707136785 +2025-07-11,19.93,21.34,19.73,21.0,126620.87,4707136785 +2025-07-14,21.59,24.48,21.11,23.66,270019.12,4707136785 +2025-07-15,23.57,24.31,23.06,23.93,231598.13,4707136785 +2025-07-16,23.56,24.31,23.23,23.5,185860.26,4707136785 +2025-07-17,23.37,24.42,23.03,23.5,162749.05,4707136785 +2025-07-18,23.37,23.72,22.91,23.16,111343.75,4707136785 +2025-07-21,23.44,24.61,23.14,24.02,176471.75,4707136785 +2025-07-22,23.79,23.95,22.83,23.07,132565.5,4707136785 +2025-07-23,22.9,23.07,22.38,22.71,71180.1,4707136785 +2025-07-24,22.55,23.17,22.52,22.71,55999.01,4707136785 +2025-07-25,22.63,22.87,22.42,22.61,54779.45,4707136785 +2025-07-28,22.97,25.66,22.97,24.61,247298.15,4707136785 +2025-07-29,24.08,24.45,23.8,24.14,144127.75,4707136785 +2025-07-30,23.96,24.23,23.15,23.27,110016.38,4707136785 +2025-07-31,23.07,23.58,22.74,22.87,84262.28,4707136785 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_010.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_010.csv new file mode 100644 index 0000000..b7a010d --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_010.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-06-06,17.64,19.41,17.16,19.41,295488.65,4910064388 +2025-06-09,20.53,21.35,20.53,21.35,162351.45,4910064388 +2025-06-10,23.48,23.48,22.95,23.48,96161.81,4910064388 +2025-06-11,25.5,25.83,24.22,25.83,538547.27,4910064388 +2025-06-12,27.48,28.07,25.7,26.81,487592.83,4910064388 +2025-06-13,25.98,26.92,25.23,26.07,302545.06,4910064388 +2025-06-16,25.81,28.68,25.41,28.68,285190.35,4910064388 +2025-06-17,30.1,31.49,28.19,28.7,386383.92,4910064388 +2025-06-18,28.11,28.57,26.72,27.7,314113.19,4910064388 +2025-06-19,28.59,30.13,27.97,28.73,229020.92,4910064388 +2025-06-20,26.78,31.6,26.78,31.6,174055.39,4910064388 +2025-06-23,31.05,31.95,28.44,31.67,234024.79,4910064388 +2025-06-24,31.07,34.13,31.07,33.05,233411.76,4910064388 +2025-06-25,31.9,34.7,31.85,32.5,242343.26,4910064388 +2025-06-26,30.83,32.1,29.28,30.55,193713.48,4910064388 +2025-06-27,30.57,33.6,30.26,33.6,131012.98,4910064388 +2025-06-30,33.6,36.97,33.6,36.71,195320.19,4910064388 +2025-07-01,35.84,40.38,35.83,40.38,161306.24,4910064388 +2025-07-02,40.35,44.42,39.75,44.42,212138.21,4910064388 +2025-07-03,40.56,48.08,40.56,44.44,176844.52,4910064388 +2025-07-04,43.53,44.12,40.49,40.96,138196.86,4910064388 +2025-07-07,42.76,42.76,39.49,41.12,105590.86,4910064388 +2025-07-08,41.36,41.5,38.07,39.52,105213.28,4910064388 +2025-07-09,39.31,40.63,37.92,39.33,97899.1,4910064388 +2025-07-10,39.46,39.56,36.96,37.43,79650.61,4910064388 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_011.csv b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_011.csv new file mode 100644 index 0000000..417a3c6 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/case_011.csv @@ -0,0 +1,26 @@ +date,open,high,low,close,volume,market_cap +2025-09-30,7.57,7.76,7.57,7.74,204915.56,22650295811 +2025-10-09,7.75,7.8,7.68,7.8,196793.96,22650295811 +2025-10-10,7.79,7.82,7.73,7.75,163527.18,22650295811 +2025-10-13,7.6,7.8,7.47,7.8,208009.58,22650295811 +2025-10-14,7.82,7.9,7.73,7.78,203765.38,22650295811 +2025-10-15,7.77,7.78,7.67,7.75,158196.56,22650295811 +2025-10-16,7.74,7.76,7.61,7.63,151268.43,22650295811 +2025-10-17,7.62,7.72,7.47,7.48,162246.05,22650295811 +2025-10-20,7.55,7.61,7.51,7.58,122212.11,22650295811 +2025-10-21,7.58,7.67,7.56,7.64,121825.06,22650295811 +2025-10-22,7.64,7.86,7.58,7.82,322717.34,22650295811 +2025-10-23,7.8,7.82,7.67,7.81,170156.0,22650295811 +2025-10-24,8.1,8.3,7.95,7.98,615299.42,22650295811 +2025-10-27,8.0,8.18,7.94,8.04,433223.34,22650295811 +2025-10-28,7.99,8.84,7.97,8.84,1610159.98,22650295811 +2025-10-29,8.6,9.0,8.41,8.75,1722050.97,22650295811 +2025-10-30,8.7,8.82,8.51,8.6,1035934.51,22650295811 +2025-10-31,8.57,8.62,8.34,8.37,686044.09,22650295811 +2025-11-03,8.37,8.61,8.33,8.6,748009.31,22650295811 +2025-11-04,8.52,9.26,8.5,8.98,1365750.29,22650295811 +2025-11-05,8.76,8.91,8.67,8.81,823459.67,22650295811 +2025-11-06,8.77,8.8,8.6,8.65,553188.01,22650295811 +2025-11-07,8.67,8.75,8.56,8.67,592496.69,22650295811 +2025-11-10,8.74,8.79,8.51,8.53,513705.42,22650295811 +2025-11-11,8.48,8.54,8.39,8.47,440314.81,22650295811 diff --git a/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/golden.json b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/golden.json new file mode 100644 index 0000000..def8624 --- /dev/null +++ b/zhixing-server/tests/fixtures/selection/zhixing_b1/pattern_scoring/golden.json @@ -0,0 +1,43 @@ +{ + "algorithm": { + "version": "zhixing_b1_pattern_fastdtw_v1", + "radius": 1, + "distance": "scalar_euclidean", + "lookback_days": 25, + "threshold": 60.0, + "weights": [0.10, 0.20, 0.25, 0.45] + }, + "self_match": { + "status": "matched", + "value": 95.0, + "case_id": "case_001", + "breakdown": { + "trend_structure": 50.0, + "kdj_state": 100.0, + "volume_pattern": 100.0, + "price_shape": 100.0 + } + }, + "time_warped": { + "status": "matched", + "value": 78.38, + "case_id": "case_001", + "breakdown": { + "trend_structure": 44.51, + "kdj_state": 77.15, + "volume_pattern": 65.0, + "price_shape": 93.89 + } + }, + "below_threshold": { + "status": "below_threshold", + "value": 46.27, + "case_id": "case_010", + "breakdown": { + "trend_structure": 28.33, + "kdj_state": 91.79, + "volume_pattern": 27.5, + "price_shape": 40.45 + } + } +} diff --git a/zhixing-server/tests/integration/test_market_data_migration.py b/zhixing-server/tests/integration/test_market_data_migration.py index 0e047b0..7dc5af3 100644 --- a/zhixing-server/tests/integration/test_market_data_migration.py +++ b/zhixing-server/tests/integration/test_market_data_migration.py @@ -26,7 +26,8 @@ def test_postgres_migration_creates_market_data_contract( engine: Engine = create_engine(sqlalchemy_url) command.upgrade(config, "head") try: - tables = set(inspect(engine).get_table_names()) + inspector = inspect(engine) + tables = set(inspector.get_table_names()) assert { "market_stock", "market_daily_bar", @@ -46,6 +47,25 @@ def test_postgres_migration_creates_market_data_contract( "sector_radar_daily_aggregate", "sector_radar_publication_source", } <= tables + item_columns = {column["name"] for column in inspector.get_columns("selection_run_item")} + assert { + "score_status", + "score_value", + "score_threshold", + "score_version", + "match_case_id", + "match_case_name", + "match_case_breakout_date", + "match_breakdown", + "score_reason", + } <= item_columns + constraint_names = { + constraint["name"] + for constraint in inspector.get_check_constraints("selection_run_item") + } + assert "ck_selection_run_item_breakdown_range" in constraint_names + index_names = {index["name"] for index in inspector.get_indexes("selection_run_item")} + assert "ix_selection_run_item_score" in index_names finally: engine.dispose() get_settings.cache_clear() diff --git a/zhixing-server/tests/test_selection_http.py b/zhixing-server/tests/test_selection_http.py index f98196e..c1df119 100644 --- a/zhixing-server/tests/test_selection_http.py +++ b/zhixing-server/tests/test_selection_http.py @@ -1,5 +1,6 @@ """HTTP contracts for triggering and querying persisted selection runs.""" +from dataclasses import replace from datetime import date from decimal import Decimal @@ -11,6 +12,12 @@ from zhixing_server.bootstrap.app import create_app from zhixing_server.bootstrap.config import Settings from zhixing_server.modules.selection.application.run import PreparedSelectionRun from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category +from zhixing_server.modules.selection.domain.pattern_scoring import ( + PATTERN_SCORING_VERSION, + ZHIXING_B1_PATTERN_CASES, + PatternScore, + PatternScoreBreakdown, +) from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, @@ -130,6 +137,14 @@ def _run(run_id: str, status: str) -> SelectionRun: name="平安银行", status="selected", signal_count=2, + pattern_score=PatternScore( + status="matched", + value=86.4, + threshold=60.0, + version=PATTERN_SCORING_VERSION, + case=ZHIXING_B1_PATTERN_CASES[0], + breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1), + ), signals=(original_signal, pullback_signal), ), ), @@ -238,6 +253,24 @@ def test_query_returns_persisted_signal_details() -> None: assert "signals" not in body assert len(body["stocks"]) == 1 assert body["stocks"][0]["ts_code"] == "000001.SZ" + assert body["stocks"][0]["score"] == { + "status": "matched", + "value": 86.4, + "threshold": 60.0, + "version": PATTERN_SCORING_VERSION, + "case": { + "id": "case_001", + "name": "华纳药厂", + "breakout_date": "2025-05-12", + }, + "breakdown": { + "trend_structure": 71.2, + "kdj_state": 83.0, + "volume_pattern": 88.0, + "price_shape": 90.1, + }, + "reason": None, + } assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [ "zhixing_b1_original_b1", "zhixing_b1_pullback_white", @@ -248,6 +281,59 @@ def test_query_returns_persisted_signal_details() -> None: ] +@pytest.mark.parametrize( + ("pattern_score", "expected_score"), + [ + ( + PatternScore( + status="below_threshold", + value=42.5, + threshold=60.0, + version=PATTERN_SCORING_VERSION, + case=ZHIXING_B1_PATTERN_CASES[0], + breakdown=PatternScoreBreakdown(40.0, 42.0, 43.0, 44.0), + ), + { + "status": "below_threshold", + "value": None, + "threshold": 60.0, + "version": PATTERN_SCORING_VERSION, + "case": None, + "breakdown": None, + "reason": "未匹配到评分阈值以上案例", + }, + ), + ( + PatternScore.failed("FastDTW unavailable"), + { + "status": "failed", + "value": None, + "threshold": None, + "version": None, + "case": None, + "breakdown": None, + "reason": "FastDTW unavailable", + }, + ), + (PatternScore(), None), + ], +) +def test_query_preserves_signals_for_every_pattern_score_state( + pattern_score: PatternScore, + expected_score: dict[str, object] | None, +) -> None: + run = _run("run-http", "success") + run = replace(run, items=(replace(run.items[0], pattern_score=pattern_score),)) + + response = _client(FakeSelectionService(run)).get("/api/v1/selection/results") + + assert response.status_code == 200 + stock = response.json()["stocks"][0] + assert stock["score"] == expected_score + assert len(stock["signals"]) == 2 + assert response.json()["failures"] == [] + + def test_query_forwards_pagination_and_filters() -> None: service = FakeSelectionService(_run("run-http", "success")) @@ -259,6 +345,7 @@ def test_query_forwards_pagination_and_filters() -> None: "page_size": 5, "search": " 平安银行 ", "category": "original", + "sort": "score_desc", }, ) @@ -268,6 +355,7 @@ def test_query_forwards_pagination_and_filters() -> None: page_size=5, search="平安银行", category="original", + sort="score_desc", ) assert response.json()["page"] == 2 assert response.json()["page_size"] == 5 @@ -282,6 +370,18 @@ def test_query_rejects_invalid_page_size() -> None: assert response.status_code == 422 +def test_query_forwards_score_ascending_sort() -> None: + service = FakeSelectionService(_run("run-http", "success")) + + response = _client(service).get( + "/api/v1/selection/results", + params={"strategy": "zhixing_b1", "sort": "score_asc"}, + ) + + assert response.status_code == 200 + assert service.last_query == SelectionResultQuery(sort="score_asc") + + def test_run_polling_returns_the_persisted_terminal_result() -> None: response = _client(FakeSelectionService(_run("run-http", "success"))).get( "/api/v1/selection/runs/run-http" diff --git a/zhixing-server/tests/unit/selection/test_pattern_scoring.py b/zhixing-server/tests/unit/selection/test_pattern_scoring.py new file mode 100644 index 0000000..8efcfb0 --- /dev/null +++ b/zhixing-server/tests/unit/selection/test_pattern_scoring.py @@ -0,0 +1,182 @@ +"""Golden and invariant tests for versioned B1 FastDTW scoring.""" + +from __future__ import annotations + +import json +from datetime import date, timedelta +from pathlib import Path +from typing import cast + +import pandas as pd +import pytest + +from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory +from zhixing_server.modules.selection.domain.pattern_scoring import ( + PATTERN_FASTDTW_RADIUS, + PATTERN_SCORING_VERSION, + ZHIXING_B1_PATTERN_CASES, + PatternCase, + PatternCaseLibraryError, + PatternFeatures, + PatternScore, + PatternScoreBreakdown, + PatternScoringError, + ZhixingB1PatternScorer, + build_pattern_case, +) + +FIXTURES = Path(__file__).parents[2] / "fixtures" / "selection" / "zhixing_b1" / "pattern_scoring" + + +def _history(case_id: str, ts_code: str, name: str) -> StockHistory: + frame = pd.read_csv(FIXTURES / f"{case_id}.csv") + bars = tuple( + SelectionBar( + trade_date=date.fromisoformat(str(row.date)), + open=float(str(row.open)), + high=float(str(row.high)), + low=float(str(row.low)), + close=float(str(row.close)), + volume=float(str(row.volume)), + ) + for row in frame.itertuples(index=False) + ) + return StockHistory(ts_code=ts_code, name=name, bars=bars) + + +def _cases() -> tuple[PatternCase, ...]: + return tuple( + build_pattern_case( + definition, + _history(definition.id, definition.ts_code, definition.name), + ) + for definition in ZHIXING_B1_PATTERN_CASES + ) + + +def _golden(name: str) -> dict[str, object]: + payload = cast(dict[str, object], json.loads((FIXTURES / "golden.json").read_text())) + return cast(dict[str, object], payload[name]) + + +def _assert_golden(score: PatternScore, expected: dict[str, object]) -> None: + assert score.status == expected["status"] + assert score.value == expected["value"] + assert score.case is not None + assert score.case.id == expected["case_id"] + assert score.breakdown is not None + assert score.breakdown.as_dict() == expected["breakdown"] + + +def test_fastdtw_v1_self_match_golden_is_finite_and_deterministic() -> None: + cases = _cases() + scorer = ZhixingB1PatternScorer() + + first = scorer.score(cases[0].history, cases) + second = scorer.score(cases[0].history, cases) + + assert PATTERN_SCORING_VERSION == "zhixing_b1_pattern_fastdtw_v1" + assert PATTERN_FASTDTW_RADIUS == 1 + assert first == second + _assert_golden(first, _golden("self_match")) + assert cases[0].features.trend_structure["short_vs_bullbear"] is None + + +def test_fastdtw_v1_time_warped_curve_golden() -> None: + cases = _cases() + base = cases[0].history.bars + delayed = base[:1] * 3 + base[:-3] + bars = tuple( + SelectionBar( + trade_date=base[index].trade_date, + open=delayed[index].open, + high=delayed[index].high, + low=delayed[index].low, + close=delayed[index].close, + volume=delayed[index].volume, + ) + for index in range(25) + ) + + result = ZhixingB1PatternScorer().score( + StockHistory(ts_code="TEST.SZ", name="time warped", bars=bars), + cases, + ) + + _assert_golden(result, _golden("time_warped")) + + +def test_below_threshold_golden_remains_a_successful_computation() -> None: + bars = tuple( + SelectionBar( + trade_date=date(2026, 1, 1) + timedelta(days=index), + open=100.0 if index % 2 == 0 else 1.0, + high=110.0, + low=0.9, + close=1.0 if index % 2 == 0 else 100.0, + volume=1.0 if index < 13 else 1_000_000.0, + ) + for index in range(25) + ) + + result = ZhixingB1PatternScorer().score( + StockHistory(ts_code="TEST.SZ", name="below", bars=bars), + _cases(), + ) + + _assert_golden(result, _golden("below_threshold")) + + +def test_case_library_rejects_partial_or_short_input() -> None: + cases = _cases() + with pytest.raises(PatternScoringError, match="incomplete or out of order"): + ZhixingB1PatternScorer().score(cases[0].history, cases[:-1]) + + definition = ZHIXING_B1_PATTERN_CASES[0] + short = _history(definition.id, definition.ts_code, definition.name) + with pytest.raises(PatternCaseLibraryError, match="requires 25 complete rows"): + build_pattern_case( + definition, + StockHistory(short.ts_code, short.name, short.bars[:-1]), + ) + + +def test_fastdtw_failure_is_not_replaced_by_simple_dtw(monkeypatch: pytest.MonkeyPatch) -> None: + import zhixing_server.modules.selection.domain.pattern_scoring as scoring + + def fail(*_args: object, **_kwargs: object) -> tuple[float, list[tuple[int, int]]]: + raise RuntimeError("fastdtw unavailable") + + monkeypatch.setattr(scoring, "_fastdtw", lambda: fail) + + with pytest.raises(RuntimeError, match="fastdtw unavailable"): + ZhixingB1PatternScorer().score(_cases()[0].history, _cases()) + + +def test_failed_score_requires_a_safe_reason() -> None: + with pytest.raises(ValueError, match="requires a safe reason"): + PatternScore(status="failed") + + assert PatternScore.failed(" ").reason == "pattern scoring failed" + + +def test_threshold_is_inclusive_and_equal_scores_keep_first_case( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import zhixing_server.modules.selection.domain.pattern_scoring as scoring + + tied = PatternScoreBreakdown(60.0, 60.0, 60.0, 60.0) + + def tied_match( + _candidate: PatternFeatures, + _case: PatternFeatures, + ) -> PatternScoreBreakdown: + return tied + + monkeypatch.setattr(scoring, "_match", tied_match) + + result = ZhixingB1PatternScorer().score(_cases()[0].history, _cases()) + + assert result.status == "matched" + assert result.value == 60.0 + assert result.case == ZHIXING_B1_PATTERN_CASES[0] diff --git a/zhixing-server/tests/unit/selection/test_postgres_reader.py b/zhixing-server/tests/unit/selection/test_postgres_reader.py index 7f5d748..7d9b384 100644 --- a/zhixing-server/tests/unit/selection/test_postgres_reader.py +++ b/zhixing-server/tests/unit/selection/test_postgres_reader.py @@ -2,17 +2,19 @@ from collections.abc import Generator from contextlib import contextmanager -from datetime import date +from datetime import date, timedelta from decimal import Decimal from typing import cast import psycopg import pytest +from zhixing_server.modules.selection.domain.pattern_scoring import ZHIXING_B1_PATTERN_CASES from zhixing_server.modules.selection.domain.runs import SelectionStock from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool from zhixing_server.modules.selection.infrastructure.postgres_reader import ( PostgresMarketDataReader, + PostgresPatternCaseLibraryLoader, SelectionMarketDataNotReady, ) @@ -237,3 +239,34 @@ def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.M "zhixing_b1", date(2026, 8, 8), ) + + +def test_pattern_case_loader_reads_one_complete_exclusive_qfq_library() -> None: + rows: list[tuple[object, ...]] = [] + for definition in ZHIXING_B1_PATTERN_CASES: + for offset in range(definition.lookback_days, 0, -1): + rows.append( + ( + definition.id, + definition.ts_code, + definition.breakout_date - timedelta(days=offset), + "10", + "11", + "9", + str(10 + offset / 100), + str(1000 + offset), + ) + ) + connection = FakeConnection(rows) + pool = Pool(connection) + owner = SelectionPostgresPool("postgresql://test", max_connections=2, pool=pool) + + cases = PostgresPatternCaseLibraryLoader("postgresql://test", pool=owner).load() + + assert tuple(case.definition for case in cases) == ZHIXING_B1_PATTERN_CASES + assert all(len(case.history.bars) == 25 for case in cases) + assert all(case.history.bars[-1].trade_date < case.definition.breakout_date for case in cases) + assert "bar.trade_date < definition.breakout_date" in cast(str, connection.query) + assert "bar.source_adj = 'qfq'" in cast(str, connection.query) + assert connection.parameters is not None + assert connection.parameters[0] == [definition.id for definition in ZHIXING_B1_PATTERN_CASES] diff --git a/zhixing-server/tests/unit/selection/test_postgres_runs.py b/zhixing-server/tests/unit/selection/test_postgres_runs.py index ceaf968..3869127 100644 --- a/zhixing-server/tests/unit/selection/test_postgres_runs.py +++ b/zhixing-server/tests/unit/selection/test_postgres_runs.py @@ -8,6 +8,12 @@ import pytest from psycopg.types.json import Jsonb from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category +from zhixing_server.modules.selection.domain.pattern_scoring import ( + PATTERN_SCORING_VERSION, + ZHIXING_B1_PATTERN_CASES, + PatternScore, + PatternScoreBreakdown, +) from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionRerunRequired, @@ -222,6 +228,14 @@ def test_record_items_uses_one_delete_and_two_batch_upserts( name="平安银行", status="selected", signal_count=2, + pattern_score=PatternScore( + status="matched", + value=86.4, + threshold=60.0, + version=PATTERN_SCORING_VERSION, + case=ZHIXING_B1_PATTERN_CASES[0], + breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1), + ), signals=(first, second), ), SelectionRunItem( @@ -238,6 +252,28 @@ def test_record_items_uses_one_delete_and_two_batch_upserts( assert delete_parameters == ("run-1", ["000001.SZ", "600000.SH"]) assert len(connection.executemany_calls) == 2 assert "INSERT INTO selection_run_item" in connection.executemany_calls[0][0] + item_parameters = connection.executemany_calls[0][1] + assert item_parameters[0][6:13] == ( + "matched", + 86.4, + 60.0, + PATTERN_SCORING_VERSION, + "case_001", + "华纳药厂", + date(2025, 5, 12), + ) + assert isinstance(item_parameters[0][13], Jsonb) + assert item_parameters[1][6:] == ( + "not_executed", + None, + None, + None, + None, + None, + None, + None, + None, + ) assert "INSERT INTO selection_signal" in connection.executemany_calls[1][0] signal_parameters = connection.executemany_calls[1][1] assert len(signal_parameters) == 2 @@ -281,11 +317,35 @@ class LoadConnection: None, ) ) - if "FROM selection_run_item" in query: - return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) - if "COUNT(DISTINCT ts_code) FROM selection_signal" in query: + if "FROM selection_run_item\n" in query: + return LoadResult( + rows=[ + ( + "000001.SZ", + "平安银行", + "selected", + 2, + None, + "matched", + Decimal("86.40"), + Decimal("60.00"), + PATTERN_SCORING_VERSION, + "case_001", + "华纳药厂", + date(2025, 5, 12), + { + "trend_structure": 71.2, + "kdj_state": 83.0, + "volume_pattern": 88.0, + "price_shape": 90.1, + }, + None, + ) + ] + ) + if "SELECT COUNT(*) FROM selection_run_item AS item" in query: return LoadResult(row=(2,)) - if "SELECT DISTINCT ts_code" in query: + if "SELECT item.ts_code" in query: return LoadResult(rows=[("000001.SZ",)]) return LoadResult( rows=[ @@ -315,7 +375,7 @@ class EmptyStockPageConnection(LoadConnection): """Return a non-zero filtered total with no stocks on the requested page.""" def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": - if "SELECT DISTINCT ts_code" in query: + if "SELECT item.ts_code" in query: self.statements.append((query, parameters)) return LoadResult(rows=[]) return super().execute(query, parameters) @@ -355,6 +415,7 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches( page_size=1, search="100%", category="pullback", + sort="score_desc", ), ) @@ -364,19 +425,21 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches( ZHIXING_B1_SIGNAL_ORDER[-1], ] assert run.stocks_total == 2 + assert run.items[0].pattern_score.status == "matched" + assert run.items[0].pattern_score.value == 86.4 count_query, count_parameters = next( (query, parameters) for query, parameters in connection.statements - if "COUNT(DISTINCT ts_code) FROM selection_signal" in query + if "SELECT COUNT(*) FROM selection_run_item AS item" in query ) assert "name ILIKE %s ESCAPE" in count_query assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%") stock_page_query, page_parameters = next( (query, parameters) for query, parameters in connection.statements - if "SELECT DISTINCT ts_code" in query + if "SELECT item.ts_code" in query ) - assert "ORDER BY ts_code" in stock_page_query + assert "ORDER BY item.score_value DESC NULLS LAST, item.ts_code ASC" in stock_page_query assert page_parameters[-2:] == (1, 0) signal_query, signal_parameters = next( (query, parameters) @@ -409,8 +472,30 @@ def test_get_run_does_not_load_signals_for_an_empty_stock_page( stock_page_query, stock_page_parameters = next( (query, parameters) for query, parameters in connection.statements - if "SELECT DISTINCT ts_code" in query + if "SELECT item.ts_code" in query ) - assert "ORDER BY ts_code" in stock_page_query + assert "ORDER BY item.ts_code ASC" in stock_page_query assert stock_page_parameters[-2:] == (1, 2) assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements) + + +def test_get_run_sorts_scores_ascending_with_nulls_last_and_code_tiebreak( + monkeypatch: pytest.MonkeyPatch, +) -> None: + connection = LoadConnection() + + def connect(database_url: str) -> LoadConnection: + assert database_url == "postgresql://test" + return connection + + monkeypatch.setattr(psycopg, "connect", connect) + run = PostgresSelectionRunRepository("postgresql://test").get_run( + "run-1", + query=SelectionResultQuery(sort="score_asc"), + ) + + assert run is not None + stock_page_query = next( + query for query, _ in connection.statements if "SELECT item.ts_code" in query + ) + assert "ORDER BY item.score_value ASC NULLS LAST, item.ts_code ASC" in stock_page_query diff --git a/zhixing-server/tests/unit/selection/test_run.py b/zhixing-server/tests/unit/selection/test_run.py index bb3ea0a..fdf0c90 100644 --- a/zhixing-server/tests/unit/selection/test_run.py +++ b/zhixing-server/tests/unit/selection/test_run.py @@ -2,6 +2,7 @@ import threading import time +from collections.abc import Sequence from datetime import date from decimal import Decimal from typing import Literal @@ -15,6 +16,14 @@ from zhixing_server.modules.selection.domain.models import ( SelectionSignal, StockHistory, ) +from zhixing_server.modules.selection.domain.pattern_scoring import ( + PATTERN_SCORE_THRESHOLD, + PATTERN_SCORING_VERSION, + ZHIXING_B1_PATTERN_CASES, + PatternCase, + PatternScore, + PatternScoreBreakdown, +) from zhixing_server.modules.selection.domain.runs import ( SelectionExecutionSource, SelectionResultQuery, @@ -198,6 +207,37 @@ class ConcurrentHistoryEvaluator: return SelectionEvaluation(history.ts_code, target_trade_date, "no_signal") +class FakePatternCaseLoader: + def __init__(self, *, error: Exception | None = None) -> None: + self.calls = 0 + self.error = error + + def load(self) -> tuple[PatternCase, ...]: + self.calls += 1 + if self.error is not None: + raise self.error + return () + + +class FakePatternScorer: + def __init__(self, *, error: Exception | None = None) -> None: + self.calls: list[str] = [] + self.error = error + + def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore: + self.calls.append(history.ts_code) + if self.error is not None: + raise self.error + return PatternScore( + status="matched", + value=88.0, + threshold=PATTERN_SCORE_THRESHOLD, + version=PATTERN_SCORING_VERSION, + case=ZHIXING_B1_PATTERN_CASES[0], + breakdown=PatternScoreBreakdown(80.0, 85.0, 90.0, 88.0), + ) + + def _source() -> SelectionExecutionSource: return SelectionExecutionSource( market_sync_batch_id="market-run-1", @@ -364,3 +404,149 @@ def test_execute_marks_batch_write_failure_as_failed() -> None: assert store.finished[0:2] == ("run-1", "failed") assert store.finished[2]["error_type"] == "batch_error" assert store.finished[2]["failed_count"] == 1 + + +def test_execute_loads_cases_once_and_scores_only_selected_stocks() -> None: + source = _source() + reader = BatchReader(source) + store = FakeStore() + loader = FakePatternCaseLoader() + scorer = FakePatternScorer() + evaluator = FakeEvaluator( + { + "000001.SZ": SelectionEvaluation( + "000001.SZ", + TARGET, + "selected", + signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),), + ), + "600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"), + } + ) + service = RunZhixingB1( + reader, + store, + evaluator, + loader, + scorer, + pattern_scoring_enabled=True, + batch_size=1, + ) + + service.execute(service.prepare("zhixing_b1", TARGET, rerun=False)) + + assert loader.calls == 1 + assert scorer.calls == ["000001.SZ"] + assert [item.pattern_score.status for item in store.items] == ["matched", "not_executed"] + assert store.finished is not None + assert store.finished[0:2] == ("run-1", "success") + assert store.finished[2]["failed_count"] == 0 + + +def test_execute_isolates_pattern_scoring_failure_from_selection_status() -> None: + source = _source() + store = FakeStore() + loader = FakePatternCaseLoader() + scorer = FakePatternScorer(error=RuntimeError("FastDTW unavailable")) + evaluator = FakeEvaluator( + { + "000001.SZ": SelectionEvaluation( + "000001.SZ", + TARGET, + "selected", + signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),), + ), + "600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"), + } + ) + service = RunZhixingB1( + BatchReader(source), + store, + evaluator, + loader, + scorer, + pattern_scoring_enabled=True, + ) + + service.execute(service.prepare("zhixing_b1", TARGET, rerun=False)) + + assert store.items[0].status == "selected" + assert store.items[0].pattern_score == PatternScore.failed("FastDTW unavailable") + assert store.finished is not None + assert store.finished[0:2] == ("run-1", "success") + assert store.finished[2]["failed_count"] == 0 + + +def test_execute_skips_pattern_dependencies_when_feature_flag_is_disabled() -> None: + source = _source() + store = FakeStore() + loader = FakePatternCaseLoader(error=AssertionError("loader must not run")) + scorer = FakePatternScorer(error=AssertionError("scorer must not run")) + evaluator = FakeEvaluator( + { + "000001.SZ": SelectionEvaluation( + "000001.SZ", + TARGET, + "selected", + signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),), + ), + "600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"), + } + ) + service = RunZhixingB1( + BatchReader(source), + store, + evaluator, + loader, + scorer, + pattern_scoring_enabled=False, + ) + + service.execute(service.prepare("zhixing_b1", TARGET, rerun=False)) + + assert loader.calls == 0 + assert scorer.calls == [] + assert [item.pattern_score.status for item in store.items] == [ + "not_executed", + "not_executed", + ] + assert store.items[0].signal_count == 1 + assert store.finished is not None + assert store.finished[2]["failed_count"] == 0 + + +def test_execute_marks_scores_failed_when_case_library_is_unavailable() -> None: + source = _source() + store = FakeStore() + loader = FakePatternCaseLoader(error=RuntimeError("case_011 requires 25 qfq rows")) + scorer = FakePatternScorer() + evaluator = FakeEvaluator( + { + "000001.SZ": SelectionEvaluation( + "000001.SZ", + TARGET, + "selected", + signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),), + ), + "600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"), + } + ) + service = RunZhixingB1( + BatchReader(source), + store, + evaluator, + loader, + scorer, + pattern_scoring_enabled=True, + ) + + service.execute(service.prepare("zhixing_b1", TARGET, rerun=False)) + + assert loader.calls == 1 + assert scorer.calls == [] + assert store.items[0].status == "selected" + assert store.items[0].pattern_score.status == "failed" + assert store.items[0].signals[0].category.value == "zhixing_b1_original_b1" + assert store.finished is not None + assert store.finished[0:2] == ("run-1", "success") + assert store.finished[2]["failed_count"] == 0 diff --git a/zhixing-server/uv.lock b/zhixing-server/uv.lock index 960515a..1cec2ad 100644 --- a/zhixing-server/uv.lock +++ b/zhixing-server/uv.lock @@ -169,6 +169,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] +[[package]] +name = "fastdtw" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/43/30f2d8db076f216b15c10db663b46e22d1750b1ebacd7af6e62b83d6ab98/fastdtw-0.3.4.tar.gz", hash = "sha256:2350fa6ec36bcad186eaf81f46eff35181baf04e324f522de8aeb43d0243f64f", size = 133402, upload-time = "2019-10-07T16:02:29.982Z" } + [[package]] name = "greenlet" version = "3.5.4" @@ -892,6 +901,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "fastapi" }, + { name = "fastdtw" }, { name = "numpy" }, { name = "pandas" }, { name = "psycopg", extra = ["binary", "pool"] }, @@ -915,6 +925,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.18.0" }, { name = "fastapi", specifier = ">=0.141.1" }, + { name = "fastdtw", specifier = ">=0.3.4" }, { name = "numpy", specifier = ">=2.4.0" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" }, diff --git a/zhixing-web/src/features/selection/api/selection.api.test.ts b/zhixing-web/src/features/selection/api/selection.api.test.ts index 24609fd..abd96d6 100644 --- a/zhixing-web/src/features/selection/api/selection.api.test.ts +++ b/zhixing-web/src/features/selection/api/selection.api.test.ts @@ -17,6 +17,7 @@ describe("selection API adapters", () => { page: 2, pageSize: 15, search: "平安银行", + sort: "score_desc", }) const [input, init] = requestJson.mock.calls[0] as [ @@ -30,6 +31,7 @@ describe("selection API adapters", () => { expect(params.get("page_size")).toBe("15") expect(params.get("search")).toBe("平安银行") expect(params.get("category")).toBe("pullback") + expect(params.get("sort")).toBe("score_desc") expect(init).toEqual({ signal: undefined }) }) @@ -43,5 +45,6 @@ describe("selection API adapters", () => { expect(params.get("page_size")).toBe("5") expect(params.has("search")).toBe(false) expect(params.has("category")).toBe(false) + expect(params.has("sort")).toBe(false) }) }) diff --git a/zhixing-web/src/features/selection/api/selection.api.ts b/zhixing-web/src/features/selection/api/selection.api.ts index 689f0b5..121e4bb 100644 --- a/zhixing-web/src/features/selection/api/selection.api.ts +++ b/zhixing-web/src/features/selection/api/selection.api.ts @@ -44,6 +44,7 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) { }) if (query.search) params.set("search", query.search) if (query.category) params.set("category", query.category) + if (query.sort) params.set("sort", query.sort) return params } diff --git a/zhixing-web/src/features/selection/api/selection.query.ts b/zhixing-web/src/features/selection/api/selection.query.ts index 3ffc178..4667739 100644 --- a/zhixing-web/src/features/selection/api/selection.query.ts +++ b/zhixing-web/src/features/selection/api/selection.query.ts @@ -31,6 +31,7 @@ export const selectionResultsQueryKey = ( query.pageSize, query.search ?? "", query.category ?? "all", + query.sort ?? "code", ] as const export const selectionRunQueryKey = ( @@ -45,6 +46,7 @@ export const selectionRunQueryKey = ( query.pageSize, query.search ?? "", query.category ?? "all", + query.sort ?? "code", ] as const export function useSelectionResults( diff --git a/zhixing-web/src/features/selection/api/selection.types.ts b/zhixing-web/src/features/selection/api/selection.types.ts index 368d044..d947ad6 100644 --- a/zhixing-web/src/features/selection/api/selection.types.ts +++ b/zhixing-web/src/features/selection/api/selection.types.ts @@ -10,11 +10,16 @@ export const selectionCategoryFilters = [ "original", ] as const +export type SelectionSort = "code" | "score_desc" | "score_asc" + +export const selectionSorts = ["code", "score_desc", "score_asc"] as const + export interface SelectionResultsQuery { page: number pageSize: number search?: string category?: Exclude + sort?: SelectionSort } export type SelectionRunStatus = @@ -49,9 +54,33 @@ export interface SelectionStockResult { target_trade_date: string strategy: SelectionStrategy close: number + score: SelectionPatternScore | null signals: SelectionSignal[] } +export interface SelectionPatternScore { + status: "matched" | "below_threshold" | "failed" + value: number | null + threshold: number | null + version: string | null + case: SelectionPatternCase | null + breakdown: SelectionPatternBreakdown | null + reason: string | null +} + +export interface SelectionPatternCase { + id: string + name: string + breakout_date: string +} + +export interface SelectionPatternBreakdown { + trend_structure: number + kdj_state: number + volume_pattern: number + price_shape: number +} + export interface SelectionFailure { ts_code: string name: string diff --git a/zhixing-web/src/features/selection/components/pattern-score.tsx b/zhixing-web/src/features/selection/components/pattern-score.tsx new file mode 100644 index 0000000..a782510 --- /dev/null +++ b/zhixing-web/src/features/selection/components/pattern-score.tsx @@ -0,0 +1,99 @@ +import { Badge } from "@/shared/ui/badge" + +import type { SelectionPatternScore } from "../api/selection.types" + +interface PatternScoreProps { + score: SelectionPatternScore | null +} + +const breakdownLabels = { + trend_structure: "趋势", + kdj_state: "KDJ", + volume_pattern: "量能", + price_shape: "价格形态", +} as const + +export function PatternScoreSummary({ score }: PatternScoreProps) { + if (!score) return 未评分 + if (score.status === "failed") { + return 评分暂不可用 + } + if (score.status === "below_threshold") { + return ( + + 未匹配到 {formatScore(score.threshold)} 分以上案例 + + ) + } + return ( + + + {formatScore(score.value)} 分 + + + {score.case?.name ?? "最佳案例未知"} + + + ) +} + +export function PatternScoreDetails({ score }: PatternScoreProps) { + if (!score) { + return ( +

本次运行未执行图形评分。

+ ) + } + if (score.status === "failed") { + return ( +
+ 评分暂不可用 +

+ {score.reason || "案例库或评分计算暂时不可用。"} +

+
+ ) + } + if (score.status === "below_threshold") { + return ( +
+ 低于匹配阈值 +

+ 未匹配到 {formatScore(score.threshold)} 分以上案例。 +

+
+ ) + } + return ( +
+
+
+

+ {formatScore(score.value)} 分 +

+

+ 最佳案例:{score.case?.name ?? "未知"} +

+
+ 已匹配 +
+ {score.breakdown ? ( +
+ {Object.entries(breakdownLabels).map(([key, label]) => ( +
+
{label}
+
+ {formatScore( + score.breakdown?.[key as keyof typeof breakdownLabels], + )} +
+
+ ))} +
+ ) : null} +
+ ) +} + +function formatScore(value: number | null | undefined) { + return value === null || value === undefined ? "—" : value.toFixed(2) +} diff --git a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx index 0b2bf50..5b25772 100644 --- a/zhixing-web/src/features/selection/components/selection-results-workbench.tsx +++ b/zhixing-web/src/features/selection/components/selection-results-workbench.tsx @@ -14,6 +14,7 @@ import { import type { SelectionResults, + SelectionSort, SelectionStockResult, } from "../api/selection.types" import { ExecutionStatusTrigger } from "./execution-status-trigger" @@ -28,6 +29,14 @@ import { import { SignalTable } from "./signal-table" const PAGE_SIZE_OPTIONS = [5, 10, 15] as const +const SCORE_SORT_OPTIONS: ReadonlyArray<{ + label: string + value: SelectionSort +}> = [ + { label: "按股票代码", value: "code" }, + { label: "评分从高到低", value: "score_desc" }, + { label: "评分从低到高", value: "score_asc" }, +] interface SelectionResultsWorkbenchProps { drawerOpen: boolean @@ -46,6 +55,7 @@ export function SelectionResultsWorkbench({ const navigate = useNavigate({ from: "/selection" }) const query = search.search ?? "" const category = search.category ?? "all" + const sort = search.sort ?? "code" const [selectedKey, setSelectedKey] = useState(null) const [expandedKeys, setExpandedKeys] = useState>( new Set(), @@ -84,6 +94,7 @@ export function SelectionResultsWorkbench({ pageSize?: number search?: string category?: SignalCategoryFilter + sort?: SelectionSort }) { void navigate({ search: (previous) => ({ ...previous, ...next }) }) } @@ -96,6 +107,10 @@ export function SelectionResultsWorkbench({ updateSearch({ category: value, page: 1 }) } + function handleSortChange(value: SelectionSort) { + updateSearch({ page: 1, sort: value }) + } + function handleToggleExpanded(stock: SelectionStockResult) { const key = getStockKey(stock) setExpandedKeys((previous) => { @@ -143,6 +158,31 @@ export function SelectionResultsWorkbench({ + 筛选结果 {stocksTotal} 只 diff --git a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx index 89886e9..5e05460 100644 --- a/zhixing-web/src/features/selection/components/signal-detail-panel.tsx +++ b/zhixing-web/src/features/selection/components/signal-detail-panel.tsx @@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge" import { Card } from "@/shared/ui/card" import type { SelectionStockResult } from "../api/selection.types" +import { PatternScoreDetails } from "./pattern-score" import { categoryToneClass, getCategoryLabel } from "./selection-presentation" import { SignalDetails } from "./signal-details" @@ -84,6 +85,16 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) { +
+

+ 图形相似度评分 +

+ +
+

+
+ +
{stock.signals.map((signal) => ( @@ -95,6 +99,12 @@ export function SignalRecordList({
{expanded ? (
+
+

+ 图形相似度评分 +

+ +
{stock.signals.map((signal) => (

diff --git a/zhixing-web/src/features/selection/components/signal-table.tsx b/zhixing-web/src/features/selection/components/signal-table.tsx index 267dab7..905187d 100644 --- a/zhixing-web/src/features/selection/components/signal-table.tsx +++ b/zhixing-web/src/features/selection/components/signal-table.tsx @@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react" import { Badge } from "@/shared/ui/badge" import type { SelectionStockResult } from "../api/selection.types" +import { PatternScoreSummary } from "./pattern-score" import { categoryToneClass, getCategoryLabel, @@ -38,6 +39,7 @@ export function SignalTable({ 股票 信号类型 + 图形评分 J 值 收盘价 @@ -75,6 +77,9 @@ export function SignalTable({ ))}

+ + + {stock.signals.map((signal) => ( @@ -92,7 +97,7 @@ export function SignalTable({ 没有符合当前筛选条件的信号。 diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx index f12442d..848f427 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.test.tsx @@ -7,7 +7,10 @@ import { } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" -import type { SelectionResults } from "../api/selection.types" +import type { + SelectionPatternScore, + SelectionResults, +} from "../api/selection.types" import { SelectionResultsPage } from "./selection-results-page" @@ -26,7 +29,13 @@ vi.mock("@/features/selection/api/selection.query", () => ({ vi.mock("@tanstack/react-router", () => ({ useNavigate: () => routerNavigate, - useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }), + useSearch: () => ({ + page: 1, + pageSize: 5, + search: "", + category: "all", + sort: "code", + }), })) const selectedResult: SelectionResults = { @@ -50,6 +59,24 @@ const selectedResult: SelectionResults = { { close: 10.5, name: "平安银行", + score: { + breakdown: { + kdj_state: 83, + price_shape: 90.1, + trend_structure: 71.2, + volume_pattern: 88, + }, + case: { + breakout_date: "2025-05-12", + id: "case_001", + name: "华纳药厂", + }, + reason: null, + status: "matched", + threshold: 60, + value: 86.4, + version: "zhixing_b1_pattern_fastdtw_v1", + }, signals: [ { category: "zhixing_b1_original_b1", @@ -117,6 +144,9 @@ describe("SelectionResultsPage", () => { expect( within(table).getByRole("columnheader", { name: "J 值" }), ).toBeInTheDocument() + expect( + within(table).getByRole("columnheader", { name: "图形评分" }), + ).toBeInTheDocument() expect( within(table).queryByRole("columnheader", { name: "关键详情" }), ).not.toBeInTheDocument() @@ -125,15 +155,75 @@ describe("SelectionResultsPage", () => { expect(within(table).getByText("12")).toBeInTheDocument() expect(within(table).getByText("13")).toBeInTheDocument() expect(within(table).getByText("平安银行")).toBeInTheDocument() + expect(within(table).getByText("86.40 分")).toBeInTheDocument() + expect(within(table).getByText("华纳药厂")).toBeInTheDocument() const detailPanel = screen.getByRole("complementary", { name: "当前股票详情", }) expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument() + expect(within(detailPanel).getByText("图形相似度评分")).toBeInTheDocument() + expect( + within(detailPanel).getByText("最佳案例:华纳药厂"), + ).toBeInTheDocument() + expect(within(detailPanel).getByText("价格形态")).toBeInTheDocument() expect(within(detailPanel).getAllByText("J 值")).toHaveLength(2) expect(within(detailPanel).getByText("RSI")).toBeInTheDocument() }) + it.each<{ + expected: string + score: SelectionPatternScore | null + }>([ + { + expected: "本次运行未执行图形评分。", + score: null, + }, + { + expected: "未匹配到 60.00 分以上案例。", + score: { + breakdown: null, + case: null, + reason: "未匹配到评分阈值以上案例", + status: "below_threshold", + threshold: 60, + value: null, + version: "zhixing_b1_pattern_fastdtw_v1", + }, + }, + { + expected: "评分暂不可用", + score: { + breakdown: null, + case: null, + reason: "FastDTW unavailable", + status: "failed", + threshold: null, + value: null, + version: null, + }, + }, + ])( + "keeps selection signals visible when score state changes", + ({ expected, score }) => { + useSelectionResults.mockReturnValue({ + data: { + ...selectedResult, + stocks: [{ ...selectedResult.stocks[0], score }], + }, + isError: false, + isPending: false, + }) + + render() + + expect(screen.getAllByText(expected).length).toBeGreaterThan(0) + const table = screen.getByRole("table") + expect(within(table).getByText("原始 B1")).toBeInTheDocument() + expect(within(table).getByText("12")).toBeInTheDocument() + }, + ) + it("opens execution status details in a drawer and restores trigger focus", async () => { useSelectionResults.mockReturnValue({ data: { @@ -328,6 +418,42 @@ describe("SelectionResultsPage", () => { }) }) + it("requests database-backed score sorting and resets the page", async () => { + render() + + const sortTrigger = screen.getByRole("combobox", { + name: "排序命中股票", + }) + fireEvent.click(sortTrigger) + const option = await screen.findByRole("option", { name: "评分从高到低" }) + fireEvent.pointerDown(option, { pointerType: "mouse" }) + fireEvent.click(option) + + const lastCall = routerNavigate.mock.calls.at(-1) + const searchUpdate = lastCall?.[0].search as (previous: { + category: string + page: number + pageSize: number + search: string + sort: string + }) => Record + expect( + searchUpdate({ + category: "all", + page: 3, + pageSize: 5, + search: "", + sort: "code", + }), + ).toEqual({ + category: "all", + page: 1, + pageSize: 5, + search: "", + sort: "score_desc", + }) + }) + it("filters signals, updates the detail panel, and expands mobile details", () => { const extraStock = { ...selectedResult.stocks[0], @@ -536,6 +662,7 @@ describe("SelectionResultsPage", () => { expect(useSelectionRun).toHaveBeenCalledWith("run-1", { page: 1, pageSize: 5, + sort: "code", }) }) diff --git a/zhixing-web/src/features/selection/pages/selection-results-page.tsx b/zhixing-web/src/features/selection/pages/selection-results-page.tsx index 36ea777..b49f990 100644 --- a/zhixing-web/src/features/selection/pages/selection-results-page.tsx +++ b/zhixing-web/src/features/selection/pages/selection-results-page.tsx @@ -61,6 +61,7 @@ export function SelectionResultsPage() { pageSize: search.pageSize, ...(search.search ? { search: search.search } : {}), ...(search.category !== "all" ? { category: search.category } : {}), + sort: search.sort, } const results = useSelectionResults( diff --git a/zhixing-web/src/routes/route-tree.tsx b/zhixing-web/src/routes/route-tree.tsx index 2821f36..ab102ad 100644 --- a/zhixing-web/src/routes/route-tree.tsx +++ b/zhixing-web/src/routes/route-tree.tsx @@ -16,7 +16,9 @@ import { import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page" import { selectionCategoryFilters, + selectionSorts, type SelectionCategoryFilter, + type SelectionSort, } from "@/features/selection/api/selection.types" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" import { SyncPage } from "@/features/sync/pages/sync-page" @@ -112,7 +114,13 @@ const selectionRoute = createRoute({ ) ? (rawCategory as SelectionCategoryFilter) : "all" - return { page, pageSize, search: searchValue, category } + const rawSort = typeof search.sort === "string" ? search.sort : "code" + const sort: SelectionSort = selectionSorts.includes( + rawSort as SelectionSort, + ) + ? (rawSort as SelectionSort) + : "code" + return { page, pageSize, search: searchValue, category, sort } }, component: SelectionResultsPage, })