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
+89
View File
@@ -0,0 +1,89 @@
# 2026-06-01 — "Win" che perdono: metrica netto-fee + filtro TP edge-minimo
> Partito da un'osservazione dell'utente sui trade live PORT06: **ci sono close con
> `win=True` ma `pnl` negativo**. Due problemi distinti, entrambi risolti.
## Problema 1 — la metrica `win` mentiva (lordo invece di netto)
In `strategy_worker.py::_close_position`:
```python
trade_return = price_change * direction # LORDO, prima delle fee
net = trade_return * leverage - fee_rt * leverage
pnl = capital * position_size * net # corretto (netto)
is_win = trade_return > 0 # BUG: usa il LORDO
```
`is_win` scattava appena il prezzo si muoveva di un soffio a favore, **prima delle
fee**. Capitale e PnL erano giusti (netti); solo la metrica `win`/`accuracy` era
gonfiata.
**Quantificazione (51 close live):** 39 win dichiarate (76,5%) → **13 falsi win**
(`win=True` ma `pnl≤0`) → accuratezza **netta reale 52,9%**. PnL realizzato +€0,77
(resta positivo: lo trascinano i pairs).
**Fix:** `is_win = net > 0`. + `tests/portfolio/test_win_net_of_fees.py` (mossa
sotto-fee = non win; oltre-fee = win; perdita = non win).
**Riconciliazione contatori persistiti:** i `total_wins` su disco erano gonfiati dal
vecchio conteggio lordo. Ricalcolati come `net_return>0` dai `trades.jsonl`:
**MR01_BTC 7→1, DIP01_BTC 7→1** (gli unici toccati; tutti gli altri già coerenti).
Capitale invariato.
## Problema 2 — i 13 falsi win erano tutti MR01_BTC / DIP01_BTC in take_profit
Causa: in `MR01_bollinger_fade` e `DIP01_dip_buy` il **TP è la media** (`tp = ma[i]`)
e l'entry è a `close[i]` appena fuori banda. Nel regime BTC **piatto** (inchiodato
~73.700 per ore, vol bassissima) la media è a pochi dollari dall'entry → il TP cade
**dentro** il costo round-trip (0,10%): colpire il TP = perdita netta garantita.
**Meccanismo del fix (importante).** "Spostare il TP più in là" NON garantisce di non
perdere: il prezzo rientra solo fino alla media, non oltre → si finirebbe su SL/time-
limit, perdendo di più. La mossa provabilmente non-perdente è un **filtro di edge
minimo**: se `|tp entry|/entry ≤ min_tp_frac` non si apre la trade. Break-even
esatto = `fee_rt` (= 0,10%, indipendente dalla leva, perché
`ret = mossa·lev fee_rt·lev > 0 ⇔ mossa > fee_rt`).
**Implementazione:** parametro `min_tp_frac` (default 0.0 = off) in **tutte le 4 fade**
(MR01 banda, MR02 midpoint canale, MR07 ATR-scaled) e DIP01; salta i segnali sotto
soglia. Cablato negli sleeve live a **0.0015 (1,5× fee)** in `_defs.py` (`MIN_TP_FRAC`).
**Validazione backtest (BTC+ETH 1h, config sleeve, min_tp_frac ∈ {0,.001,.0015,.002}):**
neutro su tutte e 4 le fade.
- MR01: 0 trade rimossi (BTC +8028€, ETH +10395€) — metriche identiche.
- DIP01 BTC: 1 trade a 0.002, **migliora** (+7492→+7522€, DD 26,3→25,9%).
- MR02 BTC: 1 trade a 0.0015 (pnl invariato +12198€), ETH 0 rimossi.
- MR07 BTC/ETH: 0 rimossi (TP ATR-scaled sempre ben oltre le fee nello storico).
Conclusione: i micro-scalp sotto-fee **non esistono nel campione storico** — sono un
artefatto del regime attuale. Il filtro è **puro upside**: neutro sul backtest validato,
protettivo dal vivo. (Le 12 trade live incriminate, tutte MR01/DIP01 BTC, avevano gap
~0,026%, ben sotto 0,15% → tutte bloccate.)
+ `tests/portfolio/test_min_tp_frac.py` (monotonia + ogni superstite ha gap > soglia
+ default-off invariato).
## Nota deploy — il codice è COTTO nell'immagine, non montato
Scoperta durante il deploy: `docker-compose.yml` monta solo `./data` e
`./portfolios.yml`; il sorgente (`src/`, `scripts/`) è `COPY` nel Dockerfile. Quindi
**`docker compose restart` NON ricarica le modifiche al codice** — serve
`docker compose up -d --build`. Conseguenza retroattiva: anche il fix SH01
horizon-exit di stamattina è andato live solo con questo rebuild. Da ricordare per ogni
futura modifica ai worker. Il volume `./data` persiste → i 14 worker fanno RESUME
puliti dopo il rebuild (capitale e posizioni intatti).
## Stato finale
- `is_win = net > 0` live; contatori riconciliati (MR01/DIP01 BTC 1/9).
- Filtro `min_tp_frac=0.0015` live su tutti i fade + DIP01 (attivo solo MR01/DIP01).
- Fix SH01 horizon-exit ora **effettivamente** live (rebuild).
- Suite: 49 passed. Container ricostruito, healthy, 14 sleeve in RESUME.
## Lezione
1. Una metrica di "win" deve essere **netto fee**, altrimenti l'accuracy è teatro.
2. Quando il TP è dentro il costo di transazione, la trade è persa in partenza: meglio
**non prenderla** che ritoccare il TP.
3. Per i worker live in Docker: **rebuild**, non restart. Il restart ricarica solo lo
stato dal volume, non il codice.
+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},
+1 -1
View File
@@ -164,7 +164,7 @@ class StrategyWorker:
net = trade_return * self.leverage - self.fee_rt * self.leverage
pnl = self.capital * self.position_size * net
is_win = trade_return > 0
is_win = net > 0 # win = profitto NETTO dopo fee (non il lordo trade_return)
self.capital += pnl
self.capital = max(self.capital, 0)
self.total_trades += 1
+81
View File
@@ -0,0 +1,81 @@
"""Filtro edge minimo (`min_tp_frac`): MR01/DIP01 NON devono emettere segnali il cui TP
(la media) cade entro `min_tp_frac` dall'entry — sarebbero perdenti garantiti netto fee.
Proprietà testate su dati reali BTC 1h:
1. monotonia: alzando min_tp_frac il numero di segnali non aumenta;
2. ogni segnale superstite ha gap TP > min_tp_frac;
3. con min_tp_frac=0 il comportamento è invariato (default off = backtest validato intatto).
"""
import numpy as np
import pytest
from src.data.downloader import load_data
from scripts.strategies.MR01_bollinger_fade import BollingerFade
import importlib
_dip_mod = importlib.import_module("scripts.strategies.DIP01_dip_buy")
DipCls = next(v for k, v in vars(_dip_mod).items()
if isinstance(v, type) and k.lower().startswith("dip"))
@pytest.fixture(scope="module")
def btc():
df = load_data("BTC", "1h")
return df, df.index # ts non usato dalle fade, basta un placeholder
def _gaps(signals, df):
c = df["close"].values
return [abs(s.metadata["tp"] - c[s.idx]) / c[s.idx] for s in signals]
def test_mr01_filter_monotone_and_gap(btc):
df, ts = btc
s = BollingerFade()
base = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
for f in (0.0010, 0.0015, 0.0020, 0.005):
sig = s.generate_signals(df, ts, **base, min_tp_frac=f)
assert len(sig) <= n0 # monotonia
gaps = _gaps(sig, df)
assert all(g > f for g in gaps) # nessun superstite sotto soglia
def test_mr01_default_off_unchanged(btc):
df, ts = btc
s = BollingerFade()
base = dict(bb_window=50, k=2.5, sl_atr=2.0, max_bars=24)
a = s.generate_signals(df, ts, **base) # default (no kw)
b = s.generate_signals(df, ts, **base, min_tp_frac=0.0)
assert len(a) == len(b)
def test_dip01_filter_gap(btc):
df, ts = btc
s = DipCls()
base = dict(n=50, z_in=2.0, sl_atr=2.5, max_bars=24)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
sig = s.generate_signals(df, ts, **base, min_tp_frac=0.0020)
assert len(sig) <= n0
assert all(g > 0.0020 for g in _gaps(sig, df))
def _load(mod_name):
import importlib
m = importlib.import_module(mod_name)
return next(v() for k, v in vars(m).items()
if isinstance(v, type) and getattr(v, "__module__", "") == m.__name__
and hasattr(v, "generate_signals"))
def test_mr02_mr07_filter_gap(btc):
"""Anche MR02 (midpoint canale) e MR07 (ATR-scaled) onorano min_tp_frac."""
df, ts = btc
for mod, base in (
("scripts.strategies.MR02_donchian_fade", dict(n=20, sl_atr=2.0, max_bars=24)),
("scripts.strategies.MR07_return_reversal",
dict(n=50, k=3.5, tp_atr=2.0, sl_atr=1.5, max_bars=24)),
):
s = _load(mod)
n0 = len(s.generate_signals(df, ts, **base, min_tp_frac=0.0))
sig = s.generate_signals(df, ts, **base, min_tp_frac=0.0015)
assert len(sig) <= n0
assert all(g > 0.0015 for g in _gaps(sig, df))
+53
View File
@@ -0,0 +1,53 @@
"""Fix metrica `win`: una trade è win solo se il PnL è positivo NETTO delle fee, non sul
lordo. Prima del fix `is_win = trade_return > 0` (lordo): un take-profit colpito con un
movimento più piccolo del round-trip fee veniva contato come win pur perdendo denaro,
gonfiando l'accuracy (es. MR01/DIP01 BTC in regime piatto: TP entro le fee)."""
import pandas as pd
from src.live.strategy_worker import StrategyWorker
from src.live.strategy_loader import load_strategy
def _worker(tmp, fee_rt=0.001):
w = StrategyWorker(strategy=load_strategy("MR01_bollinger_fade"), asset="BTC", tf="1h",
capital=1000.0, data_dir=tmp)
w._notify = lambda *a, **k: None
w.fee_rt = fee_rt
w.in_position = True
w.direction = 1
w.entry_price = 100.0
return w
def _last_close(w):
import json
rows = [json.loads(l) for l in w.trades_path.read_text().strip().splitlines()]
return [r for r in rows if r.get("event") == "CLOSE"][-1]
def test_tiny_favorable_move_is_loss_after_fees(tmp_path):
"""Mossa lorda +0,05% < fee RT 0,10%: prezzo salito, ma netto negativo -> NON è win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(100.05, "take_profit") # +0.05% lordo, sotto le fee
c = _last_close(w)
assert c["net_return"] < 0
assert c["win"] is False # prima del fix era True
assert w.total_wins == 0
def test_move_beyond_fees_is_win(tmp_path):
"""Mossa lorda +0,30% > fee 0,10%: netto positivo -> win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(100.30, "take_profit")
c = _last_close(w)
assert c["net_return"] > 0
assert c["win"] is True
assert w.total_wins == 1
def test_loss_is_not_win(tmp_path):
"""Prezzo sceso su long: perdita netta -> non win."""
w = _worker(tmp_path, fee_rt=0.001)
w._close_position(99.0, "stop_loss")
c = _last_close(w)
assert c["win"] is False
assert w.total_wins == 0