The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on a weather day defined in Local Standard Time year-round, not the calendar day in local wall clock time.
- My bot was bucketing the wrong 24-hour window during DST months, which means every summer trade was evaluated against the wrong forecast period.
- The pattern became obvious only after I overlaid trade loss timestamps against DST transition dates in a matplotlib scatter plot.
- The fix was eight lines of Python, but finding it required auditing 112 completed trades from the Predict & Profit Weather Bot and asking an uncomfortable question: what if the model was never wrong?
The Assumption I Never Questioned
When you build a weather trading bot, you make a hundred small decisions without thinking about them. What forecast hour to pull. Which grid cell to use for a given airport. How to define "today."
That last one got me.
For eight months, my bot defined "today" the way any reasonable person would: midnight to midnight in the local time zone of the weather station. If I was trading a Chicago temperature contract, I pulled the forecast for midnight to midnight Chicago time. Obvious. Sensible. Wrong.
The National Weather Service defines a weather observation day in Local Standard Time, year-round. Not local time. Not wall clock time. Standard time. Always.
In Chicago during summer, Central Daylight Time is UTC-5. Central Standard Time is UTC-6. The official NWS weather day runs from 1:00 AM CDT to 12:59 AM CDT the next calendar day. That is the midnight-to-midnight window in standard time, shifted forward one hour because the clocks moved but the definition did not.
My bot was pulling forecasts and observations for midnight CDT to midnight CDT. One hour off. For every trade between March and November, I was comparing apples to something that is almost but not quite an apple.
How I Found It
I did not find it by being clever. I found it by being forced to look.
After four months of live trading, the Weather Bot had completed 112 trades and lost roughly $23. That sounds small. It is not small. The issue is not the dollar amount, it is the Brier score. The model scored 0.2858. Simply using the historical base rate with no model at all scores 0.2439. My model was statistically worse than making no prediction. It had negative skill.
That forced a real audit. Not a vibe check. A join against the trade database.
I pulled every completed trade: the contract, the direction, the entry price, the settlement outcome, and the timestamp. Then I added a column: was this trade opened during a DST-active period?
import pandas as pd
import pytz
from datetime import datetime
def is_dst_active(ts, tz_name):
tz = pytz.timezone(tz_name)
dt = datetime.fromtimestamp(ts, tz=tz)
return bool(dt.dst())
df['dst_active'] = df['opened_at'].apply(
lambda ts: is_dst_active(ts, station_tz_map[df.loc[df['opened_at'] == ts, 'station'].values[0]])
)
Then I plotted the win rate by DST status.
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for ax, group_label in zip(axes, ['DST Active', 'Standard Time']):
mask = df['dst_active'] == (group_label == 'DST Active')
subset = df[mask]
won = subset['outcome'] == 'win'
ax.scatter(
subset.index,
subset['entry_price'],
c=won.map({True: '#00ff41', False: '#ff4444'}),
alpha=0.6,
s=40
)
win_rate = won.mean()
ax.set_title(f"{group_label}\nWin Rate: {win_rate:.1%} (n={len(subset)})")
ax.set_xlabel("Trade Index")
ax.set_ylabel("Entry Price (cents)")
ax.set_ylim(0, 100)
green_patch = mpatches.Patch(color='#00ff41', label='Win')
red_patch = mpatches.Patch(color='#ff4444', label='Loss')
fig.legend(handles=[green_patch, red_patch], loc='upper right')
fig.suptitle("Trade Outcomes: DST vs Standard Time Window", fontsize=14)
plt.tight_layout()
plt.savefig("dst_outcome_scatter.png", dpi=150, bbox_inches='tight')
plt.show()
The chart made it undeniable. During standard time months, the win rate was acceptable. During DST months, I was losing at a rate that defied the model's stated confidence. The model was not wrong about everything. It was systematically wrong about one thing that happened to apply to most of my trades.
The Actual Bug
Here is the original bucketing logic, simplified:
from datetime import datetime, timedelta
import pytz
def get_weather_day_window(date_str, tz_name):
"""
Returns (start_utc, end_utc) for a weather observation day.
WRONG: uses local wall clock midnight, not LST midnight.
"""
tz = pytz.timezone(tz_name)
local_midnight = tz.localize(datetime.strptime(date_str, "%Y-%m-%d"))
next_midnight = local_midnight + timedelta(days=1)
start_utc = local_midnight.astimezone(pytz.utc)
end_utc = next_midnight.astimezone(pytz.utc)
return start_utc, end_utc
During summer in Chicago, local_midnight is midnight CDT, which is UTC+5. That shifts the entire window one hour earlier than what the NWS actually records.
The NWS weather day is defined relative to Local Standard Time, always. The correct window during CDT is 01:00 CDT to 00:59 CDT the next day, because that span equals 00:00 CST to 23:59 CST.
Here is the fixed version:
from datetime import datetime, timedelta
import pytz
def get_weather_day_window(date_str, tz_name):
"""
Returns (start_utc, end_utc) for an NWS weather observation day.
The NWS defines the weather day in Local Standard Time year-round.
During DST, the window shifts forward one hour on the wall clock.
"""
tz = pytz.timezone(tz_name)
# Parse the date as naive, then localize to standard time
# by finding the UTC offset for that zone in standard time (no DST)
naive_date = datetime.strptime(date_str, "%Y-%m-%d")
# Get the standard time UTC offset for this zone
# pytz stores standard time as the zone's _utcoffset
std_offset = tz._utcoffset # e.g., timedelta(hours=-6) for Chicago
# Build the window start as midnight in standard time
start_utc = datetime(
naive_date.year, naive_date.month, naive_date.day,
0, 0, 0,
tzinfo=pytz.utc
) - std_offset
end_utc = start_utc + timedelta(hours=24)
return start_utc, end_utc
def get_weather_day_window_v2(date_str, tz_name):
"""
Cleaner version: force standard time offset explicitly.
Works for the CONUS zones the bot actually uses.
"""
standard_offsets = {
'America/Chicago': -6,
'America/New_York': -5,
'America/Denver': -7,
'America/Los_Angeles': -8,
'America/Phoenix': -7, # Arizona never observes DST
}
if tz_name not in standard_offsets:
raise ValueError(f"Unknown timezone: {tz_name}. Add it to standard_offsets.")
offset_hours = standard_offsets[tz_name]
naive_date = datetime.strptime(date_str, "%Y-%m-%d")
# Midnight in standard time = midnight UTC + abs(offset)
start_utc = datetime(
naive_date.year, naive_date.month, naive_date.day,
0, 0, 0,
tzinfo=pytz.utc
) + timedelta(hours=-offset_hours)
end_utc = start_utc + timedelta(hours=24)
return start_utc, end_utc
I used the second version. The explicit offset table is less clever but much easier to audit. When someone reads this code in six months, they will not need to know how pytz stores internal offsets. They will see a table and understand it immediately.
Why This Hurt So Much
The DST offset is one hour. One hour out of 24. That is 4.2% of the window.
But it is not a random 4.2%. It is the first hour of the weather day. Maximum temperature for most inland stations peaks in mid-afternoon, but minimum temperature, which is what a lot of the Kalshi contracts reference, tends to occur just before or after sunrise. In Chicago in July, sunrise is around 5:15 AM CDT. With the window shifted one hour, I was sometimes cutting off the actual daily minimum or including an observation from the previous record period.
More importantly, it was systematic. Every single summer trade had this exact error applied in the same direction. There was no cancellation effect. The error did not average out. It stacked.
Combined with overconfident probability estimates, the bot was placing high-confidence trades on systematically wrong forecasts during the months with the most trading volume. The winter trades were fine. Nobody noticed because the winter trades were not losing.
The Kalshi Settlement Detail That Triggered the Whole Audit
This bug would have stayed hidden longer if I had not gone back to read Kalshi's settlement documentation more carefully during the post-mortem.
Kalshi temperature contracts reference NOAA ASOS station data. The settlement methodology notes that the official high and low temperatures for a given contract date are the ones recorded by NWS for that weather observation day, which runs midnight to midnight in Local Standard Time.
That sentence is easy to skip. I skipped it. I assumed "midnight to midnight local time" meant wall clock local time. It does not. NWS does not change their observation day boundaries when the clocks change. They stay on standard time all year because that is how historical records have been kept since the stations were established.
The practical verification: pull a few days of raw ASOS data from Iowa State's mesonet API around a DST transition and compare the recorded daily high and low against what Kalshi actually settled. I did this after the fact. The discrepancy was obvious within the first three transition dates I checked.
import requests
from datetime import datetime, timedelta
def fetch_asos_daily(station, date_str, utc_start, utc_end):
"""
Fetch raw ASOS observations for a station between two UTC timestamps.
Use Iowa State Mesonet ASOS API.
"""
base_url = "https://mesonet.agron.iastate.edu/request/asos/1min.php"
params = {
'station': station,
'data': 'tmpf',
'year1': utc_start.year,
'month1': utc_start.month,
'day1': utc_start.day,
'hour1': utc_start.hour,
'minute1': 0,
'year2': utc_end.year,
'month2': utc_end.month,
'day2': utc_end.day,
'hour2': utc_end.hour,
'minute2': 0,
'tz': 'UTC',
'format': 'onlycomma',
'latlon': 'no',
'direct': 'no',
'report_type': 1,
}
resp = requests.get(base_url, params=params, timeout=30)
resp.raise_for_status()
lines = [l for l in resp.text.strip().split('\n') if not l.startswith('#')]
temps = []
for line in lines[1:]: # skip header
parts = line.split(',')
if len(parts) >= 3 and parts[2].strip() not in ('M', ''):
try:
temps.append(float(parts[2].strip()))
except ValueError:
continue
return {
'station': station,
'date': date_str,
'high': max(temps) if temps else None,
'low': min(temps) if temps else None,
'obs_count': len(temps),
}
Run this for the same date using the wall-clock window versus the LST window during a DST transition week. The daily high and low will differ on the transition dates. That difference is the bug.
The Broader Lesson
I have been writing data pipelines for thirty years. Time handling is the thing that bites everyone, including people who should know better. UTC everywhere is good advice. But UTC everywhere only helps when you are consistent about what the external data source actually means when it says a date.
NOAA does not say "UTC date." It says "observation day in Local Standard Time." That is a domain-specific definition that exists for meteorological record-keeping reasons that predate computers. You will not find it in any general Python datetime tutorial. You find it by reading the actual specification for the data you are consuming.
The expensive assumption is always the one that feels too obvious to check. Midnight means midnight. Local means local. Except when it does not.
Verify your time assumptions against the exchange's settlement rules. Verify your settlement rules against the underlying data source's record-keeping conventions. Do not verify them against what seems logical. The thing that seems logical is exactly where the bug lives.
Where the Bot Stands Now
The DST fix was one of seven defects found in the full audit. The rebuilt Weather Bot v2.3 uses NOAA's National Blend of Models as the primary forecast source instead of the custom ensemble, corrects all seven defects, and added 49 automated tests. It is back in paper-trading mode and has not been validated. Rebuilt and undergoing validation is the honest description.
If you are curious about the full audit, the other six defects, or the architecture of the rebuilt system, the source code is at predictandprofit.gumroad.com. The post-mortem documentation is included. I did not bury the embarrassing parts.
The DST bug is the kind of thing that hides in every data pipeline that touches weather data. If you are building anything that compares forecasts to NWS observations, go check your bucketing logic now. Not later.