Files
PythagorasGoal/scripts/live/usde_watch.py
T
Adriano 4725839041 usde_watch: notify affidabile (allertati) e quota derivata, non piu' rinviata (#29 B2, #35 B3/B4)
#29 B2: lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si
leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema
di cuscino_watch.py: nuove_condizioni()/rec["allertati"] contano una
condizione come "gia' detta" solo se l'invio precedente e' RIUSCITO; un
record vecchio senza `allertati` vale come "tutto allertato" (nessun rispam
retroattivo). Lo stato si scrive una volta sola, a fine giro.

#35 B3/B4: la quota non e' piu' una decisione dell'operatore rinviata a
lunedi' — dal 10/09 (issue #7) e' DERIVATA dal cuscino USDC
(usde._nota_cuscino) — quindi tolti DECISIONE_QUOTA_DAL, quota_da_decidere()
e la notify "la decisione di QUOTA e' dovuta". Il tetto di allerta IDONEO
legge c["quota_max_frac"] invece del 50% cablato. La riga "margine" non dice
piu' "conto SEGREGATO" (il modello e' Cross X:SM dal 31/08): stampa il totale
USDC + USDE scontato del cross, coi numeri che la funzione ha gia'.

Le due modifiche toccano lo stesso blocco (allarmi + STATE write + stampa) e
non si possono separare per hunk senza rischio: un solo commit per il file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 13:38:46 +00:00

