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,200 @@
|
||||
#!/usr/bin/env python
|
||||
"""fee_watch.py — sorveglia lo schema fee di Deribit e applica la regola DECISA IN ANTICIPO.
|
||||
|
||||
PERCHE' ESISTE. Il 2026-07-26 Deribit ha annunciato un nuovo schema fee dal **2026-08-01** senza
|
||||
pubblicare i numeri (la tabella nell'articolo Insights e' un'immagine, fonte secondaria). La
|
||||
risposta del progetto e' stata misurare la CURVA invece di aspettare il numero
|
||||
(`scripts/research/r0726_fee_sensitivity.py`):
|
||||
|
||||
bps/lato %RT TP01 Sh SKH01 Sh BOOK Sh BOOK CAGR
|
||||
0 0.00% 1.322 1.567 1.849 21.69%
|
||||
3 0.06% 1.303 1.495 1.799 20.99%
|
||||
5 0.10% 1.290 1.446 1.766 20.53% <- assunzione di TUTTI i backtest
|
||||
10 0.20% 1.258 1.324 1.682 19.39%
|
||||
15 0.30% 1.226 1.200 1.597 18.26%
|
||||
|
||||
sensibilita' marginale del BOOK: -0.017 Sharpe/bps, -0.23% CAGR/bps
|
||||
SKH01 e' ~4x piu' sensibile di TP01 (-0.69% vs -0.09% CAGR/bps): round-trip discreti contro
|
||||
posizione continua vol-targeted -> se il taker sale, il primo parametro da rivedere e' il
|
||||
PESO 75/25, non altro.
|
||||
|
||||
REGOLA CONGELATA (decisa PRIMA di vedere il numero, per non deciderla col numero davanti):
|
||||
* taker <= 5 bps/lato -> non si tocca nulla (i backtest restano conservativi o esatti)
|
||||
* 5 < taker <= 10 bps -> si riporta il costo, nessuna azione dovuta
|
||||
* taker > 10 bps/lato -> si rivede il PESO di SKH01 (`r0724_skh_live_weight` /
|
||||
`r0726_reeval_live_weight`), passando da `weights_tilt_null`
|
||||
|
||||
COSA LEGGE. `public/get_instrument` (endpoint PUBBLICO, nessuna chiave): `taker_commission` e
|
||||
`maker_commission` sono il tier BASE dell'istrumento, cioe' quello che paga questo conto — a $600
|
||||
il volume 30g e' trascurabile e ogni soglia VIP e' fuori portata. Sorveglia anche
|
||||
`max_liquidation_commission`, che l'annuncio dice diventera' 1% su tutti i prodotti.
|
||||
Cross-check autorevole (best-effort): la fee REALMENTE pagata, dai trade del conto.
|
||||
|
||||
⚠️ Il tier VIP di un conto ad alto volume NON e' leggibile qui. La guardia
|
||||
`test_fee_watch_assume_tier_base` congela questa assunzione: se il conto smettesse di essere al
|
||||
tier base, questo script misurerebbe la cosa sbagliata.
|
||||
|
||||
uv run python scripts/live/fee_watch.py # report
|
||||
uv run python scripts/live/fee_watch.py --quiet # stampa/allerta solo se qualcosa cambia
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
STATE = ROOT / "data" / "fee_watch" / "state.json"
|
||||
INSTRUMENTS = ("BTC-PERPETUAL", "ETH-PERPETUAL")
|
||||
API = "https://www.deribit.com/api/v2/public/get_instrument"
|
||||
|
||||
# --- riferimenti CONGELATI (cambiarli invalida la curva di r0726_fee_sensitivity.py) ---
|
||||
BASELINE_TAKER_BPS = 5.0 # 0.10% RT: l'assunzione di OGNI backtest del progetto
|
||||
BASELINE_MAKER_BPS = 0.0
|
||||
SOGLIA_OK = 5.0 # <= -> nessuna azione
|
||||
SOGLIA_AZIONE = 10.0 # > -> rivedere il peso di SKH01
|
||||
D_SHARPE_PER_BPS = -0.017 # sensibilita' marginale del BOOK, misurata
|
||||
D_CAGR_PER_BPS = -0.0023
|
||||
|
||||
|
||||
def fetch_fees(instrument: str) -> dict:
|
||||
"""Commissioni correnti dell'istrumento (frazione di nozionale -> bps)."""
|
||||
r = requests.get(API, params={"instrument_name": instrument}, timeout=15)
|
||||
r.raise_for_status()
|
||||
res = r.json()["result"]
|
||||
return dict(
|
||||
instrument=instrument,
|
||||
taker_bps=float(res["taker_commission"]) * 1e4,
|
||||
maker_bps=float(res["maker_commission"]) * 1e4,
|
||||
liq_bps=float(res.get("max_liquidation_commission", 0.0)) * 1e4,
|
||||
)
|
||||
|
||||
|
||||
def verdict(taker_bps: float) -> tuple[str, str]:
|
||||
"""Applica la regola congelata. Ritorna (livello, motivazione)."""
|
||||
if taker_bps <= SOGLIA_OK:
|
||||
return "OK", f"taker {taker_bps:.1f}bps <= {SOGLIA_OK:.0f}: non si tocca nulla"
|
||||
if taker_bps <= SOGLIA_AZIONE:
|
||||
d = (taker_bps - BASELINE_TAKER_BPS)
|
||||
return "NOTA", (f"taker {taker_bps:.1f}bps: costo ~{d * D_SHARPE_PER_BPS:+.3f} Sharpe / "
|
||||
f"{d * D_CAGR_PER_BPS:+.2%} CAGR di book, nessuna azione dovuta")
|
||||
return "AZIONE", (f"taker {taker_bps:.1f}bps > {SOGLIA_AZIONE:.0f}: rivedere il PESO di SKH01 "
|
||||
f"(4x piu' fee-sensibile di TP01) via weights_tilt_null")
|
||||
|
||||
|
||||
def realized_fee_bps(limit: int = 20) -> dict:
|
||||
"""Fee REALMENTE pagata sui trade del conto, in bps di nozionale. Fonte autorevole, ma
|
||||
disponibile solo se il book ha tradato di recente: {} non e' 'zero', e' 'non misurata'."""
|
||||
try:
|
||||
from src.live.deribit import DeribitRead
|
||||
d = DeribitRead()
|
||||
except Exception:
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
for ins in INSTRUMENTS:
|
||||
tot_fee_usd = tot_notional = 0.0
|
||||
try:
|
||||
trades = d.trade_history(ins, limit=limit)
|
||||
except Exception:
|
||||
continue
|
||||
for t in trades:
|
||||
try:
|
||||
notional = abs(float(t.get("amount") or 0.0)) # perp: nozionale in USD
|
||||
price = float(t.get("price") or 0.0)
|
||||
fee = abs(float(t.get("fee") or 0.0)) # in valuta di settlement
|
||||
if notional <= 0 or price <= 0:
|
||||
continue
|
||||
tot_fee_usd += fee * price
|
||||
tot_notional += notional
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if tot_notional > 0:
|
||||
out[ins] = tot_fee_usd / tot_notional * 1e4
|
||||
return out
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE.exists():
|
||||
try:
|
||||
return json.loads(STATE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(cur: dict) -> None:
|
||||
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE.write_text(json.dumps(cur, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def diff_vs(prev: dict, cur: dict) -> list[str]:
|
||||
"""Cambiamenti rispetto all'ultima lettura. Prima lettura = nessun cambiamento (non e'
|
||||
un evento: e' l'inizializzazione)."""
|
||||
if not prev:
|
||||
return []
|
||||
ch = []
|
||||
for ins, c in cur.items():
|
||||
p = prev.get(ins)
|
||||
if not p:
|
||||
ch.append(f"{ins}: strumento nuovo nella sorveglianza")
|
||||
continue
|
||||
for k, lab in (("taker_bps", "taker"), ("maker_bps", "maker"), ("liq_bps", "liquidazione")):
|
||||
if abs(float(p.get(k, -1)) - float(c[k])) > 1e-9:
|
||||
ch.append(f"{ins} {lab}: {float(p.get(k, float('nan'))):.2f} -> {c[k]:.2f} bps")
|
||||
return ch
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
cur = {ins: fetch_fees(ins) for ins in INSTRUMENTS}
|
||||
prev = load_state()
|
||||
changes = diff_vs(prev, cur)
|
||||
worst = max(c["taker_bps"] for c in cur.values())
|
||||
lvl, why = verdict(worst)
|
||||
return dict(current=cur, changes=changes, worst_taker_bps=worst, level=lvl, reason=why,
|
||||
first_read=not prev)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = run()
|
||||
quiet = "--quiet" in sys.argv
|
||||
interessante = bool(r["changes"]) or r["level"] != "OK"
|
||||
|
||||
if not quiet or interessante:
|
||||
print("=" * 86)
|
||||
print(" FEE WATCH — schema fee Deribit vs la regola decisa in anticipo")
|
||||
print("=" * 86)
|
||||
print(f"\n {'strumento':<16}{'taker':>10}{'maker':>10}{'liquidaz.':>12}")
|
||||
for ins, c in r["current"].items():
|
||||
print(f" {ins:<16}{c['taker_bps']:>9.2f}{c['maker_bps']:>10.2f}{c['liq_bps']:>11.2f}")
|
||||
print(f"\n baseline dei backtest: taker {BASELINE_TAKER_BPS:.1f} / "
|
||||
f"maker {BASELINE_MAKER_BPS:.1f} bps (0.10% RT)")
|
||||
print(f" verdetto: [{r['level']}] {r['reason']}")
|
||||
if r["first_read"]:
|
||||
print(" (prima lettura: stato inizializzato, nessun confronto possibile)")
|
||||
for c in r["changes"]:
|
||||
print(f" CAMBIO: {c}")
|
||||
real = realized_fee_bps()
|
||||
if real:
|
||||
print("\n cross-check sui trade REALI del conto (fonte autorevole):")
|
||||
for ins, bps in real.items():
|
||||
print(f" {ins:<16}{bps:>9.2f} bps/lato effettivi")
|
||||
else:
|
||||
print("\n cross-check sui trade reali: NON MISURATO (nessun trade recente leggibile)")
|
||||
|
||||
if r["changes"] or r["level"] == "AZIONE":
|
||||
notify("💸 FEE WATCH — schema fee Deribit", {
|
||||
"verdetto": f"[{r['level']}] {r['reason']}",
|
||||
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
|
||||
})
|
||||
save_state(r["current"])
|
||||
return 2 if r["level"] == "AZIONE" else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
"""monitor_health.py — i forward-monitor stanno registrando? Riporta e allerta, non tocca nulla.
|
||||
|
||||
Tre gate pre-registrati (STATARB 27/09, XSR01 23/10, DVOLSPREAD 24/10) si decideranno leggendo
|
||||
serie forward che, tranne una, **nessuno sorvegliava**. Un monitor fermo produce silenzio, e il
|
||||
silenzio in una serie di ritorni si legge come "zero", cioe' come una misura. Non lo e'.
|
||||
|
||||
Vedi `src/live/monitor_health.py` per le due misure (coda + buchi interni), le cadenze per
|
||||
monitor e la soglia di copertura 0.80 (la stessa del veto d'integrita' di DVOLSPREAD).
|
||||
|
||||
uv run python scripts/live/monitor_health.py # report
|
||||
uv run python scripts/live/monitor_health.py --quiet # stampa/allerta solo se scatta
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.live.monitor_health import MIN_COVERAGE, alerts_from, check_all # noqa: E402
|
||||
from src.live.notifier import notify # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
rows = check_all(ROOT)
|
||||
alerts = alerts_from(rows)
|
||||
|
||||
if "--quiet" not in sys.argv or alerts:
|
||||
print("=" * 92)
|
||||
print(" MONITOR HEALTH — i forward-monitor stanno ancora registrando?")
|
||||
print("=" * 92)
|
||||
print(f"\n {'monitor':<20}{'stato':>10}{'barre':>8}{'attese':>8}"
|
||||
f"{'copert.':>10}{'eta ultima':>13} gate che ne dipende")
|
||||
for r in rows:
|
||||
cov = f"{r['coverage']:.0%}" if r["coverage"] is not None else "-"
|
||||
exp = f"{r['expected']}" if r["expected"] is not None else "-"
|
||||
age = f"{r['age_h']:.0f}h" if r["age_h"] is not None else "-"
|
||||
gate = (r["gate"] or "").split(" — ")[0]
|
||||
print(f" {r['name']:<20}{r['status']:>10}{r['n_bars']:>8}{exp:>8}"
|
||||
f"{cov:>10}{age:>13} {gate}")
|
||||
print(f"\n soglia di copertura {MIN_COVERAGE:.0%} (= veto d'integrita' di DVOLSPREAD) · "
|
||||
f"'NUOVO' = troppo giovane per un giudizio, non 'OK'")
|
||||
for r in rows:
|
||||
if r["why"] and r["status"] != "OK":
|
||||
print(f" {r['name']}: {r['why']}")
|
||||
|
||||
if alerts:
|
||||
notify("🩺 MONITOR HEALTH — un forward-monitor non sta registrando",
|
||||
{f"alert {i+1}": a for i, a in enumerate(alerts)})
|
||||
for a in alerts:
|
||||
print(f" ALERT: {a}")
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user