fix(live): win netto-fee + filtro TP edge-minimo sulle fade

Due correzioni emerse da close live con win=True ma pnl<0.

1) Metrica win lorda -> netta. _close_position contava is_win=trade_return>0
   (lordo), gonfiando l'accuracy: un take-profit colpito con mossa < fee RT
   risultava "win" pur perdendo. 51 close live: 39 win (76,5%) -> 13 falsi win
   -> accuracy netta reale 52,9%. Fix: is_win = net > 0. Capitale/PnL erano
   già corretti (netti). Contatori persistiti riconciliati a parte (MR01/DIP01
   BTC 7->1).

2) Filtro edge-minimo min_tp_frac. I 13 falsi win erano tutti MR01/DIP01 BTC in
   regime piatto: TP (la media) entro il costo round-trip -> perdenti garantiti.
   Aggiunto param min_tp_frac (default 0.0=off) a tutte e 4 le fade (MR01 banda,
   MR02 midpoint, MR07 ATR, DIP01): salta i segnali col TP entro la soglia.
   Non si "allarga" il TP (rischierebbe di perdere di piu'): si evita la trade.
   Cablato live a 0.0015 (1,5x fee) in _defs.py.

Validazione backtest BTC+ETH 1h: neutro su tutte le fade (0-1 trade rimossi,
pnl invariato o +leggero su DIP01). I micro-scalp sotto-fee non esistono nello
storico -> artefatto del regime attuale. Filtro puro-upside.

Test: test_win_net_of_fees.py, test_min_tp_frac.py (monotonia + gap > soglia +
default-off invariato). Suite: 50 passed.

NB deploy: il sorgente e' COPY nell'immagine, non montato -> serve
`docker compose up -d --build`, non un semplice restart (vale anche per il fix
SH01 horizon-exit, andato live solo con questo rebuild). Volume ./data persiste,
14 worker in RESUME puliti.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-01 11:07:30 +00:00
parent 05ebd6754b
commit ab4f706057
9 changed files with 251 additions and 3 deletions
+10 -2
View File
@@ -12,11 +12,19 @@ from src.portfolio.base import Portfolio, SleeveSpec # noqa: E402
# Universo live tradabile (8 asset con feed Cerbero v2 + parquet). ROT02/TSM01 ci ruotano sopra.
UNIVERSE8 = ["ADA", "BNB", "BTC", "DOGE", "ETH", "LTC", "SOL", "XRP"]
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev")
# Edge minimo (solo live): salta i fade/dip il cui TP cade entro 1.5x il costo round-trip
# (perdenti garantiti in regime piatto). Neutro sul backtest storico (0 trade rimossi su
# MR01, +leggero su DIP01), protettivo dal vivo. Solo MR01/DIP01 leggono il param;
# MR02/MR07 lo ignorano (**params). Vedi docs/diary/2026-06-01-tp-min-edge.md.
MIN_TP_FRAC = 0.0015
FADE = [SleeveSpec(kind="single", name=c, sid=f"{c}_{a}", asset=a, cluster=f"{a}-rev",
params={"min_tp_frac": MIN_TP_FRAC})
for a in ("BTC", "ETH") for c in ("MR01", "MR02", "MR07")]
HONEST = [
# DIP01: single-asset 1h -> StrategyWorker (Strategy DIP01_dip_buy). TR01/ROT02: multi-asset.
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev"),
SleeveSpec(kind="single", name="DIP01", sid="DIP01_BTC", asset="BTC", cluster="BTC-rev",
params={"min_tp_frac": MIN_TP_FRAC}),
SleeveSpec(kind="basket", name="TR01", sid="TR01_basket", cluster="trend",
params={"universe": ["BNB", "BTC", "DOGE", "SOL", "XRP"], "tf": "4h"}),
SleeveSpec(kind="rotation", name="ROT02", sid="ROT02_rot", cluster="rotation",
+4
View File
@@ -42,11 +42,15 @@ class Dip01DipBuy(Strategy):
sd = pd.Series(c).rolling(n).std().values
a = _atr(df, 14)
z = (c - ma) / np.where(sd == 0, np.nan, sd)
# Edge minimo: salta i dip il cui TP (la media) è entro il costo round-trip. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
out: list[Signal] = []
for i in range(n + 14, len(c)):
if np.isnan(z[i]) or np.isnan(a[i]) or np.isnan(ma[i]):
continue
if z[i] <= -z_in and z[i - 1] > -z_in:
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
out.append(Signal(idx=i, direction=1, entry_price=float(c[i]),
metadata={"tp": float(ma[i]),
"sl": float(c[i] - sl_atr * a[i]),
@@ -59,6 +59,9 @@ class BollingerFade(Strategy):
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i segnali il cui TP (la media) è più vicino dell'entry del
# costo round-trip -> perdenti garantiti anche colpendo il TP. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
ma = pd.Series(c).rolling(bb_w).mean().values
sd = pd.Series(c).rolling(bb_w).std().values
@@ -78,6 +81,8 @@ class BollingerFade(Strategy):
d, sl = -1, c[i] + sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(ma[i] - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(ma[i]), "sl": float(sl), "max_bars": max_bars},
+4
View File
@@ -42,6 +42,8 @@ class DonchianFade(FadeStrategy):
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i fade il cui TP (midpoint canale) è entro il costo RT. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
h, l, c = df["high"].values, df["low"].values, df["close"].values
hh = pd.Series(h).rolling(n).max().shift(1).values
@@ -62,6 +64,8 @@ class DonchianFade(FadeStrategy):
d, sl = 1, c[i] - sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(mid - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(mid), "sl": float(sl), "max_bars": max_bars},
@@ -47,6 +47,8 @@ class ReturnReversal(FadeStrategy):
max_bars = params.get("max_bars", 24)
trend_max = params.get("trend_max") # None = filtro disattivo
ema_long = params.get("ema_long", 200)
# Edge minimo: salta i fade il cui TP (ATR-scaled) è entro il costo RT. 0 = off.
min_tp_frac = params.get("min_tp_frac", 0.0)
c = df["close"].values
ret = np.zeros_like(c)
@@ -68,6 +70,8 @@ class ReturnReversal(FadeStrategy):
d, tp, sl = -1, c[i] - tp_atr * a[i], c[i] + sl_atr * a[i]
else:
continue
if min_tp_frac > 0 and abs(tp - c[i]) / c[i] <= min_tp_frac:
continue # TP entro le fee -> non eseguibile in utile
signals.append(Signal(
idx=i, direction=d, entry_price=c[i],
metadata={"tp": float(tp), "sl": float(sl), "max_bars": max_bars},