Two-lens dissent restricted to cyclicals (sector or industry): secular winners are not trimmed early
Browse files- src/metrics.py +9 -0
- src/orchestrator.py +3 -1
- src/personas.py +11 -5
src/metrics.py
CHANGED
|
@@ -1053,6 +1053,15 @@ def candidate_fit(cand: dict, holdings: list[dict], pm: dict, risk: dict, needs:
|
|
| 1053 |
|
| 1054 |
|
| 1055 |
CYCLICAL_SECTORS = {"Industrials", "Energy", "Basic Materials", "Consumer Cyclical"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
|
| 1057 |
|
| 1058 |
# ---------------- practitioner layer: asymmetry, sizing, benchmark-relative risk ----------------
|
|
|
|
| 1053 |
|
| 1054 |
|
| 1055 |
CYCLICAL_SECTORS = {"Industrials", "Energy", "Basic Materials", "Consumer Cyclical"}
|
| 1056 |
+
# sector labels hide the deep cyclicals inside Technology/Healthcare (memory chips, autos…)
|
| 1057 |
+
_CYCLICAL_INDUSTRY = ("semiconductor", "memory", "auto", "steel", "chemical", "oil", "gas", "mining", "metal",
|
| 1058 |
+
"homebuild", "airline", "shipping", "freight", "truck", "machinery", "construction",
|
| 1059 |
+
"lumber", "paper", "copper", "aluminum", "coal", "drilling", "equipment")
|
| 1060 |
+
|
| 1061 |
+
|
| 1062 |
+
def is_cyclical(sector, industry) -> bool:
|
| 1063 |
+
sec, ind = str(sector or "").title(), str(industry or "").lower()
|
| 1064 |
+
return sec in CYCLICAL_SECTORS or any(k in ind for k in _CYCLICAL_INDUSTRY)
|
| 1065 |
|
| 1066 |
|
| 1067 |
# ---------------- practitioner layer: asymmetry, sizing, benchmark-relative risk ----------------
|
src/orchestrator.py
CHANGED
|
@@ -651,7 +651,9 @@ def run_analysis(rows: list[dict], emit, context: dict | None = None, budget_s:
|
|
| 651 |
sector_over = {h["symbol"]: over.get("label") for h in holdings
|
| 652 |
if over.get("sector") and str(h["data"].get("sector") or "") == over["sector"]}
|
| 653 |
decisions = P.decide_actions(panel, [h["symbol"] for h in holdings],
|
| 654 |
-
{h["symbol"]: h["weight"] for h in holdings}, fund_syms, asym, sector_over
|
|
|
|
|
|
|
| 655 |
cautious_book = (str((context or {}).get("risk") or "").lower() == "conservative"
|
| 656 |
or "under 3" in str((context or {}).get("horizon") or "").lower()
|
| 657 |
or "defensive" in needs)
|
|
|
|
| 651 |
sector_over = {h["symbol"]: over.get("label") for h in holdings
|
| 652 |
if over.get("sector") and str(h["data"].get("sector") or "") == over["sector"]}
|
| 653 |
decisions = P.decide_actions(panel, [h["symbol"] for h in holdings],
|
| 654 |
+
{h["symbol"]: h["weight"] for h in holdings}, fund_syms, asym, sector_over,
|
| 655 |
+
cyclical={h["symbol"] for h in holdings
|
| 656 |
+
if M.is_cyclical(h["data"].get("sector"), h["data"].get("industry"))})
|
| 657 |
cautious_book = (str((context or {}).get("risk") or "").lower() == "conservative"
|
| 658 |
or "under 3" in str((context or {}).get("horizon") or "").lower()
|
| 659 |
or "defensive" in needs)
|
src/personas.py
CHANGED
|
@@ -495,7 +495,8 @@ def decide_actions(panel: dict[str, dict], tickers: list[str],
|
|
| 495 |
weights: dict[str, float] | None = None,
|
| 496 |
funds: set[str] | None = None,
|
| 497 |
asym: dict[str, dict] | None = None,
|
| 498 |
-
sector_over: dict[str, str] | None = None
|
|
|
|
| 499 |
"""{ticker: {action, conviction, dispersion, votes_low, scores{persona: conv}}}.
|
| 500 |
|
| 501 |
Weight-aware: however strong the conviction, "Buy More" is never issued for a
|
|
@@ -504,6 +505,7 @@ def decide_actions(panel: dict[str, dict], tickers: list[str],
|
|
| 504 |
position with only lukewarm backing tilts to Trim."""
|
| 505 |
from .metrics import FUND_CAP, effective_name_cap
|
| 506 |
weights = weights or {}
|
|
|
|
| 507 |
funds = funds or set()
|
| 508 |
book_cap = effective_name_cap(len(tickers))
|
| 509 |
out = {}
|
|
@@ -556,12 +558,16 @@ def decide_actions(panel: dict[str, dict], tickers: list[str],
|
|
| 556 |
action = "Trim"
|
| 557 |
size_note = (f"at {w * 100:.0f}% of the book against a {cap * 100:.0f}% cap, "
|
| 558 |
f"size itself is the risk — trim back toward the cap")
|
| 559 |
-
elif action in ("Keep", "Buy More") and sum(1 for v in vals if v <= 35) >= 2 and len(vals) >= 4
|
| 560 |
-
|
| 561 |
-
#
|
|
|
|
|
|
|
|
|
|
| 562 |
action = "Trim"
|
| 563 |
low = [k for k, v in scores.items() if v <= 35]
|
| 564 |
-
size_note = f"the {' and '.join(low)} lens{'es' if len(low) > 1 else ''}
|
|
|
|
| 565 |
out[t] = {"action": action, "conviction": round(med), "dispersion": round(disp),
|
| 566 |
"votes_low": votes_low, "n_lenses": len(vals), "scores": scores,
|
| 567 |
"size_note": size_note}
|
|
|
|
| 495 |
weights: dict[str, float] | None = None,
|
| 496 |
funds: set[str] | None = None,
|
| 497 |
asym: dict[str, dict] | None = None,
|
| 498 |
+
sector_over: dict[str, str] | None = None,
|
| 499 |
+
cyclical: set[str] | None = None) -> dict[str, dict]:
|
| 500 |
"""{ticker: {action, conviction, dispersion, votes_low, scores{persona: conv}}}.
|
| 501 |
|
| 502 |
Weight-aware: however strong the conviction, "Buy More" is never issued for a
|
|
|
|
| 505 |
position with only lukewarm backing tilts to Trim."""
|
| 506 |
from .metrics import FUND_CAP, effective_name_cap
|
| 507 |
weights = weights or {}
|
| 508 |
+
cyclical = cyclical or set()
|
| 509 |
funds = funds or set()
|
| 510 |
book_cap = effective_name_cap(len(tickers))
|
| 511 |
out = {}
|
|
|
|
| 558 |
action = "Trim"
|
| 559 |
size_note = (f"at {w * 100:.0f}% of the book against a {cap * 100:.0f}% cap, "
|
| 560 |
f"size itself is the risk — trim back toward the cap")
|
| 561 |
+
elif (action in ("Keep", "Buy More") and sum(1 for v in vals if v <= 35) >= 2 and len(vals) >= 4
|
| 562 |
+
and t in cyclical):
|
| 563 |
+
# two-lens dissent, CYCLICALS ONLY: at a cycle peak a momentum-led median does not
|
| 564 |
+
# earn a hold when two analysts see damage (a 13-bagger memory name with value 35 /
|
| 565 |
+
# risk 35). Secular winners are deliberately left alone — a winner is not sold
|
| 566 |
+
# early on valuation grumbles while its growth and trend lenses still back it.
|
| 567 |
action = "Trim"
|
| 568 |
low = [k for k, v in scores.items() if v <= 35]
|
| 569 |
+
size_note = (f"cyclical at a peak — the {' and '.join(low)} lens{'es' if len(low) > 1 else ''} "
|
| 570 |
+
f"see real damage; trim rather than ride the cycle down")
|
| 571 |
out[t] = {"action": action, "conviction": round(med), "dispersion": round(disp),
|
| 572 |
"votes_low": votes_low, "n_lenses": len(vals), "scores": scores,
|
| 573 |
"size_note": size_note}
|