fd7595e819
Lo screenshot della pagina margini era su Wasabi (rclone remote wasabi:, bucket
adp-work, cartella _scambio), non sulla VPS: per questo il percorso non esisteva.
Riconciliazione riproducibile in scripts/research/r0831_margini_conto.py (N11).
(a) HAIRCUT = 5%. La pagina non lo espone come numero: si ricava per differenza,
perche' il CROSS conta l'USDE scontato e il SEGREGATO non lo conta affatto.
S:SM (attivo) USDC Available 1.400,86 vs equity 1.412,28 - IM 11,55 = 1.400,73
X:SM CROSS Available 2.011,73
contributo USDE al cross = $610,81 su $643,05 -> 5,0137%
5% (KB) atteso $610,89 scarto $ 0,09 TORNA
10% (nostra) atteso $578,74 scarto $32,06 NON torna
config/live.json: haircut 0.10 -> 0.05. Il 26/08 il 10% fu registrato come
"verificato sul venue" senza traccia di COME, e non lo era.
(b) 🚨 IL CONTO NON E' CROSS-COLLATERAL: modello attivo "Segregated: Standard
Margin" (S:SM). Nella tabella del modello attivo l'USDE NON COMPARE: non fa
margine per i perp USDC-settled del book, e l'haircut oggi non si applica. Il che
spiega a posteriori perche' la misura di stamattina trovava $0,0000 accantonati:
non stavo guardando nel secchio sbagliato, cercavo un parametro che sul nostro
conto non e' in vigore.
NON MORDE: al massimo lordo del libro (1,0x = ~$2.056 di nozionale) l'IM sarebbe
~$41 contro $1.400 di USDC disponibile, 34x di copertura. Nessuna decisione
operativa cambia oggi.
Ma la premessa in CLAUDE.md era falsa, e il modo in cui lo era e' istruttivo:
"l'equity del book e' il TOTALE cross-collateral" metteva due cose sotto un nome
solo. Come RICCHEZZA sommare USDC+USDE e' giusto ed era il punto della riparazione
del 26/08 (evito' il falso "USCITA DI FONDI -24%"); come CAPACITA' DI MARGINE e'
sbagliato, perche' nel modello attivo l'USDE vale zero. Finche' il margine non
morde le due coincidono nell'uso, e infatti non era mai emerso.
Corretta anche la riga di usde_watch che stampava "margine utilizzabile ~$2.013
(haircut 10%)": era falsa due volte insieme. Ora stampa il solo silo USDC e,
accanto, cosa darebbe il cross.
La quota USDE non e' "collaterale diversificato": e' cassa a rendimento FUORI dal
sistema di margine. Passare a X:SM aggiungerebbe $610,87 di margine utilizzabile
ma porta la meccanica cross (collateral fee 0,05%/giorno sul saldo negativo,
ribilanciamento automatico): decisione dell'operatore, oggi non serve.
Ipotesi nuova e non verificata: il tetto del ~31,2% potrebbe dipendere proprio dal
modello segregato. Si saprebbe passando a X:SM e ri-sondando.
Suite: 800 passati.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
335 lines
16 KiB
Python
Executable File
335 lines
16 KiB
Python
Executable File
#!/usr/bin/env python
|
|
"""usde_watch.py — sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA.
|
|
|
|
PERCHE' ESISTE. Dal 2026-08-26 il conto tiene USDE come collaterale a rendimento (test di
|
|
eligibilita': 500 USDE @ 1.0003, regola pre-registrata nel diario 2026-08-26-usde-analisi).
|
|
Tre cose vanno sorvegliate e nessuna aveva un posto:
|
|
|
|
1. i REWARD giornalieri (~12:00 UTC) — sono la ragione della posizione, e il gateway non
|
|
espone il Transaction Log: si rilevano per DELTA di equity USDE al netto dei trade spot.
|
|
Metodo DICHIARATO, coi suoi limiti: un delta con trade nel mezzo si riporta con
|
|
`con_trade=true` e se i trade non sono leggibili il delta resta NON ATTRIBUITO invece
|
|
di essere inventato (P12: una riparazione silenziosa e' un'invenzione).
|
|
2. il DEPEG — soglie e criteri in config/live.json `_nota_usde` (P6: dichiarati prima):
|
|
warn = fuori dalla banda operativa dello spot e oltre il clamp per-fonte dell'indice;
|
|
crit = meta' del buffer di haircut consumata.
|
|
3. la QUOTA sul totale — N4: la quota e' l'unica leva contro il rischio emittente
|
|
(-100% = 25 anni di resa, non recuperabile). Sopra `quota_max_frac` allerta, e vale
|
|
anche per deriva PASSIVA: il libro perde -> la quota sale da sola.
|
|
|
|
VERDETTO DI ELIGIBILITA' (regola pre-registrata il 26/08, PRIMA dell'esito): >=1 reward
|
|
entro il 29/08 -> IDONEO (si apre la decisione di quota, che e' dell'OPERATORE); zero reward
|
|
alla lettura del 29/08 dopo la finestra delle 12:00 UTC -> NON IDONEO, si riconverte e la
|
|
pista si chiude. Questo script APPLICA la regola, non la decide.
|
|
|
|
LIMITE DICHIARATO (P6: la latenza si confronta con la durata del fenomeno). Cadenza
|
|
giornaliera (cron 12:35 UTC, dopo la finestra reward): il depeg Binance del 10/10/2025 duro'
|
|
~8h, questa sorveglianza puo' MANCARLO. Accettato perche' la protezione di SIZING e' oraria
|
|
per costruzione (shadow._collaterale_usde legge l'indice a ogni giro del book) e il danno e'
|
|
limitato dalla quota. Qui si ALLERTA, non si protegge.
|
|
|
|
ALLARMI (P9: il massimo si spende per l'evento vero): 🚨 solo depeg<crit, ripetuto finche'
|
|
attivo; ⚠️ per warn/quota/BLIND SOLO alla transizione (il giorno che compaiono); 📌 per il
|
|
verdetto IDONEO. BLIND = conto non leggibile, registrato e detto (P5: "non vedo" non e'
|
|
"va tutto bene"). La serie data/live/usde_watch.jsonl e' dentro il perimetro di backup ed
|
|
e' sorvegliata da monitor_health (un watch fermo = niente allarmi depeg, e il silenzio
|
|
si legge come "va tutto bene").
|
|
|
|
uv run python scripts/live/usde_watch.py # report completo
|
|
uv run python scripts/live/usde_watch.py --quiet # stampa/allerta solo transizioni
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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 import usde as U # noqa: E402
|
|
from src.live.notifier import notify # noqa: E402
|
|
|
|
STATE = ROOT / "data" / "live" / "usde_watch.jsonl"
|
|
EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500 e' ~0.055/giorno
|
|
|
|
# --- finestra di eligibilita', DALLA REGOLA PRE-REGISTRATA (diario 2026-08-26-usde-analisi) ---
|
|
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
|
|
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
|
|
|
|
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
|
|
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
|
|
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
|
|
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
|
|
# ogni giorno finche' la riga non viene tolta.
|
|
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
|
|
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
|
|
|
|
|
|
def _parse(ts: str) -> datetime:
|
|
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
|
|
|
|
def leggi(path: Path = STATE) -> list[dict]:
|
|
if not path.exists():
|
|
return []
|
|
out = []
|
|
for ln in path.read_text().splitlines():
|
|
ln = ln.strip()
|
|
if ln:
|
|
out.append(json.loads(ln))
|
|
return out
|
|
|
|
|
|
def analizza(prev: dict | None, eq_now: float, trades_usde: float | None) -> dict:
|
|
"""PURA. Reward per delta di equity USDE al netto dei trade spot fra le due letture.
|
|
|
|
prev senza equity (primo giro, o giro BLIND) -> baseline: nessun delta da attribuire.
|
|
trades_usde None = storia trade non leggibile -> il delta si riporta ma NON si attribuisce
|
|
a reward (P12): meglio un giorno di latenza che un reward inventato."""
|
|
if prev is None or prev.get("eq_usde") is None:
|
|
return dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
|
|
delta = round(eq_now - float(prev["eq_usde"]), 8)
|
|
if trades_usde is None:
|
|
return dict(delta=delta, reward_stimato=None, reward_rilevato=False, con_trade=None)
|
|
stima = round(delta - trades_usde, 8)
|
|
return dict(delta=delta, reward_stimato=stima,
|
|
reward_rilevato=bool(stima > EPS_REWARD),
|
|
con_trade=bool(abs(trades_usde) > 0))
|
|
|
|
|
|
def verdetto(records: list[dict], now: datetime) -> tuple[str, str]:
|
|
"""PURA. Applica la regola pre-registrata del 26/08. -> (stato, motivo)."""
|
|
for r in records:
|
|
if r.get("reward_rilevato"):
|
|
return "IDONEO", (f"reward rilevato il {r.get('data')} "
|
|
f"(+{r.get('reward_stimato')} USDE)")
|
|
if now >= _parse(VERDETTO_DOPO):
|
|
return "NON_IDONEO", "zero reward entro la finestra del 29/08 -> riconvertire, pista chiusa"
|
|
return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}"
|
|
|
|
|
|
def rendimento(records: list[dict]) -> dict:
|
|
"""PURA. Il rendimento sulle finestre OSSERVATE, col loro NUMERO accanto.
|
|
|
|
E' il numero che serve alla decisione di quota, e l'unico modo onesto di darlo e' con `n`
|
|
attaccato: un pagamento non e' un tasso. Il denominatore e' il TEMPO VERO fra la prima e
|
|
l'ultima lettura misurabile, non il conteggio delle finestre — cosi' una finestra che non
|
|
paga abbassa la stima invece di sparire (P5: il silenzio in una serie di ritorni si legge
|
|
come zero, e qui e' proprio uno zero).
|
|
|
|
Si escludono le letture con trade nel mezzo (`con_trade`): quel delta non e' solo reward, e
|
|
una riparazione silenziosa sarebbe un'invenzione (P12).
|
|
"""
|
|
usabili = [r for r in records
|
|
if r.get("reward_stimato") is not None and not r.get("con_trade")]
|
|
pagate = [r for r in usabili if r.get("reward_rilevato")]
|
|
if not usabili:
|
|
return dict(apr=None, finestre=0, pagate=0, giorni=0.0, tot=0.0,
|
|
nota="nessuna finestra misurabile")
|
|
tot = float(sum(r["reward_stimato"] for r in usabili))
|
|
eq = [float(r["eq_usde"]) for r in usabili if r.get("eq_usde")]
|
|
eq_med = sum(eq) / len(eq) if eq else 0.0
|
|
# tempo vero coperto: dalla lettura PRIMA della prima misurabile, all'ultima
|
|
i0 = records.index(usabili[0])
|
|
t0 = records[max(0, i0 - 1)]["ts"]
|
|
giorni = (usabili[-1]["ts"] - t0) / 86_400_000.0
|
|
if giorni < EPS_GIORNI or eq_med <= 0:
|
|
return dict(apr=None, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
|
|
nota=f"solo {giorni:.2f} giorni osservati: non si annualizza")
|
|
apr = tot / eq_med / giorni * 365.0
|
|
return dict(apr=apr, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
|
|
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
|
|
|
|
|
|
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
|
|
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
|
|
if verdetto_corrente != "IDONEO":
|
|
return False
|
|
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
|
|
|
|
|
|
def condizioni(rec: dict | None, c: dict) -> set[str]:
|
|
"""PURA. Condizioni di allerta attive su un record."""
|
|
if not rec:
|
|
return set()
|
|
if rec.get("stato") == "BLIND":
|
|
return {"BLIND"}
|
|
att = set()
|
|
px = rec.get("px")
|
|
if px is not None:
|
|
if px < c["depeg_crit"]:
|
|
att.add("DEPEG_CRIT")
|
|
elif px < c["depeg_warn"]:
|
|
att.add("DEPEG_WARN")
|
|
q = rec.get("quota")
|
|
if q is not None and q > c["quota_max_frac"]:
|
|
att.add("QUOTA_OVER")
|
|
return att
|
|
|
|
|
|
def _safe_client():
|
|
try:
|
|
from src.live.deribit import DeribitRead
|
|
return DeribitRead()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
|
|
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile."""
|
|
if client is None:
|
|
return None, None
|
|
try:
|
|
rows = client.trade_history(U.SPOT, limit=50)
|
|
except Exception:
|
|
return None, None
|
|
tot, n = 0.0, 0
|
|
for t in rows:
|
|
if int(t.get("timestamp") or 0) <= ts_ms:
|
|
continue
|
|
amt = float(t.get("amount") or 0)
|
|
tot += amt if (t.get("direction") or "").lower() == "buy" else -amt
|
|
n += 1
|
|
return round(tot, 8), n
|
|
|
|
|
|
def main() -> int:
|
|
quiet = "--quiet" in sys.argv
|
|
c = U.cfg()
|
|
records = leggi()
|
|
prev = records[-1] if records else None
|
|
now = datetime.now(timezone.utc)
|
|
client = _safe_client()
|
|
|
|
stato, motivo_blind, eq_usde, eq_usdc = "OK", None, None, None
|
|
if client is None:
|
|
stato, motivo_blind = "BLIND", "gateway non raggiungibile"
|
|
else:
|
|
try:
|
|
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
|
|
except Exception as e:
|
|
stato, motivo_blind = "BLIND", f"conto USDE non leggibile ({type(e).__name__})"
|
|
try:
|
|
eq_usdc = float(client.account_summary("USDC").get("equity") or 0)
|
|
except Exception:
|
|
eq_usdc = None # quota non computabile: dichiarato, non 0
|
|
|
|
px, px_fonte = U.prezzo(client)
|
|
usd = None
|
|
if eq_usde is not None:
|
|
usd, _nota = U.valuta(eq_usde, px)
|
|
|
|
trades_usde, n_trades = (None, None)
|
|
an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
|
|
if stato == "OK" and prev is not None and prev.get("eq_usde") is not None:
|
|
trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"]))
|
|
an = analizza(prev, eq_usde, trades_usde)
|
|
|
|
quota = None
|
|
if usd is not None and eq_usdc is not None and (usd + eq_usdc) > 0:
|
|
quota = round(usd / (usd + eq_usdc), 4)
|
|
|
|
rec = dict(
|
|
ts=int(now.timestamp() * 1000),
|
|
data=now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
stato=stato, motivo_blind=motivo_blind,
|
|
eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd,
|
|
eq_usdc=eq_usdc, quota=quota,
|
|
trades_usde=trades_usde, n_trades=n_trades, **an,
|
|
)
|
|
v, v_motivo = verdetto(records + [rec], now)
|
|
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
|
|
|
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
with STATE.open("a") as fh:
|
|
fh.write(json.dumps(rec) + "\n")
|
|
|
|
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit ---
|
|
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
|
|
nuove = att_now - att_prev
|
|
inviati = []
|
|
if "DEPEG_CRIT" in att_now:
|
|
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
|
|
"collaterale": f"${usd:,.0f}" if usd else "?",
|
|
"azione": "valutare riconversione — la quota e' il controllo (N4)"})
|
|
inviati.append("DEPEG_CRIT")
|
|
if "DEPEG_WARN" in nuove:
|
|
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
|
|
"nota": "entra gia' nel sizing orario del book"})
|
|
inviati.append("DEPEG_WARN")
|
|
if "QUOTA_OVER" in nuove:
|
|
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
|
|
"tetto": f"{c['quota_max_frac']:.0%}",
|
|
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
|
|
inviati.append("QUOTA_OVER")
|
|
if "BLIND" in nuove:
|
|
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
|
|
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
|
inviati.append("BLIND")
|
|
v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
|
|
if v != v_prev:
|
|
if v == "IDONEO":
|
|
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo,
|
|
"prossimo passo": "decisione di quota dell'operatore (tetto allerta 50%)"})
|
|
elif v == "NON_IDONEO":
|
|
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
|
|
inviati.append(f"VERDETTO->{v}")
|
|
|
|
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
|
|
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
|
|
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
|
|
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
|
|
rend = rendimento(records + [rec])
|
|
if quota_da_decidere(now, v):
|
|
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
|
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
|
|
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
|
|
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
|
|
"rendimento osservato": f"{apr} — {rend['nota']}, {rend['giorni']:.1f} giorni",
|
|
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
|
|
inviati.append("QUOTA_DA_DECIDERE")
|
|
|
|
cambiato = bool(inviati) or v != v_prev
|
|
if not quiet or cambiato:
|
|
print("=" * 78)
|
|
print(f" USDE WATCH — {rec['data']} (config: {c['fonte']})")
|
|
print("=" * 78)
|
|
if stato == "BLIND":
|
|
print(f" stato : BLIND — {motivo_blind}")
|
|
else:
|
|
print(f" equity USDE : {eq_usde:,.4f} @ {px if px is not None else 'n/d'}"
|
|
f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
|
|
if quota is not None:
|
|
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08):
|
|
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il
|
|
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross,
|
|
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
|
|
cross = eq_usdc + usd * (1.0 - c["haircut"])
|
|
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})")
|
|
print(f" margine : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
|
|
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
|
|
f" {c['haircut']:.0%})")
|
|
if an["delta"] is not None:
|
|
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None
|
|
else f"reward stimato {an['reward_stimato']:+.6f} USDE"
|
|
+ (" [con trade nel mezzo]" if an["con_trade"] else ""))
|
|
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
|
|
print(f" verdetto : {v} — {v_motivo}")
|
|
if rend["finestre"]:
|
|
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
|
|
print(f" rendimento : {apr} su {rend['giorni']:.1f} giorni — {rend['nota']}"
|
|
f" (tot {rend['tot']:+.6f} USDE)")
|
|
if rend["finestre"] < 4:
|
|
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
|
|
if v == "IDONEO" and not quota_da_decidere(now, v):
|
|
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
|
|
if inviati:
|
|
print(f" allarmi : {', '.join(inviati)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|