|
| 1 | +"""Deterministic episode-failure taxonomy for OpenOutcry rollouts. |
| 2 | +
|
| 3 | +A trading episode can end in more than one way, and "did it end badly?" is not one bit — |
| 4 | +a margin cascade that wiped equity, a plain bankruptcy, a drawdown stop-out, and a mandate |
| 5 | +violation are distinct failures that call for distinct fixes. :func:`classify_episode_failure` |
| 6 | +reduces an episode to a single :class:`FailureMode` over signals the env and its wrappers |
| 7 | +**already surface at episode end** — the realized return series, the accumulated ``events``, |
| 8 | +and the scenario :class:`~openoutcry.mandate.Mandate` — with **no new signals invented** and |
| 9 | +**no LLM judge** (a subjective judge is a no-fit for a deterministic, leak-free market; the |
| 10 | +verdict here has to be reproducible byte-for-byte). |
| 11 | +
|
| 12 | +Signals consumed (all pre-existing): |
| 13 | +
|
| 14 | +* **returns** — the per-bar realized-return series. Its running product is the NAV path, so |
| 15 | + a NAV that reaches ``<= 0`` is a bankruptcy, and the realized drawdown feeds the mandate |
| 16 | + drawdown check. |
| 17 | +* **events** — the accumulated per-bar event dicts. The forced-liquidation cascade |
| 18 | + (:mod:`openoutcry.cascade`) emits ``margin_call`` / ``cascade_impact`` records carrying the |
| 19 | + breach NAV and each step's mark drop, so a cascade's survived-vs-wiped outcome is |
| 20 | + reconstructable from the exact fields it already emits. A ``stopped_out`` marker |
| 21 | + (:class:`~openoutcry.risk.DrawdownStopper`) is read when present. The ``target_weights`` |
| 22 | + records feed the mandate structural / inventory checks. |
| 23 | +* **mandate** — the per-scenario objective. Which mandate source was breached (structural / |
| 24 | + drawdown / inventory) is recovered by re-scoring against *isolated* single-source mandates |
| 25 | + through the existing Rust :func:`~openoutcry.mandate.mandate_breach` kernel — no |
| 26 | + reimplementation of the breach math. |
| 27 | +
|
| 28 | +Classification is worst-case-first (the :func:`~openoutcry.mandate.mandate_breach` ``max`` |
| 29 | +convention): a terminal capital outcome outranks a mandate-policy violation, and the more |
| 30 | +specific cascade wipe outranks a generic bankruptcy. |
| 31 | +""" |
| 32 | + |
| 33 | +from __future__ import annotations |
| 34 | + |
| 35 | +from dataclasses import dataclass |
| 36 | +from enum import Enum |
| 37 | +from typing import Any, Iterable, Optional, Sequence, Union |
| 38 | + |
| 39 | +from .mandate import Mandate, mandate_breach, validate_mandate |
| 40 | + |
| 41 | + |
| 42 | +class FailureMode(str, Enum): |
| 43 | + """The terminal disposition of an episode (``str``-valued for plain-JSON traces).""" |
| 44 | + |
| 45 | + CLEAN = "clean" |
| 46 | + BANKRUPT = "bankrupt" |
| 47 | + STOPPED_OUT = "stopped_out" |
| 48 | + CASCADE_WIPED = "cascade_wiped" |
| 49 | + MANDATE_STRUCTURAL = "mandate_structural" |
| 50 | + MANDATE_DRAWDOWN = "mandate_drawdown" |
| 51 | + MANDATE_INVENTORY = "mandate_inventory" |
| 52 | + |
| 53 | + |
| 54 | +def _nav_bankrupt(returns: Sequence[float]) -> bool: |
| 55 | + """True if the NAV path (running product of ``1 + r``) reaches ``<= 0`` — bankruptcy.""" |
| 56 | + nav = 1.0 |
| 57 | + for r in returns or []: |
| 58 | + nav *= 1.0 + float(r) |
| 59 | + if nav <= 0.0: |
| 60 | + return True |
| 61 | + return False |
| 62 | + |
| 63 | + |
| 64 | +def _cascade_outcome(events: Sequence[dict]) -> str: |
| 65 | + """Reconstruct the forced-liquidation cascade outcome from its emitted events. |
| 66 | +
|
| 67 | + Returns ``"none"`` (no cascade fired), ``"survived"`` (a cascade fired but equity held), |
| 68 | + or ``"wiped"`` (a cascade drove equity ``<= 0``). Each ``margin_call`` opens a chain at |
| 69 | + its breach NAV; the following ``cascade_impact`` mark drops subtract from it until the |
| 70 | + next ``margin_call`` — exactly the arithmetic :func:`openoutcry.cascade._run_cascade` |
| 71 | + performs, read back off the fields it already surfaces. |
| 72 | + """ |
| 73 | + fired = False |
| 74 | + wiped = False |
| 75 | + cur: Optional[float] = None |
| 76 | + for e in events or []: |
| 77 | + name = str(e.get("event", "")) |
| 78 | + if name == "margin_call": |
| 79 | + fired = True |
| 80 | + cur = float(e.get("nav", 0.0)) |
| 81 | + elif name == "cascade_impact" and cur is not None: |
| 82 | + cur -= float(e.get("mark_drop", 0.0)) |
| 83 | + if cur <= 0.0: |
| 84 | + wiped = True |
| 85 | + if not fired: |
| 86 | + return "none" |
| 87 | + return "wiped" if wiped else "survived" |
| 88 | + |
| 89 | + |
| 90 | +def _has_stop_out(events: Sequence[dict]) -> bool: |
| 91 | + """True if a drawdown stop-out is signalled — either a ``stopped_out`` event or the |
| 92 | + top-level ``stopped_out`` flag :class:`~openoutcry.risk.DrawdownStopper` sets, whichever |
| 93 | + the caller folded into the event stream.""" |
| 94 | + for e in events or []: |
| 95 | + if str(e.get("event", "")) == "stopped_out": |
| 96 | + return True |
| 97 | + if e.get("stopped_out"): |
| 98 | + return True |
| 99 | + return False |
| 100 | + |
| 101 | + |
| 102 | +def _mandate_failure( |
| 103 | + mandate: Union[Mandate, dict], |
| 104 | + returns: Sequence[float], |
| 105 | + events: Sequence[dict], |
| 106 | + *, |
| 107 | + tol: float, |
| 108 | +) -> Optional[FailureMode]: |
| 109 | + """The dominant mandate-breach source, recovered via isolated single-source re-scoring. |
| 110 | +
|
| 111 | + Re-scores the episode against three isolated mandates through the real |
| 112 | + :func:`~openoutcry.mandate.mandate_breach` kernel — one carrying only the structural |
| 113 | + style, one only the drawdown cap, one only the inventory cap — so the per-source breach |
| 114 | + is read straight from the kernel without reimplementing its math. The largest source |
| 115 | + strictly above ``tol`` wins; ties break structural > drawdown > inventory (the enum |
| 116 | + order). ``None`` when no source is breached. |
| 117 | + """ |
| 118 | + m = Mandate.from_dict(mandate) if isinstance(mandate, dict) else mandate |
| 119 | + rets = [float(r) for r in returns or []] |
| 120 | + evs = list(events or []) |
| 121 | + |
| 122 | + candidates: list[tuple[float, FailureMode]] = [] |
| 123 | + structural = mandate_breach(Mandate(style=m.style), rets, evs) |
| 124 | + candidates.append((structural, FailureMode.MANDATE_STRUCTURAL)) |
| 125 | + if m.max_drawdown is not None: |
| 126 | + dd = mandate_breach( |
| 127 | + Mandate(style="unconstrained", max_drawdown=m.max_drawdown), rets, evs |
| 128 | + ) |
| 129 | + candidates.append((dd, FailureMode.MANDATE_DRAWDOWN)) |
| 130 | + if m.max_inventory is not None: |
| 131 | + inv = mandate_breach( |
| 132 | + Mandate(style="unconstrained", max_inventory=m.max_inventory), rets, evs |
| 133 | + ) |
| 134 | + candidates.append((inv, FailureMode.MANDATE_INVENTORY)) |
| 135 | + |
| 136 | + best_breach, best_mode = max(candidates, key=lambda c: c[0]) |
| 137 | + return best_mode if best_breach > tol else None |
| 138 | + |
| 139 | + |
| 140 | +def classify_episode_failure( |
| 141 | + returns: Sequence[float], |
| 142 | + events: Sequence[dict], |
| 143 | + mandate: Union[Mandate, dict, None] = None, |
| 144 | + *, |
| 145 | + mandate_tol: float = 0.0, |
| 146 | +) -> FailureMode: |
| 147 | + """Classify one episode into a single :class:`FailureMode`, worst-case first. |
| 148 | +
|
| 149 | + Precedence (most catastrophic first): a cascade that wiped equity |
| 150 | + (:attr:`FailureMode.CASCADE_WIPED`) outranks a plain bankruptcy |
| 151 | + (:attr:`FailureMode.BANKRUPT`), which outranks a stop-out — a drawdown stop marker or a |
| 152 | + *survived* cascade (:attr:`FailureMode.STOPPED_OUT`) — which outranks a mandate-policy |
| 153 | + breach (:attr:`FailureMode.MANDATE_STRUCTURAL` / :attr:`FailureMode.MANDATE_DRAWDOWN` / |
| 154 | + :attr:`FailureMode.MANDATE_INVENTORY`). An episode tripping none of these is |
| 155 | + :attr:`FailureMode.CLEAN`. Deterministic and pure; safe on empty inputs. |
| 156 | + """ |
| 157 | + cascade = _cascade_outcome(events) |
| 158 | + if cascade == "wiped": |
| 159 | + return FailureMode.CASCADE_WIPED |
| 160 | + if _nav_bankrupt(returns): |
| 161 | + return FailureMode.BANKRUPT |
| 162 | + if cascade == "survived" or _has_stop_out(events): |
| 163 | + return FailureMode.STOPPED_OUT |
| 164 | + if validate_mandate(mandate): |
| 165 | + mode = _mandate_failure(mandate, returns, events, tol=mandate_tol) # type: ignore[arg-type] |
| 166 | + if mode is not None: |
| 167 | + return mode |
| 168 | + return FailureMode.CLEAN |
| 169 | + |
| 170 | + |
| 171 | +@dataclass(frozen=True) |
| 172 | +class FailureRollup: |
| 173 | + """A suite-level tally of episode dispositions for the evaluation path. |
| 174 | +
|
| 175 | + ``counts`` carries every :class:`FailureMode` value (zeros included) so the schema is |
| 176 | + stable across suites — a regression snapshot can diff it directly. ``clean_rate`` is the |
| 177 | + share of clean episodes, ``failure_rate`` its complement. |
| 178 | + """ |
| 179 | + |
| 180 | + counts: dict[str, int] |
| 181 | + total: int |
| 182 | + clean: int |
| 183 | + failures: int |
| 184 | + clean_rate: float |
| 185 | + failure_rate: float |
| 186 | + |
| 187 | + def to_dict(self) -> dict[str, Any]: |
| 188 | + return { |
| 189 | + "counts": dict(self.counts), |
| 190 | + "total": self.total, |
| 191 | + "clean": self.clean, |
| 192 | + "failures": self.failures, |
| 193 | + "clean_rate": self.clean_rate, |
| 194 | + "failure_rate": self.failure_rate, |
| 195 | + } |
| 196 | + |
| 197 | + |
| 198 | +def _as_mode(item: Any, *, mandate_tol: float) -> FailureMode: |
| 199 | + """Coerce a rollup item to a :class:`FailureMode`: an already-classified mode passes |
| 200 | + through; a mapping is classified from its ``returns`` / ``events`` / ``mandate`` keys; a |
| 201 | + ``(returns, events, mandate)`` sequence is classified positionally.""" |
| 202 | + if isinstance(item, FailureMode): |
| 203 | + return item |
| 204 | + if isinstance(item, str): |
| 205 | + return FailureMode(item) |
| 206 | + if isinstance(item, dict): |
| 207 | + return classify_episode_failure( |
| 208 | + item.get("returns", []), |
| 209 | + item.get("events", []), |
| 210 | + item.get("mandate"), |
| 211 | + mandate_tol=mandate_tol, |
| 212 | + ) |
| 213 | + returns, events, *rest = tuple(item) |
| 214 | + mandate = rest[0] if rest else None |
| 215 | + return classify_episode_failure(returns, events, mandate, mandate_tol=mandate_tol) |
| 216 | + |
| 217 | + |
| 218 | +def rollup_failure_modes( |
| 219 | + episodes: Iterable[Any], *, mandate_tol: float = 0.0 |
| 220 | +) -> FailureRollup: |
| 221 | + """Tally :class:`FailureMode` over a suite of episodes for the evaluation path. |
| 222 | +
|
| 223 | + Each item may be a pre-classified :class:`FailureMode` / its string value, a mapping with |
| 224 | + ``returns`` / ``events`` / ``mandate`` keys, or a ``(returns, events, mandate)`` sequence |
| 225 | + — so a caller can roll up raw rollouts directly or pre-classified verdicts. Deterministic: |
| 226 | + the same suite yields the same tally. |
| 227 | + """ |
| 228 | + counts: dict[str, int] = {mode.value: 0 for mode in FailureMode} |
| 229 | + total = 0 |
| 230 | + for item in episodes: |
| 231 | + mode = _as_mode(item, mandate_tol=mandate_tol) |
| 232 | + counts[mode.value] += 1 |
| 233 | + total += 1 |
| 234 | + clean = counts[FailureMode.CLEAN.value] |
| 235 | + failures = total - clean |
| 236 | + clean_rate = clean / total if total else 0.0 |
| 237 | + failure_rate = failures / total if total else 0.0 |
| 238 | + return FailureRollup( |
| 239 | + counts=counts, |
| 240 | + total=total, |
| 241 | + clean=clean, |
| 242 | + failures=failures, |
| 243 | + clean_rate=clean_rate, |
| 244 | + failure_rate=failure_rate, |
| 245 | + ) |
| 246 | + |
| 247 | + |
| 248 | +__all__ = [ |
| 249 | + "FailureMode", |
| 250 | + "classify_episode_failure", |
| 251 | + "FailureRollup", |
| 252 | + "rollup_failure_modes", |
| 253 | +] |
0 commit comments