< Back to Blog

Visualizing Trade Timestamps Against DST Transitions: The Chart That Revealed the Bug

TL;DR / Key Takeaways

  • A DST bucketing bug caused the Weather Bot to analyze the wrong 24-hour window for eight months of the year, and the logs showed nothing obviously wrong
  • Overlaying trade timestamps against DST transition dates revealed the pattern instantly: losses clustered inside the DST window, wins clustered outside it
  • Visual debugging finds temporal patterns that log-scanning misses because humans are bad at spotting time-based correlation in raw text
  • The fix was three lines of code; finding it required a chart

The Logs Looked Fine

I spent two days reading trade logs before I made the chart.

The logs were clean. Timestamps formatted correctly. API responses parsed. Database writes confirmed. Every decision logged with a reason. Nothing obviously broken. The bot was running, trading, settling, and recording outcomes exactly as designed.

It was also losing. Quietly, consistently, in a way that looked like bad luck.

After the 112-trade audit showed the model scored worse than guessing the base rate, I knew something was structurally wrong. Not "the market was hard" wrong. Statistically worse than no model at all wrong. That is not noise. That is a defect.

The question was where.

I had seven defects to find. The DST bug was the hardest to see because the logs never showed an error. The data loaded. The forecasts ran. The trades fired. Everything worked. It was just working on the wrong 24 hours.

What the Bug Actually Was

The National Weather Service defines a weather observation day in Local Standard Time year-round. That sounds like a minor technical footnote. It is not.

In winter, LST and local clock time are the same. No problem. But from mid-March to early November, when clocks spring forward, the official NWS weather day does not move with them. The NWS day still starts at midnight LST, which is 1:00 AM local daylight time. The official daily high temperature for a given date is the highest reading between 1:00 AM and 12:59 AM the following calendar day, in local time.

Kalshi temperature contracts settle on that NWS daily high. So the settlement window is 1:00 AM to 12:59 AM during DST.

My original code bucketed forecast data by midnight-to-midnight local time. Standard, obvious, wrong. For eight months of the year, the model was analyzing a 24-hour window that was offset by one hour from the window the exchange actually used to settle the contract.

One hour of offset in temperature data is not always catastrophic. But it is enough to corrupt the probability estimates on days with strong morning or late-night temperature swings, which is exactly when you most want accurate forecasts.

The logs never flagged this. The data loaded from NOAA for the right date. The forecast timestamps parsed without error. The trade fired. Everything looked correct because the code was doing exactly what it was written to do. It was just written against the wrong specification.

Why I Made the Chart

I was not looking for a DST bug specifically. I was looking for any temporal pattern in the losses.

When you cannot see a bug by reading logs, you plot the data. This is not a novel insight. It is just the step most engineers skip because running a query feels faster than writing a visualization. It is not faster. It is slower, because you end up running thirty queries trying to find the thing that one chart would have shown you in five minutes.

The hypothesis going in was loose: maybe the bot performs differently depending on the time of year, day of week, or market conditions at the time of trade. I did not know what I was looking for. That is exactly when you reach for a chart.

The Data Setup

First I needed trade outcomes with timestamps pulled from the database and DST transition dates for the trading period.

import sqlite3
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from datetime import date

# Pull trade history from local DB
conn = sqlite3.connect("trades.db")

df = pd.read_sql_query("""
    SELECT
        t.id,
        t.market_ticker,
        t.executed_at,
        t.side,
        t.fill_price,
        t.contracts,
        s.outcome,
        s.settled_at,
        s.pnl
    FROM trades t
    LEFT JOIN settlements s ON t.id = s.trade_id
    WHERE s.outcome IS NOT NULL
    ORDER BY t.executed_at ASC
""", conn)

conn.close()

# Parse timestamps
df["executed_at"] = pd.to_datetime(df["executed_at"])
df["trade_date"] = df["executed_at"].dt.date
df["trade_date"] = pd.to_datetime(df["trade_date"])

# Boolean outcome column
df["won"] = df["outcome"] == "win"

Then I built the DST windows. The US transitions on the second Sunday in March and the first Sunday in November. For the trading period (October 2025 through February 2026 for v2.1), that meant one full DST window and two standard-time windows.

