Nautilus DST Handling

Disambiguation up front. “DST” in the Nautilus docs URL (/concepts/dst/) means Deterministic Simulation Testing - a Rust test technique covered briefly here for completeness. “DST” for Cortana means Daylight Saving Time - the twice-yearly US/Eastern shift that breaks any naive “minutes since open” / “minutes to close” / Power Hour gate that does not anchor on a timezone-aware datetime. This page is primarily about Daylight Saving Time because that is the actual failure mode for SPY 0DTE: spring forward (2nd Sun March, 02:00→03:00) and fall back (1st Sun Nov, 02:00→01:00) silently shift the relationship between UTC, ET, CT, and the launchd cron line. Nautilus represents all time as UTC nanoseconds since epoch (UnixNanos); timezone awareness lives in the strategy layer via pytz / zoneinfo and the Clock interface. Nautilus’s own Clock.set_time_alert(...) takes a datetime - if that datetime is timezone-aware, the alert is firing at the wall-clock time you mean; if it is naive, you are guessing. Cortana MK3’s Power Hour gate, 0DTE expiry math, and any backtest spanning the 2nd-Sun-March or 1st-Sun-Nov boundary must be timezone-aware end-to-end. This page is the canonical reference for the Saturday 2026-05-09 spike.

Two meanings of “DST”

TermMeaningWhere it applies
DST (Nautilus docs)Deterministic Simulation Testing - Rust async runtime under madsim with seed-controlled schedulingRust engine internals only; Python strategies are out of scope
DST (this page’s primary subject)Daylight Saving Time - US Eastern timezone shift in March and NovemberEvery timestamp in Cortana’s strategy layer, every Power Hour decision, every backtest crossing a boundary

The Nautilus URL /concepts/dst/ documents the first. The Cortana operational concern is the second. Both are covered below; the Daylight Saving Time content is load-bearing for MK3.

Part 1 - Daylight Saving Time

How Nautilus represents time

From nautilus-concepts.md (Time, Clock section) and nautilus-data.md (ts_event vs ts_init):

  • All Nautilus timestamps are UnixNanos - u64 nanoseconds since the UNIX epoch (1970-01-01T00:00:00Z). UTC, no offset, no zone info.
  • Every Data subclass carries ts_event and ts_init as UnixNanos.
  • Every Order, Fill, Position event timestamp is UnixNanos.
  • Clock.timestamp_ns() returns UnixNanos. Clock.utc_now() returns a Python datetime with tzinfo=timezone.utc.

This means Nautilus itself does not have a DST problem. UTC has no Daylight Saving Time; the framework cannot get the offset wrong because it never applies one. The DST problem appears the moment a strategy asks “is it 15:45 ET?” - that question requires translating UTC nanos into a timezone-aware ET datetime.

Where DST actually bites

Cortana cares about wall-clock time in three places, and DST hits all three:

  1. Session boundaries. “Market open at 09:30 ET” is 14:30 UTC in EDT (DST observed) and 14:30 UTC in EST (DST not observed) - wait, no: EDT = UTC-4, EST = UTC-5, so 09:30 ET is 13:30 UTC under EDT and 14:30 UTC under EST. A backtest that hardcodes “market open is 13:30 UTC” works April through October and silently misfires by an hour November through March (and vice versa).
  2. Power Hour gate. 14:30-15:00 ET is the entry window (project_eod_power_hour.md). In UTC that is 18:30-19:00 (EDT) or 19:30-20:00 (EST). A naive “fire alert at 19:00 UTC” is right half the year and wrong the other half.
  3. 0DTE expiry. SPY options expire at 16:00 ET - 21:00 UTC (EDT) or 22:00 UTC (EST). The contract’s expiration_utc field on the OptionContract instrument is set correctly by the venue adapter (it comes from IBKR with the right UTC value for that calendar date). The DTE math (expiration_utc - clock.utc_now()).total_seconds() is timezone-correct as long as you use UTC on both sides. It silently breaks if a strategy converts expiration_utc to a naive ET datetime first.

Spring forward and fall back - the two boundary days

EventDate (US)Local clock changeUTC offset beforeUTC offset after
Spring forward2nd Sunday of March02:00 → 03:00 ET (skipped hour)EST (UTC-5)EDT (UTC-4)
Fall back1st Sunday of November02:00 → 01:00 ET (repeated hour)EDT (UTC-4)EST (UTC-5)

