perf: cache backtests by input signature
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
"""Historical backtest engine for Bitcoin Accumulation Zone scoring."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
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")
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""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.
|
||||
|
||||
If ml_mode=True, uses ML-optimized metric weights instead of equal weights.
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user