< Back to Blog

The Most Dangerous Bugs Are the Ones That Don't Throw Errors

TL;DR / Key Takeaways

  • Silent bugs return wrong answers instead of raising errors, so your monitoring stays green while your logic goes off the rails
  • A single wrong dict key made the bot invisible to its own open positions for weeks
  • A DST bucketing error meant every summer forecast was built from the wrong 24-hour window, with no warning
  • The fix is not better error handling alone; it is logging what your system sees, not just what it does

The bugs that embarrass you are not the ones that crash your program.

Those are almost fine. A traceback is a gift. It tells you something went wrong, where it went wrong, and usually gives you a line number. You fix it, you deploy, you move on.

The bugs that cost you are the ones that return a plausible answer. They do not raise exceptions. They do not trigger alerts. Every health check passes. Every dashboard stays green. The system just quietly produces wrong output for days or weeks while you assume everything is working.

I found three of these in my trading bots during the v2.1 post-mortem. None of them threw errors. All of them mattered.


Bug 1: The Dict Key That Made the Bot Blind to Itself

The Kalshi API has a method that returns your open positions. In my kalshi_client.py wrapper, I was calling it like this:

def get_positions(self) -> list:
    response = self.client.portfolio.get_positions()
    return response.get("positions", [])

The Kalshi API does not return "positions". It returns this:

{
  "event_positions": [...],
  "market_positions": [...]
}

So response.get("positions", []) returned an empty list every single time. No error. No warning. Just empty.

The downstream effects were extensive. The bot had an open trade counter that worked by counting positions. It saw zero positions. It thought the book was empty. So it kept entering trades it should not have entered, because it believed it had no exposure.

There was also a regime-change detector that monitors whether open positions are on the right side of the market after a significant probability shift. That module reads positions before deciding whether to fire a close order. It read zero positions, saw nothing to close, and went dormant. Permanently.

The bot was not crashing. It was not logging errors. It was just wrong about reality, confidently, every cycle.

The fix was one line:

def get_positions(self) -> list:
    response = self.client.portfolio.get_positions()
    return response.get("market_positions", [])

One line. Biggest single-change impact in the entire project.

Here is what I should have added at the same time:

def get_positions(self) -> list:
    response = self.client.portfolio.get_positions()
    positions = response.get("market_positions", [])

    # Log what we actually received, not just what we extracted
    logger.debug(
        "get_positions raw keys=%s count=%d",
        list(response.keys()),
        len(positions)
    )

    if not positions and "event_positions" in response:
        event_count = len(response["event_positions"])
        logger.warning(
            "market_positions empty but event_positions has %d entries. "
            "Check key mapping.",
            event_count
        )

    return positions

The warning on the empty-but-not-really case would have caught this in the first cycle. Instead it ran for weeks.


Bug 2: The DST Bucketing Error That Invalidated Eight Months of Forecasts

This one is subtle enough that I want to explain the setup first.

Kalshi's temperature markets settle on the official high temperature for a given day at a specific weather station. The "day" is defined by the National Weather Service using Local Standard Time year-round. Not local time. Standard time. Always.

In winter, that lines up with clock time. In summer, when clocks spring forward, the official NWS weather day runs from 1:00 AM local time to 12:59 AM the following day (because 1:00 AM standard = 2:00 AM daylight).

My original code was bucketing observations using local clock time:

from datetime import datetime
import pytz

def get_daily_high(station: str, date: str, tz_name: str) -> float:
    tz = pytz.timezone(tz_name)
    target_date = datetime.strptime(date, "%Y-%m-%d")

    # Midnight to midnight in local time
    start = tz.localize(target_date.replace(hour=0, minute=0))
    end = tz.localize(target_date.replace(hour=23, minute=59))

    observations = fetch_observations(station, start, end)
    return max(obs["temp"] for obs in observations)

During standard time (November through March), this is correct. During daylight saving time (March through November), this is wrong. The window shifts by an hour. The actual high temperature for the contract's settlement day might be recorded at 6:00 PM local time, which is 5:00 PM standard time, but if the NWS observation period started at 1:00 AM and my bucket started at midnight, I was including one hour of yesterday and excluding one hour that counted.

The correct implementation anchors to standard time regardless of the calendar date:

from datetime import datetime, timedelta
import pytz

def get_daily_high(station: str, date: str, tz_name: str) -> float:
    tz = pytz.timezone(tz_name)
    target_date = datetime.strptime(date, "%Y-%m-%d")

    # NWS weather day is always defined in Local Standard Time.
    # Use is_dst=False to force standard time anchoring.
    try:
        start = tz.localize(target_date.replace(hour=1, minute=0), is_dst=False)
    except pytz.exceptions.NonExistentTimeError:
        # Handle the spring-forward gap gracefully
        start = tz.localize(target_date.replace(hour=1, minute=0), is_dst=True)

    end = start + timedelta(hours=24) - timedelta(minutes=1)

    observations = fetch_observations(station, start, end)
    return max(obs["temp"] for obs in observations)

No error was ever raised by the original code. It calculated a high temperature every time. The number was just wrong for eight months of the year.

Every losing trade from the original bot that fell in the DST window was working off forecasts and observations that were misaligned by an hour. The model was comparing apples to oranges without knowing it.

A logging approach that would have surfaced this faster:

logger.debug(
    "date=%s station=%s window_start=%s window_end=%s is_dst=%s obs_count=%d high=%.1f",
    date,
    station,
    start.isoformat(),
    end.isoformat(),
    bool(start.dst()),
    len(observations),
    result
)

