From a754722aacbc9d9dd151074e73390d1a863e8480 Mon Sep 17 00:00:00 2001 From: WormsCanned Date: Wed, 28 Oct 2026 18:22:08 +0000 Subject: [PATCH 1/2] fix(market-hours): the NYSE rules win when Alpaca's calendar disagrees, with a notice Alpaca's v2 calendar is trusted only where it agrees with the NYSE rules: - an empty answer on a rules trading day uses the rules (the routines run), isn't cached, and raises one WARNING notice calendar_disagrees: - a row dated for another day is ignored (an out-of-range query once came back as 1970-01-02) - on a rules early-close day the earlier 13:00 close wins, so the evening routine still runs before the close - weekends and days after 2029-12-24 are answered by the rules with no request: checked 2026-10-28, v2's data ends there (2029-12-26/31 and 2030 come back empty although they trade) - a session the rules call closed is still trusted, and noted Tests: NYSE's published 2026-2028 holidays and early closes as an oracle independent of Alpaca's own output. Co-Authored-By: Claude Opus 5.5 --- src/trading/scheduling/daily_scheduler.py | 27 +++++++ src/trading/scheduling/notices.py | 1 + src/utils/market_hours.py | 87 +++++++++++++++++----- tests/unit/conftest.py | 1 + tests/unit/test_market_hours_calendar.py | 75 ++++++++++++++++++- tests/unit/trading/test_cron_robustness.py | 37 +++++++-- 6 files changed, 202 insertions(+), 26 deletions(-) diff --git a/src/trading/scheduling/daily_scheduler.py b/src/trading/scheduling/daily_scheduler.py index a3df31c0..645cb5cb 100644 --- a/src/trading/scheduling/daily_scheduler.py +++ b/src/trading/scheduling/daily_scheduler.py @@ -32,6 +32,7 @@ from zoneinfo import ZoneInfo from .notices import ( + CALENDAR_DISAGREES, CRITICAL, ROUTINE_FAILED, ROUTINE_MISSED, @@ -53,6 +54,7 @@ from src.utils.market_hours import ( DEFAULT_MARKET_CLOSE, EARLY_MARKET_CLOSE, + calendar_disagreement, get_market_hours_for_date, is_nyse_trading_day, nyse_early_closes, @@ -659,8 +661,33 @@ def _market_hours(self, day: date) -> Tuple[dt_time, dt_time, bool]: """(open, close, is a trading day) for a day, from the calendar once per check.""" if day not in self._hours_memo: self._hours_memo[day] = get_market_hours_for_date(day) + self._note_calendar_disagreement(day) return self._hours_memo[day] + def _note_calendar_disagreement(self, day: date) -> None: + """ + A WARNING notice when Alpaca's calendar and the NYSE rules disagreed on a day (the + rules' answer was used). One notice per day, however many ticks see it. Contained. + """ + try: + why = calendar_disagreement(day) + if why is None: + return + self.notice_store.raise_notice( + key=f"{CALENDAR_DISAGREES}:{day.isoformat()}", + kind=CALENDAR_DISAGREES, + severity=WARNING, + title=f"Market calendar disagrees with the NYSE rules on {day.isoformat()}", + detail=( + f"{why}. The routines followed the NYSE rules. If NYSE announced a " + "closure, add the date to config_defaults/market_hours.yaml." + ), + market_date=day.isoformat(), + update_existing=False, + ) + except Exception as e: + logger.warning("Calendar notice not recorded for %s: %s", day, e) + def _nyse_scheduled_at(self, task: ScheduledTask, day: date) -> Optional[datetime]: """ When a task runs on a day, from the NYSE rules alone (no calendar call): diff --git a/src/trading/scheduling/notices.py b/src/trading/scheduling/notices.py index 13f2b90a..691a284a 100644 --- a/src/trading/scheduling/notices.py +++ b/src/trading/scheduling/notices.py @@ -66,6 +66,7 @@ UNPROTECTED = "unprotected" STATE_DB_ERROR = "state_db_error" NOTICES_FILE_RESET = "notices_file_reset" +CALENDAR_DISAGREES = "calendar_disagrees" # What a completed run clears for its routine and day ROUTINE_KINDS = (ROUTINE_FAILED, ROUTINE_MISSED, ROUTINE_STUCK) diff --git a/src/utils/market_hours.py b/src/utils/market_hours.py index 93f46d95..1c9e8a0f 100644 --- a/src/utils/market_hours.py +++ b/src/utils/market_hours.py @@ -50,6 +50,12 @@ # After a calendar failure, skip the API (use NYSE rules) until this monotonic time _CALENDAR_RETRY_SECONDS = 300 _CALENDAR_DOWN_UNTIL = 0.0 +# The last day Alpaca's v2 calendar has data for: it answers nothing for any later day +# (checked 2026-10-28: 2029's sessions end on 12-24, and 2029-12-26/31 and 2030 come back +# empty). Later days, like weekends, are answered by the NYSE rules without a request. +CALENDAR_LAST_DAY = date(2029, 12, 24) +# Days the calendar and the NYSE rules disagreed on, with why (calendar_disagreement) +_CALENDAR_DISAGREEMENTS: Dict[date, str] = {} def _load_market_config() -> Dict[str, Any]: @@ -275,36 +281,79 @@ def get_market_hours_for_date( if day in _CALENDAR_CACHE: return _CALENDAR_CACHE[day] + rules = _rule_based_hours(day) + if day.weekday() >= 5 or day > CALENDAR_LAST_DAY: + return rules # weekends, and days past the calendar's data: no request + client = _get_alpaca_calendar() if not client or monotonic() < _CALENDAR_DOWN_UNTIL: - return _rule_based_hours(day) + return rules try: calendar = client.get_calendar(GetCalendarRequest(start=day, end=day)) except Exception as e: _calendar_failed(e) - return _rule_based_hours(day) + return rules + + hours = _hours_from_calendar(day, calendar, rules) + if hours is None: + return rules # not cached: the next check asks again + _CALENDAR_CACHE[day] = hours + return hours + +def _hours_from_calendar( + day: date, calendar: Any, rules: Tuple[time, time, bool] +) -> Optional[Tuple[time, time, bool]]: + """ + Market hours from the calendar's answer for a day, checked against the NYSE rules. + + When they disagree on whether the market trades, the rules win (running on a closed + market costs less than skipping a trading day: stops stay managed) and the day is + recorded for calendar_disagreement(); None then means "use the rules, don't cache". + """ if not calendar: - # Market is closed (holiday or weekend) - if _rule_based_hours(day)[2]: - logger.warning( - "Alpaca calendar returned nothing for %s, a trading day by the NYSE rules; " - "treating it as closed", - day, - ) - hours = (DEFAULT_MARKET_OPEN, DEFAULT_MARKET_CLOSE, False) - else: - cal_day = calendar[0] - # Alpaca returns ET open/close as naive datetimes - open_time = cal_day.open.time() if hasattr(cal_day.open, "time") else DEFAULT_MARKET_OPEN - close_time = ( - cal_day.close.time() if hasattr(cal_day.close, "time") else DEFAULT_MARKET_CLOSE + if rules[2]: + return _disagree(day, "the Alpaca calendar returned nothing for a trading day") + return DEFAULT_MARKET_OPEN, DEFAULT_MARKET_CLOSE, False + cal_day = calendar[0] + cal_date = getattr(cal_day, "date", day) + cal_date = cal_date.date() if isinstance(cal_date, datetime) else cal_date + if cal_date != day: + return _disagree(day, f"the Alpaca calendar answered for {cal_date} instead") + if not rules[2]: + # Listed by the calendar, closed by the rules: trust the calendar (it knows new + # sessions), but say so + _note_disagreement(day, "the Alpaca calendar lists a day the NYSE rules call closed") + # Alpaca returns ET open/close as naive datetimes + open_time = cal_day.open.time() if hasattr(cal_day.open, "time") else DEFAULT_MARKET_OPEN + close_time = cal_day.close.time() if hasattr(cal_day.close, "time") else DEFAULT_MARKET_CLOSE + if rules[2] and rules[1] < close_time: + _note_disagreement( + day, f"the Alpaca calendar closes at {close_time:%H:%M}, an early-close day" ) - hours = (open_time, close_time, True) + close_time = rules[1] # the earlier close, so the evening routine runs before it + return open_time, close_time, True - _CALENDAR_CACHE[day] = hours - return hours + +def _note_disagreement(day: date, why: str) -> None: + """Record and log (once per day per process) a calendar disagreement.""" + if day not in _CALENDAR_DISAGREEMENTS: + logger.warning( + "Market calendar for %s: %s; using the NYSE rules where they differ", day, why + ) + _CALENDAR_DISAGREEMENTS[day] = why + + +def _disagree(day: date, why: str) -> None: + """Note a disagreement where the rules' answer replaces the calendar's (returns None).""" + _note_disagreement(day, why) + return None + + +def calendar_disagreement(day: date) -> Optional[str]: + """Why the calendar and the NYSE rules disagreed on a day in this process, or None.""" + return _CALENDAR_DISAGREEMENTS.get(day) def is_market_hours() -> bool: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 193218af..66778daf 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -30,6 +30,7 @@ def offline_market_calendar(monkeypatch): monkeypatch.setattr(market_hours, "_CALENDAR_CLIENT", None) monkeypatch.setattr(market_hours, "_CALENDAR_CACHE", {}) monkeypatch.setattr(market_hours, "_CALENDAR_DOWN_UNTIL", 0.0) + monkeypatch.setattr(market_hours, "_CALENDAR_DISAGREEMENTS", {}) @pytest.fixture(autouse=True) diff --git a/tests/unit/test_market_hours_calendar.py b/tests/unit/test_market_hours_calendar.py index 41e24132..114d0b34 100644 --- a/tests/unit/test_market_hours_calendar.py +++ b/tests/unit/test_market_hours_calendar.py @@ -68,9 +68,11 @@ def get_calendar(self, filters): return [day] if day else [] -def _session(day, close_hour=16): +def _session(day, close_hour=16, listed_as=None): return SimpleNamespace( - open=datetime.combine(day, time(9, 30)), close=datetime.combine(day, time(close_hour, 0)) + date=listed_as or day, + open=datetime.combine(day, time(9, 30)), + close=datetime.combine(day, time(close_hour, 0)), ) @@ -146,3 +148,72 @@ def flaky_client(**kwargs): clock["now"] += mh._CALENDAR_RETRY_SECONDS + 1 assert mh._get_alpaca_calendar() == "client" assert len(attempts) == 2 + + +# --- NYSE's own published calendar: an oracle independent of Alpaca's ------------------- +# From NYSE Group's release of 2025-12-23 ("2026, 2027 and 2028 Holiday and Early Closings +# Calendar"). 2028-01-01 is a Saturday and no holiday is observed for it. +NYSE_HOLIDAYS = { + 2026: "01-01 01-19 02-16 04-03 05-25 06-19 07-03 09-07 11-26 12-25", + 2027: "01-01 01-18 02-15 03-26 05-31 06-18 07-05 09-06 11-25 12-24", + 2028: "01-17 02-21 04-14 05-29 06-19 07-04 09-04 11-23 12-25", +} +NYSE_EARLY_CLOSES = {2026: "11-27 12-24", 2027: "11-26", 2028: "07-03 11-24"} + + +@pytest.mark.parametrize("year", sorted(NYSE_HOLIDAYS)) +def test_rules_match_nyse_published_holidays_and_early_closes(year): + assert mh.nyse_holidays(year) == _dates(year, NYSE_HOLIDAYS[year]) + assert mh.nyse_early_closes(year) == _dates(year, NYSE_EARLY_CLOSES[year]) + + +# --- Guards on the calendar's answer ---------------------------------------------------- + + +@pytest.mark.parametrize( + "day", + [date(2026, 10, 31), date(2029, 12, 26), date(2030, 1, 2)], # Saturday; past the data +) +def test_weekends_and_days_past_the_calendar_data_make_no_request(monkeypatch, day): + client = _FakeCalendarClient() + monkeypatch.setattr(mh, "_CALENDAR_CLIENT", client) + + hours = mh.get_market_hours_for_date(day) + + assert client.requests == [] + assert hours == mh._rule_based_hours(day) + assert mh.calendar_disagreement(day) is None + + +def test_a_row_for_another_day_is_not_trusted(monkeypatch): + day = date(2026, 11, 26) # Thanksgiving; an out-of-range answer once came back as 1970 + client = _FakeCalendarClient({day: _session(day, listed_as=date(1970, 1, 2))}) + monkeypatch.setattr(mh, "_CALENDAR_CLIENT", client) + + assert mh.get_market_hours_for_date(day)[2] is False # the rules: closed + assert "1970-01-02" in mh.calendar_disagreement(day) + assert day not in mh._CALENDAR_CACHE + + +def test_an_early_close_day_listed_until_16_00_closes_at_13_00(monkeypatch): + day = date(2026, 11, 27) + monkeypatch.setattr(mh, "_CALENDAR_CLIENT", _FakeCalendarClient({day: _session(day)})) + + assert mh.get_market_hours_for_date(day) == (time(9, 30), time(13, 0), True) + assert "16:00" in mh.calendar_disagreement(day) + + +def test_a_session_the_rules_call_closed_is_trusted_but_noted(monkeypatch): + day = date(2026, 11, 26) # as if NYSE had opened on Thanksgiving + monkeypatch.setattr(mh, "_CALENDAR_CLIENT", _FakeCalendarClient({day: _session(day)})) + + assert mh.get_market_hours_for_date(day)[2] is True + assert "call closed" in mh.calendar_disagreement(day) + + +def test_agreeing_answers_note_nothing(monkeypatch): + day = date(2026, 10, 28) + monkeypatch.setattr(mh, "_CALENDAR_CLIENT", _FakeCalendarClient({day: _session(day)})) + + assert mh.get_market_hours_for_date(day)[2] is True + assert mh.calendar_disagreement(day) is None diff --git a/tests/unit/trading/test_cron_robustness.py b/tests/unit/trading/test_cron_robustness.py index 7e47d3b1..7462ee90 100644 --- a/tests/unit/trading/test_cron_robustness.py +++ b/tests/unit/trading/test_cron_robustness.py @@ -429,14 +429,15 @@ def empty_calendar(monkeypatch): monkeypatch.setattr(mh, "_CALENDAR_CACHE", {}) -def test_an_empty_calendar_answer_on_a_rules_trading_day_is_a_warning(empty_calendar, caplog): +def test_an_empty_calendar_answer_on_a_rules_trading_day_follows_the_rules(empty_calendar, caplog): with caplog.at_level(logging.WARNING, logger=mh.logger.name): hours = mh.get_market_hours_for_date(date(2026, 10, 14)) - assert hours[2] is False # behaviour unchanged: closed, and cached - assert mh.get_market_hours_for_date(date(2026, 10, 14))[2] is False - assert caplog.text.count("2026-10-14") == 1 # the repeat came from the cache - assert "trading day by the NYSE rules" in caplog.text + assert hours == (mh.DEFAULT_MARKET_OPEN, mh.DEFAULT_MARKET_CLOSE, True) # the rules win + assert date(2026, 10, 14) not in mh._CALENDAR_CACHE # not cached: asked again next time + assert mh.get_market_hours_for_date(date(2026, 10, 14))[2] is True + assert caplog.text.count("2026-10-14") == 1 # warned once per process + assert "returned nothing for a trading day" in mh.calendar_disagreement(date(2026, 10, 14)) @pytest.mark.parametrize("day", [date(2026, 10, 17), date(2026, 11, 26)]) # Saturday, Thanksgiving @@ -579,3 +580,29 @@ def test_next_run_is_never_in_the_past(make_scheduler): task, when = scheduler.next_run(now=now) assert (task.name, when) == ("morning_routine", now) + + +def test_a_calendar_disagreement_is_one_warning_notice_over_ticks( + make_scheduler, empty_calendar, monkeypatch +): + scheduler = make_scheduler() + # the real lookup, answered by the empty fake calendar + monkeypatch.setattr(daily_scheduler, "get_market_hours_for_date", mh.get_market_hours_for_date) + task = next(t for t in scheduler.tasks if t.name == "morning_routine") + for _tick in range(2): # two cron ticks + scheduler._hours_memo.clear() + assert scheduler.scheduled_at(task, date(2026, 10, 14)) is not None # rules: trading day + + [notice] = scheduler.notice_store.all_notices() + assert notice.key == "calendar_disagrees:2026-10-14" + assert (notice.severity, notice.count) == ("WARNING", 1) + assert "market_hours.yaml" in notice.detail + + +def test_agreeing_calendar_answers_raise_no_notice(make_scheduler): + scheduler = make_scheduler() # the autouse fixture answers from the rules + task = next(t for t in scheduler.tasks if t.name == "morning_routine") + + scheduler.scheduled_at(task, date(2026, 10, 14)) + + assert scheduler.notice_store.all_notices() == [] From 7e45f51e833f3ea4f6c4c6f7f107482502191f26 Mon Sep 17 00:00:00 2001 From: WormsCanned Date: Wed, 28 Oct 2026 18:26:36 +0000 Subject: [PATCH 2/2] fix(market-hours): address review - one request per process, notice text per case, earlier close either way - a disagreement caches the rules' answer for the process (cron's next tick asks again), instead of a new request on every lookup - each disagreement says what was followed; the notice no longer claims the rules were used when the calendar was - a calendar close earlier than the rules' on a full day is also used and noted - the _disagree helper is gone Co-Authored-By: Claude Opus 5.5 --- src/trading/scheduling/daily_scheduler.py | 4 +-- src/utils/market_hours.py | 41 +++++++++++++--------- tests/unit/test_market_hours_calendar.py | 13 ++++++- tests/unit/trading/test_cron_robustness.py | 2 +- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/trading/scheduling/daily_scheduler.py b/src/trading/scheduling/daily_scheduler.py index 645cb5cb..fef90a05 100644 --- a/src/trading/scheduling/daily_scheduler.py +++ b/src/trading/scheduling/daily_scheduler.py @@ -679,8 +679,8 @@ def _note_calendar_disagreement(self, day: date) -> None: severity=WARNING, title=f"Market calendar disagrees with the NYSE rules on {day.isoformat()}", detail=( - f"{why}. The routines followed the NYSE rules. If NYSE announced a " - "closure, add the date to config_defaults/market_hours.yaml." + f"{why}. If NYSE announced a closure or a change of hours, add it to " + "config_defaults/market_hours.yaml or the NYSE rules." ), market_date=day.isoformat(), update_existing=False, diff --git a/src/utils/market_hours.py b/src/utils/market_hours.py index 1c9e8a0f..dab7cddc 100644 --- a/src/utils/market_hours.py +++ b/src/utils/market_hours.py @@ -296,10 +296,10 @@ def get_market_hours_for_date( return rules hours = _hours_from_calendar(day, calendar, rules) - if hours is None: - return rules # not cached: the next check asks again - _CALENDAR_CACHE[day] = hours - return hours + # A disagreement caches the rules' answer for this process: cron starts a new process + # every tick, so the next tick asks the calendar again + _CALENDAR_CACHE[day] = rules if hours is None else hours + return _CALENDAR_CACHE[day] def _hours_from_calendar( @@ -310,29 +310,42 @@ def _hours_from_calendar( When they disagree on whether the market trades, the rules win (running on a closed market costs less than skipping a trading day: stops stay managed) and the day is - recorded for calendar_disagreement(); None then means "use the rules, don't cache". + recorded for calendar_disagreement(); None then means "use the rules". """ if not calendar: if rules[2]: - return _disagree(day, "the Alpaca calendar returned nothing for a trading day") + _note_disagreement( + day, + "the Alpaca calendar returned nothing for a trading day; the routines followed the NYSE rules and ran", + ) + return None return DEFAULT_MARKET_OPEN, DEFAULT_MARKET_CLOSE, False cal_day = calendar[0] cal_date = getattr(cal_day, "date", day) cal_date = cal_date.date() if isinstance(cal_date, datetime) else cal_date if cal_date != day: - return _disagree(day, f"the Alpaca calendar answered for {cal_date} instead") + _note_disagreement( + day, f"the Alpaca calendar answered for {cal_date} instead; the NYSE rules were used" + ) + return None if not rules[2]: # Listed by the calendar, closed by the rules: trust the calendar (it knows new # sessions), but say so - _note_disagreement(day, "the Alpaca calendar lists a day the NYSE rules call closed") + _note_disagreement( + day, + "the Alpaca calendar lists a day the NYSE rules call closed; the calendar was followed, so check the NYSE rules", + ) # Alpaca returns ET open/close as naive datetimes open_time = cal_day.open.time() if hasattr(cal_day.open, "time") else DEFAULT_MARKET_OPEN close_time = cal_day.close.time() if hasattr(cal_day.close, "time") else DEFAULT_MARKET_CLOSE - if rules[2] and rules[1] < close_time: + if rules[2] and rules[1] != close_time: + earlier = min(rules[1], close_time) # so the evening routine runs before the close _note_disagreement( - day, f"the Alpaca calendar closes at {close_time:%H:%M}, an early-close day" + day, + f"the Alpaca calendar closes at {close_time:%H:%M} and the NYSE rules at " + f"{rules[1]:%H:%M}; the earlier close ({earlier:%H:%M}) was used", ) - close_time = rules[1] # the earlier close, so the evening routine runs before it + close_time = earlier return open_time, close_time, True @@ -345,12 +358,6 @@ def _note_disagreement(day: date, why: str) -> None: _CALENDAR_DISAGREEMENTS[day] = why -def _disagree(day: date, why: str) -> None: - """Note a disagreement where the rules' answer replaces the calendar's (returns None).""" - _note_disagreement(day, why) - return None - - def calendar_disagreement(day: date) -> Optional[str]: """Why the calendar and the NYSE rules disagreed on a day in this process, or None.""" return _CALENDAR_DISAGREEMENTS.get(day) diff --git a/tests/unit/test_market_hours_calendar.py b/tests/unit/test_market_hours_calendar.py index 114d0b34..5dc0dcf8 100644 --- a/tests/unit/test_market_hours_calendar.py +++ b/tests/unit/test_market_hours_calendar.py @@ -192,7 +192,8 @@ def test_a_row_for_another_day_is_not_trusted(monkeypatch): assert mh.get_market_hours_for_date(day)[2] is False # the rules: closed assert "1970-01-02" in mh.calendar_disagreement(day) - assert day not in mh._CALENDAR_CACHE + assert mh.get_market_hours_for_date(day)[2] is False + assert len(client.requests) == 1 # the rules' answer is cached for the process def test_an_early_close_day_listed_until_16_00_closes_at_13_00(monkeypatch): @@ -217,3 +218,13 @@ def test_agreeing_answers_note_nothing(monkeypatch): assert mh.get_market_hours_for_date(day)[2] is True assert mh.calendar_disagreement(day) is None + + +def test_an_earlier_calendar_close_on_a_full_rules_day_is_used_and_noted(monkeypatch): + day = date(2026, 10, 28) # as if NYSE had announced an unscheduled 13:00 close + monkeypatch.setattr( + mh, "_CALENDAR_CLIENT", _FakeCalendarClient({day: _session(day, close_hour=13)}) + ) + + assert mh.get_market_hours_for_date(day) == (time(9, 30), time(13, 0), True) + assert "13:00" in mh.calendar_disagreement(day) diff --git a/tests/unit/trading/test_cron_robustness.py b/tests/unit/trading/test_cron_robustness.py index 7462ee90..d27aac89 100644 --- a/tests/unit/trading/test_cron_robustness.py +++ b/tests/unit/trading/test_cron_robustness.py @@ -434,7 +434,7 @@ def test_an_empty_calendar_answer_on_a_rules_trading_day_follows_the_rules(empty hours = mh.get_market_hours_for_date(date(2026, 10, 14)) assert hours == (mh.DEFAULT_MARKET_OPEN, mh.DEFAULT_MARKET_CLOSE, True) # the rules win - assert date(2026, 10, 14) not in mh._CALENDAR_CACHE # not cached: asked again next time + assert mh._CALENDAR_CACHE[date(2026, 10, 14)][2] is True # the rules' answer, this process assert mh.get_market_hours_for_date(date(2026, 10, 14))[2] is True assert caplog.text.count("2026-10-14") == 1 # warned once per process assert "returned nothing for a trading day" in mh.calendar_disagreement(date(2026, 10, 14))