"""Tests for calendar-aware backtest schedule resolution and execution delay mapping. These tests validate the fixes for: - Finding 1: Calendar-named cadences must use actual calendar dates, not elapsed time - Finding 2: Execution delay mapping must be explicit, not substring-based - Finding 3: Session enforcement must be enabled for CME - Finding 4: Vectorized path must use resolved schedule, not gather_every """ from datetime import datetime import polars as pl import pytest # --------------------------------------------------------------------------- # resolve_rebalance_timestamps tests # --------------------------------------------------------------------------- def _make_weekday_series(start: str, end: str) -> pl.Series: """Create a daily timestamp series (weekdays only, simulating trading days).""" dates = pl.date_range( pl.lit(datetime.strptime(start, "%Y-%m-%d")), pl.lit(datetime.strptime(end, "%Y-%m-%d")), interval="1d", eager=True, ) # Filter to weekdays (Mon=1..Fri=5 in Polars) df = pl.DataFrame({"ts": dates}).filter(pl.col("ts").dt.weekday() <= 5) return df["ts"].sort() class TestResolveRebalanceTimestamps: """Test calendar-aware schedule resolution.""" def test_monthly_month_end_returns_actual_month_ends(self): """ETF scenario: monthly_month_end must return last session of each month.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = _make_weekday_series("2016-01-01", "2016-06-30") result = resolve_rebalance_timestamps(ts, "monthly_month_end") all_dates = ts.to_list() for dt in result.to_list(): month, year = dt.month, dt.year later = [d for d in all_dates if d.year == year and d.month == month and d > dt] assert len(later) == 0, f"Month-end {dt} is not the last session in {year}-{month:02d}" def test_monthly_month_end_not_every_21_days(self): """Regression: month-end should NOT produce evenly-spaced 21-day intervals.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = _make_weekday_series("2016-01-01", "2016-12-31") result = resolve_rebalance_timestamps(ts, "monthly_month_end") assert 11 <= len(result) <= 12 gaps = result.diff().drop_nulls().dt.total_seconds() / 86400 gap_values = set(int(g) for g in gaps.to_list()) assert len(gap_values) > 1, "Month-end gaps should not all be identical" def test_weekly_friday_returns_end_of_week(self): """CME/SP500 scenario: weekly_friday_close returns last session per ISO week.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = _make_weekday_series("2018-08-01", "2018-10-31") result = resolve_rebalance_timestamps(ts, "weekly_friday_close") # Exclude last week (may be incomplete if date range ends mid-week) dates = result.to_list() # All complete weeks should end on Friday for dt in dates[:-1]: assert dt.weekday() == 4, f"Expected Friday, got {dt} (weekday={dt.weekday()})" def test_weekly_friday_holiday_fallback(self): """If Friday is missing (holiday), should take Thursday of that week.""" from datetime import date from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = _make_weekday_series("2018-08-27", "2018-09-14") # Remove Friday 2018-09-07 (simulate holiday) # ts contains date objects, so compare with date friday_to_remove = date(2018, 9, 7) ts_list = [d for d in ts.to_list() if d != friday_to_remove] ts_filtered = pl.Series("ts", ts_list) result = resolve_rebalance_timestamps(ts_filtered, "weekly_friday_close") # The week of Sep 3-7 should still have a rebalance, but on Thursday Sep 6 week_36_dates = [dt for dt in result.to_list() if dt.isocalendar()[1] == 36] assert len(week_36_dates) == 1 assert week_36_dates[0] == date(2018, 9, 6), ( f"Expected Thursday fallback, got {week_36_dates[0]}" ) def test_daily_returns_all_timestamps(self): """Daily cadence should return every available timestamp.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = _make_weekday_series("2020-01-01", "2020-01-31") for cadence in ("daily", "daily_close", "daily_ny_close"): result = resolve_rebalance_timestamps(ts, cadence) assert len(result) == len(ts), f"{cadence}: expected all {len(ts)} dates" def test_eight_hour_returns_all_timestamps(self): """8-hour funding cadence should return all timestamps.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps dates = pl.datetime_range( datetime(2020, 1, 1), datetime(2020, 1, 31), interval="8h", eager=True, ) result = resolve_rebalance_timestamps(dates, "8_hour_funding_aligned") assert len(result) == len(dates.unique()) def test_empty_series(self): """Empty input should return empty output.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps ts = pl.Series("ts", [], dtype=pl.Datetime("us")) result = resolve_rebalance_timestamps(ts, "monthly_month_end") assert len(result) == 0 # --------------------------------------------------------------------------- # Execution delay mapping tests # --------------------------------------------------------------------------- class TestExecutionDelayMapping: """Test explicit execution delay -> ExecutionMode mapping.""" def test_next_bar_open_maps_to_next_bar(self): from ml4t.backtest import ExecutionMode from case_studies.utils.backtest_presets import ( resolve_execution_mode as _resolve_execution_mode, ) assert _resolve_execution_mode("NEXT_BAR_OPEN") == ExecutionMode.NEXT_BAR assert _resolve_execution_mode("next_bar_open") == ExecutionMode.NEXT_BAR def test_monday_open_maps_to_next_bar(self): """Regression: monday_open must NOT fall through to SAME_BAR.""" from ml4t.backtest import ExecutionMode from case_studies.utils.backtest_presets import ( resolve_execution_mode as _resolve_execution_mode, ) assert _resolve_execution_mode("MONDAY_OPEN") == ExecutionMode.NEXT_BAR assert _resolve_execution_mode("monday_open") == ExecutionMode.NEXT_BAR def test_1_bar_maps_to_next_bar(self): from ml4t.backtest import ExecutionMode from case_studies.utils.backtest_presets import ( resolve_execution_mode as _resolve_execution_mode, ) assert _resolve_execution_mode("1_BAR") == ExecutionMode.NEXT_BAR assert _resolve_execution_mode("1_bar") == ExecutionMode.NEXT_BAR def test_at_funding_timestamp_maps_to_same_bar(self): from ml4t.backtest import ExecutionMode from case_studies.utils.backtest_presets import ( resolve_execution_mode as _resolve_execution_mode, ) assert _resolve_execution_mode("AT_FUNDING_TIMESTAMP") == ExecutionMode.SAME_BAR def test_unknown_token_raises_value_error(self): """Unknown execution delay must raise, not silently degrade.""" from case_studies.utils.backtest_presets import ( resolve_execution_mode as _resolve_execution_mode, ) with pytest.raises(ValueError, match="Unknown execution delay"): _resolve_execution_mode("SOME_RANDOM_TOKEN") # --------------------------------------------------------------------------- # Vectorized thinning tests # --------------------------------------------------------------------------- class TestThinToRebalanceDates: """Test that vectorized thinning uses calendar-aware schedule.""" def test_weekly_friday_keeps_fridays_not_every_5th(self): """Regression: sp500_options weekly_friday must keep actual Fridays.""" from case_studies.utils.backtest_loaders import thin_to_rebalance_dates dates = _make_weekday_series("2020-01-01", "2020-02-28") preds = pl.DataFrame( { "timestamp": dates, "symbol": ["SPY"] * len(dates), "y_score": [0.1] * len(dates), "y_true": [0.01] * len(dates), } ) # fwd_ret_5d on weekly_friday schedule: step=1 (5d horizon <= 7d gap) result = thin_to_rebalance_dates(preds, cadence="weekly_friday", step=1) unique_dates = result["timestamp"].unique().sort() # Exclude last date (may be incomplete week) for dt in unique_dates.to_list()[:-1]: assert dt.weekday() in (3, 4), f"Expected Thu/Fri, got {dt} (weekday={dt.weekday()})" def test_monthly_month_end_keeps_month_ends(self): """Regression: ETFs monthly_month_end must keep actual month-end sessions.""" from case_studies.utils.backtest_loaders import thin_to_rebalance_dates dates = _make_weekday_series("2016-01-01", "2016-06-30") all_dates = dates.to_list() preds = pl.DataFrame( { "timestamp": dates, "symbol": ["SPY"] * len(dates), "y_score": [0.1] * len(dates), "y_true": [0.01] * len(dates), } ) # fwd_ret_21d on monthly_month_end schedule: step=1 (21d horizon <= 30d gap) result = thin_to_rebalance_dates(preds, cadence="monthly_month_end", step=1) unique_dates = result["timestamp"].unique().sort() assert 5 <= len(unique_dates) <= 6 for dt in unique_dates.to_list(): month, year = dt.month, dt.year later = [d for d in all_dates if d.year == year and d.month == month and d > dt] assert len(later) == 0, f"{dt} is not the last trading day of {year}-{month:02d}" # --------------------------------------------------------------------------- # Rebalance-step lookup (declared in each case study's setup.yaml) # --------------------------------------------------------------------------- class TestGetRebalanceStep: """Verify that per-label thinning steps are read from setup.yaml. Replaces the legacy regex-based implementation which silently returned step=1 for any label without a digit-unit token (e.g., ``ret_to_expiry``), producing 4-5× inflated Sharpe for overlapping-cohort strategies. The step is now a design-time constant declared under ``labels.rebalance_step`` in each case study's setup.yaml. """ def test_sp500_options_ret_to_expiry_is_5(self): """Regression: ret_to_expiry must thin weekly_friday cohorts by 5. 30-day DTE on a 7-day schedule -> ceil(30/7) = 5. Pre-fix this silently returned 1 (overlapping 5-cohort double-counting). """ from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("sp500_options", "ret_to_expiry") == 5 def test_cme_fwd_ret_21d_is_3(self): """cme_futures fwd_ret_21d on weekly_friday -> ceil(21/7) = 3.""" from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("cme_futures", "fwd_ret_21d") == 3 def test_us_firm_fwd_ret_1m_is_1(self): """Monthly label on monthly schedule -> step=1.""" from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("us_firm_characteristics", "fwd_ret_1m") == 1 def test_nasdaq100_fwd_ret_60m_is_4(self): """nasdaq100 fwd_ret_60m on 15-minute schedule -> ceil(60/15) = 4.""" from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("nasdaq100_microstructure", "fwd_ret_60m") == 4 def test_nasdaq100_fwd_ret_5m_is_1(self): """Regression: fwd_ret_5m on 15-minute schedule must stay at 1. Pre-fix, the regex matched `(5, m)` and the old `n <= 12` branch mis-read it as 5 MONTHS, computing step ~10,000 and collapsing backtests to a handful of points. """ from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("nasdaq100_microstructure", "fwd_ret_5m") == 1 def test_crypto_fwd_ret_24h_is_3(self): """crypto 24h label on 8h schedule -> ceil(24/8) = 3.""" from case_studies.utils.backtest_loaders import get_rebalance_step assert get_rebalance_step("crypto_perps_funding", "fwd_ret_24h") == 3 def test_unknown_label_raises(self): """Unknown label must raise KeyError pointing at setup.yaml.""" from case_studies.utils.backtest_loaders import get_rebalance_step with pytest.raises(KeyError, match="rebalance_step"): get_rebalance_step("sp500_options", "fwd_ret_unknown_label") # --------------------------------------------------------------------------- # Integration: engine schedule set membership # --------------------------------------------------------------------------- class TestEngineScheduleIntegration: """Verify the engine path builds correct schedule sets.""" def test_etf_monthly_schedule_matches_month_ends(self): """ETF backtest should rebalance on actual month-end sessions.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps dates = _make_weekday_series("2015-12-01", "2016-04-30") schedule = resolve_rebalance_timestamps(dates, "monthly_month_end") all_dates = dates.to_list() for dt in schedule.to_list(): month, year = dt.month, dt.year later = [d for d in all_dates if d.month == month and d.year == year and d > dt] assert len(later) == 0, f"Rebalance {dt} is not month-end: later dates {later[:3]}" def test_cme_weekly_schedule_matches_fridays(self): """CME backtest should rebalance on Friday sessions.""" from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps dates = _make_weekday_series("2018-08-01", "2018-10-26") # End on a Friday schedule = resolve_rebalance_timestamps(dates, "weekly_friday_close") for dt in schedule.to_list(): assert dt.weekday() == 4, ( f"CME rebalance {dt} should be Friday, got weekday={dt.weekday()}" )