Download src/metrics.py from s-ttp/vitals: direct link, hf CLI and curl.
- Browser
- Download file 64.2 kB
-
https://huggingface.co/spaces/s-ttp/vitals/resolve/main/src/metrics.py
- Command line
-
hf download hf://spaces/s-ttp/vitals/src/metrics.py
-
curl -L -o metrics.py https://huggingface.co/spaces/s-ttp/vitals/resolve/main/src/metrics.py
64.2 kB
| """Deterministic portfolio math — no LLM anywhere in this module. | |
| Reused: slipstream/book.py (_name_cap 15% justification, sector-cap bands, _regime_on), | |
| marketfm_space _curve (Sharpe/drawdown), follow_the_money_backtest (beta via covariance), | |
| slipstream/services/supervisor.py (OK/WATCH/ALERT scorecard pattern), | |
| crucible/engine/damodaran_portfolio.py (2-stage DCF math + trap flags — pure functions only). | |
| New here (existed nowhere): correlation clusters, HHI/effective-N, weighted beta, health score. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import pandas as pd | |
| MAX_POS_W = 0.15 # slipstream backtest: 15% name cap best Calmar (weighting_study.py) | |
| FUND_CAP = 0.60 # relaxed cap for a broad fund (internally diversified) | |
| FUND_TYPES = ("ETF", "MUTUALFUND", "INDEX", "FUND") | |
| def effective_name_cap(n: int) -> float: | |
| """Per-name cap a book of n names can actually satisfy. A 15% cap needs ≥7 names — | |
| judging a 6-stock book against it produced "trim everything" (every position is | |
| ≥16.7% by construction). Small books are judged on a proportional cap, and the | |
| breadth check carries the real message: add names.""" | |
| return min(0.30, max(MAX_POS_W, 1.25 / max(n, 1))) | |
| SECTOR_CAP_BASE, SECTOR_CAP_LO, SECTOR_CAP_HI = 0.30, 0.15, 0.45 | |
| CORR_ALERT = 0.80 # pairwise correlation flagged as a cluster | |
| # Damodaran DCF constants (crucible) | |
| RF, ERP, G_STABLE = 0.045, 0.045, 0.025 | |
| SECTOR_BETA = {"Technology": 1.15, "Consumer Cyclical": 1.10, "Financial Services": 1.10, | |
| "Industrials": 1.05, "Healthcare": 0.95, "Energy": 1.10, "Basic Materials": 1.05, | |
| "Consumer Defensive": 0.75, "Communication Services": 1.00, "Utilities": 0.55, | |
| "Real Estate": 0.90} | |
| # ---------------- Damodaran 2-stage DCF (crucible dcf_from_assumptions, pure port) ---------------- | |
| def dcf_from_assumptions(eps_base: float | None, price: float | None, A: dict | None): | |
| """Deterministic 2-stage DCF driven by LLM-Damodaran assumptions (auditable math). | |
| Returns (intrinsic, upside, ke, lifecycle) or (nan, nan, nan, None).""" | |
| if not A or not eps_base or eps_base <= 0 or not price or price <= 0: | |
| return float("nan"), float("nan"), float("nan"), None | |
| ke = float(np.clip(A.get("cost_of_equity", 0.09), 0.06, 0.16)) | |
| g1 = float(np.clip(A.get("high_growth_rate", 0.05), -0.30, 0.60)) | |
| N = int(np.clip(A.get("high_growth_years", 5), 1, 12)) | |
| gt = float(np.clip(A.get("terminal_growth", 0.025), 0.0, 0.045)) | |
| roic = float(np.clip(A.get("reinvestment_roic", 0.15), 0.05, 0.40)) | |
| if g1 > 0: | |
| # growth = reinvestment × ROIC; a 22% grower on a 20% ROIC would reinvest 110% of | |
| # earnings for years and the model priced NOW at −75%. Accounting ROIC understates | |
| # expensed R&D/SBC — cap reinvestment at 80% of earnings (ROIC floor = g / 0.8) | |
| roic = max(roic, min(g1 / 0.8, 0.60)) | |
| eps = eps_base * float(np.clip(A.get("normalized_eps_factor", 1.0), 0.5, 1.5)) | |
| if ke <= gt: | |
| return float("nan"), float("nan"), ke, A.get("lifecycle_stage") | |
| pv = 0.0 | |
| for t in range(1, N + 1): | |
| g = g1 + (gt - g1) * (t - 1) / max(N - 1, 1) | |
| eps *= (1 + g) | |
| po = float(np.clip(1 - g / roic, 0.0, 1.0)) | |
| pv += (eps * po) / (1 + ke) ** t | |
| po_t = float(np.clip(1 - gt / roic, 0.30, 1.0)) | |
| pv += (eps * (1 + gt) * po_t) / (ke - gt) / (1 + ke) ** N | |
| up = float(np.clip(pv / price - 1, -0.90, 3.0)) | |
| return pv, up, ke, A.get("lifecycle_stage") | |
| def growth_stage_value(d: dict, price) -> dict | None: | |
| """Loss-making growth names have no EPS to discount, so the DCF column stayed blank for | |
| SNOW, COIN, SNAP and friends. Justified EV/Sales from the rule of 40: multiple = 0.2 × (revenue | |
| growth % + FCF margin %), floored at 1× and capped at 15× — a stated, auditable yardstick, | |
| shown with a wide range.""" | |
| ps, g = d.get("ps"), d.get("rev_growth") | |
| if not isinstance(ps, (int, float)) or ps <= 0 or not isinstance(g, (int, float)) or not price: | |
| return None | |
| econ = ((d.get("us") or {}).get("econ") or {}) | |
| score = econ.get("rule_of_40") # growth + FCF margin from the statements — never growth alone | |
| if not isinstance(score, (int, float)): | |
| return None # (SNAP once read +145% on a growth-only score) | |
| if any("distress" in str(f).lower() for f in ((d.get("us") or {}).get("flags") or [])): | |
| return None # a distressed balance sheet has no "justified" sales multiple | |
| # sub-20 scores are priced like ordinary businesses (≈1× sales per 10 points), not like compounders | |
| mult = float(np.clip(0.2 * score if score >= 20 else 0.1 * max(score, 5.0), 0.5, 15.0)) | |
| sales_ps = float(price) / float(ps) | |
| iv = sales_ps * mult | |
| return {"intrinsic": round(iv, 2), "upside": round(float(np.clip(iv / price - 1, -0.9, 3.0)), 4), | |
| "intrinsic_low": round(iv * 0.65, 2), "intrinsic_high": round(iv * 1.35, 2), | |
| "implied_growth": None, "model": "Growth-stage EV/Sales (rule of 40)", | |
| "assumptions": {"rule_of_40_score": round(score, 1), "justified_ev_sales": round(mult, 2), | |
| "current_ev_sales": round(float(ps), 2)}, | |
| "ke": None, "lifecycle": "high-growth", "src": "mechanical"} | |
| def mechanical_assumptions(d: dict) -> dict: | |
| """Crucible's fixed-DCF path as data-driven default assumptions — used when the | |
| LLM-Damodaran call fails or is unavailable, so a positive-EPS name always gets an | |
| intrinsic value. Growth from reported EPS/revenue growth (faded), ke from sector beta.""" | |
| def num(x, default): | |
| return float(x) if isinstance(x, (int, float)) and x == x else default | |
| g1 = num(d.get("eps_growth"), num(d.get("rev_growth"), 0.05)) | |
| roe = num(d.get("roe"), 0.15) | |
| return {"lifecycle_stage": "mechanical", | |
| "high_growth_rate": float(np.clip(g1, -0.30, 0.40)), | |
| "high_growth_years": 5, | |
| "terminal_growth": G_STABLE, | |
| "cost_of_equity": RF + SECTOR_BETA.get(str(d.get("sector")), 1.0) * ERP, | |
| "reinvestment_roic": float(np.clip(roe, 0.05, 0.40)), | |
| "normalized_eps_factor": 1.0} | |
| def waterfill(raw: dict, sect: dict, name_cap: float, sec_cap: float, | |
| no_topup: frozenset = frozenset()) -> dict: | |
| """Normalize raw>=0 into weights summing to 1 under joint per-name and per-sector caps | |
| (crucible damodaran_portfolio._waterfill, pure port). Caps relax if infeasible for the | |
| counts so the book is always fully invested. Names in `no_topup` keep their pro-rata | |
| share but never receive overflow redistribution (e.g. committee-Trim names must not | |
| grow just because everything else is capped).""" | |
| tickers = list(raw) | |
| groups: dict[str, list] = {} | |
| for t in tickers: | |
| groups.setdefault(sect.get(t, "?"), []).append(t) | |
| name_cap = max(name_cap, 1.0 / max(len(tickers), 1) + 1e-9) | |
| sec_cap = max(sec_cap, 1.0 / max(len(groups), 1) + 1e-9) | |
| cap_of = lambda nc, sc: sum(min(sc, len(mem) * nc) for mem in groups.values()) | |
| while cap_of(name_cap, sec_cap) < 1.0 - 1e-9 and name_cap < 1.0: | |
| name_cap = min(1.0, name_cap + 0.02) | |
| sec_cap = min(1.0, sec_cap + 0.02) | |
| tot = sum(raw.values()) | |
| if tot <= 0: | |
| return {t: 0.0 for t in tickers} | |
| w = {t: raw[t] / tot for t in tickers} | |
| for _ in range(500): | |
| excess = 0.0 | |
| for t in tickers: | |
| if w[t] > name_cap + 1e-12: | |
| excess += w[t] - name_cap | |
| w[t] = name_cap | |
| for mem in groups.values(): | |
| ssum = sum(w[t] for t in mem) | |
| if ssum > sec_cap + 1e-12: | |
| sc = sec_cap / ssum | |
| for t in mem: | |
| excess += w[t] * (1 - sc) | |
| w[t] *= sc | |
| if excess < 1e-9: | |
| break | |
| room = {} | |
| for mem in groups.values(): | |
| sec_room = max(sec_cap - sum(w[t] for t in mem), 0.0) | |
| for t in mem: | |
| if t not in no_topup and min(name_cap - w[t], sec_room) > 1e-12: | |
| room[t] = max(raw[t], 1e-9) | |
| if not room: | |
| break | |
| rs = sum(room.values()) | |
| for t in room: | |
| w[t] += excess * room[t] / rs | |
| s = sum(w.values()) | |
| return {t: w[t] / s for t in w} if s > 0 else w | |
| def trap_flags(d: dict) -> list[str]: | |
| """Value-trap guards (crucible): deterioration, inorganic EPS, leverage-inflated ROE.""" | |
| flags = [] | |
| def num(x) -> float | None: | |
| return float(x) if isinstance(x, (int, float)) and x == x else None | |
| rev_g, eps_g = num(d.get("rev_growth")), num(d.get("eps_growth")) | |
| roe, de = num(d.get("roe")), num(d.get("de")) | |
| # de arrives normalized to a plain ratio (market_data divides Yahoo's percent by 100); | |
| # the old >10 percent-sniffing heuristic false-flagged debt-free INFY (9.54% → "9.5x") | |
| if rev_g is not None and eps_g is not None: | |
| if rev_g < 0 and eps_g < 0: | |
| flags.append("VALUE_TRAP(rev↓+eps↓)") | |
| if eps_g > 0.20 and rev_g < 0: | |
| flags.append("INORGANIC(eps↑/rev↓)") | |
| if de is not None and roe is not None and de > 3.0 and roe > 0.20: | |
| flags.append("LEVERAGE_INFLATED_ROE") | |
| return flags | |
| # ---------------- portfolio-level metrics ---------------- | |
| def _regime_on(hist: pd.DataFrame, bench: str = "SPY") -> bool | None: | |
| """Benchmark at/above its 200-day MA now AND ~1 month ago (slipstream confirmation rule).""" | |
| col = bench if bench in hist.columns else "SPY" | |
| if col not in hist.columns or len(hist) < 221: | |
| return None | |
| spx = hist[col].ffill() | |
| now = spx.iloc[-1] >= spx.iloc[-200:].mean() | |
| prev = spx.iloc[-22] >= spx.iloc[-221:-21].mean() | |
| return bool(now and prev) | |
| def sector_etf_sector(symbol: str) -> str | None: | |
| """XLF is a Financials bet, not a 'diversified fund': the sector it tracks, else None.""" | |
| return _SECTOR_ETFS.get(str(symbol or "").upper()) | |
| def _load_sector_etfs() -> dict[str, str]: | |
| try: | |
| import json | |
| from pathlib import Path | |
| rows = json.loads((Path(__file__).resolve().parent.parent / "data" / "instruments.json").read_text()) | |
| return {str(i["ticker"]).upper(): str(i["sector"]) for i in rows | |
| if i.get("asset_class") == "sector_etf" and i.get("sector")} | |
| except Exception: | |
| return {} | |
| _SECTOR_ETFS = _load_sector_etfs() | |
| def portfolio_metrics(holdings: list[dict], hist: pd.DataFrame, bench: str = "SPY") -> dict: | |
| """All the deterministic numbers: allocation, concentration, risk, correlation, benchmark. | |
| `hist` may span several years (for stress tests); 1y stats use the last ~252 rows.""" | |
| bcol = bench if bench in hist.columns else "SPY" | |
| if len(hist) > 262: | |
| hist = hist.iloc[-262:] | |
| w = {h["symbol"]: h["weight"] for h in holdings} | |
| # a broad fund is internally diversified — it gets its own "sector" bucket and a | |
| # relaxed concentration cap instead of being treated as one giant single stock | |
| fund_syms = {h["symbol"] for h in holdings | |
| if str(h["data"].get("quote_type") or "").upper() in FUND_TYPES | |
| and not sector_etf_sector(h["symbol"])} # a sector ETF is one sector, not ballast | |
| sectors: dict[str, float] = {} | |
| for h in holdings: | |
| s = "Diversified fund" if h["symbol"] in fund_syms \ | |
| else (sector_etf_sector(h["symbol"]) or h["data"].get("sector") or "Unknown") | |
| sectors[s] = sectors.get(s, 0.0) + h["weight"] | |
| hhi = sum(x * x for x in w.values()) | |
| m: dict = { | |
| "n_holdings": len(holdings), | |
| "equal_weighted": bool(holdings and holdings[0].get("equal_weighted")), | |
| "sector_allocation": dict(sorted(sectors.items(), key=lambda kv: -kv[1])), | |
| "hhi": round(hhi, 4), | |
| "effective_n": round(1.0 / hhi, 1) if hhi > 0 else None, | |
| "top_position": max(w.items(), key=lambda kv: kv[1]) if w else None, | |
| "top_is_fund": (max(w.items(), key=lambda kv: kv[1])[0] in fund_syms) if w else False, | |
| "weighted_beta": None, "portfolio_vol": None, "max_drawdown_1y": None, | |
| "return_1y": None, "spy_return_1y": None, "sharpe_1y": None, | |
| "corr_clusters": [], "regime_risk_on": _regime_on(hist, bcol), | |
| "total_pl_pct": None, "benchmark": bcol, | |
| } | |
| betas = [(h["weight"], h["data"].get("beta")) for h in holdings | |
| if isinstance(h["data"].get("beta"), (int, float))] | |
| if betas: | |
| m["weighted_beta"] = round(sum(wt * b for wt, b in betas) / sum(wt for wt, _ in betas), 2) | |
| if all(h.get("market_value") for h in holdings): | |
| cost = sum(h["cost_usd"] for h in holdings) | |
| m["total_pl_pct"] = round(sum(h["market_value"] for h in holdings) / cost - 1, 4) if cost else None | |
| else: | |
| m["total_pl_pct"] = round(float(np.mean([h["pl_pct"] for h in holdings])), 4) | |
| cols = [t for t in w if t in hist.columns] | |
| if len(hist) > 60 and cols: | |
| sub: pd.DataFrame = hist.loc[:, cols] | |
| rets = sub.pct_change(fill_method=None).dropna(how="all") | |
| wv = np.array([w[t] for t in cols]) | |
| wv = wv / wv.sum() | |
| port_ret = pd.Series(rets.fillna(0.0).to_numpy() @ wv, index=rets.index) | |
| if len(port_ret) > 40: | |
| m["portfolio_vol"] = round(float(port_ret.std() * np.sqrt(252)), 4) | |
| m["sharpe_1y"] = round(float(port_ret.mean() / port_ret.std() * np.sqrt(252)), 2) \ | |
| if port_ret.std() > 0 else None | |
| curve = (1 + port_ret).cumprod() | |
| m["return_1y"] = round(float(curve.iloc[-1] - 1), 4) | |
| m["max_drawdown_1y"] = round(float((curve / curve.cummax() - 1).min()), 4) | |
| m["equity_curve"] = [ | |
| {"date": str(d.date()), "portfolio": round(float(v), 4)} | |
| for d, v in curve.iloc[::max(1, len(curve) // 120)].items()] | |
| if bcol in hist.columns: | |
| spy = hist[bcol].pct_change(fill_method=None).reindex(port_ret.index).fillna(0.0) | |
| if len(spy) > 40: | |
| # spy_return_1y keeps its name for every downstream consumer — it is the | |
| # BENCHMARK return (S&P 500 for US books, NIFTY/FTSE/… by domicile) | |
| m["spy_return_1y"] = round(float((1 + spy).prod() - 1), 4) | |
| cov = float(np.cov(port_ret, spy)[0, 1]) | |
| var = float(spy.var()) | |
| if var > 0: | |
| m["realized_beta"] = round(cov / var, 2) | |
| resid = port_ret - (cov / var) * spy | |
| m["tracking_error"] = round(float(resid.std() * np.sqrt(252)), 4) | |
| m.update(var_cvar(port_ret)) | |
| # correlation clusters (new — exists in no prior project) | |
| if len(cols) >= 2: | |
| corr = rets.corr() | |
| clusters = [] | |
| for i, a in enumerate(cols): | |
| for b in cols[i + 1:]: | |
| c = corr.loc[a, b] | |
| if isinstance(c, (int, float)) and c == c and c >= CORR_ALERT: | |
| clusters.append({"pair": [a, b], "corr": round(float(c), 2)}) | |
| m["corr_clusters"] = sorted(clusters, key=lambda x: -x["corr"])[:5] | |
| return m | |
| # ---------------- deterministic risk scorecard (supervisor.py OK/WATCH/ALERT pattern) ---------------- | |
| def risk_scorecard(m: dict) -> list[dict]: | |
| """Never depends on an LLM call succeeding. Each check: {check, status, detail}.""" | |
| cards = [] | |
| # single-name concentration (WATCH at cap+3pp, ALERT at cap+7pp — supervisor bands) | |
| top = m.get("top_position") | |
| if top: | |
| t, wt = top | |
| n = int(m.get("n_holdings") or 0) | |
| cap = FUND_CAP if m.get("top_is_fund") else effective_name_cap(n) | |
| status = "ALERT" if wt > cap + 0.07 else "WATCH" if wt > cap + 0.03 else "OK" | |
| why = (" for a broad, internally diversified fund" if m.get("top_is_fund") | |
| else f" in a {n}-stock book" if cap > MAX_POS_W else "") | |
| cards.append({"check": "Position concentration", "status": status, | |
| "detail": f"Largest position {t} is {wt * 100:.0f}% of the portfolio " | |
| f"(healthy cap ~{cap * 100:.0f}%{why})."}) | |
| # sector concentration vs the 30% base cap (broad funds live in their own bucket) | |
| sa = {s: v for s, v in m.get("sector_allocation", {}).items() if s != "Diversified fund"} | |
| if sa: | |
| s, wt = max(sa.items(), key=lambda kv: kv[1]) | |
| status = "ALERT" if wt > SECTOR_CAP_HI else "WATCH" if wt > SECTOR_CAP_BASE else "OK" | |
| cards.append({"check": "Sector concentration", "status": status, | |
| "detail": f"{s} is {wt * 100:.0f}% of the portfolio " | |
| f"(healthy band {SECTOR_CAP_LO * 100:.0f}–{SECTOR_CAP_HI * 100:.0f}%, base {SECTOR_CAP_BASE * 100:.0f}%)."}) | |
| # breadth | |
| n, eff = m.get("n_holdings", 0), m.get("effective_n") | |
| if eff: | |
| want = max(7.0, 0.4 * n) if n >= 12 else 7.0 # 47 names behaving like 13 is not OK | |
| status = "ALERT" if eff < 4 else "WATCH" if eff < want else "OK" | |
| cards.append({"check": "Diversification breadth", "status": status, | |
| "detail": f"{n} holdings behave like ~{eff:.0f} independent positions " | |
| f"(effective N from concentration)."}) | |
| # market sensitivity | |
| beta = m.get("realized_beta") or m.get("weighted_beta") | |
| if beta is not None: | |
| status = "ALERT" if beta > 1.5 else "WATCH" if beta > 1.2 else "OK" | |
| cards.append({"check": "Market sensitivity", "status": status, | |
| "detail": f"Portfolio beta ≈ {beta:.2f} vs the S&P 500 — " | |
| + ("amplifies market swings." if beta > 1.1 else | |
| "roughly market-like." if beta > 0.9 else "defensive tilt.")}) | |
| # correlation clusters | |
| cl = m.get("corr_clusters", []) | |
| if cl: | |
| worst = cl[0] | |
| status = "ALERT" if worst["corr"] >= 0.9 else "WATCH" | |
| cards.append({"check": "Correlated positions", "status": status, | |
| "detail": f"{' & '.join(worst['pair'])} move together (1y correlation " | |
| f"{worst['corr']:.2f}) — they act like one bet." | |
| + (f" {len(cl)} correlated pairs total." if len(cl) > 1 else "")}) | |
| else: | |
| cards.append({"check": "Correlated positions", "status": "OK", | |
| "detail": "No highly correlated pairs (≥0.80) among your holdings."}) | |
| # regime | |
| reg = m.get("regime_risk_on") | |
| if reg is not None: | |
| cards.append({"check": "Market regime", "status": "OK" if reg else "WATCH", | |
| "detail": "S&P 500 above its 200-day average — trend supportive." if reg | |
| else "S&P 500 below its 200-day average — trend regime is risk-off."}) | |
| return cards | |
| # ---------------- health score (deterministic continuous composite, 5-97) ---------------- | |
| HEALTH_WEIGHTS = {"prospects": 0.25, "concentration": 0.20, "diversification": 0.15, | |
| "quality": 0.15, "drawdown": 0.15, "valuation": 0.10} | |
| def _profile_weights(context: dict | None) -> dict[str, float]: | |
| """Drawdown weight follows the stated profile: a growth investor accepts a bigger ride.""" | |
| w = dict(HEALTH_WEIGHTS) | |
| risk = str((context or {}).get("risk") or "").lower() | |
| if risk == "conservative": | |
| w["drawdown"] = 0.25 | |
| elif risk == "aggressive": | |
| w["drawdown"] = 0.08 | |
| tot = sum(w.values()) | |
| return {k: v / tot for k, v in w.items()} | |
| def _pillar_prospects(holdings, decisions) -> tuple[float, str]: | |
| """Forward return and growth quality, weight-averaged: committee conviction anchored, then | |
| the payoff asymmetry where the DCF is trusted, the quant model's 12m excess where covered, | |
| growth durability (revenue/EPS CAGR, incremental ROIC, Rule of 40) and estimate momentum. | |
| Growth-stage names are judged on durability and momentum, never on a mature-company DCF gap.""" | |
| tot, acc, notes = 0.0, 0.0, [] | |
| for h in holdings: | |
| w = float(h.get("weight") or 0) | |
| if w <= 0: | |
| continue | |
| t = h["symbol"] | |
| d = h.get("data") or {} | |
| conv = ((decisions or {}).get(t) or {}).get("conviction") | |
| sc = float(conv) if isinstance(conv, (int, float)) else 50.0 | |
| a = h.get("asymmetry") or {} | |
| if a and a.get("gated", True) and isinstance(a.get("expected_return"), (int, float)): | |
| sc += float(np.clip(a["expected_return"] * 100, -25, 25)) * 0.5 | |
| pred = (d.get("panel") or {}).get("pred_excess_12m") | |
| if isinstance(pred, (int, float)): | |
| sc += float(np.clip(pred * 200, -20, 20)) | |
| e = ((d.get("us") or {}).get("econ") or {}) | |
| g = e.get("rev_cagr_3y"); ge = e.get("eps_cagr_3y") | |
| best_g = max([x for x in (g, ge) if isinstance(x, (int, float))], default=None) | |
| if best_g is not None: | |
| sc += 8 if best_g >= 0.15 else 4 if best_g >= 0.08 else -8 if best_g <= 0 else 0 | |
| if isinstance(e.get("incremental_roic_5y"), (int, float)): | |
| sc += 4 if e["incremental_roic_5y"] >= 0.15 else -4 if e["incremental_roic_5y"] < 0.05 else 0 | |
| if isinstance(e.get("rule_of_40"), (int, float)): | |
| sc += 4 if e["rule_of_40"] >= 40 else -4 | |
| st = (d.get("us") or {}).get("street") or {} | |
| if isinstance(st.get("implied_return"), (int, float)) and (st.get("n_analysts") or 0) >= 5: | |
| sc += float(np.clip(st["implied_return"] * 50, -6, 6)) # street view: small weight | |
| er = (d.get("us") or {}).get("earnings") or {} | |
| if isinstance(er.get("eps_revision_90d_pct"), (int, float)): | |
| sc += float(np.clip(er["eps_revision_90d_pct"], -10, 10)) * 0.8 | |
| if er.get("n_q"): | |
| sc += 4 if er["beats_8q"] >= 7 else -4 if er["beats_8q"] <= 3 else 0 | |
| sc = float(np.clip(sc, 0, 100)) | |
| acc += w * sc | |
| tot += w | |
| if not tot: | |
| return 50.0, "no holdings" | |
| val = acc / tot | |
| return round(val, 1), f"weighted forward-return read {val:.0f}/100 across the book" | |
| def _pillar_concentration(m: dict, holdings, risk) -> tuple[float, str]: | |
| pen, notes = 0.0, [] | |
| top = m.get("top_position") | |
| n = int(m.get("n_holdings") or len(holdings) or 1) | |
| if top: | |
| cap = FUND_CAP if m.get("top_is_fund") else effective_name_cap(n) | |
| x = max(0.0, float(top[1]) - cap) | |
| if x > 0: | |
| pen += min(60.0, 350 * x) # 23% vs 15% cap → −28 | |
| if float(top[1]) >= 0.20 and not m.get("top_is_fund"): | |
| pen += 10 # one name at a fifth of the book is a regime of its own | |
| notes.append(f"{top[0]} {float(top[1]) * 100:.0f}% vs {cap * 100:.0f}% cap") | |
| ws = sorted((float(h.get("weight") or 0) for h in holdings), reverse=True) | |
| top3 = sum(ws[:3]) | |
| if n >= 6 and top3 > 0.5: | |
| pen += min(20.0, 100 * (top3 - 0.5)) | |
| notes.append(f"top three names {top3 * 100:.0f}%") | |
| sa = {s: v for s, v in (m.get("sector_allocation") or {}).items() if s != "Diversified fund"} | |
| if sa: | |
| s_name, sw = max(sa.items(), key=lambda kv: kv[1]) | |
| if sw > SECTOR_CAP_BASE: | |
| pen += min(40.0, 120 * (sw - SECTOR_CAP_BASE)) | |
| notes.append(f"{s_name} {sw * 100:.0f}% of the book") | |
| act = (risk or {}).get("active_sectors") or [] | |
| if act: | |
| mx = max((float(a.get("active") or 0) for a in act), default=0.0) | |
| if mx > 0.15: | |
| pen += min(20.0, 60 * (mx - 0.15)) | |
| notes.append(f"largest active sector bet +{mx * 100:.0f}pp vs benchmark") | |
| return round(float(np.clip(100 - pen, 0, 100)), 1), ("; ".join(notes) or "no oversized bet") | |
| def _pillar_diversification(m: dict, holdings, context) -> tuple[float, str]: | |
| n = int(m.get("n_holdings") or len(holdings) or 1) | |
| eff = m.get("effective_n") | |
| target = min(n, max(8.0, 0.4 * n)) # 47 names behaving like 13 is cosmetic diversification | |
| base = float(np.clip(100 * float(eff) / max(target, 1), 20, 100)) if eff else 60.0 | |
| notes = [f"~{eff} effective positions of {n}"] if eff else [] | |
| k = len(m.get("corr_clusters") or []) | |
| if k: | |
| base -= 8 * min(k, 3) | |
| notes.append(f"{k} correlated pair{'s' if k > 1 else ''}") | |
| cautious = (str((context or {}).get("risk") or "").lower() == "conservative" | |
| or "under 3" in str((context or {}).get("horizon") or "").lower()) | |
| if cautious: | |
| ballast = sum(float(h.get("weight") or 0) for h in holdings if is_ballast_fund(h)) | |
| if ballast < 0.10: | |
| base -= 20 | |
| notes.append("no bond/cash ballast for a cautious profile") | |
| return round(float(np.clip(base, 0, 100)), 1), ("; ".join(notes) or "balanced") | |
| def _pillar_quality(holdings) -> tuple[float, str]: | |
| tot, exposure, f_acc, f_w, worst = 0.0, 0.0, 0.0, 0.0, [] | |
| for h in holdings: | |
| w = float(h.get("weight") or 0) | |
| if w <= 0: | |
| continue | |
| tot += w | |
| d = h.get("data") or {} | |
| us = d.get("us") or {} | |
| sev = 0.0 | |
| for fl in us.get("flags") or []: | |
| sev += (1.0 if ("distress" in fl or "earnings-quality" in fl) | |
| else 0.25 if ("insiders" in fl or "estimates cut" in fl) # routine at mega-caps | |
| else 0.5) | |
| sev += 0.5 * len((h.get("dcf") or {}).get("flags") or []) | |
| if str((h.get("dcf") or {}).get("note") or "").startswith("loss-making"): | |
| sev += 0.5 | |
| sev = min(sev, 1.0) | |
| exposure += w * sev | |
| if sev >= 1.0: | |
| worst.append(h["symbol"]) | |
| pz = (us.get("piotroski") or {}).get("score") | |
| if isinstance(pz, (int, float)): | |
| f_acc += w * pz | |
| f_w += w | |
| if not tot: | |
| return 60.0, "no data" | |
| score = 100 - 90 * (exposure / tot) | |
| if f_w: | |
| score += (f_acc / f_w - 5) * 4 | |
| note = (f"{exposure / tot * 100:.0f}% of the book carries forensic flags" + (f" ({', '.join(worst[:4])} serious)" if worst else "")) | |
| return round(float(np.clip(score, 0, 100)), 1), note | |
| def _pillar_drawdown(m: dict, risk) -> tuple[float, str]: | |
| """Drawdown EFFICIENCY: did the book fall further than its beta explains? A high-beta | |
| growth book that fell in line with beta is efficient; one that fell more is not.""" | |
| beta = m.get("realized_beta") or m.get("weighted_beta") | |
| stress = (risk or {}).get("stress") or [] | |
| pairs = [(float(s["portfolio"]), float(s["benchmark"])) for s in stress | |
| if isinstance(s.get("portfolio"), (int, float)) and isinstance(s.get("benchmark"), (int, float))] | |
| if not pairs: | |
| return 65.0, "no stress history" | |
| # the structural (fundamental) beta, not last year's realized one — a staples book showed | |
| # a 0.04 realized beta in a rotation year and was then "inefficient" in every crash replay | |
| wb = m.get("weighted_beta") | |
| b = float(wb) if isinstance(wb, (int, float)) and wb > 0 else (float(beta) if isinstance(beta, (int, float)) and beta > 0 else 1.0) | |
| b = max(b, 0.5) | |
| excess = [p - b * q for p, q in pairs] # negative = worse than beta warrants | |
| e = sum(excess) / len(excess) | |
| eff = float(np.clip(70 + 300 * e, 0, 100)) | |
| ratios = [p / q for p, q in pairs if q < 0] | |
| ratio = sum(ratios) / len(ratios) if ratios else 1.0 # average fall relative to the index | |
| absolute = float(np.clip(100 - 65 * ratio + 30, 0, 100)) # 0.5× → 97, 1.0× → 65, 1.5× → 33 | |
| score = 0.5 * eff + 0.5 * absolute | |
| v = m.get("var_1m_95") | |
| if isinstance(v, (int, float)) and v > 0.15: | |
| score -= 8 | |
| note = (f"crash replays average {ratio:.2f}× the index's fall, " | |
| f"{'better' if e >= 0 else 'worse'} than beta {b:.2f} explains by {abs(e) * 100:.0f}pp") | |
| return round(float(np.clip(score, 0, 100)), 1), note | |
| def _pillar_valuation(holdings) -> tuple[float, str]: | |
| ups, pct_acc, pct_w = [], 0.0, 0.0 | |
| for h in holdings: | |
| w = float(h.get("weight") or 0) | |
| dcf = h.get("dcf") or {} | |
| a = h.get("asymmetry") or {} | |
| if isinstance(dcf.get("upside"), (int, float)) and a.get("gated", True) and not dcf.get("note"): | |
| ups.append((w, float(dcf["upside"]))) | |
| pe = (((h.get("data") or {}).get("us") or {}).get("valuation_hist") or {}).get("pe") | |
| if isinstance(pe, dict) and isinstance(pe.get("pctile"), (int, float)): | |
| pct_acc += w * pe["pctile"] | |
| pct_w += w | |
| if ups: | |
| cov_w = sum(w for w, _ in ups) | |
| gap = sum(w * u for w, u in ups) / max(cov_w, 1e-9) | |
| # the verdict applies to the covered slice only: a book that is half an index fund is | |
| # not "48% overvalued" because its one big stock is — the rest is neutral (60) | |
| share = float(np.clip(cov_w / max(sum(float(h.get("weight") or 0) for h in holdings), 1e-9), 0.0, 1.0)) | |
| score = share * (60 + 100 * float(np.clip(gap, -0.5, 0.35))) + (1 - share) * 60 | |
| note = (f"book trades {abs(gap) * 100:.0f}% {'below' if gap > 0 else 'above'} DCF value where the model applies" | |
| + (f" ({share:.0%} of the book)" if share < 0.85 else "")) | |
| else: | |
| score, note = 60.0, "DCF not applicable to most of the book" | |
| if pct_w: | |
| pc = pct_acc / pct_w | |
| score += -10 if pc > 90 else -5 if pc > 75 else 5 if pc < 25 else 0 | |
| note += f"; P/E at the {pc:.0f}th percentile of own history" | |
| return round(float(np.clip(score, 0, 100)), 1), note | |
| def health_breakdown(cards: list[dict], m: dict, holdings: list[dict], decisions: dict | None = None, | |
| context: dict | None = None, risk: dict | None = None) -> dict: | |
| """Six pillars, each 0-100, weighted by the investor's profile → one health number with | |
| the reasons visible. Past performance is a tie-breaker (±3), never a pillar; a stated loss | |
| tolerance the book breaches is a flat charge.""" | |
| weights = _profile_weights(context) | |
| pillars = { | |
| "prospects": _pillar_prospects(holdings, decisions), | |
| "concentration": _pillar_concentration(m, holdings, risk), | |
| "diversification": _pillar_diversification(m, holdings, context), | |
| "quality": _pillar_quality(holdings), | |
| "drawdown": _pillar_drawdown(m, risk), | |
| "valuation": _pillar_valuation(holdings), | |
| } | |
| score = sum(weights[k] * v[0] for k, v in pillars.items()) | |
| r1, rs = m.get("return_1y"), m.get("spy_return_1y") | |
| tie = float(np.clip((r1 - rs) * 10, -3, 3)) if isinstance(r1, (int, float)) and isinstance(rs, (int, float)) else 0.0 | |
| breach = -8.0 if any(c.get("check") == "Loss tolerance" and c.get("status") == "ALERT" for c in cards) else 0.0 | |
| # weakest link: one broken pillar caps the whole — a 76%-one-sector book cannot read "healthy" | |
| # on the strength of its prospects | |
| weakest = min(v[0] for v in pillars.values()) | |
| score = min(score, 45 + 0.6 * weakest) | |
| total = int(np.clip(round(score + tie + breach), 5, 97)) | |
| labels = {"prospects": "Prospects", "concentration": "Concentration", "diversification": "Diversification", | |
| "quality": "Quality", "drawdown": "Drawdown efficiency", "valuation": "Valuation"} | |
| return {"score": total, | |
| "pillars": [{"key": k, "label": labels[k], "score": v[0], "weight": round(weights[k], 3), "note": v[1]} | |
| for k, v in pillars.items()], | |
| "tiebreak": round(tie, 1), "tolerance_penalty": breach} | |
| def health_score(cards: list[dict], m: dict, holdings: list[dict], decisions: dict | None = None, | |
| context: dict | None = None, risk: dict | None = None) -> int: | |
| return health_breakdown(cards, m, holdings, decisions, context, risk)["score"] | |
| # ═══════════════════════ institutional-grade additions ═══════════════════════ | |
| # ---------------- valuation: ranges, reverse DCF, sector models ---------------- | |
| def dcf_range(eps_base, price, A: dict | None) -> tuple[float, float]: | |
| """Bear/bull intrinsic values from the same DCF with stressed assumptions — a point | |
| estimate invites false precision. Bear: growth ×0.6, cost of equity +1.5pp, terminal | |
| growth −0.5pp. Bull: growth ×1.25, ke −1pp. Returns (low, high); NaNs on failure.""" | |
| if not A: | |
| return float("nan"), float("nan") | |
| bear = dict(A, high_growth_rate=float(A.get("high_growth_rate", 0.05)) * 0.6, | |
| cost_of_equity=float(A.get("cost_of_equity", 0.09)) + 0.015, | |
| terminal_growth=max(0.0, float(A.get("terminal_growth", 0.025)) - 0.005)) | |
| bull = dict(A, high_growth_rate=float(A.get("high_growth_rate", 0.05)) * 1.25, | |
| cost_of_equity=max(0.06, float(A.get("cost_of_equity", 0.09)) - 0.01)) | |
| lo, _, _, _ = dcf_from_assumptions(eps_base, price, bear) | |
| hi, _, _, _ = dcf_from_assumptions(eps_base, price, bull) | |
| if lo == lo and hi == hi and lo > hi: | |
| lo, hi = hi, lo | |
| return lo, hi | |
| def reverse_dcf(eps_base, price, A: dict | None) -> float | None: | |
| """The growth rate the CURRENT PRICE implies under the same DCF (bisection on the | |
| high-growth rate, everything else held). 'The price assumes 28%/yr for 7 years — | |
| do you believe that?' is the most honest valuation statement the tool can make.""" | |
| if not A or not eps_base or eps_base <= 0 or not price or price <= 0: | |
| return None | |
| lo, hi = -0.30, 0.60 # the DCF's own clip range for the high-growth rate | |
| f = lambda g: dcf_from_assumptions(eps_base, price, dict(A, high_growth_rate=g))[0] - price | |
| try: | |
| flo, fhi = f(lo), f(hi) | |
| if flo != flo or fhi != fhi: | |
| return None | |
| if flo > 0: # price is below even a shrinking-earnings value | |
| return lo | |
| if fhi < 0: # price needs MORE than 60%/yr — report the bound | |
| return hi | |
| for _ in range(50): | |
| mid = (lo + hi) / 2 | |
| fm = f(mid) | |
| if fm != fm: | |
| return None | |
| if flo * fm <= 0: | |
| hi, fhi = mid, fm | |
| else: | |
| lo, flo = mid, fm | |
| return round((lo + hi) / 2, 4) | |
| except Exception: | |
| return None | |
| def bank_value(bvps, roe, sector: str | None, g: float = G_STABLE) -> tuple[float, float]: | |
| """Justified P/B for financials: P/B* = (ROE − g) / (ke − g); intrinsic = BVPS × P/B*. | |
| An EPS-DCF misreads banks and insurers (leverage IS the business); this is the | |
| standard excess-returns shortcut. Returns (intrinsic, ke); NaN if inputs unusable.""" | |
| if not isinstance(bvps, (int, float)) or bvps <= 0 or not isinstance(roe, (int, float)): | |
| return float("nan"), float("nan") | |
| ke = RF + SECTOR_BETA.get(str(sector), 1.10) * ERP | |
| roe = float(np.clip(roe, -0.2, 0.6)) | |
| if ke <= g: | |
| return float("nan"), ke | |
| pb = max(0.2, (roe - g) / (ke - g)) | |
| return bvps * pb, ke | |
| # ---------------- risk analytics ---------------- | |
| STRESS_WINDOWS = [ | |
| ("COVID crash", "2020-02-19", "2020-03-23"), | |
| ("2022 rate shock", "2022-01-03", "2022-10-12"), | |
| ("Q4-2018 selloff", "2018-09-20", "2018-12-24"), | |
| ("2025 tariff shock", "2025-02-19", "2025-04-08"), | |
| ] | |
| def stress_tests(holdings: list[dict], hist: pd.DataFrame, bench: str = "SPY") -> list[dict]: | |
| """Historical shock replay: what would TODAY's weights have returned through each | |
| window, vs the benchmark. Coverage = share of the book with prices in the window.""" | |
| out = [] | |
| if hist is None or hist.empty: | |
| return out | |
| bcol = bench if bench in hist.columns else "SPY" | |
| idx = pd.to_datetime(hist.index) | |
| for name, a, b in STRESS_WINDOWS: | |
| sub = hist[(idx >= a) & (idx <= b)] | |
| if len(sub) < 5: | |
| continue | |
| first, last = sub.ffill().bfill().iloc[0], sub.ffill().iloc[-1] | |
| tot_w, ret = 0.0, 0.0 | |
| for h in holdings: | |
| t, w = h["symbol"], float(h.get("weight") or 0) | |
| if t in sub.columns and int(pd.Series(sub[t]).notna().sum()) >= 3: | |
| p0, p1 = float(first[t]), float(last[t]) | |
| if p0 > 0 and p1 == p1: | |
| ret += w * (p1 / p0 - 1) | |
| tot_w += w | |
| if tot_w < 0.5: | |
| continue | |
| b_ret = None | |
| if bcol in sub.columns and float(first[bcol]) > 0: | |
| b_ret = round(float(last[bcol] / first[bcol] - 1), 4) | |
| out.append({"name": name, "window": f"{a} → {b}", "portfolio": round(ret / tot_w, 4), | |
| "benchmark": b_ret, "coverage": round(tot_w, 2)}) | |
| return out | |
| def var_cvar(port_ret: pd.Series, level: float = 0.95) -> dict: | |
| """Historical 1-day VaR/CVaR at 95% from the last year of daily portfolio returns, | |
| plus a √21-scaled one-month VaR. Loss expressed as a positive fraction.""" | |
| r = pd.Series(port_ret).dropna() | |
| if len(r) < 60: | |
| return {} | |
| q = float(np.quantile(r, 1 - level)) | |
| tail = r[r <= q] | |
| return {"var_1d_95": round(-q, 4), | |
| "cvar_1d_95": round(-float(tail.mean()), 4) if len(tail) else None, | |
| "var_1m_95": round(-q * float(np.sqrt(21)), 4)} | |
| def currency_exposure(holdings: list[dict]) -> dict[str, float]: | |
| out: dict[str, float] = {} | |
| for h in holdings: | |
| c = str(h["data"].get("currency") or "USD") | |
| c = "GBP" if c == "GBp" else c | |
| out[c] = out.get(c, 0.0) + float(h.get("weight") or 0) | |
| return dict(sorted(((k, round(v, 4)) for k, v in out.items()), key=lambda kv: -kv[1])) | |
| def liquidity(holdings: list[dict]) -> list[dict]: | |
| """Days to liquidate each position at 20% of average daily dollar volume — only when | |
| quantities are known. Flags anything over one trading day.""" | |
| out = [] | |
| for h in holdings: | |
| mv, d = h.get("market_value"), h["data"] | |
| vol, px, fx = d.get("avg_volume"), d.get("price"), h.get("fx_usd") or 1.0 | |
| if not (mv and isinstance(vol, (int, float)) and vol > 0 and px): | |
| continue | |
| adv_usd = vol * px * fx | |
| days = mv / (0.2 * adv_usd) | |
| if days > 1.0: | |
| out.append({"ticker": h["symbol"], "days_to_liquidate": round(days, 1), | |
| "adv_usd": round(adv_usd)}) | |
| return sorted(out, key=lambda x: -x["days_to_liquidate"]) | |
| def lookthrough(holdings: list[dict], fund_lt: dict[str, dict]) -> dict: | |
| """Effective single-name and sector exposure with funds decomposed into their | |
| disclosed constituents. {'top_names': [[sym, direct, via_funds, total]], 'sectors': {...}, | |
| 'coverage': share of fund weight with disclosed holdings}.""" | |
| direct: dict[str, float] = {} | |
| via: dict[str, float] = {} | |
| sectors: dict[str, float] = {} | |
| fund_w, disclosed_w = 0.0, 0.0 | |
| for h in holdings: | |
| t, w = h["symbol"], float(h.get("weight") or 0) | |
| lt = fund_lt.get(t) | |
| if lt: | |
| fund_w += w | |
| tops = lt.get("top") or [] | |
| if tops: | |
| disclosed_w += w * sum(p for _, p in tops) | |
| for sym, p in tops: | |
| via[sym] = via.get(sym, 0.0) + w * p | |
| for s, p in (lt.get("sectors") or {}).items(): | |
| sectors[s] = sectors.get(s, 0.0) + w * p | |
| else: | |
| direct[t] = direct.get(t, 0.0) + w | |
| s = str(h["data"].get("sector") or "Unknown") | |
| sectors[s] = sectors.get(s, 0.0) + w | |
| if not fund_w: | |
| return {} | |
| names = sorted(set(direct) | set(via)) | |
| rows = [[n, round(direct.get(n, 0), 4), round(via.get(n, 0), 4), | |
| round(direct.get(n, 0) + via.get(n, 0), 4)] for n in names] | |
| rows.sort(key=lambda r: -r[3]) | |
| return {"top_names": rows[:10], | |
| "sectors": dict(sorted(((k, round(v, 4)) for k, v in sectors.items()), key=lambda kv: -kv[1])), | |
| "fund_weight": round(fund_w, 4), | |
| "coverage": round(disclosed_w / fund_w, 2) if fund_w else 0.0} | |
| _FACTOR_GROUPS = { | |
| "value": [("n_earnings_yield", 1), ("n_fcf_yield", 1), ("n_book_to_price", 1), ("n_sales_yield", 1)], | |
| "quality": [("n_roe", 1), ("n_roa", 1), ("n_netmargin", 1), ("n_grossmargin", 1), ("n_de", -1)], | |
| "momentum": [("n_mom_12_1", 1), ("n_ret_26w", 1), ("n_ret_13w", 1)], | |
| "low_vol": [("n_vol_13w", -1)], | |
| } | |
| def factor_tilts(holdings: list[dict]) -> dict: | |
| """Weighted style exposures (−1…+1) from the MarketFM normalized factor scores, plus | |
| the share of the book covered (US names in the panel only).""" | |
| tot, cov = 0.0, 0.0 | |
| acc = {g: 0.0 for g in _FACTOR_GROUPS} | |
| for h in holdings: | |
| w = float(h.get("weight") or 0) | |
| tot += w | |
| fac = (h["data"].get("panel") or {}).get("factors") or {} | |
| if not fac: | |
| continue | |
| cov += w | |
| for g, feats in _FACTOR_GROUPS.items(): | |
| vals = [sign * fac[f] for f, sign in feats if isinstance(fac.get(f), (int, float))] | |
| if vals: | |
| acc[g] += w * sum(vals) / len(vals) | |
| if not cov or cov / max(tot, 1e-9) < 0.4: | |
| return {} | |
| return {"coverage": round(cov / tot, 2), | |
| "tilts": {g: round(v / cov, 3) for g, v in acc.items()}} | |
| _BALLAST_WORDS = ("bond", "treasury", "aggregate", "gilt", "t-bill", "bill etf", "money market", "fixed income", | |
| "short-term", "ultra short", "floating rate", "tips", "inflation-protected", "corporate bond", "muni") | |
| _BALLAST_TICKERS = {"BND", "AGG", "SHY", "IEF", "TLT", "BIL", "SGOV", "GOVT", "LQD", "VCIT", "VCSH", "BSV", "BIV", "TIP", | |
| "VTIP", "SCHZ", "SCHO", "SCHR", "IUSB", "BNDX", "VGIT", "VGSH", "SHV", "MINT", "JPST", "ICSH", "IGLT.L", | |
| "IGLS.L", "LIQUIDBEES.NS"} | |
| def is_ballast_fund(h: dict) -> bool: | |
| """Bond / cash funds are ballast, not equity theses — equity lenses must not eject them.""" | |
| d = h.get("data") or {} | |
| if str(d.get("quote_type") or "").upper() not in FUND_TYPES: | |
| return False | |
| if h["symbol"].upper() in _BALLAST_TICKERS: | |
| return True | |
| name = str(d.get("name") or "").lower() | |
| return any(w in name for w in _BALLAST_WORDS) | |
| def tax_note(h: dict, action: str) -> str | None: | |
| """Every Trim/Sell carries its tax cost: estimated gain (in local currency) and the | |
| holding period when a purchase date was given. Advice that ignores tax is not advice.""" | |
| if action not in ("Trim", "Sell"): | |
| return None | |
| gain_ps = float(h["price"]) - float(h["entry"]) | |
| qty = h.get("qty") | |
| ccy = str(h["data"].get("currency") or "USD") | |
| parts = [] | |
| if qty: | |
| g = gain_ps * float(qty) | |
| parts.append(f"{'gain' if g >= 0 else 'loss'} ≈ {abs(g):,.0f} {ccy} on the full position") | |
| else: | |
| parts.append(f"{'gain' if gain_ps >= 0 else 'loss'} of {abs(h['pl_pct']) * 100:.0f}% since entry") | |
| bought = h.get("bought") | |
| if "." in str(h.get("symbol") or ""): # non-US listing: no US short/long-term regime | |
| parts.append("capital-gains treatment depends on your jurisdiction (e.g. UK CGT annual " | |
| "exemption, no holding-period distinction) — check your local rules") | |
| bought = None | |
| lots = [l for l in (h.get("lots") or []) if l.get("side") != "sell" and l.get("bought")] | |
| if bought and len(lots) > 1: # several dated lots: say which are short-term | |
| try: | |
| from datetime import date | |
| def _days(b): | |
| y, mo, d = (int(x) for x in str(b)[:10].split("-")) | |
| return (date.today() - date(y, mo, d)).days | |
| st = [l for l in lots if _days(l["bought"]) < 365] | |
| if st: | |
| parts.append(f"{len(lots)} lots — {len(st)} SHORT-TERM (bought " | |
| + ", ".join(str(l["bought"]) for l in st) + "); sell the long-term lots first") | |
| else: | |
| parts.append(f"{len(lots)} lots, all long-term") | |
| bought = None | |
| except Exception: | |
| pass | |
| if bought: | |
| try: | |
| from datetime import date | |
| y, mo, d = (int(x) for x in str(bought)[:10].split("-")) | |
| days = (date.today() - date(y, mo, d)).days | |
| parts.append(f"held {days // 30} months — " | |
| + ("long-term" if days >= 365 else "SHORT-TERM (higher tax rate)")) | |
| except Exception: | |
| pass | |
| else: | |
| parts.append("add a purchase date to see short- vs long-term treatment") | |
| if gain_ps > 0: | |
| parts.append("realizing it is taxable — weigh the tax cost against the risk you remove") | |
| return "; ".join(parts) | |
| # ═══════════════ portfolio-aware recommendation engine (marginal improvement) ═══════════════ | |
| # A recommendation is only "top class" if it is derived from THIS portfolio's problems and | |
| # says, with numbers, what it fixes. diagnose_needs turns the analysis into weighted needs; | |
| # candidate_fit scores each candidate on the measured improvement of adding it. | |
| ALL_SECTORS = list(SECTOR_BETA) | |
| def _tol_from_context(context: dict | None) -> float | None: | |
| tol = str((context or {}).get("loss_tolerance") or "") | |
| try: | |
| return float(tol.replace("%", "").split()[0]) / 100 if tol else None | |
| except Exception: | |
| return None | |
| def diagnose_needs(pm: dict, risk: dict, holdings: list[dict], context: dict | None) -> dict: | |
| """{need: {weight 0-1, label, ...}} — what would most improve this book.""" | |
| context = context or {} | |
| needs: dict = {} | |
| if (str(context.get("risk") or "").lower() == "conservative" | |
| or "under 3" in str(context.get("horizon") or "").lower()): | |
| needs["_cautious"] = {"weight": 0.0, "label": None} # marker only — never a need to fill | |
| sa = {s: w for s, w in (pm.get("sector_allocation") or {}).items() if s != "Diversified fund"} | |
| lt = ((risk or {}).get("lookthrough") or {}).get("sectors") or {} | |
| eff = lt if lt else sa | |
| gaps = [s for s in ALL_SECTORS if eff.get(s, 0.0) < 0.03] | |
| if gaps: | |
| cautious = bool(needs.get("_cautious")) | |
| # a retiree's problem is drawdown, not a missing cyclical sector — gaps matter less | |
| needs["sector_gaps"] = {"weight": (0.3 if cautious else 1.0) * min(1.0, 0.3 + 0.08 * len(gaps)), "sectors": gaps, | |
| "label": f"no exposure to {', '.join(gaps[:4])}" + (" and more" if len(gaps) > 4 else "")} | |
| if sa: | |
| s, w = max(sa.items(), key=lambda kv: kv[1]) | |
| if w > SECTOR_CAP_BASE: | |
| needs["sector_overweight"] = {"weight": min(1.0, (w - SECTOR_CAP_BASE) / 0.3), "sector": s, | |
| "label": f"{s} is {w * 100:.0f}% of the book"} | |
| effn = float(pm.get("effective_n") or 0) | |
| if effn and effn < 7: | |
| needs["diversifier"] = {"weight": min(1.0, (7 - effn) / 5), "label": f"only ~{effn:.1f} effective positions"} | |
| beta = pm.get("realized_beta") or pm.get("weighted_beta") | |
| stress = (risk or {}).get("stress") or [] | |
| worst = min((s["portfolio"] for s in stress), default=None) | |
| tol = _tol_from_context(context) | |
| d_w = 0.0 | |
| if isinstance(beta, (int, float)) and beta > 1.25: | |
| d_w = max(d_w, min(1.0, (beta - 1.25) / 0.5)) | |
| if tol is not None and worst is not None and worst < -tol: | |
| d_w = 1.0 | |
| if str(context.get("risk") or "").lower() == "conservative": | |
| d_w = max(d_w, 0.6) | |
| if d_w: | |
| lab = (f"crash replay {worst * 100:.0f}% breaches your {tol * 100:.0f}% tolerance" if tol is not None and worst is not None and worst < -tol | |
| else f"portfolio beta {beta:.2f}" if isinstance(beta, (int, float)) and beta > 1.25 else "conservative profile") | |
| needs["defensive"] = {"weight": d_w, "label": lab} | |
| if "income" in str(context.get("objective") or "").lower(): | |
| needs["income"] = {"weight": 1.0, "label": "income objective"} | |
| tilts = ((risk or {}).get("factor_tilts") or {}).get("tilts") or {} | |
| ups = [(h["weight"], h["dcf"]["upside"]) for h in holdings | |
| if isinstance((h.get("dcf") or {}).get("upside"), (int, float))] | |
| wavg = sum(w * u for w, u in ups) / max(sum(w for w, _ in ups), 1e-9) if ups else None | |
| v_w = 0.0 | |
| if tilts.get("value", 0) < -0.15: | |
| v_w = min(1.0, -tilts["value"]) | |
| if wavg is not None and wavg < -0.2: | |
| v_w = max(v_w, min(1.0, -wavg)) | |
| if v_w: | |
| needs["value"] = {"weight": v_w, "label": (f"holdings sit {abs(wavg) * 100:.0f}% above DCF value on average" | |
| if wavg is not None and wavg < -0.2 else "book tilts expensive")} | |
| if tilts.get("quality", 0) < -0.1: | |
| needs["quality"] = {"weight": min(1.0, -tilts["quality"] * 2), "label": "quality tilt is negative"} | |
| if not needs: | |
| needs["quality"] = {"weight": 0.5, "label": "no structural gaps — favour quality compounders"} | |
| return needs | |
| def portfolio_returns(holdings: list[dict], hist: pd.DataFrame, window: int = 262) -> pd.Series | None: | |
| cols = [h["symbol"] for h in holdings if h["symbol"] in hist.columns] | |
| if len(hist) < 60 or not cols: | |
| return None | |
| sub: pd.DataFrame = hist.loc[:, cols].iloc[-window:] | |
| rets = sub.pct_change(fill_method=None).dropna(how="all") | |
| wv = np.array([float(h["weight"]) for h in holdings if h["symbol"] in hist.columns]) | |
| wv = wv / wv.sum() | |
| return pd.Series(rets.fillna(0.0).to_numpy() @ wv, index=rets.index) | |
| def _window_return(series: pd.Series, a: str, b: str) -> float | None: | |
| idx = pd.to_datetime(series.index) | |
| s = series[(idx >= a) & (idx <= b)].dropna() | |
| if len(s) < 5 or float(s.iloc[0]) <= 0: | |
| return None | |
| return float(s.iloc[-1] / s.iloc[0] - 1) | |
| def candidate_fit(cand: dict, holdings: list[dict], pm: dict, risk: dict, needs: dict, | |
| port_ret: pd.Series | None, cand_hist: pd.DataFrame | None) -> dict: | |
| """Score one live-verified candidate on the MEASURED improvement of adding it at a | |
| starter weight: correlation to the book, effective-N, sector balance, beta, worst | |
| crash replay, yield, valuation — each weighted by the diagnosed needs. Returns | |
| {score, fixes[], corr_to_book, w_add, deltas{}} — the evidence the CIO must cite.""" | |
| t = cand["ticker"] | |
| live = cand.get("live") | |
| if not isinstance(live, dict): # keep the reference: measured beta must land on the candidate | |
| live = {} | |
| cand["live"] = live | |
| n = len(holdings) | |
| w_add = min(0.08, 1.0 / (n + 1)) | |
| score, fixes = 0.0, [] | |
| deltas: dict = {} | |
| # standalone merit (never the main driver) | |
| rp = cand.get("rank_pct") | |
| if isinstance(rp, (int, float)): | |
| score += rp / 100 * 0.4 | |
| if any("smart-money" in s for s in cand.get("signals") or []): | |
| score += 0.2 | |
| up = cand.get("upside") | |
| if isinstance(up, (int, float)): | |
| score += float(np.clip(up, -0.5, 0.5)) * 0.4 | |
| # correlation to the book — the core of "based on the existing portfolio" | |
| corr = None | |
| if port_ret is not None and cand_hist is not None and t in cand_hist.columns: | |
| cr = pd.Series(cand_hist[t]).pct_change(fill_method=None).dropna() | |
| joined = pd.DataFrame({"p": port_ret, "c": cr}).dropna() | |
| p_arr, c_arr = joined["p"].to_numpy(dtype=float), joined["c"].to_numpy(dtype=float) | |
| if len(joined) > 60 and c_arr.std() > 0 and p_arr.std() > 0: | |
| corr = float(np.corrcoef(p_arr, c_arr)[0, 1]) | |
| if corr is not None: | |
| w_div = max(0.3, float(needs.get("diversifier", {}).get("weight", 0.0))) | |
| score += w_div * (1.0 - corr) | |
| if corr < 0.5: | |
| fixes.append(f"low correlation to your holdings ({corr:.2f})") | |
| # sector balance | |
| sec = str(cand.get("sector") or "?") | |
| gap = needs.get("sector_gaps") | |
| if gap and sec in gap["sectors"]: | |
| score += gap["weight"] | |
| fixes.append(f"adds {sec} (currently ~0% of the book)") | |
| ow = needs.get("sector_overweight") | |
| if ow and sec == ow["sector"]: | |
| score -= 0.8 * ow["weight"] | |
| # effective positions | |
| effn = float(pm.get("effective_n") or 0) | |
| if effn: | |
| w_old = [float(h["weight"]) * (1 - w_add) for h in holdings] + [w_add] | |
| new_effn = 1.0 / sum(x * x for x in w_old) | |
| deltas["effective_n"] = [round(effn, 1), round(new_effn, 1)] | |
| if new_effn - effn > 0.3 and "diversifier" in needs: | |
| score += needs["diversifier"]["weight"] * min(1.0, (new_effn - effn) / 2) | |
| fixes.append(f"lifts effective positions {effn:.1f}→{new_effn:.1f}") | |
| # defensiveness: beta and worst crash replay | |
| d_need = needs.get("defensive") | |
| beta_book = pm.get("realized_beta") or pm.get("weighted_beta") | |
| beta_c = live.get("beta") | |
| if not isinstance(beta_c, (int, float)) and cand_hist is not None and t in cand_hist.columns \ | |
| and "SPY" in cand_hist.columns: # ETFs/bonds carry no beta in the feed — measure it | |
| j = cand_hist[[t, "SPY"]].pct_change(fill_method=None).dropna().iloc[-252:] | |
| if len(j) > 60 and float(j["SPY"].var()) > 0: | |
| beta_c = float(np.cov(j[t].to_numpy(dtype=float), j["SPY"].to_numpy(dtype=float))[0, 1] / j["SPY"].var()) | |
| live["beta"] = round(beta_c, 2) | |
| if cand.get("instrument") and cand.get("need_target") in needs: | |
| # an instrument exists to fix ONE diagnosed need; give it the credit a stock earns | |
| # from quant rank + sector gap, so bonds/ETFs compete on level terms | |
| nt = cand["need_target"] | |
| score += needs[nt]["weight"] * 0.9 | |
| fixes.append({"defensive": "asset-class ballast for the defensive need", | |
| "income": "asset-class income fix", "diversifier": "broad diversified exposure", | |
| "sector_gaps": f"diversified {sec} exposure without single-name risk"}.get(nt, "asset-class fix")) | |
| if isinstance(beta_book, (int, float)) and isinstance(beta_c, (int, float)): | |
| new_beta = beta_book * (1 - w_add) + beta_c * w_add | |
| deltas["beta"] = [round(beta_book, 2), round(new_beta, 2)] | |
| if d_need: | |
| score += d_need["weight"] * max(0.0, (beta_book - beta_c) / max(beta_book, 0.5)) | |
| if beta_c < beta_book - 0.25: | |
| fixes.append(f"lowers portfolio beta {beta_book:.2f}→{new_beta:.2f}") | |
| elif beta_c > beta_book + 0.4: # a diversifier that RAISES risk is off-profile | |
| score -= d_need["weight"] * min(1.2, (beta_c - beta_book) / 0.8) | |
| stress = (risk or {}).get("stress") or [] | |
| if stress and cand_hist is not None and t in cand_hist.columns: | |
| worst = min(stress, key=lambda s: s["portfolio"]) | |
| a, b = worst["window"].split(" → ") | |
| cr_w = _window_return(pd.Series(cand_hist[t]), a, b) | |
| if cr_w is not None: | |
| new_loss = worst["portfolio"] * (1 - w_add) + cr_w * w_add | |
| deltas["worst_stress"] = [round(worst["portfolio"], 3), round(new_loss, 3), worst["name"]] | |
| gain = new_loss - worst["portfolio"] | |
| if d_need and gain > 0.004: | |
| score += d_need["weight"] * min(1.0, gain * 15) | |
| fixes.append(f"cuts the {worst['name']} replay {worst['portfolio'] * 100:.0f}%→{new_loss * 100:.0f}%") | |
| # income | |
| dy = live.get("div_yield") | |
| if needs.get("income"): | |
| if isinstance(dy, (int, float)) and dy >= 1.5: | |
| score += needs["income"]["weight"] * min(1.0, dy / 5.0) | |
| if dy >= 2.5: | |
| fixes.append(f"pays {dy:.1f}% (income goal)") | |
| else: # an income investor should not be handed a non-payer, however diversifying | |
| score -= needs["income"]["weight"] * 1.0 | |
| # valuation | |
| if needs.get("value") and isinstance(up, (int, float)) and up > 0.05: | |
| score += needs["value"]["weight"] * min(1.0, up / 0.5) | |
| fixes.append(f"trades {up * 100:.0f}% below DCF value") | |
| # cautions — the guards a quant rank alone never applies (the EXPD lesson: a 94th- | |
| # percentile freight forwarder at peak-cycle earnings, 18% above its 200-day, with | |
| # informed money NOT accumulating, was offered as a switch destination) | |
| cautions = [] | |
| eps_g, sma, px = live.get("eps_growth"), live.get("sma200"), live.get("price") | |
| ext = (px / sma - 1) if isinstance(px, (int, float)) and isinstance(sma, (int, float)) and sma else None | |
| if (sec in CYCLICAL_SECTORS and isinstance(eps_g, (int, float)) and eps_g > 0.40 | |
| and ext is not None and ext > 0.12): | |
| cautions.append(f"peak-cycle risk: EPS +{eps_g * 100:.0f}% in a cyclical business with the " | |
| f"price {ext * 100:+.0f}% above its 200-day") | |
| score -= 0.6 | |
| elif ext is not None and ext > 0.25: | |
| cautions.append(f"extended: {ext * 100:+.0f}% above its 200-day average") | |
| score -= 0.3 | |
| if cand.get("smart_money_neg"): | |
| cautions.append("informed money is not accumulating (13F/insider score negative)") | |
| score -= 0.35 | |
| if cand.get("flags"): | |
| score -= 1.0 | |
| return {"score": round(score, 3), "corr_to_book": round(corr, 2) if corr is not None else None, | |
| "w_add": round(w_add, 3), "fixes": fixes[:4], "cautions": cautions, "deltas": deltas} | |
| CYCLICAL_SECTORS = {"Industrials", "Energy", "Basic Materials", "Consumer Cyclical"} | |
| # sector labels hide the deep cyclicals inside Technology/Healthcare (memory chips, autos…) | |
| _CYCLICAL_INDUSTRY = ("semiconductor", "memory", "auto", "steel", "chemical", "oil", "gas", "mining", "metal", | |
| "homebuild", "airline", "shipping", "freight", "truck", "machinery", "construction", | |
| "lumber", "paper", "copper", "aluminum", "coal", "drilling", "equipment") | |
| def is_cyclical(sector, industry) -> bool: | |
| sec, ind = str(sector or "").title(), str(industry or "").lower() | |
| return sec in CYCLICAL_SECTORS or any(k in ind for k in _CYCLICAL_INDUSTRY) | |
| # ---------------- practitioner layer: asymmetry, sizing, benchmark-relative risk ---------------- | |
| def asymmetry(dcf: dict | None, conviction: float | None = None) -> dict | None: | |
| """Klarman/Marks payoff test from the DCF range: bear/base/bull outcomes with probabilities | |
| tilted by committee conviction → expected 12-18m return and upside:downside ratio. | |
| A 'Buy More' that cannot show ≥ 2:1 up/down and a positive expected return is not one.""" | |
| if not dcf or dcf.get("note") or not isinstance(dcf.get("intrinsic"), (int, float)): | |
| return None | |
| up = dcf.get("upside") | |
| lo, hi = dcf.get("intrinsic_low"), dcf.get("intrinsic_high") | |
| price_ratio_base = 1 + float(up) if isinstance(up, (int, float)) else None | |
| if price_ratio_base is None or not isinstance(lo, (int, float)) or not isinstance(hi, (int, float)): | |
| return None | |
| iv = float(dcf["intrinsic"]) | |
| if iv <= 0: | |
| return None | |
| price = iv / price_ratio_base | |
| bear, base, bull = lo / price - 1, up, hi / price - 1 | |
| c = float(conviction) if isinstance(conviction, (int, float)) else 50.0 | |
| tilt = max(-0.15, min(0.15, (c - 50) / 100)) # 65 → +0.15 to bull, 35 → +0.15 to bear | |
| p_bull, p_bear = 0.25 + tilt, 0.25 - tilt | |
| p_base = 1 - p_bull - p_bear | |
| exp = p_bear * bear + p_base * base + p_bull * bull | |
| upside = max(bull, 0.0) | |
| downside = abs(min(bear, 0.0)) | |
| ratio = (upside / downside) if downside > 0.01 else (9.9 if upside > 0 else 0.0) | |
| return {"bear": round(bear, 3), "base": round(float(base), 3), "bull": round(bull, 3), | |
| "p": {"bear": round(p_bear, 2), "base": round(p_base, 2), "bull": round(p_bull, 2)}, | |
| "expected_return": round(exp, 3), "up_down_ratio": round(min(ratio, 9.9), 2), | |
| "clears_hurdle": bool(exp >= 0.08 and ratio >= 2.0)} | |
| def kelly_size(expected_return: float | None, vol: float | None, cap: float) -> dict | None: | |
| """Half-Kelly guardrail: f* = μ/σ² on the 12m view, halved, capped at the name cap. | |
| Not a target — the ceiling conviction alone can justify (Buffett concentration, Thorp maths).""" | |
| if not isinstance(expected_return, (int, float)) or not isinstance(vol, (int, float)) or vol <= 0: | |
| return None | |
| f = expected_return / (vol ** 2) | |
| half = max(0.0, min(cap, 0.5 * f)) | |
| return {"full_kelly": round(max(0.0, f), 3), "half_kelly": round(half, 3), "cap": cap} | |
| def factor_betas(port_ret: pd.Series | None, hist: pd.DataFrame, bench: str = "SPY") -> dict: | |
| """Measured style exposures: multivariate OLS of the book's daily excess returns on | |
| long-only factor ETF excess returns (momentum MTUM, quality QUAL, value VLUE, | |
| min-vol USMV, size IWM), each vs the market. Complements the panel-score tilts.""" | |
| if port_ret is None or hist is None or hist.empty or bench not in hist.columns: | |
| return {} | |
| facs = [f for f in ("MTUM", "QUAL", "VLUE", "USMV", "IWM") if f in hist.columns] | |
| if len(facs) < 3: | |
| return {} | |
| rets = hist[[bench] + facs].pct_change() | |
| df = pd.concat([port_ret.rename("p"), rets], axis=1).dropna() | |
| if len(df) < 120: | |
| return {} | |
| y = (df["p"] - df[bench]).values | |
| X = np.column_stack([np.ones(len(df))] + [(df[f] - df[bench]).values for f in facs]) | |
| try: | |
| beta, *_ = np.linalg.lstsq(X, y, rcond=None) | |
| except Exception: | |
| return {} | |
| mkt_beta = float(np.polyfit(df[bench].values, df["p"].values, 1)[0]) | |
| names = {"MTUM": "momentum", "QUAL": "quality", "VLUE": "value", "USMV": "low_vol", "IWM": "small_size"} | |
| return {"market_beta": round(mkt_beta, 2), "n_days": int(len(df)), | |
| "betas": {names[f]: round(float(b), 2) for f, b in zip(facs, beta[1:])}} | |
| def benchmark_relative(port_ret: pd.Series | None, hist: pd.DataFrame, bench: str = "SPY") -> dict: | |
| """Upside/downside capture, Sortino, Calmar and the annualized active return vs the benchmark.""" | |
| if port_ret is None or hist is None or bench not in hist.columns: | |
| return {} | |
| b = hist[bench].pct_change() | |
| df = pd.concat([port_ret.rename("p"), b.rename("b")], axis=1).dropna() | |
| if len(df) < 120: | |
| return {} | |
| up, dn = df[df["b"] > 0], df[df["b"] < 0] | |
| out = {} | |
| if len(up) > 20 and up["b"].mean() > 0: | |
| out["upside_capture"] = round(float(up["p"].mean() / up["b"].mean()), 2) | |
| if len(dn) > 20 and dn["b"].mean() < 0: | |
| out["downside_capture"] = round(float(dn["p"].mean() / dn["b"].mean()), 2) | |
| ann = float(df["p"].mean() * 252) | |
| downside = float(df["p"][df["p"] < 0].std() * np.sqrt(252)) if (df["p"] < 0).any() else None | |
| if downside: | |
| out["sortino"] = round((ann - RF) / downside, 2) | |
| curve = (1 + df["p"]).cumprod() | |
| mdd = float((curve / curve.cummax() - 1).min()) | |
| if mdd < 0: | |
| out["calmar"] = round(ann / abs(mdd), 2) | |
| out["active_return_ann"] = round(ann - float(df["b"].mean() * 252), 4) | |
| return out | |
| # S&P 500 sector mix (approximate, refreshed by the live ETF profile when available) — the | |
| # health score's "active bet" charge must not appear and vanish with a rate-limited fetch | |
| SPY_SECTORS_FALLBACK = {"Technology": 0.33, "Financial Services": 0.13, "Consumer Cyclical": 0.105, | |
| "Communication Services": 0.095, "Healthcare": 0.095, "Industrials": 0.085, | |
| "Consumer Defensive": 0.055, "Energy": 0.03, "Utilities": 0.025, | |
| "Real Estate": 0.02, "Basic Materials": 0.02} | |
| def active_sector_weights(pm_sectors: dict, bench_sectors: dict) -> list[dict]: | |
| """Book vs benchmark sector weights — the active bets, largest first.""" | |
| if not bench_sectors: | |
| bench_sectors = SPY_SECTORS_FALLBACK | |
| if not pm_sectors: | |
| return [] | |
| tot = sum(float(v) for v in bench_sectors.values()) or 1.0 | |
| bench = {str(k).title(): float(v) / tot for k, v in bench_sectors.items()} | |
| rows = [] | |
| for s in set(pm_sectors) | set(bench): | |
| if s in ("Diversified Fund", "Diversified fund", "Unknown", "?"): | |
| continue | |
| w, bw = float(pm_sectors.get(s, 0.0)), float(bench.get(s, 0.0)) | |
| rows.append({"sector": s, "book": round(w, 4), "benchmark": round(bw, 4), "active": round(w - bw, 4)}) | |
| rows.sort(key=lambda r: -abs(r["active"])) | |
| return rows[:8] | |