< Back to Blog

The Daylight Saving Time Bug That Broke My Weather Bot for 8 Months

TL;DR / Key Takeaways

  • The National Weather Service defines a weather "day" in Local Standard Time year-round, not local clock time. In summer, that window is 1:00 AM to 12:59 AM the next calendar day.
  • My bot bucketed the wrong 24 hours for 8 months because I assumed midnight-to-midnight like a reasonable person would.
  • When I overlaid my losing trades against DST transition dates on a matplotlib chart, the pattern was immediate and embarrassing.
  • The fix was 4 lines. The lesson cost $23 and eight months of bad data.

I found this bug the same way I find most serious bugs: by refusing to accept a vague answer.

The Weather Bot had been running for four months. I pulled the full trade database for the post-mortem audit. 112 completed trades. Net loss of roughly $23. Not catastrophic, but the Brier score was 0.2858, which means the model was statistically worse than ignoring the model entirely and just guessing the historical base rate (0.2439). That's the kind of result that means something is structurally wrong, not just unlucky.

I started tagging every losing trade by city, by market type, by model confidence bucket. Then I tagged them by date and plotted them on a calendar.

I didn't expect what came back.

What the Chart Showed

Here's the matplotlib visualization I built to overlay the losing trades against DST windows:

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import pandas as pd

# Load trade results from local DB
df = pd.read_csv("trade_audit_112.csv", parse_dates=["trade_date"])
df["month"] = df["trade_date"].dt.month

# DST is active April through October in US cities
dst_months = [4, 5, 6, 7, 8, 9, 10]
df["in_dst"] = df["month"].isin(dst_months)

# Group win rate by DST window
win_rates = df.groupby("in_dst")["won"].mean()

fig, axes = plt.subplots(1, 2, figsize=(12, 5))

# Left: win rate inside vs outside DST
axes[0].bar(
    ["Standard Time", "Daylight Saving Time"],
    [win_rates[False], win_rates[True]],
    color=["#2ecc71", "#e74c3c"]
)
axes[0].axhline(0.5, linestyle="--", color="gray", label="Breakeven")
axes[0].set_title("Win Rate by DST Window")
axes[0].set_ylabel("Win Rate")
axes[0].legend()

# Right: monthly loss heatmap
monthly = df.groupby("month")["pnl"].sum()
colors = ["#e74c3c" if v < 0 else "#2ecc71" for v in monthly]
axes[1].bar(monthly.index, monthly.values, color=colors)
axes[1].set_title("PnL by Month")
axes[1].set_xlabel("Month")
axes[1].set_ylabel("Net PnL ($)")

# Shade DST months
for month in dst_months:
    axes[1].axvspan(month - 0.5, month + 0.5, alpha=0.1, color="red")

dst_patch = mpatches.Patch(color="red", alpha=0.3, label="DST Active")
axes[1].legend(handles=[dst_patch])

plt.tight_layout()
plt.savefig("dst_trade_audit.png", dpi=150)
plt.show()

The left chart showed a win rate near 50% during DST months and noticeably better during standard time. The right chart showed every month of net loss falling squarely in the shaded DST window.

That's not noise. That's a systematic error with a calendar trigger.

The Actual Settlement Rule I Ignored

I went back and read Kalshi's settlement documentation. Then I went and read the National Weather Service documentation it references. Here's the thing I should have read before writing any forecast code:

The NWS defines a climate "day" for temperature records as the 24 hours ending at midnight Local Standard Time, year-round.

In winter, that's fine. Local Standard Time is what the clock says.

In summer, when we've sprung forward by an hour, the official NWS weather day doesn't shift with the clock. It runs from 1:00 AM local clock time to 12:59 AM the following calendar day (local clock time). The day ends at what the clock calls midnight, but that's 11:00 PM in standard time, so the NWS day actually overflows into the next calendar date by an hour.

My bot was doing this:

# WRONG: assumes calendar midnight-to-midnight
def get_forecast_window(target_date: date) -> tuple[datetime, datetime]:
    start = datetime.combine(target_date, time(0, 0), tzinfo=local_tz)
    end = datetime.combine(target_date, time(23, 59, 59), tzinfo=local_tz)
    return start, end

That seems completely reasonable. It is completely wrong.

What I needed was this:

from zoneinfo import ZoneInfo
from datetime import date, datetime, time, timedelta

def get_forecast_window(
    target_date: date,
    station_tz: str
) -> tuple[datetime, datetime]:
    """
    Returns the NWS climate day window for a given date and station timezone.

    The NWS defines a climate day as midnight-to-midnight in Local Standard
    Time, year-round. During DST, this does NOT align with the local clock.
    The window must be computed in standard time and then converted.
    """
    tz = ZoneInfo(station_tz)

    # Build the window in standard time by using January 1 as the DST-free
    # anchor, then extract the UTC offset for standard time only.
    # Simpler approach: use the non-DST offset directly.
    std_offset = _get_standard_offset(station_tz)

    # Midnight LST = midnight UTC minus the standard offset
    start_utc = datetime(
        target_date.year,
        target_date.month,
        target_date.day,
        0, 0, 0,
        tzinfo=ZoneInfo("UTC")
    ) + std_offset

    end_utc = start_utc + timedelta(hours=24)

    return start_utc.astimezone(tz), end_utc.astimezone(tz)


def _get_standard_offset(tz_name: str) -> timedelta:
    """
    Returns the standard time (non-DST) UTC offset for a given IANA timezone.
    Uses January 1 as a guaranteed standard-time date for US timezones.
    """
    tz = ZoneInfo(tz_name)
    winter_dt = datetime(2024, 1, 15, 12, 0, tzinfo=tz)
    offset = winter_dt.utcoffset()
    return -offset  # We want the magnitude to add back to UTC

