"""Test della sorveglianza fee (scripts/live/fee_watch.py). Il nuovo schema fee Deribit entra in vigore il 2026-08-01 SENZA numeri pubblicati da fonte primaria. La regola e' stata decisa PRIMA di vedere il numero (r0726_fee_sensitivity.py): taker <=5bps -> nulla, >10bps -> rivedere il peso di SKH01. Questi test congelano la regola e la sua taratura, cosi' che il numero non possa muovere la soglia una volta arrivato. Il test piu' importante e' `test_baseline_e_quella_dei_backtest`: lega la soglia della sorveglianza alla fee usata nei backtest. Se qualcuno cambia una delle due senza l'altra, il confronto smette di avere senso e il test lo dice. """ from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "scripts" / "live")) import fee_watch as FW # noqa: E402 # =========================================================================== # la regola congelata # =========================================================================== def test_al_tier_di_oggi_non_si_tocca_nulla(): assert FW.verdict(5.0)[0] == "OK" def test_sotto_il_tier_di_oggi_i_backtest_restano_conservativi(): """Se la fee SCENDE i numeri pubblicati non vanno rifatti: sono un limite inferiore.""" assert FW.verdict(2.0)[0] == "OK" def test_fra_5_e_10_bps_si_riporta_il_costo_ma_non_si_agisce(): assert FW.verdict(5.1)[0] == "NOTA" assert FW.verdict(10.0)[0] == "NOTA" def test_sopra_10_bps_scatta_la_revisione_del_peso_di_skh01(): lvl, why = FW.verdict(10.1) assert lvl == "AZIONE" assert "SKH01" in why def test_le_soglie_sono_quelle_dichiarate_in_claude_md(): """Una regola decisa in anticipo vale solo se non la si puo' spostare dopo aver visto il numero: qui le due soglie sono congelate.""" assert (FW.SOGLIA_OK, FW.SOGLIA_AZIONE) == (5.0, 10.0) def test_baseline_e_quella_dei_backtest(): """5 bps/lato = 0.10% RT = il default di backtest_signals. Le due devono restare agganciate.""" from src.backtest.harness import backtest_signals import inspect default_fee_rt = inspect.signature(backtest_signals).parameters["fee_rt"].default assert FW.BASELINE_TAKER_BPS * 2 / 1e4 == default_fee_rt def test_sensibilita_marginale_e_quella_misurata(): """-0.017 Sharpe/bps di book (r0726_fee_sensitivity). Se cambia la misura, cambia qui.""" assert FW.D_SHARPE_PER_BPS == -0.017 # =========================================================================== # rilevazione del cambiamento — il motivo per cui e' un cron e non un promemoria # =========================================================================== def test_prima_lettura_non_e_un_evento(): """Senza stato precedente non c'e' un cambiamento: c'e' un'inizializzazione. Allertare qui vorrebbe dire allertare a ogni installazione.""" assert FW.diff_vs({}, {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}) == [] def test_nessun_cambiamento_nessun_rumore(): cur = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)} assert FW.diff_vs(dict(cur), cur) == [] def test_un_taker_che_sale_viene_visto(): prev = {"BTC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)} cur = {"BTC-PERPETUAL": dict(taker_bps=8.0, maker_bps=0.0, liq_bps=75.0)} ch = FW.diff_vs(prev, cur) assert len(ch) == 1 and "taker" in ch[0] and "8.00" in ch[0] def test_anche_maker_e_liquidazione_sono_sorvegliati(): """L'annuncio tocca tre cose: taker piu' bassi, rebate maker piu' bassi, liquidation fee 1%. Sorvegliarne una sola vorrebbe dire non accorgersi delle altre due.""" prev = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=90.0)} cur = {"ETH-PERPETUAL": dict(taker_bps=5.0, maker_bps=1.0, liq_bps=100.0)} ch = FW.diff_vs(prev, cur) assert len(ch) == 2 assert any("maker" in c for c in ch) and any("liquidazione" in c for c in ch) # =========================================================================== # assunzione dichiarata # =========================================================================== def test_sorveglia_il_tier_base_e_lo_dichiara(): """A $600 il volume 30g e' trascurabile: il conto sta al tier BASE, che e' cio' che l'endpoint pubblico riporta. Se un giorno il conto salisse di tier, questo script misurerebbe la cosa sbagliata — l'assunzione sta nel docstring, e questo test la lega.""" assert "public/get_instrument" in FW.API or "get_instrument" in FW.API assert "tier BASE" in FW.__doc__ or "tier base" in FW.__doc__ def test_fee_reali_assenti_non_sono_fee_zero(): """{} = 'non misurata'. Un dict vuoto letto come 'zero' direbbe che il book trada gratis.""" assert FW.realized_fee_bps.__doc__ and "non misurata" in FW.realized_fee_bps.__doc__ # =========================================================================== # STRUMENTI E UNITA' — il difetto trovato il 2026-08-21. # `INSTRUMENTS` era la tupla cablata ("BTC-PERPETUAL", "ETH-PERPETUAL"), cioe' gli INVERSE, # mentre il book esegue sui LINEARI USDC. Il tier sorvegliato era di un prodotto mai tradato # e il cross-check sui trade reali non poteva misurare nulla per costruzione. # =========================================================================== def test_sorveglia_esattamente_gli_strumenti_DEL_BOOK(): """La divergenza non dev'essere un rischio da ricordare: dev'essere impossibile. Il 18/08 Deribit ha cambiato tick e size dei SOLI perpetual lineari USDC lasciando intatti gli inverse: le due linee di prodotto si muovono in modo indipendente, quindi sorvegliare l'una mentre si trada l'altra e' un controllo che non controlla. """ from src.live.book import INSTRUMENT as BOOK assert set(FW.INSTRUMENTS) == set(BOOK.values()), \ "gli strumenti sorvegliati devono essere quelli su cui il book esegue" assert all(i.endswith("_USDC-PERPETUAL") for i in FW.INSTRUMENTS) def test_la_convenzione_di_unita_e_dichiarata_e_non_indovinata(): assert FW.convenzione("BTC_USDC-PERPETUAL") == "lineare" assert FW.convenzione("BTC-PERPETUAL") == "inverse" # una famiglia che non conosciamo NON si indovina: un fill misurato con la convenzione # sbagliata non da' un errore, da' un numero. assert FW.convenzione("BTC-28MAR27-100000-C") == "ignota" assert FW.fee_bps_di_un_fill("BTC-28MAR27-100000-C", 1.0, 100.0, 0.01) is None def test_le_due_famiglie_hanno_unita_DIVERSE_e_scambiarle_non_fa_rumore(): """⚠️ Il punto che rende il difetto pericoloso: applicare la convenzione sbagliata non solleva niente. Su un fill VERO del 21/08 (0.001 BTC @ 74.305,80, fee 0,02600703 USDC) la formula giusta da' il tier taker esatto; quella inverse da' un numero enorme e muto. """ amount, price, fee = 0.001, 74305.8, 0.02600703 giusto = FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", amount, price, fee) assert giusto is not None and abs(giusto - 3.50) < 0.01, f"atteso ~3.50 bps, ottenuto {giusto}" # la stessa riga letta con la convenzione inverse: nessuna eccezione, solo un numero falso sbagliato = FW.fee_bps_di_un_fill("BTC-PERPETUAL", amount, price, fee) assert sbagliato is not None and sbagliato > 1e6, \ "il controllo positivo deve mostrare che la convenzione sbagliata NON fallisce, mente" # e su un fill inverse la convenzione inverse e' quella giusta (amount in USD, fee in BTC) inv = FW.fee_bps_di_un_fill("BTC-PERPETUAL", 74.3058, 74305.8, 74.3058 * 3.5e-4 / 74305.8) assert inv is not None and abs(inv - 3.50) < 0.01 def test_un_fill_non_misurabile_e_None_non_zero(): for bad in ((0.0, 100.0, 1.0), (1.0, 0.0, 1.0), (None, 100.0, 1.0), ("x", 100.0, 1.0)): assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", *bad) is None # fee nulla e' invece una misura legittima (ordine maker a rebate zero) assert FW.fee_bps_di_un_fill("BTC_USDC-PERPETUAL", 1.0, 100.0, 0.0) == 0.0 # =========================================================================== # issue #29 (B8) — save_state SOLO se l'invio (quando serviva) e' riuscito # =========================================================================== # Fino a qui `save_state` girava SEMPRE dopo l'eventuale notify, anche se l'invio necessario # falliva: il giro dopo `diff_vs` avrebbe confrontato lo stato nuovo con se stesso e il cambio # sarebbe sparito senza essere mai stato detto. _RIS_AZIONE = dict( current={"BTC_USDC-PERPETUAL": dict(taker_bps=12.0, maker_bps=0.0, liq_bps=100.0)}, changes=["BTC_USDC-PERPETUAL taker: 5.00 -> 12.00 bps"], worst_taker_bps=12.0, level="AZIONE", reason="taker 12.0bps > 10: rivedere il peso di SKH01", first_read=False, ) _RIS_OK_SENZA_CAMBI = dict( current={"BTC_USDC-PERPETUAL": dict(taker_bps=3.0, maker_bps=0.0, liq_bps=75.0)}, changes=[], worst_taker_bps=3.0, level="OK", reason="ok", first_read=False, ) def _prepara(monkeypatch, risultato, esito_invio): salvato, inviato = [], [] monkeypatch.setattr(FW, "run", lambda: risultato) monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur)) monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio) monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None) monkeypatch.setattr(FW, "realized_fee_bps", lambda *a, **k: {}) return salvato, inviato def test_invio_fallito_non_salva_lo_stato(monkeypatch): salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=False) rc = FW.main() assert len(inviato) == 1, "un cambio con livello AZIONE deve tentare l'invio" assert salvato == [], "invio fallito -> non si salva, il giro dopo ritenta lo stesso cambio" assert rc == 2 def test_invio_riuscito_salva_lo_stato(monkeypatch): salvato, inviato = _prepara(monkeypatch, _RIS_AZIONE, esito_invio=True) FW.main() assert len(inviato) == 1 # (#37 B9) lo stato salvato porta anche il marcatore "divergenza_detta" (qui False: la # fixture non ha divergenze), sibling delle fee per-strumento, non piu' solo `current`. assert len(salvato) == 1 assert salvato[0] == {**_RIS_AZIONE["current"], "divergenza_detta": False} def test_nessun_invio_dovuto_salva_comunque(monkeypatch): """Senza cambi e a livello OK non c'e' niente da dire: si salva, e non si chiama notify.""" salvato, inviato = _prepara(monkeypatch, _RIS_OK_SENZA_CAMBI, esito_invio=False) FW.main() assert inviato == [], "senza changes e livello OK non deve tentare l'invio" assert len(salvato) == 1 # =========================================================================== # issue #37 (B9) — realized_fee_bps() SEMPRE calcolato (non solo nel blocco di stampa) e # divergenza dal tier pubblico notificata UNA volta per episodio. # =========================================================================== def test_run_calcola_sempre_la_fee_realizzata(monkeypatch): """Prima del #37 la fee realizzata girava solo dentro `if not quiet or interessante`: in cron (--quiet) non girava mai. Ora e' `run()` a calcolarla, sempre.""" monkeypatch.setattr(FW, "fetch_fees", lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)) monkeypatch.setattr(FW, "load_state", lambda: {}) ins = FW.INSTRUMENTS[0] monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 7.5}) r = FW.run() assert r["realized"] == {ins: 7.5} assert len(r["divergenze"]) == 1 and ins in r["divergenze"][0] assert r["divergenza_gia_detta"] is False def test_run_nessun_trade_e_None_non_zero(monkeypatch): monkeypatch.setattr(FW, "fetch_fees", lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)) monkeypatch.setattr(FW, "load_state", lambda: {}) monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {}) r = FW.run() assert r["realized"] is None assert r["divergenze"] == [] def test_run_sotto_soglia_non_e_divergenza(monkeypatch): monkeypatch.setattr(FW, "fetch_fees", lambda ins: dict(instrument=ins, taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)) monkeypatch.setattr(FW, "load_state", lambda: {}) ins = FW.INSTRUMENTS[0] monkeypatch.setattr(FW, "realized_fee_bps", lambda limit=20: {ins: 5.3}) # +0.3 < 1.0 r = FW.run() assert r["divergenze"] == [] _RIS_DIVERGE = dict( current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}, changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False, realized={"BTC_USDC-PERPETUAL": 7.5}, divergenze=["BTC_USDC-PERPETUAL: realizzata 7.50 bps vs tier pubblico 5.00 (+2.50)"], divergenza_gia_detta=False, ) _RIS_DIVERGE_RIPETUTA = dict(_RIS_DIVERGE, divergenza_gia_detta=True) _RIS_DIVERGE_RIENTRATA = dict( current={"BTC_USDC-PERPETUAL": dict(taker_bps=5.0, maker_bps=0.0, liq_bps=75.0)}, changes=[], worst_taker_bps=5.0, level="OK", reason="ok", first_read=False, realized={"BTC_USDC-PERPETUAL": 5.3}, divergenze=[], divergenza_gia_detta=True, ) def _prepara_b9(monkeypatch, risultato, esito_invio): """Come `_prepara`, ma senza `changes`/livello AZIONE: isola il notify di B9 da quello di B8 (qui non richiesto, dato che tutte le fixture hanno level='OK' e changes=[]).""" salvato, inviato = [], [] monkeypatch.setattr(FW, "run", lambda: risultato) monkeypatch.setattr(FW, "save_state", lambda cur: salvato.append(cur)) monkeypatch.setattr(FW, "notify", lambda *a, **k: inviato.append((a, k)) or esito_invio) monkeypatch.setattr(FW, "registra_giro", lambda *a, **k: None) return salvato, inviato def test_divergenza_prima_volta_notifica_e_marca(monkeypatch): salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=True) FW.main() assert len(inviato) == 1 assert salvato[-1]["divergenza_detta"] is True def test_divergenza_stesso_episodio_non_ripete(monkeypatch): salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIPETUTA, esito_invio=True) FW.main() assert inviato == [], "gia' detta in questo episodio: nessun secondo invio" assert salvato[-1]["divergenza_detta"] is True def test_divergenza_notify_fallita_non_marca(monkeypatch): salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE, esito_invio=False) FW.main() assert len(inviato) == 1 assert salvato[-1]["divergenza_detta"] is False, \ "invio fallito -> marcatore non scritto, il giro dopo ritenta" def test_divergenza_rientrata_azzera_il_marcatore(monkeypatch): salvato, inviato = _prepara_b9(monkeypatch, _RIS_DIVERGE_RIENTRATA, esito_invio=False) FW.main() assert inviato == [], "sotto soglia: nessun invio da tentare" assert salvato[-1]["divergenza_detta"] is False