""" Price data endpoints """ from datetime import datetime, timezone, date, timedelta from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db, AsyncSessionLocal from app.schemas.financial import ( PriceDataRequest, PriceDataResponse, BulkPriceDataRequest, BulkPriceDataResponse, BulkPriceDataItem, PriceDataPoint, ErrorResponse, ErrorType, QuoteResponse, IntradayResponse, IntradayCandle, TodayOHLCResponse, ) from app.services.price_data_service import PriceDataService from app.schemas.financial import AlpacaMultiBarsResponse from app.core.config import settings from app.utils.date_utils import quarters_to_date_range from app.utils.cache import ( build_cache_key, get_cached_response, set_cached_response, ) router = APIRouter() @router.post( "/data", response_model=PriceDataResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid request parameters"}, 404: {"model": ErrorResponse, "description": "Data not found"}, 500: {"model": ErrorResponse, "description": "Internal server error"} }, summary="Get enhanced price data via yfinance-plus", description=""" Retrieve historical price data for a specific ticker using enhanced yfinance-plus integration. **๐Ÿ”ฅ Three Ways to Specify Time Period (choose one):** 1. **Period String** (NEW! Most convenient): - `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max" - Example: `{"ticker": "AAPL", "period": "3m", "interval": "1d"}` - Last 3 months, daily prices - Example: `{"ticker": "TSLA", "period": "max", "interval": "1d"}` - Maximum 20 years of data 2. **Date Range** (Traditional): - `start_date` + `end_date`: Specific date range - Example: `{"ticker": "AAPL", "start_date": "2024-01-01", "end_date": "2024-12-31", "interval": "1d"}` 3. **Quarters** (Quarter-based): - `quarters`: List of quarters like ["2024Q1", "2024Q2"] - Example: `{"ticker": "AAPL", "quarters": ["2024Q1", "2024Q2"], "interval": "1d"}` **Data Source:** - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching - **Financial Data**: Available via separate financial endpoints using SEC EDGAR **This endpoint returns:** - OHLCV data (Open, High, Low, Close, Volume) - Adjusted close prices with dividend/split adjustments - Multiple intervals: 1d, 1w, 1m, 1h (where available) - Extensive historical data (decades for most symbols) **Enhanced Features (yfinance-plus):** - Intelligent rate limiting to prevent API throttling - Multi-threaded bulk downloads for better performance - Advanced caching with cache management - Automatic retry with exponential backoff - Multiple user agents for improved reliability - Enhanced error handling and recovery **Performance:** - Database caching to minimize external API calls - Bulk mode capable of 59+ tickers/second throughput - 4.3x faster than individual ticker requests - Use `force_refresh=true` to fetch fresh data from Yahoo Finance **Example Requests:** ```json // Using period (simplest) { "ticker": "AAPL", "period": "6m", "interval": "1d" } // Using date range { "ticker": "TSLA", "start_date": "2024-01-01", "end_date": "2024-12-31", "interval": "1w" } // Using quarters { "ticker": "NVDA", "quarters": ["2024Q1", "2024Q2"], "interval": "1d", "force_refresh": true } ``` """ ) async def get_price_data( request: PriceDataRequest, response: Response, ): """Get price data for a ticker using period, quarters, or date range""" try: # Use the updated service that handles period resolution price_service = PriceDataService() # Resolve time parameters to get start and end dates from app.utils.date_utils import resolve_time_parameters start_date, end_date = resolve_time_parameters( start_date=request.start_date, end_date=request.end_date, quarters=request.quarters, period=request.period ) # Build cache key (normalized to resolved dates) cache_key = build_cache_key( "price:data", request.ticker.upper(), request.interval, start_date.date().isoformat() if start_date else "", end_date.date().isoformat() if end_date else "", ) # Try cache (skip if force_refresh) if not request.force_refresh: cached = await get_cached_response(cache_key) if cached: cached_body, etag = cached response.headers["X-Cache"] = "HIT" response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}" response.headers["ETag"] = etag response.headers["X-Data-Source"] = "redis-cache" return cached_body # Check if we have existing data to determine X-Data-Source header (short session) async with AsyncSessionLocal() as db: missing_periods = await price_service._check_missing_periods( db, request.ticker.upper(), start_date, end_date, request.interval ) # Determine data source if request.force_refresh: data_source = "yfinance-fresh" elif missing_periods: data_source = "yfinance-partial" else: data_source = "database-cache" # Add data source header response.headers["X-Data-Source"] = data_source # Get price data using resolved dates (service manages its own sessions) price_data = await price_service.get_or_update_price_data( request.ticker, start_date, end_date, request.interval, request.force_refresh ) if not price_data: raise HTTPException( status_code=404, detail={ "error_type": ErrorType.DATA_NOT_FOUND, "message": f"No price data found for ticker {request.ticker}", "detail": { "ticker": request.ticker, "period": f"{start_date} to {end_date}", "interval": request.interval } } ) # Convert to response models price_points = [ PriceDataPoint.model_validate(pd) for pd in price_data ] # Calculate actual date range from returned data actual_start_date = start_date actual_end_date = end_date if price_points: # Get actual start and end dates from the data actual_start_date = min(point.date for point in price_points) actual_end_date = max(point.date for point in price_points) body = PriceDataResponse( ticker=request.ticker.upper(), interval=request.interval, data=price_points, metadata={ "request_id": str(request.ticker), "data_points": len(price_points), "interval": request.interval, "quarters_requested": request.quarters if request.quarters else None, "date_range": { "start": actual_start_date.isoformat(), "end": actual_end_date.isoformat() }, "last_updated": datetime.now(timezone.utc).isoformat() } ) # Use extended TTL for purely historical date ranges (end_date < yesterday) _historical = ( end_date is not None and end_date.date() < date.today() - timedelta(days=1) ) cache_ttl = 60 * 60 * 24 * 7 if _historical else settings.CACHE_TTL # 7d vs 1h # Cache the response body body_dict = body.model_dump() etag = await set_cached_response(cache_key, body_dict, ttl_seconds=cache_ttl) response.headers["X-Cache"] = "MISS" response.headers["Cache-Control"] = f"public, max-age={cache_ttl}" response.headers["ETag"] = etag return body except HTTPException: # Let FastAPI HTTPExceptions (404, 400, etc.) propagate as-is raise except TimeoutError as e: raise HTTPException( status_code=503, detail={ "error_type": "TIMEOUT", "message": "Data fetch timed out. Please try again.", "detail": {"error": str(e)} } ) except ValueError as e: if "Yahoo Finance data source not available" in str(e): raise HTTPException( status_code=503, detail={ "error_type": ErrorType.SEC_API_ERROR, "message": "Yahoo Finance data source not available" } ) raise HTTPException( status_code=400, detail={ "error_type": ErrorType.PARSING_ERROR, "message": str(e) } ) except Exception as e: err_msg = str(e).lower() if "rate limit" in err_msg or "too many requests" in err_msg or "429" in err_msg: raise HTTPException( status_code=429, detail={ "error_type": ErrorType.RATE_LIMIT_ERROR, "message": "Yahoo Finance rate limit exceeded. Retry after a short delay.", "detail": {"error": str(e)} }, headers={"Retry-After": "30"} ) raise HTTPException( status_code=500, detail={ "error_type": ErrorType.DATABASE_ERROR, "message": "An error occurred while processing your request", "detail": {"error": str(e)} } ) @router.get( "/data", response_model=AlpacaMultiBarsResponse, summary="Get daily bars for multiple tickers via yfinance (DB-backed)", description=( "Fetch OHLCV daily bars for one or more tickers via Yahoo Finance (yfinance-plus). " "Results are stored in DB; subsequent calls for the same range skip the external API.\n\n" "- `tickers` or `ticker`: comma-separated list, e.g. `AAPL,MSFT` or single `QQQ`\n" "- `force_refresh=true`: re-fetch from Yahoo Finance even if DB has data\n" "- Up to 1000 tickers per request (auto-chunked internally)\n\n" "**๊ฒฝ๋กœ ํŒŒ๋ผ๋ฏธํ„ฐ ๋Œ€์•ˆ**: ๋‹จ์ผ ์ข…๋ชฉ์€ `/data/{ticker}?start_date=...&end_date=...` ๋„ ๋™์ผํ•˜๊ฒŒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค." ), tags=["price"], ) async def get_multi_ticker_daily_bars( tickers: Optional[str] = Query(None, description="Comma-separated tickers, e.g. AAPL,MSFT,QQQ"), ticker: Optional[str] = Query(None, description="Alias for tickers (single ticker shorthand)"), start_date: date = Query(..., description="Start date (YYYY-MM-DD)"), end_date: date = Query(..., description="End date (YYYY-MM-DD)"), interval: str = Query("1d", description="Bar interval: 1d, 1w, 1m"), force_refresh: bool = Query(False, description="Re-fetch from Yahoo Finance even if DB has data"), ): """Multi-ticker daily bars via yfinance with DB storage.""" raw = tickers or ticker if not raw: raise HTTPException(status_code=400, detail="No tickers provided. Use ?tickers=AAPL,MSFT or ?ticker=QQQ.") symbols = [s.strip().upper() for s in raw.split(",") if s.strip()] if not symbols: raise HTTPException(status_code=400, detail="No tickers provided.") if len(symbols) > 1000: raise HTTPException(status_code=400, detail="Maximum 1000 tickers per request.") start_dt = datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc) end_dt = datetime.combine(end_date, datetime.max.time()).replace(tzinfo=timezone.utc) price_service = PriceDataService() try: results, _, _ = await price_service.get_multiple_tickers_data_optimized( tickers=symbols, start_date=start_dt, end_date=end_dt, interval=interval, force_refresh=force_refresh, ) except Exception as e: raise HTTPException(status_code=500, detail=f"yfinance error: {e}") bars = {} for item in results: if item.success and item.data and item.data.data: bars[item.ticker] = [ { "date": point.date.isoformat(), "open": point.open, "high": point.high, "low": point.low, "close": point.close, "volume": point.volume, } for point in item.data.data ] return AlpacaMultiBarsResponse( source="YAHOO_FINANCE", interval=interval, count=len(bars), bars=bars, ) @router.get( "/data/{ticker}", response_model=PriceDataResponse, summary="Get price data by ticker (simplified)", description=""" Simplified GET endpoint to retrieve price data with query parameters. **Time Period Options:** - Use `period` for convenience: "1d", "7d", "1m", "3m", "6m", "1y", "2y", "5y", "max" - OR use `start_date` and `end_date` for specific date range - Cannot use both approaches simultaneously **Examples:** - `/api/v1/price/data/AAPL?period=1y&interval=1d` - Last year of daily prices - `/api/v1/price/data/TSLA?period=max&interval=1d` - Maximum 20 years of data for Tesla - `/api/v1/price/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&interval=1d` - Specific date range """ ) async def get_price_data_simple( ticker: str, response: Response, period: Optional[str] = Query(None, description="Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"), start_date: Optional[date] = Query(None, description="Start date for data retrieval (use with end_date, not with period)"), end_date: Optional[date] = Query(None, description="End date for data retrieval (use with start_date, not with period)"), start: Optional[date] = Query(None, description="Alias for start_date"), end: Optional[date] = Query(None, description="Alias for end_date"), interval: str = Query("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc."), force_refresh: bool = Query(False, description="Force refresh from Yahoo Finance"), ): """Simplified GET endpoint for price data""" # Resolve aliases: start/end โ†’ start_date/end_date if start and not start_date: start_date = start if end and not end_date: end_date = end # Validate that either period OR date range is provided, not both if period and (start_date or end_date): raise HTTPException( status_code=400, detail={ "error_type": ErrorType.VALIDATION_ERROR, "message": "Cannot specify both period and date range. Use either period OR start_date+end_date." } ) if not period and not (start_date and end_date): raise HTTPException( status_code=400, detail={ "error_type": ErrorType.VALIDATION_ERROR, "message": "Must specify either period OR both start_date and end_date." } ) # Create request based on provided parameters if period: request = PriceDataRequest( ticker=ticker, period=period, interval=interval, force_refresh=force_refresh ) else: request = PriceDataRequest( ticker=ticker, start_date=start_date, end_date=end_date, interval=interval, force_refresh=force_refresh ) return await get_price_data(request, response) @router.post( "/data/bulk", response_model=BulkPriceDataResponse, responses={ 400: {"model": ErrorResponse, "description": "Invalid request parameters"}, 500: {"model": ErrorResponse, "description": "Internal server error"} }, summary="Get enhanced price data for multiple tickers via yfinance-plus", description=""" Retrieve historical price data for multiple tickers in a single request using enhanced yfinance-plus integration. **๐Ÿ”ฅ Three Ways to Specify Time Period (choose one):** 1. **Period String** (NEW! Most convenient): - `period`: "1d", "7d", "30d", "1m", "3m", "6m", "1y", "2y", "5y", "max" - Example: Last 3 months for multiple tickers, or "max" for maximum 20 years of data 2. **Date Range** (Traditional): - `start_date` + `end_date`: Specific date range - Example: Specific date range for all tickers 3. **Quarters** (Quarter-based): - `quarters`: List of quarters like ["2024Q1", "2024Q2"] - Example: Specific quarters for all tickers **Data Source:** - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching - **Financial Data**: Available via separate financial endpoints using SEC EDGAR **Bulk Processing Features:** - Processes up to 100 tickers in parallel for maximum throughput - Returns individual success/failure results for each ticker - Handles partial failures gracefully (some tickers can fail while others succeed) - Uses the same enhanced data retrieval logic as single ticker endpoint **Enhanced Performance (yfinance-plus):** - Multi-threaded bulk downloads with intelligent rate limiting - 4.3x faster than individual ticker requests - Bulk mode capable of 59+ tickers/second throughput - Advanced caching and automatic retry with exponential backoff - Enhanced error handling and recovery mechanisms **Data Quality:** - OHLCV data with dividend/split adjustments - Multiple intervals: 1d, 1w, 1m, 1h (where available) - Extensive historical data (decades for most symbols) - Database caching to minimize external API calls **Example Requests:** ```json // Using period (simplest) { "tickers": ["AAPL", "MSFT", "GOOGL"], "period": "3m", "interval": "1d" } // Using date range { "tickers": ["NVDA", "AMD", "INTC"], "start_date": "2024-01-01", "end_date": "2024-12-31", "interval": "1w" } // Using quarters { "tickers": ["TSLA", "F", "GM"], "quarters": ["2024Q1", "2024Q2"], "interval": "1d", "force_refresh": true } ``` Each ticker result includes the same comprehensive price data structure as the single ticker endpoint. Failed tickers will have detailed error messages while successful ones will have complete OHLCV data. """ ) async def get_bulk_price_data( request: BulkPriceDataRequest, ): """Get price data for multiple tickers""" # Use the updated service that handles period resolution price_service = PriceDataService() # Resolve time parameters to get start and end dates from app.utils.date_utils import resolve_time_parameters start_date, end_date = resolve_time_parameters( start_date=request.start_date, end_date=request.end_date, quarters=request.quarters, period=request.period ) # Validate date range - ensure both dates are timezone-aware if start_date and start_date.tzinfo is None: start_date = start_date.replace(tzinfo=timezone.utc) if end_date and end_date.tzinfo is None: end_date = end_date.replace(tzinfo=timezone.utc) if start_date and end_date and start_date >= end_date: raise HTTPException( status_code=400, detail={ "error_type": ErrorType.VALIDATION_ERROR, "message": "Start date must be before end date" } ) # Future date check current_time = datetime.now(timezone.utc) if start_date and start_date > current_time: raise HTTPException( status_code=400, detail={ "error_type": ErrorType.INVALID_PERIOD, "message": "Cannot request data for future dates" } ) # Use optimized bulk processing method (300s endpoint-level timeout) import asyncio as _asyncio try: results, successful_count, failed_count = await _asyncio.wait_for( price_service.get_multiple_tickers_data_optimized( tickers=request.tickers, start_date=start_date, end_date=end_date, interval=request.interval, force_refresh=request.force_refresh ), timeout=300, ) except _asyncio.TimeoutError: raise HTTPException(status_code=504, detail="Bulk price data request timed out after 300s.") return BulkPriceDataResponse( results=results, metadata={ "total_requested": len(request.tickers), "successful": successful_count, "failed": failed_count, "interval": request.interval, "quarters_requested": request.quarters if request.quarters else None, "date_range": { "start": start_date.isoformat(), "end": end_date.isoformat() }, "force_refresh": request.force_refresh, "processed_at": datetime.now(timezone.utc).isoformat() } ) @router.get( "/latest/{ticker}", response_model=PriceDataPoint, summary="Get latest price for a ticker", description="Get the most recent price data point for a ticker" ) async def get_latest_price( ticker: str, db: AsyncSession = Depends(get_db) ): """Get latest price for a ticker""" try: price_service = PriceDataService() latest_price = await price_service.get_latest_price(db, ticker) if not latest_price: raise HTTPException( status_code=404, detail={ "error_type": ErrorType.DATA_NOT_FOUND, "message": f"No price data found for ticker {ticker}" } ) return PriceDataPoint.model_validate(latest_price) except Exception as e: err_msg = str(e).lower() if "rate limit" in err_msg or "too many requests" in err_msg or "429" in err_msg: raise HTTPException( status_code=429, detail={ "error_type": ErrorType.RATE_LIMIT_ERROR, "message": "Yahoo Finance rate limit exceeded. Retry after a short delay.", "detail": {"error": str(e)} }, headers={"Retry-After": "30"} ) raise HTTPException( status_code=500, detail={ "error_type": ErrorType.DATABASE_ERROR, "message": "An error occurred while processing your request", "detail": {"error": str(e)} } ) @router.get( "/quote/{ticker}", response_model=QuoteResponse, summary="Get latest quote (regular/pre/post)", description="Return latest price with regular/pre/post market fields from yfinance-plus" ) async def get_quote( ticker: str, use_prepost: bool = Query(True, description="Include pre/post market prices if available"), ): svc = PriceDataService() try: data = await svc.get_quote(ticker, use_prepost=use_prepost) 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( "/intraday", response_model=AlpacaMultiBarsResponse, summary="Get intraday bars for multiple tickers via Yahoo Finance", description=( "Fetch intraday OHLCV bars for up to ~500 tickers using Yahoo Finance.\n\n" "**โš ๏ธ Yahoo Finance ๋ถ„๋ด‰ ๋ฐ์ดํ„ฐ ํ•œ๊ณ„**\n\n" "| ํ•ญ๋ชฉ | ๋‚ด์šฉ |\n" "|------|------|\n" "| ์ง€์—ฐ | **15๋ถ„ ์ง€์—ฐ** (์‹ค์‹œ๊ฐ„ ์•„๋‹˜) |\n" "| `1m` ์ตœ๋Œ€ ์กฐํšŒ ๊ธฐ๊ฐ„ | ์ตœ๊ทผ **7์ผ** ์ด๋‚ด |\n" "| `2m`/`5m`/`15m`/`30m`/`90m` | ์ตœ๊ทผ **60์ผ** ์ด๋‚ด |\n" "| `1h` | ์ตœ๊ทผ **730์ผ** ์ด๋‚ด |\n" "| ์‹ค์‹œ๊ฐ„ ๊ฑฐ๋ž˜ ์ „๋žต | **๋ถ€์ ํ•ฉ** โ€” 15๋ถ„ ์ง€์—ฐ์œผ๋กœ ORB ๋“ฑ ๋‹น์ผ ์ „๋žต์— ์‚ฌ์šฉ ๋ถˆ๊ฐ€ |\n" "| ๋ฐ์ดํ„ฐ ํ’ˆ์งˆ | Yahoo Finance ์ž์ฒด ์ง‘๊ณ„, ๊ฐ„ํ—์  ๋ˆ„๋ฝ/์˜ค๋ฅ˜ ๊ฐ€๋Šฅ |\n\n" "**๊ถŒ์žฅ ์šฉ๋„**: ๋ฐฑํ…Œ์ŠคํŠธ, ๊ณผ๊ฑฐ ๋ถ„๋ด‰ ๋ถ„์„ (60์ผ ์ด๋‚ด)\n\n" "**์‹ค์‹œ๊ฐ„ ๋‹น์ผ ๋ถ„๋ด‰์ด ํ•„์š”ํ•˜๋ฉด** โ†’ `GET /api/v1/alpaca/intraday` ์‚ฌ์šฉ (Alpaca IEX ํ”ผ๋“œ, ์‹ค์‹œ๊ฐ„)\n\n" "- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n" "- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n" "- Redis 5๋ถ„ TTL ์บ์‹œ ์ ์šฉ" ), ) async def get_multi_ticker_intraday( tickers: str = Query(..., description="Comma-separated tickers"), interval: str = Query("5m", description="Interval: 1m, 5m, 15m, 30m, 1h"), start_date: Optional[date] = Query(None, description="Start date (YYYY-MM-DD)"), end_date: Optional[date] = Query(None, description="End date (YYYY-MM-DD)"), response: Response = None, ): """Multi-ticker intraday bars via Yahoo Finance (ORB engine interface).""" symbols = [s.strip().upper() for s in tickers.split(",") if s.strip()] if not symbols: raise HTTPException(status_code=400, detail="No tickers provided.") if len(symbols) > 1000: raise HTTPException(status_code=400, detail="Maximum 1000 tickers per request.") # Redis cache (5-min TTL for intraday data) import hashlib tickers_hash = hashlib.sha256(",".join(sorted(symbols)).encode()).hexdigest()[:16] cache_key = build_cache_key("price:intraday", tickers_hash, interval, start_date.isoformat() if start_date else "none", end_date.isoformat() if end_date else "none") cached = await get_cached_response(cache_key) if cached: cached_body, etag = cached if response is not None: response.headers["X-Cache"] = "HIT" return cached_body svc = PriceDataService() try: data = await svc.get_multi_intraday( tickers=symbols, interval=interval, start_date=start_date, end_date=end_date or date.today(), ) except Exception as e: raise HTTPException(status_code=502, detail=f"Yahoo Finance error: {e}") body = AlpacaMultiBarsResponse( source="YAHOO_FINANCE", interval=interval, count=len(symbols), bars=data, ) body_dict = body.model_dump() await set_cached_response(cache_key, body_dict, ttl_seconds=300) # 5๋ถ„ TTL if response is not None: response.headers["X-Cache"] = "MISS" return body @router.get( "/intraday/{ticker}", response_model=IntradayResponse, summary="Get intraday candles", description="Return intraday candles using yfinance-plus history(period,interval)" ) async def get_intraday( ticker: str, interval: str = Query("1m"), period: str = Query("1d"), ): svc = PriceDataService() try: candles = await svc.get_intraday(ticker, interval=interval, period=period) return IntradayResponse( ticker=ticker.upper(), interval=interval, period=period, candles=[IntradayCandle(**c) for c in 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( "/today/{ticker}", response_model=TodayOHLCResponse, summary="Get today's OHLC", description="Return today's OHLC. If daily not finalized yet, aggregate from 1m intraday." ) async def get_today_ohlc( ticker: str, ): svc = PriceDataService() try: data = await svc.get_today_ohlc(ticker) 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)})