From 99f6e80ea1e3077581f3336600e7bf81668dadf5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Jul 2026 23:07:20 +0000 Subject: [PATCH] perf: cache backtests by input signature --- backtesting/engine.py | 42 ++++++++++++++++++++++++++++ tests/test_backtest_cache.py | 54 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 tests/test_backtest_cache.py diff --git a/backtesting/engine.py b/backtesting/engine.py index 7cbff96..cb10ba1 100644 --- a/backtesting/engine.py +++ b/backtesting/engine.py @@ -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. diff --git a/tests/test_backtest_cache.py b/tests/test_backtest_cache.py new file mode 100644 index 0000000..5e4294e --- /dev/null +++ b/tests/test_backtest_cache.py @@ -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