From cb64f7bf656960fbe3afa0c05a9716d6ac799395 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 12 Jul 2026 22:45:05 +0200 Subject: [PATCH] Fix bounded S/R training portfolio calendars --- app/services/backtest_service.py | 15 ++++++++++++--- docs/research/sr-levels-and-exits.md | 7 +++++++ scripts/run_sr_v2_matrix.py | 6 +++++- tests/unit/test_backtest_service.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 91883de..03995eb 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -33,7 +33,7 @@ import statistics from collections import defaultdict from collections.abc import Callable from concurrent.futures import ProcessPoolExecutor -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from types import SimpleNamespace from typing import Any @@ -1376,8 +1376,17 @@ def _simulate_portfolio( qualified_fn = _default_qualified entries_by_ord: dict[int, list[dict]] = defaultdict(list) - start_ord = start_date.toordinal() if start_date is not None else None - end_ord = end_date.toordinal() if end_date is not None else None + configured_start, configured_end = _backtest_entry_bounds() + effective_start = start_date if start_date is not None else configured_start + start_ord = effective_start.toordinal() if effective_start is not None else None + if end_date is not None: + # Explicit simulator/holdout end dates are exclusive split boundaries. + end_ord = end_date.toordinal() + elif configured_end is not None: + # BACKTEST_ENTRY_END is documented and applied as an inclusive bound. + end_ord = (configured_end + timedelta(days=1)).toordinal() + else: + end_ord = None for c in candidates: if not qualified_fn(c) or c.get("direction") != "long": continue diff --git a/docs/research/sr-levels-and-exits.md b/docs/research/sr-levels-and-exits.md index 3a7d48f..12dcdba 100644 --- a/docs/research/sr-levels-and-exits.md +++ b/docs/research/sr-levels-and-exits.md @@ -429,6 +429,13 @@ Choose one arm and record that lock before running exactly control and that arm: Replace `confirmed_rounds` with the recorded winner. The validation command also calls `scripts/compare_sr_variants.py` to produce the paired cohort CSV and JSON. +> Validation result: `confirmed_rounds` is rejected and is no longer a lockable +> arm. It remains in the corrected training matrix only to preserve the causal +> experiment record. The first training reports used an entry end bound without +> forwarding it to the portfolio calendar, leaving each book in flat cash through +> the test period. The simulator now treats `BACKTEST_ENTRY_END` as an inclusive +> entry bound and truncates the calendar after the final position can resolve. + The post-2024 interval has informed earlier research, so this is validation rather than a pristine holdout; do not sweep variants on it. No deployment follows automatically. A lower validation Sharpe or higher drawdown remains a no-ship diff --git a/scripts/run_sr_v2_matrix.py b/scripts/run_sr_v2_matrix.py index 39a619e..f5f4286 100644 --- a/scripts/run_sr_v2_matrix.py +++ b/scripts/run_sr_v2_matrix.py @@ -23,7 +23,11 @@ TRAINING_ARMS = ( "confirmed_rounds", "gate_v2", ) -LOCKABLE_ARMS = TRAINING_ARMS[1:] +# confirmed_rounds failed the post-2024 validation decisively and must not be +# selected again merely because a corrected training curve looks attractive. +LOCKABLE_ARMS = tuple( + arm for arm in TRAINING_ARMS[1:] if arm != "confirmed_rounds" +) def _add_common(parser: argparse.ArgumentParser) -> None: diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index b2dfd36..096d886 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -604,6 +604,34 @@ class TestSimulatePortfolio: def test_nothing_qualified_returns_none(self): assert bt._simulate_portfolio([], {}, None, "hold", 30) is None + def test_configured_entry_end_truncates_flat_calendar_tail(self, monkeypatch): + closes = [100.0 + i for i in range(100)] + prices = {"AAA": _sim_prices(self.ORD, closes)} + cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0) + monkeypatch.setenv( + "BACKTEST_ENTRY_END", date.fromordinal(self.ORD).isoformat() + ) + + sim = bt._simulate_portfolio([cand], prices, None, "hold", 3) + + assert sim is not None + assert sim["end_date"] == date.fromordinal(self.ORD + 3).isoformat() + + def test_configured_entry_start_aligns_book_calendar(self, monkeypatch): + closes = [100.0 + i for i in range(10)] + prices = {"AAA": _sim_prices(self.ORD, closes)} + cand = _sim_cand( + "AAA", self.ORD + 1, entry=101.0, stop=96.0, target=130.0 + ) + monkeypatch.setenv( + "BACKTEST_ENTRY_START", date.fromordinal(self.ORD).isoformat() + ) + + sim = bt._simulate_portfolio([cand], prices, None, "hold", 3) + + assert sim is not None + assert sim["start_date"] == date.fromordinal(self.ORD).isoformat() + def test_bucket_stats_counts_and_expectancy(): cands = [