# DST windows for the trading period
# Format: (start, end, label)
dst_windows = [
    (date(2025, 3, 9),  date(2025, 11, 2),  "DST 2025"),
    (date(2026, 3, 8),  date(2026, 11, 1),  "DST 2026"),
]

def in_dst_window(d):
    for start, end, _ in dst_windows:
        if start <= d.date() <= end:
            return True
    return False

df["in_dst"] = df["trade_date"].apply(in_dst_window)

The Chart

The chart overlays four things: trade outcomes as scatter points, DST windows as shaded regions, and a 7-day rolling win rate to smooth the noise.

fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8), sharex=True,
                                gridspec_kw={"height_ratios": [2, 1]})

fig.suptitle(
    "Trade Outcomes vs. DST Windows — Weather Bot v2.1 (112 Trades)",
    fontsize=13, fontweight="bold", y=0.98
)

# --- Top panel: scatter of wins/losses ---

wins  = df[df["won"] == True]
losses = df[df["won"] == False]

ax1.scatter(wins["trade_date"],   [1] * len(wins),   color="#2ecc71",
            alpha=0.6, s=40, label="Win",  zorder=3)
ax1.scatter(losses["trade_date"], [0] * len(losses), color="#e74c3c",
            alpha=0.6, s=40, label="Loss", zorder=3)

# Shade DST windows
for start, end, label in dst_windows:
    ax1.axvspan(pd.Timestamp(start), pd.Timestamp(end),
                alpha=0.12, color="#f39c12", zorder=1)
    ax1.text(pd.Timestamp(start) + pd.Timedelta(days=5), 1.08,
             label, fontsize=8, color="#b7770d")

ax1.set_yticks([0, 1])
ax1.set_yticklabels(["Loss", "Win"])
ax1.set_ylabel("Outcome")
ax1.legend(loc="lower right", fontsize=9)
ax1.set_ylim(-0.3, 1.3)
ax1.grid(axis="x", alpha=0.3)

# --- Bottom panel: 7-day rolling win rate ---

daily = df.groupby("trade_date")["won"].mean().reset_index()
daily = daily.sort_values("trade_date")
daily["rolling_wr"] = daily["won"].rolling(7, min_periods=3).mean()

ax2.plot(daily["trade_date"], daily["rolling_wr"],
         color="#3498db", linewidth=2, label="7-day rolling win rate")
ax2.axhline(0.5, color="#7f8c8d", linestyle="--",
            linewidth=1, alpha=0.7, label="Break-even (0.50)")

for start, end, _ in dst_windows:
    ax2.axvspan(pd.Timestamp(start), pd.Timestamp(end),
                alpha=0.12, color="#f39c12", zorder=1)

ax2.set_ylabel("Win Rate (7-day)")
ax2.set_xlabel("Trade Date")
ax2.set_ylim(0, 1)
ax2.legend(loc="lower right", fontsize=9)
ax2.grid(axis="x", alpha=0.3)

plt.tight_layout()
plt.savefig("dst_trade_analysis.png", dpi=150, bbox_inches="tight")
plt.show()

I ran this and stared at the output for about thirty seconds.

The DST window shading covered most of the chart. Losses were dense inside it. The handful of trades in the short standard-time window on either end of the chart looked noticeably cleaner.

The 7-day rolling win rate in the lower panel told the same story more clearly: it sagged inside the DST window and recovered where the shading ended.

That is the moment where you feel the specific frustration of finding a bug that was hiding in plain sight the whole time. The data was always there. I just had not looked at it this way.

Quantifying What the Chart Showed

The visual was convincing but I needed the numbers to confirm it was not a coincidence.

dst_trades  = df[df["in_dst"] == True]
std_trades  = df[df["in_dst"] == False]

print(f"Trades in DST window:       {len(dst_trades)}")
print(f"  Win rate inside DST:      {dst_trades['won'].mean():.1%}")
print(f"  Net PnL inside DST:       ${dst_trades['pnl'].sum():.2f}")
print()
print(f"Trades outside DST window:  {len(std_trades)}")
print(f"  Win rate outside DST:     {std_trades['won'].mean():.1%}")
print(f"  Net PnL outside DST:      ${std_trades['pnl'].sum():.2f}")

