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 wall-clock time.
- My bot was bucketing the wrong 24-hour window for eight months of the year during DST, trading on misaligned forecast data.
- Overlaying trade timestamps with DST transition dates in matplotlib made the loss pattern obvious in about 30 seconds.
- The DST fix is one of seven defects corrected in the Weather Bot v2.3 rebuild, which is currently rebuilt and undergoing validation in paper-trading mode.
I have written production code for 30 years. I have debugged race conditions, corrupted database migrations, and a memorable incident involving a mainframe batch job and the wrong fiscal year. I thought I had seen most of the ways a silent assumption can burn you.
Then I spent four months running a Kalshi weather trading bot and lost money on a bug that any meteorology textbook would have warned me about on page one.
The bug was a time zone assumption. Not even a clever one. I assumed the weather day ran from midnight to midnight in local wall-clock time. That is wrong. It was wrong every single day from March through November. Every losing trade fell inside that window.
Here is exactly what happened.
How Kalshi Settles Temperature Contracts
Kalshi runs temperature markets tied to specific airport weather stations. The question is typically something like "Will the high temperature at Chicago Midway exceed 85°F on July 15?" The settlement value comes from official NOAA ASOS observations at the designated station.
The thing that matters, and that I did not verify until it was too late, is how Kalshi defines a "weather day." The National Weather Service measures daily high and low temperatures using Local Standard Time year-round. Not local time. Not UTC. Standard time, twelve months a year.
In winter that makes no difference. Standard time and local time are the same thing.
In summer, during Daylight Saving Time, they diverge by an hour. The NWS weather day for a summer date in Chicago runs from 1:00 AM CDT to 12:59 AM CDT the following night. If you are bucketing midnight to midnight, you are off by one hour on both ends.
One hour sounds small. It is not. The coolest part of a summer day is typically right around midnight. That is also when temperatures are most volatile relative to the daily high. An hour of boundary slippage on a temperature extreme contract is enough to turn a confident "yes" into a losing "no."
My bot was doing exactly this for eight months.
What the Code Was Doing Wrong
The forecast aggregation logic pulled NOAA grid data and bucketed observations into calendar dates using local wall-clock time. In winter this was fine. In summer it was silently wrong.
Here is a simplified version of the original bucketing function:
from datetime import datetime
import pytz
def get_weather_day_range(target_date: str, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the weather day for a given date.
BUG: Uses local wall-clock time instead of local standard time.
"""
tz = pytz.timezone(timezone_str)
# This gives midnight-to-midnight in local time.
# During DST, this is WRONG. The NWS weather day does not follow DST.
day_start = tz.localize(datetime.strptime(target_date, "%Y-%m-%d"))
day_end = tz.localize(datetime.strptime(target_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59
))
return day_start.astimezone(pytz.utc), day_end.astimezone(pytz.utc)
This looks reasonable. It is wrong.
During CDT (UTC-5), midnight local is 05:00 UTC. But the NWS weather day starts at 1:00 AM CDT, which is 06:00 UTC. My function was starting the window an hour early and ending it an hour early. Every summer day, every affected market.
The fix is to always localize in standard time, then convert to UTC:
from datetime import datetime
import pytz
# Standard time offsets, hardcoded by NWS convention
STANDARD_TIME_ZONES = {
"America/Chicago": "Etc/GMT+6", # CST, UTC-6 always
"America/New_York": "Etc/GMT+5", # EST, UTC-5 always
"America/Denver": "Etc/GMT+7", # MST, UTC-7 always
"America/Los_Angeles": "Etc/GMT+8", # PST, UTC-8 always
"America/Phoenix": "Etc/GMT+7", # MST year-round, no DST
}
def get_weather_day_range(target_date: str, timezone_str: str) -> tuple[datetime, datetime]:
"""
Returns the start and end of the NWS weather day in UTC.
Uses Local Standard Time year-round, per NWS convention.
This is correct.
"""
std_tz_str = STANDARD_TIME_ZONES.get(timezone_str)
if std_tz_str is None:
raise ValueError(f"No standard time zone mapping for {timezone_str}")
std_tz = pytz.timezone(std_tz_str)
# Midnight in standard time = start of NWS weather day
day_start_local = datetime.strptime(target_date, "%Y-%m-%d")
day_end_local = day_start_local.replace(hour=23, minute=59, second=59)
day_start_utc = std_tz.localize(day_start_local).astimezone(pytz.utc)
day_end_utc = std_tz.localize(day_end_local).astimezone(pytz.utc)
return day_start_utc, day_end_utc
One-line conceptual fix, but it touched every part of the forecast pipeline that depended on date bucketing, which was most of it.
How I Found It
The audit process started because the numbers were bad and I wanted to know why. 112 completed trades. Net loss of roughly $23. Brier score of 0.2858 against a baseline of 0.2439. The model was statistically worse than guessing the historical base rate.
That last number is what got me. Not the money. The money was small. It was the fact that my sophisticated multi-model ensemble was being outperformed by doing nothing. That does not happen by accident.
I exported the full trade ledger from PostgreSQL and wrote a quick analysis script. One of the first things I did was plot losses over time with DST transition dates overlaid.
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import date
# DST transitions for the US, 2025-2026 (second Sunday March, first Sunday November)
DST_TRANSITIONS = [
("2025-03-09", "2025-11-02"),
("2026-03-08", "2026-11-01"),
]
def plot_pnl_with_dst(trades_df: pd.DataFrame, output_path: str = "dst_analysis.png"):
fig, ax = plt.subplots(figsize=(14, 6))
# Cumulative P&L line
trades_df = trades_df.sort_values("settled_at")
trades_df["cumulative_pnl"] = trades_df["pnl_cents"].cumsum() / 100
ax.plot(
trades_df["settled_at"],
trades_df["cumulative_pnl"],
color="#00ff41",
linewidth=1.5,
label="Cumulative P&L ($)"
)
# Shade DST windows
for dst_start_str, dst_end_str in DST_TRANSITIONS:
dst_start = pd.Timestamp(dst_start_str)
dst_end = pd.Timestamp(dst_end_str)
ax.axvspan(dst_start, dst_end, alpha=0.15, color="red", label="DST window")
ax.axhline(y=0, color="white", linestyle="--", linewidth=0.8, alpha=0.5)
ax.set_facecolor("#0a0a0a")
fig.patch.set_facecolor("#000000")
ax.tick_params(colors="white")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.set_xlabel("Settlement Date", color="white")
ax.set_ylabel("Cumulative P&L ($)", color="white")
ax.set_title("Weather Bot P&L vs DST Windows", color="white")
ax.legend(facecolor="#111111", labelcolor="white")
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
print(f"Saved: {output_path}")
# Load from DB
import psycopg2
conn = psycopg2.connect("dbname=kalshi_trades user=steve")
trades_df = pd.read_sql(
"SELECT settled_at, pnl_cents FROM trades WHERE status = 'settled' ORDER BY settled_at",
conn
)
trades_df["settled_at"] = pd.to_datetime(trades_df["settled_at"])
plot_pnl_with_dst(trades_df)
The chart came back and the pattern was not subtle. During both DST windows, the cumulative P&L line sloped steadily down. Outside the DST windows, performance was roughly flat. Not good, not a winner, but flat. Inside the DST windows, money left the account on a predictable schedule.
I stared at that chart for a while. Then I went looking for what was different about those date ranges.
The DST connection took about ten minutes to confirm once I was looking for it. I pulled the NWS documentation on temperature observation periods, matched it against my bucketing logic, and the off-by-one-hour error was right there.
Why This Kind of Bug Hides So Well
I want to be specific about why this is so easy to miss, because I suspect it is sitting in other data pipelines right now.
The bug is silent. The code runs. No exceptions. No warnings. The timestamps look reasonable because midnight in CDT is a real time that makes intuitive sense as a day boundary. You would have to know NWS measurement conventions to know it is wrong.
The damage is diffuse. Each individual trade was off by one hour at each boundary. That is not a catastrophic error. It is a systematic one. It compresses only into a pattern when you aggregate enough trades across enough DST days.
The seasonal signal is slow. A pattern that only manifests for eight months out of twelve, and only hurts you a little bit per trade, takes a full cycle to become visible. By the time I had enough trades to see it clearly, the damage was done.
This is the most expensive assumption I have ever made in production code. Not the $23. The four months of bad data. I was generating forecasts against the wrong 24-hour window for every summer market and had no idea.
The Broader Lesson About Exchange Settlement Rules
When you trade a contract that settles against real-world data, you need to know exactly how that settlement is calculated. Not approximately. Exactly.
For Kalshi temperature markets that means:
-
Which station? Not just the city. The specific ASOS station identifier. Chicago settles on Midway (KMDW), not O'Hare (KORD). Houston settles on Hobby (KHOU), not IAH. I found the correct list by querying the Kalshi metadata API directly.
-
Which hours? The NWS weather day in Local Standard Time. Not UTC, not local wall-clock time during summer.
-
Which observation type? ASOS automated surface observations, not manual or synoptic.
If any of those three are wrong, you are trading on a different question than the one the contract is actually asking. You can have the best forecast in the world and still lose money because you are forecasting the wrong thing.
I had the wrong hours. For eight months.
Where the Bot Stands Now
The DST fix is one of seven distinct defects that came out of the four-month audit. The others included a wrong dict key in the Kalshi client that prevented the bot from seeing its own positions, a stale settlement status update in the database, and a calibration problem that produced wildly overconfident probabilities.
The rebuilt system, Weather Bot v2.3, replaces the custom ensemble entirely with NOAA's National Blend of Models as the primary forecast source. NBM is professionally calibrated, bias-corrected, and covers exactly the stations Kalshi settles on. We were hand-rolling a worse version of something NOAA publishes for free.
The bot is rebuilt and undergoing validation in paper-trading mode. I am not claiming it works. I am not claiming the DST fix was the one thing that would have made it profitable. The Brier score audit found that the original model had no predictive skill regardless of the time zone problem. Both things can be true: there was a DST bug, and there was also a bad model. Fixing the bug does not fix the model.
What I know is that the rebuilt system is forecasting the correct 24-hour windows, against the correct stations, using calibrated public forecasts instead of a hand-rolled ensemble that was statistically worse than guessing.
That is the baseline. Validation comes after that.
Practical Takeaways
If you are building anything that trades against real-world settlement data, go verify the settlement rules directly from the exchange, not from what seems logical.
Pull the metadata. For Kalshi it is a simple API call:
import requests
def get_market_settlement_details(ticker: str, api_key: str) -> dict:
url = f"https://trading-api.kalshi.com/trade-api/v2/markets/{ticker}"
headers = {"Authorization": f"Bearer {api_key}"}
resp = requests.get(url, headers=headers)
resp.raise_for_status()
return resp.json().get("market", {})
Read what comes back. All of it. If there is a settlement source field, look up how that source defines its measurement periods.
Do not assume the exchange follows the same time conventions you would use naturally. In this case the convention is a century-old meteorological standard that predates Daylight Saving Time as a concept. The market built around it, not the other way around.
Midnight to midnight feels like a weather day. It is not. Not in summer.
The Predict & Profit Weather Bot v2.3 source code is included in the $97 bundle at predictandprofit.gumroad.com, along with the Econ Bot. The DST fix, the station mapping fix, and all seven corrected defects are in the current codebase. Use REDDIT at checkout for 15% off.
The bot is rebuilt and undergoing validation. If you want to run it yourself, dry-run mode is on by default. No real orders until you explicitly turn it off.