mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-18 21:08:07 +00:00
177 lines
6.6 KiB
Python
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,
|
|
}
|