From 1e2678cdeedb15f7017b0b608900d30f5d39234b Mon Sep 17 00:00:00 2001 From: leoca Date: Wed, 16 Sep 2026 18:12:48 +0200 Subject: [PATCH] fix: catch up weekly and monthly schedules missed at a period boundary _schedule_due only looked at the target day of the current week or month. When that day had no bar and the next bar opened a new period (Good Friday with weekday=5, a month-end on a weekend with monthday=31, or a Saturday target on a stock market), the run was silently dropped. Inside a period the missed day was already caught up on the next bar; apply the same rule to the previous period's target day. --- .../app/services/strategy_v2/runtime.py | 48 ++++++++++++------- .../tests/test_strategy_v2_runtime.py | 45 +++++++++++++++++ 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/backend_api_python/app/services/strategy_v2/runtime.py b/backend_api_python/app/services/strategy_v2/runtime.py index 095dfc554..2471d92aa 100644 --- a/backend_api_python/app/services/strategy_v2/runtime.py +++ b/backend_api_python/app/services/strategy_v2/runtime.py @@ -1910,27 +1910,39 @@ def _schedule_due( ) -> bool: current = pd.Timestamp(current) previous = pd.Timestamp(previous) if previous is not None else None - scheduled_date = current.normalize() - if schedule.frequency == "weekly": - target_weekday = max(1, min(7, int(schedule.weekday or 1))) - 1 - scheduled_date = current.normalize() + pd.Timedelta(days=target_weekday - current.weekday()) - elif schedule.frequency == "monthly": - target_day = max(1, int(schedule.monthday or 1)) - last_day = calendar.monthrange(current.year, current.month)[1] - scheduled_date = pd.Timestamp( - year=current.year, - month=current.month, - day=min(target_day, last_day), - tz=current.tz, - ) - elif schedule.frequency != "daily": + if schedule.frequency not in {"daily", "weekly", "monthly"}: return False - scheduled_at = scheduled_date - if _is_intraday_frequency(bar_frequency) and schedule.time: - scheduled_at += _parse_schedule_time(schedule.time) + def occurrence(anchor: pd.Timestamp) -> pd.Timestamp: + scheduled_date = anchor.normalize() + if schedule.frequency == "weekly": + target_weekday = max(1, min(7, int(schedule.weekday or 1))) - 1 + scheduled_date += pd.Timedelta(days=target_weekday - anchor.weekday()) + elif schedule.frequency == "monthly": + target_day = max(1, int(schedule.monthday or 1)) + last_day = calendar.monthrange(anchor.year, anchor.month)[1] + scheduled_date = pd.Timestamp( + year=anchor.year, + month=anchor.month, + day=min(target_day, last_day), + tz=anchor.tz, + ) + if _is_intraday_frequency(bar_frequency) and schedule.time: + scheduled_date += _parse_schedule_time(schedule.time) + return scheduled_date + + scheduled_at = occurrence(current) if current < scheduled_at: - return False + if previous is None or schedule.frequency == "daily": + return False + # The target day of the previous period may have had no bar + # (weekend, holiday). Catch it up on the first bar after it, as + # already happens when the gap stays inside one period. + if schedule.frequency == "weekly": + scheduled_at = occurrence(current - pd.DateOffset(days=7)) + else: + scheduled_at = occurrence(current.replace(day=1) - pd.DateOffset(days=1)) + return previous < scheduled_at <= current if previous is None: return True if schedule.frequency == "daily" and not _is_intraday_frequency(bar_frequency): diff --git a/backend_api_python/tests/test_strategy_v2_runtime.py b/backend_api_python/tests/test_strategy_v2_runtime.py index 81e067bda..8c9848e63 100644 --- a/backend_api_python/tests/test_strategy_v2_runtime.py +++ b/backend_api_python/tests/test_strategy_v2_runtime.py @@ -804,6 +804,51 @@ def test_scheduler_honors_weekday_monthday_and_intraday_time(): ) +def test_scheduler_catches_up_target_day_missed_at_period_boundary(): + good_friday_weekly = ScheduleSpec("weekly", "rebalance", weekday=5) + month_end = ScheduleSpec("monthly", "rebalance", monthday=31) + weekend_weekly = ScheduleSpec("weekly", "rebalance", weekday=6) + + # 2026-04-03 is Good Friday (no US session): the week must still run once. + assert StrategyV2BacktestRunner._schedule_due( + good_friday_weekly, pd.Timestamp("2026-04-06"), pd.Timestamp("2026-04-02"), "1d" + ) + assert not StrategyV2BacktestRunner._schedule_due( + good_friday_weekly, pd.Timestamp("2026-04-07"), pd.Timestamp("2026-04-06"), "1d" + ) + # 2026-01-31 is a Saturday: the January month-end run happens on 2026-02-02. + assert StrategyV2BacktestRunner._schedule_due( + month_end, pd.Timestamp("2026-02-02"), pd.Timestamp("2026-01-30"), "1d" + ) + assert not StrategyV2BacktestRunner._schedule_due( + month_end, pd.Timestamp("2026-02-03"), pd.Timestamp("2026-02-02"), "1d" + ) + # A Saturday target on a weekday-only market runs on the following Monday. + assert StrategyV2BacktestRunner._schedule_due( + weekend_weekly, pd.Timestamp("2026-01-12"), pd.Timestamp("2026-01-09"), "1d" + ) + assert not StrategyV2BacktestRunner._schedule_due( + weekend_weekly, pd.Timestamp("2026-01-13"), pd.Timestamp("2026-01-12"), "1d" + ) + # Intraday: a Monday bar before the scheduled time does not re-run last week. + intraday = ScheduleSpec("weekly", "rebalance", weekday=1, time="09:35") + assert not StrategyV2BacktestRunner._schedule_due( + intraday, pd.Timestamp("2026-01-12 09:30"), pd.Timestamp("2026-01-09 16:00"), "5m" + ) + assert not StrategyV2BacktestRunner._schedule_due( + good_friday_weekly, pd.Timestamp("2026-04-06"), None, "1d" + ) + # Live clocks are zone-aware: stepping back a week must not cross into + # the week before when the gap spans a DST change (2026-03-08 in New York). + live = ScheduleSpec("weekly", "rebalance", weekday=6, time="09:35") + assert StrategyV2BacktestRunner._schedule_due( + live, + pd.Timestamp("2026-03-09 00:30", tz="America/New_York"), + pd.Timestamp("2026-03-06 16:00", tz="America/New_York"), + "1m", + ) + + def test_rejected_and_deferred_orders_are_visible_in_audit_ledger(): frame = _frame([100, 101, 102]) frame["is_suspended"] = [False, True, False]