Markets are closed on both Sundays, so the trading day is never mid-transition. The hazard is structural:

  • The week before vs the week after. Friday before spring forward, 16:00 ET = 21:00 UTC. Monday after, 16:00 ET = 20:00 UTC. A backtest spanning the weekend that hardcodes “close = 21:00 UTC” wins on Friday and breaks on Monday.
  • launchd / cron. MK2’s plist starts at “8:10 AM CT” - but launchd on macOS runs in the system timezone (US/Central) and tracks DST automatically, so “08:10 CT” really is 08:10 CT year-round. The wire shows up as 13:10 UTC (CDT) or 14:10 UTC (CST). Cron jobs written in UTC do not auto-shift.
  • DTE = 0 across the boundary. A 0DTE contract that opened at 16:00 ET on Friday before spring forward expires the same trading Friday. Any historical replay that filters bars by “after open of market day X, before close of market day X” must derive the open and close from a timezone-aware datetime, not a fixed UTC offset.

Timezone-aware scheduling in Nautilus

Clock.set_time_alert(name, alert_time) accepts a Python datetime. If the datetime is timezone-aware, the alert fires at the wall-clock moment you specified. Internally Nautilus converts to UTC nanos for the alert queue, but the conversion respects the offset.

Canonical pattern - “fire at 15:45 ET every trading day”:

from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo  # Python 3.9+ stdlib
 
ET = ZoneInfo("America/New_York")  # handles EDT/EST automatically
 
def schedule_power_hour_alert(self) -> None:
    # Get "now" in ET, then build today's 15:45 ET, then convert to UTC
    now_utc = self.clock.utc_now()
    now_et = now_utc.astimezone(ET)
    target_et = datetime.combine(now_et.date(), time(15, 45), tzinfo=ET)
    # If we're already past 15:45 today, schedule for next trading day
    if target_et <= now_et:
        target_et = target_et + timedelta(days=1)
    self.clock.set_time_alert(
        name="power_hour_entry_window",
        alert_time=target_et,  # tz-aware; Nautilus converts to UTC internally
    )
 
def on_time_event(self, event) -> None:
    if event.name == "power_hour_entry_window":
        self._enter_power_hour_mode()
        # Re-schedule for next trading day
        self.schedule_power_hour_alert()

Why ZoneInfo("America/New_York") and not pytz: stdlib-only, no extra dependency, handles all historical DST transitions correctly. pytz works too but its localize() API differs from zoneinfo’s direct constructor pattern.

For recurring alerts, Clock.set_timer(name, interval, ...) works in intervals (timedeltas), not wall-clock anchors - use a self-rescheduling set_time_alert chain when you need “every day at 15:45 ET regardless of what UTC offset that maps to today.”

Are Nautilus’s time alerts timezone-aware?

Yes - at the input boundary. set_time_alert accepts a datetime and respects its tzinfo. Internally everything is UTC nanos. The caller is responsible for handing in a timezone-aware datetime; if you pass a naive datetime Nautilus assumes UTC, which is almost always wrong for ET-anchored gates.

The TimeEvent callback receives the alert name and the firing timestamp (UTC nanos and datetime). Convert back to ET inside the handler if you need to log or branch on wall-clock context.

DTE math - is it calendar-correct?

nautilus-options.md (Expiry, DTE math, and 0DTE specifics) is explicit: Nautilus does not bake DTE math into the instrument. OptionContract.expiration_utc is a UTC datetime; the strategy computes DTE itself. For 0DTE this is straightforward:

def time_to_expiry_seconds(self) -> float:
    expiry = self.instrument.expiration_utc  # already UTC, tz-aware
    now = self.clock.utc_now()  # already UTC, tz-aware
    return (expiry - now).total_seconds()

This is DST-correct as long as expiration_utc is set correctly by the venue adapter. IBKR’s adapter sets it from the contract spec (16:00 ET on the expiry date converted to UTC for that date’s offset). The danger is round-tripping through a naive ET datetime in user code. Don’t:

# BROKEN - drops tzinfo, silently uses local system tz
naive_expiry = expiry.replace(tzinfo=None)

Day-count for “is this 0DTE?” is the calendar date in ET, not UTC:

ET = ZoneInfo("America/New_York")
expiry_date_et = self.instrument.expiration_utc.astimezone(ET).date()
today_et = self.clock.utc_now().astimezone(ET).date()
is_0dte = (expiry_date_et == today_et)

