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 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:33:48 +00:00
parent 1a7f670966
commit cfc42653cc
4 changed files with 152 additions and 32 deletions
+24 -1
View File
@@ -29,6 +29,19 @@ class GuardrailError(RuntimeError):
pass 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 @dataclass
class Fill: class Fill:
"""Esito verificato di un ordine reale.""" """Esito verificato di un ordine reale."""
@@ -150,11 +163,17 @@ class DeribitTrader(DeribitRead):
- target ~0 -> close(); - target ~0 -> close();
- stesso segno, |target|<|cur| -> REDUCE reduce_only; - stesso segno, |target|<|cur| -> REDUCE reduce_only;
- apertura/aumento -> open buy/sell (capped dal guardrail apertura). - 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) cur = self.position_usd(instrument)
if abs(target_notional_usd - cur) < min_usd: if abs(target_notional_usd - cur) < min_usd:
return [] return []
fills: list[Fill] = [] fills: list[Fill] = []
try:
crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \ crossing = (cur > FLAT_USD and target_notional_usd < -FLAT_USD) or \
(cur < -FLAT_USD and target_notional_usd > FLAT_USD) (cur < -FLAT_USD and target_notional_usd > FLAT_USD)
if crossing: # flip: flatta, poi riparti da zero if crossing: # flip: flatta, poi riparti da zero
@@ -180,6 +199,10 @@ class DeribitTrader(DeribitRead):
side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target side = "buy" if target_notional_usd > 0 else "sell" # apri/aumenta verso il target
fills.append(self.open(instrument, side, amount, label="book-open")) fills.append(self.open(instrument, side, amount, label="book-open"))
return fills 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) --- # --- DISASTER BRACKET (assicurazione on-book per outage; da Old) ---
def place_disaster_sl(self, instrument: str, side_held: str, amount: float, def place_disaster_sl(self, instrument: str, side_held: str, amount: float,
+20 -7
View File
@@ -95,15 +95,20 @@ def _positions(client):
USDE_INDEX_URL = _usde.index_url() 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 """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 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, 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: try:
eq = float(client.account_summary("USDE").get("equity") or 0) eq = float(client.account_summary("USDE").get("equity") or 0)
except Exception: except Exception as e:
return 0.0, "" return None, f"USDE illeggibile: {type(e).__name__}: {e}"
if eq <= 0: if eq <= 0:
return 0.0, "" return 0.0, ""
px, _fonte = _usde.prezzo(client, url=USDE_INDEX_URL) 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) eq = float(client.account_summary("USDC").get("equity") or 0)
if eq > 1: if eq > 1:
usde_usd, usde_note = _collaterale_usde(client) 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: if usde_usd > 0:
return eq + usde_usd, f"mainnet USDC + {usde_note}" return eq + usde_usd, f"mainnet USDC + {usde_note}"
return eq, "mainnet USDC" return eq, "mainnet USDC"
@@ -133,7 +140,8 @@ def _equity(client, marks):
if any_ok and tot > 1: if any_ok and tot > 1:
return tot, "mainnet coin-margined" return tot, "mainnet coin-margined"
# real_eq=None qui -> shadow_report ripiega su paper_cap e alza eq_fallback (diagnostica B): # 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" 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 eq_basis = ("override" if equity_override is not None
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)") 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 # 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} " 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 f"invece del conto reale") if (equity_override is None and client is not None
and not real_eq) else None and not real_eq) else None
@@ -208,7 +218,10 @@ def shadow_report(offline: bool = False, equity_override: float | None = None) -
return dict( return dict(
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()), 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, real_equity=real_eq, equity=equity, eq_basis=eq_basis,
pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback, pos_src=pos_src, pos_error=pos_error, eq_fallback=eq_fallback,
assets=assets, orders=orders, live_trades=live_trades, assets=assets, orders=orders, live_trades=live_trades,
+78
View File
@@ -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
+8 -2
View File
@@ -73,6 +73,12 @@ def test_indice_illeggibile_vale_uno_DICHIARATO_mai_zero():
assert "non leggibile" in src 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), {}) 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