1"""Unified Prognosis envelope. 2 3Most MAF arenas were splitting verdicts across two outboxes — ``TradingAction`` 4on ``maf:actions:out`` for ticker arenas and ``GenericDecision`` on 5``maf:decisions:out`` for everything else. The split was driven by 6``target_key == "ticker"`` and made the dashboard's verdict rendering 7arena-specific. 8 9``Prognosis`` is the agnostic shape: every arena emits one of these, 10which carries either a structured trade verdict (BUY/SELL/HOLD with 11size + horizon) or a free-form statement / prognosis. The router that 12:func:`maf.app.MAFApp._publish_arena_output` runs is unchanged — the 13underlying envelopes ``TradingAction`` and ``GenericDecision`` keep 14their on-stream shapes so existing consumers don't break. Prognosis is 15the **single** type the dashboard reads to render any arena's verdict 16consistently. 17 18Wire shape: 19 20* ``verdict`` — optional, set for tradeable targets. One of 21 ``BUY`` / ``SELL`` / ``HOLD``. 22* ``statement`` — required, free-form one-line summary suitable for the 23 UI's verdict pill on non-trading arenas. 24* ``confidence`` — 0.0–1.0. 25* ``target`` — typed ``Target`` (type + primary_id + secondaries + meta). 26* ``key_factors`` — short bullets surfacing the agents' top reasoning. 27* ``ideas`` — optional list of ticker/idea hooks (research outputs). 28* ``alternatives_considered`` — optional list of {outcome, why_rejected}. 29* ``data_quality`` — ``good`` | ``partial`` | ``poor``. 30* ``citations`` — list of source/signal references. 31""" 32 33from __future__ import annotations 34 35import uuid 36from datetime import datetime, timezone 37from typing import Any, Literal 38 39from pydantic import BaseModel, ConfigDict, Field 40 41from maf.config import Target 42 43 44UTC = timezone.utc 45 46 47def _utcnow() -> datetime: 48 return datetime.now(UTC) 49 50 51Verdict = Literal["BUY", "SELL", "HOLD"] 52 53 54class PrognosisIdea(BaseModel): 55 """One actionable name within a research-style prognosis.""" 56 57 ticker: str = "" 58 thesis: str = "" 59 confidence: float = Field(ge=0.0, le=1.0, default=0.0) 60 61 62class PrognosisAlternative(BaseModel): 63 """An outcome the synthesis considered but rejected.""" 64 65 outcome: str 66 why_rejected: str = "" 67 68 69class Prognosis(BaseModel): 70 """Unified verdict envelope for any arena run. 71 72 Trade arenas set ``verdict`` and may leave ``statement`` to a short 73 one-liner. Research / deliberation arenas leave ``verdict`` None and 74 put their answer in ``statement``. Either way the dashboard renders 75 a single shape. 76 """ 77 78 model_config = ConfigDict(frozen=True) 79 80 schema_version: Literal["1"] = "1" 81 arena: str 82 arena_id: str = "" 83 correlation_id: str = Field(default_factory=lambda: uuid.uuid4().hex) 84 published_at: datetime = Field(default_factory=_utcnow) 85 86 target: Target 87 verdict: Verdict | None = None 88 statement: str 89 confidence: float = Field(ge=0.0, le=1.0, default=0.0) 90 91 key_factors: list[str] = Field(default_factory=list) 92 ideas: list[PrognosisIdea] = Field(default_factory=list) 93 alternatives_considered: list[PrognosisAlternative] = Field(default_factory=list) 94 95 data_quality: Literal["good", "partial", "poor"] = "good" 96 citations: list[str] = Field(default_factory=list) 97 98 # Free-form metadata: model name, agent count, source-metric digest, etc. 99 meta: dict[str, Any] = Field(default_factory=dict) 100 101 102# ── Helpers ──────────────────────────────────────────────────────────── 103 104 105def from_arena_state( 106 arena_name: str, 107 state: dict[str, Any], 108 *, 109 target: Target | None = None, 110) -> Prognosis: 111 """Build a Prognosis from a final arena state dict. 112 113 Reads the canonical fields the synthesis layer populates: 114 synthesis_verdict, synthesis_confidence, synthesis_reasoning, 115 agent_signals (top 3 → key_factors), target. 116 """ 117 if target is None: 118 target = Target.from_dict(state.get("target") or {}) 119 120 raw_verdict = (state.get("synthesis_verdict") or state.get("signal") or "").upper() 121 verdict: Verdict | None 122 if raw_verdict in ("BUY", "SELL", "HOLD"): 123 verdict = raw_verdict # type: ignore[assignment] 124 else: 125 verdict = None 126 127 statement = state.get("synthesis_reasoning") or state.get("synthesis_summary") or "" 128 if not statement and verdict: 129 statement = f"{verdict} call from {arena_name}" 130 if not statement: 131 statement = f"No conclusion reached by {arena_name}" 132 133 # Promote the strongest 3 agent signals to key_factors. 134 key_factors: list[str] = [] 135 for sig in (state.get("agent_signals") or [])[:3]: 136 s = sig.get("summary") or sig.get("signal") or "" 137 if s: 138 agent = sig.get("agent", "") 139 key_factors.append(f"[{agent}] {s}" if agent else s) 140 141 # Ideas shortlist — used by equity_research-style arenas + news_event 142 # (which packs impacted_tickers into ideas with a `{direction, 143 # magnitude, why}`-shaped raw payload). Map any event-shape entries 144 # into PrognosisIdea by composing the thesis string from the impact 145 # fields so the synthesis output isn't silently dropped. 146 def _coerce_idea(raw: dict[str, Any]) -> PrognosisIdea: 147 ticker = raw.get("ticker", "") 148 # Native PrognosisIdea shape — pass through. 149 if "thesis" in raw: 150 return PrognosisIdea( 151 ticker=ticker, 152 thesis=raw.get("thesis", ""), 153 confidence=float(raw.get("confidence", 0.0) or 0.0), 154 ) 155 # Event-impact shape: compose `direction magnitude — why` as the thesis. 156 direction = raw.get("direction", "") 157 magnitude = raw.get("magnitude", "") 158 why = raw.get("why", "") 159 composed = " ".join(p for p in (direction, magnitude, why) if p).strip() 160 if not composed: 161 composed = raw.get("summary", "") 162 # Magnitude → rough confidence proxy when no explicit number. 163 mag_conf = {"high": 0.8, "medium": 0.55, "low": 0.3}.get(magnitude, 0.0) 164 return PrognosisIdea( 165 ticker=ticker, 166 thesis=composed, 167 confidence=float(raw.get("confidence", mag_conf) or 0.0), 168 ) 169 170 ideas_raw = ( 171 state.get("ideas_shortlist") 172 or state.get("ideas") 173 or state.get("impacted_tickers") # news_event-shape direct 174 or [] 175 ) 176 ideas = [_coerce_idea(i) for i in ideas_raw if isinstance(i, dict)] 177 178 # Alternatives considered — populated by the ad_hoc synthesis layer. 179 # Accepts either {outcome, why_rejected} dicts (preferred) or bare 180 # strings (treated as outcome with empty why). 181 alternatives_raw = state.get("alternatives_considered") or [] 182 alternatives: list[PrognosisAlternative] = [] 183 for alt in alternatives_raw: 184 if isinstance(alt, dict): 185 alternatives.append(PrognosisAlternative( 186 outcome=alt.get("outcome", ""), 187 why_rejected=alt.get("why_rejected", ""), 188 )) 189 elif isinstance(alt, str) and alt: 190 alternatives.append(PrognosisAlternative(outcome=alt)) 191 192 # Data-quality heuristic: poor if no signals or all signals had errors; 193 # partial if half failed; good otherwise. 194 signals = state.get("agent_signals") or [] 195 if not signals: 196 data_quality: Literal["good", "partial", "poor"] = "poor" 197 else: 198 errors = sum(1 for s in signals if s.get("error")) 199 if errors == 0: 200 data_quality = "good" 201 elif errors < len(signals) / 2: 202 data_quality = "partial" 203 else: 204 data_quality = "poor" 205 206 return Prognosis( 207 arena=arena_name, 208 arena_id=state.get("arena_id", ""), 209 target=target, 210 verdict=verdict, 211 statement=statement, 212 confidence=float(state.get("synthesis_confidence", 0.0) or 0.0), 213 key_factors=key_factors, 214 ideas=ideas, 215 alternatives_considered=alternatives, 216 data_quality=data_quality, 217 meta={ 218 "ensemble_score": state.get("synthesis_score"), 219 "iteration": state.get("iteration", 0), 220 "elapsed_s": state.get("arena_total_seconds"), 221 }, 222 )