From 4725839041eb4c9f00b4c72cae500cc2b1d4446e Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:38:46 +0000 Subject: [PATCH] usde_watch: notify affidabile (allertati) e quota derivata, non piu' rinviata (#29 B2, #35 B3/B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- scripts/live/usde_watch.py | 101 +++++++++++++++++++------------------ tests/test_usde_watch.py | 56 +++++++++++++++----- 2 files changed, 95 insertions(+), 62 deletions(-) diff --git a/scripts/live/usde_watch.py b/scripts/live/usde_watch.py index f689c91..8abb017 100755 --- a/scripts/live/usde_watch.py +++ b/scripts/live/usde_watch.py @@ -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 diff --git a/tests/test_usde_watch.py b/tests/test_usde_watch.py index 5aa8be9..c77ab50 100644 --- a/tests/test_usde_watch.py +++ b/tests/test_usde_watch.py @@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero(): assert r["apr"] is None and r["finestre"] == 0 -def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO(): - from datetime import datetime, timezone - - def q(giorno, v): - return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v) - - assert W.DECISIONE_QUOTA_DAL == "2026-08-31" - assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato" - assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta" - assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica" - assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo" - assert q("2026-08-31", "NON_IDONEO") is False +def test_quota_da_decidere_e_stata_rimossa(): + """#35 (B3/B4): dal 10/09 (issue #7) la quota e' DERIVATA dal cuscino USDC + (usde._nota_cuscino), non e' piu' una decisione dell'operatore rinviata da ricordare ogni + giorno finche' non la si toglie a mano.""" + assert not hasattr(W, "quota_da_decidere") + assert not hasattr(W, "DECISIONE_QUOTA_DAL") # ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------ @@ -266,3 +260,41 @@ def test_i_trade_prima_della_lettura_precedente_non_contano(): rows = _ordini(2, prev - 60_000) + [{"timestamp": prev + 1, "direction": "sell", "amount": 40.0}] tot, n, motivo = W._trades_spot_da(_ClientFinto(rows), prev, now) assert motivo is None and n == 1 and abs(tot + 40.0) < 1e-9 + + +# ============================================================================================= +# issue #29 (B2) — un invio fallito non deve consumare la transizione (schema di cuscino_watch) +# ============================================================================================= + +def test_notify_fallito_la_condizione_si_ripresenta_come_nuova(): + """Il difetto: lo stato si salvava PRIMA dell'invio e il False di notify() non si leggeva -> + un invio fallito restava 'gia' detto' per l'episodio intero. Qui: la condizione era attiva + l'ultima volta ma NON e' fra le confermate (`allertati`) -> deve tornare 'nuova'.""" + att = {"QUOTA_OVER"} + prev = dict(allertati=[]) # QUOTA_OVER era attivo, l'invio era FALLITO + assert W.nuove_condizioni(att, att, prev) == {"QUOTA_OVER"} + + +def test_notify_riuscito_non_si_ripete(): + att = {"QUOTA_OVER"} + prev = dict(allertati=["QUOTA_OVER"]) # invio RIUSCITO l'ultima volta + assert W.nuove_condizioni(att, att, prev) == set() + + +def test_record_vecchio_senza_allertati_vale_come_tutto_allertato(): + """Compatibilita': un record scritto prima di questo fix non ha il campo `allertati`. Non + deve produrre un rispam retroattivo delle condizioni gia' viste.""" + att = {"QUOTA_OVER", "BLIND"} + prev = dict(stato="OK") # nessun campo `allertati` + assert W.nuove_condizioni(att, att, prev) == set() + + +def test_senza_record_precedente_tutto_e_nuovo(): + att = {"QUOTA_OVER", "DEPEG_WARN"} + assert W.nuove_condizioni(att, set(), None) == att + + +def test_una_condizione_appena_comparsa_e_sempre_nuova(): + """Anche con `allertati` presente, una condizione che non era in att_prev e' nuova per + definizione — l'insieme confermato non la riguarda.""" + assert W.nuove_condizioni({"BLIND"}, set(), dict(allertati=["QUOTA_OVER"])) == {"BLIND"}