< 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, meaning the official 24-hour window shifts by one clock hour every spring when DST begins.
  • My bot was bucketing the wrong 24 hours for eight months of the year, forecasting the wrong temperature window against Kalshi's settlement rules.
  • A matplotlib scatter plot overlaying trade outcomes with DST transition dates made the pattern impossible to ignore.
  • 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 have written a lot of production code in 30 years. Pipeline failures, race conditions, off-by-one errors in financial calculations. I have seen the whole catalog.

Nothing has ever cost me as quietly as this one.

The bug did not crash anything. No exception was raised. No alert fired. The bot ran perfectly for four months, logged every trade, reported healthy, and lost money on a pattern I could not see because I was looking at the wrong dimension entirely.

It was a time zone assumption. The kind every engineer makes and almost never checks.


What Kalshi Temperature Contracts Actually Settle On

This is where I should have started, and did not.

Kalshi's temperature markets ask something like: "Will the high temperature in Chicago be above 85°F on July 15th?" The settlement source is a specific ASOS weather station. Chicago settles on Midway, not O'Hare. Houston settles on Hobby, not IAH. I got those wrong too, but that is a different post.

The relevant detail here is how the National Weather Service defines a "day" for temperature records.

NWS uses Local Standard Time year-round for daily weather summaries. Not local clock time. Not UTC. Local Standard Time, fixed, regardless of whether the clocks have sprung forward.

In Chicago, Local Standard Time is UTC-6. In winter, Chicago clocks are also at UTC-6, so there is no difference. But in summer, Chicago clocks are at UTC-5 (CDT). The official NWS weather day still runs midnight-to-midnight in UTC-6.

Which means in summer, the official "July 15th" weather day runs from 1:00 AM CDT to 1:00 AM CDT the following night.

My bot was using midnight to midnight local clock time. Off by one hour, every single day, from mid-March through early November.

Eight months of the year. Wrong window. Every trade.


What the Original Code Looked Like

Here is a simplified version of the bucketing logic from v2.1:

from datetime import datetime
import pytz

