37d1565a1e
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
300 lines
17 KiB
Python
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())
|