checking system…
Docs / back / src/maf/actions/prognosis.py · line 66
Python · 223 lines
  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    )