The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on the NWS definition of a "weather day," which runs in Local Standard Time year-round, not wall-clock local time
- During daylight saving time, that means the settlement window is 1:00 AM to 12:59 AM the next day in local clock time, not midnight to midnight
- My bot was silently bucketing the wrong 24 hours for 8 months during DST, affecting every summer trade
- The pattern only became visible when I overlaid trade timestamps against DST transition dates on a matplotlib chart
- The fix is 4 lines of Python. The damage was 8 months of poisoned forecasts.
The Assumption That Felt Obvious
When I started building the weather bot, I made a decision so fast I didn't write it down.
Temperature contracts on Kalshi settle based on the high or low temperature for a given day. A "day" is midnight to midnight. Obviously.
Except it isn't.
The National Weather Service defines a weather day in Local Standard Time, year-round. That means the official 24-hour window they use for recording high and low temperatures never shifts for daylight saving time. When your clock springs forward in March, the NWS keeps measuring from the same UTC anchor.
During DST, the settlement window in wall-clock local time runs from 1:00 AM to 12:59 AM the next day, not midnight to midnight.
My bot was pulling the wrong hour of data at the start and end of every day from March through November. Eight months a year, every year, for every city in the eastern and central time zones where most of the Kalshi temperature volume sits.
Every forecast I fed into the trading decision was built on slightly wrong data. Sometimes the error was small. Sometimes the final hour of the actual settlement day belonged to a different temperature spike than I thought. And I had no idea, because the bot was logging "green" on every cycle.
How I Found It
I didn't find this during development. I found it during the post-mortem.
After the original Weather Bot v2.1 ran for four months and lost $23 across 112 trades, I audited the full trade ledger against model predictions. The Brier score came back at 0.2858. Simply guessing the historical base rate scores 0.2439. My model was statistically worse than making no prediction at all.
I started slicing the loss by every variable I could think of: city, time of year, forecast horizon, temperature range. The pattern that came up was ugly and specific.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# Load trade ledger from local DB
df = pd.read_csv("trade_audit_112.csv", parse_dates=["trade_date"])
# DST 2025 windows (US Eastern as example)
dst_windows = [
("2025-03-09", "2025-11-02"),
]
df["in_dst"] = False
for start, end in dst_windows:
mask = (df["trade_date"] >= start) & (df["trade_date"] <= end)
df.loc[mask, "in_dst"] = True
# Plot outcome by DST window
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
fig.suptitle("Trade Outcomes: DST Window vs. Standard Time", fontsize=13)
for ax, (label, group) in zip(axes, df.groupby("in_dst")):
wins = (group["outcome"] == "win").sum()
losses = (group["outcome"] == "loss").sum()
ax.bar(["Win", "Loss"], [wins, losses], color=["#4CAF50", "#F44336"])
ax.set_title("DST Window" if label else "Standard Time")
ax.set_ylabel("Trade Count")
ax.set_xlabel("Outcome")
for i, v in enumerate([wins, losses]):
ax.text(i, v + 0.3, str(v), ha="center", fontweight="bold")
plt.tight_layout()
plt.savefig("dst_trade_split.png", dpi=150)
plt.show()
The chart that came back was not subtle. Losses clustered in the DST window with a consistency that ruled out noise. Standard time trades showed a much more even distribution. DST trades were skewed badly.
That's when I started reading the NWS documentation more carefully than I should have needed to.
What the NWS Actually Says
From the NWS Cooperative Observer Program handbook:
The calendar day used for recording maximum and minimum temperatures runs from midnight to midnight, Local Standard Time.
Local Standard Time. Not local time. Not wall-clock time. Standard Time, always.
In practice this means:
| Time Zone | DST Offset | Settlement Window (Clock Time, DST Active) | |---|---|---| | Eastern | UTC-4 (DST) vs UTC-5 (ST) | 1:00 AM to 12:59 AM next day | | Central | UTC-5 (DST) vs UTC-6 (ST) | 1:00 AM to 12:59 AM next day | | Mountain | UTC-6 (DST) vs UTC-7 (ST) | 1:00 AM to 12:59 AM next day | | Pacific | UTC-7 (DST) vs UTC-8 (ST) | 1:00 AM to 12:59 AM next day |
My bot was fetching forecasts and building temperature windows based on midnight local time. During DST, that meant I was including 12:00 AM to 1:00 AM local time, which belongs to the previous NWS weather day, and I was missing 12:00 AM to 1:00 AM on the next calendar date, which belongs to the current settlement day.
One hour off, every single day, from March through November, in every time zone observing DST.
The Code That Was Wrong
Here's the original fetch logic, simplified:
from datetime import datetime, timedelta
import pytz
def get_settlement_window(city_tz: str, target_date: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the temperature measurement window
for a given settlement date.
"""
tz = pytz.timezone(city_tz)
naive_start = datetime.strptime(target_date, "%Y-%m-%d")
naive_end = naive_start + timedelta(days=1)
# THIS IS THE BUG
# localize() applies DST offset based on wall-clock time
# During DST, this returns 1:00 AM UTC when we want midnight LST
window_start = tz.localize(naive_start)
window_end = tz.localize(naive_end)
return window_start.astimezone(pytz.utc), window_end.astimezone(pytz.utc)
The problem is tz.localize(naive_start). When DST is active, pytz applies the DST offset. For Eastern time in July, that converts midnight local time to UTC-4 instead of UTC-5. The UTC anchor shifts by one hour, and I'm now measuring the wrong 24 hours against NWS records.
The Fix
The correct approach is to anchor to Local Standard Time regardless of DST. pytz exposes the standard time offset via _utcoffset on the timezone object, but the cleaner route is to use the is_dst=False flag explicitly.
from datetime import datetime, timedelta
import pytz
def get_settlement_window(city_tz: str, target_date: str) -> tuple[datetime, datetime]:
"""
Returns the UTC start and end of the NWS weather day for settlement.
NWS defines weather days in Local Standard Time year-round.
DST is explicitly suppressed so the UTC anchor never shifts.
"""
tz = pytz.timezone(city_tz)
naive_start = datetime.strptime(target_date, "%Y-%m-%d")
naive_end = naive_start + timedelta(days=1)
# is_dst=False forces standard time offset, regardless of DST calendar
window_start = tz.localize(naive_start, is_dst=False)
window_end = tz.localize(naive_end, is_dst=False)
return window_start.astimezone(pytz.utc), window_end.astimezone(pytz.utc)
Four lines changed. One parameter added. Eight months of bad bucketing corrected.
The difference in UTC terms for a Chicago (Central) trade on July 4th:
| Version | Window Start (UTC) | Window End (UTC) | |---|---|---| | Buggy (DST applied) | 2025-07-04 05:00 UTC | 2025-07-05 05:00 UTC | | Fixed (LST enforced) | 2025-07-04 06:00 UTC | 2025-07-05 06:00 UTC |
One hour. Every summer day. Every city in a DST-observing time zone.
Writing a Test That Catches It
This is the kind of bug that hides because it doesn't throw an error. The function runs fine. The data comes back. The numbers look plausible. You need a test that explicitly probes DST transition dates.
import pytest
from datetime import timezone, datetime
from zoneinfo import ZoneInfo
from weather_bot.time_utils import get_settlement_window
def test_dst_window_does_not_shift_in_summer():
"""
During DST (summer), the settlement window should still anchor to
Local Standard Time. Eastern standard offset is UTC-5.
July 4th midnight LST = 2025-07-04 05:00 UTC.
If DST is incorrectly applied, this returns 04:00 UTC instead.
"""
start_utc, end_utc = get_settlement_window("America/New_York", "2025-07-04")
expected_start = datetime(2025, 7, 4, 5, 0, 0, tzinfo=timezone.utc)
expected_end = datetime(2025, 7, 5, 5, 0, 0, tzinfo=timezone.utc)
assert start_utc == expected_start, (
f"DST contamination detected: got {start_utc}, expected {expected_start}"
)
assert end_utc == expected_end
def test_standard_time_window_unchanged_in_winter():
"""
During standard time (winter), the behavior should be identical.
January 15th midnight EST = 2025-01-15 05:00 UTC.
"""
start_utc, end_utc = get_settlement_window("America/New_York", "2025-01-15")
expected_start = datetime(2025, 1, 15, 5, 0, 0, tzinfo=timezone.utc)
assert start_utc == expected_start
def test_dst_transition_date_spring_forward():
"""
March 9, 2025: clocks spring forward at 2 AM Eastern.
Settlement window for this date must still use UTC-5, not UTC-4.
"""
start_utc, _ = get_settlement_window("America/New_York", "2025-03-09")
expected_start = datetime(2025, 3, 9, 5, 0, 0, tzinfo=timezone.utc)
assert start_utc == expected_start, "Transition date incorrectly resolved to DST offset"
The third test is the one that caught the original bug in replay testing. The transition date itself is the hardest case. Some DST handling libraries will apply the post-transition offset because the localization timestamp falls on the same calendar day the clocks change.
Why This Hid for So Long
Three reasons this stayed invisible:
1. The bot logged success, not correctness. Every cycle logged "forecast fetched," "window calculated," "position evaluated." None of that tells you the window was an hour off. I was measuring execution, not accuracy.
2. The error was consistent. Random errors create noise you can see. A consistent one-hour shift in the same direction for eight months just looks like your model has a slight systematic weakness. It does not look like a code defect unless you know to look for it.
3. I validated against my own data. I was checking that the function returned the same result as last time. That's not a test. That's a regression check on a bug.
The only thing that found it was overlaying trade performance against calendar dates and asking "why does this look different in summer?" I should have asked that question during development, not after 112 trades.
What the Exchange Actually Says
I want to be direct about this: I assumed I knew how Kalshi contracts settle without reading the full settlement methodology documentation. I cross-referenced the NWS data format and stopped there.
The right process is to go from exchange settlement rules to NWS documentation to your bucketing code, in that order. Not the other way around.
Kalshi's contract specifications reference NWS ASOS station data. NWS ASOS records temperatures in Local Standard Time year-round. That chain exists in the documentation. I just didn't trace it all the way through before writing code.
For the v2.3 rebuild, every settlement-related assumption in the codebase now has a comment that cites the specific source document and page. Not because I expect anyone else to read it. Because writing the citation forced me to verify I'd actually read the thing I was claiming justified the implementation.
This Is Not a Clever Bug
I want to be clear about what kind of mistake this is. It is not a subtle timezone edge case that requires deep expertise to anticipate. It is the most common class of time-handling error in production code: assuming your definition of "a day" matches the upstream system's definition, without checking.
Every data pipeline that touches timestamps has this potential somewhere. If your settlement window, your data bucketing, and your source system don't share the same definition of when a period starts and ends, you are measuring something other than what you think you are measuring.
The DST bug cost me eight months of clean data. The fix is four lines. The lesson is older than Python: verify your time assumptions against the system that will judge you, not against what seems logical to you at 11 PM when you're writing data fetch code.
The Weather Bot is rebuilt and currently undergoing validation in paper-trading mode. We have not confirmed whether the fix improved outcomes. That will take months of completed trades to measure honestly. But at least we know what we're measuring now.