research(capitale): il lump-sum vale 2.45x, e la protezione dalla rovina non passa da GTAA01

Quattro filoni chiesti dall'operatore ("proposte"). Book, pesi, config: INVARIATI.

1. LUMP-SUM + VENUE (r0727_lumpsum_split.py). Tutte le traiettorie del 25-26/07 avevano
   START=600 cablato: mai misurato un versamento iniziale, mentre ~10k EUR stanno fermi
   altrove. Macchineria validata: con lump 0 riproduce IDENTICI i numeri del 26/07.
   - 10k EUR oggi e mai piu' nulla -> traguardo 17.2a, P 62%, rendita 61.58 EUR/g
   - equivalenza onesta: +154 EUR/mese per 13 anni = 24.523 EUR, cioe' 2.45x
     (la prima stesura misurava i versamenti risparmiati: numero giusto, domanda sbagliata)
   - col rischio venue: a 11.500$ lo split e' possibile (quota IB 26%, non 25%) e taglia
     P(perso tutto) da 18.4% a 3.5% a p=1%, costando 1.9-2.6pp di P(arrivare)
   - SPLIT-CASSA: seconda gamba ferma costa altri 0.6-0.8pp e protegge IDENTICO
     -> la protezione non e' bloccata dal PRIIPs: serve un CONTO, non uno sleeve

2. FEE WATCH (scripts/live/fee_watch.py). Nuovo schema Deribit dal 1 agosto senza numeri
   pubblicati -> sorvegliante invece di promemoria. Legge il tier base dall'endpoint
   pubblico (oggi taker 5.00 bps), applica la regola congelata e allerta sui cambiamenti.

3. MONITOR HEALTH (src/live/monitor_health.py). Tre gate pre-registrati si decidono su
   serie forward di cui una sola era sorvegliata. Misura coda E buchi interni: una serie
   bucata ma fresca passa qualunque guardia di freschezza.

4. BANDA GTAA01 25% VALIDATA (r0727_gtaa_band_gate.py). 30 celle, 29.9 anni, dpy=252.
   Non e' selection-on-holdout (4/30 IS, 5/30 OOS), DSR 0.999, tracking OK ma AL BORDO.
   Il modo di fallire non e' il de-levering (la vol non scende) ma la perdita di tracking.
   Impatto sul book: zero -> REBAL_BAND_USD non toccato, si applica al deploy.

Aggiunto anche il bullet edge_watch, cablato il 26/07 e mai finito in CLAUDE.md.

