"""Page-Hinkley mean-SHIFT edge-decay detector with binary kill + hysteresis. Pure, no I/O.

Detects a sustained DOWNWARD shift in a return stream's mean (slow-bleed edge death a drawdown
trigger misses). Shift-form (not level). Consumes the SHADOW (unkilled) return so it can detect
recovery and re-arm while booked exposure is flat.
"""
from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Any


@dataclass
class PageHinkleyDecay:
    delta: float = 0.0        # deadband: larger value tolerates more downward deviation (LESS sensitive)
    lam: float = 0.20         # cumulative-deviation threshold to KILL (pre-registered, conservative)
    reenter_days: int = 10    # consecutive non-negative shadow returns required to re-arm
    n: int = 0
    mean: float = 0.0
    m_t: float = 0.0
    m_max: float = 0.0
    killed: bool = False
    recover: int = 0

    def update(self, ret: float) -> bool:
        """Feed one realized SHADOW daily return; return True if ALIVE (book), False if KILLED (flat)."""
        if self.killed:
            self.recover = self.recover + 1 if ret >= 0.0 else 0
            if self.recover >= self.reenter_days:
                self.n, self.mean, self.m_t, self.m_max = 0, 0.0, 0.0, 0.0
                self.killed, self.recover = False, 0
            return not self.killed
        self.n += 1
        self.mean += (ret - self.mean) / self.n
        self.m_t += (ret - self.mean + self.delta)
        if self.m_t > self.m_max:
            self.m_max = self.m_t
        if (self.m_max - self.m_t) > self.lam:
            self.killed, self.recover = True, 0
            return False
        return True

    def health(self, theta_min: float = 0.0) -> float:
        """Continuous edge-health ∈ [theta_min, 1.0] from the CURRENT PH state (pure read; no mutation).

        The kill statistic is `(m_max - m_t)` compared against `lam` (see `update`): a sustained DOWNWARD
        drift drives `m_t` below its running peak `m_max`, so `(m_max - m_t)` grows toward `lam`. Map that to
        a health score so a healthy series → 1.0 and a decaying series → theta_min as PH nears its kill
        threshold: `health = clip(1 - max(0, (m_max - m_t)) / lam, theta_min, 1.0)`. Fresh / healthy state
        (m_max == m_t) → 1.0; deviation == lam → theta_min. Pure; does NOT mutate."""
        if self.lam <= 0.0:
            return 1.0
        dev = max(0.0, self.m_max - self.m_t)
        return max(theta_min, min(1.0, 1.0 - dev / self.lam))

    def to_dict(self) -> dict[str, Any]:
        return {"delta": self.delta, "lam": self.lam, "reenter_days": self.reenter_days,
                "n": self.n, "mean": self.mean, "m_t": self.m_t, "m_max": self.m_max,
                "killed": self.killed, "recover": self.recover}

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> PageHinkleyDecay:
        return cls(**d)


