The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on midnight-to-midnight in Local Standard Time year-round, not wall clock time
- During summer, that means the official weather day runs from 1:00 AM to 12:59 AM EDT the following night
- My bot was bucketing the wrong 24 hours for eight months of every year
- A single matplotlib scatter plot overlaying trade outcomes against DST transition dates made the pattern impossible to ignore
The Bug I Should Have Found on Day One
I built the Weather Bot to trade Kalshi temperature markets. The core idea is straightforward: if you can forecast daily high temperatures more accurately than the market is pricing, you have edge. So I built a forecasting ensemble, wired it to the Kalshi API, and let it run.
It ran for four months. It lost $23 across 112 trades. Not catastrophic, but not the point. The point is what the audit revealed.
When I audited the trade database I found that my Brier score was 0.2858. Guessing the historical base rate scores 0.2439. My model was statistically worse than making no prediction at all.
I tore apart the ensemble looking for calibration failures, data quality issues, bad feature engineering. I eventually found seven distinct defects. But one of them was responsible for a disproportionate share of the pain, and it was the dumbest one: I was measuring the wrong 24 hours.
How Kalshi Temperature Contracts Actually Settle
Here is the thing about temperature markets that I assumed rather than verified.
I assumed "daily high temperature" meant midnight to midnight in local time. That is what "daily" means in ordinary English. The weather app on your phone shows it that way. The evening news talks about it that way.
The National Weather Service does not do it that way.
NOAA defines a climate day for temperature records in Local Standard Time year-round. Not local wall clock time. Not UTC. Local Standard Time, always. That means in summer, when your wall clock says midnight but you are in a daylight saving timezone, the official weather day has already been running for an hour.
Concretely, for Chicago in July:
- Wall clock midnight (CDT) = 11:00 PM CST the previous day
- The official NOAA climate day started at 12:00 AM CST = 1:00 AM CDT
- The official NOAA climate day ends at 11:59 PM CST = 12:59 AM CDT the next night
My bot was bucketing 12:00 AM CDT to 11:59 PM CDT. The exchange settles on 1:00 AM CDT to 12:59 AM CDT. Off by exactly one hour, for every day from March to November.
I was forecasting the wrong day. For eight months of every year.
The Code That Was Wrong
Here is a simplified version of what I had:
from datetime import datetime
import pytz
def get_weather_day_bounds(date_str: str, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns (start, end) for the weather day at the given location.
WRONG VERSION: uses wall clock midnight.
"""
tz = pytz.timezone(timezone_str)
# Parse the target date
naive_start = datetime.strptime(date_str, "%Y-%m-%d")
naive_end = naive_start.replace(hour=23, minute=59, second=59)
# Localize to the target timezone
# BUG: this uses DST-aware local time, not standard time
local_start = tz.localize(naive_start)
local_end = tz.localize(naive_end)
return local_start.astimezone(pytz.utc), local_end.astimezone(pytz.utc)
In winter, this is fine. Chicago is on CST (UTC-6) in winter. The NOAA climate day runs midnight to midnight CST. My code produces midnight to midnight CST. They match.
In summer, Chicago switches to CDT (UTC-5). My code produces midnight to midnight CDT. NOAA still uses midnight to midnight CST, which is 1:00 AM CDT to 12:59 AM CDT. They do not match.
The fix requires pinning to standard time offset, not wall clock time:
from datetime import datetime, timedelta, timezone
import pytz
# Standard time UTC offsets by timezone
STANDARD_TIME_OFFSETS = {
"America/Chicago": -6,
"America/New_York": -5,
"America/Denver": -7,
"America/Los_Angeles": -8,
"America/Phoenix": -7, # Arizona: no DST, stays MST year-round
"America/Anchorage": -9,
}
def get_weather_day_bounds(date_str: str, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns (start_utc, end_utc) for the NOAA climate day at the given location.
Uses Local Standard Time year-round, matching NOAA's definition.
"""
offset_hours = STANDARD_TIME_OFFSETS.get(timezone_str)
if offset_hours is None:
raise ValueError(f"No standard time offset configured for {timezone_str}")
std_offset = timezone(timedelta(hours=offset_hours))
# Midnight in standard time = start of NOAA climate day
start_naive = datetime.strptime(date_str, "%Y-%m-%d")
start_std = start_naive.replace(tzinfo=std_offset)
end_std = start_std.replace(hour=23, minute=59, second=59)
# Convert to UTC for comparison against observation timestamps
start_utc = start_std.astimezone(timezone.utc)
end_utc = end_std.astimezone(timezone.utc)
return start_utc, end_utc
One hour difference. Eight months of the year. Every temperature observation near midnight was being assigned to the wrong contract.
How I Found It
I did not find this by reading the NOAA documentation carefully. I found it by making a plot.
After the audit surfaced the Brier score failure, I pulled every completed trade from the database and started looking for patterns. One of the first things I plotted was trade outcome versus trade date.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
# Load trades from database
df = pd.read_csv("trade_audit.csv", parse_dates=["trade_date", "settlement_date"])
df["won"] = (df["outcome"] == "YES_WIN") | (df["outcome"] == "NO_WIN")
# Define approximate DST windows for the audit period
# Spring forward: second Sunday in March
# Fall back: first Sunday in November
dst_windows = [
("2024-03-10", "2024-11-03"),
("2025-03-09", "2025-11-02"),
]
fig, ax = plt.subplots(figsize=(14, 5))
# Plot wins and losses
wins = df[df["won"]]
losses = df[~df["won"]]
ax.scatter(wins["settlement_date"], wins["market_city"],
color="steelblue", alpha=0.6, s=30, label="Won", zorder=3)
ax.scatter(losses["settlement_date"], losses["market_city"],
color="tomato", alpha=0.8, s=40, marker="x", label="Lost", zorder=3)
# Shade DST windows
for dst_start, dst_end in dst_windows:
ax.axvspan(pd.Timestamp(dst_start), pd.Timestamp(dst_end),
alpha=0.12, color="orange", label="DST Window")
ax.set_xlabel("Settlement Date")
ax.set_ylabel("Market City")
ax.set_title("Trade Outcomes vs DST Windows")
ax.legend()
plt.tight_layout()
plt.savefig("dst_pattern.png", dpi=150)
plt.show()
The plot was immediate. The losses clustered inside the orange shaded regions. Wins were distributed more evenly throughout the year. The DST windows lit up red.
I stared at it for about ten seconds before I understood what I was looking at.
Why This Kind of Bug Survives So Long
It does not fail loudly. That is the whole problem.
My bot was still running. Still fetching forecasts. Still placing trades. Still logging outcomes. Everything looked operational. The dashboard showed green. The only signal that something was wrong was the Brier score, and even that was not alarming enough to trigger investigation until I ran the full four-month audit.
The bug also had natural camouflage. Half the year, in winter, my bucketing was correct. Those trades had a better outcome distribution. The summer trades dragged the aggregate numbers down, but not dramatically enough to look like a systematic failure rather than normal variance.
This is the category of bug I fear most: the one that operates silently for months, generating plausible-looking output that is subtly wrong in ways that only compound over time.
In a data pipeline at a corporation, this bug might survive years. Nobody is running a Brier score on the quarterly report.
What the Exchange Actually Uses
After I found the pattern, I went back and read Kalshi's settlement rules carefully. Then I read the NOAA documentation on climate normals and daily temperature records. Then I pulled the actual observation data for a handful of dates and verified manually.
The NOAA documentation is not hidden. It is publicly available. I just had not read it before shipping.
The relevant passage is in the NOAA Climate Normals documentation: daily extremes are computed from Local Standard Time observation windows, year-round.
I had also made a second related mistake on station mapping. Kalshi settles Chicago temperature contracts on Midway airport, not O'Hare. Houston settles on Hobby, not Bush Intercontinental. I caught that one separately. But the DST bug and the station mapping bug were both rooted in the same failure mode: I made assumptions about exchange rules instead of reading the exchange documentation.
After v2.3, every settlement station is verified against Kalshi's metadata API and pinned in tests. The DST bucketing is now locked to standard time offsets stored in a config table with one entry per timezone, each with a comment linking to the NOAA specification that justifies it.
# config/timezone_offsets.py
# Standard time UTC offsets for NOAA climate day bucketing.
# Source: https://www.ncei.noaa.gov/products/land-based-station/us-climate-normals
# NOAA defines climate days in Local Standard Time year-round.
# Do NOT use pytz.localize() for these boundaries -- it will apply DST automatically.
STANDARD_OFFSETS = {
# (city, kalshi_station_id): utc_offset_hours
("Chicago", "KMDW"): -6, # Midway, CST
("Houston", "KHOU"): -6, # Hobby, CST
("New York", "KLGA"): -5, # LaGuardia, EST
("Los Angeles", "KLAX"): -8, # LAX, PST
("Phoenix", "KPHX"): -7, # PHX, MST (no DST in Arizona)
("Atlanta", "KATL"): -5, # Hartsfield, EST
("Miami", "KMIA"): -5, # MIA, EST
("Denver", "KDEN"): -7, # DEN, MST
}
There is a test that asserts this table is populated for every active market in the bot's scanner. If I add a new city and forget to add its offset, the test fails before the code ships.
The Deeper Lesson
I have 30 years of software development experience. I have built data pipelines at scale for companies that process millions of records a day. I know that time zones are a swamp. I know that "midnight" is not a universal concept. I know these things intellectually.
I still made this mistake.
The reason is that I was moving fast on a side project and I defaulted to what seemed logical rather than what was specified. "Daily high temperature" seems like it should mean the calendar day in local time. That is the intuitive interpretation. The intuitive interpretation is wrong.
Every financial instrument settles against a specific, documented rule. That rule exists because ambiguity costs money when disputes arise. The exchange has read their own rules. Your code should too.
I now have a checklist item for any new market I add to the scanner: locate the settlement specification, find the exact time window definition, write it down in the code as a comment with a source link, and write a test that verifies a known historical settlement against the documented rule.
This sounds obvious. I did not do it. The bot ran for eight months before I noticed.
Where the Bot Is Now
The v2.3 Weather Bot is in paper trading mode. The DST bug is fixed. The station mapping is verified. The forecasting model is rebuilt around NOAA's National Blend of Models, which publishes calibrated, bias-corrected temperature forecasts for free, for exactly the stations Kalshi uses.
It is not validated. I am not claiming it works. The rebuild fixed seven defects and the current paper trading period will tell me whether the underlying strategy has any edge once the technical failures are removed.
What I can say is that every losing trade in the four-month audit fell inside a DST window. Whether that is the whole explanation or just a major contributor, I will know once the paper trading data accumulates.
The most expensive assumption I have ever made in production code was that "daily" meant what I thought it meant. It cost about $23 and eight months of bad data. In a production financial system at a real company, that same assumption could cost considerably more and survive considerably longer.
Read the settlement spec. Then read it again. Then write a test that proves your code matches it.
The full Weather Bot post-mortem, including all seven defects found and the Brier score analysis, is documented at predictandprofit.io. The bot source code is available as a one-time purchase. Weather Bot status: rebuilt, in paper trading, not yet validated.