DEV Community

Cover image for My Trading Bot "Perma-Held" a Delisted Stock: The Silent Bug of Disappearing Historical Data
oji - building AI in public
oji - building AI in public

Posted on

My Trading Bot "Perma-Held" a Delisted Stock: The Silent Bug of Disappearing Historical Data

Hey there, it's your friendly neighborhood 'Ojii' (old man). I'm 38, a corporate drone during the week, and spend my weekends tinkering with AI trading bots.

While doing routine maintenance on my weekend bots, I noticed an unfamiliar stock lingering in my portfolio. "Huh, when did I even enter this position?" I wondered.

Turns out, it was a stock that had been delisted several months ago. My bot wasn't selling it; it was just quietly "perma-holding" it. The P&L was negligible, but having a system maintain unintended positions is a serious issue. I immediately started investigating.

Symptom: Silently Vanishing from the Exit Logic

First, I dove into the logs. I found that at a certain point, the stock had completely disappeared from the list of assets considered for exit decisions. No errors. It was simply treated as if it no longer existed.

The root cause lay with the external API my bot used to fetch stock price data. When a stock is delisted, that API stops returning its historical data. That's a pretty standard API behavior.

The problem was in my code. A fundamental condition for triggering the exit logic was "having at least 260 days of candlestick data." This was necessary for calculating various technical indicators.

What happened to stocks that didn't meet this condition? They weren't throwing exceptions; they were simply skipped.

# Problematic code: If data is less than 260 days, it's not even considered for exit
frames = {}
for ticker in ALL_TICKERS:
    # Attempts to fetch 2 years of data from an external API
    df = yf.download(ticker, period='2y') 

    # If data cannot be fetched or is less than 260 days, it's filtered out here
    if len(df.dropna()) >= 260:
        frames[ticker] = df

# ... Subsequent logic completely ignores tickers not in `frames`
Enter fullscreen mode Exit fullscreen mode

This if statement was the culprit. Stocks delisted and thus no longer providing data were silently excluded from processing here. No error logs, just a single warning log line. How could I have noticed? A classic silent bug pattern.

Cause: Inconsistency Between Two APIs

What made things even more complicated was the way I was using two different APIs:

  1. Decision-making API: Used for technical analysis and exit decisions (e.g., historical price data). -> Data vanished from here
  2. Valuation API: Used for portfolio valuation and actual position management (e.g., current holdings). -> The position continued to exist here

The inconsistency between these two data sources critically delayed bug detection. In one world, it was a "non-existent stock," but in the other, it was an "owned asset." There was no mechanism to detect this discrepancy. That's brutal.

Fix: Position-Driven Data Freshness Check

The fundamental problem was the flow: "first, gather data for all stocks, then select those for processing." This approach meant that the moment a stock disappeared from the data source, its existence became untraceable.

So, I reversed the order of operations. The new flow is: "first, list all currently held positions. Then, for each of those stocks, check if data can be properly retrieved."

If data cannot be retrieved or is too old, it's treated as an "abnormal situation." It's either forcibly marked for exit, or at the very least, a critical error notification is triggered.

Here's the conceptual code for the fix:

# Revised conceptual code: Check data freshness individually for held positions
live_positions = get_current_positions() # Fetch current holdings from brokerage API
plan = {"sell": [], "stale": []}

for symbol, position in live_positions.items():
    # Attempt to fetch data individually
    df = yf.download(symbol, period='2y') 

    # If data is missing or insufficient, mark as "stale"
    if df is None or len(df.dropna()) < 260:
        plan.stale.append(symbol)
        log.error(f"Data for position {symbol} is stale or missing. Marked for investigation.")
        continue

    # ... If data is normal, proceed with regular exit logic
Enter fullscreen mode Exit fullscreen mode

This ensures that stocks that disappear from the data source are no longer left unattended. Stocks marked as stale can then be manually checked, liquidated, or routed to a separate emergency handling flow.

Lessons Learned: No Data is an Error

Three key lessons I took away from this incident:

  1. Design for Data Source Inconsistencies: In systems using multiple APIs, data might vanish from one but remain in another. Especially when dealing with assets that have a lifecycle (like listing/delisting), a mechanism to detect this inconsistency is crucial.
  2. Don't Tolerate Silent Errors: Designs using try-except-pass or continuing processing with just a warning log when conditions aren't met are convenient during development but become time bombs in production. For critical processes, explicitly fail or send alerts (e.g., to Slack).
  3. Seriously Handle "Non-Existence": If your logic assumes data "exists," it will break when it "disappears." The state of "data cannot be retrieved" isn't just a skip condition; it's a critical signal that the system is in an unexpected situation.

When you're coding side projects late at night or on weekends, it's easy to get lazy with error handling. But skimping on it here can lead to much larger time losses later. Good learning experience.

Hope this helps other independent bot developers out there.

Top comments (0)