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:
+24
-1
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user