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.
338 lines
15 KiB
Python
338 lines
15 KiB
Python
"""
|
|
SEC EDGAR Direct API Service
|
|
Fetches real financial data directly from SEC EDGAR without dependencies
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Dict, List, Optional, Tuple, Any
|
|
import logging
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, and_
|
|
|
|
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
|
|
from app.schemas.financial import DataSource
|
|
from app.services.sec_http_client import SECHttpClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class SECEdgarService:
|
|
"""Direct SEC EDGAR API service for financial data"""
|
|
|
|
def __init__(self):
|
|
self._http = SECHttpClient("Stock Oracle SEC Service")
|
|
|
|
async def get_company_cik(self, ticker: str) -> Optional[str]:
|
|
"""Get company CIK from SEC ticker mapping"""
|
|
return await self._http.get_company_cik(ticker)
|
|
|
|
async def get_company_facts(self, cik: str) -> Optional[Dict]:
|
|
"""Get company facts from SEC EDGAR API"""
|
|
try:
|
|
url = f"{self._http.sec_base_data}/api/xbrl/companyfacts/CIK{cik.zfill(10)}.json"
|
|
data = await self._http.fetch_json(url)
|
|
logger.info(f"Successfully fetched SEC facts for CIK {cik}")
|
|
return data
|
|
except Exception as e:
|
|
if "404" in str(e):
|
|
logger.warning(f"No SEC data found for CIK {cik}")
|
|
return None
|
|
logger.error(f"Error fetching company facts for CIK {cik}: {e}")
|
|
return None
|
|
|
|
def extract_financial_data(self, facts_data: Dict, start_date: datetime, end_date: datetime) -> List[Dict]:
|
|
"""Extract financial data from SEC facts"""
|
|
try:
|
|
if not facts_data or 'facts' not in facts_data:
|
|
return []
|
|
|
|
facts = facts_data['facts']
|
|
financial_records = []
|
|
|
|
# Common XBRL concepts mapping (without namespace prefix - it's already in the structure)
|
|
concept_mapping = {
|
|
# Revenue concepts
|
|
'Revenues': 'revenue',
|
|
'RevenueFromContractWithCustomerExcludingAssessedTax': 'revenue',
|
|
'SalesRevenueNet': 'revenue',
|
|
# Income concepts
|
|
'OperatingIncomeLoss': 'operating_income',
|
|
'NetIncomeLoss': 'net_income',
|
|
'GrossProfit': 'gross_profit',
|
|
# Balance sheet concepts
|
|
'Assets': 'total_assets',
|
|
'StockholdersEquity': 'total_equity',
|
|
'LiabilitiesAndStockholdersEquity': 'total_assets', # Alternative for total assets
|
|
'Liabilities': 'total_debt',
|
|
'CashAndCashEquivalentsAtCarryingValue': 'cash',
|
|
'CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents': 'cash',
|
|
# Share data
|
|
'CommonStockSharesOutstanding': 'shares_outstanding',
|
|
'WeightedAverageNumberOfSharesOutstandingBasic': 'shares_outstanding',
|
|
'WeightedAverageNumberOfDilutedSharesOutstanding': 'shares_outstanding',
|
|
# Cash flow concepts
|
|
'NetCashProvidedByUsedInOperatingActivities': 'operating_cash_flow',
|
|
'PaymentsToAcquirePropertyPlantAndEquipment': 'capex'
|
|
}
|
|
|
|
# Collect all quarterly and annual data points
|
|
data_points = {}
|
|
|
|
# Access us-gaap namespace
|
|
us_gaap_facts = facts.get('us-gaap', {})
|
|
|
|
for concept, field_name in concept_mapping.items():
|
|
if concept in us_gaap_facts:
|
|
units = us_gaap_facts[concept].get('units', {})
|
|
|
|
# Try USD first, then shares for share counts
|
|
unit_key = 'USD' if 'USD' in units else ('shares' if 'shares' in units else None)
|
|
|
|
if unit_key and unit_key in units:
|
|
for entry in units[unit_key]:
|
|
# Get the period end date
|
|
end = entry.get('end')
|
|
if not end:
|
|
continue
|
|
|
|
try:
|
|
# Handle date format like '2016-09-24'
|
|
if 'T' not in end and 'Z' not in end:
|
|
period_date = datetime.strptime(end, '%Y-%m-%d')
|
|
period_date = period_date.replace(tzinfo=timezone.utc)
|
|
else:
|
|
period_date = datetime.fromisoformat(end.replace('Z', '+00:00'))
|
|
except Exception as e:
|
|
logger.warning(f"Could not parse date {end}: {e}")
|
|
continue
|
|
|
|
# Check if within date range
|
|
if period_date < start_date or period_date > end_date:
|
|
continue
|
|
|
|
# Get period info
|
|
form = entry.get('form', '')
|
|
filing_date = entry.get('filed', '')
|
|
value = entry.get('val')
|
|
|
|
if value is None:
|
|
continue
|
|
|
|
# Create period key (quarter end date)
|
|
period_key = period_date.strftime('%Y-%m-%d')
|
|
|
|
if period_key not in data_points:
|
|
data_points[period_key] = {
|
|
'period_date': period_date,
|
|
'form': form,
|
|
'filing_date': filing_date,
|
|
'period_type': 'quarterly' if form == '10-Q' else 'annual'
|
|
}
|
|
|
|
# Store the value
|
|
data_points[period_key][field_name] = float(value)
|
|
|
|
# Convert to financial records
|
|
for period_key, data in data_points.items():
|
|
if len(data) > 4: # Must have more than just metadata
|
|
financial_records.append(data)
|
|
|
|
# Sort by period date
|
|
financial_records.sort(key=lambda x: x['period_date'])
|
|
|
|
logger.info(f"Extracted {len(financial_records)} financial periods from SEC data")
|
|
return financial_records
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error extracting financial data: {e}")
|
|
return []
|
|
|
|
async def get_financial_data(
|
|
self,
|
|
db: AsyncSession,
|
|
ticker: str,
|
|
start_date: datetime,
|
|
end_date: datetime,
|
|
force_refresh: bool = False
|
|
) -> List[FinancialData]:
|
|
"""Get financial data for a company from SEC EDGAR"""
|
|
|
|
ticker = ticker.upper()
|
|
|
|
# Check if we already have data
|
|
if not force_refresh:
|
|
existing_result = await db.execute(
|
|
select(FinancialData).where(
|
|
and_(
|
|
FinancialData.ticker == ticker,
|
|
FinancialData.period_date >= start_date,
|
|
FinancialData.period_date <= end_date,
|
|
FinancialData.data_source == DataSource.SEC_EDGAR.value,
|
|
FinancialData.is_estimated == False
|
|
)
|
|
).order_by(FinancialData.period_date)
|
|
)
|
|
existing_data = existing_result.scalars().all()
|
|
|
|
if existing_data:
|
|
logger.info(f"Found {len(existing_data)} existing SEC records for {ticker}")
|
|
return existing_data
|
|
|
|
# Get company CIK
|
|
cik = await self.get_company_cik(ticker)
|
|
if not cik:
|
|
logger.error(f"Could not find CIK for ticker {ticker}")
|
|
return []
|
|
|
|
# Get company facts from SEC
|
|
facts_data = await self.get_company_facts(cik)
|
|
if not facts_data:
|
|
logger.error(f"Could not fetch SEC facts for {ticker} (CIK: {cik})")
|
|
return []
|
|
|
|
# Extract financial data
|
|
financial_periods = self.extract_financial_data(facts_data, start_date, end_date)
|
|
|
|
if not financial_periods:
|
|
logger.warning(f"No financial data extracted for {ticker}")
|
|
return []
|
|
|
|
# Convert to database records
|
|
financial_records = []
|
|
|
|
for period_data in financial_periods:
|
|
try:
|
|
# Check if record already exists
|
|
period_date = period_data['period_date']
|
|
period_type = period_data.get('period_type', 'quarterly')
|
|
|
|
existing_result = await db.execute(
|
|
select(FinancialData).where(
|
|
and_(
|
|
FinancialData.ticker == ticker,
|
|
FinancialData.period_date == period_date,
|
|
FinancialData.period_type == period_type
|
|
)
|
|
)
|
|
)
|
|
existing_record = existing_result.scalar_one_or_none()
|
|
|
|
if existing_record and not force_refresh:
|
|
financial_records.append(existing_record)
|
|
continue
|
|
|
|
# Calculate EPS if we have net income and shares
|
|
eps = None
|
|
net_income = period_data.get('net_income')
|
|
shares_outstanding = period_data.get('shares_outstanding')
|
|
if net_income and shares_outstanding and shares_outstanding > 0:
|
|
eps = net_income / shares_outstanding
|
|
|
|
# Calculate free cash flow
|
|
free_cash_flow = None
|
|
operating_cash_flow = period_data.get('operating_cash_flow')
|
|
capex = period_data.get('capex')
|
|
if operating_cash_flow and capex:
|
|
free_cash_flow = operating_cash_flow - abs(capex) # capex is usually negative
|
|
|
|
if existing_record:
|
|
# Update existing record
|
|
existing_record.revenue = period_data.get('revenue')
|
|
existing_record.gross_profit = period_data.get('gross_profit')
|
|
existing_record.operating_income = period_data.get('operating_income')
|
|
existing_record.net_income = net_income
|
|
existing_record.eps = eps
|
|
existing_record.total_assets = period_data.get('total_assets')
|
|
existing_record.total_equity = period_data.get('total_equity')
|
|
existing_record.total_debt = period_data.get('total_debt')
|
|
existing_record.cash = period_data.get('cash')
|
|
existing_record.shares_outstanding = shares_outstanding
|
|
existing_record.operating_cash_flow = operating_cash_flow
|
|
existing_record.free_cash_flow = free_cash_flow
|
|
existing_record.capex = abs(capex) if capex else None
|
|
existing_record.data_source = DataSource.SEC_EDGAR.value
|
|
existing_record.is_estimated = False
|
|
existing_record.updated_at = datetime.now(timezone.utc)
|
|
|
|
financial_records.append(existing_record)
|
|
else:
|
|
# Create new record
|
|
filing_type = "10-K" if period_type == "annual" else "10-Q"
|
|
|
|
financial_record = FinancialData(
|
|
ticker=ticker,
|
|
period_date=period_date,
|
|
period_type=period_type,
|
|
filing_type=filing_type,
|
|
revenue=period_data.get('revenue'),
|
|
gross_profit=period_data.get('gross_profit'),
|
|
operating_income=period_data.get('operating_income'),
|
|
net_income=net_income,
|
|
eps=eps,
|
|
total_assets=period_data.get('total_assets'),
|
|
total_equity=period_data.get('total_equity'),
|
|
total_debt=period_data.get('total_debt'),
|
|
cash=period_data.get('cash'),
|
|
shares_outstanding=shares_outstanding,
|
|
operating_cash_flow=operating_cash_flow,
|
|
free_cash_flow=free_cash_flow,
|
|
capex=abs(capex) if capex else None,
|
|
data_source=DataSource.SEC_EDGAR.value,
|
|
is_estimated=False,
|
|
created_at=datetime.now(timezone.utc),
|
|
updated_at=datetime.now(timezone.utc)
|
|
)
|
|
|
|
db.add(financial_record)
|
|
financial_records.append(financial_record)
|
|
|
|
logger.info(f"Processed SEC data for {ticker} {period_date.date()}: Revenue=${period_data.get('revenue', 0):,.0f}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error processing period data for {ticker}: {e}")
|
|
continue
|
|
|
|
if financial_records:
|
|
await db.commit()
|
|
# Refresh all records to get IDs
|
|
for record in financial_records:
|
|
if record.id is None: # Only refresh new records
|
|
await db.refresh(record)
|
|
|
|
logger.info(f"Successfully fetched {len(financial_records)} SEC financial records for {ticker}")
|
|
return financial_records
|
|
|
|
async def get_company_info(self, ticker: str) -> Dict[str, Any]:
|
|
"""Get company information from SEC"""
|
|
try:
|
|
cik = await self.get_company_cik(ticker)
|
|
if not cik:
|
|
return self._get_fallback_company_info(ticker)
|
|
|
|
facts_data = await self.get_company_facts(cik)
|
|
if not facts_data:
|
|
return self._get_fallback_company_info(ticker)
|
|
|
|
entity_info = facts_data.get('entityName', ticker)
|
|
|
|
return {
|
|
'name': entity_info,
|
|
'cik': cik,
|
|
'sector': 'Technology', # SEC doesn't provide sector info directly
|
|
'industry': 'Software',
|
|
'business_description': f'{entity_info} - SEC registered company'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching SEC company info for {ticker}: {e}")
|
|
return self._get_fallback_company_info(ticker)
|
|
|
|
def _get_fallback_company_info(self, ticker: str) -> Dict[str, Any]:
|
|
"""Fallback company info"""
|
|
return {
|
|
'name': f'{ticker} Corporation',
|
|
'cik': f'000{hash(ticker) % 1000000:06d}',
|
|
'sector': 'Technology',
|
|
'industry': 'Software',
|
|
'business_description': f'{ticker} technology company'
|
|
} |