Merge pull request 'Develop' (#20) from develop into main
Deploy Production / deploy (push) Successful in 48s
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:
@@ -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 是用户明确选择的新评分版本。
|
||||||
+52
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+141
@@ -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
|
||||||
|
|||||||
+113
-12
@@ -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,不再静默接受部分案例。
|
||||||
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+26
@@ -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
|
||||||
|
+43
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+11
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user