""" Pydantic schemas for API requests and responses """ from datetime import datetime, date from typing import Optional, Dict, List, Any from pydantic import BaseModel, Field, ConfigDict, validator from enum import Enum import uuid import re from .validators import ( validate_period_field, validate_quarters_field, validate_time_approaches, validate_end_date_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") @validator('period') def validate_period(cls, v): return validate_period_field(cls, v) @validator('quarters') def validate_quarters(cls, v): return validate_quarters_field(cls, v) @validator('start_date') def validate_time_approaches(cls, v, values): return validate_time_approaches(cls, v, values) @validator('end_date') def validate_end_date(cls, v, values): return validate_end_date_field(cls, v, values) 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") @validator('quarters') def validate_quarters(cls, v): """Validate quarter format""" if v: for quarter in v: if not re.match(r'^\d{4}Q[1-4]$', quarter): raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)") return v @validator('start_date') def validate_dates_or_quarters(cls, v, values): """Ensure either dates or quarters are provided""" quarters = values.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 @validator('end_date') def validate_end_date(cls, v, values): """Validate end_date if using date-based approach""" start_date = values.get('start_date') quarters = values.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") @validator('interval') def validate_interval(cls, v): """Validate interval format""" valid_intervals = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1w', '1mo', '3mo'] if v not in valid_intervals: raise ValueError(f"Invalid interval: {v}. Valid intervals: {', '.join(valid_intervals)}") return v @validator('quarters') def validate_quarters(cls, v): """Validate quarter format""" if v: for quarter in v: if not re.match(r'^\d{4}Q[1-4]$', quarter): raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)") return v @validator('start_date') def validate_dates_or_quarters(cls, v, values): """Ensure either dates or quarters are provided""" quarters = values.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 @validator('end_date') def validate_end_date(cls, v, values): """Validate end_date if using date-based approach""" start_date = values.get('start_date') quarters = values.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") @validator('interval') def validate_interval(cls, v): """Validate interval format""" valid_intervals = ['1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1w', '1mo', '3mo'] if v not in valid_intervals: raise ValueError(f"Invalid interval: {v}. Valid intervals: {', '.join(valid_intervals)}") return v @validator('quarters') def validate_quarters(cls, v): """Validate quarter format""" if v: for quarter in v: if not re.match(r'^\d{4}Q[1-4]$', quarter): raise ValueError(f"Invalid quarter format: {quarter}. Expected format: YYYYQN (e.g., 2020Q1)") return v @validator('start_date') def validate_dates_or_quarters(cls, v, values): """Ensure either dates or quarters are provided""" quarters = values.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 @validator('end_date') def validate_end_date(cls, v, values): """Validate end_date if using date-based approach""" start_date = values.get('start_date') quarters = values.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 @validator('date', pre=True) 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" @validator('date', pre=True) 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=datetime.utcnow) # 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