diff options
| author | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-04-01 23:32:01 -0700 |
|---|---|---|
| committer | Openclaw <openclaw@mail.tylerhoang.xyz> | 2026-04-01 23:32:01 -0700 |
| commit | 3806bd3b4d69917f3f5312acfa57bc4ee2886a49 (patch) | |
| tree | 7fca5c5c8aacc5365d2db33e40da2e251e3c67b0 | |
| parent | 96b27f1d00ae8110273de973053c3d6bfc4f3662 (diff) | |
Harden valuation edge cases
| -rw-r--r-- | components/valuation.py | 206 | ||||
| -rw-r--r-- | services/data_service.py | 68 | ||||
| -rw-r--r-- | services/fmp_service.py | 39 | ||||
| -rw-r--r-- | services/valuation_service.py | 7 |
4 files changed, 262 insertions, 58 deletions
diff --git a/components/valuation.py b/components/valuation.py index a72d177..72c8001 100644 --- a/components/valuation.py +++ b/components/valuation.py @@ -146,35 +146,138 @@ def _render_ratios(ticker: str): st.info("Ratio data unavailable.") return - def r(key, fmt=fmt_ratio): + def _normalized_label(label: str) -> str: + return " ".join(str(label).replace("/", " ").replace("-", " ").split()).strip().lower() + + def _display_value(key: str, fmt=fmt_ratio): val = ratios.get(key) if ratios else None return fmt(val) if val is not None else "—" + def _company_context() -> dict: + return info or {} + + def _display_reasoned_metric(key: str, fmt=fmt_ratio) -> str: + val = ratios.get(key) if ratios else None + if val is not None: + return fmt(val) + + ctx = _company_context() + + if key == "peRatioTTM": + trailing_pe = ctx.get("trailingPE") + if trailing_pe is not None: + return fmt_ratio(trailing_pe) + if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: + return "N/M (neg. TTM earnings)" + trailing_eps = ctx.get("trailingEps") + if trailing_eps is not None: + try: + if float(trailing_eps) <= 0: + return "N/M (neg. TTM earnings)" + except (TypeError, ValueError): + pass + return "—" + + if key == "priceToBookRatioTTM": + book_value = ctx.get("bookValue") + if book_value is not None: + try: + if float(book_value) <= 0: + return "N/M (neg. equity)" + except (TypeError, ValueError): + pass + return "—" + + if key == "enterpriseValueMultipleTTM": + ebitda = ratios.get("ebitdaTTM") if ratios else None + if ebitda is not None: + try: + if float(ebitda) <= 0: + return "N/M (neg. EBITDA)" + except (TypeError, ValueError): + pass + return "—" + + if key == "dividendPayoutRatioTTM": + payout_ratio = ctx.get("payoutRatio") + if payout_ratio is not None: + try: + if float(payout_ratio) <= 0: + return "—" + except (TypeError, ValueError): + pass + if ratios and ratios.get("netProfitMarginTTM") is not None and ratios.get("netProfitMarginTTM") < 0: + return "N/M (neg. earnings)" + return "—" + + if key == "returnOnEquityTTM": + book_value = ctx.get("bookValue") + if book_value is not None: + try: + if float(book_value) <= 0: + return "N/M (neg. equity)" + except (TypeError, ValueError): + pass + return "—" + + if key == "debtToEquityRatioTTM": + book_value = ctx.get("bookValue") + if book_value is not None: + try: + if float(book_value) <= 0: + return "N/M (neg. equity)" + except (TypeError, ValueError): + pass + return "—" + + if key == "interestCoverageRatioTTM": + operating_margins = ctx.get("operatingMargins") + if operating_margins is not None: + try: + if float(operating_margins) <= 0: + return "N/M (neg. EBIT)" + except (TypeError, ValueError): + pass + return "—" + + return "—" + + def _dedupe_metrics(metrics: list[tuple[str, str]]) -> list[tuple[str, str]]: + deduped: list[tuple[str, str]] = [] + seen_labels: set[str] = set() + for label, val in metrics: + norm = _normalized_label(label) + if norm in seen_labels: + continue + seen_labels.add(norm) + deduped.append((label, val)) + return deduped + rows = [ - ("Valuation", [ - ("P/E (TTM)", r("peRatioTTM")), - ("Forward P/E", r("forwardPE")), - ("P/S (TTM)", r("priceToSalesRatioTTM")), - ("P/B", r("priceToBookRatioTTM")), - ("EV/EBITDA", r("enterpriseValueMultipleTTM")), - ("EV/Revenue", r("evToSalesTTM")), - ]), - ("Profitability", [ - ("Gross Margin", r("grossProfitMarginTTM", fmt=fmt_pct)), - ("Operating Margin", r("operatingProfitMarginTTM", fmt=fmt_pct)), - ("Net Margin", r("netProfitMarginTTM", fmt=fmt_pct)), - ("ROE", r("returnOnEquityTTM", fmt=fmt_pct)), - ("ROA", r("returnOnAssetsTTM", fmt=fmt_pct)), - ("ROIC", r("returnOnInvestedCapitalTTM", fmt=fmt_pct)), - ]), - ("Leverage & Liquidity", [ - ("Debt/Equity", r("debtToEquityRatioTTM")), - ("Current Ratio", r("currentRatioTTM")), - ("Quick Ratio", r("quickRatioTTM")), - ("Interest Coverage", r("interestCoverageRatioTTM")), - ("Dividend Yield", r("dividendYieldTTM", fmt=fmt_pct)), - ("Payout Ratio", r("dividendPayoutRatioTTM", fmt=fmt_pct)), - ]), + ("Valuation", _dedupe_metrics([ + ("P/E (TTM)", _display_reasoned_metric("peRatioTTM")), + ("Forward P/E", _display_value("forwardPE")), + ("P/S (TTM)", _display_value("priceToSalesRatioTTM")), + ("P/B", _display_reasoned_metric("priceToBookRatioTTM")), + ("EV/EBITDA", _display_reasoned_metric("enterpriseValueMultipleTTM")), + ("EV/Revenue", _display_value("evToSalesTTM")), + ])), + ("Profitability", _dedupe_metrics([ + ("Gross Margin", _display_value("grossProfitMarginTTM", fmt=fmt_pct)), + ("Operating Margin", _display_value("operatingProfitMarginTTM", fmt=fmt_pct)), + ("Net Margin", _display_value("netProfitMarginTTM", fmt=fmt_pct)), + ("ROE", _display_reasoned_metric("returnOnEquityTTM", fmt=fmt_pct)), + ("ROA", _display_value("returnOnAssetsTTM", fmt=fmt_pct)), + ("ROIC", _display_value("returnOnInvestedCapitalTTM", fmt=fmt_pct)), + ])), + ("Leverage & Liquidity", _dedupe_metrics([ + ("Debt/Equity", _display_reasoned_metric("debtToEquityRatioTTM")), + ("Current Ratio", _display_value("currentRatioTTM")), + ("Quick Ratio", _display_value("quickRatioTTM")), + ("Interest Coverage", _display_reasoned_metric("interestCoverageRatioTTM")), + ("Dividend Yield", _display_value("dividendYieldTTM", fmt=fmt_pct)), + ("Payout Ratio", _display_reasoned_metric("dividendPayoutRatioTTM", fmt=fmt_pct)), + ])), ] for section_name, metrics in rows: @@ -463,14 +566,30 @@ def _render_comps(ticker: str): available = [c for c in ["symbol", "peRatioTTM", "priceToSalesRatioTTM", "priceToBookRatioTTM", "enterpriseValueMultipleTTM", "netProfitMarginTTM", "returnOnEquityTTM", "debtToEquityRatioTTM"] if c in df.columns] df = df[available].rename(columns=display_cols) - pct_cols = {"Net Margin", "ROE"} + def _format_comp_value(column: str, value): + if value is None: + return "—" + try: + v = float(value) + except (TypeError, ValueError): + return "—" + + if column == "P/E": + return fmt_ratio(v) if v > 0 else "N/M (neg. earnings)" + if column == "P/B": + return fmt_ratio(v) if v > 0 else "N/M (neg. equity)" + if column == "EV/EBITDA": + return fmt_ratio(v) if v > 0 else "N/M (neg. EBITDA)" + if column == "D/E": + return fmt_ratio(v) if v >= 0 else "N/M (neg. equity)" + if column in {"Net Margin", "ROE"}: + return fmt_pct(v) + return fmt_ratio(v) if v > 0 else "—" + for col in df.columns: if col == "Ticker": continue - if col in pct_cols: - df[col] = df[col].apply(lambda v: fmt_pct(v) if v is not None else "—") - else: - df[col] = df[col].apply(lambda v: fmt_ratio(v) if v is not None else "—") + df[col] = df[col].apply(lambda v, c=col: _format_comp_value(c, v)) def highlight_subject(row): if row["Ticker"] == ticker.upper(): @@ -737,19 +856,30 @@ def _render_historical_ratios(ticker: str): primary, alt, fmt = _HIST_RATIO_OPTIONS[label] display_cols[label] = (primary, alt, fmt) + def _format_hist_value(label: str, value, fmt: str | None) -> str: + if value is None: + return "—" + try: + v = float(value) + except (TypeError, ValueError): + return "—" + + if fmt == "pct": + return f"{v * 100:.2f}%" + if label == "P/E": + return f"{v:.2f}x" if v > 0 else "N/M (neg. earnings)" + if label == "EV/EBITDA": + return f"{v:.2f}x" if v > 0 else "N/M (neg. EBITDA)" + if label in {"P/B", "Debt/Equity"}: + return f"{v:.2f}x" if v > 0 else "N/M (neg. equity)" + return f"{v:.2f}x" if v > 0 else "—" + table_rows = [] for row in merged_rows: r: dict = {"Year": str(row.get("date", ""))[:4]} for label, (primary, alt, fmt) in display_cols.items(): val = row.get(primary) or (row.get(alt) if alt else None) - if val is not None: - try: - v = float(val) - r[label] = f"{v * 100:.2f}%" if fmt == "pct" else f"{v:.2f}x" - except (TypeError, ValueError): - r[label] = "—" - else: - r[label] = "—" + r[label] = _format_hist_value(label, val, fmt) table_rows.append(r) if table_rows: diff --git a/services/data_service.py b/services/data_service.py index 412ca94..c278a2f 100644 --- a/services/data_service.py +++ b/services/data_service.py @@ -298,10 +298,14 @@ def compute_ttm_ratios(ticker: str) -> dict: ratios["netProfitMarginTTM"] = net_income / revenue if equity and equity > 0 and net_income is not None: - ratios["returnOnEquityTTM"] = net_income / equity + roe = net_income / equity + if abs(roe) < 10: + ratios["returnOnEquityTTM"] = roe if total_assets and total_assets > 0 and net_income is not None: - ratios["returnOnAssetsTTM"] = net_income / total_assets + roa = net_income / total_assets + if abs(roa) < 10: + ratios["returnOnAssetsTTM"] = roa # ROIC = NOPAT / Invested Capital if ebit is not None and pretax_income and pretax_income != 0: @@ -309,27 +313,39 @@ def compute_ttm_ratios(ticker: str) -> dict: nopat = ebit * (1 - effective_tax_rate) invested_capital = (equity or 0) + (total_debt or 0) - cash if invested_capital > 0: - ratios["returnOnInvestedCapitalTTM"] = nopat / invested_capital + roic = nopat / invested_capital + if abs(roic) < 10: + ratios["returnOnInvestedCapitalTTM"] = roic # ── Valuation multiples ─────────────────────────────────────────────── if market_cap and market_cap > 0: if net_income and net_income > 0: ratios["peRatioTTM"] = market_cap / net_income if revenue and revenue > 0: - ratios["priceToSalesRatioTTM"] = market_cap / revenue + ps = market_cap / revenue + if 0 < ps < 100: + ratios["priceToSalesRatioTTM"] = ps if equity and equity > 0: - ratios["priceToBookRatioTTM"] = market_cap / equity + pb = market_cap / equity + if 0 < pb < 100: + ratios["priceToBookRatioTTM"] = pb 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: - ratios["enterpriseValueMultipleTTM"] = ev / ebitda + ev_sales = ev / revenue + if 0 < ev_sales < 100: + ratios["evToSalesTTM"] = ev_sales + if ebitda and ebitda > 1e6: + ev_ebitda = ev / ebitda + if 0 < ev_ebitda < 500: + ratios["enterpriseValueMultipleTTM"] = ev_ebitda # ── Leverage & Liquidity ────────────────────────────────────────────── if equity and equity > 0 and total_debt is not None: - ratios["debtToEquityRatioTTM"] = total_debt / equity + de = total_debt / equity + if 0 <= de < 100: + ratios["debtToEquityRatioTTM"] = de if current_liabilities and current_liabilities > 0 and current_assets is not None: ratios["currentRatioTTM"] = current_assets / current_liabilities @@ -338,8 +354,10 @@ def compute_ttm_ratios(ticker: str) -> dict: if ebit is not None and interest_expense: ie = abs(interest_expense) - if ie > 0: - ratios["interestCoverageRatioTTM"] = ebit / ie + if ie > 0 and ebit > 0: + coverage = ebit / ie + if 0 < coverage < 1000: + ratios["interestCoverageRatioTTM"] = coverage # ── Dividends (from cash flow statement) ───────────────────────────── dividends_paid = None @@ -351,9 +369,13 @@ def compute_ttm_ratios(ticker: str) -> dict: if dividends_paid and dividends_paid > 0: if market_cap and market_cap > 0: - ratios["dividendYieldTTM"] = dividends_paid / market_cap + div_yield = dividends_paid / market_cap + if 0 <= div_yield < 1: + ratios["dividendYieldTTM"] = div_yield if net_income and net_income > 0: - ratios["dividendPayoutRatioTTM"] = dividends_paid / net_income + payout = dividends_paid / net_income + if 0 <= payout < 10: + ratios["dividendPayoutRatioTTM"] = payout # Expose raw EBITDA so callers (e.g. DCF EV/EBITDA section) use the # same TTM figure as the Key Ratios tab — single canonical source. @@ -498,12 +520,18 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]: if equity and equity > 0: if net_income is not None: - row["returnOnEquity"] = net_income / equity + roe = net_income / equity + if abs(roe) < 10: + row["returnOnEquity"] = roe if total_debt is not None: - row["debtEquityRatio"] = total_debt / equity + de = total_debt / equity + if 0 <= de < 100: + row["debtEquityRatio"] = de if total_assets and total_assets > 0 and net_income is not None: - row["returnOnAssets"] = net_income / total_assets + roa = net_income / total_assets + if abs(roa) < 10: + row["returnOnAssets"] = roa # Price-based ratios — average closing price in ±45-day window around year-end if shares and not hist.empty: @@ -525,9 +553,13 @@ def get_historical_ratios_yfinance(ticker: str) -> list[dict]: if net_income and net_income > 0: row["peRatio"] = market_cap / net_income if equity and equity > 0: - row["priceToBookRatio"] = market_cap / equity + pb = market_cap / equity + if 0 < pb < 100: + row["priceToBookRatio"] = pb if total_rev and total_rev > 0: - row["priceToSalesRatio"] = market_cap / total_rev + ps = market_cap / total_rev + if 0 < ps < 100: + row["priceToSalesRatio"] = ps # EV/EBITDA — approximate. Skip if EBITDA is too small to be meaningful, # which otherwise creates absurd multiples for some software names. diff --git a/services/fmp_service.py b/services/fmp_service.py index 1e5ea42..82a9c4c 100644 --- a/services/fmp_service.py +++ b/services/fmp_service.py @@ -33,6 +33,11 @@ def get_key_ratios(ticker: str) -> dict: All ratios are self-computed via compute_ttm_ratios() — no FMP calls. Forward P/E and dividend fallbacks come from yfinance's info dict. + + For edge cases, trailing P/E prefers the vendor-supplied value from the + info dict when the self-computed statement-based figure is missing or + materially inconsistent. This avoids obviously bad P/E outputs on tickers + with restatements, near-zero earnings, or statement mapping quirks. """ ticker = ticker.upper() merged = {"symbol": ticker} @@ -44,13 +49,43 @@ def get_key_ratios(ticker: str) -> dict: # Forward P/E requires analyst estimates — can't compute from statements info = get_company_info(ticker) if info: + trailing_pe_info = info.get("trailingPE") + trailing_pe_computed = merged.get("peRatioTTM") + + if trailing_pe_info is not None: + try: + vendor_pe = float(trailing_pe_info) + except (TypeError, ValueError): + vendor_pe = None + + try: + computed_pe = float(trailing_pe_computed) if trailing_pe_computed is not None else None + except (TypeError, ValueError): + computed_pe = None + + if vendor_pe is not None and vendor_pe > 0: + if computed_pe is None or computed_pe <= 0: + merged["peRatioTTM"] = vendor_pe + else: + # If the two values are wildly different, trust the vendor + # trailing P/E. This prevents edge-case display bugs where a + # malformed TTM net income makes P/E look duplicated/wrong. + ratio_gap = max(vendor_pe, computed_pe) / max(min(vendor_pe, computed_pe), 1e-9) + if ratio_gap > 2.0: + merged["peRatioTTM"] = vendor_pe + if info.get("forwardPE") is not None: merged["forwardPE"] = info["forwardPE"] # Fallback: dividends from info dict when cash-flow data is missing if merged.get("dividendYieldTTM") is None and info.get("dividendYield") is not None: merged["dividendYieldTTM"] = info["dividendYield"] - if merged.get("dividendPayoutRatioTTM") is None and info.get("payoutRatio") is not None: - merged["dividendPayoutRatioTTM"] = info["payoutRatio"] + payout_ratio_info = info.get("payoutRatio") + if ( + merged.get("dividendPayoutRatioTTM") is None + and payout_ratio_info is not None + and float(payout_ratio_info) > 0 + ): + merged["dividendPayoutRatioTTM"] = payout_ratio_info return merged if len(merged) > 1 else {} diff --git a/services/valuation_service.py b/services/valuation_service.py index 6db4053..8559842 100644 --- a/services/valuation_service.py +++ b/services/valuation_service.py @@ -79,6 +79,13 @@ def run_dcf( growth_rate = historical_growth if historical_growth is not None else 0.05 base_fcf = float(historical[-1]) + if base_fcf <= 0: + return { + "error": ( + "DCF is not meaningful with zero or negative base free cash flow. " + "Use comps, EV/EBITDA, or adjust the model only after underwriting a credible FCF turnaround." + ) + } projected_fcfs = [] for year in range(1, projection_years + 1): |
