chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera libreria "validata OOS" era artefatto di feed contaminato (print fantasma del feed Cerbero TESTNET + storico Binance/USDT). - Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE 50-82% barre flat; XRP/BNB non certificabili). - Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST con segnale residuo, da ri-validare in isolamento. - Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio, runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/ portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/ (preservati, non cancellati). Diario consolidato in un unico documento. - Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal + src/backtest/engine + load_data; tool dati certificati (rebuild_history, certify_feed, audit_feed, multi_source_check). - Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user