f82f685528
Decisi dall'operatore il 2026-09-10, verificati da revisione fable (15 segnalazioni, 12 applicate). - usde_watch: TRADE_LIMIT 1000 (count max Deribit) e trade_copertura(): lista troncata o ultima lettura oltre la finestra di 24h del gateway => somma NON leggibile, reward non attribuito (P12). Riga del 07/09 corretta nel log con campo `correzione` (400 USDE erano acquisti, non reward). - cuscino_watch.py (cron :53, monitor_health): equity USDC contro cuscino derivato da usde.cuscino_richiesto_usd (formula spostata in src/live/usde.py, usde_convert la importa); OK/PREAVVISO/SCOPERTO/BLIND; sotto zero lancia usde_convert --quota quota_ripristino(0.20)=0.64 --esegui con guardie (execution_enabled, depeg_warn, 1 tentativo/6h). Primo giro: PREAVVISO, +$26. - usde_convert: il tetto del venue vale solo in ACQUISTO (bloccava la vendita). - paper_prevday: GATE PREVDAY-01 cablato (2027-06-21, kill Sharpe giornaliero < -0,50 su >=180 g attivi, veto >=80% barre ricostruibili + divergenze non crescenti con soglia materiale). Oggi: +0,95 su 81 g, 1942/1943 ricostruibili, kill NON MATURO. - CLAUDE.md: arming 20/06 (TP01) / 23/06 (BOOK); piano EUR 5.000 chiuso col versamento 04/09 ($2.414,68 dal balance, dichiarato); SCALA-01 non prima del 2027-02-28 (A2 dal 01/09); PREVDAY-01 con data, kill, veto; §5.17 riparato con i limiti dichiarati (ratchet, slack zero a 0,70). - test: 1062 (+31): test_cuscino_watch (16), test_paper_prevday_gate (8), test_usde_watch (+5). Fixes #2 Fixes #3 Fixes #4 Fixes #5 Fixes #6 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
290 lines
15 KiB
Python
Executable File
290 lines
15 KiB
Python
Executable File
#!/usr/bin/env python
|
|
"""cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento. PUO' INVIARE ORDINI.
|
|
|
|
PERCHE' ESISTE (debito §5.17, issue #3). Dal 06/09 il 68,7% dell'equity e' USDE. P&L e funding dei
|
|
perp USDC-lineari si regolano in USDC: una perdita del libro consuma il cuscino e fa salire la quota
|
|
USDE DA SOLA, senza un ordine. `usde_watch` allerta solo su `quota > quota_max_frac` (0,85): a
|
|
68,7% lo slack sul cuscino era ~$41-58, e un saldo USDC negativo costa lo 0,05%/GIORNO (18,25%/anno,
|
|
4,3x la resa che l'USDE compra). Nessuno guardava la grandezza giusta.
|
|
|
|
LA MISURA (decisione dell'operatore, 10/09): l'EQUITY USDC del conto — balance piu' P&L non
|
|
realizzato — perche' e' quella che risponde del regolamento; il balance vedrebbe il buco solo a
|
|
trade chiuso. Il cuscino richiesto e' DERIVATO da config/live.json e src/live/book.py tramite
|
|
`usde.cuscino_richiesto_usd` (n_asset x frac x disaster_sl_pct x equity totale): la stessa formula
|
|
che `usde_convert` applica a ogni piano — un sorvegliante che ridichiara il proprio bersaglio non
|
|
sta controllando niente (P1, 5 occorrenze nel progetto).
|
|
|
|
TRE STATI, TRE AZIONI (P5: distinguere anche quando l'azione e' la stessa):
|
|
* OK — slack >= PREAVVISO_FRAC x cuscino: niente.
|
|
* PREAVVISO — 0 <= slack < 10% del cuscino: ⚠️ alla transizione. E' latenza comprata apposta:
|
|
un'ora in perdita non deve trovare il cuscino gia' scoperto.
|
|
* SCOPERTO — slack < 0: 🚨 alla transizione, e RICONVERSIONE AUTOMATICA (decisione
|
|
dell'operatore, 10/09): lancia `usde_convert --quota q --esegui` con
|
|
q = usde.quota_ripristino(MARGINE_RIPRISTINO), cioe' la quota che lascia il cuscino
|
|
piu' un 20% di margine, cosi' non si torna sul bordo alla prima ora in perdita.
|
|
Vende USDE contro USDC (~6 bps di spread, fee 0).
|
|
* BLIND — conto non leggibile: ⚠️ alla transizione. "Non vedo" non e' "va tutto bene".
|
|
|
|
LE GUARDIE DELLA RICONVERSIONE (tutte dichiarate qui):
|
|
* `execution_enabled` in config/live.json deve essere true — lo stesso interruttore del libro:
|
|
disarmare il libro disarma anche questo, senza un secondo posto da ricordare.
|
|
* NON si riconverte sotto `depeg_warn`: vendere USDE sotto la pari cristallizza la perdita del
|
|
collaterale; li' decide l'operatore, e l'allarme lo dice.
|
|
* al massimo UN tentativo ogni RIPRISTINO_MIN_ORE (6h): un tentativo fallito che si ripete ogni
|
|
ora e' spam, e un sorvegliante che si ripete si impara a ignorare (P14).
|
|
* `usde_convert` porta le proprie guardie (banda di prezzo, book leggibile, tetto HARD sulla
|
|
quota): questo script NON le duplica, le eredita lanciando lo script vero (P15: la regola si
|
|
prova contro il codice che la esegue).
|
|
* `--secco`: niente ordini, niente Telegram, NIENTE riga nel registro: stampa cosa FAREBBE.
|
|
* niente lock: un `usde_convert --esegui` lanciato a mano nello stesso minuto del cron (:53)
|
|
puo' vendere due volte verso bersagli diversi. Limite dichiarato (D5), non riparato.
|
|
|
|
LIMITE DICHIARATO — IL RIPRISTINO E' UN RATCHET VERSO IL BASSO. Dopo una riconversione la quota
|
|
resta a `quota_ripristino` (~0,64) e nessuno la riporta a `usde.quota_target` (0,70): ricomprare
|
|
USDE e' una decisione dell'operatore (`usde_convert --quota 0.70`), non un automatismo di questo
|
|
script. E a 0,70 lo slack e' ZERO per costruzione (§1: «coincidenza per costruzione, zero slack»),
|
|
quindi PREAVVISO e' lo stato NORMALE del conto a quota piena — allerta una volta, alla transizione.
|
|
|
|
TRASPORTO (lezione del debito §5.2): il marcatore "gia' detto" e' l'esito di `notify` scritto nel
|
|
record (`allertato`); se l'invio fallisce la transizione si ripete al giro dopo invece di andare
|
|
persa per l'episodio intero.
|
|
|
|
La serie data/live/cuscino_watch.jsonl e' dentro il perimetro di backup ed e' sorvegliata da
|
|
monitor_health (un watch fermo = nessuno guarda il cuscino, e il silenzio si legge come zero).
|
|
|
|
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
|
|
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
|
|
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from src.live import usde as U # noqa: E402
|
|
from src.live.notifier import notify # noqa: E402
|
|
|
|
STATE = ROOT / "data" / "live" / "cuscino_watch.jsonl"
|
|
CONVERT = ROOT / "scripts" / "live" / "usde_convert.py"
|
|
PREAVVISO_FRAC = 0.10 # sotto il 10% di slack (in frazione del cuscino) si preavvisa
|
|
MARGINE_RIPRISTINO = 0.20 # la riconversione lascia cuscino x 1,20: DOPPIO del preavviso, cosi'
|
|
# dopo il ripristino lo stato e' OK e non PREAVVISO per costruzione
|
|
# (revisione fable 10/09: a 0,10 == PREAVVISO_FRAC il floor di
|
|
# usde_convert lasciava il conto sotto la soglia)
|
|
RIPRISTINO_MIN_ORE = 6.0 # un tentativo al massimo ogni 6 ore
|
|
TIMEOUT_CONVERT_S = 900 # usde_convert spazia gli ordini di 12s: 60 ordini = 12 min
|
|
|
|
|
|
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 giudica(eq_usdc: float | None, equity_tot: float | None, cuscino: float | None,
|
|
preavviso_frac: float = PREAVVISO_FRAC) -> dict:
|
|
"""PURA. -> stato, slack, soglia di preavviso. `None` in ingresso = BLIND."""
|
|
if eq_usdc is None or equity_tot is None or cuscino is None:
|
|
return dict(stato="BLIND", slack=None, preavviso_usd=None)
|
|
slack = eq_usdc - cuscino
|
|
preavviso = preavviso_frac * cuscino
|
|
if slack < 0:
|
|
stato = "SCOPERTO"
|
|
elif slack < preavviso:
|
|
stato = "PREAVVISO"
|
|
else:
|
|
stato = "OK"
|
|
return dict(stato=stato, slack=round(slack, 2), preavviso_usd=round(preavviso, 2))
|
|
|
|
|
|
def transizione(prev: dict | None, stato: str) -> bool:
|
|
"""PURA. Si allerta se lo stato e' cambiato, O se l'ultima allerta per questo stato non e'
|
|
partita (`allertato` False): un invio fallito non consuma la transizione (debito §5.2)."""
|
|
if stato == "OK":
|
|
return False
|
|
if prev is None or prev.get("stato") != stato:
|
|
return True
|
|
return not bool(prev.get("allertato"))
|
|
|
|
|
|
def puo_riconvertire(prev_records: list[dict], now_ms: int, execution_enabled: bool,
|
|
px: float | None, depeg_warn: float,
|
|
min_ore: float = RIPRISTINO_MIN_ORE) -> tuple[bool, str]:
|
|
"""PURA. Le guardie della riconversione automatica. -> (si', motivo se no)."""
|
|
if not execution_enabled:
|
|
return False, "execution_enabled=false in config/live.json (il libro e' disarmato: anche questo)"
|
|
if px is None:
|
|
return False, "prezzo USDE non leggibile: non si vende al buio (P5)"
|
|
if px < depeg_warn:
|
|
return False, f"USDE {px:.4f} sotto depeg_warn {depeg_warn}: vendere ora cristallizza la perdita — decide l'operatore"
|
|
# contano solo i tentativi VERI (subprocess lanciato): un record con `tentata=False` — prezzo
|
|
# illeggibile, --secco, interruttore spento — non consuma il budget (revisione fable 10/09)
|
|
ultimo = [r for r in prev_records if (r.get("riconversione") or {}).get("tentata")]
|
|
if ultimo:
|
|
ore = (now_ms - int(ultimo[-1]["ts"])) / 3_600_000
|
|
if ore < min_ore:
|
|
return False, f"ultimo tentativo {ore:.1f}h fa (< {min_ore:.0f}h): non si insiste ogni ora"
|
|
return True, ""
|
|
|
|
|
|
def riconverti(quota: float, timeout_s: float = TIMEOUT_CONVERT_S) -> dict:
|
|
"""Lancia lo script VERO con le sue guardie. Ritorna esito + coda dell'output (P3)."""
|
|
cmd = [sys.executable, str(CONVERT), "--quota", f"{quota:.4f}", "--esegui"]
|
|
try:
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, cwd=str(ROOT))
|
|
coda = (r.stdout + r.stderr).strip().splitlines()[-12:]
|
|
return dict(rc=r.returncode, ok=r.returncode == 0, coda=coda, cmd=" ".join(cmd[1:]))
|
|
except subprocess.TimeoutExpired:
|
|
return dict(rc=None, ok=False, coda=[f"timeout dopo {timeout_s:.0f}s"], cmd=" ".join(cmd[1:]))
|
|
except Exception as e: # noqa: BLE001 — si registra, non si ingoia
|
|
return dict(rc=None, ok=False, coda=[f"{type(e).__name__}: {e}"], cmd=" ".join(cmd[1:]))
|
|
|
|
|
|
def _safe_client():
|
|
try:
|
|
from src.live.deribit import DeribitRead
|
|
return DeribitRead()
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _execution_enabled() -> bool:
|
|
try:
|
|
return bool(json.loads((ROOT / "config" / "live.json").read_text()).get("execution_enabled"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
quiet, secco = "--quiet" in sys.argv, "--secco" in sys.argv
|
|
c = U.cfg()
|
|
records = leggi()
|
|
prev = records[-1] if records else None
|
|
now = datetime.now(timezone.utc)
|
|
now_ms = int(now.timestamp() * 1000)
|
|
client = _safe_client()
|
|
|
|
eq_usdc = eq_usde = None
|
|
motivo_blind = None
|
|
if client is None:
|
|
motivo_blind = "gateway non raggiungibile"
|
|
else:
|
|
try:
|
|
eq_usdc = float(client.account_summary("USDC")["equity"])
|
|
except Exception as e:
|
|
motivo_blind = f"conto USDC non leggibile ({type(e).__name__})"
|
|
try:
|
|
eq_usde = float(client.account_summary("USDE").get("equity") or 0)
|
|
except Exception as e:
|
|
motivo_blind = motivo_blind or f"conto USDE non leggibile ({type(e).__name__})"
|
|
px, px_fonte = U.prezzo(client)
|
|
equity_tot = cuscino = come = None
|
|
if eq_usdc is not None and eq_usde is not None:
|
|
usd, _ = U.valuta(eq_usde, px)
|
|
equity_tot = eq_usdc + usd
|
|
cuscino, come = U.cuscino_richiesto_usd(equity_tot)
|
|
g = giudica(eq_usdc, equity_tot, cuscino)
|
|
stato = g["stato"]
|
|
|
|
rec = dict(ts=now_ms, data=now.strftime("%Y-%m-%dT%H:%M:%SZ"), stato=stato,
|
|
motivo_blind=motivo_blind, eq_usdc=eq_usdc, eq_usde=eq_usde, px=px,
|
|
px_fonte=px_fonte, equity_tot=equity_tot, cuscino=cuscino, cuscino_come=come,
|
|
slack=g["slack"], preavviso_usd=g["preavviso_usd"], allertato=None,
|
|
riconversione=None, secco=secco)
|
|
|
|
# --- riconversione automatica: solo SCOPERTO, solo se le guardie lo permettono ---
|
|
if stato == "SCOPERTO":
|
|
ok, perche = puo_riconvertire(records, now_ms, _execution_enabled(), px, c["depeg_warn"])
|
|
quota = U.quota_ripristino(MARGINE_RIPRISTINO)
|
|
if not ok:
|
|
rec["riconversione"] = dict(tentata=False, quota=quota, motivo=perche)
|
|
elif secco:
|
|
rec["riconversione"] = dict(tentata=False, quota=quota,
|
|
motivo=f"--secco: avrei lanciato usde_convert --quota {quota:.4f} --esegui")
|
|
else:
|
|
esito = riconverti(quota)
|
|
rec["riconversione"] = dict(tentata=True, quota=quota, **esito)
|
|
|
|
# --- allarmi: alla transizione, marcatore scritto con l'esito dell'invio ---
|
|
if transizione(prev, stato):
|
|
if secco:
|
|
rec["allertato"] = False
|
|
elif stato == "SCOPERTO":
|
|
r = rec["riconversione"] or {}
|
|
azione = ("riconversione " + ("ESEGUITA" if r.get("ok") else "FALLITA")
|
|
+ f" (quota -> {r.get('quota', 0):.0%})" if r.get("tentata")
|
|
else f"NON riconvertito: {r.get('motivo')}")
|
|
rec["allertato"] = notify("🚨 CUSCINO USDC SCOPERTO", {
|
|
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f} ({come})",
|
|
"slack": f"${g['slack']:+,.2f}", "azione": azione,
|
|
"nota": "un USDC negativo costa 0,05%/giorno; il libro si regola in USDC"}, tentativi=3)
|
|
elif stato == "PREAVVISO":
|
|
rec["allertato"] = notify("⚠️ cuscino USDC in esaurimento", {
|
|
"USDC equity": f"${eq_usdc:,.2f}", "cuscino richiesto": f"${cuscino:,.2f}",
|
|
"slack": f"${g['slack']:+,.2f} (< {PREAVVISO_FRAC:.0%} del cuscino)",
|
|
"nota": f"sotto zero riconverte da solo a quota {U.quota_ripristino(MARGINE_RIPRISTINO):.0%}"})
|
|
elif stato == "BLIND":
|
|
rec["allertato"] = notify("⚠️ cuscino_watch BLIND", {"motivo": motivo_blind or "?",
|
|
"nota": "'non vedo' non e' 'va tutto bene' (P5)"})
|
|
elif stato == "SCOPERTO" and rec["riconversione"] and rec["riconversione"].get("tentata") and not secco:
|
|
# non e' una transizione ma e' un ordine mandato: si dice sempre
|
|
r = rec["riconversione"]
|
|
rec["allertato"] = notify("📌 cuscino USDC: riconversione " + ("eseguita" if r["ok"] else "FALLITA"),
|
|
{"quota bersaglio": f"{r['quota']:.0%}", "esito": " | ".join(r["coda"][-3:])})
|
|
|
|
# --secco e' secco ANCHE sul registro: un giro a mano non deve diventare il `prev` del cron,
|
|
# ne' far sembrare vivo un monitor fermo (revisione fable 10/09)
|
|
if not secco:
|
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
with STATE.open("a") as fh:
|
|
fh.write(json.dumps(rec) + "\n")
|
|
|
|
cambiato = prev is None or prev.get("stato") != stato or rec["riconversione"] is not None
|
|
if not quiet or cambiato:
|
|
print("=" * 78)
|
|
print(f" CUSCINO WATCH — {rec['data']} {'(SECCO)' if secco else ''}")
|
|
print("=" * 78)
|
|
if stato == "BLIND":
|
|
print(f" stato : BLIND — {motivo_blind}")
|
|
else:
|
|
print(f" conto : USDC equity ${eq_usdc:,.2f} + USDE {eq_usde:,.2f} @ {px if px else 'n/d'}"
|
|
f" ({px_fonte}) = ${equity_tot:,.2f}")
|
|
print(f" cuscino : ${cuscino:,.2f} richiesti ({come})")
|
|
print(f" slack : ${g['slack']:+,.2f} (preavviso sotto ${g['preavviso_usd']:,.2f})")
|
|
print(f" stato : {stato}")
|
|
if rec["riconversione"]:
|
|
r = rec["riconversione"]
|
|
print(f" riconvers. : {'TENTATA' if r.get('tentata') else 'NON tentata'} — quota {r['quota']:.4f}"
|
|
+ (f" — {r.get('motivo')}" if r.get("motivo") else ""))
|
|
for ln in r.get("coda", []):
|
|
print(f" {ln}")
|
|
if rec["allertato"] is not None:
|
|
print(f" allerta : {'inviata' if rec['allertato'] else 'NON inviata (si ripete al giro dopo)'}")
|
|
return 0
|
|
|
|
|
|
USO = """uso: cuscino_watch.py [--quiet] [--secco]
|
|
|
|
cuscino_watch.py — sorveglianza ORARIA del cuscino USDC di regolamento; sotto zero riconverte.
|
|
uv run python scripts/live/cuscino_watch.py # un giro: legge, giudica, agisce
|
|
uv run python scripts/live/cuscino_watch.py --quiet # stampa solo se cambia qualcosa
|
|
uv run python scripts/live/cuscino_watch.py --secco # nessun ordine, nessun Telegram
|
|
|
|
Dettaglio nel docstring in testa al file."""
|
|
|
|
if __name__ == "__main__":
|
|
from src.live.cli import valida
|
|
valida("cuscino_watch.py", USO, flag=("--quiet", "--secco"), con_valore=())
|
|
sys.exit(main())
|