A 0DTE contract opened Friday 15:30 ET expires Friday 16:00 ET - same ET date, so is_0dte == True. In UTC the open is 19:30 or 20:30 and the expiry is 20:00 or 21:00 depending on the DST status - both still the same UTC date, but the difference can flip across midnight UTC for late-day trades, so always compare ET dates, not UTC dates.

Backtest replay across DST boundaries

For a backtest that crosses the spring-forward or fall-back Sunday:

  1. Use BacktestDataConfig with UTC string timestamps. The ParquetDataCatalog stores ts_event / ts_init as UTC nanos. A query window like start="2026-03-06T00:00:00Z", end="2026-03-13T00:00:00Z" covers the 2026-03-08 spring-forward Sunday and the trading days on either side without ambiguity.
  2. Strategy schedules anchor on ET. Re-use the schedule_power_hour_alert pattern above. The simulated Clock advances in UTC nanos; converting to ET via ZoneInfo inside the strategy gives the right wall-clock time on every simulated day.
  3. Identical decisions to live. Because the strategy’s gates are defined in ET via ZoneInfo, and the simulated Clock advances through UTC nanos that span the boundary, the strategy fires at 15:45 ET on Friday 2026-03-06 (20:45 UTC, EST) AND at 15:45 ET on Monday 2026-03-09 (19:45 UTC, EDT). The backtest is bitwise-equivalent to a live run that happened across that weekend.
  4. time_bars_origin_offset for ET-anchored bars. From nautilus-data.md, DataEngineConfig exposes time_bars_origin_offset to align bar boundaries to a non-UTC anchor (e.g., 09:30 ET market open). Set this with ZoneInfo("America/New_York") semantics in mind so 5-minute bars line up to 09:30 / 09:35 / … / 15:55 ET on every day, including the days after a DST shift.

Common pitfalls

PitfallFailure modeFix
Hardcoded UTC offset (now - timedelta(hours=4))Right half the year, off by 1h the other halfZoneInfo("America/New_York")
Naive datetime.now() in a strategyDepends on system TZ; backtest non-deterministicself.clock.utc_now() (Nautilus injects simulated clock in backtest)
Stripping tzinfo for arithmeticSilent timezone driftKeep tzinfo end-to-end; arithmetic on tz-aware datetimes is DST-safe
Comparing UTC dates for 0DTELate-day trades cross midnight UTC and confuse the checkCompare astimezone(ET).date() on both sides
Cron / launchd in UTCDoesn’t auto-shift across DSTExpress in America/New_York (zoneinfo) or accept the 1h drift twice yearly
Using pytz.timezone(...) without .localize()datetime(2026, 3, 8, 2, 30, tzinfo=pytz.timezone("America/New_York")) returns a wrong offset because pytz requires .localize() for transition-day datetimesUse stdlib ZoneInfo (no localize() footgun) or pytz.timezone(...).localize(naive_dt)
set_time_alert with naive datetimeNautilus assumes UTC, alert fires at wrong wall-clock timeAlways pass tz-aware datetime
Schedule “16:00 ET” on the spring-forward Sunday itself02:00-03:00 ET doesn’t exist that day; arithmetic raises or jumpsMarkets closed Sunday; not a real trading concern, but be aware in any reusable scheduler
Schedule “01:30 ET” on the fall-back Sunday01:00-02:00 ET happens twice that day; ambiguousZoneInfo resolves via fold=0/fold=1; markets closed, not a real trading concern

Part 2 - Deterministic Simulation Testing (DST per the Nautilus URL)

For completeness - this is what the Nautilus docs page at /concepts/dst/ actually documents. Already summarized in nautilus-concepts.md under the Deterministic Simulation Testing section. Cliff notes:

  • Goal. Seed-reproducible execution of the Rust async runtime so race conditions and recovery-path bugs become bitwise-replayable from a single integer seed.
  • Mechanism. Two layers. (1) Runtime swap: under the simulation Cargo feature with RUSTFLAGS="--cfg madsim", four tokio submodules (time, task, runtime, signal) are aliased to madsim’s deterministic counterparts. (2) Nondeterminism substitution: wall-clock reads route through nautilus_core::time::duration_since_unix_epoch, monotonic reads through nautilus_common::live::dst::time::Instant, randomness through madsim::rand, hash iteration via IndexMap/ IndexSet, tokio::select! with biased; modifier.
  • Static enforcement. A pre-commit hook (.pre-commit-hooks/check_dst_conventions.sh) bans raw Instant::now, SystemTime::now, raw RNG calls, unbiased select!, raw thread spawning, and AHashMap/AHashSet in iteration-order-sensitive files. Inline // dst-ok markers permit per-line exceptions.
  • Scope boundaries (load-bearing).
    • Python is NOT in DST scope. The Rust engine is deterministic; Python strategies running on real wall-clock and real RNG can vary their command stream between runs. End-to-end Python replay is not guaranteed.
    • Transport I/O runs on real sockets. tokio-tungstenite, tokio-rustls, reqwest, redis, sqlx use real tokio internally.
    • Adapters are out of scope. Each adapter has its own clock/RNG/ transport sites and requires a separate audit before entering the DST path.
    • Platform-scoped. madsim’s libc overrides for clock_gettime and getrandom are platform-specific; cross-platform bitwise reproducibility is not claimed.
  • In-scope crates. 16 workspace crates in the transitive closure of nautilus-live: analysis, common, core, cryptography, data, execution, indicators, live, model, network, persistence, portfolio, risk, serialization, system, trading.
  • Status. Layer 1 + Layer 2 implemented. Pre-commit hook active. Smoke gate runs make cargo-test-sim on the in-scope crates. End-to-end same-seed diff over an in-scope code path is not yet a regression gate.

