Merge pull request 'Develop' (#20) from develop into main
Deploy Production / deploy (push) Successful in 48s

Reviewed-on: sakibcc/zhixing-system#20
This commit was merged in pull request #20.
This commit is contained in:
2026-08-31 17:16:06 +08:00
53 changed files with 2942 additions and 43 deletions
+1
View File
@@ -30,4 +30,5 @@ ZHIXING_SECTOR_RADAR_REQUEST_INTERVAL_SECONDS=0.2
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522 ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY=7380522
ZHIXING_SELECTION_MAX_WORKERS=4 ZHIXING_SELECTION_MAX_WORKERS=4
ZHIXING_SELECTION_BATCH_SIZE=200 ZHIXING_SELECTION_BATCH_SIZE=200
ZHIXING_SELECTION_PATTERN_SCORING_ENABLED=true
API_UPSTREAM=http://server:8000 API_UPSTREAM=http://server:8000
@@ -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 目标。"}
@@ -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/文档中明确分数是当前版本模板相似度,不是历史收益承诺。
@@ -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":"原评分算法、案例资产、缓存风险与当前集成接缝的源码证据。"}
@@ -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 主观视觉评分”保留为独立后续需求,不在本任务顺带实现。
@@ -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 是用户明确选择的新评分版本。
@@ -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` 已移至系统废纸篓,没有保留对原项目的改动。
@@ -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": {}
}
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 11 - **Total Sessions**: 12
- **Last Active**: 2026-08-31 - **Last Active**: 2026-08-31
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~291 | Active | | `journal-1.md` | ~313 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | 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` | | 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` | | 10 | 2026-08-29 | 完成板块资金雷达 Tushare 独立生产 MVP | `3789008`, `284c480`, `d9bae72`, `efc4c3d`, `8e96e64`, `23493fa`, `2fd16e5` | `codex/zijin` |
| 9 | 2026-08-12 | 完成选股执行性能优化 | `8963c06` | `develop` | | 9 | 2026-08-12 | 完成选股执行性能优化 | `8963c06` | `develop` |
+22
View File
@@ -289,3 +289,25 @@
### Status ### Status
[OK] **Completed** [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**
+1
View File
@@ -43,6 +43,7 @@ services:
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4}
ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} 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:-} ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
init: true init: true
ports: ports:
+1
View File
@@ -25,6 +25,7 @@ services:
ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522} ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY: ${ZHIXING_SECTOR_RADAR_ADVISORY_LOCK_KEY:-7380522}
ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4} ZHIXING_SELECTION_MAX_WORKERS: ${ZHIXING_SELECTION_MAX_WORKERS:-4}
ZHIXING_SELECTION_BATCH_SIZE: ${ZHIXING_SELECTION_BATCH_SIZE:-200} 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:-} ZHIXING_TUSHARE_TOKEN: ${ZHIXING_TUSHARE_TOKEN:-}
init: true init: true
expose: expose:
@@ -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)
+1
View File
@@ -7,6 +7,7 @@ requires-python = ">=3.12,<3.13"
dependencies = [ dependencies = [
"alembic>=1.18.0", "alembic>=1.18.0",
"fastapi>=0.141.1", "fastapi>=0.141.1",
"fastdtw>=0.3.4",
"numpy>=2.4.0", "numpy>=2.4.0",
"pandas>=2.3.3", "pandas>=2.3.3",
"psycopg[binary,pool]>=3.3.2", "psycopg[binary,pool]>=3.3.2",
@@ -31,6 +31,7 @@ class Settings(BaseSettings):
sector_radar_advisory_lock_key: int = 7_380_522 sector_radar_advisory_lock_key: int = 7_380_522
selection_max_workers: int = Field(default=4, ge=1) selection_max_workers: int = Field(default=4, ge=1)
selection_batch_size: int = Field(default=200, ge=1) selection_batch_size: int = Field(default=200, ge=1)
selection_pattern_scoring_enabled: bool = True
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
@@ -2,6 +2,7 @@
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
CheckConstraint,
Column, Column,
Date, Date,
DateTime, DateTime,
@@ -22,6 +23,24 @@ from sqlalchemy.dialects.postgresql import JSONB
metadata = MetaData() 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 = Table(
"market_stock", "market_stock",
metadata, metadata,
@@ -153,8 +172,51 @@ selection_run_item = Table(
Column("status", String(32), nullable=False), Column("status", String(32), nullable=False),
Column("signal_count", Integer, nullable=False, server_default="0"), Column("signal_count", Integer, nullable=False, server_default="0"),
Column("reason", Text), 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()), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("run_id", "ts_code"), 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( selection_signal = Table(
@@ -223,6 +285,12 @@ Index(
selection_run.c.target_trade_date, 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_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( Index(
"ix_selection_signal_strategy_date", "ix_selection_signal_strategy_date",
selection_signal.c.strategy, selection_signal.c.strategy,
@@ -11,6 +11,12 @@ from datetime import date
from typing import Literal, Protocol, cast from typing import Literal, Protocol, cast
from ..domain.models import SelectionEvaluation, StockHistory from ..domain.models import SelectionEvaluation, StockHistory
from ..domain.pattern_scoring import (
PatternCase,
PatternCaseLibraryLoader,
PatternScore,
PatternScorer,
)
from ..domain.runs import ( from ..domain.runs import (
BatchSelectionRunStore, BatchSelectionRunStore,
BatchSelectionUniverseReader, BatchSelectionUniverseReader,
@@ -54,7 +60,10 @@ class RunZhixingB1:
reader: SelectionUniverseReader, reader: SelectionUniverseReader,
store: SelectionRunStore, store: SelectionRunStore,
evaluator: SelectionEvaluator | None = None, evaluator: SelectionEvaluator | None = None,
pattern_case_loader: PatternCaseLibraryLoader | None = None,
pattern_scorer: PatternScorer | None = None,
*, *,
pattern_scoring_enabled: bool = False,
max_workers: int = 4, max_workers: int = 4,
batch_size: int = 200, batch_size: int = 200,
) -> None: ) -> None:
@@ -67,6 +76,9 @@ class RunZhixingB1:
self.reader = reader self.reader = reader
self.store = store self.store = store
self.evaluator = evaluator or EvaluateZhixingB1(reader) 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.max_workers = max_workers
self.batch_size = batch_size self.batch_size = batch_size
@@ -106,7 +118,9 @@ class RunZhixingB1:
read_seconds = 0.0 read_seconds = 0.0
evaluate_seconds = 0.0 evaluate_seconds = 0.0
persist_seconds = 0.0 persist_seconds = 0.0
scoring_seconds = 0.0
try: try:
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor: with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for batch_stocks in _chunks(stocks, self.batch_size): for batch_stocks in _chunks(stocks, self.batch_size):
read_started = time.perf_counter() read_started = time.perf_counter()
@@ -120,24 +134,39 @@ class RunZhixingB1:
) )
evaluate_started = time.perf_counter() 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( items = tuple(
_to_item( _to_item(
stock.ts_code, stock.ts_code,
stock.name, stock.name,
evaluation, evaluation,
) pattern_score=self._score_stock(
for stock, evaluation in zip( prepared.run.id,
batch_stocks, stock,
executor.map( history,
self._evaluate_stock, evaluation,
batch_stocks, pattern_cases,
histories, pattern_library_error,
[prepared.source.target_trade_date] * len(batch_stocks),
), ),
)
for stock, history, evaluation in zip(
batch_stocks,
histories,
evaluations,
strict=True, strict=True,
) )
) )
evaluate_seconds += time.perf_counter() - evaluate_started scoring_seconds += time.perf_counter() - scoring_started
evaluated_count += len(items) evaluated_count += len(items)
selected_stock_count += sum(item.status == "selected" for item in items) selected_stock_count += sum(item.status == "selected" for item in items)
@@ -184,7 +213,7 @@ class RunZhixingB1:
logger.info( logger.info(
"selection_run_summary run_id=%s stock_count=%d history_rows=%d " "selection_run_summary run_id=%s stock_count=%d history_rows=%d "
"batch_count=%d worker_count=%d read_seconds=%.3f " "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, prepared.run.id,
len(stocks), len(stocks),
history_rows, history_rows,
@@ -192,9 +221,64 @@ class RunZhixingB1:
self.max_workers, self.max_workers,
read_seconds, read_seconds,
evaluate_seconds, evaluate_seconds,
scoring_seconds,
persist_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( def _load_histories(
self, self,
stocks: Sequence[SelectionStock], stocks: Sequence[SelectionStock],
@@ -287,7 +371,13 @@ class RunZhixingB1:
return self.store.get_latest_run(strategy, target_trade_date, query=query) 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.""" """Translate a single-stock domain result into a stored item."""
return SelectionRunItem( return SelectionRunItem(
@@ -296,6 +386,7 @@ def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> Select
status=evaluation.status, status=evaluation.status,
signal_count=len(evaluation.signals), signal_count=len(evaluation.signals),
reason=evaluation.reason, reason=evaluation.reason,
pattern_score=pattern_score or PatternScore(),
signals=evaluation.signals, signals=evaluation.signals,
) )
@@ -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",
]
@@ -9,10 +9,12 @@ from decimal import Decimal
from typing import Literal, Protocol from typing import Literal, Protocol
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
from .pattern_scoring import PatternScore
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"] SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus SelectionRunItemStatus = SelectionEvaluationStatus
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"] SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"]
SelectionResultSort = Literal["code", "score_desc", "score_asc"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -23,6 +25,7 @@ class SelectionResultQuery:
page_size: int = 10 page_size: int = 10
search: str | None = None search: str | None = None
category: SelectionSignalCategoryFilter | None = None category: SelectionSignalCategoryFilter | None = None
sort: SelectionResultSort = "code"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -54,6 +57,7 @@ class SelectionRunItem:
status: SelectionRunItemStatus status: SelectionRunItemStatus
signal_count: int = 0 signal_count: int = 0
reason: str | None = None reason: str | None = None
pattern_score: PatternScore = field(default_factory=PatternScore)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple) signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
@@ -12,6 +12,12 @@ import psycopg
from ....bootstrap.config import Settings from ....bootstrap.config import Settings
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory 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.ports import MarketDataReaderError
from ..domain.runs import SelectionExecutionSource, SelectionStock from ..domain.runs import SelectionExecutionSource, SelectionStock
from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool
@@ -103,6 +109,38 @@ WHERE stock.is_active = true
ORDER BY stock.ts_code 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: def _as_date(value: object) -> date:
"""Convert a PostgreSQL date-like scalar to a date.""" """Convert a PostgreSQL date-like scalar to a date."""
@@ -386,3 +424,106 @@ class PostgresMarketDataReader:
raise raise
except Exception as exc: # noqa: BLE001 - normalize pool/driver failures except Exception as exc: # noqa: BLE001 - normalize pool/driver failures
raise SelectionReaderError("selection database operation failed") from exc 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
@@ -15,6 +15,11 @@ import psycopg
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from ..domain.models import SelectionSignal, ZhixingB1Category from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.pattern_scoring import (
ZHIXING_B1_PATTERN_CASES,
PatternScore,
PatternScoreBreakdown,
)
from ..domain.runs import ( from ..domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
@@ -44,17 +49,37 @@ _SIGNAL_ORDER_SQL = (
) )
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END" + 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 = """ _ITEM_UPSERT = """
INSERT INTO selection_run_item 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 ON CONFLICT (run_id, ts_code) DO UPDATE SET
name = EXCLUDED.name, name = EXCLUDED.name,
status = EXCLUDED.status, status = EXCLUDED.status,
signal_count = EXCLUDED.signal_count, 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 = """ _SIGNAL_UPSERT = """
INSERT INTO selection_signal INSERT INTO selection_signal
@@ -200,6 +225,19 @@ class PostgresSelectionRunRepository(SelectionRunStore):
item.status, item.status,
item.signal_count, item.signal_count,
item.reason, 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 for item in items
) )
@@ -354,7 +392,11 @@ class PostgresSelectionRunRepository(SelectionRunStore):
return None return None
item_rows = connection.execute( 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 FROM selection_run_item
WHERE run_id = %s WHERE run_id = %s
ORDER BY ts_code ORDER BY ts_code
@@ -363,7 +405,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
).fetchall() ).fetchall()
stock_filter, stock_parameters = _stock_filter(query, run_id) stock_filter, stock_parameters = _stock_filter(query, run_id)
stock_total_row = connection.execute( 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), tuple(stock_parameters),
).fetchone() ).fetchone()
stock_total = int(stock_total_row[0] or 0) if stock_total_row else 0 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, ...]], list[tuple[object, ...]],
connection.execute( connection.execute(
f""" f"""
SELECT DISTINCT ts_code SELECT item.ts_code
FROM selection_signal FROM selection_run_item AS item
WHERE {stock_filter} WHERE {stock_filter}
ORDER BY ts_code ORDER BY {_STOCK_ORDER_SQL[query.sort]}
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""", """,
tuple((*stock_parameters, query.page_size, offset)), tuple((*stock_parameters, query.page_size, offset)),
@@ -403,7 +445,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
sorted( sorted(
(_signal_from_row(value) for value in signal_rows), (_signal_from_row(value) for value in signal_rows),
key=lambda signal: ( key=lambda signal: (
signal.ts_code, stock_codes.index(signal.ts_code),
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)), _SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
), ),
) )
@@ -427,6 +469,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
), ),
signal_count=int(value[3] or 0), signal_count=int(value[3] or 0),
reason=str(value[4]) if value[4] is not None else None, 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]), ())), signals=tuple(signals_by_stock.get(str(value[0]), ())),
) )
for value in item_rows 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. 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] parameters: list[object] = [run_id]
if query.search: if query.search:
pattern = f"%{_escape_like(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)) parameters.extend((pattern, pattern))
if query.category: 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]}%") parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
return " AND ".join(clauses), parameters 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: def _escape_like(value: str) -> str:
"""Escape user wildcards before placing text inside a SQL LIKE pattern.""" """Escape user wildcards before placing text inside a SQL LIKE pattern."""
@@ -13,6 +13,10 @@ from zhixing_server.modules.selection.application.run import (
RunZhixingB1, RunZhixingB1,
) )
from zhixing_server.modules.selection.domain.models import SelectionSignal 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 ( from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired, SelectionRerunRequired,
SelectionResultQuery, 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_pool import SelectionPostgresPool
from zhixing_server.modules.selection.infrastructure.postgres_reader import ( from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader, PostgresMarketDataReader,
PostgresPatternCaseLibraryLoader,
SelectionMarketDataNotReady, SelectionMarketDataNotReady,
SelectionReaderError, SelectionReaderError,
) )
@@ -82,6 +87,35 @@ class SelectionFailureResponse(BaseModel):
reason: str | None 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]: def _empty_failures() -> list[SelectionFailureResponse]:
"""Create a typed default list for Pydantic's strict checker.""" """Create a typed default list for Pydantic's strict checker."""
@@ -102,6 +136,7 @@ class SelectionStockResponse(BaseModel):
target_trade_date: date target_trade_date: date
strategy: StrategyValue strategy: StrategyValue
close: float close: float
score: SelectionPatternScoreResponse | None = None
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals) signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
@@ -144,10 +179,14 @@ def get_selection_service(
pool = get_selection_postgres_pool(settings) pool = get_selection_postgres_pool(settings)
reader = PostgresMarketDataReader(settings, pool=pool) reader = PostgresMarketDataReader(settings, pool=pool)
pattern_case_loader = PostgresPatternCaseLibraryLoader(settings, pool=pool)
store = PostgresSelectionRunRepository(settings.database_url, pool=pool) store = PostgresSelectionRunRepository(settings.database_url, pool=pool)
return RunZhixingB1( return RunZhixingB1(
reader, reader,
store, store,
pattern_case_loader=pattern_case_loader,
pattern_scorer=ZhixingB1PatternScorer(),
pattern_scoring_enabled=settings.selection_pattern_scoring_enabled,
max_workers=settings.selection_max_workers, max_workers=settings.selection_max_workers,
batch_size=settings.selection_batch_size, batch_size=settings.selection_batch_size,
) )
@@ -225,11 +264,12 @@ def get_selection_run(
page_size: Annotated[int, Query(ge=1, le=100)] = 10, page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None, search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None, category: Literal["pullback", "oversold", "original"] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return one run for asynchronous polling.""" """Return one run for asynchronous polling."""
try: 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) run = service.get_run(run_id, query=query)
except SelectionRunStoreError as exc: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from 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, page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None, search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None, category: Literal["pullback", "oversold", "original"] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse: ) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date.""" """Return the current persisted result for a strategy and optional date."""
try: 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) run = service.get_latest(strategy, target_trade_date, query=query)
except SelectionRunStoreError as exc: except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from 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]] = {} signals_by_stock: dict[str, list[SelectionSignalResponse]] = {}
for signal in run.signals: for signal in run.signals:
signals_by_stock.setdefault(signal.ts_code, []).append(_signal_response(signal)) 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( return SelectionResultsResponse(
strategy=run.strategy, strategy=run.strategy,
@@ -316,6 +358,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
target_trade_date=signals[0].target_trade_date, target_trade_date=signals[0].target_trade_date,
strategy=signals[0].strategy, strategy=signals[0].strategy,
close=signals[0].close, close=signals[0].close,
score=_pattern_score_response(items_by_stock[signals[0].ts_code].pattern_score),
signals=signals, signals=signals,
) )
for signals in signals_by_stock.values() 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( def _result_query(
page: int, page: int,
page_size: int, page_size: int,
search: str | None, search: str | None,
category: Literal["pullback", "oversold", "original"] | None, category: Literal["pullback", "oversold", "original"] | None,
sort: Literal["code", "score_desc", "score_asc"],
) -> SelectionResultQuery: ) -> SelectionResultQuery:
"""Normalize HTTP query values before handing them to the selection port.""" """Normalize HTTP query values before handing them to the selection port."""
@@ -351,6 +426,7 @@ def _result_query(
page_size=page_size, page_size=page_size,
search=normalized_search or None, search=normalized_search or None,
category=category, category=category,
sort=sort,
) )
@@ -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,不再静默接受部分案例。
@@ -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
1 date open high low close volume market_cap
2 2025-04-01 27.93 29.03 27.8 28.94 27218.84 5612754000
3 2025-04-02 28.9 29.18 28.69 28.94 12933.01 5612754000
4 2025-04-03 28.71 29.07 28.54 28.73 11232.75 5612754000
5 2025-04-07 27.93 27.93 23.19 24.02 37675.08 5612754000
6 2025-04-08 24.03 25.03 24.03 24.86 16676.65 5612754000
7 2025-04-09 24.49 24.87 23.12 24.73 14468.45 5612754000
8 2025-04-10 24.96 25.53 24.89 25.1 11065.87 5612754000
9 2025-04-11 25.02 25.91 24.7 25.67 11201.14 5612754000
10 2025-04-14 25.75 26.88 25.75 26.29 14566.38 5612754000
11 2025-04-15 26.41 27.19 26.09 26.17 10132.02 5612754000
12 2025-04-16 26.04 26.71 25.88 26.38 13525.38 5612754000
13 2025-04-17 26.11 28.69 26.04 28.39 42729.26 5612754000
14 2025-04-18 28.83 29.54 27.91 28.51 48214.96 5612754000
15 2025-04-21 28.83 31.25 27.94 30.52 96121.97 5612754000
16 2025-04-22 30.52 35.02 30.52 32.77 148408.79 5612754000
17 2025-04-23 32.43 33.71 30.92 32.34 56626.78 5612754000
18 2025-04-24 32.44 34.67 32.44 34.18 48618.11 5612754000
19 2025-04-25 34.18 34.55 30.24 30.67 75477.69 5612754000
20 2025-04-28 30.93 32.63 30.24 31.15 62031.09 5612754000
21 2025-04-29 31.8 32.54 30.97 31.39 34211.51 5612754000
22 2025-04-30 32.02 32.02 29.92 30.19 48359.44 5612754000
23 2025-05-06 30.24 30.62 29.2 29.5 36216.67 5612754000
24 2025-05-07 29.67 30.34 29.18 29.54 26316.22 5612754000
25 2025-05-08 29.54 29.94 29.26 29.82 20883.91 5612754000
26 2025-05-09 29.82 30.44 29.32 29.44 16659.71 5612754000
@@ -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
1 date open high low close volume market_cap
2 2025-07-02 10.88 11.1 10.57 10.67 1287084.86 12045490456
3 2025-07-03 10.62 10.9 10.54 10.82 991702.0 12045490456
4 2025-07-04 10.83 10.88 10.4 10.45 855100.82 12045490456
5 2025-07-07 10.28 11.26 10.28 10.98 1216456.82 12045490456
6 2025-07-08 10.89 11.52 10.81 11.15 1558398.35 12045490456
7 2025-07-09 11.18 11.23 10.79 10.84 1033990.38 12045490456
8 2025-07-10 11.27 11.86 10.93 11.64 2056513.24 12045490456
9 2025-07-11 11.87 12.46 11.53 12.13 2320402.62 12045490456
10 2025-07-14 12.19 12.46 11.57 11.62 1491417.84 12045490456
11 2025-07-15 11.58 12.78 11.58 12.2 2460847.71 12045490456
12 2025-07-16 11.98 12.04 11.27 11.32 1938934.42 12045490456
13 2025-07-17 11.08 11.49 10.97 11.4 1018625.42 12045490456
14 2025-07-18 11.34 12.14 11.32 11.68 1574602.28 12045490456
15 2025-07-21 11.6 11.98 11.57 11.8 1226347.09 12045490456
16 2025-07-22 11.68 12.0 11.47 11.56 985223.02 12045490456
17 2025-07-23 11.46 11.73 11.2 11.5 751845.98 12045490456
18 2025-07-24 11.42 12.4 11.39 12.27 1884541.46 12045490456
19 2025-07-25 12.22 13.06 12.12 12.61 1848357.03 12045490456
20 2025-07-28 12.91 12.97 12.61 12.69 1106575.15 12045490456
21 2025-07-29 12.41 12.64 12.28 12.4 794365.97 12045490456
22 2025-07-30 12.38 12.44 11.83 12.09 880349.27 12045490456
23 2025-07-31 11.97 12.13 11.75 11.81 547576.88 12045490456
24 2025-08-01 11.8 11.8 11.54 11.58 448552.57 12045490456
25 2025-08-04 11.6 11.68 11.51 11.63 404376.06 12045490456
26 2025-08-05 11.8 11.89 11.63 11.68 518346.76 12045490456
@@ -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
1 date open high low close volume market_cap
2 2025-05-15 17.33 17.42 17.1 17.25 20642.05 11720226798
3 2025-05-16 17.18 17.73 17.17 17.43 35369.48 11720226798
4 2025-05-19 17.48 17.48 17.08 17.25 25489.78 11720226798
5 2025-05-20 17.36 17.74 17.32 17.53 36708.2 11720226798
6 2025-05-21 17.72 18.22 17.47 17.72 41463.24 11720226798
7 2025-05-22 17.62 17.81 17.37 17.58 40314.58 11720226798
8 2025-05-23 17.52 17.97 17.47 17.51 46281.21 11720226798
9 2025-05-26 17.63 17.63 17.05 17.09 38830.29 11720226798
10 2025-05-27 17.17 17.32 17.0 17.16 42731.45 11720226798
11 2025-05-28 17.17 18.4 17.08 18.21 123423.01 11720226798
12 2025-05-29 18.44 20.16 18.36 19.65 194317.79 11720226798
13 2025-05-30 19.74 19.96 19.39 19.76 132173.99 11720226798
14 2025-06-03 19.86 22.94 19.85 22.36 290301.1 11720226798
15 2025-06-04 22.17 22.76 21.58 22.54 199596.74 11720226798
16 2025-06-05 22.54 23.42 21.96 23.31 231289.99 11720226798
17 2025-06-06 23.01 23.11 21.66 22.86 233436.91 11720226798
18 2025-06-09 22.76 24.44 22.76 23.71 261851.09 11720226798
19 2025-06-10 23.69 23.82 22.66 22.81 190046.45 11720226798
20 2025-06-11 22.89 23.06 22.32 22.37 116651.04 11720226798
21 2025-06-12 22.64 24.05 22.18 23.28 190460.15 11720226798
22 2025-06-13 23.16 23.64 22.72 22.88 106830.32 11720226798
23 2025-06-16 22.88 23.32 22.51 22.75 70989.1 11720226798
24 2025-06-17 23.23 23.41 21.97 22.18 139623.83 11720226798
25 2025-06-18 21.85 22.29 21.61 22.22 100081.97 11720226798
26 2025-06-19 22.22 22.49 21.31 21.44 76485.29 11720226798
@@ -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
1 date open high low close volume market_cap
2 2025-06-18 4.65 4.86 4.6 4.83 2051281.76 39275697251
3 2025-06-19 4.79 4.98 4.75 4.78 1715941.43 39275697251
4 2025-06-20 4.77 4.81 4.63 4.65 1051952.17 39275697251
5 2025-06-23 4.6 4.75 4.57 4.7 934722.87 39275697251
6 2025-06-24 4.72 4.81 4.7 4.78 945714.0 39275697251
7 2025-06-25 4.8 4.85 4.73 4.81 1124786.7 39275697251
8 2025-06-26 4.86 5.05 4.83 4.94 2459293.2 39275697251
9 2025-06-27 4.94 5.42 4.85 5.27 4029657.48 39275697251
10 2025-06-30 5.25 5.43 5.25 5.34 2441261.2 39275697251
11 2025-07-01 5.31 5.38 5.24 5.3 1702111.13 39275697251
12 2025-07-02 5.26 5.28 5.05 5.08 1565861.38 39275697251
13 2025-07-03 5.08 5.59 5.08 5.59 4250014.47 39275697251
14 2025-07-04 5.6 5.74 5.52 5.6 4529145.33 39275697251
15 2025-07-07 5.5 5.79 5.49 5.58 2463078.1 39275697251
16 2025-07-08 5.55 5.95 5.53 5.78 3665165.9 39275697251
17 2025-07-09 5.75 5.82 5.65 5.69 2274246.96 39275697251
18 2025-07-10 5.67 5.76 5.51 5.58 2005171.32 39275697251
19 2025-07-11 5.57 5.58 5.39 5.5 1839462.11 39275697251
20 2025-07-14 5.51 5.55 5.42 5.44 1238426.57 39275697251
21 2025-07-15 5.45 5.6 5.4 5.47 2322143.38 39275697251
22 2025-07-16 5.29 5.47 5.29 5.36 1945350.4 39275697251
23 2025-07-17 5.33 5.57 5.3 5.48 2190584.97 39275697251
24 2025-07-18 5.47 5.65 5.45 5.5 2020531.6 39275697251
25 2025-07-21 5.52 5.66 5.43 5.48 1384268.25 39275697251
26 2025-07-22 5.45 5.55 5.35 5.37 1735870.73 39275697251
@@ -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
1 date open high low close volume market_cap
2 2025-06-30 32.11 33.05 31.61 32.23 1421653.88 51810407315
3 2025-07-01 31.82 32.18 30.63 31.51 1313625.56 51810407315
4 2025-07-02 31.51 31.57 30.79 30.9 615208.98 51810407315
5 2025-07-03 31.16 31.21 30.49 30.96 810657.45 51810407315
6 2025-07-04 30.65 30.93 29.93 30.43 799767.87 51810407315
7 2025-07-07 30.43 30.67 30.09 30.22 482072.12 51810407315
8 2025-07-08 30.11 30.34 29.97 30.12 621125.66 51810407315
9 2025-07-09 30.19 30.86 29.73 29.83 1103713.14 51810407315
10 2025-07-10 29.58 30.08 29.46 29.7 591122.59 51810407315
11 2025-07-11 29.61 30.49 29.52 30.06 833099.95 51810407315
12 2025-07-14 30.07 30.42 29.69 29.86 504302.93 51810407315
13 2025-07-15 29.75 30.23 29.0 29.19 737141.8 51810407315
14 2025-07-16 29.18 29.45 29.01 29.21 367657.7 51810407315
15 2025-07-17 29.2 29.94 28.85 29.79 688212.84 51810407315
16 2025-07-18 30.09 31.56 29.9 31.0 1211206.13 51810407315
17 2025-07-21 30.98 31.37 30.36 31.07 772026.09 51810407315
18 2025-07-22 30.78 31.45 30.43 30.8 785708.74 51810407315
19 2025-07-23 30.56 30.57 29.89 29.92 703169.55 51810407315
20 2025-07-24 29.84 30.46 29.77 30.31 543627.27 51810407315
21 2025-07-25 30.36 31.13 30.36 30.45 619316.45 51810407315
22 2025-07-28 30.43 31.24 30.18 31.04 702169.14 51810407315
23 2025-07-29 30.79 31.15 30.33 30.69 542088.91 51810407315
24 2025-07-30 30.84 30.85 29.24 29.48 761650.21 51810407315
25 2025-07-31 29.34 30.01 28.94 29.12 470283.8 51810407315
26 2025-08-01 28.99 29.22 28.64 28.67 386407.75 51810407315
@@ -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
1 date open high low close volume market_cap
2 2025-06-27 19.98 22.09 19.61 22.09 276542.2 3567378360
3 2025-06-30 21.86 24.19 21.46 23.37 328614.33 3567378360
4 2025-07-01 22.39 22.77 21.46 21.46 246739.02 3567378360
5 2025-07-02 20.91 21.41 20.4 20.88 152920.45 3567378360
6 2025-07-03 20.81 22.49 20.72 22.05 226316.34 3567378360
7 2025-07-04 21.4 21.76 20.75 20.76 157410.92 3567378360
8 2025-07-07 20.47 21.17 20.32 21.0 89066.04 3567378360
9 2025-07-08 21.01 21.11 20.67 20.9 81440.83 3567378360
10 2025-07-09 20.91 21.39 20.42 20.51 94518.29 3567378360
11 2025-07-10 20.51 20.51 19.94 20.32 82854.2 3567378360
12 2025-07-11 20.42 20.6 20.12 20.41 63631.7 3567378360
13 2025-07-14 20.57 20.93 20.51 20.6 71670.49 3567378360
14 2025-07-15 20.45 20.78 20.13 20.47 70849.72 3567378360
15 2025-07-16 20.6 20.86 20.33 20.47 68310.79 3567378360
16 2025-07-17 20.26 20.6 19.92 20.51 62354.29 3567378360
17 2025-07-18 20.48 20.79 20.36 20.49 62896.87 3567378360
18 2025-07-21 20.36 20.97 20.12 20.52 68576.12 3567378360
19 2025-07-22 20.4 21.25 20.34 20.96 129095.1 3567378360
20 2025-07-23 20.84 20.88 20.09 20.17 96276.56 3567378360
21 2025-07-24 20.16 20.35 20.08 20.19 45888.62 3567378360
22 2025-07-25 20.21 20.21 19.97 20.08 38465.12 3567378360
23 2025-07-28 20.09 20.55 20.06 20.4 51218.04 3567378360
24 2025-07-29 20.34 20.54 19.79 19.93 61055.53 3567378360
25 2025-07-30 19.81 20.21 19.2 19.86 79996.39 3567378360
26 2025-07-31 19.66 19.99 19.37 19.48 43501.6 3567378360
@@ -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
1 date open high low close volume market_cap
2 2025-06-05 13.86 13.93 13.61 13.82 76839.0 9399394575
3 2025-06-06 13.83 14.01 13.66 13.7 69401.0 9399394575
4 2025-06-09 13.69 13.87 13.64 13.76 75300.24 9399394575
5 2025-06-10 13.7 13.74 12.99 13.18 174574.3 9399394575
6 2025-06-11 13.2 13.34 13.11 13.3 61388.02 9399394575
7 2025-06-12 13.26 13.35 13.14 13.21 46718.0 9399394575
8 2025-06-13 13.17 13.52 13.17 13.38 164443.0 9399394575
9 2025-06-16 13.48 13.75 13.17 13.68 140522.0 9399394575
10 2025-06-17 13.63 14.09 13.62 13.97 143405.8 9399394575
11 2025-06-18 13.98 14.72 13.89 14.72 275552.83 9399394575
12 2025-06-19 14.48 14.48 13.72 14.15 252934.0 9399394575
13 2025-06-20 14.15 14.16 13.75 13.8 127924.0 9399394575
14 2025-06-23 14.01 14.33 13.9 14.33 160493.0 9399394575
15 2025-06-24 14.19 14.87 13.84 14.54 252237.43 9399394575
16 2025-06-25 14.78 16.0 14.71 16.0 600588.02 9399394575
17 2025-06-26 16.0 17.6 15.98 16.63 846170.51 9399394575
18 2025-06-27 16.56 17.27 16.3 16.42 651687.06 9399394575
19 2025-06-30 16.58 17.57 16.58 17.54 612607.43 9399394575
20 2025-07-01 17.28 17.9 16.88 17.24 468426.25 9399394575
21 2025-07-02 17.18 17.18 16.42 16.61 337259.72 9399394575
22 2025-07-03 16.62 16.84 16.37 16.46 199869.31 9399394575
23 2025-07-04 16.37 16.45 16.04 16.1 180557.04 9399394575
24 2025-07-07 16.03 16.32 15.86 16.12 142471.31 9399394575
25 2025-07-08 15.98 16.16 15.91 16.07 122700.83 9399394575
26 2025-07-09 16.08 16.45 15.94 15.99 230184.09 9399394575
@@ -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
1 date open high low close volume market_cap
2 2025-06-27 20.43 20.56 20.04 20.27 54029.3 4707136785
3 2025-06-30 20.22 20.5 20.18 20.44 45101.98 4707136785
4 2025-07-01 20.43 20.56 20.05 20.3 46657.31 4707136785
5 2025-07-02 20.28 20.28 19.76 20.03 40227.67 4707136785
6 2025-07-03 20.1 20.18 19.73 19.89 29471.22 4707136785
7 2025-07-04 20.02 20.02 19.43 19.62 32150.52 4707136785
8 2025-07-07 19.61 19.85 19.4 19.78 24560.45 4707136785
9 2025-07-08 19.71 21.16 19.71 20.73 110928.39 4707136785
10 2025-07-09 21.04 21.22 20.32 20.47 80045.04 4707136785
11 2025-07-10 20.25 20.51 19.88 20.01 62948.74 4707136785
12 2025-07-11 19.93 21.34 19.73 21.0 126620.87 4707136785
13 2025-07-14 21.59 24.48 21.11 23.66 270019.12 4707136785
14 2025-07-15 23.57 24.31 23.06 23.93 231598.13 4707136785
15 2025-07-16 23.56 24.31 23.23 23.5 185860.26 4707136785
16 2025-07-17 23.37 24.42 23.03 23.5 162749.05 4707136785
17 2025-07-18 23.37 23.72 22.91 23.16 111343.75 4707136785
18 2025-07-21 23.44 24.61 23.14 24.02 176471.75 4707136785
19 2025-07-22 23.79 23.95 22.83 23.07 132565.5 4707136785
20 2025-07-23 22.9 23.07 22.38 22.71 71180.1 4707136785
21 2025-07-24 22.55 23.17 22.52 22.71 55999.01 4707136785
22 2025-07-25 22.63 22.87 22.42 22.61 54779.45 4707136785
23 2025-07-28 22.97 25.66 22.97 24.61 247298.15 4707136785
24 2025-07-29 24.08 24.45 23.8 24.14 144127.75 4707136785
25 2025-07-30 23.96 24.23 23.15 23.27 110016.38 4707136785
26 2025-07-31 23.07 23.58 22.74 22.87 84262.28 4707136785
@@ -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
1 date open high low close volume market_cap
2 2025-06-06 17.64 19.41 17.16 19.41 295488.65 4910064388
3 2025-06-09 20.53 21.35 20.53 21.35 162351.45 4910064388
4 2025-06-10 23.48 23.48 22.95 23.48 96161.81 4910064388
5 2025-06-11 25.5 25.83 24.22 25.83 538547.27 4910064388
6 2025-06-12 27.48 28.07 25.7 26.81 487592.83 4910064388
7 2025-06-13 25.98 26.92 25.23 26.07 302545.06 4910064388
8 2025-06-16 25.81 28.68 25.41 28.68 285190.35 4910064388
9 2025-06-17 30.1 31.49 28.19 28.7 386383.92 4910064388
10 2025-06-18 28.11 28.57 26.72 27.7 314113.19 4910064388
11 2025-06-19 28.59 30.13 27.97 28.73 229020.92 4910064388
12 2025-06-20 26.78 31.6 26.78 31.6 174055.39 4910064388
13 2025-06-23 31.05 31.95 28.44 31.67 234024.79 4910064388
14 2025-06-24 31.07 34.13 31.07 33.05 233411.76 4910064388
15 2025-06-25 31.9 34.7 31.85 32.5 242343.26 4910064388
16 2025-06-26 30.83 32.1 29.28 30.55 193713.48 4910064388
17 2025-06-27 30.57 33.6 30.26 33.6 131012.98 4910064388
18 2025-06-30 33.6 36.97 33.6 36.71 195320.19 4910064388
19 2025-07-01 35.84 40.38 35.83 40.38 161306.24 4910064388
20 2025-07-02 40.35 44.42 39.75 44.42 212138.21 4910064388
21 2025-07-03 40.56 48.08 40.56 44.44 176844.52 4910064388
22 2025-07-04 43.53 44.12 40.49 40.96 138196.86 4910064388
23 2025-07-07 42.76 42.76 39.49 41.12 105590.86 4910064388
24 2025-07-08 41.36 41.5 38.07 39.52 105213.28 4910064388
25 2025-07-09 39.31 40.63 37.92 39.33 97899.1 4910064388
26 2025-07-10 39.46 39.56 36.96 37.43 79650.61 4910064388
@@ -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
1 date open high low close volume market_cap
2 2025-09-30 7.57 7.76 7.57 7.74 204915.56 22650295811
3 2025-10-09 7.75 7.8 7.68 7.8 196793.96 22650295811
4 2025-10-10 7.79 7.82 7.73 7.75 163527.18 22650295811
5 2025-10-13 7.6 7.8 7.47 7.8 208009.58 22650295811
6 2025-10-14 7.82 7.9 7.73 7.78 203765.38 22650295811
7 2025-10-15 7.77 7.78 7.67 7.75 158196.56 22650295811
8 2025-10-16 7.74 7.76 7.61 7.63 151268.43 22650295811
9 2025-10-17 7.62 7.72 7.47 7.48 162246.05 22650295811
10 2025-10-20 7.55 7.61 7.51 7.58 122212.11 22650295811
11 2025-10-21 7.58 7.67 7.56 7.64 121825.06 22650295811
12 2025-10-22 7.64 7.86 7.58 7.82 322717.34 22650295811
13 2025-10-23 7.8 7.82 7.67 7.81 170156.0 22650295811
14 2025-10-24 8.1 8.3 7.95 7.98 615299.42 22650295811
15 2025-10-27 8.0 8.18 7.94 8.04 433223.34 22650295811
16 2025-10-28 7.99 8.84 7.97 8.84 1610159.98 22650295811
17 2025-10-29 8.6 9.0 8.41 8.75 1722050.97 22650295811
18 2025-10-30 8.7 8.82 8.51 8.6 1035934.51 22650295811
19 2025-10-31 8.57 8.62 8.34 8.37 686044.09 22650295811
20 2025-11-03 8.37 8.61 8.33 8.6 748009.31 22650295811
21 2025-11-04 8.52 9.26 8.5 8.98 1365750.29 22650295811
22 2025-11-05 8.76 8.91 8.67 8.81 823459.67 22650295811
23 2025-11-06 8.77 8.8 8.6 8.65 553188.01 22650295811
24 2025-11-07 8.67 8.75 8.56 8.67 592496.69 22650295811
25 2025-11-10 8.74 8.79 8.51 8.53 513705.42 22650295811
26 2025-11-11 8.48 8.54 8.39 8.47 440314.81 22650295811
@@ -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
}
}
}
@@ -26,7 +26,8 @@ def test_postgres_migration_creates_market_data_contract(
engine: Engine = create_engine(sqlalchemy_url) engine: Engine = create_engine(sqlalchemy_url)
command.upgrade(config, "head") command.upgrade(config, "head")
try: try:
tables = set(inspect(engine).get_table_names()) inspector = inspect(engine)
tables = set(inspector.get_table_names())
assert { assert {
"market_stock", "market_stock",
"market_daily_bar", "market_daily_bar",
@@ -46,6 +47,25 @@ def test_postgres_migration_creates_market_data_contract(
"sector_radar_daily_aggregate", "sector_radar_daily_aggregate",
"sector_radar_publication_source", "sector_radar_publication_source",
} <= tables } <= 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: finally:
engine.dispose() engine.dispose()
get_settings.cache_clear() get_settings.cache_clear()
+100
View File
@@ -1,5 +1,6 @@
"""HTTP contracts for triggering and querying persisted selection runs.""" """HTTP contracts for triggering and querying persisted selection runs."""
from dataclasses import replace
from datetime import date from datetime import date
from decimal import Decimal 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.bootstrap.config import Settings
from zhixing_server.modules.selection.application.run import PreparedSelectionRun 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.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 ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
@@ -130,6 +137,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
name="平安银行", name="平安银行",
status="selected", status="selected",
signal_count=2, 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), signals=(original_signal, pullback_signal),
), ),
), ),
@@ -238,6 +253,24 @@ def test_query_returns_persisted_signal_details() -> None:
assert "signals" not in body assert "signals" not in body
assert len(body["stocks"]) == 1 assert len(body["stocks"]) == 1
assert body["stocks"][0]["ts_code"] == "000001.SZ" 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"]] == [ assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [
"zhixing_b1_original_b1", "zhixing_b1_original_b1",
"zhixing_b1_pullback_white", "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: def test_query_forwards_pagination_and_filters() -> None:
service = FakeSelectionService(_run("run-http", "success")) service = FakeSelectionService(_run("run-http", "success"))
@@ -259,6 +345,7 @@ def test_query_forwards_pagination_and_filters() -> None:
"page_size": 5, "page_size": 5,
"search": " 平安银行 ", "search": " 平安银行 ",
"category": "original", "category": "original",
"sort": "score_desc",
}, },
) )
@@ -268,6 +355,7 @@ def test_query_forwards_pagination_and_filters() -> None:
page_size=5, page_size=5,
search="平安银行", search="平安银行",
category="original", category="original",
sort="score_desc",
) )
assert response.json()["page"] == 2 assert response.json()["page"] == 2
assert response.json()["page_size"] == 5 assert response.json()["page_size"] == 5
@@ -282,6 +370,18 @@ def test_query_rejects_invalid_page_size() -> None:
assert response.status_code == 422 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: def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get( response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http" "/api/v1/selection/runs/run-http"
@@ -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]
@@ -2,17 +2,19 @@
from collections.abc import Generator from collections.abc import Generator
from contextlib import contextmanager from contextlib import contextmanager
from datetime import date from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import cast from typing import cast
import psycopg import psycopg
import pytest 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.domain.runs import SelectionStock
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
from zhixing_server.modules.selection.infrastructure.postgres_reader import ( from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader, PostgresMarketDataReader,
PostgresPatternCaseLibraryLoader,
SelectionMarketDataNotReady, SelectionMarketDataNotReady,
) )
@@ -237,3 +239,34 @@ def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.M
"zhixing_b1", "zhixing_b1",
date(2026, 8, 8), 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]
@@ -8,6 +8,12 @@ import pytest
from psycopg.types.json import Jsonb from psycopg.types.json import Jsonb
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category 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 ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionRerunRequired, SelectionRerunRequired,
@@ -222,6 +228,14 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
name="平安银行", name="平安银行",
status="selected", status="selected",
signal_count=2, 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), signals=(first, second),
), ),
SelectionRunItem( 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 delete_parameters == ("run-1", ["000001.SZ", "600000.SH"])
assert len(connection.executemany_calls) == 2 assert len(connection.executemany_calls) == 2
assert "INSERT INTO selection_run_item" in connection.executemany_calls[0][0] 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] assert "INSERT INTO selection_signal" in connection.executemany_calls[1][0]
signal_parameters = connection.executemany_calls[1][1] signal_parameters = connection.executemany_calls[1][1]
assert len(signal_parameters) == 2 assert len(signal_parameters) == 2
@@ -281,11 +317,35 @@ class LoadConnection:
None, None,
) )
) )
if "FROM selection_run_item" in query: if "FROM selection_run_item\n" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)]) return LoadResult(
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query: 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,)) 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=[("000001.SZ",)])
return LoadResult( return LoadResult(
rows=[ rows=[
@@ -315,7 +375,7 @@ class EmptyStockPageConnection(LoadConnection):
"""Return a non-zero filtered total with no stocks on the requested page.""" """Return a non-zero filtered total with no stocks on the requested page."""
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult": 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)) self.statements.append((query, parameters))
return LoadResult(rows=[]) return LoadResult(rows=[])
return super().execute(query, parameters) 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, page_size=1,
search="100%", search="100%",
category="pullback", 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], ZHIXING_B1_SIGNAL_ORDER[-1],
] ]
assert run.stocks_total == 2 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( count_query, count_parameters = next(
(query, parameters) (query, parameters)
for query, parameters in connection.statements 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 "name ILIKE %s ESCAPE" in count_query
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%") assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
stock_page_query, page_parameters = next( stock_page_query, page_parameters = next(
(query, parameters) (query, parameters)
for query, parameters in connection.statements 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) assert page_parameters[-2:] == (1, 0)
signal_query, signal_parameters = next( signal_query, signal_parameters = next(
(query, parameters) (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( stock_page_query, stock_page_parameters = next(
(query, parameters) (query, parameters)
for query, parameters in connection.statements 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 stock_page_parameters[-2:] == (1, 2)
assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements) 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
@@ -2,6 +2,7 @@
import threading import threading
import time import time
from collections.abc import Sequence
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from typing import Literal from typing import Literal
@@ -15,6 +16,14 @@ from zhixing_server.modules.selection.domain.models import (
SelectionSignal, SelectionSignal,
StockHistory, 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 ( from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource, SelectionExecutionSource,
SelectionResultQuery, SelectionResultQuery,
@@ -198,6 +207,37 @@ class ConcurrentHistoryEvaluator:
return SelectionEvaluation(history.ts_code, target_trade_date, "no_signal") 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: def _source() -> SelectionExecutionSource:
return SelectionExecutionSource( return SelectionExecutionSource(
market_sync_batch_id="market-run-1", 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[0:2] == ("run-1", "failed")
assert store.finished[2]["error_type"] == "batch_error" assert store.finished[2]["error_type"] == "batch_error"
assert store.finished[2]["failed_count"] == 1 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
+11
View File
@@ -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" }, { 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.4" version = "3.5.4"
@@ -892,6 +901,7 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastdtw" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pandas" }, { name = "pandas" },
{ name = "psycopg", extra = ["binary", "pool"] }, { name = "psycopg", extra = ["binary", "pool"] },
@@ -915,6 +925,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.0" }, { name = "alembic", specifier = ">=1.18.0" },
{ name = "fastapi", specifier = ">=0.141.1" }, { name = "fastapi", specifier = ">=0.141.1" },
{ name = "fastdtw", specifier = ">=0.3.4" },
{ name = "numpy", specifier = ">=2.4.0" }, { name = "numpy", specifier = ">=2.4.0" },
{ name = "pandas", specifier = ">=2.3.3" }, { name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
@@ -17,6 +17,7 @@ describe("selection API adapters", () => {
page: 2, page: 2,
pageSize: 15, pageSize: 15,
search: "平安银行", search: "平安银行",
sort: "score_desc",
}) })
const [input, init] = requestJson.mock.calls[0] as [ 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("page_size")).toBe("15")
expect(params.get("search")).toBe("平安银行") expect(params.get("search")).toBe("平安银行")
expect(params.get("category")).toBe("pullback") expect(params.get("category")).toBe("pullback")
expect(params.get("sort")).toBe("score_desc")
expect(init).toEqual({ signal: undefined }) expect(init).toEqual({ signal: undefined })
}) })
@@ -43,5 +45,6 @@ describe("selection API adapters", () => {
expect(params.get("page_size")).toBe("5") expect(params.get("page_size")).toBe("5")
expect(params.has("search")).toBe(false) expect(params.has("search")).toBe(false)
expect(params.has("category")).toBe(false) expect(params.has("category")).toBe(false)
expect(params.has("sort")).toBe(false)
}) })
}) })
@@ -44,6 +44,7 @@ function buildSelectionQueryParams(query: SelectionResultsQuery) {
}) })
if (query.search) params.set("search", query.search) if (query.search) params.set("search", query.search)
if (query.category) params.set("category", query.category) if (query.category) params.set("category", query.category)
if (query.sort) params.set("sort", query.sort)
return params return params
} }
@@ -31,6 +31,7 @@ export const selectionResultsQueryKey = (
query.pageSize, query.pageSize,
query.search ?? "", query.search ?? "",
query.category ?? "all", query.category ?? "all",
query.sort ?? "code",
] as const ] as const
export const selectionRunQueryKey = ( export const selectionRunQueryKey = (
@@ -45,6 +46,7 @@ export const selectionRunQueryKey = (
query.pageSize, query.pageSize,
query.search ?? "", query.search ?? "",
query.category ?? "all", query.category ?? "all",
query.sort ?? "code",
] as const ] as const
export function useSelectionResults( export function useSelectionResults(
@@ -10,11 +10,16 @@ export const selectionCategoryFilters = [
"original", "original",
] as const ] as const
export type SelectionSort = "code" | "score_desc" | "score_asc"
export const selectionSorts = ["code", "score_desc", "score_asc"] as const
export interface SelectionResultsQuery { export interface SelectionResultsQuery {
page: number page: number
pageSize: number pageSize: number
search?: string search?: string
category?: Exclude<SelectionCategoryFilter, "all"> category?: Exclude<SelectionCategoryFilter, "all">
sort?: SelectionSort
} }
export type SelectionRunStatus = export type SelectionRunStatus =
@@ -49,9 +54,33 @@ export interface SelectionStockResult {
target_trade_date: string target_trade_date: string
strategy: SelectionStrategy strategy: SelectionStrategy
close: number close: number
score: SelectionPatternScore | null
signals: SelectionSignal[] 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 { export interface SelectionFailure {
ts_code: string ts_code: string
name: string name: string
@@ -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 <span className="text-muted-foreground">未评分</span>
if (score.status === "failed") {
return <Badge variant="destructive">评分暂不可用</Badge>
}
if (score.status === "below_threshold") {
return (
<Badge variant="outline">
未匹配到 {formatScore(score.threshold)} 分以上案例
</Badge>
)
}
return (
<span className="space-y-0.5">
<strong className="block tabular-nums text-primary">
{formatScore(score.value)} 分
</strong>
<span className="block text-[10px] text-muted-foreground">
{score.case?.name ?? "最佳案例未知"}
</span>
</span>
)
}
export function PatternScoreDetails({ score }: PatternScoreProps) {
if (!score) {
return (
<p className="text-xs text-muted-foreground">本次运行未执行图形评分。</p>
)
}
if (score.status === "failed") {
return (
<div className="space-y-1">
<Badge variant="destructive">评分暂不可用</Badge>
<p className="text-xs text-muted-foreground">
{score.reason || "案例库或评分计算暂时不可用。"}
</p>
</div>
)
}
if (score.status === "below_threshold") {
return (
<div className="space-y-1">
<Badge variant="outline">低于匹配阈值</Badge>
<p className="text-xs text-muted-foreground">
未匹配到 {formatScore(score.threshold)} 分以上案例。
</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-2xl font-semibold tabular-nums">
{formatScore(score.value)} 分
</p>
<p className="text-xs text-muted-foreground">
最佳案例:{score.case?.name ?? "未知"}
</p>
</div>
<Badge variant="default">已匹配</Badge>
</div>
{score.breakdown ? (
<dl className="grid grid-cols-2 gap-2 text-xs">
{Object.entries(breakdownLabels).map(([key, label]) => (
<div key={key} className="rounded-md border border-border/60 p-2">
<dt className="text-muted-foreground">{label}</dt>
<dd className="mt-1 font-semibold tabular-nums">
{formatScore(
score.breakdown?.[key as keyof typeof breakdownLabels],
)}
</dd>
</div>
))}
</dl>
) : null}
</div>
)
}
function formatScore(value: number | null | undefined) {
return value === null || value === undefined ? "—" : value.toFixed(2)
}
@@ -14,6 +14,7 @@ import {
import type { import type {
SelectionResults, SelectionResults,
SelectionSort,
SelectionStockResult, SelectionStockResult,
} from "../api/selection.types" } from "../api/selection.types"
import { ExecutionStatusTrigger } from "./execution-status-trigger" import { ExecutionStatusTrigger } from "./execution-status-trigger"
@@ -28,6 +29,14 @@ import {
import { SignalTable } from "./signal-table" import { SignalTable } from "./signal-table"
const PAGE_SIZE_OPTIONS = [5, 10, 15] as const 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 { interface SelectionResultsWorkbenchProps {
drawerOpen: boolean drawerOpen: boolean
@@ -46,6 +55,7 @@ export function SelectionResultsWorkbench({
const navigate = useNavigate({ from: "/selection" }) const navigate = useNavigate({ from: "/selection" })
const query = search.search ?? "" const query = search.search ?? ""
const category = search.category ?? "all" const category = search.category ?? "all"
const sort = search.sort ?? "code"
const [selectedKey, setSelectedKey] = useState<string | null>(null) const [selectedKey, setSelectedKey] = useState<string | null>(null)
const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>( const [expandedKeys, setExpandedKeys] = useState<ReadonlySet<string>>(
new Set(), new Set(),
@@ -84,6 +94,7 @@ export function SelectionResultsWorkbench({
pageSize?: number pageSize?: number
search?: string search?: string
category?: SignalCategoryFilter category?: SignalCategoryFilter
sort?: SelectionSort
}) { }) {
void navigate({ search: (previous) => ({ ...previous, ...next }) }) void navigate({ search: (previous) => ({ ...previous, ...next }) })
} }
@@ -96,6 +107,10 @@ export function SelectionResultsWorkbench({
updateSearch({ category: value, page: 1 }) updateSearch({ category: value, page: 1 })
} }
function handleSortChange(value: SelectionSort) {
updateSearch({ page: 1, sort: value })
}
function handleToggleExpanded(stock: SelectionStockResult) { function handleToggleExpanded(stock: SelectionStockResult) {
const key = getStockKey(stock) const key = getStockKey(stock)
setExpandedKeys((previous) => { setExpandedKeys((previous) => {
@@ -143,6 +158,31 @@ export function SelectionResultsWorkbench({
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<Select
items={SCORE_SORT_OPTIONS}
onValueChange={(value) => {
if (typeof value === "string") {
handleSortChange(value as SelectionSort)
}
}}
value={sort}
>
<SelectTrigger
aria-label="排序命中股票"
className="h-11 w-full bg-background text-sm sm:w-auto sm:min-w-36 md:h-8"
>
<SelectValue placeholder="排序命中股票" />
</SelectTrigger>
<SelectContent align="start" alignItemWithTrigger={false}>
<SelectGroup>
{SCORE_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="text-xs tabular-nums text-muted-foreground sm:ml-auto"> <span className="text-xs tabular-nums text-muted-foreground sm:ml-auto">
筛选结果 {stocksTotal} 只 筛选结果 {stocksTotal} 只
</span> </span>
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge"
import { Card } from "@/shared/ui/card" import { Card } from "@/shared/ui/card"
import type { SelectionStockResult } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails } from "./pattern-score"
import { categoryToneClass, getCategoryLabel } from "./selection-presentation" import { categoryToneClass, getCategoryLabel } from "./selection-presentation"
import { SignalDetails } from "./signal-details" import { SignalDetails } from "./signal-details"
@@ -84,6 +85,16 @@ export function SignalDetailPanel({ stock }: SignalDetailPanelProps) {
</div> </div>
</dl> </dl>
<section className="mt-4" aria-labelledby="pattern-score-details">
<h3
className="mb-2 text-xs font-medium text-muted-foreground"
id="pattern-score-details"
>
图形相似度评分
</h3>
<PatternScoreDetails score={stock.score} />
</section>
<section className="mt-4" aria-labelledby="signal-detail-metrics"> <section className="mt-4" aria-labelledby="signal-detail-metrics">
<h3 <h3
className="mb-2 text-xs font-medium text-muted-foreground" className="mb-2 text-xs font-medium text-muted-foreground"
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge"
import { Button } from "@/shared/ui/button" import { Button } from "@/shared/ui/button"
import type { SelectionStockResult } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreDetails, PatternScoreSummary } from "./pattern-score"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
@@ -57,6 +58,9 @@ export function SignalRecordList({
¥ {stock.close.toFixed(2)} ¥ {stock.close.toFixed(2)}
</span> </span>
</button> </button>
<div className="text-xs">
<PatternScoreSummary score={stock.score} />
</div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{stock.signals.map((signal) => ( {stock.signals.map((signal) => (
@@ -95,6 +99,12 @@ export function SignalRecordList({
</div> </div>
{expanded ? ( {expanded ? (
<div className="space-y-3"> <div className="space-y-3">
<section className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
图形相似度评分
</p>
<PatternScoreDetails score={stock.score} />
</section>
{stock.signals.map((signal) => ( {stock.signals.map((signal) => (
<section key={signal.category} className="space-y-2"> <section key={signal.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground"> <p className="text-xs font-medium text-muted-foreground">
@@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react"
import { Badge } from "@/shared/ui/badge" import { Badge } from "@/shared/ui/badge"
import type { SelectionStockResult } from "../api/selection.types" import type { SelectionStockResult } from "../api/selection.types"
import { PatternScoreSummary } from "./pattern-score"
import { import {
categoryToneClass, categoryToneClass,
getCategoryLabel, getCategoryLabel,
@@ -38,6 +39,7 @@ export function SignalTable({
<tr className="border-b border-border/70"> <tr className="border-b border-border/70">
<th className="h-9 px-3 font-medium">股票</th> <th className="h-9 px-3 font-medium">股票</th>
<th className="h-9 px-3 font-medium">信号类型</th> <th className="h-9 px-3 font-medium">信号类型</th>
<th className="h-9 px-3 text-right font-medium">图形评分</th>
<th className="h-9 px-3 text-right font-medium">J 值</th> <th className="h-9 px-3 text-right font-medium">J 值</th>
<th className="h-9 px-3 text-right font-medium">收盘价</th> <th className="h-9 px-3 text-right font-medium">收盘价</th>
</tr> </tr>
@@ -75,6 +77,9 @@ export function SignalTable({
))} ))}
</div> </div>
</td> </td>
<td className="px-3 text-right align-middle">
<PatternScoreSummary score={stock.score} />
</td>
<td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground"> <td className="px-3 text-right align-middle font-semibold tabular-nums text-foreground">
{stock.signals.map((signal) => ( {stock.signals.map((signal) => (
<span key={signal.category} className="block"> <span key={signal.category} className="block">
@@ -92,7 +97,7 @@ export function SignalTable({
<tr> <tr>
<td <td
className="h-32 px-3 text-center text-sm text-muted-foreground" className="h-32 px-3 text-center text-sm text-muted-foreground"
colSpan={4} colSpan={5}
> >
没有符合当前筛选条件的信号。 没有符合当前筛选条件的信号。
</td> </td>
@@ -7,7 +7,10 @@ import {
} from "@testing-library/react" } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest" 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" import { SelectionResultsPage } from "./selection-results-page"
@@ -26,7 +29,13 @@ vi.mock("@/features/selection/api/selection.query", () => ({
vi.mock("@tanstack/react-router", () => ({ vi.mock("@tanstack/react-router", () => ({
useNavigate: () => routerNavigate, useNavigate: () => routerNavigate,
useSearch: () => ({ page: 1, pageSize: 5, search: "", category: "all" }), useSearch: () => ({
page: 1,
pageSize: 5,
search: "",
category: "all",
sort: "code",
}),
})) }))
const selectedResult: SelectionResults = { const selectedResult: SelectionResults = {
@@ -50,6 +59,24 @@ const selectedResult: SelectionResults = {
{ {
close: 10.5, close: 10.5,
name: "平安银行", 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: [ signals: [
{ {
category: "zhixing_b1_original_b1", category: "zhixing_b1_original_b1",
@@ -117,6 +144,9 @@ describe("SelectionResultsPage", () => {
expect( expect(
within(table).getByRole("columnheader", { name: "J 值" }), within(table).getByRole("columnheader", { name: "J 值" }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(
within(table).getByRole("columnheader", { name: "图形评分" }),
).toBeInTheDocument()
expect( expect(
within(table).queryByRole("columnheader", { name: "关键详情" }), within(table).queryByRole("columnheader", { name: "关键详情" }),
).not.toBeInTheDocument() ).not.toBeInTheDocument()
@@ -125,15 +155,75 @@ describe("SelectionResultsPage", () => {
expect(within(table).getByText("12")).toBeInTheDocument() expect(within(table).getByText("12")).toBeInTheDocument()
expect(within(table).getByText("13")).toBeInTheDocument() expect(within(table).getByText("13")).toBeInTheDocument()
expect(within(table).getByText("平安银行")).toBeInTheDocument() expect(within(table).getByText("平安银行")).toBeInTheDocument()
expect(within(table).getByText("86.40 分")).toBeInTheDocument()
expect(within(table).getByText("华纳药厂")).toBeInTheDocument()
const detailPanel = screen.getByRole("complementary", { const detailPanel = screen.getByRole("complementary", {
name: "当前股票详情", name: "当前股票详情",
}) })
expect(within(detailPanel).getByText("关键指标")).toBeInTheDocument() 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).getAllByText("J 值")).toHaveLength(2)
expect(within(detailPanel).getByText("RSI")).toBeInTheDocument() 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(<SelectionResultsPage />)
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 () => { it("opens execution status details in a drawer and restores trigger focus", async () => {
useSelectionResults.mockReturnValue({ useSelectionResults.mockReturnValue({
data: { data: {
@@ -328,6 +418,42 @@ describe("SelectionResultsPage", () => {
}) })
}) })
it("requests database-backed score sorting and resets the page", async () => {
render(<SelectionResultsPage />)
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<string, unknown>
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", () => { it("filters signals, updates the detail panel, and expands mobile details", () => {
const extraStock = { const extraStock = {
...selectedResult.stocks[0], ...selectedResult.stocks[0],
@@ -536,6 +662,7 @@ describe("SelectionResultsPage", () => {
expect(useSelectionRun).toHaveBeenCalledWith("run-1", { expect(useSelectionRun).toHaveBeenCalledWith("run-1", {
page: 1, page: 1,
pageSize: 5, pageSize: 5,
sort: "code",
}) })
}) })
@@ -61,6 +61,7 @@ export function SelectionResultsPage() {
pageSize: search.pageSize, pageSize: search.pageSize,
...(search.search ? { search: search.search } : {}), ...(search.search ? { search: search.search } : {}),
...(search.category !== "all" ? { category: search.category } : {}), ...(search.category !== "all" ? { category: search.category } : {}),
sort: search.sort,
} }
const results = useSelectionResults( const results = useSelectionResults(
+9 -1
View File
@@ -16,7 +16,9 @@ import {
import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page" import { SectorRadarPage } from "@/features/sector-radar/pages/sector-radar-page"
import { import {
selectionCategoryFilters, selectionCategoryFilters,
selectionSorts,
type SelectionCategoryFilter, type SelectionCategoryFilter,
type SelectionSort,
} from "@/features/selection/api/selection.types" } from "@/features/selection/api/selection.types"
import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page" import { SelectionResultsPage } from "@/features/selection/pages/selection-results-page"
import { SyncPage } from "@/features/sync/pages/sync-page" import { SyncPage } from "@/features/sync/pages/sync-page"
@@ -112,7 +114,13 @@ const selectionRoute = createRoute({
) )
? (rawCategory as SelectionCategoryFilter) ? (rawCategory as SelectionCategoryFilter)
: "all" : "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, component: SelectionResultsPage,
}) })