aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/valuation.py2
-rw-r--r--services/data_service.py114
-rw-r--r--services/fmp_service.py18
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)