def get_weather_day_bounds(target_date: str, station_tz: str) -> tuple[datetime, datetime]:
    """
    Returns UTC start/end datetimes for the weather day.
    target_date: 'YYYY-MM-DD'
    station_tz: e.g. 'America/Chicago'
    """
    tz = pytz.timezone(station_tz)
    
    # Parse the target date as midnight local time
    local_midnight = tz.localize(
        datetime.strptime(target_date, "%Y-%m-%d")
    )
    local_end = tz.localize(
        datetime.strptime(target_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
    )
    
    utc_start = local_midnight.astimezone(pytz.utc)
    utc_end = local_end.astimezone(pytz.utc)
    
    return utc_start, utc_end

Looks reasonable. Readable. Does exactly what it says.

It is wrong.

pytz.timezone('America/Chicago').localize(datetime(...)) will apply CDT in summer. So midnight local means midnight CDT, which is UTC-5. The NWS window starts at midnight CST, which is UTC-6. One hour earlier.

In summer, this function was handing the forecast model the interval from 06:00 UTC to 05:59 UTC the next day. The NWS settlement window is 06:00 UTC to 05:59 UTC the next day in winter, but 05:00 UTC to 04:59 UTC in summer.

Those are not the same hour. In a temperature contest decided by the daily high, that missing or misassigned hour can be the peak.


How I Found It

I did not find it by reading the code. I found it with a chart.

After the full 112-trade audit in July 2026 showed the model had no predictive skill, I started pulling apart every dimension I could think of: station, contract type, forecast lead time, confidence level, win/loss. Nothing clean emerged.

Then I added a time dimension. I plotted every trade outcome against calendar date, colored by win or loss, and drew vertical lines at the DST transition dates.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd

df = pd.read_csv("trade_audit.csv", parse_dates=["trade_date"])

dst_transitions = pd.to_datetime([
    "2025-03-09",  # Spring forward
    "2025-11-02",  # Fall back
    "2026-03-08",  # Spring forward
])

wins = df[df["outcome"] == "win"]
losses = df[df["outcome"] == "loss"]

fig, ax = plt.subplots(figsize=(14, 5))

ax.scatter(wins["trade_date"], wins["confidence"], 
           color="green", alpha=0.6, label="Win", s=40)
ax.scatter(losses["trade_date"], losses["confidence"], 
           color="red", alpha=0.6, label="Loss", s=40)

for dt in dst_transitions:
    ax.axvline(x=dt, color="orange", linestyle="--", linewidth=1.5, alpha=0.8)

ax.xaxis.set_major_formatter(mdates.DateFormatter("%b %Y"))
ax.xaxis.set_major_locator(mdates.MonthLocator())
plt.xticks(rotation=45)
ax.set_ylabel("Model Confidence")
ax.set_xlabel("Trade Date")
ax.set_title("Trade Outcomes vs DST Transitions")
ax.legend()
plt.tight_layout()
plt.savefig("dst_audit.png", dpi=150)

The chart was not subtle. Before the first spring transition, the losses were distributed roughly evenly across confidence levels. After the spring transition date, high-confidence losses clustered. After the fall transition, the pattern reset.

I stared at that plot for about ten minutes before I typed "nws local standard time" into a search window.

I found the NWS documentation on their temperature record methodology inside of five minutes. It had been publicly available the entire time.


The Fix

The correct approach is to localize against standard time explicitly, ignoring DST entirely. In Python, the cleanest way to do that is to work in the fixed UTC offset for each station rather than the DST-aware timezone name.

from datetime import datetime, timezone, timedelta

# Standard UTC offsets (LST, not LDT)
STATION_LST_OFFSETS = {
    "KMDW": -6,   # Chicago Midway — CST
    "KHOU": -6,   # Houston Hobby — CST
    "KLAX": -8,   # Los Angeles — PST
    "KJFK": -5,   # New York JFK — EST
    "KBOS": -5,   # Boston Logan — EST
    "KPHX": -7,   # Phoenix Sky Harbor — MST (no DST)
    "KATL": -5,   # Atlanta Hartsfield — EST
    "KDFW": -6,   # Dallas/Fort Worth — CST
    "KSEA": -8,   # Seattle-Tacoma — PST
    "KDEN": -7,   # Denver International — MST
}

def get_nws_weather_day_bounds_utc(target_date: str, station_id: str) -> tuple[datetime, datetime]:
    """
    Returns UTC start/end datetimes for the NWS weather day.
    NWS defines a weather day as midnight-to-midnight in Local Standard Time,
    year-round, regardless of DST. This function applies that rule correctly.
    
    target_date: 'YYYY-MM-DD'
    station_id: ICAO station code, e.g. 'KMDW'
    """
    if station_id not in STATION_LST_OFFSETS:
        raise ValueError(f"Unknown station: {station_id}. Add it to STATION_LST_OFFSETS.")
    
    lst_offset = STATION_LST_OFFSETS[station_id]
    lst_tz = timezone(timedelta(hours=lst_offset))
    
    # Midnight in Local Standard Time
    lst_midnight = datetime(
        *[int(x) for x in target_date.split("-")],
        0, 0, 0,
        tzinfo=lst_tz
    )
    lst_end = lst_midnight.replace(hour=23, minute=59, second=59)
    
    utc_start = lst_midnight.astimezone(timezone.utc)
    utc_end = lst_end.astimezone(timezone.utc)
    
    return utc_start, utc_end

The key difference: timezone(timedelta(hours=-6)) is a fixed offset. It does not know what month it is. It does not care whether the clocks changed. It always means UTC-6, period.

For Phoenix, which does not observe DST, the old code and the new code produce identical results. For every other station, the new code gives you the correct NWS window in summer and the same correct result in winter.

One small but important note on Phoenix: Arizona standard time is UTC-7, and since Phoenix skips DST, it actually aligns with Mountain Daylight Time in summer. That is a different source of confusion and worth double-checking for any Phoenix-area contracts.


A Quick Sanity Check

After writing the fix, I added a test to verify the summer behavior explicitly:

import pytest
from datetime import timezone, timedelta

def test_chicago_summer_window_is_lst_not_ldt():
    """
    On July 15 in Chicago, the NWS weather day should start at 05:00 UTC
    (midnight CST = UTC-6), NOT 06:00 UTC (midnight CDT = UTC-5).
    """
    utc_start, utc_end = get_nws_weather_day_bounds_utc("2026-07-15", "KMDW")
    
    expected_start = datetime(2026, 7, 15, 5, 0, 0, tzinfo=timezone.utc)
    expected_end = datetime(2026, 7, 16, 4, 59, 59, tzinfo=timezone.utc)
    
    assert utc_start == expected_start, f"Expected 05:00 UTC, got {utc_start}"
    assert utc_end == expected_end, f"Expected 04:59:59 UTC next day, got {utc_end}"

def test_chicago_winter_window_matches_both_approaches():
    """
    In winter, LST and LDT produce the same result for Chicago.
    """
    utc_start, _ = get_nws_weather_day_bounds_utc("2026-01-15", "KMDW")
    expected_start = datetime(2026, 1, 15, 6, 0, 0, tzinfo=timezone.utc)
    assert utc_start == expected_start

Both tests pass. More importantly, they will keep passing if someone touches the bucketing logic six months from now and accidentally reintroduces DST-aware localization.

This is one of the 49 new automated tests added in the v2.3 rebuild.


Why This Is So Easy to Miss

Every data engineer has written pytz.timezone('America/New_York').localize(dt) a hundred times. It is idiomatic. It is what the library is designed to do.

The problem is that "correct local time" and "correct NWS time" are two different things, and nothing in the code tells you that. The datetime object does not know it is being used to query weather data. The pytz library does not know you are working with a domain that ignores DST. The only way to get this right is to read the source documentation for the data you are consuming and verify your assumptions explicitly.

I did not do that. I assumed midnight meant midnight. It does not, not for this domain.

The expensive assumption was not wrong in isolation. It was wrong in context, and the context was buried in NWS methodology documentation that I had no reason to look for until the pattern showed up on a chart.


What the Bot Looked Like After the Fix

The v2.3 rebuild replaced DST-aware bucketing with fixed LST offsets across the board. Every station in the system has a hardcoded LST offset. Every date range query uses those fixed offsets. DST is not a concept that exists anywhere in the weather day calculation anymore.

The bot is rebuilt and undergoing validation. It is in paper-trading mode. It has not been validated against live markets. That takes time, somewhere between two weeks to assess forecast quality and four to six months to accumulate enough completed trades to say anything meaningful about the strategy.

I am not claiming the DST fix makes it profitable. I am saying it was wrong before and it is correct now. That is the bar a rebuilt system has to clear first.


The Takeaway

If you are consuming public weather data to make any kind of time-sensitive decision, read the methodology documentation before you write a single line of code.

Not the API docs. Not the quickstart guide. The actual methodology document that defines how the agency constructs its records.

NWS uses Local Standard Time year-round for daily summaries. NOAA ASOS quality control has its own timestamp conventions. Climate divisions and grid cell assignments have edge cases at borders. None of this is hidden. All of it will burn you if you assume the obvious interpretation is the correct one.

Check your time assumptions against the exchange's actual settlement rules. Check them against the data source's own documentation. Then write a test that will catch it if someone changes the code and reintroduces the same bug.

I learned this the slow way. You do not have to.