dda5d41f38
save_state girava sempre dopo l'eventuale notify, anche quando l'invio falliva: il giro dopo diff_vs confrontava lo stato nuovo con se stesso e il cambiamento (o il livello AZIONE) spariva senza essere mai stato detto. Ora lo stato si salva solo se non serviva un invio, o se l'invio e' riuscito. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
216 lines
10 KiB
Python
216 lines
10 KiB
Python
"""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
|
|
assert len(salvato) == 1 and salvato[0] == _RIS_AZIONE["current"]
|
|
|
|
|
|
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
|