Compare commits

11 Commits

Author SHA1 Message Date
Adriano Dal Pastro 993d17556a usde: gate CHIUSO IDONEO, quota rinviata a lunedi' — l'APR si legge col suo n
Il 2026-08-28 12:35Z `usde_watch` ha rilevato il primo reward (+0.052055 USDE su
500), e la regola pre-registrata il 26/08 PRIMA dell'esito ha chiuso il gate
USDE-01 su IDONEO. Si apre la decisione di QUOTA, che e' dell'operatore.

DECISIONE DELL'OPERATORE (29/08): la quota si decide lunedi' 31/08, su tre-quattro
finestre invece che su una. Il motivo e' un numero: sul solo pagamento il tasso
implicito e' 3,80% annuo, su DUE finestre — il 27/08 aveva pagato ZERO — e' 1,90%.
Fra i due c'e' tutta la decisione, e il campione e' UN pagamento.

`rendimento()`: APR sulle finestre OSSERVATE, col denominatore sul TEMPO VERO e
non sul numero di finestre pagate — cosi' una finestra che non paga ABBASSA la
stima invece di sparire (P5: quel silenzio e' uno zero). Le letture con trade nel
mezzo si escludono, non si riparano in silenzio (P12). L'APR si stampa sempre col
suo `n`, e sotto 4 finestre la riga dice «un pagamento non e' un tasso». Sulle
letture vere: 1,97% annuo su 1,9 giorni, 1/2 finestre pagate.

`quota_da_decidere()`: vera SOLO se IDONEO E il rinvio e' scaduto. Da lunedi' il
watch manda il 📌 OGNI giorno — quota attuale, tetto di allerta, APR osservato —
finche' non si decide (N9: una decisione rinviata senza promemoria e' rinviata per
sempre). Stesso schema della soglia $15k di GTAA01, con la data al posto del
capitale. Si smette togliendo `DECISIONE_QUOTA_DAL`.

6 test nuovi, incluso il controllo positivo — «la finestra sola darebbe il doppio»
— che e' esattamente la ragione per cui l'APR non si cita senza il suo n.

CONFERMATO il fix IB di ieri: il giro delle 00:30 ha 0 righe
`orders request timed out` (erano 2 per giro, 63 su 63) e tutti e sei gli ETF
scaricati. E una conferma involontaria del debito §5.13: SPY e' passato da
1996-09-04 a 1996-09-05 — la finestra rotolante ha perso un altro giorno in una
notte, come misurato.

Diario: docs/diary/2026-08-29-usde-idoneo-e-ib.md
Suite: 795 passati, 0 falliti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 06:45:47 +00:00
Adriano Dal Pastro 2beb11b764 fetch IB: client in readonly — via due righe d'errore da 63 notti su 63
`fetch_ib_equities.py` si connetteva senza `readonly`, e ogni notte il log del
cron si prendeva

    open orders request timed out
    completed orders request timed out

126 righe in 63 giri. Due errori innocui ripetuti per sempre sono il modo in cui
un errore VERO smette di farsi notare (P14): su `cron_daily.log`, 126 dei 146
match di "error" erano questi.

CAUSA, letta nel sorgente di ib_async e non indovinata: in `IB.connectAsync` le
richieste "open orders" e "completed orders" esistono SOLO se il client non e'
readonly (`if not readonly: reqs[...]`), e sul gateway paper non rispondono.
Questo client scarica storico e non manda ordini mai: `readonly=True` e' insieme
la cura del rumore e la dichiarazione corretta di cosa fa. Tolta la CAUSA, non
filtrato il messaggio — filtrarlo avrebbe nascosto anche il giorno in cui quel
timeout significasse qualcosa.

VERIFICATO con un A/B sul solo flag, contro il gateway vero:
  · readonly=False -> le due righe compaiono, e si apre sul gateway il dialogo
    modale "API client needs write access action confirmation" (visto nei log del
    container, resta su ~75s);
  · readonly=True  -> nessuna delle due righe, nessun dialogo.

⚠️ CIO' CHE NON E' STATO VERIFICATO, e va detto: in nessuna delle quattro prove
fra le 13:05 e le 15:35 UTC il gateway ha servito storico — 0 barre con ENTRAMBI
i flag, quindi la causa non e' questa modifica, ma non ho potuto confermare
end-to-end che il fetch continui a riportare barre. La conferma e' il log del
cron di stanotte: se SPY/QQQ/IWM/TLT/GLD/HYG tornano con le loro barre e senza
le due righe di timeout, e' a posto; se tornano tutti a 0, si revoca il flag.
Il fallimento e' comunque innocuo: con 0 barre lo script NON sovrascrive i
parquet (verificato: eq_spy/eq_qqq intatti dopo i tentativi falliti).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:31:22 +00:00
Adriano Dal Pastro 32e1649dcb test: la suite non parla piu' al canale di allarme vero
Difetto trovato addosso: `uv run pytest` mandava messaggi Telegram VERI sul
canale dell'operatore — cinque per giro, col testo delle fixture («Analisi di
ieri.», «rete KO», «Testo dell'agente.») sotto l'intestazione di una voce di
giornale del 21/08. Colpevoli i cinque test di `analista.CLI.scrivi()`, che non
passavano `telegram=False` e percorrevano `invia()` fino a `notifier.send()`.
`_cfg()` legge il token da `.env.mainnet`/`.env`: il fatto che non fosse
nell'ambiente non proteggeva niente.

NON E' COSMETICO. E' P9 al contrario — allarmi finti spesi su un canale che
deve restare credibile il giorno che l'allarme e' vero. Chi riceve cinque
messaggi identici a ogni giro di test impara a non aprirli, ed e' l'unico
canale da cui passano disaster-SL, uscita di fondi e venue giu'.

RIPARAZIONE STRUTTURALE, stessa lezione del watermark (§5.12): si blocca la
RETE in una fixture autouse, non si chiede a ogni autore di ricordarsi un
parametro. Un test che vuole davvero esercitare `send()` continua a funzionare
— patcha `urlopen` nel proprio corpo e vince su questo.

In piu' una guardia di sessione: bloccare non basta, un tentativo va TROVATO e
reso esplicito, o resta li' pronto a tornare vero il giorno che qualcuno cambia
il boundary. Validata su controllo positivo (M15): un test che chiama
`notifier.send()` fa fallire la sessione.

⚠️ E un difetto fatto e corretto nello stesso giro: la prima versione registrava
l'URL, che contiene il BOT TOKEN in chiaro — e quella lista finisce nel
messaggio di un assert, cioe' nell'output della suite e nei log. Ora si registra
solo "telegram sendMessage": serve sapere CHE si e' tentato, non VERSO DOVE.
Il token e' stato esposto una volta nell'output di quella verifica: va ruotato.

Suite: 789 passati, 0 falliti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:04:19 +00:00
Adriano Dal Pastro 418f523be7 gtaa: la decisione tenere/bloccare va a $15k — gate (A) ritirato, soglia sorvegliata
Decisione dell'operatore: GTAA01 NON si blocca oggi, si decide quando il book
arriva a $15k. Il motivo registrato non e' «non contribuisce» — la misura dice
il contrario (+0,095/+0,124 di Sharpe a iso-rischio secondo la fase, positivo in
tutte e cinque, hold-out +0,247, 6 anni su 8, correlazione col resto +0,087).
Il motivo e' che sotto $15k di book lo sleeve NON E' ACCENDIBILE:
GTAA_MIN_CAPITAL e' $3.000 allocati, che al peso 20% fa $15.000 contro i $2.068
attuali. Finora era manutenzione senza beneficio incassabile.

Registrate anche le due ragioni contro il blocco, che restano valide: N7 (uno
sleeve difensivo si giudica sul sinistro, e questa finestra non lo contiene) e
N4 (togliendolo il portafoglio di ricerca diventa 100% cripto su un venue solo).
E il costo che sarebbe andato pagato: GTAA01 e' uno dei quattro nomi di
W_DEPLOY, da cui escono i $313k del muro.

PERCHE' LA DECISIONE NON SI DIMENTICHI (N9). Una decisione parcheggiata su un
numero che nessuno sorveglia e' parcheggiata per sempre. `journal.SOGLIE_CAPITALE`
legge l'equity ogni giorno — il giornale lo fa comunque — e il giorno che supera
la soglia la voce dice quale decisione si sblocca e perche'. Seconda riga: $20k,
dove si riapre «100% Deribit fino a $20k». Una riga si TOGLIE quando la decisione
e' presa. 5 test, incluso «equity non leggibile non e' una soglia superata» (P5).

GATE (A) RITIRATO come pass/fail, congelando il MOTIVO e non l'esito — come il
07/08 col confronto fra ranghi, e per la stessa ragione: il criterio decide su un
margine piu' piccolo del rumore che lo scuote. In piu' una guardia sulla CAUSA
(`test_la_fase_di_ribilanciamento_e_ancora_ancorata_alla_POSIZIONE`) che si rompe
il giorno che qualcuno ancorasse la fase al calendario: quel giorno il gate
potrebbe tornare decidibile, ed e' un fatto da guardare, non da ignorare.
Il test sul margine assoluto si e' auto-ritirato: diceva «se scendesse sotto un
centesimo questo criterio smetterebbe di essere una misura», ed e' sceso a 0,0074.

CLAUDE.md: §3 nuova riga · §5.2 RISOLTO (trasporto allarmi) · §5.4 riscritto (la
domanda fiscale (a) ha una risposta alla fonte: derivati in c-quater al 26%, non
c-sexies al 33% — Circolare AdE 30/E del 27/10/2023, citata verbatim) · §5.13
nuovo debito, la fase che ruota — quantificata e dichiarata INNOCUA oggi (D5):
0,029 di Sharpe a livello di portafoglio, dentro la banda [1,81-2,12].

Diario: docs/diary/2026-08-28-gtaa-fase-e-allarmi.md
Suite: 789 passati, 0 falliti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:54:08 +00:00
Adriano Dal Pastro 21fe289383 research(gtaa): cosa cambia togliendo GTAA01 — +0.11 di Sharpe a iso-rischio, sul filo
Misura chiesta prima di decidere del gate (A): se togliere lo sleeve non
cambiasse niente, la domanda sul gate sarebbe accademica. Portafoglio di
RICERCA a 5 sleeve (33/15/12/20/20), NON il book live (dove GTAA01 non c'e').

Due lenti, ed e' la seconda che decide (M6: un diversificatore a basso CAGR si
giudica a iso-rischio, mai a iso-nozionale; M5: "meno drawdown" si testa a
iso-rischio o si sta comprando de-levering):
  · iso-nozionale: CON Sh 2.28 CAGR +19.2% DD 6.1% vol 7.8%
                   SENZA Sh 2.16 CAGR +23.8% DD 7.8% vol 10.1%
    -> togliendolo si guadagna DRIFT e si compra VOLATILITA'.
  · iso-rischio (×0.77): SENZA Sh 2.16 CAGR +18.0% DD 6.0%
    -> Δ Sharpe +0.121, Δ maxDD +0.02pp.
Hold-out 2025+: Δ +0.247. Per anno: GTAA01 aiuta in 6/8. Correlazione col resto
del portafoglio +0.087 — e' davvero altro. Standalone Sh 1.06, CAGR +5.8%.

⚠️ E LA BANDA DI FASE, che e' la ragione per cui questo numero va citato con la
sua incertezza: il Δ sulle 5 fasi va da +0.095 a +0.124 e supera la soglia
dichiarata (0.12) in 2 fasi su 5. Il SEGNO e' stabile — GTAA01 aiuta in ogni
fase — ma il verdetto BINARIO dipende dalla notte in cui lo si legge. Sanity:
la fase 0 riproduce lo sleeve di produzione bit-exact (max|Δ| = 0.00e+00).

Lettura: il contributo e' reale e sempre positivo, e vale ~+0.11 di Sharpe, cioe'
ESATTAMENTE quanto lo spread fra le tre baseline che il progetto gia' si porta
dietro (0.12, §2). Non e' "GTAA01 e' inutile": e' "sta al limite di cio' che
questi dati risolvono".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:34:44 +00:00
Adriano Dal Pastro 801bd13f10 allarmi: il marcatore "gia' detto" si scrive DOPO l'invio, non prima
Debito §5.2 chiuso su decisione dell'operatore. `run_once` salvava lo stato coi
marcatori `alerted` gia' a True e l'invio lo faceva il chiamante DOPO: col 6,9%
di invii falliti misurato (2 su 29), un 🚨 perso restava perso per l'EPISODIO
INTERO — l'ora dopo lo stato diceva "gia' detto" e usciva WATCH/MUTO. Gli
episodi storici durano 200-2.324 ore, quindi il buco non era teorico.

- `run_once(state_path, sender=None)`: il sender e' INIETTATO, non importato —
  e' cio' che tiene la funzione testabile senza rete d'uscita, che era la
  ragione del disegno precedente. Senza sender il comportamento resta quello di
  prima e il report lo DICE (`invio`), invece di lasciar credere che qualcosa
  sia partito.
- Su invio fallito si disfano SOLO i marcatori "gia' detto", non le misure:
  · asset in ALERT -> alerted=False, l'ora dopo ri-allerta;
  · lock MAINT/ALERT -> alerted_soft/hard=False ma le ORE restano a correre,
    cosi' una manutenzione che sfora la grazia sale ad ALERT anche col trasporto
    giu' (disfare anche le ore congelerebbe l'escalation proprio mentre non si
    riesce a parlare);
  · lock RIENTRATO -> si ripristina l'intero LockState, perche' il rientro si
    annuncia una volta sola e senza le ore non ci sarebbe piu' niente da dire.
- `notify(..., tentativi=)`: il retry esisteva in `send` e non arrivava qui.
  venue_watch ora manda con 3 tentativi.
- L'esito dell'invio finisce nel log del cron invece di sparire.

5 test nuovi. Il primo e' quello che conta — dopo un invio fallito, l'ora dopo
ri-allerta — col suo controllo positivo (un invio riuscito consuma l'allarme
UNA volta sola), senza il quale "ri-allerta sempre" passerebbe.

Suite: 782 passati, 2 falliti (i due del gate GTAA, non toccati qui).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:34:44 +00:00
Adriano Dal Pastro c21a0d4058 research(gtaa): criterio (C) — la mediana delle 5 fasi regge come strumento, e sceglie il 60%
Analisi richiesta prima di decidere che fare del gate (A). Il criterio: a cadenza
di produzione, la banda si sceglie sulla MEDIANA delle 5 fasi di ribilanciamento
invece che sulla fase capitata (M7: su ancore appaiate la statistica e' la
mediana). Toglie la componente di FASE; resta quella di DATO.

⚠️ DICHIARATO IN TESTA ALLO SCRIPT: (C) e' stato scelto DOPO aver visto la
tabella delle fasi di r0828_gtaa_band_phase. Sceglierne uno nuovo guardando
l'esito del vecchio e' selezione. Quindi (C) misura lo STRUMENTO — risoluzione
e potenza — e NON valida la proposta del 27/07. Pavimento tenuto a 0.01, quello
che il test si diede il 07/08: il metro non si ritocca sul risultato.

ESITO, coi tre criteri calcolati a runtime:
  (i)   scelta stabile su 10 notti : SI — ['60%'] in tutte e dieci
  (ii)  margine sempre >= 0.01     : SI — minimo 0.0197 (fase singola: 0.0026,
        e sotto il pavimento in 4 notti su 10)
  (iii) potenza (in-sample ≠ hold-out) : SI — al buio 60%, sull'hold-out 40%
Lo strumento funziona: l'escursione dello Sharpe in-sample su 10 notti cala del
74-99% per ogni banda (60%: da 0.1120 a 0.0006).

MA LA CELLA CHE SCEGLIE NON E' LA PROPOSTA. Al buio esce il **60%**; il 25%
vince **0 notti su 10**. Da tenere accanto: sull'hold-out il 60% e' penultimo
(0.7430 contro 0.9315 del 40%), coerente con lo Spearman IS/OOS ~0 misurato il
07/08 — la scelta in-sample non predice, e non va letta come "la banda giusta".

