From cfc42653cc8ec46ba90ae1eab7712ffde436cbc9 Mon Sep 17 00:00:00 2001 From: "Adriano (goldrake)" Date: Sun, 27 Sep 2026 13:33:48 +0000 Subject: [PATCH] shadow: USDE illeggibile -> equity IGNOTA non 0; online richiede tutti i mark mainnet (#20, #25) Un'eccezione sulla lettura del conto USDE valeva 0 USDE: se il conto ne aveva davvero, l'equity totale risultava sottostimata senza che nulla lo dicesse. Ora _collaterale_usde ritorna None + la nota dell'eccezione, e _equity propaga l'ignoto (real_eq None -> eq_fallback, non un numero falso). shadow_report.online guardava solo il mark di BTC: con ETH in fallback il giro si considerava comunque online. Ora richiede TUTTI i mark da mainnet. Co-Authored-By: Claude Sonnet 5 --- src/live/execution.py | 69 ++++++++++++++++++++++------------ src/live/shadow.py | 27 ++++++++++---- tests/test_book_u2.py | 78 +++++++++++++++++++++++++++++++++++++++ tests/test_shadow_usde.py | 10 ++++- 4 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 tests/test_book_u2.py diff --git a/src/live/execution.py b/src/live/execution.py index 37bec30..7684e63 100644 --- a/src/live/execution.py +++ b/src/live/execution.py @@ -29,6 +29,19 @@ class GuardrailError(RuntimeError): pass +class RebalanceParziale(RuntimeError): + """`rebalance_signed` ha eseguito almeno un fill (es. il close di un flip) e POI un passo + successivo ha sollevato (es. l'open dall'altro lato). Il fill gia' eseguito NON e' un fill + perso: `.fills` li porta al chiamante (che li registra) e `.causa` e' l'eccezione originale + (il chiamante la rilancia dentro il suo handler di isolamento, #23).""" + + def __init__(self, fills: list["Fill"], causa: Exception): + self.fills = fills + self.causa = causa + super().__init__(f"rebalance parziale: {len(fills)} fill eseguiti, poi " + f"{type(causa).__name__}: {causa}") + + @dataclass class Fill: """Esito verificato di un ordine reale.""" @@ -150,36 +163,46 @@ class DeribitTrader(DeribitRead): - target ~0 -> close(); - stesso segno, |target|<|cur| -> REDUCE reduce_only; - apertura/aumento -> open buy/sell (capped dal guardrail apertura). - Ritorna i Fill eseguiti.""" + Ritorna i Fill eseguiti. + + ⚠️ FILL PERSI (#23). Un flip e' DUE passi (close, poi open dall'altra parte): se il primo + va a segno e il secondo solleva, il fill del primo passo NON deve sparire. Da qui in poi + un'eccezione con almeno un fill gia' in `fills` diventa `RebalanceParziale(fills, causa)` + invece di propagare nuda: il chiamante registra i fill fatti e poi rilancia `causa`.""" cur = self.position_usd(instrument) if abs(target_notional_usd - cur) < min_usd: return [] fills: list[Fill] = [] - crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ - (cur < -FLAT_USD and target_notional_usd > FLAT_USD) - if crossing: # flip: flatta, poi riparti da zero - f = self.close(instrument, label="book-flip-close") - if f: - fills.append(f) - cur = 0.0 - if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa) - if abs(cur) > FLAT_USD: - f = self.close(instrument, label="book-exit") + try: + crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ + (cur < -FLAT_USD and target_notional_usd > FLAT_USD) + if crossing: # flip: flatta, poi riparti da zero + f = self.close(instrument, label="book-flip-close") if f: fills.append(f) + cur = 0.0 + if abs(target_notional_usd) < FLAT_USD: # target flat -> esci (sempre permessa) + if abs(cur) > FLAT_USD: + f = self.close(instrument, label="book-exit") + if f: + fills.append(f) + return fills + delta = target_notional_usd - cur + amount = notional_to_amount(instrument, abs(delta), price=mark) + if amount <= 0: + return fills + same_sign = (target_notional_usd > 0) == (cur > 0) + if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur): + side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only + fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce")) + else: + side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target + fills.append(self.open(instrument, side, amount, label="book-open")) return fills - delta = target_notional_usd - cur - amount = notional_to_amount(instrument, abs(delta), price=mark) - if amount <= 0: - return fills - same_sign = (target_notional_usd > 0) == (cur > 0) - if cur != 0.0 and same_sign and abs(target_notional_usd) < abs(cur): - side = "sell" if cur > 0 else "buy" # riduci nello stesso verso, reduce_only - fills.append(self._submit(instrument, side, amount, reduce_only=True, label="book-reduce")) - else: - side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target - fills.append(self.open(instrument, side, amount, label="book-open")) - return fills + except Exception as e: # noqa: BLE001 — un fill gia' eseguito non e' un fill perso (#23) + if fills: + raise RebalanceParziale(fills, e) from e + raise # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) --- def place_disaster_sl(self, instrument: str, side_held: str, amount: float, diff --git a/src/live/shadow.py b/src/live/shadow.py index 5996749..6c2963c 100644 --- a/src/live/shadow.py +++ b/src/live/shadow.py @@ -95,15 +95,20 @@ def _positions(client): USDE_INDEX_URL = _usde.index_url() -def _collaterale_usde(client) -> tuple[float, str]: +def _collaterale_usde(client) -> tuple[float | None, str]: """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.""" + -24% fantasma dopo la prima conversione. + + ⚠️ (#20) Un'eccezione sulla LETTURA del conto USDE non e' "0 USDE": e' "non lo so". Ritorna + None (non 0.0) + la nota dell'eccezione -> `_equity` propaga l'ignoto invece di dedurre un + equity piu' basso di quello vero. Eq letta con successo e <= 0 resta 0.0 (conto senza USDE: + quello e' un fatto accertato, legittimo).""" try: eq = float(client.account_summary("USDE").get("equity") or 0) - except Exception: - return 0.0, "" + except Exception as e: + return None, f"USDE illeggibile: {type(e).__name__}: {e}" if eq <= 0: return 0.0, "" px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL) @@ -117,6 +122,8 @@ def _equity(client, marks): eq = float(client.account_summary("USDC").get("equity") or 0) if eq > 1: usde_usd, usde_note = _collaterale_usde(client) + if usde_usd is None: # (#20) USDE illeggibile -> l'equity e' IGNOTA, + return None, usde_note # non "USDC senza USDE": non si assume 0. if usde_usd > 0: return eq + usde_usd, f"mainnet USDC + {usde_note}" return eq, "mainnet USDC" @@ -133,7 +140,8 @@ def _equity(client, marks): if any_ok and tot > 1: return tot, "mainnet coin-margined" # real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B): - # segnalato a book_execute (alert), non blocca (l'hard-cap $300/asset limita il downside). + # segnalato a book_execute, che in modo SOLO PROTEZIONE non manda alcun rebalance_signed — + # controlla comunque il disaster-SL, che dipende da posizione/mark, non da equity (#21). return None, "conto flat / non finanziato" @@ -158,7 +166,9 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - eq_basis = ("override" if equity_override is not None else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)") # Opzione B (diagnostica): ONLINE ma equity reale non leggibile -> sizing su paper_cap invece del - # conto reale. NON blocca (l'hard-cap $300/asset limita il downside), ma va SEGNALATO. + # conto reale. SEGNALATO, e book_execute passa a SOLO PROTEZIONE: nessun rebalance_signed (non + # si dimensiona un ordine senza sapere quanto vale il conto), ma il disaster-SL si controlla + # comunque per ogni asset (#21). eq_fallback = (f"equity reale non leggibile ({eq_src}) -> sizing su paper_cap ${paper_cap:,.0f} " f"invece del conto reale") if (equity_override is None and client is not None and not real_eq) else None @@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) - return dict( last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()), - online=(client is not None and marks_src.get("BTC") == "mainnet"), + # (#25) TUTTI i mark devono venire da mainnet, non solo BTC: con due asset, un solo + # controllo lasciava ETH libero di essere in fallback (close certificata) senza che + # `online` lo dicesse -> il gate offline di book_execute non si accorgeva del guasto. + online=(client is not None and all(marks_src.get(a) == "mainnet" for a in ASSETS)), real_equity=real_eq, equity=equity, eq_basis=eq_basis, pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback, assets=assets, orders=orders, live_trades=live_trades, diff --git a/tests/test_book_u2.py b/tests/test_book_u2.py new file mode 100644 index 0000000..bc0e410 --- /dev/null +++ b/tests/test_book_u2.py @@ -0,0 +1,78 @@ +"""Test U2 (gitea:Adriano/PythagorasGoal #20, #21, #22, #23, #25). + +Principio unico: quando il giro non sa QUANTO vale il conto (#20, #21) o QUALE e' il segnale +(#22), NON manda ordini di ribilancio, ma CONTROLLA LO STESSO il disaster-SL di ogni asset — la +protezione dipende da posizione e mark, non da equity o segnale. E un fill gia' eseguito non e' +un fill perso se un passo successivo del rebalance, o la lettura della posizione dopo, falliscono +(#23). `online` richiede TUTTI i mark da mainnet, non solo BTC (#25). + +Nessun test tocca la rete: trader ed eccezioni sono finti. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import carica_script +from src.live import shadow as S +from src.live.execution import DeribitTrader, Fill, RebalanceParziale + + +def _fresh_bar() -> str: + import pandas as _pd + return str(_pd.Timestamp.now(tz="UTC").normalize().date()) + + +# --------------------------------------------------------------------------- +# #20 — USDE illeggibile: l'equity diventa IGNOTA (None), non 0. +# --------------------------------------------------------------------------- +class _ClientUSDEBoom: + def __init__(self, usdc=2056.0): + self._usdc = usdc + + def account_summary(self, cur): + if cur == "USDE": + raise RuntimeError("gateway giu'") + return {"equity": self._usdc} + + +def test_usde_illeggibile_rende_equity_ignota_non_zero(): + eq, src = S._equity(_ClientUSDEBoom(), {}) + assert eq is None + assert "USDE illeggibile" in src and "RuntimeError" in src + + +def test_watermark_non_si_scrive_su_equity_ignota(tmp_path, monkeypatch): + """`write_equity_watermark` riceve None quando l'equity e' ignota: non deve scrivere il + file (gia' cosi' nel codice esistente — qui lo si blinda con un test, come chiesto).""" + import src.live.book as book + monkeypatch.setattr(book, "EQUITY_WATERMARK", tmp_path / "equity_seen.json") + assert book.write_equity_watermark(None) is None + assert not (tmp_path / "equity_seen.json").exists() + + +# --------------------------------------------------------------------------- +# #25 — online richiede TUTTI i mark mainnet, non solo BTC. +# --------------------------------------------------------------------------- +def test_online_richiede_tutti_i_mark_mainnet(monkeypatch): + monkeypatch.setattr(S, "_safe_client", lambda: object()) + monkeypatch.setattr(S, "_marks", lambda c, d: ( + {"BTC": 60000.0, "ETH": 3000.0}, + {"BTC": "mainnet", "ETH": "fallback close (RuntimeError)"})) + monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None)) + monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC")) + r = S.shadow_report() + assert r["online"] is False # prima di #25 sarebbe stato True (guardava solo BTC) + + +def test_online_true_quando_tutti_i_mark_sono_mainnet(monkeypatch): + monkeypatch.setattr(S, "_safe_client", lambda: object()) + monkeypatch.setattr(S, "_marks", lambda c, d: ( + {"BTC": 60000.0, "ETH": 3000.0}, {"BTC": "mainnet", "ETH": "mainnet"})) + monkeypatch.setattr(S, "_positions", lambda c: ({a: 0.0 for a in S.ASSETS}, "mainnet", None)) + monkeypatch.setattr(S, "_equity", lambda c, m: (600.0, "mainnet USDC")) + assert S.shadow_report()["online"] is True diff --git a/tests/test_shadow_usde.py b/tests/test_shadow_usde.py index 3284af5..e83e944 100644 --- a/tests/test_shadow_usde.py +++ b/tests/test_shadow_usde.py @@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero(): assert "non leggibile" in src -def test_gateway_usde_giu_non_rompe_la_lettura_usdc(): +def test_gateway_usde_giu_rende_lequity_IGNOTA_non_solo_usdc(): + """(#20) Comportamento cambiato: un'eccezione sulla lettura del conto USDE NON vale 0 USDE. + Prima di questa riparazione, `eq` restava $2056 (solo USDC) come se l'USDE non ci fosse: + se sul conto c'era davvero collaterale USDE, quel $2056 era un equity sottostimato spacciato + per certo. Ora l'intera equity e' IGNOTA (None) — coerente col trattamento di ogni altra + lettura fallita nel modulo (posizione, mark): non si assume, si segnala.""" eq, src = S._equity(_Client(usdc=2056.0, usde=500.0, usde_raises=True), {}) - assert eq == 2056.0 and src == "mainnet USDC" + assert eq is None + assert "USDE illeggibile" in src and "RuntimeError" in src