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.

672 lines
26 KiB
Python

"""
Financial 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, AsyncSessionLocal
from app.schemas.financial import (
FinancialDataRequest,
FinancialDataResponse,
BulkFinancialDataRequest,
BulkFinancialDataResponse,
BulkFinancialDataItem,
ErrorResponse,
ErrorType,
CompanyInfo,
FinancialDataPoint,
CalculatedMetricsData
)
from app.services.sec_data_service import SECDataService
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=FinancialDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
404: {"model": ErrorResponse, "description": "Data not found"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get SEC EDGAR financial data for a ticker",
description="""
Retrieve comprehensive financial data directly from SEC EDGAR filings for a specific ticker and time period.
**🔥 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"
- Examples: `{"ticker": "AAPL", "period": "1y"}` - Last 1 year of data
- Example: `{"ticker": "TSLA", "period": "max"}` - All available data from listing date to SEC limits
2. **Date Range** (Traditional):
- `start_date` + `end_date`: Specific date range
- Example: `{"ticker": "AAPL", "start_date": "2024-01-01", "end_date": "2024-12-31"}`
3. **Quarters** (Quarter-based):
- `quarters`: List of quarters like ["2024Q1", "2024Q2"]
- Example: `{"ticker": "AAPL", "quarters": ["2024Q1", "2024Q2", "2024Q3"]}`
**Data Sources:**
- **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)
- **Price Data**: Available via separate price data endpoints using yfinance-plus
**This endpoint returns:**
- Company information (name, CIK, sector, industry)
- Financial statements data from SEC filings (income statement, balance sheet, cash flow)
- Calculated financial metrics (ratios, margins, growth rates)
- Period types: quarterly (10-Q) and annual (10-K) filings
**Performance Features:**
- Database caching to avoid repeated SEC API calls
- Historical data available from 1994-present
- 15+ years of data typically available for most companies
- Use `force_refresh=true` to fetch fresh data from SEC EDGAR
**Data Quality:**
- All financial data sourced directly from official SEC filings
- No estimated or synthetic data - only actual reported figures
- Automatic validation and error handling for missing periods
**Example Requests:**
```json
// Using period (simplest)
{
"ticker": "AAPL",
"period": "1y",
"include_metrics": true
}
// Using date range
{
"ticker": "MSFT",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"period_type": "quarterly"
}
// Using quarters
{
"ticker": "GOOGL",
"quarters": ["2024Q1", "2024Q2"],
"include_metrics": true
}
```
"""
)
async def get_financial_data(
request: FinancialDataRequest,
response: Response,
db: AsyncSession = Depends(get_db)
):
"""Get financial data for a ticker using period, quarters, or date range"""
try:
# Use the updated service that handles period resolution
from app.services.financial_service import FinancialService
financial_service = FinancialService()
# Resolve time parameters for metadata
from app.utils.date_utils import resolve_time_parameters
resolved_start, resolved_end = resolve_time_parameters(
request.start_date, request.end_date, request.quarters, request.period, request.ticker,
ticker_max_range_fn=financial_service._get_ticker_max_range
)
# Build cache key using normalized inputs
cache_key = build_cache_key(
"financial:data",
request.ticker.upper(),
request.period_type.value if hasattr(request.period_type, 'value') else str(request.period_type),
"metrics" if request.include_metrics else "no-metrics",
(resolved_start.date().isoformat() if resolved_start else ""),
(resolved_end.date().isoformat() if resolved_end else ""),
)
# Financial data is released quarterly — use 24 h TTL
_FIN_TTL = 60 * 60 * 24
# Try cache unless 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={_FIN_TTL}"
response.headers["ETag"] = etag
return cached_body
data = await financial_service.get_or_create_company_data(
db,
request.ticker,
start_date=request.start_date,
end_date=request.end_date,
quarters=request.quarters,
period=request.period,
force_refresh=request.force_refresh
)
# Format response
company = data["company"]
financial_data = data["financial_data"]
calculated_metrics = data["calculated_metrics"]
# Convert to response models
company_info = CompanyInfo(
ticker=company.ticker,
name=company.name,
cik=company.cik,
sector=company.sector,
industry=company.industry,
business_description=company.business_description
)
# Filter financial data by period type
if request.period_type != "all":
financial_data = [fd for fd in financial_data if fd.period_type == request.period_type]
# Merge financial data with calculated metrics
financial_points = []
for fd in financial_data:
# Convert to dict for merging
fd_dict = fd.__dict__ if hasattr(fd, '__dict__') else {}
# Find matching calculated metrics for this period
matching_metrics = None
if request.include_metrics and calculated_metrics:
for cm in calculated_metrics:
if cm.period_date == fd.period_date:
matching_metrics = cm
break
# Merge metrics into financial data point
if matching_metrics:
fd_dict.update({
'pe_ratio': matching_metrics.pe_ratio,
'pb_ratio': matching_metrics.pb_ratio,
'ps_ratio': matching_metrics.ps_ratio,
'roe': matching_metrics.roe,
'roa': matching_metrics.roa,
'gross_margin': matching_metrics.gross_margin,
'operating_margin': matching_metrics.operating_margin,
'net_margin': matching_metrics.net_margin,
'debt_to_equity': matching_metrics.debt_to_equity,
'debt_to_assets': matching_metrics.debt_to_assets,
'ocf_margin': matching_metrics.ocf_margin,
'fcf_margin': matching_metrics.fcf_margin,
'market_cap': matching_metrics.market_cap
})
financial_points.append(FinancialDataPoint.model_validate(fd_dict))
# Calculate actual date range from returned data
actual_start_date = resolved_start
actual_end_date = resolved_end
if financial_points:
# Get actual start and end dates from the financial data
actual_start_date = min(point.period_date for point in financial_points)
actual_end_date = max(point.period_date for point in financial_points)
body = FinancialDataResponse(
company=company_info,
financial_data=financial_points,
metadata={
"request_id": str(request.ticker),
"data_points": len(financial_points),
"period_type": request.period_type.value,
"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()
}
)
etag = await set_cached_response(cache_key, body.model_dump(), ttl_seconds=_FIN_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={_FIN_TTL}"
response.headers["ETag"] = etag
return body
except HTTPException:
raise
except ValueError as e:
if "No data returned" in str(e):
raise HTTPException(
status_code=404,
detail={
"error_type": ErrorType.DATA_NOT_FOUND,
"message": f"No financial data found for ticker {request.ticker}",
"detail": {
"ticker": request.ticker,
"period": f"{request.start_date} to {request.end_date}"
}
}
)
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=FinancialDataResponse,
summary="Get financial data by ticker (simplified)",
description="""
Simplified GET endpoint to retrieve financial 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/financial/data/AAPL?period=1y&include_metrics=true` - Last year of financial data
- `/api/v1/financial/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&period_type=quarterly` - Specific date range
"""
)
async def get_financial_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)"),
period_type: str = Query("all", description="Period type: quarterly, annual, or all"),
include_metrics: bool = Query(True, description="Include calculated metrics"),
force_refresh: bool = Query(False, description="Force refresh from SEC"),
db: AsyncSession = Depends(get_db)
):
"""Simplified GET endpoint for financial 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 = FinancialDataRequest(
ticker=ticker,
period=period,
period_type=period_type,
include_metrics=include_metrics,
force_refresh=force_refresh
)
else:
request = FinancialDataRequest(
ticker=ticker,
start_date=start_date,
end_date=end_date,
period_type=period_type,
include_metrics=include_metrics,
force_refresh=force_refresh
)
return await get_financial_data(request, response, db)
@router.post(
"/data/bulk",
response_model=BulkFinancialDataResponse,
responses={
400: {"model": ErrorResponse, "description": "Invalid request parameters"},
500: {"model": ErrorResponse, "description": "Internal server error"}
},
summary="Get SEC EDGAR financial data for multiple tickers",
description="""
Retrieve comprehensive financial data for multiple tickers in a single request directly from SEC EDGAR filings.
**🔥 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 1 year for multiple tickers, or "max" for all available 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 Sources:**
- **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)
- **Price Data**: Available via separate price data endpoints using yfinance-plus
**Bulk Processing Features:**
- Processes up to 100 tickers in parallel for maximum efficiency
- Returns individual success/failure results for each ticker
- Handles partial failures gracefully (some tickers can fail while others succeed)
- Uses the same robust SEC data retrieval logic as single ticker endpoint
**SEC EDGAR Integration:**
- Direct API calls to official SEC EDGAR database
- All financial data sourced from actual SEC filings (10-K, 10-Q)
- No estimated or synthetic data - only actual reported figures
- Historical data available from 1994-present (15+ years for most companies)
- Automatic validation and error handling for missing periods
**Data Quality & Features:**
- Company information (name, CIK, sector, industry, business description)
- Comprehensive financial statements (income statement, balance sheet, cash flow)
- Calculated financial metrics (ratios, margins, growth rates)
- Period types: quarterly (10-Q) and annual (10-K) filings
- Database caching to avoid repeated SEC API calls
**Performance:**
- Parallel processing for bulk requests
- Intelligent caching and rate limiting
- Use `force_refresh=true` to fetch fresh data from SEC EDGAR
**Example Requests:**
```json
// Using period (simplest)
{
"tickers": ["AAPL", "MSFT", "GOOGL"],
"period": "1y",
"include_metrics": true
}
// Using date range
{
"tickers": ["NVDA", "AMD", "INTC"],
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"period_type": "quarterly"
}
// Using quarters
{
"tickers": ["TSLA", "F", "GM"],
"quarters": ["2024Q1", "2024Q2"],
"include_metrics": true
}
```
Each ticker result includes the same comprehensive financial data structure as the single ticker endpoint.
Failed tickers will have detailed error messages while successful ones will have complete SEC filing data.
"""
)
async def get_bulk_financial_data(
request: BulkFinancialDataRequest,
db: AsyncSession = Depends(get_db)
):
"""Get financial data for multiple tickers"""
# Convert time parameters to dates using the same logic as single endpoint
if request.period:
# Use period approach - import the parse_period function
from app.utils.date_utils import parse_period
try:
start_date, end_date = parse_period(request.period)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": f"Invalid period format: {str(e)}"
}
)
elif request.quarters:
try:
start_date, end_date = quarters_to_date_range(request.quarters)
except ValueError as e:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.VALIDATION_ERROR,
"message": str(e)
}
)
else:
# Convert date to datetime for internal processing
start_date = datetime.combine(request.start_date, datetime.min.time()).replace(tzinfo=timezone.utc) if request.start_date else None
end_date = datetime.combine(request.end_date, datetime.max.time()).replace(tzinfo=timezone.utc) if request.end_date else None
# Validate date range
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"
}
)
# Check if requested period is valid (SEC data available from 1994)
if start_date and start_date.year < settings.SEC_DATA_START_YEAR:
raise HTTPException(
status_code=400,
detail={
"error_type": ErrorType.INVALID_PERIOD,
"message": f"SEC data is only available from {settings.SEC_DATA_START_YEAR}",
"detail": {
"requested_start": start_date.isoformat(),
"earliest_available": f"{settings.SEC_DATA_START_YEAR}-01-01"
}
}
)
# 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"
}
)
import asyncio
results = []
successful_count = 0
failed_count = 0
async def process_ticker(ticker: str):
"""Process a single ticker and return result"""
try:
# Use an isolated DB session per task to avoid concurrent use of a single session
async with AsyncSessionLocal() as session:
# Get data using existing service
sec_service = SECDataService()
data = await sec_service.get_or_update_company_data(
session,
ticker,
start_date,
end_date,
request.force_refresh
)
# Format response
company = data["company"]
financial_data = data["financial_data"]
calculated_metrics = data["calculated_metrics"] # Always include metrics
# Convert to response models
company_info = CompanyInfo(
ticker=company.ticker,
name=company.name,
cik=company.cik,
sector=company.sector,
industry=company.industry,
business_description=company.business_description
)
# Filter financial data by period type
if request.period_type != "all":
financial_data = [fd for fd in financial_data if fd.period_type == request.period_type]
# Merge financial data with calculated metrics
financial_points = []
for fd in financial_data:
# Convert to dict for merging
fd_dict = fd.__dict__ if hasattr(fd, '__dict__') else {}
# Find matching calculated metrics for this period
matching_metrics = None
if request.include_metrics and calculated_metrics:
for cm in calculated_metrics:
if cm.period_date == fd.period_date:
matching_metrics = cm
break
# Merge metrics into financial data point
if matching_metrics:
fd_dict.update({
'pe_ratio': matching_metrics.pe_ratio,
'pb_ratio': matching_metrics.pb_ratio,
'ps_ratio': matching_metrics.ps_ratio,
'roe': matching_metrics.roe,
'roa': matching_metrics.roa,
'gross_margin': matching_metrics.gross_margin,
'operating_margin': matching_metrics.operating_margin,
'net_margin': matching_metrics.net_margin,
'debt_to_equity': matching_metrics.debt_to_equity,
'debt_to_assets': matching_metrics.debt_to_assets,
'ocf_margin': matching_metrics.ocf_margin,
'fcf_margin': matching_metrics.fcf_margin,
'market_cap': matching_metrics.market_cap
})
financial_points.append(FinancialDataPoint.model_validate(fd_dict))
# Calculate actual date range from returned data
actual_start_date = start_date
actual_end_date = end_date
if financial_points:
# Get actual start and end dates from the financial data
actual_start_date = min(point.period_date for point in financial_points)
actual_end_date = max(point.period_date for point in financial_points)
response = FinancialDataResponse(
company=company_info,
financial_data=financial_points,
metadata={
"request_id": str(ticker),
"data_points": len(financial_points),
"period_type": request.period_type.value,
"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()
}
)
return BulkFinancialDataItem(
ticker=ticker,
success=True,
data=response,
error=None
)
except Exception as e:
# Handle individual ticker failure
error_message = str(e)
if "No data returned" in error_message:
error_message = f"No financial data found for ticker {ticker}"
elif "Invalid ticker" in error_message:
error_message = f"Invalid or unknown ticker: {ticker}"
return BulkFinancialDataItem(
ticker=ticker,
success=False,
data=None,
error=error_message
)
# Process all tickers in parallel with concurrency limit
semaphore = asyncio.Semaphore(10) # Limit concurrent operations to avoid overwhelming DB/APIs
async def process_with_limit(ticker: str):
async with semaphore:
return await process_ticker(ticker)
# Execute all tickers in parallel (300s endpoint-level timeout)
tasks = [process_with_limit(ticker) for ticker in request.tickers]
try:
results = await asyncio.wait_for(
asyncio.gather(*tasks, return_exceptions=True),
timeout=300,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Bulk financial data request timed out after 300s.")
# Count successful and failed results
successful_count = 0
failed_count = 0
for i, result in enumerate(results):
if isinstance(result, Exception):
# Handle unexpected exceptions
error_message = f"Unexpected error processing {request.tickers[i]}: {str(result)}"
results[i] = BulkFinancialDataItem(
ticker=request.tickers[i],
success=False,
data=None,
error=error_message
)
failed_count += 1
elif result.success:
successful_count += 1
else:
failed_count += 1
return BulkFinancialDataResponse(
results=results,
metadata={
"total_requested": len(request.tickers),
"successful": successful_count,
"failed": failed_count,
"period_type": request.period_type.value,
"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()
}
)