From b388c462e83de2418d64dc91072575109ee03264 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 21 Jun 2026 19:45:54 +0000 Subject: [PATCH] research(intraday): PREVDAY turnover-reduction (no free lunch) + long-only probe (e' un HEDGE) La fee (~2.6%/anno) viene dai ~70 flip/anno, non dal churn sub-dollaro (da fill_haircut). Sweep delle leve che riducono i flip a livello di segnale (buffer k, anchor multi-giorno, min-hold): - allargare buffer/anchor taglia fee e turnover ma l'uplift hold-out del blend cala monotono (k0.30->0.50->0.75 = upl 0.56->0.40->0.00); anchor multi-giorno tutto peggio (conferma anchor=1). - min_hold=24h e' l'unico ritocco quasi-gratis (upl 0.56->0.60) ma peggiora il DD -27%->-32%. - la config congelata e' gia' sulla frontiera efficiente turnover<->edge -> nessun cambio. Bonus blocker #1: long-only vs long-short. long-only ha Sharpe standalone PIU' ALTO (1.55 vs 1.23) ma corr a TP01 +0.64 e blend uplift solo +0.09. TUTTO il valore di portafoglio e' la gamba SHORT (decorrelazione 0.64->0.15, uplift 0.09->0.56). PREVDAY non e' alpha: e' un HEDGE di regime-down (costa nel toro, paga nell'orso 2022/2025-26), additivo alla flat-stance di TP01. Restano i blocker null-corr-zero e tail-luck. Forward-monitor invariato; eventuale ruolo = overlay di tail-hedge. Diario: 2026-06-21-prevday-turnover-and-hedge.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-21-prevday-turnover-and-hedge.md | 64 +++++++++ scripts/research/intraday/prevday_turnover.py | 134 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 docs/diary/2026-06-21-prevday-turnover-and-hedge.md create mode 100644 scripts/research/intraday/prevday_turnover.py diff --git a/docs/diary/2026-06-21-prevday-turnover-and-hedge.md b/docs/diary/2026-06-21-prevday-turnover-and-hedge.md new file mode 100644 index 0000000..6cf6fe6 --- /dev/null +++ b/docs/diary/2026-06-21-prevday-turnover-and-hedge.md @@ -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. diff --git a/scripts/research/intraday/prevday_turnover.py b/scripts/research/intraday/prevday_turnover.py new file mode 100644 index 0000000..b881710 --- /dev/null +++ b/scripts/research/intraday/prevday_turnover.py @@ -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()