#!/usr/bin/env python """balance_watch.py — registra il BALANCE esatto per valuta. SOLA LETTURA, nessun ordine. PERCHE' ESISTE. Il 2026-08-30 e' emerso che Deribit pubblica una APR anche per l'USDC (3,4000%, contro 4,1071% dell'USDE): se fosse accreditata, il guadagno del passaggio a USDE non sarebbe il tasso ma lo SPREAD (0,71 punti), e la pista USDE varrebbe ~$4,6/anno invece di ~$21. La domanda "l'USDC frutta davvero SUL NOSTRO conto?" non era rispondibile, perche': * il gateway NON espone il Transaction Log (provati get_transaction_log, get_settlement_history, get_deposits, get_transfers, get_interest_history: tutti 404 — e' il debito #11, si ha solo cio' che il gateway espone); * l'unica serie storica del conto e' `trades.db.equity`, che e' EQUITY **arrotondata a 2 decimali**: $0,13/giorno di interesse atteso ci sparisce dentro, e l'equity contiene comunque il P&L non realizzato, che vale mille volte tanto. Il `balance` no: cambia solo per P&L REALIZZATO, fee, funding, movimenti di fondi e interessi. Su USDE (nessuna posizione denominata in USDE) e' gia' stato il rivelatore dei reward. Questa serie fa lo stesso per l'USDC — ed e' il dato che mancava, non un'analisi nuova. COME SI SEPARA L'INTERESSE DAL RESTO (P7: il numero si etichetta con la sua configurazione): * P&L realizzato e fee -> noti ESATTAMENTE da `trades.db.fills`; ogni record porta il conteggio dei fill dall'ultimo campione, cosi' una finestra sporca si riconosce invece di essere mediata dentro; * funding -> proporzionale al NOZIONALE, quindi **zero quando il libro e' FLAT**. Il libro e' flat il 28% dei giorni: la lettura pulita arriva da sola, non va costruita; * interesse -> proporzionale al BALANCE. Una finestra con 0 fill e nozionale 0 misura l'interesse e basta. Le altre si etichettano. MISURATO GIA' ORA (30/08, tre letture a 8 decimali durante le conversioni USDE): il balance USDC si e' mosso **esattamente** dell'importo degli ordini (1555.84720621 - 10.002 = 1545.84520621, poi -134.0268 = 1411.81840621). Su ~10 minuti con $568 di posizioni aperte: **ne' funding ne' interesse arrivano in continuo sul balance**. Se l'interesse esiste, e' un accredito PERIODICO — come i reward USDE, che compaiono in un colpo intorno alle 12:00 UTC. Percio' la cadenza e' ORARIA: serve a stringere la finestra intorno all'accredito, non a campionare di piu'. uv run python scripts/live/balance_watch.py # un campione + lettura della serie uv run python scripts/live/balance_watch.py --report # solo la lettura, nessun campione """ from __future__ import annotations import argparse import json import sqlite3 import sys from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from src.live.deribit import DeribitRead # noqa: E402 STATE = ROOT / "data" / "live" / "balance_watch.jsonl" TRADES_DB = ROOT / "data" / "live" / "trades.db" VALUTE = ("USDC", "USDE", "BUIDL") APR_ATTESE = {"USDC": 0.0340, "USDE": 0.042143, "BUIDL": 0.032018} # public/get_currencies def fills_da(ts_iso: str | None) -> int | None: """Quanti fill dopo `ts_iso` (None se il DB non e' leggibile: non si inventa 0).""" if ts_iso is None: return None try: con = sqlite3.connect(f"file:{TRADES_DB}?mode=ro", uri=True) try: return con.execute("select count(*) from fills where ts_utc > ?", (ts_iso,)).fetchone()[0] finally: con.close() except Exception: return None def campiona(c: DeribitRead) -> dict: """Un campione: balance per valuta (8 decimali), nozionale lordo, posizioni. Mai solleva.""" rec = {"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "valute": {}} for cur in VALUTE: try: s = c.account_summary(cur) rec["valute"][cur] = {"balance": float(s["balance"]), "equity": float(s["equity"])} except Exception as e: rec["valute"][cur] = {"errore": f"{type(e).__name__}"} try: pos = c.positions("USDC") rec["nozionale_lordo"] = round(sum(abs(float(p.get("size") or 0)) for p in pos), 2) rec["n_posizioni"] = sum(1 for p in pos if abs(float(p.get("size") or 0)) > 1) except Exception: rec["nozionale_lordo"], rec["n_posizioni"] = None, None # INDICE usde_usdc, ORARIO (aggiunto 2026-09-02, debito §5.15). `usde_watch` lo registra una # volta al giorno (12:35Z): troppo rado per il classificatore dei movimenti di capitale, che # confronta due letture di equity a un'ora di distanza e oggi guarda solo BTC/ETH — con il # 31% dell'equity in USDE, un depeg sarebbe scorporato come un PRELIEVO. Questa e' la serie # che manca; il cablaggio nel bound viene dopo, quando ci sara' storia. None se non leggibile # (P5: "non vedo" non e' "il peg tiene"). try: from src.live.usde import prezzo_indice rec["usde_usdc"] = prezzo_indice() except Exception as e: rec["usde_usdc"], rec["usde_usdc_errore"] = None, f"{type(e).__name__}" return rec def serie() -> list[dict]: if not STATE.exists(): return [] out = [] for line in STATE.read_text().splitlines(): try: out.append(json.loads(line)) except Exception: pass return out def leggi(righe: list[dict]) -> None: print(f" campioni: {len(righe)}" + (f" da {righe[0]['ts']} a {righe[-1]['ts']}" if righe else "")) if len(righe) < 2: print(" → serve almeno un secondo campione: la prima differenza e' la prima misura.") return print(f"\n {'finestra':>34s} {'ore':>5s} {'Δ USDC':>13s} {'Δ USDE':>13s} {'fill':>5s} {'lordo':>9s} lettura") pulite = [] for a, b in zip(righe, righe[1:]): ba, bb = a["valute"].get("USDC", {}), b["valute"].get("USDC", {}) ea, eb = a["valute"].get("USDE", {}), b["valute"].get("USDE", {}) if "balance" not in ba or "balance" not in bb: continue t0 = datetime.strptime(a["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) t1 = datetime.strptime(b["ts"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) ore = (t1 - t0).total_seconds() / 3600 d_usdc = bb["balance"] - ba["balance"] d_usde = (eb.get("balance", 0) - ea.get("balance", 0)) if "balance" in eb and "balance" in ea else None nf = b.get("fills_da_ultimo") lordo = b.get("nozionale_lordo") pulita = (nf == 0) and (lordo is not None and lordo < 1) nota = "PULITA (0 fill, libro flat) → il Δ USDC E' interesse" if pulita else ( "sporca: fill nella finestra" if nf else "0 fill ma libro esposto → Δ = funding + interesse" if nf == 0 else "fill ignoti") if pulita and ore > 0: pulite.append((d_usdc, ba["balance"], ore)) print(f" {a['ts'][5:16]}→{b['ts'][5:16]:>16s} {ore:5.1f} {d_usdc:+13.8f} " f"{(f'{d_usde:+13.8f}' if d_usde is not None else ' n/d')} " f"{('' if nf is None else nf):>5} {('' if lordo is None else f'${lordo:,.0f}'):>9} {nota}") print() if pulite: tot_d = sum(d for d, _, _ in pulite) tot_h = sum(h for _, _, h in pulite) bal = sum(b * h for _, b, h in pulite) / tot_h apr = (tot_d / bal) * (8760 / tot_h) if bal and tot_h else float("nan") print(f" ⇒ FINESTRE PULITE: {len(pulite)}, {tot_h:.1f} ore, Δ totale {tot_d:+.8f} USDC " f"su balance medio ${bal:,.2f}") print(f" APR implicita {apr:+.4%} (attesa da public/get_currencies: " f"{APR_ATTESE['USDC']:.4%})") print(f" verdetto: {'FRUTTA' if tot_d > 1e-6 else 'NON frutta (Δ nullo su finestra pulita)'}") else: print(" ⇒ nessuna finestra PULITA ancora (serve il libro FLAT e 0 fill: succede il 28% dei giorni).") print(" Nel frattempo le finestre a 0 fill misurano funding+interesse insieme.") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--report", action="store_true", help="solo lettura, nessun campione nuovo") a = ap.parse_args() righe = serie() if not a.report: rec = campiona(DeribitRead()) rec["fills_da_ultimo"] = fills_da(righe[-1]["ts"] if righe else None) STATE.parent.mkdir(parents=True, exist_ok=True) with STATE.open("a") as f: f.write(json.dumps(rec) + "\n") righe.append(rec) u = rec["valute"].get("USDC", {}); e = rec["valute"].get("USDE", {}) print(f" campione {rec['ts']}: USDC balance {u.get('balance')} · USDE {e.get('balance')}" f" · lordo ${rec.get('nozionale_lordo')}") print("=" * 78) print(" BALANCE WATCH — l'USDC frutta sul nostro conto?") print("=" * 78) leggi(righe) return 0 if __name__ == "__main__": raise SystemExit(main())