diff options
| author | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-03-30 19:36:42 -0700 |
|---|---|---|
| committer | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-03-30 19:36:42 -0700 |
| commit | 2de6ae37b902e3632ea62b904164552538501ec3 (patch) | |
| tree | 1fef3aa3df32dc27278e2be7d5b8fd88cb9bae06 | |
| parent | 7a169d4f2bfeb79735823c1eb39f9162329b240e (diff) | |
Unify valuation calculations across Prism
- compute EV consistently as market cap + debt - cash
- derive DCF/EV bridge inputs from balance-sheet rows
- centralize latest price, shares outstanding, and computed market cap helpers
- relabel negative net debt as net cash in valuation UI
- self-compute historical ratios/key metrics instead of relying on vendor ratios
- guard against nonsensical historical EV/EBITDA values
- add methodology/source notes in DCF tab
| -rw-r--r-- | components/valuation.py | 69 | ||||
| -rw-r--r-- | services/data_service.py | 146 | ||||
| -rw-r--r-- | services/fmp_service.py | 23 |
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) |
