summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--backend/app/main.py7
-rw-r--r--backend/app/schemas.py30
-rw-r--r--backend/app/services/data_service.py307
-rw-r--r--backend/tests/test_api.py340
-rw-r--r--frontend/app/prism-shell.css193
-rw-r--r--frontend/components/prism/FinancialsCard.tsx20
-rw-r--r--frontend/components/prism/FinancialsPage.tsx68
-rw-r--r--frontend/components/prism/RatiosCard.tsx212
-rw-r--r--frontend/components/prism/RatiosPage.tsx57
-rw-r--r--frontend/lib/api.ts7
-rw-r--r--frontend/types/api.ts30
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;
+};