diff --git a/CLAUDE.md b/CLAUDE.md index bb1fcfe..cecd09d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,15 @@ lorda massima 1,0x**, e nessun livello di capitale cambia il profilo di rischio `min_order_usd` 5 ยท `disaster_sl_pct` 0.30 on-book sulla posizione **netta** ยท staleness-gate feed 2 giorni (blocca) ยท `skh_feed_max_age_min` 30 (allerta, **non** blocca) ยท alert Telegram. +๐Ÿ“Œ **Dal 2026-08-26 l'equity del book e' il TOTALE cross-collateral** (USDC + USDE valutato +all'indice pubblico `usde_usdc`): il conto tiene **500 USDE** (test di eligibilita' reward, verdetto +29/08 โ€” GATE USDE-01 in ยง4). Autorita' dei parametri: sezione `usde` di `config/live.json` +(haircut 10%, tetto ALLERTA quota 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta +da `src/live/usde.py` โ€” shadow (sizing orario) e `usde_watch` (sorveglianza 12:35 UTC, cron +proprio, sotto `monitor_health`) derivano entrambi da li'. โš ๏ธ Il difetto d'origine: `shadow._equity` +leggeva SOLO il conto USDC โ€” la prima conversione avrebbe causato un falso "USCITA DI FONDI โˆ’24%" +e vendite indesiderate. Chi aggiunge una valuta di collaterale DEVE passare da `usde.py`/shadow. + โš ๏ธ **Il nome "disaster-SL โˆ’30%" inganna:** e' **rotolante e simmetrico**, non un massimo di perdita per trade. Su cicli a ingresso proprio lascia passare perdite oltre โˆ’30% senza scattare, e il peggior DD dall'ingresso misurato e' **โˆ’60,6% BTC / โˆ’61,5% ETH**. Dettaglio in `40-produzione`. @@ -161,6 +170,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure. | gate | data | criterio | stato | |---|---|---|---| +| **USDE-01 (eligibilita' reward)** | 2026-08-29 | โ‰ฅ1 reward entro il 29/08 (rilevati da `usde_watch` per delta di equity USDE al netto dei trade spot) โ†’ IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% โ€” R1 emittente non recuperabile); zero โ†’ si riconverte e la pista si chiude | โœ… pre-registrato il 26/08 PRIMA dell'esito (diario `2026-08-26-usde-analisi`); 500 USDE in conto dalle 13:04Z, baseline watch 14:17Z, verdetto applicato dal cron 12:35 | | **STATARB** | 2026-09-27 | soglie 24/07 (non toccate) + diagnostica statica sempre-short | โœ… monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **โˆ’1,61** a oggi โ†’ coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima | | **XSR01** | 2026-10-23 | Sharpe โ‰ฅ1,0 **E** haircut โ‰ค40% โ€” dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti โ€” poi `weights_tilt_null` e **capitale โ‰ฅ$20k** | โœ… **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe โˆ’2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (๐Ÿ“Œ sotto) | | **DVOLSPREAD** | kill 2026-10-24 se Sh<โˆ’0,50; decisione 2027-01-24 | (a) Sharpe>0 (b) marginale ADDS+robust_oos+insample_edge (c) **DSR โ‰ฅ0,95** (d) `weights_tilt_null`; **veto** se barre attive <80% โ†’ si estende, non si decide | โœ… serie rigenerata il 26/08 (โˆ’4,41 a oggi, era โˆ’14,73 sulla serie rotta); il veto barre-attive ora conta barre VERE e la guardia PREMATURO sorveglia il monitor | @@ -229,7 +239,7 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per finestra hold-out che si allunga โ€” e ๐Ÿ“Œ **la SOLA settimana 15-22/08 vale +0,35 di Sharpe hold-out** (rally ETH con SKH01 long): il numero e' fragile, coerente con ยง2. Il test ora **taglia il feed al 02/07** e verifica la riproduzione stretta (ยฑ0,02): si rompe solo se - cambiano codice o dato sulla finestra congelata. Suite: **751 passati, 0 falliti** (26/08). + cambiano codice o dato sulla finestra congelata. Suite: **775 passati, 0 falliti** (26/08). 10. โš ๏ธ **Nessuna tabella pubblicata prima del 22/08 contiene fisco E funding insieme.** L'unica congiunta e' quella in `30-piano-capitale-fisco.md` โ€” le altre vanno lette con lo sconto. 11. ๐Ÿšจ **Le credenziali Deribit esistono SOLO dentro il gateway** (`cerbero-mcp`): in locale c'e' @@ -466,7 +476,7 @@ uv run python scripts/live/trades_db.py --report # stato libro d uv run python scripts/live/trades_db.py --reconcile # incrocio delle 3 fonti sui fill uv run python scripts/live/journal.py # voce del giorno (numeri + lettura) uv run python scripts/live/analista.py --secco # analisi del giorno, senza salvare -uv run pytest # test (751, tutti verdi dal 26/08) +uv run pytest # test (775, tutti verdi dal 26/08) ``` ```python diff --git a/config/live.json b/config/live.json index bb6a3ba..56e3dcd 100644 --- a/config/live.json +++ b/config/live.json @@ -9,5 +9,13 @@ "_nota_stale": "Staleness-gate (2026-07-25): se l'ultima barra del feed certificato e' piu' vecchia di max_data_age_days, book_execute NON invia ordini e allerta su Telegram. Il 2026-07-14 il book compro' ETH con il feed fermo da 6 giorni (conto online e posizione leggibile -> gli altri due gate non scattavano). Follow-up raccomandato nel diario 2026-07-15-feed-freeze, ora cablato.", "max_data_age_days": 2, "_nota_skh_feed": "Freschezza del feed 5m usato per il segnale SKH01 (2026-07-26). fresh_5m ricade sul feed certificato IN SILENZIO se il fetch pubblico Deribit fallisce, e il certificato si rigenera 1x/giorno: senza controllo la latenza d'uscita di SKH01 passa da ~1h a ~1 giorno senza segnalazione. Sopra soglia book_execute ALLERTA e NON blocca (bloccare fermerebbe anche TP01, nettato sullo stesso strumento, per un guasto di rete). Diario 2026-07-26-t1-esecuzione-skh-live.md.", - "skh_feed_max_age_min": 30 + "skh_feed_max_age_min": 30, + "_nota_usde": "Collaterale USDE a rendimento (test di eligibilita' 2026-08-26: 500 USDE, verdetto entro il 29/08 โ€” diario 2026-08-26-usde-analisi). L'autorita' che LEGGE questa sezione e' src/live/usde.py; la usano shadow._collaterale_usde (equity oraria del book) e scripts/live/usde_watch.py (sorveglianza giornaliera 12:35 UTC). Criteri delle soglie, dichiarati (P6): depeg_warn 0.99 = fuori dalla banda operativa dello spot (~3 bps) e oltre il clamp +-0.5% per fonte dell'indice usde_usdc โ€” a quel prezzo non e' rumore di book; depeg_crit 0.95 = meta' del buffer di haircut (10%) consumata. quota_max_frac 0.50 = tetto di ALLERTA sulla quota USDE/equity totale (N4: la quota e' l'unica leva contro il rischio emittente, -100% = 25 anni di resa, non recuperabile); la quota EFFETTIVA la decide l'operatore, e vale anche per deriva PASSIVA (il libro perde -> la quota sale da sola). haircut 0.10 verificato sul venue il 26/08 (USDC 0%): il 90% dell'USDE fa margine.", + "usde": { + "index_name": "usde_usdc", + "haircut": 0.10, + "quota_max_frac": 0.50, + "depeg_warn": 0.99, + "depeg_crit": 0.95 + } } diff --git a/docs/diary/2026-08-26-usde-analisi.md b/docs/diary/2026-08-26-usde-analisi.md index e1573fb..d3d0c4e 100644 --- a/docs/diary/2026-08-26-usde-analisi.md +++ b/docs/diary/2026-08-26-usde-analisi.md @@ -72,3 +72,34 @@ al cross-collateral: se la prima conversione fosse stata fatta a quota vera, l'e sarebbe costato caro. I test piccoli esistono per questo.* *Esito eligibilita': (da scrivere entro il 29/08)* + +## STRUTTURA (stesso giorno, ~14:20 UTC โ€” "struttura il progetto per gestire subito usde") + +La patch d'emergenza delle 13:07 e' diventata struttura di prima classe: + +- **`config/live.json` sezione `usde`** โ€” l'unica autorita' sui parametri (P1): `index_name`, + `haircut` 0.10, `quota_max_frac` 0.50 (tetto di ALLERTA โ€” la quota effettiva la decide + l'operatore), `depeg_warn` 0.99 / `depeg_crit` 0.95 coi **criteri dichiarati** in `_nota_usde` + (P6: warn = fuori dalla banda spot ~3 bps e oltre il clamp ยฑ0,5% per fonte dell'indice; + crit = meta' del buffer di haircut consumata). +- **`src/live/usde.py`** โ€” modulo unico per config, catena di prezzo (indice pubblico โ†’ ticker + spot โ†’ 1,0 dichiarato) e valutazione `valuta()` PURA. `shadow._collaterale_usde` ora DERIVA + da qui (niente ridichiarazioni); i 6 test di shadow passano invariati. +- **`scripts/live/usde_watch.py`** + `cron_usde.sh` alle **12:35 UTC** (dopo la finestra reward + ~12:00, fuori da :00/:25/:47) โ€” sola lettura: (1) **reward per DELTA di equity al netto dei + trade spot** (il gateway non espone il Transaction Log; un delta con trade nel mezzo si + dichiara, trade illeggibili โ†’ delta NON attribuito, P12); (2) **depeg**: ๐Ÿšจ solo sotto crit + e ripetuto, โš ๏ธ warn/quota/BLIND solo alla transizione (P9); (3) **quota sul totale**, che + allerta anche per deriva PASSIVA (il libro perde โ†’ la quota sale da sola, N4). Applica il + **verdetto pre-registrato** (โ‰ฅ1 reward entro il 29/08 โ†’ IDONEO; zero โ†’ riconvertire) invece + di lasciarlo a un promemoria. Limite dichiarato: cadenza giornaliera, un depeg intraday puo' + sfuggire all'ALLERTA โ€” ma non al SIZING, che legge l'indice a ogni giro orario del book. +- **Serie `data/live/usde_watch.jsonl`** โ€” dentro il perimetro di backup, `ts` in ms epoch โ†’ + sorvegliata da **`monitor_health`** (max_age 30h, `open_labeled=False`): un watch fermo + spegnerebbe gli allarmi depeg e il silenzio si leggerebbe come "va tutto bene" (P5). +- **Baseline registrata alle 14:17:52Z**: 500 USDE @ 1,0000 (indice) = $500,00 ยท quota 24,3% + (tetto 50%) ยท margine utilizzabile ~$2.008 ยท verdetto IN_ATTESA. E' la lettura che rende + misurabile il delta del 28/08. +- **Test: 775 passati** (+17 nuovi in `tests/test_usde_watch.py`: valutazione condivisa, + rilevatore reward, verdetto โ€” incluso che NON anticipa la scadenza del 29/08 โ€”, condizioni + di allerta, `px=None` che non finge un depeg). diff --git a/scripts/cron_usde.sh b/scripts/cron_usde.sh new file mode 100755 index 0000000..9c78253 --- /dev/null +++ b/scripts/cron_usde.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# Sorveglianza collaterale USDE (reward/depeg/quota) โ€” v2.0.0+. GIORNALIERA alle 12:35 UTC: +# DOPO la finestra reward Deribit (~12:00), fuori dal minuto :00 (rate-limit per-IP, misura +# 2026-07-30), dal :25 (catena opzioni) e dal :47 (book). Sola lettura, mai blocca nulla. +export PATH="/home/adriano/.local/bin:$PATH" +cd /opt/docker/PythagorasGoal || exit 1 +mkdir -p logs +{ + echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_usde =====" + uv run python scripts/live/usde_watch.py --quiet || true + echo "===== done $(date -u '+%H:%M:%SZ') =====" +} >> logs/cron_usde.log 2>&1 diff --git a/scripts/live/usde_watch.py b/scripts/live/usde_watch.py new file mode 100755 index 0000000..3948b67 --- /dev/null +++ b/scripts/live/usde_watch.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python +"""usde_watch.py โ€” sorveglianza giornaliera del collaterale USDE: reward, depeg, quota. SOLA LETTURA. + +PERCHE' ESISTE. Dal 2026-08-26 il conto tiene USDE come collaterale a rendimento (test di +eligibilita': 500 USDE @ 1.0003, regola pre-registrata nel diario 2026-08-26-usde-analisi). +Tre cose vanno sorvegliate e nessuna aveva un posto: + + 1. i REWARD giornalieri (~12:00 UTC) โ€” sono la ragione della posizione, e il gateway non + espone il Transaction Log: si rilevano per DELTA di equity USDE al netto dei trade spot. + Metodo DICHIARATO, coi suoi limiti: un delta con trade nel mezzo si riporta con + `con_trade=true` e se i trade non sono leggibili il delta resta NON ATTRIBUITO invece + di essere inventato (P12: una riparazione silenziosa e' un'invenzione). + 2. il DEPEG โ€” soglie e criteri in config/live.json `_nota_usde` (P6: dichiarati prima): + warn = fuori dalla banda operativa dello spot e oltre il clamp per-fonte dell'indice; + crit = meta' del buffer di haircut consumata. + 3. la QUOTA sul totale โ€” N4: la quota e' l'unica leva contro il rischio emittente + (-100% = 25 anni di resa, non recuperabile). Sopra `quota_max_frac` allerta, e vale + anche per deriva PASSIVA: il libro perde -> la quota sale da sola. + +VERDETTO DI ELIGIBILITA' (regola pre-registrata il 26/08, PRIMA dell'esito): >=1 reward +entro il 29/08 -> IDONEO (si apre la decisione di quota, che e' dell'OPERATORE); zero reward +alla lettura del 29/08 dopo la finestra delle 12:00 UTC -> NON IDONEO, si riconverte e la +pista si chiude. Questo script APPLICA la regola, non la decide. + +LIMITE DICHIARATO (P6: la latenza si confronta con la durata del fenomeno). Cadenza +giornaliera (cron 12:35 UTC, dopo la finestra reward): il depeg Binance del 10/10/2025 duro' +~8h, questa sorveglianza puo' MANCARLO. Accettato perche' la protezione di SIZING e' oraria +per costruzione (shadow._collaterale_usde legge l'indice a ogni giro del book) e il danno e' +limitato dalla quota. Qui si ALLERTA, non si protegge. + +ALLARMI (P9: il massimo si spende per l'evento vero): ๐Ÿšจ solo depeg datetime: + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + + +def leggi(path: Path = STATE) -> list[dict]: + if not path.exists(): + return [] + out = [] + for ln in path.read_text().splitlines(): + ln = ln.strip() + if ln: + out.append(json.loads(ln)) + return out + + +def analizza(prev: dict | None, eq_now: float, trades_usde: float | None) -> dict: + """PURA. Reward per delta di equity USDE al netto dei trade spot fra le due letture. + + prev senza equity (primo giro, o giro BLIND) -> baseline: nessun delta da attribuire. + trades_usde None = storia trade non leggibile -> il delta si riporta ma NON si attribuisce + a reward (P12): meglio un giorno di latenza che un reward inventato.""" + if prev is None or prev.get("eq_usde") is None: + return dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) + delta = round(eq_now - float(prev["eq_usde"]), 8) + if trades_usde is None: + return dict(delta=delta, reward_stimato=None, reward_rilevato=False, con_trade=None) + stima = round(delta - trades_usde, 8) + return dict(delta=delta, reward_stimato=stima, + reward_rilevato=bool(stima > EPS_REWARD), + con_trade=bool(abs(trades_usde) > 0)) + + +def verdetto(records: list[dict], now: datetime) -> tuple[str, str]: + """PURA. Applica la regola pre-registrata del 26/08. -> (stato, motivo).""" + for r in records: + if r.get("reward_rilevato"): + return "IDONEO", (f"reward rilevato il {r.get('data')} " + f"(+{r.get('reward_stimato')} USDE)") + if now >= _parse(VERDETTO_DOPO): + return "NON_IDONEO", "zero reward entro la finestra del 29/08 -> riconvertire, pista chiusa" + return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}" + + +def condizioni(rec: dict | None, c: dict) -> set[str]: + """PURA. Condizioni di allerta attive su un record.""" + if not rec: + return set() + if rec.get("stato") == "BLIND": + return {"BLIND"} + att = set() + px = rec.get("px") + if px is not None: + if px < c["depeg_crit"]: + att.add("DEPEG_CRIT") + elif px < c["depeg_warn"]: + att.add("DEPEG_WARN") + q = rec.get("quota") + if q is not None and q > c["quota_max_frac"]: + att.add("QUOTA_OVER") + return att + + +def _safe_client(): + try: + from src.live.deribit import DeribitRead + return DeribitRead() + except Exception: + return None + + +def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]: + """Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile.""" + if client is None: + return None, None + try: + rows = client.trade_history(U.SPOT, limit=50) + except Exception: + return None, None + tot, n = 0.0, 0 + for t in rows: + if int(t.get("timestamp") or 0) <= ts_ms: + continue + amt = float(t.get("amount") or 0) + tot += amt if (t.get("direction") or "").lower() == "buy" else -amt + n += 1 + return round(tot, 8), n + + +def main() -> int: + quiet = "--quiet" in sys.argv + c = U.cfg() + records = leggi() + prev = records[-1] if records else None + now = datetime.now(timezone.utc) + client = _safe_client() + + stato, motivo_blind, eq_usde, eq_usdc = "OK", None, None, None + if client is None: + stato, motivo_blind = "BLIND", "gateway non raggiungibile" + else: + try: + eq_usde = float(client.account_summary("USDE").get("equity") or 0) + except Exception as e: + stato, motivo_blind = "BLIND", f"conto USDE non leggibile ({type(e).__name__})" + try: + eq_usdc = float(client.account_summary("USDC").get("equity") or 0) + except Exception: + eq_usdc = None # quota non computabile: dichiarato, non 0 + + px, px_fonte = U.prezzo(client) + usd = None + if eq_usde is not None: + usd, _nota = U.valuta(eq_usde, px) + + trades_usde, n_trades = (None, None) + an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None) + if stato == "OK" and prev is not None and prev.get("eq_usde") is not None: + trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"])) + an = analizza(prev, eq_usde, trades_usde) + + quota = None + if usd is not None and eq_usdc is not None and (usd + eq_usdc) > 0: + quota = round(usd / (usd + eq_usdc), 4) + + rec = dict( + ts=int(now.timestamp() * 1000), + data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), + stato=stato, motivo_blind=motivo_blind, + eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd, + eq_usdc=eq_usdc, quota=quota, + trades_usde=trades_usde, n_trades=n_trades, **an, + ) + 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 --- + att_now, att_prev = condizioni(rec, c), condizioni(prev, c) + nuove = att_now - att_prev + inviati = [] + 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)"}) + inviati.append("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"}) + inviati.append("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)"}) + inviati.append("QUOTA_OVER") + if "BLIND" in nuove: + notify("โš ๏ธ usde_watch BLIND", {"motivo": motivo_blind or "?", + "nota": "'non vedo' non e' 'va tutto bene' (P5)"}) + inviati.append("BLIND") + 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%)"}) + elif v == "NON_IDONEO": + notify("โš ๏ธ USDE: conto NON idoneo", {"dettaglio": v_motivo}) + inviati.append(f"VERDETTO->{v}") + + cambiato = bool(inviati) or v != v_prev + if not quiet or cambiato: + print("=" * 78) + print(f" USDE WATCH โ€” {rec['data']} (config: {c['fonte']})") + print("=" * 78) + if stato == "BLIND": + print(f" stato : BLIND โ€” {motivo_blind}") + else: + 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: + margine = eq_usdc + usd * (1.0 - c["haircut"]) + print(f" quota : {quota:.1%} del totale (tetto allerta {c['quota_max_frac']:.0%})" + f" ยท margine utilizzabile ~${margine:,.0f} (haircut {c['haircut']:.0%})") + if an["delta"] is not None: + attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None + else f"reward stimato {an['reward_stimato']:+.6f} USDE" + + (" [con trade nel mezzo]" if an["con_trade"] else "")) + print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}") + print(f" verdetto : {v} โ€” {v_motivo}") + if inviati: + print(f" allarmi : {', '.join(inviati)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/live/monitor_health.py b/src/live/monitor_health.py index 4ca0939..d86e27c 100644 --- a/src/live/monitor_health.py +++ b/src/live/monitor_health.py @@ -86,6 +86,12 @@ MONITORS: tuple[MonitorSpec, ...] = ( MonitorSpec("collect_chain", "chain_collect", "runs.jsonl", 1.0, max_age_h=3.0, gate="serie irrecuperabile โ€” la catena opzioni non si ricostruisce a posteriori", open_labeled=False), + # Sorveglianza del collaterale USDE (cron 12:35 UTC): se si ferma, spariscono gli allarmi + # depeg/quota e il silenzio si legge come "va tutto bene" (P5). max_age 30h = un giro + # mancato + margine; timbri di esecuzione come collect_chain -> open_labeled=False. + MonitorSpec("usde_watch", "live", "usde_watch.jsonl", 24.0, max_age_h=30.0, + gate="allarmi depeg/quota USDE + verdetto eligibilita' 29/08", + open_labeled=False), ) diff --git a/src/live/shadow.py b/src/live/shadow.py index 994bd16..5996749 100644 --- a/src/live/shadow.py +++ b/src/live/shadow.py @@ -12,6 +12,7 @@ import pandas as pd from src.backtest.harness import load from src.live.deribit import DISASTER_LABEL, INSTRUMENT, DeribitRead, build_rebalance_order +from src.live import usde as _usde from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -88,42 +89,25 @@ def _positions(client): return pos, note, err -USDE_INDEX_URL = "https://www.deribit.com/api/v2/public/get_index_price?index_name=usde_usdc" +# L'autorita' su config/prezzo/valutazione USDE e' src/live/usde.py (che legge la sezione +# `usde` di config/live.json). L'URL resta un attributo di modulo perche' i test lo patchano +# (tests/test_shadow_usde.py) per forzare il fallback al ticker senza toccare la rete. +USDE_INDEX_URL = _usde.index_url() def _collaterale_usde(client) -> tuple[float, str]: - """Valore USD dell'USDE tenuto come collaterale (test di eligibilita' 2026-08-26). - - PERCHE' ESISTE. `_equity` leggeva SOLO il conto USDC: convertire $500 in USDE ha fatto - sparire il 24% dell'equity dalla vista del book โ€” falso 'USCITA DI FONDI' e vendite - indesiderate al giro successivo. L'equity del conto e' il TOTALE cross-collateral. - - IL PREZZO viene dall'indice pubblico `usde_usdc` (mediana multi-exchange con clamp - ยฑ0,5% per fonte) โ€” NON dal book interno ne' da 1.0 cablato: il 10/10/2025 su Binance - l'oracle sul proprio book ha marcato USDe a $0,65 mentre l'indice mondo diceva ~$0,99. - Sotto la pari il prezzo PASSA (un depeg vero deve ridurre il sizing, non sparire); - sopra la pari si clampa a 1.0 (conservativo). Se l'indice non e' leggibile: fallback al - last del ticker via gateway, poi 1.0 DICHIARATO โ€” contare 0 ricreerebbe esattamente il - falso -24% che questa funzione elimina (P5: il fallback si sceglie sul danno).""" + """Valore USD dell'USDE tenuto come collaterale. Il PERCHE' e le proprieta' (depeg che + PASSA nel sizing, clamp sopra la pari, fallback 1.0 dichiarato mai 0) stanno in + src/live/usde.py โ€” difetto d'origine 2026-08-26: `_equity` leggeva solo il conto USDC, + -24% fantasma dopo la prima conversione.""" try: eq = float(client.account_summary("USDE").get("equity") or 0) except Exception: return 0.0, "" if eq <= 0: return 0.0, "" - px = None - try: - import requests - r = requests.get(USDE_INDEX_URL, timeout=5).json() - px = float(r["result"]["index_price"]) - except Exception: - try: - px = float(client.ticker("USDE_USDC").get("last_price") or 0) or None - except Exception: - px = None - if px is None or not (0.0 < px < 2.0): - return eq, f"USDE {eq:,.0f} valutato 1.0000 (indice non leggibile)" - return eq * min(px, 1.0), f"USDE {eq:,.0f} @ {min(px, 1.0):.4f}" + px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL) + return _usde.valuta(eq, px) def _equity(client, marks): diff --git a/src/live/usde.py b/src/live/usde.py new file mode 100644 index 0000000..204dffa --- /dev/null +++ b/src/live/usde.py @@ -0,0 +1,93 @@ +"""USDE come collaterale a rendimento โ€” autorita' unica su config, prezzo e valutazione. + +PERCHE' UN MODULO. Il 2026-08-26 il test di eligibilita' ($500 USDC->USDE) ha scoperto che +`shadow._equity` leggeva solo il conto USDC: -24% fantasma, falso 'USCITA DI FONDI' e vendite +indesiderate al giro successivo. La riparazione e' nata dentro shadow.py; qui diventa +struttura: shadow (equity ORARIA del book) e scripts/live/usde_watch.py (sorveglianza +GIORNALIERA) derivano entrambi da questo modulo, che a sua volta legge la sezione `usde` di +`config/live.json` (P1: il bersaglio si deriva, non si ridichiara). + +LE PROPRIETA' DELLA VALUTAZIONE (blindate in tests/test_shadow_usde.py e test_usde_watch.py): + * il prezzo viene dall'indice pubblico `usde_usdc` (mediana multi-exchange, clamp +-0.5% + per fonte) โ€” MAI dal book interno: il 10/10/2025 Binance marco' USDe a $0.65 sull'oracle + del proprio book mentre l'indice mondo diceva ~$0.99; + * un depeg sotto la pari PASSA nel sizing (il rischio vero e' il mark-down del collaterale); + * sopra la pari si clampa a 1.0 (conservativo); + * prezzo non leggibile -> 1.0 DICHIARATO, mai 0 (contare 0 ricrea il falso -24%; P5: il + fallback si sceglie sul danno). +""" +from __future__ import annotations + +import json +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +CFG_PATH = PROJECT_ROOT / "config" / "live.json" +SPOT = "USDE_USDC" # coppia spot Deribit (fee 0, spread osservato ~3 bps il 26/08) + +# Default = i valori decisi il 2026-08-26 (r0826_usde_scenari + verifica sul venue). Usati SOLO +# se config/live.json non ha la sezione `usde`: la provenienza sta nel campo `fonte` di cfg(), +# cosi' un report dice quale delle due configurazioni sta guardando (P7). +_DEFAULTS = { + "index_name": "usde_usdc", + "haircut": 0.10, + "quota_max_frac": 0.50, + "depeg_warn": 0.99, + "depeg_crit": 0.95, +} + + +def cfg() -> dict: + """Sezione `usde` di config/live.json; default dichiarati se assente. Mai solleva.""" + try: + raw = json.loads(CFG_PATH.read_text()).get("usde") + except Exception: + raw = None + out = dict(_DEFAULTS) + if isinstance(raw, dict): + out.update(raw) + out["fonte"] = "config/live.json" + else: + out["fonte"] = "default (sezione `usde` assente in config)" + return out + + +def index_url(index_name: str | None = None) -> str: + return ("https://www.deribit.com/api/v2/public/get_index_price?index_name=" + f"{index_name or cfg()['index_name']}") + + +def prezzo_indice(url: str | None = None, timeout: float = 5.0) -> float | None: + """Indice pubblico Deribit (tokenless). None se non leggibile o fuori banda. Mai solleva.""" + try: + import requests + r = requests.get(url or index_url(), timeout=timeout).json() + px = float(r["result"]["index_price"]) + return px if 0.0 < px < 2.0 else None + except Exception: + return None + + +def prezzo(client=None, url: str | None = None) -> tuple[float | None, str]: + """Catena di prezzo: indice pubblico -> ticker spot via gateway -> None. -> (px, fonte).""" + px = prezzo_indice(url=url) + if px is not None: + return px, "indice" + if client is not None: + try: + px = float(client.ticker(SPOT).get("last_price") or 0) or None + if px is not None and 0.0 < px < 2.0: + return px, "ticker spot" + except Exception: + pass + return None, "non leggibile" + + +def valuta(eq_usde: float, px: float | None) -> tuple[float, str]: + """PURA. -> (valore USD del collaterale USDE, nota di provenienza).""" + if eq_usde <= 0: + return 0.0, "" + if px is None or not (0.0 < px < 2.0): + return eq_usde, f"USDE {eq_usde:,.0f} valutato 1.0000 (indice non leggibile)" + p = min(px, 1.0) + return eq_usde * p, f"USDE {eq_usde:,.0f} @ {p:.4f}" diff --git a/tests/test_usde_watch.py b/tests/test_usde_watch.py new file mode 100644 index 0000000..f401d34 --- /dev/null +++ b/tests/test_usde_watch.py @@ -0,0 +1,143 @@ +"""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