The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months
TL;DR / Key Takeaways
- Kalshi temperature contracts settle on a 24-hour window defined in Local Standard Time year-round, not wall clock time.
- My bot was bucketing the wrong hours during DST summer months, feeding forecasts for the wrong day into every trade decision.
- A matplotlib scatter plot overlaying trade outcomes against DST transition dates made the pattern obvious in about 30 seconds.
- The Predict & Profit Weather Bot v2.3 has been rebuilt with correct DST handling and is now undergoing paper-trading validation.
When I audited 112 completed trades from my Kalshi weather bot and found a net loss of roughly $23, I expected to find something subtle. A signal that was slightly miscalibrated. A threshold that needed tuning. Maybe the ensemble was too noisy.
What I found instead was that for eight months out of every year, my bot was forecasting the wrong day entirely.
That is not a subtle miscalibration. That is a fundamental data alignment failure. Every decision the bot made between mid-March and early November was built on a misread of what 24-hour window Kalshi was actually settling.
This post is about that bug. How it worked, how I found it, and exactly what the fix looks like.
How Kalshi Temperature Contracts Settle
Kalshi temperature markets settle on whether the high or low temperature at a specific airport exceeds a given threshold on a specific date. Simple enough.
The part I assumed incorrectly: what "a specific date" means.
The National Weather Service defines an official weather day in Local Standard Time, year-round. Not local wall clock time. Not UTC. Local Standard Time, always.
For a city like Chicago (CST is UTC-6, CDT is UTC-5), the official weather day runs from midnight LST to 11:59 PM LST. In winter that lines up with the clock on the wall. In summer, when clocks have sprung forward an hour, the official weather day runs from 1:00 AM CDT to 12:59 AM CDT the following morning.
So if you are building a system that pulls hourly temperature observations and groups them into calendar days, and you group by the local wall clock date, you get the wrong 24-hour window for roughly eight months of the year.
That is exactly what my bot was doing.
The Code That Was Wrong
My observation fetching pipeline pulled hourly ASOS data and bucketed readings into calendar dates using Python's .date() method on a timezone-aware datetime. The problem is .date() extracts the local calendar date, which follows DST.
# WRONG: This buckets by wall clock date, not LST date
def get_daily_high(observations: list[dict], target_date: date) -> float:
readings = [
obs["temperature_f"]
for obs in observations
if obs["timestamp"].astimezone(LOCAL_TZ).date() == target_date
]
return max(readings) if readings else float("nan")
During winter this works. The wall clock date and the LST date are the same thing.
During summer, when LOCAL_TZ is CDT (UTC-5), a reading at 12:30 AM CDT on July 15 gets bucketed into July 15. But the NWS weather day for July 14 runs until 12:59 AM CDT. That 12:30 AM reading belongs to July 14's official record, not July 15.
The fix is to strip DST offset before extracting the date. You convert the timestamp to standard time first, then extract the date.
from datetime import datetime, timezone, timedelta
import pytz
# The standard time offset for each station's timezone (no DST adjustment)
STANDARD_OFFSETS = {
"KMDW": timedelta(hours=-6), # Chicago Midway: CST always
"KHOU": timedelta(hours=-6), # Houston Hobby: CST always
"KATL": timedelta(hours=-5), # Atlanta: EST always
"KLAX": timedelta(hours=-8), # Los Angeles: PST always
"KJFK": timedelta(hours=-5), # New York: EST always
"KPHX": timedelta(hours=-7), # Phoenix: MST always (no DST in AZ)
}
def to_lst(dt: datetime, station: str) -> datetime:
"""Convert a UTC datetime to Local Standard Time for a given station.
LST never observes DST. This is what the NWS uses for weather day boundaries.
"""
offset = STANDARD_OFFSETS[station]
return dt.astimezone(timezone.utc).replace(tzinfo=None) + offset
def get_daily_high(observations: list[dict], target_date: date, station: str) -> float:
# CORRECT: bucket by LST date, not wall clock date
readings = [
obs["temperature_f"]
for obs in observations
if to_lst(obs["timestamp"], station).date() == target_date
]
return max(readings) if readings else float("nan")
One function. The entire bug lives in how you convert the timestamp before calling .date().
How I Found It
I was not looking for a DST bug. I was building a visualization to understand why wins and losses were distributed the way they were. My working theory was that the bot was overtrading on volatile days.
I pulled the full trade ledger from the database and built a scatter plot: trade outcome (win/loss, sized by contract price) on the y-axis, trade date on the x-axis, with DST transition dates marked as vertical lines.
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
# Load trade log from database
df = pd.read_sql("""
SELECT
trade_date,
outcome,
contract_price,
profit_loss
FROM trades
WHERE status = 'settled'
ORDER BY trade_date
""", conn)
df["trade_date"] = pd.to_datetime(df["trade_date"])
df["win"] = df["outcome"] == "win"
# DST transitions in 2025-2026
dst_starts = pd.to_datetime(["2025-03-09", "2026-03-08"])
dst_ends = pd.to_datetime(["2025-11-02", "2026-11-01"])
fig, ax = plt.subplots(figsize=(14, 5))
wins = df[df["win"]]
losses = df[~df["win"]]
ax.scatter(wins["trade_date"], wins["profit_loss"],
c="#00ff41", s=wins["contract_price"] * 3,
alpha=0.7, label="Win", zorder=3)
ax.scatter(losses["trade_date"], losses["profit_loss"],
c="#ff4444", s=losses["contract_price"] * 3,
alpha=0.7, label="Loss", zorder=3)
for ds in dst_starts:
ax.axvline(ds, color="yellow", linestyle="--", linewidth=1.2,
label="DST Start" if ds == dst_starts[0] else "")
for de in dst_ends:
ax.axvline(de, color="orange", linestyle="--", linewidth=1.2,
label="DST End" if de == dst_ends[0] else "")
ax.axhline(0, color="white", linewidth=0.5, linestyle=":")
ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.xaxis.set_major_locator(mdates.MonthLocator())
plt.xticks(rotation=45)
ax.set_facecolor("#111111")
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_title("Trade Outcomes vs DST Windows")
ax.set_ylabel("Profit / Loss ($)")
ax.legend(facecolor="#222222", labelcolor="white")
plt.tight_layout()
plt.savefig("dst_trade_scatter.png", dpi=150, bbox_inches="tight")
I ran this expecting to see noise. What I saw instead: the red dots clustered almost entirely between the yellow lines. Between DST start and DST end, the summer window, losses dominated. Outside that window, results were roughly even.
I stared at it for a minute. Then I went and looked up how the NWS defines an official weather day.
That is when I found the LST rule. That is when I understood what had been happening.
Why This Is Expensive in Prediction Markets
In a prediction market, you are not just wrong, you are wrong with a contract that expires. There is no "hold and wait for recovery."
My bot was pulling a 24-hour temperature window that was off by one hour in summer. That means the daily high I was computing could include the wrong early-morning reading or exclude the correct one. For markets that settle within a few tenths of a degree of the threshold, being off by one hour of observations is enough to flip the outcome.
The bot was confident. The confidence came from a forecast that lined up well with my computed historical baseline. But my baseline was built on the same broken bucketing logic. So the model was consistently forecasting the wrong thing and the historical calibration confirmed it, because both sides of the comparison were wrong in the same direction.
That is the worst kind of bug. It does not crash. It does not throw an error. It just quietly produces plausible-looking numbers that are slightly wrong in a way that only shows up in your trade P&L.
Verifying the Correct Settlement Stations
While I was in there, I also verified that I had the right airports to begin with.
I was bucketing temperature data for Chicago using O'Hare (KORD). Kalshi actually settles Chicago temperature contracts on Midway (KMDW). Similar issue with Houston: I was using IAH, Kalshi uses Hobby (KHOU).
I confirmed the full list by querying Kalshi's own metadata API rather than guessing:
import httpx
def get_settlement_stations(event_ticker: str, client_headers: dict) -> list[str]:
"""Pull settlement details from Kalshi's market metadata."""
url = f"https://trading-api.kalshi.com/trade-api/v2/events/{event_ticker}"
resp = httpx.get(url, headers=client_headers)
resp.raise_for_status()
event = resp.json().get("event", {})
# Settlement rules are embedded in the event description and strike metadata.
# Parse the station identifiers from the settlement_sources field if present,
# or extract ICAO codes from the event description text.
return event.get("settlement_sources", [])
If the settlement source is not explicitly in the API response, the event description text contains the station name. Parse it. Do not assume O'Hare because it is the more famous airport.
What the Rebuilt Bot Does Differently
The v2.3 rebuild addresses this in two places.
First, all timestamp conversions now go through the to_lst() function above before any date bucketing. There is no .date() call anywhere in the pipeline that is not preceded by a standard-time conversion.
Second, I added a startup validation check that pulls one week of historical ASOS data for each configured station, applies both the old and new bucketing logic, and logs any day where the computed daily high differs between the two methods. During DST months, you see differences on roughly a third of days. That is how you know the logic matters.
def validate_dst_handling(station: str, days: int = 7) -> None:
"""Smoke test: compare wall-clock vs LST bucketing for recent dates.
Logs a warning if any day shows a different daily high under the two methods.
"""
observations = fetch_recent_asos(station, days=days)
mismatches = 0
for d in recent_dates(days):
high_wall = get_daily_high_wall_clock(observations, d, station)
high_lst = get_daily_high(observations, d, station)
if abs(high_wall - high_lst) > 0.01:
mismatches += 1
logger.warning(
"DST mismatch on %s for %s: wall=%.2f lst=%.2f",
station, d, high_wall, high_lst
)
if mismatches:
logger.warning(
"%d/%d days showed DST bucketing differences for %s. "
"DST handling is active and required for this station.",
mismatches, days, station
)
else:
logger.info("DST validation passed for %s (no differences in %d days).", station, days)
This runs at bot startup. If you ever swap in a new station or change the timezone logic, you will see it immediately rather than eight months later in your P&L.
The Status of the Bot Now
The Weather Bot v2.3 is rebuilt and undergoing validation. Paper trading only. The DST fix is in, the station mapping is correct, and there are 49 new automated tests covering the date bucketing logic among other things.
Whether the rebuilt system actually finds edge against Kalshi's markets is a separate question that requires live data and completed trades to answer. That takes time. I am not going to pretend otherwise.
The Practical Takeaway
Time zone bugs are the cockroaches of data engineering. They hide in code that runs fine for months, produce results that look plausible, and only reveal themselves when you overlay your outcomes against a calendar.
Any time your system interprets a 24-hour window, verify what the data provider means by a day. Not what seems logical. Not what your local clock shows. What the specification actually says.
For Kalshi temperature markets, the specification is the National Weather Service definition of an official weather day: midnight to midnight in Local Standard Time, always, regardless of what time your clock says it is outside.
If I had read that one paragraph in the NWS documentation before writing the first line of the forecasting pipeline, I would have saved four months of paper losses and one very uncomfortable audit.