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 +5 -0
- src/metrics.py +21 -1
- src/orchestrator.py +50 -2
- src/pdf_report.py +4 -1
- src/personas.py +5 -1
- src/report.py +4 -1
- src/revival.py +16 -1
- static/app.js +6 -2
- static/style.css +2 -0
- tests/test_recommend.py +24 -0
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", "
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"]}
|