Cortana implication. Nautilus’s DST does not help us replay Python strategies bitwise. It does help us trust that the underlying engine (matching, reconciliation, risk, execution state machines) won’t surface Heisenbugs under load. For Cortana MK3, this is a “framework hardness” benefit, not an “our backtests are bitwise reproducible” benefit. Our backtest determinism comes from the same-Kernel + simulated-Clock guarantee documented in nautilus-concepts.md (Backtest engine and simulated venue), not from the Nautilus DST contract.

Cortana MK3 implications

  1. Power Hour gate must be timezone-aware. Schedule via set_time_alert with a ZoneInfo("America/New_York")-anchored datetime, never a hardcoded UTC offset. Re-schedule on each fire so the gate fires at 14:30 ET / 15:00 ET on every trading day, regardless of EDT vs EST.
  2. 0DTE expiry math must be calendar-correct. Compute DTE as (expiration_utc - clock.utc_now()).total_seconds(). Compute “is 0DTE?” via astimezone(ET).date() on both sides. Never strip tzinfo.
  3. Backtests crossing the 2nd-Sun-March or 1st-Sun-Nov boundary must produce identical decisions to a live run that had occurred over the same weekend. The way to guarantee this: strategy gates anchored on ZoneInfo("America/New_York"), simulated Clock advancing through UTC nanos, no naive datetimes anywhere. The BacktestDataConfig window uses UTC strings (no ambiguity).
  4. Replay validation. Pick a known DST-boundary weekend (e.g., 2026-03-08 or 2026-11-01) and run the strategy over the Friday/Monday pair. Assert that the Power Hour alert fires at 14:30 ET on both days even though the UTC offsets differ. This is the regression gate that catches naive-datetime drift.
  5. Time bars anchored to market open. Use time_bars_origin_offset so 5-minute bars start at 09:30 ET, not at the top of the UTC hour. This works because the offset is computed per-day relative to the data’s ts_event, not as a fixed UTC anchor.
  6. launchd plist is fine as-is. macOS launchd respects the system timezone (America/Chicago in our case) and tracks DST automatically. “8:10 AM CT” really is 08:10 CT year-round. The drift risk is on the strategy side, not the launchd side.
  7. Ignore Nautilus’s /concepts/dst/ page for migration planning. It is about Rust async-runtime determinism, not Daylight Saving Time. The benefit accrues to framework hardness, not to our user-facing replay determinism.

See Also

  • Clock section) - UTC nanoseconds, Clock interface, live-vs-backtest split
  • Nautilus Architecture - same Kernel in backtest and live; clock injected, not chosen by strategy
  • Nautilus Data Model - ts_event vs ts_init in UTC nanos; time_bars_origin_offset for ET-anchored bars
  • Nautilus Backtesting (parallel filing) - BacktestDataConfig UTC windowing; deterministic replay across multi-day spans
  • Nautilus Options - OptionContract.expiration_utc, 0DTE DTE math, ET-date comparisons for “is 0DTE?”
  • 2026-05-09 Nautilus Spike Plan: ~/conductor/workspaces/cortanaroi-mk2/belo-horizonte/plans/2026-05-09-nautilus-spike.md
  • project_eod_power_hour.md
    • last 15-30 min before close as a first-class regime; the gate whose timezone-correctness is on the line

Timeline

  • 2026-05-07 | Cody - Filed during pre-spike concept mastery sweep batch 3.