318 lines
15 KiB
Python
318 lines
15 KiB
Python
#!/usr/bin/env python
|
|
"""fee_watch.py — sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
|
|
|
|
PERCHE' ESISTE. Il 2026-07-26 Deribit ha annunciato un nuovo schema fee dal **2026-08-01** senza
|
|
pubblicare i numeri (la tabella nell'articolo Insights e' un'immagine, fonte secondaria). La
|
|
risposta del progetto e' stata misurare la CURVA invece di aspettare il numero
|
|
(`scripts/research/r0726_fee_sensitivity.py`):
|
|
|
|
bps/lato %RT TP01 Sh SKH01 Sh BOOK Sh BOOK CAGR
|
|
0 0.00% 1.322 1.567 1.849 21.69%
|
|
3 0.06% 1.303 1.495 1.799 20.99%
|
|
5 0.10% 1.290 1.446 1.766 20.53% <- assunzione di TUTTI i backtest
|
|
10 0.20% 1.258 1.324 1.682 19.39%
|
|
15 0.30% 1.226 1.200 1.597 18.26%
|
|
|
|
sensibilita' marginale del BOOK: -0.017 Sharpe/bps, -0.23% CAGR/bps
|
|
SKH01 e' ~4x piu' sensibile di TP01 (-0.69% vs -0.09% CAGR/bps): round-trip discreti contro
|
|
posizione continua vol-targeted -> se il taker sale, il primo parametro da rivedere e' il
|
|
PESO 75/25, non altro.
|
|
|
|
REGOLA CONGELATA (decisa PRIMA di vedere il numero, per non deciderla col numero davanti):
|
|
* taker <= 5 bps/lato -> non si tocca nulla (i backtest restano conservativi o esatti)
|
|
* 5 < taker <= 10 bps -> si riporta il costo, nessuna azione dovuta
|
|
* taker > 10 bps/lato -> si rivede il PESO di SKH01 (`r0724_skh_live_weight` /
|
|
`r0726_reeval_live_weight`), passando da `weights_tilt_null`
|
|
|
|
COSA LEGGE. `public/get_instrument` (endpoint PUBBLICO, nessuna chiave): `taker_commission` e
|
|
`maker_commission` sono il tier BASE dell'istrumento, cioe' quello che paga questo conto — a $600
|
|
il volume 30g e' trascurabile e ogni soglia VIP e' fuori portata. Sorveglia anche
|
|
`max_liquidation_commission`, che l'annuncio dice diventera' 1% su tutti i prodotti.
|
|
Cross-check autorevole (best-effort): la fee REALMENTE pagata, dai trade del conto.
|
|
|
|
⚠️ Il tier VIP di un conto ad alto volume NON e' leggibile qui. La guardia
|
|
`test_fee_watch_assume_tier_base` congela questa assunzione: se il conto smettesse di essere al
|
|
tier base, questo script misurerebbe la cosa sbagliata.
|
|
|
|
uv run python scripts/live/fee_watch.py # report
|
|
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
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.giri import registra_giro # noqa: E402
|
|
from src.live.notifier import notify # noqa: E402
|
|
|
|
STATE = ROOT / "data" / "fee_watch" / "state.json"
|
|
RUNS = ROOT / "data" / "fee_watch" / "runs.jsonl"
|
|
# ⚠️ 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
|
|
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:
|
|
"""Commissioni correnti dell'istrumento (frazione di nozionale -> bps)."""
|
|
r = requests.get(API, params={"instrument_name": instrument}, timeout=15)
|
|
r.raise_for_status()
|
|
res = r.json()["result"]
|
|
return dict(
|
|
instrument=instrument,
|
|
taker_bps=float(res["taker_commission"]) * 1e4,
|
|
maker_bps=float(res["maker_commission"]) * 1e4,
|
|
liq_bps=float(res.get("max_liquidation_commission", 0.0)) * 1e4,
|
|
)
|
|
|
|
|
|
def verdict(taker_bps: float) -> tuple[str, str]:
|
|
"""Applica la regola congelata. Ritorna (livello, motivazione)."""
|
|
if taker_bps <= SOGLIA_OK:
|
|
return "OK", f"taker {taker_bps:.1f}bps <= {SOGLIA_OK:.0f}: non si tocca nulla"
|
|
if taker_bps <= SOGLIA_AZIONE:
|
|
d = (taker_bps - BASELINE_TAKER_BPS)
|
|
return "NOTA", (f"taker {taker_bps:.1f}bps: costo ~{d * D_SHARPE_PER_BPS:+.3f} Sharpe / "
|
|
f"{d * D_CAGR_PER_BPS:+.2%} CAGR di book, nessuna azione dovuta")
|
|
return "AZIONE", (f"taker {taker_bps:.1f}bps > {SOGLIA_AZIONE:.0f}: rivedere il PESO di SKH01 "
|
|
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 (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()
|
|
except Exception:
|
|
return {}
|
|
out: dict[str, float] = {}
|
|
for ins in INSTRUMENTS:
|
|
tot_fee_usd = tot_notional = 0.0
|
|
try:
|
|
trades = d.trade_history(ins, limit=limit)
|
|
except Exception:
|
|
continue
|
|
for t in trades:
|
|
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
|
|
|
|
|
|
def load_state() -> dict:
|
|
if STATE.exists():
|
|
try:
|
|
return json.loads(STATE.read_text())
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def save_state(cur: dict) -> None:
|
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE.write_text(json.dumps(cur, indent=2, sort_keys=True))
|
|
|
|
|
|
def diff_vs(prev: dict, cur: dict) -> list[str]:
|
|
"""Cambiamenti rispetto all'ultima lettura. Prima lettura = nessun cambiamento (non e'
|
|
un evento: e' l'inizializzazione)."""
|
|
if not prev:
|
|
return []
|
|
ch = []
|
|
for ins, c in cur.items():
|
|
p = prev.get(ins)
|
|
if not p:
|
|
ch.append(f"{ins}: strumento nuovo nella sorveglianza")
|
|
continue
|
|
for k, lab in (("taker_bps", "taker"), ("maker_bps", "maker"), ("liq_bps", "liquidazione")):
|
|
if abs(float(p.get(k, -1)) - float(c[k])) > 1e-9:
|
|
ch.append(f"{ins} {lab}: {float(p.get(k, float('nan'))):.2f} -> {c[k]:.2f} bps")
|
|
return ch
|
|
|
|
|
|
def run() -> dict:
|
|
cur = {ins: fetch_fees(ins) for ins in INSTRUMENTS}
|
|
prev = load_state()
|
|
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, 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" or bool(r.get("divergenze"))
|
|
|
|
if not quiet or interessante:
|
|
print("=" * 86)
|
|
print(" FEE WATCH — schema fee Deribit vs la regola decisa in anticipo")
|
|
print("=" * 86)
|
|
print(f"\n {'strumento':<16}{'taker':>10}{'maker':>10}{'liquidaz.':>12}")
|
|
for ins, c in r["current"].items():
|
|
print(f" {ins:<16}{c['taker_bps']:>9.2f}{c['maker_bps']:>10.2f}{c['liq_bps']:>11.2f}")
|
|
print(f"\n baseline dei backtest: taker {BASELINE_TAKER_BPS:.1f} / "
|
|
f"maker {BASELINE_MAKER_BPS:.1f} bps (0.10% RT)")
|
|
print(f" verdetto: [{r['level']}] {r['reason']}")
|
|
if r["first_read"]:
|
|
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
|
for c in r["changes"]:
|
|
print(f" CAMBIO: {c}")
|
|
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():
|
|
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) > 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)")
|
|
|
|
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
|
|
# il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
|
|
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
|
|
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
|
|
inviato = True
|
|
if serve_invio:
|
|
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
|
|
"verdetto": f"[{r['level']}] {r['reason']}",
|
|
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
|
})
|
|
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(stato_da_salvare)
|
|
registra_giro(RUNS, level=r["level"], realized=r.get("realized"))
|
|
return 2 if r["level"] == "AZIONE" else 0
|
|
|
|
|
|
USO = """uso: fee_watch.py [--quiet]
|
|
|
|
fee_watch.py — sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
|
|
uv run python scripts/live/fee_watch.py # report
|
|
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
|
|
|
|
Dettaglio nel docstring in testa al file."""
|
|
|
|
if __name__ == "__main__":
|
|
from src.live.cli import valida
|
|
valida("fee_watch.py", USO, flag=("--quiet",), con_valore=())
|
|
raise SystemExit(main())
|