< Back to Blog

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 in Local Standard Time year-round, not wall clock time, which means during DST the settlement window shifts by a full hour relative to UTC.
  • My bot was bucketing the wrong 24 hours for eight months of the year, producing forecasts for the wrong weather day on every DST-affected trade.
  • A matplotlib scatter plot overlaying trade outcomes against DST transition dates made the pattern obvious in about 90 seconds.
  • 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 am going to tell you about the most expensive assumption I have ever made in production code.

It was not a complex assumption. It was not buried in some obscure edge case. It was the kind of assumption every engineer makes without thinking: that "today" means midnight to midnight in local time.

That assumption was wrong. It cost me eight months of bad trades and a $23 loss across 112 completed contracts. And the worst part is that the fix was one function rewrite.


How Kalshi Temperature Contracts Actually Settle

Before I get into the bug, you need to understand how the exchange actually defines a weather day.

Kalshi's temperature markets settle based on official NWS temperature records at specific airport stations. Chicago settles on Midway (MDW), not O'Hare. Houston settles on Hobby (HOU), not IAH. That part I eventually got right.

What I got wrong was the time window.

The National Weather Service defines an official weather day in Local Standard Time year-round. Not local time. Not wall clock time. Standard time. Always.

For most of the US, that means the official weather day runs from midnight LST to 11:59 PM LST. During winter, when local time and standard time match, this is invisible. During summer, when clocks spring forward, the official weather day runs from 1:00 AM local time to 12:59 AM the following morning.

Read that again. The official weather day during daylight saving time starts at 1 AM wall clock and ends at 12:59 AM the next wall clock day.

My bot was reading UTC timestamps, converting to local wall clock time, and bucketing by calendar date. During summer, that meant every single temperature reading between midnight and 1:00 AM was getting assigned to the wrong day. My 24-hour window was shifted by exactly one hour, every day, from March through November.

I was forecasting the wrong day and did not know it.


What the Bug Looked Like in Code

Here is a simplified version of the original bucketing logic. This is the pattern that was wrong:

from datetime import datetime
import pytz

def get_weather_day(utc_timestamp: datetime, station_tz: str) -> str:
    """
    Convert a UTC observation timestamp to a local date string.
    WRONG: This uses wall clock local time, not Local Standard Time.
    """
    local_tz = pytz.timezone(station_tz)
    local_dt = utc_timestamp.astimezone(local_tz)
    return local_dt.strftime("%Y-%m-%d")

Looks fine. It is not fine.

During CDT (Central Daylight Time), a UTC timestamp of 2026-06-15 05:30:00Z converts to 2026-06-15 00:30:00 CDT. My function returns 2026-06-15. The bot forecasts June 15th.

But in CST (the NWS definition of the weather day), that same UTC timestamp is 2026-06-14 23:30:00 CST. The official weather day is June 14th.

My forecast was for June 15th. The settlement was computed on June 14th. Off by one day, eight months a year, on every affected station.

The corrected version:

from datetime import datetime
import pytz

# Mapping from Kalshi station to its standard time zone.
# Use the non-DST zone explicitly. Do not use America/Chicago.
# Use America/Chicago CST (UTC-6) equivalent logic.
STATION_STANDARD_OFFSETS = {
    "MDW": -6,   # Chicago Midway: CST = UTC-6
    "HOU": -6,   # Houston Hobby: CST = UTC-6
    "BOS": -5,   # Boston Logan: EST = UTC-5
    "DEN": -7,   # Denver Intl: MST = UTC-7
    "LAX": -8,   # Los Angeles: PST = UTC-8
    "JFK": -5,   # New York JFK: EST = UTC-5
    "PHX": -7,   # Phoenix: MST = UTC-7 (Arizona never observes DST)
    "SEA": -8,   # Seattle: PST = UTC-8
    "MIA": -5,   # Miami: EST = UTC-5
    "ORD": -6,   # Chicago O'Hare: CST = UTC-6 (if used)
}

