aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--components/valuation.py206
-rw-r--r--services/data_service.py68
-rw-r--r--services/fmp_service.py39
-rw-r--r--services/valuation_service.py7
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):