diff --git a/scripts/live/fee_watch.py b/scripts/live/fee_watch.py index 36bb057..98dd2c4 100644 --- a/scripts/live/fee_watch.py +++ b/scripts/live/fee_watch.py @@ -79,6 +79,7 @@ SOGLIA_OK = 5.0 # <= -> nessuna azione SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01 D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata D_CAGR_PER_BPS = -0.0023 +SOGLIA_DIVERGENZA_BPS = 1.0 # #37 B9: |fee realizzata - tier pubblico| oltre questo -> notify def fetch_fees(instrument: str) -> dict: @@ -209,14 +210,30 @@ def run() -> dict: changes = diff_vs(prev, cur) worst = max(c["taker_bps"] for c in cur.values()) lvl, why = verdict(worst) + # issue #37 (B9). realized_fee_bps() girava SOLO dentro il blocco di stampa: in cron + # (--quiet, il caso normale) non girava mai e una fee realmente pagata diversa dal tier + # pubblico non avvisava nessuno. Qui si calcola SEMPRE ed entra nel record del giro + # (registra_giro, sotto). {} -> None: "non misurata" (nessun trade recente), mai "zero". + real = realized_fee_bps() or None + divergenze = [] + if real: + for ins, bps in real.items(): + tier = cur.get(ins, {}).get("taker_bps") + if tier is None: + continue + d = bps - tier + if abs(d) > SOGLIA_DIVERGENZA_BPS: + divergenze.append( + f"{ins}: realizzata {bps:.2f} bps vs tier pubblico {tier:.2f} ({d:+.2f})") return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why, - first_read=not prev) + first_read=not prev, realized=real, divergenze=divergenze, + divergenza_gia_detta=bool(prev.get("divergenza_detta"))) def main() -> int: r = run() quiet = "--quiet" in sys.argv - interessante = bool(r["changes"]) or r["level"] != "OK" + interessante = bool(r["changes"]) or r["level"] != "OK" or bool(r.get("divergenze")) if not quiet or interessante: print("=" * 86) @@ -232,7 +249,7 @@ def main() -> int: print(" (prima lettura: stato inizializzato, nessun confronto possibile)") for c in r["changes"]: print(f" CAMBIO: {c}") - real = realized_fee_bps() + real = r.get("realized") # calcolato una volta in run(), non piu' qui (#37 B9) if real: print("\n cross-check sui trade REALI del conto (fonte autorevole):") for ins, bps in real.items(): @@ -240,7 +257,8 @@ def main() -> int: nota = "" if tier is not None: d = bps - tier - nota = f" (tier taker {tier:.2f} → {d:+.2f})" + (" ⚠️ DIVERGE" if abs(d) > 1.0 else "") + nota = (f" (tier taker {tier:.2f} → {d:+.2f})" + + (" ⚠️ DIVERGE" if abs(d) > SOGLIA_DIVERGENZA_BPS 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)") @@ -257,9 +275,31 @@ def main() -> int: }) if not inviato and (not quiet or interessante): print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta") + # issue #37 (B9): fee realmente pagata che diverge dal tier pubblico atteso -> notify UNA + # volta per episodio. Marcatore "divergenza_detta" nello stesso state.json (stessa regola di + # B8: si scrive solo se l'invio e' riuscito), azzerato quando la divergenza torna sotto + # soglia. E' un evento indipendente dal cambio di tier: se qui serve un invio e quello di B8 + # e' appena fallito, save_state sotto non gira (stessa gate di B8) e il marcatore non si + # scrive — al giro dopo si ridice, ridondante ma mai persa, la stessa scelta di B8. + divergenze = r.get("divergenze") or [] + gia_detta = bool(r.get("divergenza_gia_detta")) + marcatore = gia_detta + if divergenze and not gia_detta: + inviata_div = notify("💸 FEE WATCH — fee realizzata diverge dal tier pubblico", { + f"divergenza {i+1}": d for i, d in enumerate(divergenze) + }) + if inviata_div: + marcatore = True + elif not quiet or interessante: + print(" ⚠️ invio divergenza FALLITO — marcatore non scritto: il prossimo giro ritenta") + elif not divergenze: + marcatore = False + + stato_da_salvare = dict(r["current"]) + stato_da_salvare["divergenza_detta"] = marcatore if not serve_invio or inviato: - save_state(r["current"]) - registra_giro(RUNS, level=r["level"]) + save_state(stato_da_salvare) + registra_giro(RUNS, level=r["level"], realized=r.get("realized")) return 2 if r["level"] == "AZIONE" else 0 diff --git a/tests/test_fee_watch.py b/tests/test_fee_watch.py index 86a4bac..9df098e 100644 --- a/tests/test_fee_watch.py +++ b/tests/test_fee_watch.py @@ -204,7 +204,10 @@ 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"] + # (#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): @@ -213,3 +216,96 @@ def test_nessun_invio_dovuto_salva_comunque(monkeypatch): 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