def get_weather_day_lst(utc_timestamp: datetime, station_id: str) -> str:
    """
    Convert a UTC observation timestamp to the NWS weather day string.
    Uses Local Standard Time year-round, ignoring DST.
    This matches how Kalshi temperature contracts settle.
    """
    if station_id not in STATION_STANDARD_OFFSETS:
        raise ValueError(f"Unknown station: {station_id}. Add it to STATION_STANDARD_OFFSETS.")

    offset_hours = STATION_STANDARD_OFFSETS[station_id]
    from datetime import timezone, timedelta
    lst_tz = timezone(timedelta(hours=offset_hours))
    lst_dt = utc_timestamp.astimezone(lst_tz)
    return lst_dt.strftime("%Y-%m-%d")

The key difference: instead of pytz.timezone("America/Chicago"), which respects DST transitions, I compute the offset using the fixed standard time offset. No DST. Ever. Because that is how NWS defines the weather day.

Phoenix is an interesting edge case. Arizona does not observe DST, so America/Phoenix is always UTC-7 and the bug never manifested there. That station would have been a signal if I had been paying attention to per-station win rates.


How I Found It

The audit started because the overall numbers were bad. 112 completed trades. Net loss of roughly $23. Brier score of 0.2858, where simply guessing the historical base rate scores 0.2439. Our model was statistically worse than making no prediction at all.

I started by pulling every completed trade from the database with its outcome, the bot's probability estimate, and the UTC timestamp of the trade decision.

import pandas as pd
import sqlite3

conn = sqlite3.connect("trades.db")

df = pd.read_sql_query("""
    SELECT
        t.trade_id,
        t.station_id,
        t.contract_side,
        t.bot_probability,
        t.kalshi_market_price,
        t.outcome,        -- 1 = win, 0 = loss
        t.decision_utc,
        t.settlement_date
    FROM trades t
    WHERE t.status = 'settled'
    ORDER BY t.decision_utc
""", conn)

df["decision_utc"] = pd.to_datetime(df["decision_utc"], utc=True)
df["month"] = df["decision_utc"].dt.month
df["is_dst_month"] = df["month"].isin([3, 4, 5, 6, 7, 8, 9, 10, 11])

Then I plotted it.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import numpy as np

fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

# Top panel: win/loss scatter by timestamp
ax1 = axes[0]
wins = df[df["outcome"] == 1]
losses = df[df["outcome"] == 0]

ax1.scatter(wins["decision_utc"], wins["bot_probability"],
            color="green", alpha=0.6, s=30, label="Win")
ax1.scatter(losses["decision_utc"], losses["bot_probability"],
            color="red", alpha=0.6, s=30, label="Loss")

# Shade DST periods
dst_start_2026 = pd.Timestamp("2026-03-08", tz="UTC")
dst_end_2026 = pd.Timestamp("2026-11-01", tz="UTC")
dst_start_2025 = pd.Timestamp("2025-03-09", tz="UTC")
dst_end_2025 = pd.Timestamp("2025-11-02", tz="UTC")

for start, end in [(dst_start_2025, dst_end_2025), (dst_start_2026, dst_end_2026)]:
    ax1.axvspan(start, end, alpha=0.1, color="orange", label="DST Active" if start == dst_start_2025 else "")

ax1.set_ylabel("Bot Probability Estimate")
ax1.set_title("Trade Outcomes vs. Time — DST Periods Shaded Orange")
ax1.legend(loc="upper left")
ax1.set_ylim(0, 1)

# Bottom panel: rolling win rate (20-trade window)
ax2 = axes[1]
df_sorted = df.sort_values("decision_utc").copy()
df_sorted["rolling_winrate"] = df_sorted["outcome"].rolling(20, min_periods=5).mean()

