diff --git a/src/trading/scheduling/daily_scheduler.py b/src/trading/scheduling/daily_scheduler.py index a3df31c0..fef90a05 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}. 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, + ) + 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..dab7cddc 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,86 @@ 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) + # 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( + 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". + """ 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", + if rules[2]: + _note_disagreement( day, + "the Alpaca calendar returned nothing for a trading day; the routines followed the NYSE rules and ran", ) - 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 + 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: + _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; 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: + 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} and the NYSE rules at " + f"{rules[1]:%H:%M}; the earlier close ({earlier:%H:%M}) was used", ) - hours = (open_time, close_time, True) + close_time = earlier + return open_time, close_time, True + + +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 + - _CALENDAR_CACHE[day] = hours - return hours +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..5dc0dcf8 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,83 @@ 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 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): + 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 + + +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 7e47d3b1..d27aac89 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 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)) @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() == []