From 425874931ffd3e3cef60262f7f0b7cb678629278 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 29 Mar 2026 18:04:14 -0700 Subject: Add yfinance fallback for historical ratios when FMP quota is exhausted FMP free tier caps at 250 req/day; hitting the limit caused the Historical Ratios tab to show an error. get_historical_ratios_yfinance now computes margins, ROE, ROA, D/E, P/E, P/B, P/S, and EV/EBITDA directly from yfinance income statements, balance sheets, and price history. FMP functions fall back to this automatically when they receive an empty response. Co-Authored-By: Claude Sonnet 4.6 --- components/valuation.py | 2 +- services/data_service.py | 114 +++++++++++++++++++++++++++++++++++++++++++++++ services/fmp_service.py | 18 +++++--- 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/components/valuation.py b/components/valuation.py index 0c50a28..4dea754 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -640,7 +640,7 @@ def _render_historical_ratios(ticker: str): metric_rows = get_historical_key_metrics(ticker) if not ratio_rows and not metric_rows: - st.info("Historical ratio data unavailable. Requires FMP API key.") + st.info("Historical ratio data unavailable.") return # Merge both lists by date diff --git a/services/data_service.py b/services/data_service.py index 5ac8573..f0f981b 100644 --- a/services/data_service.py +++ b/services/data_service.py @@ -171,6 +171,120 @@ def get_sec_filings(ticker: str) -> list[dict]: return [] +@st.cache_data(ttl=86400) +def get_historical_ratios_yfinance(ticker: str) -> list[dict]: + """Compute annual historical ratios from yfinance financial statements. + + Returns dicts with the same field names used by FMP's /ratios and /key-metrics + endpoints so callers can use either source interchangeably. + Covers: margins, ROE, ROA, D/E, P/E, P/B, P/S (price-based ratios are + approximate — they use average price near each fiscal year-end date). + """ + try: + t = yf.Ticker(ticker.upper()) + income = t.income_stmt # rows=metrics, cols=fiscal-year dates + balance = t.balance_sheet + info = t.info or {} + + if income is None or income.empty: + return [] + + # One year of monthly price history per fiscal year going back 10 years + hist = t.history(period="10y", interval="1mo") + shares = info.get("sharesOutstanding") or info.get("impliedSharesOutstanding") + + rows: list[dict] = [] + for date in income.columns: + row: dict = {"date": str(date)[:10]} + + # Pull income-statement items (may be NaN) + def _inc(label): + try: + v = income.loc[label, date] + return float(v) if pd.notna(v) else None + except KeyError: + return None + + total_rev = _inc("Total Revenue") + gross_profit = _inc("Gross Profit") + operating_income = _inc("Operating Income") + net_income = _inc("Net Income") + ebitda_raw = _inc("EBITDA") or _inc("Normalized EBITDA") + + # Margins + if total_rev and total_rev > 0: + if gross_profit is not None: + row["grossProfitMargin"] = gross_profit / total_rev + if operating_income is not None: + row["operatingProfitMargin"] = operating_income / total_rev + if net_income is not None: + row["netProfitMargin"] = net_income / total_rev + + # Balance-sheet items + equity = None + total_assets = None + total_debt = None + if balance is not None and not balance.empty and date in balance.columns: + def _bal(label): + try: + v = balance.loc[label, date] + return float(v) if pd.notna(v) else None + except KeyError: + return None + + 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") + total_cash = _bal("Cash And Cash Equivalents") or _bal("Cash Cash Equivalents And Short Term Investments") or 0.0 + + if equity and equity > 0: + if net_income is not None: + row["returnOnEquity"] = net_income / equity + if total_debt is not None: + row["debtEquityRatio"] = total_debt / equity + + if total_assets and total_assets > 0 and net_income is not None: + row["returnOnAssets"] = net_income / total_assets + + # Price-based ratios — average closing price in ±45-day window around year-end + if shares and not hist.empty: + try: + date_ts = pd.Timestamp(date) + # Normalize timezones: yfinance history index may be tz-aware + hist_idx = hist.index + if hist_idx.tz is not None: + date_ts = date_ts.tz_localize(hist_idx.tz) + mask = ( + (hist_idx >= date_ts - pd.DateOffset(days=45)) & + (hist_idx <= date_ts + pd.DateOffset(days=45)) + ) + window = hist.loc[mask, "Close"] + if not window.empty: + price = float(window.mean()) + market_cap = price * shares + + if net_income and net_income > 0: + row["peRatio"] = market_cap / net_income + if equity and equity > 0: + row["priceToBookRatio"] = market_cap / equity + if total_rev and total_rev > 0: + row["priceToSalesRatio"] = market_cap / total_rev + + # EV/EBITDA — approximate + if ebitda_raw and ebitda_raw > 0 and total_debt is not None: + ev = market_cap + (total_debt or 0) - (total_cash or 0) + row["enterpriseValueMultiple"] = ev / ebitda_raw + except Exception: + pass + + if len(row) > 1: + rows.append(row) + + return rows + except Exception: + return [] + + @st.cache_data(ttl=3600) def get_free_cash_flow_series(ticker: str) -> pd.Series: """Return annual Free Cash Flow series (most recent first).""" diff --git a/services/fmp_service.py b/services/fmp_service.py index bba6c85..3bfa5c1 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 +from services.data_service import get_company_info, get_historical_ratios_yfinance load_dotenv() @@ -121,16 +121,24 @@ def get_company_news(ticker: str, limit: int = 20) -> list[dict]: @st.cache_data(ttl=86400) def get_historical_ratios(ticker: str, limit: int = 10) -> list[dict]: - """Annual historical valuation ratios (P/E, P/B, P/S, EV/EBITDA, etc.).""" + """Annual historical valuation ratios (P/E, P/B, P/S, EV/EBITDA, etc.). + Falls back to yfinance-computed ratios if FMP returns empty (e.g. rate limit).""" data = _get(STABLE_BASE, "/ratios", params={"symbol": ticker.upper(), "limit": limit}) - return data if isinstance(data, list) else [] + if isinstance(data, list) and data: + return data + return get_historical_ratios_yfinance(ticker.upper()) @st.cache_data(ttl=86400) def get_historical_key_metrics(ticker: str, limit: int = 10) -> list[dict]: - """Annual historical key metrics (ROE, ROA, margins, debt/equity, etc.).""" + """Annual historical key metrics (ROE, ROA, margins, debt/equity, etc.). + Falls back to yfinance-computed metrics if FMP returns empty (e.g. rate limit).""" data = _get(STABLE_BASE, "/key-metrics", params={"symbol": ticker.upper(), "limit": limit}) - return data if isinstance(data, list) else [] + if isinstance(data, list) and data: + return data + # yfinance fallback already covers all key metrics — return empty to avoid duplication + # (get_historical_ratios will have already provided the full merged dataset) + return [] @st.cache_data(ttl=3600) -- cgit v1.3-2-g0d8e