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:
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user