diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e93a26..8a5a82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,15 @@ - **`easy-tdx strategies` 内置策略列表命令**:列出注册表全部策略(名称/中文标签/参数默认值/预设寻优网格点数/说明),`--output json` 输出与 Web API `GET /backtest/strategies` 同构的完整 schema。 - **`easy-tdx portfolio --evaluate / --wf`**:组合级一条龙评估(组合回测 + 组合 WF + 跨标的适配性体检 + 综合评分 + 组合评级 + 等权买入持有基准对比,调 `evaluate_portfolio`)与组合级 Walk-Forward(`PortfolioWalkForwardEngine`),输出与 Web UI /portfolio 页同构;同时补上 `--auto-fees` 品种感知费率旗标。 +### 修复 + +- **寻优指标缓存自 v1.25 起全程 0 命中(两段式加速静默失效)**([indicator_cache.py](src/easy_tdx/backtest/indicator_cache.py)):缓存键此前用数组**对象 id** 做签名,而引擎每次 run 会重建数组对象,id 逐点漂移导致跨网格点永不命中——`test_optimizer_cache_reuse_across_grid_points` 一直在失败(仅在带缓存统计的测试里暴露,正确性对拍不受影响,属纯性能回归)。改为**内容哈希**签名(blake2b 16 字节 + dtype/shape),同值不同对象视为同一数据;附带删除防 id 复用的 `_array_refs` 强引用表(内容寻址下不再需要,缓存生命周期内的数组引用也随之释放)。实测 16 点网格命中率 38%,新增「同内容不同对象必须命中」回归测试。 + ### 测试 -- 新增 12 例:`optimize` 命令互斥/未知策略/未知参数/畸形参数校验(联网前快速失败)、`strategies` 表格与 JSON 输出、`portfolio --help` 新旗标、`optimize_all_strategies` 排名序/label/skipped/JSON 原生类型。 +- 新增 13 例:`optimize` 命令互斥/未知策略/未知参数/畸形参数校验(联网前快速失败)、`strategies` 表格与 JSON 输出、`portfolio --help` 新旗标、`optimize_all_strategies` 排名序/label/skipped/JSON 原生类型、指标缓存「同内容不同对象必须命中」回归。 - 实测验证:`strategies` 列出 54 策略;`optimize --strategy`(3×3 网格)与 `optimize --all --workers 4`(54 策略 316 网格点)真实行情跑通;`portfolio --evaluate`(完整报告含评分/评级/WF/基准)与 `--wf` 真实跑通。 -- 全量回归:pytest 1611 通过(`test_optimizer_cache_reuse_across_grid_points` 为 main 既有失败,与本次无关)、ruff/ruff format/mypy 全绿。 +- 全量回归:pytest 1613 全部通过、ruff/ruff format/mypy 全绿(原 v1.25 既有失败 `test_optimizer_cache_reuse_across_grid_points` 随缓存键修复转绿)。 ## [1.31.2] — 2026-09-04 diff --git a/src/easy_tdx/backtest/indicator_cache.py b/src/easy_tdx/backtest/indicator_cache.py index a7f9cf2..d68f12f 100644 --- a/src/easy_tdx/backtest/indicator_cache.py +++ b/src/easy_tdx/backtest/indicator_cache.py @@ -6,9 +6,11 @@ 例如 ``{"fast": [5,10,20], "slow": [10,20,30]}`` 的 9 个点里, ``MA(close, 5)`` 会被计算 3 次(与每个 slow 组合各一次),实际只需 1 次。 -:key 设计:``(函数限定名, 参数原子序列)``。数组参数用 ``(id, shape)`` 做 -签名——缓存持有数组强引用,id 在缓存生命周期内不会被复用;标量参数直接 -repr。跨进程不共享(进程池并行模式下各 worker 各自建缓存)。 +:key 设计:``(函数限定名, 参数原子序列)``。数组参数用 ``(dtype, shape, +内容哈希)`` 做签名——引擎每次 run 会重建数组对象(对象 id 不稳定), +必须按内容寻址才能跨网格点命中;标量参数直接 repr。哈希用 +blake2b(16 字节摘要),对回测级数组(KB 量级)开销可忽略。跨进程不 +共享(进程池并行模式下各 worker 各自建缓存)。 收益上限取决于指标层在回测耗时中的占比;引擎的逐 bar Python 循环无法 通用缓存,故大网格另配 ``ParamGridOptimizer(workers=N)`` 进程级并行, @@ -17,6 +19,7 @@ repr。跨进程不共享(进程池并行模式下各 worker 各自建缓存 from __future__ import annotations +import hashlib from collections.abc import Callable from typing import Any @@ -30,7 +33,6 @@ class IndicatorCache: def __init__(self) -> None: self._store: dict[tuple[Any, ...], Any] = {} - self._array_refs: dict[int, np.ndarray] = {} # 防 id 复用:持有数组强引用 self.hits = 0 self.misses = 0 @@ -86,9 +88,11 @@ class IndicatorCache: def _atom(self, a: Any) -> Any: """把单个参数转为可哈希原子。""" if isinstance(a, np.ndarray): - arr_id = id(a) - self._array_refs[arr_id] = a # 持引用,防 id 复用 - return ("arr", arr_id, a.shape) + # 内容寻址:同值不同对象必须视为同一数据(引擎每次 run 重建数组, + # 对象 id 不稳定)。NaN 按字节参与哈希,位模式不同只多算一次, + # 不会误命中。 + digest = hashlib.blake2b(a.tobytes(), digest_size=16).hexdigest() + return ("arr", str(a.dtype), a.shape, digest) if isinstance(a, int | float | str | bool | None): return ("s", type(a).__name__, repr(a)) return ("o", type(a).__name__, repr(a)) diff --git a/tests/unit/test_optimizer_cache_multiseed.py b/tests/unit/test_optimizer_cache_multiseed.py index 999b334..491bf96 100644 --- a/tests/unit/test_optimizer_cache_multiseed.py +++ b/tests/unit/test_optimizer_cache_multiseed.py @@ -62,6 +62,22 @@ def test_indicator_cache_distinguishes_arrays(): assert cache.misses == 2 # 不同数组不误命中 +def test_indicator_cache_hits_copied_array_with_same_content(): + """同内容、不同对象(引擎每次 run 重建数组的情形)必须命中。 + + 回归:键曾用对象 id 做数组签名,引擎逐 run 重建数组对象导致寻优 + 缓存全程 0 命中(两段式加速静默失效)。 + """ + from easy_tdx.MyTT import MA + + arr = _pool_df(100)["close"].to_numpy() + cache = IndicatorCache() + cache.get_or_compute(MA, (arr, 5), {}) + cache.get_or_compute(MA, (arr.copy(), 5), {}) # 同内容不同对象 + assert cache.hits == 1 + assert cache.misses == 1 + + # ── 优化器集成(缓存命中 + 结果一致 + 并行)─────────────────────────────────