diff --git a/docs/diary/2026-06-01-tp-min-edge.md b/docs/diary/2026-06-01-tp-min-edge.md new file mode 100644 index 0000000..21ab8f6 --- /dev/null +++ b/docs/diary/2026-06-01-tp-min-edge.md @@ -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. diff --git a/scripts/portfolios/_defs.py b/scripts/portfolios/_defs.py index 50470df..ee51aa6 100644 --- a/scripts/portfolios/_defs.py +++ b/scripts/portfolios/_defs.py @@ -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", diff --git a/scripts/strategies/DIP01_dip_buy.py b/scripts/strategies/DIP01_dip_buy.py index 9b60b50..2361e5e 100644 --- a/scripts/strategies/DIP01_dip_buy.py +++ b/scripts/strategies/DIP01_dip_buy.py @@ -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]), diff --git a/scripts/strategies/MR01_bollinger_fade.py b/scripts/strategies/MR01_bollinger_fade.py index ebbfe12..fdf7451 100644 --- a/scripts/strategies/MR01_bollinger_fade.py +++ b/scripts/strategies/MR01_bollinger_fade.py @@ -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}, diff --git a/scripts/strategies/MR02_donchian_fade.py b/scripts/strategies/MR02_donchian_fade.py index ec8d7fc..824174f 100644 --- a/scripts/strategies/MR02_donchian_fade.py +++ b/scripts/strategies/MR02_donchian_fade.py @@ -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}, diff --git a/scripts/strategies/MR07_return_reversal.py b/scripts/strategies/MR07_return_reversal.py index 70b41c8..1ee5ab8 100644 --- a/scripts/strategies/MR07_return_reversal.py +++ b/scripts/strategies/MR07_return_reversal.py @@ -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}, diff --git a/src/live/strategy_worker.py b/src/live/strategy_worker.py index 8b269f5..823740f 100644 --- a/src/live/strategy_worker.py +++ b/src/live/strategy_worker.py @@ -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 diff --git a/tests/portfolio/test_min_tp_frac.py b/tests/portfolio/test_min_tp_frac.py new file mode 100644 index 0000000..2985d58 --- /dev/null +++ b/tests/portfolio/test_min_tp_frac.py @@ -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)) diff --git a/tests/portfolio/test_win_net_of_fees.py b/tests/portfolio/test_win_net_of_fees.py new file mode 100644 index 0000000..e7971fb --- /dev/null +++ b/tests/portfolio/test_win_net_of_fees.py @@ -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