perf(selection): 优化选股执行性能
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/backend/selection.md","reason":"检查批量执行后公式结果、状态矩阵、独立 signals、分页排序和重跑契约没有漂移。"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查类型、异常隔离、测试形状和全量质量命令是否满足后端规格。"}
|
||||
{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"检查 pool/worker 生命周期、Settings 环境变量和进程关闭行为。"}
|
||||
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"检查是否复用了已有池化 PostgreSQL 适配器模式,避免引入无必要抽象。"}
|
||||
@@ -0,0 +1,151 @@
|
||||
# 技术设计:选股执行性能优化
|
||||
|
||||
## 1. 设计目标与边界
|
||||
|
||||
本次只优化 `selection` bounded context 的执行数据流,不改变 `ZhixingB1Strategy`
|
||||
的公式实现、`StockHistory` 的业务语义和现有 HTTP 结果契约。
|
||||
|
||||
保留以下外部事实:
|
||||
|
||||
- PostgreSQL 是行情和选股最终结果的事实源;
|
||||
- 选股运行仍由 `POST /api/v1/selection/runs` 创建并返回 `run_id`;
|
||||
- 每个股票仍有一个 `SelectionRunItem`,每类命中仍有一个独立
|
||||
`SelectionSignal`;
|
||||
- 单股评估异常继续隔离,不中断整个运行;
|
||||
- `(strategy, target_trade_date)` 的运行 claim、重跑保护和 advisory lock 保持不变。
|
||||
|
||||
不在本次设计中加入 Redis、外部任务队列、进程级任务恢复或新的前端进度协议。
|
||||
|
||||
## 2. 模块与接缝
|
||||
|
||||
### 2.1 应用模块
|
||||
|
||||
继续由 `RunZhixingB1` 作为深模块,对 presentation 暴露 prepare/execute/query
|
||||
能力。它内部增加三个私有阶段:
|
||||
|
||||
1. `load_histories`:按股票分块从 reader 读取历史;
|
||||
2. `evaluate_histories`:使用固定数量的线程 worker 调用现有
|
||||
`EvaluateZhixingB1.execute_history`;
|
||||
3. `record_items`:按结果分块提交 PostgreSQL。
|
||||
|
||||
公式策略本身仍只接收一个 `StockHistory`,避免把数据库、连接池和并发细节带入
|
||||
domain。
|
||||
|
||||
### 2.2 批量读取接口
|
||||
|
||||
为兼容已有单股调用者和测试 fake,在 `SelectionUniverseReader` 旁增加可选的批量
|
||||
历史读取扩展协议;生产适配器实现该扩展,执行器运行时优先探测批量方法,缺失时
|
||||
保留原单股 fallback。批量历史读取能力为:
|
||||
|
||||
```python
|
||||
load_histories(
|
||||
stocks: Sequence[SelectionStock],
|
||||
target_trade_date: date,
|
||||
) -> tuple[StockHistory, ...]
|
||||
```
|
||||
|
||||
`load_history(ts_code, target_trade_date)` 保留给单股调用者和兼容测试;批量执行路径
|
||||
不再调用它。
|
||||
|
||||
`PostgresMarketDataReader.load_histories` 使用一条参数化 SQL 读取一个分块:
|
||||
|
||||
- `bar.ts_code = ANY(%s)`;
|
||||
- `bar.source_adj = 'qfq'`;
|
||||
- `bar.trade_date <= %s`;
|
||||
- 按 `bar.ts_code, bar.trade_date` 升序返回;
|
||||
- 当前 B1 路径只读取股票名和 OHLCV,不再为历史每一行 LEFT JOIN
|
||||
`market_daily_basic`;目标日 basic 的完整性仍由执行源查询检查。
|
||||
|
||||
读取结果按股票代码分组,缺失代码返回空 `StockHistory`,由现有策略状态矩阵映射为
|
||||
`missing_target_bar` 或 `insufficient_history`。不会改变目标日截断、qfq 和排序契约。
|
||||
|
||||
分块大小由 `selection_batch_size` 控制,默认 200;每个分块读取完成、评估完成并写入
|
||||
后才释放历史对象,避免全市场历史同时驻留内存。
|
||||
|
||||
### 2.3 有界 PostgreSQL 资源
|
||||
|
||||
新增 selection infrastructure 的轻量资源 owner,内部持有一个
|
||||
`psycopg_pool.ConnectionPool`:
|
||||
|
||||
- `max_size = selection_max_workers + 2`,默认 6;
|
||||
- reader 和 run repository 共享同一个 pool;
|
||||
- pool 在进程内按 `(database_url, max_workers)` 缓存;
|
||||
- 首次使用时 open,进程退出时 close;
|
||||
- 测试通过构造函数注入 fake pool/connection。
|
||||
|
||||
这沿用市场数据模块现有的 pool 生命周期模式,不让每个 HTTP 请求或每只股票拥有
|
||||
独立 pool。读写 adapter 只借用短生命周期连接,业务事务仍由 adapter 控制。
|
||||
|
||||
### 2.4 批量写入接口
|
||||
|
||||
为兼容已有单项调用者和测试 fake,在 `SelectionRunStore` 旁增加可选的批量写入
|
||||
扩展协议:
|
||||
|
||||
```python
|
||||
record_items(run_id: str, items: Sequence[SelectionRunItem]) -> None
|
||||
```
|
||||
|
||||
`PostgresSelectionRunRepository.record_items` 在一个事务内:
|
||||
|
||||
1. 按分块股票代码删除该 run/股票已有 signal,保证重试幂等;
|
||||
2. 使用 psycopg connection cursor 的 `executemany` upsert 全部
|
||||
`selection_run_item`;
|
||||
3. 使用同一批量 API 插入全部独立 `selection_signal`;旧 fake connection 没有
|
||||
cursor 时保留逐条 execute fallback;
|
||||
4. 事务成功后返回。
|
||||
|
||||
原 `record_item` 保留为单项兼容 wrapper,并委托给 `record_items([item])`;应用批量
|
||||
路径不再调用它。新 run 的每个写入分块只提交一次事务,单股公式异常仍在应用层被
|
||||
转成 `data_error` 后进入该批次。
|
||||
|
||||
### 2.5 四 worker 执行模型
|
||||
|
||||
`RunZhixingB1` 构造时接收 `max_workers=4`,也可由 Settings 注入。每个 history 分块
|
||||
使用一个长期存在的 `ThreadPoolExecutor(max_workers=4)` 评估;worker 不直接写库。
|
||||
|
||||
选择线程而不是立即引入进程池的原因:
|
||||
|
||||
- 历史读取已经在应用线程按分块完成,无需跨进程复制数据库连接;
|
||||
- pandas/numpy 的一部分计算可以释放 GIL;
|
||||
- 线程共享只读 `StockHistory`,实现和回滚成本较低;
|
||||
- 若基准证明公式 CPU/GIL 成为主瓶颈,后续可以在同一 evaluator 接缝替换为进程
|
||||
worker,不影响 reader/store 契约。
|
||||
|
||||
每个分块保持如下状态流:
|
||||
|
||||
```text
|
||||
读取分块 → 4 worker 评估 → 聚合计数 → 一次批量写入 → 处理下一分块
|
||||
```
|
||||
|
||||
评估完成顺序不作为业务契约;查询端仍按股票代码和公式优先级稳定排序。
|
||||
|
||||
## 3. 配置与可观测性
|
||||
|
||||
新增 Settings 字段:
|
||||
|
||||
- `selection_max_workers: int = 4`,环境变量 `ZHIXING_SELECTION_MAX_WORKERS`;
|
||||
- `selection_batch_size: int = 200`,环境变量 `ZHIXING_SELECTION_BATCH_SIZE`。
|
||||
|
||||
执行结束时写一条安全的汇总日志,包含:run id、股票数、历史行数、分块数、worker
|
||||
数以及 read/evaluate/persist 的 wall-clock 秒数。不得写入连接串、token、行情详情
|
||||
或完整异常堆栈。
|
||||
|
||||
## 4. 兼容性与回滚
|
||||
|
||||
- 不新增或修改数据库表、索引和 HTTP 字段;无需 migration。
|
||||
- 如果批量读取/写入出现问题,可暂时将 `selection_batch_size=1`,保留相同接口并
|
||||
回到单股票批次;`max_workers=1` 可关闭并发以定位问题。
|
||||
- 通过 golden、应用层多分类测试和结果存储测试保证公式结果不漂移。
|
||||
- 任何 pool 获取失败都按现有 storage error 语义处理,不把半批次伪装成成功。
|
||||
|
||||
## 5. 关键取舍
|
||||
|
||||
| 选择 | 本次决定 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| Redis | 不引入 | 当前问题是 PostgreSQL 往返和串行执行,已有持久化事实源足够 |
|
||||
| 全市场一次读取 | 不采用 | 六年历史乘以全股票池会增加内存峰值 |
|
||||
| 分块读取 | 采用,默认 200 | 控制内存并保留中间进度/失败隔离 |
|
||||
| 每股事务 | 不采用 | 事务数量随股票数线性增长 |
|
||||
| 分块事务 | 采用 | 减少提交次数,同时保留可控的部分进度 |
|
||||
| 无界并发 | 不采用 | 可能耗尽 PostgreSQL 连接和内存 |
|
||||
| 4 个线程 worker | 采用 | 用户确认的初始并发度,后续以基准调整 |
|
||||
@@ -0,0 +1,4 @@
|
||||
{"file":".trellis/spec/backend/selection.md","reason":"保留 qfq、目标交易日、七类独立信号、run 重跑保护和批次状态契约。"}
|
||||
{"file":".trellis/spec/backend/configuration-and-runtime.md","reason":"新增 worker/批次配置并复用应用级资源生命周期、Settings 注入和进程关闭约定。"}
|
||||
{"file":".trellis/spec/backend/quality-guidelines.md","reason":"实现连接池、批量写入和并发后执行 Ruff、Pyright、pytest 质量门禁。"}
|
||||
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"优先复用现有市场数据 ConnectionPool 和应用组合模式,避免重复基础设施。"}
|
||||
@@ -0,0 +1,60 @@
|
||||
# 实现计划:选股执行性能优化
|
||||
|
||||
## Phase 1:基线与配置
|
||||
|
||||
1. 增加 `selection_max_workers=4` 和 `selection_batch_size=200` 配置,并同步
|
||||
`.env.example`、开发/生产 compose 的可配置环境变量。
|
||||
2. 为运行执行器增加 read/evaluate/persist 的安全汇总计时;不要输出单股行情或
|
||||
凭据。
|
||||
3. 先运行现有 selection 测试,记录基线;确认工作区中没有用户并行改动。
|
||||
|
||||
## Phase 2:连接池与批量存储
|
||||
|
||||
4. 新增 selection PostgreSQL pool resource owner,支持注入 fake pool、open/close
|
||||
和借用连接;在 presentation 组合层按数据库配置缓存并注册退出清理。
|
||||
5. 改造 `PostgresMarketDataReader` 和 `PostgresSelectionRunRepository` 使用共享
|
||||
pool,同时保留直接构造/测试兼容路径。
|
||||
6. 在 `SelectionRunStore` 增加 `record_items`;实现 chunk 内一次事务、item
|
||||
`executemany`、signal `executemany` 和重试幂等删除。
|
||||
7. 更新应用层 FakeStore、PostgreSQL adapter tests,锁定每批写入的 SQL 数量和
|
||||
独立 signal 不丢失。
|
||||
|
||||
## Phase 3:批量读取与四 worker
|
||||
|
||||
8. 在 reader 的可选批量扩展和 `EvaluateZhixingB1` 测试 seam 中加入批量 history
|
||||
读取/已有 history 评估能力;保持单股 `execute` 兼容。
|
||||
9. 实现按 `ts_code` 分组的 qfq 批量 SQL,移除当前 B1 不使用的历史 daily-basic
|
||||
join/字段,保留执行源的目标日 basic 完整性校验。
|
||||
10. 将 `RunZhixingB1.execute` 改为 200 股票分块:批量读、4 worker 评估、聚合、批量
|
||||
写入;批量扩展缺失时 fallback 到旧单股/单项接口;保留单股异常隔离和最终状态统计。
|
||||
11. 增加批量读取、缺失 history、worker 异常、重复批次写入和结果顺序稳定性测试。
|
||||
|
||||
## Phase 4:质量门禁与实测
|
||||
|
||||
12. 运行 selection 单元/golden/HTTP 测试,确认七类独立信号和重跑契约不变。
|
||||
13. 如 PostgreSQL 环境可用,使用固定目标日运行一次真实 harness,记录 worker=1
|
||||
与 worker=4 的 read/evaluate/persist 以及总耗时;检查连接数没有超过 pool 上限。
|
||||
14. 运行完整后端格式、lint、type-check、pytest;必要时运行根目录 check/test。
|
||||
15. 检查 diff 只包含本任务文件,确认不包含 Redis 或无关前端改动。
|
||||
|
||||
## 主要验证命令
|
||||
|
||||
```bash
|
||||
cd zhixing-server
|
||||
uv run pytest tests/unit/selection tests/integration/test_zhixing_b1_golden.py -q
|
||||
uv run pytest tests/test_selection_http.py -q
|
||||
uv run ruff format --check .
|
||||
uv run ruff check .
|
||||
uv run pyright
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- pool 生命周期:先用 fake pool 测试 open/close,再接入 HTTP dependency;发生资源
|
||||
泄漏时回滚组合层缓存,不动公式。
|
||||
- 批量 SQL:先保持旧单股 reader/record wrapper,批量路径验证通过后再切换应用调用。
|
||||
- 线程 worker:先以 `max_workers=1` 验证结果等价,再使用默认 4;任何状态/信号差异
|
||||
都回滚并发切换,保留批量读取/写入的独立改动。
|
||||
- 内存:固定分块 200 并在每批完成后释放 histories;如果真实数据峰值过高,先调低
|
||||
`selection_batch_size`,不增加 Redis。
|
||||
@@ -0,0 +1,87 @@
|
||||
# 优化选股执行性能
|
||||
|
||||
## Goal
|
||||
|
||||
在不改变知行 B1 公式语义和结果持久化契约的前提下,降低全量选股执行的
|
||||
数据库连接、查询和事务开销,并默认使用 4 个受限 worker 并发评估股票。
|
||||
|
||||
用户价值:执行同一目标交易日的选股策略时,系统更快完成且仍能保留完整的
|
||||
逐股状态、失败原因和七类独立子信号。
|
||||
|
||||
## Background and Confirmed Facts
|
||||
|
||||
- `RunZhixingB1.execute` 当前按 `prepared.source.stocks` 串行逐股执行:
|
||||
[application/run.py:76-122](../../../zhixing-server/src/zhixing_server/modules/selection/application/run.py:76)。
|
||||
- `PostgresMarketDataReader.load_history` 每只股票建立一次直接 PostgreSQL
|
||||
连接并读取目标日前的全部 qfq 行情:
|
||||
[infrastructure/postgres_reader.py:25-163](../../../zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_reader.py:25)。
|
||||
- `PostgresSelectionRunRepository.record_item` 每只股票建立独立事务,并逐条
|
||||
插入其信号:
|
||||
[infrastructure/postgres_runs.py:139-197](../../../zhixing-server/src/zhixing_server/modules/selection/infrastructure/postgres_runs.py:139)。
|
||||
- 市场数据模块已经有可复用的有上限 `psycopg_pool.ConnectionPool` 模式:
|
||||
[market_data/infrastructure/postgres.py:34-53](../../../zhixing-server/src/zhixing_server/modules/market_data/infrastructure/postgres.py:34)。
|
||||
- 知行 B1 需要保留七类独立子信号;历史 golden 和现有测试是结果兼容基线。
|
||||
- 本任务不引入 Redis,不改选股公式规则,不实现独立任务队列或新的进度 API。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 可观测的性能基线
|
||||
|
||||
- 为一次运行记录可复现的阶段指标:有效股票数、历史行数、读取耗时、公式
|
||||
评估耗时、持久化耗时、worker 数和批次数。
|
||||
- 指标不能输出数据库 URL、密码、Tushare token 或单股完整行情。
|
||||
- 运行结果、信号分类和失败状态仍以 PostgreSQL 持久化结果为准。
|
||||
|
||||
### R2. 连接池化
|
||||
|
||||
- 选股读取适配器和结果存储适配器使用应用生命周期内的有上限 PostgreSQL
|
||||
连接池,不再为每只股票创建和销毁连接。
|
||||
- 连接池大小必须覆盖 4 个 worker、批量写入和必要的查询余量,不能无限增长。
|
||||
- 应用关闭时可靠关闭连接池;单元测试可以注入 fake pool/connection。
|
||||
|
||||
### R3. 批量历史读取
|
||||
|
||||
- 保持 qfq、目标交易日截断、升序日期和六年数据保留语义不变。
|
||||
- 将逐股历史读取改为按股票批量/分块读取;分块大小可配置但必须有默认上限,
|
||||
防止一次性把全市场历史全部载入内存。
|
||||
- 当前 B1 公式不使用历史 `turnover_rate` 和 `total_mv`;本任务可以移除历史
|
||||
查询中不必要的 daily-basic 字段/连接,但目标日数据完整性校验必须保留。
|
||||
|
||||
### R4. 批量结果写入
|
||||
|
||||
- 将逐股 `record_item` 改为按批次写入 `selection_run_item` 和
|
||||
`selection_signal`,默认批次大小为 200,且保留逐股评估异常隔离。
|
||||
- 批次提交失败时不能标记为成功;运行最终状态必须正确收敛为
|
||||
`success`、`partial_success` 或 `failed`。
|
||||
- 结果查询、重跑唯一性和七类独立信号身份不变。
|
||||
|
||||
### R5. 四 worker 有界并发
|
||||
|
||||
- 默认使用 4 个 worker;并发度必须可配置且至少为 1。
|
||||
- worker 不得无限创建连接、线程或进程;数据库连接数受池上限约束。
|
||||
- 同一股票只评估一次;结果顺序不作为业务契约,API 查询仍按既有稳定规则排序。
|
||||
- 公式计算失败仍只影响该股票,不能丢失其他股票结果。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Redis、Celery/RQ/Arq 等外部任务队列或独立 worker 服务。
|
||||
- 前端轮询协议和执行进度接口重构。
|
||||
- 公式阈值、指标定义、历史窗口、股票池范围和信号分类调整。
|
||||
- 结果表结构的大规模迁移或删除历史结果。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 在固定历史 fixture 上,现有 golden、七类独立信号和选股状态全部保持一致。
|
||||
- [ ] 单元测试覆盖连接池生命周期、批量历史按股票分组、批量写入、4 worker
|
||||
并发上限、单股异常隔离和批次失败状态收敛。
|
||||
- [ ] 一次选股运行不再产生每股一次的数据库连接;读取和写入均通过连接池。
|
||||
- [ ] 一次选股运行不再为每只股票单独提交结果事务;结果按批次提交。
|
||||
- [ ] 运行日志/基准输出包含 R1 指标,并能区分 read/evaluate/persist 三段耗时。
|
||||
- [ ] 使用实际 PostgreSQL 数据或等价可复现 harness 验证 4 worker 下运行成功,
|
||||
且没有出现超出连接池上限的连接创建。
|
||||
- [ ] 选股 HTTP 契约、重跑保护、最终分页结果和失败列表相关测试通过。
|
||||
- [ ] 不引入 Redis,工作区只包含本任务相关改动。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无阻塞问题。批量大小默认 200,worker 默认 4;两者保持配置化,后续以基准结果调整。
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "optimize-selection-execution-performance",
|
||||
"name": "optimize-selection-execution-performance",
|
||||
"title": "优化选股执行性能",
|
||||
"description": "",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": null,
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "yuxuanhui",
|
||||
"assignee": "yuxuanhui",
|
||||
"createdAt": "2026-08-12",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
Reference in New Issue
Block a user