If I had been logging the actual window boundaries instead of just the result, someone would have noticed in April that the windows shifted by an hour. Probably me, around April 15th, instead of eight months later during the post-mortem.


Bug 3: The Settlement Status That Never Updated

This one lives in db/connection.py. The function settle_trade() was supposed to mark a trade as settled in the local database after the contract resolved. Here is what it actually did:

def settle_trade(trade_id: int, outcome: str, pnl: float) -> None:
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(
        """
        UPDATE trades
        SET outcome = %s, pnl = %s, settled_at = NOW()
        WHERE id = %s
        """,
        (outcome, pnl, trade_id)
    )
    conn.commit()

Notice what is missing. The status column never gets updated. The row stays at status = 'open' forever.

The bot had logic that checked how many open trades it had before entering a new one. It read status = 'open' from the database. Settled trades were still 'open' in the database, so the open-trade counter kept growing. After a few weeks of trading, the counter was claiming ten or twelve open positions when the actual live book on Kalshi had maybe three or four.

The bot refused to enter high-edge candidates because it thought it was at capacity. It was self-throttling based on phantom positions.

Again: no error. No crash. The database query ran successfully. The counter returned a number. The number was just wrong.

The fix adds one field:

def settle_trade(trade_id: int, outcome: str, pnl: float) -> None:
    conn = get_connection()
    cursor = conn.cursor()
    cursor.execute(
        """
        UPDATE trades
        SET outcome = %s, pnl = %s, settled_at = NOW(), status = 'settled'
        WHERE id = %s
        """,
        (outcome, pnl, trade_id)
    )
    conn.commit()

The thing that would have caught this earlier is a consistency check. I now run this at startup and log the result:

def audit_position_consistency() -> dict:
    """
    Compare local DB open trade count to live Kalshi position count.
    Returns a dict with both numbers and a flag if they diverge.
    """
    db_open = count_db_open_trades()
    live_open = len(get_positions())

    result = {
        "db_open": db_open,
        "live_open": live_open,
        "diverged": abs(db_open - live_open) > 1
    }

    if result["diverged"]:
        logger.warning(
            "Position count mismatch: db=%d live=%d. "
            "Possible stale records. Investigate before trading.",
            db_open,
            live_open
        )

    return result

If this had been running, the mismatch would have shown up within the first day the bug appeared.


The Pattern

Three different bugs. Three different subsystems. One common structure.

Each one returned a value. Each value was technically valid Python. No assertion failed. No exception propagated. The code did exactly what it was told to do. It was just told to do the wrong thing.

Silent failures tend to share a few traits:

They use a default fallback. dict.get("key", []) never raises. It just returns empty. This is a convenience feature that doubles as a place for bugs to hide. When you use a default, log the fact that you used it.

They produce plausible output. Zero positions is a valid state. An off-by-one-hour temperature window produces a real temperature. A status column that never changes is still a valid status. Nothing looks obviously broken.

They affect downstream logic, not their own output. The dict key bug broke the open-trade counter. The DST bug broke the forecast comparison. The settlement bug broke the entry filter. The error appears far from the cause, which makes the post-mortem harder.


Defensive Patterns That Help

Log what your system sees, not just what it does.

"Entered trade X" is less useful than "Entered trade X with 3 open positions per Kalshi, 3 per DB, signal edge 0.12."

If the bot had been logging the position count it observed on every cycle, the dict key bug would have shown up as a persistent zero on day one.

Cross-check external state against internal state.

Anything the bot reads from an external API should be compared against the bot's local model of the world. If they diverge by more than a threshold, log a warning before proceeding.

Make your windows explicit and log them.

Any time you are operating on a time window, log the actual UTC timestamps you are using. Not the date string. The timestamps. DST bugs are invisible until you see that your "2026-07-15" window starts at 2026-07-15T05:00:00Z in January and 2026-07-15T06:00:00Z in July.

Write a consistency check and run it at startup.

Five minutes of code to compare DB state against live API state catches an entire class of silent divergence bugs. If the numbers do not match, do not trade. Log and stop.

def startup_checks() -> bool:
    checks = {
        "positions_consistent": audit_position_consistency()["diverged"] is False,
        "db_connected": test_db_connection(),
        "api_reachable": test_api_connection(),
    }
    for check, passed in checks.items():
        logger.info("startup_check name=%s passed=%s", check, passed)

    return all(checks.values())

What the Post-Mortem Actually Taught Me

I audited 112 trades and found that my forecasting model had no skill. Brier score of 0.2858 versus a base-rate guess of 0.2439. Worse than doing nothing.

But underneath the model problem were these three silent bugs, running the whole time, making the situation worse than it needed to be. The bot was blind to its own positions. It was forecasting the wrong time window. It was over-trading based on phantom open trades.

Bad inputs, bad logic, bad state management. All invisible. All producing valid-looking output.

The model problem got the headline. These bugs were doing quiet damage alongside it.

The lesson is not "write better error handling," though that helps. The lesson is that in an automated system, you are not watching. The system has to watch itself and tell you what it sees. Logs are not optional. Consistency checks are not optional. If you are only logging what your system does, you will not know it is wrong until you count the losses.

That is what the post-mortem taught me. The rebuild has 49 new automated tests and a startup audit that runs before the first trade cycle. Silent bugs still exist. They are just harder to hide now.


The Weather Bot is rebuilt and undergoing validation in paper-trading mode. The source code for both bots ships as a one-time $75 package at predictandprofit.io. The post-mortem is in the docs. The bugs are fixed. The validation is ongoing.