The difference in practice: during Chicago's summer, my old code fetched temperatures from midnight to 11:59 PM local time. The NWS settlement window actually runs from 1:00 AM to 12:59 AM the next day. I was systematically off by one hour, clipping the end of the previous day's data and including data that didn't belong.

For a temperature max/min calculation, being off by an hour at the edges of the window is exactly when the daily extremes often occur. Morning lows. Late-night lows. The data I was chopping off mattered.

How I Confirmed It Wasn't a Coincidence

Before I called this a root cause, I wanted to verify the correlation wasn't just a seasonal weather pattern driving different forecast difficulty in summer. That's a legitimate alternative explanation.

I pulled the NWS forecast verification data for the same stations and date ranges. Forecast difficulty (measured by spread in ensemble members) was not significantly higher in summer months for daily high and low temperatures. Temperature forecast skill degrades more in spring and fall transition periods than in stable summer.

The pattern in my data was calendar-aligned to DST transitions specifically, not to summer broadly.

I also checked: did my losing trades cluster near the DST start and end dates themselves? Yes. The months immediately after the spring-forward (April, May) and before the fall-back (September, October) showed the highest loss concentration. That's exactly when you'd expect the bucketing error to compound with real meteorological uncertainty.

I ran a quick chi-square test to confirm the DST/non-DST split wasn't random:

from scipy.stats import chi2_contingency
import numpy as np

# Contingency table: [wins, losses] for [standard_time, dst]
table = np.array([
    [dst_false_wins, dst_false_losses],
    [dst_true_wins, dst_true_losses]
])

chi2, p_value, dof, expected = chi2_contingency(table)
print(f"Chi-square: {chi2:.4f}, p-value: {p_value:.4f}")

p-value came back at 0.031. Not overwhelming, but with 112 trades you're not going to get a 0.001. It was enough to call it.

The DST Bug Was Not the Only Problem

I want to be honest about this because it would be easy to write a clean narrative where one bug explains everything.

The full audit found 7 distinct defects. The DST bucketing was one of them. Another was that the model was fundamentally overconfident: producing 95%+ probability estimates on markets that were genuine coin flips. That's a calibration problem that exists independent of whether the input data was bucketed correctly.

The DST bug corrupted the training signal for the ensemble. But even with correct data, the custom ensemble's probability outputs were not trustworthy. Fixing the bucketing would have given the model better inputs. It would not have fixed what the model did with them.

That's why the rebuild switched to NOAA's National Blend of Models entirely. NOAA already publishes professionally calibrated, bias-corrected temperature forecasts for exactly the stations Kalshi settles on. For free. We were hand-rolling a worse version of a public good, feeding it corrupted data through a DST bug, and wondering why the results were bad.

The DST bug is a satisfying story because it has a clean cause-and-effect shape. The real story is messier: a cascade of assumptions, each one individually defensible, that combined into a system that couldn't beat random.

The Stations Are Now Pinned and Verified

One other thing I fixed during this audit: I verified every settlement station against Kalshi's public metadata API instead of assuming I knew the right airport.

Chicago settles on Midway (KMDW), not O'Hare (KORD). Houston settles on Hobby (KHOU), not Bush Intercontinental (KIAH). These are not the airports most people think of when they think of those cities. They're the ones in the exchange metadata.

def verify_settlement_stations(client: KalshiClient) -> dict[str, str]:
    """
    Pulls settlement station IDs from Kalshi's market metadata and
    returns a mapping of city -> ICAO station code.
    Raises ValueError if any known city maps to an unexpected station.
    """
    markets = client.get_markets(category="Climate and Weather")
    station_map = {}

    for market in markets:
        if market.get("settlement_source") == "NWS":
            city = market["city"]
            station = market["settlement_station"]
            station_map[city] = station

    # Hard-coded known-good values as a sanity check
    expected = {
        "Chicago": "KMDW",
        "Houston": "KHOU",
    }

    for city, expected_station in expected.items():
        actual = station_map.get(city)
        if actual != expected_station:
            raise ValueError(
                f"Station mismatch for {city}: "
                f"expected {expected_station}, got {actual}"
            )

    return station_map

This runs at bot startup now. If Kalshi ever changes a settlement station, the test fails loudly instead of silently trading against the wrong location for months.

The Practical Lesson

Time zones are the oldest unsolved problem in software. Every engineer knows this. We've all been burned by it. We write the code, it seems to work, we move on.

The failure mode here is not "I didn't know about DST." The failure mode is "I didn't ask what standard the exchange actually uses." Those are different things. One is ignorance. The other is assumption.

Before you write any forecast bucketing code for a settlement-based market, answer these questions explicitly:

  1. What timezone does the exchange use for settlement? (Not the city's timezone. The exchange's defined timezone for contract settlement.)
  2. Does that timezone observe DST, or does it stay fixed year-round?
  3. What is the exact 24-hour window the exchange measures? Is it calendar midnight? Local standard time midnight? UTC?
  4. Where is that written down? Not in a blog post. In the exchange's actual settlement rules.

Get those answers before you write line one. Write them as comments in the code. Write a test that asserts them. The NWS definition is in their documentation. Kalshi's settlement methodology links to it. I just didn't read it carefully enough.

That's the expensive assumption. Not a complex algorithm failure. Not a model architecture mistake. A calendar.

The bot is rebuilt, DST logic is correct, settlement stations are verified, and the whole thing is back in paper-trading mode while we validate the new forecasting approach. The post-mortem cost $23 and eight months of bad data. The lesson was worth more than that.

Read the settlement rules. All of them. Before you write anything else.