The surprising failure mode
When you make too many requests to a free financial data API too quickly, you expect an error. An exception, a 429 status, something your try/except can catch.
What you get instead is silence. The response arrives normally. The data structure is there. But the values inside are None, NaN, or an empty DataFrame.
Your application continues. No exception is raised. No error is logged. Your dashboard shows dashes instead of numbers, and if you're not looking carefully, you might not notice for hours.
Why this happens
Free-tier financial data APIs are built to be graceful. Raising exceptions on rate limits would break the many scripts that don't handle errors properly. Returning empty data is safer — callers that check for data presence handle it correctly; callers that don't were probably going to crash anyway.
The API is also incentivised to give you something. A response that looks valid but contains no useful data is harder to detect and easier to attribute to "the company hasn't reported yet" than a hard error.
The pattern: accept None at every layer
The correct design accepts None explicitly at every step of the data pipeline:
def get_metric(ticker: str, metric: str) -> float | None:
try:
info = fetch_ticker_info(ticker)
value = info.get(metric)
if value is None or (isinstance(value, float) and math.isnan(value)):
return None
return float(value)
except Exception:
return None
def compute_score(roic: float | None, margin: float | None) -> int | None:
if roic is None or margin is None:
return None # can't score without data
...
Every function that touches external data returns Optional. Every function that computes on that data checks for None before computing. Never assume the data arrived.
The rate limiting discipline
Beyond handling None, you need to prevent the condition in the first place:
TICKERS = ["AAPL", "MSFT", "GOOGL", ...]
for ticker in TICKERS:
data = fetch_ticker(ticker)
process(data)
time.sleep(2) # mandatory between requests
Two seconds between requests is a reasonable floor for free-tier APIs. Reduce it and you'll see more None returns. The sleep is not optional.
Do not use asyncio.gather() or parallel threads for financial data fetching from free APIs. The throughput gain is not worth the data quality loss.
Making the absence visible
The right UX for missing data is explicit absence, not zero:
- Show
—not0when a metric couldn't be fetched - Show
N/Anot a calculated score when inputs are missing - Log a warning (not an error) when a ticker returns empty data
Zero and — are different claims. Zero means the metric was measured and is zero. Dash means the metric wasn't available. Confusing them produces false analysis.
Layering data sources
For cases where data quality is critical, fetch from multiple sources and use the first non-None value:
def get_cash_flow(ticker: str) -> float | None:
# Try primary source first
primary = fetch_from_primary(ticker)
if primary is not None:
return primary
# Fall back to secondary source (e.g. SEC filings)
return fetch_from_sec(ticker)
This layering pattern gives you resilience against any single source's rate limits or data gaps, without requiring both sources to be available simultaneously.