Files
PythagorasGoal/scripts/live/usde_convert.py
T
Adriano Dal Pastro 37d1565a1e cuscino_watch: riacquisto automatico di USDE con isteresi (ECCEDENTE), bande in config, escape HTML nel sink di notify
Decisione dell'operatore 10/09 («riportarla a quota in autonomia»). Verificato da revisione fable
(10 segnalazioni, 9 applicate; loop numerico su usde_convert.piano: nessuna sequenza vendita→acquisto).

- quarto stato ECCEDENTE: slack > cuscino_riacquisto_frac (0,40) x cuscino => acquisto di USDE fino
  allo stesso bersaglio della vendita, q* = 1 - 0,30 x (1 + cuscino_margine_frac 0,20) = 0,64.
  Isteresi [0 ; 0,40] x cuscino con bersaglio unico 0,20: per oscillare servono +-8,6% di equity
  USDC (~$380); un bonifico alza lo slack di 0,7 x importo e sopra ~9% dell'equity ricompra da solo.
- le tre frazioni (preavviso/margine/riacquisto) in config/live.json sezione usde, lette da
  usde.bande_cuscino() che verifica l'ordine; quota_target 0,70 TOLTO (slack zero per costruzione).
- notifier.notify: html.escape su titolo e valori — la prima allerta vera (13:53Z) era andata persa
  per un '<' nudo; allerta_errore registrato nel record (P3).
- da ECCEDENTE un fallimento si ritenta ogni 24h (non 4 Telegram/giorno per sempre); bande fuori
  ordine => BLIND con motivo, non un traceback ingoiato dal cron.
- CLAUDE.md §1/§4/§5.17, config _nota_usde/_nota_cuscino, diario. 0,64 e' una DERIVAZIONE
  dell'autore, non la quota decisa il 30/08 (0,70): dichiarato, da confermare.
- test: 1070 (+8 netti).

Fixes #7

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
2026-09-10 14:14:29 +00:00

300 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.64 # piano, nessun ordine (0,64 = bersaglio di cuscino_watch)
uv run python scripts/live/usde_convert.py --quota 0.64 --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.
# Il cuscino di regolamento vive in src/live/usde.py dal 10/09 (issue #3): cuscino_watch lo
# sorveglia ogni ora e questo script lo rispetta a ogni piano — due lettori, UNA formula (P1).
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cuscino_richiesto_usd = U.cuscino_richiesto_usd
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}")
# il tetto del venue limita l'ACQUISTO di USDE: una VENDITA va sempre verso il tetto, e bloccarla
# renderebbe morta la riconversione automatica di cuscino_watch (revisione fable 10/09)
if side == "buy" and 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())