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.

567 lines
20 KiB
Python

"""
Price data endpoints
"""
from datetime import datetime, timezone, date
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.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()
}
)
# Cache the response body
body_dict = body.model_dump()
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
return body
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:
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/{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)"),
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"""
# 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:
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)