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, so summer dates run 1:00 AM to 12:59 AM the next calendar day, not midnight to midnight.
- My bot was bucketing the wrong 24-hour window for eight months during DST, comparing forecasts to the wrong observation period.
- A matplotlib overlay of trade timestamps against DST transition dates made the pattern obvious in about ten minutes.
- The Weather Bot v2.3 (rebuilt and undergoing validation) fixes this with explicit LST-anchored date logic and a 49-test suite to prevent regression.
When I audited 112 completed trades from the original weather bot, I expected to find noise. Maybe a few bad markets, a couple of bad luck events, a model that was slightly off.
What I found was a clean pattern. Every losing cluster mapped directly onto the DST window.
Not some of them. Not most of them. The losses were distributed exactly where you would expect them if the bot were working with the wrong 24-hour window for half the year. Once I saw it plotted, I could not unsee it. It was one of those bugs that makes you feel genuinely stupid for not catching it sooner, and then makes you feel better when you realize how plausible the original assumption was.
The assumption: a calendar date is midnight to midnight. That is what every Python developer learns. That is what datetime.date.today() gives you. That is what makes sense to every human who has ever looked at a clock.
It is wrong for NWS temperature records, and that wrong assumption cost me eight months of bad trades.
How the NWS Actually Defines a Weather Day
The National Weather Service records maximum and minimum temperatures for a given "day" using a window anchored to Local Standard Time, year-round. In winter, when clocks are already on standard time, this lines up perfectly with the calendar date. Midnight LST is midnight clock time. No problem.
In summer, when clocks spring forward by an hour, the official weather day still starts and ends at the equivalent of midnight LST. That means the NWS weather day for, say, July 14 runs from 1:00 AM local clock time on July 14 to 12:59 AM local clock time on July 15.
Kalshi temperature contracts settle against the official NWS high or low temperature for a given date at a given airport. That means Kalshi is using the NWS definition. That means the settlement window is LST-anchored.
My bot was pulling hourly observations and bucketing them by calendar date in local wall-clock time. In winter, that is correct. In summer, it is off by exactly one hour at both ends of the day. I was comparing a forecast for the wrong 24-hour block against the right settlement result.
The model was not just noisy. It was answering a subtly different question than the one Kalshi was asking.
The Code That Was Wrong
The original observation-bucketing logic looked roughly like this:
from datetime import datetime, date
import pytz
def get_daily_observations(station_id: str, target_date: date, timezone_str: str) -> dict:
"""
Fetch hourly obs for a station and return daily high/low for target_date.
"""
tz = pytz.timezone(timezone_str)
# Define the window as midnight-to-midnight in local wall time
day_start = tz.localize(datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0))
day_end = tz.localize(datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59))
observations = fetch_asos_observations(station_id, day_start, day_end)
temps = [obs['tmpf'] for obs in observations if obs['tmpf'] is not None]
return {
'date': target_date,
'high': max(temps) if temps else None,
'low': min(temps) if temps else None,
'obs_count': len(temps)
}
See it? day_start and day_end are midnight to midnight in local wall-clock time. During CDT (UTC-5), that window is correct relative to LST (UTC-6) in winter, but in summer it starts and ends one hour too early.
The fix is to anchor the window to Local Standard Time explicitly, regardless of what the clock says:
from datetime import datetime, date, timedelta
import pytz
def get_daily_observations_lst(station_id: str, target_date: date, standard_tz_str: str) -> dict:
"""
Fetch hourly obs using LST-anchored window, matching NWS settlement definition.
standard_tz_str should be the standard time zone (e.g. 'Etc/GMT+6' for CST).
"""
# Use the fixed-offset standard time zone, never DST-adjusted
lst_tz = pytz.timezone(standard_tz_str)
# Window: midnight LST on target_date to midnight LST on target_date + 1
day_start_lst = lst_tz.localize(
datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0)
)
day_end_lst = lst_tz.localize(
datetime(target_date.year, target_date.month, target_date.day + 1, 0, 0, 0)
) - timedelta(seconds=1)
# Convert to UTC for the API call
day_start_utc = day_start_lst.astimezone(pytz.utc)
day_end_utc = day_end_lst.astimezone(pytz.utc)
observations = fetch_asos_observations(station_id, day_start_utc, day_end_utc)
temps = [obs['tmpf'] for obs in observations if obs['tmpf'] is not None]
return {
'date': target_date,
'high': max(temps) if temps else None,
'low': min(temps) if temps else None,
'obs_count': len(temps),
'window_start_utc': day_start_utc.isoformat(),
'window_end_utc': day_end_utc.isoformat()
}
The key change: use a fixed-offset time zone (like Etc/GMT+6 for Central Standard Time) instead of a DST-aware zone like America/Chicago. The fixed-offset zone never adjusts for daylight saving. The window is always anchored to standard time, which matches how the NWS computes the official daily record.
For the standard time zone string per station, I map each settlement airport to its UTC offset and use the corresponding Etc/GMT+N identifier. Chicago (CST) is Etc/GMT+6. Houston (CST) is also Etc/GMT+6. Note that Etc/GMT+6 is UTC-6, which is counterintuitive but correct: POSIX convention inverts the sign.
How I Found It
The audit started with a simple question: is there any structure to when we lose?
I pulled the full trade ledger from the database and added a column for whether each trade fell within a DST window, defined as the period between the second Sunday in March and the first Sunday in November for US markets.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def is_dst_period(dt: pd.Timestamp) -> bool:
"""Rough US DST check: second Sunday in March through first Sunday in November."""
year = dt.year
# Second Sunday in March
mar1 = pd.Timestamp(year=year, month=3, day=1)
first_sun_mar = mar1 + pd.offsets.Week(weekday=6)
dst_start = first_sun_mar + pd.offsets.Week(1)
# First Sunday in November
nov1 = pd.Timestamp(year=year, month=11, day=1)
dst_end = nov1 + pd.offsets.Week(weekday=6)
return dst_start <= dt < dst_end
df = pd.read_sql("SELECT * FROM trades WHERE status = 'settled'", conn)
df['opened_at'] = pd.to_datetime(df['opened_at'])
df['in_dst'] = df['opened_at'].apply(is_dst_period)
df['pnl'] = df['payout'] - df['cost']
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
# Top chart: cumulative P&L
df_sorted = df.sort_values('opened_at')
df_sorted['cumulative_pnl'] = df_sorted['pnl'].cumsum()
axes[0].plot(df_sorted['opened_at'], df_sorted['cumulative_pnl'], color='#00ff41', linewidth=1.5)
axes[0].axhline(0, color='white', linewidth=0.5, linestyle='--')
axes[0].set_ylabel('Cumulative P&L ($)', color='white')
axes[0].set_facecolor('#111111')
axes[0].tick_params(colors='white')
# Shade DST periods
dst_mask = df_sorted['in_dst']
for _, row in df_sorted[dst_mask].iterrows():
axes[0].axvspan(row['opened_at'] - pd.Timedelta(hours=12),
row['opened_at'] + pd.Timedelta(hours=12),
alpha=0.15, color='red', linewidth=0)
# Bottom chart: trade P&L by DST vs non-DST
dst_pnl = df[df['in_dst']]['pnl']
non_dst_pnl = df[~df['in_dst']]['pnl']
axes[1].bar(range(len(dst_pnl)), dst_pnl.values, color='red', alpha=0.7, label='DST window')
axes[1].bar(range(len(non_dst_pnl)), non_dst_pnl.values,
color='#00ff41', alpha=0.7, label='Non-DST window')
axes[1].axhline(0, color='white', linewidth=0.5, linestyle='--')
axes[1].set_ylabel('Trade P&L ($)', color='white')
axes[1].set_facecolor('#111111')
axes[1].tick_params(colors='white')
axes[1].legend(facecolor='#222222', labelcolor='white')
for ax in axes:
ax.spines['bottom'].set_color('#333333')
ax.spines['top'].set_color('#333333')
ax.spines['left'].set_color('#333333')
ax.spines['right'].set_color('#333333')
fig.patch.set_facecolor('#000000')
fig.suptitle('Trade P&L vs DST Window', color='white', fontsize=14)
plt.tight_layout()
plt.savefig('dst_audit.png', dpi=150, bbox_inches='tight', facecolor='#000000')
plt.show()
When this rendered, the pattern was immediate. The DST-window trades were a wall of red. The non-DST trades had a mixed distribution that looked roughly like what you would expect from a coin-flip market. No edge, but no obvious directional bleed either.
The DST window had directional bleed. The bot was systematically comparing forecasts to the wrong observation window, and the error was not random. It introduced a consistent one-hour shift that changed which hour was included at the margins of each day. For temperature markets, the hours near midnight are often the coldest of the day. Including or excluding them is not noise.
Why This Was Easy to Miss
The bot was never comparing observation windows to settlement windows side by side. It was just fetching what felt like the right data for a given date and running the forecast comparison. The output looked reasonable. Temperature numbers came back. High and low values were plausible. Nothing crashed. Nothing raised an exception.
This is the worst category of bug: silent, plausible-looking, and localized to a subset of inputs. In production, it ran for eight months across the full DST season with no alarm ever firing.
The thing that should have caught this earlier was a settlement verification step. Before ever placing a live trade, the bot should have fetched the official settled temperature from Kalshi after settlement, compared it to the observation the bot computed internally, and flagged any mismatch. I added that step in v2.3. For every settled trade, the bot now logs both the Kalshi settlement value and the internally computed observation value. If they diverge by more than 0.5 degrees, it writes a warning to the audit table.
That comparison would have surfaced this on day one of DST season.
The Station Mapping Bug Was Related
While I was in there fixing the DST logic, I also found that the bot was using the wrong airports for Chicago and Houston. Kalshi settles Chicago contracts on Midway (MDW), not O'Hare (ORD). Houston is Hobby (HOU), not IAH.
I confirmed the correct list by calling Kalshi's own market metadata API:
import requests
def get_settlement_station_for_market(market_ticker: str, api_key: str) -> str:
"""
Pull the settlement description directly from Kalshi's market metadata.
Parse out the airport code from the description string.
"""
url = f"https://api.kalshi.com/trade-api/v2/markets/{market_ticker}"
headers = {"Authorization": f"Bearer {api_key}"}
response = requests.get(url, headers=headers)
response.raise_for_status()
market = response.json().get('market', {})
subtitle = market.get('subtitle', '')
# Kalshi subtitles typically include station code in parentheses
# e.g. "High Temperature in Chicago (MDW) above 85°F"
import re
match = re.search(r'\(([A-Z]{3})\)', subtitle)
if match:
return match.group(1)
return subtitle # Return raw if no code found
# Example usage
ticker = "HIGHNY-26JUL30-T85"
station = get_settlement_station_for_market(ticker, api_key)
print(f"Settlement station: {station}")
Do not guess the station from the city name. Do not look it up on Wikipedia. Ask the exchange directly.
What the Audit Actually Showed
After fixing the DST bucketing and the station mapping in isolation during the audit, I went back and re-tagged the 112 trades with corrected flags. The number of trades that had either wrong observation window or wrong station was significant enough that the overall model-skill question was basically unanswerable without fixing both defects first.
Which is when I made the call to rebuild rather than patch. There were five other defects in the audit besides these two, including the xarray truthiness bug and the position-detection bug where the bot literally could not see its own open positions. Patching one defect at a time on a model that was also failing the Brier score test did not make sense.
The rebuilt bot (v2.3) switched to NOAA's National Blend of Models as the primary forecast source. NOAA already publishes calibrated, bias-corrected temperature forecasts for the exact stations Kalshi settles on. For free. My hand-rolled ensemble, fixed or not, was never going to beat the output of a professional calibration team.
The Weather Bot v2.3 is rebuilt and undergoing validation. Paper trading only. I am not claiming it works. I am claiming the DST bug is fixed, the stations are right, and the underlying forecast source is dramatically better.
The Practical Lesson
Every data pipeline that touches time has a time-zone assumption buried somewhere. Usually in the place where you define "what counts as today."
If your pipeline settlement depends on an external definition of a day, find that definition first. Read the documentation. If there is no documentation, ask the exchange, query the API, or pull a few historical results and reverse-engineer the window. Do not assume the exchange is using midnight-to-midnight wall time because that is what you would use.
Then write a test that verifies a known historical result. Pick a date that fell in the DST window, pull the official settled value from Kalshi, compute your bot's internal value, and assert they match. If that test passes year-round, your bucketing is correct. If it fails in summer, you found the bug before it cost you eight months of trades.
Time is the most dangerous input in production data engineering. It looks simple until it is not.