aboutsummaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-29 18:04:14 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-29 18:04:14 -0700
commit425874931ffd3e3cef60262f7f0b7cb678629278 (patch)
treeb99dcbba9dba249ee94f9d7caa9986ffe2c23f6f /services
parent678d3290e87a2043b4cb338df6cb93307e91ffc1 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'services')
-rw-r--r--services/data_service.py114
-rw-r--r--services/fmp_service.py18
2 files changed, 127 insertions, 5 deletions
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)