The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on a midnight-to-midnight window defined in Local Standard Time year-round, including summer when clocks are shifted forward.
- My bot was bucketing the wrong 24 hours from April through November, which is eight months of every calendar year.
- When I overlaid trade outcomes against DST transition dates, the loss pattern was immediate and obvious in a single matplotlib chart.
- The Weather Bot v2.3 (rebuilt and undergoing validation) corrects this bug along with six other defects found during a full audit of 112 completed trades.
The $23 Lesson I Should Have Caught on Day One
The Weather Bot lost roughly $23 over four months of live trading. That sounds like a rounding error. The problem is it lost it systematically, and when I finally sat down and audited 112 completed trades against their timestamps, the pattern was not subtle.
Every bad trade cluster fell in the same calendar window. April through November. Daylight saving time.
I want to walk through exactly what happened, because this is not an exotic edge case. It is the kind of assumption that hides in every data pipeline that touches weather data, and it cost me four months before I stopped to look.
What I Assumed vs. What Is Actually True
My assumption: a Kalshi temperature contract for "will the high temperature in Chicago exceed 85°F on July 15th" measures the highest temperature recorded at the settlement station during the calendar day of July 15th, midnight to midnight in local time.
That is wrong.
The National Weather Service defines a weather day for temperature records in Local Standard Time year-round. Not local clock time. Not UTC. Local Standard Time, always.
In Chicago, that is UTC-6 in winter and UTC-6 in summer. The clock shifts, the weather day does not.
So during summer, when Chicago clocks read midnight at 12:00 AM CDT, the official weather day for July 15th actually started at 1:00 AM CDT (midnight CST) and ends at 12:59 AM CDT on July 16th. The entire 24-hour window is shifted one hour forward relative to what the wall clock says.
My bot was pulling temperature observations and bucketing them midnight to midnight by local clock time. For eight months of the year, it was looking at the wrong 24 hours.
The Code That Was Wrong
Here is a simplified version of what the original date bucketing looked like:
from datetime import datetime
import pytz
def get_weather_day_window(date_str: str, station_tz: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the weather day for a given date.
WRONG: Uses wall clock midnight, not standard time midnight.
"""
tz = pytz.timezone(station_tz)
naive_start = datetime.strptime(date_str, "%Y-%m-%d")
naive_end = naive_start.replace(hour=23, minute=59, second=59)
# This is the bug. localize() respects DST, which is not what we want.
start = tz.localize(naive_start)
end = tz.localize(naive_end)
return start, end
The call to tz.localize() applies the UTC offset that is in effect at that moment. In summer, Chicago is CDT (UTC-5). So start becomes 2026-07-15 00:00:00-05:00. That looks correct. It is not.
The official weather day should start at midnight CST (UTC-6), which in summer wall clock time is 1:00 AM CDT. We were starting an hour early, ending an hour early, and including the last hour of the previous official weather day instead.
How I Found It
The audit started because the overall Brier score was damning: 0.2858 against a base-rate baseline of 0.2439. Our model was statistically worse than making no prediction at all. That told me something structural was broken, not just noisy.
I pulled the full trade log and added a column for whether each trade fell within a DST-active period. The query was simple:
import sqlite3
import pandas as pd
from datetime import datetime
import pytz
def is_dst_active(trade_date: str, station_tz: str) -> bool:
tz = pytz.timezone(station_tz)
dt = datetime.strptime(trade_date, "%Y-%m-%d")
# Check if DST is in effect at noon on that day
localized = tz.localize(dt.replace(hour=12))
return bool(localized.dst().total_seconds() > 0)
conn = sqlite3.connect("trades.db")
df = pd.read_sql_query("""
SELECT trade_date, station, outcome, pnl, bot_probability, market_price
FROM completed_trades
ORDER BY trade_date
""", conn)
df["dst_active"] = df.apply(
lambda row: is_dst_active(row["trade_date"], station_tz_map[row["station"]]),
axis=1
)
print(df.groupby("dst_active")["pnl"].agg(["sum", "mean", "count"]))
Output:
sum mean count
dst_active
False 8.43 0.2948 31
True -31.47 -0.3874 81
Eighty-one of the 112 trades fell in the DST window. Those trades lost $31.47 combined. The non-DST trades were slightly profitable. The signal was not ambiguous.
The Visualization That Made It Undeniable
I then plotted cumulative PnL over time with vertical lines at DST transition dates. This is the matplotlib code. I am including the real version because the chart was the moment I stopped having any doubt:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from datetime import date
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.sort_values("trade_date")
df["cumulative_pnl"] = df["pnl"].cumsum()
# DST transitions for 2025-2026
dst_starts = [date(2025, 3, 9), date(2026, 3, 8)]
dst_ends = [date(2025, 11, 2), date(2026, 11, 1)]
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(df["trade_date"], df["cumulative_pnl"],
color="#00ff41", linewidth=1.8, label="Cumulative PnL")
for ds in dst_starts:
ax.axvline(pd.Timestamp(ds), color="red", linestyle="--",
alpha=0.7, label="DST Start")
for de in dst_ends:
ax.axvline(pd.Timestamp(de), color="cyan", linestyle="--",
alpha=0.7, label="DST End")
# Shade DST-active periods
for ds, de in zip(dst_starts, dst_ends):
ax.axvspan(pd.Timestamp(ds), pd.Timestamp(de),
alpha=0.08, color="red")
ax.set_facecolor("#0d0d0d")
fig.patch.set_facecolor("#000000")
ax.tick_params(colors="white")
ax.yaxis.label.set_color("white")
ax.xaxis.label.set_color("white")
ax.title.set_color("white")
ax.set_xlabel("Trade Date")
ax.set_ylabel("Cumulative PnL ($)")
ax.set_title("Cumulative PnL vs DST Windows — Weather Bot v2.1 Audit")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.legend(facecolor="#111", labelcolor="white")
plt.tight_layout()
plt.savefig("dst_audit.png", dpi=150, facecolor=fig.get_facecolor())
plt.show()
The chart showed a relatively flat line during the November-to-March window. Then the line dropped every time a DST-active period began. Not gradually. The slope changed direction right at the transition. The shaded red regions were where the money went.
I stared at that chart for about ten minutes before writing a single line of fix code.
The Fix
The fix required understanding what "Local Standard Time year-round" actually means in Python datetime terms. The answer is: do not localize at all. Attach the standard time UTC offset directly and never let DST adjust it.
from datetime import datetime, timezone, timedelta
# UTC offset map for standard time (no DST adjustment, ever)
STATION_STD_UTC_OFFSETS = {
"KMDW": -6, # Chicago Midway (CST)
"KHOU": -6, # Houston Hobby (CST)
"KJFK": -5, # New York JFK (EST)
"KLAX": -8, # Los Angeles LAX (PST)
"KPHX": -7, # Phoenix Sky Harbor (MST, no DST)
"KATL": -5, # Atlanta Hartsfield (EST)
"KDFW": -6, # Dallas Fort Worth (CST)
"KSEA": -8, # Seattle Tacoma (PST)
}
def get_weather_day_window(date_str: str, station: str) -> tuple[datetime, datetime]:
"""
Returns the weather day window in UTC for a given calendar date and station.
Uses Local Standard Time year-round, regardless of DST.
The NWS weather day is midnight-to-midnight LST, always.
"""
std_offset_hours = STATION_STD_UTC_OFFSETS[station]
std_tz = timezone(timedelta(hours=std_offset_hours))
naive_start = datetime.strptime(date_str, "%Y-%m-%d")
# Attach standard time offset directly, no pytz localize
start_lst = naive_start.replace(tzinfo=std_tz)
end_lst = naive_start.replace(hour=23, minute=59, second=59, tzinfo=std_tz)
# Convert to UTC for data fetching
start_utc = start_lst.astimezone(timezone.utc)
end_utc = end_lst.astimezone(timezone.utc)
return start_utc, end_utc
For Chicago on July 15th, this now returns:
- Start:
2026-07-15 06:00:00+00:00(midnight CST in UTC) - End:
2026-07-16 05:59:59+00:00(11:59 PM CST in UTC)
Compare to the old broken version, which returned:
- Start:
2026-07-15 05:00:00+00:00(midnight CDT in UTC, one hour too early) - End:
2026-07-16 04:59:59+00:00(one hour too early throughout)
One hour. That is what it came down to. Eighty-one bad trades, eight months of drift, all because pytz.localize() was doing exactly what it was designed to do, just not what I needed.
Why This Is Easy to Miss
A few things made this bug unusually hard to catch before it ran:
The bot "worked" in winter. During November through March, local clock time and local standard time are the same thing. Tests written in January passed. Manual spot-checks in February passed. The bug only activated when the clocks changed.
The losses looked like noise. A $23 loss over four months across 112 trades is not the kind of number that screams "structural defect." It looks like a bot that needs more signal tuning. I spent time looking at the wrong things before I ran the audit.
Weather data APIs do not tell you which time definition they are using. I queried NOAA ASOS data and got timestamps back in UTC, which I then converted. The conversion step was where the bug lived, not in the source data.
Temperature records near midnight are the most dangerous. A temperature reading at 11:30 PM CDT in July belongs to a different official weather day than it appears to. Miss that, and you are scoring outcomes against forecasts that are not aligned.
The Settlement Station Problem Was Separate
While I was auditing the DST bug, I also confirmed that I had the wrong settlement stations for two major markets. Kalshi settles Chicago temperature contracts on Midway (KMDW), not O'Hare (KORD). Houston settles on Hobby (KHOU), not IAH.
I confirmed the full station list by calling the Kalshi metadata API directly:
import requests
def get_kalshi_settlement_stations(market_ticker_prefix: str) -> dict:
"""
Pulls settlement station info from Kalshi's public market metadata.
Do not guess. Ask the exchange.
"""
url = f"https://api.elections.kalshi.com/trade-api/v2/markets"
params = {"series_ticker": market_ticker_prefix, "limit": 100}
headers = {"accept": "application/json"}
resp = requests.get(url, headers=headers, params=params)
resp.raise_for_status()
markets = resp.json().get("markets", [])
station_map = {}
for m in markets:
subtitle = m.get("subtitle", "")
ticker = m.get("ticker", "")
station_map[ticker] = subtitle
return station_map
That query is public. No authentication required. It tells you exactly what station Kalshi uses to settle each contract. There is no reason to guess, and guessing costs you trades.
What the Rebuild Looks Like Now
The DST fix and the station mapping fix are two of seven defects corrected in the v2.3 rebuild. The others include an xarray truthiness bug, a wrong dictionary key in the Kalshi position fetcher, a stale settlement status in the database, a logging gap that hid all skip reasons, and overconfident probability calibration in the original ensemble.
The rebuilt bot also replaced the custom ensemble model with NOAA's National Blend of Models as the primary forecast source. The ensemble approach was not the core problem, but our calibration was worse than NOAA's free published forecasts, so there was no reason to keep it.
The Weather Bot v2.3 is rebuilt and undergoing validation. It is in paper-trading mode. I am not claiming it is fixed or profitable. What I can say is that the defects found in the audit have been corrected, 49 new automated tests have been added, and the bot is no longer looking at the wrong 24 hours eight months of the year.
The Practical Takeaway
If your code touches weather data and makes decisions based on calendar dates, verify your time window assumptions against the exchange's actual settlement rules before you run anything live.
Not what seems logical. Not what the API returns by default. The actual documented settlement methodology from the exchange.
Local Standard Time year-round is not an obvious convention. It is a specific definition used by the National Weather Service for historical temperature records, and Kalshi's contracts inherit it. I found out by losing trades. You can find out by reading the documentation first.
The bug was one line of code. The audit to find it took longer than building the original bot.
Full source for both the Weather Bot v2.3 and the Econ Bot is available at predictandprofit.gumroad.com. The DST fix, the station map, and the audit pipeline are all in the package. If you have the v2.1 code and want the corrected time window logic, this post has everything you need.