summaryrefslogtreecommitdiff
path: root/backend/app/services
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:31:48 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-18 22:31:48 -0700
commitacb628932215338b7971f3051b1e5d89e58573a1 (patch)
tree7fc9b299ea8098d0d97f85798c2b268084e1f2f1 /backend/app/services
parent16d9eb4f864fe8c29a9dee57ec47f77b34ae0df4 (diff)
fix: populate sector benchmark values for ratios tab
Diffstat (limited to 'backend/app/services')
-rw-r--r--backend/app/services/data_service.py101
1 files changed, 96 insertions, 5 deletions
diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py
index e356cb4..ab06723 100644
--- a/backend/app/services/data_service.py
+++ b/backend/app/services/data_service.py
@@ -3,6 +3,7 @@ from __future__ import annotations
import math
import os
+import statistics
from typing import Any
import httpx
@@ -30,6 +31,7 @@ FINANCIALS_CACHE = TTLCache(maxsize=128, ttl=3600)
VALUATION_CACHE = TTLCache(maxsize=128, ttl=3600)
HIST_RATIOS_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
RATIOS_ENDPOINT_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
+SECTOR_BENCHMARK_CACHE: TTLCache = TTLCache(maxsize=128, ttl=3600)
PERIODS = {"1m", "3m", "6m", "1y", "2y", "5y"}
YF_PERIOD_MAP = {"1m": "1mo", "3m": "3mo", "6m": "6mo", "1y": "1y", "2y": "2y", "5y": "5y"}
@@ -750,6 +752,7 @@ def get_ratios(symbol: str) -> dict:
ttm = compute_ttm_ratios(sym)
hist = compute_historical_ratios(sym)
info = get_company_info(sym)
+ sector_bench = compute_sector_ratio_benchmarks(sym)
income = get_income_statement(sym, quarterly=True)
balance = get_balance_sheet(sym, quarterly=True)
@@ -785,10 +788,17 @@ def get_ratios(symbol: str) -> dict:
fwd_pe = _safe_float(info.get("forwardPE")) if info else None
forward_pe = fwd_pe if fwd_pe and 0 < fwd_pe < 500 else None
- def point(ttm_key: str | None, hist_key: str | None, override: float | None = None) -> dict:
+ def point(
+ ttm_key: str | None,
+ hist_key: str | None,
+ override: float | None = None,
+ sector_key: str | None = None,
+ ) -> dict:
val = override if override is not None else (ttm.get(ttm_key) if ttm_key else None)
spark = hist.get(hist_key, []) if hist_key else []
- return {"value": val, "spark": spark, "vs_sector": None}
+ skey = sector_key if sector_key is not None else ttm_key
+ vs_sector = sector_bench.get(skey) if skey else None
+ return {"value": val, "spark": spark, "vs_sector": vs_sector}
return {
"pe_ttm": point("trailing_pe", "trailing_pe"),
@@ -799,22 +809,103 @@ def get_ratios(symbol: str) -> dict:
"price_to_sales": point("price_to_sales", "price_to_sales"),
"ev_to_sales": point("ev_to_sales", None),
"p_fcf": point(None, None, p_fcf),
- "forward_pe": point(None, None, forward_pe),
+ "forward_pe": point(None, None, forward_pe, "trailing_pe"),
"operating_margin": point("operating_margin_ttm", "operating_margin"),
- "ebitda_margin": point(None, "ebitda_margin", ebitda_margin),
+ "ebitda_margin": point(None, "ebitda_margin", ebitda_margin, "operating_margin_ttm"),
"fcf_margin": point(None, None, fcf_margin),
"roe": point("roe_ttm", "roe"),
"roa": point("roa_ttm", "roa"),
"roic": point("roic_ttm", None),
"debt_to_equity": point("debt_to_equity", "debt_to_equity"),
"current_ratio": point("current_ratio", "current_ratio"),
- "quick_ratio": point(None, None, quick_ratio),
+ "quick_ratio": point(None, None, quick_ratio, "current_ratio"),
"interest_coverage": point(None, None, interest_coverage),
"dividend_yield": point("dividend_yield_ttm", None),
"dividend_payout": point("dividend_payout_ratio_ttm", None),
}
+@cached(SECTOR_BENCHMARK_CACHE)
+def compute_sector_ratio_benchmarks(symbol: str) -> dict[str, float]:
+ """Median TTM ratio benchmarks from same-sector peers (FMP-backed when available)."""
+ sym = normalize_symbol(symbol)
+ fmp_key = os.getenv("FMP_API_KEY")
+ if not fmp_key:
+ return {}
+
+ info = get_company_info(sym)
+ sector_raw = info.get("sector") if isinstance(info, dict) else None
+ sector = str(sector_raw or "").strip()
+ if not sector:
+ enrichment = get_profile_enrichment(sym)
+ sector = str((enrichment or {}).get("sector") or "").strip()
+ if not sector:
+ return {}
+
+ peer_symbols: list[str] = []
+ try:
+ with httpx.Client(timeout=3.5) as client:
+ res = client.get(
+ "https://financialmodelingprep.com/api/v3/stock-screener",
+ params={
+ "sector": sector,
+ "isEtf": "false",
+ "isActivelyTrading": "true",
+ "limit": 12,
+ "apikey": fmp_key,
+ },
+ )
+ rows = res.json()
+ if isinstance(rows, list):
+ for row in rows:
+ psym = normalize_symbol((row or {}).get("symbol"))
+ if not psym or psym == sym:
+ continue
+ peer_symbols.append(psym)
+ except Exception:
+ return {}
+
+ if not peer_symbols:
+ return {}
+
+ keys = [
+ "trailing_pe",
+ "ev_to_ebitda",
+ "gross_margin_ttm",
+ "net_margin_ttm",
+ "price_to_book",
+ "price_to_sales",
+ "ev_to_sales",
+ "operating_margin_ttm",
+ "roe_ttm",
+ "roa_ttm",
+ "roic_ttm",
+ "debt_to_equity",
+ "current_ratio",
+ "dividend_yield_ttm",
+ "dividend_payout_ratio_ttm",
+ ]
+ buckets: dict[str, list[float]] = {k: [] for k in keys}
+
+ for psym in peer_symbols[:6]:
+ try:
+ ratios = compute_ttm_ratios(psym)
+ except Exception:
+ continue
+ if not isinstance(ratios, dict):
+ continue
+ for key in keys:
+ val = _safe_float(ratios.get(key))
+ if val is not None:
+ buckets[key].append(val)
+
+ out: dict[str, float] = {}
+ for key, values in buckets.items():
+ if values:
+ out[key] = float(statistics.median(values))
+ return out
+
+
def _pick_search_match(symbol: str) -> dict[str, Any]:
sym = normalize_symbol(symbol)
results = search_tickers(sym)