Files
PythagorasGoal/docs/diary/2026-06-01-tp-min-edge.md
T
Adriano Dal Pastro ab4f706057 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>
2026-06-01 11:07:30 +00:00

4.6 KiB
Raw Blame History

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:

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.