@dataclass
class WindowedEdgeHealth:
    """Bounded, scale-invariant, continuously-recovering edge-health from the trailing risk-adjusted
    drift (a t-stat of recent returns). Pure, O(1) state, serializable.

    Fixes the three defects that make PageHinkleyDecay.health useless as a CONTINUOUS health:
      (1) NOT scale-invariant — PH's `(m_max - m_t)/lam` is in return units, so the same edge scores
          differently at 3%/day vs 0.8%/day. Here health is a function of a unitless t-stat, so a
          ×k rescale of the whole return series leaves the health sequence unchanged.
      (2) UNBOUNDED — PH's inception-cumulative deviation grows ~σ√n and crosses `lam` eventually
          regardless of edge quality. Here the t-stat uses EWMA moments with `eff_n = min(n, window)`,
          so a stationary positive-mean series stays HIGH forever (no false decay).
      (3) DEAD re-arm — PH needs N consecutive non-negative days to recover (almost never on daily
          crypto). Here recovery is automatic: as positive returns re-enter the EWMA window the t-stat
          climbs back to t_full and health returns to 1.0 within ~window days, no streak required.

    health() maps the trailing t-stat through a linear ramp:
        t >= t_full -> 1.0;  t <= t_dead -> theta_min;  linear in between.
    Bootstrap: health == 1.0 until `min_obs` observations (don't cut a young edge)."""

    window: int = 63          # ≈ a trading quarter — the effective lookback (EWMA span)
    t_full: float = 0.5       # t-stat at/above which health = 1.0
    t_dead: float = -2.0      # t-stat at/below which health = theta_min
    theta_min: float = 0.0    # floor on health
    min_obs: int = 10         # bootstrap: health = 1.0 until this many observations
    n: int = 0
    ewma_mean: float = 0.0
    ewma_var: float = 0.0     # West's incremental EWMA variance

    def update(self, ret: float) -> None:
        """Feed one realized daily return; advance the EWMA mean/variance moments. Pure mutation."""
        if self.n == 0:
            self.ewma_mean = ret
            self.ewma_var = 0.0
        else:
            alpha = 2.0 / (self.window + 1)
            dev = ret - self.ewma_mean
            self.ewma_mean += alpha * dev
            self.ewma_var = (1.0 - alpha) * (self.ewma_var + alpha * dev * dev)
        self.n += 1

    def health(self) -> float:
        """Continuous edge-health ∈ [theta_min, 1.0] from current EWMA state (pure read; no mutation).

        Bootstrap (n < min_obs) → 1.0. Else t = (ewma_mean / sd) * sqrt(min(n, window)); a degenerate
        sd (no observed variance yet) → 1.0 (no evidence of decay). Map t through the linear ramp."""
        if self.n < self.min_obs:
            return 1.0
        sd = math.sqrt(self.ewma_var)
        if sd <= 0.0:
            return 1.0
        eff_n = min(self.n, self.window)
        t = (self.ewma_mean / sd) * math.sqrt(eff_n)
        theta = (t - self.t_dead) / (self.t_full - self.t_dead)
        return max(self.theta_min, min(1.0, theta))

    def to_dict(self) -> dict[str, Any]:
        return {"window": self.window, "t_full": self.t_full, "t_dead": self.t_dead,
                "theta_min": self.theta_min, "min_obs": self.min_obs, "n": self.n,
                "ewma_mean": self.ewma_mean, "ewma_var": self.ewma_var}

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> WindowedEdgeHealth:
        return cls(**d)


@dataclass
class RetirementGate:
    """Per-sleeve STRUCTURAL-retirement gate: a slow, binary hysteresis state machine over a
    WindowedEdgeHealth reading. Pure, O(1) state, serializable, deterministic.

    NOT a daily sizing throttle (that whipsaws a momentum book). Instead it answers the rare,
    structural question "is this specific edge still alive?" — drop a sleeve whose health stays
    below `retire_floor` for `retire_window` consecutive days; re-admit one whose health stays at/
    above `readmit_floor` for `readmit_window` consecutive days. `readmit_floor > retire_floor`
    (hysteresis) + long confirmation windows mean it CANNOT whipsaw: a transient dip below the
    floor (shorter than retire_window) does nothing, and health in the band between the two floors
    neither retires an active sleeve nor re-admits a retired one.

    Each day, feed the sleeve's (shadow) daily return via `update(ret)`; it advances the inner WEH
    and then steps the status state machine. Bootstrap: WEH.health() == 1.0 while young (n < min_obs)
    → a young sleeve is never retired early.
    """

    retire_floor: float = 0.15
    retire_window: int = 30
    readmit_floor: float = 0.50
    readmit_window: int = 30
    weh_window: int = 63
    status: str = "active"          # ∈ {"active", "retired"}
    below_run: int = 0             # consecutive days health < retire_floor (while active)
    above_run: int = 0            # consecutive days health >= readmit_floor (while retired)
    weh: WindowedEdgeHealth = None  # type: ignore[assignment]

    def __post_init__(self) -> None:
        if self.weh is None:
            self.weh = WindowedEdgeHealth(window=self.weh_window)

    def update(self, ret: float) -> str:
        """Feed one realized (shadow) daily return; advance the WEH then step the state machine.
        Returns the post-update `status`. Pure mutation; deterministic."""
        self.weh.update(ret)
        h = self.weh.health()
        if self.status == "active":
            self.below_run = self.below_run + 1 if h < self.retire_floor else 0
            if self.below_run >= self.retire_window:
                self.status = "retired"
                self.below_run = self.above_run = 0
        else:  # retired
            self.above_run = self.above_run + 1 if h >= self.readmit_floor else 0
            if self.above_run >= self.readmit_window:
                self.status = "active"
                self.below_run = self.above_run = 0
        return self.status

    def to_dict(self) -> dict[str, Any]:
        return {"retire_floor": self.retire_floor, "retire_window": self.retire_window,
                "readmit_floor": self.readmit_floor, "readmit_window": self.readmit_window,
                "weh_window": self.weh_window, "status": self.status,
                "below_run": self.below_run, "above_run": self.above_run,
                "weh": self.weh.to_dict()}

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> RetirementGate:
        d = dict(d)
        weh = WindowedEdgeHealth.from_dict(d.pop("weh"))
        return cls(weh=weh, **d)
