From 92b7eae36866c3424f44b4b6a653833a65df91a9 Mon Sep 17 00:00:00 2001 From: Tyler Date: Mon, 30 Mar 2026 19:09:45 -0700 Subject: Compute all Key Ratios from raw statements, eliminating FMP ratio calls Added compute_ttm_ratios() which derives all 16 TTM ratios directly from yfinance quarterly income statements, balance sheets, and cash flow: Valuation: P/E, P/S, P/B, EV/EBITDA, EV/Revenue Profitability: Gross/Operating/Net Margin, ROE, ROA, ROIC Leverage: D/E, Current Ratio, Quick Ratio, Interest Coverage Dividends: Yield, Payout Ratio get_key_ratios() no longer calls FMP's /ratios-ttm or /key-metrics-ttm endpoints, saving ~2 FMP API calls per ticker load (including each Comps peer). Forward P/E still comes from yfinance info dict (analyst estimate). This also fixes EV/EBITDA for all tickers (DDOG was 4998x from FMP/yfinance pre-computed values, now correctly 194x from income statement EBITDA). Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 47 ++++++--------- services/data_service.py | 147 +++++++++++++++++++++++++++++++++++++++++++++++ services/fmp_service.py | 62 +++++++------------- 3 files changed, 186 insertions(+), 70 deletions(-) diff --git a/components/valuation.py b/components/valuation.py index e77073f..7ac32f5 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -143,45 +143,34 @@ def _render_ratios(ticker: str): st.info("Ratio data unavailable. Check your FMP API key.") return - def r(fmp_key, yf_key=None, fmt=fmt_ratio): - val = ratios.get(fmp_key) if ratios else None - if val is None and yf_key and info: - val = info.get(yf_key) + def r(key, fmt=fmt_ratio): + val = ratios.get(key) if ratios else None return fmt(val) if val is not None else "—" - # Always compute EV/EBITDA from income statement — both FMP and yfinance's - # pre-computed multiples use a bad EBITDA figure for many tickers. - def _ev_ebitda() -> str: - ev = info.get("enterpriseValue") - ebitda = get_ebitda_from_income_stmt(ticker) - if ev and ebitda and ebitda > 0: - return fmt_ratio(ev / ebitda) - return "—" - rows = [ ("Valuation", [ - ("P/E (TTM)", r("peRatioTTM", "trailingPE")), - ("Forward P/E", fmt_ratio(info.get("forwardPE")) if info.get("forwardPE") is not None else "—"), - ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")), - ("P/B", r("priceToBookRatioTTM", "priceToBook")), - ("EV/EBITDA", _ev_ebitda()), - ("EV/Revenue", r("evToSalesTTM", "enterpriseToRevenue")), + ("P/E (TTM)", r("peRatioTTM")), + ("Forward P/E", r("forwardPE")), + ("P/S (TTM)", r("priceToSalesRatioTTM")), + ("P/B", r("priceToBookRatioTTM")), + ("EV/EBITDA", r("enterpriseValueMultipleTTM")), + ("EV/Revenue", r("evToSalesTTM")), ]), ("Profitability", [ - ("Gross Margin", r("grossProfitMarginTTM", "grossMargins", fmt_pct)), - ("Operating Margin", r("operatingProfitMarginTTM", "operatingMargins", fmt_pct)), - ("Net Margin", r("netProfitMarginTTM", "profitMargins", fmt_pct)), - ("ROE", r("returnOnEquityTTM", "returnOnEquity", fmt_pct)), - ("ROA", r("returnOnAssetsTTM", "returnOnAssets", fmt_pct)), + ("Gross Margin", r("grossProfitMarginTTM", fmt=fmt_pct)), + ("Operating Margin", r("operatingProfitMarginTTM", fmt=fmt_pct)), + ("Net Margin", r("netProfitMarginTTM", fmt=fmt_pct)), + ("ROE", r("returnOnEquityTTM", fmt=fmt_pct)), + ("ROA", r("returnOnAssetsTTM", fmt=fmt_pct)), ("ROIC", r("returnOnInvestedCapitalTTM", fmt=fmt_pct)), ]), ("Leverage & Liquidity", [ - ("Debt/Equity", r("debtToEquityRatioTTM", "debtToEquity")), - ("Current Ratio", r("currentRatioTTM", "currentRatio")), - ("Quick Ratio", r("quickRatioTTM", "quickRatio")), + ("Debt/Equity", r("debtToEquityRatioTTM")), + ("Current Ratio", r("currentRatioTTM")), + ("Quick Ratio", r("quickRatioTTM")), ("Interest Coverage", r("interestCoverageRatioTTM")), - ("Dividend Yield", r("dividendYieldTTM", "dividendYield", fmt_pct)), - ("Payout Ratio", r("dividendPayoutRatioTTM", "payoutRatio", fmt_pct)), + ("Dividend Yield", r("dividendYieldTTM", fmt=fmt_pct)), + ("Payout Ratio", r("dividendPayoutRatioTTM", fmt=fmt_pct)), ]), ] diff --git a/services/data_service.py b/services/data_service.py index acc935f..73374df 100644 --- a/services/data_service.py +++ b/services/data_service.py @@ -158,6 +158,153 @@ def get_insider_transactions(ticker: str) -> pd.DataFrame: return pd.DataFrame() +@st.cache_data(ttl=3600) +def compute_ttm_ratios(ticker: str) -> dict: + """Compute all key financial ratios from raw yfinance quarterly statements. + + Returns a dict with FMP-compatible key names so existing rendering code + doesn't need changes. Income items use TTM (sum of last 4 quarters). + Balance-sheet items use the most recent quarter. Market data (price, + market cap, EV) comes from yfinance's info dict. + + This replaces both FMP's /ratios-ttm and /key-metrics-ttm endpoints, + saving ~2 API calls per ticker. + """ + try: + t = yf.Ticker(ticker.upper()) + info = t.info or {} + inc_q = t.quarterly_income_stmt + bal_q = t.quarterly_balance_sheet + cf_q = t.quarterly_cashflow + + if inc_q is None or inc_q.empty: + return {} + + ratios: dict = {} + + def ttm(label): + """Sum last 4 quarters from quarterly income statement.""" + if label in inc_q.index: + vals = inc_q.loc[label].iloc[:4].dropna() + if len(vals) == 4: + return float(vals.sum()) + return None + + def ttm_cf(label): + """Sum last 4 quarters from quarterly cash flow.""" + if cf_q is not None and not cf_q.empty and label in cf_q.index: + vals = cf_q.loc[label].iloc[:4].dropna() + if len(vals) == 4: + return float(vals.sum()) + return None + + def bs(label): + """Most recent quarterly balance-sheet value.""" + if bal_q is not None and not bal_q.empty and label in bal_q.index: + val = bal_q.loc[label].iloc[0] + if pd.notna(val): + return float(val) + return None + + # ── TTM income items ────────────────────────────────────────────────── + revenue = ttm("Total Revenue") + gross_profit = ttm("Gross Profit") + operating_income = ttm("Operating Income") + net_income = ttm("Net Income") + ebit = ttm("EBIT") + ebitda = ttm("EBITDA") or ttm("Normalized EBITDA") + interest_expense = ttm("Interest Expense") + tax_provision = ttm("Tax Provision") + pretax_income = ttm("Pretax Income") + + # ── Balance-sheet items (most recent quarter) ──────────────────────── + equity = bs("Stockholders Equity") or bs("Common Stock Equity") + total_assets = bs("Total Assets") + total_debt = bs("Total Debt") + current_assets = bs("Current Assets") + current_liabilities = bs("Current Liabilities") + inventory = bs("Inventory") + cash = ( + bs("Cash And Cash Equivalents") + or bs("Cash Cash Equivalents And Short Term Investments") + or 0.0 + ) + + # ── Market data (live) ──────────────────────────────────────────────── + market_cap = info.get("marketCap") + ev = info.get("enterpriseValue") + + # ── Profitability ───────────────────────────────────────────────────── + if revenue and revenue > 0: + if gross_profit is not None: + ratios["grossProfitMarginTTM"] = gross_profit / revenue + if operating_income is not None: + ratios["operatingProfitMarginTTM"] = operating_income / revenue + if net_income is not None: + ratios["netProfitMarginTTM"] = net_income / revenue + + if equity and equity > 0 and net_income is not None: + ratios["returnOnEquityTTM"] = net_income / equity + + if total_assets and total_assets > 0 and net_income is not None: + ratios["returnOnAssetsTTM"] = net_income / total_assets + + # ROIC = NOPAT / Invested Capital + if ebit is not None and pretax_income and pretax_income != 0: + effective_tax_rate = max(0.0, (tax_provision or 0) / pretax_income) + nopat = ebit * (1 - effective_tax_rate) + invested_capital = (equity or 0) + (total_debt or 0) - cash + if invested_capital > 0: + ratios["returnOnInvestedCapitalTTM"] = nopat / invested_capital + + # ── Valuation multiples ─────────────────────────────────────────────── + if market_cap and market_cap > 0: + if net_income and net_income > 0: + ratios["peRatioTTM"] = market_cap / net_income + if revenue and revenue > 0: + ratios["priceToSalesRatioTTM"] = market_cap / revenue + if equity and equity > 0: + ratios["priceToBookRatioTTM"] = market_cap / equity + + if ev and ev > 0: + if revenue and revenue > 0: + ratios["evToSalesTTM"] = ev / revenue + if ebitda and ebitda > 0: + ratios["enterpriseValueMultipleTTM"] = ev / ebitda + + # ── Leverage & Liquidity ────────────────────────────────────────────── + if equity and equity > 0 and total_debt is not None: + ratios["debtToEquityRatioTTM"] = total_debt / equity + + if current_liabilities and current_liabilities > 0 and current_assets is not None: + ratios["currentRatioTTM"] = current_assets / current_liabilities + inv = inventory if inventory is not None else 0.0 + ratios["quickRatioTTM"] = (current_assets - inv) / current_liabilities + + if ebit is not None and interest_expense: + ie = abs(interest_expense) + if ie > 0: + ratios["interestCoverageRatioTTM"] = ebit / ie + + # ── Dividends (from cash flow statement) ───────────────────────────── + dividends_paid = None + for label in ("Cash Dividends Paid", "Common Stock Dividend Paid"): + val = ttm_cf(label) + if val is not None: + dividends_paid = abs(val) + break + + if dividends_paid and dividends_paid > 0: + if market_cap and market_cap > 0: + ratios["dividendYieldTTM"] = dividends_paid / market_cap + if net_income and net_income > 0: + ratios["dividendPayoutRatioTTM"] = dividends_paid / net_income + + return ratios + except Exception: + return {} + + @st.cache_data(ttl=3600) def get_ebitda_from_income_stmt(ticker: str) -> float | None: """Return the most recent annual EBITDA from the income statement. diff --git a/services/fmp_service.py b/services/fmp_service.py index 3bfa5c1..6d0ecd0 100644 --- a/services/fmp_service.py +++ b/services/fmp_service.py @@ -3,7 +3,7 @@ import os import requests import streamlit as st from dotenv import load_dotenv -from services.data_service import get_company_info, get_historical_ratios_yfinance +from services.data_service import get_company_info, get_historical_ratios_yfinance, compute_ttm_ratios load_dotenv() @@ -27,51 +27,31 @@ def _get(base_url: str, endpoint: str, params: dict | None = None) -> dict | lis return None -def _apply_yfinance_ratio_fallbacks(ticker: str, merged: dict) -> dict: - info = get_company_info(ticker) - if not info: - return merged - - fallback_map = { - "peRatioTTM": info.get("trailingPE"), - "priceToSalesRatioTTM": info.get("priceToSalesTrailing12Months"), - "priceToBookRatioTTM": info.get("priceToBook"), - "enterpriseValueMultipleTTM": info.get("enterpriseToEbitda"), - "evToEBITDATTM": info.get("enterpriseToEbitda"), - "evToSalesTTM": info.get("enterpriseToRevenue"), - "grossProfitMarginTTM": info.get("grossMargins"), - "operatingProfitMarginTTM": info.get("operatingMargins"), - "netProfitMarginTTM": info.get("profitMargins"), - "returnOnEquityTTM": info.get("returnOnEquity"), - "returnOnAssetsTTM": info.get("returnOnAssets"), - "debtToEquityRatioTTM": info.get("debtToEquity"), - "currentRatioTTM": info.get("currentRatio"), - "quickRatioTTM": info.get("quickRatio"), - "dividendYieldTTM": info.get("dividendYield"), - "dividendPayoutRatioTTM": info.get("payoutRatio"), - } - - for key, value in fallback_map.items(): - if merged.get(key) is None and value is not None: - merged[key] = value - - return merged - - @st.cache_data(ttl=3600) def get_key_ratios(ticker: str) -> dict: - """Return merged stable TTM ratios + key metrics for a ticker, with yfinance fallbacks.""" - ticker = ticker.upper() - ratios = _get(STABLE_BASE, "/ratios-ttm", params={"symbol": ticker}) - metrics = _get(STABLE_BASE, "/key-metrics-ttm", params={"symbol": ticker}) + """Return TTM ratios for a ticker, computed from raw financial statements. + All ratios are self-computed via compute_ttm_ratios() — no FMP calls. + Forward P/E and dividend fallbacks come from yfinance's info dict. + """ + ticker = ticker.upper() merged = {"symbol": ticker} - if isinstance(ratios, list) and ratios: - merged.update(ratios[0]) - if isinstance(metrics, list) and metrics: - merged.update(metrics[0]) - merged = _apply_yfinance_ratio_fallbacks(ticker, merged) + computed = compute_ttm_ratios(ticker) + if computed: + merged.update(computed) + + # Forward P/E requires analyst estimates — can't compute from statements + info = get_company_info(ticker) + if info: + if info.get("forwardPE") is not None: + merged["forwardPE"] = info["forwardPE"] + # Fallback: dividends from info dict when cash-flow data is missing + if merged.get("dividendYieldTTM") is None and info.get("dividendYield") is not None: + merged["dividendYieldTTM"] = info["dividendYield"] + if merged.get("dividendPayoutRatioTTM") is None and info.get("payoutRatio") is not None: + merged["dividendPayoutRatioTTM"] = info["payoutRatio"] + return merged if len(merged) > 1 else {} -- cgit v1.3-2-g0d8e