"""EXIT-11 — z_overshoot: TP OLTRE la media (overshoot di z_off deviazioni std).

PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' la SMA_n al close[i-1].
Ipotesi di questa famiglia: la reversione spesso NON si ferma esattamente alla media,
ma la attraversa (overshoot). Spostando il TP un po' OLTRE la media nella direzione
del trade catturiamo l'overshoot quando c'e', al prezzo di mancare alcuni TP che si
fermano alla media (che poi rientrano o vengono presi da max_bars/SL).

  tp(j) = SMA_n[j-1] + d * z_off * STD_n[j-1]

  long  (d=+1): si compra sotto la media -> TP = media + z_off*std (sopra la media: piu' lontano).
  short (d=-1): si vende sopra la media  -> TP = media - z_off*std (sotto la media: piu' lontano).

In ENTRAMBI i casi il TP si allontana di z_off*std OLTRE la media nella direzione
del profitto -> target piu' ambizioso. NB: lo z e' un overshoot ADDIZIONALE rispetto
alla media corrente; la media stessa si muove (come EXIT-10) quindi il target insegue
la media + overshoot.

CAP ONESTO (anti exit-in-perdita): se la media + overshoot finisce contro l'entry
(media e' gia' scappata oltre entry contro di noi), il TP non deve diventare un'uscita
in perdita mascherata. Floor a breakeven+fee come EXIT-10:
    long : tp(j) = max(tp(j), entry*(1+0.002))
    short: tp(j) = min(tp(j), entry*(1-0.002))

SL FISSO (sl0) invariato; horizon = max_bars invariato.

ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() e std_n =
rolling(n).std(ddof=0) su close (causali: ogni valore dipende solo da close <= indice).
In levels(j) si legge sma_n[j-1] e std_n[j-1] -> solo dati <= j-1, mai il bar j.
SL e horizon invariati. OK per costruzione.

GRID: n in {20, 50} x z_off in {0.25, 0.5} (4 celle).
"""
import sys
from pathlib import Path

import numpy as np
import pandas as pd

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate  # noqa: E402


class ZOvershoot(ExitPolicy):
    name = "z_overshoot"

    @classmethod
    def prepare(cls, ctx, **params):
        n = int(params.get("n", 50))
        kmean = f"sma_{n}"
        kstd = f"std_{n}"
        if kmean not in ctx or kstd not in ctx:
            c = pd.Series(ctx["close"])
            ctx[kmean] = c.rolling(n).mean().values
            ctx[kstd] = c.rolling(n).std(ddof=0).values

    def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
        super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
        n = int(params.get("n", 50))
        self.z_off = float(params.get("z_off", 0.5))
        self.sma = ctx[f"sma_{n}"]
        self.std = ctx[f"std_{n}"]
        # cap onesto: il TP non puo' diventare un'uscita in perdita
        if d == 1:
            self.cap = entry * (1.0 + 0.002)
        else:
            self.cap = entry * (1.0 - 0.002)

    def levels(self, j: int):
        # SOLO dati <= j-1
        m = self.sma[j - 1]
        s = self.std[j - 1]
        if not (np.isfinite(m) and np.isfinite(s)):
            # warmup non pronto -> ricadi sul TP fisso d'entrata
            return self.tp0, self.sl0, 1.0
        tp = m + self.d * self.z_off * s
        if self.d == 1:
            tp = max(tp, self.cap)
        else:
            tp = min(tp, self.cap)
        return tp, self.sl0, 1.0


GRID = [
    {"n": 20, "z_off": 0.25},
    {"n": 20, "z_off": 0.5},
    {"n": 50, "z_off": 0.25},
    {"n": 50, "z_off": 0.5},
]


if __name__ == "__main__":
    evaluate(ZOvershoot, GRID)
