The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- The National Weather Service defines a weather "day" in Local Standard Time year-round, not local clock time, meaning the official 24-hour window shifts by one hour during DST.
- My Weather Bot was bucketing the wrong observations all summer, feeding misaligned data into every forecast for eight months.
- I only found it by overlaying trade loss timestamps against DST transition dates in a matplotlib scatter plot, at which point the pattern was impossible to ignore.
- The Predict & Profit Weather Bot v2.3 has been rebuilt with correct DST-aware date bucketing and is currently undergoing validation in paper-trading mode.
I have been writing data pipelines for thirty years. I have seen timestamp bugs eat production systems alive. I thought I knew all the flavors.
I did not know this one.
When I audited 112 completed trades from the original Weather Bot and found that the forecasting model had no predictive skill, I assumed the problem was the model. Wrong signal sources. Bad calibration. Maybe the ensemble weighting was off. I spent two weeks going down that road before I looked at something more basic: when, exactly, was I bucketing my weather observations?
The answer was humiliating.
How Kalshi Temperature Contracts Actually Settle
Kalshi's temperature contracts resolve on the official high or low temperature for a given city on a given date. That sounds simple. It is not simple.
The National Weather Service does not define a weather "day" the way most people assume. They do not use midnight local clock time. They use Local Standard Time, year-round.
That means for a city like Chicago:
- In winter (standard time): the weather day runs from midnight to 11:59 PM CST. Clock time and LST agree.
- In summer (daylight saving time): the weather day runs from 1:00 AM CDT to 12:59 AM CDT the following day. The official day starts one clock hour later than midnight.
The NWS made this decision decades ago for consistency. They wanted every historical record to represent the same real-world 24-hour window regardless of what humans decided to do with their clocks. Reasonable call. But if your software doesn't know about it, you are silently reading the wrong data all summer.
Kalshi settles on whatever the NWS records. So if you are building a bot to trade Kalshi temperature markets and you are pulling observations bucketed by local midnight, you are misaligned for roughly eight months out of the year in any city that observes DST.
That was my bot.
The Code That Was Wrong
Here is the original observation-bucketing logic, simplified slightly for clarity:
from datetime import datetime, date
import pytz
def get_weather_day_observations(station: str, target_date: date, tz_name: str) -> list[dict]:
"""
Pull ASOS observations for a station and bucket them into a single weather day.
"""
local_tz = pytz.timezone(tz_name)
# Wrong: this uses local CLOCK midnight, not Local Standard Time midnight
day_start = local_tz.localize(datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0))
day_end = local_tz.localize(datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59))
day_start_utc = day_start.astimezone(pytz.utc)
day_end_utc = day_end.astimezone(pytz.utc)
return fetch_observations(station, day_start_utc, day_end_utc)
Looks fine. It is not fine.
During DST, local_tz.localize(datetime(..., 0, 0, 0)) gives you the clock midnight in CDT, which is UTC-5. But Local Standard Time midnight is UTC-6, which is 1:00 AM CDT. The window I was pulling started one hour too early and ended one hour too early.
The temperature recorded at 12:30 AM CDT on a summer night belongs to the previous NWS weather day. My code included it in the current day. The temperature recorded at 12:30 AM CDT the following morning belongs to the current NWS weather day. My code excluded it.
Every single summer trade was running on observations from the wrong 24-hour window.
How I Found It
I did not find it by reading documentation. I found it by making a chart.
After the 112-trade audit showed the model had no skill, I wrote a script to dump every trade's metadata: date, city, forecast probability, actual outcome, win or loss. Then I added a column: was this trade during DST?
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from datetime import date
# US DST 2025-2026 (second Sunday March to first Sunday November)
DST_PERIODS = [
(date(2025, 3, 9), date(2025, 11, 2)),
(date(2026, 3, 8), date(2026, 11, 1)),
]
def in_dst(trade_date: date) -> bool:
for start, end in DST_PERIODS:
if start <= trade_date < end:
return True
return False
df = pd.read_csv("trade_audit.csv", parse_dates=["trade_date"])
df["trade_date"] = df["trade_date"].dt.date
df["dst"] = df["trade_date"].apply(in_dst)
df["won"] = df["outcome"] == "win"
fig, ax = plt.subplots(figsize=(14, 5))
colors = df.apply(lambda r: "#00ff41" if r["won"] else "#ff4444", axis=1)
markers = df["dst"].map({True: "^", False: "o"})
for _, row in df.iterrows():
ax.scatter(
row["trade_date"],
row["forecast_prob"],
color="#00ff41" if row["won"] else "#ff4444",
marker="^" if row["dst"] else "o",
s=60,
alpha=0.75
)
# Shade DST periods
for start, end in DST_PERIODS:
ax.axvspan(start, end, alpha=0.08, color="yellow", label="DST window")
ax.set_xlabel("Trade Date")
ax.set_ylabel("Forecast Probability")
ax.set_title("Trade Outcomes vs DST Windows")
ax.set_ylim(0, 1)
win_patch = mpatches.Patch(color="#00ff41", label="Win")
loss_patch = mpatches.Patch(color="#ff4444", label="Loss")
dst_patch = mpatches.Patch(color="yellow", alpha=0.3, label="DST Period")
ax.legend(handles=[win_patch, loss_patch, dst_patch])
plt.tight_layout()
plt.savefig("dst_trade_audit.png", dpi=150)
plt.show()
When this chart rendered, I sat there for a full minute.
The losses were not random. They clustered in the shaded DST windows with a consistency that was not coincidence. Winter trades showed a mixed pattern, wins and losses scattered the way you'd expect from a noisy model. The DST windows were a graveyard.
That chart cost me probably ten minutes to build. I should have built it before I spent four months trading.
Why This Bug Hides So Well
DST bugs are common. This specific one is unusually hard to catch for a few reasons.
First, it passes every basic sanity check. The code pulls observations. The observations are real. The timestamps parse correctly. Nothing throws an error. You get a result, it just covers the wrong 24 hours.
Second, the effect is subtle. You are off by one hour at each end of the window. In most cases the temperature readings at the edges of the day are not the high or low, so the error does not always change the observed max or min. It just shifts the window, and you never notice unless you go looking.
Third, the documentation that explains this behavior is buried. The NWS climate observation standards are not in any API readme. They are in operational manuals that nobody reads unless they already know to go looking.
The only way I found it was by overlaying outcomes against dates and asking why the pattern looked the way it looked.
The Fix
Correct bucketing requires explicitly working in Local Standard Time regardless of what the clock says.
from datetime import datetime, date, timedelta
import pytz
# UTC offsets for standard time by timezone name (not daylight time)
STANDARD_UTC_OFFSETS = {
"America/Chicago": -6,
"America/New_York": -5,
"America/Los_Angeles": -8,
"America/Denver": -7,
"America/Phoenix": -7, # Arizona does not observe DST
"America/Houston": -6,
}
def get_nws_weather_day_utc(target_date: date, tz_name: str) -> tuple[datetime, datetime]:
"""
Return the UTC start and end of the NWS weather day for a given local date.
The NWS defines weather days in Local Standard Time year-round.
During DST, the weather day starts at 1:00 AM local clock time (= LST midnight).
"""
standard_offset_hours = STANDARD_UTC_OFFSETS[tz_name]
# LST midnight = UTC midnight minus the standard offset
# e.g. Chicago LST midnight = UTC 06:00
lst_midnight_utc = datetime(
target_date.year,
target_date.month,
target_date.day,
0, 0, 0,
tzinfo=pytz.utc
) - timedelta(hours=standard_offset_hours)
lst_end_utc = lst_midnight_utc + timedelta(hours=24) - timedelta(seconds=1)
return lst_midnight_utc, lst_end_utc
def get_weather_day_observations(station: str, target_date: date, tz_name: str) -> list[dict]:
"""
Pull ASOS observations for a station bucketed by the NWS weather day definition.
"""
day_start_utc, day_end_utc = get_nws_weather_day_utc(target_date, tz_name)
return fetch_observations(station, day_start_utc, day_end_utc)
This works correctly in both standard and daylight saving time. During winter in Chicago, lst_midnight_utc is 06:00 UTC, which is midnight CST. During summer, it is still 06:00 UTC, which is 1:00 AM CDT. Exactly right, for exactly the reason the NWS intends.
The STANDARD_UTC_OFFSETS table is explicit and auditable. No pytz DST inference. No ambiguous localize() calls during the transition hour. The standard offset for each timezone is a fixed number that does not change. You look it up once and hardcode it.
Arizona is in there because it does not observe DST. The bot has to know that too.
What the Audit Said About This Bug
Out of seven distinct defects found in the post-mortem audit, the DST bucketing error was the one with the clearest pattern in the data. Every losing trade in the summer months touched this bug.
That does not mean fixing DST alone would have made the bot profitable. The full audit showed the model had no predictive skill independent of this bug. The Brier score was 0.2858 against a base-rate baseline of 0.2439. The model was worse than knowing nothing.
But this bug guaranteed that even if the model had skill, the summer trades were running on corrupted input. It is the kind of defect that makes it impossible to know whether your strategy works, because your data is silently wrong for eight months out of the year.
The Practical Lesson
When you build a data pipeline that depends on time windowing, the documentation that matters is not your language's datetime library. It is the operational definition used by whoever records the data you are consuming.
The NWS uses LST year-round. ASOS uses UTC. Kalshi settles on NWS records. All three are internally consistent. The bug lived entirely in my bucketing code, in the gap between what I assumed and what the NWS actually does.
Before you write a single line of time-windowing code, find the answer to this question: what does the data source define as a day? Not what you would define as a day. What they define.
Then verify it against a known example. Pull a summer date. Pull a winter date. Check that your computed window matches the recorded official observation for a day where the edge reading was the reported high or low. Do this before you trade a single cent.
The Weather Bot v2.3 has this fixed. It is rebuilt and undergoing validation in paper-trading mode. Eight months of bad summer bucketing is not something I am going to rush past with a one-line fix and a shrug.
Verify your assumptions. Especially the ones that seem too obvious to check.