#!/usr/bin/env python3 """SECTOR_EXPOSURE_GRAPH_V1 + LEADER_LIFECYCLE_GATE_V1 — spec/formulas/domains/sector.yaml. ETF lookthrough exposure + factor beta residualization + leader role promotion/demotion. governance/todo/v8_9_p1_adoption_plan.yaml P1-A.3. Hard rule (AGENTS.md): missing ETF constituents or peer betas are never assumed zero. """ from __future__ import annotations import argparse import json from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DEFAULT_POSITIONS = ROOT / "Temp" / "sector_exposure_positions_v1.json" DEFAULT_OUT = ROOT / "Temp" / "sector_exposure_graph_v1.json" PROMOTION_PATH = ["LAGGARD", "CYCLICAL_BETA", "ENABLER", "CORE_LEADER", "CAPTAIN"] def _load(path: Path) -> dict: if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except Exception: return {} def sector_exposure(position: dict) -> dict: direct_weight_pct = float(position.get("direct_weight_pct") or 0.0) etf_constituents = position.get("etf_constituents_json") etf_weight_pct = position.get("etf_weight_pct") sector_id = position.get("sector_id") if etf_constituents is None or etf_weight_pct is None: return { "sector_id": sector_id, "direct_weight_pct": direct_weight_pct, "lookthrough_etf_weight_pct": None, "sector_family_total_pct": None, "gate": "ETF_BUY_BLOCKED", "reason_code": "constituents_missing", } lookthrough = sum( float(c.get("weight_pct", 0.0)) * float(etf_weight_pct) / 100.0 for c in etf_constituents if isinstance(c, dict) and c.get("sector_id") == sector_id ) sector_family_total_pct = direct_weight_pct + lookthrough return { "sector_id": sector_id, "direct_weight_pct": direct_weight_pct, "lookthrough_etf_weight_pct": lookthrough, "sector_family_total_pct": sector_family_total_pct, "gate": "PASS", } def residualize_factor_beta(factor_beta_raw: float, peer_sector_betas: list | None) -> tuple[float | None, str]: if peer_sector_betas is None: return factor_beta_raw, "PARTIAL_raw_beta_peer_data_missing" shared_variance = sum(float(p.get("shared_variance", 0.0)) for p in peer_sector_betas if isinstance(p, dict)) return factor_beta_raw - shared_variance, "PASS" def evaluate_leader_role(position: dict) -> dict: required_fields = [ "relative_strength_leads_sector", "volume_quality_confirmed", "above_ma60_or_reclaim_confirmed", "earnings_revision_status", "institutional_flow_status", ] current_role = position.get("current_role") or "LAGGARD" if any(position.get(f) is None for f in required_fields): return {"leader_role": current_role, "role_transition_reason": "DATA_MISSING", "role_changed": False} demotion = ( (position["above_ma60_or_reclaim_confirmed"] is False and position["institutional_flow_status"] == "distribution") or position["earnings_revision_status"] == "negative" or ( position["institutional_flow_status"] == "distribution" and current_role in ("CAPTAIN", "CORE_LEADER") ) ) if demotion: return { "leader_role": "DISTRIBUTION_RISK", "role_transition_reason": "demotion_trigger", "role_changed": current_role != "DISTRIBUTION_RISK", } promotion_ok = ( position["relative_strength_leads_sector"] is True and position["volume_quality_confirmed"] is True and position["above_ma60_or_reclaim_confirmed"] is True and position["earnings_revision_status"] != "negative" and position["institutional_flow_status"] != "distribution" ) if promotion_ok and current_role in PROMOTION_PATH: idx = PROMOTION_PATH.index(current_role) next_role = PROMOTION_PATH[min(idx + 1, len(PROMOTION_PATH) - 1)] return { "leader_role": next_role, "role_transition_reason": "promotion_requires_all_satisfied", "role_changed": next_role != current_role, } return {"leader_role": current_role, "role_transition_reason": "no_change", "role_changed": False} def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--positions", default=str(DEFAULT_POSITIONS)) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() doc = _load(Path(args.positions)) positions = doc.get("positions") if isinstance(doc.get("positions"), list) else [] rows = [] for position in positions: if not isinstance(position, dict): continue exposure = sector_exposure(position) leader = evaluate_leader_role(position) beta_residualized, beta_status = residualize_factor_beta( float(position.get("factor_beta_raw") or 0.0), position.get("peer_sector_betas") ) rows.append({**exposure, **leader, "factor_beta_residualized": beta_residualized, "beta_status": beta_status}) result = { "formula_id": "SECTOR_EXPOSURE_GRAPH_V1", "gate": "PASS" if rows else "DATA_MISSING", "rows": rows, "source_paths": [str(Path(args.positions))], } out = Path(args.out) out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())