ax2.plot(df_sorted["decision_utc"], df_sorted["rolling_winrate"],
         color="white", linewidth=1.5, label="20-trade rolling win rate")
ax2.axhline(0.5, color="gray", linestyle="--", alpha=0.5, label="Break-even")

for start, end in [(dst_start_2025, dst_end_2025), (dst_start_2026, dst_end_2026)]:
    ax2.axvspan(start, end, alpha=0.1, color="orange")

ax2.set_ylabel("Rolling Win Rate")
ax2.set_xlabel("Trade Date (UTC)")
ax2.set_ylim(0, 1)
ax2.legend(loc="upper left")

ax2.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
fig.autofmt_xdate()

plt.tight_layout()
plt.savefig("dst_audit.png", dpi=150, facecolor="#111111")
plt.show()

The chart was not subtle. The rolling win rate collapsed every time the DST shading turned on and recovered in November. It looked like a heartbeat where every summer beat was missing.

I stared at it for about 30 seconds before I knew exactly what had happened.


Why This Bug Hides So Well

The reason this kind of bug survives in production for eight months is that it looks like noise.

A date bucketing error that is off by one hour does not produce crashes. It does not produce obvious data gaps. It produces forecasts that are subtly wrong in a way that looks like normal variance.

The bot was still trading. It was still producing probability estimates. The numbers were in a plausible range. There were wins mixed in with losses. Without the time-series visualization and DST overlay, I would have spent months tuning the forecast model, which was not the problem.

The problem was that I was forecasting the right temperature for the wrong day.

A few other things that helped the bug hide:

Winter trades were fine. November through early March, local time equals standard time. No offset. Those trades performed reasonably. That muddied the aggregate numbers enough to make it look like general model noise rather than a systematic defect.

High confidence losses looked like unlucky tails. When the model said 90% probability and lost, I assumed it was in the unlucky 10%. It was not. It was forecasting the wrong day with high confidence.

I trusted the timestamp logic. Date and time handling is the kind of thing you write once, convince yourself it works on the first test case, and never look at again. astimezone() to local time followed by strftime("%Y-%m-%d") looks obviously correct. It is not obviously wrong until you know the settlement rules.


The Lesson Is Not Specific to Weather

Every time you ingest a timestamp and map it to a "day" or "period," you are making an assumption about which timezone defines that boundary. That assumption is almost always implicit. It is almost never documented in the code.

In most production systems, this does not matter. A log event timestamped one hour off is not a disaster.

In a system where the "day" boundary determines whether your model is predicting the right thing, it matters completely.

Before you write another strftime("%Y-%m-%d") on a UTC timestamp in a trading context, ask yourself: whose definition of "day" does the exchange use? Is it wall clock local time? Standard time? UTC? What happens at DST transitions? What happens at year boundaries?

Then go read the exchange's actual settlement documentation. Not a blog post about it. Not a Stack Overflow answer. The actual spec.

I did not do that. I assumed. It cost me eight months.


Where the Bot Stands Now

The DST fix was one of seven defects we found and fixed in the v2.3 rebuild. The others included wrong settlement station mappings, an xarray truthiness error, a wrong dictionary key in the Kalshi position fetch that made the bot blind to its own holdings, a stale settlement status in the database, and overconfident probability calibration from a custom ensemble that turned out to have no predictive skill.

Weather Bot v2.3 is rebuilt and undergoing validation. It is in paper-trading mode and has not been validated for live trading. The DST bucketing logic is fixed. Whether the rebuilt system actually finds edge in these markets is a separate question, and one that needs 100+ completed trades to answer.

The Econ Bot is running live. The Weather Bot is being watched carefully.

If you want to see the full source, including the corrected get_weather_day_lst() function and the full audit pipeline, it ships with the bot package at $97. The post-mortem is not marketing. It is documentation.

The DST bug was the most expensive assumption I ever made. I wrote it in one line and did not look at it for eight months. Check your time assumptions before they cost you more than $23.