The split was not subtle. The standard-time trades were a small sample, so I did not want to over-index on them, but the directional pattern matched the chart exactly. The DST window was where the model fell apart.

That told me the bug was temporal and offset-based, not random noise. A random defect would scatter losses evenly. A DST bucketing error would do exactly what I was looking at.

The Fix

Once I knew what to look for, finding the specific code took ten minutes.

# WRONG: midnight-to-midnight local time
day_start = datetime(year, month, day, 0, 0, 0, tzinfo=local_tz)
day_end   = datetime(year, month, day, 23, 59, 59, tzinfo=local_tz)

# CORRECT: NWS settlement window in Local Standard Time year-round
# During DST, the NWS day starts at 01:00 local daylight time
import pytz

def get_nws_day_bounds(date_obj, station_tz_name):
    """
    NWS defines the observation day in Local Standard Time.
    The daily high is the max between the LST-equivalent of
    midnight and the LST-equivalent of 23:59:59 the same LST date.
    During DST, this is 01:00 to 00:59 the next calendar day (local time).
    """
    tz = pytz.timezone(station_tz_name)

    # Build the date in LST by using the standard offset
    # pytz.localize with is_dst=False gives us LST even in summer
    day_start_lst = tz.localize(
        datetime(date_obj.year, date_obj.month, date_obj.day, 0, 0, 0),
        is_dst=False
    )
    day_end_lst = tz.localize(
        datetime(date_obj.year, date_obj.month, date_obj.day, 23, 59, 59),
        is_dst=False
    )

    # Convert to UTC for NOAA data queries
    return day_start_lst.astimezone(pytz.utc), day_end_lst.astimezone(pytz.utc)

Three lines of actual change. The rest is comments explaining why.

I added a test to lock this behavior in permanently:

def test_dst_window_alignment():
    """
    During DST (e.g. June), NWS day for Chicago should start at
    01:00 CDT (05:00 UTC), not 00:00 CDT (06:00 UTC).
    """
    chicago_date = date(2025, 6, 15)
    start_utc, end_utc = get_nws_day_bounds(chicago_date, "America/Chicago")

    # 00:00 CST = 06:00 UTC. During CDT, that LST midnight = 05:00 UTC.
    assert start_utc.hour == 5,  f"Expected 05:00 UTC, got {start_utc.hour:02d}:00 UTC"
    assert end_utc.hour   == 4,  f"Expected 04:59 UTC, got {end_utc.hour:02d}:59 UTC"

That test now lives in the v2.5 suite. It runs on every commit. The DST offset bug cannot come back silently.

Why Visual Debugging Works When Logs Don't

Logs are event-by-event. They show you what happened at each moment but they do not show you the relationship between moments. A temporal correlation that spans weeks is invisible in a log file. You would have to read thousands of lines and mentally track dates across a DST boundary to notice it.

A chart compresses weeks of data into one image and puts time on an axis where your eye can actually use it. Pattern recognition is what human visual processing is good at. Scanning log files for temporal patterns is what it is bad at.

This is not a novel observation. Engineers have known this since at least the 1970s. We still skip the chart because we are impatient and log-scanning feels like progress.

The rule I use now: if the bug is intermittent, temporal, or statistically-shaped rather than error-shaped, make the chart first. Do not spend two days reading logs. Spend twenty minutes writing the visualization.

What I Would Do Differently

I would have made this chart after the first month of trading, not after four months and 112 trades.

A rolling win-rate chart plotted weekly would have shown the DST pattern within the first DST window. I could have caught this with 30 trades instead of 112.

The instrumentation to do it was already there. The trade database had timestamps and outcomes. I just never looked at them visually because the dashboard was green and the logs were clean.

Green dashboards are not debugging tools. They tell you the system is running. They do not tell you the system is right.


The Weather Bot is rebuilt around NOAA's National Blend of Models now and is in paper-trading mode undergoing validation. Whether the fix holds is a question for the next 100 trades. What I know is that the bug is gone, there is a test proving it, and I found it by making a chart instead of reading more logs. Do that part earlier than I did.