diff --git a/docs/diary/2026-06-21-prevday-fill-haircut.md b/docs/diary/2026-06-21-prevday-fill-haircut.md new file mode 100644 index 0000000..ab8568c --- /dev/null +++ b/docs/diary/2026-06-21-prevday-fill-haircut.md @@ -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. diff --git a/scripts/research/intraday/fill_haircut.py b/scripts/research/intraday/fill_haircut.py new file mode 100644 index 0000000..119c802 --- /dev/null +++ b/scripts/research/intraday/fill_haircut.py @@ -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()