Compare commits

3 Commits

Author SHA1 Message Date
Adriano Dal Pastro 3bc620e914 docs: memoria — gate GTAA corretto, storia troncata di TLT, tabelle del piano al netto
Tre aggiornamenti alla memoria operativa:

1. Il bullet "BANDA GTAA01 AL 25%" registrava «proposta 4/30 in-sample, 5/30 hold-out →
   il rango NON migliora» come evidenza. Non lo era: quel criterio lo passa il 47% della
   griglia per costruzione. Sostituito col criterio decidibile (la banda scelta al buio
   in-sample E' la proposta, quella scelta sull'hold-out no), che e' piu' forte del
   precedente. Registrata la storia troncata di TLT e la guardia cablata.

2. Il bullet "IL FISCO DURANTE L'ACCUMULO" dichiarava che tutte le tabelle a 15-20 anni
   erano al lordo. Ora ci sono le versioni nette (muro, traiettorie, versamenti, rendita)
   con il controllo di replica.

3. Le due tabelle lorde piu' citate (traiettoria da $600 e "quanto versare per un
   orizzonte dato") portano un rimando esplicito alla versione netta: restano perche'
   sono la replica di controllo del fattore d'ancora, non perche' siano il piano.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:31:02 +00:00
Adriano Dal Pastro 02e0cf775f research(capitale): il piano rifatto AL NETTO — il muro si sposta poco, i versamenti molto
Il 07/08 era stato misurato che l'accumulo composto al lordo sovrastima il capitale del
30% a 10 anni, ma i numeri del piano non erano stati rifatti. Qui lo sono.

Il muro usava una convenzione ASIMMETRICA: prelievo lordizzato (€50/g netti → $29.690
lordi) ma capitale che compone senza mai pagare imposte. Coerente (imposte annue dentro
il portafoglio, prelievo gia' netto): perpetua 10.91% → 7.70%, muro $272.061 → $258.338
(-5.0%). I due errori vanno in versi opposti e si compensano quasi — per caso, non per
costruzione.

E' sulle traiettorie che il fisco morde, e si legge nella PROBABILITA':
€250/mese P(entro 20a) 92% → 52%; €500/mese 100% → 99%. Il versamento necessario a P=90%
passa da €237 a €371/mese a 20 anni (+57%), da €509 a €672 a 15 (+32%), da €1.178 a
€1.323 a 10 (+12%): l'errore era composto, quindi cresce con l'orizzonte. Rendita a 20
anni con €250/mese: 91.30 → 46.61 €/g, P(€50/g) 90% → 42%.

Controllo di replica superato prima di guardare i numeri nuovi: a fisco spento la macchina
riproduce $272.061 al dollaro (implementazione separata) e la colonna LORDA riproduce 4
righe su 4 della tabella pubblicata. Trovato per strada: perp_and_wall gira a 2000 path e
a quella taglia da' $269.648 — la terza cifra del muro e' rumore Monte Carlo.

Errore mio catturato prima di pubblicare: la mediana degli anni calcolata sull'INTERO
vettore coi non-arrivi a -1 faceva risultare €250/mese PIU' VELOCE col fisco (15.7 →
15.3 anni) mentre P crollava. Un non-arrivo va codificato +inf, mai -1.

Book/pesi/cron/config INVARIATI: non tocca la produzione.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:30:49 +00:00
Adriano Dal Pastro 963776e5d2 research(gtaa): il gate (A) non misurava cio' che dichiarava — e TLT ha 13.5 anni in meno
Il test del gate sulla banda GTAA01 aveva smesso di passare senza che il codice fosse
cambiato (data/raw/ e' gitignored, IB rivede ADJUSTED_LAST all'indietro ogni notte).
Invece di allentare la soglia, misurata la risoluzione del criterio.

`rank_in <= rank_oos` lo passano 14/30 celle (47%) PER COSTRUZIONE: la somma dei ranghi
e' la stessa nelle due finestre. E sulla proposta si decideva su 0.00116 di Sharpe contro
uno spread di griglia di 0.3124. Il 27/07 quel criterio passava, e passava per caso.

Criterio sostituito con quello decidibile: la proposta e' una BANDA (la cadenza settimanale
e' gia' produzione), quindi a cadenza fissa la banda scelta sui soli dati pre-2015 e' 25%
= la proposta, margine +0.0151 (13x il vecchio); chi avesse scelto sull'hold-out avrebbe
preso 40%. Controllo positivo incluso. Regge sull'universo coerente a 5 gambe.

TROVATO PER STRADA: TLT parte dal 2016-02-03 invece che dalla quotazione (2002-07-22) →
GTAA01 gira su CINQUE gambe prima del 2016, e quella assente e' la gamba obbligazionaria.
Non e' di oggi (cosi' dal primo giro nel cron log del 24/06, prima della validazione) e non
e' un fetch da rifare: una richiesta retro esplicita a IB ritorna 0 barre.

