cinque punti della revisione 09/09: usde_watch a 1000 trade + finestra 24h, cuscino_watch (equity USDC, riconverte da solo), PREVDAY-01 kill/veto cablati, SCALA-01 al 2027-02-28, versamento 04/09 dichiarato

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
This commit is contained in:
Adriano Dal Pastro
2026-09-10 13:24:28 +00:00
parent 79afe41ec2
commit f82f685528
13 changed files with 1110 additions and 36 deletions
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Sorveglianza ORARIA del cuscino USDC di regolamento (debito §5.17, issue #3) — v2.0.0+.
# Minuto :53 = DOPO il giro del book (:47, i cui fill si regolano in USDC) e fuori dal minuto
# tondo (rate-limit per-IP), dal :25 (catena), dal :35 (usde) e dal :42 (balance).
# PUO' INVIARE ORDINI: sotto zero di slack lancia usde_convert --esegui (vende USDE), con le
# guardie dichiarate nel docstring di scripts/live/cuscino_watch.py; lo interruttore e' lo
# stesso del libro (execution_enabled in config/live.json).
export PATH="/home/adriano/.local/bin:$PATH"
cd /opt/docker/PythagorasGoal || exit 1
mkdir -p logs
{
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_cuscino ====="
uv run python scripts/live/cuscino_watch.py --quiet || true
echo "===== done $(date -u '+%H:%M:%SZ') ====="
} >> logs/cron_cuscino.log 2>&1
+289
View File
@@ -0,0 +1,289 @@
#!/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())
+163 -1
View File
@@ -53,6 +53,21 @@ MODELED_CAPITAL = 2000.0 # nominale, ribilanciamento continuo
REAL_CAPITAL = 600.0 # capitale mainnet reale
MIN_ORDER = 5.0 # min order Deribit -> sotto, il conto vero NON ribilancia
# --- GATE PRE-REGISTRATO `GATE PREVDAY-01` (2026-08-23, RESULTS-0822 §54) -----------------------
# Cablato qui il 2026-09-10 (issue #4): esisteva solo nel testo, e la revisione del 09/09 lo ha
# letto come "gate senza data". Decisione dell'operatore: tenere la data, cablare kill e veto.
GATE_DATE = "2027-06-21" # decisione piena: 7 condizioni (a)-(g), tutte necessarie
KILL_SHARPE = -0.50 # kill: Sharpe forward < -0,50 ...
KILL_MIN_ACTIVE_DAYS = 180 # ... su >= 180 giorni di barre ATTIVE (posizione != 0)
MIN_RECON_FRAC = 0.80 # veto d'integrita': >= 80% di barre ricostruibili dal feed
RECON_TOL = 1.5e-6 # net_modeled e' registrato a 6 decimali: mezzo ulp di tolleranza
RECENTI_GIORNI = 30 # "i minuti non registrati non devono crescere": ultimi 30g vs tutto
MIN_DIV_CRESCITA = 5 # ... ma UNA barra divergente su 1943 non e' una crescita (P14): la
# crescita richiede >= 5 divergenti recenti E tasso doppio del totale
# LENTE: il kill si misura sullo Sharpe GIORNALIERO (somma delle barre orarie per giorno UTC),
# la lente con cui e' misurato ogni altro sleeve del progetto — §54: sulla lente ORARIA lo stesso
# forward vale +2,04 contro +1,56 giornaliero. Al kill la famiglia NON si ri-ottimizza.
def build_bars() -> dict[str, pd.DataFrame]:
return {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
@@ -145,6 +160,151 @@ def advance(st: dict, dfs: dict) -> dict:
return st
def _returns_df() -> pd.DataFrame | None:
if not RETURNS_FILE.exists():
return None
r = pd.read_json(RETURNS_FILE, lines=True)
if r.empty:
return None
r["dt"] = pd.to_datetime(r["ts"], unit="ms", utc=True)
return r
def sharpe_giornaliero(r: pd.DataFrame, col: str = "net_modeled") -> tuple[float, int]:
"""PURA. Sharpe annualizzato sui rendimenti GIORNALIERI (somma delle barre orarie per giorno
UTC) -> (sharpe, n_giorni). nan sotto 30 giorni o a varianza nulla."""
d = r.groupby(r["dt"].dt.floor("D"))[col].sum()
if len(d) < 30 or d.std() == 0:
return float("nan"), int(len(d))
return float(d.mean() / d.std() * np.sqrt(365.25)), int(len(d))
def giorni_attivi(r: pd.DataFrame) -> int:
"""PURA. Giorni UTC con almeno una barra a posizione != 0 (il kill conta le barre ATTIVE).
⚠️ le posizioni registrate sono quelle del libro REAL-$600 (advance scrive `pr`), lo Sharpe
del kill e' MODELED: divergono solo se un ingresso da 0 vale < $5 di nozionale, cosa che a
vol-target 20% non succede (10/09: 81/81 giorni). Dichiarato, non riparato (D5)."""
att = r[(r["pos_btc"] != 0) | (r["pos_eth"] != 0)]
return int(att["dt"].dt.floor("D").nunique())
def kill_check(sharpe: float, n_attivi: int, today: pd.Timestamp) -> tuple[str, str]:
"""PURA. -> (stato, motivo). KILL solo con >= 180 giorni attivi E Sharpe < -0,50 (§54):
a quell'orizzonte P(uccidere un edge vivo a Sharpe 1,2) = 9,7%."""
if n_attivi < KILL_MIN_ACTIVE_DAYS:
return "NON_MATURO", (f"{n_attivi} giorni attivi < {KILL_MIN_ACTIVE_DAYS}: il kill non e' "
f"leggibile (mancano {KILL_MIN_ACTIVE_DAYS - n_attivi} giorni)")
if not np.isfinite(sharpe):
return "NON_MISURABILE", "Sharpe forward non misurabile"
if sharpe < KILL_SHARPE:
return "KILL", f"Sharpe {sharpe:+.2f} < {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi -> RITIRARE, senza ri-ottimizzare"
return "SUPERATO", f"Sharpe {sharpe:+.2f} >= {KILL_SHARPE:+.2f} su {n_attivi} giorni attivi"
def ricostruzione(r: pd.DataFrame, dfs: dict, start_ts: int) -> dict:
"""Rigioca la strategia CONGELATA sul feed di OGGI dallo `start_ts` del monitor e confronta
barra per barra `net_modeled` registrato con quello ricalcolato (ribilanciamento continuo,
la stessa aritmetica di `advance`). Una barra e' RICOSTRUIBILE se coincide entro RECON_TOL.
Cosa misura: quante barre registrate sono riproducibili dal dato certificato. Le divergenti
sono barre calcolate su un feed che il rebuild ha poi rivisto (§54: 67 su 1512, tutte
all'ora del cron) — e' la misura del veto d'integrita' (>= 80%, e i minuti persi non devono
crescere: qui, divergenti/giorno negli ultimi 30g contro l'intera finestra)."""
data = {}
for a in ASSETS:
df = dfs[a]
c = df["close"].values.astype(float)
rr = np.zeros(len(c)); rr[1:] = c[1:] / c[:-1] - 1.0
data[a] = dict(ts=df["timestamp"].values.astype("int64"), r=rr, tgt=prevday_target(df))
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
# posizione iniziale come init_state: target all'ultima barra <= start_ts
pos = {}
for a in ASSETS:
i0 = max(i for t, i in idx[a].items() if t <= start_ts)
pos[a] = float(data[a]["tgt"][i0])
ricalc, presenti = [], []
for t in r["ts"].astype("int64"):
t = int(t)
if any(t not in idx[a] for a in ASSETS):
ricalc.append(np.nan); presenti.append(False); continue
net = 0.0
for a in ASSETS:
i = idx[a][t]
tgt = float(data[a]["tgt"][i])
net += WEIGHT * (pos[a] * float(data[a]["r"][i]) - FEE_SIDE * abs(tgt - pos[a]))
pos[a] = tgt
ricalc.append(net); presenti.append(True)
ricalc = np.asarray(ricalc, dtype=float)
reg = r["net_modeled"].values.astype(float)
ok = np.isfinite(ricalc) & (np.abs(ricalc - reg) <= RECON_TOL)
n = len(reg)
giorni = r["dt"].dt.floor("D")
span_g = max(1, int(giorni.nunique()))
recenti = r["dt"] >= (r["dt"].max() - pd.Timedelta(days=RECENTI_GIORNI))
div_rec = int((~ok[recenti.values]).sum())
g_rec = max(1, int(giorni[recenti].nunique()))
return dict(n=n, ricostruibili=int(ok.sum()), frac=float(ok.sum() / n) if n else float("nan"),
assenti_nel_feed=int((~np.asarray(presenti)).sum()),
div_per_giorno=float((n - ok.sum()) / span_g),
div_recenti=div_rec, div_per_giorno_recenti=float(div_rec / g_rec),
divergenti_ts=[int(t) for t in r["ts"].values[~ok]][:20])
def veto_check(frac: float, div_g_tot: float, div_g_rec: float, div_rec: int) -> tuple[bool, str]:
"""PURA. Veto d'integrita' (blocca, non decide): sotto l'80% di barre ricostruibili, o con le
divergenze che CRESCONO negli ultimi 30 giorni, la finestra si ESTENDE invece di decidere."""
motivi = []
if not np.isfinite(frac) or frac < MIN_RECON_FRAC:
motivi.append(f"barre ricostruibili {frac:.1%} < {MIN_RECON_FRAC:.0%}")
if div_rec >= MIN_DIV_CRESCITA and div_g_rec > 2.0 * div_g_tot + 1e-12:
motivi.append(f"divergenze in crescita: {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g sull'intera finestra)")
return (not motivi), ("; ".join(motivi) if motivi else
f"ricostruibili {frac:.1%}, divergenti {div_rec} negli ultimi {RECENTI_GIORNI}g "
f"({div_g_rec:.2f}/g contro {div_g_tot:.2f}/g totale)")
def print_gate(st: dict, dfs: dict, today: pd.Timestamp | None = None) -> dict:
"""Stampa lo stato del gate pre-registrato e lo RITORNA (per i test e per chi legge il log).
Non decide niente: al kill e alla data stampa cosa la regola dice, in maiuscolo."""
r = _returns_df()
out = dict(gate_date=GATE_DATE, kill="NON_MISURABILE", veto_ok=None)
print(f"\n GATE PRE-REGISTRATO `PREVDAY-01` (23/08, RESULTS-0822 §54) — decisione {GATE_DATE}")
if r is None or len(r) < 2:
print(" serie forward assente o troppo corta: niente da misurare")
return out
today = today or pd.Timestamp.now(tz="UTC").normalize()
sh, n_g = sharpe_giornaliero(r)
n_att = giorni_attivi(r)
stato, motivo = kill_check(sh, n_att, today)
out.update(sharpe_giornaliero=sh, n_giorni=n_g, n_attivi=n_att, kill=stato, kill_motivo=motivo)
print(f" Sharpe forward GIORNALIERO (MODELED): {sh:+.2f} su {n_g} giorni, {n_att} attivi"
f" [lente oraria {_sharpe_orario(r):+.2f}: non si cita, §54]")
if stato == "KILL":
print(f" *** KILL: {motivo} ***")
else:
print(f" kill (Sharpe < {KILL_SHARPE:+.2f} su >= {KILL_MIN_ACTIVE_DAYS}g attivi): {stato}{motivo}")
rc = ricostruzione(r, dfs, int(st["start_ts"]))
ok, vm = veto_check(rc["frac"], rc["div_per_giorno"], rc["div_per_giorno_recenti"], rc["div_recenti"])
out.update(ricostruzione=rc, veto_ok=ok, veto_motivo=vm)
print(f" veto d'integrita': {'ok' if ok else '*** VETO — la finestra si ESTENDE ***'}{vm}"
f" ({rc['ricostruibili']}/{rc['n']} barre, {rc['assenti_nel_feed']} assenti nel feed)")
if today >= pd.Timestamp(GATE_DATE, tz="UTC"):
print(f" *** DECISIONE DOVUTA ({GATE_DATE}): (a) DSR >= 0,95 sulla famiglia dichiarata "
f"(b) cella al buio == congelata (c) delta libro > +0,05 col funding (d) weights_tilt_null "
f"(e) ADDS+robust_oos+non-hedge sulla cella al buio (f) day_boundary_robust "
f"(g) Sharpe forward > 0 [{sh:+.2f}] — TUTTE necessarie ***")
else:
print(f" decisione fra {(pd.Timestamp(GATE_DATE, tz='UTC') - today).days} giorni; "
f"le condizioni (a)-(b) sono STRUTTURALI e il forward non le cambia (§54)")
return out
def _sharpe_orario(r: pd.DataFrame) -> float:
x = r["net_modeled"].astype(float)
return float(x.mean() / x.std() * np.sqrt(24 * 365.25)) if x.std() > 0 else float("nan")
def print_status(st: dict, dfs: dict):
days = (max(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS) - st["start_ts"]) / 86400_000
rm = st["cap_modeled"] / MODELED_CAPITAL - 1
@@ -156,7 +316,9 @@ def print_status(st: dict, dfs: dict):
print(f" MODELED ($2000 nominale): {rm*100:+6.2f}% eq ${st['cap_modeled']:.2f} maxDD {st['dd_modeled']*100:.1f}%")
print(f" REAL-$600 (min-order $5) : {rr*100:+6.2f}% eq ${st['cap_real']:.2f} maxDD {st['dd_real']*100:.1f}%")
print(f" -> fill-haircut MODELED-REAL: {(rm-rr)*100:+.2f} pp (lo scettico l'aveva segnalato)")
print(f" log: {RETURNS_FILE}\n")
print(f" log: {RETURNS_FILE}")
print_gate(st, dfs)
print()
def main():
+7 -10
View File
@@ -88,15 +88,10 @@ EPS_CUSCINO = 0.01 # 1 centesimo: il criterio e' ">= cuscino", e
# questo epsilon il rumore float boccia il caso di pareggio.
def cuscino_richiesto_usd(equity_tot: float) -> tuple[float, str]:
"""Cuscino USDC di REGOLAMENTO = disaster-SL sulla massima esposizione lorda del libro.
Derivato da config/live.json + src/live/book.py, mai ridichiarato qui."""
cfg = json.loads((ROOT / "config" / "live.json").read_text())
sl = float(cfg["disaster_sl_pct"])
from src.live import book as B
frac, n_asset = float(B.WEIGHT), 2
lordo_max = equity_tot * frac * n_asset
return lordo_max * sl, f"{n_asset} asset x frac {frac} x SL {sl:.0%} su ${equity_tot:,.2f}"
# Il cuscino di regolamento vive in src/live/usde.py dal 10/09 (issue #3): cuscino_watch lo
# sorveglia ogni ora e questo script lo rispetta a ogni piano — due lettori, UNA formula (P1).
# L'alias resta per r0906_usde_tetto_sonda, che lo importa da qui.
cuscino_richiesto_usd = U.cuscino_richiesto_usd
def book_top(timeout: float = 5.0) -> tuple[float | None, float | None]:
@@ -165,7 +160,9 @@ def piano(eq_usdc: float, eq_usde: float, px: float, quota_target: float,
motivi.append(f"gia' a bersaglio ({quota_ora:.1%} vs {quota_target:.0%}, toll {TOLL:.1%})")
if abs(delta_usd) < MIN_ORDER_USD:
motivi.append(f"delta ${abs(delta_usd):,.2f} sotto il minimo ${MIN_ORDER_USD:,.2f}")
if cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
# il tetto del venue limita l'ACQUISTO di USDE: una VENDITA va sempre verso il tetto, e bloccarla
# renderebbe morta la riconversione automatica di cuscino_watch (revisione fable 10/09)
if side == "buy" and cap_usd is not None and val_target > cap_usd + EPS_CUSCINO:
motivi.append(
f"TETTO DEL VENUE: bersaglio ${val_target:,.2f} sopra ${cap_usd:,.2f} "
f"({cap_frac:.1%} dell'equity, misurato {cfg.get('venue_cap_misurato')}). "
+54 -11
View File
@@ -66,6 +66,25 @@ VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conc
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
# --- lettura dei trade spot: i DUE limiti del canale, misurati il 2026-09-10 -----------------
# (1) `count` massimo di Deribit per get_user_trades_by_instrument: 1000. Il 07/09 il lettore
# chiedeva limit=50 e la sonda del 06/09 aveva fatto 54 ordini: i 4 non letti (400 USDE)
# sono finiti nel «reward» (issue #2). Se il venue restituisce ESATTAMENTE `limit` righe la
# lista puo' essere troncata e la somma NON e' leggibile (P12: non si attribuisce).
# (2) il gateway chiama l'endpoint senza `historical`/timestamp: Deribit restituisce solo le
# ultime 24h — DOCUMENTATO ("Accessing historical trades and orders using API": recent
# trades 24h, orders 30 min, count max 1000) e misurato (il fill 08/09 06:47Z invisibile
# alle 12:55Z del 10/09, quello del 09/09 21:47Z visibile). Se l'ultima lettura e' piu'
# vecchia della finestra, i trade fra la lettura e il bordo sono INVISIBILI e la somma non
# e' leggibile.
# ⚠️ BUCO DICHIARATO (D5): il cron gira ogni 24h + pochi secondi; con la tolleranza di 5 min
# i trade nei primi ≤5 min dopo la lettura precedente sarebbero invisibili e sommati come
# «leggibile». Innocuo: nessun attrezzo converte USDE alle 12:35 UTC, e senza tolleranza il
# giro quotidiano sarebbe SEMPRE «non leggibile» e il tasso non avrebbe mai finestre nuove.
TRADE_LIMIT = 1000
FINESTRA_GATEWAY_MS = 24 * 3_600_000
TOLL_FINESTRA_MS = 5 * 60_000
def _parse(ts: str) -> datetime:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
@@ -177,14 +196,36 @@ def _safe_client():
return None
def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms. None = non leggibile."""
def trade_copertura(n_righe: int, prev_ts_ms: int, now_ms: int,
limit: int = TRADE_LIMIT, finestra_ms: int = FINESTRA_GATEWAY_MS,
toll_ms: int = TOLL_FINESTRA_MS) -> str | None:
"""PURA. None se la lista di trade COPRE l'intervallo (prev, now]; altrimenti il motivo per cui
NON lo copre — e allora la somma non si usa (P12: un reward calcolato su una lista incompleta
e' un'invenzione, il 07/09 valeva 400 USDE)."""
if n_righe >= limit:
return f"lista troncata: {n_righe} righe = limite {limit} del venue"
if now_ms - prev_ts_ms > finestra_ms + toll_ms:
ore = (now_ms - prev_ts_ms) / 3_600_000
return (f"ultima lettura {ore:.1f}h fa, oltre la finestra di {finestra_ms / 3_600_000:.0f}h "
f"del gateway: i trade piu' vecchi sono invisibili")
return None
def _trades_spot_da(client, ts_ms: int, now_ms: int | None = None
) -> tuple[float | None, int | None, str | None]:
"""Somma con segno (buy +, sell -) dell'USDE scambiato spot dopo ts_ms.
-> (somma, n, motivo). somma None = non leggibile, e `motivo` dice perche' (P4)."""
if client is None:
return None, None
return None, None, "gateway non raggiungibile"
try:
rows = client.trade_history(U.SPOT, limit=50)
except Exception:
return None, None
rows = client.trade_history(U.SPOT, limit=TRADE_LIMIT)
except Exception as e:
return None, None, f"trade_history: {type(e).__name__}"
if now_ms is None:
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
motivo = trade_copertura(len(rows), ts_ms, now_ms)
if motivo is not None:
return None, None, motivo
tot, n = 0.0, 0
for t in rows:
if int(t.get("timestamp") or 0) <= ts_ms:
@@ -192,7 +233,7 @@ def _trades_spot_da(client, ts_ms: int) -> tuple[float | None, int | None]:
amt = float(t.get("amount") or 0)
tot += amt if (t.get("direction") or "").lower() == "buy" else -amt
n += 1
return round(tot, 8), n
return round(tot, 8), n, None
def main() -> int:
@@ -221,10 +262,11 @@ def main() -> int:
if eq_usde is not None:
usd, _nota = U.valuta(eq_usde, px)
trades_usde, n_trades = (None, None)
trades_usde, n_trades, trades_motivo = (None, None, None)
an = dict(delta=None, reward_stimato=None, reward_rilevato=False, con_trade=None)
if stato == "OK" and prev is not None and prev.get("eq_usde") is not None:
trades_usde, n_trades = _trades_spot_da(client, int(prev["ts"]))
trades_usde, n_trades, trades_motivo = _trades_spot_da(
client, int(prev["ts"]), int(now.timestamp() * 1000))
an = analizza(prev, eq_usde, trades_usde)
quota = None
@@ -237,7 +279,7 @@ def main() -> int:
stato=stato, motivo_blind=motivo_blind,
eq_usde=eq_usde, px=px, px_fonte=px_fonte, usd=usd,
eq_usdc=eq_usdc, quota=quota,
trades_usde=trades_usde, n_trades=n_trades, **an,
trades_usde=trades_usde, n_trades=n_trades, trades_motivo=trades_motivo, **an,
)
v, v_motivo = verdetto(records + [rec], now)
rec["verdetto"], rec["verdetto_motivo"] = v, v_motivo
@@ -312,7 +354,8 @@ def main() -> int:
f" fa margine). Con cross X:SM sarebbero ~${cross:,.0f} (haircut"
f" {c['haircut']:.0%})")
if an["delta"] is not None:
attr = ("non attribuibile (trade non leggibili)" if an["reward_stimato"] is None
attr = (f"non attribuibile (trade non leggibili: {trades_motivo})"
if an["reward_stimato"] is None
else f"reward stimato {an['reward_stimato']:+.6f} USDE"
+ (" [con trade nel mezzo]" if an["con_trade"] else ""))
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")