From 6c7eee201248049272144dffc985dd5c94d27494 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:28:52 +0800 Subject: [PATCH] fix(backtest): reject internal calendar gaps in daily pool research Co-Authored-By: Codex --- src/backtest.py | 2 ++ tests/test_backtest_accounting.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/backtest.py b/src/backtest.py index 3ae0e2f..2bb73a0 100644 --- a/src/backtest.py +++ b/src/backtest.py @@ -214,6 +214,8 @@ def run_single_backtest( if score_column not in panel: raise ValueError("Backtest requires the requested score column.") dates = list(panel.index.get_level_values("date").unique().sort_values()) + if dates and not pd.DatetimeIndex(dates).equals(pd.date_range(dates[0], dates[-1], freq="D")): + raise ValueError("Backtest requires consecutive calendar days within the input window.") rebalance_dates = make_schedule(dates, strategy_cfg["rebalance_frequency"]) all_symbols = sorted(panel.loc[panel["in_universe"]].index.get_level_values("symbol").unique()) diff --git a/tests/test_backtest_accounting.py b/tests/test_backtest_accounting.py index 41a41d1..a1b8cb2 100644 --- a/tests/test_backtest_accounting.py +++ b/tests/test_backtest_accounting.py @@ -21,6 +21,29 @@ def panel(self, symbols=("A",)) -> pd.DataFrame: index = pd.MultiIndex.from_product([self.dates, symbols], names=["date", "symbol"]) return pd.DataFrame({"in_universe": True, "final_score": 1.0, "open": 100.0}, index=index) + def test_internal_calendar_gap_is_rejected(self) -> None: + self.dates = pd.date_range("2024-01-05", periods=6) + panel = self.panel() + panel["open"] = 100.0 * 1.01 ** np.arange(len(self.dates)) + for missing_day in (self.dates[2], self.dates[4]): + with self.subTest(missing_day=missing_day): + incomplete = panel.drop(index=missing_day, level="date") + with self.assertRaisesRegex(ValueError, "consecutive calendar days"): + run_single_backtest(incomplete, "final_score", self.config) + + def test_contiguous_subwindow_does_not_require_external_dates(self) -> None: + panel = self.panel().loc[(self.dates[1:-1], slice(None)), :] + result = run_single_backtest(panel, "final_score", self.config) + self.assertEqual(result.returns.index.tolist(), self.dates[1:-1].tolist()) + + def test_unselected_symbol_gap_does_not_invalidate_complete_calendar(self) -> None: + panel = self.panel(("A", "B")) + panel.loc[(slice(None), "B"), "in_universe"] = False + panel = panel.drop(index=(self.dates[2], "B")) + result = run_single_backtest(panel, "final_score", self.config) + self.assertEqual(result.returns.index.tolist(), self.dates.tolist()) + self.assertEqual(result.trades["symbol"].tolist(), ["A"]) + def test_cash_exit_and_reentry_follow_signal_lag_and_trade_log(self) -> None: panel = self.panel() panel.loc[(self.dates[1:3], "A"), "in_universe"] = False