Files
PythagorasGoal/scripts/live/balance_watch.py
T
Adriano Dal Pastro 41f83b5dd8 BUIDL-01 CHIUSO: non entrabile — e la frase pubblicata da Deribit e' falsa per noi
Esito del test pre-registrato dieci minuti prima (7b8e356). Si chiude con un esito
che NON era fra i due previsti: i criteri contemplavano ">=1 reward -> IDONEO" e
"zero reward -> NON IDONEO", ma non si riesce a comprare BUIDL affatto, quindi la
domanda sull'eligibilita' ai reward e' priva di oggetto. Un gate puo' fallire sulla
sua PRECONDIZIONE invece che sul suo criterio, e va scritto cosi'.

Undici ordini rifiutati con not_enough_funds_in_currency, fino a 1 unita' a limite
1,0100 contro ask 1,0004 con 10.730 di profondita', tenendo ZERO BUIDL e avendo
$1.412 disponibili per comprarne $1. Esclusi uno per uno tutti i sospetti che
sull'USDE avevano portato fuori strada: prezzo (incrocia di 96 bps), liquidita',
taglia (il minimo dello strumento), fondi, tetto sul livello (teniamo zero), wallet
(account_summary risponde). Costo del test: $0, nessun fill.

🚨 "All Deribit users are permitted to buy and sell BUIDL tokens in the spot markets
on Deribit, with no extra requirements" — insights.deribit.com. NON per noi.
Motivo plausibile e non verificato: BUIDL e' un titolo (fondo BlackRock via
Securitize), distribuzione ristretta a monte dell'exchange. Il fatto misurato e' il
rifiuto, non il suo motivo.

REGOLA (due affermazioni pubblicate smentite dal conto in due giorni — APR USDC
3,40% e "all users can buy BUIDL"): una capacita' PUBBLICATA dal venue non e' una
capacita' del CONTO. Si verifica sul conto, prima che entri in un piano, e costa un
ordine da $1. E' N10 un passo piu' in la': non basta il sito, non basta il venue,
serve il conto.

SOTTOPRODOTTO che vale piu' del test: not_enough_funds_in_currency e' il messaggio
GENERICO di Deribit per "non puoi acquisire altra di questa valuta" — permesso
(BUIDL, zero in mano) o tetto (USDE, ~31,2% dell'equity), mai i fondi. Chi lo
incontra salti subito alla domanda giusta invece di inseguire taglia, cadenza e
prezzo come e' successo il 30/08.

=> L'USDE resta l'unico collaterale a rendimento che il conto puo' usare, ed e' al
suo tetto; i $1.412 di USDC continuano a rendere zero. Non per mancanza di
alternative: le tre alternative sono chiuse per giurisdizione (USDC), per permesso
(BUIDL) e perche' non e' un dollaro (stETH).

balance_watch esteso a BUIDL: se l'accesso si aprisse, un balance non nullo lo
direbbe da solo. Suite: 795 passati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:35:32 +00:00

172 lines
8.1 KiB
Python

#!/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
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())