Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ All notable changes to Investo are documented here. The format follows
the CLI.)

### Fixed
- **Dividend yield was silently `None` for every company.** yfinance reports `dividendYield` as a
*percent* (`1.61` = 1.61%), but `ratios.py` filtered it as a fraction with a `<= 0.15` bound, so
every real yield — even a 0.3% one — was discarded. `dividend_yield` now prefers the unambiguous
fraction fields (`trailingAnnualDividendYield`, then `dividendRate / price`) and normalizes
`dividendYield` from percent only as a last resort, bounded to a sane 0–30%. The figure now
appears in the research note's valuation grid (e.g. ITC 5.2%, Coal India 6.2%).
- **Relative-to-industry reported 0.37 confidence over zero data.** A company in no curated peer
group (KPIT and the whole automotive ER&D cohort were in none) computed no metrics, then scored
`0.80 × (0.4 + 0.6×0) = 0.32` plus a `+0.05` **cross-source agreement bonus awarded over zero
Expand Down
42 changes: 39 additions & 3 deletions src/investo/analysis/ratios.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,7 @@ def bounded(name: str, value: float | None) -> float | None:
"peg": bounded("peg", g("trailingPegRatio") or g("pegRatio")),
"ev_ebitda": bounded("ev_ebitda", g("enterpriseToEbitda")),
"price_to_sales": bounded("price_to_sales", g("priceToSalesTrailing12Months")),
# Yahoo's dividendYield is inconsistent/occasionally garbage; keep only realistic
# equity yields (<=15%) and drop anomalies.
"dividend_yield": (lambda y: y if (y is not None and 0 <= y <= 0.15) else None)(g("dividendYield")),
"dividend_yield": _dividend_yield(g),
"roe": g("returnOnEquity"),
"roa": g("returnOnAssets"),
"gross_margin": g("grossMargins"),
Expand All @@ -73,6 +71,44 @@ def bounded(name: str, value: float | None) -> float | None:
}


# A dividend yield above this (as a fraction) is treated as garbage rather than a real yield.
_MAX_DIV_YIELD = 0.30


def _dividend_yield(g) -> float | None:
"""Dividend yield as a decimal fraction (0.03 == 3%).

Yahoo's dividend fields are on inconsistent scales, and ``dividendYield`` specifically has
flipped between a fraction and a percent across yfinance versions (1.5.x returns a *percent*:
``1.61`` means 1.61%). Rather than guess its scale we prefer the unambiguous fields:

1. ``trailingAnnualDividendYield`` — a decimal fraction, stable across versions.
2. ``dividendRate / price`` — a computed fraction (matches the trailing figure in practice).
3. ``dividendYield`` — last resort, normalized from percent to fraction.

Any candidate outside a sane 0-30% band is rejected as an anomaly.
"""
def ok(frac: float | None) -> float | None:
return frac if (frac is not None and 0 < frac <= _MAX_DIV_YIELD) else None

tady = ok(g("trailingAnnualDividendYield")) # already a fraction
if tady is not None:
return tady

rate, price = g("dividendRate"), g("currentPrice") or g("regularMarketPrice")
if rate is not None and price:
computed = ok(rate / price)
if computed is not None:
return computed

dy = g("dividendYield")
if dy is not None:
# yfinance >=1.x reports this as a percent; >1 is unambiguously a percent, and the
# current contract is percent throughout, so normalize by 100.
return ok(dy / 100.0)
return None


def _tax_rate(inc_values: dict[str, float | None]) -> float:
rate = F.pick(inc_values, *F.TAX_RATE)
if rate is not None and 0 <= rate <= 1:
Expand Down
1 change: 1 addition & 0 deletions src/investo/render/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ def _valuation(r: AnalysisReport) -> str:
("ROE", pct(ra.roe)), ("ROCE", pct(ra.roce)),
("Operating margin", pct(ra.operating_margin)), ("Net margin", pct(ra.net_margin)),
("Debt/Equity", ratio(ra.debt_to_equity)), ("Current ratio", ratio(ra.current_ratio)),
("Dividend yield", pct(ra.dividend_yield)),
]
live = [(k, v) for k, v in pairs if v != EM_DASH]
if live:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_ratios.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,50 @@ def test_implausible_multiples_dropped():
r = compute_ratios("X.NS", info={"enterpriseToEbitda": 975.0, "trailingPE": 12.0}, financials=_financials())
assert r.ev_ebitda is None # 975 is out of bounds -> treated as unknown
assert r.pe == 12.0


# --------------------------------------------------------------------------------------
# Dividend yield: yfinance reports these fields on different scales, and dividendYield in
# particular flipped to a *percent* (1.61 == 1.61%) — the old <=0.15 fraction filter nulled
# every real yield. Field shapes below are the actual values fetched for these names.
# --------------------------------------------------------------------------------------
def test_dividend_yield_prefers_the_unambiguous_fraction_field():
# trailingAnnualDividendYield is a decimal fraction (HDFC Bank ~1.6%).
r = compute_ratios("HDFCBANK.NS", info={
"dividendYield": 1.61, "trailingAnnualDividendYield": 0.016083,
"dividendRate": 13.0, "currentPrice": 819.6}, financials=_financials())
assert r.dividend_yield is not None
assert abs(r.dividend_yield - 0.0161) < 1e-4 # ~1.6%, NOT nulled


def test_percent_scale_dividend_yield_is_normalized_not_dropped():
# Only dividendYield present, on the percent scale. Must become a 5.7% fraction, not None.
r = compute_ratios("ITC.NS", info={"dividendYield": 5.73}, financials=_financials())
assert r.dividend_yield is not None
assert abs(r.dividend_yield - 0.0573) < 1e-4


def test_low_yield_is_kept_not_dropped():
# AAPL ~0.3% — the old filter kept only <0.15% and this survived by luck; confirm it holds.
r = compute_ratios("AAPL", info={
"dividendYield": 0.32, "trailingAnnualDividendYield": 0.0031}, financials=_financials())
assert r.dividend_yield is not None
assert abs(r.dividend_yield - 0.0031) < 1e-4


def test_dividend_rate_over_price_is_the_fallback():
r = compute_ratios("X.NS", info={"dividendRate": 22.0, "currentPrice": 427.65},
financials=_financials())
assert r.dividend_yield is not None
assert abs(r.dividend_yield - 0.0514) < 1e-3 # 22/427.65


def test_no_dividend_is_none():
r = compute_ratios("NODIV.NS", info={"trailingPE": 30.0}, financials=_financials())
assert r.dividend_yield is None


def test_garbage_dividend_yield_is_rejected():
# A 300% "yield" is an anomaly, not a real payout.
r = compute_ratios("X.NS", info={"trailingAnnualDividendYield": 3.0}, financials=_financials())
assert r.dividend_yield is None
Loading