fix: price_data_service null/zero close 자동 재수집 + Yahoo rate limit 429 + earnings 캐시

- price_data_service: null/zero close를 missing으로 처리 → 재수집 트리거
  UTC 자정 정규화로 yf.history / yf.download 중복 방지
  get_quote fallback: .info 실패 시 fast_info + history('2d') 체인
  historical upsert: close IS NULL/0인 경우만 덮어쓰기 (정상 데이터 보호)
- price.py: Yahoo rate limit → HTTP 429 + Retry-After: 30 응답
- earnings.py: bulk calendar 엔드포인트 캐시 1시간 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent e9ad89ff74
commit 2fb6ec0f0c

@ -102,6 +102,7 @@ async def get_earnings_calendar(
"Useful for checking upcoming earnings of sector peers or candidates." "Useful for checking upcoming earnings of sector peers or candidates."
), ),
) )
@with_cache(namespace="earnings:calendar_bulk", ttl=3600, key_params=["request"])
async def get_bulk_earnings_calendar( async def get_bulk_earnings_calendar(
request: BulkEarningsCalendarRequest, request: BulkEarningsCalendarRequest,
response: Response, response: Response,

@ -641,8 +641,18 @@ async def get_quote(
use_prepost: bool = Query(True, description="Include pre/post market prices if available"), use_prepost: bool = Query(True, description="Include pre/post market prices if available"),
): ):
svc = PriceDataService() svc = PriceDataService()
try:
data = await svc.get_quote(ticker, use_prepost=use_prepost) data = await svc.get_quote(ticker, use_prepost=use_prepost)
return QuoteResponse(**data) return QuoteResponse(**data)
except Exception as e:
err = str(e).lower()
if "rate limit" in err or "too many requests" in err or "429" in err or "ratelimit" in err:
raise HTTPException(
status_code=429,
detail={"error_type": "RATE_LIMIT_ERROR", "message": "Yahoo Finance rate limit. Retry after a short delay."},
headers={"Retry-After": "30"},
)
raise HTTPException(status_code=500, detail={"error_type": "SERVER_ERROR", "message": str(e)})
@router.get( @router.get(
"/intraday", "/intraday",
@ -729,6 +739,7 @@ async def get_intraday(
period: str = Query("1d"), period: str = Query("1d"),
): ):
svc = PriceDataService() svc = PriceDataService()
try:
candles = await svc.get_intraday(ticker, interval=interval, period=period) candles = await svc.get_intraday(ticker, interval=interval, period=period)
return IntradayResponse( return IntradayResponse(
ticker=ticker.upper(), ticker=ticker.upper(),
@ -737,6 +748,11 @@ async def get_intraday(
candles=[IntradayCandle(**c) for c in candles], candles=[IntradayCandle(**c) for c in candles],
metadata={"count": len(candles)} metadata={"count": len(candles)}
) )
except Exception as e:
err = str(e).lower()
if "rate limit" in err or "too many requests" in err or "429" in err or "ratelimit" in err:
raise HTTPException(status_code=429, detail={"error_type": "RATE_LIMIT_ERROR", "message": "Yahoo Finance rate limit. Retry after a short delay."}, headers={"Retry-After": "30"})
raise HTTPException(status_code=500, detail={"error_type": "SERVER_ERROR", "message": str(e)})
@router.get( @router.get(
"/today/{ticker}", "/today/{ticker}",
@ -748,5 +764,11 @@ async def get_today_ohlc(
ticker: str, ticker: str,
): ):
svc = PriceDataService() svc = PriceDataService()
try:
data = await svc.get_today_ohlc(ticker) data = await svc.get_today_ohlc(ticker)
return TodayOHLCResponse(**data) return TodayOHLCResponse(**data)
except Exception as e:
err = str(e).lower()
if "rate limit" in err or "too many requests" in err or "429" in err or "ratelimit" in err:
raise HTTPException(status_code=429, detail={"error_type": "RATE_LIMIT_ERROR", "message": "Yahoo Finance rate limit. Retry after a short delay."}, headers={"Retry-After": "30"})
raise HTTPException(status_code=500, detail={"error_type": "SERVER_ERROR", "message": str(e)})

@ -6,7 +6,7 @@ from datetime import datetime, timezone, timedelta, date
from typing import Dict, List, Optional, Tuple, Union from typing import Dict, List, Optional, Tuple, Union
import logging import logging
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc from sqlalchemy import select, and_, desc, or_
import asyncio import asyncio
import sys import sys
import os import os
@ -120,14 +120,16 @@ class PriceDataService:
if end_date.date() >= today: if end_date.date() >= today:
return [datetime.now()] return [datetime.now()]
# Check if we have any data for this ticker and interval # Count only rows with valid close (null/0 rows are treated as missing)
result = await db.execute( result = await db.execute(
select(PriceData.date) select(PriceData.date)
.where( .where(
and_( and_(
PriceData.ticker == ticker, PriceData.ticker == ticker,
PriceData.date >= start_date, PriceData.date >= start_date,
PriceData.date <= end_date PriceData.date <= end_date,
PriceData.close.is_not(None),
PriceData.close > 0,
) )
) )
.order_by(PriceData.date) .order_by(PriceData.date)
@ -228,22 +230,28 @@ class PriceDataService:
return hist_data return hist_data
async def get_quote(self, ticker: str, use_prepost: bool = True) -> Dict: async def get_quote(self, ticker: str, use_prepost: bool = True) -> Dict:
"""Get latest quote using yfinance-plus .info fields with fallback to fast history last row.""" """Get latest quote using yfinance-plus .info fields with fallback to fast_info + history."""
if not self.yf_available: if not self.yf_available:
raise ValueError("Yahoo Finance (yfinance-plus) data source not available") raise ValueError("Yahoo Finance (yfinance-plus) data source not available")
try:
yf_ticker = yf.Ticker(ticker) yf_ticker = yf.Ticker(ticker)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Try .info first; fall back to fast_info + history on timeout/error
info = None
try:
info = await _run_with_timeout( info = await _run_with_timeout(
loop.run_in_executor(None, lambda: yf_ticker.info), loop.run_in_executor(None, lambda: yf_ticker.info),
timeout_seconds=20, timeout_seconds=20,
description=f"info {ticker}" description=f"info {ticker}"
) )
# Prefer regular/post/pre values except Exception as e:
logger.warning(f"get_quote .info failed for {ticker}: {e}, falling back to fast_info")
if info:
regular = info.get("regularMarketPrice") regular = info.get("regularMarketPrice")
post = info.get("postMarketPrice") if use_prepost else None post = info.get("postMarketPrice") if use_prepost else None
pre = info.get("preMarketPrice") if use_prepost else None pre = info.get("preMarketPrice") if use_prepost else None
price = post or pre or regular
currency = info.get("currency") currency = info.get("currency")
exchange = info.get("exchange") or info.get("fullExchangeName") exchange = info.get("exchange") or info.get("fullExchangeName")
market_state = info.get("marketState") market_state = info.get("marketState")
@ -255,12 +263,60 @@ class PriceDataService:
ts = ts.replace(tzinfo=timezone.utc) ts = ts.replace(tzinfo=timezone.utc)
else: else:
ts = datetime.now(timezone.utc) ts = datetime.now(timezone.utc)
pre_val = float(pre) if pre is not None else None
post_val = float(post) if post is not None else None
regular_val = float(regular) if regular is not None else None
price_val = post_val or pre_val or regular_val
else:
# Fallback: fast_info for price/currency/exchange, history for timestamp
try:
fast = await _run_with_timeout(
loop.run_in_executor(None, lambda: yf_ticker.fast_info),
timeout_seconds=10,
description=f"fast_info {ticker}"
)
regular_val = getattr(fast, "last_price", None)
if regular_val is None and isinstance(fast, dict):
regular_val = fast.get("lastPrice") or fast.get("last_price")
currency = getattr(fast, "currency", None) or (fast.get("currency") if isinstance(fast, dict) else None)
exchange = getattr(fast, "exchange", None) or (fast.get("exchange") if isinstance(fast, dict) else None)
except Exception as e2:
logger.error(f"get_quote fast_info also failed for {ticker}: {e2}")
raise
# Try to get last close from recent history for timestamp
try:
df = await _run_with_timeout(
loop.run_in_executor(
None,
lambda: yf_ticker.history(period="2d", interval="1d", auto_adjust=True)
),
timeout_seconds=15,
description=f"history fallback {ticker}"
)
if not df.empty:
last_row = df.iloc[-1]
if regular_val is None:
regular_val = float(last_row.get("Close", 0)) or None
ts = df.index[-1].to_pydatetime()
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
else:
ts = datetime.now(timezone.utc)
except Exception:
ts = datetime.now(timezone.utc)
pre_val = None
post_val = None
price_val = regular_val
market_state = None
return { return {
"ticker": ticker.upper(), "ticker": ticker.upper(),
"price": float(price) if price is not None else None, "price": float(price_val) if price_val is not None else None,
"regular_price": float(regular) if regular is not None else None, "regular_price": float(regular_val) if regular_val is not None else None,
"pre_market_price": float(pre) if pre is not None else None, "pre_market_price": pre_val,
"post_market_price": float(post) if post is not None else None, "post_market_price": post_val,
"currency": currency, "currency": currency,
"exchange": exchange, "exchange": exchange,
"market_state": market_state, "market_state": market_state,
@ -268,9 +324,6 @@ class PriceDataService:
"source": DataSource.YAHOO_FINANCE, "source": DataSource.YAHOO_FINANCE,
"delayed": True, "delayed": True,
} }
except Exception as e:
logger.error(f"Error fetching quote for {ticker}: {str(e)}")
raise
async def get_intraday(self, ticker: str, interval: str = "1m", period: str = "1d") -> List[Dict]: async def get_intraday(self, ticker: str, interval: str = "1m", period: str = "1d") -> List[Dict]:
"""Get intraday candles using yfinance-plus history with period/interval.""" """Get intraday candles using yfinance-plus history with period/interval."""
@ -487,6 +540,15 @@ class PriceDataService:
price_date = date_idx.to_pydatetime() price_date = date_idx.to_pydatetime()
if price_date.tzinfo is None: if price_date.tzinfo is None:
price_date = price_date.replace(tzinfo=timezone.utc) price_date = price_date.replace(tzinfo=timezone.utc)
# Normalize to UTC midnight so uq_price_data(ticker, date) deduplicates
# correctly regardless of whether data came from yf.Ticker().history()
# (returns Eastern midnight = UTC 04:00) or yf.download() (returns UTC 00:00).
price_date = price_date.replace(hour=0, minute=0, second=0, microsecond=0,
tzinfo=timezone.utc)
close_val = _safe(row.get('Close'))
if close_val is None: # Skip rows with no valid close price
continue
target_rows = live_rows if price_date.date() >= today else historical_rows target_rows = live_rows if price_date.date() >= today else historical_rows
target_rows.append({ target_rows.append({
@ -496,9 +558,9 @@ class PriceDataService:
'open': _safe(row.get('Open')), 'open': _safe(row.get('Open')),
'high': _safe(row.get('High')), 'high': _safe(row.get('High')),
'low': _safe(row.get('Low')), 'low': _safe(row.get('Low')),
'close': _safe(row.get('Close')) or 0.0, 'close': close_val,
'volume': _safe(row.get('Volume')), 'volume': _safe(row.get('Volume')),
'adjusted_close': _safe(row.get('Close')), 'adjusted_close': close_val,
'data_source': DataSource.YAHOO_FINANCE.value, 'data_source': DataSource.YAHOO_FINANCE.value,
'created_at': now, 'created_at': now,
'updated_at': now, 'updated_at': now,
@ -507,13 +569,23 @@ class PriceDataService:
if not historical_rows and not live_rows: if not historical_rows and not live_rows:
return return
# Yahoo-adjusted historical prices are not point-in-time stable: future # Historical rows: preserve valid data but overwrite null/zero-close garbage
# dividends/splits can rewrite old OHLC values. Preserve existing # (yf.download MultiIndex parse failures leave close=0 rows that must self-heal).
# historical rows and only update today's row, where intraday partials
# legitimately need EOD replacement.
if historical_rows: if historical_rows:
stmt = pg_insert(PriceData).values(historical_rows) stmt = pg_insert(PriceData).values(historical_rows)
stmt = stmt.on_conflict_do_nothing(constraint='uq_price_data') stmt = stmt.on_conflict_do_update(
constraint='uq_price_data',
set_={
'open': stmt.excluded.open,
'high': stmt.excluded.high,
'low': stmt.excluded.low,
'close': stmt.excluded.close,
'volume': stmt.excluded.volume,
'adjusted_close': stmt.excluded.adjusted_close,
'updated_at': stmt.excluded.updated_at,
},
where=or_(PriceData.close.is_(None), PriceData.close == 0.0),
)
await db.execute(stmt) await db.execute(stmt)
if live_rows: if live_rows:
@ -794,7 +866,7 @@ class PriceDataService:
logger.debug(f"_batch_check_missing_periods: end_date includes today — forcing re-fetch for all {len(tickers)} tickers") logger.debug(f"_batch_check_missing_periods: end_date includes today — forcing re-fetch for all {len(tickers)} tickers")
return list(tickers) return list(tickers)
# Single query to check all tickers at once # Single query to check all tickers at once — count only valid rows
from sqlalchemy import func, case from sqlalchemy import func, case
result = await db.execute( result = await db.execute(
@ -808,7 +880,9 @@ class PriceDataService:
and_( and_(
PriceData.ticker.in_(tickers), PriceData.ticker.in_(tickers),
PriceData.date >= start_date, PriceData.date >= start_date,
PriceData.date <= end_date PriceData.date <= end_date,
PriceData.close.is_not(None),
PriceData.close > 0,
) )
) )
.group_by(PriceData.ticker) .group_by(PriceData.ticker)
@ -918,8 +992,15 @@ class PriceDataService:
# Handle different data structures from yfinance bulk download # Handle different data structures from yfinance bulk download
if len(tickers) == 1: if len(tickers) == 1:
# Single ticker - data is a simple DataFrame # yf.download with group_by='ticker' returns MultiIndex columns even for a
await self._store_ticker_data(db, tickers[0], bulk_data, interval) # single ticker: [('SGOV', 'Open'), ('SGOV', 'Close'), ...]. Extract the
# ticker slice so _store_ticker_data receives a flat DataFrame.
ticker = tickers[0]
if hasattr(bulk_data.columns, 'levels') and ticker in bulk_data.columns.get_level_values(0):
ticker_data = bulk_data[ticker]
else:
ticker_data = bulk_data
await self._store_ticker_data(db, ticker, ticker_data, interval)
else: else:
# Multiple tickers - data is grouped by ticker # Multiple tickers - data is grouped by ticker
for ticker in tickers: for ticker in tickers:
@ -960,6 +1041,13 @@ class PriceDataService:
price_date = date_idx.to_pydatetime() price_date = date_idx.to_pydatetime()
if price_date.tzinfo is None: if price_date.tzinfo is None:
price_date = price_date.replace(tzinfo=timezone.utc) price_date = price_date.replace(tzinfo=timezone.utc)
# Normalize to UTC midnight (same logic as _store_price_data)
price_date = price_date.replace(hour=0, minute=0, second=0, microsecond=0,
tzinfo=timezone.utc)
close_val = _safe(row.get('Close'))
if close_val is None: # Skip rows with no valid close price
continue
target_rows = live_rows if price_date.date() >= today else historical_rows target_rows = live_rows if price_date.date() >= today else historical_rows
target_rows.append({ target_rows.append({
@ -969,9 +1057,9 @@ class PriceDataService:
'open': _safe(row.get('Open')), 'open': _safe(row.get('Open')),
'high': _safe(row.get('High')), 'high': _safe(row.get('High')),
'low': _safe(row.get('Low')), 'low': _safe(row.get('Low')),
'close': _safe(row.get('Close')) or 0.0, 'close': close_val,
'volume': _safe(row.get('Volume')), 'volume': _safe(row.get('Volume')),
'adjusted_close': _safe(row.get('Close')), 'adjusted_close': close_val,
'data_source': DataSource.YAHOO_FINANCE.value, 'data_source': DataSource.YAHOO_FINANCE.value,
'created_at': now, 'created_at': now,
'updated_at': now, 'updated_at': now,
@ -982,7 +1070,19 @@ class PriceDataService:
if historical_rows: if historical_rows:
stmt = pg_insert(PriceData).values(historical_rows) stmt = pg_insert(PriceData).values(historical_rows)
stmt = stmt.on_conflict_do_nothing(constraint='uq_price_data') stmt = stmt.on_conflict_do_update(
constraint='uq_price_data',
set_={
'open': stmt.excluded.open,
'high': stmt.excluded.high,
'low': stmt.excluded.low,
'close': stmt.excluded.close,
'volume': stmt.excluded.volume,
'adjusted_close': stmt.excluded.adjusted_close,
'updated_at': stmt.excluded.updated_at,
},
where=or_(PriceData.close.is_(None), PriceData.close == 0.0),
)
await db.execute(stmt) await db.execute(stmt)
if live_rows: if live_rows:
@ -1002,7 +1102,7 @@ class PriceDataService:
) )
await db.execute(stmt) await db.execute(stmt)
logger.info( logger.info(
"Stored price records for %s: historical_insert_only=%d live_upsert=%d", "Stored price records for %s: historical=%d live=%d",
ticker, ticker,
len(historical_rows), len(historical_rows),
len(live_rows), len(live_rows),

Loading…
Cancel
Save