aboutsummaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorOpenclaw <openclaw@mail.tylerhoang.xyz>2026-04-01 23:32:01 -0700
committerOpenclaw <openclaw@mail.tylerhoang.xyz>2026-04-01 23:32:01 -0700
commit3806bd3b4d69917f3f5312acfa57bc4ee2886a49 (patch)
tree7fca5c5c8aacc5365d2db33e40da2e251e3c67b0 /services
parent96b27f1d00ae8110273de973053c3d6bfc4f3662 (diff)
Harden valuation edge cases
Diffstat (limited to 'services')
-rw-r--r--services/data_service.py68
-rw-r--r--services/fmp_service.py39
-rw-r--r--services/valuation_service.py7
3 files changed, 94 insertions, 20 deletions
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):