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.

749 lines
27 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",
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"
"- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n"
"- `interval`: `1m` (7 days), `5m`/`15m`/`30m` (60 days), `1h` (730 days)\n"
"- No subscription required — uses yfinance free data.\n"
"- **No DB cache** — Redis 5-min TTL for live data."
),
)
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()
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)