s-ttp commited on
Commit
39941ec
ยท
verified ยท
1 Parent(s): 34158ed

candidate guards (peak-cycle, extended, informed money leaving) + analyst committee screen on prospective additions; switches/target need a clean committee verdict

Browse files
README.md CHANGED
@@ -60,6 +60,11 @@ entry price, quantity optional), and get:
60
  holdings, effective-N, beta, worst crash replay, yield, DCF gap โ€” and only fit-eligible
61
  names reach the CIO and the switch engine. Each pick states the problem it fixes with the
62
  number ("moves opposite your holdings (correlation โˆ’0.40) ยท cuts the 2022 replay โˆ’53%โ†’โˆ’49%").
 
 
 
 
 
63
  - **Recommendation completeness** โ€” every Trim/Sell carries its tax cost (gain and, with an
64
  optional purchase date, short- vs long-term treatment); orders carry estimated
65
  transaction cost and liquidity notes; investor context now includes loss tolerance and
 
60
  holdings, effective-N, beta, worst crash replay, yield, DCF gap โ€” and only fit-eligible
61
  names reach the CIO and the switch engine. Each pick states the problem it fixes with the
62
  number ("moves opposite your holdings (correlation โˆ’0.40) ยท cuts the 2022 replay โˆ’53%โ†’โˆ’49%").
63
+ Deterministic guards a quant rank never applies (peak-cycle earnings in a cyclical business,
64
+ an extended price, informed money leaving) surface as cautions and cost fit; then the same
65
+ five analyst lenses **screen every shortlisted candidate as a prospective addition** โ€” a
66
+ name the committee would not add (below 52/100) is never recommended or used as a switch
67
+ destination.
68
  - **Recommendation completeness** โ€” every Trim/Sell carries its tax cost (gain and, with an
69
  optional purchase date, short- vs long-term treatment); orders carry estimated
70
  transaction cost and liquidity notes; investor context now includes loss tolerance and
