perf: cache backtests by input signature
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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