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.
587 lines
19 KiB
Python
587 lines
19 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 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 AlpacaSnapshotResponse(BaseModel):
|
|
"""Real-time snapshot for a single ticker via Alpaca."""
|
|
ticker: str
|
|
source: str = "ALPACA"
|
|
timestamp: Optional[str] = None # latestTrade.t
|
|
|
|
# Latest trade
|
|
price: Optional[float] = None
|
|
trade_size: Optional[int] = None
|
|
|
|
# Latest quote (bid/ask)
|
|
bid: Optional[float] = None
|
|
ask: Optional[float] = None
|
|
bid_size: Optional[int] = None
|
|
ask_size: Optional[int] = None
|
|
|
|
# Today's session (dailyBar)
|
|
open: Optional[float] = None
|
|
high: Optional[float] = None
|
|
low: Optional[float] = None
|
|
volume: Optional[int] = None
|
|
vwap: Optional[float] = None
|
|
|
|
# Change vs previous close (prevDailyBar.c)
|
|
prev_close: Optional[float] = None
|
|
change: Optional[float] = None
|
|
change_pct: Optional[float] = None
|
|
|
|
|
|
class AlpacaMultiSnapshotResponse(BaseModel):
|
|
"""Real-time snapshots for multiple tickers."""
|
|
source: str = "ALPACA"
|
|
count: int
|
|
snapshots: List[AlpacaSnapshotResponse]
|
|
|
|
|
|
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] |