From 2de6ae37b902e3632ea62b904164552538501ec3 Mon Sep 17 00:00:00 2001 From: Openclaw Date: Mon, 30 Mar 2026 19:36:42 -0700 Subject: 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 --- services/data_service.py | 146 ++++++++++++++++++++++++++++++++++++++++++++--- services/fmp_service.py | 23 ++++---- 2 files changed, 150 insertions(+), 19 deletions(-) (limited to 'services') 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 @@ -31,6 +31,60 @@ def get_company_info(ticker: str) -> dict: return info +@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.""" @@ -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 @@ -484,6 +542,80 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]: return [] +@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).""" 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) -- cgit v1.3-2-g0d8e