src/metrics.py CHANGED
@@ -779,7 +779,27 @@ def candidate_fit(cand: dict, holdings: list[dict], pm: dict, risk: dict, needs:
779
  if needs.get("value") and isinstance(up, (int, float)) and up > 0.05:
780
  score += needs["value"]["weight"] * min(1.0, up / 0.5)
781
  fixes.append(f"trades {up * 100:.0f}% below DCF value")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
  if cand.get("flags"):
783
  score -= 1.0
784
  return {"score": round(score, 3), "corr_to_book": round(corr, 2) if corr is not None else None,
785
- "w_add": round(w_add, 3), "fixes": fixes[:4], "deltas": deltas}
 
 
 
 
779
  if needs.get("value") and isinstance(up, (int, float)) and up > 0.05:
780
  score += needs["value"]["weight"] * min(1.0, up / 0.5)
781
  fixes.append(f"trades {up * 100:.0f}% below DCF value")
782
+ # cautions โ€” the guards a quant rank alone never applies (the EXPD lesson: a 94th-
783
+ # percentile freight forwarder at peak-cycle earnings, 18% above its 200-day, with
784
+ # informed money NOT accumulating, was offered as a switch destination)
785
+ cautions = []
786
+ eps_g, sma, px = live.get("eps_growth"), live.get("sma200"), live.get("price")
787
+ ext = (px / sma - 1) if isinstance(px, (int, float)) and isinstance(sma, (int, float)) and sma else None
788
+ if (sec in CYCLICAL_SECTORS and isinstance(eps_g, (int, float)) and eps_g > 0.40
789
+ and ext is not None and ext > 0.12):
790
+ cautions.append(f"peak-cycle risk: EPS +{eps_g * 100:.0f}% in a cyclical business with the "
791
+ f"price {ext * 100:+.0f}% above its 200-day")
792
+ score -= 0.6
793
+ elif ext is not None and ext > 0.25:
794
+ cautions.append(f"extended: {ext * 100:+.0f}% above its 200-day average")
795
+ score -= 0.3
796
+ if cand.get("smart_money_neg"):
797
+ cautions.append("informed money is not accumulating (13F/insider score negative)")
798
+ score -= 0.35
799
  if cand.get("flags"):
800
  score -= 1.0
801
  return {"score": round(score, 3), "corr_to_book": round(corr, 2) if corr is not None else None,
802
+ "w_add": round(w_add, 3), "fixes": fixes[:4], "cautions": cautions, "deltas": deltas}
803
+
804
+
805
+ CYCLICAL_SECTORS = {"Industrials", "Energy", "Basic Materials", "Consumer Cyclical"}
src/orchestrator.py CHANGED
@@ -63,6 +63,9 @@ def _alt_candidates(holdings: list[dict], gaps: list[str]) -> list[dict]:
63
  # must never mint the "both signals agree" boost (the EXPD lesson: the pool's WORST
64
  # score once got boosted and tagged as smart-money corroboration).
65
  accum = {t: v for t, v in slip.items() if t not in held and v["score"] > 0}
 
 
 
66
  ranked = sorted(v["score"] for v in accum.values())
67
  for t, v in accum.items():
68
  pct = ranked.index(v["score"]) / max(len(ranked) - 1, 1) # percentile among accumulators
@@ -95,7 +98,8 @@ def _enrich_candidates(cands: list[dict], k: int = 8) -> None:
95
  try:
96
  d = fetch_holding(c["ticker"])
97
  c["live"] = {kk: d.get(kk) for kk in
98
- ("name", "price", "sma200", "beta", "pe", "rev_growth", "div_yield")}
 
99
  c["flags"] = M.trap_flags(d) # same guards holdings get (value trap, inorganic EPSโ€ฆ)
100
  price, sma = d.get("price"), d.get("sma200")
101
  c["trend_ok"] = bool(price and (not sma or price >= sma))
@@ -235,6 +239,25 @@ def _risk_cards(risk: dict, holdings: list[dict], context: dict | None) -> list[
235
  return cards
236
 
237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  _LOCAL_SYSTEM = (
239
  "You suggest LOCAL-market stock ideas for an investor whose portfolio is listed on a "
240
  "non-US exchange. Given their holdings, exchange suffix and the sectors they lack, return "
@@ -450,6 +473,29 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
450
  eligible = [c for c in scored if c["fit"]["score"] >= max(0.5 * top_fit, 0.0)]
451
  if len(eligible) < 3:
452
  eligible = scored[:3]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  if has_llm():
454
  emit(84, "The Chief Investment Officer drafts the verdictโ€ฆ", chip="cio")
455
  cio = P.cio_synthesis({
@@ -480,7 +526,7 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
480
  # top up to >=3 new names straight from the ranked pool: without fresh names the
481
  # cap overflow from a concentrated book would inflate tiny existing positions
482
  have = {c["ticker"] for c in chosen}
483
- for c in eligible:
484
  if len(chosen) >= 3:
485
  break
486
  if c["ticker"] not in have:
@@ -533,9 +579,11 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None) -> dict:
533
  sig_meta = {c["ticker"]: c.get("signals", []) for c in candidates}
534
  mostly_foreign = sum(1 for h in holdings if "." in h["symbol"]) > len(holdings) / 2
535
  fit_meta = {c["ticker"]: c.get("fit") for c in candidates}
 
536
  for a in result["alternatives"]:
537
  a["signals"] = list(sig_meta.get(a["ticker"], []))
538
  a["fit"] = fit_meta.get(a["ticker"])
 
539
  if mostly_foreign and "." not in a["ticker"]:
540
  a["signals"].append("US-listed โ€” needs US market access")
541
  result["portfolio_needs"] = needs
 
63
  # must never mint the "both signals agree" boost (the EXPD lesson: the pool's WORST
64
  # score once got boosted and tagged as smart-money corroboration).
65
  accum = {t: v for t, v in slip.items() if t not in held and v["score"] > 0}
66
+ for t, v in slip.items(): # a NEGATIVE score is evidence too โ€” informed money leaving
67
+ if t in merged and v["score"] < 0:
68
+ merged[t]["smart_money_neg"] = True
69
  ranked = sorted(v["score"] for v in accum.values())
70
  for t, v in accum.items():
71
  pct = ranked.index(v["score"]) / max(len(ranked) - 1, 1) # percentile among accumulators
 
98
  try:
99
  d = fetch_holding(c["ticker"])
100
  c["live"] = {kk: d.get(kk) for kk in
101
+ ("name", "price", "sma200", "beta", "pe", "rev_growth", "eps_growth",
102
+ "div_yield", "hi52")}
103
  c["flags"] = M.trap_flags(d) # same guards holdings get (value trap, inorganic EPSโ€ฆ)
104
  price, sma = d.get("price"), d.get("sma200")
105
  c["trend_ok"] = bool(price and (not sma or price >= sma))
 
239
  return cards
240
 
241
 
242
+ def _candidate_brief(c: dict) -> str:
243
+ """The same grounded fact sheet a holding gets, framed as a prospective addition, plus
244
+ why the fit engine shortlisted it and any deterministic cautions."""
245
+ try:
246
+ d = fetch_holding(c["ticker"])
247
+ except Exception:
248
+ return f"{c['ticker']}: no live data"
249
+ pseudo = {"entry": d["price"], "pl_pct": 0.0, "weight": None}
250
+ up = c.get("upside")
251
+ dcf = ({"intrinsic": round(d["price"] * (1 + up), 2), "upside": up, "upside_vs_entry": up,
252
+ "lifecycle": "mechanical", "model": "mechanical DCF", "flags": c.get("flags") or []}
253
+ if isinstance(up, (int, float)) else None)
254
+ fit = c.get("fit") or {}
255
+ tail = "Why shortlisted: " + ("; ".join(fit.get("fixes") or []) or "quant rank")
256
+ if fit.get("cautions"):
257
+ tail += " | CAUTIONS: " + "; ".join(fit["cautions"])
258
+ return "PROSPECTIVE ADDITION (not held).\n" + fact_sheet(d, pseudo, fetch_news(c["ticker"]), dcf) + "\n" + tail
259
+
260
+
261
  _LOCAL_SYSTEM = (
262
  "You suggest LOCAL-market stock ideas for an investor whose portfolio is listed on a "
263
  "non-US exchange. Given their holdings, exchange suffix and the sectors they lack, return "
 
473
  eligible = [c for c in scored if c["fit"]["score"] >= max(0.5 * top_fit, 0.0)]
474
  if len(eligible) < 3:
475
  eligible = scored[:3]
476
+ # the analyst committee screens the shortlist as PROSPECTIVE ADDITIONS โ€” the same five
477
+ # lenses that judge holdings; a name they would not add is never recommended
478
+ if has_llm() and eligible:
479
+ emit(82, "Analyst committee screening the candidate ideasโ€ฆ")
480
+ short = eligible[:8]
481
+ cbriefs = {c["ticker"]: _candidate_brief(c) for c in short}
482
+ cctx = ("These are PROSPECTIVE ADDITIONS to the portfolio below โ€” NOT current holdings. "
483
+ "Score each purely as a candidate to ADD now: 50 = no case to add, >=62 = worth "
484
+ "adding, <45 = do not add. Be as demanding as you would be with your own money. "
485
+ f"Portfolio: {n} holdings; sector mix " + ", ".join(
486
+ f"{s} {w * 100:.0f}%" for s, w in list(pm["sector_allocation"].items())[:5])
487
+ + f"; what it lacks: " + "; ".join(v["label"] for v in needs.values() if v.get("label")))
488
+ cpanel = P.run_panel(cbriefs, cctx, samples=1)
489
+ for c in short:
490
+ sc = {k: v[c["ticker"]] for k, v in cpanel.items() if c["ticker"] in v}
491
+ if sc:
492
+ convs = [x["conviction"] for x in sc.values()]
493
+ c["committee"] = {"conviction": int(round(float(np.median(convs)))), "n": len(convs),
494
+ "scores": {k: round(x["conviction"]) for k, x in sc.items()},
495
+ "risks": [x["key_risk"] for x in sc.values() if x.get("key_risk")][:2],
496
+ "points": [x["key_point"] for x in sc.values() if x.get("key_point")][:2]}
497
+ eligible = [c for c in eligible
498
+ if not c.get("committee") or c["committee"]["conviction"] >= 52]
499
  if has_llm():
500
  emit(84, "The Chief Investment Officer drafts the verdictโ€ฆ", chip="cio")
501
  cio = P.cio_synthesis({
 
526
  # top up to >=3 new names straight from the ranked pool: without fresh names the
527
  # cap overflow from a concentrated book would inflate tiny existing positions
528
  have = {c["ticker"] for c in chosen}
529
+ for c in [x for x in eligible if REV.strong_ok(x)]: # target-book slots need a clean bill
530
  if len(chosen) >= 3:
531
  break
532
  if c["ticker"] not in have:
 
579
  sig_meta = {c["ticker"]: c.get("signals", []) for c in candidates}
580
  mostly_foreign = sum(1 for h in holdings if "." in h["symbol"]) > len(holdings) / 2
581
  fit_meta = {c["ticker"]: c.get("fit") for c in candidates}
582
+ com_meta = {c["ticker"]: c.get("committee") for c in candidates}
583
  for a in result["alternatives"]:
584
  a["signals"] = list(sig_meta.get(a["ticker"], []))
585
  a["fit"] = fit_meta.get(a["ticker"])
586
+ a["committee"] = com_meta.get(a["ticker"])
587
  if mostly_foreign and "." not in a["ticker"]:
588
  a["signals"].append("US-listed โ€” needs US market access")
589
  result["portfolio_needs"] = needs
src/pdf_report.py CHANGED
@@ -198,7 +198,10 @@ def build_pdf(res: dict) -> bytes:
198
  E.append(Spacer(1, 3))
199
  for a in alts:
200
  fit = a.get("fit") or {}
201
- sig = " ยท ".join((fit.get("fixes") or []) + (a.get("signals") or []))
 
 
 
202
  E.append(Paragraph(
203
  f"<b>{_txt(a.get('ticker'))}</b> โ€” {_txt(a.get('reason'))}"
204
  + (f" <font size='7' color='#{DIM.hexval()[2:]}'>[{_txt(sig)}]</font>" if sig else ""),
 
198
  E.append(Spacer(1, 3))
199
  for a in alts:
200
  fit = a.get("fit") or {}
201
+ com = a.get("committee") or {}
202
+ sig = " ยท ".join((fit.get("fixes") or []) + [f"CAUTION: {x}" for x in (fit.get("cautions") or [])]
203
+ + ([f"committee {com['conviction']}/100 on adding"] if com.get("conviction") else [])
204
+ + (a.get("signals") or []))
205
  E.append(Paragraph(
206
  f"<b>{_txt(a.get('ticker'))}</b> โ€” {_txt(a.get('reason'))}"
207
  + (f" <font size='7' color='#{DIM.hexval()[2:]}'>[{_txt(sig)}]</font>" if sig else ""),
src/personas.py CHANGED
@@ -151,7 +151,11 @@ _CIO_SYSTEM = (
151
  "ALREADY RANKED by measured fit to THIS portfolio: portfolio_needs states what the book "
152
  "lacks, and each candidate's 'fit' block gives the specific problems it fixes ('fixes') "
153
  "with measured deltas (correlation to the holdings, effective positions, beta, worst crash "
154
- "replay, yield, DCF gap). Prefer the top of the list; your one-sentence reason MUST name "
 
 
 
 
155
  "the portfolio problem the pick addresses and cite one measured number from its fit block "
156
  "โ€” never a generic 'strong company' pitch. Cite ONLY signals actually listed for that "
157
  "candidate. Weigh the DCF gap: a candidate far above its DCF value needs a non-valuation "
 
151
  "ALREADY RANKED by measured fit to THIS portfolio: portfolio_needs states what the book "
152
  "lacks, and each candidate's 'fit' block gives the specific problems it fixes ('fixes') "
153
  "with measured deltas (correlation to the holdings, effective positions, beta, worst crash "
154
+ "replay, yield, DCF gap), plus โ€” when present โ€” 'cautions' from deterministic guards "
155
+ "(peak-cycle earnings, extended price, informed money leaving) and a 'committee' block "
156
+ "(the five analysts' conviction that it is worth ADDING, with their top risks); a "
157
+ "candidate with a caution or a committee risk must have it acknowledged in your reason. "
158
+ "Prefer the top of the list; your one-sentence reason MUST name "
159
  "the portfolio problem the pick addresses and cite one measured number from its fit block "
160
  "โ€” never a generic 'strong company' pitch. Cite ONLY signals actually listed for that "
161
  "candidate. Weigh the DCF gap: a candidate far above its DCF value needs a non-valuation "
src/report.py CHANGED
@@ -188,8 +188,11 @@ def _markdown(res: dict, panel: dict) -> str:
188
  L.append("*What this portfolio needs:* " + "; ".join(v["label"] for v in needs.values() if v.get("label")))
189
  for a in res["alternatives"]:
190
  fit = a.get("fit") or {}
 
191
  L.append(f"- **{a['ticker']}** โ€” {a['reason']}"
192
- + (f" *(fixes: {'; '.join(fit['fixes'])})*" if fit.get("fixes") else ""))
 
 
193
  if panel:
194
  L += ["", "## Appendix โ€” analyst committee detail (per holding)"]
195
  for r in res["holdings"]:
 
188
  L.append("*What this portfolio needs:* " + "; ".join(v["label"] for v in needs.values() if v.get("label")))
189
  for a in res["alternatives"]:
190
  fit = a.get("fit") or {}
191
+ com = a.get("committee") or {}
192
  L.append(f"- **{a['ticker']}** โ€” {a['reason']}"
193
+ + (f" *(fixes: {'; '.join(fit['fixes'])})*" if fit.get("fixes") else "")
194
+ + (f" โš  {'; '.join(fit['cautions'])}" if fit.get("cautions") else "")
195
+ + (f" ยท committee {com['conviction']}/100 on adding" if com.get("conviction") else ""))
196
  if panel:
197
  L += ["", "## Appendix โ€” analyst committee detail (per holding)"]
198
  for r in res["holdings"]:
src/revival.py CHANGED
@@ -71,12 +71,24 @@ def triage(holdings: list[dict], decisions: dict) -> list[dict]:
71
 
72
  # ---------------- switch pairs (exit โ†’ live-verified destination) ----------------
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def switch_pairs(holdings: list[dict], decisions: dict, candidates: list[dict],
75
  needs: dict | None = None) -> list[dict]:
76
  """Pair each Sell/Trim with the best trend-intact candidate (candidates arrive ranked by
77
  measured fit to the book). Same sector first โ€” EXCEPT when that sector is the diagnosed
78
  overweight, where a like-for-like swap would defeat the point of the exit."""
79
- pool = [c for c in candidates if c.get("trend_ok") and not c.get("flags")]
80
  overweight = str(((needs or {}).get("sector_overweight") or {}).get("sector") or "")
81
  used, pairs = set(), []
82
  exits = sorted((h for h in holdings if decisions[h["symbol"]]["action"] in ("Sell", "Trim")),
@@ -107,6 +119,9 @@ def switch_pairs(holdings: list[dict], decisions: dict, candidates: list[dict],
107
  "in": cand["ticker"], "in_name": live.get("name") or cand["ticker"],
108
  "sector": cand.get("sector"), "same_sector": cand.get("sector") == sec,
109
  "cross_market": cross_market, "signals": signals,
 
 
 
110
  "compare": {"out": _snap(h["data"], h["data"].get("price")),
111
  "in": _snap(live, live.get("price"))},
112
  })
 
71
 
72
  # ---------------- switch pairs (exit โ†’ live-verified destination) ----------------
73
 
74
+ def strong_ok(c: dict) -> bool:
75
+ """A switch destination or a target-book slot is a STRONGER recommendation than
76
+ 'worth a look': it needs the committee clearly on side (>=58), or on side (>=52) with
77
+ no deterministic caution (peak-cycle, extended, informed money leaving). VLO once became
78
+ a switch destination at 52 with two cautions โ€” that is not a recommendation to stand on."""
79
+ com = c.get("committee") or {}
80
+ cautions = (c.get("fit") or {}).get("cautions") or []
81
+ if com.get("conviction") is not None:
82
+ return com["conviction"] >= 58 or (com["conviction"] >= 52 and not cautions)
83
+ return not cautions
84
+
85
+
86
  def switch_pairs(holdings: list[dict], decisions: dict, candidates: list[dict],
87
  needs: dict | None = None) -> list[dict]:
88
  """Pair each Sell/Trim with the best trend-intact candidate (candidates arrive ranked by
89
  measured fit to the book). Same sector first โ€” EXCEPT when that sector is the diagnosed
90
  overweight, where a like-for-like swap would defeat the point of the exit."""
91
+ pool = [c for c in candidates if c.get("trend_ok") and not c.get("flags") and strong_ok(c)]
92
  overweight = str(((needs or {}).get("sector_overweight") or {}).get("sector") or "")
93
  used, pairs = set(), []
94
  exits = sorted((h for h in holdings if decisions[h["symbol"]]["action"] in ("Sell", "Trim")),
 
119
  "in": cand["ticker"], "in_name": live.get("name") or cand["ticker"],
120
  "sector": cand.get("sector"), "same_sector": cand.get("sector") == sec,
121
  "cross_market": cross_market, "signals": signals,
122
+ "fixes": (cand.get("fit") or {}).get("fixes") or [],
123
+ "cautions": (cand.get("fit") or {}).get("cautions") or [],
124
+ "committee": cand.get("committee"),
125
  "compare": {"out": _snap(h["data"], h["data"].get("price")),
126
  "in": _snap(live, live.get("price"))},
127
  })
static/app.js CHANGED
@@ -377,9 +377,13 @@ function renderResults(R) {
377
  const tags = (a.signals || []).filter(s => s.includes("smart") || s.includes("quant") || s.includes("local") || s.includes("US-listed")).map(s =>
378
  `<span class="sig">${esc(s.includes("smart") ? "๐Ÿ‹ smart money" : s.includes("quant") ? "๐Ÿงฎ quant rank" : s.includes("local") ? "๐Ÿ“ local market" : "๐Ÿ‡บ๐Ÿ‡ธ US-listed")}</span>`).join(" ");
379
  const fit = a.fit || {};
380
- const fixes = (fit.fixes || []).map(f => `<span class="fix">โœ“ ${esc(f)}</span>`).join(" ");
 
 
 
 
381
  av.appendChild(el("div", "alt", `<b>${esc(a.ticker)}</b> ${tags}<p>${esc(a.reason)}</p>` +
382
- (fixes ? `<div class="fixes">${fixes}</div>` : "")));
383
  }
384
  // recommended target portfolio (only when the user gave context)
385
  const target = R.target_portfolio || [];
 
377
  const tags = (a.signals || []).filter(s => s.includes("smart") || s.includes("quant") || s.includes("local") || s.includes("US-listed")).map(s =>
378
  `<span class="sig">${esc(s.includes("smart") ? "๐Ÿ‹ smart money" : s.includes("quant") ? "๐Ÿงฎ quant rank" : s.includes("local") ? "๐Ÿ“ local market" : "๐Ÿ‡บ๐Ÿ‡ธ US-listed")}</span>`).join(" ");
379
  const fit = a.fit || {};
380
+ const fixes = (fit.fixes || []).map(f => `<span class="fix">โœ“ ${esc(f)}</span>`).join(" ") +
381
+ (fit.cautions || []).map(f => `<span class="caution">โš  ${esc(f)}</span>`).join(" ");
382
+ const com = a.committee;
383
+ const comLine = com ? `<div class="committee">Committee ${com.conviction}/100 on adding it` +
384
+ ((com.risks || []).length ? ` ยท top risk: ${esc(com.risks[0])}` : "") + `</div>` : "";
385
  av.appendChild(el("div", "alt", `<b>${esc(a.ticker)}</b> ${tags}<p>${esc(a.reason)}</p>` +
386
+ (fixes ? `<div class="fixes">${fixes}</div>` : "") + comLine));
387
  }
388
  // recommended target portfolio (only when the user gave context)
389
  const target = R.target_portfolio || [];
static/style.css CHANGED
@@ -166,6 +166,8 @@ button, .primary { font: inherit; cursor: pointer; border-radius: 10px; }
166
  .needs-line b { color: var(--text); }
167
  .fixes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
168
  .fix { font-size: .74rem; color: var(--good); background: #2fbf7118; border: 1px solid #2fbf7144; border-radius: 6px; padding: 2px 8px; }
 
 
169
  .risk-body h4 { font-size: .78rem; color: var(--accent2); text-transform: uppercase; letter-spacing: .07em; margin: 4px 0 8px; }
170
  .risk-line { color: var(--dim); font-size: .88rem; line-height: 1.5; margin: 8px 0; }
171
  .risk-line b { color: var(--text); }
 
166
  .needs-line b { color: var(--text); }
167
  .fixes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
168
  .fix { font-size: .74rem; color: var(--good); background: #2fbf7118; border: 1px solid #2fbf7144; border-radius: 6px; padding: 2px 8px; }
169
+ .caution { font-size: .74rem; color: var(--warn); background: #e8a83818; border: 1px solid #e8a83844; border-radius: 6px; padding: 2px 8px; }
170
+ .committee { color: var(--dim); font-size: .78rem; margin-top: 8px; }
171
  .risk-body h4 { font-size: .78rem; color: var(--accent2); text-transform: uppercase; letter-spacing: .07em; margin: 4px 0 8px; }
172
  .risk-line { color: var(--dim); font-size: .88rem; line-height: 1.5; margin: 8px 0; }
173
  .risk-line b { color: var(--text); }
tests/test_recommend.py CHANGED
@@ -82,6 +82,30 @@ def test_switch_pairs_avoid_overweight_sector():
82
  assert switch_pairs(hs, dec, cands, {"sector_overweight": {"sector": "Technology"}})[0]["in"] == "ED"
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def test_candidate_fit_flags_penalized():
86
  hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
87
  c = {"ticker": "X", "sector": "Energy", "rank_pct": 99, "signals": [], "live": {}, "flags": ["VALUE_TRAP"]}
 
82
  assert switch_pairs(hs, dec, cands, {"sector_overweight": {"sector": "Technology"}})[0]["in"] == "ED"
83
 
84
 
85
+ def test_peak_cycle_and_smart_money_guards():
86
+ hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
87
+ pm = _pm(sector_allocation={"Technology": 1.0}, effective_n=1.0)
88
+ needs = M.diagnose_needs(pm, {}, [], None)
89
+ clean = {"ticker": "X", "sector": "Industrials", "rank_pct": 94, "signals": [],
90
+ "live": {"eps_growth": 0.08, "sma200": 100.0, "price": 104.0, "beta": 1.0}, "upside": 0.0}
91
+ expd = {"ticker": "X", "sector": "Industrials", "rank_pct": 94, "signals": [], "smart_money_neg": True,
92
+ "live": {"eps_growth": 0.51, "sma200": 158.0, "price": 186.0, "beta": 1.0}, "upside": 0.0}
93
+ fc = M.candidate_fit(clean, hs, pm, {}, needs, None, None)
94
+ fe = M.candidate_fit(expd, hs, pm, {}, needs, None, None)
95
+ assert fe["score"] < fc["score"] - 0.9
96
+ assert any("peak-cycle" in c for c in fe["cautions"]) and any("informed money" in c for c in fe["cautions"])
97
+ assert fc["cautions"] == []
98
+
99
+
100
+ def test_strong_ok_requires_clean_committee_verdict():
101
+ from src.revival import strong_ok
102
+ assert strong_ok({"committee": {"conviction": 64}, "fit": {"cautions": ["peak-cycle"]}})
103
+ assert strong_ok({"committee": {"conviction": 52}, "fit": {"cautions": []}})
104
+ assert not strong_ok({"committee": {"conviction": 52}, "fit": {"cautions": ["peak-cycle"]}})
105
+ assert not strong_ok({"committee": {"conviction": 45}, "fit": {}})
106
+ assert strong_ok({"fit": {"cautions": []}}) and not strong_ok({"fit": {"cautions": ["extended"]}})
107
+
108
+
109
  def test_candidate_fit_flags_penalized():
110
  hs = [{"symbol": "AAA", "weight": 1.0, "data": {}}]
111
  c = {"ticker": "X", "sector": "Energy", "rank_pct": 99, "signals": [], "live": {}, "flags": ["VALUE_TRAP"]}