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