diff --git a/src/investo/analysis/dcf.py b/src/investo/analysis/dcf.py index 3ee997b..5a6e8c1 100644 --- a/src/investo/analysis/dcf.py +++ b/src/investo/analysis/dcf.py @@ -54,26 +54,6 @@ def _estimate_growth(ratios: Ratios, terminal_growth: float) -> float: return _clamp(g, terminal_growth + 0.01, _GROWTH_CAP) -def _net_debt(fin: Financials, info: dict[str, Any]) -> float: - bal = F.latest(fin.balance_sheet) - total_debt = F.pick(bal, *F.TOTAL_DEBT) - cash = F.pick(bal, *F.CASH) - if total_debt is None: - total_debt = _f(info.get("totalDebt")) - if cash is None: - cash = _f(info.get("totalCash")) - total_debt = total_debt or 0.0 - cash = cash or 0.0 - return total_debt - cash - - -def _f(v: Any) -> float | None: - try: - return float(v) if v is not None else None - except (TypeError, ValueError): - return None - - def compute_dcf( symbol: str, info: dict[str, Any] | None = None, @@ -133,7 +113,7 @@ def compute_dcf( pv_terminal = terminal_value / ((1 + r) ** n) enterprise_value = pv_fcf + pv_terminal - net_debt = _net_debt(financials, info) + net_debt = F.net_debt(financials, info) equity_value_stmt = enterprise_value - net_debt result.enterprise_value = enterprise_value @@ -141,8 +121,8 @@ def compute_dcf( # Convert equity value to trading currency for a per-share figure. fx = data.fx_rate(stmt_ccy, price_ccy) - shares = _f(info.get("sharesOutstanding")) - price = _f(info.get("currentPrice") or info.get("regularMarketPrice")) + shares = F._f(info.get("sharesOutstanding")) + price = F._f(info.get("currentPrice") or info.get("regularMarketPrice")) result.current_price = price assumptions = [ diff --git a/src/investo/analysis/finutils.py b/src/investo/analysis/finutils.py index b18b4eb..30ce1f6 100644 --- a/src/investo/analysis/finutils.py +++ b/src/investo/analysis/finutils.py @@ -2,7 +2,9 @@ from __future__ import annotations -from ..models import FinancialPeriod +from typing import Any + +from ..models import Financials, FinancialPeriod def safe_div(numerator: float | None, denominator: float | None) -> float | None: @@ -84,3 +86,28 @@ def yoy(newest_first: list[float | None]) -> float | None: CAPEX = ("Capital Expenditure", "Capital Expenditure Reported") DIVIDENDS_PAID = ("Cash Dividends Paid", "Common Stock Dividend Paid") REPURCHASE = ("Repurchase Of Capital Stock", "Common Stock Payments") + + +def _f(v: Any) -> float | None: + """Best-effort float coercion; None on failure.""" + try: + return float(v) if v is not None else None + except (TypeError, ValueError): + return None + + +def net_debt(fin: Financials, info: dict[str, Any]) -> float: + """Total debt minus cash in statement currency (0.0 fallbacks). + + Reads the balance sheet first, then falls back to Yahoo ``info`` totals. A negative result + means the company holds more cash than debt (net cash). Single source of truth for both the + DCF equity bridge and the Ratios net-cash fields. + """ + bal = latest(fin.balance_sheet) + total_debt = pick(bal, *TOTAL_DEBT) + cash = pick(bal, *CASH) + if total_debt is None: + total_debt = _f(info.get("totalDebt")) + if cash is None: + cash = _f(info.get("totalCash")) + return (total_debt or 0.0) - (cash or 0.0) diff --git a/src/investo/analysis/ratios.py b/src/investo/analysis/ratios.py index 33239c1..74a9646 100644 --- a/src/investo/analysis/ratios.py +++ b/src/investo/analysis/ratios.py @@ -228,4 +228,18 @@ def compute_ratios( merged["ticker"] = symbol.upper() merged["currency"] = info.get("financialCurrency") or info.get("currency") + + # Net cash (cash - total debt) and its size relative to market cap. Both use the same + # net-debt source as the DCF equity bridge. Cash is in the statement currency while market + # cap is in the trading currency, so convert with the same FX the DCF uses (the Infosys + # USD-statements / INR-price case); leave the ratio None when FX or market cap is missing. + net_cash_stmt = -F.net_debt(financials, info) + stmt_ccy = info.get("financialCurrency") or info.get("currency") + price_ccy = info.get("currency") or stmt_ccy + fx = data.fx_rate(stmt_ccy, price_ccy) + mcap = info.get("marketCap") + merged["net_cash"] = net_cash_stmt + merged["net_cash_to_market_cap"] = ( + (net_cash_stmt * fx) / mcap if (fx and mcap) else None + ) return Ratios(**merged) diff --git a/src/investo/analysis/scoring.py b/src/investo/analysis/scoring.py index 287018d..2807bcc 100644 --- a/src/investo/analysis/scoring.py +++ b/src/investo/analysis/scoring.py @@ -2,8 +2,12 @@ Weights (out of 100; ESG is an optional 11th that renormalizes the total): - Growth 15 | Profitability 15 | Cash Flow 10 | Debt 10 | Valuation 15 - Moat 10 | Management 10 | Industry 5 | Innovation 5 | Risk 5 | (ESG 5, optional) + Growth 15 | Profitability 17 | Cash Flow 10 | Balance Sheet 11 | Valuation 10 + Moat 12 | Management 10 | Industry 5 | Innovation 5 | Risk 5 | (ESG 5, optional) + +Valuation is quality-aware: the acceptable multiple ceilings widen with the company's economics +(returns, margins, growth), and net cash lowers the effective equity multiple -- so a proven, +cash-rich compounder is not floored for trading at a premium. Financial buckets are computed from ratios/DCF. Qualitative buckets (moat, management, industry, innovation, risk) use transparent heuristics from the same data; the host LLM can @@ -17,14 +21,16 @@ from ..models import DCFResult, Ratios, Score, ScoreBucket -# Bucket weights (core ten sum to 100). +# Bucket weights (core ten sum to 100). Leans on durable quality over absolute cheapness: +# Valuation is a lighter cross-check (10) while Profitability (17), Moat (12) and the net-cash- +# aware Balance Sheet (11) carry more. WEIGHTS = { "Growth": 15.0, - "Profitability": 15.0, + "Profitability": 17.0, "Cash Flow": 10.0, - "Debt": 10.0, - "Valuation": 15.0, - "Competitive Moat": 10.0, + "Balance Sheet": 11.0, + "Valuation": 10.0, + "Competitive Moat": 12.0, "Management": 10.0, "Industry Outlook": 5.0, "Innovation": 5.0, @@ -61,6 +67,25 @@ def _pct(x: float | None) -> str: return f"{x:.1%}" if x is not None else "n/a" +def _quality_factor(r: Ratios) -> float: + """Business-quality read in [0, 1] from returns, margins and growth. + + Four independent signals so the factor can't be gamed by leverage-inflated ROE (operating + margin) or a hollow top line (EPS CAGR blended into revenue growth). Each `_lin` clamps, so a + single sky-high metric can't dominate. Reusable across scorers; neutral 0.5 when data is absent. + """ + growth_blend = _avg([r.revenue_cagr_3y, r.eps_cagr_3y]) + if growth_blend is None: + growth_blend = r.revenue_growth_yoy + q = _avg([ + _lin(r.roe, 0.10, 0.30), + _lin(r.roce, 0.12, 0.35), + _lin(r.operating_margin, 0.05, 0.30), + _lin(growth_blend, 0.0, 0.20), + ]) + return q if q is not None else 0.5 + + # -------------------------------------------------------------------------------------- # Per-bucket scorers -> normalized [0,1] # -------------------------------------------------------------------------------------- @@ -97,6 +122,7 @@ def score_cashflow(r: Ratios) -> tuple[float | None, str, dict]: def score_debt(r: Ratios, sector: str | None = None) -> tuple[float | None, str, dict]: + """Balance-sheet strength: leverage, liquidity and net-cash position (higher = stronger).""" is_financial = sector in _FINANCIAL_SECTORS parts = [ _lin(r.interest_coverage, 2.0, 12.0), @@ -104,37 +130,71 @@ def score_debt(r: Ratios, sector: str | None = None) -> tuple[float | None, str, ] if not is_financial: # leverage is structurally high for banks/NBFCs; don't penalize parts.append(_inv(r.debt_to_equity, 0.0, 2.0)) + # Signed net-cash term: net debt (-20% of m.cap) drags below the debt-free midpoint, + # a fortress net-cash pile (+20%) lifts it -- so leveraged / debt-free / net-cash names + # are separated. (Skipped for financials, where cash and debt are the business.) + parts.append(_lin(r.net_cash_to_market_cap, -0.20, 0.20)) n = _avg(parts) de = "excluded (financial sector)" if is_financial else \ (f"{r.debt_to_equity:.2f}" if r.debt_to_equity is not None else "n/a") cov = f"{r.interest_coverage:.1f}x" if r.interest_coverage is not None else "n/a" rat = f"debt/equity {de}, interest coverage {cov}" + if not is_financial and r.net_cash_to_market_cap is not None: + label = "net cash" if r.net_cash_to_market_cap >= 0 else "net debt" + rat += f", {label} {_pct(abs(r.net_cash_to_market_cap))} of m.cap" return n, rat, {"debt_to_equity": r.debt_to_equity, "interest_coverage": r.interest_coverage, - "current_ratio": r.current_ratio} + "current_ratio": r.current_ratio, + "net_cash_to_market_cap": r.net_cash_to_market_cap} def score_valuation(r: Ratios, dcf: DCFResult | None = None) -> tuple[float | None, str, dict]: + q = _quality_factor(r) + # Ceilings widen *quadratically* with quality: average names keep today's ceilings (40 / 8 / 25); + # only exceptional quality unlocks real headroom -- so a proven compounder isn't floored for a + # premium multiple, while a low-quality expensive name still is. + qf = q * q + pe_hi = 40.0 + qf * 35.0 + pb_hi = 8.0 + qf * 7.0 + ev_hi = 25.0 + qf * 15.0 + # Ex-cash equity multiples: net cash lowers the effective P/E and P/B (dampened so a cash-heavy + # balance sheet helps without a runaway boost; net debt raises them). EV/EBITDA is already + # cash-adjusted, so it's left alone to avoid double-counting. + ncm = max(-0.5, min(0.8, r.net_cash_to_market_cap or 0.0)) + cash_factor = 1.0 - 0.5 * ncm + eff_pe = r.pe * cash_factor if r.pe is not None else None + eff_pb = r.pb * cash_factor if r.pb is not None else None multiples = _avg([ - _inv(r.pe, 8.0, 40.0), - _inv(r.pb, 1.0, 8.0), - _inv(r.ev_ebitda, 6.0, 25.0), + _inv(eff_pe, 8.0, pe_hi), + _inv(eff_pb, 1.0, pb_hi), + _inv(r.ev_ebitda, 6.0, ev_hi), _inv(r.peg, 1.0, 3.0) if (r.peg is not None and r.peg > 0) else None, ]) mos = dcf.margin_of_safety if dcf else None dcf_score = None if mos is not None: - dcf_score = _lin(max(-1.0, min(0.7, mos)), -0.3, 0.4) # clamp: DCF unreliable for capex-heavy - # Multiples dominate (0.75); DCF is a secondary cross-check (0.25). + # Recentred so fair value (mos~0) scores ~0.5; clamp: DCF unreliable for capex-heavy names. + dcf_score = _lin(max(-1.0, min(0.7, mos)), -0.4, 0.4) + # Multiples dominate (0.70); DCF is an assumption-sensitive cross-check (0.30). n: float | None if multiples is not None and dcf_score is not None: - n = 0.75 * multiples + 0.25 * dcf_score + n = 0.70 * multiples + 0.30 * dcf_score else: n = multiples if multiples is not None else dcf_score + # Rationale: make the quality-aware assessment explicit rather than reading as a blind penalty. pe = f"{r.pe:.1f}" if r.pe is not None else "n/a" rat = f"P/E {pe}, P/B {r.pb:.1f}" if r.pb is not None else f"P/E {pe}" + premium = (r.pe is not None and r.pe > 40) or (r.pb is not None and r.pb > 8) + if premium and q >= 0.66: + rat += (" - premium multiples assessed relative to strong quality " + "(ROE/margins/growth), which justifies part of the premium") + if r.net_cash_to_market_cap is not None and r.net_cash_to_market_cap > 0.05: + rat += f", net cash {_pct(r.net_cash_to_market_cap)} of m.cap" if mos is not None: rat += f", DCF margin of safety {_pct(mos)}" - return n, rat, {"pe": r.pe, "pb": r.pb, "ev_ebitda": r.ev_ebitda, "dcf_margin_of_safety": mos} + return n, rat, {"pe": r.pe, "pb": r.pb, "ev_ebitda": r.ev_ebitda, + "quality_factor": round(q, 3), + "net_cash_to_market_cap": r.net_cash_to_market_cap, + "dcf_margin_of_safety": mos} def score_moat(r: Ratios, market_share_proxy: float | None = None) -> tuple[float | None, str, dict]: @@ -243,7 +303,7 @@ def compute_score( ("Growth", "computed", *score_growth(ratios)), ("Profitability", "computed", *score_profitability(ratios)), ("Cash Flow", "computed", *score_cashflow(ratios)), - ("Debt", "computed", *score_debt(ratios, sector)), + ("Balance Sheet", "computed", *score_debt(ratios, sector)), ("Valuation", "computed", *score_valuation(ratios, dcf)), ("Competitive Moat", "heuristic", *score_moat(ratios, market_share_proxy)), ("Management", "heuristic", *score_management(ratios, promoter_holding)), diff --git a/src/investo/models.py b/src/investo/models.py index 4cf696f..5dd4c3e 100644 --- a/src/investo/models.py +++ b/src/investo/models.py @@ -144,6 +144,8 @@ class Ratios(_Base): interest_coverage: float | None = None current_ratio: float | None = None quick_ratio: float | None = None + net_cash: float | None = None # cash - total debt, statement currency + net_cash_to_market_cap: float | None = None # FX-normalized, dimensionless (signed) # Growth revenue_growth_yoy: float | None = None diff --git a/tests/test_scoring.py b/tests/test_scoring.py index adab898..fc15053 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -74,3 +74,204 @@ def test_financial_sector_excludes_debt_equity(): r = Ratios(ticker="BANK", debt_to_equity=8.0, interest_coverage=None, current_ratio=None) n_fin, rat, _ = scoring.score_debt(r, sector="Financial Services") assert "excluded" in rat + + +def test_balance_sheet_bucket_renamed(): + s = scoring.compute_score("X", _good()) + names = {b.name for b in s.buckets} + assert "Balance Sheet" in names + assert "Debt" not in names + + +# -------------------------------------------------------------------------------------- +# Quality-aware valuation: a premium multiple should not floor the bucket when the company's +# economics justify it, but a low-quality name at the same multiple still floors. +# -------------------------------------------------------------------------------------- +def _val_base(**over) -> Ratios: + d = dict(ticker="V", roe=0.15, roce=0.16, operating_margin=0.15, net_margin=0.10, + revenue_cagr_3y=0.10, eps_cagr_3y=0.10, revenue_growth_yoy=0.10, + pe=30.0, pb=6.0, ev_ebitda=18.0, peg=1.5, net_cash_to_market_cap=0.0) + d.update(over) + return Ratios(**d) + + +def test_quality_justifies_premium_multiple(): + prem_hi_q = _val_base(roe=0.30, roce=0.34, operating_margin=0.28, revenue_cagr_3y=0.20, + eps_cagr_3y=0.20, pe=45.0, pb=11.0, ev_ebitda=28.0, peg=2.5) + prem_lo_q = _val_base(roe=0.08, roce=0.08, operating_margin=0.04, revenue_cagr_3y=0.02, + eps_cagr_3y=0.02, pe=45.0, pb=11.0, ev_ebitda=28.0, peg=2.5) + hi = scoring.score_valuation(prem_hi_q)[0] + lo = scoring.score_valuation(prem_lo_q)[0] + assert hi > lo + 0.1 # quality unlocks headroom at the same rich multiple + assert lo < 0.15 # a low-quality premium name still floors (as before) + + +def test_valuation_monotonic_in_each_quality_signal(): + # Increasing any single quality signal (or net cash) must never lower the valuation bucket. + grids = { + "roe": [0.05, 0.15, 0.25, 0.35], + "operating_margin": [0.0, 0.10, 0.20, 0.30], + "eps_cagr_3y": [0.0, 0.08, 0.16, 0.24], + "net_cash_to_market_cap": [-0.3, 0.0, 0.2, 0.5], + } + for field, steps in grids.items(): + prev = None + for v in steps: + n = scoring.score_valuation(_val_base(**{field: v}))[0] + assert n is not None + if prev is not None: + assert n >= prev - 1e-9, f"{field}={v}: valuation dropped ({n} < {prev})" + prev = n + + +# -------------------------------------------------------------------------------------- +# Net cash reward: lifts both Valuation (ex-cash multiple) and Balance Sheet; net debt drags. +# -------------------------------------------------------------------------------------- +def test_net_cash_lifts_valuation_and_balance_sheet(): + base = _val_base(net_cash_to_market_cap=0.0) + rich = _val_base(net_cash_to_market_cap=0.30) + assert scoring.score_valuation(rich)[0] > scoring.score_valuation(base)[0] + assert scoring.score_debt(rich)[0] > scoring.score_debt(base)[0] + + +def test_net_debt_drags_balance_sheet_below_debt_free(): + debt_free = Ratios(ticker="DF", debt_to_equity=0.0, net_cash_to_market_cap=0.0) + net_debt = Ratios(ticker="ND", debt_to_equity=0.0, net_cash_to_market_cap=-0.20) + assert scoring.score_debt(net_debt)[0] < scoring.score_debt(debt_free)[0] + + +# -------------------------------------------------------------------------------------- +# Regression: the change must be *targeted*, not a blanket re-rank or inflation. A synthetic +# basket spanning the quality/valuation/leverage spectrum is scored and compared against a +# frozen baseline (the pre-change totals) -- rank order must be preserved (Spearman >= 0.90) +# and each archetype must move in the intended direction. +# -------------------------------------------------------------------------------------- +def _archetypes() -> dict: + return { + "cash_rich_compounder": (Ratios( + ticker="COMPOUNDER", roe=0.28, roce=0.30, roic=0.26, operating_margin=0.20, + net_margin=0.16, gross_margin=0.35, fcf_margin=0.15, ocf_to_ebitda=1.0, + debt_to_equity=0.02, interest_coverage=120.0, current_ratio=3.0, pe=45.0, pb=11.0, + ev_ebitda=28.0, peg=2.2, revenue_growth_yoy=0.18, revenue_cagr_3y=0.20, + earnings_growth_yoy=0.22, eps_cagr_3y=0.21, beta=0.9, net_cash_to_market_cap=0.10), None), + "cheap_cyclical": (Ratios( + ticker="CYCLICAL", roe=0.14, roce=0.16, roic=0.13, operating_margin=0.14, + net_margin=0.09, gross_margin=0.22, fcf_margin=0.08, ocf_to_ebitda=0.9, + debt_to_equity=0.5, interest_coverage=6.0, current_ratio=1.6, pe=9.0, pb=1.1, + ev_ebitda=6.0, peg=0.9, revenue_growth_yoy=0.06, revenue_cagr_3y=0.05, + earnings_growth_yoy=0.04, eps_cagr_3y=0.05, beta=1.2, net_cash_to_market_cap=-0.05), None), + "deep_value": (Ratios( + ticker="DEEPVALUE", roe=0.08, roce=0.09, roic=0.07, operating_margin=0.09, + net_margin=0.05, gross_margin=0.18, fcf_margin=0.04, ocf_to_ebitda=0.8, + debt_to_equity=0.6, interest_coverage=4.0, current_ratio=1.4, pe=6.0, pb=0.7, + ev_ebitda=4.0, peg=1.2, revenue_growth_yoy=0.02, revenue_cagr_3y=0.01, + earnings_growth_yoy=0.0, eps_cagr_3y=0.0, beta=1.1, net_cash_to_market_cap=0.0), None), + "loss_making_growth": (Ratios( + ticker="LOSSGROWTH", roe=-0.05, roce=-0.03, roic=-0.04, operating_margin=-0.10, + net_margin=-0.15, gross_margin=0.55, fcf_margin=-0.12, ocf_to_ebitda=0.2, + debt_to_equity=0.1, interest_coverage=None, current_ratio=2.5, pe=None, pb=8.0, + ev_ebitda=40.0, peg=None, revenue_growth_yoy=0.40, revenue_cagr_3y=0.45, + earnings_growth_yoy=None, eps_cagr_3y=None, beta=1.6, net_cash_to_market_cap=0.15), None), + "leveraged_industrial": (Ratios( + ticker="LEVERAGED", roe=0.15, roce=0.11, roic=0.10, operating_margin=0.12, + net_margin=0.06, gross_margin=0.20, fcf_margin=0.05, ocf_to_ebitda=0.85, + debt_to_equity=1.8, interest_coverage=3.0, current_ratio=1.1, pe=14.0, pb=2.0, + ev_ebitda=9.0, peg=1.5, revenue_growth_yoy=0.09, revenue_cagr_3y=0.08, + earnings_growth_yoy=0.07, eps_cagr_3y=0.07, beta=1.3, net_cash_to_market_cap=-0.30), None), + "asset_heavy_bank": (Ratios( + ticker="BANK", roe=0.16, roce=None, roic=None, operating_margin=None, net_margin=0.22, + gross_margin=0.0, fcf_margin=None, ocf_to_ebitda=None, debt_to_equity=8.0, + interest_coverage=None, current_ratio=None, pe=12.0, pb=1.6, ev_ebitda=None, peg=1.1, + revenue_growth_yoy=0.12, revenue_cagr_3y=0.11, earnings_growth_yoy=0.13, + eps_cagr_3y=0.12, beta=1.0, net_cash_to_market_cap=None), "Financial Services"), + } + + +def _synth(i: int, roe: float, pe: float, ncm: float) -> Ratios: + return Ratios( + ticker=f"G{i}", roe=roe, roce=roe * 1.1, roic=roe * 0.95, + operating_margin=0.02 + roe * 0.8, net_margin=roe * 0.6, gross_margin=0.25 + roe, + fcf_margin=max(-0.05, roe * 0.6), ocf_to_ebitda=0.9, debt_to_equity=max(0.0, 0.6 - ncm), + interest_coverage=4.0 + roe * 120, current_ratio=1.4 + max(0.0, ncm) * 2, pe=pe, + pb=max(0.4, pe * roe * 1.1), ev_ebitda=pe * 0.6, peg=pe / max(1.0, roe * 100), + revenue_growth_yoy=roe * 0.7, revenue_cagr_3y=roe * 0.7, earnings_growth_yoy=roe * 0.65, + eps_cagr_3y=roe * 0.6, beta=1.0, net_cash_to_market_cap=ncm) + + +def _basket() -> list: + rows = [(name, r, sector) for name, (r, sector) in _archetypes().items()] + i = 0 + for roe in (0.06, 0.14, 0.22, 0.30): + for pe in (10.0, 22.0, 40.0): + for ncm in (-0.2, 0.05, 0.2): + rows.append((f"g{i}", _synth(i, roe, pe, ncm), None)) + i += 1 + return rows + + +# Frozen pre-change totals (the scoring model before this change), one per basket entry. +_OLD_BASELINE = { + "cash_rich_compounder": 67.6, "cheap_cyclical": 48.9, "deep_value": 36.0, + "loss_making_growth": 38.0, "leveraged_industrial": 38.0, "asset_heavy_bank": 61.0, + "g0": 40.4, "g1": 41.3, "g2": 42.3, "g3": 34.9, "g4": 35.7, "g5": 36.8, "g6": 30.0, + "g7": 30.8, "g8": 31.9, "g9": 54.4, "g10": 55.3, "g11": 56.3, "g12": 49.5, "g13": 50.4, + "g14": 51.4, "g15": 41.4, "g16": 42.2, "g17": 43.3, "g18": 68.5, "g19": 69.3, "g20": 70.4, + "g21": 64.1, "g22": 65.0, "g23": 66.0, "g24": 56.9, "g25": 57.7, "g26": 58.8, "g27": 78.2, + "g28": 79.0, "g29": 80.1, "g30": 73.2, "g31": 74.1, "g32": 75.1, "g33": 68.0, "g34": 68.8, + "g35": 69.9, +} + + +def _spearman(a: list, b: list) -> float: + def ranks(xs): + order = sorted(range(len(xs)), key=lambda i: xs[i]) + r = [0.0] * len(xs) + i = 0 + while i < len(xs): + j = i + while j + 1 < len(xs) and xs[order[j + 1]] == xs[order[i]]: + j += 1 + for k in range(i, j + 1): + r[order[k]] = (i + j) / 2.0 + i = j + 1 + return r + ra, rb = ranks(a), ranks(b) + n = len(a) + ma, mb = sum(ra) / n, sum(rb) / n + cov = sum((ra[i] - ma) * (rb[i] - mb) for i in range(n)) + va = sum((x - ma) ** 2 for x in ra) ** 0.5 + vb = sum((x - mb) ** 2 for x in rb) ** 0.5 + return cov / (va * vb) + + +def _new_totals() -> dict: + return {name: scoring.compute_score(name, r, sector=sector).total + for name, r, sector in _basket()} + + +def test_rank_order_is_preserved(): + new = _new_totals() + names = [n for n, _, _ in _basket()] + old_vec = [_OLD_BASELINE[n] for n in names] + new_vec = [new[n] for n in names] + assert _spearman(old_vec, new_vec) >= 0.90 + + +def test_change_is_targeted_per_archetype(): + new = _new_totals() + + def d(name): + return new[name] - _OLD_BASELINE[name] + + assert d("cash_rich_compounder") >= 2.0 # moderate increase + assert abs(d("cheap_cyclical")) <= 5.0 # ~unchanged + assert abs(d("deep_value")) <= 5.0 # ~unchanged + assert abs(d("loss_making_growth")) <= 4.0 # little change + assert -8.0 <= d("leveraged_industrial") < 0.0 # slight decrease + assert abs(d("asset_heavy_bank")) <= 3.0 # nearly unchanged + + +def test_no_blanket_inflation(): + new = _new_totals() + deltas = [new[n] - _OLD_BASELINE[n] for n, _, _ in _basket()] + assert abs(sum(deltas) / len(deltas)) <= 2.0 # mean move near zero