diff options
| -rw-r--r-- | components/valuation.py | 19 | ||||
| -rw-r--r-- | services/data_service.py | 22 |
2 files changed, 39 insertions, 2 deletions
diff --git a/components/valuation.py b/components/valuation.py index b6e9d46..a168580 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -9,6 +9,7 @@ from services.data_service import ( get_recommendations_summary, get_earnings_history, get_next_earnings_date, + get_ebitda_from_income_stmt, ) from services.fmp_service import ( get_key_ratios, @@ -148,13 +149,26 @@ def _render_ratios(ticker: str): val = info.get(yf_key) return fmt(val) if val is not None else "—" + # Compute EV/EBITDA from income statement EBITDA — yfinance's info["ebitda"] + # is a known bad value for many tickers (miscalculated TTM aggregation). + def _ev_ebitda() -> str: + # Prefer FMP if available + fmp_val = (ratios or {}).get("enterpriseValueMultipleTTM") or (ratios or {}).get("evToEBITDATTM") + if fmp_val is not None: + return fmt_ratio(fmp_val) + ev = info.get("enterpriseValue") + ebitda = get_ebitda_from_income_stmt(ticker) + if ev and ebitda and ebitda > 0: + return fmt_ratio(ev / ebitda) + return "—" + rows = [ ("Valuation", [ ("P/E (TTM)", r("peRatioTTM", "trailingPE")), ("Forward P/E", fmt_ratio(info.get("forwardPE")) if info.get("forwardPE") is not None else "—"), ("P/S (TTM)", r("priceToSalesRatioTTM", "priceToSalesTrailing12Months")), ("P/B", r("priceToBookRatioTTM", "priceToBook")), - ("EV/EBITDA", r("enterpriseValueMultipleTTM", "enterpriseToEbitda") if ratios.get("enterpriseValueMultipleTTM") is not None else r("evToEBITDATTM", "enterpriseToEbitda")), + ("EV/EBITDA", _ev_ebitda()), ("EV/Revenue", r("evToSalesTTM", "enterpriseToRevenue")), ]), ("Profitability", [ @@ -315,7 +329,8 @@ def _render_dcf(ticker: str): st.divider() st.markdown("**EV/EBITDA Valuation**") - ebitda = info.get("ebitda") + # 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_ebitda_current = info.get("enterpriseToEbitda") diff --git a/services/data_service.py b/services/data_service.py index 3de2484..acc935f 100644 --- a/services/data_service.py +++ b/services/data_service.py @@ -158,6 +158,28 @@ def get_insider_transactions(ticker: str) -> pd.DataFrame: return pd.DataFrame() +@st.cache_data(ttl=3600) +def get_ebitda_from_income_stmt(ticker: str) -> float | None: + """Return the most recent annual EBITDA from the income statement. + + yfinance's info['ebitda'] can be badly wrong for companies with large + stock-based compensation (e.g. it may deduct SBC, leaving near-zero EBITDA + even when the income statement EBITDA line is hundreds of millions). + The income statement 'EBITDA' row is the standard EBIT + D&A figure. + """ + try: + t = yf.Ticker(ticker.upper()) + inc = t.income_stmt + for label in ("EBITDA", "Normalized EBITDA"): + if label in inc.index: + val = inc.loc[label].iloc[0] + if val is not None and pd.notna(val): + return float(val) + return None + except Exception: + return None + + @st.cache_data(ttl=900) def get_options_chain(ticker: str) -> dict: """Return options chain data for the nearest available expirations. |