Test: 56 nuovi, 504/504 verdi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-27 14:14:39 +00:00
parent 3f0812c7fc
commit fd05307c96
13 changed files with 1963 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
"""monitor_health — i forward-monitor stanno ancora registrando? (puro, testabile)
IL BUCO CHE CHIUDE. Il progetto ha **tre gate pre-registrati** che si decideranno leggendo una
serie forward: STATARB **2026-09-27**, XSR01 **2026-10-23**, DVOLSPREAD kill **2026-10-24**
(decisione piena 2027-01-24). Di quelle serie, **una sola** ha una guardia d'integrita'
(`paper_dvolspread`, contabilita' a 3 stati + veto sotto l'80% di barre attive). Le altre no: se
un monitor smettesse di avanzare in silenzio — feed rotto, eccezione ingoiata, cron che non gira —
il gate verrebbe deciso su dati mancanti, e i dati mancanti **somigliano a dati tranquilli**.
E' lo stesso schema gia' pagato due volte: `fresh_5m` che falliva in silenzio (26/07) e il
feed-freeze del 14/07. La regola del progetto e' che *"non vedo" non e' "va tutto bene"* — qui
diventa: **un monitor fermo non e' un monitor che dice zero.**
DUE GUASTI, DUE MISURE (una sola non basta):
* **coda** — l'ultima barra e' vecchia: il monitor si e' fermato *adesso*. Misurata come eta'
dell'ultima registrazione, sulla cadenza dichiarata dal monitor.
* **buchi interni** — il monitor gira ma perde barre: la serie e' piu' corta di quanto il suo
stesso arco temporale richieda. Misurata come copertura fra la PRIMA e l'ULTIMA barra.
Un monitor che ha perso il 30% delle barre di mezzo passerebbe qualunque controllo di
freschezza.
CADENZE, che NON sono tutte uguali (e sbagliarle vuol dire un falso allarme a settimana):
* `paper_prevday` registra a barra ORARIA (864 barre in 36 giorni), avanzato dal cron daily;
* `paper_combo` vive sul calendario di BORSA (gambe IB): il venerdi' e' l'ultima barra fino al
lunedi', e tre giorni di eta' sono la norma, non un guasto;
* gli altri sono crypto giornalieri, 365 barre l'anno.
SOGLIA DI COPERTURA 0.80 — la stessa del veto d'integrita' gia' pre-registrato per DVOLSPREAD
("se le barre attive sono sotto l'80% si ESTENDE la finestra, non si decide"). Riusarla e' voluto:
una soglia diversa per monitor renderebbe i gate non confrontabili.
⚠️ LIMITE DICHIARATO. `np.busday_count` non conosce le FESTIVITA' di borsa: sul calendario equity
la copertura attesa e' sovrastimata di un giorno per festivita' nell'arco misurato (oggi
`paper_combo` legge 96% per il 3 luglio, non per un guasto). E' un errore in direzione
CONSERVATIVA — segnala di piu', non di meno — e resta ampiamente sopra la soglia 0.80 finche' le
festivita' sono <20% dei giorni. Se un giorno servisse precisione qui, la risposta e' un
calendario di borsa vero, non alzare la soglia.
"""
from __future__ import annotations
import csv
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
MIN_COVERAGE = 0.80 # sotto: la serie ha buchi -> non ci si decide sopra un gate
MIN_BARS_PER_JUDGE = 2 # con una barra sola non esiste ne' coda ne' copertura
@dataclass(frozen=True)
class MonitorSpec:
"""Cosa ci si aspetta da un monitor. `gate` = la decisione che dipende da questa serie."""
name: str
directory: str
series: str # returns.jsonl | equity.csv
cadence_h: float # passo nominale fra due barre
calendar: str = "crypto" # crypto (365g) | equity (giorni di borsa)
max_age_h: float = 48.0 # oltre: FERMO
gate: str | None = None
MONITORS: tuple[MonitorSpec, ...] = (
MonitorSpec("paper_portfolio", "paper_portfolio", "equity.csv", 24.0),
MonitorSpec("paper_prevday", "paper_prevday", "returns.jsonl", 1.0),
MonitorSpec("paper_statarb", "paper_statarb", "returns.jsonl", 24.0,
gate="2026-09-27 — gate di deploy STATARB-RESID"),
MonitorSpec("paper_xsr", "paper_xsr", "returns.jsonl", 24.0,
gate="2026-10-23 — gate di deploy XSR01 (Sharpe>=1.0 E haircut<=40%)"),
MonitorSpec("paper_dvolspread", "paper_dvolspread", "returns.jsonl", 24.0,
gate="2026-10-24 kill / 2027-01-24 decisione — DVOLSPREAD"),
# calendario di BORSA: le gambe GTAA vengono da IB, il weekend non e' un guasto.
MonitorSpec("paper_combo", "paper_combo", "equity.csv", 24.0,
calendar="equity", max_age_h=120.0),
)
def read_stamps(path: Path) -> list[datetime]:
"""Timestamp di ogni barra registrata, in UTC e ordinati. Formati: jsonl con `ts` in ms
epoch, oppure csv con la data in prima colonna."""
if not path.exists():
return []
out: list[datetime] = []
if path.suffix == ".jsonl":
for ln in path.read_text().splitlines():
ln = ln.strip()
if not ln:
continue
try:
ts = json.loads(ln).get("ts")
except json.JSONDecodeError:
continue
if ts is not None:
out.append(datetime.fromtimestamp(float(ts) / 1000.0, tz=timezone.utc))
else:
with path.open() as fh:
for row in csv.DictReader(fh):
raw = next(iter(row.values()))
try:
d = datetime.fromisoformat(str(raw))
except ValueError:
continue
out.append(d if d.tzinfo else d.replace(tzinfo=timezone.utc))
return sorted(out)
def expected_bars(first: datetime, last: datetime, cadence_h: float, calendar: str) -> int:
"""Quante barre DOVREBBERO esserci fra la prima e l'ultima, estremi inclusi."""
if calendar == "equity":
n = int(np.busday_count(first.date(), last.date())) + 1
return max(n, 1)
span_h = (last - first).total_seconds() / 3600.0
return int(round(span_h / cadence_h)) + 1
def age_hours(last: datetime, now: datetime, calendar: str) -> float:
"""Eta' dell'ultima barra. Sul calendario di borsa il weekend non invecchia la serie:
contarlo produrrebbe un allarme ogni lunedi'."""
raw = (now - last).total_seconds() / 3600.0
if calendar == "equity":
bd = int(np.busday_count(last.date(), now.date()))
return min(raw, bd * 24.0 + (raw % 24.0))
return raw
def assess(spec: MonitorSpec, stamps: list[datetime], now: datetime) -> dict:
"""Verdetto su un monitor. Gli stati NON sono due: 'assente' e 'troppo giovane per un
giudizio' non sono 'OK', e non devono poter passare per tali."""
base = dict(name=spec.name, gate=spec.gate, n_bars=len(stamps),
age_h=None, coverage=None, expected=None)
if not stamps:
return {**base, "status": "ASSENTE",
"why": "nessuna serie leggibile: il monitor non ha mai scritto, o lo stato e' perso"}
age = age_hours(stamps[-1], now, spec.calendar)
if len(stamps) < MIN_BARS_PER_JUDGE:
return {**base, "age_h": age, "status": "NUOVO",
"why": f"{len(stamps)} barra: non c'e' abbastanza serie per misurare la copertura"}
exp = expected_bars(stamps[0], stamps[-1], spec.cadence_h, spec.calendar)
cov = len(stamps) / exp if exp > 0 else float("nan")
d = {**base, "age_h": age, "coverage": cov, "expected": exp}
if age > spec.max_age_h:
return {**d, "status": "FERMO",
"why": f"ultima barra {age:.0f}h fa (limite {spec.max_age_h:.0f}h)"}
if cov < MIN_COVERAGE:
return {**d, "status": "BUCATO",
"why": f"copertura {cov:.0%} < {MIN_COVERAGE:.0%}: {exp - len(stamps)} barre mancanti"}
return {**d, "status": "OK", "why": ""}
def check_all(root: Path, now: datetime | None = None,
monitors: tuple[MonitorSpec, ...] = MONITORS) -> list[dict]:
now = now or datetime.now(timezone.utc)
out = []
for spec in monitors:
stamps = read_stamps(root / "data" / spec.directory / spec.series)
out.append(assess(spec, stamps, now))
return out
def alerts_from(rows: list[dict]) -> list[str]:
"""Cosa vale un allarme. Un monitor NUOVO non lo e' (sta solo iniziando); un monitor che
alimenta un gate pre-registrato e' peggio degli altri, e l'allarme lo dice."""
out = []
for r in rows:
if r["status"] in ("OK", "NUOVO"):
continue
msg = f"{r['name']}: {r['status']}{r['why']}"
if r["gate"]:
msg += f" | METTE A RISCHIO il gate {r['gate']}"
out.append(msg)
return out