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.

551 lines
18 KiB
Python

"""
Pydantic schemas for API requests and responses
"""
from datetime import datetime, date, timezone
from typing import Optional, Dict, List, Any
from pydantic import BaseModel, Field, ConfigDict, field_validator
from enum import Enum
import re
from .validators import (
validate_period_field,
validate_quarters_field,
validate_time_approaches,
validate_end_date_field,
validate_interval_field,
)
class PeriodType(str, Enum):
QUARTERLY = "quarterly"
ANNUAL = "annual"
ALL = "all"
class DataSource(str, Enum):
SEC_EDGAR = "SEC_EDGAR"
YAHOO_FINANCE = "YAHOO_FINANCE"
ALPACA = "ALPACA"
ALPHA_VANTAGE = "ALPHA_VANTAGE"
MOCK = "MOCK"
class ErrorType(str, Enum):
PARSING_ERROR = "PARSING_ERROR"
DATA_NOT_FOUND = "DATA_NOT_FOUND"
INVALID_PERIOD = "INVALID_PERIOD"
SEC_API_ERROR = "SEC_API_ERROR"
DATABASE_ERROR = "DATABASE_ERROR"
VALIDATION_ERROR = "VALIDATION_ERROR"
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR"
RATE_LIMIT_ERROR = "RATE_LIMIT_ERROR"
# Request Schemas
class FinancialDataRequest(BaseModel):
"""
Request for financial data with flexible time period specification.
**Three ways to specify time period (choose one):**
1. **Date Range**: Use start_date and end_date
2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])
3. **Period**: Use period string (e.g., '1d', '3m', '2y')
**Important**: Cannot mix approaches in the same request.
"""
ticker: str = Field(..., min_length=1, max_length=10, description="Stock ticker symbol")
# Date range approach
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
period_type: PeriodType = Field(PeriodType.ALL, description="Type of financial periods to retrieve")
include_metrics: bool = Field(True, description="Include calculated metrics in response")
force_refresh: bool = Field(False, description="Force refresh data from SEC")
@field_validator('period')
@classmethod
def validate_period(cls, v):
return validate_period_field(cls, v)
@field_validator('quarters')
@classmethod
def validate_quarters(cls, v):
return validate_quarters_field(cls, v)
@field_validator('start_date')
@classmethod
def validate_time_approaches(cls, v, info):
return validate_time_approaches(cls, v, info.data)
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v, info):
return validate_end_date_field(cls, v, info.data)
class BulkFinancialDataRequest(BaseModel):
tickers: List[str] = Field(..., min_items=1, max_items=500, description="List of stock ticker symbols (max 500 for efficient bulk processing)")
# Three approaches: use either date range, quarters, or period (not mix)
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
period_type: PeriodType = Field(PeriodType.ALL, description="Type of financial periods to retrieve")
include_metrics: bool = Field(True, description="Include calculated metrics in response")
force_refresh: bool = Field(False, description="Force refresh data from SEC")
@field_validator('quarters')
@classmethod
def validate_quarters(cls, v):
return validate_quarters_field(cls, v)
@field_validator('start_date')
@classmethod
def validate_dates_or_quarters(cls, v, info):
"""Ensure either dates or quarters are provided"""
quarters = info.data.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v, info):
"""Validate end_date if using date-based approach"""
start_date = info.data.get('start_date')
quarters = info.data.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
class PriceDataRequest(BaseModel):
"""
Request for price data with flexible time period specification.
**Three ways to specify time period (choose one):**
1. **Date Range**: Use start_date and end_date
2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])
3. **Period**: Use period string (e.g., '1d', '3m', '2y')
**Important**: Cannot mix approaches in the same request.
"""
ticker: str = Field(..., min_length=1, max_length=10, description="Stock ticker symbol")
# Date range approach
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
interval: str = Field("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc.")
force_refresh: bool = Field(False, description="Force refresh data from Yahoo Finance")
@field_validator('interval')
@classmethod
def validate_interval(cls, v):
return validate_interval_field(cls, v)
@field_validator('quarters')
@classmethod
def validate_quarters(cls, v):
return validate_quarters_field(cls, v)
@field_validator('start_date')
@classmethod
def validate_dates_or_quarters(cls, v, info):
"""Ensure either dates or quarters are provided"""
quarters = info.data.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v, info):
"""Validate end_date if using date-based approach"""
start_date = info.data.get('start_date')
quarters = info.data.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
class BulkPriceDataRequest(BaseModel):
tickers: List[str] = Field(..., min_items=1, max_items=500, description="List of stock ticker symbols (max 500 for efficient bulk processing)")
# Three approaches: use either date range, quarters, or period (not mix)
start_date: Optional[date] = Field(None, description="Start date for data retrieval. Cannot be used with quarters or period.")
end_date: Optional[date] = Field(None, description="End date for data retrieval. Cannot be used with quarters or period.")
# Quarter-based approach
quarters: Optional[List[str]] = Field(
None,
min_items=1,
max_items=40,
description="List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."
)
# Period-based approach
period: Optional[str] = Field(
None,
description="Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."
)
interval: str = Field("1d", description="Data interval: 1d, 1w, 1m, 5d, 1h, etc.")
force_refresh: bool = Field(False, description="Force refresh data from Yahoo Finance")
@field_validator('interval')
@classmethod
def validate_interval(cls, v):
return validate_interval_field(cls, v)
@field_validator('quarters')
@classmethod
def validate_quarters(cls, v):
return validate_quarters_field(cls, v)
@field_validator('start_date')
@classmethod
def validate_dates_or_quarters(cls, v, info):
"""Ensure either dates or quarters are provided"""
quarters = info.data.get('quarters')
if not v and not quarters:
raise ValueError("Either start_date/end_date or quarters must be provided")
if v and quarters:
raise ValueError("Cannot specify both date range and quarters - use one or the other")
return v
@field_validator('end_date')
@classmethod
def validate_end_date(cls, v, info):
"""Validate end_date if using date-based approach"""
start_date = info.data.get('start_date')
quarters = info.data.get('quarters')
if not quarters: # Using date-based approach
if not v:
raise ValueError("end_date is required when not using quarters")
if start_date and v <= start_date:
raise ValueError("end_date must be after start_date")
return v
# Response Schemas
class CompanyInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
ticker: str
name: str
cik: Optional[str] = None
sector: Optional[str] = None
industry: Optional[str] = None
business_description: Optional[str] = None
class FinancialDataPoint(BaseModel):
model_config = ConfigDict(from_attributes=True)
period_date: datetime
period_type: str
filing_type: Optional[str] = None
# Income Statement
revenue: Optional[float] = None
gross_profit: Optional[float] = None
operating_income: Optional[float] = None
net_income: Optional[float] = None
eps: Optional[float] = None
# Balance Sheet
total_assets: Optional[float] = None
total_equity: Optional[float] = None
total_debt: Optional[float] = None
cash: Optional[float] = None
shares_outstanding: Optional[float] = None
# Cash Flow
operating_cash_flow: Optional[float] = None
free_cash_flow: Optional[float] = None
capex: Optional[float] = None
# Calculated Metrics (계산된 지표들)
pe_ratio: Optional[float] = None
pb_ratio: Optional[float] = None
ps_ratio: Optional[float] = None
roe: Optional[float] = None
roa: Optional[float] = None
gross_margin: Optional[float] = None
operating_margin: Optional[float] = None
net_margin: Optional[float] = None
debt_to_equity: Optional[float] = None
debt_to_assets: Optional[float] = None
ocf_margin: Optional[float] = None
fcf_margin: Optional[float] = None
market_cap: Optional[float] = None
# Metadata
data_source: str
is_estimated: bool
class CalculatedMetricsData(BaseModel):
model_config = ConfigDict(from_attributes=True)
period_date: datetime
calculation_date: datetime
# Valuation (실제로 계산 가능한 것만)
pe_ratio: Optional[float] = None
pb_ratio: Optional[float] = None
ps_ratio: Optional[float] = None
# Profitability
roe: Optional[float] = None
roa: Optional[float] = None
gross_margin: Optional[float] = None
operating_margin: Optional[float] = None
net_margin: Optional[float] = None
# Liquidity & Solvency
debt_to_equity: Optional[float] = None
debt_to_assets: Optional[float] = None
# Cash Flow
ocf_margin: Optional[float] = None
fcf_margin: Optional[float] = None
# Market
market_cap: Optional[float] = None
class PriceDataPoint(BaseModel):
model_config = ConfigDict(from_attributes=True)
date: date
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
adjusted_close: Optional[float] = None
data_source: str
@field_validator('date', mode='before')
@classmethod
def convert_datetime_to_date(cls, v):
"""Convert datetime to date if needed"""
if isinstance(v, datetime):
return v.date()
return v
class AlpacaPriceDataPoint(BaseModel):
model_config = ConfigDict(from_attributes=True)
date: date
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
vwap: Optional[float] = None
trade_count: Optional[int] = None
data_source: str = "ALPACA"
@field_validator('date', mode='before')
@classmethod
def convert_datetime_to_date(cls, v):
if isinstance(v, datetime):
return v.date()
return v
class FinancialDataResponse(BaseModel):
company: CompanyInfo
financial_data: List[FinancialDataPoint]
metadata: Dict[str, Any] = Field(default_factory=dict)
class BulkFinancialDataItem(BaseModel):
ticker: str
success: bool
data: Optional[FinancialDataResponse] = None
error: Optional[str] = None
class BulkFinancialDataResponse(BaseModel):
results: List[BulkFinancialDataItem]
metadata: Dict[str, Any] = Field(default_factory=dict)
class PriceDataResponse(BaseModel):
ticker: str
interval: str
data: List[PriceDataPoint]
metadata: Dict[str, Any] = Field(default_factory=dict)
class BulkPriceDataItem(BaseModel):
ticker: str
success: bool
data: Optional[PriceDataResponse] = None
error: Optional[str] = None
class BulkPriceDataResponse(BaseModel):
results: List[BulkPriceDataItem]
metadata: Dict[str, Any] = Field(default_factory=dict)
class ErrorResponse(BaseModel):
error_type: ErrorType
message: str
detail: Optional[Dict[str, Any]] = None
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# New schemas for quote/intraday/today endpoints
class QuoteResponse(BaseModel):
ticker: str
price: float
regular_price: Optional[float] = None
pre_market_price: Optional[float] = None
post_market_price: Optional[float] = None
currency: Optional[str] = None
exchange: Optional[str] = None
market_state: Optional[str] = None
timestamp: datetime
source: str = Field("YAHOO_FINANCE")
delayed: Optional[bool] = True
class IntradayCandle(BaseModel):
timestamp: datetime
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
class IntradayResponse(BaseModel):
ticker: str
interval: str
period: str
candles: List[IntradayCandle]
metadata: Dict[str, Any] = Field(default_factory=dict)
class TodayOHLCResponse(BaseModel):
ticker: str
date: date
open: Optional[float] = None
high: Optional[float] = None
low: Optional[float] = None
close: float
volume: Optional[float] = None
source: str = Field("YAHOO_FINANCE")
method: str = Field("daily")
metadata: Dict[str, Any] = Field(default_factory=dict)
class DataCatalogItem(BaseModel):
field_name: str
description: str
data_type: str
unit: Optional[str] = None
calculation: Optional[str] = None
source: str
class DataCatalogResponse(BaseModel):
categories: Dict[str, List[DataCatalogItem]]
last_updated: datetime
class HealthCheckResponse(BaseModel):
status: str
version: str
database: str
cache: str
sec_data_available: bool
timestamp: datetime
class MigrationRequest(BaseModel):
source_url: str = Field(..., description="Source API URL to migrate from")
api_key: str = Field(..., description="API key for authentication")
tickers: Optional[List[str]] = Field(None, description="Specific tickers to migrate, or all if not specified")
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
class MigrationResponse(BaseModel):
status: str
total_records: int
migrated_records: int
failed_records: int
errors: List[Dict[str, Any]] = Field(default_factory=list)
duration_seconds: float
class AlpacaBarsResponse(BaseModel):
ticker: str
interval: str
count: int
bars: List[Dict[str, Any]]
class AlpacaIntradayResponse(BaseModel):
ticker: str
interval: str
source: str = "ALPACA"
count: int
candles: List[Dict[str, Any]]
class NewsOnlyResponse(BaseModel):
ticker: str
retrieved_at: str
news: Dict[str, Any]
summary: Dict[str, Any]
class SocialOnlyResponse(BaseModel):
ticker: str
retrieved_at: str
social_media: Dict[str, Any]
summary: Dict[str, Any]