3 Commits

Author SHA1 Message Date
Adriano 1a7f670966 sei sorveglianti registrano i giri e stanno in monitor_health (Fixes #16)
src/live/giri.registra_giro: una riga jsonl {ts ms, campi} dopo una misura completata,
mai un'eccezione. edge_watch, fee_watch, scale_watch, vrp_f_watch, venue_news (cron_daily)
e venue_watch (cron_book, orario) scrivono data/<dir>/runs.jsonl; niente riga senza misura.
monitor_health: sei MonitorSpec (30h i giornalieri, 3h venue_watch), open_labeled=False.
Stessa riparazione di cc01 (#10, #12), generalizzata. Primo giro notturno: NUOVO, non allerta.
Test: incrocio P1 percorso/RUNS, misura -> una riga, non-misura -> nessuna. Suite: 1098 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:48:07 +00:00
Adriano e4efdaaa53 analista: niente capitale cablato nelle istruzioni, round-trip = lotto FIFO (Fixes #13)
ISTRUZIONI diceva «capitale ~$635» (equity reale ~$4.5k dal 04/09): valore ridichiarato,
forma di P1, e fuori pagina per la regola 1 delle istruzioni stesse. Ora il capitale e'
l'equity in pagina. Aggiunto che fifo_roundtrips chiude un round-trip per lotto: piu'
round-trip che fill nello stesso giorno e' atteso (l'analista lo segnalava come anomalia).
Test: nessun importo in ISTRUZIONI; FIFO e lotto nominati.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:48:07 +00:00
Adriano b46041d11d CLAUDE.md: §1 tetto allerta USDE 0,85 e frase ricucita, garanzia dell'ombra anche per permesso; §4 soglie STATARB verbatim (Fixes #14, Fixes #15)
- §1: «tetto ALLERTA quota 50%» contro config usde.quota_max_frac 0.85 (dal 06/09;
  0.50 resta default di codice in src/live/usde.py). Frase «dopo un bonifico si» troncata
  dal 10/09 (37d1565), ricucita.
- §1 OMBRA: dal 21/09 il token dell'ombra e' a scope di sola lettura (Adriano/TradingBook#19),
  misura del 21/09 riportata; il segreto resta pieno, la chiave read-only la crea l'operatore.
- §4 STATARB: regola del 24/07 copiata verbatim da scripts/research/r0724_statarb_deploy_gate.py
  (esiti, guardie, addendum 25/07 non binding). Nessun verdetto anticipato.
Autorizzato da Adriano il 21/09 dopo la revisione settimanale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 08:48:07 +00:00
14 changed files with 454 additions and 9 deletions
+4
View File
@@ -81,6 +81,10 @@ data/chain_collect/
data/vrp_f_watch/
# giri misurati del trigger di regime CC01 (letti da monitor_health, #12)
data/cc01_regime/
# giri misurati dei sorveglianti generalizzati da cc01 (#16), letti da monitor_health
data/edge_watch/
data/scale_watch/
data/venue_news/
# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale)
data/live/
+11 -7
View File
@@ -72,10 +72,14 @@ scrive in un registro, **senza inviare**. Finestra dichiarata fino al **30/09/20
`data/live/equity_seen.json`. Sui giri delle 18:17, 19:17 e 20:44 del 16/09 **nessun file** di
`data/` o `logs/` di questo progetto e' cambiato: il watermark di qui non lo tocca (P16).
- **perche' non invia**: `book_ombra.py` non contiene il percorso d'invio, e
`TradingBook/tests/test_ombra_non_invia.py` lo verifica sul sorgente. 🚨 **La garanzia e' il CODICE,
non il permesso**: la chiave puo' fare trading. Una chiave Deribit di **sola lettura** per l'ombra
la crea l'operatore; finche' non c'e', qualunque script di TradingBook con un percorso d'invio
opererebbe con soldi veri.
`TradingBook/tests/test_ombra_non_invia.py` lo verifica sul sorgente. ✅ **Dal 21/09 anche il
PERMESSO** (TradingBook#19): il gateway chiede a Deribit un token con scope
`account:read trade:read wallet:none block_trade:none block_rfq:none custody:none` e rifiuta il token
se lo scope concesso contiene un `:read_write`. Misurato il 21/09: senza scope la chiave dava
**tutto in read_write, anche `wallet` e `custody`**; con lo scope ridotto Deribit concede
`trade:read account:read` e risponde `13021 forbidden` a un `private/cancel`. 🚨 **Il segreto resta
quello pieno**: chi legge `TradingBook/secrets/deribit.json` puo' chiedere un token senza scope. Una
chiave Deribit di **sola lettura** per l'ombra la crea solo l'operatore.
- **orario**: `:44` con `timeout -k 10 150`, quindi finisce entro le `:46:40`, prima del giro di qui.
Era `:17` fino al 16/09 e cadeva nello slot di release del martedi' (15/09 09:17: manutenzione
11051, due BUY calcolati sul paper capital nel registro d'ombra); da TradingBook#16 un conto o una
@@ -111,9 +115,9 @@ fino al 31,8%*, che dimostra ≥, non =. Non spiegato (D5): puo' tornare. `venue
`quota_max_frac` → **0,85** (il valore che l'operatore aveva scelto il 30/08 per questo scenario).
✅ **Il cuscino USDC e' sorvegliato dal 10/09** (`cuscino_watch`, §5.17): una perdita del libro (che si regola in USDC)
fa salire la quota da sola, e sotto zero di slack il sorvegliante vende USDE; prima nessuno lo guardava — a −$58 di USDC
il cuscino era gia' scoperto. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico si
il cuscino era gia' scoperto. GATE USDE-01 in §4). ⚠️ **Ogni versamento abbassa la quota**: dopo un bonifico va riportata al bersaglio, e
**dal 10/09 lo fa `cuscino_watch` da solo** (stato ECCEDENTE: slack > 40% del cuscino ⇒ ricompra USDE fino a quota **0,64**, issue #7); prima si rilanciava `usde_convert --quota 0.70` a mano, e il 04/09 nessuno l'aveva fatto. Autorita' dei parametri: sezione `usde` di `config/live.json`
(haircut **5%** — ✅ divergenza chiusa il 31/08, la config diceva 10% ed era sbagliato (`r0831_margini_conto.py`); oggi comunque **non si applica**, vedi sopra — tetto ALLERTA quota 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
(haircut **5%** — ✅ divergenza chiusa il 31/08, la config diceva 10% ed era sbagliato (`r0831_margini_conto.py`); oggi comunque **non si applica**, vedi sopra — tetto ALLERTA `quota_max_frac` **0,85** (era 50% fino al 06/09; 0,50 resta solo come default di codice in `src/live/usde.py`, la config lo sovrascrive), soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta
da `src/live/usde.py` — shadow (sizing orario) e `usde_watch` (sorveglianza 12:35 UTC, cron
proprio, sotto `monitor_health`) derivano entrambi da li'. ⚠️ Il difetto d'origine: `shadow._equity`
leggeva SOLO il conto USDC — la prima conversione avrebbe causato un falso "USCITA DI FONDI −24%"
@@ -277,7 +281,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure.
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 → IDONEO e si apre la decisione di QUOTA (dell'operatore; tetto allerta 50%, mai 100% — R1 emittente non recuperabile); zero → si riconverte e la pista si chiude | ✅ **CHIUSO IDONEO** il 2026-08-28 12:35Z: primo reward **+0,052055 USDE** rilevato per delta al netto dei trade, coi criteri pre-registrati il 26/08 PRIMA dell'esito. **QUOTA DECISA il 30/08** (l'operatore ha anticipato il rinvio: *"porta in usde tutto il capitale che non viene usato"*): bersaglio **70%**, `usde.quota_target` in config (📌 **dal 10/09 la quota decisa e' 0,64, derivata dalle bande del cuscino: §3 e §5.17**). Tasso a 4 finestre (30/08): **3,26% annuo, banda bootstrap [1,13% – 4,51%]** — 3/4 hanno pagato (0,00 · 3,80 · 4,51 · 4,51%), e la banda larga 3,4 punti **e' il risultato**: a n=4 il tasso non e' misurato. `usde_watch.rendimento()` lo stampa **col suo n**; `quota_da_decidere()` lo chiede ogni giorno da lunedi'. 🚨 **L'EV non puo' scegliere la quota** — e' lineare in q, quindi il suo argmax e' sempre un angolo (0% o 100%): ogni quota interna nasce da un criterio sulla **coda**, dichiarato non ottimizzato (M8). Il resto e' misurato (`r0830_usde_quota.py`, diario 30/08): **hazard di pareggio = l'APR** (EV-positiva sse Ethena sta sotto il 3,3%/anno, numero **assunto** come il `p` di Deribit) · l'haircut **non morde a nessuna quota** fino al 90%, nemmeno a leva 1,50x con USDE marcato a 0,95 (29× di cuscino): **il vincolo non e' il margine** · la resa di oggi vale **$16/anno**, meno di UN mese di versamento ⇒ la leva binding resta il **bonifico** · gia' a 24,2% l'evento emittente vale **3,1× il maxDD dell'intero libro**. 📌 **Aspettare costa $1,33**: alzare a 50% fra 4 settimane invece che subito lascia sul tavolo $1,33 di resa (conversione reversibile a ~6 bps) e porta il campione da **4 a 32 finestre**, stringendo la banda ~2,8×. 🚨 **DUE FATTI DEL 30/08 CHE CAMBIANO IL GATE** (diario `2026-08-30-usde-quota-tetto-venue`). **(a) TETTO DEL VENUE ≈31-32% dell'equity — e' una FRAZIONE, non un livello.** 🚨 **SUPERATO IL 06/09: il
tetto non c'era piu'** — quota portata al **68,7%** con 39 ordini senza rifiuti, fermata dal cuscino di
regolamento (§1). Quanto segue e' la misura del 30-31/08, vera allora, tenuta perche' il tetto puo' tornare. Ogni acquisto oltre il tetto e' rifiutato con `not_enough_funds_in_currency` pur avendo **$1.400 disponibili**: messaggio **fuorviante**, che ha fatto inseguire tre ipotesi sbagliate (taglia, cadenza, prezzo). Che sia un tetto e non un problema d'ordine e' provato a saldo neutro (BUY rifiutato → SELL OK → BUY riaccettato → BUY rifiutato); che sia una **frazione** e' provato il 31/08 lasciando scendere l'equity di $8: **il tetto e' sceso con lei** — 30/08 in [644,18·645,18) con equity $2.063,79 = **31,21-31,26%**, 31/08 in [643,18·644,18) con equity $2.055,56 = **31,29-31,34%** (0,05pp di scarto, dentro il rumore dell'equity che oscilla ±$2-8/ora). ⚠️ **Ri-sondato il 31/08 dopo il passaggio a X:SM** (l'ipotesi era che il tetto dipendesse dal modello segregato): **[654,18-655,18) con equity $2.054,90 = 31,84-31,88%**. Il modello ENTRA nel tetto ma **non lo spiega** — ha comprato **+0,55pp = +11 USDE (~$11)**, non le centinaia che servirebbero. ⇒ **il tetto SCALA col conto**: la quota resta ~31% e il valore in dollari cresce col capitale, ma **il 70% non e' raggiungibile ne' ora ne' mai**. 🚨 **Deribit non lo documenta**: l'articolo ufficiale *Yield/reward bearing coins* descrive un **Cap ETHENA** che diluisce il **TASSO** a livello di exchange (`User APR = min(bal_exchange, cap)/bal_exchange × Ethena APR`) e **nessun limite sulle quantita' detenibili** da un conto. Ora e' in config (`usde.venue_cap_frac` 0.312, bordo basso dei bracket) e `usde_convert` **rifiuta il piano prima di sparare** invece di scoprirlo dal venue. **(b) L'USDC NON frutta SUL NOSTRO CONTO — misurato, e la premessa del gate e' RESTAURATA.** `public/get_currencies` dice USDC 3,4000% (USDE 4,1071%) e per qualche ora quel numero e' stato scritto qui come se fosse nostro: **era un listino del venue, non un accredito**. Deribit paga i reward USDC **una volta al MESE** («paid out as a single monthly payment early in the following month», accrual 00:00 UTC sulla **minima equity**) — non ogni giorno come l'USDE, ed e' per questo che una sorveglianza giornaliera non poteva vederli. Verificato sui **due confini di mese a libro FLAT**, dove `equity == balance == USDC` e ogni scalino sarebbe un accredito: **29/06→08/07 equity 598,06 costante, 237/237 ore ferme, 0 scalini** (attesi $0,45) e **31/07→03/08 equity 596,92 costante per 4 giorni** (attesi **$1,72 contro una risoluzione di $0,01, 172×**). **Zero misurato, non zero sotto soglia.** ⇒ il guadagno di tenere USDE e' il **tasso pieno ~4,1%**, non uno spread: **~$26/anno** sui $644. 🚨 **Il motivo ora e' DOCUMENTATO, e non e' MiCA come avevo ipotizzato**: l'articolo ufficiale elenca le giurisdizioni escluse dai reward USDC e **l'ITALIA c'e'** (con Canada e Giappone: non e' una lista MiCA, e' policy di giurisdizione Deribit). ⚠️ **E la finestra di pagamento e' piu' larga di quella che avevo controllato**: «within the **first two weeks** of the following month». Rifatta la verifica sulle due settimane vere: **luglio e' conclusivo** (atteso $1,72 contro un'escursione TOTALE dell'equity di **$0,48** in 1-16/08, zero scalini compatibili), **giugno NO** (il libro ha iniziato a operare a meta' mese e il rumore e' della taglia del segnale). Quindi: **una finestra conclusiva piu' la lista ufficiale**, non due finestre — il «172× la risoluzione» del 30/08 era sovra-affermato. 📌 **Un tasso pubblicato non e' un tasso incassato: si verifica sul CONTO** — ed e' costato una query su una serie che avevamo gia'. Strumento: **`scripts/live/balance_watch.py`** + `cron_balance.sh` (orario :42, sola lettura) registra il **balance a 8 decimali**, la serie che mancava. 📌 **Il vincolo che l'analisi del margine non aveva guardato**: P&L e funding dei perp USDC-lineari **si regolano in USDC**, quindi la quota massima difendibile non e' data dall'haircut ma dal **cuscino di regolamento** = disaster-SL sulla massima esposizione lorda (`n_asset × frac × disaster_sl_pct` = **30% dell'equity**), che lascia esattamente il **70%** — coincidenza per costruzione, zero slack (M8: non e' un argmax). 📌 **Il costo del NON averlo e' ora documentato e vale il criterio**: un'equity negativa in una valuta paga una *collateral fee* dello **0,05% AL GIORNO** — **18,25%/anno**, addebitata al secondo, **4,3× la resa USDE che si starebbe comprando** (fonte: KB *Cross collateral specifications*). Il ribilanciamento automatico NON salva: scatta solo a **$1M** assoluti o al **100% della cross equity**, soglie che a $2k non si toccano mai — si sanguina la fee e basta. Il 30/08 avevo scritto «Deribit lo finanzia a interesse» **senza il numero**: il numero e' questo, e rende il cuscino piu' giustificato, non meno. Attrezzo: **`scripts/live/usde_convert.py`** (dry-run di default, guardie proprie; NON passa da `execution.ALLOWED`, che resta ai soli due perp) |
| **STATARB** | 2026-09-27 | soglie 24/07 (non toccate) + diagnostica statica sempre-short | ✅ monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **−1,61** a oggi → coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima |
| **STATARB** | 2026-09-27 | soglie 24/07 (non toccate), **copiate verbatim** da `scripts/research/r0724_statarb_deploy_gate.py` (docstring e costanti; diario `2026-07-24-goal-50eur-roadmap.md` §Migliorie 1). Metrica: «Sharpe annualizzato di net_modeled su TUTTA la finestra forward (nessuna sottofinestra), da data/paper_statarb/returns.jsonl». «Sharpe >= 0.5 -> CANDIDATO AL DEPLOY: proporre peso {10,15,20}% del book Deribit, giudicato con weights_tilt_null + stima maxDD combinata < 15%. Deploy solo se il tilt-null passa.» · «0 <= Sh < 0.5 -> ESTENDI una sola volta di altri 90g (decisione 2026-12-26). Alla seconda scadenza: o >=0.5 o RITIRO. Niente terza proroga.» · «Sharpe < 0 -> RITIRO dal forward-monitor». Guardie «tutte richieste per il deploy»: «fill-haircut cumulato \|MODELED - REAL\| < 0.5 pp» · «config invariata (W=45, sgn=+1)» · «maxDD forward < 10%». Addendum 25/07: Sharpe della **statica sempre-short** a pari vol-target sulla stessa finestra — «Non e' una soglia binding», diagnostica obbligatoria; renderla binding spetta all'operatore. La lettura si fa lanciando lo script (`uv run python scripts/research/r0724_statarb_deploy_gate.py`), che applica la regola dal 27/09 | ✅ monitor riparato e rigenerato il 26/08: la serie vera da' Sharpe **−1,61** a oggi → coi criteri invariati punta al **RITIRO**. Si legge il 27/09, non prima |
| **XSR01** | 2026-10-23 | Sharpe ≥1,0 **E** haircut ≤40% — dal 26/08 il numero decisivo e' `r0826_xsr_haircut.py` a **pavimento $10**, citato con la frazione di ordini eseguiti — poi `weights_tilt_null` e **capitale ≥$20k** | ✅ **riscritto il 26/08 su decisione dell'operatore** (dichiarato prima dell'esito, stringe: diario `2026-08-26-xsr01-gate-riscritto`): capitale allineato a "100% Deribit fino a $20k"; serie forward rigenerata (31 barre vere, Sharpe −2,72). Sotto la lente RENDITA il sleeve resta **eguagliato da un conto remunerato** (📌 sotto) |
| **DVOLSPREAD** | kill 2026-10-24 se Sh<−0,50; decisione 2027-01-24 | (a) Sharpe>0 (b) marginale ADDS+robust_oos+insample_edge (c) **DSR ≥0,95** (d) `weights_tilt_null`; **veto** se barre attive <80% → si estende, non si decide | ✅ serie rigenerata il 26/08 (−4,41 a oggi, era −14,73 sulla serie rotta); il veto barre-attive ora conta barre VERE e la guardia PREMATURO sorveglia il monitor |
| **GATE PROP-01** | chiuso 22-23/08 | (a) listino 13/13 ✅ (b) ancora SKH01 23/23 ✅ (c) economica+sopravvivenza 2/2 ✅ | **chiuso 3/3** — ma il numero operativo e' sceso 42% → 7,8% → 4,4% → 2,6% |
+4
View File
@@ -42,10 +42,13 @@ import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
from src.portfolio.portfolio import combine_outer # noqa: E402
from src.portfolio.sleeves import deribit_book_sleeves, tp01_sleeve # noqa: E402
RUNS = ROOT / "data" / "edge_watch" / "runs.jsonl"
# --- soglie CONGELATE (cambiarle invalida la taratura di r0726_edge_death.py) ---
WIN_MONTHS = 36
SHARPE_KILL = -0.5
@@ -119,6 +122,7 @@ def run() -> dict:
def main() -> int:
r = run()
registra_giro(RUNS, sharpe=r["sharpe"], n_alert=len(r["alerts"]))
if "--quiet" not in sys.argv:
print("=" * 78)
print(" EDGE WATCH — il book live sta ancora funzionando?")
+3
View File
@@ -49,9 +49,11 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.book import INSTRUMENT as BOOK_INSTRUMENT # noqa: E402
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
STATE = ROOT / "data" / "fee_watch" / "state.json"
RUNS = ROOT / "data" / "fee_watch" / "runs.jsonl"
# ⚠️ CORREZIONE 2026-08-21. Qui c'era la tupla CABLATA ("BTC-PERPETUAL", "ETH-PERPETUAL") — gli
# INVERSE, regolati in BTC/ETH — mentre il book esegue sui LINEARI USDC (BTC_USDC-PERPETUAL).
# Sono due linee di prodotto distinte e Deribit le cambia in modo indipendente: il 18/08 ha
@@ -249,6 +251,7 @@ def main() -> int:
**{f"cambio {i+1}": c for i, c in enumerate(r["changes"])},
})
save_state(r["current"])
registra_giro(RUNS, level=r["level"])
return 2 if r["level"] == "AZIONE" else 0
+6 -1
View File
@@ -13,11 +13,15 @@ from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
from src.live.scale_watch import ALLARME, OK, run_once # noqa: E402
RUNS = ROOT / "data" / "scale_watch" / "runs.jsonl"
def _sender(testo: str):
try:
@@ -31,6 +35,7 @@ def main() -> None:
secco = "--secco" in sys.argv
quiet = "--quiet" in sys.argv
rep = run_once(sender=None if secco else _sender)
registra_giro(RUNS, stato=rep["stato"])
icona = {OK: "✅", ALLARME: "🚨"}.get(rep["stato"], "⚠️")
if quiet:
fb = rep["fallback"]
+3
View File
@@ -40,10 +40,12 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
FEED = "https://insights.deribit.com/exchange-updates/feed/"
STATE = ROOT / "data" / "live" / "venue_news.jsonl"
RUNS = ROOT / "data" / "venue_news" / "runs.jsonl"
# Parole che alzano un annuncio a 🚨. Le prime sono FISSE (categorie di rischio che ci toccano
# per costruzione); le altre si DERIVANO dal codice, sotto.
@@ -140,6 +142,7 @@ def main() -> int:
v["seed"] = primo_giro
f.write(json.dumps(v) + "\n")
nuove.append(v)
registra_giro(RUNS, voci=len(tutte), nuove=len(nuove))
# Primo giro: si semina lo storico SENZA allertare (P9: l'allarme massimo non si spende
# per un arretrato di 10 voci, o non verra' letto il giorno che e' vero).
+4
View File
@@ -16,10 +16,13 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.live.deribit import check_specs # noqa: E402
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify, ultimo_errore # noqa: E402
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
run_once)
RUNS = ROOT / "data" / "venue_watch" / "runs.jsonl"
def _titolo(rep: dict) -> str:
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
@@ -70,6 +73,7 @@ def main() -> int:
# che manda ordini. Il 18/08 Deribit ha cambiato tick e size dei perpetual lineari USDC e
# niente se n'era accorto: e' andata bene perche' i cambi erano riduzioni.
spec = check_specs()
registra_giro(RUNS, livelli=rep["levels"])
if spec["divergenze"]:
rifiuto = [d for d in spec["divergenze"] if d["rischio"] == "rifiuto"]
righe = {f"{d['strumento']} {d['campo']}": f"dichiarato {d['dichiarato']} / "
+4
View File
@@ -51,9 +51,11 @@ sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research"))
from cblib import ( # noqa: E402
dvol_series, f_factors, load_chain, pick_legs, puts, spot_series,
)
from src.live.giri import registra_giro # noqa: E402
from src.live.notifier import notify # noqa: E402
STATE = PROJECT_ROOT / "data" / "vrp_f_watch" / "state.json"
RUNS = PROJECT_ROOT / "data" / "vrp_f_watch" / "runs.jsonl"
# --- config CONGELATA il 2026-07-30 ---------------------------------------
STRUCTS = {
@@ -190,6 +192,8 @@ def main() -> int:
a["notified"] = bool(prev.get("notified", False))
_save_state(a)
if not R.empty:
registra_giro(RUNS, pairs=a["pairs"], ready=a["ready"])
return 0
+5 -1
View File
@@ -58,9 +58,13 @@ REGOLE VINCOLANTI:
5. Se noti qualcosa che merita un controllo umano, dillo in una frase, senza drammatizzare.
6. Massimo %d parole. Prosa, niente elenchi puntati, niente titoli.
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit, capitale ~$635.
Il libro e' TP01 (trend long-flat, 75%%) + SKH01 (breakout, 25%%) su Deribit; il capitale e'
l'equity scritta nella pagina qui sotto, non un valore da ricordare.
Il progetto e' esplicitamente scettico: il suo campione e' corto e il P&L non distingue ancora
l'edge dalla fortuna. Scrivere con quella consapevolezza e' parte del compito.
Un round-trip e' un lotto chiuso in FIFO (tradesdb.fifo_roundtrips): una vendita puo' chiudere
piu' lotti in una volta, quindi vedere piu' round-trip che fill nello stesso giorno e' atteso,
non un'anomalia da segnalare.
""" % MAX_PAROLE
+35
View File
@@ -0,0 +1,35 @@
"""registra_giro — il giro che un sorvegliante ha misurato, come riga jsonl (2026-09-21, #16).
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
perche' non scriveva niente da sorvegliare: `monitor_health` guarda solo le serie che i monitor
scrivono, e un monitor silenzioso non e' distinguibile da uno che dice "tutto ok". La riparazione
di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915) e' il modello: qui si generalizza a ogni
sorvegliante, cosi' un monitor fermo diventa FERMO in `monitor_health`, non invisibile.
Un registro che fallisce non deve cambiare l'esito del sorvegliante che lo scrive: `registra_giro`
non solleva mai, e su errore lo dice su stderr e ritorna False.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
def registra_giro(path: Path, **campi) -> bool:
"""Appende `{"ts": <ms epoch>, **campi}` a `path` (jsonl, una riga). Crea la cartella se manca.
Ritorna True se la riga e' stata scritta, False su OSError (disco pieno, permessi, percorso
invalido...) dopo aver scritto il motivo su stderr. Non solleva mai: la misura del
sorvegliante e' gia' fatta, e un registro che fallisce non deve buttarla via.
"""
riga = {"ts": int(time.time() * 1000), **campi}
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(riga, default=str) + "\n")
return True
except OSError as e:
print(f"registra_giro: non scrivo {path}: {type(e).__name__}: {e}", file=sys.stderr)
return False
+23
View File
@@ -105,6 +105,29 @@ MONITORS: tuple[MonitorSpec, ...] = (
MonitorSpec("cc01_regime_watch", "cc01_regime", "runs.jsonl", 24.0, max_age_h=30.0,
gate="trigger di regime CC01: WARN>=10% / ALERT>=15% funding 30g (riapre il dossier)",
open_labeled=False),
# I sei sorveglianti che generalizzano la riparazione di cc01 (#16): ognuno registra il
# proprio giro in `data/<dir>/runs.jsonl` (src.live.giri.registra_giro), timbri di
# ESECUZIONE come cc01 -> open_labeled=False. cron_daily.sh (GIORNALIERO) per i primi
# cinque: max_age 30h = un giro mancato + margine.
MonitorSpec("edge_watch", "edge_watch", "runs.jsonl", 24.0, max_age_h=30.0,
gate="kill del libro live (Sharpe 36m < -0,5 / protezione TP01)",
open_labeled=False),
MonitorSpec("fee_watch", "fee_watch", "runs.jsonl", 24.0, max_age_h=30.0,
gate="regola fee §3 decisa in anticipo",
open_labeled=False),
MonitorSpec("scale_watch", "scale_watch", "runs.jsonl", 24.0, max_age_h=30.0,
gate="GATE SCALA-01 criterio A2",
open_labeled=False),
MonitorSpec("vrp_f_watch", "vrp_f_watch", "runs.jsonl", 24.0, max_age_h=30.0,
gate="criteri (a)(b) §5.18",
open_labeled=False),
MonitorSpec("venue_news", "venue_news", "runs.jsonl", 24.0, max_age_h=30.0,
gate="annunci Deribit, debito 8",
open_labeled=False),
# cron_book.sh: gira OGNI ORA accanto al libro vivo. max_age 3h = due giri persi.
MonitorSpec("venue_watch", "venue_watch", "runs.jsonl", 1.0, max_age_h=3.0,
gate="tripwire di venue orario",
open_labeled=False),
)
+13
View File
@@ -6,6 +6,7 @@ giorno che non risponde.
"""
from __future__ import annotations
import re
import subprocess
import sys
from datetime import date
@@ -75,6 +76,18 @@ def test_il_prompt_non_contiene_l_analisi_vecchia():
assert "Testo vecchio" not in p and "storico" in p
# ------------------------------------------------- le istruzioni non portano un capitale fisso
def test_istruzioni_non_hanno_un_importo_in_dollari():
"""Il capitale e' l'equity in pagina, non una cifra da ricordare (#13)."""
assert re.search(r"\$\s?\d", A.ISTRUZIONI) is None
def test_istruzioni_nominano_fifo_e_lotto():
"""Piu' round-trip che fill nello stesso giorno e' atteso, non un'anomalia (#13)."""
assert "FIFO" in A.ISTRUZIONI and "lotto" in A.ISTRUZIONI
# ------------------------------------------------- i numeri inventati
def test_scopre_un_numero_che_non_c_e():
+69
View File
@@ -0,0 +1,69 @@
"""registra_giro (src/live/giri.py, #16): il registro che i sorveglianti scrivono a ogni giro.
Il contratto e' quello di cc01_regime_watch, generalizzato: appende `{"ts": <ms>, **campi}` come
riga jsonl e non solleva MAI — un registro che fallisce non deve buttare via una misura gia' fatta.
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.live.giri import registra_giro # noqa: E402
def test_scrive_una_riga_con_ts_in_millisecondi(tmp_path):
p = tmp_path / "sub" / "runs.jsonl"
prima = time.time() * 1000
assert registra_giro(p, stato="OK", n=3) is True
riga = json.loads(p.read_text().strip())
assert abs(riga["ts"] - prima) < 5_000
assert isinstance(riga["ts"], int)
assert riga["stato"] == "OK" and riga["n"] == 3
def test_crea_la_cartella_se_manca(tmp_path):
p = tmp_path / "non-esiste-ancora" / "runs.jsonl"
assert not p.parent.exists()
registra_giro(p, x=1)
assert p.exists()
def test_appende_senza_troncare(tmp_path):
p = tmp_path / "runs.jsonl"
registra_giro(p, giro=1)
registra_giro(p, giro=2)
righe = [json.loads(r) for r in p.read_text().splitlines()]
assert [r["giro"] for r in righe] == [1, 2]
def test_default_str_copre_i_valori_non_serializzabili(tmp_path):
"""json.dumps(default=str): un valore che non e' nativamente JSON (es. un Path, o un
dataclass senza __dict__ diretto) non deve far fallire l'append — diventa la sua str()."""
p = tmp_path / "runs.jsonl"
assert registra_giro(p, percorso=Path("/tmp/x")) is True
riga = json.loads(p.read_text().strip())
assert riga["percorso"] == str(Path("/tmp/x"))
def test_oserror_ritorna_false_senza_sollevare(tmp_path, capsys):
"""Un percorso impossibile (qui: dentro un FILE, non una cartella) e' un OSError alla
mkdir/scrittura. Il chiamante non deve vedere un'eccezione: la misura e' gia' fatta."""
ostacolo = tmp_path / "non-e-una-cartella"
ostacolo.write_text("sono un file")
p = ostacolo / "runs.jsonl"
assert registra_giro(p, stato="OK") is False
err = capsys.readouterr().err
assert "registra_giro" in err and str(p) in err
def test_un_valore_non_serializzabile_diventa_stringa_non_solleva(tmp_path):
p = tmp_path / "runs.jsonl"
oggetto = object()
assert registra_giro(p, oggetto=oggetto) is True
riga = json.loads(p.read_text().strip())
assert riga["oggetto"] == str(oggetto)
+270
View File
@@ -0,0 +1,270 @@
"""registra_giro nei sei sorveglianti generalizzati da cc01_regime_watch (2026-09-21, #16).
PERCHE' ESISTE. `cc01_regime_watch` e' morto 14 notti di fila (#10) senza che nessuno lo vedesse,
perche' non scriveva niente da sorvegliare (`monitor_health` guarda solo le serie che i monitor
scrivono). La riparazione di cc01 (`data/cc01_regime/runs.jsonl`, commit 31f5915,
tests/test_cc01_regime_watch.py) e' il modello: qui si verifica che sia stata applicata agli
altri sei sorveglianti giornalieri/orari, con lo stesso incrocio P1 con `monitor_health.MONITORS`
e lo stesso doppio ramo (misura -> una riga, non-misura -> nessuna riga).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "live"))
from src.live.monitor_health import MONITORS # noqa: E402
import edge_watch as EW # noqa: E402
import fee_watch as FW # noqa: E402
import scale_watch as SW # noqa: E402
def _carica(nome: str):
sp = importlib.util.spec_from_file_location(nome, ROOT / "scripts" / "live" / f"{nome}.py")
m = importlib.util.module_from_spec(sp)
sp.loader.exec_module(m)
return m
VF = _carica("vrp_f_watch")
VN = _carica("venue_news")
VW = _carica("venue_watch")
def _righe(path: Path) -> list[dict]:
import json
if not path.exists():
return []
return [json.loads(r) for r in path.read_text().splitlines() if r.strip()]
def _spec(nome: str):
s = next((m for m in MONITORS if m.name == nome), None)
assert s is not None, f"{nome} non e' in monitor_health.MONITORS"
return s
# ===========================================================================
# P1 — il bersaglio della sorveglianza si deriva dal codice sorvegliato
# ===========================================================================
@pytest.mark.parametrize("nome,modulo,cadence_h,max_age_h", [
("edge_watch", EW, 24.0, 30.0),
("fee_watch", FW, 24.0, 30.0),
("scale_watch", SW, 24.0, 30.0),
("vrp_f_watch", VF, 24.0, 30.0),
("venue_news", VN, 24.0, 30.0),
("venue_watch", VW, 1.0, 3.0),
])
def test_registrato_in_monitor_health_sul_file_che_scrive(nome, modulo, cadence_h, max_age_h):
s = _spec(nome)
assert ROOT / "data" / s.directory / s.series == modulo.RUNS
assert s.open_labeled is False
assert s.cadence_h == cadence_h
assert s.max_age_h <= max_age_h
def test_venue_watch_ha_la_cadenza_oraria_del_cron_book():
"""⚠️ venue_watch gira OGNI ORA in cron_book.sh: max_age stretto, non i 30h dei giornalieri."""
assert _spec("venue_watch").cadence_h == 1.0 and _spec("venue_watch").max_age_h == 3.0
# ===========================================================================
# edge_watch — dopo run(): sharpe, n_alert
# ===========================================================================
def test_edge_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(EW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
monkeypatch.setattr(EW, "run", lambda: dict(sharpe=0.7, protection=[], consec_fail=0, alerts=[]))
assert EW.main() == 0
righe = _righe(runs)
assert len(righe) == 1
assert righe[0]["sharpe"] == 0.7 and righe[0]["n_alert"] == 0
assert isinstance(righe[0]["ts"], int)
def test_edge_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(EW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["edge_watch.py", "--quiet"])
def boom():
raise RuntimeError("dati di mercato non leggibili")
monkeypatch.setattr(EW, "run", boom)
with pytest.raises(RuntimeError):
EW.main()
assert not runs.exists()
# ===========================================================================
# fee_watch — dopo save_state(): level
# ===========================================================================
def test_fee_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(FW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
monkeypatch.setattr(FW, "run", lambda: dict(current={}, changes=[], worst_taker_bps=0.0,
level="OK", reason="tutto in regola", first_read=True))
monkeypatch.setattr(FW, "save_state", lambda cur: None)
assert FW.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["level"] == "OK"
def test_fee_watch_run_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(FW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["fee_watch.py", "--quiet"])
def boom():
raise RuntimeError("API Deribit irraggiungibile")
monkeypatch.setattr(FW, "run", boom)
with pytest.raises(RuntimeError):
FW.main()
assert not runs.exists()
# ===========================================================================
# scale_watch — dopo run_once(): stato
# ===========================================================================
_REP_SCALA = dict(stato="OK", scala_dichiarata=1.0, ladder="0.5/1/2", tetto=2.0,
domande={}, fallback={"stato": "OK", "quota": 0.0, "fallback_veri": 0,
"giri": 10, "soglia": 0.1, "paper": 0},
lettore={"stato": "OK", "motivo": "ok"}, invio="non tentato")
def test_scale_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(SW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
monkeypatch.setattr(SW, "run_once", lambda sender=None: dict(_REP_SCALA))
SW.main()
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["stato"] == "OK"
def test_scale_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(SW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["scale_watch.py", "--quiet", "--secco"])
def boom(sender=None):
raise RuntimeError("lettore di produzione irraggiungibile")
monkeypatch.setattr(SW, "run_once", boom)
with pytest.raises(RuntimeError):
SW.main()
assert not runs.exists()
# ===========================================================================
# vrp_f_watch — dopo _save_state(), SOLO se R non e' vuoto: pairs, ready
# ===========================================================================
def _frame_coppie(n: int = 45) -> pd.DataFrame:
rows = []
for i in range(n):
exp = pd.Timestamp("2026-01-01") + pd.Timedelta(days=i)
for name, v in (("canonico", 0.70), ("candidato", 0.80)):
rows.append(dict(asset="BTC", struct=name, exp=exp, ts=pd.Timestamp("2026-01-01"),
dte=7.0, f_short=1.0, f_long=2.0, f_net=v, f_net_mid=v,
quota_lunga=0.18))
return pd.DataFrame(rows)
def test_vrp_f_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VF, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
frame = _frame_coppie()
monkeypatch.setattr(VF, "measure", lambda chain=None: frame)
monkeypatch.setattr(VF, "_save_state", lambda d: None)
monkeypatch.setattr(VF, "_load_state", lambda: {})
monkeypatch.setattr(VF, "notify", lambda *a, **k: True)
assert VF.main() == 0
righe = _righe(runs)
atteso = VF.assess(frame)
assert len(righe) == 1
assert righe[0]["pairs"] == atteso["pairs"] and righe[0]["ready"] == atteso["ready"]
def test_vrp_f_watch_senza_osservazioni_non_scrive(tmp_path, monkeypatch):
"""P5: R vuoto non e' un giro misurato — nessuna riga (a differenza di cc01, qui non
c'e' nemmeno un `return` anticipato: la guardia sta nella condizione prima di registrare)."""
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VF, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["vrp_f_watch.py", "--quiet"])
monkeypatch.setattr(VF, "measure", lambda chain=None: pd.DataFrame())
monkeypatch.setattr(VF, "_save_state", lambda d: None)
monkeypatch.setattr(VF, "_load_state", lambda: {})
assert VF.main() == 0
assert not runs.exists()
# ===========================================================================
# venue_news — dopo la scrittura delle voci: voci, nuove. Feed illeggibile -> nessuna riga
# ===========================================================================
_FEED = """<rss><channel>
<item><title>Deribit Welcomes Orbit Markets</title>
<link>https://x/a</link><guid>https://x/?p=1</guid><pubDate>Fri, 10 Jul 2026 00:00:00 +0000</pubDate></item>
<item><title>Another Unrelated Announcement</title>
<link>https://x/b</link><guid>https://x/?p=2</guid><pubDate>Fri, 11 Jul 2026 00:00:00 +0000</pubDate></item>
</channel></rss>"""
def test_venue_news_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
state = tmp_path / "venue_news.jsonl"
monkeypatch.setattr(VN, "RUNS", runs)
monkeypatch.setattr(VN, "STATE", state)
monkeypatch.setattr(VN, "scarica", lambda *a, **k: _FEED)
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
assert VN.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["voci"] == 2 and righe[0]["nuove"] == 2
def test_venue_news_feed_illeggibile_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VN, "RUNS", runs)
monkeypatch.setattr(VN, "STATE", tmp_path / "venue_news.jsonl")
monkeypatch.setattr(VN, "scarica", lambda *a, **k: None)
monkeypatch.setattr(VN, "notify", lambda *a, **k: True)
monkeypatch.setattr(sys, "argv", ["venue_news.py", "--quiet"])
assert VN.main() == 2
assert not runs.exists()
# ===========================================================================
# venue_watch — dopo run_once() e check_specs(): livelli (rep["levels"])
# ===========================================================================
def test_venue_watch_un_giro_misurato_scrive_una_riga(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
monkeypatch.setattr(VW, "run_once", lambda sender=None: dict(
levels={"BTC": "OK", "ETH": "OK"}, alerts=[], platform_locked=False))
monkeypatch.setattr(VW, "check_specs", lambda: dict(divergenze=[]))
assert VW.main() == 0
righe = _righe(runs)
assert len(righe) == 1 and righe[0]["livelli"] == {"BTC": "OK", "ETH": "OK"}
def test_venue_watch_run_once_che_fallisce_non_scrive(tmp_path, monkeypatch):
runs = tmp_path / "runs.jsonl"
monkeypatch.setattr(VW, "RUNS", runs)
monkeypatch.setattr(sys, "argv", ["venue_watch.py", "--quiet"])
def boom(sender=None):
raise RuntimeError("osservazione del consenso fallita")
monkeypatch.setattr(VW, "run_once", boom)
with pytest.raises(RuntimeError):
VW.main()
assert not runs.exists()