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