Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/peer_comparison_service.py
T

177 lines
6.6 KiB
Python

"""Peer valuation multiples for overview (yfinance, no LLM)."""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
import yfinance as yf
from server.core.data_gateway import DataGateway, Fundamentals
from server.core.providers.base import DataUnavailable
from server.utils.peer_universe import peer_symbols_for_profile
from server.utils.safe_float import _safe_float
def _peer_symbols(ticker: str, sector: str, industry: str) -> List[str]:
return peer_symbols_for_profile(ticker, sector, industry, cap=8)
def _peer_row(sym: str) -> Dict[str, Any]:
info = (yf.Ticker(sym).info) or {}
return {
"ticker": sym,
"name": str(info.get("shortName") or info.get("longName") or sym)[:80],
"market_cap": _safe_float(info.get("marketCap")),
"pe": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")),
"pb": _safe_float(info.get("priceToBook")),
"ps": _safe_float(info.get("priceToSalesTrailing12Months")),
"ev_ebitda": _safe_float(info.get("enterpriseToEbitda")),
"roic": _safe_float(info.get("returnOnInvestedCapital") or info.get("returnOnCapital")),
"gross_margin": _safe_float(info.get("grossMargins")),
"rev_growth": _safe_float(info.get("revenueGrowth")),
}
def _avg(vals: List[Optional[float]]) -> Optional[float]:
nums = [v for v in vals if v is not None]
if not nums:
return None
return round(sum(nums) / len(nums), 2)
def build_peer_comparison(ticker: str) -> Dict[str, Any]:
"""Return payload matching PeerComparisonData on the frontend."""
t = ticker.upper().strip()
try:
info = (yf.Ticker(t).info) or {}
except Exception:
info = {}
sector = str(info.get("sector") or "")
industry = str(info.get("industry") or "")
syms = _peer_symbols(t, sector, industry)
peers = [_peer_row(s) for s in syms]
pes = [p["pe"] for p in peers]
pbs = [p["pb"] for p in peers]
pss = [p["ps"] for p in peers]
evs = [p["ev_ebitda"] for p in peers]
roics = [p["roic"] for p in peers]
gross_margins = [p["gross_margin"] for p in peers]
rev_growths = [p["rev_growth"] for p in peers]
return {
"ticker": t,
"primary": t,
"sector": sector or "—",
"industry": industry or "—",
"metrics": ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"],
"averages": {
"pe": _avg(pes),
"pb": _avg(pbs),
"ps": _avg(pss),
"ev_ebitda": _avg(evs),
"roic": _avg(roics),
"gross_margin": _avg(gross_margins),
"rev_growth": _avg(rev_growths),
},
"peer_symbols": syms[1:],
"matrix": peers,
"peers": peers,
}
def _metric_from_fundamentals(fundamentals: Fundamentals, metric: str) -> Optional[float]:
raw = fundamentals.raw or {}
if metric == "pe":
return _safe_float(fundamentals.pe or raw.get("trailingPE") or raw.get("forwardPE"))
if metric == "pb":
return _safe_float(fundamentals.pb or raw.get("priceToBook"))
if metric == "ps":
return _safe_float(fundamentals.ps or raw.get("priceToSalesTrailing12Months"))
if metric == "ev_ebitda":
return _safe_float(fundamentals.ev_ebitda or raw.get("enterpriseToEbitda"))
if metric == "roic":
return _safe_float(fundamentals.roic or raw.get("returnOnInvestedCapital") or raw.get("returnOnCapital"))
if metric == "gross_margin":
return _safe_float(fundamentals.gross_margin or raw.get("grossMargins"))
if metric in {"rev_growth", "revenue_growth"}:
return _safe_float(fundamentals.revenue_growth or raw.get("revenueGrowth"))
return None
def _matrix_row(symbol: str, fundamentals: Fundamentals, metrics: list[str]) -> Dict[str, Any]:
raw = fundamentals.raw or {}
row: Dict[str, Any] = {
"ticker": symbol.upper(),
"name": str(fundamentals.name or raw.get("shortName") or raw.get("longName") or symbol.upper())[:80],
"market_cap": _safe_float(fundamentals.market_cap or raw.get("marketCap")),
"source": fundamentals.source,
}
for metric in metrics:
row[metric] = _metric_from_fundamentals(fundamentals, metric)
# Keep report/overview legacy fields available even when callers request a
# smaller metric set.
for metric in ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"]:
row.setdefault(metric, _metric_from_fundamentals(fundamentals, metric))
return row
async def build_peer_comparison_matrix(
ticker: str,
metrics: list[str],
gateway: DataGateway,
max_peers: int = 5,
) -> Dict[str, Any]:
"""Build a gateway-backed peer matrix with bounded parallel fundamentals fetches."""
primary = ticker.upper().strip()
requested_metrics = [m.strip().lower() for m in metrics if m.strip()]
if not requested_metrics:
requested_metrics = ["pe", "ev_ebitda", "roic", "gross_margin"]
try:
profile, peer_symbols = await asyncio.gather(
gateway.profile(primary),
gateway.peers(primary),
)
except DataUnavailable:
legacy = await asyncio.to_thread(build_peer_comparison, primary)
legacy["metrics"] = requested_metrics
return legacy
targets = [primary] + [symbol.upper() for symbol in peer_symbols if symbol.upper() != primary][:max_peers]
semaphore = asyncio.Semaphore(5)
async def fetch_one(symbol: str) -> Fundamentals | Exception:
async with semaphore:
try:
return await gateway.fundamentals(symbol, period="ttm")
except Exception as exc:
return exc
results = await asyncio.gather(*(fetch_one(symbol) for symbol in targets))
matrix: list[Dict[str, Any]] = []
for symbol, result in zip(targets, results):
if isinstance(result, Fundamentals):
matrix.append(_matrix_row(symbol, result, requested_metrics))
if not matrix:
legacy = await asyncio.to_thread(build_peer_comparison, primary)
legacy["metrics"] = requested_metrics
return legacy
averages = {
metric: _avg([_safe_float(row.get(metric)) for row in matrix])
for metric in ["pe", "pb", "ps", "ev_ebitda", "roic", "gross_margin", "rev_growth"]
}
return {
"ticker": primary,
"primary": primary,
"sector": profile.sector or "—",
"industry": profile.industry or "—",
"metrics": requested_metrics,
"peer_symbols": targets[1:],
"averages": averages,
"matrix": matrix,
"peers": matrix,
}