research(exit-lab): 34 agenti su exit dinamiche → EXIT-16 close-confirm SL PROMOSSO a livello PORT06
23 famiglie esplorate (harness condiviso exit_lab, train/OOS embargo nov-2023, tutto lo storico 1h 2018-2026) + 10 verifiche avversariali + test PORT06. 'Cavalcare il prezzo' non esiste (4a conferma: oltre il TP=media non c'e' coda). Scoperta: lo SL intrabar fisso e' il distruttore di valore n.1 delle fade (stop da wick = falsi negativi). Forma robusta: SL solo su CHIUSURA oltre sl0±0.5·ATR14 — PORT06 FULL Sharpe 6.47→7.84 DD 4.10→2.60, OOS 8.82→10.06. Collaterali: bias gap-through dell'engine sugli stop stretti; ramo -2% del worker morto con sl=0. Diario: docs/diary/2026-06-04-exit-lab.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""TEST DECISIVO: impatto di EXIT-16 (close_confirm_sl, buffer=0.5 ATR) sul PORT06,
|
||||
nel PATH CANONICO del backtest (NON exit_lab).
|
||||
|
||||
EXIT-16: lo SL intrabar e' DISATTIVATO; si esce al close del bar j solo se il close
|
||||
ha sfondato il livello di buffer*ATR14:
|
||||
long (d=1): esci a close[j] se close[j] < sl0 - 0.5*atr14[j]
|
||||
short (d=-1): esci a close[j] se close[j] > sl0 + 0.5*atr14[j]
|
||||
TP intrabar al livello e max_bars al close restano INVARIATI.
|
||||
|
||||
Metodo (come fu fatto per il loss-guard Hurst):
|
||||
1. build_everything() canonico -> equity giornaliere di TUTTI gli sleeve (cache intatta).
|
||||
2. ricostruisco le 6 equity fade in variante EXIT-16 replicando ESATTAMENTE
|
||||
fade_daily_equity/build_trades (stessi segnali fn(df,**params), trend_max=3.0,
|
||||
fee 0.10%RT*lev3, pos 0.15, compounding, non-overlap), cambiando SOLO il ramo SL.
|
||||
3. PARITA': con la SL-rule originale il replay deve riprodurre le equity canoniche.
|
||||
4. PORT06 base vs EXIT-16 con la STESSA matematica dei pesi (Portfolio.backtest):
|
||||
weighting cap, caps PAIRS 0.33, ribilancio 1D, metriche FULL e OOS.
|
||||
|
||||
NB: la leva 2x del portfolios.yml NON entra nel backtest (Portfolio.backtest la ignora;
|
||||
e' un knob live). Le equity fade gia' includono lev=3 dentro build_trades.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.strategy_research import atr
|
||||
from scripts.analysis.risk_management import strats_for, FEE_RT, LEV, POS, INIT
|
||||
from scripts.analysis.combine_portfolio import (
|
||||
fade_daily_equity, _norm, IDX, port_returns, metrics, SPLIT, OOS_DATE,
|
||||
)
|
||||
from scripts.portfolios._defs import PORTFOLIOS
|
||||
from src.portfolio import weighting as W
|
||||
|
||||
BUFFER = 0.5 # EXIT-16 promossa: close-confirm con buffer 0.5 ATR
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- engine replay
|
||||
def build_trades_variant(ents, df, mode, buffer=BUFFER,
|
||||
lev=LEV, fee_rt=FEE_RT, trend_max=3.0, ema_long=200):
|
||||
"""Replica ESATTA di risk_management.build_trades, cambiando SOLO il ramo SL.
|
||||
|
||||
mode="orig" : SL intrabar al livello (SL prima del TP) == canonico.
|
||||
mode="exit16" : SL intrabar DISATTIVATO; close-confirm sul close[j]:
|
||||
long esci a close[j] se close[j] < sl0 - buffer*atr14[j]
|
||||
short esci a close[j] se close[j] > sl0 + buffer*atr14[j]
|
||||
TP intrabar al livello e max_bars al close INVARIATI.
|
||||
"""
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
n = len(c)
|
||||
a = atr(df, 14)
|
||||
el = pd.Series(c).ewm(span=ema_long, adjust=False).mean().values
|
||||
fee = fee_rt * lev
|
||||
out = []
|
||||
last = -1
|
||||
for e in ents:
|
||||
i, d = e["i"], e["d"]
|
||||
if i <= last or i + 1 >= n:
|
||||
continue
|
||||
if trend_max is not None and a[i] and abs(c[i] - el[i]) / a[i] > trend_max:
|
||||
continue
|
||||
entry = c[i]
|
||||
tp, sl0, mb = e["tp"], e["sl"], e["max_bars"]
|
||||
exit_p = c[min(i + mb, n - 1)]
|
||||
j = min(i + mb, n - 1)
|
||||
for k in range(1, mb + 1):
|
||||
j = i + k
|
||||
if j >= n:
|
||||
exit_p = c[n - 1]
|
||||
break
|
||||
if mode == "orig":
|
||||
hs = (d == 1 and l[j] <= sl0) or (d == -1 and h[j] >= sl0)
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if hs:
|
||||
exit_p = sl0
|
||||
break
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
else: # exit16: no SL intrabar; TP intrabar; poi close-confirm SL al close[j]
|
||||
ht = (d == 1 and h[j] >= tp) or (d == -1 and l[j] <= tp)
|
||||
if ht:
|
||||
exit_p = tp
|
||||
break
|
||||
aj = a[j] if np.isfinite(a[j]) else 0.0
|
||||
confirm = (d == 1 and c[j] < sl0 - buffer * aj) or \
|
||||
(d == -1 and c[j] > sl0 + buffer * aj)
|
||||
if confirm:
|
||||
exit_p = c[j]
|
||||
break
|
||||
if k == mb:
|
||||
exit_p = c[j]
|
||||
ret = (exit_p - entry) / entry * d * lev - fee
|
||||
out.append((i, j, ret))
|
||||
last = j
|
||||
return out
|
||||
|
||||
|
||||
def fade_equity_variant(asset, fn, params, mode):
|
||||
"""Stesso flusso di combine_portfolio.fade_daily_equity ma con build_trades_variant."""
|
||||
df = load_data(asset, "1h")
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
trades = build_trades_variant(fn(df, **params), df, mode=mode, trend_max=3.0)
|
||||
n = len(df)
|
||||
eq = np.full(n, INIT, dtype=float)
|
||||
cap = INIT
|
||||
for i, j, ret in sorted(trades, key=lambda t: t[1]):
|
||||
cap = max(cap + cap * POS * ret, 10.0)
|
||||
eq[j:] = cap
|
||||
s = pd.Series(eq, index=ts).resample("1D").last().reindex(IDX).ffill().bfill()
|
||||
return _norm(s)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- pesi PORT06
|
||||
def port_metrics(members: dict[str, pd.Series], weights: dict[str, float]):
|
||||
dr = port_returns(members, weights)
|
||||
return metrics(dr), metrics(dr, lo=SPLIT)
|
||||
|
||||
|
||||
def main():
|
||||
p = PORTFOLIOS["PORT06"]
|
||||
fade_ids = [s.sid for s in p.sleeves if s.sid.startswith("MR")]
|
||||
print("=" * 96)
|
||||
print(" TEST DECISIVO EXIT-16 (close_confirm_sl buffer=0.5 ATR) su PORT06 — path canonico")
|
||||
print(f" fade sleeve: {fade_ids}")
|
||||
print("=" * 96)
|
||||
|
||||
# --- 1. equity canoniche di TUTTI gli sleeve (cache intatta) ---
|
||||
print("\n[1] build_everything() canonico (pesante, ~2-3 min)...")
|
||||
from src.portfolio.sleeves import all_sleeve_equities
|
||||
eq_base = dict(all_sleeve_equities()) # {sid: equity giornaliera}
|
||||
print(f" sleeve totali: {len(eq_base)}")
|
||||
|
||||
# --- 2. PARITA': replay 'orig' deve riprodurre le equity canoniche ---
|
||||
print("\n[2] PARITA' replay (mode=orig) vs canonico (fade_daily_equity):")
|
||||
print(f" {'sleeve':<10s}{'corr':>10s}{'ret_canon%':>14s}{'ret_replay%':>14s}{'diff%':>9s}")
|
||||
parity_ok = True
|
||||
eq_orig, eq_e16 = {}, {}
|
||||
for asset in ("BTC", "ETH"):
|
||||
for nm, (fn, params) in strats_for(asset).items():
|
||||
sid = f"{nm}_{asset}"
|
||||
if sid not in fade_ids:
|
||||
continue
|
||||
eq_orig[sid] = fade_equity_variant(asset, fn, params, mode="orig")
|
||||
eq_e16[sid] = fade_equity_variant(asset, fn, params, mode="exit16")
|
||||
base = eq_base[sid]
|
||||
rep = eq_orig[sid]
|
||||
corr = base.pct_change().fillna(0).corr(rep.pct_change().fillna(0))
|
||||
rb = (base.iloc[-1] / base.iloc[0] - 1) * 100
|
||||
rr = (rep.iloc[-1] / rep.iloc[0] - 1) * 100
|
||||
diff = rr - rb
|
||||
flag = "" if (corr > 0.999 and abs(diff) <= max(1.0, abs(rb) * 0.01)) else " <-- MISMATCH"
|
||||
if flag:
|
||||
parity_ok = False
|
||||
print(f" {sid:<10s}{corr:>10.5f}{rb:>14.1f}{rr:>14.1f}{diff:>+9.2f}{flag}")
|
||||
print(f"\n PARITA' {'OK' if parity_ok else 'FALLITA'} "
|
||||
f"(corr>0.999 e ret finale entro 1%).")
|
||||
if not parity_ok:
|
||||
print("\n >>> Parita' non raggiunta: NON forzo. Diagnostico sopra. STOP.")
|
||||
return
|
||||
|
||||
# --- 3. PORT06 base vs EXIT-16: stessi pesi cap, stessa matematica ---
|
||||
members_base = dict(eq_base)
|
||||
members_e16 = dict(eq_base)
|
||||
for sid in fade_ids:
|
||||
members_e16[sid] = eq_e16[sid] # sostituisco SOLO le 6 colonne fade
|
||||
|
||||
ids = p.sleeve_ids
|
||||
# pesi cap canonici (gli stessi che usa Portfolio.backtest)
|
||||
dr_base = pd.DataFrame({i: members_base[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_base = W.weight_vector(p.weighting, ids, dr_base, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
dr_e16 = pd.DataFrame({i: members_e16[i].pct_change().fillna(0.0) for i in ids})
|
||||
w_e16 = W.weight_vector(p.weighting, ids, dr_e16, weights=p.weights,
|
||||
caps=p.caps, clusters=p.clusters, lookback=p.vol_lookback)
|
||||
|
||||
f_b, o_b = port_metrics({i: members_base[i] for i in ids}, w_base)
|
||||
f_e, o_e = port_metrics({i: members_e16[i] for i in ids}, w_e16)
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f" [3] PORT06 — pesi={p.weighting} caps={p.caps} | OOS da {OOS_DATE} | leva3x interna fade, pos0.15")
|
||||
print("=" * 96)
|
||||
print(f" {'variante':<14s}{'FULL Sh':>9s}{'FULL DD%':>10s}{'FULL CAGR':>11s}"
|
||||
f" | {'OOS Sh':>8s}{'OOS DD%':>9s}{'OOS CAGR':>10s}")
|
||||
print(" " + "-" * 90)
|
||||
print(f" {'BASE':<14s}{f_b['sharpe']:>9.2f}{f_b['dd']:>10.2f}{f_b['cagr']:>10.0f}%"
|
||||
f" | {o_b['sharpe']:>8.2f}{o_b['dd']:>9.2f}{o_b['cagr']:>9.0f}%")
|
||||
print(f" {'EXIT-16':<14s}{f_e['sharpe']:>9.2f}{f_e['dd']:>10.2f}{f_e['cagr']:>10.0f}%"
|
||||
f" | {o_e['sharpe']:>8.2f}{o_e['dd']:>9.2f}{o_e['cagr']:>9.0f}%")
|
||||
print(" " + "-" * 90)
|
||||
print(f" {'DELTA':<14s}{f_e['sharpe']-f_b['sharpe']:>+9.2f}{f_e['dd']-f_b['dd']:>+10.2f}"
|
||||
f"{f_e['cagr']-f_b['cagr']:>+10.0f}% | {o_e['sharpe']-o_b['sharpe']:>+8.2f}"
|
||||
f"{o_e['dd']-o_b['dd']:>+9.2f}{o_e['cagr']-o_b['cagr']:>+9.0f}%")
|
||||
|
||||
# --- per-sleeve fade: differenze principali ---
|
||||
print("\n Per-sleeve fade (equity FULL ret%, EXIT-16 vs orig-replay):")
|
||||
print(f" {'sleeve':<10s}{'orig ret%':>12s}{'exit16 ret%':>14s}{'delta%':>10s}"
|
||||
f"{'orig DD%':>10s}{'e16 DD%':>10s}")
|
||||
for sid in fade_ids:
|
||||
ro = eq_orig[sid]; re = eq_e16[sid]
|
||||
def _dd(s):
|
||||
pk = s.cummax(); return float(((pk - s) / pk).max() * 100)
|
||||
rro = (ro.iloc[-1] / ro.iloc[0] - 1) * 100
|
||||
rre = (re.iloc[-1] / re.iloc[0] - 1) * 100
|
||||
print(f" {sid:<10s}{rro:>12.1f}{rre:>14.1f}{rre-rro:>+10.1f}"
|
||||
f"{_dd(ro):>10.1f}{_dd(re):>10.1f}")
|
||||
|
||||
# --- GATE ---
|
||||
print("\n" + "=" * 96)
|
||||
print(" GATE (stesso del loss-guard): PROMOSSO se OOS Sharpe migliora/pari E DD non peggiora")
|
||||
print(" materialmente, E in FULL non degrada.")
|
||||
print("=" * 96)
|
||||
oos_sh_ok = o_e['sharpe'] >= o_b['sharpe'] - 0.02
|
||||
oos_dd_ok = o_e['dd'] <= o_b['dd'] + 0.20 # no peggioramento materiale DD
|
||||
full_ok = f_e['sharpe'] >= f_b['sharpe'] - 0.02 and f_e['dd'] <= f_b['dd'] + 0.20
|
||||
promoted = oos_sh_ok and oos_dd_ok and full_ok
|
||||
print(f" OOS Sharpe {o_b['sharpe']:.2f} -> {o_e['sharpe']:.2f} "
|
||||
f"({'OK' if oos_sh_ok else 'KO'})")
|
||||
print(f" OOS DD% {o_b['dd']:.2f} -> {o_e['dd']:.2f} "
|
||||
f"({'OK' if oos_dd_ok else 'KO'})")
|
||||
print(f" FULL Sharpe {f_b['sharpe']:.2f} -> {f_e['sharpe']:.2f} | "
|
||||
f"FULL DD {f_b['dd']:.2f} -> {f_e['dd']:.2f} ({'OK' if full_ok else 'KO'})")
|
||||
print("\n VERDETTO: " + (">>> PROMOSSO <<<" if promoted else ">>> BOCCIATO <<<"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,259 @@
|
||||
"""EXIT LAB — harness onesto e CONDIVISO per la ricerca di policy di uscita
|
||||
(TP dinamico, SL dinamico/trailing, partial, ride) sulle fade attive.
|
||||
|
||||
Ricerca 2026-06-04 (>=20 agenti): ogni agente implementa una ExitPolicy in
|
||||
scripts/analysis/exit_policies/<id>_<nome>.py e la valuta QUI, sugli STESSI
|
||||
segnali (cache su disco) e con lo stesso engine intrabar di fade_base.
|
||||
|
||||
CONTRATTO ANTI-LOOK-AHEAD (vincolante, verra' verificato da agenti avversari):
|
||||
- i livelli attivi nel bar j (`levels(j)`) possono usare SOLO dati <= j-1
|
||||
(il worker live li fissa al close del bar precedente, poi il bar j li tocca);
|
||||
- `after_bar(j)` decide sul CLOSE del bar j (eseguibile al poll del tick);
|
||||
- indicatori: usare l'indice j-1 degli array causali (es. ctx["atr14"][j-1]).
|
||||
|
||||
PROTOCOLLO ANTI-OVERFIT (vincolante):
|
||||
- TRAIN = storico fino al 2023-11-01, OOS = dopo. La SELEZIONE dei parametri
|
||||
si fa SOLO sul train; l'OOS si guarda una volta, per il verdetto.
|
||||
- gate: il miglioramento deve tenere su ENTRAMBI gli asset e su TUTTE e 3 le
|
||||
strategie (train E oos), con plateau sulla griglia (non una cella isolata).
|
||||
- fee 0.10% RT x leva su tutto il notional; nessuna fee scontata sui limit.
|
||||
|
||||
Baseline = exit attuale (TP/SL fissi dall'entrata + max_bars): la parita' con
|
||||
`partial_tp_ladder.py --base` e' verificata da `parity_check()`.
|
||||
|
||||
uv run python scripts/analysis/exit_lab.py # build cache + parity check
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pickle
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.data.downloader import load_data # noqa: E402
|
||||
from src.live.strategy_loader import load_strategy # noqa: E402
|
||||
|
||||
LIVE_PARAMS = dict(trend_max=3.0, ema_long=200, hurst_max=0.55, min_tp_frac=0.0015)
|
||||
OOS_START_MS = int(pd.Timestamp("2023-11-01", tz="UTC").value // 1e6)
|
||||
LEV, POS, FEE_RT = 3.0, 0.15, 0.001
|
||||
CODES = ["MR01_bollinger_fade", "MR02_donchian_fade", "MR07_return_reversal"]
|
||||
ASSETS = ("BTC", "ETH")
|
||||
CACHE = PROJECT_ROOT / "data" / "cache" / "exit_lab_signals.pkl"
|
||||
HARD_CAP = 240 # bound assoluto ai bar in posizione (policy "ride" comprese)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- dati
|
||||
|
||||
def _atr14(h: np.ndarray, l: np.ndarray, c: np.ndarray) -> np.ndarray:
|
||||
pc = np.roll(c, 1); pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).rolling(14).mean().values
|
||||
|
||||
|
||||
def load_sleeves(refresh: bool = False) -> dict:
|
||||
"""{(code, asset): sleeve} con cache. sleeve = {signals, open, high, low,
|
||||
close, ts_ms, atr14}. signals = [(i, d, tp0, sl0, mb), ...] dai params LIVE."""
|
||||
if CACHE.exists() and not refresh:
|
||||
with open(CACHE, "rb") as f:
|
||||
return pickle.load(f)
|
||||
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, **LIVE_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),
|
||||
}
|
||||
print(f" cache {code} {asset}: {len(sigs)} segnali, {len(c)} barre "
|
||||
f"({ts.iloc[0].date()} -> {ts.iloc[-1].date()})")
|
||||
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CACHE, "wb") as f:
|
||||
pickle.dump(out, f)
|
||||
return out
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- policy
|
||||
|
||||
class ExitPolicy:
|
||||
"""Baseline = exit live attuale. Le sottoclassi ridefinisco levels/after_bar.
|
||||
|
||||
Una ISTANZA per trade. `ctx` e' il dict sleeve (array completi + indicatori
|
||||
aggiunti da prepare()): per contratto si legge SOLO fino a j-1 in levels(j)
|
||||
e fino a j in after_bar(j)/on_partial(j).
|
||||
"""
|
||||
name = "base"
|
||||
|
||||
@classmethod
|
||||
def prepare(cls, ctx: dict, **params) -> None:
|
||||
"""Pre-calcola array causali per-sleeve (una volta), es. SMA/EMA."""
|
||||
|
||||
def __init__(self, ctx: dict, i: int, d: int, entry: float,
|
||||
tp0: float, sl0: float, mb: int, **params):
|
||||
self.ctx, self.i, self.d, self.entry = ctx, i, d, entry
|
||||
self.tp0, self.sl0, self.mb = tp0, sl0, mb
|
||||
self.horizon = mb # le sottoclassi possono estendere (cap HARD_CAP)
|
||||
|
||||
def levels(self, j: int):
|
||||
"""Livelli ATTIVI nel bar j -> (tp, sl, tp_frac). None = livello assente.
|
||||
tp_frac = quota del RESIDUO che esce al tocco del TP (1.0 = tutta)."""
|
||||
return self.tp0, self.sl0, 1.0
|
||||
|
||||
def on_partial(self, j: int, price: float, remaining: float) -> None:
|
||||
"""Notifica del fill parziale al TP nel bar j (aggiorna lo stato qui)."""
|
||||
|
||||
def after_bar(self, j: int) -> bool:
|
||||
"""True = chiudi il residuo al close[j] (decisione sul close, eseguibile)."""
|
||||
return False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- engine
|
||||
|
||||
def simulate(policy_cls, sleeve: dict, params: dict | None = None,
|
||||
start_ms: int | None = None, end_ms: int | None = None) -> dict:
|
||||
"""Replay intrabar dei segnali dello sleeve con la policy. SL prioritario
|
||||
sul TP nello stesso bar (conservativo); fill parziali pesati; max_bars/
|
||||
horizon esce al close; non-overlap (una posizione per volta)."""
|
||||
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 = 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), HARD_CAP)
|
||||
fills: list[tuple[float, float]] = []
|
||||
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: # conservativo: SL prima del TP
|
||||
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: # safety (non dovrebbe accadere)
|
||||
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,
|
||||
"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": bars_tot / trades,
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------- report
|
||||
|
||||
def evaluate(policy_cls, grid: list[dict], data: dict | None = None,
|
||||
quiet: bool = False) -> dict:
|
||||
"""Protocollo train/OOS su tutta la griglia. La selezione dei parametri va
|
||||
fatta SUL TRAIN (l'OOS si riporta, non si ottimizza). Ritorna dict
|
||||
{params_str: {sleeve: {train: {...}, oos: {...}}}} + baseline."""
|
||||
data = data or load_sleeves()
|
||||
out: dict = {}
|
||||
rows = [("base", ExitPolicy, {})] + [
|
||||
(", ".join(f"{k}={v}" for k, v in g.items()) or "default", policy_cls, g)
|
||||
for g in grid]
|
||||
for tag, cls, g in rows:
|
||||
out[tag] = {}
|
||||
for (code, asset), sleeve in data.items():
|
||||
key = f"{code.split('_')[0]} {asset}"
|
||||
tr = simulate(cls, sleeve, g, end_ms=OOS_START_MS)
|
||||
oo = simulate(cls, sleeve, g, start_ms=OOS_START_MS)
|
||||
out[tag][key] = {"train": tr, "oos": oo}
|
||||
if not quiet:
|
||||
print(f"{tag:<28}{key:<10}"
|
||||
f"TRAIN ret{tr.get('ret_pct', 0):>7.0f}% dd{tr.get('dd_pct', 0):>5.1f} "
|
||||
f"sh{tr.get('sharpe_t', 0):>5.2f} n{tr.get('trades', 0):>4} | "
|
||||
f"OOS ret{oo.get('ret_pct', 0):>6.0f}% dd{oo.get('dd_pct', 0):>5.1f} "
|
||||
f"sh{oo.get('sharpe_t', 0):>5.2f} n{oo.get('trades', 0):>4} "
|
||||
f"bars{oo.get('avg_bars', 0):>5.1f}")
|
||||
return out
|
||||
|
||||
|
||||
def parity_check() -> None:
|
||||
"""La baseline qui deve riprodurre i numeri FULL di partial_tp_ladder (base):
|
||||
MR01 BTC ~92%/13.8dd, MR01 ETH ~194%/16.5dd, MR02 ETH ~2135%/16.2dd..."""
|
||||
data = load_sleeves()
|
||||
print("\nParity check baseline (FULL, atteso = partial_tp_ladder base):")
|
||||
expected = {("MR01_bollinger_fade", "BTC"): 92, ("MR01_bollinger_fade", "ETH"): 194,
|
||||
("MR02_donchian_fade", "BTC"): 129, ("MR02_donchian_fade", "ETH"): 2135,
|
||||
("MR07_return_reversal", "BTC"): 78, ("MR07_return_reversal", "ETH"): 115}
|
||||
ok = True
|
||||
for key, sleeve in data.items():
|
||||
r = simulate(ExitPolicy, sleeve)
|
||||
exp = expected[key]
|
||||
match = abs(r["ret_pct"] - exp) < 1.0
|
||||
ok &= match
|
||||
print(f" {key[0].split('_')[0]} {key[1]}: ret {r['ret_pct']:.0f}% "
|
||||
f"(atteso ~{exp}) {'OK' if match else 'MISMATCH'}")
|
||||
print("PARITY", "OK" if ok else "FAILED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
load_sleeves(refresh="--refresh" in sys.argv)
|
||||
parity_check()
|
||||
@@ -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