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 viapytz/zoneinfoand theClockinterface. Nautilus’s ownClock.set_time_alert(...)takes adatetime- 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”
| Term | Meaning | Where it applies |
|---|---|---|
| DST (Nautilus docs) | Deterministic Simulation Testing - Rust async runtime under madsim with seed-controlled scheduling | Rust 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 November | Every 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-u64nanoseconds since the UNIX epoch (1970-01-01T00:00:00Z). UTC, no offset, no zone info. - Every
Datasubclass carriests_eventandts_initasUnixNanos. - Every
Order,Fill,Positionevent timestamp isUnixNanos. Clock.timestamp_ns()returnsUnixNanos.Clock.utc_now()returns a Pythondatetimewithtzinfo=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:
- 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).
- 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.
- 0DTE expiry. SPY options expire at 16:00 ET -
21:00 UTC(EDT) or22:00 UTC(EST). The contract’sexpiration_utcfield on theOptionContractinstrument 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 convertsexpiration_utcto a naive ET datetime first.
Spring forward and fall back - the two boundary days
| Event | Date (US) | Local clock change | UTC offset before | UTC offset after |
|---|---|---|---|---|
| Spring forward | 2nd Sunday of March | 02:00 → 03:00 ET (skipped hour) | EST (UTC-5) | EDT (UTC-4) |
| Fall back | 1st Sunday of November | 02: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
launchdon 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:
- Use
BacktestDataConfigwith UTC string timestamps. TheParquetDataCatalogstorests_event/ts_initas UTC nanos. A query window likestart="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. - Strategy schedules anchor on ET. Re-use the
schedule_power_hour_alertpattern above. The simulatedClockadvances in UTC nanos; converting to ET viaZoneInfoinside the strategy gives the right wall-clock time on every simulated day. - 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. time_bars_origin_offsetfor ET-anchored bars. From nautilus-data.md,DataEngineConfigexposestime_bars_origin_offsetto align bar boundaries to a non-UTC anchor (e.g., 09:30 ET market open). Set this withZoneInfo("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
| Pitfall | Failure mode | Fix |
|---|---|---|
Hardcoded UTC offset (now - timedelta(hours=4)) | Right half the year, off by 1h the other half | ZoneInfo("America/New_York") |
Naive datetime.now() in a strategy | Depends on system TZ; backtest non-deterministic | self.clock.utc_now() (Nautilus injects simulated clock in backtest) |
Stripping tzinfo for arithmetic | Silent timezone drift | Keep tzinfo end-to-end; arithmetic on tz-aware datetimes is DST-safe |
| Comparing UTC dates for 0DTE | Late-day trades cross midnight UTC and confuse the check | Compare astimezone(ET).date() on both sides |
| Cron / launchd in UTC | Doesn’t auto-shift across DST | Express 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 datetimes | Use stdlib ZoneInfo (no localize() footgun) or pytz.timezone(...).localize(naive_dt) |
set_time_alert with naive datetime | Nautilus assumes UTC, alert fires at wrong wall-clock time | Always pass tz-aware datetime |
| Schedule “16:00 ET” on the spring-forward Sunday itself | 02:00-03:00 ET doesn’t exist that day; arithmetic raises or jumps | Markets closed Sunday; not a real trading concern, but be aware in any reusable scheduler |
| Schedule “01:30 ET” on the fall-back Sunday | 01:00-02:00 ET happens twice that day; ambiguous | ZoneInfo 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
simulationCargo feature withRUSTFLAGS="--cfg madsim", fourtokiosubmodules (time,task,runtime,signal) are aliased tomadsim’s deterministic counterparts. (2) Nondeterminism substitution: wall-clock reads route throughnautilus_core::time::duration_since_unix_epoch, monotonic reads throughnautilus_common::live::dst::time::Instant, randomness throughmadsim::rand, hash iteration viaIndexMap/IndexSet,tokio::select!withbiased;modifier. - Static enforcement. A pre-commit hook
(
.pre-commit-hooks/check_dst_conventions.sh) bans rawInstant::now,SystemTime::now, raw RNG calls, unbiasedselect!, raw thread spawning, andAHashMap/AHashSetin iteration-order-sensitive files. Inline// dst-okmarkers 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,sqlxuse realtokiointernally. - 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 forclock_gettimeandgetrandomare 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-simon 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
- Power Hour gate must be timezone-aware. Schedule via
set_time_alertwith aZoneInfo("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. - 0DTE expiry math must be calendar-correct. Compute DTE as
(expiration_utc - clock.utc_now()).total_seconds(). Compute “is 0DTE?” viaastimezone(ET).date()on both sides. Never strip tzinfo. - 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"), simulatedClockadvancing through UTC nanos, no naive datetimes anywhere. TheBacktestDataConfigwindow uses UTC strings (no ambiguity). - 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.
- Time bars anchored to market open. Use
time_bars_origin_offsetso 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’sts_event, not as a fixed UTC anchor. - launchd plist is fine as-is. macOS launchd respects the system
timezone (
America/Chicagoin 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. - 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_eventvsts_initin UTC nanos;time_bars_origin_offsetfor ET-anchored bars - Nautilus Backtesting (parallel filing) -
BacktestDataConfigUTC 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.