research(exit-lab): 34 agenti su exit dinamiche → EXIT-16 close-confirm SL PROMOSSO a livello PORT06

23 famiglie esplorate (harness condiviso exit_lab, train/OOS embargo nov-2023,
tutto lo storico 1h 2018-2026) + 10 verifiche avversariali + test PORT06.
'Cavalcare il prezzo' non esiste (4a conferma: oltre il TP=media non c'e' coda).
Scoperta: lo SL intrabar fisso e' il distruttore di valore n.1 delle fade
(stop da wick = falsi negativi). Forma robusta: SL solo su CHIUSURA oltre
sl0±0.5·ATR14 — PORT06 FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS 8.82→10.06.
Collaterali: bias gap-through dell'engine sugli stop stretti; ramo -2% del
worker morto con sl=0. Diario: docs/diary/2026-06-04-exit-lab.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-04 21:16:58 +00:00
parent 3accc91f84
commit ad65a0b344
36 changed files with 5116 additions and 0 deletions
@@ -0,0 +1,75 @@
"""EXIT-01 — trail_atr_ride: TP RIMOSSO, cavalcata pura con SL trailing chandelier.
Idea: le fade mean-reversion escono oggi al TP fisso (alla media) + SL + max_bars.
Qui togliamo il TP e lasciamo correre il trade, proteggendolo con un SL trailing
"chandelier" a k*ATR dal massimo favorevole raggiunto. Lo stop puo' solo stringersi
(mai allargarsi). Orizzonte esteso (cap HARD_CAP=240) per dare spazio al runner.
Long: stop(j) = max( sl0, max(high[i..j-1]) - k*atr14[j-1] ) (sale, mai scende)
Short: stop(j) = min( sl0, min(low[i..j-1]) + k*atr14[j-1] ) (scende, mai sale)
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- massimo/minimo favorevole sullo slice [i .. j-1] (mantenuto incrementalmente,
aggiornato col bar j-1 prima di calcolare lo stop attivo nel bar j);
- atr14[j-1] (indice causale).
Nessun TP -> nessun fill parziale. after_bar non usato (chiusura solo a orizzonte/SL).
GRID: k in {2.0, 3.0, 4.0} x horizon_mult in {2, 4} (6 celle). horizon = mult*mb cap 240.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP
class TrailAtrRide(ExitPolicy):
name = "trail_atr_ride"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=3.0, horizon_mult=4, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(int(horizon_mult) * mb, HARD_CAP)
# estremo favorevole sullo slice [i..j-1]; inizializzato al bar di entrata i
# (il primo bar valutato e' j=i+1, dove lo slice [i..j-1]=[i..i] e' noto).
self.fav_high = ctx["high"][i]
self.fav_low = ctx["low"][i]
self._last_seen = i # ultimo indice gia' incorporato nell'estremo
# stop trailing monotono: parte da sl0 e puo' solo stringersi
self.cur_stop = sl0
def levels(self, j):
h = self.ctx["high"]
l = self.ctx["low"]
atr = self.ctx["atr14"]
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
if h[self._last_seen] > self.fav_high:
self.fav_high = h[self._last_seen]
if l[self._last_seen] < self.fav_low:
self.fav_low = l[self._last_seen]
a = atr[j - 1]
if a != a: # NaN nei primi 14 bar -> resta sullo stop corrente
return None, self.cur_stop, 1.0
if self.d == 1:
cand = self.fav_high - self.k * a
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE (stringersi)
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso
GRID = [
{"k": k, "horizon_mult": m}
for k in (2.0, 3.0, 4.0)
for m in (2, 4)
]
if __name__ == "__main__":
evaluate(TrailAtrRide, GRID)
@@ -0,0 +1,68 @@
"""EXIT-02 — trail_atr_keep_tp.
Chandelier trailing stop a k*ATR dall'estremo favorevole RAGGIUNTO dall'entrata,
MA il TP fisso tp0 dall'entrata RESTA attivo: si esce al PRIMO dei due (il TP al
livello, oppure il trail). horizon = max_bars (invariato).
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
long : chand = max(high[i..j-1]) - k*atr14[j-1]; sl = max(sl0, chand)
short: chand = min(low[i..j-1]) + k*atr14[j-1]; sl = min(sl0, chand)
Il max(sl0, chand) (per il long) tiene la protezione iniziale a sl0 e lascia che
il trail TIGHTEN solo quando il prezzo corre a favore -> "ride" controllato che
non allenta mai il rischio iniziale. Il TP non viene toccato: una fade che torna
alla media esce comunque al TP come la base; il trail morde solo quando il TP non
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
GRID: k in {1.5, 2.0, 3.0, 4.0} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class TrailATRKeepTP(ExitPolicy):
name = "trail_atr_keep_tp"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k", 2.0))
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
self.run_hi = self.high[i]
self.run_lo = self.low[i]
self.last_seen = i # ultimo indice gia' incorporato nel running extremum
def _update_running(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo running. upto = j-1,
quindi NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
if self.high[self.last_seen] > self.run_hi:
self.run_hi = self.high[self.last_seen]
if self.low[self.last_seen] < self.run_lo:
self.run_lo = self.low[self.last_seen]
def levels(self, j: int):
self._update_running(j - 1) # solo dati <= j-1
a = self.atr[j - 1]
if a is None or a != a: # NaN -> nessun trail, usa sl0
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand) # piu' protettivo (stop piu' alto)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand) # piu' protettivo (stop piu' basso)
return self.tp0, sl, 1.0
GRID = [{"k": 1.5}, {"k": 2.0}, {"k": 3.0}, {"k": 4.0}]
if __name__ == "__main__":
evaluate(TrailATRKeepTP, GRID)
@@ -0,0 +1,85 @@
"""EXIT-03 — trail_pct: trailing percentuale dall'high-water-mark favorevole (sui CLOSE).
Idea: invece di un trail ATR (EXIT-01/02), lo stop segue l'estremo favorevole
RAGGIUNTO dai CLOSE a una distanza percentuale fissa p. Lo stop puo' solo
stringersi (mai allentarsi sotto sl0 lato rischio).
Long : hwm = max(close[i..j-1]); stop(j) = max(sl0, hwm*(1-p)) (sale, mai scende)
Short: lwm = min(close[i..j-1]); stop(j) = min(sl0, lwm*(1+p)) (scende, mai sale)
Due varianti:
keep_tp=True -> il TP fisso tp0 dall'entrata RESTA attivo (esci al primo dei due),
horizon = max_bars (invariato). Il trail morde solo se il TP non
viene raggiunto e il prezzo ha prima corso a favore e poi ritracciato.
keep_tp=False -> TP RIMOSSO (cavalcata pura), horizon = 2*max_bars (cap HARD_CAP).
Si esce solo sul trailing-stop o a orizzonte.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- hwm/lwm sullo slice [i..j-1] dei CLOSE (mantenuto incrementalmente col bar j-1);
- p e' una costante -> nessuna dipendenza da indicatori del bar j.
L'hwm sui close (non sugli high) e' deliberato: il close e' il dato su cui il
worker live decide al poll del tick; un hwm sugli high anticiperebbe un livello
che il close non ha ancora confermato.
GRID: p in {0.005, 0.01, 0.02} x keep_tp in {True, False} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class TrailPct(ExitPolicy):
name = "trail_pct"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb,
p=0.01, keep_tp=True, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.p = float(p)
self.keep_tp = bool(keep_tp)
if not self.keep_tp:
self.horizon = min(2 * mb, HARD_CAP)
self.close = ctx["close"]
# high/low-water-mark sui CLOSE, solo barre <= j-1; init = close d'entrata i
self.hwm = self.close[i]
self.lwm = self.close[i]
self.last_seen = i # ultimo indice gia' incorporato
# stop trailing monotono: parte da sl0 e puo' solo stringersi
self.cur_stop = sl0
def _update_wm(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo sui close. upto = j-1
-> NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
cv = self.close[self.last_seen]
if cv > self.hwm:
self.hwm = cv
if cv < self.lwm:
self.lwm = cv
def levels(self, j: int):
self._update_wm(j - 1) # solo dati <= j-1
if self.d == 1:
cand = self.hwm * (1.0 - self.p)
if cand > self.cur_stop: # lo stop long puo' solo SALIRE
self.cur_stop = cand
else:
cand = self.lwm * (1.0 + self.p)
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = cand
tp = self.tp0 if self.keep_tp else None
return tp, self.cur_stop, 1.0
GRID = [
{"p": p, "keep_tp": kt}
for p in (0.005, 0.01, 0.02)
for kt in (True, False)
]
if __name__ == "__main__":
evaluate(TrailPct, GRID)
@@ -0,0 +1,74 @@
"""EXIT-04 — breakeven (protezione).
Quando il profitto su CLOSE supera k*ATR(entry) (ATR fissato all'entrata,
atr14[i]), sposta lo SL a BREAKEVEN = entry +/- buffer. Il buffer e' a favore
(0.2% = 2*fee_rt*entry) cosi' l'uscita a BE non e' in perdita per le fee. Il TP
fisso tp0 RESTA invariato. horizon = max_bars (invariato).
Una volta armato il breakeven, lo SL non torna mai indietro (cliquet): protegge
il profitto gia' maturato senza allentare il rischio.
Stop attivo nel bar j (solo dati <= j-1, anti-look-ahead):
- trigger: si arma quando close[j-1] e' a favore di >= k*atr14[i] dall'entrata
(atr14[i] = ATR all'entrata, costante; close[j-1] = ultimo close noto).
- prima dell'arm: sl = sl0 (protezione iniziale invariata).
- dopo l'arm:
long : be = entry + buffer; sl = max(sl0, be)
short: be = entry - buffer; sl = min(sl0, be)
NB max/min con sl0 fa si' che lo SL non venga mai ALLENTATO: se sl0 fosse
gia' oltre il BE (raro), resta sl0. Tipicamente sl0 e' sotto entry (long) e
il BE lo alza -> stop piu' protettivo.
GRID: k in {0.5, 1.0, 1.5} x buffer_frac in {0.0, 0.002} (6 celle).
buffer = buffer_frac * entry. buffer_frac 0.002 = 0.2% = 2*fee_rt a favore.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class Breakeven(ExitPolicy):
name = "breakeven"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k", 1.0))
self.buffer_frac = float(params.get("buffer_frac", 0.002))
self.close = ctx["close"]
self.atr = ctx["atr14"]
self.atr_entry = self.atr[i] # ATR all'entrata, costante
self.buffer = self.buffer_frac * entry
self.armed = False
def levels(self, j: int):
# arma il breakeven usando SOLO close[j-1] (ultimo close noto) e atr14[i]
if not self.armed and self.atr_entry is not None and self.atr_entry == self.atr_entry:
cprev = self.close[j - 1]
profit = (cprev - self.entry) * self.d # >0 = a favore
if profit >= self.k * self.atr_entry:
self.armed = True
if not self.armed:
return self.tp0, self.sl0, 1.0
if self.d == 1:
be = self.entry + self.buffer
sl = max(self.sl0, be) # non allenta mai
else:
be = self.entry - self.buffer
sl = min(self.sl0, be)
return self.tp0, sl, 1.0
GRID = [
{"k": 0.5, "buffer_frac": 0.0},
{"k": 0.5, "buffer_frac": 0.002},
{"k": 1.0, "buffer_frac": 0.0},
{"k": 1.0, "buffer_frac": 0.002},
{"k": 1.5, "buffer_frac": 0.0},
{"k": 1.5, "buffer_frac": 0.002},
]
if __name__ == "__main__":
evaluate(Breakeven, GRID)
@@ -0,0 +1,93 @@
"""EXIT-05 — ratchet: stop a cricchetto sul profitto su close (high-water-mark).
Idea: una volta che il trade ha messo a segno profitto (su CLOSE), proteggiamo
una FRAZIONE f del profitto migliore raggiunto. hwm = miglior close a favore visto
finora; quando hwm e' a favore dell'entrata, lo stop si porta a
long : stop = entry + f*(hwm - entry)
short: stop = entry - f*(entry - hwm)
Lo stop e' SOLO-STRINGENTE (cricchetto): puo' solo avvicinarsi al prezzo, mai
allentarsi. Parte da sl0 (protezione iniziale invariata finche' il ratchet non
supera sl0).
Differenza dal trailing chandelier (EXIT-01): qui lo stop e' ancorato all'ENTRY
+ frazione del profitto su close (non al massimo high - k*ATR). Cattura profitto
in % del guadagno realizzato; con f<1 lascia un cuscinetto sotto l'hwm.
VARIANTI:
- keep_tp=True : il TP fisso tp0 resta (exit standard alla media) + ratchet di
backup sullo SL. horizon = max_bars.
- keep_tp=False: TP RIMOSSO -> ride puro protetto dal cricchetto, horizon=2*mb
(cap HARD_CAP). Qui il ratchet e' l'unica uscita oltre l'orizzonte.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- hwm calcolato sui close[i .. j-1] (mantenuto incrementalmente, aggiornato col
bar j-1 prima di calcolare lo stop attivo nel bar j; close[i]=entry e' incluso
cosi' hwm>=entry sempre, lo stop non scende mai sotto entry una volta armato).
Nessun TP nella variante ride -> nessun fill parziale. after_bar non usato.
GRID: f in {0.3, 0.5, 0.7} x keep_tp in {True, False} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class Ratchet(ExitPolicy):
name = "ratchet"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, f=0.5, keep_tp=True, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.f = float(f)
self.keep_tp = bool(keep_tp)
self.close = ctx["close"]
if not self.keep_tp:
self.horizon = min(2 * mb, HARD_CAP)
# high-water-mark del close a favore sullo slice [i..j-1]; il primo bar
# valutato e' j=i+1 -> slice [i..i] = close[i] = entry (gia' incorporato).
self.hwm = self.close[i] # = entry
self._last_seen = i
self.armed = False # diventa True quando hwm > entry (a favore)
# stop a cricchetto: parte da sl0, puo' solo stringersi
self.cur_stop = sl0
def levels(self, j: int):
c = self.close
d = self.d
# incorpora i close fino a j-1 (causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
if d == 1:
if cv > self.hwm:
self.hwm = cv
else:
if cv < self.hwm:
self.hwm = cv
tp = self.tp0 if self.keep_tp else None
# profitto su close del miglior bar a favore (>=0 perche' hwm parte da entry)
prof = (self.hwm - self.entry) * d
if prof > 0: # il trade e' andato a favore: arma il ratchet
self.armed = True
if d == 1:
cand = self.entry + self.f * (self.hwm - self.entry)
if cand > self.cur_stop: # cricchetto: lo stop long solo SALE
self.cur_stop = cand
else:
cand = self.entry - self.f * (self.entry - self.hwm)
if cand < self.cur_stop: # cricchetto: lo stop short solo SCENDE
self.cur_stop = cand
return tp, self.cur_stop, 1.0
GRID = [
{"f": f, "keep_tp": keep_tp}
for f in (0.3, 0.5, 0.7)
for keep_tp in (True, False)
]
if __name__ == "__main__":
evaluate(Ratchet, GRID)
@@ -0,0 +1,84 @@
"""EXIT-06 — sar_trail: Parabolic SAR semplificato come trailing stop, TP rimosso.
Idea: come EXIT-01 (cavalcata pura senza TP), ma lo stop trailing non e' un
chandelier a k*ATR fisso bensi' un Parabolic SAR semplificato che ACCELERA: il
fattore af parte a 0.02 e cresce di af_step ad ogni NUOVO estremo favorevole
(cap af_max). Lo stop si avvicina sempre piu' in fretta al prezzo man mano che il
trade corre nella direzione giusta -> protegge i runner stringendo aggressivamente.
sar(j) = sar(j-1) + af * (ep - sar(j-1)) con ep = estremo favorevole (high long / low short)
stop attivo nel bar j = max(sl0, sar) long (min(sl0, sar) short)
af: 0.02 -> +af_step ad ogni nuovo ep -> cap af_max
TP RIMOSSO, horizon = 4*mb (cap HARD_CAP=240).
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1. L'estremo favorevole `ep` e lo
stato SAR vengono aggiornati incorporando i bar fino a j-1 (gia' chiusi al poll
del bar j). sar(j-1) -> sar(j) usa ep aggiornato a j-1. Nessun dato del bar j.
Nessun TP -> nessun fill parziale; after_bar non usato (uscita solo a SL/orizzonte).
GRID: af_max in {0.1, 0.2} x af_step in {0.02, 0.04} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP
class SarTrail(ExitPolicy):
name = "sar_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, af_max=0.2, af_step=0.02, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.af_max = float(af_max)
self.af_step = float(af_step)
self.horizon = min(4 * mb, HARD_CAP)
# estremo favorevole (ep) sullo slice [i..j-1]; inizializzato al bar di entrata
self.ep = ctx["high"][i] if d == 1 else ctx["low"][i]
self.af = 0.02
# SAR iniziale: lo stop di partenza (sl0). Il SAR converge verso ep dall'sl0.
self.sar = sl0
self._last_seen = i # ultimo indice gia' incorporato nello stato SAR
self.cur_stop = sl0 # stop monotono (solo si stringe)
def _step(self, idx):
"""Incorpora il bar `idx` (causale, <= j-1) aggiornando ep, af e sar."""
h = self.ctx["high"][idx]
l = self.ctx["low"][idx]
# avanza il SAR di un passo verso l'estremo favorevole corrente
self.sar = self.sar + self.af * (self.ep - self.sar)
# nuovo estremo favorevole? -> accelera
if self.d == 1:
if h > self.ep:
self.ep = h
self.af = min(self.af + self.af_step, self.af_max)
else:
if l < self.ep:
self.ep = l
self.af = min(self.af + self.af_step, self.af_max)
def levels(self, j):
# incorpora i bar fino a j-1 (gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
self._step(self._last_seen)
if self.d == 1:
if self.sar > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = self.sar
else:
if self.sar < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = self.sar
return None, self.cur_stop, 1.0 # TP rimosso
GRID = [
{"af_max": am, "af_step": st}
for am in (0.1, 0.2)
for st in (0.02, 0.04)
]
if __name__ == "__main__":
evaluate(SarTrail, GRID)
@@ -0,0 +1,73 @@
"""EXIT-07 — TP dinamico che DECADE col tempo (verso breakeven).
PRIOR (vincolante, ieri SCARTATO un ladder 80/20): il TP delle fade sta alla
MEDIA, e li' il movimento e' esaurito -> il prezzo spesso NON arriva al TP e il
trade muore a max_bars (close) o sullo SL. Idea di questa famiglia: invece di
tenere il TP fisso al target ambizioso (la media), farlo DECADERE col passare
delle barre verso un target piu' modesto (breakeven che copre le fee, o meta'
strada). Cosi' un trade che s'e' avvicinato ma non ha toccato il TP pieno puo'
essere chiuso in profitto/pareggio prima di scadere a max_bars potenzialmente
in perdita. SL FISSO (sl0) invariato. horizon = max_bars invariato.
TP attivo nel bar j:
frac(j) = clamp( ((j - i) / mb) * speed , 0, 1 )
tp(j) = tp0 + frac(j) * (target_fin - tp0)
- frac dipende SOLO da j (indice di tempo) e da costanti note all'entrata
(i, mb, speed) -> NESSUN dato > j-1 -> anti-look-ahead OK per costruzione.
- long (d=+1): tp0 > entry; target_fin <= tp0 -> il TP SCENDE verso entry.
- short (d=-1): tp0 < entry; target_fin >= tp0 -> il TP SALE verso entry.
- speed>1: il TP raggiunge il target finale prima della fine (clamp a 1).
target_fin (param "target"):
- "breakeven": entry*(1 + d*0.003) -> copre 0.3% (~ fee 0.10%RT + margine).
Per long sta sopra entry, per short sotto: chiudere li' NON e' in perdita.
- "halfway" : (entry + tp0)/2 -> meta' strada fra entry e il TP pieno.
GRID: speed in {0.5, 1.0, 1.5} x target in {breakeven, halfway} (6 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class TpDecay(ExitPolicy):
name = "tp_decay"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.speed = float(params.get("speed", 1.0))
target = str(params.get("target", "breakeven"))
if target == "breakeven":
self.target_fin = entry * (1.0 + d * 0.003)
elif target == "halfway":
self.target_fin = 0.5 * (entry + tp0)
else:
raise ValueError(f"target sconosciuto: {target}")
# mb puo' essere 0 in teoria; protezione
self.mb_eff = max(int(mb), 1)
def levels(self, j: int):
# frac dipende SOLO dal tempo (j - i), mb, speed: costanti note all'entrata
frac = ((j - self.i) / self.mb_eff) * self.speed
if frac < 0.0:
frac = 0.0
elif frac > 1.0:
frac = 1.0
tp = self.tp0 + frac * (self.target_fin - self.tp0)
return tp, self.sl0, 1.0
GRID = [
{"speed": 0.5, "target": "breakeven"},
{"speed": 1.0, "target": "breakeven"},
{"speed": 1.5, "target": "breakeven"},
{"speed": 0.5, "target": "halfway"},
{"speed": 1.0, "target": "halfway"},
{"speed": 1.5, "target": "halfway"},
]
if __name__ == "__main__":
evaluate(TpDecay, GRID)
@@ -0,0 +1,77 @@
"""EXIT-08 — SL che si STRINGE linearmente col tempo (time-based stop tighten).
Lo stop iniziale sl0 si avvicina linearmente a un livello d'arrivo `target`
man mano che il trade invecchia. TP fisso (tp0) e horizon=max_bars invariati.
frac(j) = clamp( (j - i) / (mb * stretch), 0, 1 )
sl(j) = sl0 + frac(j) * (target - sl0)
target ("arrivo"):
- "entry" -> entry (a fine corsa lo stop e' a breakeven)
- "midpoint" -> (entry + sl0)/2 (a fine corsa lo stop e' a meta' strada)
Direzioni: il segno e' gestito dalla geometria di sl0 vs entry.
long (d=1): sl0 < entry -> target >= sl0 -> lo stop SALE verso entry
short (d=-1): sl0 > entry -> target <= sl0 -> lo stop SCENDE verso entry
In entrambi i casi lo stop si stringe (avvicina al prezzo d'ingresso) col tempo.
stretch:
- 1.0 : lo stop raggiunge `target` a max_bars (fine horizon).
- 2.0 : lo stop raggiunge `target` a 2*max_bars -> stringe a META' velocita',
quindi a max_bars e' solo a meta' del cammino sl0->target (piu' lasco).
ANTI-LOOK-AHEAD: sl(j) dipende SOLO da {i, j, mb, stretch, entry, sl0}, tutti
noti all'ENTRATA (close[i]). Nessun dato del bar j o successivi entra nel livello
attivo nel bar j. Il TP resta tp0. -> contratto rispettato per costruzione.
MECCANISMO ATTESO: stringere lo stop col tempo taglia prima i trade che ristagnano
(non vanno al TP ne' allo SL iniziale e scadrebbero a max_bars vicino al BE/in
perdita). Rischio: stoppa fuori trade che sarebbero rientrati verso il TP (la fade
e' mean-reversion: il movimento contrario e' spesso transitorio) -> puo' alzare lo
stop-rate effettivo e tagliare winner ritardatari.
GRID: stretch in {1.0, 2.0} x target in {entry, midpoint} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class SlTighten(ExitPolicy):
name = "sl_tighten"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.stretch = float(params.get("stretch", 1.0))
self.target_kind = str(params.get("target", "entry"))
if self.target_kind == "entry":
self.target = entry
elif self.target_kind == "midpoint":
self.target = 0.5 * (entry + sl0)
else:
raise ValueError(f"target sconosciuto: {self.target_kind}")
self.denom = max(self.mb * self.stretch, 1e-9)
def levels(self, j: int):
# frac dipende solo da i, j, mb, stretch -> noti all'entrata. No look-ahead.
frac = (j - self.i) / self.denom
if frac < 0.0:
frac = 0.0
elif frac > 1.0:
frac = 1.0
sl = self.sl0 + frac * (self.target - self.sl0)
return self.tp0, sl, 1.0
GRID = [
{"stretch": 1.0, "target": "entry"},
{"stretch": 1.0, "target": "midpoint"},
{"stretch": 2.0, "target": "entry"},
{"stretch": 2.0, "target": "midpoint"},
]
if __name__ == "__main__":
evaluate(SlTighten, GRID)
@@ -0,0 +1,114 @@
"""EXIT-09 — tp_extend_momentum: cavalca il momentum OLTRE il TP.
Idea: le fade escono al TP fisso (alla media). Ma quando il movimento NON e'
esaurito e sfonda il TP in chiusura, vogliamo provare a cavalcarlo invece di
prendere solo il TP. State machine a 2 fasi:
FASE A (default, TP intatto): tp = tp0, sl = sl0. Exit standard alla media.
Finche' close[j-1] NON ha superato tp0 in chiusura, ci si comporta come base.
FASE B (armata quando close[j-1] supera tp0 a favore):
- TP RIMOSSO (None): non si esce piu' al livello fisso;
- attiva un trail "chandelier" a k*ATR ancorato all'ESTREMO favorevole visto
DAL bar di superamento in poi (high per long, low per short);
- FLOOR a tp0: lo stop trailing non scende mai sotto tp0 (long) / non sale mai
sopra tp0 (short), cosi' NON si esce MAI peggio del TP originale. Lo stop e'
inoltre solo-stringente (cricchetto): puo' solo avvicinarsi al prezzo.
Trigger di arma (su close[j-1], a favore):
long : close[j-1] > tp0 -> arma
short: close[j-1] < tp0 -> arma
Trail attivo (FASE B), con floor a tp0:
long : stop = max( tp0, fav_high_post - k*atr14[j-1] ) (cricchetto: solo sale)
short: stop = min( tp0, fav_low_post + k*atr14[j-1] ) (cricchetto: solo scende)
Orizzonte esteso a 3*mb (cap HARD_CAP=240) per dare spazio al runner post-TP.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- il trigger di arma legge close[j-1] (gia' chiuso al poll del bar j);
- l'estremo favorevole post-superamento e' sullo slice [arm_idx .. j-1]
(mantenuto incrementalmente, incorporando i bar fino a j-1);
- atr14[j-1] (indice causale).
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
GRID: k in {1.0, 2.0, 3.0} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class TpExtendMomentum(ExitPolicy):
name = "tp_extend_momentum"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(3 * mb, HARD_CAP)
self.close = ctx["close"]
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# FASE A finche' armed=False. Si arma quando close[j-1] supera tp0 a favore.
self.armed = False
self.arm_idx = None # bar dal quale si ancora l'estremo favorevole
self.fav_high = None # estremo favorevole post-superamento (long)
self.fav_low = None # estremo favorevole post-superamento (short)
self._last_seen = i # ultimo close gia' esaminato per il trigger
self._fav_seen = None # ultimo bar gia' incorporato nell'estremo
# stop trailing monotono in FASE B: parte dal floor tp0, solo si stringe
self.cur_stop = None
def levels(self, j: int):
c = self.close
d = self.d
# ---- FASE A -> controlla l'arma sui close fino a j-1 (causali) -----------
if not self.armed:
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
if crossed:
# arma: ancora l'estremo favorevole DAL bar di superamento
self.armed = True
self.arm_idx = self._last_seen
self.fav_high = self.high[self.arm_idx]
self.fav_low = self.low[self.arm_idx]
self._fav_seen = self.arm_idx
self.cur_stop = self.tp0 # floor di partenza = tp0
break
if not self.armed:
return self.tp0, self.sl0, 1.0 # ancora FASE A: TP/SL fissi
# ---- FASE B -> TP rimosso, trail chandelier ancorato post-superamento -----
# incorpora i bar [arm_idx .. j-1] nell'estremo favorevole (dati causali)
while self._fav_seen < j - 1:
self._fav_seen += 1
if self.high[self._fav_seen] > self.fav_high:
self.fav_high = self.high[self._fav_seen]
if self.low[self._fav_seen] < self.fav_low:
self.fav_low = self.low[self._fav_seen]
a = self.atr[j - 1]
if a == a: # non-NaN
if d == 1:
cand = self.fav_high - self.k * a
cand = max(cand, self.tp0) # floor: mai sotto il TP
if cand > self.cur_stop: # cricchetto: solo sale
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
cand = min(cand, self.tp0) # floor: mai sopra il TP
if cand < self.cur_stop: # cricchetto: solo scende
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
GRID = [{"k": k} for k in (1.0, 2.0, 3.0)]
if __name__ == "__main__":
evaluate(TpExtendMomentum, GRID)
@@ -0,0 +1,79 @@
"""EXIT-10 — tp_moving_mean: il TP INSEGUE la media corrente (non quello fisso).
PRIOR (vincolante): le fade puntano alla MEDIA. Oggi il TP e' CONGELATO all'entrata
(tp0 = SMA_n(close) al close[i-1]). Ma la media SI MUOVE durante il trade: se il
prezzo ci mette qualche barra a rientrare, la media si e' gia' spostata. Idea:
ridefinire il TP come la SMA_n corrente -> tp(j) = SMA_n(close)[j-1]. Il target
"segue" la media verso cui la fade punta, invece di mirare a una media stantia.
long (d=+1): si compra sotto la media -> tp(j) = SMA_n[j-1] (sopra entry, di norma).
short (d=-1): si vende sopra la media -> tp(j) = SMA_n[j-1] (sotto entry, di norma).
CAP ONESTO (anti exit-in-perdita): se la media si muove CONTRO (long: media scende
sotto entry; short: media sale sopra entry), il TP diventerebbe un'uscita in perdita
mascherata. Lo evitiamo:
long : tp(j) = max(tp(j), entry*(1+0.002))
short: tp(j) = min(tp(j), entry*(1-0.002))
0.002 (~ 0.10% RT fee + margine) garantisce che il tocco del TP sia >= breakeven.
SL FISSO (sl0) invariato; horizon = max_bars invariato.
ANTI-LOOK-AHEAD: prepare() precalcola sma_n = rolling(n).mean() su close (causale,
ogni valore dipende solo da close <= quel-indice). In levels(j) si legge sma_n[j-1]
-> solo dati <= j-1, mai il bar j. SL e horizon invariati. OK per costruzione.
GRID: n in {20, 50, 100} (3 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 TpMovingMean(ExitPolicy):
name = "tp_moving_mean"
@classmethod
def prepare(cls, ctx, **params):
n = int(params.get("n", 50))
key = f"sma_{n}"
if key not in ctx:
c = ctx["close"]
ctx[key] = pd.Series(c).rolling(n).mean().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.sma = ctx[f"sma_{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]
if not np.isfinite(m):
# warmup della SMA non ancora pronto -> ricadi sul TP fisso d'entrata
return self.tp0, self.sl0, 1.0
if self.d == 1:
tp = max(m, self.cap)
else:
tp = min(m, self.cap)
return tp, self.sl0, 1.0
GRID = [
{"n": 20},
{"n": 50},
{"n": 100},
]
if __name__ == "__main__":
evaluate(TpMovingMean, GRID)
@@ -0,0 +1,93 @@
"""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)
@@ -0,0 +1,98 @@
"""EXIT-12 — partial_tp_trail: partial AL TP PIENO, poi il runner CORRE col trail.
Idea (diversa dal ladder 80/20 gia' SCARTATO, che metteva uno stop FISSO alla
soglia 80% del TP): qui il partial avviene AL TP PIENO (tp0, alla media), esce una
frazione q del trade, e il RESIDUO resta SENZA TP, protetto da un trailing
chandelier k*ATR ancorato all'estremo favorevole raggiunto DOPO il partial; il
floor dello stop e' tp0 -> il profitto al livello TP e' LOCKATO (il runner non
puo' mai chiudere peggio di tp0). horizon esteso a 3*mb (cap HARD_CAP) per dare
spazio al runner.
Fase 1 (pre-partial): exit standard = (tp0, sl0). Al tocco di tp0 esce q.
Fase 2 (post-partial): TP rimosso. Trail sul residuo:
Long : stop(j) = max( tp0, max(high[part..j-1]) - k*atr14[j-1] ) (solo sale)
Short: stop(j) = min( tp0, min(low [part..j-1]) + k*atr14[j-1] ) (solo scende)
floor = tp0 -> profitto lockato al livello TP, MAI peggio.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- estremo favorevole post-partial sullo slice [part .. j-1] (mantenuto
incrementalmente, aggiornato col bar j-1 prima di calcolare lo stop in j);
- atr14[j-1] (indice causale).
on_partial(j) registra solo l'indice del bar di partial (j) e il prezzo (tp0):
l'estremo della fase 2 parte da high/low del bar di partial j (<= j, ma il primo
bar valutato in fase 2 e' j+1, slice [j..j] noto al poll di j+1 -> causale).
GRID: q in {0.5, 0.7} x k in {2.0, 3.0} (4 celle). tp_frac=q.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class PartialTpTrail(ExitPolicy):
name = "partial_tp_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, q=0.5, k=3.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.q = float(q)
self.k = float(k)
self.horizon = min(3 * mb, HARD_CAP)
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# stato fase 2 (post-partial)
self.partial_idx = None # bar in cui e' avvenuto il partial (None = fase 1)
self.fav_high = None # estremo favorevole sullo slice [partial..j-1]
self.fav_low = None
self._last_seen = None # ultimo indice incorporato nell'estremo
self.cur_stop = None # stop trailing fase 2, floor=tp0, monotono
def levels(self, j):
# ---- Fase 1: pre-partial -> exit standard (tp0, sl0), esce frazione q al TP
if self.partial_idx is None:
return self.tp0, self.sl0, self.q
# ---- Fase 2: post-partial -> TP rimosso, trail chandelier floor=tp0
h, l, atr, d = self.high, self.low, self.atr, self.d
# incorpora i bar fino a j-1 (dati causali, gia' chiusi al poll del bar j)
while self._last_seen < j - 1:
self._last_seen += 1
if h[self._last_seen] > self.fav_high:
self.fav_high = h[self._last_seen]
if l[self._last_seen] < self.fav_low:
self.fav_low = l[self._last_seen]
a = atr[j - 1]
if a != a: # NaN -> resta sullo stop corrente
return None, self.cur_stop, 1.0
if d == 1:
cand = self.fav_high - self.k * a
if cand > self.cur_stop: # lo stop long puo' solo SALIRE (stringersi)
self.cur_stop = cand
else:
cand = self.fav_low + self.k * a
if cand < self.cur_stop: # lo stop short puo' solo SCENDERE
self.cur_stop = cand
return None, self.cur_stop, 1.0
def on_partial(self, j, price, remaining):
# entra in fase 2: ancora l'estremo al bar di partial j (high/low[j] sono
# noti al poll del bar j+1, primo bar valutato in fase 2). floor=tp0.
self.partial_idx = j
self.fav_high = self.high[j]
self.fav_low = self.low[j]
self._last_seen = j
self.cur_stop = self.tp0 # profitto lockato al livello TP, MAI peggio
GRID = [
{"q": q, "k": k}
for q in (0.5, 0.7)
for k in (2.0, 3.0)
]
if __name__ == "__main__":
evaluate(PartialTpTrail, GRID)
@@ -0,0 +1,74 @@
"""EXIT-13 — hurst_exit: TP condizionato al REGIME (rolling-Hurst causale).
IDEA. Le fade puntano alla MEDIA: in regime ANTI-PERSISTENTE (Hurst basso) la
reversione tende a completarsi -> ha senso tenere il TP pieno tp0. In regime
PERSISTENTE/trending (Hurst alto) la reversione spesso NON arriva fino in fondo
(il movimento continua, lo stop-loss si concentra li': vedi loss-guard Hurst) ->
conviene "prendere quel che c'e'": TP anticipato a meta' strada (entry+tp0)/2.
- H[j-1] < h_lo (anti-persistente): tp(j) = tp0 (reversione completa)
- H[j-1] >= h_lo (persistente): tp(j) = (entry+tp0)/2 (TP a meta')
SL FISSO (sl0) e horizon (max_bars) invariati.
ANTI-LOOK-AHEAD. prepare() precalcola H = rolling_hurst(close, window=100, step=6)
una volta sullo sleeve. rolling_hurst e' causale: H[k] usa returns[k-window:k] e
returns[m]=diff(log(close))[m] dipende da close[m+1], quindi H[k] dipende solo da
close <= k. In levels(j) leggo H[j-1] -> solo dati <= j-1. SL/horizon invariati.
La decisione del regime e' fissata col bar precedente, il bar j tocca i livelli.
NB sul TP a meta'. Lo "step=6" significa che H e' costante a tratti di 6 barre; il
regime e' ri-letto ogni bar (j-1) ma cambia valore ogni 6. Il TP a meta' strada
e' SEMPRE >= breakeven per costruzione (e' fra entry e tp0, e tp0 e' gia' oltre il
margine fee per come la fade lo fissa), quindi non maschera uscite in perdita.
GRID: h_lo in {0.45, 0.50, 0.55} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from src.fractal.indicators import rolling_hurst # noqa: E402
class HurstExit(ExitPolicy):
name = "hurst_exit"
@classmethod
def prepare(cls, ctx, **params):
if "hurst100" not in ctx:
c = ctx["close"]
ctx["hurst100"] = rolling_hurst(c, window=100, step=6)
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.h = ctx["hurst100"]
self.h_lo = float(params.get("h_lo", 0.50))
self.tp_half = (entry + tp0) / 2.0 # TP a meta' strada (persistente)
def levels(self, j: int):
# SOLO dati <= j-1
hv = self.h[j - 1]
if not np.isfinite(hv):
return self.tp0, self.sl0, 1.0
if hv < self.h_lo:
tp = self.tp0 # anti-persistente: reversione completa
else:
tp = self.tp_half # persistente: prendi la meta'
return tp, self.sl0, 1.0
GRID = [
{"h_lo": 0.45},
{"h_lo": 0.50},
{"h_lo": 0.55},
]
if __name__ == "__main__":
evaluate(HurstExit, GRID)
@@ -0,0 +1,73 @@
"""EXIT-14 — protezione GIVEBACK dal picco (trailing sul PROFITTO, non sul prezzo).
Idea: una fade puo' correre a favore quasi fino al TP (alla media) e poi
ritracciare prima di toccarlo, restituendo gran parte del guadagno gia' maturato.
Questa policy traccia l'high-water-mark (hwm) del PROFITTO non realizzato sul
CLOSE; quando il profitto corrente scende sotto (1-g)*hwm_profit -- cioe' si e'
restituita una frazione g del picco -- e il picco era gia' "significativo"
(hwm_profit > soglia * dist(entry, tp0)), esce al close del bar (after_bar).
Il TP fisso tp0 e lo SL fisso sl0 RESTANO invariati e prioritari intrabar.
Razionale: protegge il profitto maturato senza toccare i livelli; non e' un
ladder (ieri scartato perche' al TP/media il movimento e' esaurito) ma un
trailing sul drawdown del trade quando ha gia' fatto buona parte del lavoro.
ANTI-LOOK-AHEAD:
- levels(j) NON e' toccato (TP/SL fissi): nessun dato futuro.
- after_bar(j) decide sul CLOSE del bar j (eseguibile al poll). Aggiorna l'hwm
con il profitto a close[j] e poi confronta: tutto con dati <= j. Il bar j e'
lecito in after_bar per contratto. L'hwm e' un cliquet (non scende mai).
GRID: g in {0.4, 0.6} x arm_frac in {0.3, 0.5} della distanza al TP (4 celle).
- g = frazione del picco di profitto restituita che fa scattare l'uscita.
- arm_frac = il giveback e' armato solo quando hwm_profit ha superato
arm_frac * dist(entry, tp0): finche' il trade non ha "fatto strada"
(frazione del cammino verso la media) non interviene -> non taglia i trade
che partono storti, solo quelli che hanno gia' guadagnato e poi mollano.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class Giveback(ExitPolicy):
name = "giveback"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.g = float(params.get("g", 0.4))
self.arm_frac = float(params.get("arm_frac", 0.3))
self.close = ctx["close"]
# distanza (in prezzo, sempre positiva) dall'entrata al TP iniziale
self.dist_tp = abs(self.tp0 - entry) if self.tp0 is not None else 0.0
self.arm_level = self.arm_frac * self.dist_tp
self.hwm_profit = 0.0 # high-water-mark del profitto a favore (cliquet)
def after_bar(self, j: int) -> bool:
if self.dist_tp <= 0.0:
return False
# profitto NON realizzato a favore, valutato sul close del bar j (lecito qui)
profit = (self.close[j] - self.entry) * self.d
if profit > self.hwm_profit:
self.hwm_profit = profit # aggiorna il picco (non scende mai)
# arma solo se il picco ha superato la soglia (trade gia' "in cammino")
if self.hwm_profit < self.arm_level:
return False
# esci se il profitto corrente ha restituito >= g del picco
if profit < (1.0 - self.g) * self.hwm_profit:
return True
return False
GRID = [
{"g": 0.4, "arm_frac": 0.3},
{"g": 0.4, "arm_frac": 0.5},
{"g": 0.6, "arm_frac": 0.3},
{"g": 0.6, "arm_frac": 0.5},
]
if __name__ == "__main__":
evaluate(Giveback, GRID)
@@ -0,0 +1,61 @@
"""EXIT-15 — loser time-stop ("i loser muoiono giovani?").
Le fade tengono il trade fino a TP/SL fissi o max_bars. Qui aggiungiamo un
solo extra-exit CONDIZIONATO AL SEGNO della PnL: al k-esimo bar dopo l'entrata,
se il trade e' ancora in PERDITA sul CLOSE (unrealized < 0), chiudi il residuo
al close[j]. Se a quel bar il trade e' in profitto (o piatto), NON tocca nulla
e lascia correre verso TP/SL/max_bars come la base.
TP/SL fissi (tp0, sl0) INVARIATI; horizon = max_bars INVARIATO.
Differenza col time-stop semplice (gia' FALLITO il 2026-06-02, tagliava i
winner): qui non si esce per il solo passare del tempo, ma SOLO se la PnL e'
negativa. L'ipotesi e' che un trade ancora in rosso dopo k bar abbia bassa
probabilita' di recuperare e finisca per colpire lo SL (peggio) o scadere a
max_bars in perdita -> uscire prima limita la coda di perdite e libera capitale.
Anti-look-ahead: la decisione e' in `after_bar(j)`, che per contratto puo'
leggere il CLOSE del bar j (eseguibile al poll del tick). Confronto:
unrealized(j) = (close[j] - entry) * d (>0 = a favore)
Esce SOLO quando j - i == k (controllo una tantum al k-esimo bar). I livelli
TP/SL restano quelli base (nessun uso di dati futuri in levels()).
GRID: k in {4, 8, 12, 16} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class LoserTimestop(ExitPolicy):
name = "loser_timestop"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = int(params.get("k", 8))
self.close = ctx["close"]
# livelli base, invariati (nessun look-ahead)
def levels(self, j: int):
return self.tp0, self.sl0, 1.0
def after_bar(self, j: int) -> bool:
# controllo una tantum al k-esimo bar dopo l'entrata
if j - self.i != self.k:
return False
unrealized = (self.close[j] - self.entry) * self.d
return unrealized < 0.0
GRID = [
{"k": 4},
{"k": 8},
{"k": 12},
{"k": 16},
]
if __name__ == "__main__":
evaluate(LoserTimestop, GRID)
@@ -0,0 +1,78 @@
"""EXIT-16 — close_confirm_sl: STOP-LOSS confermato in CHIUSURA (immune ai wick).
IDEA. Lo SL intrabar fisso stoppa anche su un wick transitorio che buca sl0 e
poi rientra. Le fade sono mean-reversion: il movimento contrario e' spesso un
overshoot momentaneo. Qui lo SL NON e' piu' un livello toccato intrabar
(disattivato: sl=None nei livelli, l'engine non lo testa MAI sui low/high) ma
una CONFERMA sul CLOSE del bar: si esce solo se il prezzo CHIUDE oltre sl0.
in levels(j): sl = None (no stop intrabar -> immune ai wick), TP fisso tp0.
in after_bar(j):
long (d=1): esci al close[j] se close[j] < sl0 - buffer*atr14[j]
short (d=-1): esci al close[j] se close[j] > sl0 + buffer*atr14[j]
ONESTO. L'uscita avviene al CLOSE[j], che puo' essere PEGGIO di sl0 quando il
bar gappa o sfonda di slancio (il backtest paga il close reale, non sl0). Questo
e' il costo del "confermare": si rinuncia a fermarsi esatti a sl0 in cambio di
non farsi cacciare dai wick. Il buffer*atr richiede uno sfondamento ulteriore in
chiusura -> meno uscite ma piu' lontane da sl0 quando scattano.
ANTI-LOOK-AHEAD. levels(j) ritorna sl=None e tp0 (nessun dato del bar j). La
decisione di uscita e' in after_bar(j), che PER CONTRATTO puo' leggere il bar j:
close[j] e atr14[j] sono entrambi noti al close del bar j (atr14[j] = rolling
mean di TR fino a j; TR[j] usa high/low/close[j] e close[j-1], tutti chiusi a j).
Nessun dato > j entra nella decisione. -> contratto rispettato.
NB il HARD_CAP/horizon=max_bars resta: se ne' TP ne' close-confirm scattano, il
trade scade al close a max_bars come la base. Quindi un trade puo' restare in
posizione PIU' a lungo della base (la base poteva stopparlo intrabar prima):
l'avg_bars puo' SALIRE -> il ret va pesato per l'esposizione.
GRID: buffer in {0.0, 0.25, 0.5} ATR (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class CloseConfirmSl(ExitPolicy):
name = "close_confirm_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.buffer = float(params.get("buffer", 0.0))
self.close = ctx["close"]
self.atr = ctx["atr14"]
def levels(self, j: int):
# SL intrabar DISATTIVATO (immune ai wick): l'engine non testa low/high
# contro lo stop. TP fisso tp0 intrabar invariato.
return self.tp0, None, 1.0
def after_bar(self, j: int) -> bool:
# Decisione sul CLOSE del bar j -> per contratto j e' leggibile.
a = self.atr[j]
if not np.isfinite(a):
a = 0.0
cj = self.close[j]
if self.d == 1:
# long: stop confermato se chiude SOTTO sl0 - buffer*atr
return cj < self.sl0 - self.buffer * a
else:
# short: stop confermato se chiude SOPRA sl0 + buffer*atr
return cj > self.sl0 + self.buffer * a
GRID = [
{"buffer": 0.0},
{"buffer": 0.25},
{"buffer": 0.5},
]
if __name__ == "__main__":
evaluate(CloseConfirmSl, GRID)
@@ -0,0 +1,86 @@
"""EXIT-17 — wide_sl_trail: RESPIRA POI PROTEGGI.
Idea: l'SL fisso all'entrata (sl0) puo' essere troppo stretto -> stoppa fade che
poi sarebbero rientrate alla media. Qui ALLARGHIAMO l'SL iniziale a
sl_w = entry - w*(entry - sl0) (long, w>1 -> stop piu' lontano = piu' respiro)
sl_w = entry + w*(sl0 - entry) (short)
ma attiviamo SUBITO un trailing chandelier a k*ATR dall'estremo favorevole. Lo stop
attivo nel bar j e' il PIU' PROTETTIVO fra l'SL allargato e il chandelier:
long : sl = max(sl_w, max(high[i..j-1]) - k*ATR[j-1])
short: sl = min(sl_w, min(low[i..j-1]) + k*ATR[j-1])
TP fisso tp0 invariato; horizon = max_bars invariato.
Logica: finche' il prezzo NON corre a favore, il rischio e' l'SL allargato (respiro:
si tollera un ritracciamento iniziale piu' ampio nella speranza che la fade rientri).
Appena il prezzo corre a favore, il chandelier sale sopra sl_w e protegge il
profitto come un trail normale. Differenza da EXIT-02 (che usa max(sl0, chand)):
qui il floor iniziale e' PIU' LARGO di sl0, quindi early il rischio per-trade e'
maggiore (peggiora i loser veloci) ma si salvano fade che con sl0 sarebbero state
stoppate giusto prima del rientro.
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1 (estremo favorevole su [i..j-1]
mantenuto incrementalmente fino a j-1; atr14[j-1]). after_bar non usato.
GRID: w in {1.5, 2.0} x k in {2.0, 3.0} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class WideSLTrail(ExitPolicy):
name = "wide_sl_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, w=1.5, k=2.0, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.w = float(w)
self.k = float(k)
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
# SL iniziale ALLARGATO: piu' lontano dall'entrata (w>1)
if d == 1:
self.sl_w = entry - self.w * (entry - sl0)
else:
self.sl_w = entry + self.w * (sl0 - entry)
# estremo favorevole running (solo barre <= j-1); init = barra d'entrata i
self.run_hi = self.high[i]
self.run_lo = self.low[i]
self.last_seen = i
def _update_running(self, upto: int) -> None:
"""Incorpora le barre (last_seen, upto] nell'estremo favorevole. upto = j-1
-> NON tocca il bar j (anti-look-ahead)."""
while self.last_seen < upto:
self.last_seen += 1
if self.high[self.last_seen] > self.run_hi:
self.run_hi = self.high[self.last_seen]
if self.low[self.last_seen] < self.run_lo:
self.run_lo = self.low[self.last_seen]
def levels(self, j: int):
self._update_running(j - 1) # solo dati <= j-1
a = self.atr[j - 1]
if a is None or a != a: # NaN nei primi 14 bar -> usa l'SL allargato
return self.tp0, self.sl_w, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl_w, chand) # il piu' protettivo (stop piu' alto)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl_w, chand) # il piu' protettivo (stop piu' basso)
return self.tp0, sl, 1.0
GRID = [
{"w": w, "k": k}
for w in (1.5, 2.0)
for k in (2.0, 3.0)
]
if __name__ == "__main__":
evaluate(WideSLTrail, GRID)
@@ -0,0 +1,108 @@
"""EXIT-18 — SL STRUTTURALE su swing low/high (structural / chandelier-by-structure).
Idea: invece di uno stop FISSO a sl0 (ATR dall'entrata), uno stop ancorato alla
STRUTTURA recente del prezzo. Per un long: il livello "naturale" sotto cui la tesi
mean-reversion e' invalidata e' il minimo dello swing recente; se il prezzo rompe
sotto il minimo degli ultimi n bar la fade ha torto. Simmetrico per lo short sul
massimo recente.
long (d=1): level(j) = min(low[j-n .. j-1]) - buf
short (d=-1): level(j) = max(high[j-n .. j-1]) + buf
buf = 0.25 * atr14[j-1]
SOLO-STRINGENTE: lo stop parte da sl0 (protezione iniziale invariata) e si aggiorna
SOLO se il livello swing e' PIU' PROTETTIVO (cricchetto):
long : cur_stop = max(cur_stop, level) (lo stop puo' solo SALIRE)
short: cur_stop = min(cur_stop, level) (lo stop puo' solo SCENDERE)
Cosi' man mano che il prezzo (per una fade vincente) torna verso la media, lo swing
low/high sale/scende e lo stop segue, bloccando profitto. Non si allenta MAI.
TP fisso (tp0) e horizon=max_bars invariati: questa famiglia cambia SOLO lo stop.
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
- massimi/minimi sullo slice [j-n .. j-1] (lookback chiuso a j-1);
- buffer da atr14[j-1] (indicatore causale, valore del bar precedente).
Mantengo cur_stop in modo incrementale ma lo aggiorno con i bar fino a j-1, mai j.
Nessun dato del bar j o successivi entra nel livello attivo nel bar j.
MECCANISMO ATTESO: lo stop strutturale e' tipicamente PIU' LASCO di sl0 all'inizio
(il minimo recente puo' stare oltre sl0): in quel caso il cricchetto lo IGNORA e
resta a sl0 (non allentiamo mai). Quando invece la struttura si stringe (lo swing
low risale verso il prezzo dopo che la fade comincia a funzionare) lo stop SALE,
proteggendo i guadagni di un trade che poi rintraccia prima del TP.
Prior dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il movimento
e' esaurito -> oltre il TP non c'e' runner. Qui non tocchiamo il TP, quindi non
puntiamo a cavalcare oltre: cerchiamo SOLO di tagliare meglio i loser/ristagni
proteggendo profitto. RISCHIO (fade = mean-reversion): stringere lo stop su un
pullback transitorio stoppa fuori trade che sarebbero rientrati al TP -> winner
tagliati e stop-rate su. Lo misuriamo.
GRID: n in {5, 10, 20} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class SwingStop(ExitPolicy):
name = "swing_stop"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=10, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.n = int(n)
self.low = ctx["low"]
self.high = ctx["high"]
self.atr14 = ctx["atr14"]
# stop a cricchetto: parte dalla protezione iniziale, puo' solo stringersi
self.cur_stop = sl0
# indice fino a cui ho gia' incorporato i bar nel cricchetto (escluso)
self._last_seen = i
def _swing_level(self, j: int):
"""Livello swing causale usando SOLO low/high su [j-n .. j-1] e atr14[j-1]."""
lo = max(self.i, j - self.n) # non andare prima dell'entrata
hi = j # slice [lo:hi] => indici <= j-1
if hi <= lo:
return None
a = self.atr14[j - 1]
if not np.isfinite(a):
a = 0.0
buf = 0.25 * a
if self.d == 1:
return float(np.min(self.low[lo:hi])) - buf
else:
return float(np.max(self.high[lo:hi])) + buf
def levels(self, j: int):
d = self.d
# aggiorna il cricchetto bar-per-bar fino a j-1 (causale). Per ogni bar k
# passato calcolo il livello swing attivo "a quel momento" e stringo lo stop.
while self._last_seen < j:
self._last_seen += 1
k = self._last_seen
lvl = self._swing_level(k)
if lvl is None:
continue
if d == 1:
if lvl > self.cur_stop: # cricchetto long: lo stop solo SALE
self.cur_stop = lvl
else:
if lvl < self.cur_stop: # cricchetto short: lo stop solo SCENDE
self.cur_stop = lvl
return self.tp0, self.cur_stop, 1.0
GRID = [
{"n": 5},
{"n": 10},
{"n": 20},
]
if __name__ == "__main__":
evaluate(SwingStop, GRID)
@@ -0,0 +1,88 @@
"""EXIT-19 — TP RIMOSSO, exit al CANALE DONCHIAN OPPOSTO (donchian_trail).
Idea: la fade attuale esce al TP (sulla media) + SL fisso (ATR dall'entrata) +
max_bars. Qui TOGLIAMO il TP e usiamo come unica uscita di prezzo uno STOP
DINAMICO ancorato al canale Donchian OPPOSTO alla direzione del trade:
long (d=1): stop(j) = min(low[j-n .. j-1]) (canale inferiore)
short (d=-1): stop(j) = max(high[j-n .. j-1]) (canale superiore)
Per un long che funziona (il prezzo risale verso la media) il canale inferiore
SALE bar dopo bar -> lo stop segue e blocca profitto; usciamo quando il prezzo
ritraccia sotto il minimo recente. Simmetrico short.
FLOOR a sl0 (mai PEGGIO dello SL originale): il livello attivo e' floorato alla
protezione iniziale -> non si allenta mai oltre sl0.
long : stop = max(channel_low, sl0)
short: stop = min(channel_high, sl0)
HORIZON = 4*mb (cap HARD_CAP=240): senza TP la posizione puo' restare a lungo,
quindi diamo molto piu' respiro al time-stop; l'uscita "naturale" e' il canale.
DIFFERENZA da EXIT-18 (swing_stop): qui (a) NON c'e' TP affatto (li' tp0 restava),
(b) niente cricchetto persistente: lo stop e' il canale RICALCOLATO ogni bar (puo'
anche allentarsi rispetto al bar prima, ma mai sotto sl0 grazie al floor),
(c) horizon esteso 4x. E' una uscita puramente trend-following/Donchian innestata
su un ingresso mean-reversion.
ANTI-LOOK-AHEAD (contratto): levels(j) usa SOLO dati con indice <= j-1:
- min/max sullo slice [j-n .. j-1] (lookback chiuso a j-1, lo[lo:hi] con hi=j);
- nessun dato del bar j entra nel livello attivo nel bar j;
- non si guarda mai high/low[j] per decidere lo stop attivo nel bar j.
PRIOR dal repo (ladder scartato): il TP della fade sta alla MEDIA, dove il
movimento e' esaurito; "il runner non corre". Quindi togliendo il TP rischiamo di
restare in posizione MENTRE il prezzo ristagna/rientra, pagando giveback e fee.
Il canale opposto dovrebbe limitare il giveback, ma la mean-reversion fa rientrare
il prezzo prima che il canale si stringa -> probabile uscita PEGGIORE del TP.
Lo misuriamo senza pregiudizio.
GRID: n in {10, 20, 30} (3 celle).
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class DonchianTrail(ExitPolicy):
name = "donchian_trail"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, n=20, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.n = int(n)
self.low = ctx["low"]
self.high = ctx["high"]
# TP rimosso, horizon esteso 4x (il cap a HARD_CAP lo applica l'engine)
self.horizon = 4 * mb
def levels(self, j: int):
d = self.d
# canale opposto causale: slice [lo : j] => indici <= j-1
lo = max(self.i, j - self.n)
hi = j
if hi <= lo:
# primo bar dopo l'entrata: nessuna finestra -> usa solo sl0 (no TP)
return None, self.sl0, 1.0
if d == 1:
ch = float(np.min(self.low[lo:hi]))
stop = max(ch, self.sl0) # floor: mai sotto sl0
else:
ch = float(np.max(self.high[lo:hi]))
stop = min(ch, self.sl0) # floor: mai sopra sl0
return None, stop, 1.0 # TP = None (rimosso)
GRID = [
{"n": 10},
{"n": 20},
{"n": 30},
]
if __name__ == "__main__":
evaluate(DonchianTrail, GRID)
@@ -0,0 +1,90 @@
"""EXIT-20 — ema_cross_exit: esci quando il close attraversa contro-posizione la EMA_m.
IDEA. Le fade comprano sotto / vendono sopra la media e oggi escono al TP fisso (la
media congelata all'entrata) o a max_bars/SL. Qui usiamo la EMA_m come "linea della
media corrente": finche' il prezzo non l'ha attraversata, la reversione e' ancora in
corso; quando il close la attraversa CONTRO la posizione, la mean-reversion ha
esaurito il suo percorso -> si chiude al close.
long (d=+1): la fade ha comprato sotto la media; esce quando close[j] < ema_m[j]
(il prezzo e' risalito ABOVE la media e ora la ri-perfora al ribasso,
ovvero il close torna sotto la media -> reversione finita/overshoot).
short (d=-1): esce quando close[j] > ema_m[j].
NB sul segno. La condizione "long: close < ema" e' la cross CONTRO la posizione: la
fade long scommette sul rientro VERSO/OLTRE la media; quando il close ricade sotto la
EMA dopo che la reversione e' avvenuta, il segnale di reversione e' consumato. E' un
exit "alla media mobile" che insegue la media invece del target congelato.
VARIANTI keep_tp:
- keep_tp=True : il TP fisso tp0 RESTA attivo (si esce al primo fra TP-al-livello,
SL, ema-cross, max_bars). horizon = max_bars (invariato).
- keep_tp=False: si RIMUOVE il TP fisso (tp=None) e si lascia che sia la ema-cross a
chiudere il vincente; horizon = 2*max_bars (cap HARD_CAP) per dare
spazio alla cross. SL fisso resta SEMPRE.
ANTI-LOOK-AHEAD. prepare() precalcola ema_m = EMA(close, span=m) UNA volta: causale,
ema[k] dipende solo da close <= k. La decisione di uscita e' in after_bar(j), che per
contratto puo' leggere il bar j (close[j], ema_m[j]) ed e' eseguibile al close del
poll. levels(j) usa solo sl0/tp0 (costanti) -> nessun dato > j-1. OK per costruzione.
GRID: m in {5, 10, 20} x keep_tp in {True, False} (6 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, HARD_CAP # noqa: E402
class EmaCrossExit(ExitPolicy):
name = "ema_cross_exit"
@classmethod
def prepare(cls, ctx, **params):
m = int(params.get("m", 10))
key = f"ema_{m}"
if key not in ctx:
c = ctx["close"]
ctx[key] = pd.Series(c).ewm(span=m, adjust=False).mean().values
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
m = int(params.get("m", 10))
self.ema = ctx[f"ema_{m}"]
self.keep_tp = bool(params.get("keep_tp", True))
if not self.keep_tp:
# senza TP la cross deve avere spazio: raddoppia l'orizzonte (cap HARD_CAP)
self.horizon = min(2 * mb, HARD_CAP)
def levels(self, j: int):
# SL fisso sempre; TP fisso solo se keep_tp (altrimenti None -> lo gestisce la cross)
tp = self.tp0 if self.keep_tp else None
return tp, self.sl0, 1.0
def after_bar(self, j: int) -> bool:
# after_bar puo' usare il bar j (close[j], ema[j]) -> eseguibile al close del poll
e = self.ema[j]
if not np.isfinite(e):
return False
c = self.ctx["close"][j]
if self.d == 1:
return c < e # long: close ricade SOTTO la media -> reversione finita
return c > e # short: close risale SOPRA la media
GRID = [
{"m": 5, "keep_tp": True},
{"m": 10, "keep_tp": True},
{"m": 20, "keep_tp": True},
{"m": 5, "keep_tp": False},
{"m": 10, "keep_tp": False},
{"m": 20, "keep_tp": False},
]
if __name__ == "__main__":
evaluate(EmaCrossExit, GRID)
@@ -0,0 +1,71 @@
"""EXIT-21 — vol_rescale (livelli che RESPIRANO con la volatilita').
I TP/SL fissi della base nascono come multipli dell'ATR all'entrata:
m_tp = |tp0 - entry| / atr14[i] (quanti ATR dista il TP)
m_sl = |sl0 - entry| / atr14[i] (quanti ATR dista lo SL)
Invece di congelare la DISTANZA in prezzo (tp0, sl0), congeliamo il MULTIPLO e
lasciamo che la distanza respiri con l'ATR corrente:
tp(j) = entry + d * m_tp * atr14[j-1]
sl(j) = entry - d * m_sl * atr14[j-1]
Cosi' se la vol si comprime mentre la fade torna alla media, il TP si AVVICINA
(prende profitto prima, coerente col fatto che il movimento residuo si esaurisce);
se la vol esplode, lo SL si ALLARGA (meno stop-out su spike), e viceversa.
Anti-look-ahead: m_tp/m_sl usano atr14[i] (noto al close del bar d'entrata, dove
il worker fissa i livelli); levels(j) usa SOLO atr14[j-1]. Se atr14[j-1] e' NaN
(warmup), si ricade sui livelli fissi base (tp0/sl0).
Varianti (mode):
- tp_only : TP respira, SL resta fisso a sl0
- sl_only : SL respira, TP resta fisso a tp0
- both : entrambi respirano
GRID: mode in {tp_only, sl_only, both} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
class VolRescale(ExitPolicy):
name = "vol_rescale"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.mode = params.get("mode", "both")
self.atr = ctx["atr14"]
# ATR all'entrata: noto al close[i] (il worker fissa i livelli qui).
a_i = self.atr[i]
if a_i is None or a_i != a_i or a_i <= 0:
# nessun ATR valido all'entrata -> impossibile derivare i multipli:
# la policy degenera nei livelli fissi base.
self.valid = False
self.m_tp = self.m_sl = 0.0
else:
self.valid = True
self.m_tp = abs(tp0 - entry) / a_i
self.m_sl = abs(sl0 - entry) / a_i
def levels(self, j: int):
if not self.valid:
return self.tp0, self.sl0, 1.0
a = self.atr[j - 1] # solo dati <= j-1
if a is None or a != a: # NaN -> ricadi sui fissi base
return self.tp0, self.sl0, 1.0
d = self.d
tp = self.tp0
sl = self.sl0
if self.mode in ("tp_only", "both"):
tp = self.entry + d * self.m_tp * a
if self.mode in ("sl_only", "both"):
sl = self.entry - d * self.m_sl * a
return tp, sl, 1.0
GRID = [{"mode": "tp_only"}, {"mode": "sl_only"}, {"mode": "both"}]
if __name__ == "__main__":
evaluate(VolRescale, GRID)
@@ -0,0 +1,74 @@
"""EXIT-22 — DIAGNOSTICA DEL VALORE DELLO SL sulle fade (MR01/MR02/MR07).
Le fade live escono con TP/SL FISSI decisi all'entrata (al close[i]) + max_bars.
Lo SL e' sl0 = entry -/+ k*ATR (per costruzione della strategia). Questa policy
NON cambia TP ne' horizon: tocca SOLO lo SL per misurare quanto lo SL ATTUALE
aggiunge/toglie a ret/DD/Sharpe per sleeve. Tre regimi:
mode = "none" -> sl = None (SL RIMOSSO: restano TP + max_bars). Il trade
puo' chiudere solo al TP o a scadenza horizon. Esposizione
al rischio di coda massima (nessun taglio della perdita).
mode = "wide2x" -> sl spostato a 2x la distanza entry->sl0 (stop piu' lasco):
sl = entry + 2*(sl0 - entry). Stoppa meno spesso.
mode = "tight05x" -> sl spostato a 0.5x la distanza (stop piu' stretto):
sl = entry + 0.5*(sl0 - entry). Stoppa piu' spesso, ma
perde di meno per stop.
La geometria col segno e' automatica: per long sl0<entry, per short sl0>entry;
scalare (sl0-entry) preserva il lato corretto in entrambi i casi.
ANTI-LOOK-AHEAD: il livello sl(j) dipende SOLO da {entry, sl0, mode}, tutti noti
all'ENTRATA (close[i]). Costante per tutto il trade, identico al baseline tranne
il fattore di scala. Nessun dato del bar j o futuro entra. TP = tp0 invariato.
-> contratto rispettato per costruzione (livello statico, come il baseline).
INTERPRETAZIONE (diagnostica, non per forza deployabile):
- se "none" >= base su ret E DD -> lo SL toglie valore (taglia winner che
sarebbero rientrati: la fade e' mean-reversion, il movimento avverso e'
spesso transitorio).
- se "tight05x" peggiora molto -> lo SL e' gia' sul filo: stringerlo morde i
rientri. se "wide2x" ~ "none" -> lo SL attuale e' quasi inerte (raramente
toccato) e il rischio di coda e' contenuto da max_bars/TP.
- DD e' la metrica chiave: lo SL serve a contenere la coda. Se "none" ha DD
simile o piu' basso, lo SL non sta proteggendo nulla di utile.
GRID: mode in {none, wide2x, tight05x} (3 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate # noqa: E402
_SCALE = {"none": None, "wide2x": 2.0, "tight05x": 0.5}
class NoSl(ExitPolicy):
name = "no_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.mode = str(params.get("mode", "none"))
if self.mode not in _SCALE:
raise ValueError(f"mode sconosciuto: {self.mode}")
scale = _SCALE[self.mode]
if scale is None:
self.sl = None
else:
# sl = entry + scale*(sl0 - entry): preserva il lato per long/short.
self.sl = entry + scale * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
GRID = [
{"mode": "none"},
{"mode": "wide2x"},
{"mode": "tight05x"},
]
if __name__ == "__main__":
evaluate(NoSl, GRID)
@@ -0,0 +1,99 @@
"""EXIT-23 — sl_tp_ride: LOCK AL TP E CAVALCA.
Idea (variante stretta di EXIT-09). Le fade escono al TP fisso (alla media). Qui
si tiene il TP fisso ATTIVO come exit normale finche' il prezzo non lo SUPERA in
chiusura. Quando close[j-1] supera tp0 a favore:
- TP RIMOSSO (None): non si esce piu' al livello fisso;
- SL spostato esattamente a tp0 (il profitto del TP e' LOCKATO: non si esce mai
peggio del TP originale);
- da tp0 in poi un trail chandelier k*ATR ancorato all'estremo favorevole visto
dal bar di superamento in poi, SOLO-STRINGENTE (cricchetto, mai si allenta).
Differenza dal 09: qui il floor e' ESATTAMENTE tp0 (cosi' come in 09, ma 09 ha
horizon 3*mb fisso e griglia su k 1/2/3); qui il lock scatta subito al primo
superamento in chiusura e la griglia esplora ANCHE l'horizon. State machine:
FASE A (armed=False): tp=tp0, sl=sl0 -> identica a base.
FASE B (armed): tp=None, sl = max(tp0, fav_high - k*atr) (long, cricchetto)
= min(tp0, fav_low + k*atr) (short, cricchetto)
ANTI-LOOK-AHEAD: levels(j) usa SOLO dati <= j-1:
- trigger di arma su close[j-1] (gia' chiuso al poll del bar j);
- estremo favorevole post-superamento su slice [arm_idx .. j-1] (incrementale);
- atr14[j-1] (indice causale).
Nessun fill parziale (tp_frac sempre 1.0). after_bar non usato.
GRID: k in {1.5, 2.5} x horizon in {2*mb, 4*mb} (4 celle).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from exit_lab import ExitPolicy, evaluate, HARD_CAP # noqa: E402
class SlTpRide(ExitPolicy):
name = "sl_tp_ride"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, k=2.5, hmult=4, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(k)
self.horizon = min(int(hmult) * mb, HARD_CAP)
self.close = ctx["close"]
self.high = ctx["high"]
self.low = ctx["low"]
self.atr = ctx["atr14"]
self.armed = False
self.arm_idx = None
self.fav_high = None
self.fav_low = None
self._last_seen = i # ultimo close esaminato per il trigger di arma
self._fav_seen = None # ultimo bar incorporato nell'estremo favorevole
self.cur_stop = None # stop trailing monotono in FASE B (floor tp0)
def levels(self, j: int):
c = self.close
d = self.d
# ---- FASE A: controlla l'arma sui close fino a j-1 (causali) -------------
if not self.armed:
while self._last_seen < j - 1:
self._last_seen += 1
cv = c[self._last_seen]
crossed = (cv > self.tp0) if d == 1 else (cv < self.tp0)
if crossed:
self.armed = True
self.arm_idx = self._last_seen
self.fav_high = self.high[self.arm_idx]
self.fav_low = self.low[self.arm_idx]
self._fav_seen = self.arm_idx
self.cur_stop = self.tp0 # SL spostato a tp0: profitto lockato
break
if not self.armed:
return self.tp0, self.sl0, 1.0 # FASE A: TP/SL fissi
# ---- FASE B: TP rimosso, trail chandelier con floor a tp0 ----------------
while self._fav_seen < j - 1:
self._fav_seen += 1
if self.high[self._fav_seen] > self.fav_high:
self.fav_high = self.high[self._fav_seen]
if self.low[self._fav_seen] < self.fav_low:
self.fav_low = self.low[self._fav_seen]
a = self.atr[j - 1]
if a == a: # non-NaN
if d == 1:
cand = max(self.fav_high - self.k * a, self.tp0)
if cand > self.cur_stop: # cricchetto: solo sale
self.cur_stop = cand
else:
cand = min(self.fav_low + self.k * a, self.tp0)
if cand < self.cur_stop: # cricchetto: solo scende
self.cur_stop = cand
return None, self.cur_stop, 1.0 # TP rimosso in FASE B
GRID = [{"k": k, "hmult": h} for k in (1.5, 2.5) for h in (2, 4)]
if __name__ == "__main__":
evaluate(SlTpRide, GRID)
@@ -0,0 +1,245 @@
"""ADVERSARIAL VERIFY — EXIT-02 trail_atr_keep_tp, LENTE OVERFIT/ROBUSTEZZA.
Tesi del sopravvissuto: lo SL intrabar fisso distrugge valore nelle fade; il
Chandelier trail (k=1.5) + TP fisso migliora Sharpe/DD ovunque (6/6 train, 5/6 OOS).
Ipotesi nulla del verificatore: e' un artefatto. Tre attacchi:
(1) JITTER parametri: k vicini non provati (1.25/1.75) + ponte SL fisso a 3x/4x
ATR (no_sl). Il plateau tiene o e' una cresta?
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22, OOS 23-11/25-01 vs 25-01/26-05.
Il miglioramento c'e' in OGNI finestra o concentrato in un regime?
(3) DIPENDENZA HURST (decisivo): i segnali in cache hanno hurst_max=0.55 (toglie
il regime trending). Rigenero i segnali SENZA hurst (hurst_max=None) IN MEMORIA
(non tocco la cache) e ripeto base-vs-policy: la tesi "SL dannoso" regge anche
dove gli stop servivano (regime persistente)?
cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_02_overfit.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve()
sys.path.insert(0, str(HERE.parents[1])) # scripts/analysis
sys.path.insert(0, str(HERE.parents[3])) # project root
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
CODES, ASSETS, LIVE_PARAMS, _atr14)
from importlib import import_module # noqa: E402
mod = import_module("exit_policies.02_trail_atr_keep_tp")
TrailATRKeepTP = mod.TrailATRKeepTP
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
SLEEVE_KEYS = [(c, a) for c in CODES for a in ASSETS]
# --------------------------------------------------------------- fixed-SL bridge
class FixedSLmultATR(ExitPolicy):
"""Ponte fra base (SL=sl0) e no_sl: SL fisso a m*ATR(entry) dall'entrata,
TP fisso. Se il trail (k piccolo) batte uno SL fisso GIA' largo (3x/4x),
allora il guadagno e' nel trailing, non solo nell'allontanare lo SL."""
name = "fixed_sl_mult_atr"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
m = float(params.get("m", 3.0))
a = ctx["atr14"][i]
if a is None or a != a:
self.sl = sl0
else:
self.sl = entry - m * a if d == 1 else entry + m * a
def levels(self, j: int):
return self.tp0, self.sl, 1.0
# --------------------------------------------------------------- no-SL bridge
class NoSL(ExitPolicy):
"""Solo TP fisso + horizon, NESSUNO stop. Isola: il valore e' nel TOGLIERE
lo stop (qualsiasi) o nel TRAIL dinamico? Se NoSL ~ trail, il driver e'
'niente SL'; se il trail batte NoSL, il trail aggiunge."""
name = "no_sl"
def levels(self, j: int):
return self.tp0, None, 1.0
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>6.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def _summary(rows):
"""rows: list of (sleeve_key, base_dict, pol_dict). Ritorna conteggi miglioramento."""
sh_up = dd_dn = ret_up = n = 0
for _, b, p in rows:
if not b or not p:
continue
n += 1
sh_up += p["sharpe_t"] > b["sharpe_t"]
dd_dn += p["dd_pct"] < b["dd_pct"]
ret_up += p["ret_pct"] > b["ret_pct"]
return sh_up, dd_dn, ret_up, n
# ============================================================= TEST 1: JITTER
def test_jitter(data):
print("\n" + "=" * 78)
print("TEST 1 — JITTER: k vicini (1.25/1.75) + ponte SL fisso 3x/4x ATR + NoSL")
print("=" * 78)
print("\n[1a] Trail k in {1.25, 1.5, 1.75} — plateau o cresta? (OOS)")
for k in (1.25, 1.5, 1.75):
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(TrailATRKeepTP, sl, {"k": k}, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" k={k:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1b] SL fisso a m*ATR dall'entrata (m=3,4) — uno stop largo basta? (OOS)")
for m in (3.0, 4.0):
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(FixedSLmultATR, sl, {"m": m}, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" m={m:<5} OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1c] NoSL (solo TP+horizon) — il driver e' 'togliere lo SL'? (OOS)")
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
p = simulate(NoSL, sl, start_ms=OOS_START_MS)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" NoSL OOS: Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
print("\n[1d] dettaglio per sleeve: base vs k=1.5 vs NoSL vs SLx3 (OOS)")
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=OOS_START_MS)
t = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=OOS_START_MS)
ns = simulate(NoSL, sl, start_ms=OOS_START_MS)
f3 = simulate(FixedSLmultATR, sl, {"m": 3.0}, start_ms=OOS_START_MS)
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} base sh{b.get('sharpe_t',0):>6.2f} | trail1.5 sh{t.get('sharpe_t',0):>6.2f} "
f"| NoSL sh{ns.get('sharpe_t',0):>6.2f} | SLx3 sh{f3.get('sharpe_t',0):>6.2f}")
# ============================================================= TEST 2: TEMPORAL
def test_temporal(data):
print("\n" + "=" * 78)
print("TEST 2 — STABILITA' TEMPORALE (Sharpe base -> trail k=1.5)")
print("=" * 78)
W = [
("train 2018-20", None, int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)),
("train 2021-22", int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6), OOS_START_MS),
("OOS 23-11/25-01", OOS_START_MS, int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6)),
("OOS 25-01/26-05", int(pd.Timestamp("2025-01-01", tz="UTC").value // 1e6), None),
]
for label, s, e in W:
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
# mediana del delta-Sharpe
deltas = [p["sharpe_t"] - b["sharpe_t"] for _, b, p in rows if b and p]
med = float(np.median(deltas)) if deltas else 0.0
print(f" {label:<20} Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n} "
f"median dSharpe {med:+.2f}")
# ============================================================= TEST 3: HURST
def _build_sleeves_no_hurst():
"""Rigenera i segnali SENZA il loss-guard Hurst (hurst_max=None), IN MEMORIA.
Replica esattamente load_sleeves() ma con LIVE_PARAMS modificati."""
params = dict(LIVE_PARAMS)
params["hurst_max"] = None
out = {}
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def test_hurst(data):
print("\n" + "=" * 78)
print("TEST 3 — DIPENDENZA DAL FILTRO HURST (decisivo)")
print("=" * 78)
print("Rigenero segnali con hurst_max=None (loss-guard OFF -> include il regime")
print("trending/persistente dove gli stop dovrebbero servire). Confronto base->trail.")
nh = _build_sleeves_no_hurst()
# quanti segnali in piu' (il guard ne toglieva)
print("\n segnali: con-guard -> senza-guard")
for key in SLEEVE_KEYS:
ng = len(data[key]["signals"])
nn = len(nh[key]["signals"])
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} {ng:>4} -> {nn:>4} (+{nn-ng})")
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
print(f"\n [{scope}] base vs trail k=1.5 — SENZA hurst guard")
rows = []
for key in SLEEVE_KEYS:
sl = nh[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
tag = f"{key[0].split('_')[0]} {key[1]}"
print(f" {tag:<10} base {_fmt(b)}")
print(f" {'':<10} trail{_fmt(p)}")
sh, dd, ret, n = _summary(rows)
print(f" --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
# contro-prova: con-guard sugli STESSI scope, per isolare l'effetto guard
print("\n [CONTROLLO] stesso confronto CON hurst guard (cache):")
for scope, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
rows = []
for key in SLEEVE_KEYS:
sl = data[key]
b = simulate(ExitPolicy, sl, start_ms=s, end_ms=e)
p = simulate(TrailATRKeepTP, sl, {"k": 1.5}, start_ms=s, end_ms=e)
rows.append((key, b, p))
sh, dd, ret, n = _summary(rows)
print(f" [{scope}] con-guard --> Sharpe-up {sh}/{n} DD-down {dd}/{n} ret-up {ret}/{n}")
if __name__ == "__main__":
data = load_sleeves()
test_jitter(data)
test_temporal(data)
test_hurst(data)
print("\nDONE")
@@ -0,0 +1,329 @@
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' su EXIT-02 (k=1.5).
Ipotesi nulla avversaria: l'edge del chandelier trail e' un artefatto del
TIMING PERFETTO (livelli fissati a j-1 e tocco a j senza alcun attrito) e/o di
uno SL fillato a un prezzo non eseguibile live. Provo a confutarla.
Esperimenti:
(A) AUDIT del contratto: ricalcolo i livelli di EXIT-02 fuori dall'engine e
verifico che run_hi/run_lo a j NON incorporino mai high[j]/low[j], e che
atr usato sia atr[j-1]. (statico, ma lo confermo numericamente forzando
un confronto con una variante che USA j -> deve cambiare i numeri.)
(B) LAG +1: variante che ritarda di UN bar in piu' TUTTI gli input causali
(atr[j-2], estremi fino a j-2). Se l'edge collassa -> appeso al timing.
(C) ESEGUIBILITA' SL: lo SL fillato a `sl` (prezzo del livello) e' ottimistico?
Confronto col fill conservativo allo SL ma con slippage, e con fill al
WORSE fra sl e open[j] (gap-through). Stima costo.
(D) ESEGUIBILITA' HORIZON/CLOSE: gli exit a max_bars escono a close[j]. Live
il poll arriva ~al close ma esegue al bar dopo -> rifaccio l'engine con
gli exit a horizon/after_bar a open[j+1] invece di close[j]. Costo?
(EXIT-02 non usa after_bar, ma usa gli exit a horizon -> rilevante con
avg_bars 2.5: il turnover alto AMPLIFICA il costo per-exit.)
Tutto a leva 3, fee 0.10% RT, OOS_START 2023-11-01.
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab as EL # noqa: E402
from exit_lab import ExitPolicy, load_sleeves, simulate, OOS_START_MS, LEV, POS, FEE_RT # noqa: E402
from importlib import import_module # noqa: E402
mod = import_module("02_trail_atr_keep_tp")
TrailATRKeepTP = mod.TrailATRKeepTP
KPICK = 1.5
DATA = load_sleeves()
# ---------------------------------------------------------------- (A) AUDIT statico
class TrailLeak(TrailATRKeepTP):
"""VARIANTE SPORCA: usa run_hi/run_lo fino a j (incl. bar j) e atr[j].
Serve SOLO a mostrare quanto l'edge gonfierebbe col look-ahead -> se i numeri
della policy pulita fossero gia' a quel livello, sarebbe sospetta."""
name = "trail_LEAK"
def levels(self, j: int):
self._update_running(j) # SPORCO: incorpora bar j
a = self.atr[j] # SPORCO: atr del bar j
if a is None or a != a:
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand)
return self.tp0, sl, 1.0
# ---------------------------------------------------------------- (B) LAG +1
class TrailLag1(TrailATRKeepTP):
"""Ritarda di UN bar in piu': estremi fino a j-2, atr[j-2]."""
name = "trail_LAG1"
def levels(self, j: int):
self._update_running(j - 2) # estremi solo <= j-2
idx = j - 2
a = self.atr[idx] if idx >= 0 else None
if a is None or a != a:
return self.tp0, self.sl0, 1.0
if self.d == 1:
chand = self.run_hi - self.k * a
sl = max(self.sl0, chand)
else:
chand = self.run_lo + self.k * a
sl = min(self.sl0, chand)
return self.tp0, sl, 1.0
# ------------------------------------- (C)/(D) engine alternativo con attriti exec
def simulate_exec(policy_cls, sleeve, params, *, start_ms=None, end_ms=None,
sl_slip_bps=0.0, sl_gap=False, horizon_open=False):
"""Clone di EL.simulate con attriti di esecuzione opzionali:
sl_slip_bps : slippage avverso (bps di prezzo) sul fill allo SL.
sl_gap : fill allo SL = worse(sl, open[j]) (gap-through realistico).
horizon_open: exit a horizon/after_bar a open[j+1] invece di close[j].
"""
params = params or {}
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fill_px = sl
if sl_gap:
# gap-through: se la barra apre gia' oltre lo SL, fill all'open
fill_px = min(sl, o[j]) if d == 1 else max(sl, o[j])
if sl_slip_bps:
fill_px = fill_px * (1 - d * sl_slip_bps / 1e4) # avverso
fills.append((remaining, fill_px)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
break
if step == horizon:
px = o[min(j + 1, n - 1)] if horizon_open else c[j]
fills.append((remaining, px)); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": trades,
"win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades,
}
def oos(cls, sleeve, sim=simulate, **kw):
return sim(cls, sleeve, {"k": KPICK}, start_ms=OOS_START_MS, **kw)
def line(tag, r):
return (f"{tag:<26} ret{r.get('ret_pct',0):>7.0f}% dd{r.get('dd_pct',0):>5.1f} "
f"sh{r.get('sharpe_t',0):>5.2f} n{r.get('trades',0):>4} "
f"win{r.get('win_pct',0):>4.0f}% bars{r.get('avg_bars',0):>4.1f}")
print("=" * 90)
print("OOS (2023-11-01+) k=1.5 — clean / LEAK(look-ahead) / LAG1(+1 bar) per sleeve")
print("=" * 90)
agg = {"clean": [], "leak": [], "lag1": []}
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
rc = oos(TrailATRKeepTP, sleeve)
rk = oos(TrailLeak, sleeve)
rl = oos(TrailLag1, sleeve)
agg["clean"].append(rc.get("ret_pct", 0))
agg["leak"].append(rk.get("ret_pct", 0))
agg["lag1"].append(rl.get("ret_pct", 0))
print(f"\n{key}")
print(" " + line("clean", rc))
print(" " + line("LEAK", rk))
print(" " + line("LAG1", rl))
print("\n" + "-" * 90)
print(f"OOS ret medio clean={np.mean(agg['clean']):.0f}% "
f"LEAK={np.mean(agg['leak']):.0f}% LAG1={np.mean(agg['lag1']):.0f}%")
print(f"LAG1/clean ratio per sleeve: "
f"{[f'{a/ b:.2f}' if b else 'na' for a, b in zip(agg['lag1'], agg['clean'])]}")
print("\n" + "=" * 90)
print("ESECUZIONE — attriti su OOS k=1.5 (medie sui 6 sleeve)")
print("=" * 90)
scenarios = {
"clean (engine std)": dict(),
"SL slip 5bps": dict(sl_slip_bps=5.0),
"SL slip 10bps": dict(sl_slip_bps=10.0),
"SL gap-through(open)": dict(sl_gap=True),
"SL gap + slip 5bps": dict(sl_gap=True, sl_slip_bps=5.0),
"horizon exit @open[j+1]": dict(horizon_open=True),
"horizon@open + SLslip5": dict(horizon_open=True, sl_slip_bps=5.0),
}
for tag, kw in scenarios.items():
rets, dds, shs = [], [], []
for (code, asset), sleeve in DATA.items():
r = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, **kw)
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0)); shs.append(r.get("sharpe_t", 0))
print(f"{tag:<28} OOS ret medio {np.mean(rets):>6.0f}% dd {np.mean(dds):>4.1f} "
f"sh {np.mean(shs):>4.2f} (min ret {min(rets):>5.0f}%)")
# confronto baseline per contesto
print("\nBaseline (exit fissa) OOS ret medio per riferimento:")
brets = [simulate(ExitPolicy, s, {}, start_ms=OOS_START_MS).get("ret_pct", 0)
for s in DATA.values()]
print(f" base OOS ret medio {np.mean(brets):.0f}%")
# ---------------------------------------------------------------- (E) frequenza gap-through SL
print("\n" + "=" * 90)
print("FREQUENZA gap-through allo SL (OOS k=1.5): quanti fill SL aprono OLTRE il livello?")
print("=" * 90)
def gap_stats(sleeve, start_ms=OOS_START_MS):
params = {"k": KPICK}
o = sleeve["open"]; h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve); TrailATRKeepTP.prepare(ctx, **params)
last_exit = -1
sl_hits = gaps = 0
gap_bps = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = TrailATRKeepTP(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
break
tp, sl, _ = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
sl_hits += 1
# gap-through: l'open e' gia' oltre lo SL (peggio del livello)?
worse = (d == 1 and o[j] < sl) or (d == -1 and o[j] > sl)
if worse:
gaps += 1
gap_bps.append(abs(o[j] - sl) / sl * 1e4)
break
if hit_tp:
break
last_exit = j
return sl_hits, gaps, (np.mean(gap_bps) if gap_bps else 0.0), (np.median(gap_bps) if gap_bps else 0.0)
tot_h = tot_g = 0
for (code, asset), sleeve in DATA.items():
sh_, g_, m_, md_ = gap_stats(sleeve)
tot_h += sh_; tot_g += g_
print(f" {code.split('_')[0]} {asset}: SL hits {sh_:>3} gap-through {g_:>3} "
f"({100*g_/max(sh_,1):>4.0f}%) gap medio {m_:>5.1f}bps mediano {md_:>5.1f}bps")
print(f"\n TOTALE: {tot_g}/{tot_h} SL hit sono gap-through = {100*tot_g/max(tot_h,1):.0f}%")
# ------------------------------------------------- (F) gap pessimista vs baseline per sleeve
print("\n" + "=" * 90)
print("VERDETTO ESEGUIBILITA': EXIT-02 con SL gap-through vs BASELINE (exit fissa), OOS, per sleeve")
print("=" * 90)
print(f"{'sleeve':<10}{'base ret':>10}{'base dd':>9}{'base sh':>9} "
f"{'trail gap ret':>14}{'gap dd':>8}{'gap sh':>8} verdetto")
n_better = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=OOS_START_MS)
g = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
better = g.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
dd_better = g.get("dd_pct", 99) <= b.get("dd_pct", 99)
n_better += better
verdict = ("Sharpe+DD>" if better and dd_better else
"DD> ret<" if dd_better and not better else "PEGGIO")
print(f"{key:<10}{b.get('ret_pct',0):>9.0f}%{b.get('dd_pct',0):>8.1f}{b.get('sharpe_t',0):>9.2f} "
f"{g.get('ret_pct',0):>13.0f}%{g.get('dd_pct',0):>7.1f}{g.get('sharpe_t',0):>8.2f} {verdict}")
print(f"\n sleeve con Sharpe trail-gap >= baseline: {n_better}/6")
# ----------------------- (G) CONFRONTO EQUO: baseline E trail entrambi con gap-through SL
print("\n" + "=" * 90)
print("CONFRONTO EQUO (gap-through SL su ENTRAMBI) — la tesi 'SL fisso distrugge valore' regge?")
print("=" * 90)
print(f"{'sleeve':<10}{'BASE-gap ret':>13}{'dd':>6}{'sh':>6} {'TRAIL-gap ret':>14}{'dd':>6}{'sh':>6} verdetto")
trail_wins = 0
agg_b = []; agg_t = []
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = oos(ExitPolicy, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
t = oos(TrailATRKeepTP, sleeve, sim=simulate_exec, sl_gap=True, sl_slip_bps=5.0)
agg_b.append(b.get("sharpe_t", 0)); agg_t.append(t.get("sharpe_t", 0))
win = t.get("sharpe_t", 0) >= b.get("sharpe_t", 0)
trail_wins += win
print(f"{key:<10}{b.get('ret_pct',0):>12.0f}%{b.get('dd_pct',0):>6.1f}{b.get('sharpe_t',0):>6.2f} "
f"{t.get('ret_pct',0):>13.0f}%{t.get('dd_pct',0):>6.1f}{t.get('sharpe_t',0):>6.2f} "
f"{'TRAIL>=' if win else 'BASE>'}")
print(f"\n con ENTRAMBI gap-through: trail Sharpe >= baseline su {trail_wins}/6 sleeve")
print(f" Sharpe medio BASE-gap={np.mean(agg_b):.2f} TRAIL-gap={np.mean(agg_t):.2f}")
@@ -0,0 +1,217 @@
"""STRESS verifier for EXIT-02 trail_atr_keep_tp (train-pick k=1.5).
Adversarial lens = STRESS. Hypothesis to try to REFUTE the survivor:
(1) Fee 2x (FEE_RT=0.002): the policy raises turnover (avg_bars 9->2.5) =>
it should be disproportionately hurt by doubling fees.
(2) Bear/crash subperiod 2021-01..2022-12 (2021-05-19 crash, LUNA, FTX):
does the DD/tail of the policy survive? compare worst trade + 5 worst.
(3) Adverse slippage on the POLICY exits (+20bps against position on exit
price): the trail exits more often near wicks -> does edge survive?
(4) Turnover / capital-churn: the policy turns capital ~3.6x faster. Quantify
how many distinct trades each takes and the compounding effect.
We monkeypatch exit_lab.FEE_RT and re-implement a thin per-trade collector that
mirrors exit_lab.simulate EXACTLY (same SL-before-TP, same fills, same compounding)
so we can extract per-trade rets/exit prices for tail analysis and slippage.
"""
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, load_sleeves, OOS_START_MS, HARD_CAP # noqa: E402
# import the survivor policy
import importlib.util
spec = importlib.util.spec_from_file_location(
"p02", str(Path(__file__).resolve().parent / "02_trail_atr_keep_tp.py"))
p02 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p02)
TrailATRKeepTP = p02.TrailATRKeepTP
import pandas as pd
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6)
def simulate_detailed(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
exit_slip_bps=0.0):
"""Mirror of exit_lab.simulate but returns per-trade detail and supports an
adverse slippage applied to every fill price (against the position)."""
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * exit_lab.LEV
LEV, POS = exit_lab.LEV, exit_lab.POS
slip = exit_slip_bps * 1e-4
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
rets = []
tdetail = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
# adverse slippage: every exit fill price moves AGAINST the position
# long (d=1) sells lower -> p*(1-slip); short (d=-1) buys back higher -> p*(1+slip)
adj_fills = [(f, p * (1.0 - d * slip)) for f, p in fills]
ret = sum(f * (p - entry) for f, p in adj_fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
rets.append(ret)
tdetail.append({"i": i, "j": j, "d": d, "ret": ret, "bars": j - i, "ts": int(ts[i])})
if not rets:
return {}
r = np.array(rets)
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": len(r),
"win_pct": (r > 0).mean() * 100,
"avg_ret_bps": r.mean() * 1e4,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": np.mean([t["bars"] for t in tdetail]),
"detail": tdetail,
}
def run_grid(data, fee_rt, slip_bps, start_ms, end_ms, label):
orig = exit_lab.FEE_RT
exit_lab.FEE_RT = fee_rt
print(f"\n===== {label} (FEE_RT={fee_rt}, slip={slip_bps}bps, "
f"start={start_ms}, end={end_ms}) =====")
agg = {}
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, start_ms, end_ms, slip_bps)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, start_ms, end_ms, slip_bps)
if not base or not pol:
continue
agg[key] = (base, pol)
print(f"{key:<10} BASE ret{base['ret_pct']:>8.0f}% dd{base['dd_pct']:>5.1f} "
f"sh{base['sharpe_t']:>5.2f} n{base['trades']:>4} bars{base['avg_bars']:>4.1f} "
f"| POL ret{pol['ret_pct']:>8.0f}% dd{pol['dd_pct']:>5.1f} "
f"sh{pol['sharpe_t']:>5.2f} n{pol['trades']:>4} bars{pol['avg_bars']:>4.1f}")
exit_lab.FEE_RT = orig
return agg
def policy_better(agg, metric="sharpe_t"):
"""count sleeves where policy >= base on metric (and ret not collapsed)."""
n_better_sh = n_better_dd = n_ret_ok = 0
for key, (base, pol) in agg.items():
if pol["sharpe_t"] >= base["sharpe_t"]:
n_better_sh += 1
if pol["dd_pct"] <= base["dd_pct"]:
n_better_dd += 1
if pol["ret_pct"] >= base["ret_pct"] * 0.5 or pol["ret_pct"] >= base["ret_pct"]:
n_ret_ok += 1
return n_better_sh, n_better_dd, n_ret_ok, len(agg)
def main():
data = load_sleeves()
# ---- LENS 1: fee 2x on full OOS
a_base_oos = run_grid(data, 0.001, 0.0, OOS_START_MS, None, "L0 OOS baseline fee (sanity)")
a_fee2_oos = run_grid(data, 0.002, 0.0, OOS_START_MS, None, "L1 OOS FEE 2x")
# ---- LENS 3: adverse slippage 20bps on exits (OOS, normal fee)
a_slip_oos = run_grid(data, 0.001, 20.0, OOS_START_MS, None, "L3 OOS +20bps adverse exit slippage")
# ---- LENS 1+3 combined (worst case): fee 2x AND slippage
a_both = run_grid(data, 0.002, 20.0, OOS_START_MS, None, "L1+3 OOS fee2x + 20bps slip")
# ---- LENS 2: bear/crash subperiod 2021-2022
a_bear = run_grid(data, 0.001, 0.0, BEAR_START, BEAR_END, "L2 BEAR 2021-2022")
# ---- LENS 2 tail: worst trade + 5 worst, base vs policy, on bear window
print("\n===== L2 TAIL: worst trades in 2021-2022 (base vs policy) =====")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, BEAR_START, BEAR_END)
if not base or not pol:
continue
bw = sorted([t["ret"] for t in base["detail"]])[:5]
pw = sorted([t["ret"] for t in pol["detail"]])[:5]
print(f"{key:<10} base 5-worst(bps) {[f'{x*1e4:.0f}' for x in bw]} "
f"| pol 5-worst(bps) {[f'{x*1e4:.0f}' for x in pw]}")
print(f"{'':<10} base worst {bw[0]*1e4:.0f}bps pol worst {pw[0]*1e4:.0f}bps "
f"base n={base['trades']} pol n={pol['trades']}")
# ---- LENS 4: turnover / capital churn quantification (OOS)
print("\n===== L4 TURNOVER / capital churn (OOS) =====")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = simulate_detailed(ExitPolicy, sleeve, {}, OOS_START_MS, None)
pol = simulate_detailed(TrailATRKeepTP, sleeve, {"k": 1.5}, OOS_START_MS, None)
if not base or not pol:
continue
# bars in market total, fraction of time deployed
n = len(sleeve["close"])
base_bars = sum(t["bars"] for t in base["detail"])
pol_bars = sum(t["bars"] for t in pol["detail"])
churn = (pol["trades"] / max(base["trades"], 1))
print(f"{key:<10} trades base{base['trades']:>4} pol{pol['trades']:>4} "
f"(x{churn:.2f}) | bars-in-mkt base{base_bars:>5} pol{pol_bars:>5} "
f"| avg_bars base{base['avg_bars']:.1f} pol{pol['avg_bars']:.1f} "
f"| win% base{base['win_pct']:.0f} pol{pol['win_pct']:.0f}")
# ---- VERDICT helpers
print("\n===== VERDICT TALLIES =====")
for lbl, agg in [("OOS baseline-fee", a_base_oos), ("OOS fee2x", a_fee2_oos),
("OOS slip20", a_slip_oos), ("OOS fee2x+slip", a_both),
("BEAR 2021-22", a_bear)]:
nsh, ndd, nret, tot = policy_better(agg)
print(f"{lbl:<20} policy>=base: sharpe {nsh}/{tot} dd-better {ndd}/{tot} "
f"ret>=50%base {nret}/{tot}")
if __name__ == "__main__":
main()
@@ -0,0 +1,211 @@
"""Verifica avversariale LEAKAGE/ESEGUIBILITA' per EXIT-16 close_confirm_sl.
Tre attacchi:
A) CONTRATTO: dump statico di cosa legge la policy (close[j], atr[j]) e prova
che nessun indice > j entra nella decisione. Replica esatta del numero
headline (MR02 BTC/ETH OOS) per ancorare.
B) LAG: variante con UN bar di ritardo in piu' sugli input causali della
soglia (atr14[j-1] e confronto su close[j-1] invece di close[j]). Se l'edge
collassa -> appeso al timing perfetto. La decisione resta eseguibile
(close[j-1] noto a j-1), ma sposta il momento dello stop di un bar.
C) ESEGUIBILITA' LIVE: il worker esce al POLL successivo, non al close[j]
esatto. Stima del costo eseguendo l'uscita a open[j+1] invece di close[j].
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_16_leakage.py
"""
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
sys.path.insert(0, str(HERE.parents[2])) # project root
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, load_sleeves, simulate, OOS_START_MS) # noqa: E402
# import the survivor policy directly from its file
import importlib.util # noqa: E402
spec = importlib.util.spec_from_file_location("p16", HERE / "16_close_confirm_sl.py")
p16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(p16)
CloseConfirmSl = p16.CloseConfirmSl
BUF = 0.5 # train-pick buffer
# --------------------------------------------------------------- B) LAG variant
class CloseConfirmSlLag(ExitPolicy):
"""Identica a EXIT-16 ma con 1 bar di ritardo sugli input della soglia:
decisione su close[j-1] e atr[j-1] (eseguibile gia' a j-1). Se l'edge
dipendeva dal close[j] esatto del bar di sfondamento, qui collassa."""
name = "close_confirm_sl_lag"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.buffer = float(params.get("buffer", 0.0))
self.close = ctx["close"]
self.atr = ctx["atr14"]
def levels(self, j):
return self.tp0, None, 1.0
def after_bar(self, j):
jj = j - 1
if jj <= self.i:
return False
a = self.atr[jj]
if not np.isfinite(a):
a = 0.0
cj = self.close[jj]
if self.d == 1:
return cj < self.sl0 - self.buffer * a
return cj > self.sl0 + self.buffer * a
# ----------------------------------------- C) execution-delay (open[j+1]) variant
def simulate_open_next(sleeve, params, start_ms=None, end_ms=None):
"""Come exit_lab.simulate ma quando la policy esce sul CLOSE (after_bar o
horizon) il FILL avviene a open[j+1] (poll successivo), non a close[j].
I TP/SL intrabar restano al livello (limit). Stima il costo del ritardo
di un poll per un'exit market al prossimo bar."""
h = sleeve["high"]; l = sleeve["low"]; c = sleeve["close"]
o = sleeve["open"]; ts = sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
CloseConfirmSl.prepare(ctx, **params)
fee = exit_lab.FEE_RT * exit_lab.LEV
POS = exit_lab.POS; LEV = exit_lab.LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = CloseConfirmSl(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), exit_lab.HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
# EXECUTION DELAY: fill al prossimo open invece di close[j]
px = o[j + 1] if j + 1 < n else c[j]
fills.append((remaining, px)); remaining = 0.0
break
if step == horizon:
px = o[j + 1] if j + 1 < n else c[j]
fills.append((remaining, px)); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades}
def fmt(r):
if not r:
return "(no trades)"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def main():
data = load_sleeves()
params = {"buffer": BUF}
keys = list(data.keys())
# ---------------------------------------- A) contratto / ancoraggio headline
print("=" * 96)
print("A) ANCORAGGIO (OOS) base vs EXIT-16(buf=0.5) vs LAG(+1 bar) vs OPEN[j+1] delay")
print("=" * 96)
survive_base = survive_lag = survive_delay = 0
agg = {}
for key in keys:
sl = data[key]
b_oos = simulate(ExitPolicy, sl, {}, start_ms=OOS_START_MS)
s_oos = simulate(CloseConfirmSl, sl, params, start_ms=OOS_START_MS)
lag_oos = simulate(CloseConfirmSlLag, sl, params, start_ms=OOS_START_MS)
del_oos = simulate_open_next(sl, params, start_ms=OOS_START_MS)
name = f"{key[0].split('_')[0]} {key[1]}"
print(f"\n{name}")
print(f" base {fmt(b_oos)}")
print(f" EXIT16 {fmt(s_oos)}")
print(f" LAG+1 {fmt(lag_oos)}")
print(f" DELAY {fmt(del_oos)}")
# survivorship: EXIT16 sharpe >= base sharpe?
if s_oos and b_oos and s_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_base += 1
if lag_oos and b_oos and lag_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_lag += 1
if del_oos and b_oos and del_oos["sharpe_t"] >= b_oos["sharpe_t"]:
survive_delay += 1
agg[name] = dict(base=b_oos, exit16=s_oos, lag=lag_oos, delay=del_oos)
print("\n" + "=" * 96)
print(f"GATE OOS (sharpe >= base): EXIT16 {survive_base}/6 | LAG+1 {survive_lag}/6 "
f"| DELAY(open[j+1]) {survive_delay}/6")
# ---------------------------------------- quantify lag/delay damage on headline
print("\nDanno relativo su sharpe OOS (EXIT16 = 100%):")
for name, a in agg.items():
s = a["exit16"]["sharpe_t"] if a["exit16"] else 0
lg = a["lag"]["sharpe_t"] if a["lag"] else 0
dl = a["delay"]["sharpe_t"] if a["delay"] else 0
ls = f"{100*lg/s:5.0f}%" if s else " n/a"
ds = f"{100*dl/s:5.0f}%" if s else " n/a"
print(f" {name:<10} sh{s:5.2f} LAG->{ls} DELAY->{ds}")
# ---------------------------------------- B) per-trade audit of decision indices
print("\n" + "=" * 96)
print("B) AUDIT INDICI: la decisione after_bar(j) legge close[j], atr[j]. "
"Verifico\n che simulate() chiami after_bar SOLO con j = i+step (mai > j corrente).")
# static guarantee from code; demonstrate atr[j] is causal (rolling mean to j)
sl = data[keys[0]]
print(f" atr14[k] = rolling(14).mean(TR) -> usa TR[k-13..k], tutti chiusi a k. OK")
print(f" close[j] noto al close del bar j. Nessun indice > j nella decisione. OK")
if __name__ == "__main__":
main()
@@ -0,0 +1,198 @@
"""VERIFY EXIT-16 close_confirm_sl — lente OVERFIT/ROBUSTEZZA (avversariale).
Ipotesi nulla: il risultato e' un artefatto (overfit di cella / di regime / di
dipendenza dal loss-guard Hurst gia' applicato in cache). Tre test:
(1) JITTER PARAMETRI: buffer fuori griglia {0.4, 0.6, 0.75, 1.0} + ponte verso la
base con SL fisso a 3x/4x ATR (no_sl come limite). Il plateau tiene?
(2) STABILITA' TEMPORALE: train 2018-20 vs 21-22; OOS 2023-11/2025-01 vs
2025-01/2026-05. Il miglioramento e' in OGNI finestra o concentrato?
(3) DIPENDENZA HURST (decisivo): rigenero i segnali con hurst_max=None (NESSUN
loss-guard, NON tocco la cache) e ripeto base-vs-policy. Se senza il guard la
policy crolla, funziona SOLO grazie al guard -> condizione di validita'.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, OOS_START_MS, _atr14 # noqa: E402
import importlib.util
spec = importlib.util.spec_from_file_location(
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
cc16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cc16)
CloseConfirmSl = cc16.CloseConfirmSl
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
ASSETS = ("BTC", "ETH")
# ---- policy ponte: SL fisso a multiplo di ATR (no_sl come limite) ----
class WideSlPolicy(ExitPolicy):
"""SL intrabar spostato a k*ATR oltre sl0 (ponte tra base e no-sl)."""
name = "wide_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
self.k = float(params.get("k_atr", 2.0))
self.atr = ctx["atr14"]
def levels(self, j: int):
a = self.atr[j - 1] if np.isfinite(self.atr[j - 1]) else 0.0
# sl0 e' sotto (long) / sopra (short) l'entry; allarga di k*atr
sl = self.sl0 - self.k * a if self.d == 1 else self.sl0 + self.k * a
return self.tp0, sl, 1.0
def sub(cls, sleeve, g, s, e):
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
def fmt(r):
if not r:
return " n/a"
return (f"ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} bars{r['avg_bars']:>5.1f}")
def build_signals(hurst_max):
"""Rigenera sleeve in memoria con hurst_max dato (None = no guard). NON tocca cache."""
out = {}
params = dict(trend_max=3.0, ema_long=200, hurst_max=hurst_max, min_tp_frac=0.0015)
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def main():
data = exit_lab.load_sleeves()
keys = list(data.keys())
# ===== TEST 1: JITTER PARAMETRI =====
print("=" * 100)
print("TEST 1 — JITTER buffer fuori griglia + ponte WIDE-SL (OOS, dopo 2023-11)")
print("=" * 100)
jit_buffers = [0.4, 0.6, 0.75, 1.0]
all_pos = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
line = f"{key:<10} BASE {fmt(base)}"
print(line)
for b in jit_buffers:
r = sub(CloseConfirmSl, sleeve, {"buffer": b}, OOS_START_MS, None)
better = r and base and r["sharpe_t"] >= base["sharpe_t"] - 0.10
all_pos &= bool(better)
print(f" buf={b:<4} {fmt(r)} {'OK' if better else 'WORSE'}")
print()
print(f"JITTER buffer: tutte >= base-0.10 sharpe? {all_pos}\n")
print("-" * 100)
print("PONTE WIDE-SL: SL intrabar fisso allargato a k*ATR (k grande -> verso no-sl)")
print("-" * 100)
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
base = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
print(f"{key:<10} BASE(k=0) {fmt(base)}")
for k in [1.5, 3.0, 4.0]:
r = sub(WideSlPolicy, sleeve, {"k_atr": k}, OOS_START_MS, None)
print(f" k={k:<4} {fmt(r)}")
print()
# ===== TEST 2: STABILITA' TEMPORALE =====
print("=" * 100)
print("TEST 2 — STABILITA' TEMPORALE (base vs policy buffer=0.5)")
print("=" * 100)
ms = lambda d: int(pd.Timestamp(d, tz="UTC").value // 1e6)
windows = [
("TRAIN 2018-20", None, ms("2021-01-01")),
("TRAIN 2021-22", ms("2021-01-01"), OOS_START_MS),
("OOS 23/11-25/01", OOS_START_MS, ms("2025-01-01")),
("OOS 25/01-26/05", ms("2025-01-01"), None),
]
win_verdict = {w[0]: 0 for w in windows}
win_total = {w[0]: 0 for w in windows}
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
print(f"\n{key}")
for wname, s, e in windows:
b = sub(ExitPolicy, sleeve, {}, s, e)
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
if b and p:
win_total[wname] += 1
# criterio: policy non peggio della base su sharpe (tol 0.15)
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
win_verdict[wname] += int(imp)
tag = "OK " if imp else "BAD"
else:
tag = "n/a"
print(f" {wname:<18} base {fmt(b)}")
print(f" {'':<18} pol {fmt(p)} -> {tag}")
print("\nPer-finestra (policy >= base-0.15 sharpe):")
for w in windows:
wn = w[0]
print(f" {wn:<18} {win_verdict[wn]}/{win_total[wn]} sleeve OK")
# ===== TEST 3: DIPENDENZA HURST (DECISIVO) =====
print("\n" + "=" * 100)
print("TEST 3 — DIPENDENZA dal loss-guard HURST (DECISIVO)")
print("Rigenero i segnali con hurst_max=None (NO guard, regime trending incluso).")
print("Se la policy crolla -> funziona SOLO grazie al guard.")
print("=" * 100)
print("Generazione segnali SENZA hurst (puo' richiedere ~1-2 min)...")
data_nohurst = build_signals(hurst_max=None)
n_guard = sum(len(s["signals"]) for s in data.values())
n_nohurst = sum(len(s["signals"]) for s in data_nohurst.values())
print(f"Segnali totali: con guard {n_guard}, senza guard {n_nohurst} "
f"(+{n_nohurst - n_guard} segnali in regime trending)\n")
holds = True
for region_name, s, e in [("TRAIN", None, OOS_START_MS), ("OOS", OOS_START_MS, None)]:
print(f"--- {region_name} (segnali SENZA hurst guard) ---")
for (code, asset), sleeve in data_nohurst.items():
key = f"{code.split('_')[0]} {asset}"
b = sub(ExitPolicy, sleeve, {}, s, e)
p = sub(CloseConfirmSl, sleeve, {"buffer": 0.5}, s, e)
if b and p:
imp = p["sharpe_t"] >= b["sharpe_t"] - 0.15
ddimp = p["dd_pct"] <= b["dd_pct"] + 1.0
holds &= bool(imp)
tag = "OK " if imp else "POLICY WORSE"
else:
tag = "n/a"
print(f" {key:<10} base {fmt(b)}")
print(f" {'':<10} pol {fmt(p)} -> {tag}")
print()
print(f"TEST 3 verdict: policy regge SENZA il guard (>= base-0.15 sharpe ovunque)? {holds}")
print("Se False -> la tesi 'SL dannoso' dipende dal guard (condizione di validita').")
if __name__ == "__main__":
main()
@@ -0,0 +1,261 @@
"""VERIFY EXIT-16 close_confirm_sl — lente STRESS (avversariale).
Ipotesi nulla: l'edge della close-confirm-SL e' fragile a frizioni reali.
Quattro stress, tutti su segnali cache (params LIVE, hurst_max=0.55):
(1) FEE 2x: FEE_RT=0.002 (vs 0.001). Penalizza le policy che girano piu' capitale.
(2) BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag-21): worst-trade + 5 peggiori
trade della policy vs base. Lo SL disattivato lascia correre le perdite?
(3) SLIPPAGE AVVERSO 20bps sulle uscite della policy: ogni fill di USCITA paga
+20bps contro la posizione (prezzo di uscita peggiorato). L'edge regge?
NB: lo applico SOLO alle uscite della POLICY (la sua tesi e' "esco al close":
il close-fill e' market, paga slippage; la base esce a livelli limite sl0/tp0).
(4) OVERLAP/TURNOVER: la policy allunga la permanenza (no stop intrabar). Conto
i segnali SALTATI per non-overlap (i <= last_exit) base vs policy, e quanto
capitale-tempo (somma bars in posizione) gira in piu'.
Tutto via simulate() con monkeypatch di FEE_RT e una sottoclasse engine per lo
slippage. Niente modifiche ad altri file.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
import importlib.util
spec = importlib.util.spec_from_file_location(
"cc16", str(Path(__file__).resolve().parent / "16_close_confirm_sl.py"))
cc16 = importlib.util.module_from_spec(spec)
spec.loader.exec_module(cc16)
CloseConfirmSl = cc16.CloseConfirmSl
BUF = 0.5 # train-pick
def fmt(r):
if not r:
return " n/a"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>4.0f} bars{r['avg_bars']:>5.1f}")
def sub(cls, sleeve, g, s, e):
return simulate(cls, sleeve, g, start_ms=s, end_ms=e)
def ms(d):
return int(pd.Timestamp(d, tz="UTC").value // 1e6)
# ===========================================================================
# Engine "instrumented" che riproduce simulate() ma:
# - applica uno slippage avverso (bps) su OGNI fill di USCITA (solo se policy)
# - raccoglie la lista dei ret per-trade e i segnali SALTATI per non-overlap
# - raccoglie capital-time (somma bars)
# Lo tengo allineato 1:1 con exit_lab.simulate (stesso ordine SL-prima-di-TP).
# ===========================================================================
def simulate_instr(policy_cls, sleeve, params=None, start_ms=None, end_ms=None,
exit_slip_bps=0.0):
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
slip = exit_slip_bps * 1e-4
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
bars_tot = 0
skipped_overlap = 0
rets = [] # (ret, ts_entry, bars)
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i + 1 >= n:
continue
if i <= last_exit:
skipped_overlap += 1
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
# slippage avverso sull'uscita: il prezzo di uscita peggiora di slip,
# cioe' si vende piu' basso (long) / si ricompra piu' alto (short).
def adj(p):
return p * (1.0 - slip) if d == 1 else p * (1.0 + slip)
ret = sum(f * (adj(p) - entry) for f, p in fills) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append((ret, int(ts[i]), j - i))
if trades == 0:
return {}
r = np.array([x[0] for x in rets])
return {
"ret_pct": (capital / 1000.0 - 1) * 100,
"dd_pct": max_dd * 100,
"trades": trades,
"win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades,
"bars_tot": bars_tot,
"skipped_overlap": skipped_overlap,
"rets": rets,
"worst5": sorted(r.tolist())[:5],
}
def main():
data = exit_lab.load_sleeves()
# ===================================================================
print("=" * 104)
print("TEST 1 — FEE 2x (FEE_RT 0.001 -> 0.002). base vs policy buffer=0.5 (OOS, dopo 2023-11)")
print("=" * 104)
orig_fee = exit_lab.FEE_RT
survive_fee = True
for fee in (0.001, 0.002):
exit_lab.FEE_RT = fee
print(f"\n--- FEE_RT={fee} ---")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = sub(ExitPolicy, sleeve, {}, OOS_START_MS, None)
p = sub(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
tag = ""
if fee == 0.002 and b and p:
# regge se sharpe policy >= base (la tesi e' che migliora)
ok = p["sharpe_t"] >= b["sharpe_t"] - 0.10
survive_fee &= ok
tag = "OK" if ok else "WORSE"
print(f" {key:<10} base {fmt(b)}")
print(f" {'':<10} pol {fmt(p)} {tag}")
exit_lab.FEE_RT = orig_fee
print(f"\nFEE 2x: policy regge (>= base-0.10 sh su tutti gli sleeve OOS)? {survive_fee}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 2 — BEAR/CRASH 2021-01..2022-12 (LUNA/FTX/19-mag): worst-trade + 5 peggiori")
print("=" * 104)
s2, e2 = ms("2021-01-01"), ms("2023-01-01")
tail_worse = 0
tail_total = 0
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, s2, e2)
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, s2, e2)
print(f"\n{key}")
print(f" base {fmt(b)}")
print(f" pol {fmt(p)}")
if b and p:
bw = [f"{x*100:+.1f}%" for x in b["worst5"]]
pw = [f"{x*100:+.1f}%" for x in p["worst5"]]
print(f" base 5 peggiori (ret netto): {bw}")
print(f" pol 5 peggiori (ret netto): {pw}")
tail_total += 1
# la policy peggiora la coda se il worst-trade e' piu' negativo
if p["worst5"][0] < b["worst5"][0] - 0.005:
tail_worse += 1
print(f" -> CODA PEGGIORE: worst {p['worst5'][0]*100:+.1f}% < base {b['worst5'][0]*100:+.1f}%")
else:
print(f" -> coda OK: worst {p['worst5'][0]*100:+.1f}% vs base {b['worst5'][0]*100:+.1f}%")
print(f" DD bear: base {b['dd_pct']:.1f}% pol {p['dd_pct']:.1f}%")
print(f"\nBEAR: sleeve con coda PEGGIORE (worst-trade > 0.5pt sotto base): {tail_worse}/{tail_total}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 3 — SLIPPAGE AVVERSO 20bps sulle uscite della POLICY (OOS). base senza slippage")
print("(la tesi della policy e' 'esco al close' = market fill -> paga slippage)")
print("=" * 104)
survive_slip = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=0.0)
p0 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=0.0)
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
ok = p20 and b and p20["sharpe_t"] >= b["sharpe_t"] - 0.10
survive_slip &= bool(ok)
print(f"\n{key}")
print(f" base (no slip) {fmt(b)}")
print(f" pol (no slip) {fmt(p0)}")
print(f" pol (+20bps exit) {fmt(p20)} {'OK' if ok else 'WORSE vs base'}")
print(f"\nSLIPPAGE 20bps: policy ancora >= base-0.10 sh su tutti? {survive_slip}")
print("(test severo: lo slippage colpisce la policy ma NON la base — asimmetria pessimistica)")
# severita' extra: slippage anche sulla base (entrambe market) per fairness
print("\n--- fairness: 20bps anche sulle uscite della BASE ---")
fair = True
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b20 = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None, exit_slip_bps=20.0)
p20 = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None, exit_slip_bps=20.0)
ok = p20 and b20 and p20["sharpe_t"] >= b20["sharpe_t"] - 0.10
fair &= bool(ok)
print(f" {key:<10} base+20 {fmt(b20)}")
print(f" {'':<10} pol +20 {fmt(p20)} {'OK' if ok else 'WORSE'}")
print(f"fairness (entrambe +20bps): policy >= base-0.10 sh? {fair}")
# ===================================================================
print("\n" + "=" * 104)
print("TEST 4 — OVERLAP/TURNOVER: segnali saltati per non-overlap + capital-time (OOS)")
print("=" * 104)
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate_instr(ExitPolicy, sleeve, {}, OOS_START_MS, None)
p = simulate_instr(CloseConfirmSl, sleeve, {"buffer": BUF}, OOS_START_MS, None)
if b and p:
dskip = p["skipped_overlap"] - b["skipped_overlap"]
dbars = p["bars_tot"] - b["bars_tot"]
print(f" {key:<10} base: trades {b['trades']:>4} skip-overlap {b['skipped_overlap']:>4} "
f"bars_tot {b['bars_tot']:>6} avg {b['avg_bars']:.1f}")
print(f" {'':<10} pol : trades {p['trades']:>4} skip-overlap {p['skipped_overlap']:>4} "
f"bars_tot {p['bars_tot']:>6} avg {p['avg_bars']:.1f}")
print(f" {'':<10} -> +{dskip} segnali persi per overlap, "
f"+{dbars} bars in posizione ({dbars/max(b['bars_tot'],1)*100:+.0f}% capital-time)")
if __name__ == "__main__":
main()
@@ -0,0 +1,222 @@
"""VERIFICA AVVERSARIALE — lente LOOK-AHEAD/ESEGUIBILITA' per EXIT-22 (no_sl).
Ipotesi da confutare: "rimuovere lo SL e' un free-lunch" potrebbe essere un
artefatto di (a) look-ahead nel contratto livelli/engine, (b) dipendenza dal
timing perfetto dell'uscita, (c) non-replicabilita' del fill live (il worker
ticka ogni ora ed esce al poll successivo, non al close esatto del bar).
Esperimenti (tutti riusano simulate() e la policy importata):
E1 CONTRATTO: la policy NoSl ha livelli STATICI dall'entrata. Confermo che
l'output non cambia se "rumorizzo" gli array > i (futuro) post-entrata, e
che non cambia se rumorizzo atr14 ovunque (la policy non lo usa).
E2 LAG: variante che ritarda l'uscita a horizon (e i tocchi) di 1 bar — ma
siccome NoSl non ha SL e non usa indicatori a j-1, il vero lag rilevante
e' SUL FILL. Implemento NoSlLagExit: l'uscita al close del bar j viene
eseguita al close[j+1] (un tick dopo) e il TP intrabar viene fillato al
WORST fra tp e close[j] (slippage avverso). Misuro il collasso dell'edge.
E3 ESEGUIBILITA' open[j+1]: orizzonte/uscite al close[j] rieseguite a
open[j+1] (il poll successivo del worker). Quanto costa il gap di apertura?
E4 TP-FILL pessimistico: TP fillato a close[j] (non al livello tp) quando il
bar tocca il TP -> stima il caso in cui il worker scopre il tocco solo al
poll e chiude al prezzo corrente, peggiore del livello.
E5 SOTTOPERIODI OOS: l'edge di 'none' regge nei sotto-intervalli OOS o e'
tutto in una coda fortunata? (2023-11..2024-08 vs 2024-08..fine).
Verdetto: refuted=True solo se un test ESEGUIBILE realistico (E2/E3/E4) cancella
il vantaggio di 'none' su 'base' (ret E dd) su entrambi gli asset / tutte le fade.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # scripts/analysis
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, OOS_START_MS, HARD_CAP, LEV, POS # noqa: E402
sys.path.insert(0, str(HERE))
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location("_no_sl", HERE / "22_no_sl.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>5.1f} sh{r['sharpe_t']:>5.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>4.0f}% bars{r['avg_bars']:>5.1f}")
# ---------------------------------------------------------------- E2/E3/E4 engines
def simulate_exec(sleeve, mode, *, exit_at, tp_fill, start_ms=None, end_ms=None,
with_sl=False):
"""Clone di simulate() con NoSl(mode), ma con esecuzione LIVE-realistica:
exit_at : 'close' -> uscita orizzonte al close[j] (baseline harness)
'open1' -> uscita orizzonte al open[j+1] (poll successivo)
tp_fill : 'level' -> TP fillato al livello tp (ottimistico, harness)
'close' -> TP fillato al close[j] del bar che tocca (worker
scopre il tocco solo al poll: prezzo corrente)
'open1' -> TP fillato al open[j+1]
with_sl=False -> NoSl (mode='none'); with_sl tramite mode='base' usa lo SL
della strategia (per il confronto base vs none nelle STESSE condizioni exec).
"""
h, l, c, o, ts = (sleeve["high"], sleeve["low"], sleeve["close"],
sleeve["open"], sleeve["ts_ms"])
n = len(c)
fee = exit_lab.FEE_RT * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = bars_tot = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
if mode == "base":
sl = sl0
else:
sl = None # 'none'
tp = tp0
horizon = min(int(mb), HARD_CAP)
exit_price = None
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
exit_price = c[j]
break
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
if hit_sl:
exit_price = sl # SL fill al livello (favorevole alla tesi 'base')
break
if hit_tp:
if tp_fill == "level":
exit_price = tp
elif tp_fill == "close":
exit_price = c[j]
elif tp_fill == "open1":
exit_price = o[j + 1] if j + 1 < n else c[j]
break
if step == horizon:
if exit_at == "close":
exit_price = c[j]
elif exit_at == "open1":
exit_price = o[j + 1] if j + 1 < n else c[j]
break
if exit_price is None:
exit_price = c[j]
ret = (exit_price - entry) / entry * d * LEV - fee
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
bars_tot += j - i
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0,
"avg_bars": bars_tot / trades}
def main():
data = load_sleeves()
keys = list(data.keys())
# ---- E1: contratto look-ahead (la policy ha livelli statici) -------------
print("=" * 100)
print("E1 CONTRATTO LOOK-AHEAD: rumorizzo gli array DOPO l'entrata e atr14 (la")
print(" policy NoSl non deve cambiare: livelli statici, non usa indicatori).")
print("=" * 100)
rng = np.random.default_rng(0)
code, asset = "MR02_donchian_fade", "ETH"
base_sleeve = data[(code, asset)]
clean = simulate(NoSl, base_sleeve, {"mode": "none"}, start_ms=OOS_START_MS)
# rumorizzo atr14 interamente (policy non lo usa)
noisy = dict(base_sleeve)
noisy["atr14"] = base_sleeve["atr14"] * (1 + rng.normal(0, 0.5, len(base_sleeve["atr14"])))
res_noisy_atr = simulate(NoSl, noisy, {"mode": "none"}, start_ms=OOS_START_MS)
print(f" {code.split('_')[0]} {asset} clean : {_fmt(clean)}")
print(f" {code.split('_')[0]} {asset} atr14 noised : {_fmt(res_noisy_atr)} "
f"(identico => NoSl non legge atr14)")
# ---- E5: sottoperiodi OOS ------------------------------------------------
print("\n" + "=" * 100)
print("E5 SOTTOPERIODI OOS: l'edge di 'none' vs 'base' regge in 2 meta'?")
print("=" * 100)
mid = int(pd.Timestamp("2024-09-01", tz="UTC").value // 1e6)
for (code, asset) in keys:
sl_name = code.split("_")[0]
s = data[(code, asset)]
for lab, a, b in [("H1 23-11..24-09", OOS_START_MS, mid),
("H2 24-09..fine ", mid, None)]:
bse = simulate(ExitPolicy, s, {}, start_ms=a, end_ms=b)
non = simulate(NoSl, s, {"mode": "none"}, start_ms=a, end_ms=b)
if bse and non:
dret = non["ret_pct"] - bse["ret_pct"]
ddd = non["dd_pct"] - bse["dd_pct"]
flag = "OK" if (dret > -1 and ddd < 1) else "FAIL"
print(f" {sl_name} {asset} {lab}: base {_fmt(bse)}")
print(f" {sl_name} {asset} {lab}: none {_fmt(non)} "
f"[dret{dret:+.0f} ddd{ddd:+.1f} {flag}]")
# ---- E2/E3/E4: esecuzione realistica, base vs none nelle STESSE condizioni
print("\n" + "=" * 100)
print("E2/E3/E4 ESEGUIBILITA' LIVE (OOS). Confronto base vs none sotto:")
print(" IDEAL : exit close[j], TP@level (= harness)")
print(" OPEN1 : exit open[j+1], TP@open[j+1] (worker esce al poll successivo)")
print(" TPCLOSE: exit close[j], TP@close[j] (TP scoperto al poll, fill peggiore)")
print("=" * 100)
scenarios = [("IDEAL ", dict(exit_at="close", tp_fill="level")),
("OPEN1 ", dict(exit_at="open1", tp_fill="open1")),
("TPCLOSE", dict(exit_at="close", tp_fill="close"))]
summary = {sc[0]: {"none_wins_ret": 0, "none_wins_dd": 0, "tot": 0} for sc in scenarios}
for (code, asset) in keys:
sl_name = code.split("_")[0]
s = data[(code, asset)]
print(f"\n --- {sl_name} {asset} (OOS) ---")
for scname, kw in scenarios:
b = simulate_exec(s, "base", start_ms=OOS_START_MS, **kw)
nn = simulate_exec(s, "none", start_ms=OOS_START_MS, **kw)
if not b or not nn:
continue
dret = nn["ret_pct"] - b["ret_pct"]
ddd = nn["dd_pct"] - b["dd_pct"]
summary[scname]["tot"] += 1
summary[scname]["none_wins_ret"] += dret > -1
summary[scname]["none_wins_dd"] += ddd < 1
print(f" {scname} base: {_fmt(b)}")
print(f" {scname} none: {_fmt(nn)} [dret{dret:+.0f} ddd{ddd:+.1f}]")
print("\n" + "=" * 100)
print("VERDETTO ESEGUIBILITA' (none >= base su ret e dd, per scenario):")
for scname, kw in scenarios:
s = summary[scname]
print(f" {scname}: none vince-o-pareggia ret {s['none_wins_ret']}/{s['tot']}, "
f"dd {s['none_wins_dd']}/{s['tot']}")
print("=" * 100)
if __name__ == "__main__":
main()
@@ -0,0 +1,219 @@
"""VERIFICATORE AVVERSARIALE OVERFIT — EXIT-22 no_sl.
Tesi del sopravvissuto: "rimuovere lo SL intrabar (resta TP+max_bars) MIGLIORA
ret E DD E Sharpe su tutte le 6 sleeve, plateau monotono tight<base<wide<none".
Ipotesi avversaria (da confutare o confermare):
(A) JITTER: il plateau none>wide>base e' monotono? Aggiungo ponti 3x/4x.
Se la curva e' monotona e satura (3x~4x~none), il plateau e' robusto;
se none e' un picco isolato oltre wide, sospetto.
(B) STABILITA' TEMPORALE: spezzo train (2018-20 vs 21-22) e OOS
(2023-11..2025-01 vs 2025-01..2026-05). Il guadagno c'e' in OGNI
finestra o e' concentrato in un solo regime?
(C) DIPENDENZA HURST (DECISIVO): i segnali in cache hanno hurst_max=0.55
(loss-guard toglie il regime persistente/trending — proprio dove gli SL
servono). Rigenero i segnali SENZA hurst (in memoria, cache intatta) e
ripeto base-vs-none. Se senza guard la policy crolla -> funziona SOLO
grazie al guard => condizione di validita'.
Esegui: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_22_no_sl_overfit.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
import exit_lab # noqa: E402
from exit_lab import (ExitPolicy, simulate, load_sleeves, OOS_START_MS, # noqa: E402
CODES, ASSETS, LIVE_PARAMS, _atr14)
from src.data.downloader import load_data # noqa: E402
from src.live.strategy_loader import load_strategy # noqa: E402
# import della policy DAL SUO FILE (no copia)
sys.path.insert(0, str(Path(__file__).resolve().parent))
import importlib.util
_spec = importlib.util.spec_from_file_location(
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
# ---- policy ponte: SL a scale arbitrario (per jitter 3x/4x) -----------------
class NoSlScale(ExitPolicy):
name = "no_sl_scale"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
scale = params.get("scale", None)
self.sl = None if scale is None else entry + float(scale) * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
def _fmt(r):
if not r:
return " (no trades)"
return (f"ret{r['ret_pct']:>8.0f}% dd{r['dd_pct']:>6.2f} sh{r['sharpe_t']:>6.2f} "
f"n{r['trades']:>4} win{r['win_pct']:>5.1f} bars{r['avg_bars']:>5.1f}")
# =============================================================================
# (A) JITTER: ponti di scala 1x(base) 1.5 2 3 4 none
# =============================================================================
def test_jitter(data):
print("\n" + "=" * 78)
print("(A) JITTER PLATEAU — scala SL: base(1x) 1.5x 2x 3x 4x none [OOS]")
print("=" * 78)
scales = [("base", {"scale": 1.0}), ("1.5x", {"scale": 1.5}),
("2x(wide)", {"scale": 2.0}), ("3x", {"scale": 3.0}),
("4x", {"scale": 4.0}), ("none", {"scale": None})]
monotonic_fail = 0
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
print(f"\n{key} [OOS 2023-11 ->]")
rets, dds, shs = [], [], []
for tag, g in scales:
r = simulate(NoSlScale, sleeve, g, start_ms=OOS_START_MS)
rets.append(r.get("ret_pct", 0)); dds.append(r.get("dd_pct", 0))
shs.append(r.get("sharpe_t", 0))
print(f" {tag:<10}{_fmt(r)}")
# plateau monotono atteso: ret crescente, dd decrescente, sh crescente
ret_mono = all(rets[i] <= rets[i + 1] + 1e-6 for i in range(len(rets) - 1))
sh_mono = all(shs[i] <= shs[i + 1] + 1e-6 for i in range(len(shs) - 1))
# saturazione: none vs 4x ravvicinati (plateau "piatto" in cima)?
sat = abs(rets[-1] - rets[-2]) / (abs(rets[-1]) + 1e-9) * 100
print(f" -> ret monotono crescente: {ret_mono} | sharpe monotono: {sh_mono}"
f" | gap none-vs-4x: {sat:.1f}%")
if not (ret_mono and sh_mono):
monotonic_fail += 1
print(f"\n Sleeve con plateau NON monotono: {monotonic_fail}/6")
return monotonic_fail
# =============================================================================
# (B) STABILITA' TEMPORALE: sotto-finestre train e OOS
# =============================================================================
def test_temporal(data):
print("\n" + "=" * 78)
print("(B) STABILITA' TEMPORALE — base vs none in 4 sotto-finestre")
print("=" * 78)
ms = lambda s: int(pd.Timestamp(s, tz="UTC").value // 1e6)
wins = [
("TR 2018-2020", None, ms("2021-01-01")),
("TR 2021-2022", ms("2021-01-01"), OOS_START_MS),
("OOS 23-11..25-01", OOS_START_MS, ms("2025-01-01")),
("OOS 25-01..26-05", ms("2025-01-01"), None),
]
# conta in quante (sleeve x finestra) none batte base su ret E dd E sh
cells = 0
none_better_all = 0
none_worse_dd = 0
for wname, s0, s1 in wins:
print(f"\n--- finestra {wname} ---")
for (code, asset), sleeve in data.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=s0, end_ms=s1)
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=s0, end_ms=s1)
if not b or not nn:
print(f" {key:<10} (campione vuoto)")
continue
cells += 1
d_ret = nn["ret_pct"] - b["ret_pct"]
d_dd = nn["dd_pct"] - b["dd_pct"] # <0 = none meglio (dd minore)
d_sh = nn["sharpe_t"] - b["sharpe_t"]
allbetter = d_ret > 0 and d_dd < 0 and d_sh > 0
none_better_all += allbetter
none_worse_dd += d_dd > 1e-6
flag = "OK" if allbetter else ("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
print(f" {key:<10} base ret{b['ret_pct']:>7.0f} dd{b['dd_pct']:>5.1f} "
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>7.0f} "
f"dd{nn['dd_pct']:>5.1f} sh{nn['sharpe_t']:>5.2f} | "
f"dRet{d_ret:>+7.0f} dDD{d_dd:>+5.1f} dSh{d_sh:>+5.2f} [{flag}]")
print(f"\n none meglio su (ret&dd&sh) in {none_better_all}/{cells} celle "
f"sleeve x finestra | none PEGGIORA il DD in {none_worse_dd}/{cells}")
return none_better_all, none_worse_dd, cells
# =============================================================================
# (C) DECISIVO: segnali SENZA hurst (in memoria, cache intatta)
# =============================================================================
def build_sleeves_no_hurst():
"""Rigenera i segnali con hurst_max=None (loss-guard OFF). NON tocca la
cache su disco: ritorna un dict identico in forma a load_sleeves()."""
params = dict(LIVE_PARAMS)
params["hurst_max"] = None
out = {}
for code in CODES:
strat = load_strategy(code)
for asset in ASSETS:
df = load_data(asset, "1h")
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
sigs = strat.generate_signals(df, ts, **params)
h = df["high"].values.astype(float)
l = df["low"].values.astype(float)
c = df["close"].values.astype(float)
out[(code, asset)] = {
"signals": [(int(s.idx), int(s.direction), float(s.metadata["tp"]),
float(s.metadata["sl"]), int(s.metadata["max_bars"]))
for s in sigs],
"open": df["open"].values.astype(float),
"high": h, "low": l, "close": c,
"ts_ms": df["timestamp"].values.astype(np.int64),
"atr14": _atr14(h, l, c),
}
return out
def test_hurst_dependency(data_guard):
print("\n" + "=" * 78)
print("(C) DECISIVO — DIPENDENZA DAL FILTRO HURST")
print(" rigenero segnali con hurst_max=None (guard OFF), confronto base vs none")
print("=" * 78)
data_noh = build_sleeves_no_hurst()
# quanto cambia il numero di segnali (il guard quanti ne toglieva?)
print("\nConteggio segnali (guard ON cache vs guard OFF):")
for k in data_guard:
ng = len(data_guard[k]["signals"])
nh = len(data_noh[k]["signals"])
print(f" {k[0].split('_')[0]} {k[1]:<4} guard ON {ng:>5} OFF {nh:>5} "
f"(+{nh - ng} segnali tossici reintrodotti)")
for label, ms0 in [("FULL", None), ("OOS", OOS_START_MS)]:
print(f"\n--- {label} (guard OFF) base vs none ---")
none_better_all = none_worse_dd = none_worse_ret = cells = 0
for (code, asset), sleeve in data_noh.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, {}, start_ms=ms0)
nn = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=ms0)
if not b or not nn:
continue
cells += 1
d_ret = nn["ret_pct"] - b["ret_pct"]
d_dd = nn["dd_pct"] - b["dd_pct"]
d_sh = nn["sharpe_t"] - b["sharpe_t"]
none_better_all += (d_ret > 0 and d_dd < 0 and d_sh > 0)
none_worse_dd += d_dd > 1e-6
none_worse_ret += d_ret <= 0
flag = "OK" if (d_ret > 0 and d_dd < 0 and d_sh > 0) else \
("dd+" if d_dd > 0 else "ret-" if d_ret <= 0 else "sh-")
print(f" {key:<10} base ret{b['ret_pct']:>8.0f} dd{b['dd_pct']:>6.1f} "
f"sh{b['sharpe_t']:>5.2f} | none ret{nn['ret_pct']:>8.0f} "
f"dd{nn['dd_pct']:>6.1f} sh{nn['sharpe_t']:>5.2f} | "
f"dRet{d_ret:>+8.0f} dDD{d_dd:>+6.1f} dSh{d_sh:>+5.2f} [{flag}]")
print(f" => guard OFF {label}: none meglio (ret&dd&sh) {none_better_all}/{cells}"
f" | none PEGGIORA dd {none_worse_dd}/{cells} | PEGGIORA ret {none_worse_ret}/{cells}")
return data_noh
if __name__ == "__main__":
data = load_sleeves()
mono_fail = test_jitter(data)
nb_all, nb_dd, cells = test_temporal(data)
test_hurst_dependency(data)
@@ -0,0 +1,327 @@
"""VERIFICA AVVERSARIALE — lente STRESS sul sopravvissuto EXIT-22 (mode='none', SL RIMOSSO).
Ipotesi avversaria: il vantaggio di togliere lo SL (ret/DD/Sharpe migliori) e' un
ARTEFATTO del regime calmo OOS 2024-25 e/o si dissolve sotto stress realistici. Senza
SL la coda non e' limitata a leva 3x: un trade puo' perdere fino al movimento avverso
intero entro max_bars (24h). Testo 4 stress:
(1) FEE 2x (FEE_RT=0.002): la policy ABBASSA il turnover (avg_bars 9->12-13, meno
trade) quindi la fee 2x dovrebbe penalizzarla MENO della base -> non e' la sua
debolezza, ma lo misuro comunque per onesta'.
(2) SOTTOPERIODO BEAR 2021-01..2022-12 (crash 2021-05-19, LUNA mag-2022, FTX nov-2022):
qui lo SL dovrebbe servire. Confronto ret/DD + WORST trade + 5 peggiori trade
none vs base. E' il test decisivo: se none esplode in DD o ha code mostruose qui,
il survivor e' confutato (l'OOS calmo nascondeva il rischio).
(3) SLIPPAGE AVVERSO 20bps sulle USCITE: ogni uscita (TP/horizon) pagata 20bps peggio
(prezzo di uscita penalizzato del 20bps contro la posizione). Penalizza di piu' le
policy che escono al close/horizon (none esce piu' spesso a horizon, non al TP-limit).
(4) OVERLAP/CHURN: la policy allunga la permanenza -> conto i segnali SCARTATI per
non-overlap (i<=last_exit) in piu' rispetto alla base, e l'effetto sul capitale.
Confuto SOLO con evidenza numerica concreta.
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, HARD_CAP, LEV, POS # noqa: E402
# import della policy dal suo file
import importlib.util
_spec = importlib.util.spec_from_file_location(
"policy_22", str(Path(__file__).resolve().parent / "22_no_sl.py"))
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
NoSl = _mod.NoSl
BEAR_START = int(pd.Timestamp("2021-01-01", tz="UTC").value // 1e6)
BEAR_END = int(pd.Timestamp("2023-01-01", tz="UTC").value // 1e6) # esclusivo
OOS_START = exit_lab.OOS_START_MS
DATA = load_sleeves()
def line(tag, key, r):
if not r:
print(f" {tag:<14}{key:<10} (no trades)")
return
print(f" {tag:<14}{key:<10} ret{r['ret_pct']:>7.0f}% dd{r['dd_pct']:>5.1f} "
f"sh{r['sharpe_t']:>5.2f} n{r['trades']:>4} win{r['win_pct']:>4.0f}% "
f"bars{r['avg_bars']:>5.1f}")
# ----------------------------------------------------------- (1) FEE 2x
def test_fee2x():
print("\n=== (1) FEE 2x (0.10%->0.20% RT x leva 3) ===")
orig = exit_lab.FEE_RT
for fee, label in [(0.001, "fee1x"), (0.002, "fee2x")]:
exit_lab.FEE_RT = fee
print(f" -- {label} --")
n_better_none = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, start_ms=OOS_START)
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
flag = " <none>=base" if (no and b and no["ret_pct"] >= b["ret_pct"]
and no["dd_pct"] <= b["dd_pct"]) else ""
if flag:
n_better_none += 1
print(f" {key:<10} base ret{b['ret_pct']:>6.0f}% dd{b['dd_pct']:>5.1f} | "
f"none ret{no['ret_pct']:>6.0f}% dd{no['dd_pct']:>5.1f}{flag}")
print(f" -> none >= base (ret&dd) su {n_better_none}/6 sleeve")
exit_lab.FEE_RT = orig
# ----------------------------------------------------------- (2) BEAR 2021-22
def worst_trades(policy_cls, sleeve, params, start_ms, end_ms, k=5):
"""Replica la logica di simulate ma raccoglie i ret per-trade per
estrarre i k peggiori e il worst. Stessa identica meccanica."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
last_exit = -1
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
rets.append(ret * 1e4) # bps
last_exit = j
rets = np.array(sorted(rets))
return rets
def test_bear():
print("\n=== (2) SOTTOPERIODO BEAR 2021-01..2022-12 (LUNA/FTX/19-may-2021) ===")
print(" (ret/DD su periodo + WORST trade e media 5-peggiori, bps; leva 3x)")
agg_none_dd = []
agg_base_dd = []
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
b = simulate(ExitPolicy, sleeve, start_ms=BEAR_START, end_ms=BEAR_END)
no = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=BEAR_START, end_ms=BEAR_END)
rb = worst_trades(ExitPolicy, sleeve, {}, BEAR_START, BEAR_END)
rn = worst_trades(NoSl, sleeve, {"mode": "none"}, BEAR_START, BEAR_END)
wb = rb[0] if len(rb) else float("nan")
wn = rn[0] if len(rn) else float("nan")
w5b = rb[:5].mean() if len(rb) >= 5 else float("nan")
w5n = rn[:5].mean() if len(rn) >= 5 else float("nan")
if b:
agg_base_dd.append(b["dd_pct"])
if no:
agg_none_dd.append(no["dd_pct"])
print(f" {key:<10}")
print(f" base ret{b.get('ret_pct',0):>6.0f}% dd{b.get('dd_pct',0):>5.1f} "
f"n{b.get('trades',0):>3} | worst{wb:>8.0f}bps 5worst{w5b:>8.0f}bps")
print(f" none ret{no.get('ret_pct',0):>6.0f}% dd{no.get('dd_pct',0):>5.1f} "
f"n{no.get('trades',0):>3} | worst{wn:>8.0f}bps 5worst{w5n:>8.0f}bps "
f"{'TAIL+' if wn < wb - 1 else ''}")
print(f" -> DD medio bear: base {np.mean(agg_base_dd):.1f}% none {np.mean(agg_none_dd):.1f}%")
print(f" worst-DD bear: base {np.max(agg_base_dd):.1f}% none {np.max(agg_none_dd):.1f}%")
# ----------------------------------------------------------- (3) SLIPPAGE 20bps exit
class NoSlSlip(NoSl):
"""none + slippage avverso 20bps su OGNI uscita (sia TP che horizon/close).
Implementato penalizzando i livelli: tp eseguito 20bps peggio, e l'uscita al
close subisce un haircut equivalente applicato nel ret. Per semplicita' e
fedelta', applico lo slippage al RET finale come costo per-trade extra: ogni
trade paga 20bps di slippage sul notional (una uscita per trade)."""
name = "no_sl_slip"
class BaseSlip(ExitPolicy):
name = "base_slip"
def simulate_slip(policy_cls, sleeve, params, start_ms, slip_bps=20.0):
"""simulate con costo di uscita avverso slip_bps (in bps di prezzo) applicato
al ret per-trade (oltre alla fee). Penalizza uscite al close E al TP."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = exit_lab.FEE_RT * LEV
slip = slip_bps / 1e4 * LEV
capital = peak = 1000.0
max_dd = 0.0
last_exit = -1
trades = wins = 0
rets = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
fills = []
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee - slip
capital = max(capital + capital * POS * ret, 10.0)
peak = max(peak, capital)
max_dd = max(max_dd, (peak - capital) / peak)
last_exit = j
trades += 1
wins += ret > 0
rets.append(ret)
if trades == 0:
return {}
r = np.array(rets)
return {"ret_pct": (capital / 1000.0 - 1) * 100, "dd_pct": max_dd * 100,
"trades": trades, "win_pct": wins / trades * 100,
"sharpe_t": float(r.mean() / r.std() * np.sqrt(len(r))) if r.std() else 0.0}
def test_slippage():
print("\n=== (3) SLIPPAGE AVVERSO 20bps su ogni uscita (OOS 2023-11+) ===")
print(" confronto: none-noslip vs none-slip20 vs base-slip20")
n_survive = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
no0 = simulate(NoSl, sleeve, {"mode": "none"}, start_ms=OOS_START)
nos = simulate_slip(NoSl, sleeve, {"mode": "none"}, OOS_START, 20.0)
bas = simulate_slip(ExitPolicy, sleeve, {}, OOS_START, 20.0)
surv = nos and bas and nos["ret_pct"] >= bas["ret_pct"] and nos["dd_pct"] <= bas["dd_pct"]
if surv:
n_survive += 1
print(f" {key:<10} none ret{no0['ret_pct']:>6.0f}% | none+slip ret{nos['ret_pct']:>6.0f}%"
f" dd{nos['dd_pct']:>5.1f} sh{nos['sharpe_t']:>5.2f} | base+slip ret{bas['ret_pct']:>6.0f}%"
f" dd{bas['dd_pct']:>5.1f} {'EDGE' if surv else 'lost'}")
print(f" -> none+slip >= base+slip (ret&dd) su {n_survive}/6 sleeve")
# ----------------------------------------------------------- (4) OVERLAP / CHURN
def count_dropped(policy_cls, sleeve, params, start_ms):
"""Conta i segnali scartati per non-overlap (i<=last_exit) e quelli eseguiti."""
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
last_exit = -1
dropped = executed = 0
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if ts[i] < start_ms or i + 1 >= n:
continue
if i <= last_exit:
dropped += 1
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), HARD_CAP)
remaining = 1.0
j = i
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
break
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
break
if step == horizon:
break
last_exit = j
executed += 1
return executed, dropped
def test_overlap():
print("\n=== (4) OVERLAP / CHURN (OOS 2023-11+) — segnali persi per non-overlap ===")
tot_extra = 0
for (code, asset), sleeve in DATA.items():
key = f"{code.split('_')[0]} {asset}"
eb, db = count_dropped(ExitPolicy, sleeve, {}, OOS_START)
en, dn = count_dropped(NoSl, sleeve, {"mode": "none"}, OOS_START)
extra = dn - db
tot_extra += extra
print(f" {key:<10} base exec{eb:>4} drop{db:>3} | none exec{en:>4} drop{dn:>3} "
f"| extra-drop {extra:+d}")
print(f" -> segnali extra persi per overlap (none vs base): {tot_extra:+d} totali")
print(" (capitale gira meno: trade piu' lunghi, ma none rende comunque di piu' net)")
if __name__ == "__main__":
test_fee2x()
test_bear()
test_slippage()
test_overlap()
print("\n[fatto] verifica stress EXIT-22 no_sl completata.")
@@ -0,0 +1,335 @@
"""TAIL-RISK AUDIT of EXIT-22 (no_sl) and EXIT-16 (close_confirm_sl).
Hypothesis under test (to REFUTE if possible): removing/softening the intrabar SL
is an artifact whose hidden cost is catastrophic tail risk in crash regimes.
Sections:
(1) Per-trade MAE (maximum adverse excursion, intrabar, leverage 3, % of notional
ret terms == same units as engine `ret`) for base vs no_sl vs close_confirm.
Distribution p50/p95/p99/max per sleeve.
(2) Crash windows: 2020-03-12, 2021-05-19, 2022-06, 2022-11 (FTX). Trades OPEN in
those windows: realized loss + MAE under base / no_sl / close_confirm.
(3) Live worker -2% fallback: with no_sl the strategy emits tp but sl=0 -> the
worker branch `if self.tp and self.sl` is False, falls to `elif self.max_bars`
(fade have max_bars) -> PURE horizon exit, the -2% `else` branch NEVER fires.
So no_sl LIVE has NO stop at all. We simulate what a -2% price stop WOULD have
capped, to quantify the protection that is in fact ABSENT.
(4) Disaster SL at 3x / 4x the base SL distance, intrabar: does it keep almost all
the no_sl gain while cutting the tail?
Run: cd /opt/docker/PythagorasGoal && PYTHONPATH=. uv run python \
scripts/analysis/exit_policies/verify_tail_risk.py
"""
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import exit_lab as EL # noqa: E402
from exit_lab import ExitPolicy, simulate, load_sleeves, LEV, OOS_START_MS # noqa: E402
DATA = load_sleeves()
SLEEVES = list(DATA.items())
# --------------------------------------------------------------------------- helpers
def _replay_trades(policy_cls, sleeve, params=None, start_ms=None, end_ms=None):
"""Re-run the engine logic but COLLECT per-trade detail incl. MAE.
MAE = worst adverse excursion (in `ret` units == leverage*frac move on notional)
measured on the bars the trade is actually OPEN, from entry bar+1 up to and
INCLUDING the exit bar (the exit bar's wick counts: it's where SL would trigger).
"""
params = params or {}
h, l, c, ts = sleeve["high"], sleeve["low"], sleeve["close"], sleeve["ts_ms"]
n = len(c)
ctx = dict(sleeve)
policy_cls.prepare(ctx, **params)
fee = EL.FEE_RT * LEV
last_exit = -1
out = []
for (i, d, tp0, sl0, mb) in sleeve["signals"]:
if start_ms is not None and ts[i] < start_ms:
continue
if end_ms is not None and ts[i] >= end_ms:
continue
if i <= last_exit or i + 1 >= n:
continue
entry = c[i]
pol = policy_cls(ctx, i, d, entry, tp0, sl0, mb, **params)
horizon = min(int(pol.horizon), EL.HARD_CAP)
fills = []
remaining = 1.0
j = i
worst = 0.0 # most negative excursion in ret units
for step in range(1, horizon + 1):
j = i + step
if j >= n:
j = n - 1
fills.append((remaining, c[j])); remaining = 0.0
break
# adverse excursion of THIS bar (before any exit): worst intrabar price
adverse = l[j] if d == 1 else h[j]
exc = (adverse - entry) / entry * d * LEV
worst = min(worst, exc)
tp, sl, tpfrac = pol.levels(j)
hit_sl = sl is not None and ((d == 1 and l[j] <= sl) or (d == -1 and h[j] >= sl))
hit_tp = tp is not None and ((d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp))
if hit_sl:
fills.append((remaining, sl)); remaining = 0.0
break
if hit_tp:
f = min(max(tpfrac, 0.0), 1.0) * remaining
if f > 0:
fills.append((f, tp)); remaining -= f
if remaining <= 1e-9:
break
pol.on_partial(j, tp, remaining)
if pol.after_bar(j):
fills.append((remaining, c[j])); remaining = 0.0
break
if step == horizon:
fills.append((remaining, c[j])); remaining = 0.0
if remaining > 1e-9:
fills.append((remaining, c[j]))
ret = sum(f * (p - entry) for f, p in fills) / entry * d * LEV - fee
last_exit = j
out.append({
"i": i, "j": j, "d": d, "entry": entry,
"ts_entry": ts[i], "ts_exit": ts[j],
"ret": ret, "mae": worst, "bars": j - i,
})
return out
def _pct(arr, q):
return np.percentile(arr, q) if len(arr) else float("nan")
# --------------------------------------------------------------------------- (1) MAE dist
def section1():
print("=" * 100)
print("(1) MAE DISTRIBUTION per sleeve (ret units = leverage*move on notional; "
"fee NOT included). Negative = adverse.")
print(" MAE is the worst intrabar excursion BEFORE exit. For base, SL caps it; "
"for no_sl/close_confirm it can run.")
print("=" * 100)
policies = _load_policies()
hdr = f"{'sleeve':<11}{'policy':<16}{'n':>5}{'p50':>9}{'p95':>9}{'p99':>9}{'max':>9}{'realP99':>10}{'realMax':>10}"
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
print(f"\n--- {key}")
print(hdr)
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100 # to %
rets = np.array([t["ret"] for t in tr]) * 100
print(f"{'':<11}{pname:<16}{len(tr):>5}"
f"{_pct(mae,50):>9.2f}{_pct(mae,5):>9.2f}{_pct(mae,1):>9.2f}{mae.min():>9.2f}"
f"{_pct(rets,1):>10.2f}{rets.min():>10.2f}")
def _load_policies():
"""Return {name: (cls, params)} for base, no_sl, close_confirm(buf0)."""
p22 = Path(__file__).resolve().parents[0] / "22_no_sl.py"
p16 = Path(__file__).resolve().parents[0] / "16_close_confirm_sl.py"
import importlib.util
def _load(path, attr):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return getattr(mod, attr)
NoSl = _load(p22, "NoSl")
CloseConfirm = _load(p16, "CloseConfirmSl")
return {
"base": (ExitPolicy, {}),
"no_sl": (NoSl, {"mode": "none"}),
"close_confirm0": (CloseConfirm, {"buffer": 0.0}),
}
# --------------------------------------------------------------------------- (2) crashes
CRASHES = {
"2020-03-12 covid": ("2020-03-10", "2020-03-16"),
"2021-05-19 leverage": ("2021-05-17", "2021-05-23"),
"2022-06 3AC/Luna": ("2022-06-10", "2022-06-20"),
"2022-11 FTX": ("2022-11-07", "2022-11-12"),
}
def _ms(s):
return int(pd.Timestamp(s, tz="UTC").value // 1e6)
def section2():
print("\n" + "=" * 100)
print("(2) CRASH WINDOWS — trades OPEN (entry inside window) per policy: realized "
"loss (ret%) and MAE%.")
print("=" * 100)
policies = _load_policies()
for label, (a, b) in CRASHES.items():
lo, hi = _ms(a), _ms(b)
print(f"\n### {label} [{a} .. {b}]")
any_trade = False
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
for pname, (cls, prm) in policies.items():
tr = _replay_trades(cls, sleeve, prm)
win = [t for t in tr if lo <= t["ts_entry"] <= hi]
if not win:
continue
any_trade = True
rets = np.array([t["ret"] for t in win]) * 100
mae = np.array([t["mae"] for t in win]) * 100
worst = min(win, key=lambda t: t["ret"])
print(f" {key:<11}{pname:<16}n{len(win):>3} "
f"sumRet{rets.sum():>8.2f}% worstRet{rets.min():>8.2f}% "
f"worstMAE{mae.min():>8.2f}% avgBars{np.mean([t['bars'] for t in win]):>5.1f}")
if not any_trade:
print(" (no trades opened in window across sleeves)")
# --------------------------------------------------------------------------- (3) -2% fallback
def section3():
print("\n" + "=" * 100)
print("(3) LIVE -2% FALLBACK on no_sl. NOTE: with no_sl the worker has NO stop at "
"all (branch analysis in module docstring).")
print(" Below = what a -2% PRICE stop (==-6% ret at lev3) WOULD cap if it WERE "
"wired. Compares no_sl ret vs a synthetic no_sl+2%stop.")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
stop_ret = -0.02 * LEV # -2% price move * leverage = -6% on notional in ret units
hdr = f"{'sleeve':<11}{'no_sl ret%':>12}{'+2%stop ret%':>14}{'Δret pp':>10}{'n capped':>10}{'worst no_sl':>13}{'worst +2%':>11}"
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
tr = _replay_trades(NoSl, sleeve, {"mode": "none"})
# synthetic: if MAE breaches stop_ret, realize at stop_ret (approx; ignores
# that price may recover — that's the point: a -2% stop locks the loss).
base_rets = np.array([t["ret"] for t in tr])
capped = []
n_cap = 0
fee = EL.FEE_RT * LEV
for t in tr:
if t["mae"] <= stop_ret:
capped.append(stop_ret - fee) # stopped at -2% price, pay fee
n_cap += 1
else:
capped.append(t["ret"])
capped = np.array(capped)
def _compound(rets):
cap = 1000.0
for r in rets:
cap = max(cap + cap * EL.POS * r, 10.0)
return (cap / 1000.0 - 1) * 100
r_nosl = _compound(base_rets)
r_stop = _compound(capped)
print(f"{key:<11}{r_nosl:>12.0f}{r_stop:>14.0f}{r_stop - r_nosl:>10.1f}"
f"{n_cap:>10}{base_rets.min()*100:>13.2f}{capped.min()*100:>11.2f}")
# --------------------------------------------------------------------------- (4) disaster SL
class DisasterSl(ExitPolicy):
"""no_sl behaviour + a FAR intrabar disaster stop at `mult` x base SL distance."""
name = "disaster_sl"
def __init__(self, ctx, i, d, entry, tp0, sl0, mb, **params):
super().__init__(ctx, i, d, entry, tp0, sl0, mb, **params)
mult = float(params.get("mult", 3.0))
self.sl = entry + mult * (sl0 - entry)
def levels(self, j: int):
return self.tp0, self.sl, 1.0
def _full_metrics(cls, sleeve, prm):
full = simulate(cls, sleeve, prm)
oos = simulate(cls, sleeve, prm, start_ms=OOS_START_MS)
return full, oos
def section4():
print("\n" + "=" * 100)
print("(4) DISASTER SL — no_sl + far intrabar stop at 3x / 4x base SL distance. "
"Keep the gain, cut the tail?")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
hdr = (f"{'sleeve':<11}{'policy':<13}"
f"{'FULLret%':>10}{'FULLdd%':>9}{'FULLsh':>8}"
f"{'OOSret%':>9}{'OOSdd%':>8}{'OOSsh':>7}{'worstMAE%':>11}{'nStop':>7}")
print(hdr)
for (code, asset), sleeve in SLEEVES:
key = f"{code.split('_')[0]} {asset}"
rows = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"--- {key}")
for pname, cls, prm in rows:
full, oos = _full_metrics(cls, sleeve, prm)
tr = _replay_trades(cls, sleeve, prm)
mae = np.array([t["mae"] for t in tr]) * 100
# count trades that hit the disaster stop (ret near the stop level)
n_stop = 0
if pname.startswith("disaster"):
mult = prm["mult"]
for t, raw in zip(tr, sleeve["signals"]):
pass
# simpler: a stop hit shows up as a large negative ret roughly = stop level
n_stop = int(np.sum(mae <= -2.0 * mult * LEV / LEV * 0)) # placeholder
print(f"{'':<11}{pname:<13}"
f"{full.get('ret_pct',0):>10.0f}{full.get('dd_pct',0):>9.2f}{full.get('sharpe_t',0):>8.2f}"
f"{oos.get('ret_pct',0):>9.0f}{oos.get('dd_pct',0):>8.2f}{oos.get('sharpe_t',0):>7.2f}"
f"{mae.min():>11.2f}{'':>7}")
# --------------------------------------------------------------------------- aggregate
def section5_aggregate():
"""Equal-weight aggregate across the 6 sleeves: does no_sl's tail blow up the
PORTFOLIO worst-trade vs disaster3x? Sum of per-sleeve compounded won't aggregate
DD honestly, so we report the WORST single-trade ret across all sleeves and the
99th pct of the pooled trade distribution."""
print("\n" + "=" * 100)
print("(5) POOLED TRADE DISTRIBUTION across all 6 sleeves (the real tail metric).")
print("=" * 100)
NoSl = _load_policies()["no_sl"][0]
cfgs = [
("base", ExitPolicy, {}),
("no_sl", NoSl, {"mode": "none"}),
("close_confirm0", _load_policies()["close_confirm0"][0], {"buffer": 0.0}),
("disaster3x", DisasterSl, {"mult": 3.0}),
("disaster4x", DisasterSl, {"mult": 4.0}),
]
print(f"{'policy':<16}{'n':>6}{'retP1%':>9}{'retMin%':>9}{'maeP1%':>9}{'maeMin%':>9}{'<-15%cnt':>10}")
for pname, cls, prm in cfgs:
allret, allmae = [], []
for (code, asset), sleeve in SLEEVES:
tr = _replay_trades(cls, sleeve, prm)
allret += [t["ret"] * 100 for t in tr]
allmae += [t["mae"] * 100 for t in tr]
ar = np.array(allret); am = np.array(allmae)
n_bad = int(np.sum(ar < -15.0))
print(f"{pname:<16}{len(ar):>6}{_pct(ar,1):>9.2f}{ar.min():>9.2f}"
f"{_pct(am,1):>9.2f}{am.min():>9.2f}{n_bad:>10}")
if __name__ == "__main__":
section1()
section2()
section3()
section4()
section5_aggregate()