aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/valuation.py69
-rw-r--r--services/data_service.py146
-rw-r--r--services/fmp_service.py23
3 files changed, 199 insertions, 39 deletions
diff --git a/components/valuation.py b/components/valuation.py
index 88ea889..863193b 100644
--- a/components/valuation.py
+++ b/components/valuation.py
@@ -4,7 +4,11 @@ import plotly.graph_objects as go
import streamlit as st
from services.data_service import (
get_company_info,
+ get_latest_price,
+ get_shares_outstanding,
+ get_market_cap_computed,
get_free_cash_flow_series,
+ get_balance_sheet_bridge_items,
get_analyst_price_targets,
get_recommendations_summary,
get_earnings_history,
@@ -140,7 +144,7 @@ def _render_ratios(ticker: str):
info = get_company_info(ticker)
if not ratios and not info:
- st.info("Ratio data unavailable. Check your FMP API key.")
+ st.info("Ratio data unavailable.")
return
def r(key, fmt=fmt_ratio):
@@ -184,6 +188,10 @@ def _render_ratios(ticker: str):
# ── DCF Model ────────────────────────────────────────────────────────────────
+def _net_debt_label(value: float) -> str:
+ return "Net Cash" if value < 0 else "Net Debt"
+
+
def _render_dcf(ticker: str):
info = get_company_info(ticker)
@@ -198,17 +206,13 @@ def _render_dcf(ticker: str):
)
return
- shares = info.get("sharesOutstanding") or info.get("floatShares")
- current_price = info.get("currentPrice") or info.get("regularMarketPrice")
- total_debt = info.get("totalDebt") or 0.0
- cash_and_equivalents = (
- info.get("totalCash")
- or info.get("cash")
- or info.get("cashAndCashEquivalents")
- or 0.0
- )
- preferred_equity = info.get("preferredStock") or 0.0
- minority_interest = info.get("minorityInterest") or 0.0
+ shares = get_shares_outstanding(ticker)
+ current_price = get_latest_price(ticker)
+ bridge_items = get_balance_sheet_bridge_items(ticker)
+ total_debt = bridge_items["total_debt"]
+ cash_and_equivalents = bridge_items["cash_and_equivalents"]
+ preferred_equity = bridge_items["preferred_equity"]
+ minority_interest = bridge_items["minority_interest"]
if not shares:
st.info("Shares outstanding not available — DCF cannot be computed.")
@@ -276,14 +280,34 @@ def _render_dcf(ticker: str):
m3.metric("Upside / Downside", f"{upside * 100:+.1f}%", delta=f"{upside * 100:+.1f}%")
m4.metric("FCF Growth Used", f"{result['growth_rate_used'] * 100:.1f}%")
+ source_date = bridge_items.get("source_date")
st.caption(
"DCF is modeled on firm-level free cash flow, so enterprise value is bridged to equity value "
- "using cash and debt before calculating per-share value."
+ "using debt and cash from the most recent balance sheet before calculating per-share value."
)
+ if source_date:
+ st.caption(f"Balance-sheet bridge source date: **{source_date}**")
+
+ with st.expander("Methodology & sources", expanded=False):
+ st.markdown(
+ "- **TTM ratios:** computed from raw quarterly financial statements where possible.\n"
+ "- **Enterprise Value:** computed as market cap + total debt - cash & equivalents.\n"
+ "- **Market cap:** computed as latest price × shares outstanding when available.\n"
+ "- **Shares outstanding:** pulled from yfinance shares fields.\n"
+ "- **DCF bridge:** uses the most recent annual balance sheet for debt, cash, preferred equity, and minority interest.\n"
+ "- **Historical ratios:** computed from annual statements plus price history, with guards against nonsensical EV/EBITDA values.\n"
+ "- **Forward metrics:** analyst-driven items such as Forward P/E and estimates still depend on vendor data."
+ )
+
+ bridge_a, bridge_b, bridge_c, bridge_d = st.columns(4)
+ bridge_a.metric("Total Debt", fmt_large(total_debt))
+ bridge_b.metric("Cash & Equivalents", fmt_large(cash_and_equivalents))
+ bridge_c.metric("Preferred Equity", fmt_large(preferred_equity))
+ bridge_d.metric("Minority Interest", fmt_large(minority_interest))
bridge1, bridge2, bridge3, bridge4 = st.columns(4)
bridge1.metric("Enterprise Value", fmt_large(result["enterprise_value"]))
- bridge2.metric("Net Debt", fmt_large(result["net_debt"]))
+ bridge2.metric(_net_debt_label(result["net_debt"]), fmt_large(abs(result["net_debt"])))
bridge3.metric("Equity Value", fmt_large(result["equity_value"]))
bridge4.metric("Terminal Value PV", fmt_large(result["terminal_value_pv"]))
@@ -316,11 +340,14 @@ def _render_dcf(ticker: str):
# Use income statement EBITDA — info["ebitda"] is unreliable in yfinance
ebitda = get_ebitda_from_income_stmt(ticker) or info.get("ebitda")
- total_debt = info.get("totalDebt") or 0.0
- total_cash = info.get("totalCash") or 0.0
+ ev_bridge_items = get_balance_sheet_bridge_items(ticker)
+ total_debt = ev_bridge_items["total_debt"]
+ total_cash = ev_bridge_items["cash_and_equivalents"]
- # Compute current EV/EBITDA from our own data, not the bad info dict value
- ev_val = info.get("enterpriseValue")
+ market_cap = get_market_cap_computed(ticker)
+ ev_val = None
+ if market_cap and ebitda and ebitda > 0:
+ ev_val = float(market_cap) + float(total_debt or 0.0) - float(total_cash or 0.0)
ev_ebitda_current = (ev_val / ebitda) if (ev_val and ebitda and ebitda > 0) else None
if not ebitda or ebitda <= 0:
@@ -367,9 +394,11 @@ def _render_dcf(ticker: str):
ev_m4.metric("Implied EV", fmt_large(ev_result["implied_ev"]))
st.caption(
f"EBITDA: {fmt_large(ebitda)} · "
- f"Net Debt: {fmt_large(ev_result['net_debt'])} · "
+ f"{_net_debt_label(ev_result['net_debt'])}: {fmt_large(abs(ev_result['net_debt']))} · "
f"Equity Value: {fmt_large(ev_result['equity_value'])}"
)
+ if ev_bridge_items.get("source_date"):
+ st.caption(f"EV/EBITDA bridge source date: **{ev_bridge_items['source_date']}**")
else:
st.warning("Could not compute EV/EBITDA valuation.")
@@ -741,7 +770,7 @@ def _render_forward_estimates(ticker: str):
return
info = get_company_info(ticker)
- current_price = info.get("currentPrice") or info.get("regularMarketPrice")
+ current_price = get_latest_price(ticker)
tab_ann, tab_qtr = st.tabs(["Annual", "Quarterly"])
diff --git a/services/data_service.py b/services/data_service.py
index 73374df..156a33e 100644
--- a/services/data_service.py
+++ b/services/data_service.py
@@ -32,6 +32,60 @@ def get_company_info(ticker: str) -> dict:
@st.cache_data(ttl=300)
+def get_latest_price(ticker: str) -> float | None:
+ """Return latest close price from recent history, falling back to info."""
+ try:
+ t = yf.Ticker(ticker.upper())
+ hist = t.history(period="5d")
+ if hist is not None and not hist.empty:
+ close = hist["Close"].dropna()
+ if not close.empty:
+ return float(close.iloc[-1])
+ info = t.info or {}
+ for key in ("currentPrice", "regularMarketPrice", "previousClose"):
+ val = info.get(key)
+ if val is not None:
+ return float(val)
+ return None
+ except Exception:
+ return None
+
+
+@st.cache_data(ttl=300)
+def get_shares_outstanding(ticker: str) -> float | None:
+ """Return shares outstanding, preferring explicit shares fields."""
+ try:
+ t = yf.Ticker(ticker.upper())
+ info = t.info or {}
+ for key in ("sharesOutstanding", "impliedSharesOutstanding", "floatShares"):
+ val = info.get(key)
+ if val is not None:
+ return float(val)
+ return None
+ except Exception:
+ return None
+
+
+@st.cache_data(ttl=300)
+def get_market_cap_computed(ticker: str) -> float | None:
+ """Return market cap computed as latest price × shares outstanding.
+
+ Falls back to info['marketCap'] only when one of the computed inputs is unavailable.
+ """
+ price = get_latest_price(ticker)
+ shares = get_shares_outstanding(ticker)
+ if price is not None and shares is not None and price > 0 and shares > 0:
+ return float(price) * float(shares)
+
+ try:
+ info = get_company_info(ticker)
+ market_cap = info.get("marketCap")
+ return float(market_cap) if market_cap is not None else None
+ except Exception:
+ return None
+
+
+@st.cache_data(ttl=300)
def get_price_history(ticker: str, period: str = "1y") -> pd.DataFrame:
"""Return OHLCV price history."""
t = yf.Ticker(ticker.upper())
@@ -231,8 +285,8 @@ def compute_ttm_ratios(ticker: str) -> dict:
)
# ── Market data (live) ────────────────────────────────────────────────
- market_cap = info.get("marketCap")
- ev = info.get("enterpriseValue")
+ market_cap = get_market_cap_computed(ticker)
+ ev = None
# ── Profitability ─────────────────────────────────────────────────────
if revenue and revenue > 0:
@@ -266,7 +320,8 @@ def compute_ttm_ratios(ticker: str) -> dict:
if equity and equity > 0:
ratios["priceToBookRatioTTM"] = market_cap / equity
- if ev and ev > 0:
+ if market_cap and market_cap > 0:
+ ev = market_cap + (total_debt or 0.0) - cash
if revenue and revenue > 0:
ratios["evToSalesTTM"] = ev / revenue
if ebitda and ebitda > 0:
@@ -390,7 +445,7 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]:
# 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")
+ shares = get_shares_outstanding(ticker)
rows: list[dict] = []
for date in income.columns:
@@ -469,10 +524,13 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]:
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/EBITDA — approximate. Skip if EBITDA is too small to be meaningful,
+ # which otherwise creates absurd multiples for some software names.
+ if ebitda_raw and ebitda_raw > 1e6 and total_debt is not None:
ev = market_cap + (total_debt or 0) - (total_cash or 0)
- row["enterpriseValueMultiple"] = ev / ebitda_raw
+ multiple = ev / ebitda_raw
+ if 0 < multiple < 500:
+ row["enterpriseValueMultiple"] = multiple
except Exception:
pass
@@ -485,6 +543,80 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]:
@st.cache_data(ttl=3600)
+def get_balance_sheet_bridge_items(ticker: str) -> dict:
+ """Return debt/cash bridge inputs from the most recent balance sheet.
+
+ Uses the same raw balance-sheet rows shown in the Financials tab so DCF
+ equity-value bridging reconciles with the visible statements.
+ """
+ df = get_balance_sheet(ticker, quarterly=False)
+ if df is None or df.empty:
+ return {
+ "total_debt": 0.0,
+ "cash_and_equivalents": 0.0,
+ "preferred_equity": 0.0,
+ "minority_interest": 0.0,
+ "net_debt": 0.0,
+ "source_date": None,
+ }
+
+ latest_col = df.columns[0]
+
+ def pick(*labels):
+ for label in labels:
+ if label in df.index:
+ val = df.loc[label, latest_col]
+ if pd.notna(val):
+ return float(val)
+ return None
+
+ short_term_debt = pick(
+ "Current Debt",
+ "Current Debt And Capital Lease Obligation",
+ "Current Capital Lease Obligation",
+ "Commercial Paper",
+ "Other Current Borrowings",
+ ) or 0.0
+
+ long_term_debt = pick(
+ "Long Term Debt",
+ "Long Term Debt And Capital Lease Obligation",
+ "Long Term Capital Lease Obligation",
+ ) or 0.0
+
+ total_debt = pick("Total Debt")
+ if total_debt is None:
+ total_debt = short_term_debt + long_term_debt
+
+ cash_and_equivalents = (
+ pick(
+ "Cash And Cash Equivalents",
+ "Cash Cash Equivalents And Short Term Investments",
+ "Cash",
+ )
+ or 0.0
+ )
+
+ preferred_equity = pick("Preferred Stock") or 0.0
+ minority_interest = pick(
+ "Minority Interest",
+ "Minority Interests",
+ "Total Equity Gross Minority Interest",
+ ) or 0.0
+
+ net_debt = total_debt - cash_and_equivalents
+
+ return {
+ "total_debt": total_debt,
+ "cash_and_equivalents": cash_and_equivalents,
+ "preferred_equity": preferred_equity,
+ "minority_interest": minority_interest,
+ "net_debt": net_debt,
+ "source_date": str(latest_col)[:10],
+ }
+
+
+@st.cache_data(ttl=3600)
def get_free_cash_flow_series(ticker: str) -> pd.Series:
"""Return annual Free Cash Flow series (most recent first)."""
t = yf.Ticker(ticker.upper())
diff --git a/services/fmp_service.py b/services/fmp_service.py
index 6d0ecd0..1e5ea42 100644
--- a/services/fmp_service.py
+++ b/services/fmp_service.py
@@ -102,23 +102,22 @@ 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.).
- 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})
- if isinstance(data, list) and data:
- return data
- return get_historical_ratios_yfinance(ticker.upper())
+
+ Prism computes these from raw yfinance statements + price history so the
+ methodology stays consistent with the rest of the app.
+ """
+ rows = get_historical_ratios_yfinance(ticker.upper())
+ return rows[:limit] if rows else []
@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.).
- 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})
- 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 []
+
+ Returned via the same self-computed historical dataset as get_historical_ratios().
+ """
+ rows = get_historical_ratios_yfinance(ticker.upper())
+ return rows[:limit] if rows else []
@st.cache_data(ttl=3600)