Skip to content

Commit e89ddf0

Browse files
merge feat-deleverage-failtax (exhaustive-pass fits: cross-sectional deleveraging breaker + episode-failure taxonomy)
2 parents 2d1dbad + 6db4a4c commit e89ddf0

5 files changed

Lines changed: 807 additions & 5 deletions

File tree

crates/openoutcry-py/python/openoutcry/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,13 @@
6161
loss_averse,
6262
)
6363
from .indicators import CausalIndicatorObservation, INDICATORS, DEFAULT_INDICATORS
64-
from .risk import DrawdownStopper, TurbulenceHalt
64+
from .risk import DrawdownStopper, TurbulenceHalt, CrossSectionalDeleverage
65+
from .failure_taxonomy import (
66+
FailureMode,
67+
classify_episode_failure,
68+
FailureRollup,
69+
rollup_failure_modes,
70+
)
6571
from .news import SyntheticNewsObservation, news_series
6672
from .discrete import DiscreteAction
6773
from .pairs import SpreadObservation
@@ -211,6 +217,11 @@
211217
"DEFAULT_INDICATORS",
212218
"DrawdownStopper",
213219
"TurbulenceHalt",
220+
"CrossSectionalDeleverage",
221+
"FailureMode",
222+
"classify_episode_failure",
223+
"FailureRollup",
224+
"rollup_failure_modes",
214225
"SyntheticNewsObservation",
215226
"news_series",
216227
"DiscreteAction",
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)