CONTORNO che vale piu' del verdetto: le 5 serie di fase correlano **0.988**,
quindi N_eff = 1.01. La mediana di 5 fasi e' UNA osservazione: toglie
l'artefatto, non compra precisione. Va citata come robustezza alla fase, mai
come campione (§2: «positivo in N/N ancore NON e' N osservazioni»).

Nessuna decisione presa qui: il gate (A) non e' toccato e i due test restano
rossi in attesa della scelta dell'operatore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:51:43 +00:00
Adriano Dal Pastro 31f82e1730 research(gtaa): il gate (A) della banda e' una moneta — la finestra "congelata" rotola ogni notte
Il 28/08 falliscono `test_la_banda_proposta_e_quella_scelta_al_buio` e
`test_il_margine_del_blind_non_e_un_arrotondamento`, a codice fermo dal 07/08
(nessun commit su src/portfolio/gtaa.py ne' su r0727_gtaa_band_gate.py). Al buio
esce il 60% invece del 25%, con margine 0.0074 — sotto il pavimento di 0.01 che
il test si era dato. Questo script chiede, per la terza volta sullo stesso
parametro, se il criterio misura cio' che dichiara. Replica bit-exact della
produzione verificata a fase 0 / taglio 0 (max|Δ| = 0.00e+00 su 7.544 barre).

(0) LA FINESTRA IN-SAMPLE NON E' FERMA. IB serve una finestra ROTOLANTE di 30
    anni. SPY e' l'unica gamba al muro (30.0 anni), e nel cron log la sua data
    d'inizio avanza di un giorno di borsa ogni notte: 1996-07-02 il 24/06,
    1996-09-04 oggi. Il pre-2015, che tutto il progetto tratta come finestra
    congelata, perde il giorno piu' vecchio ogni notte. QQQ e IWM arriveranno
    allo stesso muro fra 2,5 e 3,7 anni.

(1) L'AMPLIFICATORE E' LA FASE. `_gated_returns` ribilancia su `i % every == 0`,
    indice di POSIZIONE nell'array: se la prima barra scivola, si ri-fasa ogni
    decisione di trent'anni. Il progetto sapeva che la fase conta
    (`r0726_loo_deluck.gtaa01_at(ph)`, 5 ancore) ma la assumeva fissa alla
    canonica 0. Non lo e': la fase canonica cambia da sola ogni notte.

MISURE. Sulla finestra CHIUSA pre-2015, che non puo' acquisire dati nuovi, lo
Sharpe si e' mosso fino a 0.0760 fra il 07/08 e oggi. Su 10 notti consecutive
la banda scelta al buio e' 25% / 40% / 60% — la proposta vince il 40% delle
notti — e il margine sta sotto il pavimento in 4 notti su 10 (minimo 0.0026).
A dato FISSO, muovendo la sola fase, il verdetto cambia lo stesso (25% su 3
fasi, 60% su 2): l'imputato e' la fase, non le barre perse.

VERDETTO calcolato a runtime coi criteri dichiarati in testa (M13): il gate (A)
NON ha risoluzione. Il verdetto non e' una proprieta' della banda, e' una
proprieta' della notte in cui lo si legge. La banda al 25% non e' ne' confermata
ne' smentita: non e' decidibile cosi'.

Lo script misura e basta. Ritirare il criterio, come si fece il 07/08 col
confronto fra ranghi, e' una decisione dell'operatore e non e' presa qui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:44:25 +00:00
Adriano Dal Pastro cb40ab3e20 journal: recuperata l'analisi del 27/08, saltata dal guasto di autenticazione
Rigenerata con `analista.py --giorno 2026-08-27 --no-telegram`. Le sezioni
misurate escono IDENTICHE (ricostruzione deterministica da trades.db e dal
feed): cambiano solo l'ora di scrittura in testata e la sezione `Analisi`,
che ora c'e'. Controllo sui numeri: ok.

`--no-telegram` di proposito: la notifica delle 00:37:02Z era partita e diceva
«analisi non disponibile (errore)». Quel record e' la prova che l'allarme del
guasto e' uscito, e riscriverlo con un invio di oggi la cancellerebbe.

L'analisi legge lo storico com'e' OGGI, non com'era stanotte: la testata porta
l'ora vera di scrittura (08:29:00Z), quindi la pagina non si spaccia per una
scritta a ridosso del giorno chiuso.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:32:41 +00:00
Adriano Dal Pastro 05a698a83e analista: il motivo di un guasto si prende da stdout E da stderr, o si perde
Il 2026-08-28 alle 00:37:00Z la CLI `claude` e' uscita 1 scrivendo
«Failed to authenticate: OAuth session expired and could not be refreshed»
su STDOUT, con stderr VUOTO. `interroga()` componeva il motivo dal solo
stderr, quindi nel DB e' finito `analisi_stato='errore'`,
`analisi_motivi='uscita 1: '` — e su Telegram e' partito
«analisi non disponibile (errore): uscita 1:».

La meccanica ha retto (nessuna analisi di ieri spacciata per quella di oggi,
esito registrato, notifica partita): a mancare era solo il PERCHE'. P4 —
un'allerta risponde a due domande, e con la prima sola la causa si ricostruisce
aprendo a mano il transcript della sessione headless. P3 — quando la diagnosi
serve, il guasto e' gia' rientrato: il motivo si cattura li' o mai.

- `motivo_uscita(returncode, stdout, stderr)`: unisce i due canali (stderr per
  primo), tronca a 300 caratteri.
- Silenzio totale su entrambi i canali -> lo DICE, invece di lasciare i due
  punti a vuoto: «la CLI non ha detto perche'» e «il perche' l'abbiamo perso
  noi» sono guasti diversi e finora si scrivevano uguali.
- 4 test nuovi, fra cui la regressione col messaggio testuale del 28/08.

NON risolve la causa a monte: perche' quella refresh sia fallita non sta in
nessun log locale. Il refresh token era valido (rinnovo automatico riuscito
alle 08:03:35Z dello stesso giorno, scadenza 2026-09-25) e la stessa chiamata
rifatta a mano esce 0 -> guasto transitorio, unico su 24 sessioni locali.
Da qui in avanti, se ricapita, il motivo si legge dalla notifica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:32:41 +00:00
Adriano Dal Pastro a6b5756cc0 journal: voci automatiche del 26-27/08 — rischio tutto su TP01, SKH01 flat
Due voci scritte dal cron delle 00:37Z (27/08 e 28/08). Numeri come registrati,
nessuna riscrittura a mano.

- 26/08: equity $2.065,45, giorno $+8,86, 4 fill / 2 round-trip, 24/24 giri.
  L'analisi dell'agente segnala il denominatore cambiato il 25/08 (conversione
  USDE): stessa esposizione ($582 lordi), leva 0,28x non confrontabile coi
  giorni precedenti.
- 27/08: equity $2.070,23, giorno $+4,78, 1 fill / 1 round-trip, 24/24 giri.
  Manca la sezione "Analisi (agente)" — il passo dell'analista nel cron e'
  a `|| true`, quindi un suo fallimento non lascia traccia nella voce.

Cumulato dall'arming $+1.472,17 di equity, di cui $+1.399,39 versati:
trading **$+72,78** su 65 giorni e 23 round-trip — taglia a cui il P&L non
distingue l'edge dalla fortuna, e con l'82% del cumulato negli ultimi 7 giorni
(la settimana in cui BTC ha fatto +9,94% e il libro era lungo su entrambe le
gambe).

SKH01 flat su BTC e ETH in entrambe le voci: l'esposizione e' interamente TP01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:19:09 +00:00
22 changed files with 1661 additions and 61 deletions
+46 -13
View File
@@ -68,8 +68,8 @@ lorda massima 1,0x**, e nessun livello di capitale cambia il profilo di rischio
2 giorni (blocca) · `skh_feed_max_age_min` 30 (allerta, **non** blocca) · alert Telegram. 2 giorni (blocca) · `skh_feed_max_age_min` 30 (allerta, **non** blocca) · alert Telegram.
📌 **Dal 2026-08-26 l'equity del book e' il TOTALE cross-collateral** (USDC + USDE valutato 📌 **Dal 2026-08-26 l'equity del book e' il TOTALE cross-collateral** (USDC + USDE valutato
all'indice pubblico `usde_usdc`): il conto tiene **500 USDE** (test di eligibilita' reward, verdetto all'indice pubblico `usde_usdc`): il conto tiene **500 USDE** (**IDONEO** dal 28/08, primo reward
29/08 — GATE USDE-01 in §4). Autorita' dei parametri: sezione `usde` di `config/live.json` +0,052055 USDE; quota rinviata a lunedi' 31/08 — GATE USDE-01 in §4). Autorita' dei parametri: sezione `usde` di `config/live.json`
(haircut 10%, tetto ALLERTA quota 50%, soglie depeg 0,99/0,95 coi criteri in `_nota_usde`), letta (haircut 10%, tetto ALLERTA quota 50%, 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 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` proprio, sotto `monitor_health`) derivano entrambi da li'. ⚠️ Il difetto d'origine: `shadow._equity`
@@ -153,6 +153,7 @@ CLAUDE.md", la risposta e' la colonna *cosa la riapre*.
| **Non de-esporre il weekend** (porta il 38% del gross di TP01 nel 31% del tempo) | 17/07 | — | ogni proposta "risk-off weekend" parte **REFUTED** salvo null de-levering superato | | **Non de-esporre il weekend** (porta il 38% del gross di TP01 nel 31% del tempo) | 17/07 | — | ogni proposta "risk-off weekend" parte **REFUTED** salvo null de-levering superato |
| **Peso 75/25 confermato 3 volte** (24/07, 26/07, 24/07-follow-up) | 26/07 | — | `weights_tilt_null` superato, che finora **fallisce** | | **Peso 75/25 confermato 3 volte** (24/07, 26/07, 24/07-follow-up) | 26/07 | — | `weights_tilt_null` superato, che finora **fallisce** |
| **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo | | **Fee Deribit: ≤5 bps/lato → non si tocca nulla; >10 bps → rivedere il peso SKH01** (regola decisa *prima* di vedere il numero) | 26/07 | — | il sorvegliante `fee_watch` allerta da solo |
| **GTAA01: decisione tenere/bloccare RINVIATA a $15k di book** — sotto quella soglia lo sleeve **non e' deployabile** (`GTAA_MIN_CAPITAL` $3.000 allocati al peso 20%), quindi finora e' manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio **+0,095 / +0,124** di Sharpe secondo la fase: **reale e sempre positivo**, e delle stesse dimensioni dello spread fra le baseline (0,12) | 28/08 | «GTAA01 non contribuisce» — la misura dice il contrario | **il book a $15k**, che il giornale sorveglia da solo (`journal.SOGLIE_CAPITALE`) |
| **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | costruire prima una **chiave di scala esplicita** in config **e** rifare `r0726_fee_sensitivity` | | **Gradino di leva NON autorizzato oggi.** 1,25x autorizzabile *a condizione*; **1,50x BOCCIATO** (peggior giorno strutturale 21,48% > 20%); **k max difendibile 1,40** | 23/08 | il 1,50x | costruire prima una **chiave di scala esplicita** in config **e** rifare `r0726_fee_sensitivity` |
⚠️ **La regola *"ogni cambio di scala passa dal cap di config, non da `target_vol`"* NON E' ⚠️ **La regola *"ogni cambio di scala passa dal cap di config, non da `target_vol`"* NON E'
@@ -170,7 +171,7 @@ sull'hold-out; cambiarne le soglie guardando l'esito, pure.
| gate | data | criterio | stato | | gate | data | criterio | stato |
|---|---|---|---| |---|---|---|---|
| **USDE-01 (eligibilita' reward)** | 2026-08-29 | ≥1 reward entro il 29/08 (rilevati da `usde_watch` per delta di equity USDE al netto dei trade spot) → 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 | ✅ pre-registrato il 26/08 PRIMA dell'esito (diario `2026-08-26-usde-analisi`); 500 USDE in conto dalle 13:04Z, baseline watch 14:17Z, verdetto applicato dal cron 12:35 | | **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. 📌 **La QUOTA e' RINVIATA a lunedi' 31/08 su decisione dell'operatore** (29/08): su UNA finestra il tasso implicito e' **3,80%** annuo, su DUE e' **1,90%** — il 27/08 aveva pagato **zero** — e la differenza e' tutta la decisione. `usde_watch.rendimento()` stampa l'APR **col suo n** e da lunedi' `quota_da_decidere()` lo chiede ogni giorno finche' non si decide |
| **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) + 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 |
| **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) | | **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 | | **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 |
@@ -202,22 +203,42 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
`paper_portfolio` NON rigenerato (GTAA su ADJUSTED_LAST: replay ≠ serie registrata, P12) — `paper_portfolio` NON rigenerato (GTAA su ADJUSTED_LAST: replay ≠ serie registrata, P12) —
storia pre-fix dichiarata corrotta, coda non chiusa tolta. ⚠️ Nel fix, D6 pagata di nuovo: storia pre-fix dichiarata corrotta, coda non chiusa tolta. ⚠️ Nel fix, D6 pagata di nuovo:
`asi8` in pandas 3 e' in `us`, non `ns` — blindato con test su tre risoluzioni. `asi8` in pandas 3 e' in `us`, non `ns` — blindato con test su tre risoluzioni.
2. 🚨 **Il trasporto degli allarmi e' un punto singolo di guasto** — 6,9% di invii falliti (2/29). 2. **RISOLTO (2026-08-28, decisione dell'operatore).** Il trasporto degli allarmi era un punto
Fatte il 23/08: retry opzionale in `send()` e registrazione del motivo nel punto in cui veniva singolo di guasto — 6,9% di invii falliti (2/29) e `alerted=True` marcato **prima** dell'invio,
ingoiato. **Resta:** `alerted=True` marcato **prima** dell'invio → un 🚨 perso e' perso per quindi un 🚨 perso era perso per l'**episodio intero** (200-2.324 ore). Ora `venue_watch.run_once`
l'episodio intero (gli episodi storici durano 200-2.324 ore). Cambiarlo **cambia il comportamento** riceve il **sender iniettato** e il marcatore «gia' detto» si scrive **solo dopo un invio
→ decisione dell'operatore. riuscito**; su fallimento si disfano i soli marcatori — non le misure — e per il lock **le ore
continuano a correre**, cosi' una manutenzione che sfora la grazia sale ad ALERT anche col
trasporto giu'. `notify()` accetta `tentativi` (venue_watch: 3) e l'esito finisce nel log.
⚠️ Resta scoperto **ogni altro chiamante di `notify()`**: la disciplina «commit dopo l'invio»
e' oggi solo in `venue_watch`.
3.**RISOLTO (2026-08-26, decisione dell'operatore: opzione "riscrivere adesso").** Gate 3.**RISOLTO (2026-08-26, decisione dell'operatore: opzione "riscrivere adesso").** Gate
XSR01 riscritto in modo dichiarato, prima dell'esito, in direzione che stringe: capitale XSR01 riscritto in modo dichiarato, prima dell'esito, in direzione che stringe: capitale
$5k → **$20k** (riconcilia con la decisione 26/07, che e' posteriore e governa), gamba haircut $5k → **$20k** (riconcilia con la decisione 26/07, che e' posteriore e governa), gamba haircut
letta da `r0826_xsr_haircut.py` a pavimento $10 con la frazione di ordini eseguiti accanto letta da `r0826_xsr_haircut.py` a pavimento $10 con la frazione di ordini eseguiti accanto
(misurata: haircut FULL 1,1% con **22% di eseguiti** — la guardia da sola era vacua), soglie (misurata: haircut FULL 1,1% con **22% di eseguiti** — la guardia da sola era vacua), soglie
numeriche INVARIATE. Diario `2026-08-26-xsr01-gate-riscritto.md`. numeriche INVARIATE. Diario `2026-08-26-xsr01-gate-riscritto.md`.
4. **Due domande al commercialista, aperte** (valgono ~$22k di muro e ~€30/mese di versamento): 4. **(a) RISPOSTA TROVATA ALLA FONTE (2026-08-28), (b) resta aperta.** *Non sono pareri fiscali.*
(a) i derivati Deribit (inverse, margine e regolamento in cripto, sede extra-UE) stanno in **(a) I derivati stanno in `c-quater` → 26%, non in `c-sexies` → 33%.** Circolare AdE **30/E del
`c-sexies` (33%) o `c-quater` (26%)? E il collaterale sconta il 2‰ al 31/12? (b) i payout di una 27/10/2023**, §2.3.2 e §3, verbatim: «In generale, i redditi derivanti da **contratti derivati,
prop firm: **ne' `c-sexies` ne' `c-quater`** — le fonti convergono su **lavoro autonomo**, nessuna ancorche' aventi come sottostante cripto-attivita'**, costituiscono redditi diversi ai sensi
prassi ufficiale. *Non sono pareri fiscali: sono domande da porre.* dell'articolo 67, comma 1, **lettera c-quater)**» e «qualora le cripto-attivita' costituiscano il
sottostante di un contratto derivato (ad es. contract for difference o **future**) i redditi
derivanti da tali strumenti finanziari costituiscono redditi diversi ai sensi dell'articolo
c-quater)». La circolare conferma esplicitamente che la riforma 2023 non sposta nulla: «tali
contratti **non rientrano** nell'ambito di applicazione della lettera c-sexies». ⚠️ Conseguenza:
**le minusvalenze su derivati NON si compensano con le plusvalenze cripto** (categorie diverse) e
non c'e' franchigia. **Il collaterale sconta il 2‰**: Deribit non e' intermediario residente,
quindi non applica il bollo e scatta l'*imposta sul valore delle cripto-attivita'* sul valore al
31/12, in RW (a $2.068 fa **~$4/anno**: obbligo dichiarativo, non un costo). 📌 E la conversione
**USDC→USDE non e' fattispecie realizzativa** — §3.1: «dal 1° gennaio 2023 non costituisce
fattispecie realizzativa lo scambio di una cripto-valuta con un'altra», col valore di carico che
si trasferisce. **Resta da chiedere:** se il perpetual *inverse* regolato in cripto sia il caso
letterale della circolare (che nomina CFD e future) o serva un passaggio in piu'.
**(b) I payout di una prop firm: ancora aperta.** Le fonti convergono su **lavoro autonomo**
(partita IVA, ATECO, forfettario 5% per i primi 5 anni poi 15%, imponibile da coefficiente di
redditivita'), **non** redditi diversi al 26%. **Nessuna prassi ufficiale**, e il coefficiente
esatto dipende dal codice ATECO.
5.**FATTO (2026-08-26):** `test_leva_massima_da_config...` cancellato con nota in 5.**FATTO (2026-08-26):** `test_leva_massima_da_config...` cancellato con nota in
`tests/test_fee_sensitivity.py` — misurava `frac · n_asset`, con una chiave di scala avrebbe `tests/test_fee_sensitivity.py` — misurava `frac · n_asset`, con una chiave di scala avrebbe
continuato a passare smettendo di controllare. La guardia giusta e' il tetto sul PRODOTTO di continuato a passare smettendo di controllare. La guardia giusta e' il tetto sul PRODOTTO di
@@ -260,6 +281,18 @@ d'ancora in modo diverso (nella differenza si cancella in parte, nel livello per
definizione, patchare la costante non bastava) e `docs/journal/`; per `book_executions.jsonl` definizione, patchare la costante non bastava) e `docs/journal/`; per `book_executions.jsonl`
(caricato ad-hoc via importlib, irraggiungibile da conftest) impronta a inizio suite e verifica (caricato ad-hoc via importlib, irraggiungibile da conftest) impronta a inizio suite e verifica
a fine suite, col falso positivo possibile (fill reale del cron :47) dichiarato nel messaggio. a fine suite, col falso positivo possibile (fill reale del cron :47) dichiarato nel messaggio.
13. ⚠️ **GTAA01 gira su una finestra che ROTOLA, e la fase di ribilanciamento cambia da sola ogni
notte** (misurato 2026-08-28, `r0828_gtaa_band_phase.py`). IB serve una finestra di **30 anni**
e SPY e' al muro: la sua prima barra avanza di un giorno di borsa ogni notte (1996-07-02 il
24/06 → 1996-09-04 il 28/08). `gtaa._gated_returns` ribilancia su `i % every == 0`, cioe' sulla
**posizione nell'array**: se la prima barra scivola si ri-fasa ogni decisione di trent'anni.
Effetto misurato: su una finestra **chiusa** (pre-2015) lo Sharpe si e' mosso fino a **0,076**
a codice fermo, e la banda scelta al buio cambia fra 25% / 40% / 60% da una notte all'altra.
**Ha gia' ucciso due criteri del gate (A)** (07/08 e 28/08). **Quantificato e innocuo OGGI**
(D5): a livello di portafoglio l'escursione sulle 5 fasi vale **0,029** di Sharpe, dentro la
banda dichiarata [1,81-2,12]. QQQ e IWM arriveranno allo stesso muro **fra 2,5 e 3,7 anni**.
Riparazione (ancorare la fase al calendario) rinviata con la decisione su GTAA01: cambierebbe
**tutti** i numeri registrati dello sleeve.
--- ---
@@ -0,0 +1,158 @@
# 2026-08-28 — La fase che ruota, gli allarmi che si perdevano, e una risposta fiscale
*Scritto il 2026-08-28. Numeri riprodotti dagli script citati; le letture in prosa sono firmate
come tali (P13).*
## In una riga
Un test rosso su GTAA01 non parlava di GTAA01: parlava del fatto che **la finestra "congelata"
pre-2015 rotola da sola ogni notte**. Nel frattempo si e' chiuso il debito del trasporto degli
allarmi, e la prima delle due domande al commercialista ha trovato una risposta alla fonte.
---
## 1. Il gate (A) della banda GTAA01 e' una moneta — la finestra rotola
`r0828_gtaa_band_phase.py`. Replica bit-exact della produzione verificata (max|Δ| = 0.00e+00 su
7.544 barre). Codice fermo dal 07/08: nessun commit su `src/portfolio/gtaa.py` ne' su
`r0727_gtaa_band_gate.py`. Si e' mosso il DATO.
**(0) La finestra in-sample non e' ferma.** IB serve una finestra **rotolante di 30 anni**. SPY e'
l'unica gamba al muro (30,0 anni) e nel `logs/cron_daily.log` la sua data d'inizio avanza di un
giorno di borsa ogni notte: `1996-07-02` il 24/06 → `1996-09-04` oggi. QQQ e IWM arriveranno allo
stesso muro fra **2,5 e 3,7 anni**.
**(1) L'amplificatore e' la fase.** `_gated_returns` ribilancia su `i % every == 0`, dove `i` e' la
**posizione nell'array**. Se la prima barra scivola, si ri-fasa ogni decisione di ribilanciamento
di trent'anni di storia.
| misura | valore |
|---|---|
| spostamento max dello Sharpe in-sample dal 07/08 (finestra CHIUSA, codice fermo) | **0,076** |
| banda scelta al buio in 10 notti consecutive | 25% · 40% · 60% |
| notti col margine sotto il pavimento dichiarato (0,01) | **4/10** (minimo 0,0026) |
| a dato FISSO, muovendo solo la fase | 25% su 3 fasi, 60% su 2 |
Il criterio decideva su ~0,007-0,015 di Sharpe mentre il rumore che lo scuote vale fino a 0,076.
E' lo stesso modo di fallire del criterio a ranghi ritirato il 07/08 (che decideva su 0,00116), in
un costume nuovo.
## 2. Criterio (C): la mediana delle 5 fasi
`r0828_gtaa_band_median_phase.py`. ⚠️ **Scelto DOPO aver visto la tabella delle fasi**: misura lo
strumento, non valida la banda. Pavimento tenuto a 0,01, invariato.
- (i) scelta stabile su 10 notti: **SI**`60%` in tutte e dieci
- (ii) margine sempre ≥ 0,01: **SI** — minimo 0,0197
- (iii) potenza (al buio ≠ hold-out): **SI** — 60% contro 40%
Escursione dello Sharpe in-sample su 10 notti: **74/99%** per ogni banda. Lo strumento regge.
**Ma la cella che sceglie non e' la proposta:** al buio esce il 60%, il 25% vince **0 notti su 10**.
Da tenere accanto: sull'hold-out il 60% e' **penultimo** (0,7430 contro 0,9315 del 40%), coerente
con lo Spearman IS/OOS ~0 misurato il 07/08 — la scelta in-sample **non predice**.
**Contorno che vale piu' del verdetto:** le 5 serie di fase correlano **0,988****N_eff 1,01**.
La mediana di 5 fasi e' UNA osservazione: toglie l'artefatto, non compra precisione.
### Le due domande che erano state confuse
Il gate (A) puo' chiedere due cose diverse, e il 07/08 coincidevano **per fortuna di fase**:
| forma | difetto | quanto e' grave |
|---|---|---|
| «la banda e' la scelta che si fa al buio?» | **non ha risoluzione** | cambia verdetto ogni notte |
| «la banda non e' stata selezionata sull'hold-out?» | **non ha potenza** | 1 cella su 30 e' l'argmax dell'hold-out → **29/30 lo passano per costruzione** |
📌 *Lettura (agente, fallibile).* Il 25% non viene dalla griglia affatto: viene dall'argomento
**strutturale** del 27/07 — una banda in dollari assoluti degenera al variare del capitale, una
frazione no. Sotto la mediana delle fasi il 25% e' **2° in-sample e 3° sull'hold-out**: decente
ovunque, primo da nessuna parte, che e' la firma di un parametro NON selezionato sui dati. A un
parametro scelto per ragioni strutturali non si applica un gate di *selezione* ma uno di
*robustezza* — «fa danno da qualche parte?». Risposta: no.
**Esito:** gate (A) **ritirato** come pass/fail, congelando il MOTIVO (come il 07/08), piu' una
guardia sulla CAUSA che si rompe il giorno che la fase venisse ancorata al calendario.
## 3. Cosa cambia togliendo GTAA01 — e la decisione dell'operatore
`r0828_senza_gtaa.py`. Portafoglio di **ricerca** a 5 sleeve; NON il book live.
| | Sharpe | CAGR | maxDD | vol |
|---|---|---|---|---|
| CON GTAA01 | **2,28** | +19,2% | 6,1% | 7,8% |
| SENZA, iso-nozionale | 2,16 | **+23,8%** | 7,8% | 10,1% |
| SENZA, **iso-rischio** (×0,77) | 2,16 | +18,0% | 6,0% | 7,8% |
A iso-nozionale togliendolo si guadagna **drift** e si compra **volatilita'**. A iso-rischio — la
sola lente onesta per un diversificatore a basso CAGR (M6) — **Δ Sharpe +0,121**, maxDD invariato.
Hold-out 2025+: **Δ +0,247**. Aiuta in **6 anni su 8**. Correlazione col resto **+0,087**: e'
davvero altro. Standalone Sharpe 1,06, CAGR +5,8%.
⚠️ **Con la sua fascia di fase:** il Δ sulle 5 fasi va da **+0,095 a +0,124** e supera la soglia
dichiarata (0,12) in **2 fasi su 5**. Il **segno e' stabile**, il verdetto **binario** no.
### La decisione: rinviata a $15k di book
Discussa con l'operatore. L'argomento che ha deciso non e' «GTAA01 non contribuisce» — la misura
dice il contrario — ma che **a questo capitale lo sleeve non e' accendibile**: `GTAA_MIN_CAPITAL`
e' **$3.000 allocati**, che al peso 20% significa **$15.000 di book** contro i **$2.068** attuali.
Sotto quella soglia e' manutenzione senza beneficio incassabile.
Due ragioni registrate **contro** il blocco immediato, entrambe regole del progetto:
- **N7** — uno sleeve difensivo si giudica **sul sinistro, non sul premio**, e la finestra
2019-2026 non contiene il sinistro per cui esiste un GTAA a sei gambe;
- **N4** — il rischio di venue si compra con un **conto**, non con uno sleeve: togliere GTAA01
renderebbe il portafoglio di ricerca **100% cripto su un venue solo**.
E un costo che sarebbe andato pagato, non saltato: GTAA01 e' uno dei **quattro** nomi di
`r0726_wall_fixedpoint.W_DEPLOY`, da cui escono i **$313k** e tutta la pianificazione da €500/mese.
**Perche' la decisione non si dimentichi** (N9), la soglia e' sorvegliata dal giornale:
`journal.SOGLIE_CAPITALE` legge l'equity ogni giorno e il giorno che supera $15k la voce lo dice,
con la domanda da rispondere accanto. Stessa tabella, seconda riga: **$20k**, dove si riapre
«100% Deribit fino a $20k».
## 4. Allarmi: il marcatore «gia' detto» si scrive DOPO l'invio
Debito §5.2 chiuso su decisione dell'operatore. `venue_watch.run_once` salvava lo stato coi
marcatori `alerted` gia' a True e l'invio lo faceva il chiamante **dopo**: col 6,9% di invii
falliti misurato (2/29), un 🚨 perso restava perso per l'**episodio intero** (200-2.324 ore).
Ora il sender e' **iniettato** (la funzione resta testabile senza rete d'uscita, che era la ragione
del disegno precedente) e su fallimento si disfano i **soli marcatori**, non le misure:
asset in ALERT → ri-allerta l'ora dopo; lock MAINT/ALERT → flag azzerati **ma le ore continuano a
correre**, cosi' una manutenzione che sfora la grazia sale ad ALERT anche col trasporto giu';
lock RIENTRATO → stato ripristinato, perche' il rientro si annuncia una volta sola.
`notify()` accetta `tentativi` (venue_watch: 3) e l'esito finisce nel log del cron.
⚠️ Resta scoperto **ogni altro chiamante di `notify()`**: la disciplina e' oggi solo in venue_watch.
## 5. L'analista del 27/08, e la riga di diagnosi che mancava
La voce del 27/08 era senza analisi. Causa, dal transcript della sessione headless:
`Failed to authenticate: OAuth session expired and could not be refreshed`, exit 1 alle
`00:37:00.98Z`. La meccanica aveva retto (nessuna analisi vecchia spacciata per nuova, esito nel DB,
notifica partita) ma il motivo registrato era `uscita 1: ` — vuoto: `interroga()` leggeva il solo
**stderr**, e la CLI aveva scritto su **stdout**. Riparato (`motivo_uscita`), analisi recuperata.
Guasto **transitorio**: il refresh token era valido, unico caso su 24 sessioni.
## 6. Fisco: la domanda (a) ha una risposta alla fonte
Circolare AdE **30/E del 27/10/2023**, letta direttamente dal PDF ufficiale. **I derivati con
sottostante cripto stanno in `c-quater` → 26%**, non in `c-sexies` → 33%, e la circolare dice
esplicitamente che la riforma 2023 non sposta nulla. Il **collaterale** sconta il **2‰** al 31/12
(intermediario non residente → imposta sul valore delle cripto-attivita', ~$4/anno a questo
capitale). E la conversione **USDC→USDE non e' fattispecie realizzativa**. Dettaglio e citazioni
verbatim in CLAUDE.md §5.4. La (b), i payout di una prop firm, **resta aperta**.
## Cosa cambia
- `CLAUDE.md`: §3 nuova riga (GTAA01 rinviato a $15k) · §5.2 risolto · §5.4 riscritto · §5.13 nuovo
debito (la fase che ruota, quantificata e innocua oggi).
- `src/live/journal.py`: `SOGLIE_CAPITALE` + regola `soglia_capitale`.
- `src/live/venue_watch.py`, `src/live/notifier.py`, `scripts/live/venue_watch.py`: commit dopo l'invio.
- `src/live/analista.py`: `motivo_uscita` da stdout E stderr.
- `tests/test_gtaa_band_gate.py`: gate (A) ritirato, motivo e causa congelati.
- Script nuovi: `r0828_gtaa_band_phase.py`, `r0828_gtaa_band_median_phase.py`, `r0828_senza_gtaa.py`.
**Suite: 789 passati, 0 falliti.**
+67
View File
@@ -0,0 +1,67 @@
# 2026-08-29 — USDE idoneo, la quota rinviata a lunedì, e il fix IB confermato
*Scritto il 2026-08-29. Numeri letti dalle serie citate.*
## 1. GATE USDE-01: CHIUSO **IDONEO**
Il 2026-08-28 alle 12:35Z `usde_watch` ha rilevato il primo reward:
```
equity USDE : 500.0521 @ 1.0001 (indice) -> $500.05
delta : +0.052055 USDE dall'ultima lettura
verdetto : IDONEO — reward rilevato il 2026-08-28T12:35:01Z
```
La regola era pre-registrata il 26/08, **prima** dell'esito: ≥1 reward entro il 29/08 → IDONEO,
e si apre la decisione di QUOTA, che è dell'operatore. Il gate ha fatto quello che doveva.
## 2. Il tasso, e perché la quota è stata rinviata
| lettura | tasso implicito |
|---|---|
| il solo pagamento (0,052055 su 500) | **3,80% annuo** |
| su **due** finestre — il 27/08 ha pagato **zero** | **1,90% annuo** |
| annunciato da Deribit (ricerca 26/08) | 9% |
**Decisione dell'operatore (29/08): la quota si decide lunedì 31/08**, su tre-quattro finestre
invece che su una. Il motivo è nella tabella: fra 3,80% e 1,90% c'è tutta la decisione, e il
campione è **un pagamento**. Il costo dell'attesa è zero — i 500 USDE rendono comunque.
📌 Perché il numero non venga letto male: `usde_watch.rendimento()` calcola l'APR sulle finestre
**osservate**, col denominatore sul **tempo vero** e non sul numero di finestre pagate — così una
finestra che non paga **abbassa** la stima invece di sparire (P5: quel silenzio è uno zero). Le
letture con trade nel mezzo si escludono (P12). L'APR si stampa sempre **col suo `n`**, e sotto
4 finestre la riga dice *«un pagamento non è un tasso»*. Sulle letture vere di oggi:
**1,97% annuo su 1,9 giorni, 1/2 finestre pagate**.
📌 Perché la decisione non si dimentichi (N9): `quota_da_decidere()` è vera **solo** se il conto è
IDONEO **e** il rinvio è scaduto. Da lunedì il watch manda il 📌 **ogni giorno** — con quota
attuale, tetto di allerta e APR osservato accanto — finché non si decide. Si smette togliendo
`DECISIONE_QUOTA_DAL`. È lo stesso schema della soglia $15k di GTAA01, con la data al posto
del capitale.
⚠️ Nota di contorno che vale una lezione: il 📌 dell'IDONEO era partito su Telegram il 28/08 alle
12:35Z, **in mezzo ai messaggi-spazzatura dei test** delle 14:47 e 14:52 (difetto riparato lo
stesso giorno). L'unico messaggio che contava è arrivato nel giorno in cui il canale era sporco.
È P9 in forma concreta.
## 3. Il fix IB è confermato
Il giro del cron delle 00:30 di stanotte: **zero** righe `open orders request timed out` (erano
2 per giro, 63 giri su 63) e tutti e sei gli ETF scaricati — `SPY n=7541`, `QQQ n=6912`,
`IWM n=6602`, `TLT n=2658`, `GLD n=5476`, `HYG n=4877`, aggiornati al 28/08. Il `readonly=True`
resta.
📌 E una conferma involontaria del debito §5.13: **SPY è passato da `1996-09-04` a `1996-09-05`**.
La finestra rotolante ha perso un altro giorno di borsa in una notte, esattamente come misurato
il 28/08 — il difetto si legge a occhio nudo nel log del cron.
## Cosa cambia
- `scripts/live/usde_watch.py`: `rendimento()` (APR sulle finestre osservate, col suo n) ·
`quota_da_decidere()` · `DECISIONE_QUOTA_DAL = "2026-08-31"` · il 📌 giornaliero da lunedì.
- `tests/test_usde_watch.py`: 6 test nuovi, incluso il controllo positivo (*la finestra sola
darebbe il doppio*) che è la ragione per cui l'APR si stampa col suo `n`.
- `CLAUDE.md`: §1 e §4 aggiornati — gate CHIUSO IDONEO, quota rinviata a lunedì.
**Suite: 795 passati, 0 falliti.**
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-08-26
*Scritto 2026-08-27T00:37:47+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $79,031.00 | +0.67% | +14.00% | +24.05% | 42.0% | ↑ ↑ ↑ (3/3 su) | 40.4 (40° pctl 1a) |
| **ETH** | $2,507.65 | +2.64% | +11.34% | +32.61% | 67.8% | ↑ ↑ ↑ (3/3 su) | 56.1 (41° pctl 1a) |
## Libro
Equity **$2,065.45** · nozionale lordo $582 · leva lorda 0.28x · barra dati 2026-08-26 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.464 | flat | $+359 | $+364 | SELL $-5 |
| **ETH** | +0.278 | flat | $+215 | $+218 | HOLD (a target) |
## P&L
- giorno: **$+8.86** di equity (24 letture)
- realizzato: +0.29 netto su 2 round-trip chiusi · 4 fill · fee 0.0138
- cumulato dall'arming: **$+1,467.39** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+68.00**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+364 (TP01 +0.464, SKH01 flat) e ETH $+218 (TP01 +0.278, SKH01 flat).
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
- `[pnl]` Equity $+8.86 con 4 fill e 2 round-trip chiusi (realizzato $+0.29 netto).
- `[concentrazione]` 📌 **Il 101% di tutto il P&L cumulato ($+68.00) viene dagli ultimi 7 giorni.** Oltre il 100% perche' **il periodo precedente era in perdita**: senza questi 7 giorni il libro sarebbe sotto. Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
- `[vol]` BTC: implicita 40.4 contro realizzata 30g 42.0 (sotto); DVOL al 40° pctl di un anno; IV-rank espandente 0.14 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 56.1 contro realizzata 30g 67.8 (sotto); DVOL al 41° pctl di un anno; IV-rank espandente 0.17 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **64 giorni, 22 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-08-27T00:38:49+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto nuovo non sta nei numeri di oggi: e' il denominatore, cambiato ieri. L'equity passa da 642.02 (24/08) a 2,056.59 (25/08) a $2,065.45, ma il nozionale lordo resta $582 e le posizioni si muovono appena — BTC da $+345 a $+364, ETH da $+212 a $+218. La leva 0.28x su un tetto di 1.00x non e' quindi confrontabile con quella dei giorni precedenti: stessa esposizione, denominatore molto piu' grande. Chi rilegge fra mesi guardi $+1,399.39 versati e $+68.00 di trading prima di guardare l'equity.
La settimana che porta il 101% del cumulato e' la stessa in cui BTC ha fatto +14.00% e ETH +11.34%, TSMOM 3/3 su entrambi e il libro lungo su entrambe le gambe. «L'edge ha lavorato» e «un trend-following era lungo mentre il mercato saliva» spiegano quel dato ugualmente bene, e a 64 giorni e 22 round-trip non si separano. Anche l'$+8.86 di oggi e' quasi tutto rivalutazione: il realizzato e' $+0.29 su 2 round-trip, in un giorno da +0.67% e +2.64%.
Due cose insolite: SKH01 e' flat su entrambi gli asset dopo un mese da +24.05% e +32.61%, quindi il rischio e' tutto TP01; e l'implicita sta sotto la realizzata su entrambi (40.4 contro 42.0, 56.1 contro 67.8), con DVOL al 40° e 41° percentile.
Da controllare a mano: 4 fill in una giornata che chiude con un solo SELL $-5. Cosa sia successo fra i 24 giri non e' misurabile qui.
## Nota
*(vuota — campo dell'operatore)*
+60
View File
@@ -0,0 +1,60 @@
# Giornale di bordo — 2026-08-27
*Scritto 2026-08-28T08:28:09+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $80,266.00 | +1.56% | +9.94% | +25.66% | 42.1% | ↑ ↑ ↑ (3/3 su) | 41.5 (47° pctl 1a) |
| **ETH** | $2,511.75 | +0.16% | +7.96% | +30.79% | 67.9% | ↑ ↑ ↑ (3/3 su) | 57.1 (45° pctl 1a) |
## Libro
Equity **$2,070.23** · nozionale lordo $573 · leva lorda 0.28x · barra dati 2026-08-27 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.463 | flat | $+359 | $+361 | HOLD (a target) |
| **ETH** | +0.277 | flat | $+215 | $+212 | HOLD (a target) |
## P&L
- giorno: **$+4.78** di equity (24 letture)
- realizzato: +0.13 netto su 1 round-trip chiusi · 1 fill · fee 0.0019
- cumulato dall'arming: **$+1,472.17** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+72.78**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+361 (TP01 +0.463, SKH01 flat) e ETH $+212 (TP01 +0.277, SKH01 flat).
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
- `[pnl]` Equity $+4.78 con 1 fill e 1 round-trip chiusi (realizzato $+0.13 netto).
- `[concentrazione]` 📌 **Il 82% di tutto il P&L cumulato ($+72.78) viene dagli ultimi 7 giorni.** Un risultato concentrato in una finestra non e' un tasso di rendimento: e' un evento.
- `[vol]` BTC: implicita 41.5 contro realizzata 30g 42.1 (sotto); DVOL al 47° pctl di un anno; IV-rank espandente 0.16 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 57.1 contro realizzata 30g 67.9 (sotto); DVOL al 45° pctl di un anno; IV-rank espandente 0.19 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **65 giorni, 23 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-08-28T08:29:00+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Il fatto nuovo della settimana non sta nel P&L ma nella colonna equity: il salto del 25/08 da $642.02 a $2,056.59 è capitale entrato, non performance. Dei $+1,472.17 cumulati dall'arming, $+1,399.39 sono versamenti e solo $+72.78 è trading. Chi rileggerà la curva fra mesi non la legga come rendimento.
Merita un occhio umano il fatto che l'equity sia quasi triplicata ma il nozionale no: BTC $+361 / ETH $+212 oggi, contro $+345/$+212 del 25/08 e $+364/$+218 del 26/08 — praticamente fermi, con leva lorda scesa a 0.28x su un tetto di 1.00x. Se il target segua l'equity o l'intensità del segnale (TP01 +0.463 e +0.277) da questa pagina non è determinabile.
Il rischio è tutto su TP01: SKH01 flat su entrambe le gambe. Giornata di attività minima, 1 fill e 1 round-trip, $+4.78 di equity; quanto di quel numero sia mark-to-market delle gambe non è scomponibile qui.
Lettura alternativa della concentrazione: l'82% del cumulato sta negli ultimi 7 giorni, che sono anche i 7 giorni di BTC +9.94% ed ETH +7.96%. Un long-flat che guadagna in una settimana di trend sta facendo il suo mestiere; a 65 giorni e 23 round-trip non è separabile da un buon tratto di mercato.
Salute pulita: 24/24 giri, feed a 0 min. Su entrambi gli asset l'implicita sta sotto la realizzata, ma il gate di VRP01 blocca su un altro asse.
## Nota
*(vuota — campo dell'operatore)*
+58
View File
@@ -0,0 +1,58 @@
# Giornale di bordo — 2026-08-28
*Scritto 2026-08-29T00:36:42+00:00. Numeri misurati; nessuna interpretazione automatica.*
## Mercato
| | chiusura | 1g | 7g | 30g | RV30 ann. | TSMOM 30/90/180 | DVOL |
|---|---|---|---|---|---|---|---|
| **BTC** | $77,850.00 | -3.01% | -0.64% | +21.77% | 44.1% | ↑ ↑ ↑ (3/3 su) | 38.3 (25° pctl 1a) |
| **ETH** | $2,443.35 | -2.72% | -2.93% | +27.98% | 68.9% | ↑ ↑ ↑ (3/3 su) | 50.9 (16° pctl 1a) |
## Libro
Equity **$2,052.36** · nozionale lordo $571 · leva lorda 0.28x · barra dati 2026-08-28 · 24 giri
| | TP01 | SKH01 | target | posizione | azione |
|---|---|---|---|---|---|
| **BTC** | +0.464 | flat | $+357 | $+357 | HOLD (a target) |
| **ETH** | +0.278 | flat | $+214 | $+214 | HOLD (a target) |
## P&L
- giorno: **$-17.87** di equity (24 letture)
- realizzato: +0.00 netto su 0 round-trip chiusi · 2 fill · fee 0.0054
- cumulato dall'arming: **$+1,454.30** di equity — di cui $+1,399.39 versati/prelevati -> trading **$+54.91**
## Salute
- giri di `book_execute`: 24/24
- eta' feed SKH all'ultimo giro: 0 min
## Lettura
*Generata da regole dichiarate in `src/live/journal.py` — combina i numeri qui sopra e nient'altro. L'id fra parentesi quadre dice quale regola ha parlato. Non e' un giudizio: quello sta nella Nota.*
- `[stato]` A mercato su BTC $+357 (TP01 +0.464, SKH01 flat) e ETH $+214 (TP01 +0.278, SKH01 flat).
- `[leva]` Leva lorda **0.28x** su un tetto di 1.00x (`max_notional_per_asset_frac` 0.50 x 2 asset, letto da `config/live.json`): margine 0.72x.
- `[pnl]` Equity $-17.87 con 2 fill e 0 round-trip chiusi (realizzato $+0.00 netto).
- `[drawdown]` 📌 Equity **-1.02%** sotto il picco ($2,073.59). ⚠️ e' un DD su letture ORARIE, non sul minimo intra-giorno.
- `[vol]` BTC: implicita 38.3 contro realizzata 30g 44.1 (sotto); DVOL al 25° pctl di un anno; IV-rank espandente 0.09 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[vol]` ETH: implicita 50.9 contro realizzata 30g 68.9 (sotto); DVOL al 16° pctl di un anno; IV-rank espandente 0.12 sotto la soglia 0.30 del gate di VRP01 (sleeve fermo).
- `[evidenza]` 📌 Campione a oggi: **66 giorni, 23 round-trip**. A questa taglia il P&L non distingue l'edge dalla fortuna — il criterio di morte del libro (`edge_watch`) lavora su una finestra di 36 mesi.
## Analisi (agente)
*Scritta da `claude-opus-5` il 2026-08-29T00:37:39+00:00. Prosa di un modello: puo' sbagliare, e non e' una misura. Controllo sui numeri: **ok**.*
Prima giornata in rosso dal versamento: dopo +8.86 e +4.78, l'equity fa $-17.87 con zero round-trip chiusi. Il libro non ha deciso niente — BTC $+357 e ETH $+214 restano a target, dove erano ieri ($+361 e $+212) — quindi la perdita e' marcatura di mercato sul giorno a -3.01% BTC e -2.72% ETH, non esecuzione; e con SKH01 flat su entrambi e' esposizione interamente TP01. I 2 fill con fee 0.0054 sono aggiustamenti: se siano ritocchi al target o altro, non e' distinguibile qui. Salute pulita (24/24 giri, feed a 0 min), quindi non spiega nulla della giornata.
Cio' che e' cambiato davvero e' la scala. Dal 25/08 l'equity e' $2,052.36, e il movimento di un solo giorno ($-17.87) e' dello stesso ordine dei $+54.91 di trading accumulati dall'arming in 66 giorni. Il P&L giornaliero e' ora piu' rumoroso della cosa che dovrebbe misurare, mentre i round-trip restano 23.
Due letture reggono ugualmente sugli stessi numeri: TSMOM 3/3 su entrambi con 30 giorni a +21.77% e +27.98% dice trend intatto e ritracciamento; i 7 giorni (-0.64% e -2.93%) dicono che la spinta si era gia' fermata e oggi se ne vede la parte visibile. Con questi dati non si sceglie.
Merita un'occhiata umana il picco $2,073.59: non compare fra le chiusure giornaliere, e il -1.02% e' misurato contro quella lettura oraria.
## Nota
*(vuota — campo dell'operatore)*
+70
View File
@@ -58,6 +58,14 @@ EPS_REWARD = 1e-3 # USDE: sotto e' rumore di lettura; il reward atteso a $500
FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003 FILL_TS = "2026-08-26T13:04:32Z" # conversione eseguita: 500 USDE @ 1.0003
VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa VERDETTO_DOPO = "2026-08-29T12:30:00Z" # ultima finestra reward del 29/08 conclusa
# La QUOTA e' dell'operatore, e il 2026-08-29 e' stata RINVIATA a lunedi' 31/08 per decidere su
# piu' di una finestra invece che su una (il 27/08 aveva pagato ZERO, il 28/08 +0.052 USDE: fra
# le due letture ci sono 3,80% e 1,90% annuo, cioe' tutta la differenza che conta). N9: una
# decisione rinviata senza un promemoria e' rinviata per sempre — da lunedi' il watch lo dice
# ogni giorno finche' la riga non viene tolta.
DECISIONE_QUOTA_DAL = "2026-08-31" # lunedi': si decide la quota
EPS_GIORNI = 0.5 # sotto mezza giornata l'APR non si annualizza
def _parse(ts: str) -> datetime: def _parse(ts: str) -> datetime:
return datetime.fromisoformat(ts.replace("Z", "+00:00")) return datetime.fromisoformat(ts.replace("Z", "+00:00"))
@@ -102,6 +110,46 @@ def verdetto(records: list[dict], now: datetime) -> tuple[str, str]:
return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}" return "IN_ATTESA", f"finestre reward ~12:00 UTC, verdetto dopo {VERDETTO_DOPO}"
def rendimento(records: list[dict]) -> dict:
"""PURA. Il rendimento sulle finestre OSSERVATE, col loro NUMERO accanto.
E' il numero che serve alla decisione di quota, e l'unico modo onesto di darlo e' con `n`
attaccato: un pagamento non e' un tasso. Il denominatore e' il TEMPO VERO fra la prima e
l'ultima lettura misurabile, non il conteggio delle finestre — cosi' una finestra che non
paga abbassa la stima invece di sparire (P5: il silenzio in una serie di ritorni si legge
come zero, e qui e' proprio uno zero).
Si escludono le letture con trade nel mezzo (`con_trade`): quel delta non e' solo reward, e
una riparazione silenziosa sarebbe un'invenzione (P12).
"""
usabili = [r for r in records
if r.get("reward_stimato") is not None and not r.get("con_trade")]
pagate = [r for r in usabili if r.get("reward_rilevato")]
if not usabili:
return dict(apr=None, finestre=0, pagate=0, giorni=0.0, tot=0.0,
nota="nessuna finestra misurabile")
tot = float(sum(r["reward_stimato"] for r in usabili))
eq = [float(r["eq_usde"]) for r in usabili if r.get("eq_usde")]
eq_med = sum(eq) / len(eq) if eq else 0.0
# tempo vero coperto: dalla lettura PRIMA della prima misurabile, all'ultima
i0 = records.index(usabili[0])
t0 = records[max(0, i0 - 1)]["ts"]
giorni = (usabili[-1]["ts"] - t0) / 86_400_000.0
if giorni < EPS_GIORNI or eq_med <= 0:
return dict(apr=None, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
nota=f"solo {giorni:.2f} giorni osservati: non si annualizza")
apr = tot / eq_med / giorni * 365.0
return dict(apr=apr, finestre=len(usabili), pagate=len(pagate), giorni=giorni, tot=tot,
nota=f"{len(pagate)}/{len(usabili)} finestre hanno pagato")
def quota_da_decidere(now: datetime, verdetto_corrente: str) -> bool:
"""PURA. La decisione di quota e' dovuta? Solo se il conto e' IDONEO e il rinvio e' scaduto."""
if verdetto_corrente != "IDONEO":
return False
return now.date().isoformat() >= DECISIONE_QUOTA_DAL
def condizioni(rec: dict | None, c: dict) -> set[str]: def condizioni(rec: dict | None, c: dict) -> set[str]:
"""PURA. Condizioni di allerta attive su un record.""" """PURA. Condizioni di allerta attive su un record."""
if not rec: if not rec:
@@ -229,6 +277,20 @@ def main() -> int:
notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo}) notify("⚠️ USDE: conto NON idoneo", {"dettaglio": v_motivo})
inviati.append(f"VERDETTO->{v}") inviati.append(f"VERDETTO->{v}")
# La QUOTA e' dell'operatore ed e' stata rinviata a lunedi': da quel giorno lo si dice OGNI
# giorno, non solo alla transizione. Ripetersi qui e' voluto — non e' un allarme che chiede
# un'azione impossibile (P14), e' una domanda che da lunedi' si puo' finalmente rispondere,
# con accanto il numero per rispondere. Si smette togliendo `DECISIONE_QUOTA_DAL`.
rend = rendimento(records + [rec])
if quota_da_decidere(now, v):
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
notify("📌 USDE: la decisione di QUOTA e' dovuta", {
"quota attuale": f"{quota:.1%}" if quota is not None else "n/d",
"tetto di allerta": f"{c['quota_max_frac']:.0%} (mai 100%: R1 emittente non recuperabile)",
"rendimento osservato": f"{apr}{rend['nota']}, {rend['giorni']:.1f} giorni",
"nota": "un pagamento non e' un tasso: leggere l'APR con il suo n"})
inviati.append("QUOTA_DA_DECIDERE")
cambiato = bool(inviati) or v != v_prev cambiato = bool(inviati) or v != v_prev
if not quiet or cambiato: if not quiet or cambiato:
print("=" * 78) print("=" * 78)
@@ -249,6 +311,14 @@ def main() -> int:
+ (" [con trade nel mezzo]" if an["con_trade"] else "")) + (" [con trade nel mezzo]" if an["con_trade"] else ""))
print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}") print(f" delta : {an['delta']:+.6f} USDE dall'ultima lettura -> {attr}")
print(f" verdetto : {v}{v_motivo}") print(f" verdetto : {v}{v_motivo}")
if rend["finestre"]:
apr = "n/d" if rend["apr"] is None else f"{rend['apr']*100:.2f}% annuo"
print(f" rendimento : {apr} su {rend['giorni']:.1f} giorni — {rend['nota']}"
f" (tot {rend['tot']:+.6f} USDE)")
if rend["finestre"] < 4:
print(f" ⚠️ n={rend['finestre']}: un pagamento non e' un tasso")
if v == "IDONEO" and not quota_da_decidere(now, v):
print(f" quota : decisione RINVIATA a {DECISIONE_QUOTA_DAL} (operatore)")
if inviati: if inviati:
print(f" allarmi : {', '.join(inviati)}") print(f" allarmi : {', '.join(inviati)}")
return 0 return 0
+29 -15
View File
@@ -16,14 +16,37 @@ ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from src.live.deribit import check_specs # noqa: E402 from src.live.deribit import check_specs # noqa: E402
from src.live.notifier import notify # noqa: E402 from src.live.notifier import notify, ultimo_errore # noqa: E402
from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402 from src.live.venue_watch import (PERSIST_HOURS, THRESHOLD_BPS, # noqa: E402
run_once) run_once)
def _titolo(rep: dict) -> str:
"""⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve
sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py).
⚠️ Dal 19/08 DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di prova, anche per
un blocco da manutenzione annunciata: il 18/08 sono usciti quattro messaggi identici che
chiedevano di prelevare, tutti dopo che il book aveva gia' ripreso. Un allarme massimo speso
per un evento atteso e' un allarme che non verra' letto il giorno che e' vero."""
if rep.get("severity") == "warn":
return "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
return "🚨 VENUE WATCH — possibile stress su Deribit. PRIMO PASSO: prelievo di prova"
def _manda(rep: dict) -> tuple[bool, str]:
"""Il sender iniettato in `run_once`: e' lui a decidere se i marcatori 'gia' detto' si
scrivono. `tentativi=3` perche' su questo percorso il 6,9% degli invii si perde, e qui
perderne uno significa perdere l'episodio intero."""
ok = notify(_titolo(rep), {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])},
tentativi=3)
if ok:
return True, "ok"
return False, (ultimo_errore() or "motivo non registrato")
def main() -> int: def main() -> int:
quiet = "--quiet" in sys.argv quiet = "--quiet" in sys.argv
rep = run_once() rep = run_once(sender=_manda)
if not quiet: if not quiet:
print("=" * 78) print("=" * 78)
@@ -65,21 +88,12 @@ def main() -> int:
print(f" SPEC: {k}: {v}") print(f" SPEC: {k}: {v}")
if rep["alerts"]: if rep["alerts"]:
# ⚠️ Il titolo dice cosa FARE, non solo cosa e' successo: chi lo legge sul telefono deve # L'invio l'ha gia' fatto `run_once` attraverso `_manda`: qui si stampa e si riporta
# sapere il primo passo senza aprire il repo (runbook completo in src/live/venue_watch.py). # l'ESITO. Un invio fallito si vede nel log del cron invece di sparire — e lo stato su
# ⚠️ Dal 19/08 il titolo DIPENDE dalla gravita'. Prima era sempre il 🚨 col prelievo di # disco e' stato disfatto, quindi l'ora prossima ci riprova da solo.
# prova, anche per un blocco da manutenzione annunciata: il 18/08 sono usciti quattro
# messaggi identici che chiedevano di prelevare, tutti dopo che il book aveva gia'
# ripreso. Un allarme massimo speso per un evento atteso e' un allarme che non verra'
# letto il giorno che e' vero — ed e' l'unico giorno che conta.
if rep.get("severity") == "warn":
titolo = "⚠️ VENUE WATCH — Deribit non operativa, ma spiegata. Nessuna azione"
else:
titolo = ("🚨 VENUE WATCH — possibile stress su Deribit. "
"PRIMO PASSO: prelievo di prova")
notify(titolo, {f"alert {i+1}": a for i, a in enumerate(rep["alerts"])})
for a in rep["alerts"]: for a in rep["alerts"]:
print(f" ALERT: {a}") print(f" ALERT: {a}")
print(f" telegram: {rep.get('invio')}")
return 2 if rep.get("severity") == "alert" else 0 return 2 if rep.get("severity") == "alert" else 0
return 0 return 0
+15 -1
View File
@@ -129,7 +129,21 @@ def main():
sys.exit(2) sys.exit(2)
ib = IB() ib = IB()
try: try:
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15) # `readonly=True`: questo client SCARICA STORICO e non manda ordini mai. Non e' solo
# igiene — in `ib_async.IB.connectAsync` le richieste "open orders" e "completed orders"
# esistono SOLO se il client non e' readonly (`if not readonly: reqs[...]`), e sul gateway
# paper non rispondono: ogni notte, 63 giri su 63, il log del cron si prendeva
# open orders request timed out
# completed orders request timed out
# Due righe d'errore innocue ripetute per sempre sono il modo in cui un errore VERO
# smette di farsi notare (P14). Qui si toglie la CAUSA, non si filtra il messaggio.
# ⚠️ OSSERVATO il 2026-08-28: a meta' pomeriggio il gateway NON serve storico —
# `reqHistoricalData` va in timeout e lo script stampa "0 barre (subscription?)" per ogni
# simbolo. Quattro tentativi fra le 13:05 e le 15:35 UTC, tutti a zero, con e senza
# `readonly`; il giro del cron delle 00:30 riesce ogni notte. La causa sta nel gateway,
# non qui, e non e' stata diagnosticata. Non e' pericoloso: con 0 barre lo script NON
# sovrascrive i parquet, quindi un giro fallito lascia il dato di ieri invece di romperlo.
ib.connect("127.0.0.1", 4002, clientId=90, timeout=15, readonly=True)
except Exception as e: except Exception as e:
print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway") print(f"[CONNESSIONE FALLITA] 127.0.0.1:4002 -> {repr(e)[:120]}\n Avvia: docker compose up -d ib-gateway")
sys.exit(1) sys.exit(1)
@@ -0,0 +1,231 @@
#!/usr/bin/env python
"""r0828_gtaa_band_median_phase.py — criterio (C): la banda giudicata sulla MEDIANA delle 5 fasi.
⚠️ DA LEGGERE PRIMA DI CITARE QUALUNQUE NUMERO DI QUI.
Questo criterio e' stato scelto **DOPO** aver visto la tabella delle fasi di
`r0828_gtaa_band_phase.py`, che mostra il criterio a fase singola cambiare verdetto da una notte
all'altra. Sceglierne uno nuovo guardando l'esito del vecchio e' selezione, esattamente come
scegliere una cella guardando l'hold-out. Quindi:
· questo script NON valida la proposta del 27/07 e NON puo' essere citato come sua conferma;
· l'unica domanda che puo' porre onestamente e' quella sullo STRUMENTO — «un criterio costruito
cosi' avrebbe risoluzione e potenza?» — che si risponde senza guardare se la cella che vince
e' quella che ci piace;
· se la risposta e' si', il criterio va poi DICHIARATO e la validazione RIFATTA da capo su di
esso, con la sua data. Il verdetto di oggi sulla banda resta *non decidibile*.
L'IDEA. Il difetto misurato in `r0828_gtaa_band_phase.py` e' che `gtaa._gated_returns` fasa il
ribilanciamento sulla POSIZIONE nell'array (`i % every == 0`), e la prima barra di SPY scivola di
un giorno ogni notte perche' IB serve una finestra rotolante di 30 anni. La fase canonica quindi
cambia da sola. La mediana sulle 5 fasi toglie di mezzo la componente di FASE — e' la stessa
mossa che il progetto fa gia' in `r0726_loo_deluck` (M7: su ancore appaiate la statistica e' la
mediana). Resta la componente di DATO: le barre perse. La domanda e' se basti.
I CRITERI, DICHIARATI QUI PRIMA DI GUARDARE I NUMERI. Il pavimento resta **0.01**, quello che il
test si diede il 07/08: non lo si ritocca dopo aver visto l'esito, o si sta tarando il metro sul
risultato. Il criterio (C) ha risoluzione e potenza se e solo se, sulla statistica mediana:
(i) la banda scelta al buio e' LA STESSA in tutte le NOTTI notti simulate;
(ii) il margine sulla seconda resta >= 0.01 in tutte;
(iii) POTENZA — la banda scelta sull'hold-out e' DIVERSA da quella scelta al buio. Se
coincidono, il gate lo passerebbe anche una proposta selezionata sull'hold-out, e non
distingue niente (controllo positivo, come il 07/08).
E una lettura di contorno che vale piu' del verdetto: la mediana di 5 fasi non e' una mediana di
5 osservazioni. Si stampa la correlazione media fra le serie di fase e l'N_eff che ne segue
(§2 del CLAUDE.md: «positivo in N/N ancore NON e' N osservazioni»).
uv run python scripts/research/r0828_gtaa_band_median_phase.py
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import r0727_gtaa_band_gate as BG # noqa: E402
import src.portfolio.gtaa as G # noqa: E402
NOTTI = 10
MARGINE_MINIMO = 0.01 # invariato dal 07/08: il metro non si ritocca sul risultato
EVERY = BG.PROPOSTA[0]
FASI = tuple(range(EVERY))
PROPOSTA_BANDA = BG.PROPOSTA[1]
ROTOLANTE = ("SPY",) # l'unica gamba al muro dei 30 anni (misurato in r0828_gtaa_band_phase)
@lru_cache(maxsize=None)
def _serie(sym: str, taglio: int):
"""(prezzi, esposizione) di una gamba, tagliata in testa di `taglio` barre. In cache: e' il
pezzo caro (l'esposizione e' un doppio ciclo su 30 anni) e non dipende da banda ne' fase."""
close = G._close(sym)
if taglio:
close = close.iloc[taglio:]
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
return close, ex
def _leg(sym: str, cap_leg: float, band_usd: float, ph: int, taglio: int) -> pd.Series:
close, ex = _serie(sym, taglio)
px = close.values.astype(float)
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)):
if i % EVERY == ph:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(band_usd, G.IB_MIN_TRADE_USD):
comm[i] = G.ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
net = pos * ret - comm
net[0] = 0.0
return pd.Series(net, index=close.index)
def cella(frac: float, ph: int, taglio: int) -> pd.Series:
cap_leg = BG.CAP_REF / len(G.EQ_UNIVERSE)
band = frac * BG.CAP_REF / len(G.EQ_UNIVERSE)
cols = {a: _leg(a, cap_leg, band,
ph if a in ROTOLANTE else 0,
taglio if a in ROTOLANTE else 0)
for a in G.EQ_UNIVERSE}
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
def sh(frac: float, ph: int, taglio: int, finestra: str) -> float:
s = cella(frac, ph, taglio)
s = s.loc[: BG.HOLDOUT] if finestra == "in" else s.loc[BG.HOLDOUT:]
return BG.met(s)["sharpe"]
def mediana_fasi(frac: float, taglio: int, finestra: str = "in") -> float:
return float(np.median([sh(frac, ph, taglio, finestra) for ph in FASI]))
def scelta(valori: dict) -> tuple[float, float]:
best = max(valori, key=valori.get)
return best, valori[best] - max(v for f, v in valori.items() if f != best)
def sanity() -> bool:
mia = cella(PROPOSTA_BANDA, 0, 0)
vera = BG.run_cell(EVERY, PROPOSTA_BANDA, BG.CAP_REF)
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
d = float((j["m"] - j["v"]).abs().max())
ok = bool(np.isfinite(d) and d < 1e-12 and len(j) == len(vera))
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {d:.2e} -> {'OK' if ok else 'DIVERGE'}")
return ok
def main() -> int:
print(__doc__.split("\n\n")[0])
print("\n⚠️ criterio scelto DOPO aver visto l'esito del precedente: misura lo STRUMENTO, "
"non valida la banda.\n")
if not sanity():
return 2
# ---------------------------------------------------------------- contorno: quante osservazioni
print("=" * 100)
print("CONTORNO — la mediana di 5 fasi, quante osservazioni sono davvero?")
print("=" * 100)
serie = {ph: cella(PROPOSTA_BANDA, ph, 0).loc[: BG.HOLDOUT] for ph in FASI}
M = pd.concat(serie, axis=1, join="inner").dropna()
C = M.corr().values
rho = float((C.sum() - len(FASI)) / (len(FASI) * (len(FASI) - 1)))
n_eff = len(FASI) / (1.0 + (len(FASI) - 1) * rho)
print(f" correlazione media fra le 5 serie di fase (banda {PROPOSTA_BANDA:.0%}, in-sample): {rho:.3f}")
print(f" N_eff = {len(FASI)} / (1 + {len(FASI)-1}·ρ) = {n_eff:.2f}")
print(" -> la mediana media su ~2 osservazioni indipendenti, non su 5: toglie la fase,")
print(" non compra precisione. Va citata come robustezza alla fase, mai come campione.")
# ---------------------------------------------------------------- (i) e (ii)
print("\n" + "=" * 100)
print(f"(C) LA MEDIANA DELLE 5 FASI — banda scelta al buio, su {NOTTI} notti di finestra rotolante")
print("=" * 100)
spy = G._close("SPY")
righe, righe_singola = [], []
for k in range(NOTTI):
med = {f: mediana_fasi(f, k) for f in BG.FRAC_GRID}
b, m = scelta(med)
righe.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{b:.0%}",
margine=round(m, 4), **{f"{f:.0%}": round(med[f], 4) for f in BG.FRAC_GRID}))
sing = {f: sh(f, 0, k, "in") for f in BG.FRAC_GRID}
bs, ms = scelta(sing)
righe_singola.append(dict(notte=k, scelta=f"{bs:.0%}", margine=round(ms, 4)))
D = pd.DataFrame(righe)
pd.set_option("display.width", 250)
print(D.to_string(index=False))
scelte = set(D.scelta)
quota = float((D.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
print(f"\n celle distinte scelte: {sorted(scelte)} | la proposta ({PROPOSTA_BANDA:.0%}) vince {quota:.0%} delle notti")
print(f" margine: min {D.margine.min():.4f} mediana {D.margine.median():.4f} max {D.margine.max():.4f}")
print(f" notti sotto il pavimento {MARGINE_MINIMO}: {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
S = pd.DataFrame(righe_singola)
print("\n confronto diretto col criterio a FASE SINGOLA (quello che fallisce oggi):")
print(f" fase singola : celle {sorted(set(S.scelta))} "
f"| sotto pavimento {int((S.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
f"| margine min {S.margine.min():.4f}")
print(f" mediana fasi : celle {sorted(scelte)} "
f"| sotto pavimento {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
f"| margine min {D.margine.min():.4f}")
esc_s = {f: 0.0 for f in BG.FRAC_GRID}
for f in BG.FRAC_GRID:
vs = [sh(f, 0, k, "in") for k in range(NOTTI)]
vm = [D[f"{f:.0%}"].iloc[k] for k in range(NOTTI)]
esc_s[f] = (max(vs) - min(vs), max(vm) - min(vm))
print("\n escursione dello Sharpe in-sample su 10 notti, per banda (fase singola -> mediana):")
for f in BG.FRAC_GRID:
a, b = esc_s[f]
print(f" banda {f:>4.0%}: {a:.4f} -> {b:.4f} ({'' if b < a else '+'}{abs(b-a)/a*100:4.0f}%)")
# ---------------------------------------------------------------- (iii) potenza
print("\n" + "=" * 100)
print("(iii) POTENZA — chi guardasse l'HOLD-OUT sceglierebbe la stessa banda?")
print("=" * 100)
med_in = {f: mediana_fasi(f, 0, "in") for f in BG.FRAC_GRID}
med_oos = {f: mediana_fasi(f, 0, "oos") for f in BG.FRAC_GRID}
b_in, m_in = scelta(med_in)
b_oos, m_oos = scelta(med_oos)
T = pd.DataFrame([dict(banda=f"{f:.0%}", med_in_sample=round(med_in[f], 4),
med_hold_out=round(med_oos[f], 4)) for f in BG.FRAC_GRID])
print(T.to_string(index=False))
print(f"\n scelta al buio (pre-2015): {b_in:.0%} (margine {m_in:.4f})")
print(f" scelta sull'hold-out : {b_oos:.0%} (margine {m_oos:.4f})")
potenza = b_in != b_oos
print(f" -> {'DIVERSE: il gate ha potenza' if potenza else 'UGUALI: il gate NON ha potenza'}")
# ---------------------------------------------------------------- verdetto
print("\n" + "=" * 100)
print("VERDETTO — coi criteri dichiarati in testa, calcolati adesso")
print("=" * 100)
stabile = len(scelte) == 1
sopra = bool(D.margine.min() >= MARGINE_MINIMO)
print(f" (i) scelta stabile su {NOTTI} notti : {'SI' if stabile else 'NO'}{sorted(scelte)}")
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} — minimo {D.margine.min():.4f}")
print(f" (iii) potenza (in ≠ hold-out) : {'SI' if potenza else 'NO'}")
if stabile and sopra and potenza:
print("\n -> (C) e' uno strumento utilizzabile. NON e' una validazione della banda:")
print(" va DICHIARATO come criterio, con la sua data, e la validazione va RIFATTA su di esso.")
return 0
manca = [n for n, v in (("i", stabile), ("ii", sopra), ("iii", potenza)) if not v]
print(f"\n -> (C) NON basta: cade su ({'), ('.join(manca)}).")
print(" La mediana toglie la FASE ma non le barre perse: se il verdetto si muove ancora,")
print(" l'instabilita' e' nel DATO, e nessun criterio la aggira. Si ripara la fonte.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python
"""r0828_gtaa_band_phase.py — il gate (A) della banda GTAA01 fallisce di nuovo: e' la proposta, o e' ANCORA il gate?
CONTESTO. Il 27/07 fu proposta la banda al 25% della gamba. Il gate (A) — SELEZIONE IN-SAMPLE —
la valido' due volte, con due criteri diversi, e li ha rotti entrambi senza che nessuno toccasse
il codice:
· 27/07 criterio `rank_in <= rank_oos`. Rotto il 07/08. Motivo (r0807_gtaa_gate_resolution):
lo passa ~meta' della griglia PER COSTRUZIONE, e sulla proposta decideva su 0.00116
di Sharpe. Una moneta. RITIRATO.
· 07/08 criterio sostitutivo: «a cadenza di produzione, la banda scelta guardando SOLO il
pre-2015 e' la proposta». Passava col 25% e un margine di +0.0151 sulla seconda —
13x il criterio precedente. Rotto il 28/08: al buio esce il 60%, margine 0.0074,
SOTTO il pavimento di 0.01 che il test stesso si era dato.
Il codice e' fermo (`git log src/portfolio/gtaa.py scripts/research/r0727_gtaa_band_gate.py`
non ha commit dopo il 07/08). Quindi si e' mosso il DATO. Questo script chiede, per la terza
volta e sullo stesso parametro, se il criterio misura cio' che dichiara.
LE DUE COSE CHE SI SONO TROVATE
-------------------------------
(0) **LA FINESTRA IN-SAMPLE NON E' FERMA.** IB ritorna una finestra ROTOLANTE di 30 anni. SPY e'
l'unica gamba al muro (quotato 1993, storia servita dal 1996), e nel `logs/cron_daily.log`
la sua data d'inizio avanza di un giorno di borsa OGNI NOTTE: 1996-07-02 il 24/06,
1996-09-04 oggi. Il pre-2015 — che tutto il progetto tratta come una finestra congelata —
perde il suo giorno piu' vecchio ogni notte. (QQQ e IWM colpiranno lo stesso muro nel
2029-2030: non e' un difetto di SPY, e' il muro che avanza.)
(1) **L'AMPLIFICATORE E' LA FASE.** `gtaa._gated_returns` ribilancia su `i % every == 0`: l'indice
e' la POSIZIONE NELL'ARRAY, quindi la fase e' ancorata alla prima barra del file. Se la prima
barra scivola di un giorno, si ri-fasa ogni decisione di ribilanciamento di trent'anni di
storia. Il progetto sa gia' che la fase conta — `r0726_loo_deluck.gtaa01_at(ph)` la tratta
come un'ancora a 5 valori e ci de-lucka sopra — ma la assumeva FISSA alla fase canonica 0.
Non lo e': **la fase canonica cambia da sola ogni notte**.
I DUE ESPERIMENTI, in quest'ordine
----------------------------------
(A) **LE NOTTI.** Si taglia la testa di SPY di k=0..N-1 barre, che e' esattamente cio' che la
finestra rotolante fa da sola in N notti, e si guarda la banda scelta al buio.
(B) **LA FASE PURA.** A dato FISSO, si sposta la sola fase di ribilanciamento di SPY (ph=0..4).
Serve ad attribuire: se il verdetto si muove anche qui, l'imputato e' la fase, non le barre
perse. Col controllo su TUTTE le gambe accanto, che dice quanto sarebbe peggio quando anche
QQQ e IWM arriveranno al muro.
CRITERIO DEL VERDETTO, dichiarato PRIMA (M13: un criterio si misura sulla sua RISOLUZIONE prima
che sul suo esito). Il gate (A) ha risoluzione se e solo se:
(i) la banda scelta al buio e' LA STESSA in tutte le notti e in tutte le fasi, e
(ii) il margine sulla seconda resta sopra MARGINE_MINIMO in tutte.
Se (i) o (ii) cadono, il criterio non distingue la proposta da una cella qualunque, e va ritirato
come il suo predecessore — si congela il MOTIVO, non l'esito.
uv run python scripts/research/r0828_gtaa_band_phase.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import r0727_gtaa_band_gate as BG # noqa: E402
import src.portfolio.gtaa as G # noqa: E402
NOTTI = 10 # notti di finestra rotolante da simulare
MARGINE_MINIMO = 0.01 # il pavimento che il test si e' dato il 07/08, non ritoccato qui
MURO_IB_ANNI = 30.0 # finestra massima servita da IB: chi la tocca rotola
PROPOSTA_BANDA = BG.PROPOSTA[1]
# Tabella PUBBLICATA il 07/08 (docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md, sezione (2)).
# Serve a misurare di quanto si e' mossa una finestra che NON puo' acquisire dati nuovi.
SH_IN_0807 = {0.00: 0.5396, 0.05: 0.5714, 0.10: 0.5956, 0.25: 0.6398, 0.40: 0.6247, 0.60: 0.5874}
def _leg(sym: str, cap_leg: float, band_usd: float, every: int, ph: int,
taglio: int = 0) -> pd.Series:
"""`gtaa._gated_returns` con due manopole: la FASE (`ph`) e il TAGLIO in testa alla serie.
Copia dichiarata, non riuso: la produzione non espone ne' l'una ne' l'altra. La replica a
(ph=0, taglio=0) deve tornare bit-exact quella di produzione — lo verifica `sanity()`.
"""
close = G._close(sym)
if taglio:
close = close.iloc[taglio:]
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
px = close.values.astype(float)
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)):
if i % every == ph:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(band_usd, G.IB_MIN_TRADE_USD):
comm[i] = G.ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
net = pos * ret - comm
net[0] = 0.0
return pd.Series(net, index=close.index)
def cella(frac: float, ph: int = 0, taglio: int = 0, tutte: bool = False) -> pd.Series:
"""La cella (cadenza di produzione, banda `frac`). `ph`/`taglio` si applicano alla sola SPY
salvo `tutte`: e' SPY l'unica gamba al muro dei 30 anni, e il resto della griglia e' fermo."""
cap_leg = BG.CAP_REF / len(G.EQ_UNIVERSE)
band = frac * BG.CAP_REF / len(G.EQ_UNIVERSE)
cols = {a: _leg(a, cap_leg, band, BG.PROPOSTA[0],
ph if (tutte or a == "SPY") else 0,
taglio if (tutte or a == "SPY") else 0)
for a in G.EQ_UNIVERSE}
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
def riga(ph: int = 0, taglio: int = 0, tutte: bool = False) -> dict:
sh = {f: BG.met(cella(f, ph, taglio, tutte).loc[: BG.HOLDOUT])["sharpe"] for f in BG.FRAC_GRID}
best = max(sh, key=sh.get)
secondo = max(v for f, v in sh.items() if f != best)
return dict(scelta=best, margine=sh[best] - secondo, sh=sh)
def sanity() -> bool:
"""La replica a (fase 0, taglio 0) DEVE essere quella di produzione. Senza questo, ogni
numero sotto misura la mia copia invece dello sleeve che gira."""
mia = cella(PROPOSTA_BANDA, 0, 0)
vera = BG.run_cell(BG.PROPOSTA[0], PROPOSTA_BANDA, BG.CAP_REF)
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
delta = float((j["m"] - j["v"]).abs().max())
ok = bool(np.isfinite(delta) and delta < 1e-12 and len(j) == len(vera))
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {delta:.2e} "
f"barre {len(j)} vs {len(vera)} -> {'OK' if ok else 'DIVERGE'}")
return ok
def storia() -> pd.DataFrame:
"""(0) Quali gambe sono al muro dei 30 anni — cioe' quali hanno una finestra ROTOLANTE."""
rows = []
for a in G.EQ_UNIVERSE:
s = G._close(a)
anni = (s.index[-1] - s.index[0]).days / 365.25
rows.append(dict(gamba=a, dal=str(s.index[0].date()), barre=len(s), anni=round(anni, 1),
al_muro="SI — rotola" if anni >= MURO_IB_ANNI - 0.15 else "no",
anni_al_muro=round(MURO_IB_ANNI - anni, 1)))
return pd.DataFrame(rows)
def main() -> int:
print(__doc__.split("\n\n")[0])
print()
if not sanity():
print("!! la replica non riproduce la produzione: i numeri sotto non sono confrontabili")
return 2
print("\n" + "=" * 96)
print("(0) LA FINESTRA E' ROTOLANTE — quali gambe sono al muro dei 30 anni di IB")
print("=" * 96)
H = storia()
print(H.to_string(index=False))
rotolanti = list(H[H.al_muro.str.startswith("SI")].gamba)
print(f"\n gambe con finestra rotolante: {rotolanti or 'nessuna'} — per queste, il pre-2015 "
"perde il giorno piu' vecchio ogni notte.")
print("\n" + "=" * 96)
print(" quanto si e' mossa una finestra che NON puo' acquisire dati nuovi (07/08 -> oggi)")
print("=" * 96)
oggi = riga(0, 0)["sh"]
D = pd.DataFrame([dict(banda=f"{f:.0%}", sh_in_0807=SH_IN_0807[f], sh_in_oggi=round(oggi[f], 4),
delta=round(oggi[f] - SH_IN_0807[f], 4)) for f in BG.FRAC_GRID])
print(D.to_string(index=False))
print(f"\n spostamento massimo in valore assoluto: {D.delta.abs().max():.4f} di Sharpe, "
f"su una finestra CHIUSA (pre-2015) e a codice fermo.")
print("\n" + "=" * 96)
print(f"(A) LE NOTTI — banda scelta al buio in {NOTTI} notti consecutive di finestra rotolante")
print("=" * 96)
spy = G._close("SPY")
A = []
for k in range(NOTTI):
r = riga(0, k)
A.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{r['scelta']:.0%}",
margine=round(r["margine"], 4),
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
DA = pd.DataFrame(A)
print(DA.to_string(index=False))
scelte_A = set(DA.scelta)
quota_proposta = float((DA.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
print(f"\n celle distinte scelte: {sorted(scelte_A)} "
f"| la proposta ({PROPOSTA_BANDA:.0%}) vince {quota_proposta:.0%} delle notti")
print(f" margine: min {DA.margine.min():.4f} mediana {DA.margine.median():.4f} "
f"max {DA.margine.max():.4f} (pavimento {MARGINE_MINIMO})")
print(f" notti sotto il pavimento: {int((DA.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
print("\n" + "=" * 96)
print("(B) LA FASE PURA — dato FISSO, si muove solo la fase di ribilanciamento")
print("=" * 96)
for tutte, eti in ((False, f"fase mossa solo su {rotolanti or ['SPY']} (cio' che succede DAVVERO oggi)"),
(True, "fase mossa su TUTTE le gambe (controllo: quando anche QQQ e IWM saranno al muro)")):
B = []
for ph in range(BG.PROPOSTA[0]):
r = riga(ph, 0, tutte)
B.append(dict(fase=ph, scelta=f"{r['scelta']:.0%}", margine=round(r["margine"], 4),
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
DB = pd.DataFrame(B)
print(f"\n {eti}")
print(DB.to_string(index=False))
print(f" celle distinte: {sorted(set(DB.scelta))} | margine min {DB.margine.min():.4f}")
if not tutte:
scelte_B, marg_B = set(DB.scelta), DB.margine
print("\n" + "=" * 96)
print("VERDETTO — coi criteri dichiarati in testa allo script, calcolati adesso")
print("=" * 96)
stabile = len(scelte_A) == 1 and len(scelte_B) == 1
sopra = bool(DA.margine.min() >= MARGINE_MINIMO and marg_B.min() >= MARGINE_MINIMO)
print(f" (i) scelta stabile su notti e fasi : {'SI' if stabile else 'NO'} "
f"— notti {sorted(scelte_A)}, fasi {sorted(scelte_B)}")
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} "
f"— minimo osservato {min(DA.margine.min(), marg_B.min()):.4f}")
if stabile and sopra:
print("\n -> il gate (A) HA risoluzione: il fallimento del test e' un fatto sulla PROPOSTA.")
return 0
print("\n -> il gate (A) NON ha risoluzione. Il verdetto non e' una proprieta' della banda:")
print(" e' una proprieta' della notte in cui lo si legge. Come il criterio a ranghi")
print(" ritirato il 07/08, va ritirato — congelando il MOTIVO, non l'esito.")
print(f" La banda della proposta ({PROPOSTA_BANDA:.0%}) resta la scelta al buio in "
f"{quota_proposta:.0%} delle notti: ne' confermata ne' smentita, NON DECIDIBILE cosi'.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python
"""r0828_senza_gtaa.py — cosa cambia nel portafoglio di RICERCA se si toglie GTAA01.
PERCHE'. Il 28/08 si e' misurato che il gate (A) della banda GTAA01 non ha risoluzione, e che
lo sleeve gira su una finestra che rotola da sola (r0828_gtaa_band_phase). Prima di decidere
cosa fare del gate conviene sapere quanto pesa lo sleeve: se togliendolo non cambia niente, la
domanda sul gate e' accademica.
⚠️ QUESTO NON E' IL LIBRO LIVE. Il book che gira e' TP01+SKH01 75/25 su Deribit: GTAA01 non c'e'.
Qui si guarda il portafoglio a 5 sleeve di `sleeves.active_sleeves` (33/15/12/20/20), che e' la
serie su cui stanno quasi tutti i numeri di portafoglio del progetto.
LE DUE LENTI, ed e' la seconda quella che decide (M6: un diversificatore a BASSO CAGR si giudica
a iso-rischio, mai a iso-nozionale; M5: ogni claim "meno drawdown" si testa a iso-rischio prima
di crederci, o si sta comprando de-levering e chiamandolo diversificazione):
· ISO-NOZIONALE — si toglie GTAA01 e si ridistribuisce il suo 20% agli altri quattro in
proporzione. E' cio' che succede materialmente.
· ISO-RISCHIO — la stessa serie, riscalata alla volatilita' del portafoglio a 5. Risponde alla
domanda giusta: a parita' di rischio preso, quanto rende in piu' o in meno?
Si stampano FULL e HOLD-OUT (la data e' quella del progetto, `portfolio.HOLDOUT`), il per-anno
(M9: un contributo si scompone per anno prima di crederci) e la correlazione di GTAA01 col resto.
Verdetto calcolato a runtime coi criteri dichiarati qui sotto.
CRITERIO, dichiarato prima: GTAA01 "porta qualcosa" se, A ISO-RISCHIO, toglierlo peggiora lo
Sharpe FULL di almeno SOGLIA_SHARPE **e** l'hold-out non lo smentisce. Sotto quella soglia il
contributo e' dentro lo spread fra le tre baseline che il progetto gia' si porta dietro (0.12 di
Sharpe, §2 del CLAUDE.md), quindi non e' distinguibile dalla scelta della lente.
uv run python scripts/research/r0828_senza_gtaa.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.portfolio.portfolio import HOLDOUT, combine_outer, metrics, to_daily, yearly # noqa: E402
from src.portfolio.sleeves import active_sleeves # noqa: E402
SOGLIA_SHARPE = 0.12 # spread fra le tre baseline "libro 75/25" (§2): sotto, non si vede
FUORI = "GTAA01_eq_trend"
def serie_sleeve() -> tuple[dict, dict]:
cols, w = {}, {}
for s in active_sleeves():
cols[s.name] = s.daily().dropna()
w[s.name] = float(s.weight)
return cols, w
def senza(w: dict, fuori: str) -> dict:
"""I pesi degli altri, rinormalizzati: il 20% di GTAA01 va agli altri in proporzione."""
resto = {k: v for k, v in w.items() if k != fuori}
tot = sum(resto.values())
return {k: v / tot for k, v in resto.items()}
def riga(nome: str, m: dict) -> str:
return (f" {nome:<22} Sh {m['sharpe']:>5.2f} CAGR {m['cagr']*100:>+6.1f}% "
f"maxDD {m['maxdd']*100:>5.1f}% vol {m.get('vol', float('nan'))*100:>5.1f}% n {m['n']}")
def con_vol(s: pd.Series) -> dict:
m = metrics(s)
m["vol"] = float(np.asarray(s.dropna().values, float).std() * np.sqrt(365.0))
return m
def main() -> int:
print(__doc__.split("\n\n")[0])
cols, w5 = serie_sleeve()
if FUORI not in cols:
print(f"!! {FUORI} non e' fra gli sleeve attivi: niente da togliere")
return 2
w4 = senza(w5, FUORI)
cols4 = {k: v for k, v in cols.items() if k != FUORI}
print(f"\n pesi CON : " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w5.items()))
print(f" pesi SENZA: " + " ".join(f"{k.split('_')[0]} {v:.0%}" for k, v in w4.items()))
con = combine_outer(cols, w5)
sen = combine_outer(cols4, w4)
# allineate sullo STESSO calendario: senza questo si confronterebbero due finestre diverse
J = pd.concat({"con": con, "senza": sen}, axis=1, join="inner").dropna()
con, sen = J["con"], J["senza"]
m_con, m_sen = con_vol(con), con_vol(sen)
k = m_con["vol"] / m_sen["vol"] if m_sen["vol"] > 0 else 1.0
sen_iso = sen * k
m_iso = con_vol(sen_iso)
print("\n" + "=" * 96)
print(f" FULL ({con.index[0].date()} -> {con.index[-1].date()})")
print("=" * 96)
print(riga("CON GTAA01", m_con))
print(riga("SENZA (iso-nozionale)", m_sen))
print(riga(f"SENZA (iso-rischio ×{k:.2f})", m_iso))
d_full = m_con["sharpe"] - m_iso["sharpe"]
print(f"\n Δ Sharpe a iso-rischio (con senza): {d_full:+.3f} "
f"[soglia dichiarata {SOGLIA_SHARPE}]")
print(f" Δ maxDD a iso-rischio: {(m_con['maxdd']-m_iso['maxdd'])*100:+.2f} pp")
print("\n" + "=" * 96)
print(f" HOLD-OUT ({HOLDOUT.date()}+)")
print("=" * 96)
hc, hs = con[con.index >= HOLDOUT], sen[sen.index >= HOLDOUT]
mhc, mhs = con_vol(hc), con_vol(hs)
kh = mhc["vol"] / mhs["vol"] if mhs["vol"] > 0 else 1.0
mhi = con_vol(hs * kh)
print(riga("CON GTAA01", mhc))
print(riga(f"SENZA (iso-rischio ×{kh:.2f})", mhi))
d_hold = mhc["sharpe"] - mhi["sharpe"]
print(f"\n Δ Sharpe a iso-rischio: {d_hold:+.3f}")
print("\n" + "=" * 96)
print(" PER ANNO — un contributo positivo si scompone prima di crederci (M9)")
print("=" * 96)
yc, yi = yearly(con), yearly(sen * k)
print(f" {'anno':<6} {'CON ret':>9} {'SENZA ret':>10} {'Δ ret':>8} {'CON DD':>7} {'SENZA DD':>9}")
pos = 0
for a in sorted(set(yc) & set(yi)):
d = yc[a]["ret"] - yi[a]["ret"]
pos += d > 0
print(f" {a:<6} {yc[a]['ret']*100:>+8.1f}% {yi[a]['ret']*100:>+9.1f}% {d*100:>+7.1f}pp "
f"{yc[a]['dd']*100:>6.1f}% {yi[a]['dd']*100:>8.1f}%")
n_anni = len(set(yc) & set(yi))
print(f"\n anni in cui GTAA01 aiuta: {pos}/{n_anni}")
print("\n" + "=" * 96)
print(" CORRELAZIONE — quanto e' davvero 'altro' rispetto al resto")
print("=" * 96)
g = cols[FUORI]
altro = combine_outer(cols4, w4)
Jg = pd.concat({"g": g, "altro": altro}, axis=1, join="inner").dropna()
print(f" GTAA01 vs resto del portafoglio: corr {Jg['g'].corr(Jg['altro']):+.3f} "
f"({len(Jg)} giorni in comune)")
print(f" GTAA01 standalone: " + riga("", con_vol(g)).strip())
# ---------------------------------------------------------------- la banda di FASE sul Δ
print("\n" + "=" * 96)
print(" LA FASCIA DI FASE SUL Δ — il Δ e' una misura, o una proprieta' della notte?")
print("=" * 96)
print(" GTAA01 ribilancia su `i % 5` a partire dalla PRIMA barra del file, e la prima barra")
print(" di SPY scivola ogni notte (finestra IB rotolante a 30 anni: r0828_gtaa_band_phase).")
print(" Quindi il Δ qui sopra va letto accanto alla sua escursione sulle 5 fasi.")
sys.path.insert(0, str(ROOT / "scripts" / "research"))
try:
from r0726_loo_deluck import gtaa01_at # replica ESATTA dello sleeve, per fase
except Exception as e: # noqa: BLE001
print(f" non misurabile qui: {type(e).__name__}: {e}")
gtaa01_at = None
if gtaa01_at is not None:
base = gtaa01_at(0)
j0 = pd.concat({"a": base, "b": cols[FUORI]}, axis=1, join="inner").dropna()
d0 = float((j0["a"] - j0["b"]).abs().max())
print(f"\n [SANITY] fase 0 == sleeve di produzione: max|Δ| = {d0:.2e} "
f"-> {'OK' if d0 < 1e-12 else 'DIVERGE (i numeri sotto non valgono)'}")
deltas = []
for ph in range(5):
c = dict(cols); c[FUORI] = gtaa01_at(ph)
cc = combine_outer(c, w5)
Jp = pd.concat({"con": cc, "senza": sen}, axis=1, join="inner").dropna()
mc = con_vol(Jp["con"])
ms = con_vol(Jp["senza"])
kk = mc["vol"] / ms["vol"] if ms["vol"] > 0 else 1.0
d = mc["sharpe"] - con_vol(Jp["senza"] * kk)["sharpe"]
deltas.append(d)
print(f" fase {ph}: Sharpe CON {mc['sharpe']:.3f} Δ a iso-rischio {d:+.3f}"
+ (" <- la fase che gira stanotte" if ph == 0 else ""))
lo, hi = min(deltas), max(deltas)
print(f"\n Δ su 5 fasi: da {lo:+.3f} a {hi:+.3f} (ampiezza {hi-lo:.3f}, "
f"mediana {float(np.median(deltas)):+.3f})")
sopra = sum(d >= SOGLIA_SHARPE for d in deltas)
print(f" fasi in cui il Δ supera la soglia {SOGLIA_SHARPE}: {sopra}/5")
if 0 < sopra < 5:
print(" ⚠️ IL VERDETTO DIPENDE DALLA FASE: non e' una proprieta' di GTAA01,")
print(" e' una proprieta' della notte in cui lo si legge.")
print("\n" + "=" * 96)
print(" VERDETTO — criterio dichiarato in testa, calcolato adesso")
print("=" * 96)
porta = d_full >= SOGLIA_SHARPE and d_hold > -SOGLIA_SHARPE
print(f" Δ Sharpe FULL a iso-rischio {d_full:+.3f} >= {SOGLIA_SHARPE}? "
f"{'SI' if d_full >= SOGLIA_SHARPE else 'NO'}")
print(f" l'hold-out non lo smentisce (Δ {d_hold:+.3f} > {-SOGLIA_SHARPE})? "
f"{'SI' if d_hold > -SOGLIA_SHARPE else 'NO'}")
if porta:
print("\n -> GTAA01 PORTA: toglierlo costa piu' dello spread fra le baseline.")
return 0
print("\n -> il contributo di GTAA01 NON si distingue dalla scelta della lente.")
print(" Non e' 'GTAA01 e' inutile': e' 'con questi dati non si vede'. E cio' che")
print(" non si vede non giustifica di tenere in piedi un gate che non decide.")
return 1
if __name__ == "__main__":
raise SystemExit(main())
+18 -1
View File
@@ -149,6 +149,23 @@ def costruisci_prompt(pagina: str, storico: str) -> str:
f"Scrivi ora l'analisi, e nient'altro.") f"Scrivi ora l'analisi, e nient'altro.")
def motivo_uscita(returncode: int, stdout: str | None, stderr: str | None) -> str:
"""Il motivo di un'uscita non-zero della CLI, preso da stderr E da stdout.
PERCHE' ENTRAMBI. Il 2026-08-28 alle 00:37Z la CLI `claude` e' uscita 1 scrivendo
«Failed to authenticate: OAuth session expired and could not be refreshed» su STDOUT,
con stderr VUOTO. Leggendo il solo stderr il motivo registrato nel DB e spedito su
Telegram fu `uscita 1: `, cioe' niente: l'allerta diceva il COSA e non il PERCHE' (P4),
e per risalire alla causa e' servito aprire a mano il transcript della sessione headless.
Quando la diagnosi serve il guasto e' gia' rientrato (P3): il motivo si cattura qui o mai.
E se non c'e' NESSUN messaggio lo si dice, invece di lasciare i due punti a vuoto:
"la CLI non ha detto perche'" e "il perche' l'abbiamo perso noi" sono guasti diversi.
"""
det = " | ".join(x for x in ((stderr or "").strip(), (stdout or "").strip()) if x)
return f"uscita {returncode}: {det[:300] if det else 'nessun messaggio su stderr ne stdout'}"
def interroga(prompt: str, modello: str = MODELLO_DEFAULT, def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
timeout: int = TIMEOUT_S) -> tuple[str, str | None]: timeout: int = TIMEOUT_S) -> tuple[str, str | None]:
"""Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che """Ritorna (testo, errore). L'errore si RESTITUISCE, non si ingoia: senza, il giorno che
@@ -163,7 +180,7 @@ def interroga(prompt: str, modello: str = MODELLO_DEFAULT,
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return "", f"timeout dopo {timeout}s" return "", f"timeout dopo {timeout}s"
if p.returncode != 0: if p.returncode != 0:
return "", f"uscita {p.returncode}: {(p.stderr or '').strip()[:200]}" return "", motivo_uscita(p.returncode, p.stdout, p.stderr)
return pulisci(p.stdout), None return pulisci(p.stdout), None
+35
View File
@@ -264,6 +264,37 @@ def salva(con, voce: dict, nota: str | None = None) -> None:
# (c) MUTA sul non misurato — se il numero e' None la regola tace, non arrotonda a zero. # (c) MUTA sul non misurato — se il numero e' None la regola tace, non arrotonda a zero.
# Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano. # Il campo `nota` resta l'unico posto dove puo' finire un giudizio umano.
# =============================================================================================
# DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE
# =============================================================================================
# N9: quando una decisione viene rinviata con un motivo, il motivo va registrato — altrimenti fra
# tre mesi la stessa analisi si ripresenta come se fosse nuova. E una decisione parcheggiata su un
# numero che NESSUNO sorveglia e' parcheggiata per sempre: qui la sorveglianza e' l'equity, che il
# giornale legge comunque ogni giorno.
#
# Una riga si TOGLIE quando la decisione e' presa. Finche' c'e', la voce del giorno la ripete: e'
# voluto — non e' un allarme che chiede un'azione impossibile (P14), e' una domanda che da quel
# giorno in poi si puo' finalmente rispondere.
SOGLIE_CAPITALE: tuple[tuple[float, str], ...] = (
(15_000.0, "**GTAA01: tenere o bloccare.** Decisione rinviata a questa soglia il 2026-08-28 "
"(diario `2026-08-28-gtaa-fase-e-allarmi`). Sotto $15k di book lo sleeve NON e' "
"deployabile — `GTAA_MIN_CAPITAL` $3.000 allocati al peso 20% — quindi finora e' "
"stato manutenzione senza beneficio incassabile. Contributo misurato a iso-rischio "
"+0,095/+0,124 di Sharpe secondo la fase: reale, sempre positivo, e delle stesse "
"dimensioni dello spread fra le baseline."),
(20_000.0, "**«100% Deribit fino a $20k»** (decisione del 26/07): la soglia e' arrivata, e con "
"essa la domanda del secondo conto (N4: il rischio di venue si compra con un CONTO, "
"non con uno sleeve)."),
)
def soglie_superate(equity: float | None) -> list[tuple[float, str]]:
"""PURA. Le decisioni parcheggiate che a questa equity si possono finalmente prendere."""
if equity is None:
return []
return [(s, t) for s, t in SOGLIE_CAPITALE if equity >= s]
def _bucket_convinzione(tp: float) -> str: def _bucket_convinzione(tp: float) -> str:
"""TP01 media tre `np.sign()` e poi taglia a long-flat: la direzione vive in {0, 1/3, 1}. """TP01 media tre `np.sign()` e poi taglia a long-flat: la direzione vive in {0, 1/3, 1}.
@@ -469,6 +500,10 @@ def lettura(con, voce: dict) -> list[dict]:
dire("evidenza", f"Campione a oggi: **{gg} giorni, {n_rt} round-trip**. A questa taglia " dire("evidenza", f"Campione a oggi: **{gg} giorni, {n_rt} round-trip**. A questa taglia "
"il P&L non distingue l'edge dalla fortuna — il criterio di morte del " "il P&L non distingue l'edge dalla fortuna — il criterio di morte del "
"libro (`edge_watch`) lavora su una finestra di 36 mesi.", "nota") "libro (`edge_watch`) lavora su una finestra di 36 mesi.", "nota")
# --- decisioni che questa equity sblocca -------------------------------------------------
for soglia, testo in soglie_superate(p.get("equity_fine")):
dire("soglia_capitale", f"📌 **Soglia ${soglia:,.0f} superata** — {testo}", "attenzione")
return out return out
def _n(v, fmt="{:+.2f}", vuoto="n/d"): def _n(v, fmt="{:+.2f}", vuoto="n/d"):
+7 -3
View File
@@ -76,8 +76,12 @@ def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool:
return False return False
def notify(title: str, data: dict | None = None) -> bool: def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool:
"""Invia un alert formattato. Ritorna True se inviato (config presente + rete ok).""" """Invia un alert formattato. Ritorna True se inviato (config presente + rete ok).
`tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti; chi manda
un allarme che non puo' permettersi di perdere lo alza (venue_watch: 3).
"""
try: try:
from src.version import APP_VERSION from src.version import APP_VERSION
ver = f" <code>v{APP_VERSION}</code>" ver = f" <code>v{APP_VERSION}</code>"
@@ -86,7 +90,7 @@ def notify(title: str, data: dict | None = None) -> bool:
lines = [f"<b>{title}</b>{ver}"] lines = [f"<b>{title}</b>{ver}"]
for k, v in (data or {}).items(): for k, v in (data or {}).items():
lines.append(f" {k}: {v}") lines.append(f" {k}: {v}")
return send("\n".join(lines)) return send("\n".join(lines), tentativi=tentativi)
def is_configured() -> bool: def is_configured() -> bool:
+47 -3
View File
@@ -299,10 +299,33 @@ def observe() -> dict:
return out return out
def run_once(state_path: Path = STATE_PATH) -> dict: def run_once(state_path: Path = STATE_PATH, sender=None) -> dict:
"""Un giro completo: osserva, avanza lo stato, ritorna il report (NON invia nulla). """Un giro completo: osserva, avanza lo stato, INVIA (se gli si da' un `sender`), salva.
L'invio Telegram lo fa il chiamante, cosi' questa resta testabile senza rete d'uscita."""
PERCHE' L'INVIO E' ENTRATO QUI. Fino al 2026-08-28 questa funzione salvava lo stato — coi
marcatori `alerted` gia' a True — e l'invio lo faceva il chiamante DOPO. Con un tasso di
invii falliti misurato del 6,9% (2 su 29), un 🚨 perso restava perso per l'EPISODIO INTERO:
l'ora dopo lo stato diceva «gia' detto» e usciva WATCH/MUTO. Gli episodi storici durano
200-2.324 ore, quindi il buco non e' teorico. Il marcatore «gia' detto» adesso si scrive
solo se qualcuno l'ha davvero sentito.
`sender(report) -> (ok: bool, motivo: str)`. Iniettato, non importato: e' cio' che tiene
questa funzione testabile senza rete d'uscita, che era la ragione del disegno precedente.
Senza `sender` il comportamento resta quello di prima e il report lo DICE (`invio`), invece
di lasciar credere che qualcosa sia stato spedito.
COSA SI DISFA SU UN INVIO FALLITO — solo i marcatori «gia' detto», non le misure:
· asset in ALERT -> `alerted=False`: l'ora dopo ri-allerta;
· lock MAINT / ALERT -> `alerted_soft`/`alerted_hard`=False ma le ORE restano a correre,
cosi' una manutenzione che sfora la grazia sale ad ALERT anche se il trasporto e' giu'
(disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare);
· lock RIENTRATO -> si ripristina l'intero LockState precedente, perche' il rientro
si annuncia una volta sola e senza le ore a zero non ci sarebbe piu' un rientro da dire.
Restano fuori BLIND (si ripete ogni ora da solo) e il livello WATCH (non consuma nulla).
"""
st = load_state(state_path) st = load_state(state_path)
prima_lock = LockState(**asdict(st.lock)) # per disfare il rientro, che si dice una volta
asset_alertati: list[str] = []
obs = observe() obs = observe()
report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"], report = {"ts": obs["ts"], "platform_locked": obs["platform_locked"],
"locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False), "locked_raw": obs.get("locked_raw"), "maintenance": obs.get("maintenance", False),
@@ -313,6 +336,7 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
st.assets[a] = new st.assets[a] = new
report["levels"][a] = level report["levels"][a] = level
if level == "ALERT": if level == "ALERT":
asset_alertati.append(a)
report["alerts"].append( report["alerts"].append(
f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) " f"{a}: scarto {o['bps']:+.0f} bps vs consenso ({o['n_refs']} referenze) "
f"persistente da {new.streak_hours}h a segno costante") f"persistente da {new.streak_hours}h a segno costante")
@@ -345,5 +369,25 @@ def run_once(state_path: Path = STATE_PATH) -> dict:
report["severity"] = report["severity"] or "warn" report["severity"] = report["severity"] or "warn"
st.last_ts = obs["ts"] st.last_ts = obs["ts"]
# --- l'invio, e il commit dei marcatori SOLO se e' andato ---------------------------------
if not report["alerts"]:
report["invio"] = "niente da dire"
elif sender is None:
report["invio"] = ("non tentato (nessun sender): i marcatori 'gia' detto' restano "
"impostati, l'invio e' responsabilita' del chiamante")
else:
ok, motivo = sender(report)
report["invio"] = "inviato" if ok else f"FALLITO — {motivo}"
if not ok:
for a in asset_alertati:
st.assets[a].alerted = False
if lock_lvl == "MAINT":
st.lock.alerted_soft = False
elif lock_lvl == "ALERT":
st.lock.alerted_soft = st.lock.alerted_hard = False
elif lock_lvl == "RIENTRATO":
st.lock = prima_lock
save_state(st, state_path) save_state(st, state_path)
return report return report
+53
View File
@@ -39,6 +39,59 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
# =============================================================================================
# IL CANALE DI ALLARME VERO
# =============================================================================================
# ⚠️ 2026-08-28, difetto trovato addosso di nuovo. Lanciando `uv run pytest` la suite ha mandato
# messaggi Telegram VERI sul canale di allarme dell'operatore: cinque per giro, col testo delle
# fixture — «Analisi di ieri.», «rete KO», «Testo dell'agente.» — sotto l'intestazione di una voce
# di giornale del 2026-08-21. Colpevoli: i test di `analista.CLI.scrivi()`, che non passavano
# `telegram=False` e quindi percorrevano `invia()` fino a `notifier.send()`. `_cfg()` legge il
# token da `.env.mainnet`/`.env`, quindi il fatto che il token non fosse nell'ambiente non
# proteggeva niente.
#
# NON E' COSMETICO. E' P9 al contrario: allarmi finti spesi su un canale che deve restare
# credibile il giorno che l'allarme e' vero. Chi riceve cinque messaggi identici a ogni giro di
# test impara a non aprirli — ed e' l'unico canale da cui passano il disaster-SL, l'uscita di
# fondi e il venue giu'.
#
# LA RIPARAZIONE E' STRUTTURALE (stessa lezione del watermark): si blocca la RETE, non si chiede
# a ogni autore di ricordarsi un parametro. Un test che vuole davvero esercitare `send()` continua
# a funzionare: patcha `urlopen` da se' nel corpo, e il suo monkeypatch vince su questo.
_INVII_TENTATI: list[str] = []
@pytest.fixture(autouse=True)
def _blocca_il_canale_di_allarme_vero(monkeypatch):
"""Nessun test puo' raggiungere Telegram. Il tentativo si REGISTRA (P3: l'errore si scrive
nel punto in cui lo si ingoia — `send()` cattura tutto, quindi senza questo elenco un invio
tentato sparirebbe senza lasciare traccia)."""
from src.live import notifier
def _vietato(url, *a, **k):
# ⚠️ NON si registra l'URL: contiene il BOT TOKEN in chiaro, e questa lista finisce
# dentro il messaggio di un assert — cioe' nell'output della suite, nei log di CI e
# nello scrollback di chiunque lanci i test. Il difetto e' stato fatto e corretto il
# 2026-08-28: la prima versione stampava `https://api.telegram.org/bot<TOKEN>/sendM…`.
# Qui serve sapere CHE si e' tentato, non VERSO DOVE.
_INVII_TENTATI.append("telegram sendMessage")
raise RuntimeError("invio Telegram VERO bloccato dalla suite (tests/conftest.py)")
monkeypatch.setattr(notifier.urllib.request, "urlopen", _vietato)
@pytest.fixture(autouse=True, scope="session")
def _guardia_nessun_invio_vero():
"""Bloccare non basta: se un test PROVA a mandare, va trovato e reso esplicito. Un blocco
silenzioso lascerebbe il tentativo in giro pronto a tornare vero il giorno che qualcuno
cambia il boundary."""
yield
assert not _INVII_TENTATI, (
f"{len(_INVII_TENTATI)} test hanno PROVATO a mandare un messaggio Telegram vero "
"(bloccati, ma il tentativo non deve esistere): passare `telegram=False` o iniettare "
"un sender finto. Vedi la nota in testa a questo file.")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isola_file_operativi_vivi(tmp_path, monkeypatch): def _isola_file_operativi_vivi(tmp_path, monkeypatch):
"""Ogni test scrive il watermark in una cartella temporanea, mai in `data/live/`. """Ogni test scrive il watermark in una cartella temporanea, mai in `data/live/`.
+39 -6
View File
@@ -139,7 +139,40 @@ def test_uscita_non_zero_ritorna_errore(monkeypatch):
class R: class R:
returncode, stdout, stderr = 2, "", "boom" returncode, stdout, stderr = 2, "", "boom"
monkeypatch.setattr(subprocess, "run", lambda *a, **k: R()) monkeypatch.setattr(subprocess, "run", lambda *a, **k: R())
assert "uscita 2" in A.interroga("x")[1] err = A.interroga("x")[1]
assert "uscita 2" in err and "boom" in err
# Il guasto vero del 2026-08-28 00:37Z: la CLI e' uscita 1 scrivendo il motivo su STDOUT con
# stderr VUOTO. Il motivo registrato fu `uscita 1: ` — l'allerta diceva il COSA e non il PERCHE'
# (P4), e la causa e' stata ricostruita solo aprendo a mano il transcript della sessione headless.
# Questo test blinda che quel messaggio arrivi fino al DB e a Telegram.
def test_motivo_preso_da_stdout_quando_stderr_e_vuoto(monkeypatch):
class R:
returncode = 1
stdout = "Failed to authenticate: OAuth session expired and could not be refreshed"
stderr = ""
monkeypatch.setattr(subprocess, "run", lambda *a, **k: R())
testo, err = A.interroga("x")
assert testo == ""
assert "OAuth session expired" in err and "uscita 1" in err
def test_motivo_tiene_entrambi_i_canali():
err = A.motivo_uscita(1, "detto su stdout", "detto su stderr")
assert "detto su stderr" in err and "detto su stdout" in err
def test_silenzio_totale_si_dichiara_invece_di_lasciare_i_due_punti_a_vuoto():
"""'la CLI non ha detto perche'' e 'il perche' l'abbiamo perso noi' sono guasti diversi."""
err = A.motivo_uscita(1, "", None)
assert not err.rstrip().endswith(":")
assert "nessun messaggio" in err
def test_motivo_lungo_troncato_ma_non_vuoto():
err = A.motivo_uscita(1, "x" * 5000, "")
assert len(err) < 400 and "x" in err
# ------------------------------------------------- il DB: cosa scrive e cosa non tocca # ------------------------------------------------- il DB: cosa scrive e cosa non tocca
@@ -158,7 +191,7 @@ def test_l_agente_non_tocca_la_nota(db, monkeypatch, tmp_path):
_prepara(db) _prepara(db)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi valida senza numeri nuovi.", None)) monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi valida senza numeri nuovi.", None))
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path) monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False) CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
r = db.execute("SELECT nota, analisi, analisi_stato FROM journal WHERE giorno='2026-08-21'").fetchone() r = db.execute("SELECT nota, analisi, analisi_stato FROM journal WHERE giorno='2026-08-21'").fetchone()
assert r["nota"] == "nota dell'operatore" assert r["nota"] == "nota dell'operatore"
assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok" assert r["analisi"].startswith("Analisi valida") and r["analisi_stato"] == "ok"
@@ -170,9 +203,9 @@ def test_se_il_modello_non_risponde_l_analisi_di_IERI_non_resta_in_pagina(db, mo
_prepara(db) _prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path) monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi di ieri.", None)) monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi di ieri.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False) CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("", "rete KO")) monkeypatch.setattr(A, "interroga", lambda *a, **k: ("", "rete KO"))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False) CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
r = db.execute("SELECT analisi, analisi_stato, analisi_motivi FROM journal " r = db.execute("SELECT analisi, analisi_stato, analisi_motivi FROM journal "
"WHERE giorno='2026-08-21'").fetchone() "WHERE giorno='2026-08-21'").fetchone()
assert r["analisi"] == "" # niente in pagina assert r["analisi"] == "" # niente in pagina
@@ -184,7 +217,7 @@ def test_un_ricalcolo_del_giornale_non_cancella_l_analisi(db, monkeypatch, tmp_p
voce = _prepara(db) voce = _prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path) monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi da conservare.", None)) monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Analisi da conservare.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False) CLI.scrivi(db, date(2026, 8, 21), modello="finto", verbose=False, telegram=False)
J.salva(db, voce) # il cron rigenera le sezioni misurate J.salva(db, voce) # il cron rigenera le sezioni misurate
r = db.execute("SELECT analisi, nota FROM journal WHERE giorno='2026-08-21'").fetchone() r = db.execute("SELECT analisi, nota FROM journal WHERE giorno='2026-08-21'").fetchone()
assert r["analisi"] == "Analisi da conservare." and r["nota"] == "nota dell'operatore" assert r["analisi"] == "Analisi da conservare." and r["nota"] == "nota dell'operatore"
@@ -195,7 +228,7 @@ def test_il_markdown_dichiara_modello_ora_e_esito_del_controllo(db, monkeypatch,
voce = _prepara(db) voce = _prepara(db)
monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path) monkeypatch.setattr(J, "JOURNAL_DIR", tmp_path)
monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Testo dell'agente.", None)) monkeypatch.setattr(A, "interroga", lambda *a, **k: ("Testo dell'agente.", None))
CLI.scrivi(db, date(2026, 8, 21), modello="modello-finto", verbose=False) CLI.scrivi(db, date(2026, 8, 21), modello="modello-finto", verbose=False, telegram=False)
md = J.rendi_markdown(db, voce) md = J.rendi_markdown(db, voce)
assert "## Analisi (agente)" in md and "modello-finto" in md assert "## Analisi (agente)" in md and "modello-finto" in md
assert "puo' sbagliare" in md and "Controllo sui numeri: **ok**" in md assert "puo' sbagliare" in md and "Controllo sui numeri: **ok**" in md
+46 -19
View File
@@ -101,19 +101,50 @@ def griglia_piena():
return G return G
def test_la_banda_proposta_e_quella_scelta_al_buio(griglia_piena): # ⚠️ 2026-08-28 — SECONDO CRITERIO RITIRATO, e per la stessa ragione del primo.
"""Il gate (A), nella forma decidibile (2026-08-07). # `test_la_banda_proposta_e_quella_scelta_al_buio` asseriva che la banda scelta sui soli dati
# pre-2015 fosse la proposta. Ha smesso di passare senza che nessuno toccasse il codice: al buio
# esce il 60%, con margine 0.0074 — sotto il pavimento di 0.01 che il test stesso si era dato.
# Misurato in `r0828_gtaa_band_phase.py`: NON e' un fatto sulla banda, e' un fatto sul criterio.
# · IB serve una finestra ROTOLANTE di 30 anni e SPY e' al muro: la sua prima barra avanza di
# un giorno di borsa ogni notte. Il pre-2015 — che il progetto tratta come congelato — perde
# il giorno piu' vecchio ogni notte.
# · `gtaa._gated_returns` ribilancia su `i % every == 0`, cioe' sulla POSIZIONE nell'array:
# se la prima barra scivola, si ri-fasa ogni decisione di trent'anni. La fase canonica cambia
# da sola.
# · Esito: su 10 notti consecutive la scelta al buio e' 25% / 40% / 60%, e il margine sta sotto
# il pavimento in 4 notti su 10. A dato FISSO, muovendo la sola fase, il verdetto cambia lo
# stesso (25% su 3 fasi, 60% su 2).
# Come il 07/08 si congela il MOTIVO e non l'esito. La riparazione (ancorare la fase al calendario)
# e la decisione su GTAA01 sono rinviate alla soglia di $15k di book — sotto quella soglia lo
# sleeve non e' deployabile e la decisione non ha conseguenze (`journal.SOGLIE_CAPITALE`).
La proposta del 27/07 e' una BANDA: la cadenza settimanale e' gia' `REBAL_EVERY=5` in
produzione e non era in discussione. Quindi la domanda sulla provenienza della scelta e' def test_il_gate_A_non_ha_RISOLUZIONE_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena):
«a cadenza di produzione, quale banda si sceglie guardando SOLO il pre-2015?». """Congela il MOTIVO del secondo ritiro: il criterio decide su un margine piu' piccolo del
""" rumore che lo scuote. Non asserisce QUALE cella vinca — quella si muove da sola ogni notte,
ed e' esattamente il punto."""
G = griglia_piena G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]] riga = G[G["every"] == BG.PROPOSTA[0]]
blind = riga.sort_values("sh_in", ascending=False).iloc[0] ordinata = riga.sort_values("sh_in", ascending=False)
assert blind["frac"] == BG.PROPOSTA[1], ( margine = float(ordinata["sh_in"].iloc[0] - ordinata["sh_in"].iloc[1])
f"al buio si sceglierebbe la banda {blind['frac']:.0%}, non {BG.PROPOSTA[1]:.0%}: " spread = float(riga["sh_in"].max() - riga["sh_in"].min())
"la validazione del 27/07 andrebbe rifatta su quella cella") # il margine su cui il criterio decideva e' una FRAZIONE dello spread della griglia:
# a quella scala la scelta non e' una misura, e' l'ordine in cui il rumore si e' posato.
assert margine < 0.25 * spread, (
f"il margine ({margine:.4f}) e' tornato grande rispetto allo spread di griglia "
f"({spread:.4f}): se il rumore si fosse ridotto, il gate (A) andrebbe RIABILITATO "
"invece di restare ritirato — ed e' un fatto da guardare, non da ignorare")
def test_la_fase_di_ribilanciamento_e_ancora_ancorata_alla_POSIZIONE(griglia_piena):
"""La guardia sulla CAUSA. Se un giorno `_gated_returns` ancorasse la fase al calendario,
questo test si rompe e va riletto tutto: il gate (A) potrebbe tornare decidibile."""
import inspect
src = inspect.getsource(BG.gtaa_returns.__globals__["_gated_returns"])
assert "i % every" in src, (
"la fase non e' piu' ancorata alla posizione nell'array: la ragione del ritiro del "
"gate (A) e' venuta meno, rileggere r0828_gtaa_band_phase.py")
def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena): def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
@@ -128,15 +159,11 @@ def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
"potenza e non va citato come validazione") "potenza e non va citato come validazione")
def test_il_margine_del_blind_non_e_un_arrotondamento(griglia_piena): # `test_il_margine_del_blind_non_e_un_arrotondamento` (2026-08-07) chiedeva margine >= 0.01 e
"""La ragione per cui questo criterio ha sostituito il confronto fra ranghi: decide su # diceva, testualmente: «Se il margine scendesse sotto un centesimo, anche questo criterio
~0.015 di Sharpe invece che su ~0.001. Se il margine scendesse sotto un centesimo, anche # smetterebbe di essere una misura e andrebbe ripensato a sua volta». Il 28/08 e' sceso a 0.0074.
questo criterio smetterebbe di essere una misura e andrebbe ripensato a sua volta.""" # Il test aveva ragione e si e' auto-ritirato: la sua condizione e' ora dentro il test sul MOTIVO
G = griglia_piena # qui sopra, dove misura il margine CONTRO lo spread di griglia invece che contro un assoluto.
riga = G[G["every"] == BG.PROPOSTA[0]]
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
secondo = riga[riga["frac"] != blind["frac"]]["sh_in"].max()
assert blind["sh_in"] - secondo >= 0.01
def test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena): def test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena):
+34
View File
@@ -467,3 +467,37 @@ def test_la_concentrazione_non_conta_i_versamenti_come_pnl(db, monkeypatch):
righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25")) righe = J.lettura(db, _voce(pnl=p, giorno="2026-08-25"))
conc = [r for r in righe if r["id"] == "concentrazione"] conc = [r for r in righe if r["id"] == "concentrazione"]
assert conc == [] # il cumulato di TRADING e' 0: la regola deve tacere assert conc == [] # il cumulato di TRADING e' 0: la regola deve tacere
# =============================================================================================
# DECISIONI PARCHEGGIATE SU UNA SOGLIA DI CAPITALE
# =============================================================================================
# Il 2026-08-28 la decisione «GTAA01: tenere o bloccare» e' stata rinviata a $15k di book, che e'
# la soglia sotto la quale lo sleeve non e' deployabile. Una decisione parcheggiata su un numero
# che nessuno sorveglia e' parcheggiata per sempre (N9): questi test blindano la sorveglianza.
def test_sotto_soglia_non_dice_niente():
assert J.soglie_superate(2_068.0) == []
def test_la_soglia_gtaa_e_15k_e_scatta_quando_l_equity_arriva():
soglie = [s for s, _ in J.SOGLIE_CAPITALE]
assert 15_000.0 in soglie, "la soglia di GTAA01 non e' piu' dichiarata"
assert J.soglie_superate(14_999.99) == []
assert [s for s, _ in J.soglie_superate(15_000.0)] == [15_000.0]
def test_le_soglie_si_accumulano_e_restano_ordinate():
"""A $20k si devono presentare ENTRAMBE: la seconda non cancella la prima."""
assert [s for s, _ in J.soglie_superate(20_000.0)] == [15_000.0, 20_000.0]
def test_equity_non_leggibile_non_e_una_soglia_superata():
"""P5: «non vedo» non e' «va tutto bene» — e non e' nemmeno «la soglia e' arrivata»."""
assert J.soglie_superate(None) == []
def test_ogni_soglia_dice_COSA_decidere_non_solo_che_e_arrivata():
"""P4: un'allerta risponde a due domande. Una riga che dice solo «sei a $15k» non serve."""
for _, testo in J.SOGLIE_CAPITALE:
assert len(testo) > 80 and "**" in testo, "la soglia non dice quale decisione sblocca"
+64
View File
@@ -141,3 +141,67 @@ def test_leggi_jsonl_roundtrip(tmp_path):
+ json.dumps({"ts": 2, "eq_usde": 500.05}) + "\n") + json.dumps({"ts": 2, "eq_usde": 500.05}) + "\n")
rows = W.leggi(p) rows = W.leggi(p)
assert len(rows) == 2 and rows[-1]["eq_usde"] == 500.05 assert len(rows) == 2 and rows[-1]["eq_usde"] == 500.05
# =============================================================================================
# IL RENDIMENTO OSSERVATO, E LA DECISIONE DI QUOTA RINVIATA
# =============================================================================================
# Il 2026-08-29 il gate USDE-01 si e' chiuso IDONEO (primo reward il 28/08, +0.052055 USDE) e la
# decisione di QUOTA e' stata rinviata dall'operatore a lunedi' 31/08: su UNA finestra il tasso
# implicito e' 3,80% annuo, su DUE e' 1,90% — cioe' tutta la differenza che conta.
def _lettura(ts_ms, eq, reward=None, con_trade=False):
return dict(ts=ts_ms, eq_usde=eq, reward_stimato=reward,
reward_rilevato=bool(reward and reward > 1e-3), con_trade=con_trade)
G = 86_400_000
def test_una_finestra_che_NON_paga_abbassa_la_stima_invece_di_sparire():
"""P5: il silenzio in una serie di ritorni si legge come zero — e qui e' proprio uno zero.
Il denominatore e' il TEMPO, non il numero di finestre pagate."""
recs = [_lettura(0, 500.0), _lettura(G, 500.0, 0.0), _lettura(2 * G, 500.052055, 0.052055)]
r = W.rendimento(recs)
assert r["finestre"] == 2 and r["pagate"] == 1
# 0.052055 su 500 in 2 giorni -> ~1.90%, non il 3.80% della sola finestra che ha pagato
assert 0.018 < r["apr"] < 0.021, r
def test_la_finestra_sola_darebbe_il_doppio():
"""Il controllo positivo: se si guardasse solo il pagamento, il numero raddoppierebbe.
E' la ragione per cui l'APR si stampa sempre con il suo n."""
r = W.rendimento([_lettura(G, 500.0), _lettura(2 * G, 500.052055, 0.052055)])
assert 0.037 < r["apr"] < 0.040, r
def test_le_letture_con_trade_nel_mezzo_si_escludono():
"""P12: quel delta non e' solo reward, e ripararlo in silenzio sarebbe un'invenzione."""
recs = [_lettura(0, 500.0), _lettura(G, 600.0, 100.0, con_trade=True), _lettura(2 * G, 600.05, 0.05)]
r = W.rendimento(recs)
assert r["finestre"] == 1 and r["tot"] == 0.05
def test_troppo_poco_tempo_non_si_annualizza():
"""Un'ora di osservazione moltiplicata per 365 e' un numero, non una misura."""
r = W.rendimento([_lettura(0, 500.0), _lettura(3_600_000, 500.05, 0.05)])
assert r["apr"] is None and "non si annualizza" in r["nota"]
def test_nessuna_finestra_misurabile_non_e_zero():
r = W.rendimento([_lettura(0, 500.0)])
assert r["apr"] is None and r["finestre"] == 0
def test_la_quota_si_decide_da_lunedi_e_solo_se_IDONEO():
from datetime import datetime, timezone
def q(giorno, v):
return W.quota_da_decidere(datetime.fromisoformat(giorno + "T12:35:00+00:00"), v)
assert W.DECISIONE_QUOTA_DAL == "2026-08-31"
assert q("2026-08-30", "IDONEO") is False, "il rinvio dell'operatore non e' rispettato"
assert q("2026-08-31", "IDONEO") is True, "lunedi' la domanda non viene posta"
assert q("2026-09-30", "IDONEO") is True, "smette di chiedere: la decisione si dimentica"
assert q("2026-08-31", "IN_ATTESA") is False, "chiede la quota su un conto non idoneo"
assert q("2026-08-31", "NON_IDONEO") is False
+83
View File
@@ -29,6 +29,7 @@ sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402 from src.live.venue_watch import (BLIND_ALERT_HOURS, PERSIST_HOURS, # noqa: E402
THRESHOLD_BPS, AssetState, WatchState, THRESHOLD_BPS, AssetState, WatchState,
dislocation_bps, load_state, save_state, step) dislocation_bps, load_state, save_state, step)
import src.live.venue_watch as VW # noqa: E402 — i test sul trasporto patchano `observe`
# =========================================================================== # ===========================================================================
@@ -416,3 +417,85 @@ def test_una_sola_ora_BLIND_spezza_lo_streak_e_impedisce_l_allarme():
st2, lvl2 = step(st2, bps) st2, lvl2 = step(st2, bps)
assert lvl2 != "ALERT" assert lvl2 != "ALERT"
assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak" assert st2.streak_hours < PERSIST_HOURS, "l'ora BLIND deve azzerare lo streak"
# =============================================================================================
# IL TRASPORTO — un 🚨 perso non deve costare l'EPISODIO INTERO
# =============================================================================================
# Fino al 2026-08-28 `run_once` salvava lo stato coi marcatori `alerted` gia' a True e l'invio
# lo faceva il chiamante DOPO. Col 6,9% di invii falliti misurato (2 su 29), un allarme perso
# restava perso finche' durava l'episodio — e gli episodi storici durano 200-2.324 ore.
# Questi test guardano l'unica cosa che conta: dopo un invio FALLITO, l'ora dopo ri-allerta?
def _obs_finto(monkeypatch, bps, locked=False, maint=False):
def fake():
return {"ts": 1, "platform_locked": locked, "locked_raw": str(locked).lower(),
"maintenance": maint,
"assets": {a: dict(deribit=1.0, n_refs=3, ref_spread_bps=1.0, bps=bps)
for a in VW.ASSETS},
"deribit_errors": []}
monkeypatch.setattr(VW, "observe", fake)
def _porta_ad_alert(monkeypatch, tmp_path, sender):
"""Fa salire lo streak fino all'ALERT e ritorna il report del giro che allerta."""
sp = tmp_path / "vw.json"
_obs_finto(monkeypatch, bps=VW.THRESHOLD_BPS * 3)
rep = None
for _ in range(VW.PERSIST_HOURS):
rep = VW.run_once(state_path=sp, sender=sender)
return sp, rep
def test_invio_fallito_NON_consuma_l_allarme(monkeypatch, tmp_path):
ko = lambda rep: (False, "ConnectionError: rete giu'")
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ko)
assert any(l == "ALERT" for l in rep["levels"].values())
assert rep["invio"].startswith("FALLITO")
# il marcatore 'gia' detto' NON deve essere sul disco
st = VW.load_state(sp)
assert all(not s.alerted for s in st.assets.values()), "un invio fallito ha consumato l'allarme"
# l'ora dopo, stessa dislocazione: deve RI-allertare
rep2 = VW.run_once(state_path=sp, sender=ko)
assert any(l == "ALERT" for l in rep2["levels"].values()), "l'allarme non e' stato ritentato"
def test_invio_riuscito_consuma_l_allarme_una_volta_sola(monkeypatch, tmp_path):
"""Il controllo positivo: senza questo, 'ri-allerta sempre' passerebbe il test di sopra."""
ok = lambda rep: (True, "ok")
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, ok)
assert any(l == "ALERT" for l in rep["levels"].values())
assert rep["invio"] == "inviato"
rep2 = VW.run_once(state_path=sp, sender=ok)
assert not any(l == "ALERT" for l in rep2["levels"].values()), "allarme ripetuto ogni ora"
assert all(l == "WATCH" for l in rep2["levels"].values())
def test_lock_maint_fallito_ritenta_ma_le_ORE_continuano_a_correre(monkeypatch, tmp_path):
"""Disfare anche le ore congelerebbe l'escalation proprio mentre non si riesce a parlare:
una manutenzione che sfora la grazia deve salire ad ALERT anche col trasporto giu'."""
sp = tmp_path / "vw.json"
ko = lambda rep: (False, "rete giu'")
_obs_finto(monkeypatch, bps=0.0, locked=True, maint=True)
livelli = [VW.run_once(state_path=sp, sender=ko)["lock_level"]
for _ in range(VW.MAINT_GRACE_HOURS + 1)]
assert livelli[0] == "MAINT"
assert livelli[1] == "MAINT", "il MAINT perso non e' stato ritentato"
assert livelli[-1] == "ALERT", "le ore non sono avanzate: l'escalation si e' congelata"
def test_rientro_perso_si_riannuncia(monkeypatch, tmp_path):
"""Il rientro si dice UNA volta e azzera le ore: senza ripristinare lo stato precedente
non ci sarebbe piu' un rientro da annunciare."""
sp = tmp_path / "vw.json"
_obs_finto(monkeypatch, bps=0.0, locked=True, maint=False)
VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))
_obs_finto(monkeypatch, bps=0.0, locked=False)
assert VW.run_once(state_path=sp, sender=lambda r: (False, "giu'"))["lock_level"] == "RIENTRATO"
assert VW.run_once(state_path=sp, sender=lambda r: (True, "ok"))["lock_level"] == "RIENTRATO"
def test_senza_sender_il_report_lo_DICE(monkeypatch, tmp_path):
"""'non tentato' non e' 'inviato': il silenzio non deve leggersi come un invio riuscito."""
sp, rep = _porta_ad_alert(monkeypatch, tmp_path, None)
assert "non tentato" in rep["invio"]