4725839041
#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>
301 lines
13 KiB
Python
301 lines
13 KiB
Python
"""Il collaterale USDE ha tre sorveglianze (reward/depeg/quota) e una valutazione condivisa.
|
|
|
|
Blindano: (a) le proprieta' della valutazione in src/live/usde.py (le stesse gia' provate
|
|
end-to-end in test_shadow_usde.py, qui sulla funzione PURA che shadow e usde_watch condividono);
|
|
(b) il rilevatore di reward per delta — che NON inventa attribuzioni quando i trade non sono
|
|
leggibili (P12); (c) il verdetto di eligibilita', che applica la regola pre-registrata il 26/08
|
|
PRIMA dell'esito; (d) le condizioni di allerta con le soglie DERIVATE dalla config (P1)."""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
sys.path.insert(0, str(ROOT / "scripts" / "live"))
|
|
|
|
import usde_watch as W # noqa: E402
|
|
from src.live import usde as U # noqa: E402
|
|
|
|
CFG = {"haircut": 0.10, "quota_max_frac": 0.50, "depeg_warn": 0.99, "depeg_crit": 0.95}
|
|
|
|
|
|
# ------------------------------ valutazione (pura, condivisa) ------------------------------
|
|
|
|
def test_valuta_depeg_passa_nel_sizing():
|
|
usd, nota = U.valuta(500.0, 0.97)
|
|
assert abs(usd - 485.0) < 1e-9 and "0.9700" in nota
|
|
|
|
|
|
def test_valuta_sopra_la_pari_clampa():
|
|
usd, _ = U.valuta(500.0, 1.02)
|
|
assert abs(usd - 500.0) < 1e-9
|
|
|
|
|
|
def test_valuta_prezzo_illeggibile_e_1_dichiarato_mai_0():
|
|
usd, nota = U.valuta(500.0, None)
|
|
assert abs(usd - 500.0) < 1e-9 and "non leggibile" in nota
|
|
|
|
|
|
def test_cfg_viene_dalla_config_di_produzione():
|
|
c = U.cfg()
|
|
assert c["fonte"] == "config/live.json" # la sezione esiste davvero in config
|
|
assert 0 < c["depeg_crit"] < c["depeg_warn"] < 1.0
|
|
assert 0 < c["quota_max_frac"] <= 1.0
|
|
|
|
|
|
# ------------------------------ rilevatore di reward ------------------------------
|
|
|
|
def test_baseline_senza_prev_non_attribuisce_nulla():
|
|
an = W.analizza(None, 500.0, 0.0)
|
|
assert an["delta"] is None and not an["reward_rilevato"]
|
|
|
|
|
|
def test_reward_rilevato_dal_delta_senza_trade():
|
|
an = W.analizza({"eq_usde": 500.0}, 500.0548, 0.0)
|
|
assert an["reward_rilevato"] and abs(an["reward_stimato"] - 0.0548) < 1e-9
|
|
assert an["con_trade"] is False
|
|
|
|
|
|
def test_delta_con_trade_nel_mezzo_si_netta_e_si_dichiara():
|
|
# comprati altri 100 USDE + reward 0.05: il reward emerge al netto, ma con_trade lo dice
|
|
an = W.analizza({"eq_usde": 500.0}, 600.05, 100.0)
|
|
assert an["reward_rilevato"] and abs(an["reward_stimato"] - 0.05) < 1e-9
|
|
assert an["con_trade"] is True
|
|
|
|
|
|
def test_trade_illeggibili_il_delta_NON_si_attribuisce():
|
|
# P12: meglio un giorno di latenza che un reward inventato
|
|
an = W.analizza({"eq_usde": 500.0}, 500.0548, None)
|
|
assert an["delta"] is not None and an["reward_stimato"] is None
|
|
assert not an["reward_rilevato"]
|
|
|
|
|
|
def test_rumore_di_lettura_sotto_eps_non_e_un_reward():
|
|
an = W.analizza({"eq_usde": 500.0}, 500.0000001, 0.0)
|
|
assert not an["reward_rilevato"]
|
|
|
|
|
|
# ------------------------------ verdetto (regola pre-registrata 26/08) ------------------------------
|
|
|
|
def _rec(**kw):
|
|
base = dict(reward_rilevato=False, data="2026-08-27T12:35:00Z")
|
|
base.update(kw)
|
|
return base
|
|
|
|
|
|
def test_verdetto_in_attesa_prima_della_scadenza():
|
|
now = datetime(2026, 8, 28, 12, 35, tzinfo=timezone.utc)
|
|
v, _ = W.verdetto([_rec()], now)
|
|
assert v == "IN_ATTESA"
|
|
|
|
|
|
def test_verdetto_idoneo_al_primo_reward():
|
|
now = datetime(2026, 8, 28, 12, 35, tzinfo=timezone.utc)
|
|
v, motivo = W.verdetto([_rec(), _rec(reward_rilevato=True, reward_stimato=0.0548,
|
|
data="2026-08-28T12:35:00Z")], now)
|
|
assert v == "IDONEO" and "2026-08-28" in motivo
|
|
|
|
|
|
def test_verdetto_non_idoneo_solo_DOPO_la_finestra_del_29():
|
|
prima = datetime(2026, 8, 29, 12, 0, tzinfo=timezone.utc)
|
|
dopo = datetime(2026, 8, 29, 12, 35, tzinfo=timezone.utc)
|
|
assert W.verdetto([_rec()], prima)[0] == "IN_ATTESA" # anticiparlo = selezione sull'esito
|
|
assert W.verdetto([_rec()], dopo)[0] == "NON_IDONEO"
|
|
|
|
|
|
def test_un_reward_gia_visto_resta_idoneo_anche_dopo_la_scadenza():
|
|
dopo = datetime(2026, 9, 15, 0, 0, tzinfo=timezone.utc)
|
|
v, _ = W.verdetto([_rec(reward_rilevato=True, reward_stimato=0.05)], dopo)
|
|
assert v == "IDONEO"
|
|
|
|
|
|
# ------------------------------ condizioni di allerta ------------------------------
|
|
|
|
def test_depeg_warn_e_crit_dalle_soglie_di_config():
|
|
assert W.condizioni({"stato": "OK", "px": 0.985, "quota": 0.2}, CFG) == {"DEPEG_WARN"}
|
|
assert W.condizioni({"stato": "OK", "px": 0.94, "quota": 0.2}, CFG) == {"DEPEG_CRIT"}
|
|
assert W.condizioni({"stato": "OK", "px": 0.9999, "quota": 0.2}, CFG) == set()
|
|
|
|
|
|
def test_quota_sopra_il_tetto_allerta_anche_per_deriva_passiva():
|
|
assert W.condizioni({"stato": "OK", "px": 1.0, "quota": 0.51}, CFG) == {"QUOTA_OVER"}
|
|
|
|
|
|
def test_blind_e_una_condizione_non_un_va_tutto_bene():
|
|
assert W.condizioni({"stato": "BLIND", "px": None, "quota": None}, CFG) == {"BLIND"}
|
|
|
|
|
|
def test_px_none_non_finge_un_depeg():
|
|
# indice illeggibile: la valutazione degrada a 1.0 dichiarato, l'allerta depeg NON scatta
|
|
assert W.condizioni({"stato": "OK", "px": None, "quota": 0.2}, CFG) == set()
|
|
|
|
|
|
# ------------------------------ persistenza jsonl ------------------------------
|
|
|
|
def test_leggi_jsonl_roundtrip(tmp_path):
|
|
import json
|
|
p = tmp_path / "usde_watch.jsonl"
|
|
p.write_text(json.dumps({"ts": 1, "eq_usde": 500.0}) + "\n"
|
|
+ json.dumps({"ts": 2, "eq_usde": 500.05}) + "\n")
|
|
rows = W.leggi(p)
|
|
assert len(rows) == 2 and rows[-1]["eq_usde"] == 500.05
|
|
|
|
|
|
# =============================================================================================
|
|
# IL RENDIMENTO OSSERVATO, E LA DECISIONE DI QUOTA RINVIATA
|
|
# =============================================================================================
|
|
# Il 2026-08-29 il gate USDE-01 si e' chiuso IDONEO (primo reward il 28/08, +0.052055 USDE) e la
|
|
# decisione di QUOTA e' stata rinviata dall'operatore a lunedi' 31/08: su UNA finestra il tasso
|
|
# implicito e' 3,80% annuo, su DUE e' 1,90% — cioe' tutta la differenza che conta.
|
|
|
|
def _lettura(ts_ms, eq, reward=None, con_trade=False):
|
|
return dict(ts=ts_ms, eq_usde=eq, reward_stimato=reward,
|
|
reward_rilevato=bool(reward and reward > 1e-3), con_trade=con_trade)
|
|
|
|
|
|
G = 86_400_000
|
|
|
|
|
|
def test_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.
|
|
Il denominatore e' il TEMPO, non il numero di finestre pagate."""
|
|
recs = [_lettura(0, 500.0), _lettura(G, 500.0, 0.0), _lettura(2 * G, 500.052055, 0.052055)]
|
|
r = W.rendimento(recs)
|
|
assert r["finestre"] == 2 and r["pagate"] == 1
|
|
# 0.052055 su 500 in 2 giorni -> ~1.90%, non il 3.80% della sola finestra che ha pagato
|
|
assert 0.018 < r["apr"] < 0.021, r
|
|
|
|
|
|
def test_la_finestra_sola_darebbe_il_doppio():
|
|
"""Il controllo positivo: se si guardasse solo il pagamento, il numero raddoppierebbe.
|
|
E' la ragione per cui l'APR si stampa sempre con il suo n."""
|
|
r = W.rendimento([_lettura(G, 500.0), _lettura(2 * G, 500.052055, 0.052055)])
|
|
assert 0.037 < r["apr"] < 0.040, r
|
|
|
|
|
|
def test_le_letture_con_trade_nel_mezzo_si_escludono():
|
|
"""P12: quel delta non e' solo reward, e ripararlo in silenzio sarebbe un'invenzione."""
|
|
recs = [_lettura(0, 500.0), _lettura(G, 600.0, 100.0, con_trade=True), _lettura(2 * G, 600.05, 0.05)]
|
|
r = W.rendimento(recs)
|
|
assert r["finestre"] == 1 and r["tot"] == 0.05
|
|
|
|
|
|
def test_troppo_poco_tempo_non_si_annualizza():
|
|
"""Un'ora di osservazione moltiplicata per 365 e' un numero, non una misura."""
|
|
r = W.rendimento([_lettura(0, 500.0), _lettura(3_600_000, 500.05, 0.05)])
|
|
assert r["apr"] is None and "non si annualizza" in r["nota"]
|
|
|
|
|
|
def test_nessuna_finestra_misurabile_non_e_zero():
|
|
r = W.rendimento([_lettura(0, 500.0)])
|
|
assert r["apr"] is None and r["finestre"] == 0
|
|
|
|
|
|
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) ------------------------------
|
|
# Il 07/09 il lettore chiedeva limit=50, la sonda del 06/09 aveva fatto 54 ordini: 400 USDE di
|
|
# acquisti sono finiti nel «reward». Il lettore ora chiede il massimo del venue (1000) e, se la
|
|
# lista puo' essere incompleta — troncata al limite, o ultima lettura oltre la finestra di 24h
|
|
# che il gateway espone — dichiara «non leggibile» invece di sommare (P12).
|
|
|
|
class _ClientFinto:
|
|
def __init__(self, rows):
|
|
self.rows, self.limit_chiesto = rows, None
|
|
|
|
def trade_history(self, instrument, limit=20):
|
|
self.limit_chiesto = limit
|
|
return self.rows[:limit]
|
|
|
|
|
|
def _ordini(n, t0_ms, amt=100.0, passo_ms=15_000):
|
|
return [{"timestamp": t0_ms + i * passo_ms, "direction": "buy", "amount": amt} for i in range(n)]
|
|
|
|
|
|
def test_controllo_positivo_54_ordini_si_sommano_tutti():
|
|
"""Il caso del 06-07/09: 54 ordini in 24h. Con il vecchio limit=50 ne mancavano 4."""
|
|
prev, now = 1_788_698_101_000, 1_788_784_501_000 # 06/09 12:35Z -> 07/09 12:35Z
|
|
c = _ClientFinto(_ordini(54, prev + 30_000_000))
|
|
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
|
assert motivo is None and n == 54 and abs(tot - 5400.0) < 1e-9
|
|
assert c.limit_chiesto == W.TRADE_LIMIT >= 54
|
|
|
|
|
|
def test_lista_al_limite_del_venue_non_e_leggibile():
|
|
prev, now = 1_788_698_101_000, 1_788_784_501_000
|
|
c = _ClientFinto(_ordini(W.TRADE_LIMIT + 5, prev + 1000))
|
|
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
|
assert tot is None and n is None and "troncata" in motivo
|
|
# e il reward NON si attribuisce (P12)
|
|
an = W.analizza({"eq_usde": 1000.0}, 1400.0, tot)
|
|
assert an["reward_stimato"] is None and not an["reward_rilevato"]
|
|
|
|
|
|
def test_lettura_oltre_la_finestra_del_gateway_non_e_leggibile():
|
|
"""Giro saltato: 48h dall'ultima lettura. I trade fra 48h e 24h fa sono invisibili."""
|
|
prev = 1_788_698_101_000
|
|
now = prev + 48 * 3_600_000
|
|
c = _ClientFinto(_ordini(3, now - 3_600_000))
|
|
tot, n, motivo = W._trades_spot_da(c, prev, now)
|
|
assert tot is None and "finestra" in motivo
|
|
|
|
|
|
def test_la_cadenza_del_cron_sta_dentro_la_tolleranza():
|
|
"""24h + qualche secondo (il cron delle 12:35 di due giorni consecutivi) e' coperta."""
|
|
prev = 1_788_698_101_000
|
|
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + 2_000) is None
|
|
assert W.trade_copertura(10, prev, prev + 24 * 3_600_000 + W.TOLL_FINESTRA_MS + 1) is not None
|
|
|
|
|
|
def test_i_trade_prima_della_lettura_precedente_non_contano():
|
|
prev, now = 1_788_698_101_000, 1_788_784_501_000
|
|
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"}
|