s-ttp commited on
Commit
348d92f
·
verified ·
1 Parent(s): 7ac62dc

Two-lens dissent restricted to cyclicals (sector or industry): secular winners are not trimmed early

Browse files
Files changed (3) hide show
  1. src/metrics.py +9 -0
  2. src/orchestrator.py +3 -1
  3. 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) -> dict[str, dict]:
 
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
- # two-lens dissent: when two analysts see damage (≤35) a momentum-led median
561
- # does not earn a hold — a 13-bagger cyclical with value 35 / risk 35 gets trimmed
 
 
 
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 ''} see real damage — trim rather than ride the momentum"
 
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}