From 8cfe15cbd5ceb36b2ce17756fe72eb40f1925970 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Fri, 21 Aug 2026 19:19:22 +0000 Subject: [PATCH] fee_watch: sorvegliava i perpetual INVERSE mentre il book trada i LINEARI USDC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trovato in un check generale. INSTRUMENTS era la tupla cablata ("BTC-PERPETUAL","ETH-PERPETUAL") — gli inverse, regolati in BTC/ETH — mentre src.live.book.INSTRUMENT punta a BTC_USDC-PERPETUAL / ETH_USDC-PERPETUAL. Due conseguenze, e la seconda era gia' visibile ogni giorno nel log: 1. Il tier sorvegliato era di un prodotto che il book non tratta. Oggi coincidono (3.50/1.50 su entrambe le linee) ma la prova che si muovono in modo indipendente e' nel progetto: il cambio del 18/08 tocco' tick e size dei SOLI lineari USDC (inverse ancora tick 0.5 / min 10.0, lineari 0.1 / 0.0001). Un aumento sulla sola linea lineare sarebbe stato invisibile, e la regola decisa in anticipo (<=5bps nulla / >10bps rivedere il peso SKH01) applicata al numero sbagliato. 2. Il cross-check sui trade REALI — la fonte autorevole, cioe' quanto abbiamo davvero pagato — non poteva misurare nulla per costruzione: chiedeva la storia di uno strumento con zero fill. Stampava "NON MISURATO (nessun trade recente leggibile)" anche in un giorno con 4 esecuzioni. Verificato sul conto: inverse 0 trade, _USDC-PERPETUAL 3 (BTC) e 1 (ETH). FIX. INSTRUMENTS si DERIVA da src.live.book.INSTRUMENT: la divergenza non e' piu' un rischio da ricordare, e' impossibile. E non era un rename di due stringhe: le due famiglie hanno unita' DIVERSE. Inverse amount = nozionale USD e fee in valuta base; lineare amount = quantita' base e fee gia' in USDC. Ripuntare senza correggerle avrebbe dato, su un fill vero (0.001 BTC @ 74.305,80, fee 0,02600703 USDC), ~2,6e8 bps invece di 3,50 — senza sollevare nulla. Aggiunte convenzione() (lineare / inverse / IGNOTA: una famiglia non nota non si indovina, si dichiara) e fee_bps_di_un_fill(), entrambe pure. Il cross-check ora gira e da' 3,50 bps effettivi = il tier esatto; il report stampa lo scarto effettivo-tier con ⚠️ oltre 1 bps. Test 13 -> 17, verificati per MUTAZIONE: rimettendo la tupla cablata fallisce test_sorveglia_esattamente_gli_strumenti_DEL_BOOK; scambiando le convenzioni fallisce test_le_due_famiglie_hanno_unita_DIVERSE_e_scambiarle_non_fa_rumore, che contiene il controllo positivo (la convenzione sbagliata NON solleva niente, mente). 3a occorrenza in un giorno della stessa forma di difetto, dopo i test di book_live (potenza zero a libro flat) e la taratura di venue_watch (misurata con bitfinex mentre il live girava senza): un controllo puntato su una configurazione diversa da quella che gira passa sempre, e non sta controllando niente. REGOLA: un sorvegliante DERIVA il proprio bersaglio dal codice sorvegliato, mai lo ridichiara. Book, pesi, config, cron, soglie fee: INVARIATI. Suite 625 verdi, 1 rosso noto (test_gtaa_band_gate). NB: la prima corsa dopo il fix ha inviato una notifica Telegram legittima ("strumento nuovo nella sorveglianza"); lo stato e' poi assestato. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 24 ++++++++++++ scripts/live/fee_watch.py | 82 ++++++++++++++++++++++++++++++++------- tests/test_fee_watch.py | 54 ++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 150e668..332458f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1654,6 +1654,30 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis bps**, applica la regola congelata (≤5bps nulla · >10bps rivedere il peso SKH01) e allerta su **qualsiasi** cambiamento dei tre. `test_baseline_e_quella_dei_backtest` lega la soglia al default `fee_rt=0.001` di `backtest_signals`: se divergono, il test lo dice. + ⚠️ **CORREZIONE 2026-08-21 — sorvegliava lo STRUMENTO SBAGLIATO (test 13 → 17).** `INSTRUMENTS` + era la tupla cablata `("BTC-PERPETUAL","ETH-PERPETUAL")` = i perpetual **INVERSE** (regolati in + BTC/ETH), mentre il book esegue sui **LINEARI USDC** (`BTC_USDC-PERPETUAL`). Due conseguenze: + (a) il tier sorvegliato era di un prodotto **mai tradato** — oggi identico per caso (3.50/1.50 su + entrambe le linee) ma **la prova che si muovono in modo indipendente e' nel progetto**: il cambio + del 18/08 tocco' i SOLI lineari (inverse ancora tick 0.5/min 10.0, lineari 0.1/0.0001) → un + aumento sulla sola linea lineare sarebbe stato invisibile e la regola ≤5/>10 bps applicata al + numero sbagliato; (b) **il cross-check sui trade reali non poteva misurare nulla per + costruzione** — chiedeva `trade_history` di uno strumento con **0 fill** e stampava «NON + MISURATO» anche nei giorni con 4 esecuzioni (verificato: inverse 0 trade, `_USDC` 3+1). Ora + `INSTRUMENTS` si **DERIVA da `src.live.book.INSTRUMENT`**: la divergenza non e' un rischio da + ricordare, e' impossibile. ⚠️ **E non era un rename:** le due famiglie hanno unita' DIVERSE — + inverse `amount`=nozionale USD e `fee` in valuta base; lineare `amount`=quantita' BASE e `fee` + gia' in USDC. Ripuntare senza correggerle avrebbe dato, su un fill vero (0.001 BTC @ 74.305,80, + fee 0,02600703), **~2,6e8 bps invece di 3,50** — e **senza sollevare nulla**. Cablate + `convenzione()` (lineare/inverse/**ignota**: una famiglia non nota non si indovina) e + `fee_bps_di_un_fill()` pure; il cross-check ora gira e da' **3,50 bps effettivi = il tier + esatto**, e il report stampa lo scarto effettivo−tier con ⚠️ sopra 1 bps. + **REGOLA: un sorvegliante deve DERIVARE il proprio bersaglio dal codice sorvegliato, mai + ridichiararlo** — due liste in due file divergono in silenzio, e il giorno che divergono il + sorvegliante continua a dire OK. **3ª occorrenza in un giorno della stessa forma** (test di + `book_live` senza potenza a libro flat, taratura di `venue_watch` misurata con bitfinex mentre + il live girava senza): *un controllo puntato su una configurazione diversa da quella che gira + passa sempre, e non sta controllando niente.* (2) **`src/live/monitor_health.py` + `scripts/live/monitor_health.py`** (test 16): **tre gate pre-registrati** (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decidono su serie forward di cui **una sola** aveva una guardia d'integrita'. Un monitor fermo produce silenzio, e **il diff --git a/scripts/live/fee_watch.py b/scripts/live/fee_watch.py index bdade77..9a08d64 100644 --- a/scripts/live/fee_watch.py +++ b/scripts/live/fee_watch.py @@ -48,12 +48,28 @@ import requests ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) +from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402 from src.live.notifier import notify # noqa: E402 STATE = ROOT / "data" / "fee_watch" / "state.json" -INSTRUMENTS = ("BTC-PERPETUAL", "ETH-PERPETUAL") +# ⚠️ CORREZIONE 2026-08-21. Qui c'era la tupla CABLATA ("BTC-PERPETUAL", "ETH-PERPETUAL") — gli +# INVERSE, regolati in BTC/ETH — mentre il book esegue sui LINEARI USDC (BTC_USDC-PERPETUAL). +# Sono due linee di prodotto distinte e Deribit le cambia in modo indipendente: il 18/08 ha +# toccato tick e size dei SOLI lineari (inverse ancora tick 0.5 / min 10.0, lineari 0.1 / 0.0001). +# Conseguenze del difetto: (a) il tier sorvegliato era di un prodotto che il book non tratta — +# oggi identico per caso, domani no; (b) il cross-check sui trade reali chiedeva la storia di uno +# strumento mai tradato e stampava "NON MISURATO" anche nei giorni con 4 fill. +# La lista si DERIVA dal book: cosi' la divergenza non e' un rischio da ricordare, e' impossibile. +INSTRUMENTS = tuple(BOOK_INSTRUMENT[a] for a in sorted(BOOK_INSTRUMENT)) API = "https://www.deribit.com/api/v2/public/get_instrument" +# Convenzione di unita' dei trade, che NON e' la stessa nelle due famiglie: +# inverse (BTC-PERPETUAL) amount = nozionale in USD, fee in valuta base (BTC) +# lineare (BTC_USDC-PERPETUAL) amount = quantita' in BASE, fee gia' in USDC +# Applicare la convenzione sbagliata non da' un errore: da' un numero. Su un fill vero +# (0.001 BTC @ 74.305,8, fee 0,026 USDC) quella inverse darebbe ~2.6e8 bps invece di 3,50. +SUFFISSO_LINEARE = "_USDC-PERPETUAL" + # --- riferimenti CONGELATI (cambiarli invalida la curva di r0726_fee_sensitivity.py) --- BASELINE_TAKER_BPS = 5.0 # 0.10% RT: l'assunzione di OGNI backtest del progetto BASELINE_MAKER_BPS = 0.0 @@ -88,9 +104,47 @@ def verdict(taker_bps: float) -> tuple[str, str]: f"(4x piu' fee-sensibile di TP01) via weights_tilt_null") +def convenzione(instrument: str) -> str: + """Famiglia di unita' dell'istrumento: 'lineare' | 'inverse' | 'ignota'. PURA. + + 'ignota' NON e' un caso da indovinare: un fill misurato con la convenzione sbagliata produce + un numero plausibile-o-assurdo ma sempre SILENZIOSO. Meglio dichiarare di non sapere. + """ + if instrument.endswith(SUFFISSO_LINEARE): + return "lineare" + if instrument.endswith("-PERPETUAL"): + return "inverse" + return "ignota" + + +def fee_bps_di_un_fill(instrument: str, amount: float, price: float, fee: float) -> float | None: + """bps di nozionale pagati su UN fill, con la convenzione della sua famiglia. PURA. + + Ritorna None se non e' calcolabile (dati mancanti o famiglia ignota): None significa + 'non misurata', mai 'zero'. + """ + fam = convenzione(instrument) + try: + amount, price, fee = abs(float(amount)), float(price), abs(float(fee)) + except (TypeError, ValueError): + return None + if amount <= 0 or price <= 0: + return None + if fam == "lineare": + notional_usd, fee_usd = amount * price, fee # amount in BASE, fee gia' in USDC + elif fam == "inverse": + notional_usd, fee_usd = amount, fee * price # amount in USD, fee in valuta base + else: + return None + return fee_usd / notional_usd * 1e4 if notional_usd > 0 else None + + def realized_fee_bps(limit: int = 20) -> dict: - """Fee REALMENTE pagata sui trade del conto, in bps di nozionale. Fonte autorevole, ma - disponibile solo se il book ha tradato di recente: {} non e' 'zero', e' 'non misurata'.""" + """Fee REALMENTE pagata sui trade del conto, in bps di nozionale (media pesata sul nozionale). + + Fonte autorevole, ma disponibile solo se il book ha tradato di recente: {} non e' 'zero', + e' 'non misurata'. + """ try: from src.live.deribit import DeribitRead d = DeribitRead() @@ -104,16 +158,13 @@ def realized_fee_bps(limit: int = 20) -> dict: except Exception: continue for t in trades: - try: - notional = abs(float(t.get("amount") or 0.0)) # perp: nozionale in USD - price = float(t.get("price") or 0.0) - fee = abs(float(t.get("fee") or 0.0)) # in valuta di settlement - if notional <= 0 or price <= 0: - continue - tot_fee_usd += fee * price - tot_notional += notional - except (TypeError, ValueError): + bps = fee_bps_di_un_fill(ins, t.get("amount"), t.get("price"), t.get("fee")) + if bps is None: continue + notional = abs(float(t["amount"])) * float(t["price"]) \ + if convenzione(ins) == "lineare" else abs(float(t["amount"])) + tot_fee_usd += bps / 1e4 * notional + tot_notional += notional if tot_notional > 0: out[ins] = tot_fee_usd / tot_notional * 1e4 return out @@ -183,7 +234,12 @@ def main() -> int: if real: print("\n cross-check sui trade REALI del conto (fonte autorevole):") for ins, bps in real.items(): - print(f" {ins:<16}{bps:>9.2f} bps/lato effettivi") + tier = r["current"].get(ins, {}).get("taker_bps") + nota = "" + if tier is not None: + d = bps - tier + nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "") + print(f" {ins:<22}{bps:>9.2f} bps/lato effettivi{nota}") else: print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)") diff --git a/tests/test_fee_watch.py b/tests/test_fee_watch.py index 08d7013..481a872 100644 --- a/tests/test_fee_watch.py +++ b/tests/test_fee_watch.py @@ -108,3 +108,57 @@ def test_sorveglia_il_tier_base_e_lo_dichiara(): 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