The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on midnight-to-midnight in Local Standard Time year-round, not wall-clock time
- During DST (March through November), this means the settlement window shifts by one hour relative to local clocks
- My bot was bucketing the wrong 24-hour window for 8 months during the warm season, exactly when most weather markets are active
- A matplotlib overlay of trade outcomes against DST transition dates made the pattern undeniable in about 15 minutes
The Assumption That Broke Everything
I made the kind of assumption that kills production systems: I assumed "day" meant midnight to midnight.
It seems obvious. A day is midnight to midnight. That's how clocks work. That's how calendars work. That's how every human being on the planet thinks about it.
The National Weather Service disagrees.
For temperature records, the NWS defines a meteorological day using Local Standard Time year-round. Not local clock time. Not UTC. Local Standard Time, always, even in the summer when your clock says something different.
During Daylight Saving Time, that means the official weather day runs from 1:00 AM local time to 12:59 AM the following day. The clocks spring forward, but the weather records don't. The NWS never moves its day boundary. It stays pinned to LST.
Kalshi temperature contracts settle against NWS records. So the settlement window follows the same rule.
My bot did not know this. For 8 months.
What Was Actually Happening
Every summer, when DST is active, my bot was pulling forecast data for the wrong 24-hour window. It thought it was evaluating the correct day. It was off by one hour, in a direction that shifted the sample into a different meteorological date.
This matters because temperature is not flat across a day. The coolest hour is usually just before dawn. The hottest is mid-afternoon. Shift your window by one hour near those boundaries and you can flip which meteorological date a temperature reading lands on.
When I went looking for the bug, I wasn't looking for this. I was looking at model calibration. I suspected the ensemble was overconfident. I ran a Brier score analysis. The model scored 0.2858. Guessing the historical base rate scores 0.2439. My model was statistically worse than not having a model.
That number sent me down a different path.
How I Found It
After the Brier score result came back, I pulled the full trade ledger. 112 completed trades, timestamped, with outcomes. I added a column for whether each trade fell inside a DST window.
Then I made a simple matplotlib chart: trade outcome (win/loss) on the Y axis, trade date on the X axis, DST transition dates as vertical lines.
The pattern was immediate. I stared at it for about 30 seconds before I understood what I was looking at.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from datetime import datetime, date
import pytz
# Load trade ledger from local DB
trades = pd.read_csv("trade_ledger.csv", parse_dates=["settled_at"])
# DST transition dates for 2025 (second Sunday in March, first Sunday in November)
dst_starts = [date(2025, 3, 9), date(2024, 3, 10)]
dst_ends = [date(2025, 11, 2), date(2024, 11, 3)]
fig, ax = plt.subplots(figsize=(14, 5))
wins = trades[trades["outcome"] == "win"]
losses = trades[trades["outcome"] == "loss"]
ax.scatter(wins["settled_at"], [1] * len(wins), color="steelblue", alpha=0.6, label="Win", zorder=3)
ax.scatter(losses["settled_at"], [0] * len(losses), color="tomato", alpha=0.6, label="Loss", zorder=3)
# Shade DST windows
for start, end in zip(dst_starts, dst_ends):
ax.axvspan(
pd.Timestamp(start),
pd.Timestamp(end),
alpha=0.12,
color="orange",
label="DST Active"
)
# Mark transition dates
for d in dst_starts + dst_ends:
ax.axvline(pd.Timestamp(d), color="darkorange", linewidth=1.2, linestyle="--", alpha=0.8)
ax.set_yticks([0, 1])
ax.set_yticklabels(["Loss", "Win"])
ax.set_xlabel("Settlement Date")
ax.set_title("Trade Outcomes vs. DST Windows")
ax.legend()
plt.tight_layout()
plt.savefig("dst_outcome_overlay.png", dpi=150)
plt.show()
The orange shading covers every DST window. Losses cluster there. Wins cluster outside it. Not every trade in the DST window was a loss, but the density was obvious enough that I knew I had found something real.
I did not need a statistical test. I needed to look at my own data, which I had been avoiding.
The Broken Code
Here is what the original bucketing logic looked like. This is stripped down to the relevant part:
from datetime import datetime, timedelta
import pytz
def get_settlement_window(target_date: date, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the settlement window for a given date.
Intended to match NWS meteorological day boundaries.
"""
tz = pytz.timezone(timezone_str)
# Build midnight-to-midnight window in local time
start_local = tz.localize(datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0))
end_local = start_local + timedelta(days=1)
return start_local.astimezone(pytz.utc), end_local.astimezone(pytz.utc)
This looks correct. It localizes midnight to the city's timezone and converts to UTC. If you are running in Chicago, it will correctly account for the UTC offset.
The problem is that it uses the current UTC offset, which changes when DST flips. In summer, Chicago is UTC-5. In winter, UTC-6. The code applies whatever offset is active on the target date.
But the NWS settlement day is always anchored to Local Standard Time. Chicago standard time is always UTC-6, year-round, for purposes of the meteorological record.
During DST, the NWS meteorological day starts at 1:00 AM CDT (which is midnight CST) and ends at 12:59 AM CDT the next day. My code was treating the window as midnight CDT to midnight CDT. One hour off. Every single day from March through November.
The Fix
The fix is to always apply the standard-time offset, regardless of what the wall clock says.
from datetime import datetime, timedelta
import pytz
# Standard UTC offsets (LST) for cities with active Kalshi temperature markets.
# These are fixed. DST does not move them.
STANDARD_UTC_OFFSETS = {
"America/Chicago": -6, # Chicago/Midway
"America/New_York": -5, # New York
"America/Los_Angeles": -8, # Los Angeles
"America/Denver": -7, # Denver
"America/Phoenix": -7, # Phoenix (no DST observed)
"America/Detroit": -5, # Detroit
"America/Chicago": -6, # Houston/Hobby
}
def get_settlement_window_lst(target_date: date, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns the NWS meteorological day settlement window in UTC.
The NWS defines the weather day in Local Standard Time year-round.
During DST, this window is offset from local wall-clock midnight by one hour.
Use this function for any temperature settlement lookup against NWS records.
"""
utc_offset_hours = STANDARD_UTC_OFFSETS[timezone_str]
utc_offset = timedelta(hours=utc_offset_hours)
# Midnight LST = midnight local standard time, expressed in UTC
# This is fixed regardless of DST
start_utc = datetime(
target_date.year,
target_date.month,
target_date.day,
0, 0, 0,
tzinfo=pytz.utc
) - utc_offset
end_utc = start_utc + timedelta(days=1)
return start_utc, end_utc
No DST detection. No pytz localize. Just the fixed standard-time offset applied to a UTC calculation. The meteorological day is stable, so the code should be stable.
I added a test to lock this behavior in place:
import pytest
from datetime import date, datetime, timezone, timedelta
from bot.time_utils import get_settlement_window_lst
def test_chicago_dst_window_matches_nws():
"""
During DST (June), Chicago settlement window must start at 05:00 UTC,
which is midnight CST (UTC-6), not 06:00 UTC (midnight CDT / UTC-5).
"""
target = date(2025, 6, 15) # Summer, DST active
start_utc, end_utc = get_settlement_window_lst(target, "America/Chicago")
# Expect 05:00 UTC (midnight CST), not 06:00 UTC (midnight CDT)
assert start_utc == datetime(2025, 6, 15, 5, 0, 0, tzinfo=timezone.utc), (
f"Expected 05:00 UTC, got {start_utc}. DST bucketing may be wrong."
)
assert end_utc == datetime(2025, 6, 16, 5, 0, 0, tzinfo=timezone.utc)
def test_chicago_standard_time_window_unchanged():
"""
In standard time (January), the window should also start at 05:00 UTC.
The result should be identical. That's the point.
"""
target = date(2025, 1, 15) # Winter, no DST
start_utc, end_utc = get_settlement_window_lst(target, "America/Chicago")
assert start_utc == datetime(2025, 1, 15, 6, 0, 0, tzinfo=timezone.utc), (
f"Expected 06:00 UTC in standard time, got {start_utc}."
)
Wait. That test caught a mistake in my own documentation as I was writing this. In standard time, Chicago is UTC-6, so midnight CST is 06:00 UTC. In DST, midnight CST is still 06:00 UTC, but local clocks say 1:00 AM. Let me be precise:
| Season | Local Clock at NWS Day Start | UTC Equivalent | |---|---|---| | Standard Time (Nov-Mar) | 12:00 AM CST | 06:00 UTC | | Daylight Time (Mar-Nov) | 1:00 AM CDT | 06:00 UTC |
The UTC value is the same either way. That is the point. Standard time UTC offset is fixed. The LST-anchored window in UTC is always the same value. My original code was applying the current offset, which changed in summer, which shifted the UTC window by an hour.
The correct test:
def test_chicago_dst_and_standard_produce_same_utc_window():
"""
The LST-anchored window must be identical in UTC regardless of DST.
This is the core invariant. If this breaks, the bucketing is wrong.
"""
summer_date = date(2025, 6, 15)
winter_date = date(2025, 1, 15)
summer_start, _ = get_settlement_window_lst(summer_date, "America/Chicago")
winter_start, _ = get_settlement_window_lst(winter_date, "America/Chicago")
# Both should be 06:00 UTC (midnight CST, the standard anchor)
expected_utc_hour = 6
assert summer_start.hour == expected_utc_hour
assert winter_start.hour == expected_utc_hour
That is the test that would have caught this before it shipped.
The Part That Still Bothers Me
I do not know for certain that this bug accounts for the full loss pattern. The DST overlay was suggestive, not conclusive. 112 trades is not a large sample. I could be fitting a narrative to noise.
What I do know: the bug was real. The code was wrong. Every trade processed during DST was evaluated against the wrong 24-hour window. Whether that caused the losses or just correlated with them, I cannot say definitively without a controlled test.
What I also know: this is exactly the kind of bug that hides well. The bot ran fine. No exceptions. No crash logs. No failed API calls. The data fetched successfully every day. It was just the wrong data, fetched correctly.
Silent wrong answers are harder to find than loud errors. Every production system I have ever worked on has had at least one of these. This was mine for the weather bot.
The Practical Takeaway
If your code touches time and financial settlement, do this before you write another line:
- Find the exchange's actual settlement rules in their documentation or API metadata
- Find the underlying data source's time definitions (NWS, CME, wherever)
- Write those rules down explicitly as constants, not as derived values
- Write a test that asserts the UTC value is identical across a DST transition
- Run that test in CI
Do not trust that "local midnight" means what you think it means. It often doesn't. The NWS defines days differently than your clock. Futures markets often use exchange time, not local time. Crypto markets use UTC. Every data source has a convention, and almost none of them advertise it loudly.
I spent 8 months and $23 learning to read the documentation more carefully. That is a cheap lesson compared to what it could have been. The bot is now rebuilt, running in paper-trading mode, and undergoing validation. The DST logic has 49 automated tests sitting on top of it. None of them will let this slide quietly ever again.
The most expensive assumption you will ever make in production code is the one that looks too obvious to check.