""" 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 on or 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 on or 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 on or 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]