merge lavoro/goldrake-u3 (revisione 27/09)

This commit is contained in:
2026-09-27 13:53:10 +00:00
9 changed files with 572 additions and 65 deletions
+11 -3
View File
@@ -245,12 +245,20 @@ def main() -> int:
else:
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
if r["changes"] or r["level"] == "AZIONE":
notify("💸 FEE WATCH — schema fee Deribit", {
# issue #29 (B8): save_state veniva chiamato SEMPRE, anche se l'invio necessario falliva —
# il giro dopo `diff_vs` non avrebbe piu' visto il cambiamento (lo stato salvato era gia'
# quello nuovo) e il cambio andava perso per sempre invece che ritentare al giro dopo.
serve_invio = bool(r["changes"] or r["level"] == "AZIONE")
inviato = True
if serve_invio:
inviato = notify("💸 FEE WATCH — schema fee Deribit", {
"verdetto": f"[{r['level']}] {r['reason']}",
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
})
save_state(r["current"])
if not inviato and (not quiet or interessante):
print(" ⚠️ invio FALLITO — stato NON salvato: il prossimo giro ritenta")
if not serve_invio or inviato:
save_state(r["current"])
registra_giro(RUNS, level=r["level"])
return 2 if r["level"] == "AZIONE" else 0
+68
View File
@@ -93,6 +93,59 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cuscino_richiesto_usd = U.cuscino_richiesto_usd
# issue #26: label dei NOSTRI ordini sullo spot, per riconoscere i residui che questo script
# stesso ha lasciato aperti (mai altri: un ordine con label diversa non e' nostro e non si tocca).
LABEL_PREFIX = "usde-quota-"
def _cancella(c, order_id: str) -> tuple[bool, str]:
"""Cancella UN ordine. -> (ok, motivo se no). NON solleva: il chiamante decide (P3) — la
lettura degli ordini aperti solleva (P5), la cancellazione di un singolo ordine no, perche'
il chiamante deve poter fermarsi con un messaggio invece di un traceback."""
try:
resp = c._unwrap(c._post("/mcp-deribit/tools/cancel_order", {"order_id": order_id})) or {}
except Exception as e: # noqa: BLE001 — si registra (P3)
return False, f"{type(e).__name__}: {e}"
if not isinstance(resp, dict) or resp.get("error") or resp.get("state") == "error":
return False, str(resp.get("error", resp)) if isinstance(resp, dict) else str(resp)
return True, ""
def cancella_residui(c) -> list[str]:
"""issue #26 — GTC mai cancellati. Il gateway cerbero-mcp NON accetta `time_in_force`
(PlaceOrderReq non ha il campo, extra ignorati: gitea:Adriano/Cerbero-mcp#1): un limit
riempito solo in parte resta APERTO per sempre e si accumula a ogni giro invece di sparire
con l'ordine. Qui si legge lo spot U.SPOT e si cancella SOLO cio' che porta la NOSTRA label
(`usde-quota-*`): un ordine con altra label non e' nostro e non si tocca.
-> lista degli order_id cancellati. Se la LETTURA degli ordini aperti fallisce SOLLEVA: non
si piazza un nuovo ordine senza sapere cosa c'e' gia' aperto (P5, non si ordina al buio)."""
# non e' verificato sotto quale valuta Deribit elenchi lo spot USDE_USDC (quotazione USDC o
# base USDE): si interrogano entrambe e si unisce per order_id, cosi' non si perde il residuo
# se il venue lo elenca sotto la base.
visti = {}
for valuta in ("USDC", "USDE"):
out = c._unwrap(c._post("/mcp-deribit/tools/get_open_orders",
{"currency": valuta, "kind": "spot", "type": "all"}))
ordini = out if isinstance(out, list) else (out.get("orders", []) if isinstance(out, dict) else [])
for o in ordini:
if isinstance(o, dict) and o.get("order_id") is not None:
visti[o["order_id"]] = o
ordini = list(visti.values())
cancellati = []
for o in ordini:
if not isinstance(o, dict):
continue
instrument = o.get("instrument") or o.get("instrument_name")
label = o.get("label") or ""
if instrument == U.SPOT and label.startswith(LABEL_PREFIX):
oid = o.get("order_id")
ok, motivo = _cancella(c, oid)
if not ok:
raise RuntimeError(f"cancellazione del residuo {oid} ({label}) fallita: {motivo}")
cancellati.append(oid)
return cancellati
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
@@ -232,6 +285,9 @@ def main() -> int:
return 0
LOG.parent.mkdir(parents=True, exist_ok=True)
residui = cancella_residui(c)
print(f" residui cancellati all'avvio: {len(residui)}"
+ (f" ({', '.join(residui)})" if residui else ""))
chunk, fatti, tot_filled, prezzi, falliti = a.chunk, [], 0.0, [], 0
for _ in range(60):
eq_usdc, eq_usde, px, _f = leggi_stato(c)
@@ -274,6 +330,18 @@ def main() -> int:
"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%})")
# issue #26: un GTC riempito solo in parte (o per niente) resta APERTO — il gateway non
# accetta time_in_force, quindi la cancellazione e' tutta qui. Se la cancel fallisce si
# FERMA il ciclo: mai un secondo ordine mentre il residuo del primo e' ancora vivo.
if filled < amt:
oid = order.get("order_id")
ok, motivo = _cancella(c, oid)
fatti[-1]["cancellato"] = True if ok else f"errore: {motivo}"
if not ok:
print(f" ✗ cancellazione del residuo {oid} FALLITA: {motivo}"
" — mi fermo (mai un secondo ordine con un residuo vivo)")
break
print(f" · residuo cancellato: order_id {oid} (filled {filled:,.0f}/{amt:,.0f})")
if filled <= 0:
print(" ✗ ordine accettato ma non fillato — mi fermo"); break
time.sleep(a.pausa)
+51 -50
View File
@@ -58,12 +58,6 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
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
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
@@ -162,13 +156,6 @@ def rendimento(records: list[dict]) -> dict:
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:
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
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
@@ -284,54 +285,58 @@ def main() -> int:
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 ---
# --- 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 = att_now - att_prev
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:
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)"})
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:
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"})
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:
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)"})
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:
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
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": "decisione di quota dell'operatore (tetto allerta 50%)"})
"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}")
# 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")
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:
@@ -344,14 +349,12 @@ def main() -> int:
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.
# 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 : ~${eq_usdc:,.0f} — il solo USDC (conto SEGREGATO: l'USDE non"
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
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})"
@@ -366,8 +369,6 @@ def main() -> int:
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
+2
View File
@@ -31,6 +31,8 @@ def _titolo(rep: dict) -> str:
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
if rep.get("severity") == "blind":
return "⚠️ venue_watch CIECO — referenze non leggibili, nessun giudizio sul venue"
if rep.get("severity") == "warn":
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"