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, meaning in summer the official temperature record runs from 1:00 AM to 12:59 AM the next calendar day, not midnight to midnight.
- My bot was bucketing the wrong 24-hour window during daylight saving time for eight months, silently poisoning every forecast it made during that period.
- A matplotlib scatter plot overlaying trade losses against DST transition dates made the pattern impossible to ignore.
- The Predict & Profit Weather Bot v2.3 has been rebuilt with correct DST-aware date bucketing and is currently in paper-trading mode undergoing validation.
I spent four months watching my weather trading bot lose money and convinced myself the losses were noise.
They were not noise. There was a bug. A single assumption I had baked into the date logic so early in the project that I never questioned it again. It sat there, wrong, for eight months, through two major version updates, through every code review I did on myself.
The assumption: that a "weather day" runs from midnight to midnight.
It does not.
How Kalshi Temperature Markets Actually Settle
Kalshi temperature contracts settle on the official NWS daily maximum or minimum temperature for a given station. The official temperature record is published by NOAA's ASOS network. That part I knew.
What I did not verify carefully enough: how the NWS defines the boundary of a "day" for temperature records.
The National Weather Service uses Local Standard Time year-round to define the 24-hour observation window. In winter, that aligns with the clock: midnight to midnight. But in summer, when clocks shift forward one hour for daylight saving time, the clock reads 1:00 AM when it is really midnight in standard time. The official weather day does not shift with the clock. It stays anchored to LST.
So during DST, the official daily temperature window is:
- Starts: 1:00 AM local clock time (midnight LST)
- Ends: 12:59 AM local clock time the next day (one minute before midnight LST the following day)
My bot was bucketing observations from midnight to midnight local clock time. During DST that meant I was pulling the last hour of the previous official day and missing the last hour of the actual settlement window. Every single forecast from mid-March through early November was built on the wrong 24 hours of data.
The Code That Was Wrong
Here is roughly what the original bucketing logic looked like:
from datetime import datetime, date
import pytz
def get_observation_window(target_date: date, station_tz: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the observation window for a given date.
WRONG: uses local clock midnight, not LST midnight.
"""
tz = pytz.timezone(station_tz)
start = tz.localize(datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0))
end = tz.localize(datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59))
return start, end
This looks totally reasonable. You construct a timezone-aware datetime at midnight and one at 23:59:59. If you asked me to review this cold, I probably would have approved it.
The problem is that pytz.localize() with is_dst=None (the default) uses the DST-aware wall clock offset. In Chicago in July, that midnight is UTC-5 (CDT). But the NWS observation window starts at UTC-6 (CST), which is 1:00 AM CDT on the clock.
The fix requires anchoring to standard time explicitly, regardless of what the clock says:
from datetime import datetime, date, timezone, timedelta
import pytz
# Standard UTC offsets for common Kalshi stations (no DST adjustment)
STATION_STD_OFFSETS = {
"KMDW": -6, # Chicago Midway, CST = UTC-6
"KHOU": -6, # Houston Hobby, CST = UTC-6
"KJFK": -5, # New York JFK, EST = UTC-5
"KBOS": -5, # Boston Logan, EST = UTC-5
"KATL": -5, # Atlanta Hartsfield, EST = UTC-5
"KLAX": -8, # Los Angeles LAX, PST = UTC-8
"KORD": -6, # Chicago O'Hare (reference only), CST = UTC-6
}
def get_observation_window(target_date: date, station_id: str) -> tuple[datetime, datetime]:
"""
Returns the NWS observation window in UTC for a given date and station.
Anchored to Local Standard Time regardless of DST.
"""
std_offset_hours = STATION_STD_OFFSETS[station_id]
std_tz = timezone(timedelta(hours=std_offset_hours))
# Midnight LST = start of the NWS weather day
start_lst = datetime(target_date.year, target_date.month, target_date.day,
0, 0, 0, tzinfo=std_tz)
# 23:59:59 LST = end of the NWS weather day
end_lst = datetime(target_date.year, target_date.month, target_date.day,
23, 59, 59, tzinfo=std_tz)
# Convert to UTC for querying observation data
return start_lst.astimezone(timezone.utc), end_lst.astimezone(timezone.utc)
During DST in Chicago, start_lst now converts to 06:00 UTC, which is 1:00 AM CDT on the wall clock. That is correct. The old code was producing 05:00 UTC, one hour early, which is where the data corruption happened.
How I Found It
I did not find this bug by reading the code. I found it by looking at a chart.
After the four-month audit of 112 completed trades, I had a dataset with every trade outcome, timestamp, and edge estimate in a local PostgreSQL table. I was looking for any pattern in the losing trades. Correlation with station, with contract type, with forecast horizon. Nothing obvious jumped out.
Then I added one more column: in_dst_window. A boolean marking whether the trade's settlement date fell inside daylight saving time for that station's timezone.
The number was not subtle. The losing trades clustered almost entirely inside DST windows.
I built a quick scatter plot to make sure I was not fooling myself:
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# Load trade log from local DB
df = pd.read_sql("""
SELECT
t.settlement_date,
t.pnl,
t.station_id,
t.forecast_edge,
-- Mark DST windows (approximate; tighten this per station later)
CASE
WHEN EXTRACT(MONTH FROM t.settlement_date) BETWEEN 4 AND 10
THEN TRUE ELSE FALSE
END AS in_dst_window
FROM trades t
WHERE t.status = 'settled'
ORDER BY t.settlement_date
""", conn)
df['settlement_date'] = pd.to_datetime(df['settlement_date'])
fig, ax = plt.subplots(figsize=(14, 6))
# DST shading: rough April-October bands for each year in the dataset
for year in df['settlement_date'].dt.year.unique():
ax.axvspan(
pd.Timestamp(f'{year}-03-10'),
pd.Timestamp(f'{year}-11-03'),
alpha=0.08, color='orange', label='DST Window' if year == df['settlement_date'].dt.year.min() else ''
)
# Plot individual trades
colors = df['pnl'].apply(lambda x: '#00ff41' if x > 0 else '#ff4444')
ax.scatter(df['settlement_date'], df['pnl'], c=colors, alpha=0.7, s=40, zorder=3)
ax.axhline(0, color='white', linewidth=0.5, linestyle='--')
ax.set_facecolor('#0a0a0a')
fig.patch.set_facecolor('#000000')
ax.tick_params(colors='white')
ax.xaxis.label.set_color('white')
ax.yaxis.label.set_color('white')
ax.title.set_color('white')
ax.set_xlabel('Settlement Date')
ax.set_ylabel('P&L per Trade ($)')
ax.set_title('Trade P&L vs DST Window (112 completed trades)')
dst_patch = mpatches.Patch(color='orange', alpha=0.3, label='DST Window')
win_patch = mpatches.Patch(color='#00ff41', label='Winning Trade')
loss_patch = mpatches.Patch(color='#ff4444', label='Losing Trade')
ax.legend(handles=[dst_patch, win_patch, loss_patch], facecolor='#1a1a1a', labelcolor='white')
plt.tight_layout()
plt.savefig('dst_pnl_analysis.png', dpi=150, bbox_inches='tight', facecolor='#000000')
plt.show()
The orange DST bands and the red dots lined up almost perfectly. I sat there looking at it for a while.
This was not bad luck. This was a systematic data error showing up in the outcome distribution exactly the way you would expect a systematic data error to show up.
Why This Bug Is So Easy to Make
I want to be direct about this because I think a lot of engineers have the same bug sitting in their pipelines right now.
The assumption that a "day" runs midnight to midnight is so embedded in how we write date logic that it never comes up for review. You localize a datetime to the station's timezone and you move on. The code looks correct. It produces timezone-aware datetimes. It does not throw errors. Every unit test you write for it passes, because your test fixtures almost certainly use winter dates when LST and local clock time are the same thing.
The bug only materializes when you cross a DST boundary, and it materializes silently. Your data is wrong by exactly one hour. If your downstream calculation involves selecting observations within a 24-hour window, you get 23 hours of the right data plus 1 hour of the wrong data. The forecast degrades slightly. You chalk it up to model noise.
I did this for eight months.
The lesson is not "be more careful." The lesson is to verify your time bucketing logic directly against the exchange's actual settlement documentation, not against what seems logical. Pull the raw ASOS observation data for a handful of historical dates and manually confirm that your window captures the right rows. Do this in July, not January.
One check I added to the test suite:
def test_dst_window_chicago_july():
"""
On 2026-07-15 in Chicago, the NWS observation window should start at
06:00 UTC (1:00 AM CDT), not 05:00 UTC (midnight CDT).
"""
from datetime import date, timezone, timedelta
start_utc, end_utc = get_observation_window(date(2026, 7, 15), "KMDW")
expected_start = datetime(2026, 7, 15, 6, 0, 0, tzinfo=timezone.utc)
expected_end = datetime(2026, 7, 16, 5, 59, 59, tzinfo=timezone.utc)
assert start_utc == expected_start, f"Expected {expected_start}, got {start_utc}"
assert end_utc == expected_end, f"Expected {expected_end}, got {end_utc}"
That test would have caught this on day one. I did not have it on day one.
What This Bug Cost
The full four-month run lost roughly $23 across 112 trades. That is not a catastrophic number. The bet sizes were small because this was a new system and I was not going to bet big on an unvalidated model.
But the real cost was four months of corrupt signal. Every model evaluation I did during that period, every edge calculation, every confidence threshold I set was built on forecasts that were referencing the wrong hours. I could not trust any of the historical analysis after the fact, because I could not cleanly separate "bad model" from "bad data window."
That is why the v2.3 rebuild started from scratch rather than trying to patch in place. When your data pipeline has been wrong for eight months, you do not patch it. You audit every assumption and rebuild with tests.
The Broader Audit Context
The DST bug was one of seven distinct defects found in the original system. The others included wrong settlement station mapping (Kalshi uses Midway for Chicago, not O'Hare), an xarray truthiness error, a dict key bug in the Kalshi position API that made the bot unable to see its own open trades, and a stale settlement status write in the local database.
Any one of those bugs alone could explain the losses. All seven together meant the system was not really being tested at all. It was running, logging, placing orders, and producing numbers, but none of those numbers meant what I thought they meant.
The Weather Bot v2.3 has been rebuilt around NOAA's National Blend of Models as the primary forecast source. NBM publishes calibrated, bias-corrected temperature forecasts for exactly the stations Kalshi uses. It is free, it is operational, and it has decades of professional calibration behind it. We were hand-rolling a worse version of a public good.
The rebuilt bot is in paper-trading mode. It is undergoing validation. I am not making any claims about whether it works, because I do not know yet. That will take time and completed trades to answer honestly.
Takeaway
If you are building any system that queries time-series data and compares it to a real-world outcome that an external party controls, verify the exact time boundaries that party uses. Not what seems logical. Not what the timezone library defaults to. The exact documented boundaries.
For Kalshi weather markets, that means LST midnight, year-round, anchored to the station's standard offset. Build a test that runs in July. Then build another one that runs across a DST transition date. If both pass, you probably have it right.
I had neither. It cost me eight months of clean data and a non-trivial rebuild. Learn from that faster than I did.