389 lines
19 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
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
# (1) `count` massimo di Deribit per get_user_trades_by_instrument: 1000. Il 07/09 il lettore
# chiedeva limit=50 e la sonda del 06/09 aveva fatto 54 ordini: i 4 non letti (400 USDE)
# sono finiti nel «reward» (issue #2). Se il venue restituisce ESATTAMENTE `limit` righe la
# lista puo' essere troncata e la somma NON e' leggibile (P12: non si attribuisce).
# (2) il gateway chiama l'endpoint senza `historical`/timestamp: Deribit restituisce solo le
# ultime 24h — DOCUMENTATO ("Accessing historical trades and orders using API": recent
# trades 24h, orders 30 min, count max 1000) e misurato (il fill 08/09 06:47Z invisibile
# alle 12:55Z del 10/09, quello del 09/09 21:47Z visibile). Se l'ultima lettura e' piu'
# vecchia della finestra, i trade fra la lettura e il bordo sono INVISIBILI e la somma non
# e' leggibile.
# ⚠️ BUCO DICHIARATO (D5): il cron gira ogni 24h + pochi secondi; con la tolleranza di 5 min
# i trade nei primi ≤5 min dopo la lettura precedente sarebbero invisibili e sommati come
# «leggibile». Innocuo: nessun attrezzo converte USDE alle 12:35 UTC, e senza tolleranza il
# giro quotidiano sarebbe SEMPRE «non leggibile» e il tasso non avrebbe mai finestre nuove.
TRADE_LIMIT = 1000
FINESTRA_GATEWAY_MS = 24 * 3_600_000
TOLL_FINESTRA_MS = 5 * 60_000
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 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 nuove_condizioni(att_now: set[str], att_prev: set[str], prev: dict | None) -> set[str]:
"""PURA (issue #29 B2). Le condizioni da notificare in questo giro.
Fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify() non si leggeva: un
invio fallito restava "gia' detto" per l'episodio intero (l'ora dopo att_prev diceva ancora
che la condizione c'era, quindi non era piu' "nuova"). Schema di cuscino_watch.py: una
condizione conta come "gia' detta" solo se era attiva l'ultima volta E il suo invio e'
RIUSCITO — cioe' compare in `allertati` del record precedente. Un record vecchio senza
`allertati` (scritto prima di questo fix) vale come "tutto allertato": nessun rispam
retroattivo delle condizioni gia' viste."""
confermate_prev = set((prev or {}).get("allertati", att_prev))
return att_now - (att_prev & confermate_prev)
def _safe_client():
try:
from src.live.deribit import DeribitRead
return DeribitRead()
except Exception:
return None
def trade_copertura(n_righe: int, prev_ts_ms: int, now_ms: int,
limit: int = TRADE_LIMIT, finestra_ms: int = FINESTRA_GATEWAY_MS,
toll_ms: int = TOLL_FINESTRA_MS) -> str | None:
"""PURA. None se la lista di trade COPRE l'intervallo (prev, now]; altrimenti il motivo per cui
NON lo copre — e allora la somma non si usa (P12: un reward calcolato su una lista incompleta
e' un'invenzione, il 07/09 valeva 400 USDE)."""
if n_righe >= limit:
return f"lista troncata: {n_righe} righe = limite {limit} del venue"
if now_ms - prev_ts_ms > finestra_ms + toll_ms:
ore = (now_ms - prev_ts_ms) / 3_600_000
return (f"ultima lettura {ore:.1f}h fa, oltre la finestra di {finestra_ms / 3_600_000:.0f}h "
f"del gateway: i trade piu' vecchi sono invisibili")
return None
def _trades_spot_da(client, ts_ms: int, now_ms: int | None = None
) -> tuple[float | None, int | None, str | None]:
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms.
-> (somma, n, motivo). somma None = non leggibile, e `motivo` dice perche' (P4)."""
if client is None:
return None, None, "gateway non raggiungibile"
try:
rows = client.trade_history(U.SPOT, limit=TRADE_LIMIT)
except Exception as e:
return None, None, f"trade_history: {type(e).__name__}"
if now_ms is None:
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
motivo = trade_copertura(len(rows), ts_ms, now_ms)
if motivo is not None:
return None, None, motivo
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, None
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, trades_motivo = (None, 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_motivo = _trades_spot_da(
client, int(prev["ts"]), int(now.timestamp() * 1000))
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, trades_motivo=trades_motivo, **an,
)
v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
# issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
# non si leggeva — un invio fallito restava "gia' detto" per l'episodio intero. Schema di
# cuscino_watch.py: `allertati` nel record = le condizioni il cui invio E' RIUSCITO. Lo stato
# si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
att_now, att_prev = condizioni(rec, c), condizioni(prev, c)
nuove = nuove_condizioni(att_now, att_prev, prev)
confermate_prev = set((prev or {}).get("allertati", att_prev))
inviati = []
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
if "DEPEG_CRIT" in att_now:
ok = 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 ok:
allertati.add("DEPEG_CRIT")
if "DEPEG_WARN" in nuove:
ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"})
inviati.append("DEPEG_WARN")
if ok:
allertati.add("DEPEG_WARN")
if "QUOTA_OVER" in nuove:
ok = 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 ok:
allertati.add("QUOTA_OVER")
if "BLIND" in nuove:
ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
inviati.append("BLIND")
if ok:
allertati.add("BLIND")
rec["allertati"] = sorted(allertati)
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": f"decisione di quota dell'operatore (tetto allerta {c['quota_max_frac']:.0%})"})
elif v == "NON_IDONEO":
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
inviati.append(f"VERDETTO->{v}")
rend = rendimento(records + [rec])
STATE.parent.mkdir(parents=True, exist_ok=True)
with STATE.open("a") as fh:
fh.write(json.dumps(rec) + "\n")
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 modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
# dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
# NON e' piu' il Segregated Standard Margin di prima di quella data.
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 : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
f" {c['haircut']:.0%})")
if an["delta"] is not None:
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
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 inviati:
print(f" allarmi : {', '.join(inviati)}")
return 0
USO = """uso: usde_watch.py [--quiet]
usde_watch.py — sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA.
uv run python scripts/live/usde_watch.py # report completo
uv run python scripts/live/usde_watch.py --quiet # stampa/allerta solo transizioni
Dettaglio nel docstring in testa al file."""
if __name__ == "__main__":
from src.live.cli import valida
valida("usde_watch.py", USO, flag=("--quiet",), con_valore=())
sys.exit(main())