perf: cache backtests by input signature

This commit is contained in:
Hermes Agent
2026-07-26 23:07:30 +00:00
parent 111b458ddf
commit 99f6e80ea1
2 changed files with 96 additions and 0 deletions
+42
View File
@@ -1,9 +1,11 @@
"""Historical backtest engine for Bitcoin Accumulation Zone scoring.""" """Historical backtest engine for Bitcoin Accumulation Zone scoring."""
import copy
import json import json
import logging import logging
import os import os
import sys import sys
import threading
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -17,6 +19,10 @@ sys.path.insert(0, BASE_DIR)
HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json") HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json")
CACHE_PATH = os.path.join(BASE_DIR, "data", "cache.json") CACHE_PATH = os.path.join(BASE_DIR, "data", "cache.json")
ML_WEIGHTS_PATH = os.path.join(BASE_DIR, "config", "ml_weights.json")
_BACKTEST_CACHE = {}
_BACKTEST_CACHE_LOCK = threading.Lock()
# Score brackets matching the dashboard assessment levels # Score brackets matching the dashboard assessment levels
BRACKETS = SCORE_BRACKETS BRACKETS = SCORE_BRACKETS
@@ -398,7 +404,43 @@ def compute_max_drawdown_forward(price_lookup, date, window=90):
return round(max_dd, 2) if max_dd > 0 else 0 return round(max_dd, 2) if max_dd > 0 else 0
def _file_signature(path):
"""Return a cheap signature that invalidates when an input file changes."""
try:
stat = os.stat(path)
return path, stat.st_mtime_ns, stat.st_size
except OSError:
return path, None, None
def clear_backtest_cache():
"""Clear memoized backtest results (primarily for explicit refreshes/tests)."""
with _BACKTEST_CACHE_LOCK:
_BACKTEST_CACHE.clear()
def run_backtest(ml_mode=False): def run_backtest(ml_mode=False):
"""Return an isolated cached result keyed by all material input files."""
signature = (
bool(ml_mode),
_file_signature(HISTORY_PATH),
_file_signature(_THRESH_PATH),
_file_signature(ML_WEIGHTS_PATH),
_file_signature(CACHE_PATH),
)
with _BACKTEST_CACHE_LOCK:
cached = _BACKTEST_CACHE.get(signature)
if cached is not None:
return copy.deepcopy(cached)
result = _compute_backtest(ml_mode=ml_mode)
with _BACKTEST_CACHE_LOCK:
_BACKTEST_CACHE.clear()
_BACKTEST_CACHE[signature] = copy.deepcopy(result)
return copy.deepcopy(result)
def _compute_backtest(ml_mode=False):
"""Run the full backtest and return comprehensive results. """Run the full backtest and return comprehensive results.
If ml_mode=True, uses ML-optimized metric weights instead of equal weights. If ml_mode=True, uses ML-optimized metric weights instead of equal weights.
+54
View File
@@ -0,0 +1,54 @@
import json
import os
from backtesting import engine
def test_run_backtest_caches_by_input_file_signature(monkeypatch, tmp_path):
history = tmp_path / "history.json"
thresholds = tmp_path / "thresholds.json"
weights = tmp_path / "weights.json"
cache = tmp_path / "cache.json"
for path in (history, thresholds, weights, cache):
path.write_text("{}")
monkeypatch.setattr(engine, "HISTORY_PATH", str(history))
monkeypatch.setattr(engine, "_THRESH_PATH", str(thresholds))
monkeypatch.setattr(engine, "ML_WEIGHTS_PATH", str(weights))
monkeypatch.setattr(engine, "CACHE_PATH", str(cache))
calls = []
monkeypatch.setattr(
engine, "_compute_backtest",
lambda ml_mode=False: calls.append(ml_mode) or {"ml_mode": ml_mode, "calls": len(calls)},
)
engine.clear_backtest_cache()
first = engine.run_backtest()
second = engine.run_backtest()
ml_first = engine.run_backtest(ml_mode=True)
ml_second = engine.run_backtest(ml_mode=True)
assert first == second == {"ml_mode": False, "calls": 1}
assert ml_first == ml_second == {"ml_mode": True, "calls": 2}
assert calls == [False, True]
history.write_text('{"changed": true}')
os.utime(history, None)
invalidated = engine.run_backtest()
assert invalidated == {"ml_mode": False, "calls": 3}
def test_cached_backtest_results_are_isolated_from_caller_mutation(monkeypatch, tmp_path):
history = tmp_path / "history.json"
history.write_text("{}")
monkeypatch.setattr(engine, "HISTORY_PATH", str(history))
monkeypatch.setattr(engine, "_THRESH_PATH", str(tmp_path / "missing-thresholds.json"))
monkeypatch.setattr(engine, "ML_WEIGHTS_PATH", str(tmp_path / "missing-weights.json"))
monkeypatch.setattr(engine, "CACHE_PATH", str(tmp_path / "missing-cache.json"))
monkeypatch.setattr(engine, "_compute_backtest", lambda ml_mode=False: {"chart_data": [{"score": 10}]})
engine.clear_backtest_cache()
first = engine.run_backtest()
first["chart_data"][0]["score"] = 99
assert engine.run_backtest()["chart_data"][0]["score"] == 10