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>
This commit is contained in:
2026-09-27 13:38:46 +00:00
parent 5acc31e390
commit 4725839041
2 changed files with 95 additions and 62 deletions
+45 -44
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 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 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 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 ----------------- # --- 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") 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]: def condizioni(rec: dict | None, c: dict) -> set[str]:
"""PURA. Condizioni di allerta attive su un record.""" """PURA. Condizioni di allerta attive su un record."""
if not rec: if not rec:
@@ -188,6 +175,20 @@ def condizioni(rec: dict | None, c: dict) -> set[str]:
return att 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(): def _safe_client():
try: try:
from src.live.deribit import DeribitRead from src.live.deribit import DeribitRead
@@ -284,54 +285,58 @@ def main() -> int:
v, v_motivo = verdetto(records + [rec], now) v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
STATE.parent.mkdir(parents=True, exist_ok=True) # --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit -----------------
with STATE.open("a") as fh: # issue #29 (B2): fino a qui lo stato si salvava PRIMA dell'invio e il ritorno di notify()
fh.write(json.dumps(rec) + "\n") # 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
# --- allarmi: transizioni (warn/quota/blind), ripetizione solo per il crit --- # si scrive una volta sola, a fine giro, con `allertati` gia' dentro (vedi sotto).
att_now, att_prev = condizioni(rec, c), condizioni(prev, c) 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 = [] inviati = []
allertati = (att_now & att_prev & confermate_prev) - {"DEPEG_CRIT"} # attive, gia' confermate
if "DEPEG_CRIT" in att_now: if "DEPEG_CRIT" in att_now:
notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"], ok = notify("🚨 USDE DEPEG", {"indice": px, "soglia": c["depeg_crit"],
"collaterale": f"${usd:,.0f}" if usd else "?", "collaterale": f"${usd:,.0f}" if usd else "?",
"azione": "valutare riconversione — la quota e' il controllo (N4)"}) "azione": "valutare riconversione — la quota e' il controllo (N4)"})
inviati.append("DEPEG_CRIT") inviati.append("DEPEG_CRIT")
if ok:
allertati.add("DEPEG_CRIT")
if "DEPEG_WARN" in nuove: if "DEPEG_WARN" in nuove:
notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"], ok = notify("⚠️ USDE sotto la pari", {"indice": px, "soglia": c["depeg_warn"],
"nota": "entra gia' nel sizing orario del book"}) "nota": "entra gia' nel sizing orario del book"})
inviati.append("DEPEG_WARN") inviati.append("DEPEG_WARN")
if ok:
allertati.add("DEPEG_WARN")
if "QUOTA_OVER" in nuove: if "QUOTA_OVER" in nuove:
notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?", ok = notify("⚠️ quota USDE sopra il tetto", {"quota": f"{quota:.1%}" if quota else "?",
"tetto": f"{c['quota_max_frac']:.0%}", "tetto": f"{c['quota_max_frac']:.0%}",
"nota": "anche una deriva passiva conta (il libro perde -> quota sale)"}) "nota": "anche una deriva passiva conta (il libro perde -> quota sale)"})
inviati.append("QUOTA_OVER") inviati.append("QUOTA_OVER")
if ok:
allertati.add("QUOTA_OVER")
if "BLIND" in nuove: if "BLIND" in nuove:
notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?", ok = notify("⚠️ usde_watch BLIND", {"motivo": motivo_blind or "?",
"nota": "'non vedo' non e' 'va tutto bene' (P5)"}) "nota": "'non vedo' non e' 'va tutto bene' (P5)"})
inviati.append("BLIND") inviati.append("BLIND")
if ok:
allertati.add("BLIND")
rec["allertati"] = sorted(allertati)
v_prev = prev.get("verdetto") if prev else "IN_ATTESA" v_prev = prev.get("verdetto") if prev else "IN_ATTESA"
if v != v_prev: if v != v_prev:
if v == "IDONEO": if v == "IDONEO":
notify("📌 USDE: conto IDONEO ai reward", {"dettaglio": v_motivo, 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": elif v == "NON_IDONEO":
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo}) notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
inviati.append(f"VERDETTO->{v}") 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]) 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" STATE.parent.mkdir(parents=True, exist_ok=True)
notify("📌 USDE: la decisione di QUOTA e' dovuta", { with STATE.open("a") as fh:
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d", fh.write(json.dumps(rec) + "\n")
"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 cambiato = bool(inviati) or v != v_prev
if not quiet or cambiato: 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'}" 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") f" ({px_fonte}) -> ${usd:,.2f}" if usd is not None else " equity USDE : n/d")
if quota is not None: if quota is not None:
# Il conto e' Segregated Standard Margin (letto sulla pagina margini il 31/08): # Il modello di margine ATTIVO e' Cross X:SM dal 2026-08-31 (decisione
# l'USDE NON fa margine per i perp USDC-settled, e l'haircut non si applica. Il # dell'operatore, config._nota_usde): l'USDE fa margine, scontato dell'haircut —
# margine del book e' il solo silo USDC. Si stampa anche cosa DAREBBE il cross, # NON e' piu' il Segregated Standard Margin di prima di quella data.
# perche' e' la differenza che rende la cosa una decisione invece che un dettaglio.
cross = eq_usdc + usd * (1.0 - c["haircut"]) cross = eq_usdc + usd * (1.0 - c["haircut"])
print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})") 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" print(f" margine : ~${cross:,.0f} USDC + USDE scontato (cross X:SM, haircut"
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})") f" {c['haircut']:.0%})")
if an["delta"] is not None: if an["delta"] is not None:
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})" attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
@@ -366,8 +369,6 @@ def main() -> int:
f" (tot {rend['tot']:+.6f} USDE)") f" (tot {rend['tot']:+.6f} USDE)")
if rend["finestre"] < 4: if rend["finestre"] < 4:
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso") 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: if inviati:
print(f" allarmi : {', '.join(inviati)}") print(f" allarmi : {', '.join(inviati)}")
return 0 return 0
+44 -12
View File
@@ -193,18 +193,12 @@ def test_nessuna_finestra_misurabile_non_e_zero():
assert r["apr"] is None and r["finestre"] == 0 assert r["apr"] is None and r["finestre"] == 0
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO(): def test_quota_da_decidere_e_stata_rimossa():
from datetime import datetime, timezone """#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
def q(giorno, v): giorno finche' non la si toglie a mano."""
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v) assert not hasattr(W, "quota_da_decidere")
assert not hasattr(W, "DECISIONE_QUOTA_DAL")
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
# ------------------------------ lettura dei trade: i due limiti del canale (issue #2) ------------------------------ # ------------------------------ 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}] 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) 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 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"}