quality round 3: adaptive name cap, REIT/FFO + GBp fixes, context-fit candidates, lens retry, percent-safe narrative, yield units
Browse files- src/orchestrator.py +46 -6
src/orchestrator.py
CHANGED
|
@@ -89,7 +89,7 @@ def _enrich_candidates(cands: list[dict], k: int = 8) -> None:
|
|
| 89 |
try:
|
| 90 |
d = fetch_holding(c["ticker"])
|
| 91 |
c["live"] = {kk: d.get(kk) for kk in
|
| 92 |
-
("name", "price", "sma200", "beta", "pe", "rev_growth")}
|
| 93 |
c["flags"] = M.trap_flags(d) # same guards holdings get (value trap, inorganic EPS…)
|
| 94 |
price, sma = d.get("price"), d.get("sma200")
|
| 95 |
c["trend_ok"] = bool(price and (not sma or price >= sma))
|
|
@@ -108,6 +108,36 @@ def _enrich_candidates(cands: list[dict], k: int = 8) -> None:
|
|
| 108 |
list(ex.map(one, cands[:k]))
|
| 109 |
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
_RISK_CAPS = {"conservative": (0.10, 0.25), "balanced": (0.12, 0.30), "aggressive": (0.15, 0.40)}
|
| 112 |
|
| 113 |
|
|
@@ -188,6 +218,14 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
|
|
| 188 |
h["dcf"] = dict(h["dcf"], note="fund — n/a")
|
| 189 |
elif isinstance(eps, (int, float)) and eps == eps:
|
| 190 |
h["dcf"] = dict(h["dcf"], note="loss-making")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
continue
|
| 192 |
A, src = assumptions.get(t), "ai"
|
| 193 |
if not isinstance(A, dict):
|
|
@@ -213,11 +251,12 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
|
|
| 213 |
list(pm["sector_allocation"].items())[:6]) + ". "
|
| 214 |
f"Market regime: {'risk-on (S&P above 200-day)' if regime else 'risk-off (S&P below 200-day)' if regime is not None else 'unknown'}. "
|
| 215 |
f"Portfolio beta ~{pm.get('realized_beta') or pm.get('weighted_beta') or 'n/a'}.")
|
| 216 |
-
done_chips =
|
| 217 |
|
| 218 |
def chip_progress(k, ok):
|
| 219 |
-
done_chips.
|
| 220 |
-
|
|
|
|
| 221 |
chip=k if ok else None)
|
| 222 |
|
| 223 |
panel = P.run_panel(briefs, panel_ctx, progress=chip_progress)
|
|
@@ -238,7 +277,7 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
|
|
| 238 |
for h in holdings],
|
| 239 |
"metrics": {k: v for k, v in pm.items() if k != "equity_curve"},
|
| 240 |
"risk_scorecard": cards,
|
| 241 |
-
"healthy_bands": {"per_name_cap": M.
|
| 242 |
"sector_band": [M.SECTOR_CAP_LO, M.SECTOR_CAP_HI]},
|
| 243 |
"investor_context": context or "not provided — judge the portfolio on its own terms",
|
| 244 |
})
|
|
@@ -248,7 +287,8 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
|
|
| 248 |
gaps = (strat or {}).get("gaps") or []
|
| 249 |
candidates = _alt_candidates(holdings, gaps if isinstance(gaps, list) else [])
|
| 250 |
emit(80, "Verifying candidate ideas against live data…")
|
| 251 |
-
_enrich_candidates(candidates)
|
|
|
|
| 252 |
if has_llm():
|
| 253 |
emit(84, "The Chief Investment Officer drafts the verdict…", chip="cio")
|
| 254 |
cio = P.cio_synthesis({
|
|
|
|
| 89 |
try:
|
| 90 |
d = fetch_holding(c["ticker"])
|
| 91 |
c["live"] = {kk: d.get(kk) for kk in
|
| 92 |
+
("name", "price", "sma200", "beta", "pe", "rev_growth", "div_yield")}
|
| 93 |
c["flags"] = M.trap_flags(d) # same guards holdings get (value trap, inorganic EPS…)
|
| 94 |
price, sma = d.get("price"), d.get("sma200")
|
| 95 |
c["trend_ok"] = bool(price and (not sma or price >= sma))
|
|
|
|
| 108 |
list(ex.map(one, cands[:k]))
|
| 109 |
|
| 110 |
|
| 111 |
+
def _fit_context(cands: list[dict], context: dict | None) -> list[dict]:
|
| 112 |
+
"""Stable re-order: live-verified candidates that FIT the investor's stated needs go
|
| 113 |
+
first (income → real dividend payers; conservative → low beta). Nothing is removed,
|
| 114 |
+
but the switch pairs and the CIO now see profile-appropriate names before the rest —
|
| 115 |
+
an income investor was being told to rotate REITs into zero-yield growth stocks."""
|
| 116 |
+
if not context:
|
| 117 |
+
return cands
|
| 118 |
+
want_income = "income" in str(context.get("objective") or "").lower()
|
| 119 |
+
conservative = str(context.get("risk") or "").lower() == "conservative"
|
| 120 |
+
if not (want_income or conservative):
|
| 121 |
+
return cands
|
| 122 |
+
|
| 123 |
+
def fits(c: dict) -> bool:
|
| 124 |
+
live = c.get("live") or {}
|
| 125 |
+
ok = True
|
| 126 |
+
if want_income:
|
| 127 |
+
dy = live.get("div_yield") # always percent (normalized at fetch)
|
| 128 |
+
ok = ok and isinstance(dy, (int, float)) and dy >= 2.5
|
| 129 |
+
if conservative:
|
| 130 |
+
b = live.get("beta")
|
| 131 |
+
ok = ok and isinstance(b, (int, float)) and b <= 1.1
|
| 132 |
+
return ok
|
| 133 |
+
|
| 134 |
+
fit = [c for c in cands if c.get("live") and fits(c)]
|
| 135 |
+
rest = [c for c in cands if c not in fit]
|
| 136 |
+
for c in fit:
|
| 137 |
+
c["signals"] = c.get("signals", []) + ["fits your stated profile"]
|
| 138 |
+
return fit + rest
|
| 139 |
+
|
| 140 |
+
|
| 141 |
_RISK_CAPS = {"conservative": (0.10, 0.25), "balanced": (0.12, 0.30), "aggressive": (0.15, 0.40)}
|
| 142 |
|
| 143 |
|
|
|
|
| 218 |
h["dcf"] = dict(h["dcf"], note="fund — n/a")
|
| 219 |
elif isinstance(eps, (int, float)) and eps == eps:
|
| 220 |
h["dcf"] = dict(h["dcf"], note="loss-making")
|
| 221 |
+
else:
|
| 222 |
+
h["dcf"] = dict(h["dcf"], note="no earnings data")
|
| 223 |
+
continue
|
| 224 |
+
if "REIT" in str(d.get("industry") or "").upper():
|
| 225 |
+
# REIT EPS is crushed by depreciation on appreciating property; the industry
|
| 226 |
+
# values on FFO (crucible valuation_eps) — an EPS-DCF would scream "75%
|
| 227 |
+
# overvalued" at every healthy REIT, so it is not run at all
|
| 228 |
+
h["dcf"] = dict(h["dcf"], note="REIT — valued on FFO, not EPS")
|
| 229 |
continue
|
| 230 |
A, src = assumptions.get(t), "ai"
|
| 231 |
if not isinstance(A, dict):
|
|
|
|
| 251 |
list(pm["sector_allocation"].items())[:6]) + ". "
|
| 252 |
f"Market regime: {'risk-on (S&P above 200-day)' if regime else 'risk-off (S&P below 200-day)' if regime is not None else 'unknown'}. "
|
| 253 |
f"Portfolio beta ~{pm.get('realized_beta') or pm.get('weighted_beta') or 'n/a'}.")
|
| 254 |
+
done_chips: set = set()
|
| 255 |
|
| 256 |
def chip_progress(k, ok):
|
| 257 |
+
done_chips.add(k) # a retried lens reports twice — count it once
|
| 258 |
+
n_done = min(len(done_chips), 5)
|
| 259 |
+
emit(45 + 5 * n_done, f"Examination: {n_done}/5 analysts have ruled…",
|
| 260 |
chip=k if ok else None)
|
| 261 |
|
| 262 |
panel = P.run_panel(briefs, panel_ctx, progress=chip_progress)
|
|
|
|
| 277 |
for h in holdings],
|
| 278 |
"metrics": {k: v for k, v in pm.items() if k != "equity_curve"},
|
| 279 |
"risk_scorecard": cards,
|
| 280 |
+
"healthy_bands": {"per_name_cap": M.effective_name_cap(len(holdings)),
|
| 281 |
"sector_band": [M.SECTOR_CAP_LO, M.SECTOR_CAP_HI]},
|
| 282 |
"investor_context": context or "not provided — judge the portfolio on its own terms",
|
| 283 |
})
|
|
|
|
| 287 |
gaps = (strat or {}).get("gaps") or []
|
| 288 |
candidates = _alt_candidates(holdings, gaps if isinstance(gaps, list) else [])
|
| 289 |
emit(80, "Verifying candidate ideas against live data…")
|
| 290 |
+
_enrich_candidates(candidates, k=12 if context else 8)
|
| 291 |
+
candidates = _fit_context(candidates, context)
|
| 292 |
if has_llm():
|
| 293 |
emit(84, "The Chief Investment Officer drafts the verdict…", chip="cio")
|
| 294 |
cio = P.cio_synthesis({
|