Files
PythagorasGoal/scripts/live/usde_convert.py
T
Adriano Dal Pastro f151316bdf usde: il tetto e' una FRAZIONE dell'equity (~31,2%), non un livello — e ora e' in config
Fonte: articolo ufficiale Deribit "Yield/reward bearing coins" (WebFetch lo prende
403, si legge dall'API Help Center in JSON). Tre correzioni alle conclusioni di
ieri.

1) L'ITALIA e' nella lista delle giurisdizioni escluse dai reward USDC. Lo zero
misurato e' confermato dalla fonte, ma la mia ipotesi MiCA era SBAGLIATA: la
lista contiene Canada e Giappone, non e' il perimetro MiCA. E' policy di
giurisdizione Deribit. M27: una fonte normativa si verifica, non si deduce.

2) La finestra di pagamento USDC e' di DUE SETTIMANE ("within the first two
weeks of the following month"), non tre giorni. Avevo verificato su 8/14 e 3/14
giorni. Rifatto sulle finestre vere: LUGLIO conclusivo (atteso $1,72 contro
un'escursione TOTALE dell'equity di $0,48 in 1-16/08, zero scalini compatibili),
GIUGNO no (il libro opera da meta' mese, rumore della taglia del segnale). La
conclusione non cambia, ma l'evidenza e' UNA finestra piu' la lista ufficiale,
non due: il "172x" del 30/08 era sovra-affermato.

3) IL TETTO NON E' UN LIVELLO, E' UNA FRAZIONE. L'articolo documenta un Cap
ETHENA che diluisce il TASSO a livello di exchange e nessun limite sulle
quantita' detenibili: il muro non aveva base documentale e andava ri-sondato.
Fatto il 31/08 (giorno UTC nuovo -> non e' un limite giornaliero): ieri si
tornava a 644,18, oggi no, con l'equity scesa di $8.
    30/08  tetto [644,18 · 645,18)  equity $2.063,79  = 31,21-31,26%
    31/08  tetto [643,18 · 644,18)  equity $2.055,56  = 31,29-31,34%
0,05pp di scarto, dentro il rumore dell'equity (+-$2-8/ora). Candidato pulito
5/16 = 31,25%, ma a questa risoluzione non si distingue da una regola sul
collaterale scontato dell'haircut (~29%): si cita la banda (M25).
=> Il tetto SCALA col conto: la quota resta ~31%, il valore in dollari cresce
col capitale, il 70% non e' raggiungibile ne' ora ne' mai. E, essendo pinnati
al tetto, il rischio emittente resta una frazione COSTANTE del conto.

Cablato (rispondeva a "dove e' scritto il valore del tetto": in tre note di
testo e in nessun posto che il codice leggesse, tanto che usde_convert --quota
0.70 dichiarava "piano valido" per un ordine che il venue rifiuta):
  - config/live.json usde.venue_cap_frac 0.312 + venue_cap_misurato
  - src/live/usde.py lo porta nei default (unica autorita', P1)
  - usde_convert.piano() rifiuta il bersaglio sopra il tetto PRIMA di sparare
    e stampa il massimo raggiungibile. Verificato su entrambi i rami.

Anche: l'USDe ha un fee Deribit del 5% mai nominato prima. Il nostro misurato
(~4,5%/anno) e' gia' netto: e' l'unico numero da citare.

Stato: USDE 643,175691 (31,29%, al tetto), USDC $1.412,40, totale $2.055,57.
Suite: 795 passati.

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

303 lines
17 KiB
Python

#!/usr/bin/env python
"""usde_convert.py — porta la QUOTA di collaterale USDE a un bersaglio dichiarato. INVIA ORDINI VERI.
PERCHE' ESISTE. La conversione del 26/08 (test di eligibilita', 500 USDE) fu una chiamata a mano al
gateway: nessuno script, nessuna guardia, nessuna traccia se non il diario. Il gate USDE-01 prevede
entrambe le direzioni — IDONEO -> si decide la quota; NON IDONEO -> "si riconverte" — e nessuna delle
due aveva un attrezzo. Qui c'e', e vale in tutti e due i versi (quota piu' alta = compra USDE, piu'
bassa = vende).
PERCHE' NON PASSA DA DeribitTrader. `execution.ALLOWED` ammette solo i due perp del libro: e' il
guardrail anti-fat-finger del percorso soldi del book e NON va allargato per far entrare uno spot che
col libro non c'entra. Questo script parla al gateway per conto suo e porta le proprie guardie.
LE GUARDIE (tutte BLOCCANTI, tutte dichiarate qui):
* banda di prezzo [0.995, 1.005] — la stessa del 26/08. Fuori banda non si converte: un USDE a
0.98 e' esattamente il momento in cui NON si compra, e uno a 1.02 non esiste (si clampa a 1.0).
* quota bersaglio in [0, QUOTA_HARD_MAX] — il gate USDE-01 dice "mai 100%: R1 emittente non
recuperabile". Il tetto e' HARD e non si passa da riga di comando.
* TETTO DEL VENUE (`usde.venue_cap_frac`). Deribit rifiuta gli acquisti oltre una frazione
dell'equity totale — ~31,2%, MISURATA il 30-31/08 e non documentata da nessuna parte, col
messaggio fuorviante `not_enough_funds_in_currency`. E' una frazione, non un livello: fatta
scendere l'equity di $8, il tetto e' sceso con lei. Senza questa guardia il piano si dichiara
VALIDO e poi il venue lo rifiuta a raffica — che e' esattamente il giro di venti minuti costato
il 30/08. Il valore vive in config, non qui (P1: il bersaglio si deriva, non si ridichiara).
* CUSCINO DI REGOLAMENTO. Il P&L e il funding dei perp USDC-lineari si regolano in USDC, non nel
collaterale: a quota alta il saldo USDC va negativo alla prima perdita del libro e Deribit lo
finanzia a interesse. Il margine NON e' il vincolo (r0830_usde_quota: l'haircut non morde fino
al 90%) — il vincolo e' questo. Il cuscino richiesto e' il disaster-SL sulla MASSIMA esposizione
che il libro puo' prendere: n_asset * frac * disaster_sl_pct * equity, tutto letto da
config/live.json (P1: il bersaglio si deriva, non si ridichiara).
* dry-run DI DEFAULT: senza --esegui stampa il piano e non manda niente.
IL TETTO PER-ORDINE (misurato il 30/08, non documentato da nessuna parte). Il venue rifiuta gli
ordini spot grandi con `not_enough_funds_in_currency` ANCHE quando i fondi ci sono: 100 accettato
e 300 rifiutato con $1.442 disponibili (e 500 era passato il 26/08 con piu' saldo). Il gateway non
espone `available_withdrawal_funds` ne' `get_order_book`, quindi la causa vera NON e' leggibile da
qui e il messaggio del venue e' fuorviante: si tratta il tetto come ignoto e si CHUNKA, dimezzando
il blocco a ogni rifiuto. Costa zero: il book ha 3.990 unita' al miglior ask e la commissione spot
e' 0, quindi N fill piccoli e un fill grande pagano lo stesso prezzo.
NON E' NE' LA TAGLIA NE' IL TEMPO: E' IL PREZZO. Diagnosi finale (30/08 20:02, tre ordini da 1
USDE): SELL @1.0000 passa, BUY @1.0003 passa, BUY @1.0002 e BUY market falliscono. L'ask si era
mosso da 1.0002 a 1.0003 e l'ordine aveva smesso di INCROCIARE: Deribit rifiuta un limite BUY non
marcabile sullo spot con `not_enough_funds_in_currency`, che e' un messaggio fuorviante e ha fatto
inseguire per venti minuti due ipotesi sbagliate (tetto di size, rate-limit). Il difetto vero era
qui: il prezzo dell'ordine si derivava dall'INDICE (1.0001 + 1 tick) invece che dal BOOK. Sono due
prezzi diversi con due mestieri diversi — l'indice MARCA il collaterale (usde.valuta, mediana
multi-exchange: la lezione Binance 10/10), il book PREZZA lo scambio. Confonderli non sbaglia la
valutazione, sbaglia l'esecuzione.
LO STORICO DELLE IPOTESI SBAGLIATE, tenuto apposta: 943.98 rifiutato per "Invalid params" (vero:
min_trade_amount=1, il passo e' intero) -> 933 rifiutato per fondi con $1.541 disponibili (falso
indizio) -> 100 passa e 300 no (sembrava un tetto di size: era l'ask che si muoveva in mezzo) ->
anche 1 rifiutato otto volte (sembrava rate-limit: era sempre l'ask). Chi rilegge non deve rifare
questo giro. Misurato subito dopo: a raffica il venue rifiuta anche 1 USDE,
otto volte di fila, mentre 100 era appena passato. Il messaggio `not_enough_funds_in_currency` e'
FUORVIANTE — il vincolo e' la cadenza (regolamento spot asincrono / rate-limit), non il saldo ne'
la size. Percio' si SPAZIANO gli ordini (`--pausa`) e si fa backoff sul TEMPO, riducendo il blocco
solo dopo rifiuti ripetuti. Dimezzare la size a ogni rifiuto, come faceva la prima versione, e' la
reazione sbagliata al sintomo giusto: consuma i tentativi senza toccare la causa.
uv run python scripts/live/usde_convert.py --quota 0.70 # piano, nessun ordine
uv run python scripts/live/usde_convert.py --quota 0.70 --esegui # manda l'ordine
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live import usde as U # noqa: E402
from src.live.deribit import DeribitRead # noqa: E402
LOG = ROOT / "data" / "live" / "usde_convert.jsonl"
PX_MIN, PX_MAX = 0.995, 1.005 # banda di sicurezza sul prezzo (26/08)
QUOTA_HARD_MAX = 0.95 # gate USDE-01: "mai 100%"
MIN_ORDER_USD = 5.0
TOLL = 0.005 # entro mezzo punto di quota si considera gia' a bersaglio
EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e una quota derivata DAL
# cuscino ci cade sopra esatta (0.30*E da entrambi i lati). Senza
# questo epsilon il rumore float boccia il caso di pareggio.
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
"""Cuscino USDC di REGOLAMENTO = disaster-SL sulla massima esposizione lorda del libro.
Derivato da config/live.json + src/live/book.py, mai ridichiarato qui."""
cfg = json.loads((ROOT / "config" / "live.json").read_text())
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B
frac, n_asset = float(B.WEIGHT), 2
lordo_max = equity_tot * frac * n_asset
return lordo_max * sl, f"{n_asset} asset x frac {frac} x SL {sl:.0%} su ${equity_tot:,.2f}"
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
"""Miglior bid/ask dello spot USDE_USDC dall'API PUBBLICA Deribit (tokenless: il gateway non
espone get_order_book). -> (bid, ask), (None, None) se non leggibile. Mai solleva.
NON e' il prezzo di `usde.valuta`: quello e' l'INDICE e serve a marcare il collaterale. Questo
serve a far INCROCIARE l'ordine, ed e' l'unico che il matching engine guarda."""
try:
import requests
r = requests.get("https://www.deribit.com/api/v2/public/get_order_book",
params={"instrument_name": U.SPOT, "depth": 5}, timeout=timeout).json()
res = r.get("result", r)
bid = float(res["bids"][0][0]) if res.get("bids") else None
ask = float(res["asks"][0][0]) if res.get("asks") else None
return bid, ask
except Exception:
return None, None
def piano(eq_usdc: float, eq_usde: float, px: float, quota_target: float,
equity_tot: float, bid: float | None = None, ask: float | None = None) -> dict:
"""PURA. -> piano di conversione, con verdetto e motivo. Non manda niente."""
val_usde, _ = U.valuta(eq_usde, px)
quota_ora = val_usde / equity_tot if equity_tot > 0 else 0.0
val_target = quota_target * equity_tot
delta_usd = val_target - val_usde # >0 compra USDE, <0 vende
side = "buy" if delta_usd > 0 else "sell"
# MARCABILE CON MARGINE. Il book REST pubblico e' in RITARDO sul matching engine: misurato il
# 30/08, un BUY a ask+1tick (1.0003) e' stato rifiutato e uno a 1.0004 accettato un minuto dopo,
# col book che riportava ask 1.0002x3855 in entrambi i casi. Percio' si incrocia con 5 tick di
# margine e un TETTO DURO: il fill avviene al prezzo del LIBRO, non al limite, quindi il margine
# non si paga se il book non si e' mosso, e se si e' mosso il tetto limita il danno a ~9 bps.
# Senza book leggibile si RINUNCIA — non si tira a indovinare (P5).
TICK, MARGINE = 0.0001, 5
CAP_BUY, CAP_SELL = 1.0010, 0.9990
rif = ask if side == "buy" else bid
if rif is None:
px_ord = None
elif side == "buy":
px_ord = round(min(rif + MARGINE * TICK, CAP_BUY), 4)
else:
px_ord = round(max(rif - MARGINE * TICK, CAP_SELL), 4)
# PASSO DI SIZE = 1 USDE, misurato sul venue (30/08): 500 e 10 accettati, 943.98 e 933.99
# rifiutati con "Invalid params". `get_instrument` non e' esposto dal gateway (404), quindi il
# passo e' EMPIRICO e dichiarato tale. Si quantizza per DIFETTO: meno USDE = piu' USDC, cioe'
# dal lato del cuscino di regolamento. Sul lato vendita il difetto va nello stesso verso.
amount = float(math.floor(abs(delta_usd) / (px_ord or px)))
cfg = U.cfg()
cap_frac = float(cfg.get("venue_cap_frac") or 0)
cap_usd = cap_frac * equity_tot if cap_frac else None
cuscino, come = cuscino_richiesto_usd(equity_tot)
usdc_dopo = eq_usdc - delta_usd
motivi = []
if px_ord is None:
motivi.append("book USDE_USDC non leggibile — non si prezza l'ordine sull'indice (l'indice"
" marca il collaterale, il book prezza lo scambio)")
elif not (PX_MIN <= px_ord <= PX_MAX):
motivi.append(f"prezzo d'ordine {px_ord:.4f} fuori banda [{PX_MIN}, {PX_MAX}]")
if not (PX_MIN <= px <= PX_MAX):
motivi.append(f"prezzo {px:.4f} fuori banda [{PX_MIN}, {PX_MAX}]")
if not (0.0 <= quota_target <= QUOTA_HARD_MAX):
motivi.append(f"quota {quota_target:.0%} fuori dal tetto HARD {QUOTA_HARD_MAX:.0%} (gate USDE-01)")
if abs(quota_ora - quota_target) <= TOLL:
motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})")
if abs(delta_usd) < MIN_ORDER_USD:
motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}")
if cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
motivi.append(
f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} "
f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). "
f"Massimo raggiungibile: quota {cap_frac:.1%}")
if usdc_dopo < cuscino - EPS_CUSCINO:
motivi.append(f"cuscino di regolamento: USDC dopo ${usdc_dopo:,.2f} < ${cuscino:,.2f} richiesti ({come})")
return {"cap_usd": cap_usd, "cap_frac": cap_frac,
"quota_ora": quota_ora, "quota_target": quota_target, "val_usde": val_usde,
"equity_tot": equity_tot, "delta_usd": delta_usd, "side": side, "amount": amount,
"px": px, "px_ordine": px_ord, "usdc_dopo": usdc_dopo, "cuscino": cuscino,
"cuscino_come": come, "ok": not motivi, "motivi": motivi}
def leggi_stato(c: DeribitRead) -> tuple[float, float, float | None, str]:
eq_usdc = float(c.account_summary("USDC")["equity"])
try:
eq_usde = float(c.account_summary("USDE")["equity"])
except Exception:
eq_usde = 0.0
px, fonte = U.prezzo(client=c)
return eq_usdc, eq_usde, px, fonte
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--quota", type=float, required=True, help="quota USDE bersaglio, es. 0.70")
ap.add_argument("--esegui", action="store_true", help="manda l'ordine (senza: solo il piano)")
ap.add_argument("--chunk", type=float, default=100.0, help="blocco massimo per ordine (USDE)")
ap.add_argument("--pausa", type=float, default=12.0, help="secondi fra un ordine e il successivo")
a = ap.parse_args()
c = DeribitRead()
eq_usdc, eq_usde, px, fonte = leggi_stato(c)
if px is None:
print(" ✗ prezzo USDE non leggibile — non si converte al buio (P5)")
return 2
val_usde, _ = U.valuta(eq_usde, px)
equity_tot = eq_usdc + val_usde
bid, ask = book_top()
p = piano(eq_usdc, eq_usde, px, a.quota, equity_tot, bid, ask)
print("=" * 78)
print(f" USDE CONVERT — {datetime.now(timezone.utc):%Y-%m-%dT%H:%M:%SZ}"
f" {'ESECUZIONE' if a.esegui else 'PIANO (dry-run)'}")
print("=" * 78)
print(f" prezzo : indice {px:.4f} ({fonte}) · book bid {bid} / ask {ask}"
f" -> ordine @ {p['px_ordine']}, banda [{PX_MIN}, {PX_MAX}]")
print(f" equity : USDC ${eq_usdc:,.2f} + USDE {eq_usde:,.4f} (${val_usde:,.2f})"
f" = ${equity_tot:,.2f}")
print(f" quota : {p['quota_ora']:.1%} -> {p['quota_target']:.0%}"
+ (f" · tetto venue {p['cap_frac']:.1%} = ${p['cap_usd']:,.2f}" if p.get("cap_usd") else ""))
print(f" ordine : {p['side'].upper()} {p['amount']:,.2f} USDE_USDC @ limit {p['px_ordine']:.4f}"
f" (${abs(p['delta_usd']):,.2f})")
slack = p["usdc_dopo"] - p["cuscino"]
print(f" USDC dopo : ${p['usdc_dopo']:,.2f} · cuscino richiesto ${p['cuscino']:,.2f}"
f" ({p['cuscino_come']})")
print(f" margine : ${slack:+,.2f} di slack sul cuscino"
+ (" ⚠️ la quota e' ESATTAMENTE sul limite: zero slack" if abs(slack) <= EPS_CUSCINO else ""))
for m in p["motivi"]:
print(f" ✗ {m}")
if not p["ok"]:
print(" → NON si converte.")
return 1
if not a.esegui:
print(" → piano valido. Rilancia con --esegui per mandarlo.")
return 0
LOG.parent.mkdir(parents=True, exist_ok=True)
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
for _ in range(60):
eq_usdc, eq_usde, px, _f = leggi_stato(c)
if px is None:
print(" ✗ prezzo non piu' leggibile — mi fermo qui"); break
val, _ = U.valuta(eq_usde, px)
bid, ask = book_top()
pp = piano(eq_usdc, eq_usde, px, a.quota, eq_usdc + val, bid, ask)
if not pp["ok"]:
print(f" · fine: {pp['motivi'][0]}")
break
amt = float(min(pp["amount"], math.floor(chunk)))
if amt < 1:
print(" ✗ blocco sceso sotto 1 USDE — mi fermo"); break
resp = c._unwrap(c._post("/mcp-deribit/tools/place_order",
{"instrument_name": U.SPOT, "side": pp["side"], "amount": amt,
"type": "limit", "price": pp["px_ordine"],
"label": f"usde-quota-{a.quota:.2f}"})) or {}
if not isinstance(resp, dict) or resp.get("error") or "order" not in resp:
err = resp.get("error", resp) if isinstance(resp, dict) else resp
falliti += 1
attesa = min(a.pausa * (2 ** falliti), 90.0) # backoff sul TEMPO, non sulla taglia
if falliti >= 3: # solo allora sospetto la size
chunk = max(1.0, math.floor(chunk / 2))
print(f" · rifiutato {amt:,.0f}: {err} -> attendo {attesa:.0f}s"
+ (f", blocco a {chunk:,.0f}" if falliti >= 3 else ""))
time.sleep(attesa)
with LOG.open("a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"rifiutato": err, "amount": amt, "nuovo_chunk": chunk}) + "\n")
if falliti >= 8:
print(" ✗ troppi rifiuti consecutivi — mi fermo"); break
continue
falliti = 0
order, trades = resp["order"], resp.get("trades", []) or []
filled = float(order.get("filled_amount") or 0) or sum(float(t.get("amount", 0) or 0) for t in trades)
tot_filled += filled
prezzi += [(float(t["price"]), float(t["amount"])) for t in trades if t.get("price") and t.get("amount")]
fatti.append({"order_id": order.get("order_id"), "state": order.get("order_state"),
"amount": amt, "filled": filled})
print(f" · {pp['side']} {amt:,.0f} -> {order.get('order_state')} filled {filled:,.0f}"
f" (quota {pp['quota_ora']:.1%} -> bersaglio {a.quota:.0%})")
if filled <= 0:
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
time.sleep(a.pausa)
px_med = sum(q * v for q, v in prezzi) / sum(v for _, v in prezzi) if prezzi else None
eq_usdc, eq_usde, px, _f = leggi_stato(c)
val, _ = U.valuta(eq_usde, px)
tot = eq_usdc + val
print(f"\n ESITO : {tot_filled:,.0f} USDE in {len(fatti)} ordini"
+ (f" @ {px_med:.4f} medio" if px_med else ""))
print(f" conto : USDC ${eq_usdc:,.2f} + USDE {eq_usde:,.4f} (${val:,.2f}) = ${tot:,.2f}")
print(f" quota : {val / tot:.2%} (bersaglio {a.quota:.0%})")
with LOG.open("a") as f:
f.write(json.dumps({"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"quota_target": a.quota, "tot_filled": tot_filled, "px_medio": px_med,
"ordini": fatti, "quota_finale": val / tot if tot else None,
"usdc": eq_usdc, "usde": eq_usde}) + "\n")
print(f" registrato : {LOG.relative_to(ROOT)}")
return 0 if tot_filled > 0 else 1
if __name__ == "__main__":
raise SystemExit(main())