You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
686 lines
24 KiB
Python
686 lines
24 KiB
Python
"""
|
|
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
|
|
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.services.alpaca_price_service import AlpacaPriceService
|
|
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,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""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 source
|
|
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
|
|
price_data = await price_service.get_or_update_price_data(
|
|
db,
|
|
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 Alpaca (DB-backed)",
|
|
description=(
|
|
"Fetch OHLCV daily bars for up to ~500 tickers. Results are stored in DB so "
|
|
"subsequent calls only fetch new/missing dates from Alpaca.\n\n"
|
|
"- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n"
|
|
"- Ticker normalization: `BF-B` → `BF.B` handled automatically; "
|
|
"response keys use the original symbol names.\n"
|
|
"- `force_refresh=true`: re-fetch all from Alpaca regardless of DB state.\n"
|
|
"- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`."
|
|
),
|
|
tags=["price", "alpaca"],
|
|
)
|
|
async def get_multi_ticker_daily_bars(
|
|
tickers: str = Query(..., description="Comma-separated tickers, e.g. AAPL,MSFT,BF-B"),
|
|
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, 1mo"),
|
|
force_refresh: bool = Query(False, description="Re-fetch from Alpaca even if DB has data"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Multi-ticker daily bars via Alpaca with DB storage (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.")
|
|
|
|
svc = AlpacaPriceService()
|
|
if not svc.is_available():
|
|
raise HTTPException(status_code=503, detail="Alpaca API keys not configured.")
|
|
|
|
start_dt = datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
end_dt = datetime.combine(end_date, datetime.min.time()).replace(tzinfo=timezone.utc)
|
|
|
|
try:
|
|
data = await svc.get_or_fetch_multi_bars(
|
|
db, symbols, start_dt, end_dt, interval, force_refresh
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(status_code=502, detail=f"Alpaca API error: {e}")
|
|
finally:
|
|
await svc.client.close()
|
|
|
|
bars = {
|
|
ticker: [
|
|
{
|
|
"date": row.date.date().isoformat(),
|
|
"open": row.open,
|
|
"high": row.high,
|
|
"low": row.low,
|
|
"close": row.close,
|
|
"volume": row.volume,
|
|
}
|
|
for row in rows
|
|
]
|
|
for ticker, rows in data.items()
|
|
}
|
|
|
|
return AlpacaMultiBarsResponse(
|
|
interval=interval,
|
|
count=len(symbols),
|
|
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"),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""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, db)
|
|
|
|
@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,
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""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)
|
|
# Make start_date timezone-aware if it's naive
|
|
if start_date and start_date.tzinfo is None:
|
|
start_date = start_date.replace(tzinfo=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(
|
|
db=db,
|
|
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()
|
|
data = await svc.get_quote(ticker, use_prepost=use_prepost)
|
|
return QuoteResponse(**data)
|
|
|
|
@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()
|
|
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)}
|
|
)
|
|
|
|
@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()
|
|
data = await svc.get_today_ohlc(ticker)
|
|
return TodayOHLCResponse(**data) |