Compare commits
4 Commits
b5db59bea9
...
745ba7d066
| Author | SHA1 | Date | |
|---|---|---|---|
| 745ba7d066 | |||
| 043f141bf1 | |||
| b388c462e8 | |||
| 8514c096ea |
@@ -0,0 +1,68 @@
|
||||
# PREVDAY block-bootstrap — coda-fortuna vs persistente (blocker #2/#3)
|
||||
|
||||
**Data:** 2026-06-21 (chiude la trilogia: fill-haircut → turnover/hedge → bootstrap)
|
||||
**Script:** `scripts/research/intraday/prevday_bootstrap.py`
|
||||
**Esito:** PREVDAY-full **non** è più coda-fortuna di TP01 e l'edge è **bootstrap-robusto** (full
|
||||
99% / hold-out 93% dei resample con uplift>0). MA la gamba short (= tutto il valore) è
|
||||
**tail-dipendente** (top-5 giorni = 130% del suo netto). PREVDAY = tail-hedge legittimo dal payoff
|
||||
grumoso. Resta forward-monitor.
|
||||
|
||||
## Chiarimento di scope
|
||||
|
||||
Il "top-5 giorni = 76-83% del PnL" del diario intraday era sulle GAMBE REVERT del combo a 5 segnali
|
||||
(vol_event/volume_spike/gap_fill), poi SCARTATE. Il sopravvissuto è PREVDAY (breakout-continuation).
|
||||
Qui si testa PREVDAY STESSO — e la sua gamba SHORT, che (prevday_turnover) è l'intero valore di
|
||||
portafoglio. Block bootstrap circolare (blocchi 20g, B=3000) per preservare autocorrelazione/regime.
|
||||
|
||||
## [A] Concentrazione del PnL nei top-K giorni
|
||||
|
||||
| serie | n | totRet | top5 | top10 | top20 | giorni→50% gain |
|
||||
|-------|--:|-------:|-----:|------:|------:|----------------:|
|
||||
| PREVDAY full | 2869 | +182% | 22% | 36% | 59% | 411 (14.3%) |
|
||||
| **PREVDAY short-only** | 2869 | **+28%** | **130%** | 218% | 345% | 312 (10.9%) |
|
||||
| PREVDAY long-only | 2869 | +154% | 18% | 30% | 49% | 287 (10.0%) |
|
||||
| TP01 (riferimento) | 2657 | +116% | 19% | 33% | 55% | 213 (8.0%) |
|
||||
|
||||
- **PREVDAY-full NON è più coda-fortuna di TP01**: top5 22% vs 19%, e per il 50% del guadagno serve
|
||||
*più* tempo (14.3% dei giorni vs 8.0% → più distribuito). Il tail-luck del diario era sulle gambe
|
||||
revert scartate, non su PREVDAY.
|
||||
- **Gamba short tail-dipendente:** top5 = **130% del netto** → togliendo i 5 giorni migliori la short
|
||||
va in perdita (gli altri 2864 giorni nettano −8%). Sono i giorni-crash dove la short paga.
|
||||
|
||||
## [B] Circular block bootstrap (20g, B=3000)
|
||||
|
||||
| campione | PREVDAY Sharpe (mediana [5°,95°], %>0) | blend 80/20 uplift (mediana [5°,95°], %>0, %>+0.10) |
|
||||
|----------|----------------------------------------|------------------------------------------------------|
|
||||
| full (2018-08→2026-06) | +1.24 [+0.64,+1.80] 100% | +0.28 [+0.09,+0.47] 99% / 93% |
|
||||
| hold-out (2025+) | +1.27 [−0.01,+2.46] 95% | +0.53 [−0.05,+1.21] 93% / 88% |
|
||||
| short-only hold-out | +1.12 [−0.32,+2.41] 90% | +0.53 [−0.08,+1.31] 92% / 87% |
|
||||
|
||||
- **Full sample: edge robustissimo** — 99% dei resample dà uplift>0 (mediana +0.28). Non è "un blocco
|
||||
fortunato".
|
||||
- **Hold-out: regge con coda più larga** (5° pctl appena negativo: hold-out corto ~536g + short
|
||||
tail-dipendente), ma 93% dei resample >0, 88% >+0.10.
|
||||
|
||||
## Verdetto blocker #2/#3
|
||||
|
||||
- **#3 tail-luck — DECLASSATO per PREVDAY-full, CONFERMATO per la gamba short.** La strategia intera
|
||||
non è più concentrata di TP01 (che già deployamo); il motore di valore (la short) sì: vive su <10
|
||||
giorni-crash/anno. Bootstrap-robusto (non un singolo blocco), ma il forward sarà GRUMOSO, non un
|
||||
liscio +0.56/periodo.
|
||||
- **#2 null-corr-zero — RIDIMENSIONATO.** L'uplift è genuinamente positivo (93-99% dei resample), non
|
||||
rumore; il punto era di *efficienza relativa* (rende meno di un ipotetico asset perfettamente
|
||||
scorrelato), non di esistenza dell'edge.
|
||||
|
||||
## Sintesi della trilogia (fill-haircut + turnover/hedge + bootstrap)
|
||||
|
||||
PREVDAY, dopo tre attacchi avversariali:
|
||||
1. **Eseguibile alla taglia reale** ($600): haircut di fill +0.01 (blocker #4 smontato).
|
||||
2. **Già a turnover efficiente**: ridurlo erode l'edge; nessuna ottimizzazione (config congelata).
|
||||
3. **È un HEDGE, non alpha**: tutto il valore è la gamba short → tail-hedge di regime-down, additivo
|
||||
alla flat-stance di TP01 (blocker #1 inchiodato).
|
||||
4. **Edge bootstrap-robusto** ma **payoff grumoso** (il valore è in pochi giorni-crash) (blocker #3
|
||||
declassato sul full, confermato sulla short; #2 ridimensionato).
|
||||
|
||||
→ **Candidato tail-hedge legittimo**, non sleeve-alpha. Resta in FORWARD-MONITOR: la domanda forward
|
||||
non è più "è eseguibile / è overfit", ma **"la gamba short continua a pagare nei prossimi crash fuori
|
||||
da 2022 e 2025-26?"**. Se sì → si valuta come overlay di tail-hedge (peso piccolo, atteso payoff
|
||||
lumpy); se no → era beta-corto del regime down 2025-26.
|
||||
@@ -0,0 +1,67 @@
|
||||
# PREVDAY fill-haircut a basso capitale — il blocker d'esecuzione è BENIGNO (1/4 smontato)
|
||||
|
||||
**Data:** 2026-06-21 (follow-up di `2026-06-21-intraday-microstructure.md`)
|
||||
**Script:** `scripts/research/intraday/fill_haircut.py`
|
||||
**Esito:** l'haircut del fill reale a $600 è **+0.01 Sharpe** (trascurabile). Lo scettico
|
||||
d'esecuzione (blocker #4) è **benigno**. Gli altri 3 blocker (hedge / null-corr-zero / tail-luck)
|
||||
restano → PREVDAY resta in **forward-monitor, non deploy**.
|
||||
|
||||
## Domanda
|
||||
|
||||
Lo scettico d'esecuzione dell'onda intraday aveva segnalato: il vol-target di PREVDAY fa ~8500
|
||||
ribilanciamenti/anno per gamba, 97-98% < $1 di nozionale a $600; a quel capitale (min_order $5) NON
|
||||
puoi piazzarli, quindi il libro MODELED (ribilanciamento continuo, frictionless) è una finzione e lo
|
||||
Sharpe modellato è gonfiato. Il forward-monitor traccia MODELED-$2000 vs REAL-$600 per misurarlo nei
|
||||
mesi a venire — qui lo stimiamo SUBITO su tutto lo storico, replicando la STESSA logica dei due libri
|
||||
di `paper_prevday.py` ma sull'intero path 1h (2019-03 → 2026-06, 63.732 barre).
|
||||
|
||||
Due libri identici tranne il fill:
|
||||
- **MODELED**: ribilancia ad ogni barra (fee proporzionale su ogni |Δ|).
|
||||
- **REAL-$C**: salta i ribilanciamenti con nozionale `|Δpos|·leg_cap < $5` (posizione stale →
|
||||
tracking error, ma niente fee sui trade infinitesimi). Sweep C ∈ {600, 2000, 20000}.
|
||||
|
||||
## Risultati
|
||||
|
||||
| libro | FULL Sh | HOLD Sh | CAGR | DD | rebal/yr | skip% | fee-drag/yr |
|
||||
|-------|---------|---------|------|----|---------:|------:|------------:|
|
||||
| MODELED ($∞) | +1.23 | +1.27 | +24.3% | −27% | 17.484 | 0.0% | 2.49% |
|
||||
| REAL-$20k | +1.23 | +1.27 | +24.4% | −27% | 3.747 | 78.6% | 2.47% |
|
||||
| REAL-$2000 | +1.23 | +1.27 | +24.4% | −27% | 677 | 96.1% | 2.42% |
|
||||
| REAL-$600 | +1.22 | +1.26 | +24.2% | −27% | 277 | 98.4% | 2.39% |
|
||||
|
||||
**HAIRCUT $600 (MODELED − REAL): FULL Sharpe +0.01, HOLD-OUT +0.01.**
|
||||
|
||||
Domanda-soldi (l'uplift del blend regge col fill reale?):
|
||||
|
||||
| PV | w | FULL (uplift) | HOLD (uplift) |
|
||||
|----|---|---------------|---------------|
|
||||
| MODELED | 20% | 1.58 (+0.28) | 0.86 (+0.56) |
|
||||
| MODELED | 30% | 1.65 (+0.36) | 1.08 (+0.78) |
|
||||
| **REAL-$600** | 20% | 1.58 (+0.28) | 0.86 (**+0.55**) |
|
||||
| **REAL-$600** | 30% | 1.65 (+0.35) | 1.08 (**+0.77**) |
|
||||
|
||||
(TP01 solo: FULL +1.30, HOLD +0.31.) L'uplift hold-out sopravvive **quasi intatto**.
|
||||
|
||||
## Lettura
|
||||
|
||||
Saltare il **98.4%** dei micro-ribilanciamenti a $600 non costa quasi nulla perché quei trade sono
|
||||
*individualmente infinitesimi*: sia la fee risparmiata sia il tracking-error introdotto sono
|
||||
trascurabili. Il PnL è dominato dai ~50 flip di direzione/anno + la deriva lenta del vol-target, che
|
||||
il libro $600 cattura comunque sui movimenti grandi (la fee-drag passa solo da 2.49% a 2.39%). La
|
||||
"finzione della fee sub-dollaro" è quindi **benigna**: non gonfia lo Sharpe modellato (MODELED e
|
||||
REAL-$600 coincidono a ±0.01). NB: lo Sharpe **non si degrada** scendendo di capitale → l'edge
|
||||
modellato di PREVDAY è eseguibile alla taglia reale; il blocker era altrove.
|
||||
|
||||
## Conseguenza sul verdetto
|
||||
|
||||
Dei 4 blocker che tenevano PREVDAY fuori dal deploy, il **#4 (fill a basso capitale) è SMONTATO**.
|
||||
Restano in piedi i 3 strutturali (dall'onda intraday, non rivalutati qui):
|
||||
1. **hedge-shaped** — l'uplift viene dai regimi TP01-down (uplift +0.79 TP01-down vs +0.20 TP01-up);
|
||||
2. **fallisce il null a corr-zero** — uplift pre-2025 al 20-24° pctl del null di un asset random
|
||||
scorrelato (aggiunge MENO del rumore);
|
||||
3. **tail-luck** — top-5 giorni = 76-83% del PnL delle gambe revert, <10 eventi/anno.
|
||||
|
||||
PREVDAY resta il lead **più solido sull'esecuzione** di tutta la ricerca post-reset (il dubbio più
|
||||
"fisico" è caduto), ma **forward-monitor, non deploy**, finché il track record forward non scioglie
|
||||
hedge/coda/null. Lezione harness: `eval_weights_smallcap` (il gate min-order) va sempre eseguito
|
||||
PRIMA di scartare un lead per "fill irreale" — qui avrebbe evitato di sopravvalutare il blocker #4.
|
||||
@@ -0,0 +1,68 @@
|
||||
# PREVDAY come overlay di tail-hedge sul portafoglio — simulazione d'impatto (NON deploy)
|
||||
|
||||
**Data:** 2026-06-21 (segue la trilogia fill-haircut / turnover-hedge / bootstrap)
|
||||
**Script:** `scripts/portfolio/prevday_overlay.py`
|
||||
**Esito:** a peso 10%, PREVDAY taglia il maxDD FULL del portafoglio **14.3% → 9.9% (−31%)** e alza
|
||||
l'hold-out Sharpe **1.66 → 1.97 (+0.31)**. 10% è vicino all'ottimo di DD. MA è tutto IN-SAMPLE: il
|
||||
prize si materializza solo SE l'edge di PREVDAY persiste forward. PREVDAY resta FORWARD-MONITOR.
|
||||
|
||||
## Setup
|
||||
|
||||
Simulazione che NON tocca il registry di produzione: prende il portafoglio attivo (TP01 55% + XS01
|
||||
25% + VRP01 20%), riscala i tre sleeve a (1−W) mantenendone le proporzioni, e aggiunge PREVDAY a
|
||||
peso W. Sweep W ∈ {0,5,10,15,20%}. PREVDAY = libro 1h breakout-continuation, parametri congelati,
|
||||
50/50 BTC+ETH, fee 0.10% RT. Outer-join del portafoglio: PREVDAY dal 2018, VRP01 dal 2021, XS01 dal
|
||||
2024 → nel 2019-20 PREVDAY pesa di fatto >W (solo TP01 accanto); nell'hold-out 2025+ (tutti e 4
|
||||
attivi) pesa esattamente ~W → **l'HOLD-OUT è il confronto pulito a "10%"**.
|
||||
|
||||
## Sweep peso overlay
|
||||
|
||||
| peso PREVDAY | FULL Sharpe | FULL DD | HOLD Sharpe | HOLD DD | HOLD ret |
|
||||
|--------------|------------:|--------:|------------:|--------:|---------:|
|
||||
| BASELINE (55/25/20) | 1.68 | 14.3% | 1.66 | 3.4% | +16.7% |
|
||||
| 5% | 1.80 | 11.1% | 1.83 | 3.3% | +17.8% |
|
||||
| **10%** | **1.88** | **9.9%** | **1.97** | 3.3% | **+19.0%** |
|
||||
| 15% | 1.93 | 10.3% | 2.06 | 3.3% | +20.2% |
|
||||
| 20% | 1.95 | 10.6% | 2.09 | 3.4% | +21.4% |
|
||||
|
||||
(PREVDAY standalone: FULL Sh 1.23 / DD 26.7%; HOLD Sh 1.28 / DD 10.8%.)
|
||||
|
||||
## Lettura a 10%
|
||||
|
||||
- **FULL: Sharpe +0.20 (1.68→1.88), maxDD 14.3%→9.9% (−4.4pp ≈ −31%).** Comportamento da tail-hedge:
|
||||
la gamba short ammortizza i crash storici (2019-21, 2022).
|
||||
- **HOLD-OUT: Sharpe +0.31 (1.66→1.97), ret +16.7%→+19.0%, DD 3.4%→3.3% (già bassissimo).** Nel
|
||||
regime recente il beneficio è rendimento/Sharpe, non taglio DD.
|
||||
- **10% ≈ ottimo di DD.** Oltre, lo Sharpe sale ancora (1.93→1.95) ma il maxDD FULL smette di
|
||||
scendere (10.3→10.6%): stai solo aggiungendo rischio direzionale short. Argomento per ~10% in
|
||||
chiave hedge (massimizza il taglio-coda per unità di rischio aggiunto).
|
||||
|
||||
## Per anno (baseline → overlay 10%)
|
||||
|
||||
| anno | ret | DD |
|
||||
|------|-----|----|
|
||||
| 2019 | +11.3 → +15.2 | 10.3 → 8.2 |
|
||||
| 2020 | +51.1 → +53.1 | 8.4 → 6.3 |
|
||||
| 2021 | +32.5 → +28.3 | 5.2 → 4.3 |
|
||||
| 2022 | −3.0 → −1.6 | 3.7 → 3.0 |
|
||||
| 2023 | +11.2 → +11.4 | 9.2 → 9.9 |
|
||||
| 2024 | +24.4 → +25.7 | 3.9 → 3.4 |
|
||||
| 2025 | +12.0 → +12.0 | 3.4 → 3.3 |
|
||||
| 2026 | +4.2 → +6.2 | 2.6 → 2.2 |
|
||||
|
||||
Migliora o pareggia quasi ovunque; costa solo nel toro 2021 (premio d'assicurazione atteso per un
|
||||
hedge) e leggermente sul DD 2023; paga nel bear 2022 e nel 2026.
|
||||
|
||||
## Verdetto
|
||||
|
||||
L'overlay 10% è **attraente in simulazione** — taglia il drawdown FULL di ~31% e alza l'hold-out
|
||||
Sharpe +0.31, con 10% vicino all'ottimo di DD. Ma:
|
||||
1. **È in-sample.** I guadagni assumono che l'edge di PREVDAY persista — il forward-monitor esiste
|
||||
proprio per verificarlo. Questa simulazione quantifica il PRIZE, non lo prova.
|
||||
2. **Outer-join:** il taglio-DD storico è gonfiato dal peso effettivo >10% nel 2019-20; il read
|
||||
pulito a 10% è l'hold-out (prize = Sharpe +0.31).
|
||||
3. Incidentale: il 3-way TP01+XS01+VRP01 baseline qui fa FULL 1.68 / HOLD 1.66.
|
||||
|
||||
**Azione: nessuna.** PREVDAY resta FORWARD-MONITOR (registry di produzione invariato). Quando il
|
||||
track record forward avrà ~2-3 mesi, ri-valutare l'overlay 10% con la stessa metrica (taglio-DD +
|
||||
hold-out Sharpe) su dati VERAMENTE fuori campione. Lo script è il riferimento per quel confronto.
|
||||
@@ -0,0 +1,64 @@
|
||||
# PREVDAY — la fee viene dai FLIP (no free lunch sul turnover) + è un HEDGE, non alpha
|
||||
|
||||
**Data:** 2026-06-21 (follow-up di `2026-06-21-prevday-fill-haircut.md`)
|
||||
**Script:** `scripts/research/intraday/prevday_turnover.py`
|
||||
**Esito:** (1) ridurre il turnover di PREVDAY erode l'edge — la config congelata è già efficiente.
|
||||
(2) Il test long-only inchioda il blocker #1: **tutto il valore di portafoglio è la gamba SHORT** →
|
||||
PREVDAY è un **hedge di regime-down**, non alpha. Resta forward-monitor.
|
||||
|
||||
## Premessa (da fill_haircut)
|
||||
|
||||
Il libro REAL-$600 salta il 98.4% dei ribilanciamenti del vol-target e la fee-drag scende solo
|
||||
2.49% → 2.39%/anno. Quindi la fee (~2.6%/anno) NON viene dal churn sub-dollaro ma dai **~70 flip di
|
||||
direzione/anno**. Un deadband d'esecuzione è inutile; l'unica leva è ridurre i flip a LIVELLO DI
|
||||
SEGNALE. Qui sweep delle leve (buffer, anchor, min-hold) + long-only vs long-short. Libro MODELED
|
||||
(l'haircut di fill è +0.01, irrilevante). Metrica che conta = **uplift hold-out del blend 80/20**.
|
||||
|
||||
## (1) Turnover-reduction — no free lunch
|
||||
|
||||
| config | flip/yr | fee/yr | FULL Sh | HOLD Sh | DD | corrTP | blend HOLD upl |
|
||||
|--------|--------:|-------:|--------:|--------:|---:|-------:|---------------:|
|
||||
| **BASE** (anchor=1, k=0.30, LS) | 70 | 2.59% | +1.23 | +1.27 | −27% | +0.15 | **+0.56** |
|
||||
| k=0.50 | 48 | 1.86% | +1.23 | +0.99 | −15% | +0.20 | +0.40 |
|
||||
| k=0.75 | 32 | 1.31% | +1.06 | +0.13 | −16% | +0.27 | +0.00 |
|
||||
| k=1.00 | 23 | 1.01% | +0.88 | +0.72 | −22% | +0.36 | +0.22 |
|
||||
| anchor=2 | 39 | 1.55% | +0.89 | +0.54 | −22% | +0.25 | +0.20 |
|
||||
| anchor=3 | 27 | 1.14% | +0.67 | −0.18 | −22% | +0.29 | −0.12 |
|
||||
| anchor=5 | 15 | 0.75% | +1.15 | +0.70 | −19% | +0.41 | +0.25 |
|
||||
| min_hold=24h | 70 | 2.59% | +1.22 | +1.37 | −32% | +0.15 | **+0.60** |
|
||||
| min_hold=72h | 65 | 2.39% | +0.86 | +0.67 | −33% | +0.12 | +0.27 |
|
||||
| combo-LT (k.75+anc2+24h) | 16 | 0.79% | +0.79 | +0.69 | −20% | +0.34 | +0.24 |
|
||||
|
||||
- **Allargare buffer/anchor taglia fee e turnover ma l'uplift cala monotonicamente** (k: 0.56→0.40→
|
||||
0.00). Anchor multi-giorno tutto peggio → conferma il "anchor=1 only" del diario. I flip SONO
|
||||
l'edge: meno flip = meno edge.
|
||||
- **min_hold=24h** è l'unico ritocco "quasi gratis" (uplift +0.56→+0.60 a parità di fee) ma
|
||||
**peggiora il DD −27%→−32%** → non vale cambiare una strategia congelata in forward-monitor.
|
||||
- **Verdetto: la config base è già sulla frontiera efficiente turnover↔edge. Si lascia congelata.**
|
||||
|
||||
## (2) Long-only vs long-short — il blocker #1 inchiodato
|
||||
|
||||
| | FULL Sh | HOLD Sh | corrTP | blend HOLD upl | fee/yr |
|
||||
|--|--------:|--------:|-------:|---------------:|-------:|
|
||||
| **long-only** (no short) | **+1.55** | +0.52 | **+0.64** | **+0.09** | 1.30% |
|
||||
| long-short (BASE) | +1.23 | +1.27 | +0.15 | +0.56 | 2.59% |
|
||||
|
||||
La versione **long-only ha Sharpe standalone più ALTO** (1.55 vs 1.23) ma è **correlata +0.64 a TP01
|
||||
e non aggiunge quasi nulla al blend** (+0.09). **Tutto il valore di portafoglio viene dalla gamba
|
||||
SHORT:** la short *abbassa* lo Sharpe standalone (shortare crypto nel toro 2019-24 perde) ma fornisce
|
||||
**tutta** la decorrelazione (corrTP 0.64→0.15) e l'uplift hold-out (0.09→0.56).
|
||||
|
||||
→ **PREVDAY non è alpha: è strutturalmente un HEDGE di crash/regime-down.** Costa nel toro, paga
|
||||
nell'orso (2022, 2025-26 down/chop). È additivo a TP01, che va *flat* nel risk-off ma non *short*.
|
||||
Questo conferma e affina il blocker #1 dell'onda intraday ("l'uplift viene dai regimi TP01-down"):
|
||||
non è solo conditional sui regimi down, è **interamente la gamba short** = una scommessa direzionale
|
||||
che i ribassi continuino.
|
||||
|
||||
## Conseguenza sul verdetto
|
||||
|
||||
- Niente da ottimizzare: la config congelata è già efficiente; nessun cambio.
|
||||
- **Riframing utile:** se PREVDAY un giorno avrà un ruolo, è come **overlay di tail-hedge** (non
|
||||
sleeve-alpha), additivo alla difensività di TP01. Ma resta soggetto agli altri due blocker
|
||||
(fallisce il null a corr-zero; tail-luck: top-5 giorni = 76-83% del PnL delle gambe revert).
|
||||
- **Forward-monitor invariato.** Il test forward decisivo: la gamba short continua a pagare fuori da
|
||||
2022 e 2025-26? Se sì → candidato tail-hedge; se no → era regime-luck.
|
||||
@@ -0,0 +1,115 @@
|
||||
"""SIMULAZIONE — PREVDAY come overlay di tail-hedge sul portafoglio attivo (NON deploy).
|
||||
|
||||
PREVDAY (src/strategies/prevday_breakout) resta in FORWARD-MONITOR. Qui misuriamo SOLO, in
|
||||
simulazione, cosa farebbe al portafoglio live (TP01 55% + XS01 25% + VRP01 20%) aggiungerlo come
|
||||
overlay a peso W, riscalando i tre sleeve esistenti a (1-W) e tenendo le loro proporzioni. La
|
||||
trilogia (fill-haircut/turnover/bootstrap) ha stabilito che PREVDAY e' un HEDGE di regime-down
|
||||
(tutto il valore = gamba short) eseguibile a taglia reale: l'overlay si giudica sul TAGLIO DEL
|
||||
DRAWDOWN del portafoglio, non sul ritorno.
|
||||
|
||||
NB outer-join: PREVDAY parte dal 2018, XS01 dal 2024, VRP01 dal 2021. I pesi sono rinormalizzati
|
||||
ogni giorno fra i soli sleeve con dato -> nel 2019-20 (solo TP01+PREVDAY) PREVDAY pesa di piu' del
|
||||
target W; nell'hold-out 2025+ (tutti e 4 attivi) pesa esattamente ~W. Per questo l'HOLD-OUT e' il
|
||||
confronto piu' pulito a "peso 10%".
|
||||
|
||||
uv run python scripts/portfolio/prevday_overlay.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.strategies import prevday_breakout as pb
|
||||
from src.portfolio.portfolio import StrategyPortfolio, Sleeve, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import _tp01_returns, _xsec_returns, _vrp_combo_returns
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
FEE_SIDE = 0.0005
|
||||
BASE_W = dict(TP01=0.55, XS01=0.25, VRP01=0.20) # proporzioni dei tre sleeve attivi
|
||||
HEADLINE = 0.10
|
||||
|
||||
|
||||
def _prevday_returns() -> pd.Series:
|
||||
"""Rendimenti netti per-barra (1h) del libro PREVDAY 50/50 BTC+ETH (parametri congelati)."""
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = load(a, "1h").reset_index(drop=True)
|
||||
c = df["close"].values.astype(float)
|
||||
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||
tgt = np.nan_to_num(pb.target(df), nan=0.0)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
net = held * r - FEE_SIDE * np.abs(np.diff(tgt, prepend=tgt[0]))
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(df["datetime"], utc=True))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return pd.Series(0.5 * J.iloc[:, 0].values + 0.5 * J.iloc[:, 1].values, index=J.index)
|
||||
|
||||
|
||||
def build_portfolio(series_cache: dict, w_prev: float) -> StrategyPortfolio:
|
||||
"""Portafoglio coi 3 sleeve riscalati a (1-w_prev) + PREVDAY a w_prev (0 = baseline)."""
|
||||
sl = [
|
||||
Sleeve("TP01_trend_1d", BASE_W["TP01"] * (1 - w_prev), lambda s=series_cache["TP01"]: s),
|
||||
Sleeve("XS01_xsec_hl", BASE_W["XS01"] * (1 - w_prev), lambda s=series_cache["XS01"]: s),
|
||||
Sleeve("VRP01_shortvol", BASE_W["VRP01"] * (1 - w_prev), lambda s=series_cache["VRP01"]: s),
|
||||
]
|
||||
if w_prev > 0:
|
||||
sl.append(Sleeve("PREVDAY_hedge", w_prev, lambda s=series_cache["PREVDAY"]: s))
|
||||
return StrategyPortfolio(sl)
|
||||
|
||||
|
||||
def line(label, m):
|
||||
return (f" {label:<22s} Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% "
|
||||
f"CAGR {m['cagr']*100:>+6.1f}% | DD {m['maxdd']*100:>5.1f}% | n {m['n']}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" PREVDAY OVERLAY (simulazione, NON deploy) — tail-hedge sul portafoglio TP01+XS01+VRP01")
|
||||
print("=" * 92)
|
||||
print(" Precalcolo sleeve...", flush=True)
|
||||
cache = dict(TP01=_tp01_returns(), XS01=_xsec_returns(),
|
||||
VRP01=_vrp_combo_returns(), PREVDAY=_prevday_returns())
|
||||
|
||||
print(f"\n PREVDAY standalone (per riferimento):")
|
||||
from src.portfolio.portfolio import to_daily
|
||||
pvd = to_daily(cache["PREVDAY"])
|
||||
print(line("PREVDAY full", metrics(pvd)))
|
||||
print(line("PREVDAY hold-out", metrics(pvd[pvd.index >= HOLDOUT])))
|
||||
|
||||
print(f"\n SWEEP PESO OVERLAY (FULL | HOLD-OUT) — headline {HEADLINE*100:.0f}%:")
|
||||
print(f" {'peso PREVDAY':<14s} {'FULL Sharpe':>11s} {'FULL DD':>9s} | {'HOLD Sharpe':>11s} {'HOLD DD':>9s} {'HOLD ret':>9s}")
|
||||
rows = {}
|
||||
for w in (0.0, 0.05, 0.10, 0.15, 0.20):
|
||||
bt = build_portfolio(cache, w).backtest()
|
||||
rows[w] = bt
|
||||
tag = "BASELINE" if w == 0 else f"{w*100:.0f}%"
|
||||
star = " <-- headline" if abs(w - HEADLINE) < 1e-9 else ""
|
||||
print(f" {tag:<14s} {bt['full']['sharpe']:>11.2f} {bt['full']['maxdd']*100:>8.1f}% | "
|
||||
f"{bt['holdout']['sharpe']:>11.2f} {bt['holdout']['maxdd']*100:>8.1f}% "
|
||||
f"{bt['holdout']['ret']*100:>+8.1f}%{star}")
|
||||
|
||||
base, ov = rows[0.0], rows[HEADLINE]
|
||||
print(f"\n DETTAGLIO a {HEADLINE*100:.0f}% vs BASELINE:")
|
||||
print(line("BASELINE FULL", base['full'])); print(line(f"OVERLAY{HEADLINE*100:.0f}% FULL", ov['full']))
|
||||
print(line("BASELINE HOLD", base['holdout'])); print(line(f"OVERLAY{HEADLINE*100:.0f}% HOLD", ov['holdout']))
|
||||
dSh = ov['holdout']['sharpe'] - base['holdout']['sharpe']
|
||||
dDD = (ov['holdout']['maxdd'] - base['holdout']['maxdd']) * 100
|
||||
print(f"\n >> HOLD-OUT: ΔSharpe {dSh:+.2f} | ΔmaxDD {dDD:+.1f}pp "
|
||||
f"(tail-hedge = ci aspettiamo DD piu' basso)")
|
||||
|
||||
print(f"\n PER ANNO (baseline -> overlay {HEADLINE*100:.0f}%): ret% / DD%")
|
||||
yb, yo = base['yearly'], ov['yearly']
|
||||
for y in sorted(set(yb) | set(yo)):
|
||||
b = yb.get(y, {}); o = yo.get(y, {})
|
||||
print(f" {y}: ret {b.get('ret',0)*100:>+7.1f}% -> {o.get('ret',0)*100:>+7.1f}% "
|
||||
f"DD {b.get('dd',0)*100:>5.1f}% -> {o.get('dd',0)*100:>5.1f}%")
|
||||
print("=" * 92)
|
||||
print(" Nota: PREVDAY resta FORWARD-MONITOR. Questa e' una simulazione di impatto, non un deploy.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""fill_haircut — quanto il fill REALE a basso capitale erode il lead PREVDAY (e TP01)?
|
||||
|
||||
Lo scettico d'esecuzione (diario 2026-06-21) ha segnalato che il vol-target di PREVDAY fa ~8500
|
||||
ribilanciamenti/anno, di cui 97-98% < $1 di nozionale a $600: a quel capitale NON puoi piazzare
|
||||
quegli ordini (min_order $5), quindi il libro MODELED (ribilanciamento continuo, frictionless) è
|
||||
una finzione. Il forward-monitor traccia MODELED-$2000 vs REAL-$600 per misurare il gap nei mesi
|
||||
a venire — qui lo stimiamo SUBITO su tutto lo storico, replicando la STESSA logica di paper_prevday.
|
||||
|
||||
Due libri, identici tranne il fill:
|
||||
MODELED : ribilancia ad ogni barra alla posizione-bersaglio (fee proporzionale su ogni |Δ|).
|
||||
REAL-$C : capitale C, salta i ribilanciamenti con nozionale |Δpos|*leg_cap < min_order ($5)
|
||||
(posizione resta "stale" -> tracking error, ma niente fee sui trade infinitesimi).
|
||||
|
||||
Sweep capitale {600, 2000, 20000} per mostrare a quanto l'haircut svanisce. Poi la domanda-soldi:
|
||||
il blend 80%TP01+20%PREVDAY conserva l'uplift hold-out (+0.56 modellato) usando PREVDAY-REAL-$600?
|
||||
|
||||
uv run python scripts/research/intraday/fill_haircut.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.backtest.harness import load # noqa: E402
|
||||
from src.strategies.prevday_breakout import target as pv_target # noqa: E402
|
||||
from src.portfolio.portfolio import to_daily # noqa: E402
|
||||
from src.portfolio.sleeves import _tp01_returns # noqa: E402
|
||||
|
||||
HOLD = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||
MIN_ORDER = 5.0
|
||||
WEIGHT = 0.5
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
|
||||
|
||||
def _sh(x):
|
||||
x = x.dropna()
|
||||
return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||
|
||||
|
||||
def _dd(x):
|
||||
eq = (1 + x.fillna(0)).cumprod()
|
||||
return float(((eq - eq.cummax()) / eq.cummax()).min())
|
||||
|
||||
|
||||
def simulate(targets: dict, rets: dict, idx_dt, capital):
|
||||
"""Bar-by-bar 50/50 book. capital=None -> MODELED (continuous). Returns (daily_ret, stats)."""
|
||||
n = len(idx_dt)
|
||||
held = {a: 0.0 for a in ASSETS}
|
||||
net = np.zeros(n)
|
||||
exec_ct = {a: 0 for a in ASSETS}
|
||||
skip_ct = {a: 0 for a in ASSETS}
|
||||
fee_tot = 0.0
|
||||
for i in range(n):
|
||||
step = 0.0
|
||||
for a in ASSETS:
|
||||
tgt = float(targets[a][i]); r = float(rets[a][i]); h = held[a]
|
||||
if capital is None: # MODELED: always rebalance
|
||||
new_h = tgt; traded = abs(tgt - h)
|
||||
exec_ct[a] += 1 if traded > 1e-9 else 0
|
||||
else: # REAL-$C: skip sub-min_order
|
||||
leg_cap = capital * WEIGHT
|
||||
if abs(tgt - h) * leg_cap >= MIN_ORDER:
|
||||
new_h = tgt; traded = abs(tgt - h); exec_ct[a] += 1
|
||||
else:
|
||||
new_h = h; traded = 0.0; skip_ct[a] += 1
|
||||
fee = FEE_SIDE * traded
|
||||
fee_tot += WEIGHT * fee
|
||||
step += WEIGHT * (h * r - fee) # earn on position HELD into bar, pay fee on rebalance
|
||||
held[a] = new_h
|
||||
net[i] = step
|
||||
s = pd.Series(net, index=idx_dt)
|
||||
daily = s.groupby(s.index.floor("1D")).sum()
|
||||
yrs = (idx_dt[-1] - idx_dt[0]).days / 365.25
|
||||
ex = sum(exec_ct.values()); sk = sum(skip_ct.values())
|
||||
stats = dict(execs_per_yr=ex / yrs, skip_frac=sk / (ex + sk) if (ex + sk) else 0.0,
|
||||
fee_drag_per_yr=fee_tot / yrs)
|
||||
return daily, stats
|
||||
|
||||
|
||||
def build_targets():
|
||||
targets, rets, ts_sets = {}, {}, {}
|
||||
dts = {}
|
||||
for a in ASSETS:
|
||||
df = load(a, "1h").reset_index(drop=True)
|
||||
c = df["close"].values.astype(float)
|
||||
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||
targets[a] = pv_target(df); rets[a] = r
|
||||
ts = df["timestamp"].values.astype("int64")
|
||||
ts_sets[a] = ts
|
||||
dts[a] = pd.to_datetime(df["datetime"], utc=True).values
|
||||
common = sorted(set(ts_sets["BTC"]).intersection(ts_sets["ETH"]))
|
||||
pos = {a: {int(t): i for i, t in enumerate(ts_sets[a])} for a in ASSETS}
|
||||
T, R = {a: [] for a in ASSETS}, {a: [] for a in ASSETS}
|
||||
dt_out = []
|
||||
for t in common:
|
||||
i_btc = pos["BTC"][int(t)]
|
||||
dt_out.append(dts["BTC"][i_btc])
|
||||
for a in ASSETS:
|
||||
i = pos[a][int(t)]
|
||||
T[a].append(targets[a][i]); R[a].append(rets[a][i])
|
||||
idx = pd.to_datetime(dt_out, utc=True)
|
||||
return {a: np.array(T[a]) for a in ASSETS}, {a: np.array(R[a]) for a in ASSETS}, idx
|
||||
|
||||
|
||||
def row(label, daily):
|
||||
J = daily.dropna(); JH = J[J.index >= HOLD]
|
||||
yrs = (J.index[-1] - J.index[0]).days / 365.25
|
||||
cagr = (1 + J).prod() ** (1 / yrs) - 1
|
||||
return (f" {label:<18s} FULL Sh {_sh(J):+5.2f} HOLD Sh {_sh(JH):+5.2f} "
|
||||
f"CAGR {cagr*100:+5.1f}% DD {_dd(J)*100:4.0f}%")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" FILL-HAIRCUT — PREVDAY: libro MODELED (continuo) vs REAL-$C (skip < $5 min-order)")
|
||||
print("=" * 92)
|
||||
T, R, idx = build_targets()
|
||||
print(f" path 1h: {len(idx)} barre {idx[0].date()} -> {idx[-1].date()}\n")
|
||||
|
||||
books = {}
|
||||
for cap, lab in [(None, "MODELED ($∞)"), (20000, "REAL-$20k"), (2000, "REAL-$2000"), (600, "REAL-$600")]:
|
||||
daily, st = simulate(T, R, idx, cap)
|
||||
books[lab] = daily
|
||||
print(row(lab, daily) +
|
||||
f" | rebal/yr {st['execs_per_yr']:6.0f} skip {st['skip_frac']*100:4.1f}% "
|
||||
f"fee-drag/yr {st['fee_drag_per_yr']*100:4.2f}%")
|
||||
|
||||
mod = books["MODELED ($∞)"]; real = books["REAL-$600"]
|
||||
hc_full = _sh(mod.dropna()) - _sh(real.dropna())
|
||||
JHm = mod[mod.index >= HOLD]; JHr = real[real.index >= HOLD]
|
||||
hc_hold = _sh(JHm) - _sh(JHr)
|
||||
print(f"\n >> HAIRCUT $600 (MODELED - REAL): FULL Sharpe {hc_full:+.2f} | HOLD-OUT Sharpe {hc_hold:+.2f}")
|
||||
|
||||
# money question: does the blend uplift survive at REAL-$600?
|
||||
print("\n" + "-" * 92)
|
||||
print(" BLEND 80%TP01 + 20%PREVDAY — sopravvive l'uplift hold-out col fill reale?")
|
||||
tp = to_daily(_tp01_returns())
|
||||
for lab, pv in [("MODELED", mod), ("REAL-$600", real)]:
|
||||
J = pd.concat({"TP": tp, "PV": pv}, axis=1).dropna(); JH = J[J.index >= HOLD]
|
||||
for w in (0.20, 0.30):
|
||||
b = (1 - w) * J["TP"] + w * J["PV"]; bh = (1 - w) * JH["TP"] + w * JH["PV"]
|
||||
print(f" PV={lab:<9s} w={w:.0%} FULL {_sh(b):+.2f} (upl {_sh(b)-_sh(J['TP']):+.2f}) "
|
||||
f"HOLD {_sh(bh):+.2f} (upl {_sh(bh)-_sh(JH['TP']):+.2f})")
|
||||
print(f" [TP01 solo: FULL {_sh(tp.dropna()):+.2f} HOLD {_sh(tp[tp.index>=HOLD]):+.2f}]")
|
||||
print("=" * 92)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""prevday_bootstrap — l'edge di PREVDAY è coda-fortuna o persistente? (blocker #2/#3)
|
||||
|
||||
CHIARIMENTO: il "top-5 giorni = 76-83% del PnL" del diario intraday era sulle GAMBE REVERT del combo
|
||||
a 5 segnali (vol_event/volume_spike/gap_fill), poi SCARTATE. Il sopravvissuto in forward-monitor è
|
||||
PREVDAY (breakout-continuation). Qui testiamo la concentrazione e la robustezza di PREVDAY STESSO —
|
||||
e in particolare della sua GAMBA SHORT, che (prevday_turnover) è l'intero valore di portafoglio.
|
||||
|
||||
Due test:
|
||||
A) CONCENTRAZIONE — quota del PnL nei top-K giorni (riproduce la metrica del diario su PREVDAY,
|
||||
full / short-only / long-only, vs TP01 come riferimento: PREVDAY è PIÙ concentrato di ciò che
|
||||
già deployamo?). + giorni per arrivare al 50% del guadagno cumulato.
|
||||
B) CIRCULAR BLOCK BOOTSTRAP (blocchi da 20g, preserva autocorrelazione/regime) — distribuzione di:
|
||||
standalone Sharpe (full + hold-out) e dell'UPLIFT hold-out del blend 80%TP01+20%PREVDAY (la
|
||||
metrica-soldi). %>0 e 5° percentile = quanto l'edge dipende da quali blocchi sono capitati.
|
||||
|
||||
uv run python scripts/research/intraday/prevday_bootstrap.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.backtest.harness import load # noqa: E402
|
||||
from src.strategies import prevday_breakout as pb # noqa: E402
|
||||
from src.portfolio.portfolio import to_daily # noqa: E402
|
||||
from src.portfolio.sleeves import _tp01_returns # noqa: E402
|
||||
|
||||
HOLD = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE_SIDE = 0.0005
|
||||
WEIGHT = 0.5
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
RNG = np.random.default_rng(12345)
|
||||
B = 3000
|
||||
BLOCK = 20
|
||||
|
||||
|
||||
def _sh(x):
|
||||
x = np.asarray(x, float); x = x[np.isfinite(x)]
|
||||
return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||
|
||||
|
||||
def _leg_daily(dfs, leg):
|
||||
"""Ritorni daily 50/50 di PREVDAY restringendo la direzione: 'full'|'short'|'long'."""
|
||||
out = None
|
||||
for a in ASSETS:
|
||||
df = dfs[a]
|
||||
c = df["close"].values.astype(float)
|
||||
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||
d = pb._breakout_direction(df, pb.ANCHOR_DAYS, pb.BUFFER_K, True)
|
||||
if leg == "short":
|
||||
d = np.minimum(d, 0.0)
|
||||
elif leg == "long":
|
||||
d = np.maximum(d, 0.0)
|
||||
tgt = np.nan_to_num(pb._vol_target(d, df, pb.TARGET_VOL, pb.VOL_WIN_DAYS, pb.LEV_CAP), nan=0.0)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
net = held * r - FEE_SIDE * np.abs(np.diff(tgt, prepend=tgt[0]))
|
||||
s = pd.Series(net, index=pd.to_datetime(df["datetime"], utc=True))
|
||||
dd = s.groupby(s.index.floor("1D")).sum()
|
||||
out = dd if out is None else out.add(dd, fill_value=0)
|
||||
return WEIGHT * out
|
||||
|
||||
|
||||
def concentration(daily, label):
|
||||
s = daily.dropna()
|
||||
total = s.sum()
|
||||
pos = s[s > 0].sum()
|
||||
topk = {k: s.nlargest(k).sum() / total if total != 0 else float("nan") for k in (5, 10, 20)}
|
||||
# giorni (in ordine decrescente) per arrivare al 50% del guadagno lordo positivo
|
||||
cum = s.sort_values(ascending=False).cumsum()
|
||||
d50 = int((cum < 0.5 * pos).sum()) + 1 if pos > 0 else -1
|
||||
n = len(s)
|
||||
print(f" {label:<22s} n={n} totRet {total*100:+6.0f}% "
|
||||
f"top5 {topk[5]*100:4.0f}% top10 {topk[10]*100:4.0f}% top20 {topk[20]*100:4.0f}% "
|
||||
f"giorni->50% gain: {d50} ({d50/n*100:.1f}% dei giorni)")
|
||||
|
||||
|
||||
def block_boot_joint(tp, pv, n_iter=B, block=BLOCK):
|
||||
"""Bootstrap a blocchi circolari della serie CONGIUNTA (tp,pv) allineata. Ritorna i campioni di
|
||||
(sh_pv, uplift_blend_80_20)."""
|
||||
J = pd.concat({"TP": tp, "PV": pv}, axis=1, sort=True).dropna()
|
||||
a = J["TP"].values; b = J["PV"].values
|
||||
n = len(a)
|
||||
nblocks = int(np.ceil(n / block))
|
||||
sh_pv, upl = [], []
|
||||
base_tp = _sh(a)
|
||||
for _ in range(n_iter):
|
||||
starts = RNG.integers(0, n, size=nblocks)
|
||||
idx = np.concatenate([(np.arange(s, s + block) % n) for s in starts])[:n]
|
||||
ta, tb = a[idx], b[idx]
|
||||
sh_pv.append(_sh(tb))
|
||||
blend = 0.8 * ta + 0.2 * tb
|
||||
upl.append(_sh(blend) - _sh(ta))
|
||||
return np.array(sh_pv), np.array(upl), base_tp
|
||||
|
||||
|
||||
def report_boot(name, sh, upl):
|
||||
def q(x, p):
|
||||
return float(np.percentile(x, p))
|
||||
print(f" {name}")
|
||||
print(f" PREVDAY Sharpe : mediana {np.median(sh):+.2f} [5°,95°]=[{q(sh,5):+.2f},{q(sh,95):+.2f}] %>0 {np.mean(sh>0)*100:.0f}%")
|
||||
print(f" blend 80/20 UPLIFT: mediana {np.median(upl):+.2f} [5°,95°]=[{q(upl,5):+.2f},{q(upl,95):+.2f}] "
|
||||
f"%>0 {np.mean(upl>0)*100:.0f}% %>+0.10 {np.mean(upl>0.10)*100:.0f}%")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" PREVDAY bootstrap — l'edge è coda-fortuna o persistente? (blocco da 20g, B=%d)" % B)
|
||||
print("=" * 100)
|
||||
dfs = {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
|
||||
pv_full = _leg_daily(dfs, "full")
|
||||
pv_short = _leg_daily(dfs, "short")
|
||||
pv_long = _leg_daily(dfs, "long")
|
||||
tp = to_daily(_tp01_returns())
|
||||
|
||||
print("\n[A] CONCENTRAZIONE del PnL nei top-K giorni (più alto = più coda-fortuna):")
|
||||
concentration(pv_full, "PREVDAY full")
|
||||
concentration(pv_short, "PREVDAY short-only")
|
||||
concentration(pv_long, "PREVDAY long-only")
|
||||
concentration(tp, "TP01 (riferimento)")
|
||||
|
||||
print(f"\n[B] CIRCULAR BLOCK BOOTSTRAP — FULL ({pv_full.dropna().index.min().date()}->{pv_full.dropna().index.max().date()}):")
|
||||
sh, upl, base = block_boot_joint(tp, pv_full)
|
||||
print(f" [TP01 base full Sharpe {base:+.2f}; uplift osservato +0.28 a w20]")
|
||||
report_boot("full sample:", sh, upl)
|
||||
|
||||
print(f"\n[B] HOLD-OUT (2025+):")
|
||||
tph = tp[tp.index >= HOLD]; pvh = pv_full[pv_full.index >= HOLD]
|
||||
shH, uplH, baseH = block_boot_joint(tph, pvh)
|
||||
print(f" [TP01 base hold Sharpe {baseH:+.2f}; uplift osservato +0.56 a w20]")
|
||||
report_boot("hold-out:", shH, uplH)
|
||||
|
||||
print(f"\n[B] SHORT-ONLY hold-out (la gamba che è tutto il valore):")
|
||||
shS, uplS, _ = block_boot_joint(tph, pv_short[pv_short.index >= HOLD])
|
||||
report_boot("short-only hold-out:", shS, uplS)
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""prevday_turnover — la fee di PREVDAY viene dai FLIP, non dai micro-ribilanciamenti. Si può tagliare?
|
||||
|
||||
Da fill_haircut.py: il libro REAL-$600 salta il 98.4% dei ribilanciamenti del vol-target e la
|
||||
fee-drag scende solo 2.49% -> 2.39%/anno. Quindi la fee (~2.4%/anno) e' dominata dai ~50 FLIP di
|
||||
direzione/anno, non dal churn sub-dollaro. Un deadband d'esecuzione e' inutile; la leva e' ridurre i
|
||||
flip a LIVELLO DI SEGNALE. Qui sweepiamo le leve che riducono i flip e misuriamo il trade-off
|
||||
turnover <-> edge:
|
||||
* BUFFER_K (break piu' deciso = meno flip) {0.30 base, 0.50, 0.75, 1.00}
|
||||
* ANCHOR_DAYS (range multi-giorno = livelli piu' larghi){1 base, 2, 3, 5}
|
||||
* MIN_HOLD (non flippare entro N ore dall'ultimo flip) {0 base, 24, 72}
|
||||
e in piu' LONG-ONLY vs LONG-SHORT (isola la gamba short = l'hedge del blocker #1).
|
||||
|
||||
Per ogni config: flip/anno, fee-drag/anno, FULL/HOLD Sharpe, corr a TP01, e l'uplift hold-out del
|
||||
blend 80%TP01+20%PV (la metrica che conta). Libro MODELED (l'haircut di fill e' +0.01, irrilevante).
|
||||
|
||||
uv run python scripts/research/intraday/prevday_turnover.py
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from src.backtest.harness import load # noqa: E402
|
||||
from src.strategies import prevday_breakout as pb # noqa: E402
|
||||
from src.portfolio.portfolio import to_daily # noqa: E402
|
||||
from src.portfolio.sleeves import _tp01_returns # noqa: E402
|
||||
|
||||
HOLD = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
FEE_SIDE = 0.0005
|
||||
WEIGHT = 0.5
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
|
||||
|
||||
def _sh(x):
|
||||
x = x.dropna()
|
||||
return float(x.mean() / x.std() * np.sqrt(365.25)) if len(x) > 2 and x.std() > 0 else 0.0
|
||||
|
||||
|
||||
def _dd(x):
|
||||
eq = (1 + x.fillna(0)).cumprod()
|
||||
return float(((eq - eq.cummax()) / eq.cummax()).min())
|
||||
|
||||
|
||||
def _min_hold(direction, min_hold_bars):
|
||||
"""Sopprime i flip entro min_hold_bars dall'ultimo cambio di segno (riduce il churn di segnale)."""
|
||||
if min_hold_bars <= 0:
|
||||
return direction
|
||||
out = direction.copy()
|
||||
last_flip = -10**9
|
||||
cur = out[0]
|
||||
for i in range(len(out)):
|
||||
if np.sign(out[i]) != np.sign(cur):
|
||||
if i - last_flip >= min_hold_bars:
|
||||
cur = out[i]; last_flip = i
|
||||
else:
|
||||
out[i] = cur # mantieni la posizione (flip soppresso)
|
||||
else:
|
||||
cur = out[i]
|
||||
return out
|
||||
|
||||
|
||||
def build(dfs, anchor, k, min_hold_bars, allow_short):
|
||||
"""Ritorni daily 50/50 + flip/anno + fee-drag/anno per una config di segnale (libro modeled)."""
|
||||
legs, idx_ref = [], None
|
||||
flips = 0; fee_tot = 0.0; yrs = None
|
||||
daily_sum = None
|
||||
for a in ASSETS:
|
||||
df = dfs[a]
|
||||
c = df["close"].values.astype(float)
|
||||
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0
|
||||
direction = pb._breakout_direction(df, anchor, k, allow_short)
|
||||
direction = _min_hold(direction, min_hold_bars)
|
||||
tgt = pb._vol_target(direction, df, pb.TARGET_VOL, pb.VOL_WIN_DAYS, pb.LEV_CAP)
|
||||
tgt = np.nan_to_num(tgt, nan=0.0)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
turn = np.abs(np.diff(tgt, prepend=tgt[0]))
|
||||
net = held * r - FEE_SIDE * turn
|
||||
dt = pd.to_datetime(df["datetime"], utc=True)
|
||||
s = pd.Series(net, index=dt)
|
||||
d = s.groupby(s.index.floor("1D")).sum()
|
||||
daily_sum = d if daily_sum is None else daily_sum.add(d, fill_value=0)
|
||||
flips += int((np.sign(direction[1:]) != np.sign(direction[:-1])).sum())
|
||||
fee_tot += float((FEE_SIDE * turn).sum())
|
||||
yrs = (dt.iloc[-1] - dt.iloc[0]).days / 365.25
|
||||
daily = WEIGHT * daily_sum
|
||||
return daily, flips / 2 / yrs, WEIGHT * fee_tot / yrs # flips mediati sulle 2 gambe
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" PREVDAY turnover-reduction — la fee viene dai FLIP. Si taglia senza perdere l'edge?")
|
||||
print("=" * 104)
|
||||
dfs = {a: load(a, "1h").reset_index(drop=True) for a in ASSETS}
|
||||
tp = to_daily(_tp01_returns())
|
||||
|
||||
def line(label, daily, flips, fee):
|
||||
J = pd.concat({"TP": tp, "PV": daily}, axis=1, sort=True).dropna(); JH = J[J.index >= HOLD]
|
||||
b = 0.8 * J["TP"] + 0.2 * J["PV"]; bh = 0.8 * JH["TP"] + 0.2 * JH["PV"]
|
||||
upl_h = _sh(bh) - _sh(JH["TP"])
|
||||
print(f" {label:<26s} flip/yr {flips:5.0f} fee {fee*100:4.2f}% "
|
||||
f"FULL {_sh(J['PV']):+5.2f} HOLD {_sh(JH['PV']):+5.2f} DD {_dd(J['PV'])*100:4.0f}% "
|
||||
f"corrTP {J['PV'].corr(J['TP']):+.2f} blendHOLDupl {upl_h:+.2f}")
|
||||
|
||||
print(f" [TP01 solo: FULL {_sh(tp.dropna()):+.2f} HOLD {_sh(tp[tp.index>=HOLD]):+.2f}]\n")
|
||||
|
||||
print(" -- BASE (congelato: anchor=1, k=0.30, no min-hold, long-short) --")
|
||||
d, f, fee = build(dfs, 1, 0.30, 0, True); line("BASE", d, f, fee)
|
||||
|
||||
print("\n -- BUFFER_K piu' ampio (break piu' deciso) --")
|
||||
for k in (0.50, 0.75, 1.00):
|
||||
d, f, fee = build(dfs, 1, k, 0, True); line(f"k={k:.2f}", d, f, fee)
|
||||
|
||||
print("\n -- ANCHOR_DAYS multi-giorno (range piu' largo) --")
|
||||
for an in (2, 3, 5):
|
||||
d, f, fee = build(dfs, an, 0.30, 0, True); line(f"anchor={an}", d, f, fee)
|
||||
|
||||
print("\n -- MIN_HOLD (no flip entro N ore) --")
|
||||
for mh in (24, 72):
|
||||
d, f, fee = build(dfs, 1, 0.30, mh, True); line(f"min_hold={mh}h", d, f, fee)
|
||||
|
||||
print("\n -- combo low-turnover (k=0.75 + anchor=2 + min_hold=24h) --")
|
||||
d, f, fee = build(dfs, 2, 0.75, 24, True); line("combo-LT", d, f, fee)
|
||||
|
||||
print("\n -- LONG-ONLY vs LONG-SHORT (isola la gamba short = hedge del blocker #1) --")
|
||||
d, f, fee = build(dfs, 1, 0.30, 0, False); line("long-only (no short)", d, f, fee)
|
||||
d, f, fee = build(dfs, 1, 0.30, 0, True); line("long-short (BASE)", d, f, fee)
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user