From 043f141bf1f1b5815b7cd7db1b5f0d632990bdca Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 21 Jun 2026 19:56:11 +0000 Subject: [PATCH] research(intraday): PREVDAY block-bootstrap -> coda-fortuna vs persistente (blocker #2/#3) Chiarimento: il "top-5 giorni = 76-83%" del diario era sulle gambe REVERT scartate, non su PREVDAY (breakout). Test su PREVDAY stesso + gamba short (= tutto il valore). Block bootstrap circolare 20g B=3000. [A] Concentrazione: PREVDAY-full NON e' piu' coda-fortuna di TP01 (top5 22% vs 19%; 14.3% dei giorni per il 50% del gain vs 8.0% -> piu' distribuito). MA la gamba short e' tail-dipendente (top5=130% del netto: togliendo i 5 giorni migliori va in perdita; sono i giorni-crash). [B] Bootstrap: full robustissimo (uplift mediana +0.28, 99% dei resample >0); hold-out regge con coda piu' larga (uplift mediana +0.53, 93% >0, 5deg pctl appena negativo per hold-out corto + short tail-dipendente). Verdetto: #3 tail-luck DECLASSATO per PREVDAY-full, CONFERMATO per la gamba short (payoff grumoso, su <10 giorni-crash/anno); #2 null-corr-zero RIDIMENSIONATO (uplift genuinamente positivo, era efficienza relativa). Sintesi trilogia: PREVDAY = tail-hedge legittimo e bootstrap-robusto, eseguibile a taglia reale, payoff concentrato sui crash -> candidato overlay tail-hedge, non sleeve-alpha. Forward-monitor. Diario: 2026-06-21-prevday-bootstrap.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/diary/2026-06-21-prevday-bootstrap.md | 68 +++++++++ .../research/intraday/prevday_bootstrap.py | 142 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 docs/diary/2026-06-21-prevday-bootstrap.md create mode 100644 scripts/research/intraday/prevday_bootstrap.py diff --git a/docs/diary/2026-06-21-prevday-bootstrap.md b/docs/diary/2026-06-21-prevday-bootstrap.md new file mode 100644 index 0000000..d21aa61 --- /dev/null +++ b/docs/diary/2026-06-21-prevday-bootstrap.md @@ -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. diff --git a/scripts/research/intraday/prevday_bootstrap.py b/scripts/research/intraday/prevday_bootstrap.py new file mode 100644 index 0000000..9289884 --- /dev/null +++ b/scripts/research/intraday/prevday_bootstrap.py @@ -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()