fix bulk fundamentals: rate limits masked by partial FMP success
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s

Root cause of "price plan needed in bulk but fine on manual reload": on free
tiers FMP returns only market cap (others 402) and the chain merged that as a
partial success — so when the Finnhub/Alpha Vantage fallbacks were rate-limited
during a bulk run, the chain silently returned market-cap-only and the
collector's backoff never engaged. Manual single fetches worked because the
fallbacks weren't throttled at that moment.

Fixes:
- Chain distinguishes RateLimitError from other failures: if a fallback is
  rate-limited and fields are still missing, raise RateLimitError (unless
  allow_partial=True) so the collector backs off and retries.
- Bulk job paces requests (fundamental_request_spacing_seconds, default 3s) to
  stay under Finnhub's ~60/min, and on retry-exhaustion stores partial data and
  continues instead of aborting the whole run.
- Manual fetch passes allow_partial=True so a lone 429 doesn't fail the refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 21:18:32 +02:00
parent 5d41ccac1c
commit f24e5070ee
5 changed files with 121 additions and 22 deletions
+29 -19
View File
@@ -576,6 +576,19 @@ async def collect_fundamentals() -> None:
max_retries = max(0, settings.fundamental_rate_limit_retries)
base_backoff = max(1, settings.fundamental_rate_limit_backoff_seconds)
spacing = max(0.0, settings.fundamental_request_spacing_seconds)
async def _store(symbol: str, data) -> None:
async with async_session_factory() as db:
await fundamental_service.store_fundamental(
db,
symbol=symbol,
pe_ratio=data.pe_ratio,
revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap,
unavailable_fields=data.unavailable_fields,
)
for symbol in symbols:
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
@@ -583,16 +596,7 @@ async def collect_fundamentals() -> None:
while True:
try:
data = await provider.fetch_fundamentals(symbol)
async with async_session_factory() as db:
await fundamental_service.store_fundamental(
db,
symbol=symbol,
pe_ratio=data.pe_ratio,
revenue_growth=data.revenue_growth,
earnings_surprise=data.earnings_surprise,
market_cap=data.market_cap,
unavailable_fields=data.unavailable_fields,
)
await _store(symbol, data)
_last_successful[job_name] = symbol
processed += 1
_runtime_progress(job_name, processed=processed, total=total, current_ticker=symbol)
@@ -627,23 +631,29 @@ async def collect_fundamentals() -> None:
await asyncio.sleep(wait_seconds)
continue
# Retries exhausted: store whatever partial data we can
# still get (e.g. FMP market cap) and move on, rather than
# aborting the whole run and leaving every later ticker
# untouched.
logger.warning(json.dumps({
"event": "rate_limited",
"event": "rate_limited_partial",
"job": job_name,
"ticker": symbol,
"processed": processed,
}))
_runtime_finish(
job_name,
"rate_limited",
processed=processed,
total=total,
message=f"Rate limited at {symbol} after {attempt} retries",
)
return
try:
data = await provider.fetch_fundamentals(symbol, allow_partial=True)
await _store(symbol, data)
processed += 1
except Exception as exc2:
_log_job_error(job_name, symbol, exc2)
break
_log_job_error(job_name, symbol, exc)
break
if spacing:
await asyncio.sleep(spacing)
_last_successful[job_name] = None
logger.info(json.dumps({"event": "job_complete", "job": job_name, "tickers": processed}))
_runtime_finish(job_name, "completed", processed=processed, total=total, message=f"Processed {processed} tickers")