Files
PythagorasGoal/src/live/shadow.py
T
Adriano cfc42653cc 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>
2026-09-27 13:33:48 +00:00

232 lines
10 KiB
Python

"""Stato SHADOW di TP01 (Deribit mainnet, SOLA LETTURA): target causali + conto/posizioni REALI +
ordini di ribilancio COSTRUITI (mai inviati). Modulo condiviso da `scripts/live/live_trend.py` (CLI)
e dalla dashboard, cosi' i due non divergono. Robusto ai fallimenti di rete: degrada a offline/flat
senza sollevare eccezioni (la dashboard non deve crashare se il mainnet non risponde)."""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
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]
ASSETS = ["BTC", "ETH"]
WEIGHT = 0.5
FALLBACK_CAPITAL = 2000.0
# Fallback LEGACY: lo scrittore (paper_trend.py) e' RITIRATO 2026-07-17 (-> Old/). Il file non esiste
# piu' -> guardia .exists() sotto degrada a FALLBACK_CAPITAL (=2000) e paper_pos=None (parita' disattiva).
PAPER_STATE = PROJECT_ROOT / "data" / "paper_trend" / "state.json"
def tp01_trades(limit: int = 15) -> list[dict]:
"""Lista dei TRADE di TP01 = cambi di posizione (ENTRY long / EXIT flat) dedotti dal segnale
causale `target_series` sui dati certificati. Account-independent (gira anche offline nel
container). Si ricalcola a ogni render -> include sia lo storico sia i trade forward man mano
che arrivano. Ritorna gli ultimi `limit` (piu' recenti primi)."""
tp = TrendPortfolio(**CANONICAL)
out: list[dict] = []
for a in ASSETS:
df = resample_1d(load(a, "1h"))
c = df["close"].to_numpy(dtype=float)
dt = pd.to_datetime(df["datetime"]).to_numpy()
tgt = tp.target_series(df)
prev = 0.0
for i in range(len(tgt)):
if np.sign(tgt[i]) != np.sign(prev):
out.append(dict(
ts=int(pd.Timestamp(dt[i]).value // 10**6),
date=str(pd.Timestamp(dt[i]).date()),
asset=a,
action="ENTRY" if tgt[i] != 0 else "EXIT",
from_pos=round(float(prev), 3),
to_pos=round(float(tgt[i]), 3),
price=round(float(c[i]), 1),
))
prev = tgt[i]
out.sort(key=lambda r: r["ts"], reverse=True)
return out[:limit]
def _safe_client() -> DeribitRead | None:
try:
return DeribitRead()
except Exception:
return None
def _marks(client, dfs):
marks, src = {}, {}
for a in ASSETS:
if client is None:
marks[a], src[a] = float(dfs[a]["close"].iloc[-1]), "close certificata"
continue
try:
marks[a], src[a] = client.mark_price(INSTRUMENT[a]), "mainnet"
except Exception as e:
marks[a], src[a] = float(dfs[a]["close"].iloc[-1]), f"fallback close ({type(e).__name__})"
return marks, src
def _positions(client):
if client is None:
return {a: 0.0 for a in ASSETS}, "offline -> assunto flat", None
pos, note, failed = {}, "mainnet", []
for a in ASSETS:
try:
pos[a] = client.position_usd(INSTRUMENT[a])
except Exception as e:
pos[a], note = 0.0, f"read fallito ({type(e).__name__}) -> assunto flat"
failed.append(f"{a} ({type(e).__name__}: {e})")
# pos_error != None SOLO se ONLINE ma la read ha lanciato: la posizione e' IGNOTA (assunta flat) ->
# l'esecutore NON deve operare a cieco. L'offline (client=None) e' gestito a parte dal gate 'online'.
err = ("posizione non leggibile, assunta FLAT: " + ", ".join(failed)) if failed else None
return pos, note, err
# 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 | 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.
⚠️ (#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 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)
return _usde.valuta(eq, px)
def _equity(client, marks):
if client is None:
return None, "offline"
try:
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"
except Exception:
pass
tot, any_ok = 0.0, False
for a in ASSETS:
try:
eq = float(client.account_summary(a).get("equity") or 0)
tot += eq * marks[a]
any_ok = True
except Exception:
pass
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, 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"
def shadow_report(offline: bool = False, equity_override: float | None = None) -> dict:
"""Calcola lo stato shadow completo. NON invia nulla. Ritorna un dict serializzabile."""
dfs = {a: resample_1d(load(a, "1h")) for a in ASSETS}
tp = TrendPortfolio(**CANONICAL)
targets = {a: tp.current_target(dfs[a]) for a in ASSETS}
last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS)
client = None if offline else _safe_client()
marks, marks_src = _marks(client, dfs)
positions, pos_src, pos_error = _positions(client)
real_eq, eq_src = _equity(client, marks)
paper = json.loads(PAPER_STATE.read_text()) if PAPER_STATE.exists() else None
paper_cap = float(paper["capital"]) if paper else FALLBACK_CAPITAL
paper_pos = paper.get("positions") if paper else None
paper_ts = int(paper["last_ts"]) if paper else 0
equity = equity_override if equity_override is not None else (real_eq if real_eq else paper_cap)
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. 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
assets, orders = [], []
for a in ASSETS:
inst = INSTRUMENT[a]
order = build_rebalance_order(inst, targets[a], WEIGHT, equity, positions[a], price=marks[a])
if order:
orders.append(order)
parity = None
if paper_pos is not None:
parity = abs(float(paper_pos.get(a, 0.0)) - targets[a]) < 1e-9
assets.append(dict(
asset=a, instrument=inst, target=targets[a],
target_notional=WEIGHT * targets[a] * equity,
position_usd=positions[a], mark=marks[a], mark_src=marks_src[a],
order=order, paper=(float(paper_pos.get(a, 0.0)) if paper_pos else None), parity=parity,
))
live_trades = []
if client is not None:
for a in ASSETS:
try:
for tr in client.trade_history(INSTRUMENT[a], limit=8):
live_trades.append(dict(
ts=int(tr.get("timestamp") or 0), instrument=INSTRUMENT[a],
direction=(tr.get("direction") or "").upper(),
amount=float(tr.get("amount") or 0), price=float(tr.get("price") or 0),
fee=float(tr.get("fee") or 0)))
except Exception:
pass
live_trades.sort(key=lambda r: r["ts"], reverse=True)
live_trades = live_trades[:12]
disaster_sls = []
if client is not None:
for a in ASSETS:
try:
for o in client.open_orders(INSTRUMENT[a]):
if (o.get("label") or "") == DISASTER_LABEL:
disaster_sls.append(dict(
asset=a, instrument=INSTRUMENT[a],
amount=float(o.get("amount") or 0),
stop=float(o.get("trigger_price") or o.get("stop_price") or o.get("price") or 0)))
except Exception:
pass
return dict(
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
# (#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,
disaster_sls=disaster_sls,
flat=all(abs(targets[a]) < 1e-9 for a in ASSETS),
paper_aligned=(paper_ts == last_ts),
)