mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 20:14:16 +08:00
* feat(optimizer): 参数网格搜索优化器核心 + PanelCache 线程安全
PR2a 第一部分 (后端核心, 无 API/前端):
app/backtest/optimizer.py:
- expand_param_grid: 校验(类型/范围/选项) + 笛卡尔积展开; 支持显式候选值列表
与 {min,max,step} 范围两种写法; GRID_MAX_COMBINATIONS=2000 硬上限防爆炸。
- StrategyOptimizer.optimize: ThreadPoolExecutor 并行跑各参数组回测, 按目标指标
排序返回最优 + 全排名。objective 统一转越大越好(min 类目标取负), None/inf/
失败组沉底。支持进度回调 (done/total/best) 与 cancel_event。
- 可选目标含 PR1 新增的 sortino/mc_maxdd_*。
engine.py PanelCache 线程安全:
- get_or_compute 整体加锁 — 并行优化器对同一 symbols/日期跑几十组参数时, 首个
线程 compute 面板、其余等待后命中缓存, 同一面板只 scan_parquet+compute_all 一次
(关键性能: 参数不影响面板 key)。max_size 2->4, ttl 180->900s 适配长任务。
测试 24 例: 网格展开/校验(未知参数/越界/选项/空/组合爆炸) + 编排(排序/每组一次/
min方向/失败None沉底/取消/进度/非法目标)。用假 service 注入受控 stats 验证。
* fix(optimizer): 子代理审查修复 — 异常隔离/展示符号/浮点端点/kwargs 校验
两份子代理审查发现 4 个真实 bug:
[高] 单组异常拖垮整批: _run_one 未捕获 service.run 异常, fut.result() 会
re-raise 冲出 as_completed → 整个网格搜索崩溃、已完成结果全丢。加了并行后
这个概率显著上升。用 try/except 隔离, 该组记为 error 继续。
[高] min 方向 best_score 符号错误: 内部把 min 目标取负做排序键, 但直接把
取负值当 best_score 返回 → 用户看到 avg_holding_days=-3.0 (负天数)。根因是
排序键与展示值混用。改为分离: 内部 _sort (取负空间, 不外露) + objective_raw
(原始展示值)。best_score/进度回调均用原始值。
[中] 浮点累加丢端点: v += step 累积误差使 0.1 步长丢失 max 端点
(0.1+0.1+0.1=0.30000004 > 0.3+1e-9)。改整数计数 lo + i*step。
[中] backtest_kwargs 非法/冲突 key: 展开传给 StrategyBacktestConfig 时若含
非法或保留字段 (symbols 等) 会在 worker 抛 TypeError, 被上述 #1 放大成崩溃。
入口加白名单校验, 提前明确报错。
测试新增 8 例: 符号还原/max_drawdown 负值排序/异常隔离/kwargs 非法+保留/
base_params 合并/浮点端点/去重。原 test_min_direction 只验 params 放过了符号
bug, 现补 best_score 断言。
* feat(optimizer): 参数优化 API SSE 端点 + 前端优化器面板
PR2a 完成 (API + 前端), 接上后端核心:
后端 api/backtest.py:
- GET /optimize/stream: 复用 _BacktestJob SSE 框架, 后台线程跑 StrategyOptimizer,
progress_cb 推 done/total/best_score, 完成推 best_params/results 排名。param_grid
走 JSON 字符串查询参数 (EventSource 仅支持 GET, 同 params/overrides 惯例)。
- POST /optimize/cancel: 从 query string 复原同一 job_key, cancel_event 停止。
- backtest_kwargs 透传各回测参数 (matching/fees/mode/...) 到每组回测。
前端:
- lib/optimizerTask.ts: SSE 客户端 (镜像 backtestTask), 进度/结果/重连/取消。
- pages/backtest/StrategyOptimizer.tsx: 配置面板 (选策略 → 勾选可扫参数设
min/max/step, bool/select 自动全扫; 优化目标下拉; 日期; 组合数实时预估 + 2000
上限提示) + 结果面板 (最优参数高亮 + 排名表: objective/夏普/索提诺/收益/回撤/
胜率/交易数)。
- Backtest.tsx: 新增 '参数优化' 第三 tab。
测试 test_optimizer_api.py 3 例: job_key 确定性 + 区分 grid/objective + stream 与
cancel 复原同一 key (守护 PR3 C1 类失配)。前端 tsc 无新增类型错误。
注: SSE 端到端需真实日K数据, 本地 mode=none 无法验证实际回测; 但 SSE 管线镜像
已测的 strategy_stream, 优化器核心 24 单测覆盖。
* fix(optimizer): 子代理审查修复 — API 取消/空网格/方向对齐 + 前端重连/切换/展示
两份子代理审查(API + 前端)发现的真实问题:
后端 API:
- [中] param_grid 为 null/[]/'' 等合法 JSON 但非网格对象时, 原逻辑跳过线程却不置
job.done -> event_generator 永久空转、job 挂死在表中。改为非空 dict 校验拦下。
- [中] 取消后前端收到 done 而非取消提示: 优化器把 cancel 当每组失败正常返回 dict,
done 分支照推'完成'。改为 done 分支先检查 cancel_event, 分流为'优化已取消'。
- [低] direction 空串边界: stream 侧 '' 与 cancel 侧 or None 口径不一致致 job_key
失配(cancel 失效)。stream 加 direction = direction or None 对齐。
前端:
- [中] tryReconnectOptimize 是死代码(无调用方)违反 NO DEAD CODE: 接入 useEffect
挂载恢复(镜像 StrategyBacktest 的 tryReconnect), 刷新/切页后恢复未完成优化。
- [中] 切策略后旧结果残留错配(参数列是旧策略): onSelectStrategy 加 clearOptimize。
- [低] 排名表 slice(50) 静默截断: 加'仅显示前50/共N组'提示。
- [低] objective_raw 裸数与 best_score 精度不一: 统一 toFixed(3)。
后端 76 测试通过; 前端 tsc 无新增类型错误。
* refactor(optimizer): job_key 回吐 — 消除 cancel 两侧重算的脆弱契约
采纳子代理审查建议, 从结构上根除整类 job_key 失配 bug:
之前 cancel 需从 query string 逐字段重算 job_key, 必须与 stream 侧完全一致 —
任何默认值/None-空串/类型转换漂移都静默导致取消失效 (PR3 C1、本轮 direction
空串失配都是这个结构的产物)。
改为: stream 首个 SSE 事件 (event: job) 回吐后端算出的 job_key, 前端存下,
cancel 直接原样传回按 key 查表。cancel 侧不再重算, 契约漂移无从发生。
- 后端 optimize_stream: 首事件 yield event: job {key}; optimize_cancel 简化为
body.job_key 直接查 _running_jobs (删除 40 行 qs 重算)。
- 前端 optimizerTask: 监听 job 事件存 currentJobKey + localStorage; stopOptimize
改传 {job_key}; done/error/cancel 清理 key。
- 测试: 原 stream/cancel qs 对齐测试已无意义, 改为验证 cancel 按回吐 key 查表
(命中/已完成/未知 key 三态), 用轻量 fake Request 直调 endpoint。
后端 76 测试通过; 前端 tsc 无新增错误。策略回测路径未动 (已合并 + C1 测试守护),
本重构仅限本 PR 新增的优化器路径。
* chore(optimizer): 移除冗余 PanelCache 改动 — main 已独立实现线程安全
rebase 到 main 时发现上游已独立给 PanelCache 加锁 (且 compute 放锁外, 比本 PR
原方案更优), 并新增 asset_type 维度。故本 PR 的 PanelCache 改动 (加锁 + size/ttl
bump + docstring) 全部冗余且 docstring 已与 main 实际锁行为不符, 回退到 main 版本。
优化器共享单一 panel key, main 的 PanelCache 已完全够用。
至此本 PR 零 engine.py 改动。
* fix(optimizer): 处理 #82 review 的 6 处问题
作者 review #82 提出的阻塞/改进项, 逐条修复:
[阻塞1] 前端构建失败: EmptyState 只接受 title/hint, 误用了 description ->
npm run build (tsc -b) 报 TS2322。改为 hint, build 通过。
[阻塞2] 优化没用用户当前策略配置: optimize API/前端只传 strategy_id/param_grid/
objective/日期/mode, 未传 params/overrides。补齐 —— API 新增 params(base_params)/
overrides 两个 query 参数并纳入 job_key; 前端把选中策略的 params_defaults 作为未扫描
参数固定值, buildDefaultOverrides(strategy) 让 basic_filter/信号/风控按当前策略参与。
抽 lib/strategyOverrides.ts 共享 (与策略回测页同口径, 避免重复)。
[阻塞3] 切策略丢失运行中任务控制权: 原 clearOptimize 只清前端状态, 不关 SSE/不 cancel
后端/不清 localStorage -> 后端继续跑但 Stop 消失。改为: 有任务在跑时切策略先 stopOptimize
(真正 cancel + 关连接 + 清存储)。
[阻塞4] 停止按钮竞态: job_key 只在收到首个 job 事件后才有, 刚点开始就点停止时前端还没
key, cancel 落空。改为: stopOptimize 标记 cancelRequested, 有 key 立即 POST cancel,
无 key 则保持 SSE 等 job 事件到达时补发 cancel 再关 (关 SSE 不停后端 daemon 线程, 必须
真 POST); 加 5s 兜底。
[改进5] SSE 断线健壮性: 无 data 断线原全靠浏览器自动重连无上限。加 MAX_RECONNECT=5,
超限置 error 停 pending。
[改进6] 组合数校验与后端不一致: 前端 round((hi-lo)/step)+1 会把 min=0/max=1/step=0.6
显示为可运行 3 组, 但后端生成末值 1.2>max 报错。新增 sweepError 与后端 _candidates_for
同口径校验 (步长不整除/越界), 前端提前拦并禁用运行。
后端 156 测试通过; 前端 npm run build 通过。
126 lines
5.0 KiB
Python
126 lines
5.0 KiB
Python
"""参数网格展开与校验测试 — 优化器的纯逻辑核心。
|
|
|
|
被测:
|
|
- expand_param_grid(params_meta, param_grid): 校验 + 笛卡尔积 -> 参数组合列表
|
|
- count_combinations(params_meta, param_grid): 组合数 (不真正展开, 用于爆炸预判)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.backtest.optimizer import (
|
|
GRID_MAX_COMBINATIONS,
|
|
count_combinations,
|
|
expand_param_grid,
|
|
)
|
|
|
|
# 模拟一个策略的 params meta (对齐 StrategyDef.meta["params"] 结构)
|
|
PARAMS_META = [
|
|
{"id": "ma_proximity", "type": "float", "default": 0.02, "min": 0.01, "max": 0.05, "step": 0.005},
|
|
{"id": "min_boards", "type": "int", "default": 2, "min": 1, "max": 20, "step": 1},
|
|
{"id": "use_ma20", "type": "bool", "default": True},
|
|
{"id": "fill", "type": "select", "default": "close_t", "options": ["close_t", "open_t+1"]},
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# 显式候选值列表
|
|
# ---------------------------------------------------------------
|
|
|
|
def test_explicit_value_lists_cartesian_product():
|
|
grid = {"ma_proximity": [0.01, 0.02], "min_boards": [2, 3, 4]}
|
|
combos = expand_param_grid(PARAMS_META, grid)
|
|
assert len(combos) == 6 # 2 x 3
|
|
assert {"ma_proximity": 0.01, "min_boards": 2} in combos
|
|
assert {"ma_proximity": 0.02, "min_boards": 4} in combos
|
|
|
|
|
|
def test_single_param_sweep():
|
|
combos = expand_param_grid(PARAMS_META, {"min_boards": [1, 5, 10]})
|
|
assert combos == [{"min_boards": 1}, {"min_boards": 5}, {"min_boards": 10}]
|
|
|
|
|
|
def test_bool_and_select_sweep():
|
|
grid = {"use_ma20": [True, False], "fill": ["close_t", "open_t+1"]}
|
|
combos = expand_param_grid(PARAMS_META, grid)
|
|
assert len(combos) == 4
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# 范围 spec {min,max,step} 自动展开
|
|
# ---------------------------------------------------------------
|
|
|
|
def test_range_spec_expands_by_step():
|
|
combos = expand_param_grid(PARAMS_META, {"ma_proximity": {"min": 0.01, "max": 0.03, "step": 0.01}})
|
|
vals = sorted(c["ma_proximity"] for c in combos)
|
|
assert vals == [0.01, 0.02, 0.03] # 含端点
|
|
|
|
|
|
def test_range_spec_float_keeps_endpoint_despite_accumulation():
|
|
"""0.1 步长的浮点累加易丢端点 (0.1+0.1+0.1=0.30000004); 整数计数必须保住 0.3。"""
|
|
meta = [{"id": "p", "type": "float", "default": 0.2, "min": 0.1, "max": 0.3, "step": 0.1}]
|
|
combos = expand_param_grid(meta, {"p": {"min": 0.1, "max": 0.3, "step": 0.1}})
|
|
vals = sorted(c["p"] for c in combos)
|
|
assert vals == [0.1, 0.2, 0.3] # 含端点 0.3, 不丢
|
|
|
|
|
|
def test_duplicate_values_folded():
|
|
combos = expand_param_grid(PARAMS_META, {"ma_proximity": [0.02, 0.02, 0.03]})
|
|
vals = sorted(c["ma_proximity"] for c in combos)
|
|
assert vals == [0.02, 0.03] # 去重
|
|
|
|
|
|
def test_range_spec_int_yields_ints():
|
|
combos = expand_param_grid(PARAMS_META, {"min_boards": {"min": 1, "max": 4, "step": 1}})
|
|
vals = sorted(c["min_boards"] for c in combos)
|
|
assert vals == [1, 2, 3, 4]
|
|
assert all(isinstance(v, int) for v in vals)
|
|
|
|
|
|
# ---------------------------------------------------------------
|
|
# 校验: 拒绝非法 grid
|
|
# ---------------------------------------------------------------
|
|
|
|
def test_unknown_param_rejected():
|
|
with pytest.raises(ValueError, match="不存在"):
|
|
expand_param_grid(PARAMS_META, {"nonexistent": [1, 2]})
|
|
|
|
|
|
def test_value_out_of_range_rejected():
|
|
with pytest.raises(ValueError, match=r"超出范围|范围"):
|
|
expand_param_grid(PARAMS_META, {"ma_proximity": [0.01, 0.99]})
|
|
|
|
|
|
def test_select_value_not_in_options_rejected():
|
|
with pytest.raises(ValueError, match=r"options|选项"):
|
|
expand_param_grid(PARAMS_META, {"fill": ["close_t", "bad_value"]})
|
|
|
|
|
|
def test_empty_grid_rejected():
|
|
with pytest.raises(ValueError, match=r"空|至少"):
|
|
expand_param_grid(PARAMS_META, {})
|
|
|
|
|
|
def test_combination_explosion_rejected():
|
|
# 构造超过硬上限的组合
|
|
big = {"ma_proximity": {"min": 0.01, "max": 0.05, "step": 0.001}} # 41 个
|
|
# 单参数 41 个不会爆; 用多参数放大
|
|
grid = {
|
|
"ma_proximity": {"min": 0.01, "max": 0.05, "step": 0.001}, # 41
|
|
"min_boards": {"min": 1, "max": 20, "step": 1}, # 20
|
|
} # 41 x 20 = 820, 仍 < 2000; 再加一维
|
|
grid["use_ma20"] = [True, False] # x2 = 1640
|
|
# 到这仍 < 2000, 断言 count 正确
|
|
assert count_combinations(PARAMS_META, grid) == 1640
|
|
assert count_combinations(PARAMS_META, big) == 41
|
|
# 显式超限
|
|
huge = {
|
|
"ma_proximity": {"min": 0.01, "max": 0.05, "step": 0.001}, # 41
|
|
"min_boards": {"min": 1, "max": 20, "step": 1}, # 20
|
|
"fill": ["close_t", "open_t+1"], # 2
|
|
"use_ma20": [True, False], # 2
|
|
} # 41x20x2x2 = 3280 > 2000
|
|
assert count_combinations(PARAMS_META, huge) > GRID_MAX_COMBINATIONS
|
|
with pytest.raises(ValueError, match=r"组合数|上限|超过"):
|
|
expand_param_grid(PARAMS_META, huge)
|