diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-18 22:45:59 -0700 |
| commit | 66cfb26ebd8fa44b24e37b4ffc796ab29dcbd704 (patch) | |
| tree | 4d98b268502c6aa7c8988957d6e41dffd319534d | |
| parent | 7fc2f0177518d70114aa75b7874a0ef59bdaec61 (diff) | |
| parent | 52635efd7d435b091b4f13897511ca8e2c48f0b9 (diff) | |
Merge branch 'feat/key-ratios-tab'
| -rw-r--r-- | backend/app/main.py | 7 | ||||
| -rw-r--r-- | backend/app/schemas.py | 30 | ||||
| -rw-r--r-- | backend/app/services/data_service.py | 307 | ||||
| -rw-r--r-- | backend/tests/test_api.py | 340 | ||||
| -rw-r--r-- | frontend/app/prism-shell.css | 193 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsCard.tsx | 20 | ||||
| -rw-r--r-- | frontend/components/prism/FinancialsPage.tsx | 68 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosCard.tsx | 212 | ||||
| -rw-r--r-- | frontend/components/prism/RatiosPage.tsx | 57 | ||||
| -rw-r--r-- | frontend/lib/api.ts | 7 | ||||
| -rw-r--r-- | frontend/types/api.ts | 30 |
11 files changed, 1231 insertions, 40 deletions
diff --git a/backend/app/main.py b/backend/app/main.py index 1cc127e..7e02cbe 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi import FastAPI, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware from app.db import watchlist -from app.schemas import FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse +from app.schemas import FinancialsResponse, HistoryPoint, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse from app.services import data_service load_dotenv() @@ -75,6 +75,11 @@ def ticker_valuation(symbol: str) -> dict: return data_service.get_valuation(symbol) +@app.get("/api/tickers/{symbol}/ratios", response_model=RatiosResponse) +def ticker_ratios(symbol: str) -> dict: + return data_service.get_ratios(symbol) + + @app.get("/api/watchlist", response_model=WatchlistResponse) def get_watchlist() -> dict: items = [] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 87acd0d..2c64333 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -175,5 +175,35 @@ class WatchlistResponse(BaseModel): limit: int = 10 +class RatioPoint(BaseModel): + value: float | None = None + spark: list[float | None] = Field(default_factory=list) + vs_sector: float | None = None + + +class RatiosResponse(BaseModel): + pe_ttm: RatioPoint + ev_ebitda: RatioPoint + gross_margin: RatioPoint + net_margin: RatioPoint + price_to_book: RatioPoint + price_to_sales: RatioPoint + ev_to_sales: RatioPoint + p_fcf: RatioPoint + forward_pe: RatioPoint + operating_margin: RatioPoint + ebitda_margin: RatioPoint + fcf_margin: RatioPoint + roe: RatioPoint + roa: RatioPoint + roic: RatioPoint + debt_to_equity: RatioPoint + current_ratio: RatioPoint + quick_ratio: RatioPoint + interest_coverage: RatioPoint + dividend_yield: RatioPoint + dividend_payout: RatioPoint + + class ErrorResponse(BaseModel): detail: str diff --git a/backend/app/services/data_service.py b/backend/app/services/data_service.py index 9fe7f67..f913ec5 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 @@ -28,6 +29,9 @@ BETA_CACHE = TTLCache(maxsize=256, ttl=3600) SHORT_CACHE = TTLCache(maxsize=256, ttl=3600) 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"} @@ -614,6 +618,309 @@ def _latest_share_count(balance_sheet: pd.DataFrame) -> float | None: return shares if shares is not None and shares > 0 else None +def _find_price_at_date(price_history: list[dict], target: "pd.Timestamp") -> float | None: + """Return closing price from price_history nearest to target date (within 45 days).""" + if not price_history: + return None + best_price: float | None = None + best_delta = float("inf") + for pt in price_history: + try: + delta = abs((pd.Timestamp(pt["date"]) - target).days) + if delta < best_delta: + best_delta = delta + best_price = _safe_float(pt.get("close")) + except Exception: + continue + return best_price if best_delta <= 45 else None + + +@cached(HIST_RATIOS_CACHE) +def compute_historical_ratios(symbol: str) -> dict[str, list[float | None]]: + """Per-fiscal-year ratios from annual statements, oldest-first (up to 4 points).""" + sym = normalize_symbol(symbol) + inc_a = get_income_statement(sym, quarterly=False) + bal_a = get_balance_sheet(sym, quarterly=False) + cf_a = get_cash_flow(sym, quarterly=False) + + if inc_a is None or inc_a.empty: + return {} + + years = list(inc_a.columns[: min(len(inc_a.columns), 4)]) + price_history = get_price_history(sym, period="5y") + current_shares = get_shares_outstanding(sym) + + try: + shares_history_raw = yf.Ticker(sym).get_shares_full(start="2000-01-01") + if isinstance(shares_history_raw, pd.Series): + shares_history = pd.to_numeric(shares_history_raw, errors="coerce").dropna().sort_index() + else: + shares_history = pd.Series(dtype=float) + except Exception: + shares_history = pd.Series(dtype=float) + + result: dict[str, list[float | None]] = {k: [] for k in [ + "gross_margin", "operating_margin", "net_margin", "ebitda_margin", + "roe", "roa", "debt_to_equity", "current_ratio", + "trailing_pe", "ev_to_ebitda", "price_to_book", "price_to_sales", + ]} + + def _balance_shares(period_date: pd.Timestamp) -> float | None: + if bal_a is None or bal_a.empty or period_date not in bal_a.columns: + return None + for label in _SHARE_LABELS: + if label not in bal_a.index: + continue + shares_value = _safe_float(bal_a.loc[label, period_date]) + if shares_value is not None and shares_value > 0: + return shares_value + return None + + def _historical_shares_for_date(period_date: pd.Timestamp) -> float | None: + direct_balance_shares = _balance_shares(period_date) + if direct_balance_shares is not None: + return direct_balance_shares + if not shares_history.empty: + target = pd.Timestamp(period_date) + index = shares_history.index + if getattr(index, "tz", None) is not None and target.tzinfo is None: + target = target.tz_localize(index.tz) + elif getattr(index, "tz", None) is None and target.tzinfo is not None: + target = target.tz_localize(None) + + deltas = pd.Series(index - target, index=index).abs() + if not deltas.empty: + nearest_idx = deltas.idxmin() + if abs(pd.Timestamp(nearest_idx) - target) <= pd.Timedelta(days=180): + shares_value = _safe_float(shares_history.loc[nearest_idx]) + if shares_value is not None and shares_value > 0: + return shares_value + return current_shares + + for col in years: + col_dt = pd.Timestamp(col) + + def _inc(label: str) -> float | None: + if label not in inc_a.index: + return None + return _safe_float(inc_a.loc[label, col]) if col in inc_a.columns else None + + def _bal(label: str) -> float | None: + if bal_a is None or bal_a.empty or label not in bal_a.index: + return None + return _safe_float(bal_a.loc[label, col]) if col in bal_a.columns else None + + revenue = _inc("Total Revenue") + gross_profit = _inc("Gross Profit") + operating_income = _inc("Operating Income") + net_income = _inc("Net Income") + ebitda = _inc("EBITDA") or _inc("Normalized EBITDA") + equity = _bal("Stockholders Equity") or _bal("Common Stock Equity") + total_assets = _bal("Total Assets") + total_debt = _bal("Total Debt") or _bal("Long Term Debt And Capital Lease Obligation") + current_assets = _bal("Current Assets") + current_liabilities = _bal("Current Liabilities") + cash = _bal("Cash And Cash Equivalents") or _bal("Cash Cash Equivalents And Short Term Investments") or 0.0 + period_shares = _historical_shares_for_date(col_dt) + + rev = revenue if revenue and revenue > 0 else None + result["gross_margin"].append(_cap_ratio(gross_profit / rev, -5, 5) if rev and gross_profit is not None else None) + result["operating_margin"].append(_cap_ratio(operating_income / rev, -5, 5) if rev and operating_income is not None else None) + result["net_margin"].append(_cap_ratio(net_income / rev, -5, 5) if rev and net_income is not None else None) + result["ebitda_margin"].append(_cap_ratio(ebitda / rev, -5, 5) if rev and ebitda is not None else None) + result["roe"].append(_cap_ratio(net_income / equity, -10, 10) if equity and equity > 0 and net_income is not None else None) + result["roa"].append(_cap_ratio(net_income / total_assets, -10, 10) if total_assets and total_assets > 0 and net_income is not None else None) + result["debt_to_equity"].append(_cap_ratio(total_debt / equity, -1, 100) if equity and equity > 0 and total_debt is not None else None) + result["current_ratio"].append(current_assets / current_liabilities if current_liabilities and current_liabilities > 0 and current_assets is not None else None) + + price = _find_price_at_date(price_history, col_dt) + market_cap = price * period_shares if price and period_shares else None + ev = market_cap + (total_debt or 0.0) - cash if market_cap else None + + result["trailing_pe"].append(_cap_ratio(market_cap / net_income, 0, 500) if market_cap and net_income and net_income > 0 else None) + result["ev_to_ebitda"].append(_cap_ratio(ev / ebitda, 0, 500) if ev and ebitda and ebitda > 1e6 else None) + result["price_to_book"].append(_cap_ratio(market_cap / equity, 0, 100) if market_cap and equity and equity > 0 else None) + result["price_to_sales"].append(_cap_ratio(market_cap / revenue, 0, 100) if market_cap and revenue and revenue > 0 else None) + + return {k: list(reversed(v)) for k, v in result.items()} + + +@cached(RATIOS_ENDPOINT_CACHE) +def get_ratios(symbol: str) -> dict: + """Build the full RatiosResponse dict for the /ratios endpoint.""" + sym = normalize_symbol(symbol) + 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) + cf = get_cash_flow(sym, quarterly=True) + + ebitda = _statement_ttm(income, "EBITDA", "Normalized EBITDA") + revenue = _statement_ttm(income, "Total Revenue") + current_assets = _balance_value(balance, "Current Assets") + current_liabilities = _balance_value(balance, "Current Liabilities") + inventory = _balance_value(balance, "Inventory") + ebit = _statement_ttm(income, "EBIT") + interest_expense = _statement_ttm(income, "Interest Expense") + op_cf = _statement_ttm(cf, "Operating Cash Flow", "Cash From Operations") + capex_raw = _statement_ttm(cf, "Capital Expenditure") + capex = abs(capex_raw) if capex_raw is not None else None + fcf = (op_cf - capex) if op_cf is not None and capex is not None else None + market_cap = ttm.get("market_cap") + + quick_ratio: float | None = None + if current_liabilities and current_liabilities > 0 and current_assets is not None: + quick_ratio = (current_assets - (inventory or 0.0)) / current_liabilities + + interest_coverage: float | None = None + if interest_expense and ebit is not None: + ie = abs(interest_expense) + if ie > 0 and ebit > 0: + interest_coverage = _cap_ratio(ebit / ie, 0, 1000) + + ebitda_margin = _cap_ratio(ebitda / revenue, -5, 5) if revenue and revenue > 0 and ebitda is not None else None + fcf_margin = _cap_ratio(fcf / revenue, -5, 5) if revenue and revenue > 0 and fcf is not None else None + p_fcf = _cap_ratio(market_cap / fcf, 0, 1000) if market_cap and fcf and fcf > 0 else None + + 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, + 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 [] + 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"), + "ev_ebitda": point("ev_to_ebitda", "ev_to_ebitda"), + "gross_margin": point("gross_margin_ttm", "gross_margin"), + "net_margin": point("net_margin_ttm", "net_margin"), + "price_to_book": point("price_to_book", "price_to_book"), + "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, "trailing_pe"), + "operating_margin": point("operating_margin_ttm", "operating_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, "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") + + 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] = [] + if fmp_key: + 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: + peer_symbols = [] + + # No-key or FMP failure fallback: search by sector term, then filter by exact sector. + if not peer_symbols: + try: + candidates = search_tickers(sector) + except Exception: + candidates = [] + target_sector = sector.lower() + for row in candidates[:24]: + psym = normalize_symbol((row or {}).get("symbol")) + if not psym or psym == sym: + continue + pinfo = get_company_info(psym) + psector = str((pinfo or {}).get("sector") or "").strip().lower() + if psector and psector == target_sector: + peer_symbols.append(psym) + + 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 af43975..345c0a3 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -19,6 +19,9 @@ def clear_service_caches() -> None: data_service.RATIO_CACHE.clear() data_service.FINANCIALS_CACHE.clear() 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: @@ -940,3 +943,340 @@ def test_valuation_route_returns_structure(monkeypatch) -> None: assert result["dcf"]["intrinsic_value_per_share"] == 182.0 assert result["ev_ebitda"]["multiple_used"] == 20.0 assert result["ev_revenue"]["available"] is False + + +def test_compute_historical_ratios_margins(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [100.0, 90.0, 80.0, 70.0], + "Gross Profit": [50.0, 45.0, 40.0, 35.0], + "Operating Income": [20.0, 18.0, 16.0, 14.0], + "Net Income": [10.0, 9.0, 8.0, 7.0], + "EBITDA": [25.0, 22.5, 20.0, 17.5], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [50.0, 45.0, 40.0, 35.0], + "Total Assets": [100.0, 90.0, 80.0, 70.0], + "Total Debt": [20.0, 18.0, 16.0, 14.0], + "Current Assets": [30.0, 27.0, 24.0, 21.0], + "Current Liabilities": [15.0, 13.5, 12.0, 10.5], + "Cash And Cash Equivalents": [5.0, 4.5, 4.0, 3.5], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: annual_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 1_000.0) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["gross_margin"] == [0.5, 0.5, 0.5, 0.5] + assert result["operating_margin"] == [0.2, 0.2, 0.2, 0.2] + assert result["net_margin"] == [0.1, 0.1, 0.1, 0.1] + assert result["ebitda_margin"] == [0.25, 0.25, 0.25, 0.25] + assert result["roe"] == [0.2, 0.2, 0.2, 0.2] + assert result["roa"] == [0.1, 0.1, 0.1, 0.1] + assert result["debt_to_equity"] == [0.4, 0.4, 0.4, 0.4] + assert result["current_ratio"] == [2.0, 2.0, 2.0, 2.0] + assert result["trailing_pe"] == [None, None, None, None] + assert result["ev_to_ebitda"] == [None, None, None, None] + assert result["price_to_book"] == [None, None, None, None] + assert result["price_to_sales"] == [None, None, None, None] + + +def test_compute_historical_ratios_uses_period_share_counts_oldest_first(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [40.0, 40.0, 40.0, 40.0], + "Gross Profit": [20.0, 20.0, 20.0, 20.0], + "Operating Income": [15.0, 15.0, 15.0, 15.0], + "Net Income": [10.0, 10.0, 10.0, 10.0], + "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [20.0, 20.0, 20.0, 20.0], + "Total Assets": [50.0, 50.0, 50.0, 50.0], + "Total Debt": [100_000.0, 100_000.0, 100_000.0, 100_000.0], + "Current Assets": [10.0, 10.0, 10.0, 10.0], + "Current Liabilities": [5.0, 5.0, 5.0, 5.0], + "Cash And Cash Equivalents": [0.0, 0.0, 0.0, 0.0], + "Ordinary Shares Number": [10.0, 20.0, 40.0, 80.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: 999.0) + monkeypatch.setattr( + data_service, + "get_price_history", + lambda symbol, period="5y": [ + {"date": "2024-09-30", "close": 10.0}, + {"date": "2023-09-30", "close": 10.0}, + {"date": "2022-09-30", "close": 10.0}, + {"date": "2021-09-30", "close": 10.0}, + ], + ) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["trailing_pe"] == [80.0, 40.0, 20.0, 10.0] + assert result["price_to_book"] == [40.0, 20.0, 10.0, 5.0] + assert result["price_to_sales"] == [20.0, 10.0, 5.0, 2.5] + + +def test_compute_historical_ratios_uses_long_term_debt_fallback(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr( + data_service, + "get_income_statement", + lambda symbol, quarterly=False: annual_frame( + { + "Total Revenue": [5_000_000.0, 5_000_000.0, 5_000_000.0, 5_000_000.0], + "Gross Profit": [2_500_000.0, 2_500_000.0, 2_500_000.0, 2_500_000.0], + "Operating Income": [2_100_000.0, 2_100_000.0, 2_100_000.0, 2_100_000.0], + "Net Income": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + "EBITDA": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + } + ), + ) + monkeypatch.setattr( + data_service, + "get_balance_sheet", + lambda symbol, quarterly=False: annual_frame( + { + "Stockholders Equity": [4_000_000.0, 4_000_000.0, 4_000_000.0, 4_000_000.0], + "Total Assets": [8_000_000.0, 8_000_000.0, 8_000_000.0, 8_000_000.0], + "Long Term Debt And Capital Lease Obligation": [2_000_000.0, 2_000_000.0, 2_000_000.0, 2_000_000.0], + "Cash And Cash Equivalents": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + "Ordinary Shares Number": [1_000_000.0, 1_000_000.0, 1_000_000.0, 1_000_000.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None) + monkeypatch.setattr( + data_service, + "get_price_history", + lambda symbol, period="5y": [ + {"date": "2024-09-30", "close": 10.0}, + {"date": "2023-09-30", "close": 10.0}, + {"date": "2022-09-30", "close": 10.0}, + {"date": "2021-09-30", "close": 10.0}, + ], + ) + + result = data_service.compute_historical_ratios("AAPL") + + assert result["ev_to_ebitda"] == [5.5, 5.5, 5.5, 5.5] + + +def test_compute_historical_ratios_empty_income(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "get_income_statement", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_balance_sheet", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=False: pd.DataFrame()) + monkeypatch.setattr(data_service, "get_shares_outstanding", lambda symbol: None) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.compute_historical_ratios("AAPL") + + assert result == {} + + +def test_get_ratios_quick_ratio(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0, "current_ratio": 2.0}) + monkeypatch.setattr( + data_service, + "compute_historical_ratios", + lambda symbol: { + "current_ratio": [1.4, 1.5, 1.6, 1.8], + }, + ) + 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( + { + "Current Assets": [200.0, 0.0, 0.0, 0.0], + "Current Liabilities": [100.0, 0.0, 0.0, 0.0], + "Inventory": [30.0, 0.0, 0.0, 0.0], + } + ), + ) + monkeypatch.setattr(data_service, "get_cash_flow", lambda symbol, quarterly=True: quarterly_frame({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.get_ratios("AAPL") + + assert result["quick_ratio"]["value"] == 1.7 + assert result["quick_ratio"]["spark"] == [] + assert result["current_ratio"]["value"] == 2.0 + assert result["current_ratio"]["spark"] == [1.4, 1.5, 1.6, 1.8] + + +def test_get_ratios_interest_coverage(monkeypatch) -> None: + clear_service_caches() + monkeypatch.setattr(data_service, "compute_ttm_ratios", lambda symbol: {"market_cap": 1_000.0}) + monkeypatch.setattr(data_service, "compute_historical_ratios", lambda symbol: {}) + 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], + "EBIT": [42.5, 42.5, 42.5, 42.5], + "Interest Expense": [5.0, 5.0, 5.0, 5.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({"Operating Cash Flow": [1.0, 1.0, 1.0, 1.0]})) + monkeypatch.setattr(data_service, "get_price_history", lambda symbol, period="5y": []) + + result = data_service.get_ratios("AAPL") + + assert result["interest_coverage"]["value"] == 8.5 + 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_compute_sector_ratio_benchmarks_without_fmp_key(monkeypatch) -> None: + clear_service_caches() + monkeypatch.delenv("FMP_API_KEY", raising=False) + monkeypatch.setattr( + data_service, + "get_company_info", + lambda symbol: ( + {"sector": "Technology"} + if symbol == "AAPL" + else {"sector": "Technology"} + if symbol == "MSFT" + else {"sector": "Technology"} + if symbol == "NVDA" + else {"sector": "Healthcare"} + ), + ) + monkeypatch.setattr( + data_service, + "search_tickers", + lambda query: [ + {"symbol": "MSFT", "name": "Microsoft", "exchange": "NASDAQ"}, + {"symbol": "NVDA", "name": "NVIDIA", "exchange": "NASDAQ"}, + {"symbol": "UNH", "name": "UnitedHealth", "exchange": "NYSE"}, + ], + ) + monkeypatch.setattr( + data_service, + "compute_ttm_ratios", + lambda symbol: ( + {"trailing_pe": 30.0, "current_ratio": 2.0} + if symbol == "MSFT" + else {"trailing_pe": 20.0, "current_ratio": 1.5} + if symbol == "NVDA" + else {"trailing_pe": 10.0, "current_ratio": 1.0} + ), + ) + + result = data_service.compute_sector_ratio_benchmarks("AAPL") + + assert result["trailing_pe"] == pytest.approx(25.0) + assert result["current_ratio"] == pytest.approx(1.75) + + +def test_ticker_ratios_route(monkeypatch) -> None: + """GET /api/tickers/{symbol}/ratios returns a valid RatiosResponse shape.""" + clear_service_caches() + + def _fake_ratios(sym: str) -> dict: + def _pt(v=None): + return {"value": v, "spark": [], "vs_sector": None} + + return { + "pe_ttm": _pt(24.3), "ev_ebitda": _pt(16.1), + "gross_margin": _pt(0.46), "net_margin": _pt(0.14), + "price_to_book": _pt(5.8), "price_to_sales": _pt(6.2), + "ev_to_sales": _pt(6.5), "p_fcf": _pt(28.4), "forward_pe": _pt(22.0), + "operating_margin": _pt(0.19), "ebitda_margin": _pt(0.22), "fcf_margin": _pt(0.18), + "roe": _pt(0.38), "roa": _pt(0.12), "roic": _pt(0.22), + "debt_to_equity": _pt(1.4), "current_ratio": _pt(1.9), + "quick_ratio": _pt(1.5), "interest_coverage": _pt(8.5), + "dividend_yield": _pt(None), "dividend_payout": _pt(None), + } + + monkeypatch.setattr(main.data_service, "get_ratios", _fake_ratios) + result = main.ticker_ratios("AAPL") + assert result["pe_ttm"]["value"] == pytest.approx(24.3) + assert result["gross_margin"]["spark"] == [] + assert result["dividend_yield"]["vs_sector"] is None diff --git a/frontend/app/prism-shell.css b/frontend/app/prism-shell.css index 9a37bdd..4e65ced 100644 --- a/frontend/app/prism-shell.css +++ b/frontend/app/prism-shell.css @@ -1116,6 +1116,11 @@ overflow: hidden; } +.psm-fin-tab-bar { + border-bottom: 1px solid var(--line-1); + margin-bottom: 0; +} + .psm-fin-header { display: flex; align-items: stretch; @@ -1156,6 +1161,7 @@ display: flex; align-items: center; gap: var(--sp-1); + margin-left: auto; } .psm-fin-period-btn { @@ -1490,3 +1496,190 @@ font-variant-numeric: tabular-nums; text-align: right; } + +/* ── Key ratios tab ─────────────────────────────── */ + +.psm-ratio-card { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-ratio-heroes { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-3); +} + +.psm-ratio-hero { + display: flex; + flex-direction: column; + gap: var(--sp-3); + min-width: 0; + padding: var(--sp-4); + background: var(--ink-2); + border: 1px solid var(--line-1); + border-radius: var(--r-2); +} + +.psm-ratio-hero-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--sp-3); +} + +.psm-ratio-hero-label { + color: var(--fg-3); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-ratio-hero-sector { + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; + text-align: right; +} + +.psm-ratio-hero-value { + font-family: var(--font-mono); + font-size: var(--fs-32); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.psm-ratio-hero-spark { + width: 100%; + height: 52px; +} + +.psm-ratio-detail { + display: flex; + flex-direction: column; + gap: var(--sp-5); +} + +.psm-ratio-group-label { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 96px 88px; + gap: var(--sp-3); + align-items: center; + padding-bottom: var(--sp-2); + border-bottom: 1px solid var(--line-1); + color: var(--fg-4); + font-size: var(--fs-12); + font-weight: 600; + letter-spacing: var(--tr-wider); + text-transform: uppercase; +} + +.psm-ratio-group-label span:nth-child(2), +.psm-ratio-group-label span:nth-child(3) { + text-align: right; +} + +.psm-ratio-group-label span:last-child { + text-align: center; +} + +.psm-ratio-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px 96px 88px; + gap: var(--sp-3); + align-items: center; + padding: var(--sp-3) 0; + border-bottom: 1px solid var(--ink-2); +} + +.psm-ratio-row-label { + color: var(--fg-2); + font-size: var(--fs-13); +} + +.psm-ratio-row-value, +.psm-ratio-row-sector { + color: var(--fg-1); + font-family: var(--font-mono); + font-size: var(--fs-13); + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.psm-ratio-row-sector { + color: var(--fg-4); +} + +.psm-ratio-mini-spark { + width: 88px; + height: 24px; +} + +.psm-ratio-mini-spark, +.psm-ratio-hero-spark { + display: block; +} + +.psm-ratio-spark-empty { + display: inline-flex; + align-items: center; + justify-content: center; + width: 88px; + height: 24px; + color: var(--fg-4); + font-family: var(--font-mono); + font-size: var(--fs-12); + font-variant-numeric: tabular-nums; +} + +.psm-ratio-hero .psm-ratio-spark-empty { + width: 100%; + height: 52px; + justify-content: flex-start; +} + +@media (max-width: 980px) { + .psm-ratio-group-label, + .psm-ratio-row { + grid-template-columns: minmax(0, 1fr) 88px 88px 72px; + } + + .psm-ratio-mini-spark, + .psm-ratio-spark-empty { + width: 72px; + } +} + +@media (max-width: 680px) { + .psm-ratio-heroes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .psm-ratio-group-label { + grid-template-columns: minmax(0, 1fr) 84px 84px 64px; + gap: var(--sp-2); + } + + .psm-ratio-row { + grid-template-columns: minmax(0, 1fr) 84px 84px 64px; + gap: var(--sp-2); + } + + .psm-ratio-hero-head { + flex-direction: column; + align-items: flex-start; + } + + .psm-ratio-hero-sector { + text-align: left; + } + + .psm-ratio-mini-spark, + .psm-ratio-spark-empty { + width: 64px; + } +} diff --git a/frontend/components/prism/FinancialsCard.tsx b/frontend/components/prism/FinancialsCard.tsx index 94a6618..43a2dc2 100644 --- a/frontend/components/prism/FinancialsCard.tsx +++ b/frontend/components/prism/FinancialsCard.tsx @@ -9,16 +9,9 @@ type Props = { data: FinancialsResponse; statement: StatementKey; period: PeriodKey; - onChangeStatement: (s: StatementKey) => void; onChangePeriod: (p: PeriodKey) => void; }; -const STMT_LABELS: Record<StatementKey, string> = { - income: "INCOME", - balance: "BALANCE", - cash_flow: "CASH FLOW", -}; - function fmtFinVal(val: number | null | undefined, isMargin: boolean): string { if (val === null || val === undefined) return "—"; if (isMargin) return `${(val * 100).toFixed(1)}%`; @@ -70,7 +63,6 @@ export function FinancialsCard({ data, statement, period, - onChangeStatement, onChangePeriod, }: Props) { const stmt = data[statement]; @@ -79,18 +71,6 @@ export function FinancialsCard({ return ( <section className="psm-card psm-financials-card"> <div className="psm-fin-header"> - <div className="psm-fin-tabs"> - {(["income", "balance", "cash_flow"] as StatementKey[]).map((key) => ( - <button - key={key} - type="button" - className={`psm-fin-tab${statement === key ? " active" : ""}`} - onClick={() => onChangeStatement(key)} - > - {STMT_LABELS[key]} - </button> - ))} - </div> <div className="psm-fin-period"> {(["annual", "quarterly"] as PeriodKey[]).map((p) => ( <button diff --git a/frontend/components/prism/FinancialsPage.tsx b/frontend/components/prism/FinancialsPage.tsx index fcd2763..9a56f2c 100644 --- a/frontend/components/prism/FinancialsPage.tsx +++ b/frontend/components/prism/FinancialsPage.tsx @@ -4,10 +4,12 @@ import { api } from "@/lib/api"; import { buildKpis } from "@/lib/overview"; import { FinancialsCard } from "@/components/prism/FinancialsCard"; import { KPIStrip } from "@/components/prism/KPIStrip"; +import { RatiosPage } from "@/components/prism/RatiosPage"; import { TickerHeader } from "@/components/prism/TickerHeader"; import type { FinancialsResponse, TickerOverview } from "@/types/api"; -type StatementKey = "income" | "balance" | "cash_flow"; +type StatementKey = "income" | "balance" | "cash_flow" | "ratios"; +type FinancialStatementKey = Exclude<StatementKey, "ratios">; type PeriodKey = "annual" | "quarterly"; type FinState = "loading" | "ready" | "error"; @@ -18,6 +20,13 @@ type Props = { onToggleWatchlist: () => void; }; +const STATEMENT_LABELS: Record<StatementKey, string> = { + income: "INCOME", + balance: "BALANCE", + cash_flow: "CASH FLOW", + ratios: "RATIOS", +}; + export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) { const [statement, setStatement] = useState<StatementKey>("income"); const [period, setPeriod] = useState<PeriodKey>("annual"); @@ -26,6 +35,10 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: const kpis = buildKpis(overview); useEffect(() => { + if (statement === "ratios") { + return; + } + let cancelled = false; setFinState("loading"); setData(null); @@ -45,28 +58,47 @@ export function FinancialsPage({ ticker, overview, isSaved, onToggleWatchlist }: return () => { cancelled = true; }; - }, [ticker, period]); + }, [ticker, period, statement]); return ( <> <TickerHeader overview={overview} isSaved={isSaved} onToggleWatchlist={onToggleWatchlist} /> <KPIStrip items={kpis} /> - {finState === "loading" && ( - <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} /> - )} - {finState === "error" && ( - <section className="psm-card"> - <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p> - </section> - )} - {finState === "ready" && data && ( - <FinancialsCard - data={data} - statement={statement} - period={period} - onChangeStatement={setStatement} - onChangePeriod={setPeriod} - /> + <section className="psm-fin-tab-bar"> + <div className="psm-fin-tabs"> + {(["income", "balance", "cash_flow", "ratios"] as StatementKey[]).map((key) => ( + <button + key={key} + type="button" + className={`psm-fin-tab${statement === key ? " active" : ""}`} + onClick={() => setStatement(key)} + > + {STATEMENT_LABELS[key]} + </button> + ))} + </div> + </section> + {statement === "ratios" ? ( + <RatiosPage ticker={ticker} /> + ) : ( + <> + {finState === "loading" && ( + <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} /> + )} + {finState === "error" && ( + <section className="psm-card"> + <p className="psm-muted-copy">Financial statements unavailable for {ticker}.</p> + </section> + )} + {finState === "ready" && data && ( + <FinancialsCard + data={data} + statement={statement as FinancialStatementKey} + period={period} + onChangePeriod={setPeriod} + /> + )} + </> )} </> ); diff --git a/frontend/components/prism/RatiosCard.tsx b/frontend/components/prism/RatiosCard.tsx new file mode 100644 index 0000000..1a00829 --- /dev/null +++ b/frontend/components/prism/RatiosCard.tsx @@ -0,0 +1,212 @@ +"use client"; + +import type { RatioPoint, RatiosResponse } from "@/types/api"; +import { fmtNumber, fmtPct } from "@/lib/format"; + +const BRASS = "#C2AA7A"; +const GAIN = "#4F8C5E"; + +type Props = { + data: RatiosResponse; +}; + +type ValueKind = "multiple" | "percent" | "coverage"; + +function buildLine(values: (number | null)[], width: number, height: number): string { + const numeric = values.filter((value): value is number => value != null && Number.isFinite(value)); + if (numeric.length === 0) return ""; + if (numeric.length === 1) { + const y = height / 2; + return `0,${y} ${width},${y}`; + } + + const min = Math.min(...numeric); + const max = Math.max(...numeric); + const range = max - min || 1; + + return numeric + .map((value, index) => { + const x = (index / (numeric.length - 1)) * width; + const y = max === min ? height / 2 : height - ((value - min) / range) * height; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); +} + +function fmtMultiple(value?: number | null): string { + if (value == null || Number.isNaN(value)) return "—"; + return `${fmtNumber(value)}x`; +} + +function fmtCoverage(value?: number | null): string { + if (value == null || Number.isNaN(value)) return "—"; + return `${fmtNumber(value)}x`; +} + +function formatValue(value: number | null, kind: ValueKind): string { + if (kind === "percent") return fmtPct(value); + if (kind === "coverage") return fmtCoverage(value); + return fmtMultiple(value); +} + +function MiniSpark({ values, color }: { values: (number | null)[]; color: string }) { + const points = buildLine(values, 88, 24); + if (!points) { + return <span className="psm-ratio-spark-empty">—</span>; + } + + return ( + <svg className="psm-ratio-mini-spark" viewBox="0 0 88 24" aria-hidden="true"> + <polyline + points={points} + fill="none" + stroke={color} + strokeWidth="1.8" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function HeroSpark({ values, color }: { values: (number | null)[]; color: string }) { + const points = buildLine(values, 196, 52); + if (!points) { + return <span className="psm-ratio-spark-empty">No trend</span>; + } + + return ( + <svg className="psm-ratio-hero-spark" viewBox="0 0 196 52" aria-hidden="true"> + <polyline + points={points} + fill="none" + stroke={color} + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ); +} + +function HeroCard({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( + <article className="psm-ratio-hero"> + <div className="psm-ratio-hero-head"> + <span className="psm-ratio-hero-label">{label}</span> + <span className="psm-ratio-hero-sector"> + Sector {formatValue(point.vs_sector, kind)} + </span> + </div> + <div className="psm-ratio-hero-value" style={{ color }}> + {formatValue(point.value, kind)} + </div> + <HeroSpark values={point.spark} color={color} /> + </article> + ); +} + +function DetailRow({ + label, + point, + kind, + color, +}: { + label: string; + point: RatioPoint; + kind: ValueKind; + color: string; +}) { + return ( + <div className="psm-ratio-row"> + <span className="psm-ratio-row-label">{label}</span> + <span className="psm-ratio-row-value">{formatValue(point.value, kind)}</span> + <span className="psm-ratio-row-sector">{formatValue(point.vs_sector, kind)}</span> + <MiniSpark values={point.spark} color={color} /> + </div> + ); +} + +function GroupHeader({ label }: { label: string }) { + return ( + <div className="psm-ratio-group-label"> + <span>{label}</span> + <span>Current</span> + <span>Sector</span> + <span>Trend</span> + </div> + ); +} + +export function RatiosCard({ data }: Props) { + const showDividends = data.dividend_yield.value != null || data.dividend_payout.value != null; + + return ( + <section className="psm-card psm-ratio-card"> + <div className="psm-card-head"> + <div> + <div className="psm-eyebrow">Key Ratios</div> + <h2 className="psm-card-title">Key Ratios</h2> + </div> + </div> + + <div className="psm-ratio-heroes"> + <HeroCard label="P / E TTM" point={data.pe_ttm} kind="multiple" color={BRASS} /> + <HeroCard label="EV / EBITDA" point={data.ev_ebitda} kind="multiple" color={BRASS} /> + <HeroCard label="Gross Margin" point={data.gross_margin} kind="percent" color={GAIN} /> + <HeroCard label="Net Margin" point={data.net_margin} kind="percent" color={GAIN} /> + </div> + + <div className="psm-ratio-detail"> + <section> + <GroupHeader label="Valuation" /> + <DetailRow label="P / Book" point={data.price_to_book} kind="multiple" color={BRASS} /> + <DetailRow label="P / Sales" point={data.price_to_sales} kind="multiple" color={BRASS} /> + <DetailRow label="EV / Sales" point={data.ev_to_sales} kind="multiple" color={BRASS} /> + <DetailRow label="P / FCF" point={data.p_fcf} kind="multiple" color={BRASS} /> + <DetailRow label="Forward P / E" point={data.forward_pe} kind="multiple" color={BRASS} /> + </section> + + <section> + <GroupHeader label="Profitability" /> + <DetailRow label="Operating Margin" point={data.operating_margin} kind="percent" color={GAIN} /> + <DetailRow label="EBITDA Margin" point={data.ebitda_margin} kind="percent" color={GAIN} /> + <DetailRow label="FCF Margin" point={data.fcf_margin} kind="percent" color={GAIN} /> + </section> + + <section> + <GroupHeader label="Returns" /> + <DetailRow label="ROE" point={data.roe} kind="percent" color={GAIN} /> + <DetailRow label="ROA" point={data.roa} kind="percent" color={GAIN} /> + <DetailRow label="ROIC" point={data.roic} kind="percent" color={GAIN} /> + </section> + + <section> + <GroupHeader label="Leverage / Liquidity" /> + <DetailRow label="Debt / Equity" point={data.debt_to_equity} kind="multiple" color={BRASS} /> + <DetailRow label="Current Ratio" point={data.current_ratio} kind="multiple" color={BRASS} /> + <DetailRow label="Quick Ratio" point={data.quick_ratio} kind="multiple" color={BRASS} /> + <DetailRow label="Interest Coverage" point={data.interest_coverage} kind="coverage" color={BRASS} /> + </section> + + {showDividends && ( + <section> + <GroupHeader label="Dividends" /> + <DetailRow label="Dividend Yield" point={data.dividend_yield} kind="percent" color={GAIN} /> + <DetailRow label="Payout Ratio" point={data.dividend_payout} kind="percent" color={GAIN} /> + </section> + )} + </div> + </section> + ); +} diff --git a/frontend/components/prism/RatiosPage.tsx b/frontend/components/prism/RatiosPage.tsx new file mode 100644 index 0000000..26868f8 --- /dev/null +++ b/frontend/components/prism/RatiosPage.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { RatiosCard } from "@/components/prism/RatiosCard"; +import type { RatiosResponse } from "@/types/api"; + +type RatiosState = "loading" | "ready" | "error"; + +type Props = { + ticker: string; +}; + +export function RatiosPage({ ticker }: Props) { + const [data, setData] = useState<RatiosResponse | null>(null); + const [ratiosState, setRatiosState] = useState<RatiosState>("loading"); + + useEffect(() => { + let cancelled = false; + setRatiosState("loading"); + setData(null); + + api + .ratios(ticker) + .then((res) => { + if (!cancelled) { + setData(res); + setRatiosState("ready"); + } + }) + .catch(() => { + if (!cancelled) setRatiosState("error"); + }); + + return () => { + cancelled = true; + }; + }, [ticker]); + + if (ratiosState === "loading") { + return <section className="psm-card psm-skeleton" style={{ minHeight: 320 }} />; + } + + if (ratiosState === "error") { + return ( + <section className="psm-card"> + <p className="psm-muted-copy">Ratio data unavailable for {ticker}.</p> + </section> + ); + } + + if (ratiosState === "ready" && data) { + return <RatiosCard data={data} />; + } + + return null; +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 53b3dd3..b23edee 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,4 +1,4 @@ -import type { FinancialsResponse, HistoryPoint, MarketIndex, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api"; +import type { FinancialsResponse, HistoryPoint, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api"; const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"; @@ -58,5 +58,10 @@ export const api = { return request<ValuationResponse>( `/api/tickers/${encodeURIComponent(symbol)}/valuation` ); + }, + ratios(symbol: string) { + return request<RatiosResponse>( + `/api/tickers/${encodeURIComponent(symbol)}/ratios` + ); } }; diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 998f618..7efe628 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -156,3 +156,33 @@ export type ValuationResponse = { ev_revenue: MultipleResult; price_to_book: MultipleResult; }; + +export type RatioPoint = { + value: number | null; + spark: (number | null)[]; + vs_sector: number | null; +}; + +export type RatiosResponse = { + pe_ttm: RatioPoint; + ev_ebitda: RatioPoint; + gross_margin: RatioPoint; + net_margin: RatioPoint; + price_to_book: RatioPoint; + price_to_sales: RatioPoint; + ev_to_sales: RatioPoint; + p_fcf: RatioPoint; + forward_pe: RatioPoint; + operating_margin: RatioPoint; + ebitda_margin: RatioPoint; + fcf_margin: RatioPoint; + roe: RatioPoint; + roa: RatioPoint; + roic: RatioPoint; + debt_to_equity: RatioPoint; + current_ratio: RatioPoint; + quick_ratio: RatioPoint; + interest_coverage: RatioPoint; + dividend_yield: RatioPoint; + dividend_payout: RatioPoint; +}; |