Nessuna certificazione l'aveva visto perche' tutte guardano DENTRO la serie: una serie
troncata e' integra, senza gap, senza spike, senza duplicati. Cablate due guardie in
fetch_ib_equities.certify — TRONCATO (storia persa vs disco; il file NON viene sovrascritto
ne' fuso, ADJUSTED_LAST e' ri-aggiustato all'indietro e il giunto creerebbe un salto) e
STORIA-CORTA (parte dopo la quotazione, con distinzione dal tetto della richiesta 30Y).
Controlli negativi obbligatori: giro normale, serie al cap, ETF giovane, simbolo fuori
tabella.

Produzione INVARIATA: REBAL_BAND_USD resta $50, GTAA01 resta non deployabile (PRIIPs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 19:30:34 +00:00
10 changed files with 1550 additions and 37 deletions
+101 -17
View File
@@ -610,6 +610,10 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
| €500 | 19.6a (48%) | 15.7a (94%) | **12.4a (100%)** |
| €1000 | 14.8a (95%) | 12.0a (100%) | **9.0a (100%)** |
| €2000 | 10.0a (100%) | 8.5a (100%) | **6.0a (100%)** |
⛔ **QUESTA TABELLA E' AL LORDO del fisco d'accumulo — la versione NETTA e' nel bullet "IL FISCO
DURANTE L'ACCUMULO" (r0807_piano_netto, 07/08) e cambia la riga di testa: €250/mese passa da
P(20a) 92% a 52%.** Le colonne qui restano perche' sono la replica di controllo del fattore
d'ancora, non perche' siano il piano.
✅ La colonna ×0.60 **riproduce esattamente** i numeri del 25/07 (19.6a / 14.8a) = validazione
indipendente della replica. ⚠️ **Cio' che NON cambia col fattore: senza depositi il
capitale-rendita non si raggiunge mai** (0% dei path a 20 anni a OGNI fattore) — l'accumulo
@@ -622,6 +626,9 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
| **10 anni** | €806/m | €995/m | **€1.178/m** | €1.299/m | **$155.923** |
| 15 anni | €313/m | €408/m | €509/m | €583/m | $101.643 |
| 20 anni | €129/m | €180/m | €237/m | €280/m | $63.406 |
**AL LORDO: i numeri da usare sono quelli NETTI** (€1.323 / €672 / €371 a P=90%, bersaglio
$258k) nel bullet "IL FISCO DURANTE L'ACCUMULO" — il fisco costa **+12% al mese a 10 anni, +32%
a 15, +57% a 20**.
⚠️ **Comprimere l'orizzonte da 20 a 10 anni costa 2.5× al mese E 2.5× in totale**: a 10 anni
versi $155k per arrivare a $272k (il rendimento fa il 43%), a 20 anni ne versi $63k (il
rendimento fa il **77%**). **A orizzonti corti non fai lavorare la strategia, COMPRI il
@@ -1611,23 +1618,50 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
deploy**, con questo gate come giustificazione. **REGOLA: un parametro d'ESECUZIONE scelto
guardando il risultato e' selezione come ogni altra** e passa per gli stessi gate, anche quando
"non tocca l'allocazione".
⚠️ **QUESTO GATE OGGI FALLISCE (2026-08-07) e la sua misura NON e' riproducibile.**
`test_la_proposta_non_e_selezionata_sull_hold_out`: la proposta e' **9ª/30 in-sample e 8ª/30
sull'hold-out** (migliora dove non doveva essere guardata) contro il **4/30 e 5/30** registrato
sopra. Caratterizzato: i due ranghi distano **0.00116 di Sharpe** su un'ampiezza di griglia di
0.3124 (**0.4%**) → *il criterio non ha mai avuto margine, era un rango*; il calcolo e'
**deterministico** (2 corse, `max|diff| = 0.0`) e il codice e' **invariato** dal 27/07 (nessun
commit su `r0727_gtaa_band_gate.py`, `gtaa.py`, `eqlib.py`, `eq_splits.py`) → **e' cambiato il
DATO**: `data/raw/` e' gitignored e i parquet equity sono riscritti ogni giorno dal cron con
`ADJUSTED_LAST` di IB, che e' retroattivo.
📌 **REGOLA NUOVA: un gate validato su dati SOVRASCRITTI ogni giorno non e' ri-verificabile.**
Il lato cripto non ha il problema (`rebuild_history.py` ricostruisce da sorgente deterministica),
il lato equity si'. **Test NON toccato** (allentare una soglia perche' ha smesso di passare e'
proprio cio' che questo progetto vieta; un `xfail` silenzierebbe il segnale). Niente di operativo
dipende da questo — GTAA01 non e' deployabile (PRIIPs) e non e' nel book live — ma
**l'affermazione "il rango NON migliora sull'hold-out" oggi e' falsa**: decisione dell'operatore
se correggerla, ripensare il gate, o congelare uno snapshot dei parquet equity.
Diario `2026-08-07-crescita-fisco-etf-scelta.md` §7.
⚠️ **CORREZIONE 2026-08-07 — il criterio (A) qui sopra e' RITIRATO: non misurava cio' che
dichiarava, e il "4/30 in-sample, 5/30 hold-out" non e' un'evidenza.** Il test lo aveva
segnalato smettendo di passare (9ª/30 in-sample, 8ª/30 hold-out) **senza che il codice fosse
cambiato** — `data/raw/` e' gitignored e il cron riscrive i parquet equity ogni notte con
`ADJUSTED_LAST` di IB, che e' retroattivo. Misurato in `r0807_gtaa_gate_resolution.py`:
**(a)** i due ranghi distavano **0.00116 di Sharpe** su uno spread di griglia di **0.3124**
(0.4%); **(b)** e soprattutto **il criterio `rank_in <= rank_oos` lo passano 14/30 celle
(47%) PER COSTRUZIONE** — la somma dei ranghi e' la stessa nelle due finestre → *e' una moneta,
e il 27/07 la moneta era uscita bene*. Contorno: spostamento tipico fra le due finestre **8
ranghi** (max 25), **Spearman IS/OOS +0.05**.
**CRITERIO SOSTITUITO, e la proposta ne esce piu' forte di prima.** La proposta e' una
**BANDA** (la cadenza settimanale e' gia' `REBAL_EVERY=5` in produzione), quindi la domanda
decidibile e' *quale banda si sceglie a cadenza di produzione guardando solo il pre-2015*:
esce **25% = la proposta**, con margine **+0.0151** di Sharpe sulla seconda (**13×** il margine
del vecchio criterio); chi avesse scelto **sull'hold-out** avrebbe preso **40%** (controllo
positivo: se coincidessero il gate non avrebbe potenza). Su **tutta** la griglia la cella al
buio e' cadenza 1 / banda **25%** — stessa banda. **La proposta e' il CONTRARIO di una
selezione-sull'hold-out.** Regge identico sull'universo a **5 gambe** (blind 25%, hold-out 40%,
margine +0.0164). ⚠️ Il criterio dice da dove VIENE la scelta, **non** che sia la migliore
sull'hold-out (con Spearman ~0 nessuna cella lo sarebbe): provenienza ≠ previsione.
⚠️ **TROVATO PER STRADA, ed e' il difetto vero: TLT ha 13.5 ANNI DI STORIA IN MENO.** Parte dal
**2016-02-03** invece che dalla quotazione (2002-07-22) → **GTAA01 gira su CINQUE gambe prima
del 2016**, e quella assente e' la gamba obbligazionaria, cioe' quella che diversifica. Non e'
di oggi (cosi' fin dal primo giro nel `cron_daily.log`, 24/06 = **prima** della validazione del
27/07) e **non e' un fetch da rifare**: una richiesta retro esplicita a IB su questo conto
ritorna **0 barre**. Conseguenza metodologica: **l'in-sample e l'hold-out di GTAA01 non sono la
stessa strategia**, e ogni confronto fra le due finestre su questo sleeve va letto cosi'.
**Guardia cablata** (`fetch_ib_equities.certify`, test `tests/test_eq_history_guard.py`, 11):
**`TRONCATO`** = storia persa rispetto al disco → **il file NON viene sovrascritto** (e neppure
fuso: `ADJUSTED_LAST` e' ri-aggiustato all'indietro, incollare due vintage crea un salto sul
giunto); **`STORIA-CORTA`** = parte >1 anno dopo la quotazione e non al tetto della richiesta
(`PRIMA_QUOTAZIONE`, 6 simboli, **fonte secondaria dichiarata**). Controlli positivi
obbligatori: giro normale, serie al tetto 30Y, ETF giovane, simbolo fuori tabella.
**REGOLE:** (a) **un criterio si misura sulla sua RISOLUZIONE prima che sul suo esito** — se
decide su una frazione di percento dello spread e' rumore anche quando passa; (b) **un gate si
valida contando quante volte lo passa un candidato a caso** (qui 47%: il conto si poteva fare
il 27/07 senza dati nuovi); (c) **una certificazione che guarda solo DENTRO la serie non vede
cio' che la serie ha PERSO** — una serie troncata e' integra, senza gap, senza spike, senza
duplicati, e passa tutto (3ª occorrenza dopo split 2:1 e contaminazione EUR/USD); (d)
distinguere «giovane» / «al tetto della richiesta» / «troncato», o la guardia segnala sempre e
viene ignorata; (e) **un test che fallisce senza che il codice sia cambiato sta segnalando che
i dati non sono versionati** — si guarda sotto prima di toccarlo.
Diari `2026-08-07-crescita-fisco-etf-scelta.md` §7 (scoperta) e
`2026-08-07-gate-gtaa-e-storia-troncata.md` (risoluzione).
- 💰 **IL FISCO DURANTE L'ACCUMULO — mai contato in nessuna traiettoria, vale 30% a 10 anni
(misurato 2026-07-27, registrato qui il 2026-08-07).** ⚠️ I quattro risultati del 27/07 sera
(`r0727_3k_vs_5k.py`, `r0727_orizzonte10.py`, `r0727_tasse.py`) erano **solo nei messaggi di
@@ -1649,6 +1683,56 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis
comprano il **traguardo**, non il sorpasso: mediana al bersaglio al 12º anno invece del 15º,
P(bersaglio) a 15a da 73.2% a **99.0%**. Lump €3k vs €5k = ~1.8 mesi ogni €1.000 → non e' una
decisione. Script `scripts/research/r0807_growth_yearly.py`.
**TABELLE RIFATTE AL NETTO (2026-08-07) — il muro si sposta poco, i VERSAMENTI molto.**
`scripts/research/r0807_piano_netto.py`, test `tests/test_piano_netto.py` (16), diario
`2026-08-07-piano-al-netto.md`. **Book/pesi/cron/config INVARIATI.**
**(0) Controllo di replica superato:** a fisco spento la nuova macchina riproduce **$272.061 al
dollaro** (implementazione separata) e la colonna LORDA delle traiettorie riproduce **4 righe su
4** della tabella pubblicata (16.3/12.4/9.0/6.0 contro 16.2/12.4/9.0/6.0). ⚠️ Trovato per
strada: `perp_and_wall` gira a **2000 path** e a quella taglia da' **$269.648** → **la terza
cifra del muro e' rumore Monte Carlo (0.9%)**: si cita **$272k**, non $272.061.
**(1) IL MURO era calcolato con una convenzione ASIMMETRICA** — prelievo lordizzato ma capitale
che compone senza mai pagare imposte. Coerente (imposte annue dentro il portafoglio, prelievo
gia' netto): perpetua **10.91% → 7.70%**, muro **$272.061 → $258.338 (5.0%)**; a 26% $236.310.
I due errori vanno in versi OPPOSTI e **si compensano quasi — per caso, non per costruzione**.
**(2) TRAIETTORIE da $600** (25a, 3000 path, seed 725; mediana CONDIZIONATA all'arrivo + P(20a)):
| €/mese | LORDO anni / P(20a) | **NETTO anni / P(20a)** |
|---|---|---|
| 0 | mai / 0% | **mai / 0%** |
| **250** | 16.3a / **92%** | **19.8a / 52%** |
| 500 | 12.4a / 100% | **14.7a / 99%** |
| 800 | 10.0a / 100% | **11.4a / 100%** |
| 1000 | 9.0a / 100% | **10.0a / 100%** |
| 2000 | 6.0a / 100% | **6.4a / 100%** |
📌 **€250/mese — il livello con cui il piano risultava «P 92%, funziona» — al netto e' una
moneta (52%).**
**(3) QUANTO VERSARE, al netto** (bersaglio $258.338; fra parentesi il vecchio numero lordo):
| orizzonte | P=50% | P=75% | **P=90%** | P=95% | tot. versato @P=90% |
|---|---|---|---|---|---|
| **10 anni** | €998/m | €1.162/m | **€1.323/m** *(€1.178)* | €1.424/m | **$175.058** *($155.923)* |
| 15 anni | €470/m | €570/m | **€672/m** *(€509)* | €725/m | $133.983 *($101.643)* |
| 20 anni | €245/m | €306/m | **€371/m** *(€237)* | €417/m | $98.762 *($63.406)* |
**Il fisco costa +12% al mese a 10 anni, +32% a 15, +57% a 20** (cresce con l'orizzonte perche'
l'errore era composto). La lettura del 26/07 si RAFFORZA: a 10 anni versi $175k per arrivare a
$258k (il rendimento fa il **32%**), a 20 anni ne versi $99k (il rendimento fa il **62%**).
**(4) RENDITA €/g mediana, netta** (perpetua 7.70%, imposte gia' dentro — non lordizzare due
volte): €250/m → 4.38 (5a) / 11.82 (10a) / 24.61 (15a) / **46.61 (20a)**, P(€50/g a 20a)
**42.0%** contro i **91.30 €/g e 90.0%** pubblicati; €500/m → 92.11 e 97.6%; €800/m → 146.78.
**COSA NON CAMBIA:** senza versamenti il capitale-rendita non si raggiunge **mai** a nessuna
lente fiscale; l'ordine delle leve (versare > quando > quanto presto si smette > piatto vs
crescente > frequenza) e' invariato; il rischio di venue resta fuori scala (a p=5% il mediano
e' zero comunque).
⚠️ **ERRORE MIO catturato prima di pubblicare:** la prima stesura calcolava la mediana degli
anni sull'INTERO vettore coi non-arrivi a `-1` → €250/mese risultava passare da 15.7 a **15.3**
anni col fisco (*piu' veloce*) mentre P crollava da 91% a 53%, perche' con meta' dei path a 1
la mediana cade sui PRIMI arrivi. **REGOLA: un non-arrivo va codificato +∞, mai 1** — con 1 il
numero migliora tanto piu' quanto peggio va la colonna. E **una mediana condizionata si stampa
sempre accanto alla sua probabilita'**.
**REGOLE:** (a) un modello che tassa una meta' del conto e non l'altra non e' conservativo, e'
**incoerente** — e i due errori possono compensarsi quasi esattamente, il che li rende
invisibili finche' non si rifa' il conto in modo simmetrico; (b) prima di pubblicare un numero
nuovo, **far riprodurre alla macchina quello vecchio**; (c) **un Monte Carlo ha una risoluzione
e va detta** ($272.061 e' esatto quanto $269.648: la differenza e' la taglia del campione).
- ⚖️ **BOOK vs ETF (S&P 500 / MSCI World) — le due lenti danno risposte OPPOSTE, e la scelta
robusta e' un MIX 50/50 (2026-08-07).** Script `r0807_asset_compare.py` + `r0807_best_strategy.py`;
diario `2026-08-07-crescita-fisco-etf-scelta.md`. **Book/pesi/cron/config INVARIATI.**
@@ -0,0 +1,180 @@
# 2026-08-07 — Un gate che falliva, un criterio che non misurava, e una gamba con 13 anni in meno
**Book, pesi, cron, config: INVARIATI.** `REBAL_BAND_USD` resta $50; GTAA01 resta non deployabile
(PRIIPs) e sotto `GTAA_MIN_CAPITAL`. Cambia un test, cambia un'affermazione in CLAUDE.md, e si
aggiunge una guardia di certificazione che mancava.
Script: `scripts/research/r0807_gtaa_gate_resolution.py`.
Test: `tests/test_eq_history_guard.py` (11) + `tests/test_gtaa_band_gate.py` (16, criterio (A)
sostituito).
---
## Il punto di partenza
Il 07/08, facendo il giro dei test, `test_la_proposta_non_e_selezionata_sull_hold_out` falliva:
```
la proposta e' 8a sull'hold-out ma 9a in-sample: sta meglio dove non doveva essere guardata
```
Il gate (A) del 27/07 (`r0727_gtaa_band_gate.py`) era stato registrato cosi': «proposta 4/30
in-sample, 5/30 hold-out → il rango NON migliora sull'hold-out, quindi non e' selection-on-holdout».
Il codice non era stato toccato (`git log src/portfolio/gtaa.py` fermo al 26/07). Erano cambiati i
**dati**: `data/raw/` e' gitignored e il cron ri-scarica ogni notte i sei ETF con `ADJUSTED_LAST`,
che IB rivede all'indietro a ogni dividendo.
La tentazione ovvia era allentare la soglia o mettere un `xfail`. Sarebbe stato mettere a tacere
esattamente il segnale. La domanda giusta e' un'altra: **il criterio misura cio' che dichiara?**
---
## (0) Il difetto trovato per strada: TLT ha 13.5 anni in meno
Prima ancora di guardare il criterio, la copertura delle sei gambe:
| gamba | prima barra | quotato dal | mancano | barre | pre-2015 | 2015+ |
|---|---|---|---|---|---|---|
| SPY | 1996-08-14 | 1993-01-22 | 3.6a | 7540 | 4625 | 2915 |
| QQQ | 1999-03-10 | 1999-03-10 | 0.0a | 6896 | 3981 | 2915 |
| IWM | 2000-05-26 | 2000-05-22 | 0.0a | 6586 | 3671 | 2915 |
| **TLT** | **2016-02-03** | **2002-07-22** | **13.5a** | 2642 | **0** | 2642 |
| GLD | 2004-11-18 | 2004-11-18 | 0.0a | 5460 | 2545 | 2915 |
| HYG | 2007-04-11 | 2007-04-04 | 0.0a | 4861 | 1946 | 2915 |
(SPY parte dal 1996 perche' la richiesta chiede `durationStr="30 Y"`: e' il **tetto**, non un
difetto. La distinzione conta, senza di essa una guardia segnalerebbe ogni serie lunga.)
**GTAA01 gira su CINQUE gambe prima del 2016**, e la gamba assente e' proprio quella che
diversifica — le obbligazioni. Conseguenza diretta sul gate (A): l'in-sample (pre-2015) e
l'hold-out (2015+) **non sono la stessa strategia**.
Non e' successo il 07/08: nel `logs/cron_daily.log` TLT parte dal 2016-02-03 fin dal primo giro
registrato (24/06), quindi da **prima** della validazione del 27/07. E non e' un fetch da rifare:
una richiesta retro esplicita (`endDateTime=2016-01-01`, `durationStr="5 Y"`) su questo conto IB
ritorna **0 barre**. La storia non c'e'.
**Perche' nessuna certificazione l'ha vista.** Il feed equity ha guardie su integrita', gap lunghi,
spike, split non aggiustati (25/07) e cross-check col gemello UCITS (26/07). **Tutte guardano
DENTRO la serie.** Una serie troncata e' perfettamente integra: non ha buchi, non ha salti, non ha
duplicati. E' la terza volta in due settimane che una guardia tarata su una classe di difetto non
sorveglia le altre (soglia 50% cieca allo split 2:1; soglia sulla deviazione cieca alla
contaminazione EUR/USD; ora: ogni controllo cieco a cio' che la serie ha perso).
### Cablato
`fetch_ib_equities.certify(sym, df, prev)` ora ha **due** guardie, perche' i due difetti non si
vedono nello stesso modo:
- **`TRONCATO`** — la serie ha perso storia *rispetto al disco* (parte >10 giorni dopo, o ha >5
barre in meno). Prende una troncatura il giorno in cui compare; e' cieca a una gia' presente.
**Il file NON viene sovrascritto** — e neppure fuso: `ADJUSTED_LAST` e' ri-aggiustato
all'indietro a ogni dividendo, quindi incollare una vintage vecchia a una nuova creerebbe un
salto sul giunto, un difetto peggiore di quello che si voleva evitare.
- **`STORIA-CORTA`** — la serie parte >1 anno dopo la quotazione dello strumento, e non al tetto
della richiesta. Prende anche una troncatura presente da sempre, che e' il caso di TLT.
Riferimento: `PRIMA_QUOTAZIONE`, sei simboli, **fonte secondaria dichiarata**.
Controlli positivi obbligatori nei test: il giro normale di ogni notte non deve scattare, una
serie al tetto 30Y non deve scattare, un ETF giovane (HYG) non deve scattare, un simbolo fuori
tabella non viene giudicato. *Una guardia che non segnala mai e' indistinguibile da una rotta.*
---
## (1) Il criterio non aveva risoluzione — misurato, non argomentato
| | valore |
|---|---|
| rango della proposta | 9/30 in-sample · 8/30 hold-out |
| distanza dal rango precedente | **0.00116** di Sharpe in-sample |
| spread dell'intera griglia | 0.3124 |
| il verdetto si decideva su | **0.37% dello spread** |
E la misura che chiude la questione — **il criterio applicato a ogni cella della griglia passa
14/30 (47%)**. Non e' un caso: la somma dei ranghi e' la stessa nelle due finestre, quindi
`rank_in <= rank_oos` e' vero per circa **meta' delle celle per costruzione**, qualunque cosa la
griglia contenga.
> Un gate che una cella a caso passa il 47% delle volte non distingue una proposta onesta da una
> selezionata sull'hold-out. E' una moneta.
Contorno: lo spostamento tipico fra le due finestre e' di **8 ranghi** (massimo 25), e lo Spearman
IS/OOS e' **+0.05**. Su questa griglia il rango in-sample non porta informazione sul rango
hold-out — il che rende il confronto fra i due ranghi doppiamente privo di senso.
---
## (2) Il criterio decidibile
**La proposta del 27/07 e' una BANDA, non una cadenza.** `REBAL_EVERY=5` e' gia' la produzione e
non era in discussione. Quindi la domanda sulla provenienza della scelta e': *a cadenza di
produzione, quale banda si sceglie guardando solo il pre-2015?*
| banda | Sh in-sample | Sh hold-out |
|---|---|---|
| 0% | 0.5396 | 0.8261 |
| 5% | 0.5714 | 0.8640 |
| 10% | 0.5956 | 0.8629 |
| **25%** | **0.6398** ← argmax | 0.8529 |
| 40% | 0.6247 | **0.9081** ← argmax |
| 60% | 0.5874 | 0.7573 |
- banda scelta **sui soli dati pre-2015**: **25%** = la proposta, con margine **+0.0151** sulla 2ª;
- banda scelta **sull'hold-out**: **40%** — diversa. *(Controllo positivo: se coincidessero, il
gate non avrebbe potenza e non andrebbe citato come validazione.)*
- cella scelta al buio su **tutta** la griglia: cadenza 1, banda **25%** — stessa banda.
**La proposta e' l'esatto contrario di una selezione-sull'hold-out**: e' la cella che si sceglie
senza guardare l'hold-out, e chi avesse guardato l'hold-out ne avrebbe scelta un'altra. E il
margine e' **0.0151** contro i **0.00116** su cui si decideva il criterio a ranghi: **13×**.
⚠️ **Cio' che questo criterio NON dice:** che la banda scelta in-sample sia la migliore
sull'hold-out. Non lo e'. Con lo Spearman IS/OOS a ~0, nessuna cella di questa griglia lo sarebbe
in modo affidabile. Il gate (A) risponde alla domanda sulla **provenienza** della scelta, non a
quella sulla **previsione**. Confonderle e' esattamente il modo in cui si finisce a selezionare
sull'hold-out credendo di validare.
---
## (3) Robustezza al difetto (0)
Rifatto tutto sull'universo a **5 gambe** (senza TLT), coerente fra le due finestre:
blind **25%**, hold-out **40%**, margine **+0.0164**. Verdetto identico. Il criterio a ranghi
resta una moneta anche li' (passa 14/30).
Quindi: il difetto dei dati **non e' cio' che decide questo verdetto** — ma resta un difetto, e va
riparato per suo conto. Che e' cio' che si e' fatto al punto (0).
---
## Cosa cambia
- `tests/test_gtaa_band_gate.py`: il criterio a ranghi e' **ritirato** e sostituito da
`test_la_banda_proposta_e_quella_scelta_al_buio` + il suo controllo positivo
(`test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA`) + un test sul margine.
Il **motivo** del ritiro e' congelato in
`test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO` — si congela il
motivo, non l'esito, che dipende dai dati e si muove da solo.
- `r0727_gtaa_band_gate.py`: il gate (A) stampa il criterio decidibile; il confronto fra ranghi
resta come descrizione, con la data del ritiro.
- CLAUDE.md: l'affermazione «proposta 4/30 in-sample, 5/30 hold-out → il rango NON migliora» e'
corretta.
- `fetch_ib_equities.py`: le due guardie sulla storia + il riepilogo finale che le elenca.
---
## Regole
1. **Un test che fallisce senza che il codice sia cambiato sta segnalando che i dati non sono
versionati.** Prima di toccarlo, si guarda cosa e' cambiato sotto.
2. **Un criterio va misurato sulla sua risoluzione prima che sul suo esito.** Se decide su una
frazione di percento dello spread, il verdetto e' rumore in entrambi i versi — anche quando
passa. Il 27/07 quel criterio *passava*, e passava per caso.
3. **Un gate si valida contando quante volte lo passa un candidato a caso.** Qui: 47%. Il numero
si poteva calcolare il 27/07 senza dati nuovi.
4. **Una certificazione che guarda solo dentro la serie non vede cio' che la serie ha perso.**
Una serie troncata e' integra, senza gap, senza spike, senza duplicati: passa tutto.
5. **Distinguere «giovane», «al tetto della richiesta» e «troncato».** Sono tre cose diverse e
solo la terza e' un difetto; senza la distinzione la guardia segnala sempre e viene ignorata.
6. **Provenienza e previsione sono due domande diverse.** Un gate anti-selezione dice da dove
viene la scelta, non se funzionera'.
+164
View File
@@ -0,0 +1,164 @@
# 2026-08-07 — Il piano rifatto al netto: muro, traiettorie, versamenti
**Book, pesi, cron, config: INVARIATI.** Non tocca la produzione: rifa' i numeri del *piano di
accumulo*, che erano al lordo del fisco.
Script: `scripts/research/r0807_piano_netto.py`. Test: `tests/test_piano_netto.py` (16).
---
## Il debito
Il 07/08 (`r0727_tasse.py`, `r0807_growth_yearly.py`) era stato misurato che l'accumulo composto al
lordo sovrastima il capitale del **15.7% a 5 anni, 29.8% a 10, 42.6% a 15**. Il fatto era stato
scritto in memoria — *"TUTTE le tabelle a 15-20 anni pubblicate sopra sono al LORDO"* — ma i numeri
non erano stati rifatti. Sono numeri che sono serviti a decidere: la traiettoria da $600, la
tabella «quanto versare per un orizzonte dato», la rendita €/g.
**E il muro stesso non era neutro.** `$272.061` viene da una convenzione **asimmetrica**: il
prelievo viene lordizzato (€50/g netti → $29.690/anno lordi al 33%) ma il capitale che resta
investito compone **senza mai pagare imposte**, in accumulo come in prelievo. Le due meta' del
conto non usano lo stesso fisco.
---
## (0) Il controllo di replica, che ha trovato subito qualcosa
Prima regola: a fisco spento tutto deve riprodurre i numeri pubblicati, altrimenti un numero
diverso non si distingue da un bug.
| | perpetua | muro |
|---|---|---|
| costante pubblicata (`VR.TARGET`, citata ovunque) | — | **$272.061** |
| questa implementazione, fisco OFF, 4000 path | 10.9131% | **$272.061** |
| `r0726_capwall_refresh.perp_and_wall` ai suoi **2000** path | 11.0107% | $269.648 |
| lo stesso, portato a 4000 path | 10.9131% | **$272.061** |
Replica esatta al dollaro fra due implementazioni separate. ⚠️ **Ma trovato per strada:** la
funzione che ha prodotto il muro gira di default a **2000 path**, e a quella taglia da' $269.648 —
**0.9% di rumore Monte Carlo**. Il muro pubblicato e' corretto (fu calcolato a 4000), ma **la sua
terza cifra significativa non e' un'informazione**: si cita **$272k**, non $272.061.
---
## (1) Il muro, nella convenzione coerente
Imposte pagate ogni anno **dentro** il portafoglio (stessa meccanica dell'accumulo: plusvalenza
annua al netto dei movimenti di cassa, carry 4 anni, patrimoniale 0.2%), prelievo gia' netto:
| convenzione | prelievo/anno | perpetua | muro |
|---|---|---|---|
| (a) pubblicata — fisco solo sul prelievo | $29.690 | 10.91% | **$272.061** |
| (b) coerente, aliquota 33% | $19.892 | **7.70%** | **$258.338** |
| (b) coerente, aliquota 26% | $19.892 | 8.42% | $236.310 |
**5.0%.** I due errori della convenzione (a) vanno in versi opposti e **si compensano quasi**:
lordizzare il prelievo alza il muro, non tassare il capitale investito lo abbassa. Ma si
compensano *per caso*, non per costruzione — e' il tipo di errore che si vede solo rifacendo il
conto in modo coerente. Cio' che resta scoperto e' la patrimoniale e la non-linearita' della
rendita perpetua nel drift.
---
## (2) Le traiettorie — ed e' qui che il fisco morde
Orizzonte 25 anni, 3000 path, seed 725: **esattamente la macchina che ha prodotto la tabella
pubblicata**, quindi la colonna LORDA e' una replica di controllo.
| €/mese | versato in 20a | LORDO: anni | P(20a) | NETTO: anni | P(20a) |
|---|---|---|---|---|---|
| 0 | $600 | mai | 0% | mai | 0% |
| **250** | $66.818 | **16.3a** *(pubbl. 16.2)* | **92%** *(pubbl. 92%)* | **19.8a** | **52%** |
| 500 | $133.035 | 12.4a *(pubbl. 12.4)* | 100% | 14.7a | 99% |
| 800 | $212.496 | 10.0a | 100% | 11.4a | 100% |
| 1000 | $265.470 | 9.0a *(pubbl. 9.0)* | 100% | 10.0a | 100% |
| 2000 | $530.340 | 6.0a *(pubbl. 6.0)* | 100% | 6.4a | 100% |
**Il livello €250/mese — quello con cui il 26/07 il piano risultava «P(entro 20a) 92%» — al netto
diventa una moneta: 52%.**
⚠️ **La colonna «anni» e' la mediana CONDIZIONATA all'arrivo** (convenzione di
`r0726_capwall_refresh.trajectory`, quindi confrontabile con la tabella pubblicata) e va letta
insieme alla probabilita' accanto.
> **Errore mio, catturato prima di pubblicare.** La prima stesura calcolava la mediana
> sull'**intero** vettore, con i non-arrivi codificati `-1`. Risultato: €250/mese risultava passare
> da 15.7 a **15.3** anni col fisco — *piu' veloce* — mentre la probabilita' crollava da 91% a 53%.
> Con meta' dei path a `-1` la mediana del vettore cade sui **primi** arrivi. **Un non-arrivo va
> messo a +∞, non a 1: messo a 1 il numero migliora tanto piu' quanto peggio va la colonna**, ed
> era abbastanza plausibile da finire in un verdetto.
---
## (3) Quanto versare per un orizzonte dato — al netto
Bersaglio $258.338. Fra parentesi il numero pubblicato (lordo, bersaglio $272.061).
| orizzonte | P=50% | P=75% | P=90% | P=95% | tot. versato @P=90% |
|---|---|---|---|---|---|
| **10 anni** | €998/m | €1.162/m | **€1.323/m** *(€1.178)* | €1.424/m | **$175.058** *($155.923)* |
| 15 anni | €470/m | €570/m | **€672/m** *(€509)* | €725/m | $133.983 *($101.643)* |
| 20 anni | €245/m | €306/m | **€371/m** *(€237)* | €417/m | $98.762 *($63.406)* |
**Il fisco costa +12% al mese a 10 anni, +32% a 15, +57% a 20.** Cresce con l'orizzonte perche'
l'errore era composto: piu' tempo, piu' interessi mai maturati sulle imposte non pagate.
E la lettura del 26/07 si **rafforza**: a 10 anni versi **$175k** per arrivare a $258k (il
rendimento fa il 32%), a 20 anni ne versi **$99k** (il rendimento fa il 62%). *A orizzonti corti
non fai lavorare la strategia, compri il capitale coi bonifici* — al netto ancora di piu'.
---
## (4) Rendita €/g mediana — al netto
Le imposte sono gia' dentro la perpetua (7.70%): il numero e' netto e **non va lordizzato una
seconda volta**.
| €/mese | 5a | 10a | 15a | 20a | P(€50/g a 20a) |
|---|---|---|---|---|---|
| 0 | 0.20 | 0.34 | 0.59 | 1.01 | 0.0% |
| **250** | 4.38 | 11.82 | 24.61 | **46.61** *(pubbl. 91.30)* | **42.0%** *(pubbl. 90.0%)* |
| 500 | 8.56 | 23.31 | 48.65 | 92.11 | 97.6% |
| 800 | 13.58 | 37.08 | 77.47 | 146.78 | 100.0% |
| 1000 | 16.93 | 46.26 | 96.67 | 183.18 | 100.0% |
| 2000 | 33.66 | 92.18 | 192.79 | 365.24 | 100.0% |
A 20 anni la rendita di €250/mese si **dimezza** (91.30 → 46.61 €/g) e P(€50/g) passa da **90% a
42%**. La non-linearita' resta: da 15 a 20 anni la rendita raddoppia a ogni livello.
---
## Cosa cambia e cosa no
**Cambia:** il livello di versamento che il piano dichiarava sufficiente. €250/mese al lordo
sembrava «P 92%, il piano funziona»; al netto e' 52%, e per tornare a ~90% servono **€371/mese**
a 20 anni. Il muro si sposta poco (5%), i **versamenti** si spostano molto (+57% a 20 anni).
**Non cambia:**
- senza versamenti il capitale-rendita non si raggiunge **mai**, a qualunque lente fiscale;
- l'ordine di importanza delle leve (versare > quando > quanto presto si smette > piatto vs
crescente > frequenza) e' invariato: il fisco colpisce tutte le colonne allo stesso modo;
- il rischio di venue resta fuori scala rispetto a tutto questo (a p=5% il risultato mediano e'
zero comunque).
⚠️ **Assunzioni fiscali dichiarate, NON un parere fiscale** (33%, sensibilita' a 26%, minusvalenze
in carry 4 anni, patrimoniale 0.2%/anno). Il modello tassa la variazione **annua** di valore =
**limite superiore** rispetto alla pura realizzazione, stretto perche' TP01 ribilancia ogni giorno
e SKH01 chiude round-trip discreti.
---
## Regole
1. **Un modello che tassa una meta' del conto e non l'altra non e' conservativo, e' incoerente**
e i due errori possono compensarsi quasi esattamente, il che li rende invisibili finche' non si
rifa' il conto in modo simmetrico.
2. **Un non-arrivo va codificato +∞, mai 1.** Con 1 la mediana *migliora* quanto piu' la colonna
peggiora.
3. **Una mediana condizionata va sempre stampata accanto alla sua probabilita'**, o dice il
contrario di quello che sembra.
4. **Prima di pubblicare un numero nuovo, far riprodurre alla macchina quello vecchio** — qui la
colonna LORDA riproduce 4 righe su 4 della tabella pubblicata, ed e' l'unica ragione per cui la
colonna NETTA e' leggibile.
5. **Un Monte Carlo ha una risoluzione, e va detta.** $272.061 e' esatto quanto $269.648: la
differenza fra i due e' la taglia del campione, non un'informazione sul piano.
+64 -5
View File
@@ -38,8 +38,20 @@ BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
UNIVERSE = SECTORS + BROAD + BROAD2
# Prima quotazione degli strumenti che contano (le sei gambe di GTAA01). RIFERIMENTO DICHIARATO,
# fonte secondaria: serve solo a distinguere "questo ETF e' giovane" da "il feed ci da' meno storia
# di quella che esiste". Trovato il 2026-08-07: TLT parte dal 2016-02-03 invece che dal 2002 — IB su
# questo conto NON serve barre precedenti (richiesta retro esplicita -> 0 barre), quindi non e' un
# fetch da rifare ma un limite da dichiarare. Nessuna certificazione se n'era accorta perche' tutte
# guardavano DENTRO la serie (integrita', gap, spike, split) e nessuna la sua LUNGHEZZA.
PRIMA_QUOTAZIONE = {"SPY": "1993-01-22", "QQQ": "1999-03-10", "IWM": "2000-05-22",
"TLT": "2002-07-22", "GLD": "2004-11-18", "HYG": "2007-04-04"}
DURATION = "30 Y" # tetto della richiesta: una serie che parte QUI non e' troncata, e' al cap
def certify(sym: str, df: pd.DataFrame) -> dict:
def certify(sym: str, df: pd.DataFrame, prev: pd.DataFrame | None = None) -> dict:
"""`prev` = la versione GIA' SU DISCO. Senza, la certificazione non puo' vedere cio' che la
serie ha PERSO: tutti i controlli esistenti guardano dentro la serie che hanno in mano."""
if df.empty:
return {"sym": sym, "n": 0, "status": "VUOTO"}
idx = df.index
@@ -67,21 +79,45 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
# Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday).
splits = detect_unadjusted_splits(df)
# --- storia PERSA: due guardie, perche' i due difetti non si vedono nello stesso modo ---
# (a) REGRESSIONE rispetto al disco: la serie nuova parte dopo la vecchia, o ha meno barre.
# Prende una troncatura il giorno in cui compare — ma non una che c'era gia'.
primo = idx[0].tz_localize(None) if idx[0].tzinfo else idx[0]
ultimo = idx[-1].tz_localize(None) if idx[-1].tzinfo else idx[-1]
perse = 0
if prev is not None and len(prev):
vecchio = prev.index[0]
vecchio = vecchio.tz_localize(None) if vecchio.tzinfo else vecchio
perse = max(0, (primo - vecchio).days)
tronca = perse > 10 or (prev is not None and len(prev) > 0 and len(df) < len(prev) - 5)
# (b) DISTANZA DALLA QUOTAZIONE: prende anche una troncatura presente da sempre. Non e' un
# difetto se la serie parte al tetto della richiesta (30 anni indietro): quello e' il cap.
corta = 0.0
if sym in PRIMA_QUOTAZIONE:
atteso = max(pd.Timestamp(PRIMA_QUOTAZIONE[sym]),
ultimo - pd.DateOffset(years=int(DURATION.split()[0])))
corta = max(0, (primo - atteso).days) / 365.25
status = "OK"
if dup or not mono:
status = "INTEGRITA'"
elif tronca:
status = "TRONCATO" # il feed ha PERSO storia rispetto a ieri
elif splits:
status = "SPLIT-NON-AGG"
elif maxret > 0.5:
status = "SPIKE?"
elif longgap > 0:
status = "GAP-LUNGO"
elif corta >= 1.0:
status = "STORIA-CORTA" # meno storia di quella che lo strumento HA
elif span_y < 1:
status = "corto<1y"
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
"adj_first/last": adj_ratio, "status": status,
"adj_first/last": adj_ratio, "persi_g": perse, "manca_a": round(corta, 1),
"status": status,
"splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]}
@@ -115,7 +151,7 @@ def main():
continue
con = Stock(sym, "SMART", "USD")
try:
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="30 Y", barSizeSetting="1 day",
bars = ib.reqHistoricalData(con, endDateTime="", durationStr=DURATION, barSizeSetting="1 day",
whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1, timeout=60)
except Exception as e:
print(f" {sym:5} ERR {repr(e)[:70]}"); rep.append({"sym": sym, "status": "ERR"}); time.sleep(1.2); continue
@@ -123,8 +159,24 @@ def main():
print(f" {sym:5} 0 barre (subscription?)"); rep.append({"sym": sym, "n": 0, "status": "VUOTO"}); time.sleep(1.2); continue
df = pd.DataFrame([(pd.Timestamp(str(b.date)), b.open, b.high, b.low, b.close, b.volume) for b in bars],
columns=["ts", "open", "high", "low", "close", "volume"]).set_index("ts").sort_index()
c = certify(sym, df)
prev = None
if out_path.exists():
try:
pv = pd.read_parquet(out_path)
pv.index = pd.to_datetime(pv["timestamp"], unit="ms")
prev = pv.sort_index()
except Exception:
prev = None
c = certify(sym, df, prev)
rep.append(c)
if c["status"] == "TRONCATO":
# NON si sovrascrive e NON si fonde: ADJUSTED_LAST e' ri-aggiustato all'indietro a ogni
# dividendo, quindi incollare una vintage vecchia a una nuova crea un salto sul giunto —
# un difetto peggiore di quello che si voleva evitare. Si tiene il file coerente e si urla.
print(f" {sym:5} ⚠️ TRONCATO: {c['primo']} contro {prev.index[0].date()} su disco "
f"({c['persi_g']}g persi, {c['n']} barre contro {len(prev)}) -> FILE NON TOCCATO")
time.sleep(1.2)
continue
if c.get("n", 0) > 0:
out = df.copy()
# ms epoch (come i parquet crypto), robusto alla risoluzione datetime64 (s/us/ns)
@@ -135,10 +187,17 @@ def main():
print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} "
f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% "
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]"
+ (f" split={c['splits']}" if c.get("splits") else ""))
+ (f" split={c['splits']}" if c.get("splits") else "")
+ (f" manca_a_quotazione={c['manca_a']}a" if c.get("manca_a", 0) >= 1.0 else ""))
time.sleep(1.2) # pacing IB
print("-" * 104)
guasti = [r for r in rep if r.get("status") in ("TRONCATO", "STORIA-CORTA")]
if guasti:
print(" ⚠️ STORIA MANCANTE (le certificazioni locali non la vedono: guardano DENTRO la serie):")
for r in guasti:
print(f" {r['sym']:5} [{r['status']}] parte dal {r['primo']}"
f"{r.get('manca_a', 0)}a dopo la quotazione, {r.get('persi_g', 0)}g persi vs disco")
print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}")
sec_ok = [s for s in SECTORS if s in ok]
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
+18 -3
View File
@@ -148,9 +148,24 @@ def main() -> None:
rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1
rank_oos = int((G["sh_oos"] > prop['sh_oos']).sum()) + 1
print(f" rango della proposta : {rank_in}/{len(G)} in-sample · {rank_oos}/{len(G)} sull'hold-out")
if rank_oos < rank_in:
print(" ⚠️ La proposta sta MEGLIO sull'hold-out che in-sample: e' la firma della")
print(" selezione-sull'hold-out. Il gate si giudica su cosa succede scegliendo al buio.")
print(" (⚠️ 2026-08-07: il confronto FRA I RANGHI e' stato ritirato come criterio — passa")
print(" ~meta' delle celle per costruzione e sulla proposta si decideva su 0.001 di")
print(" Sharpe. Resta stampato come descrizione. Vedi r0807_gtaa_gate_resolution.py.)")
# criterio decidibile: la proposta e' una BANDA, la cadenza e' gia' quella di produzione
ev = PROPOSTA[0]
riga = G[G["every"] == ev]
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
hold = riga.sort_values("sh_oos", ascending=False).iloc[0]
secondo = riga[riga["frac"] != blind["frac"]]["sh_in"].max()
print(f"\n CRITERIO DECIDIBILE — a cadenza di produzione ({ev}), quale banda si sceglie?")
print(f" sui soli dati pre-2015 : {blind['frac']:.0%} "
f"(margine sulla 2ª: {blind['sh_in'] - secondo:+.4f} di Sharpe)")
print(f" guardando l'hold-out : {hold['frac']:.0%} "
"<- controllo positivo: se coincidessero, il gate non avrebbe potenza")
if blind["frac"] == PROPOSTA[1] and hold["frac"] != PROPOSTA[1]:
print(" -> la proposta e' la cella scelta AL BUIO, e chi avesse guardato l'hold-out")
print(" ne avrebbe scelta un'altra: e' il contrario di una selezione-sull'hold-out.")
print(f"\n Costo di scegliere al buio invece che a posteriori (hold-out): "
f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe")
n_best = orders_per_year(cells[(int(best["every"]), float(best["frac"]))], CAP_REF,
@@ -0,0 +1,280 @@
#!/usr/bin/env python
"""r0807_gtaa_gate_resolution.py — il gate (A) della banda GTAA01 fallisce: e' la proposta o e' il gate?
CONTESTO. Il 27/07 `r0727_gtaa_band_gate.py` valido' la proposta "banda = 25% della gamba" e il
suo gate (A) — SELEZIONE IN-SAMPLE — fu registrato cosi': «proposta 4/30 in-sample, 5/30 hold-out
-> il rango NON migliora sull'hold-out, quindi non e' selection-on-holdout», e congelato in
`tests/test_gtaa_band_gate.py::test_la_proposta_non_e_selezionata_sull_hold_out` come
`rank_in <= rank_oos`. Il 07/08 quel test FALLISCE: 9 in-sample, 8 sull'hold-out.
Il codice non e' cambiato (`git log src/portfolio/gtaa.py` fermo al 26/07), quindi sono cambiati i
DATI: `data/raw/` e' gitignored e il cron ri-scarica ogni notte i sei ETF con `ADJUSTED_LAST`, che
IB rivede all'indietro a ogni dividendo. Un risultato validato si e' spostato senza che nessuno
toccasse nulla.
Questo script non ripara il test: chiede se il criterio misura cio' che dichiara. Tre domande, in
quest'ordine, perche' la seconda ha senso solo se la prima ha una risposta pulita:
(0) **IL DIFETTO TROVATO PER STRADA.** TLT — la gamba obbligazionaria — ha 10.5 anni di storia
invece dei ~24 dalla sua quotazione (2002-07-22). Non e' successo il 07/08: e' cosi' da
almeno il 24/06 (primo giro nel `logs/cron_daily.log`), quindi da PRIMA della validazione
del 27/07 e probabilmente da sempre. Conseguenza diretta sul gate (A): l'in-sample
(pre-2015) gira su CINQUE gambe e l'hold-out su SEI. Il criterio confronta il rango di una
strategia con il rango di una strategia diversa.
(1) **RISOLUZIONE DEL CRITERIO.** Quanto vale un rango? Se la differenza fra il 9° e l'8° posto
e' un millesimo di Sharpe, il verdetto non e' una misura. E la domanda decisiva non e'
sulla proposta ma sul criterio: applicato a una cella QUALUNQUE della griglia, `rank_in <=
rank_oos` quante volte passa? La somma dei ranghi e' la stessa nelle due finestre, quindi
la risposta e' nota a priori — ~meta'. Un gate che una moneta passa la meta' delle volte
non distingue una proposta onesta da una selezionata sull'hold-out.
(2) **IL CRITERIO DECIDIBILE.** La proposta del 27/07 e' una BANDA, non una cadenza: la cadenza
settimanale e' gia' quella di produzione (`REBAL_EVERY=5`) e non era in discussione. La
domanda onesta e' quindi «scegliendo la banda al buio sui soli dati pre-2015, a cadenza di
produzione, quale esce?». E il controllo positivo obbligatorio: «e scegliendola
sull'hold-out, esce la stessa?». Se le due selezioni danno la stessa cella, il gate non ha
potenza; se danno celle diverse e la proposta e' quella IN-SAMPLE, la proposta e'
l'opposto di una selezione-sull'hold-out.
(3) **ROBUSTEZZA AL DIFETTO (0).** (1) e (2) rifatti sull'universo a 5 gambe (senza TLT), che e'
coerente fra le due finestre. Se il verdetto cambia, il difetto dei dati non e' un dettaglio.
uv run python scripts/research/r0807_gtaa_gate_resolution.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
from src.portfolio.gtaa import EQ_UNIVERSE, _close, gtaa_returns # noqa: E402
# quotazione reale dei sei ETF: serve a distinguere "la storia e' corta" da "il feed e' troncato"
INCEPTION = {"SPY": "1993-01-22", "QQQ": "1999-03-10", "IWM": "2000-05-22",
"TLT": "2002-07-22", "GLD": "2004-11-18", "HYG": "2007-04-04"}
SENZA_TLT = tuple(a for a in EQ_UNIVERSE if a != "TLT")
def cella(every: int, frac: float, universe=EQ_UNIVERSE, capital: float = BG.CAP_REF) -> pd.Series:
"""Come `BG.run_cell`, ma con l'universo esplicito: serve al controllo (3)."""
return gtaa_returns(universe=universe, capital=capital,
band_usd=frac * capital / len(universe), every=every)
def griglia(universe=EQ_UNIVERSE) -> pd.DataFrame:
rows = []
for every in BG.EVERY_GRID:
for frac in BG.FRAC_GRID:
s = cella(every, frac, universe)
rows.append(dict(every=every, frac=frac,
sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"],
sh_full=BG.met(s)["sharpe"]))
G = pd.DataFrame(rows)
G["rank_in"] = G["sh_in"].rank(ascending=False, method="min").astype(int)
G["rank_oos"] = G["sh_oos"].rank(ascending=False, method="min").astype(int)
return G
def scelta(G: pd.DataFrame, colonna: str, every: int | None = None) -> pd.Series:
"""La cella scelta massimizzando `colonna`; con `every` fissato, la banda scelta a quella cadenza."""
sub = G if every is None else G[G["every"] == every]
return sub.sort_values(colonna, ascending=False).iloc[0]
def sezione(titolo: str) -> None:
print("\n" + "=" * 104)
print(f" {titolo}")
print("=" * 104)
# ===========================================================================
# (0) il difetto: quante gambe ci sono davvero, e quando
# ===========================================================================
def parte_0() -> dict:
sezione("(0) COPERTURA DELLE SEI GAMBE — l'in-sample e l'hold-out girano sullo stesso sleeve?")
print("\n Una storia corta non e' un difetto: puo' essere la quotazione dello strumento, o il")
print(" tetto della richiesta (`durationStr=\"30 Y\"` in fetch_ib_equities). E' un difetto solo")
print(" se la serie parte DOPO entrambi.")
print(f"\n {'gamba':>6}{'prima barra':>14}{'quotato dal':>14}{'mancano':>10}"
f"{'barre':>8}{'pre-2015':>10}{'2015+':>8}")
manca = {}
for a in EQ_UNIVERSE:
c = _close(a)
primo = c.index[0].date()
inc = pd.Timestamp(INCEPTION[a]).date()
anni = (primo - inc).days / 365.25
tetto = (c.index[-1] - pd.DateOffset(years=30)).date() # il piu' indietro che la richiesta arrivi
pre = int((c.index < pd.Timestamp(BG.HOLDOUT, tz="UTC")).sum())
post = len(c) - pre
manca[a] = anni
flag = " <- TRONCATA" if anni > 1.0 and (primo - tetto).days > 10 else (
" (tetto 30Y)" if anni > 1.0 else "")
print(f" {a:>6}{str(primo):>14}{str(inc):>14}{anni:>9.1f}a{len(c):>8}{pre:>10}{post:>8}{flag}")
n_pre = sum(1 for a in EQ_UNIVERSE if _close(a).index[0] < pd.Timestamp(BG.HOLDOUT, tz="UTC"))
print(f"\n gambe vive PRE-2015 (in-sample) : {n_pre}/6")
print(f" gambe vive 2015+ (hold-out) : {len(EQ_UNIVERSE)}/6")
if n_pre < len(EQ_UNIVERSE):
print("\n ⚠️ Il gate (A) confronta il rango di uno sleeve a 5 gambe (in-sample) con quello")
print(" di uno sleeve a 6 (hold-out). Non e' rumore: e' un'altra strategia. E la")
print(" gamba assente e' proprio quella che diversifica — le obbligazioni.")
return manca
# ===========================================================================
# (1) risoluzione: quanto vale un rango, e quanto vale il criterio
# ===========================================================================
def parte_1(G: pd.DataFrame) -> dict:
sezione("(1) RISOLUZIONE DEL CRITERIO — un rango e' una misura o un arrotondamento?")
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
ri, ro = int(p["rank_in"]), int(p["rank_oos"])
print(f"\n proposta (cadenza {BG.PROPOSTA[0]}, banda {BG.PROPOSTA[1]:.0%}): "
f"rango {ri}/{len(G)} in-sample · {ro}/{len(G)} hold-out -> "
f"criterio `rank_in <= rank_oos` {'PASS' if ri <= ro else 'FAIL'}")
si = np.sort(G["sh_in"].values)[::-1]
so = np.sort(G["sh_oos"].values)[::-1]
gap_in = si[ri - 2] - si[ri - 1] if ri >= 2 else float("nan")
gap_oos = so[ro - 2] - so[ro - 1] if ro >= 2 else float("nan")
spread = float(G["sh_in"].max() - G["sh_in"].min())
print(f"\n distanza dal rango precedente : {gap_in:.5f} di Sharpe in-sample · "
f"{gap_oos:.5f} sull'hold-out")
print(f" spread dell'intera griglia : {spread:.4f}")
print(f" il verdetto si decide su : {gap_in / spread:.2%} dello spread")
# la domanda vera: il criterio, applicato a una cella qualunque
passa = int((G["rank_in"] <= G["rank_oos"]).sum())
rho = float(G["sh_in"].corr(G["sh_oos"], method="spearman"))
d = (G["rank_in"] - G["rank_oos"]).abs()
print(f"\n ⚠️ IL CRITERIO APPLICATO A OGNI CELLA DELLA GRIGLIA: passa {passa}/{len(G)} "
f"({passa / len(G):.0%})")
print(f" Non e' un caso: la somma dei ranghi e' la stessa nelle due finestre, quindi")
print(f" `rank_in <= rank_oos` e' vero per circa META' delle celle PER COSTRUZIONE.")
print(f" Spostamento tipico fra le due finestre: mediana {d.median():.0f} ranghi, "
f"massimo {d.max():.0f} (Spearman IS/OOS {rho:+.2f}).")
print(f"\n Un gate che una cella a caso passa il {passa / len(G):.0%} delle volte non")
print(f" distingue una proposta onesta da una selezionata sull'hold-out: e' una moneta.")
return dict(rank_in=ri, rank_oos=ro, gap_in=float(gap_in), spread=spread,
passa=passa, n=len(G), spearman=rho)
# ===========================================================================
# (2) il criterio decidibile: la banda scelta al buio
# ===========================================================================
def parte_2(G: pd.DataFrame, etichetta: str = "sei gambe") -> dict:
sezione(f"(2) IL CRITERIO DECIDIBILE — quale BANDA si sceglie al buio? [{etichetta}]")
print("\n La proposta del 27/07 e' una banda, non una cadenza: `REBAL_EVERY=5` e' gia' la")
print(" produzione e non era in discussione. Quindi la selezione si giudica a cadenza fissa.")
ev = BG.PROPOSTA[0]
print(f"\n cadenza {ev} (produzione) — Sharpe per banda:")
print(f" {'banda':>8}{'Sh in-sample':>15}{'Sh hold-out':>14}")
sub = G[G["every"] == ev].sort_values("frac")
for _, r in sub.iterrows():
star = " <- proposta 27/07" if r["frac"] == BG.PROPOSTA[1] else ""
print(f" {r['frac']:>8.0%}{r['sh_in']:>15.4f}{r['sh_oos']:>14.4f}{star}")
blind = scelta(G, "sh_in", every=ev)
hold = scelta(G, "sh_oos", every=ev)
marg = scelta(G, "sh_in")
secondo = G[(G["every"] == ev) & (G["frac"] != blind["frac"])]["sh_in"].max()
print(f"\n banda scelta SUI SOLI DATI PRE-2015 : {blind['frac']:.0%}"
f" (2ª: margine {blind['sh_in'] - secondo:+.4f} di Sharpe)")
print(f" banda scelta SULL'HOLD-OUT : {hold['frac']:.0%}"
f" <- controllo positivo: se coincidesse, il gate non avrebbe potenza")
print(f" cella scelta al buio su TUTTA la griglia: cadenza {int(marg['every'])}, "
f"banda {marg['frac']:.0%}")
ok_blind = blind["frac"] == BG.PROPOSTA[1]
ok_diverso = hold["frac"] != BG.PROPOSTA[1]
print(f"\n -> la banda proposta e' quella scelta al buio in-sample : "
f"{'SI' if ok_blind else 'NO'}")
print(f" -> scegliendo sull'hold-out uscirebbe una banda DIVERSA : "
f"{'SI' if ok_diverso else 'NO'}")
if ok_blind and ok_diverso:
print("\n VERDETTO: la proposta e' l'ESATTO CONTRARIO di una selezione-sull'hold-out —")
print(" e' la cella che si sceglie senza guardare l'hold-out, e chi avesse guardato")
print(f" l'hold-out avrebbe scelto la banda {hold['frac']:.0%}, non quella proposta.")
elif not ok_blind:
print("\n VERDETTO: la banda proposta NON e' quella che si sceglie al buio -> la")
print(" validazione del 27/07 va rifatta sulla cella scelta in-sample.")
else:
print("\n VERDETTO: le due selezioni coincidono -> il gate non ha potenza su questa")
print(" griglia e non dice nulla sulla proposta (ne' a favore ne' contro).")
print(f"\n margine del blind sulla 2ª: {blind['sh_in'] - secondo:.4f} di Sharpe, "
f"contro i {abs(G['sh_in'].nlargest(9).iloc[-2] - G['sh_in'].nlargest(9).iloc[-1]):.5f}")
print(f" su cui si decideva il criterio a ranghi. NON e' lo stesso ordine di grandezza.")
print("\n ⚠️ Cio' che questo criterio NON dice: che la banda scelta in-sample sia la migliore")
print(" sull'hold-out. Non lo e' — e con lo Spearman IS/OOS a ~0 (sopra) nessuna cella di")
print(" questa griglia lo sarebbe in modo affidabile. Il gate (A) risponde alla domanda")
print(" sulla PROVENIENZA della scelta («l'hold-out l'ha contaminata?»), non a quella")
print(" sulla previsione. Confonderle e' esattamente il modo in cui si finisce a")
print(" selezionare sull'hold-out credendo di validare.")
return dict(blind=float(blind["frac"]), hold=float(hold["frac"]),
margine=float(blind["sh_in"] - secondo),
marg_every=int(marg["every"]), marg_frac=float(marg["frac"]),
ok=bool(ok_blind and ok_diverso))
def main() -> None:
print("=" * 104)
print(" r0807 — IL GATE (A) DELLA BANDA GTAA01 FALLISCE: e' la proposta o e' il criterio?")
print("=" * 104)
parte_0()
G6 = griglia(EQ_UNIVERSE)
r1 = parte_1(G6)
r2 = parte_2(G6, "sei gambe, universo di produzione")
# ------------------------------------------------------------------ (3)
sezione("(3) ROBUSTEZZA AL DIFETTO (0) — stesso conto sull'universo COERENTE fra le due finestre")
print(f"\n Universo a 5 gambe {SENZA_TLT}: nessuna gamba compare a meta' strada, quindi")
print(" in-sample e hold-out girano sulla STESSA strategia. Se il verdetto di (2) cambia,")
print(" il difetto dei dati non e' un dettaglio da nota a pie' di pagina.")
G5 = griglia(SENZA_TLT)
r2b = parte_2(G5, "cinque gambe, senza TLT")
p5 = G5[(G5["every"] == BG.PROPOSTA[0]) & (G5["frac"] == BG.PROPOSTA[1])].iloc[0]
print(f"\n (per confronto, il criterio a ranghi sull'universo coerente: "
f"{int(p5['rank_in'])}/{len(G5)} in-sample · {int(p5['rank_oos'])}/{len(G5)} hold-out "
f"-> {'PASS' if p5['rank_in'] <= p5['rank_oos'] else 'FAIL'})")
print(f" passa {int((G5['rank_in'] <= G5['rank_oos']).sum())}/{len(G5)} celle: "
"la moneta resta una moneta anche togliendo il difetto.")
# ------------------------------------------------------------------ verdetto
sezione("VERDETTO")
print(f"""
1. Il criterio `rank_in <= rank_oos` NON e' un gate di selezione: passa
{r1['passa']}/{r1['n']} celle della griglia per costruzione, e sulla proposta si decideva su
{r1['gap_in']:.5f} di Sharpe ({r1['gap_in'] / r1['spread']:.1%} dello spread). Il fallimento del 07/08 non e'
una scoperta sulla proposta: e' rumore che ha attraversato una soglia senza margine.
2. Il criterio decidibile dice l'opposto, e con margine: a cadenza di produzione la banda
scelta sui soli dati pre-2015 e' {r2['blind']:.0%} (= la proposta), con {r2['margine']:.4f} di Sharpe sulla
seconda; chi avesse scelto guardando l'hold-out avrebbe preso {r2['hold']:.0%}. La proposta e'
il contrario di una selezione-sull'hold-out.
3. Regge sull'universo coerente a 5 gambe: blind {r2b['blind']:.0%}, hold-out {r2b['hold']:.0%},
margine {r2b['margine']:.4f}. Il difetto (0) non e' cio' che decide questo verdetto — ma resta
un difetto, e va riparato per suo conto: TLT ha 10.5 anni invece di ~24, e nessuna
certificazione se n'e' accorta perche' nessuna guardava la LUNGHEZZA.
NON cambia nulla in produzione: `REBAL_BAND_USD` resta $50, GTAA01 resta non deployabile
(PRIIPs) e sotto GTAA_MIN_CAPITAL. Cambia il test, che ora misura una cosa decidibile, e
cambia l'affermazione del 27/07 in CLAUDE.md, che citava dei ranghi.
""")
if __name__ == "__main__":
main()
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env python
"""r0807_piano_netto.py — il piano di accumulo rifatto AL NETTO: muro, traiettorie, versamenti.
IL DEBITO. Il 07/08 `r0727_tasse.py` / `r0807_growth_yearly.py` hanno misurato che l'accumulo
composto al lordo sovrastima il capitale del **15.7% a 5 anni, 29.8% a 10, 42.6% a 15**. Il fatto
e' stato scritto in memoria ma i NUMERI del piano non sono stati rifatti — e sono numeri che sono
serviti a decidere: la traiettoria da $600 (\"€250/mese -> 16.2 anni, P(entro 20a) 92%\"), la
tabella \"quanto versare per un orizzonte dato\", la rendita €/g per livello di versamento.
E IL MURO STESSO NON E' NEUTRO. `$272.061` viene da una convenzione ASIMMETRICA: il prelievo
viene lordizzato (`50/g netti -> $27.375/anno lordi` a fisco 33%) ma il capitale che resta
investito compone **senza mai pagare imposte**, in accumulo come in prelievo. Le due meta' del
conto non usano lo stesso fisco. Qui si misura la convenzione COERENTE le imposte si pagano
dentro il portafoglio, ogni anno, e cio' che si preleva e' gia' netto — e si guarda di quanto il
muro si sposta. I due errori vanno in versi OPPOSTI (la lordizzazione alza il muro, l'assenza di
imposte in prelievo lo abbassa), quindi il segno del netto non e' deducibile: va misurato.
COSA C'E' QUI
(0) CONTROLLO DI REPLICA a fisco spento tutto deve riprodurre i numeri pubblicati. Senza
questo, un numero diverso non si distingue da un bug.
(1) IL MURO, nelle due convenzioni.
(2) TRAIETTORIE da $600 per livello di versamento, lorde vs nette, sugli STESSI path.
(3) QUANTO VERSARE per un orizzonte dato, al netto.
(4) RENDITA /g mediana per livello di versamento, al netto.
Le assunzioni fiscali sono quelle DICHIARATE il 27/07 e NON sono un parere fiscale: 33%
(sensibilita' a 26%), minusvalenze in carry 4 anni, patrimoniale 0.2%/anno, e il modello tassa la
variazione ANNUA di valore cioe' un LIMITE SUPERIORE rispetto alla pura realizzazione, vicino
al vero perche' TP01 ribilancia ogni giorno e SKH01 chiude round-trip discreti.
uv run python scripts/research/r0807_piano_netto.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0725_capcurve as CC # noqa: E402
import r0726_capwall_refresh as WR # noqa: E402
import r0726_deposits as DP # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
N_PATHS = 4000
BLOCK = 20
SEED = 20260807
SEED_MURO = 725 # lo stesso di CC.survival: consente la replica esatta
ALIQUOTA = CC.TAX_RATE # 0.33
VERSAMENTI = (0, 250, 500, 800, 1_000, 2_000)
ORIZZONTI = (10, 15, 20)
CONFIDENZE = (0.50, 0.75, 0.90, 0.95)
# ===========================================================================
# il passo fiscale, uno solo, riusato ovunque
# ===========================================================================
def passo_fiscale(cap: np.ndarray, anno_start: np.ndarray, flusso_anno: np.ndarray,
carry: np.ndarray, aliquota: float, patrimoniale: float) -> np.ndarray:
"""Imposte di fine anno, in loco su `cap`. Ritorna l'imposta pagata.
`flusso_anno` = versamenti MENO prelievi dell'anno: la plusvalenza e' la variazione di valore
depurata dai movimenti di cassa, quindi la stessa formula serve l'accumulo e il prelievo.
Identica meccanica a `r0807_growth_yearly.traiettoria` (carry a 4 anni, piu' vecchie prima).
"""
imposta = np.zeros(len(cap))
plus = cap - anno_start - flusso_anno
if aliquota > 0:
usate = np.minimum(np.maximum(plus, 0.0), carry.sum(axis=1))
imponibile = np.maximum(plus, 0.0) - usate
resid = usate.copy()
for k in range(carry.shape[1] - 1, -1, -1):
presa = np.minimum(carry[:, k], resid)
carry[:, k] -= presa
resid -= presa
dovuta = imponibile * aliquota
cap -= np.minimum(dovuta, cap)
imposta += dovuta
carry[:] = np.roll(carry, 1, axis=1)
carry[:, 0] = np.maximum(-plus, 0.0)
if patrimoniale > 0:
p = cap * patrimoniale
cap -= np.minimum(p, cap)
imposta += p
return imposta
# ===========================================================================
# (1) il muro: sopravvivenza in prelievo, con e senza fisco dentro
# ===========================================================================
def sopravvivenza(r: np.ndarray, cap0: float, prelievo_anno: float, anni: int,
aliquota: float, patrimoniale: float, n_paths: int = N_PATHS,
seed: int = SEED_MURO) -> dict:
"""Gemella di `CC.survival` con il fisco DENTRO il portafoglio.
Con aliquota=0 e patrimoniale=0 deve dare esattamente `CC.survival` e' il controllo che
rende credibile la riga col fisco (test `test_piano_netto.py`).
"""
rng = np.random.default_rng(seed)
n_days = int(anni * 365)
paths = CC._boot_paths(r, n_paths, n_days, BLOCK, rng)
cap = np.full(n_paths, cap0, float)
vivo = np.ones(n_paths, bool)
giorno = prelievo_anno / 365.0
carry = np.zeros((n_paths, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros(n_paths)
for t in range(n_days):
cap = np.where(vivo, cap * (1.0 + paths[:, t]) - giorno, cap)
flusso = np.where(vivo, flusso - giorno, flusso)
vivo &= cap > 0
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
vivi = vivo.copy()
passo_fiscale(cap, anno_start, flusso, carry, aliquota, patrimoniale)
cap = np.where(vivi, cap, 0.0)
vivo &= cap > 0
anno_start = cap.copy()
flusso[:] = 0.0
fine = np.where(vivo, cap, 0.0)
return dict(p_survive=float(vivo.mean()), median_end=float(np.median(fine)),
p10_end=float(np.percentile(fine, 10)))
def muro(r: np.ndarray, prelievo_netto_anno: float, aliquota: float,
patrimoniale: float) -> tuple[float, float]:
"""(tasso di rendita perpetua, capitale necessario). Perpetua = il prelievo piu' alto con
P(capitale a 20 anni >= capitale iniziale) >= 90% stessa definizione del 25/07."""
lo, hi = 0.0, 0.40
for _ in range(13):
mid = (lo + hi) / 2
s = sopravvivenza(r, 1e6, 1e6 * mid, 20, aliquota, patrimoniale)
if s["p10_end"] >= 1e6:
lo = mid
else:
hi = mid
return lo, (prelievo_netto_anno / lo if lo > 0.002 else float("inf"))
# ===========================================================================
# (2-4) accumulo
# ===========================================================================
def accumula(paths: np.ndarray, dep_eur: float, bersaglio: float, aliquota: float,
patrimoniale: float, start: float = LS.START,
lump_eur: float = 0.0) -> dict:
"""Accumulo su path GIA' ESTRATTI (confronto appaiato fra regimi fiscali e versamenti).
Ritorna il capitale finale, il giorno di primo passaggio al bersaglio e il totale versato.
"""
n_paths, n_days = paths.shape
dep = dep_eur * CC.EURUSD
cap = np.full(n_paths, start + lump_eur * CC.EURUSD)
versato = start + lump_eur * CC.EURUSD
carry = np.zeros((n_paths, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros(n_paths)
colpito = np.full(n_paths, -1, int)
for t in range(n_days):
cap *= (1.0 + paths[:, t])
if t % 30 == 0 and t > 0 and dep > 0:
cap += dep
flusso += dep
versato += dep
nuovi = (colpito < 0) & (cap >= bersaglio)
colpito[nuovi] = t
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
passo_fiscale(cap, anno_start, flusso, carry, aliquota, patrimoniale)
anno_start = cap.copy()
flusso[:] = 0.0
return dict(cap=cap, colpito=colpito, versato=versato)
def anni_al_traguardo(colpito: np.ndarray) -> tuple[float, float]:
"""(anni mediani al traguardo CONDIZIONATI all'arrivo, P(traguardo entro 20 anni)).
La mediana e' condizionata — e' la convenzione di `r0726_capwall_refresh.trajectory`, quindi
i due numeri sono confrontabili con la tabella pubblicata. Va letta insieme alla probabilita':
«15 anni» su meta' dei path che arrivano non e' «15 anni». Prendere la mediana sull'INTERO
vettore (con i non-arrivi a -1) darebbe un numero piu' BASSO quanto peggio va la colonna, cioe'
esattamente il contrario di cio' che si vuole leggere.
"""
arrivati = colpito[colpito >= 0]
med = float(np.percentile(arrivati, 50)) / 365.0 if len(arrivati) > 100 else float("nan")
entro20 = float(((colpito >= 0) & (colpito <= 20 * 365)).mean())
return med, entro20
def versamento_per_confidenza(paths: np.ndarray, bersaglio: float, conf: float,
aliquota: float, patrimoniale: float) -> float:
"""Il versamento mensile minimo con P(traguardo entro l'orizzonte) >= `conf`.
Bisezione: P e' monotona nel versamento."""
lo, hi = 0.0, 6_000.0
if accumula(paths, hi, bersaglio, aliquota, patrimoniale)["colpito"].__ge__(0).mean() < conf:
return float("inf")
for _ in range(12):
mid = (lo + hi) / 2
p = float((accumula(paths, mid, bersaglio, aliquota, patrimoniale)["colpito"] >= 0).mean())
if p >= conf:
hi = mid
else:
lo = mid
return hi
def sezione(t: str) -> None:
print("\n" + "=" * 100)
print(f" {t}")
print("=" * 100)
def main() -> None:
print("=" * 100)
print(" r0807 — IL PIANO AL NETTO: muro, traiettorie e versamenti col fisco d'accumulo dentro")
print("=" * 100)
r = DP.deluck_returns()
netto_anno = CC.TARGET_EUR_DAY * 365 * CC.EURUSD # €50/g NETTI, senza lordizzare
lordo_anno = netto_anno / (1 - ALIQUOTA)
print(f"\n book live de-luckato ×{DP.DELUCK} · {len(r)} giorni · drift {r.mean() * 365:.1%} · "
f"vol {r.std() * 365 ** 0.5:.1%}")
print(f" bersaglio di rendita: €{CC.TARGET_EUR_DAY:.0f}/g netti = ${netto_anno:,.0f}/anno "
f"(${lordo_anno:,.0f} se lordizzati al {ALIQUOTA:.0%})")
# ------------------------------------------------------------------ (0)
sezione("(0) CONTROLLO DI REPLICA — a fisco spento deve uscire il numero pubblicato")
perp_mio, muro_mio = muro(r, lordo_anno, 0.0, 0.0)
n_orig = WR.N_PATHS
_, perp_2k, muro_2k = WR.perp_and_wall(r, 1.0, lordo_anno) # default dello script, 2000
try:
WR.N_PATHS = N_PATHS
_, perp_4k, muro_4k = WR.perp_and_wall(r, 1.0, lordo_anno) # alla taglia pubblicata
finally:
WR.N_PATHS = n_orig
print(f"\n costante pubblicata (VR.TARGET, citata ovunque) :"
f"{'':>13}muro ${LS.TARGET:,.0f}")
print(f" questa implementazione, fisco OFF, {N_PATHS} path : perpetua {perp_mio:.4%} · "
f"muro ${muro_mio:,.0f}")
print(f" r0726_capwall_refresh ai suoi {n_orig} path : perpetua {perp_2k:.4%} · "
f"muro ${muro_2k:,.0f}")
print(f" r0726_capwall_refresh portato a {N_PATHS} path : perpetua {perp_4k:.4%} · "
f"muro ${muro_4k:,.0f}")
ok = abs(muro_mio - LS.TARGET) < 1.0 and abs(perp_mio - perp_4k) < 1e-9
print(f" -> {'REPLICA ESATTA' if ok else 'NON REPLICA — fermarsi qui'}: due implementazioni "
"separate, stesso dollaro")
print(f"\n ⚠️ Trovato per strada: `perp_and_wall` gira a {n_orig} path e a quella taglia da'")
print(f" ${muro_2k:,.0f} invece di ${LS.TARGET:,.0f}{abs(muro_2k / LS.TARGET - 1):.1%} di rumore Monte Carlo.")
print(" Il muro pubblicato e' corretto (fu calcolato a 4000), ma la sua TERZA cifra")
print(" significativa non e' un'informazione: si cita $272k, non $272.061.")
if not ok:
raise SystemExit("la replica non torna: qualunque numero col fisco sarebbe indistinguibile da un bug")
muro_pub, perp_pub = LS.TARGET, perp_mio
# ------------------------------------------------------------------ (1)
sezione("(1) IL MURO — la convenzione pubblicata e quella coerente")
print("\n (a) PUBBLICATA: prelievo lordizzato, capitale che compone senza mai pagare imposte.")
print(" (b) COERENTE : imposte pagate ogni anno dentro il portafoglio, prelievo gia' netto.")
print(f"\n {'convenzione':<34}{'prelievo/anno':>15}{'perpetua':>11}{'muro':>14}")
print(f" {'(a) pubblicata (fisco solo sul prelievo)':<34}{lordo_anno:>15,.0f}"
f"{perp_pub:>10.2%}{muro_pub:>14,.0f}")
righe = {}
for aliq in (ALIQUOTA, 0.26):
p, w = muro(r, netto_anno, aliq, TX.PATRIMONIALE)
righe[aliq] = (p, w)
print(f" {f'(b) coerente, aliquota {aliq:.0%}':<34}{netto_anno:>15,.0f}{p:>10.2%}{w:>14,.0f}")
perp_n, muro_n = righe[ALIQUOTA]
print(f"\n Il muro coerente e' ${muro_n:,.0f} contro ${muro_pub:,.0f} pubblicati "
f"({muro_n / muro_pub - 1:+.1%}).")
print(" I due errori della convenzione (a) si compensano quasi: lordizzare il prelievo")
print(" ALZA il muro, non tassare il capitale investito lo ABBASSA. Restano scoperti la")
print(f" patrimoniale ({TX.PATRIMONIALE:.1%}/anno) e la non-linearita' della rendita perpetua")
print(" nel drift, che e' cio' che si vede nella differenza.")
# ------------------------------------------------------------------ (2)
sezione(f"(2) TRAIETTORIE DA ${LS.START:.0f} — anni al traguardo, lordo vs netto, sugli STESSI path")
anni = 25 # come `r0726_capwall_refresh.trajectory`: 20 troncherebbe la coda
rng = np.random.default_rng(725) # stesso seed della tabella
paths = CC._boot_paths(r, 3_000, anni * 365, BLOCK, rng) # e stessa taglia
print(f"\n orizzonte {anni} anni · 3000 path · seed 725 — cioe' ESATTAMENTE la macchina che")
print(" ha prodotto la tabella pubblicata: la colonna LORDA e' una replica di controllo.")
print(f" (LORDO usa il muro pubblicato ${muro_pub:,.0f}, NETTO quello coerente ${muro_n:,.0f})")
print(f"\n {'€/mese':>8}{'versato in 20a':>16} |{'LORDO: anni':>13}{'P(20a)':>9}"
f" |{'NETTO: anni':>13}{'P(20a)':>9}{'ritardo':>10}")
tab2 = []
for dep in VERSAMENTI:
L = accumula(paths, dep, muro_pub, 0.0, 0.0)
N = accumula(paths, dep, muro_n, ALIQUOTA, TX.PATRIMONIALE)
aL, pL = anni_al_traguardo(L["colpito"])
aN, pN = anni_al_traguardo(N["colpito"])
rit = f"{aN - aL:+.1f}a" if not (np.isnan(aL) or np.isnan(aN)) else ""
f = lambda a: f"{a:.1f}a" if not np.isnan(a) else "mai" # noqa: E731
versato_20a = LS.START + dep * CC.EURUSD * (20 * 365 // 30)
print(f" {dep:>8}{versato_20a:>16,.0f} |{f(aL):>13}{pL:>9.0%} "
f"|{f(aN):>13}{pN:>9.0%}{rit:>10}")
tab2.append(dict(dep=dep, anni_lordo=aL, p_lordo=pL, anni_netto=aN, p_netto=pN))
print("\n ⚠️ La colonna «anni» e' la mediana CONDIZIONATA all'arrivo: va letta insieme alla")
print(" probabilita' accanto. Dove P scende molto (€250/mese al netto) l'anno mediano")
print(" resta basso perche' descrive solo chi arriva — non e' un miglioramento.")
# ------------------------------------------------------------------ (3)
sezione("(3) QUANTO VERSARE PER UN ORIZZONTE DATO — al netto")
print(f"\n bersaglio ${muro_n:,.0f} · versamento mensile minimo per la confidenza indicata")
print(f"\n {'orizzonte':>10}" + "".join(f"{f'P={c:.0%}':>12}" for c in CONFIDENZE)
+ f"{'tot. versato @P=90%':>22}")
for anni_h in ORIZZONTI:
rng_h = np.random.default_rng(SEED)
ph = CC._boot_paths(r, N_PATHS, anni_h * 365, BLOCK, rng_h)
celle, tot90 = [], 0.0
for c in CONFIDENZE:
v = versamento_per_confidenza(ph, muro_n, c, ALIQUOTA, TX.PATRIMONIALE)
celle.append(f"{v:,.0f}/m" if np.isfinite(v) else "irraggiung.")
if c == 0.90 and np.isfinite(v):
tot90 = accumula(ph, v, muro_n, ALIQUOTA, TX.PATRIMONIALE)["versato"]
print(f" {f'{anni_h} anni':>10}" + "".join(f"{x:>12}" for x in celle)
+ f"{f'${tot90:,.0f}':>22}")
# ------------------------------------------------------------------ (4)
sezione("(4) RENDITA €/g MEDIANA — al netto, per livello di versamento")
print(f"\n rendita = capitale × perpetua({perp_n:.2%}); le imposte sono gia' dentro la")
print(" perpetua, quindi il numero e' NETTO e non va lordizzato una seconda volta.")
print(f"\n {'€/mese':>8}" + "".join(f"{f'{y}a':>12}" for y in (5, 10, 15, 20))
+ f"{'P(€50/g a 20a)':>17}")
for dep in VERSAMENTI:
celle, p50 = [], 0.0
for y in (5, 10, 15, 20):
rng_y = np.random.default_rng(SEED)
py = CC._boot_paths(r, N_PATHS, y * 365, BLOCK, rng_y)
cap = accumula(py, dep, muro_n, ALIQUOTA, TX.PATRIMONIALE)["cap"]
rend = cap * perp_n / 365.0 / CC.EURUSD
celle.append(f"{np.median(rend):,.2f}")
if y == 20:
p50 = float((rend >= CC.TARGET_EUR_DAY).mean())
print(f" {dep:>8}" + "".join(f"{x:>12}" for x in celle) + f"{p50:>17.1%}")
# ------------------------------------------------------------------ verdetto
sezione("VERDETTO — cosa cambia nelle conclusioni gia' usate per decidere")
d250 = next(x for x in tab2 if x["dep"] == 250)
d500 = next(x for x in tab2 if x["dep"] == 500)
print(f"""
1. Il MURO si sposta poco: ${muro_n:,.0f} contro ${muro_pub:,.0f} ({muro_n / muro_pub - 1:+.1%}).
La convenzione pubblicata sbagliava DUE volte in versi opposti e le due meta' quasi si
cancellano ma per caso, non per costruzione: e' il tipo di errore che si scopre solo
rifacendo il conto in modo coerente.
2. E' sulle TRAIETTORIE che il fisco morde, e si legge nella PROBABILITA' piu' che negli anni:
250/mese passa da P(entro 20a) {d250['p_lordo']:.0%} a {d250['p_netto']:.0%} (mediana condizionata
{d250['anni_lordo']:.1f} -> {d250['anni_netto']:.1f} anni); 500/mese da {d500['p_lordo']:.0%} a {d500['p_netto']:.0%}
({d500['anni_lordo']:.1f} -> {d500['anni_netto']:.1f} anni). Il livello di versamento che il 26/07 dava come
«P 92%, il piano funziona» e' quello che al netto diventa una moneta.
3. Cio' che NON cambia: senza versamenti il capitale-rendita non si raggiunge mai, a
qualunque lente fiscale. L'ordine di importanza delle leve (versare > quando > quanto
presto si smette > piatto vs crescente > frequenza) e' invariato, perche' il fisco
colpisce tutte le colonne allo stesso modo.
""")
if __name__ == "__main__":
main()
+141
View File
@@ -0,0 +1,141 @@
"""Guardie sulla STORIA del feed equity (scripts/research/fetch_ib_equities.py::certify).
Trovate il 2026-08-07 indagando un test di GTAA01 che aveva smesso di passare: TLT una delle
sei gambe dello sleeve parte dal 2016-02-03 invece che dalla sua quotazione (2002-07-22), e
nessuna certificazione se n'era accorta in sei settimane. Il motivo e' strutturale e vale come
regola: **tutti i controlli esistenti guardano DENTRO la serie** (integrita', gap, spike, split,
cross-check col gemello) e nessuno guarda cio' che la serie NON contiene. Una serie troncata e'
perfettamente integra.
Due guardie, perche' i due difetti non si vedono nello stesso modo:
- `TRONCATO` la serie ha perso storia RISPETTO AL DISCO. Prende la troncatura il giorno in
cui compare; e' cieca a una che c'era gia'.
- `STORIA-CORTA` la serie parte molto dopo la quotazione dello strumento. Prende anche una
troncatura presente da sempre che e' il caso di TLT.
Controlli positivi obbligatori: una guardia che non segnala mai e' indistinguibile da una rotta.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import fetch_ib_equities as F # noqa: E402
def serie(primo: str, ultimo: str, seed: int = 0) -> pd.DataFrame:
"""OHLCV pulito su giorni lavorativi: nessun difetto tranne quello che il test inietta."""
idx = pd.bdate_range(primo, ultimo)
rng = np.random.default_rng(seed)
c = 100.0 * np.exp(np.cumsum(rng.normal(0.0002, 0.008, len(idx))))
return pd.DataFrame({"open": c, "high": c * 1.004, "low": c * 0.996,
"close": c, "volume": 1e6}, index=idx)
# ===========================================================================
# TRONCATO — regressione rispetto alla versione su disco
# ===========================================================================
def test_una_serie_che_perde_storia_e_TRONCATA():
vecchia = serie("2002-07-22", "2026-08-06")
nuova = serie("2016-02-03", "2026-08-06")
assert F.certify("TLT", nuova, vecchia)["status"] == "TRONCATO"
def test_una_serie_che_perde_barre_in_mezzo_e_TRONCATA():
"""Stesso inizio, ma meno barre: la troncatura non e' sempre in testa."""
vecchia = serie("2010-01-04", "2026-08-06")
nuova = vecchia.iloc[:-400].copy() # stessa prima barra, 400 barre in meno
assert F.certify("SPY", nuova, vecchia)["status"] == "TRONCATO"
def test_il_giro_normale_di_ogni_notte_NON_e_una_troncatura():
"""Controllo negativo: la serie si allunga di un giorno, come ogni notte."""
vecchia = serie("2010-01-04", "2026-08-05")
nuova = serie("2010-01-04", "2026-08-06")
assert F.certify("SPY", nuova, vecchia)["status"] != "TRONCATO"
def test_qualche_barra_in_meno_non_fa_scattare_la_guardia():
"""Tolleranza dichiarata: IB restituisce ogni tanto una manciata di barre in meno senza
che sia una perdita di storia. La soglia e' 5 barre; se serve cambiarla, si cambia qui."""
vecchia = serie("2010-01-04", "2026-08-06")
nuova = vecchia.iloc[:-3].copy()
assert F.certify("SPY", nuova, vecchia)["status"] != "TRONCATO"
def test_senza_versione_su_disco_la_guardia_non_puo_scattare():
"""Retrocompatibilita': `certify(sym, df)` deve continuare a funzionare — ma va detto che
senza `prev` questa guardia e' cieca per costruzione."""
nuova = serie("2016-02-03", "2026-08-06")
c = F.certify("SPY", nuova)
assert c["status"] != "TRONCATO"
assert c["persi_g"] == 0
# ===========================================================================
# STORIA-CORTA — distanza dalla quotazione dello strumento
# ===========================================================================
def test_TLT_dal_2016_e_STORIA_CORTA():
"""Il caso reale: 13.5 anni di storia che esistono e che il feed non da'."""
c = F.certify("TLT", serie("2016-02-03", "2026-08-06"))
assert c["status"] == "STORIA-CORTA"
assert c["manca_a"] == pytest.approx(13.5, abs=0.2)
def test_una_serie_al_tetto_della_richiesta_NON_e_corta():
"""Controllo negativo decisivo: SPY e' quotato dal 1993 ma la richiesta chiede 30 anni, quindi
partire dal 1996 e' il CAP, non un difetto. Senza questa distinzione la guardia segnalerebbe
ogni serie lunga e verrebbe ignorata."""
c = F.certify("SPY", serie("1996-08-14", "2026-08-06"))
assert c["status"] == "OK"
assert c["manca_a"] == 0.0
def test_uno_strumento_senza_riferimento_non_viene_giudicato():
"""`PRIMA_QUOTAZIONE` copre le sei gambe di GTAA01. Su un simbolo fuori tabella la guardia
tace invece di inventarsi una data."""
assert "XLK" not in F.PRIMA_QUOTAZIONE
c = F.certify("XLK", serie("2016-01-04", "2026-08-06"))
assert c["status"] == "OK"
assert c["manca_a"] == 0.0
def test_la_quotazione_di_un_ETF_giovane_non_e_un_difetto():
"""HYG parte dal 2007 perche' e' del 2007: la guardia non deve confondere giovane con troncato."""
c = F.certify("HYG", serie("2007-04-11", "2026-08-06"))
assert c["status"] == "OK"
def test_la_troncatura_ha_priorita_sulla_storia_corta():
"""Se una serie e' ENTRAMBE le cose, quella che si riporta e' la piu' urgente: la perdita di
oggi (che si puo' ancora non salvare su disco), non il limite storico."""
vecchia = serie("2002-07-22", "2026-08-06")
nuova = serie("2016-02-03", "2026-08-06")
assert F.certify("TLT", nuova, vecchia)["status"] == "TRONCATO"
# ===========================================================================
# lo stato REALE dello sleeve, congelato
# ===========================================================================
def test_GTAA01_gira_su_cinque_gambe_prima_del_2016():
"""Non e' un test sul codice, e' un test sul FATTO — e sul suo effetto: l'hold-out equity di
GTAA01 (2015+) contiene la gamba obbligazionaria e l'in-sample no, quindi ogni confronto
in-sample/hold-out su questo sleeve confronta due strategie diverse.
Se un giorno IB servisse la storia piena di TLT questo test si rompe: e' voluto. Va rilette
la nota in CLAUDE.md e rifatto `r0807_gtaa_gate_resolution.py`, che qui e' documentato come
misurato SU CINQUE GAMBE prima del 2016.
"""
from src.portfolio.gtaa import EQ_UNIVERSE, _close
taglio = pd.Timestamp("2015-01-01", tz="UTC")
vive = [a for a in EQ_UNIVERSE if _close(a).index[0] < taglio]
assert len(vive) == 5 and "TLT" not in vive, (
f"gambe vive pre-2015: {vive} — se sono tornate 6, rileggi la nota GTAA01 in CLAUDE.md")
+71 -12
View File
@@ -4,9 +4,19 @@ Congela i tre esiti del gate e — soprattutto — l'ANNUALIZZAZIONE. Una serie
passata a un annualizzatore a 365 esce con lo Sharpe ×1.20 e il CAGR ×1.45: e' l'errore del
25/07, ed e' l'unico difetto qui che produrrebbe numeri sbagliati ma plausibili.
`test_la_proposta_non_e_selezionata_sull_hold_out` e' il test di metodo: se un giorno la cella
proposta risultasse in rango MIGLIORE sull'hold-out che in-sample, sarebbe la firma della
selezione-sull'hold-out e la validazione andrebbe rifatta.
Il test di METODO e' `test_la_banda_proposta_e_quella_scelta_al_buio` (+ il suo controllo
positivo): se un giorno la banda che si sceglie sui soli dati pre-2015 non fosse piu' il 25%, la
validazione del 27/07 andrebbe rifatta su quella cella.
2026-08-07 CRITERIO SOSTITUITO. Fino a oggi il gate (A) era congelato come
`rank_in <= rank_oos` sulla proposta, e ha iniziato a fallire (9ª in-sample, 8ª sull'hold-out)
senza che nessuno toccasse il codice: `data/raw/` e' gitignored e IB rivede `ADJUSTED_LAST`
all'indietro ogni notte. Misurato in `scripts/research/r0807_gtaa_gate_resolution.py`, quel
criterio non poteva reggere: lo passa ~meta' della griglia PER COSTRUZIONE (la somma dei ranghi
e' la stessa nelle due finestre) e sulla proposta si decideva su 0.001 di Sharpe contro uno
spread di griglia di 0.31. Il motivo del ritiro e' congelato in
`test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO` non l'esito,
che dipende dai dati e si muove da solo.
"""
from __future__ import annotations
@@ -76,9 +86,8 @@ def test_la_produzione_usa_ancora_la_banda_ASSOLUTA():
# ===========================================================================
# (A) selezione — il gate di metodo
# ===========================================================================
def test_la_proposta_non_e_selezionata_sull_hold_out():
"""Rango in-sample <= rango hold-out: la proposta non migliora passando all'hold-out.
Il contrario sarebbe la firma della selezione-sull'hold-out."""
@pytest.fixture(scope="module")
def griglia_piena():
rows = []
for every in BG.EVERY_GRID:
for frac in BG.FRAC_GRID:
@@ -87,12 +96,62 @@ def test_la_proposta_non_e_selezionata_sull_hold_out():
sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"]))
G = pd.DataFrame(rows)
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
rank_in = int((G["sh_in"] > p["sh_in"]).sum()) + 1
rank_oos = int((G["sh_oos"] > p["sh_oos"]).sum()) + 1
assert rank_in <= rank_oos, (
f"la proposta e' {rank_oos}a sull'hold-out ma {rank_in}a in-sample: "
"sta meglio dove non doveva essere guardata")
G["rank_in"] = G["sh_in"].rank(ascending=False, method="min").astype(int)
G["rank_oos"] = G["sh_oos"].rank(ascending=False, method="min").astype(int)
return G
def test_la_banda_proposta_e_quella_scelta_al_buio(griglia_piena):
"""Il gate (A), nella forma decidibile (2026-08-07).
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'
«a cadenza di produzione, quale banda si sceglie guardando SOLO il pre-2015?».
"""
G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]]
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
assert blind["frac"] == BG.PROPOSTA[1], (
f"al buio si sceglierebbe la banda {blind['frac']:.0%}, non {BG.PROPOSTA[1]:.0%}: "
"la validazione del 27/07 andrebbe rifatta su quella cella")
def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
"""CONTROLLO POSITIVO del test precedente. Se le due selezioni coincidessero, «la proposta e'
la scelta in-sample» sarebbe vero anche per una proposta selezionata sull'hold-out, e il gate
non direbbe nulla. Un gate che non puo' fallire e' indistinguibile da uno rotto."""
G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]]
hold = riga.sort_values("sh_oos", ascending=False).iloc[0]
assert hold["frac"] != BG.PROPOSTA[1], (
"in-sample e hold-out scelgono la stessa banda: su questa griglia il gate (A) non ha "
"potenza e non va citato come validazione")
def test_il_margine_del_blind_non_e_un_arrotondamento(griglia_piena):
"""La ragione per cui questo criterio ha sostituito il confronto fra ranghi: decide su
~0.015 di Sharpe invece che su ~0.001. Se il margine scendesse sotto un centesimo, anche
questo criterio smetterebbe di essere una misura e andrebbe ripensato a sua volta."""
G = griglia_piena
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):
"""Congela il MOTIVO del ritiro, non l'esito (che dipende dai dati e cambia da solo).
Fino al 2026-08-07 il gate (A) asseriva `rank_in <= rank_oos` sulla proposta. Quel criterio
non puo' distinguere una proposta onesta da una selezionata sull'hold-out: la somma dei
ranghi e' la stessa nelle due finestre, quindi lo passa circa META' della griglia per
costruzione, qualunque cosa contenga. Se qualcuno lo rimettesse, questo test spiega perche' no.
"""
G = griglia_piena
passa = int((G["rank_in"] <= G["rank_oos"]).sum())
assert 0.3 * len(G) <= passa <= 0.7 * len(G), (
f"passa {passa}/{len(G)} celle: se fosse molto lontano da meta', il criterio "
"porterebbe informazione e questa motivazione andrebbe riletta")
def test_l_hold_out_usato_e_quello_documentato_di_gtaa01():
+170
View File
@@ -0,0 +1,170 @@
"""Il piano di accumulo al NETTO del fisco (scripts/research/r0807_piano_netto.py).
Il numero che questo script deve poter cambiare le traiettorie e i versamenti pubblicati e'
stato usato per decidere. Percio' l'ordine dei test e' quello del metodo: prima si dimostra che
la macchina riproduce cio' che sostituisce (a fisco spento), poi si guarda cosa dice col fisco
acceso. Un numero nuovo prodotto da una macchina non validata e' indistinguibile da un bug.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0725_capcurve as CC # noqa: E402
import r0727_lumpsum_split as LS # noqa: E402
import r0727_tasse as TX # noqa: E402
import r0807_piano_netto as PN # noqa: E402
def carry_vuoto(n: int) -> np.ndarray:
return np.zeros((n, TX.CARRY_ANNI))
# ===========================================================================
# il passo fiscale
# ===========================================================================
def test_una_plusvalenza_viene_tassata_all_aliquota():
cap = np.array([110.0])
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == pytest.approx(10 * 0.33)
assert cap[0] == pytest.approx(110 - 3.3)
def test_i_versamenti_dell_anno_non_sono_plusvalenza():
"""Chi versa €1.000 e finisce l'anno con €1.000 in piu' non ha guadagnato nulla."""
cap = np.array([1_100.0])
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([1_000.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == 0.0
assert cap[0] == 1_100.0
def test_i_prelievi_dell_anno_non_sono_minusvalenza():
"""Simmetrico del precedente, ed e' il motivo per cui la stessa funzione serve accumulo e
prelievo: chi preleva 100 e chiude pari ha guadagnato 100, non zero."""
cap = np.array([1_000.0])
imp = PN.passo_fiscale(cap, np.array([1_000.0]), np.array([-100.0]), carry_vuoto(1), 0.33, 0.0)
assert imp[0] == pytest.approx(100 * 0.33)
def test_una_minusvalenza_non_produce_credito_ma_carry():
cap = np.array([90.0])
c = carry_vuoto(1)
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
assert imp[0] == 0.0 and cap[0] == 90.0
assert c[0, 0] == pytest.approx(10.0)
def test_il_carry_abbatte_l_imponibile_dell_anno_dopo():
cap = np.array([90.0])
c = carry_vuoto(1)
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # -10
cap[0] = 120.0
imp = PN.passo_fiscale(cap, np.array([90.0]), np.array([0.0]), c, 0.33, 0.0) # +30, -10 di carry
assert imp[0] == pytest.approx(20 * 0.33)
def test_il_carry_scade_dopo_quattro_anni():
c = carry_vuoto(1)
cap = np.array([90.0])
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # minus di 10
for _ in range(TX.CARRY_ANNI): # 4 anni in pari
cap[0] = 100.0
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
cap[0] = 130.0
imp = PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0)
assert imp[0] == pytest.approx(30 * 0.33), "la minusvalenza doveva essere scaduta"
def test_le_minusvalenze_piu_vecchie_si_usano_per_prime():
"""Se si consumassero le piu' recenti, quelle vecchie scadrebbero e il modello sarebbe
pessimista sul fisco senza dirlo."""
c = carry_vuoto(1)
c[0, 3] = 10.0 # vecchia, all'ultimo anno di utilizzo
c[0, 0] = 10.0 # recente
cap = np.array([105.0])
PN.passo_fiscale(cap, np.array([100.0]), np.array([0.0]), c, 0.33, 0.0) # +5 di plusvalenza
# a fine passo le colonne SLITTANO di un anno: la recente e' in posizione 1, intatta
assert c[0, 1] == pytest.approx(10.0), "doveva restare intatta la piu' recente"
# la vecchia e' stata consumata per 5 e il residuo e' scaduto insieme a lei: e' corretto,
# era al suo ultimo anno — ma va detto, perche' un residuo che sparisce sembra una perdita.
assert c[0].sum() == pytest.approx(10.0)
def test_la_patrimoniale_colpisce_il_valore_non_il_guadagno():
cap = np.array([1_000.0])
imp = PN.passo_fiscale(cap, np.array([1_000.0]), np.array([0.0]), carry_vuoto(1), 0.0, 0.002)
assert imp[0] == pytest.approx(2.0)
def test_le_imposte_non_portano_il_capitale_sotto_zero():
cap = np.array([1.0])
PN.passo_fiscale(cap, np.array([0.0]), np.array([0.0]), carry_vuoto(1), 0.33, 0.9)
assert cap[0] >= 0.0
# ===========================================================================
# le due macchine, validate contro cio' che sostituiscono
# ===========================================================================
def test_la_sopravvivenza_a_fisco_spento_e_quella_gia_in_uso():
"""Controllo di replica: `sopravvivenza(...,0,0)` deve essere `CC.survival` allo stesso seed.
E' l'unica cosa che rende leggibile la riga col fisco."""
r = np.array([0.001, -0.002, 0.003, 0.0, -0.001, 0.002] * 200)
mia = PN.sopravvivenza(r, 1e6, 60_000.0, 20, 0.0, 0.0, n_paths=400)
sua = CC.survival(r, 1e6, 60_000.0, 20, 1.0, n_paths=400, block=PN.BLOCK, seed=PN.SEED_MURO)
assert mia["p_survive"] == pytest.approx(sua["p_survive"], abs=0.0)
assert mia["p10_end"] == pytest.approx(sua["p10_end"], rel=1e-12)
def test_l_accumulo_a_fisco_spento_e_capitalizzazione_semplice():
paths = np.full((3, 400), 0.001)
out = PN.accumula(paths, 0.0, 1e12, 0.0, 0.0, start=1_000.0)
assert out["cap"][0] == pytest.approx(1_000.0 * 1.001 ** 400)
assert out["versato"] == 1_000.0
def test_il_fisco_non_puo_far_finire_con_PIU_soldi():
"""Direzione garantita: qualunque siano i path, tassare non arricchisce."""
rng = np.random.default_rng(0)
paths = rng.normal(0.0006, 0.006, (200, 365 * 3))
lordo = PN.accumula(paths, 250.0, 1e12, 0.0, 0.0)["cap"]
netto = PN.accumula(paths, 250.0, 1e12, 0.33, TX.PATRIMONIALE)["cap"]
assert (netto <= lordo + 1e-6).all()
def test_il_primo_passaggio_al_bersaglio_e_il_PRIMO():
"""`colpito` e' un primo passaggio: se il capitale tocca il bersaglio e poi scende, il
traguardo resta raggiunto e non va aggiornato a un passaggio successivo."""
paths = np.concatenate([np.full((1, 10), 0.5), np.full((1, 10), -0.5)], axis=1)
out = PN.accumula(paths, 0.0, 2_000.0, 0.0, 0.0, start=1_000.0)
assert out["colpito"][0] == 1 # 1000 -> 1500 (t=0) -> 2250 (t=1), poi crolla e resta 1
def test_versamento_per_confidenza_e_monotono():
rng = np.random.default_rng(1)
paths = rng.normal(0.0006, 0.006, (300, 365 * 5))
v50 = PN.versamento_per_confidenza(paths, 60_000.0, 0.50, 0.33, TX.PATRIMONIALE)
v90 = PN.versamento_per_confidenza(paths, 60_000.0, 0.90, 0.33, TX.PATRIMONIALE)
assert v90 > v50 > 0
def test_un_bersaglio_irraggiungibile_ritorna_infinito_non_un_numero_grande():
"""Distinguere «serve tantissimo» da «non basta nemmeno il massimo cercato»: un numero
grande verrebbe letto come una risposta."""
paths = np.zeros((50, 365))
assert PN.versamento_per_confidenza(paths, 1e12, 0.9, 0.0, 0.0) == float("inf")
# ===========================================================================
# la costante pubblicata
# ===========================================================================
def test_il_bersaglio_citato_ovunque_e_quello_congelato():
"""Se qualcuno ricalcola il muro e aggiorna la costante, questo test dice che vanno rilette
tutte le tabelle che la usano non e' un numero locale."""
assert LS.TARGET == 272_061.0