diff --git a/scripts/live/cuscino_watch.py b/scripts/live/cuscino_watch.py index bfe7134..dc7c765 100755 --- a/scripts/live/cuscino_watch.py +++ b/scripts/live/cuscino_watch.py @@ -80,7 +80,9 @@ CONVERT = ROOT / "scripts" / "live" / "usde_convert.py" # dell'ordine imposto (revisione fable 10/09: a margine == preavviso ogni 🚨 era seguito da un ⚠️). RIPRISTINO_MIN_ORE = 6.0 # un tentativo di conversione (in qualunque verso) ogni 6 ore RITENTATIVO_FALLITO_ORE = 24.0 # da ECCEDENTE, dopo un fallimento: un tentativo al giorno -TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min +TIMEOUT_CONVERT_S = 900 # usde_convert: 12s di pausa fra ordini + le GET per giro (~14s/giro + # coi 12s di pausa); 60 ordini ~= 840s: il tetto e' stretto sul caso + # massimo, non largo come "60 ordini = 12 min" lasciava credere (#35 B10) def leggi(path: Path = STATE) -> list[dict]: @@ -158,17 +160,56 @@ def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: b return True, "" +def _leggi_quota_usde() -> float | None: + """Quota USDE sul totale, STESSA formula di usde_watch.py:275 (usd/(usd+eq_usdc)), stessi + mattoni di main() qui sotto (_safe_client, account_summary, U.prezzo, U.valuta): nessuna + lettura nuova. None se il conto non e' leggibile — chi chiama tratta None come "non lo so", + non come zero (#35 B10).""" + client = _safe_client() + if client is None: + return None + try: + eq_usdc = float(client.account_summary("USDC")["equity"]) + eq_usde = float(client.account_summary("USDE").get("equity") or 0) + except Exception: + return None + px, _ = U.prezzo(client) + usd, _ = U.valuta(eq_usde, px) + try: + return round(usd / (usd + eq_usdc), 4) + except ZeroDivisionError: + return None + + def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict: - """Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3).""" + """Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3). + + Rilegge la quota USDE prima e dopo il subprocess, ANCHE su timeout o eccezione: un rc!=0 con + la quota mossa e' una conversione PARZIALE (fill gia' avvenuti prima del kill a timeout_s), + non "FALLITA" come diceva `ok = rc==0` (#35 B10). `esito` in "ok" | "parziale" | "fallita"* — + "ok" solo per compatibilita' resta anche in `ok` (bool).""" + quota_prima = _leggi_quota_usde() cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT)) + rc = r.returncode coda = (r.stdout + r.stderr).strip().splitlines()[-12:] - return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:])) except subprocess.TimeoutExpired: - return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:])) + rc, coda = None, [f"timeout dopo {timeout_s:.0f}s"] except Exception as e: # noqa: BLE001 — si registra, non si ingoia - return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:])) + rc, coda = None, [f"{type(e).__name__}: {e}"] + quota_dopo = _leggi_quota_usde() + + if rc == 0: + esito = "ok" + elif quota_dopo is None: + esito = "fallita (quota dopo non leggibile)" + else: + mossa = quota_prima is not None and abs(quota_dopo - quota_prima) > 0.005 + esito = "parziale" if mossa else "fallita" + + return dict(rc=rc, ok=esito == "ok", esito=esito, quota_prima=quota_prima, quota_dopo=quota_dopo, + coda=coda, cmd=" ".join(cmd[1:])) def _safe_client(): @@ -239,17 +280,23 @@ def main() -> int: rec["riconversione"] = dict(tentata=False, verso=AZIONE[stato], quota=quota, motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui ({AZIONE[stato]})") else: - esito = riconverti(quota) - rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **esito) + ris = riconverti(quota) + rec["riconversione"] = dict(tentata=True, verso=AZIONE[stato], quota=quota, **ris) # --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio --- # ⚠️ Telegram e' in parse_mode HTML: un '<' nudo nel testo fa fallire l'invio (successo al # primo giro vero, 13:53Z del 10/09: «(< 10% del cuscino)»). Qui non si scrive mai '<'. def _esito_azione(r: dict) -> str: - if r.get("tentata"): - return (f"{r['verso']} " + ("ESEGUITA" if r.get("ok") else "FALLITA") - + f" (quota bersaglio {r['quota']:.0%})") - return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}" + if not r.get("tentata"): + return f"{r.get('verso', 'conversione')} NON tentata: {r.get('motivo')}" + esito = r.get("esito", "ok" if r.get("ok") else "fallita") + if esito == "parziale": + qp, qd = r.get("quota_prima"), r.get("quota_dopo") + qp_s = f"{qp:.0%}" if qp is not None else "n/d" + qd_s = f"{qd:.0%}" if qd is not None else "n/d" + return f"{r['verso']} PARZIALE: quota da {qp_s} a {qd_s}" + return (f"{r['verso']} " + ("ESEGUITA" if esito == "ok" else "FALLITA") + + f" (quota bersaglio {r['quota']:.0%})") if transizione(prev, stato): if secco: @@ -275,7 +322,9 @@ def main() -> int: elif rec["riconversione"] and rec["riconversione"].get("tentata") and not secco: # non e' una transizione ma e' un ordine mandato: si dice sempre r = rec["riconversione"] - rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} " + ("eseguita" if r["ok"] else "FALLITA"), + esito = r.get("esito", "ok" if r.get("ok") else "fallita") + verbo = {"ok": "eseguita", "parziale": "PARZIALE"}.get(esito, "FALLITA") + rec["allertato"] = notify(f"📌 cuscino USDC: {r['verso']} {verbo}", {"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])}) if rec["allertato"] is False and not secco: from src.live.notifier import ultimo_errore diff --git a/scripts/live/edge_watch.py b/scripts/live/edge_watch.py index c44bd19..8a82881 100644 --- a/scripts/live/edge_watch.py +++ b/scripts/live/edge_watch.py @@ -140,7 +140,7 @@ def main() -> int: print(f"\n {n_ok}/{len(r['protection'])} anni di sinistro superati · " f"fallimenti consecutivi in corso: {r['consec_fail']}") if r["alerts"]: - notify("🚨 EDGE WATCH — criterio di kill scattato sul book LIVE", + notify("🚨 EDGE WATCH — criterio di kill scattato sul book (serie backtest dei sleeve, non il P&L eseguito)", {f"alert {i+1}": a for i, a in enumerate(r["alerts"])}) for a in r["alerts"]: print(f" ALERT: {a}") diff --git a/src/live/book.py b/src/live/book.py index 69b742e..edbafbf 100644 --- a/src/live/book.py +++ b/src/live/book.py @@ -4,8 +4,8 @@ TP01 e SKH01 tradano lo STESSO strumento (BTC/ETH _USDC-PERPETUAL). Su Deribit e posizione netta per strumento per conto -> non si possono tenere due gambe separate: si combinano in software in un unico target netto, e si manda UN ordine per asset per raggiungerlo. -Formula (preserva il budget di rischio $300/asset, split 75/25; coerente col blend di ritorni -deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): +Formula (preserva il budget di rischio per-asset di config (CAP sotto: `max_notional_per_asset_usd` +o `_frac`), split 75/25; coerente col blend di ritorni deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): net_target_usd[asset] = clamp( WEIGHT * E * (W_TP01 * tp_frac + W_SKH * skh_sign), ±CAP ) @@ -13,8 +13,8 @@ deribit_book_sleeves = 0.75*TP01 + 0.25*SKH01): tp_frac = target TP01 (causale, >=0, long-flat) da TrendPortfolio.current_target skh_sign = +1 long / -1 short / 0 flat da _skyhook_positions (book 230m) E = equity reale del conto (fallback paper se offline) - CAP = cap notional per-asset. Fisso = max_notional_per_asset_usd ($300), OPPURE - DINAMICO = equity * max_notional_per_asset_frac (es. 0.5 -> equity/2) se la + CAP = cap notional per-asset. Fisso = `max_notional_per_asset_usd` di config, OPPURE + DINAMICO = equity * `max_notional_per_asset_frac` (es. 0.5 -> equity/2) se la frazione e' presente in config -> il cap cresce col capitale e non strozza il book quando depositi (frontiera 2026-07-03). Dinamico SOLO con equity reale fidata; su fallback/offline si torna al cap fisso (protezione downside). diff --git a/src/live/deribit.py b/src/live/deribit.py index 437427a..43c56f4 100644 --- a/src/live/deribit.py +++ b/src/live/deribit.py @@ -1,13 +1,16 @@ -"""Accesso Deribit MAINNET in SOLA LETTURA (via Cerbero MCP) + costruttore ordini deterministico. +"""Accesso Deribit MAINNET (via Cerbero MCP) + costruttore ordini deterministico. La classe +`DeribitRead` qui e' in SOLA LETTURA (nessun metodo di trading, by design); l'esecuzione REALE la +estende in un modulo separato (`src.live.execution.DeribitTrader`) — non e' piu' un progetto +futuro (#35 A9): e' ARMATA e LIVE su Deribit mainnet dal 2026-06-23 (`execution_enabled` in +config/live.json), usata da `scripts/live/book_execute.py`. -Serve lo SHADOW MODE di TP01 (`scripts/live/live_trend.py`): legge prezzi / conto / posizioni REALI -dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON -esiste alcun metodo di trading — by design: l'unica via per piazzare ordini sara' un modulo separato, -abilitato esplicitamente, dopo la validazione shadow + micro-test. +Da qui vengono prezzi / conto / posizioni REALI (token `.env.mainnet`): li usa sia il libro live +(TP01+SKH01, via src.live.book + execution) sia lo shadow mode e i monitor di sorveglianza. -Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale, -e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount` -in USD notional, step verificato su Deribit: BTC $10, ETH $1). +Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet +reale. I contratti ESEGUITI sono i perp LINEARI USDC (`_USDC-PERPETUAL`, non inverse): `amount` in +base-coin (BTC/ETH), margine e P&L in USDC — vedi `_CONTRACT` sotto per step/tick/minimi per +strumento. """ from __future__ import annotations diff --git a/src/live/execution.py b/src/live/execution.py index 4edd3e1..5d7c322 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -23,10 +23,11 @@ from dataclasses import dataclass from src.live.deribit import DISASTER_LABEL, DeribitRead, disaster_stop_price, notional_to_amount, quantize_price # Conto USDC -> perp LINEARE USDC (amount in base-coin). MAX_AMOUNT = TETTO HARD anti-fat-finger di -# DEFAULT (~$630/$430 su un conto ~$600), usato SOLO quando nessuno ha chiamato set_open_cap() (il -# micro-test, che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva -# set_open_cap() dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop -# sopra il sizing di TP01/book, non il sizing operativo. +# DEFAULT (in coin, sotto), usato SOLO quando nessuno ha chiamato set_open_cap() (il micro-test, +# che invia comunque size fissa minima). Sul libro il tetto per apertura lo deriva set_open_cap() +# dal cap di config (config/live.json max_notional_per_asset_usd, #18): backstop sopra il sizing +# di TP01/book, non il sizing operativo. (#35 A9: via il "~$630/$430 su conto ~$600" cablato — +# MAX_AMOUNT e' in coin, non in dollari, e non e' il tetto operativo da quando esiste set_open_cap.) ALLOWED = {"BTC_USDC-PERPETUAL", "ETH_USDC-PERPETUAL"} MAX_AMOUNT = {"BTC_USDC-PERPETUAL": 0.01, "ETH_USDC-PERPETUAL": 0.25} # DEFAULT — vedi set_open_cap() FLAT_USD = 1.0 # |notional| < $1 = posizione considerata flat diff --git a/src/live/venue_probe.py b/src/live/venue_probe.py index 715cd07..38115c1 100644 --- a/src/live/venue_probe.py +++ b/src/live/venue_probe.py @@ -28,19 +28,21 @@ col codice 11051, e' manutenzione; se non risponde affatto, e' il venue ma per u se la sonda stessa non parte, il verdetto e' IGNOTO — **«non vedo» non e' «va tutto bene»** (P5). La sonda parte SOLO quando qualcosa e' gia' fallito: sul percorso sano non costa niente. -⚠️ **LA FINESTRA NON E' UNA PROVA.** `in_release_window()` dice soltanto che l'orologio e' -compatibile con lo slot di release. Non declassa niente da sola: declassa solo cio' che la SONDA -ha gia' riconosciuto come manutenzione, e solo dentro la durata ANNUNCIATA. Una manutenzione che -sfora resta rumorosa (il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora), e un -guasto vero di martedi' mattina resta rumoroso: altrimenti si sarebbe costruito il silenzio -proprio nell'ora in cui e' piu' probabile che serva. E' la stessa logica di -`venue_watch.MAINT_GRACE_HOURS` — *declassa dentro la tolleranza, RIALZA oltre* — applicata pero' -a una domanda diversa (**questo giro** e' spiegato?) e quindi alla risoluzione del giro orario, -non delle ore consecutive. Sono due politiche vicine e distinte: non accorparle senza misurare. - -REGOLA P9 — *un allarme massimo speso per un evento atteso e' un allarme che non verra' letto il -giorno che e' vero.* E' il motivo per cui `atteso` esiste: declassa la GRAVITA', **non** registra -di meno. L'episodio finisce nel log e nella notifica comunque. +⚠️ **IL RAMO CHE DECLASSAVA E' MORTO, RIMOSSO DA `diagnose()` (#35 B11, 27/09/2026).** +`in_release_window()` diceva se l'orologio cadeva nello slot di release Deribit (martedi' +09:00-09:30 UTC); `diagnose()` la usava per declassare la GRAVITA' di una manutenzione gia' +riconosciuta dalla SONDA (REGOLA P9 — *un allarme massimo speso per un evento atteso e' un +allarme che non verra' letto il giorno che e' vero*). Ma l'UNICO chiamante di `diagnose()` e' +`book_execute.py`, in cron al minuto **:47** di ogni ora (dal 25/08): la finestra 09:00-09:30 non +contiene mai :47, quindi quel ramo non poteva MAI diventare vero — si leggeva come una +declassazione attiva e non lo e' mai stata. Il campo `atteso` RESTA sul verdetto +(`book_execute.py` lo scrive nel dict del report: cambiare il formato per una pulizia e' un costo +che non vale la pena) ma e' hardcoded a False in `diagnose()`. `in_release_window()` e le +costanti `RELEASE_*` restano invece nel modulo: le usa `test_book_cadenza.py`/`test_revisione.py` +per verificare che i cron di book/review evitino lo stesso slot — uno scopo indipendente da +`diagnose()` e ancora valido. Per declassare di nuovo un evento annunciato in `diagnose()` la +strada e' legarlo alla DURATA dell'annuncio (`venue_news`, se e quando esiste), non all'orologio +di un chiamante che non e' mai sincrono con lo slot. """ from __future__ import annotations @@ -53,7 +55,7 @@ import requests from src.live.venue_watch import is_maintenance # P1: la firma 11051 vive LI', non qui # --- verdetti ----------------------------------------------------------------------------- -V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051: atteso se dentro lo slot +V_MANUTENZIONE = "VENUE_MANUTENZIONE" # il venue dichiara 11051 V_VENUE_GIU = "VENUE_GIU" # il venue non risponde, e non dice manutenzione V_GATEWAY = "GATEWAY" # il venue sta bene -> il guasto e' NOSTRO (riparabile) V_IGNOTO = "IGNOTO" # la sonda non e' partita: "non vedo" (P5) @@ -70,8 +72,12 @@ PROBE_TIMEOUT = 8.0 # 30/06/2026 alle 09:00 UTC, "down for around 15-30 minutes"). # ⚠️ M10: *due punti non fanno una tendenza*. Qui i punti sono quattro e c'e' una meccanica # dichiarata dal venue, ma resta una REGOLARITA' OSSERVATA, non un contratto: se Deribit sposta -# lo slot, questa costante mente in silenzio. E' per questo che la finestra non decide da sola — -# decide la sonda, e la finestra al massimo declassa. +# lo slot, questa costante mente in silenzio. +# ⚠️ #35 B11 (27/09/2026): questa finestra NON declassa piu' niente in `diagnose()` (vedi sotto): +# l'unico chiamante gira in cron al minuto :47, che non ci cade mai dentro. La funzione e le +# costanti restano perche' `test_book_cadenza.py`/`test_revisione.py` le usano per un controllo +# diverso e ancora valido: che i cron di `book`/`review` cadano FUORI da questo slot (P1, la +# stessa finestra, non una seconda dichiarata a mano). RELEASE_WEEKDAY = 1 # lunedi'=0 -> 1 = martedi' RELEASE_START_MIN = 9 * 60 # 09:00 UTC RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione torna una notizia. @@ -79,7 +85,8 @@ RELEASE_LEN_MIN = 30 # durata ANNUNCIATA. Oltre questa la manutenzione to @dataclass(frozen=True) class Diagnosi: - """Il verdetto (*cosa*), la spiegazione (*perche'*), e se l'evento era atteso (P9).""" + """Il verdetto (*cosa*) e la spiegazione (*perche'*). `atteso` resta nel formato ma e' sempre + False (#35 B11): l'unico chiamante non cade mai nella finestra che l'avrebbe declassato.""" verdetto: str perche: str atteso: bool @@ -87,7 +94,8 @@ class Diagnosi: @property def gravita(self) -> str: - """Emoji del titolo di notifica. `atteso` declassa, non silenzia (P9).""" + """Emoji del titolo di notifica. `atteso` e' sempre False (#35 B11): ramo morto lasciato + per compatibilita' di formato, non per una declassazione che avviene ancora.""" if self.atteso: return "ℹ️" return {V_GATEWAY: "🛑", V_VENUE_GIU: "🛑", V_MANUTENZIONE: "⚠️"}.get(self.verdetto, "⚠️") @@ -106,8 +114,9 @@ class Diagnosi: def in_release_window(now: datetime | None = None) -> bool: """L'orologio e' dentro lo slot di release Deribit (martedi' 09:00-09:30 UTC)? - PURA e testabile. Non e' una prova di niente da sola: vedi la nota in testa al modulo. - """ + PURA e testabile. Non declassa piu' niente in `diagnose()` (#35 B11): resta per chi verifica + che un cron (book, review) sia schedulato fuori da questo slot (P1: la finestra si deriva da + qui, non si riscrive una seconda volta nel test).""" now = now or datetime.now(timezone.utc) now = now.astimezone(timezone.utc) if now.weekday() != RELEASE_WEEKDAY: @@ -136,7 +145,6 @@ def probe_public(timeout: float = PROBE_TIMEOUT) -> tuple[bool | None, str]: def diagnose(errori_osservati: list[str] | None = None, - now: datetime | None = None, sonda=None) -> Diagnosi: """Classifica un guasto gia' avvenuto. `sonda` e' iniettabile: i test non toccano la rete. @@ -169,7 +177,10 @@ def diagnose(errori_osservati: list[str] | None = None, verdetto = V_VENUE_GIU perche = "l'API pubblica Deribit non serve, e non dichiara manutenzione" - atteso = verdetto == V_MANUTENZIONE and in_release_window(now) + # #35 B11: qui c'era `verdetto == V_MANUTENZIONE and in_release_window(now)`. L'unico + # chiamante (book_execute.py, cron :47) non cade mai nella finestra 09:00-09:30 di martedi': + # il ramo non poteva mai diventare vero. Il campo resta (formato del report), sempre False. + atteso = False prova = "; ".join([*errori[:3], grezzo])[:500] return Diagnosi(verdetto=verdetto, perche=perche, atteso=atteso, prova=prova) diff --git a/src/portfolio/sleeves.py b/src/portfolio/sleeves.py index 7f4d4a0..0944340 100644 --- a/src/portfolio/sleeves.py +++ b/src/portfolio/sleeves.py @@ -400,8 +400,14 @@ def deribit_book_sleeves() -> list[Sleeve]: e VRP01 (opzioni modellate). FULL Sharpe ~1.78 / HOLD ~1.17 / DD ~9% (research; SKH01 daily-step). Pensato per il deploy reale a basso capitale: stesso conto, stesso feed, ribilanciamento periodico (vedi src.portfolio.portfolio.rebalance_sim + scripts/portfolio/run_deribit_book.py). - TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione).""" + TP01 e' gia' armato live; SKH01 e' il candidato 2a gamba (da validare codice d'esecuzione). + + I pesi si DERIVANO da src.live.book (P1): due letterali qui erano una seconda dichiarazione, + libera di divergere dal book vero (#35 B12). Import locale: book importa questo modulo + (_skyhook_positions) a livello di modulo -> un import in testa qui creerebbe un ciclo (come + fa usde.py con book).""" + from src.live import book as B # lazy: vedi nota sul ciclo qui sopra return [ - tp01_sleeve(weight=0.75), - skyhook_sleeve(weight=0.25), + tp01_sleeve(weight=B.W_TP01), + skyhook_sleeve(weight=B.W_SKH), ] diff --git a/tests/test_book_live.py b/tests/test_book_live.py index 3407d93..301ade4 100644 --- a/tests/test_book_live.py +++ b/tests/test_book_live.py @@ -150,6 +150,27 @@ def test_weights_match_backtest_sleeves(): assert abs((W_TP01 + W_SKH) - 1.0) < 1e-12 +def test_deribit_book_sleeves_deriva_i_pesi_da_book_non_li_ridichiara(): + """#35 B12 — P1: stesso pattern di test_r0909_libro_nei_crolli.test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia. + `test_weights_match_backtest_sleeves` sopra passerebbe anche con due letterali per coincidenza + di valore (0.75/0.25 riscritti a mano): qui si verifica l'IDENTITA' con B.W_TP01/B.W_SKH e + che il letterale non sia piu' nella fonte di `deribit_book_sleeves`.""" + import inspect + import re + + import src.live.book as B + from src.portfolio.sleeves import deribit_book_sleeves + + pesi = {s.name.split("_")[0]: s.weight for s in deribit_book_sleeves()} + assert pesi["TP01"] is B.W_TP01 or pesi["TP01"] == B.W_TP01 + assert pesi["SKH01"] is B.W_SKH or pesi["SKH01"] == B.W_SKH + + src = inspect.getsource(deribit_book_sleeves) + assert "from src.live import book" in src, "i pesi devono derivare da src.live.book (P1)" + assert not re.search(r"weight\s*=\s*0\.75", src), "peso TP01 ancora cablato" + assert not re.search(r"weight\s*=\s*0\.25", src), "peso SKH01 ancora cablato" + + # --------------------------------------------------------------------------- # Costruzione ordine: side, reduce_only, flip, close, soglia minima. # --------------------------------------------------------------------------- diff --git a/tests/test_cuscino_watch.py b/tests/test_cuscino_watch.py index cd21bad..946438b 100644 --- a/tests/test_cuscino_watch.py +++ b/tests/test_cuscino_watch.py @@ -155,7 +155,15 @@ def test_un_tentativo_ogni_sei_ore(): assert W.puo_riconvertire([{"ts": now - 1000, "riconversione": None}], now, True, 1.0, 0.99)[0] +def _finto_lettore_quota(valori): + """Ritorna i valori dati in ordine, uno per chiamata (per monkeypatch di _leggi_quota_usde).""" + it = iter(valori) + return lambda: next(it) + + def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): + # rc==0: "ok" a prescindere dalla quota (lo script vero ha gia' le sue guardie, P15) — la + # quota qui non si muove, per mostrare che il caso "ok" non dipende dalla rilettura. chiamate = [] class R: @@ -166,10 +174,61 @@ def test_riconverti_lancia_lo_script_vero_con_esegui(monkeypatch): return R() monkeypatch.setattr(W.subprocess, "run", finto_run) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.67, 0.67])) e = W.riconverti(0.67) - assert e["ok"] and chiamate and chiamate[0][1].endswith("usde_convert.py") + assert e["ok"] and e["esito"] == "ok" and chiamate and chiamate[0][1].endswith("usde_convert.py") assert "--esegui" in chiamate[0] and "0.6700" in chiamate[0] assert e["coda"][-1].startswith(" ESITO") + assert e["quota_prima"] == 0.67 and e["quota_dopo"] == 0.67 + + +def test_riconverti_rc_diverso_da_zero_con_quota_mossa_e_parziale(monkeypatch): + # #35 B10: rc!=0 (es. ordine parzialmente fillato poi rifiutato) ma la quota si e' mossa + # per davvero -> "parziale", non "fallita": la vendita/acquisto e' avvenuto, solo non finito. + class R: + returncode, stdout, stderr = 1, "", "book illeggibile a meta' piano\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.70])) + e = W.riconverti(0.64) + assert e["esito"] == "parziale" and e["ok"] is False + assert e["quota_prima"] == 0.75 and e["quota_dopo"] == 0.70 + + +def test_riconverti_rc_diverso_da_zero_con_quota_ferma_e_fallita(monkeypatch): + class R: + returncode, stdout, stderr = 1, "", "prezzo fuori banda: niente ordini\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, 0.751])) + e = W.riconverti(0.64) + assert e["esito"] == "fallita" and e["ok"] is False + + +def test_riconverti_timeout_con_quota_mossa_e_parziale(monkeypatch): + # #35 B10: il subprocess viene ucciso a TIMEOUT_CONVERT_S ma alcuni ordini erano gia' fillati + # prima del kill -> oggi (ok=rc==0) diventava "FALLITA", corretto e' "parziale". + def finto_run(cmd, **kw): + raise W.subprocess.TimeoutExpired(cmd, kw.get("timeout")) + + monkeypatch.setattr(W.subprocess, "run", finto_run) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.80, 0.72])) + e = W.riconverti(0.64) + assert e["rc"] is None and e["esito"] == "parziale" and e["ok"] is False + assert e["coda"][0].startswith("timeout dopo") + + +def test_riconverti_rilettura_dopo_fallita_e_fallita_esplicita(monkeypatch): + # se non si riesce a rileggere la quota dopo, non si puo' dire "parziale": si dichiara + # l'incertezza invece di dedurre un movimento zero che non si e' verificato. + class R: + returncode, stdout, stderr = 1, "", "errore\n" + + monkeypatch.setattr(W.subprocess, "run", lambda cmd, **kw: R()) + monkeypatch.setattr(W, "_leggi_quota_usde", _finto_lettore_quota([0.75, None])) + e = W.riconverti(0.64) + assert e["esito"] == "fallita (quota dopo non leggibile)" and e["ok"] is False + assert e["quota_dopo"] is None def test_il_monitor_e_registrato_in_monitor_health(): diff --git a/tests/test_venue_probe.py b/tests/test_venue_probe.py index eee3d8d..b9758a5 100644 --- a/tests/test_venue_probe.py +++ b/tests/test_venue_probe.py @@ -8,17 +8,17 @@ Cosa difendono, in ordine di gravita': da `place-failed`. Sono due guasti diversi (P5) e solo uno lascia la posizione scoperta. 2. **isolamento per asset**: un guasto su BTC non deve impedire ad ETH di essere protetto. 3. **la diagnosi**: manutenzione, gateway e "non vedo" devono essere distinguibili (P4/P5). - 4. **P9**: `atteso` declassa la gravita' SOLO su evidenza di manutenzione, e SOLO dentro lo slot. + 4. **`atteso` e' sempre False** (#35 B11): il ramo che lo declassava (finestra di release del + martedi') e' stato rimosso perche' l'unico chiamante (cron :47) non ci cadeva mai dentro. """ import sys -from datetime import datetime, timezone from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(PROJECT_ROOT)) from src.live.venue_probe import (V_GATEWAY, V_IGNOTO, V_MANUTENZIONE, V_VENUE_GIU, diagnose, - errori_dal_report, in_release_window) + errori_dal_report) MANUTENZIONE_GREZZA = ('HTTP 503 {"jsonrpc":"2.0","error":' '{"message":"system_maintenance","code":11051}}') @@ -94,39 +94,19 @@ def test_la_prova_viaggia_sempre_con_il_verdetto(): # --------------------------------------------------------------------------- -# 2. P9 — `atteso` declassa, ma solo su EVIDENZA e solo DENTRO lo slot. +# 2. `atteso` e' sempre False (#35 B11) — nessun verdetto, manutenzione compresa, si declassa. # --------------------------------------------------------------------------- -def test_finestra_di_release_martedi_nove_utc(): - dentro = datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc) # martedi', l'episodio vero - assert in_release_window(dentro) is True - assert in_release_window(datetime(2026, 8, 25, 9, 0, tzinfo=timezone.utc)) is True - assert in_release_window(datetime(2026, 8, 25, 9, 29, tzinfo=timezone.utc)) is True - assert in_release_window(datetime(2026, 8, 25, 9, 30, tzinfo=timezone.utc)) is False # sforata - assert in_release_window(datetime(2026, 8, 25, 8, 59, tzinfo=timezone.utc)) is False - assert in_release_window(datetime(2026, 8, 26, 9, 7, tzinfo=timezone.utc)) is False # mercoledi' - - -def test_manutenzione_dentro_lo_slot_e_attesa_e_declassa(): - d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), - sonda=_sonda_manutenzione) - assert d.verdetto == V_MANUTENZIONE and d.atteso is True - assert d.gravita == "ℹ️" # declassato, NON silenziato - assert "ATTESO" in d.riga() - - -def test_manutenzione_che_sfora_lo_slot_torna_rumorosa(): - """Il 18/08 Deribit annuncio' 15-30 minuti e resto' giu' oltre l'ora: alle 10:07 la - manutenzione non era piu' l'evento annunciato, ed e' di nuovo una notizia.""" - d = diagnose([], now=datetime(2026, 8, 18, 10, 7, tzinfo=timezone.utc), - sonda=_sonda_manutenzione) +def test_manutenzione_non_si_declassa_piu_atteso_sempre_falso(): + """Il ramo che declassava (finestra di release del martedi') e' rimosso: una manutenzione + riconosciuta dalla sonda resta comunque `atteso is False` e alla gravita' piena.""" + d = diagnose([], sonda=_sonda_manutenzione) assert d.verdetto == V_MANUTENZIONE and d.atteso is False - assert d.gravita != "ℹ️" + assert d.gravita == "⚠️" + assert "ATTESO" not in d.riga() -def test_un_guasto_vero_di_martedi_mattina_non_viene_declassato(): - """Il rischio della finestra: costruire il silenzio proprio nell'ora in cui serve. Un gateway - rotto dentro lo slot di release deve restare al massimo della gravita'.""" - d = diagnose([], now=datetime(2026, 8, 25, 9, 7, tzinfo=timezone.utc), sonda=_sonda_ok) +def test_un_guasto_vero_non_e_mai_atteso(): + d = diagnose(["BTC: fallback close (HTTPError)"], sonda=_sonda_ok) assert d.verdetto == V_GATEWAY and d.atteso is False and d.gravita == "🛑"