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.

264 lines
9.2 KiB
Python

"""
CompanyMetadataService — authoritative source for company metadata.
Lookup order:
1. Redis cache (24h TTL)
2. universe_ticker_registry (DB) — if sector NOT NULL, return directly
3. yfinance .info enrichment (semaphore=5) — upsert results back to registry + companies
4. If yfinance fails, return whatever registry has (may have NULL sector)
5. If ticker not in registry AND yfinance fails/invalid → raise ValueError("invalid ticker")
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
try:
import yfinance_plus as yf
except ImportError:
import yfinance as yf
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.financial import Company
from app.models.universe_snapshot import UniverseSnapshot, UniverseTickerRegistry
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response
logger = logging.getLogger(__name__)
_YFINANCE_SEMAPHORE = asyncio.Semaphore(5)
_EXCHANGE_MAP = {
"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ",
"NCM": "NASDAQ", "ASE": "AMEX", "PCX": "NYSE_ARCA",
}
_SKIP_QUOTE_TYPES = {"ETF", "MUTUALFUND", "INDEX", "CURRENCY", "FUTURE", "OPTION"}
_CACHE_TTL = 60 * 60 * 24 # 24h
def _canonical_exchange(raw: Optional[str]) -> Optional[str]:
if not raw:
return None
return _EXCHANGE_MAP.get(raw.upper(), raw.upper()) or None
async def _fetch_yfinance_info(ticker: str) -> Optional[dict]:
"""Fetch yfinance .info in a thread pool with semaphore and 20s timeout."""
loop = asyncio.get_event_loop()
def _sync_fetch():
try:
import yfinance as _base_yf # Always use base yfinance for auth reliability
info = _base_yf.Ticker(ticker).info
# yfinance's first HTTP call may lack a valid auth token and return
# a partial response (sector/industry = None). Retry once if an
# equity ticker is missing sector — the second call uses the auth
# token that was cached by the first call.
if info and not info.get("sector") and info.get("quoteType") == "EQUITY":
info = _base_yf.Ticker(ticker).info
return info
except Exception as e:
logger.warning("yfinance .info failed for %s: %s", ticker, e)
return None
async with _YFINANCE_SEMAPHORE:
try:
return await asyncio.wait_for(
loop.run_in_executor(None, _sync_fetch),
timeout=20,
)
except asyncio.TimeoutError:
logger.warning("yfinance .info timed out for %s", ticker)
return None
except Exception as e:
logger.warning("yfinance enrichment error for %s: %s", ticker, e)
return None
async def _upsert_registry(db: AsyncSession, ticker: str, data: dict) -> None:
stmt = pg_insert(UniverseTickerRegistry).values(
ticker=ticker,
name=data.get("name"),
cik=data.get("cik"),
sector=data.get("sector"),
industry=data.get("industry"),
exchange=data.get("exchange"),
is_active=True,
updated_at=datetime.now(timezone.utc),
).on_conflict_do_update(
constraint="uq_universe_ticker_registry",
set_={
"name": data.get("name"),
"sector": data.get("sector"),
"industry": data.get("industry"),
"exchange": data.get("exchange"),
"updated_at": datetime.now(timezone.utc),
},
)
await db.execute(stmt)
async def _upsert_company(db: AsyncSession, ticker: str, data: dict) -> None:
result = await db.execute(select(Company).where(Company.ticker == ticker))
company = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
if company:
if data.get("name") and (not company.name or company.name == f"{ticker} Corporation"):
company.name = data["name"]
if data.get("sector"):
company.sector = data["sector"]
if data.get("industry"):
company.industry = data["industry"]
if data.get("exchange"):
company.exchange = data["exchange"]
if data.get("country"):
company.country = data["country"]
if data.get("market_cap"):
company.market_cap = data["market_cap"]
if data.get("business_description"):
company.business_description = data["business_description"]
company.updated_at = now
else:
company = Company(
ticker=ticker,
name=data.get("name") or f"{ticker} Corporation",
cik=data.get("cik"),
exchange=data.get("exchange"),
sector=data.get("sector"),
industry=data.get("industry"),
country=data.get("country"),
market_cap=data.get("market_cap"),
business_description=data.get("business_description"),
created_at=now,
updated_at=now,
)
db.add(company)
await db.commit()
async def get_metadata(db: AsyncSession, ticker: str) -> dict:
"""
Return company metadata dict for a ticker.
Raises ValueError("invalid ticker: {ticker}") if ticker is unknown.
Returned dict keys: ticker, name, cik, exchange, sector, industry,
country, market_cap, business_description
"""
ticker = ticker.upper()
cache_key = build_cache_key("company:meta", ticker)
# --- 1. Redis cache ---
cached = await get_cached_response(cache_key)
if cached:
body, _ = cached
return body
# --- 2. Registry DB ---
reg_result = await db.execute(
select(UniverseTickerRegistry).where(UniverseTickerRegistry.ticker == ticker)
)
reg = reg_result.scalar_one_or_none()
# --- 3. market_cap from latest snapshot (opportunistic) ---
snap_market_cap: Optional[float] = None
snap_result = await db.execute(
select(UniverseSnapshot)
.where(UniverseSnapshot.ticker == ticker)
.order_by(UniverseSnapshot.snapshot_date.desc())
.limit(1)
)
snap = snap_result.scalar_one_or_none()
if snap:
snap_market_cap = snap.market_cap
if reg and reg.sector:
# Fast path: registry has sector — no yfinance needed
data = _build_from_registry(ticker, reg, snap_market_cap)
await set_cached_response(cache_key, data, ttl_seconds=_CACHE_TTL)
return data
# --- 4. yfinance enrichment ---
info = await _fetch_yfinance_info(ticker)
if info:
quote_type = info.get("quoteType") or ""
if quote_type in _SKIP_QUOTE_TYPES:
# Valid but not an equity — return minimal
data = _build_minimal(ticker, info, snap_market_cap)
await set_cached_response(cache_key, data, ttl_seconds=_CACHE_TTL)
return data
if not quote_type and not info.get("longName") and not info.get("shortName"):
# Likely invalid ticker
if not reg:
raise ValueError(f"invalid ticker: {ticker}")
# Fall through to registry-only result
enriched = {
"name": info.get("longName") or info.get("shortName"),
"cik": reg.cik if reg else None,
"exchange": _canonical_exchange(info.get("exchange")),
"sector": info.get("sector"),
"industry": info.get("industry"),
"country": info.get("country"),
"market_cap": info.get("marketCap") or snap_market_cap,
"business_description": info.get("longBusinessSummary"),
}
# Persist enrichment
try:
await _upsert_registry(db, ticker, enriched)
await _upsert_company(db, ticker, enriched)
except Exception as e:
logger.warning("Failed to persist enrichment for %s: %s", ticker, e)
data = {
"ticker": ticker,
**enriched,
}
await set_cached_response(cache_key, data, ttl_seconds=_CACHE_TTL)
return data
# --- 5. yfinance failed — use registry if available ---
if reg:
data = _build_from_registry(ticker, reg, snap_market_cap)
# Short TTL so we retry enrichment soon
await set_cached_response(cache_key, data, ttl_seconds=60 * 15)
return data
raise ValueError(f"invalid ticker: {ticker}")
def _build_from_registry(
ticker: str, reg: UniverseTickerRegistry, market_cap: Optional[float]
) -> dict:
return {
"ticker": ticker,
"name": reg.name,
"cik": reg.cik,
"exchange": reg.exchange,
"sector": reg.sector,
"industry": reg.industry,
"country": None,
"market_cap": market_cap,
"business_description": None,
}
def _build_minimal(ticker: str, info: dict, market_cap: Optional[float]) -> dict:
return {
"ticker": ticker,
"name": info.get("longName") or info.get("shortName"),
"cik": None,
"exchange": _canonical_exchange(info.get("exchange")),
"sector": info.get("sector"),
"industry": info.get("industry"),
"country": info.get("country"),
"market_cap": info.get("marketCap") or market_cap,
"business_description": None,
}