Compare commits
3 Commits
ba5ea2f5c3
...
3bc620e914
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bc620e914 | |||
| 02e0cf775f | |||
| 963776e5d2 |
@@ -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%)** |
|
| €500 | 19.6a (48%) | 15.7a (94%) | **12.4a (100%)** |
|
||||||
| €1000 | 14.8a (95%) | 12.0a (100%) | **9.0a (100%)** |
|
| €1000 | 14.8a (95%) | 12.0a (100%) | **9.0a (100%)** |
|
||||||
| €2000 | 10.0a (100%) | 8.5a (100%) | **6.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
|
✅ 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
|
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
|
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** |
|
| **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 |
|
| 15 anni | €313/m | €408/m | €509/m | €583/m | $101.643 |
|
||||||
| 20 anni | €129/m | €180/m | €237/m | €280/m | $63.406 |
|
| 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
|
⚠️ **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
|
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
|
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
|
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
|
guardando il risultato e' selezione come ogni altra** e passa per gli stessi gate, anche quando
|
||||||
"non tocca l'allocazione".
|
"non tocca l'allocazione".
|
||||||
⚠️ **QUESTO GATE OGGI FALLISCE (2026-08-07) e la sua misura NON e' riproducibile.**
|
⚠️ **CORREZIONE 2026-08-07 — il criterio (A) qui sopra e' RITIRATO: non misurava cio' che
|
||||||
`test_la_proposta_non_e_selezionata_sull_hold_out`: la proposta e' **9ª/30 in-sample e 8ª/30
|
dichiarava, e il "4/30 in-sample, 5/30 hold-out" non e' un'evidenza.** Il test lo aveva
|
||||||
sull'hold-out** (migliora dove non doveva essere guardata) contro il **4/30 e 5/30** registrato
|
segnalato smettendo di passare (9ª/30 in-sample, 8ª/30 hold-out) **senza che il codice fosse
|
||||||
sopra. Caratterizzato: i due ranghi distano **0.00116 di Sharpe** su un'ampiezza di griglia di
|
cambiato** — `data/raw/` e' gitignored e il cron riscrive i parquet equity ogni notte con
|
||||||
0.3124 (**0.4%**) → *il criterio non ha mai avuto margine, era un rango*; il calcolo e'
|
`ADJUSTED_LAST` di IB, che e' retroattivo. Misurato in `r0807_gtaa_gate_resolution.py`:
|
||||||
**deterministico** (2 corse, `max|diff| = 0.0`) e il codice e' **invariato** dal 27/07 (nessun
|
**(a)** i due ranghi distavano **0.00116 di Sharpe** su uno spread di griglia di **0.3124**
|
||||||
commit su `r0727_gtaa_band_gate.py`, `gtaa.py`, `eqlib.py`, `eq_splits.py`) → **e' cambiato il
|
(0.4%); **(b)** e soprattutto **il criterio `rank_in <= rank_oos` lo passano 14/30 celle
|
||||||
DATO**: `data/raw/` e' gitignored e i parquet equity sono riscritti ogni giorno dal cron con
|
(47%) PER COSTRUZIONE** — la somma dei ranghi e' la stessa nelle due finestre → *e' una moneta,
|
||||||
`ADJUSTED_LAST` di IB, che e' retroattivo.
|
e il 27/07 la moneta era uscita bene*. Contorno: spostamento tipico fra le due finestre **8
|
||||||
📌 **REGOLA NUOVA: un gate validato su dati SOVRASCRITTI ogni giorno non e' ri-verificabile.**
|
ranghi** (max 25), **Spearman IS/OOS +0.05**.
|
||||||
Il lato cripto non ha il problema (`rebuild_history.py` ricostruisce da sorgente deterministica),
|
✅ **CRITERIO SOSTITUITO, e la proposta ne esce piu' forte di prima.** La proposta e' una
|
||||||
il lato equity si'. **Test NON toccato** (allentare una soglia perche' ha smesso di passare e'
|
**BANDA** (la cadenza settimanale e' gia' `REBAL_EVERY=5` in produzione), quindi la domanda
|
||||||
proprio cio' che questo progetto vieta; un `xfail` silenzierebbe il segnale). Niente di operativo
|
decidibile e' *quale banda si sceglie a cadenza di produzione guardando solo il pre-2015*:
|
||||||
dipende da questo — GTAA01 non e' deployabile (PRIIPs) e non e' nel book live — ma
|
esce **25% = la proposta**, con margine **+0.0151** di Sharpe sulla seconda (**13×** il margine
|
||||||
**l'affermazione "il rango NON migliora sull'hold-out" oggi e' falsa**: decisione dell'operatore
|
del vecchio criterio); chi avesse scelto **sull'hold-out** avrebbe preso **40%** (controllo
|
||||||
se correggerla, ripensare il gate, o congelare uno snapshot dei parquet equity.
|
positivo: se coincidessero il gate non avrebbe potenza). Su **tutta** la griglia la cella al
|
||||||
Diario `2026-08-07-crescita-fisco-etf-scelta.md` §7.
|
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
|
- 💰 **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
|
(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
|
(`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º,
|
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
|
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`.
|
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
|
- ⚖️ **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`;
|
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.**
|
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'.
|
||||||
@@ -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.
|
||||||
@@ -38,8 +38,20 @@ BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
|||||||
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
|
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
|
||||||
UNIVERSE = SECTORS + BROAD + BROAD2
|
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:
|
if df.empty:
|
||||||
return {"sym": sym, "n": 0, "status": "VUOTO"}
|
return {"sym": sym, "n": 0, "status": "VUOTO"}
|
||||||
idx = df.index
|
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).
|
# Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday).
|
||||||
splits = detect_unadjusted_splits(df)
|
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"
|
status = "OK"
|
||||||
if dup or not mono:
|
if dup or not mono:
|
||||||
status = "INTEGRITA'"
|
status = "INTEGRITA'"
|
||||||
|
elif tronca:
|
||||||
|
status = "TRONCATO" # il feed ha PERSO storia rispetto a ieri
|
||||||
elif splits:
|
elif splits:
|
||||||
status = "SPLIT-NON-AGG"
|
status = "SPLIT-NON-AGG"
|
||||||
elif maxret > 0.5:
|
elif maxret > 0.5:
|
||||||
status = "SPIKE?"
|
status = "SPIKE?"
|
||||||
elif longgap > 0:
|
elif longgap > 0:
|
||||||
status = "GAP-LUNGO"
|
status = "GAP-LUNGO"
|
||||||
|
elif corta >= 1.0:
|
||||||
|
status = "STORIA-CORTA" # meno storia di quella che lo strumento HA
|
||||||
elif span_y < 1:
|
elif span_y < 1:
|
||||||
status = "corto<1y"
|
status = "corto<1y"
|
||||||
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
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,
|
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
|
||||||
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
|
"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]}
|
"splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]}
|
||||||
|
|
||||||
|
|
||||||
@@ -115,7 +151,7 @@ def main():
|
|||||||
continue
|
continue
|
||||||
con = Stock(sym, "SMART", "USD")
|
con = Stock(sym, "SMART", "USD")
|
||||||
try:
|
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)
|
whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1, timeout=60)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" {sym:5} ERR {repr(e)[:70]}"); rep.append({"sym": sym, "status": "ERR"}); time.sleep(1.2); continue
|
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
|
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],
|
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()
|
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)
|
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:
|
if c.get("n", 0) > 0:
|
||||||
out = df.copy()
|
out = df.copy()
|
||||||
# ms epoch (come i parquet crypto), robusto alla risoluzione datetime64 (s/us/ns)
|
# 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',''))} "
|
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"{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"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
|
time.sleep(1.2) # pacing IB
|
||||||
|
|
||||||
print("-" * 104)
|
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}")
|
print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}")
|
||||||
sec_ok = [s for s in SECTORS if s in ok]
|
sec_ok = [s for s in SECTORS if s in ok]
|
||||||
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
|
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
|
||||||
|
|||||||
@@ -148,9 +148,24 @@ def main() -> None:
|
|||||||
rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1
|
rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1
|
||||||
rank_oos = int((G["sh_oos"] > prop['sh_oos']).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")
|
print(f" rango della proposta : {rank_in}/{len(G)} in-sample · {rank_oos}/{len(G)} sull'hold-out")
|
||||||
if rank_oos < rank_in:
|
print(" (⚠️ 2026-08-07: il confronto FRA I RANGHI e' stato ritirato come criterio — passa")
|
||||||
print(" ⚠️ La proposta sta MEGLIO sull'hold-out che in-sample: e' la firma della")
|
print(" ~meta' delle celle per costruzione e sulla proposta si decideva su 0.001 di")
|
||||||
print(" selezione-sull'hold-out. Il gate si giudica su cosa succede scegliendo al buio.")
|
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): "
|
print(f"\n Costo di scegliere al buio invece che a posteriori (hold-out): "
|
||||||
f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe")
|
f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe")
|
||||||
n_best = orders_per_year(cells[(int(best["every"]), float(best["frac"]))], CAP_REF,
|
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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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
|
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.
|
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
|
Il test di METODO e' `test_la_banda_proposta_e_quella_scelta_al_buio` (+ il suo controllo
|
||||||
proposta risultasse in rango MIGLIORE sull'hold-out che in-sample, sarebbe la firma della
|
positivo): se un giorno la banda che si sceglie sui soli dati pre-2015 non fosse piu' il 25%, la
|
||||||
selezione-sull'hold-out e la validazione andrebbe rifatta.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -76,9 +86,8 @@ def test_la_produzione_usa_ancora_la_banda_ASSOLUTA():
|
|||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# (A) selezione — il gate di metodo
|
# (A) selezione — il gate di metodo
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
def test_la_proposta_non_e_selezionata_sull_hold_out():
|
@pytest.fixture(scope="module")
|
||||||
"""Rango in-sample <= rango hold-out: la proposta non migliora passando all'hold-out.
|
def griglia_piena():
|
||||||
Il contrario sarebbe la firma della selezione-sull'hold-out."""
|
|
||||||
rows = []
|
rows = []
|
||||||
for every in BG.EVERY_GRID:
|
for every in BG.EVERY_GRID:
|
||||||
for frac in BG.FRAC_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_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
|
||||||
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"]))
|
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"]))
|
||||||
G = pd.DataFrame(rows)
|
G = pd.DataFrame(rows)
|
||||||
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
|
G["rank_in"] = G["sh_in"].rank(ascending=False, method="min").astype(int)
|
||||||
rank_in = int((G["sh_in"] > p["sh_in"]).sum()) + 1
|
G["rank_oos"] = G["sh_oos"].rank(ascending=False, method="min").astype(int)
|
||||||
rank_oos = int((G["sh_oos"] > p["sh_oos"]).sum()) + 1
|
return G
|
||||||
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")
|
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():
|
def test_l_hold_out_usato_e_quello_documentato_di_gtaa01():
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user