Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/trading/scheduling/daily_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from zoneinfo import ZoneInfo

from .notices import (
CALENDAR_DISAGREES,
CRITICAL,
ROUTINE_FAILED,
ROUTINE_MISSED,
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions src/trading/scheduling/notices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
90 changes: 73 additions & 17 deletions src/utils/market_hours.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
86 changes: 84 additions & 2 deletions tests/unit/test_market_hours_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
)


Expand Down Expand Up @@ -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)
37 changes: 32 additions & 5 deletions tests/unit/trading/test_cron_robustness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() == []
Loading