diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:31:48 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:31:48 -0700 |
| commit | acb628932215338b7971f3051b1e5d89e58573a1 (patch) | |
| tree | 7fc9b299ea8098d0d97f85798c2b268084e1f2f1 /backend | |
| parent | 16d9eb4f864fe8c29a9dee57ec47f77b34ae0df4 (diff) | |
fix: populate sector benchmark values for ratios tab
Diffstat (limited to 'backend')
| -rw-r--r-- | backend/app/services/data_service.py | 101 | ||||
| -rw-r--r-- | backend/tests/test_api.py | 42 |
2 files changed, 138 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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2ec4a08..300069c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -21,6 +21,7 @@ def clear_service_caches() -> None: data_service.VALUATION_CACHE.clear() data_service.HIST_RATIOS_CACHE.clear() data_service.RATIOS_ENDPOINT_CACHE.clear() + data_service.SECTOR_BENCHMARK_CACHE.clear() def quarterly_frame(rows: dict[str, list[float]]) -> pd.DataFrame: @@ -1170,6 +1171,47 @@ def test_get_ratios_interest_coverage(monkeypatch) -> None: assert result["interest_coverage"]["spark"] == [] +def test_get_ratios_sector_benchmark_fields(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: { + "market_cap": 1000.0, + "trailing_pe": 20.0, + "price_to_book": 5.0, + "current_ratio": 2.0, + "dividend_yield_ttm": 0.01, + }, + ) + monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "compute_sector_ratio_benchmarks", + lambda symbol: { + "trailing_pe": 18.0, + "price_to_book": 4.0, + "current_ratio": 1.7, + "dividend_yield_ttm": 0.012, + }, + ) + monkeypatch.setattr(data_service, "get_company_info", lambda symbol: {}) + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=True: quarterly_frame({"Total Revenue": [100.0, 100.0, 100.0, 100.0]}), + ) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=True: quarterly_frame({})) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({})) + + result = data_service.get_ratios("AAPL") + + assert result["pe_ttm"]["vs_sector"] == 18.0 + assert result["price_to_book"]["vs_sector"] == 4.0 + assert result["current_ratio"]["vs_sector"] == 1.7 + assert result["dividend_yield"]["vs_sector"] == pytest.approx(0.012) + + def test_ticker_ratios_route(monkeypatch) -> None: """GET /api/tickers/{symbol}/ratios returns a valid RatiosResponse shape.""" clear_service_caches() |
