From 7534b08be0ac1df086ce91317eab220309cb4a9b Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 18 Jul 2026 15:03:44 +0000 Subject: [PATCH] =?UTF-8?q?research(weekend):=20ondata=20ven->lun=20SCARTA?= =?UTF-8?q?TA=204/4=20=E2=80=94=20famiglia=20calendario=20SATURA;=20weeken?= =?UTF-8?q?d=20=3D=2038%=20del=20gross=20TP01=20(mai=20de-esporre)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 agenti (drift/cond/intra/overlay), 375 trial: il drift weekend e' beta B&H, i gate condizionali sono TP01 re-timed, l'intraday weekend e' moneta simmetrica (CME-gap morto anche lordo). Fatto strutturale: TP01-weekend-flat = danno certo (dSh -0.46, P=1.000) -> ogni proposta "risk-off weekend" parte REFUTED salvo null de-levering. Chiusa la famiglia calendario su BTC/ETH (SEA+expiry+event-clock +weekend). Book/pesi INVARIATI. Diario 2026-07-17-weekend-window.md. gitignore: + data/live/ (log esecuzioni book live, stato runtime del conto reale) Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + CLAUDE.md | 20 ++ docs/diary/2026-07-17-weekend-window.md | 111 ++++++ scripts/research/r0717_wk_cond.py | 334 ++++++++++++++++++ scripts/research/r0717_wk_drift.py | 247 +++++++++++++ scripts/research/r0717_wk_intra.py | 451 ++++++++++++++++++++++++ scripts/research/r0717_wk_overlay.py | 418 ++++++++++++++++++++++ 7 files changed, 1584 insertions(+) create mode 100644 docs/diary/2026-07-17-weekend-window.md create mode 100644 scripts/research/r0717_wk_cond.py create mode 100644 scripts/research/r0717_wk_drift.py create mode 100644 scripts/research/r0717_wk_intra.py create mode 100644 scripts/research/r0717_wk_overlay.py diff --git a/.gitignore b/.gitignore index a2a560c..6d415e9 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ scripts/research/blind/leaderboard.json data/paper_prevday/ data/paper_combo/ data/paper_statarb/ + +# log esecuzioni del book live (stato runtime, contiene fill/fee del conto reale) +data/live/ diff --git a/CLAUDE.md b/CLAUDE.md index ac7e6ff..8dceebd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -309,6 +309,26 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis alcuni sub-min-order a $600. **Regola candidata:** il win-rate di uno schema parziale+BE non è merito (≈1/(1+rr1)); convertire ogni claim "WR X%" in **expectancy R netto fee** prima di crederci (helper `winrate_is_a_knob()`). Diario `2026-07-07-crt-topdown-74winrate.md`. Book/pesi INVARIATI. +- **Ondata WEEKEND ven→lun (2026-07-17, 4 agenti) — SCARTATO 4/4; famiglia CALENDARIO dichiarata + SATURA; 1 fatto strutturale difensivo sul book.** Goal "strategie che lavorino solo ven→lun a + timing diversi". (1) **WK-DRIFT** (144 trial, griglia ore ven×lun × L/S × asset): il drift weekend + è **beta B&H a esposizione ridotta** — null weekday: Fri = 71° pctl, sotto Mon e Sun (nessuna + specialità); DSR 0.001; hold-out negativo **anche a fee zero**; boundary ARTIFACT-RISK. (2) + **WK-COND** (5 gate causali × 9 timing = 99 combo): 5/5 earns_slot=False, boundary ARTIFACT-RISK + 5/5; il gate TSMOM è TP01-in-weekend (β 0.67, re-timing del trend); FOLLOW-del-venerdì si inverte + OOS (il lead prevday NON si trasferisce al weekend); l'unica variante HOLD+ (vol HIGH70) non è + selezionabile in-sample = trappola selection-on-holdout. (3) **WK-INTRA** (132 celle 1h/15m: + Donchian weekend, gap "CME" dom22, magnete-livelli-venerdì): ~50% celle LORDE positive = **moneta + simmetrica** (diagnostica nuova di famiglia-rumore); il claim CME-gap è morto **anche lordo**; + magnete del venerdì inesistente; FOLLOW>FADE riconfermato (3ª volta); a $600 l'esecuzione non è + il vincolo. (4) **WK-OVERLAY** (lato eseguibile): **il weekend porta il 38% del gross di TP01 nel + 31% del tempo** (positivo ogni anno) → TP01-weekend-flat è **attivamente dannoso** (ΔSh −0.46, + P(danno)=1.000 bootstrap, banda 6/6 negativa, non-fee); half = de-levering pagato con fee + (k=0.755 lo domina); boost ×1.5 n.s. → **regola: non de-esporre il weekend del book; ogni futura + proposta "risk-off weekend" parte REFUTED** salvo null de-levering superato. Chiusura di famiglia: + SEA + expiry + event-clock + 375 trial weekend ⇒ su BTC/ETH nessun edge di calendario/finestra + oraria; riaprire solo con meccanismo non-calendario. Book/pesi INVARIATI. Diario + `2026-07-17-weekend-window.md`; script `scripts/research/r0717_wk_{drift,cond,intra,overlay}.py`. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-17-weekend-window.md b/docs/diary/2026-07-17-weekend-window.md new file mode 100644 index 0000000..9a599e5 --- /dev/null +++ b/docs/diary/2026-07-17-weekend-window.md @@ -0,0 +1,111 @@ +# 2026-07-17 — Ondata WEEKEND (ven→lun, timing multipli, 4 agenti): 0 edge, 1 fatto strutturale sul book + +**Goal:** "cerca strategie che con diversi timing lavorino solo tra venerdì e lunedì, usa diversi +agenti." Quattro filoni in parallelo su BTC/ETH 1h/15m certificati, harness `altlib` condiviso, +metodologia onesta piena (selezione in-sample-only pre-2025, banda di timing mai la best-cell, +deflated-Sharpe sull'intera famiglia, `day_boundary_robust`, fee nette 0.10% RT + sweep, +`causality_ok`, realismo small-cap $600). Prior vincolante: day-of-week già morto (SEA 2026-07-02), +anchor timing-luck su 3/3 sleeve ancorati, CRT triplo-refutato. + +**Esito: SCARTATO 4/4. Nessun LEAD, nessun forward-monitor, book/pesi INVARIATI.** +Il fatto nuovo utile è *difensivo*: il weekend è leggermente sovra-denso per TP01 (38% del gross +nel 31% del tempo) → togliere esposizione weekend al book è attivamente dannoso. + +--- + +## Filone 1 — WK-DRIFT: drift puro ven→lun (`r0717_wk_drift.py`) + +Griglia 6 H_in (ven) × 6 H_out (lun) × long/short × BTC/ETH = **144 trial**. + +- Cella scelta in-sample (LONG ETH ven08→lun16): IS +1.07 → FULL +0.84 → **HOLD −0.14** + (ret hold-out −23%), maxDD 69%. Banda long: FULL med +0.57, HOLD med **−0.21**. +- **DSR 0.001/144** (max atteso sotto il null: 1.97 ≫ 0.84 osservato). +- **Null weekday: il venerdì NON è speciale** — mediana IS della famiglia long per ancora: + Fri +0.74 = 71° percentile, sotto Mon (+0.78) e Sun (+0.78). Qualsiasi finestra ~3g long fa lo + stesso = beta di buy&hold a esposizione ridotta (B&H daily Sh FULL: BTC 0.79 / ETH 0.86 ≈ il max + della griglia). +- A fee ZERO l'hold-out resta negativo (−0.03) → non è morte-per-fee, l'edge OOS non esiste. +- `day_boundary_robust`: ARTIFACT-RISK (uplift flippa spostando il confine UTC, spread 0.43). + +**SCARTATO** — il "weekend drift" è B&H diluito nel tempo (vive del toro 2019-21, −58% nel 2022, +−33% nel 2025). + +## Filone 2 — WK-COND: weekend condizionato da gate causali (`r0717_wk_cond.py`) + +5 gate causali noti all'ingresso del venerdì × 9 timing × direzioni = **99 combo** (198 stream): +(a) TSMOM concorde, (b) FOLLOW/FADE del venerdì, (c) follow/fade della settimana, (d) regime vol +RV30 (percentile espandente causale), (e) breakout dei livelli del giovedì. + +- **5/5 gate: earns_slot=False, DSR ≤0.30, `day_boundary_robust` = ARTIFACT-RISK 5/5** (la stessa + firma che uccise `open_drive`: l'etichetta "weekend" non è dove vive alcun segnale). +- (a) TSMOM-gate = **TP01-in-weekend per costruzione** (β 0.67, uplift hold −0.015, jackknife + −0.18): re-timing del trend, non alpha. Lo short weekend perde perfino col trend a favore + (mediana IS −0.15). +- (b) FOLLOW>FADE regge in-sample ma **si inverte in hold-out** → rumore; il lead prevday-FOLLOW + non si trasferisce alla finestra weekend. Unico is_hedge=True dell'ondata, ma con HOLD −0.92. +- (d) l'unica variante con HOLD positivo (HIGH70 +0.19) è **non selezionabile in-sample** + (IS +0.22): crederle sarebbe selection-on-holdout da manuale. +- A fee zero l'hold-out resta negativo per 4/5 gate → non è morte-per-fee. + +**SCARTATO** — e con questo (99 trial qui + SEA + expiry-calendar 2026-07-02) **la famiglia +calendario su BTC/ETH è satura**: riaprirla solo con un meccanismo nuovo non-calendario. + +## Filone 3 — WK-INTRA: intraday dentro la finestra (`r0717_wk_intra.py`) + +**132 celle** su 1h+15m, tre sotto-filoni: (A) Donchian weekend-only follow/fade, +(B) gap dom 22:00 vs close ven 21:00 (meccanizzazione onesta del claim "CME gap"), +(C) livelli H/L/C del venerdì come magnete. + +- Quadro: celle IS **lorde** positive = 64/132 ≈ 50% = **moneta simmetrica** (rumore prima ancora + delle fee); IS nette >0: 18/132. DSR famiglia 0.068 → 0.001 per filone. +- (A) la cella IS (Donchian 48h follow) è trend-2022 su ETH travestito (2022 +96%; BTC per-anno + negativo nel toro); su BTC muore di fee E il lordo è sotto soglia. +- (B) **il claim "CME gap" è morto**: nessuna config ha IS netto >0; la best (revert 2%) fa IS + −0.09 *lordo +0.03* → l'edge non esiste nemmeno lordo; il suo HOLD +0.5 è la firma esatta del + selection-on-holdout. Nessuna centratura reale sulle 22:00 (shift-test). +- (C) il "magnete del venerdì" non esiste in nessuna direzione (L-fade: IS lordo +0.12 → + HOLD −0.97/−0.77; DSR 0.001). FOLLOW>FADE riconfermato (le celle fade sono le peggiori ovunque). +- Eseguibilità $600 NON è il vincolo (0% ordini sotto min-order, haircut 0.00): manca l'edge. + +**SCARTATO 3/3.** + +## Filone 4 — WK-OVERLAY: esposizione weekend di TP01 sul book (`r0717_wk_overlay.py`) + +Il lato eseguibile: conviene uscire/dimezzare/aumentare TP01 nel weekend? Griglia timing + null +de-levering (lezione DVOL 2026-06-26) + bootstrap settimanale appaiato + placebo rotazioni + smallcap. + +- **Fatto strutturale (attribuzione gross):** la finestra ven20→lun00 è il 30.9% del tempo e porta + il **38% del gross di TP01** (+44.2pp su +117.8pp, positivo ogni anno) — il weekend non è tempo + morto per uno slow-trend crypto 24/7, è sovra-denso. +- **WK-FLAT: REFUTED, attivamente dannoso** — ΔSh FULL −0.46 (banda 6/6 negativa), + **P(danno)=1.000** al bootstrap; a fee zero resta −0.41; il de-levering k=0.88 compra lo stesso + DD a Sharpe pieno (1.29 vs 0.83). Placebo: il weekend è tra i giorni *peggiori* da flattare (5/7). +- **WK-HALF: REFUTED** — solo de-levering pagato con fee (k=0.755 a pari DD: +0.20 Sh in più). +- **WK-BOOST ×1.5: neutro** — coerente col controllo simmetrico ma non significativo (P=0.12) e a + pari DD la leva uniforme rende di più sul CAGR → nessuna azione. +- **WK-ONLY**: Sh 1.04 da solo, ma k=0.54 uniforme lo domina → diagnostica, non strategia. +- Small-cap $600: haircut ≈0 su tutte — l'overlay si eseguirebbe fedelmente; non conviene e basta. + +**REFUTED/nessuna azione — book e pesi INVARIATI.** + +--- + +## Lezioni codificabili + +1. **Famiglia calendario BTC/ETH = SATURA.** SEA (day-of-week) + expiry-calendar + event-clock + (2026-07-02) + 144+99+132 trial weekend (oggi) convergono: su BTC/ETH non vive alcun edge di + calendario/finestra-oraria. Prossimi claim di questa famiglia partono da prior ~0: esigere il + pacchetto completo (null weekday-ancora + day_boundary + DSR famiglia) prima ancora di guardare + l'hold-out. +2. **"~50% di celle lorde positive" = rumore simmetrico**: diagnostica rapida di famiglia morta + prima ancora del netting fee (WK-INTRA). +3. **Il gate condizionato non salva una finestra morta**: un gate causale su una finestra senza + edge o replica il trend (TP01 travestito) o seleziona rumore che flippa OOS (5/5 in WK-COND). +4. **Non de-esporre il weekend del book**: il 38%-in-31%-del-tempo di TP01 è la ragione + quantificata per cui il libro resta 24/7. Qualsiasi futura proposta "risk-off weekend" parte + REFUTED salvo nuovo evidence (null de-levering obbligatorio). +5. Riconferme indipendenti: FOLLOW>FADE (3ª volta); "CME gap" e "magnete del venerdì" = + folklore che non sopravvive alla meccanizzazione onesta. + +**Script:** `scripts/research/r0717_wk_{drift,cond,intra,overlay}.py` (standalone, `uv run python`, +nessun file esistente toccato). **Nessun cambiamento a strategie, config, pesi o esecuzione live.** diff --git a/scripts/research/r0717_wk_cond.py b/scripts/research/r0717_wk_cond.py new file mode 100644 index 0000000..3b61f30 --- /dev/null +++ b/scripts/research/r0717_wk_cond.py @@ -0,0 +1,334 @@ +"""r0717_wk_cond — WK-COND: esposizione weekend (ven->lun) CONDIZIONATA da gate causali. + +DOMANDA: esiste un gate causale (noto al venerdi' H_in) che rende la finestra weekend un +edge, dove il drift nudo non lo e' (day-of-week nudo gia' morto, SEA ~0)? + +METODO (onesto, vettoriale su 1h, harness altlib): + * Finestra: ven H_in -> lun H_out, H_in in {12,16,20}, H_out in {0,8,16} (9 timing). + * Convenzione eval_weights: target[i] deciso con dati <= close[i], tenuto nella barra + i+1. Le barre 1h sono open-labeled -> il decision time della barra i e' datetime[i]+1h. + Barra d'ingresso = quella il cui CLOSE cade a ven H_in:00 UTC (decisione con dati fino + a ven H_in:00 esatte, nessun leak). Ultima barra target = close lun H_out-1h -> l' + esposizione reale copre esattamente [ven H_in, lun H_out). + * Gate (tutti causali, calcolati al close della barra d'ingresso): + A TSMOM multi-orizzonte 30/90/180g (LO long-se-up / SO short-se-down / LS entrambi) + B FOLLOW del venerdi' (open ven 00:00 -> close H_in), + FADE come controllo + C ritorno settimana (lun 00:00 -> ven H_in), follow + fade + D regime vol: percentile ESPANDENTE causale della RV30g (LOW: pct<=0.30 / HIGH: >=0.70) + E prevday level: close H_in vs high/low del GIOVEDI' -> breakout-follow L/S + F NAKED long (riferimento: il drift nudo NON e' un edge) + * Ogni cella = (gate-variante x timing); valutata su BTC ed ETH e come 50/50 daily + (convenzione candidate_daily). TUTTI i trial contano per il deflated_sharpe. + * Selezione cella SOLO in-sample (Sharpe 50/50 daily pre-2025), poi hold-out + banda + dei 9 timing (lezione anchor timing-luck 2026-07-02). + * Per ogni gate: study_marginal vs TP01 (corr/earns_slot/is_hedge), fee sweep, per-anno, + day_boundary_robust (il segnale E' calendario-dipendente), causality_ok. + +TRIAL: 3+2+2+2+1+1 = 11 varianti x 9 timing = 99 combo (x2 asset = 198 stream valutati). + +VINCOLI: nessun file esistente modificato; niente rete (dati locali certificati). +""" +from __future__ import annotations + +import sys + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +H_INS = (12, 16, 20) # venerdi' UTC, ora di ingresso +H_OUTS = (0, 8, 16) # lunedi' UTC, ora di uscita (0 = mezzanotte dom->lun) +ASSETS = tuple(al.CERTIFIED) +HOLDOUT = al.HOLDOUT + +# =========================================================================== +# SEGNALI CAUSALI — memo per-df (chiave su timestamp: regge shift di calendario +# di day_boundary_robust e troncamenti di causality_ok senza contaminazioni). +# Tutti i valori all'indice i usano SOLO dati <= close[i]. +# =========================================================================== +_SIG_MEMO: dict = {} + + +def get_signals(df: pd.DataFrame) -> dict: + key = (int(df["timestamp"].iloc[0]), int(df["timestamp"].iloc[-1]), len(df)) + if key in _SIG_MEMO: + return _SIG_MEMO[key] + c = df["close"].values.astype(float) + o = df["open"].values.astype(float) + h = df["high"].values.astype(float) + lo_ = df["low"].values.astype(float) + dt = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + close_dt = dt + pd.Timedelta(hours=1) # decision time della barra i + + # (A) TSMOM multi-orizzonte 30/90/180g su close 1h (voto di segni, come TP01) + votes = np.zeros(len(c)) + for hd in (30, 90, 180): + hb = hd * 24 + s = np.full(len(c), np.nan) + if len(c) > hb: + s[hb:] = np.sign(c[hb:] / c[:-hb] - 1.0) + votes += np.nan_to_num(s) + tsmom = np.sign(votes) # -1 / 0 / +1 + + # (B) direzione intraday del giorno corrente: close[i] vs OPEN del giorno UTC + day = np.asarray(dt.normalize()) + day_open = pd.Series(o).groupby(day).transform("first").values + intraday = np.sign(c / day_open - 1.0) + + # (C) direzione della settimana: close[i] vs OPEN del lunedi' 00:00 della settimana + week = np.asarray((dt - pd.to_timedelta(dt.dayofweek, unit="D")).normalize()) + week_open = pd.Series(o).groupby(week).transform("first").values + week_dir = np.sign(c / week_open - 1.0) + + # (D) percentile espandente CAUSALE della RV 30g (rank del valore corrente vs storia) + r = al.simple_returns(c) + rv = al.realized_vol(r, 30 * 24, 24 * 365.25) + volpct = pd.Series(rv).expanding(min_periods=30 * 24).rank(pct=True).values + + # (E) breakout vs high/low del giorno PRECEDENTE completo (per ven = giovedi') + dhi = pd.Series(h).groupby(day).max() + dlo = pd.Series(lo_).groupby(day).min() + prev_hi = dhi.shift(1).reindex(day).values + prev_lo = dlo.shift(1).reindex(day).values + brk = np.where(c > prev_hi, 1.0, np.where(c < prev_lo, -1.0, 0.0)) + brk[~np.isfinite(prev_hi)] = 0.0 + + S = dict(tsmom=tsmom, intraday=intraday, week=week_dir, volpct=volpct, brk=brk, + cdw=close_dt.dayofweek.values, chr=close_dt.hour.values) + if len(_SIG_MEMO) > 48: + _SIG_MEMO.clear() + _SIG_MEMO[key] = S + return S + + +# =========================================================================== +# GATE (variante -> direzione al momento dell'ingresso, da segnali causali) +# =========================================================================== +GATE_FAMILIES: dict[str, dict] = { + "A-TSMOM": { + "LO": lambda S: np.clip(S["tsmom"], 0.0, 1.0), # long solo se trend up + "SO": lambda S: np.clip(S["tsmom"], -1.0, 0.0), # short solo se trend down + "LS": lambda S: S["tsmom"], # entrambi + }, + "B-FRIDIR": { + "FOLLOW": lambda S: S["intraday"], + "FADE": lambda S: -S["intraday"], + }, + "C-WEEKDIR": { + "FOLLOW": lambda S: S["week"], + "FADE": lambda S: -S["week"], + }, + "D-VOLREG": { + "LOW30": lambda S: np.where(S["volpct"] <= 0.30, 1.0, 0.0), + "HIGH70": lambda S: np.where(S["volpct"] >= 0.70, 1.0, 0.0), + }, + "E-PREVDAY": { + "BRKFOLLOW": lambda S: S["brk"], + }, + "F-NAKED": { + "LONG": lambda S: np.ones(len(S["tsmom"])), + }, +} + + +def make_target(fam: str, var: str, hin: int, hout: int): + """target_fn(df) -> posizione per barra. Direzione congelata alla barra d'ingresso + (close = ven H_in) e tenuta su tutta la finestra [ven H_in, lun H_out).""" + gate_fn = GATE_FAMILIES[fam][var] + + def target_fn(df: pd.DataFrame) -> np.ndarray: + S = get_signals(df) + dirs = np.nan_to_num(np.asarray(gate_fn(S), float)) + dw, hr = S["cdw"], S["chr"] + # barre target = quelle il cui CLOSE cade in [ven H_in, lun H_out) + in_win = ((dw == 4) & (hr >= hin)) | (dw == 5) | (dw == 6) + if hout > 0: + in_win |= (dw == 0) & (hr < hout) + entry = (dw == 4) & (hr == hin) + sig = pd.Series(np.where(entry, dirs, np.nan)).ffill().values + return np.where(in_win, np.nan_to_num(sig), 0.0) + + target_fn.__name__ = f"wk_{fam}_{var}_F{hin}_M{hout}" + return target_fn + + +# =========================================================================== +# VALUTAZIONE DI UNA CELLA (per-asset 1h + 50/50 daily, split in/hold) +# =========================================================================== +def combined_daily(target_fn, fee_side: float = al.FEE_SIDE) -> pd.Series: + series = {} + for a in ASSETS: + df = al.get(a, "1h") + ev = al.eval_weights(df, target_fn(df), fee_side=fee_side) + series[a] = pd.Series(ev["net"], index=ev["idx"]) + J = pd.concat(series, axis=1, join="inner").fillna(0.0) + return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]) + + +def run_cell(fam: str, var: str, hin: int, hout: int) -> dict: + tfn = make_target(fam, var, hin, hout) + per_asset = {} + for a in ASSETS: + df = al.get(a, "1h") + ev = al.eval_weights(df, tfn(df)) + per_asset[a] = dict(full_sh=ev["full"]["sharpe"], hold_sh=ev["holdout"].get("sharpe", 0.0), + dd=ev["full"]["maxdd"], tim=ev["time_in_market"], + turnover=ev["turnover_per_year"], yearly=ev["yearly"]) + D = combined_daily(tfn) + ins, hold = D[D.index < HOLDOUT], D[D.index >= HOLDOUT] + eqf = np.cumprod(1 + D.values) + pk = np.maximum.accumulate(eqf) + return dict(fam=fam, var=var, hin=hin, hout=hout, target_fn=tfn, daily=D, + per_asset=per_asset, + is_sh=round(al._sh(ins), 3), full_sh=round(al._sh(D), 3), + hold_sh=round(al._sh(hold), 3), + dd_comb=round(float(np.max((pk - eqf) / pk)), 4), + ret_hold=round(float(np.prod(1 + hold.values) - 1), 4)) + + +def fee_sweep_cell(cell: dict) -> dict: + out = {} + for f in al.FEE_SWEEP: + D = combined_daily(cell["target_fn"], fee_side=f) + ins, hold = D[D.index < HOLDOUT], D[D.index >= HOLDOUT] + out[f"{2 * f * 100:.2f}%RT"] = dict(is_sh=round(al._sh(ins), 2), + full=round(al._sh(D), 2), + hold=round(al._sh(hold), 2)) + return out + + +def yearly_comb(cell: dict) -> dict: + D = cell["daily"] + out = {} + for y, g in D.groupby(D.index.year): + out[int(y)] = dict(ret=round(float(np.prod(1 + g.values) - 1), 4), + sh=round(al._sh(g), 2)) + return out + + +def gate_selectivity(cell: dict) -> dict: + """Quota di venerdi' in cui il gate apre una posizione (in-sample / hold-out), BTC.""" + df = al.get("BTC", "1h") + S = get_signals(df) + dirs = np.nan_to_num(np.asarray(GATE_FAMILIES[cell["fam"]][cell["var"]](S), float)) + entry = (S["cdw"] == 4) & (S["chr"] == cell["hin"]) + dt = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + m_ins = entry & np.asarray(dt < HOLDOUT) + m_hold = entry & np.asarray(dt >= HOLDOUT) + def frac(m): + return round(float(np.mean(dirs[m] != 0)), 3) if m.sum() else None + def longshare(m): + on = dirs[m][dirs[m] != 0] + return round(float(np.mean(on > 0)), 3) if len(on) else None + return dict(n_fridays=int(entry.sum()), on_ins=frac(m_ins), on_hold=frac(m_hold), + long_share_ins=longshare(m_ins)) + + +# =========================================================================== +# MAIN +# =========================================================================== +def main() -> None: + print("=" * 100) + print("WK-COND — weekend ven->lun condizionato da gate causali | 1h BTC/ETH certificati") + print(f"timing: H_in {H_INS} x H_out {H_OUTS} | fee {2 * al.FEE_SIDE * 100:.2f}%RT | HOLDOUT {HOLDOUT.date()}") + print("=" * 100) + + # ---- sweep completo ----------------------------------------------------------- + rows = [] + for fam, variants in GATE_FAMILIES.items(): + for var in variants: + for hin in H_INS: + for hout in H_OUTS: + rows.append(run_cell(fam, var, hin, hout)) + all_full = [r["full_sh"] for r in rows] + n_combo = len(rows) + print(f"\nTRIAL: {n_combo} combo (gate-variante x timing) x {len(ASSETS)} asset = " + f"{n_combo * len(ASSETS)} stream valutati. DSR calcolato vs tutte le {n_combo} combo 50/50.") + + # ---- tabella compatta di famiglia (mediane per variante) ---------------------- + print("\n--- PANORAMICA per variante (mediana e banda sui 9 timing, 50/50 daily) ---") + hdr = f"{'variante':<22}{'IS med [min,max]':>24}{'FULL med':>10}{'HOLD med [min,max]':>26}" + print(hdr) + for fam, variants in GATE_FAMILIES.items(): + for var in variants: + sub = [r for r in rows if r["fam"] == fam and r["var"] == var] + iss = [r["is_sh"] for r in sub]; hs = [r["hold_sh"] for r in sub] + fs = [r["full_sh"] for r in sub] + print(f"{fam + '/' + var:<22}" + f"{np.median(iss):>8.2f} [{min(iss):+.2f},{max(iss):+.2f}]" + f"{np.median(fs):>10.2f}" + f"{np.median(hs):>12.2f} [{min(hs):+.2f},{max(hs):+.2f}]") + + # ---- selezione IN-SAMPLE-ONLY per gate + verifica cella ----------------------- + print("\n" + "=" * 100) + print("SELEZIONE IN-SAMPLE-ONLY (max Sharpe 50/50 daily pre-2025) + verifica per gate") + print("=" * 100) + summary = {} + for fam in GATE_FAMILIES: + sub = [r for r in rows if r["fam"] == fam] + best = max(sub, key=lambda r: r["is_sh"]) + # banda dei 9 timing per la variante scelta (lezione anchor timing-luck) + band = sorted(r["hold_sh"] for r in sub if r["var"] == best["var"]) + band_is = sorted(r["is_sh"] for r in sub if r["var"] == best["var"]) + pctl = float(np.mean([b <= best["hold_sh"] for b in band])) + dsr, sr0 = al.deflated_sharpe(al._sh(best["daily"]), all_full, best["daily"].values) + name = f"{fam}/{best['var']} F{best['hin']}->M{best['hout']}" + print(f"\n### {name} (cella scelta in-sample su {len(sub)} trial del gate)") + print(f" 50/50: IS {best['is_sh']:+.2f} | FULL {best['full_sh']:+.2f} | " + f"HOLD {best['hold_sh']:+.2f} (ret hold {best['ret_hold'] * 100:+.1f}%) | DD {best['dd_comb'] * 100:.1f}%") + pa = best["per_asset"] + for a in ASSETS: + print(f" {a}: full {pa[a]['full_sh']:+.2f} hold {pa[a]['hold_sh']:+.2f} " + f"DD {pa[a]['dd']*100:.0f}% tim {pa[a]['tim']*100:.0f}% turn/y {pa[a]['turnover']:.0f}") + print(f" banda 9 timing (var {best['var']}): HOLD [{band[0]:+.2f} .. med {np.median(band):+.2f} .. {band[-1]:+.2f}] " + f"(cella scelta al {pctl * 100:.0f}° pctl) | IS [{band_is[0]:+.2f}..{band_is[-1]:+.2f}]") + print(f" deflated Sharpe vs {n_combo} trial: DSR={dsr:.3f} (null max atteso {sr0:.2f}) " + f"{'PASS' if dsr >= 0.95 else 'FAIL'}") + sel = gate_selectivity(best) + print(f" selettivita' (BTC, ven {best['hin']}h): on IS {sel['on_ins']} / HOLD {sel['on_hold']} " + f"(quota long IS {sel['long_share_ins']}) su {sel['n_fridays']} venerdi'") + cok = al.causality_ok(best["target_fn"], tf="1h", tail=400) + print(f" causality_ok: {cok['ok']} (max tail diff {cok['max_tail_diff']}, checked {cok['checked']})") + print(f" fee sweep 50/50: " + " ".join( + f"{k}: IS {v['is_sh']:+.2f}/FULL {v['full']:+.2f}/HOLD {v['hold']:+.2f}" + for k, v in fee_sweep_cell(best).items())) + yr = yearly_comb(best) + print(" per-anno 50/50: " + " ".join(f"{y}:{d['ret']*100:+.1f}%({d['sh']:+.1f})" for y, d in yr.items())) + summary[fam] = dict(best=best, dsr=dsr, band=band, name=name, + survivor=(best["is_sh"] >= 0.5)) + + # ---- marginale vs TP01 + day-boundary per ogni gate a-e ----------------------- + print("\n" + "=" * 100) + print("MARGINALE vs TP01 (study_marginal, 1h) + day_boundary_robust — gate A..E") + print("=" * 100) + for fam, info in summary.items(): + if fam == "F-NAKED": + continue + best = info["best"] + sm = al.study_marginal(f"WK-{info['name']}", best["target_fn"], tf="1h") + print("\n" + al.fmt_marginal(sm)) + dbr = al.day_boundary_robust(best["target_fn"], tf="1h") + print(f" day_boundary: {dbr['verdict']} (per_offset {dbr['per_offset']}, spread {dbr.get('spread')})") + info["sm"] = sm + info["dbr"] = dbr + + # ---- sintesi finale ------------------------------------------------------------ + print("\n" + "=" * 100) + print("SINTESI") + print("=" * 100) + for fam, info in summary.items(): + b = info["best"] + line = (f"{info['name']:<38} IS {b['is_sh']:+.2f} FULL {b['full_sh']:+.2f} " + f"HOLD {b['hold_sh']:+.2f} DSR {info['dsr']:.2f} survivor_IS={info['survivor']}") + if "sm" in info: + m = info["sm"] + line += (f" | marg={m['marginal_verdict']} earns_slot={m['earns_slot']} " + f"corr {m['marginal'].get('corr_full')} hedge={m['marginal'].get('is_hedge')}" + f" | boundary={info['dbr']['verdict']}") + print(line) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0717_wk_drift.py b/scripts/research/r0717_wk_drift.py new file mode 100644 index 0000000..b188473 --- /dev/null +++ b/scripts/research/r0717_wk_drift.py @@ -0,0 +1,247 @@ +"""r0717_wk_drift — WK-DRIFT: esposizione weekend pura (ven H_in -> lun H_out), griglia di timing. + +Domanda: la finestra ven->lun su BTC/ETH contiene drift direzionale sfruttabile NETTO fee, +robusto al timing? Prior art: SEA (day-of-week) e' gia' morta nel progetto; onere della prova ALTO. + +Metodo (harness onesto altlib): + * Famiglia: posizione (long E short = celle separate) da ven H_in a lun H_out, + H_in, H_out in {0,4,8,12,16,20} UTC, asset in {BTC, ETH} -> 6*6*2*2 = 144 trial. + * Causalita': eval_weights fa pos[t] = target[t-1] (target deciso a close[i] e' applicato + al ritorno close[i]->close[i+1]). L'intervallo detenuto da target[i] INIZIA al close + della barra i => l'indicatore di finestra si valuta su datetime[i] + 1h (close time). + Il segnale e' puro calendario (noto in anticipo) -> causale per costruzione; verificato + comunque con causality_ok. + * Selezione cella con SOLO Sharpe in-sample (pre-2025); poi si guarda l'hold-out. + * deflated_sharpe su TUTTA la famiglia (144 trial, unita' daily-compounded coerenti). + * NULL di specialita': stessa finestra (stessa lunghezza, stessi H_in/H_out) ancorata a + ogni altro giorno della settimana (lun->gio, mar->ven, ...) -> il ven->lun e' speciale + o qualsiasi finestra ~3g cattura lo stesso beta di B&H a esposizione ridotta? + + confronto capture: quota di log-return catturata vs quota di ore esposte. + * day_boundary_robust sulla cella selezionata (effetto calendario che si inverte + spostando il confine UTC = artefatto di etichettatura). + * Sweep fee per-side (0, 0.05%, 0.10%, 0.15%) sulla cella selezionata. + * Per-anno: la cella vive di 1-2 anni o e' positiva ogni anno? + +Vincoli: nessun file esistente modificato; dati solo via altlib (BTC/ETH 1h certificati). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") + +import numpy as np +import pandas as pd + +import altlib as al + +H_GRID = (0, 4, 8, 12, 16, 20) +ASSETS = ("BTC", "ETH") +DIRS = (1, -1) # long e short come celle separate +FRI = 4 # pandas weekday: Mon=0 ... Fri=4 +SPAN_DAYS = 3 # ven -> lun = anchor_day d -> (d+3) % 7 +DAY_NAMES = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"} + + +# --------------------------------------------------------------------------- +# Target di finestra calendario (causale: funzione deterministica del clock) +# --------------------------------------------------------------------------- +def make_target(h_in: int, h_out: int, direction: int, day_in: int = FRI): + """target[i] = direction se l'intervallo detenuto da target[i] — che inizia al + CLOSE della barra i (datetime[i] + 1h) — cade dentro [day_in H_in, day_out H_out). + day_out = (day_in + 3) % 7. Entrata eseguita al close di ven H_in:00, uscita al + close di lun H_out:00 => esposizione esattamente [ven H_in, lun H_out].""" + day_out = (day_in + SPAN_DAYS) % 7 + start = day_in * 24 + h_in # hour-of-week di inizio finestra + end = day_out * 24 + h_out # hour-of-week di fine finestra + + def fn(df): + dt = pd.to_datetime(df["datetime"], utc=True) + pd.Timedelta(hours=1) # close time barra i + wh = (dt.dt.weekday * 24 + dt.dt.hour).values + if start < end: + ind = (wh >= start) & (wh < end) + else: # la finestra scavalca il confine di settimana + ind = (wh >= start) | (wh < end) + return direction * ind.astype(float) + + return fn + + +def window_hours(h_in: int, h_out: int) -> int: + return (SPAN_DAYS * 24) - h_in + h_out # ven H_in -> lun H_out + + +# --------------------------------------------------------------------------- +# Metriche per cella (unita' PRIMARIA = Sharpe su ritorni daily-compounded, +# stessa convenzione di candidate_daily/_to_daily del marginal scorer) +# --------------------------------------------------------------------------- +def cell_metrics(asset: str, h_in: int, h_out: int, direction: int, + day_in: int = FRI, fee_side: float = al.FEE_SIDE, keep: bool = False) -> dict: + df = al.get(asset, "1h") + fn = make_target(h_in, h_out, direction, day_in) + ev = al.eval_weights(df, fn(df), fee_side=fee_side) + s = pd.Series(ev["net"], index=ev["idx"]) + d = al._to_daily(s) + ins = d[d.index < al.HOLDOUT] + hold = d[d.index >= al.HOLDOUT] + out = dict(asset=asset, h_in=h_in, h_out=h_out, dir=direction, day_in=day_in, + sh_is=al._sh(ins), sh_full=al._sh(d), sh_hold=al._sh(hold)) + if keep: + out["ev"] = ev + out["daily"] = d + return out + + +def band(vals: list[float]) -> str: + v = np.asarray(vals, float) + return f"min {v.min():+.2f} / med {np.median(v):+.2f} / max {v.max():+.2f}" + + +def main() -> None: + print("=" * 88) + print("WK-DRIFT r0717 — esposizione weekend pura ven->lun, griglia di timing (dati 1h certificati)") + print("=" * 88) + + # ================= 1) GRIGLIA COMPLETA (144 trial) ================= + rows = [] + for a in ASSETS: + for dr in DIRS: + for hi in H_GRID: + for ho in H_GRID: + rows.append(cell_metrics(a, hi, ho, dr)) + n_trials = len(rows) + print(f"\n[1] FAMIGLIA: {len(H_GRID)}x{len(H_GRID)} timing x {len(DIRS)} direzioni x " + f"{len(ASSETS)} asset = {n_trials} trial (fee {2*al.FEE_SIDE*100:.2f}% RT)") + + longs = [r for r in rows if r["dir"] == 1] + shorts = [r for r in rows if r["dir"] == -1] + print(f" BANDA FULL Sharpe (daily, netto) — tutte le 144 celle : {band([r['sh_full'] for r in rows])}") + print(f" BANDA HOLD Sharpe (2025+, netto) — tutte le 144 celle : {band([r['sh_hold'] for r in rows])}") + print(f" solo LONG (72): FULL {band([r['sh_full'] for r in longs])} | " + f"HOLD {band([r['sh_hold'] for r in longs])}") + print(f" solo SHORT (72): FULL {band([r['sh_full'] for r in shorts])} | " + f"HOLD {band([r['sh_hold'] for r in shorts])}") + n_pos_full = sum(1 for r in rows if r["sh_full"] > 0) + n_pos_both = sum(1 for r in rows if r["sh_full"] > 0 and r["sh_hold"] > 0) + print(f" celle FULL>0: {n_pos_full}/{n_trials} | celle FULL>0 E HOLD>0: {n_pos_both}/{n_trials}") + + # ================= 2) SELEZIONE IN-SAMPLE-ONLY ================= + chosen = max(rows, key=lambda r: r["sh_is"]) + ch = cell_metrics(chosen["asset"], chosen["h_in"], chosen["h_out"], chosen["dir"], keep=True) + ev = ch["ev"] + dirname = "LONG" if ch["dir"] == 1 else "SHORT" + wh = window_hours(ch["h_in"], ch["h_out"]) + print(f"\n[2] CELLA SELEZIONATA IN-SAMPLE (max Sharpe pre-2025 sui 144 trial):") + print(f" {dirname} {ch['asset']} ven {ch['h_in']:02d}:00 -> lun {ch['h_out']:02d}:00 UTC " + f"({wh}h esposte = {wh/168*100:.0f}% della settimana)") + print(f" Sharpe daily netto: IS {ch['sh_is']:+.2f} | FULL {ch['sh_full']:+.2f} | HOLD {ch['sh_hold']:+.2f}") + print(f" eval_weights (1h): FULL Sh {ev['full']['sharpe']:+.2f} CAGR {ev['full']['cagr']*100:+.1f}% " + f"maxDD {ev['full']['maxdd']*100:.1f}% ret {ev['full']['ret']*100:+.1f}%") + print(f" HOLD Sh {ev['holdout'].get('sharpe', 0):+.2f} " + f"ret {ev['holdout'].get('ret', 0)*100:+.1f}%") + print(f" time-in-market {ev['time_in_market']*100:.1f}% turnover/anno {ev['turnover_per_year']:.0f} " + f"=> fee drag ~{ev['turnover_per_year']*al.FEE_SIDE*100:.1f}%/anno a nozionale pieno") + top5 = sorted(rows, key=lambda r: -r["sh_is"])[:5] + print(" top-5 in-sample:") + for r in top5: + print(f" {'L' if r['dir']==1 else 'S'} {r['asset']} ven{r['h_in']:02d}->lun{r['h_out']:02d}: " + f"IS {r['sh_is']:+.2f} FULL {r['sh_full']:+.2f} HOLD {r['sh_hold']:+.2f}") + + # ================= 3) DEFLATED SHARPE sull'intera famiglia ================= + dsr, sr0 = al.deflated_sharpe(ch["sh_full"], [r["sh_full"] for r in rows], ch["daily"].values) + print(f"\n[3] DEFLATED SHARPE (famiglia {n_trials} trial): DSR = {dsr:.3f} " + f"(PASS>=0.95) | max Sharpe atteso sotto il null = {sr0:.2f} " + f"vs osservato {ch['sh_full']:+.2f}") + + # ================= 4) NULL DI SPECIALITA' — ancore weekday ================= + print(f"\n[4] NULL WEEKDAY — stessa finestra ({ch['h_in']:02d}->+3g {ch['h_out']:02d}, " + f"{dirname} {ch['asset']}) ancorata a ogni giorno:") + anchor = {} + for d0 in range(7): + m = cell_metrics(ch["asset"], ch["h_in"], ch["h_out"], ch["dir"], day_in=d0) + anchor[d0] = m + tag = " <== ven->lun" if d0 == FRI else "" + print(f" {DAY_NAMES[d0]}->{DAY_NAMES[(d0+3)%7]}: IS {m['sh_is']:+.2f} " + f"FULL {m['sh_full']:+.2f} HOLD {m['sh_hold']:+.2f}{tag}") + fri_is, fri_full = anchor[FRI]["sh_is"], anchor[FRI]["sh_full"] + pct_is = np.mean([anchor[d]["sh_is"] <= fri_is for d in range(7)]) + pct_full = np.mean([anchor[d]["sh_full"] <= fri_full for d in range(7)]) + print(f" percentile ancora-ven fra le 7: IS {pct_is*100:.0f}% FULL {pct_full*100:.0f}%") + + # null a livello di FAMIGLIA: mediana IS su tutte le 36 celle timing (long, entrambi gli asset) + print(" null di famiglia (mediana Sharpe IS delle 72 celle LONG BTC+ETH, per ancora):") + fam = {} + for d0 in range(7): + vals = [cell_metrics(a, hi, ho, 1, day_in=d0)["sh_is"] + for a in ASSETS for hi in H_GRID for ho in H_GRID] + fam[d0] = float(np.median(vals)) + for d0 in range(7): + tag = " <== ven" if d0 == FRI else "" + print(f" anchor {DAY_NAMES[d0]}: med IS {fam[d0]:+.2f}{tag}") + fam_pct = np.mean([fam[d] <= fam[FRI] for d in range(7)]) + print(f" percentile famiglia ancora-ven: {fam_pct*100:.0f}%") + + # confronto B&H a pari frazione di tempo: capture di log-return vs quota ore + print(" capture vs B&H (quota del log-return totale catturata dentro la finestra vs quota ore):") + for a in ASSETS: + df = al.get(a, "1h") + c = df["close"].values.astype(float) + lr = np.zeros(len(c)); lr[1:] = np.log(c[1:] / c[:-1]) + dt = pd.to_datetime(df["datetime"], utc=True) + idx = pd.DatetimeIndex(dt) + ins_mask = np.asarray(idx < al.HOLDOUT) + bh = al._to_daily(pd.Series(al.simple_returns(c), index=idx)) + for d0 in [FRI] + [d for d in range(7) if d != FRI]: + fn = make_target(ch["h_in"], ch["h_out"], 1, day_in=d0) + tgt = fn(df) + pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] # convenzione eval_weights + inw = pos > 0 + t_share = inw.mean() + cap_full = lr[inw].sum() / lr.sum() if lr.sum() != 0 else np.nan + cap_is = (lr[inw & ins_mask].sum() / lr[ins_mask].sum() + if lr[ins_mask].sum() != 0 else np.nan) + if d0 == FRI: + print(f" {a}: B&H daily Sharpe FULL {al._sh(bh):+.2f} | finestra ven: " + f"ore {t_share*100:.0f}% capture FULL {cap_full*100:.0f}% IS {cap_is*100:.0f}%") + else: + print(f" anchor {DAY_NAMES[d0]}: ore {t_share*100:.0f}% " + f"capture FULL {cap_full*100:.0f}% IS {cap_is*100:.0f}%") + + # ================= 5) DAY-BOUNDARY ROBUST sulla cella selezionata ================= + fn_ch = make_target(ch["h_in"], ch["h_out"], ch["dir"]) + dbr = al.day_boundary_robust(fn_ch, tf="1h") + print(f"\n[5] DAY_BOUNDARY_ROBUST (uplift marginale vs TP01 per offset del confine UTC):") + print(f" per_offset = {dbr['per_offset']}") + print(f" spread {dbr.get('spread')} verdetto = {dbr['verdict']}") + cok = al.causality_ok(fn_ch, tf="1h", assets=(ch["asset"],)) + print(f" causality_ok: {cok['ok']} (max_tail_diff {cok['max_tail_diff']})") + + # ================= 6) SWEEP FEE sulla cella selezionata ================= + print(f"\n[6] SWEEP FEE (per-side) sulla cella selezionata:") + for f in al.FEE_SWEEP: + m = cell_metrics(ch["asset"], ch["h_in"], ch["h_out"], ch["dir"], fee_side=f, keep=True) + e = m["ev"] + print(f" fee {f*100:.2f}%/side ({2*f*100:.2f}% RT): Sh daily FULL {m['sh_full']:+.2f} " + f"HOLD {m['sh_hold']:+.2f} | ret FULL {e['full']['ret']*100:+.0f}% " + f"CAGR {e['full']['cagr']*100:+.1f}%") + + # ================= 7) PER-ANNO della cella selezionata ================= + print(f"\n[7] PER-ANNO (netto {2*al.FEE_SIDE*100:.2f}% RT) della cella selezionata:") + yl = ev["yearly"] + n_posy = sum(1 for v in yl.values() if v["ret"] > 0) + for y, v in yl.items(): + print(f" {y}: ret {v['ret']*100:+6.1f}% dd {v['dd']*100:5.1f}%") + print(f" anni positivi: {n_posy}/{len(yl)}") + n_wk = len(ch["daily"]) / 7.02 + arith = float(np.sum(ev["net"])) + print(f" media aritmetica per weekend (netto, full): {arith / max(n_wk, 1) * 100:+.3f}% " + f"su ~{n_wk:.0f} finestre") + + print("\n" + "=" * 88) + print("FINE r0717_wk_drift — banda, selezione IS-only, DSR, null-weekday, boundary, fee, per-anno") + print("=" * 88) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0717_wk_intra.py b/scripts/research/r0717_wk_intra.py new file mode 100644 index 0000000..2c74e97 --- /dev/null +++ b/scripts/research/r0717_wk_intra.py @@ -0,0 +1,451 @@ +"""r0717_wk_intra — WK-INTRA: strategie INTRADAY attive SOLO nella finestra weekend +(ven 20:00 -> lun 12:00 UTC), BTC/ETH certificati, 1h e 15m. + +Domanda: il microclima weekend (liquidita' bassa, TradFi chiuso, riapertura CME dom ~22:00 +UTC) crea pattern intraday sfruttabili NETTI fee (0.10% RT)? + +SOTTO-FILONI (ogni cella = filone x parametri x direzione x asset x TF; tutte le celle +contano come trial per il deflated-Sharpe di famiglia): + a. Donchian weekend-only: canale N-ore calcolato SOLO su barre sab/dom (strettamente + precedenti, shiftate), breakout FOLLOW vs FADE, N in {12,24,48} ore. Entrate solo + sab/dom, posizione chiusa forzatamente entro lun 12:00. + b. Transizione dom->lun / riapertura CME: gap = close dom 22:00 vs close ven 21:00 UTC + (chiusura CME equity). |gap| >= soglia {0.5,1,2%} -> REVERT (gap-fill) o CONT + (continuazione), uscita lun alle {0,8,12} UTC. + cella riferimento always-long + dom22->lun12 (drift incondizionato). NB: per costruzione il filone e' quasi + TF-invariante (stessi prezzi di decisione a 1h e 15m). + c. Livelli del venerdi' come magnete: H/L/C del venerdi' COMPLETO (noti da sab 00:00); + sab/dom tocco/rottura del livello -> FOLLOW vs FADE; configurazioni HL (canale, + hold fino a segnale opposto), C (sign(close-friC) per barra), H-only / L-only + (posizione finche' oltre il livello). Chiusura forzata lun 12:00. + +PRIOR ART vincolante (NON rifatto qui): CRT/sweep-reclaim triplo-refutato (2026-07-02 / +07-07); "il ritest e' informazione negativa"; win-rate e' un knob -> qui tutto in Sharpe/ +ritorni netti, nessun claim su WR; anchor timing-luck -> shift-test orario +/-2/4h. + +METODO (obbligatorio, CLAUDE.md): + * causalita': segnale deciso con dati <= close[i], tenuto nella barra i+1 (eval_weights + shifta; il gating di finestra usa il calendario della barra SUCCESSIVA, deterministico); + * selezione SOLO in-sample (pre-2025, min-asset Sharpe netto) -> hold-out; banda + min/med/max su tutte le celle, mai solo la best; + * netto fee 0.10% RT + sweep {0, 0.05, 0.10, 0.15}%/side; lordo riportato per + distinguere "muore di fee" da "non esiste"; + * deflated_sharpe su TUTTI i trial della famiglia; shift-test +/-2/4h del calendario + visto dal segnale (al._shift_calendar) = detector di artefatti di etichettatura; + * per-anno sulla cella in-sample; eseguibilita' a $600 (ordini/settimana, quota <$5, + eval_weights_smallcap). + +Esecuzione: uv run python scripts/research/r0717_wk_intra.py +""" +from __future__ import annotations + +import sys +from functools import partial + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +ASSETS = ("BTC", "ETH") +TFS = ("1h", "15m") +FEE_SWEEP = (0.0, 0.0005, 0.001, 0.0015) # per-side +OFFSETS = (-4, -2, 0, 2, 4) # shift-test ore + + +# =========================================================================== +# CALENDARIO + GATING (tutto causale: la finestra e' calendario deterministico) +# =========================================================================== +def _cal(df: pd.DataFrame) -> dict: + dt = pd.to_datetime(df["datetime"], utc=True) + step = float(dt.diff().dt.total_seconds().median()) + ct = dt + pd.Timedelta(seconds=step) # istante di CHIUSURA della barra + return dict(step_h=step / 3600.0, dt=dt, + wd=dt.dt.dayofweek.values, hr=dt.dt.hour.values, mi=dt.dt.minute.values, + cwd=ct.dt.dayofweek.values, chh=ct.dt.hour.values, cmm=ct.dt.minute.values) + + +def _next(mask: np.ndarray) -> np.ndarray: + """Gating sulla barra TENUTA: eval_weights tiene target[i] nella barra i+1, quindi + target[i] = segnale(<=close[i]) * finestra(barra i+1). Calendario noto in anticipo.""" + out = np.zeros(len(mask), dtype=float) + out[:-1] = mask[1:].astype(float) + return out + + +# =========================================================================== +# BUILDER (target continui, unita' +/-1; decisione a close[i]) +# =========================================================================== +def build_a(df: pd.DataFrame, n_hours: int, mode: str) -> np.ndarray: + """Donchian su SOLE barre weekend (sab/dom), canale su N ore weekend strettamente + precedenti. follow: close>hi -> +1, close -1 (fade: opposto). Hold fino a + segnale opposto; flat forzato da lun 12:00. Reset a inizio weekend.""" + C = _cal(df) + c = df["close"].values.astype(float) + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + bars = max(2, int(round(n_hours / C["step_h"]))) + wk = (C["wd"] == 5) | (C["wd"] == 6) + iw = np.where(wk)[0] + hi = np.full(len(c), np.nan) + lo = np.full(len(c), np.nan) + if len(iw) > bars: + hi[iw] = pd.Series(h[iw]).rolling(bars, min_periods=bars).max().shift(1).values + lo[iw] = pd.Series(l[iw]).rolling(bars, min_periods=bars).min().shift(1).values + sgn = 1.0 if mode == "follow" else -1.0 + raw = np.full(len(c), np.nan) + start = wk & ~np.roll(wk, 1) + start[0] = wk[0] + raw[start] = 0.0 # reset: niente carry tra weekend + with np.errstate(invalid="ignore"): + up = wk & (c > hi) + dn = wk & (c < lo) + raw[up] = sgn + raw[dn] = -sgn + sig = pd.Series(raw).ffill().fillna(0.0).values + w = wk | ((C["wd"] == 0) & (C["hr"] < 12)) + return sig * _next(w) + + +def build_b(df: pd.DataFrame, thr: float, direction: str, h_exit: int) -> np.ndarray: + """Gap dom 22:00 vs close ven 21:00 UTC. |gap|>=thr -> revert (-sign) o cont (+sign). + Posizione dom 22:00 -> lun h_exit. direction='always' = riferimento long incondizionato.""" + C = _cal(df) + c = df["close"].values.astype(float) + n = len(c) + ref_mask = (C["cwd"] == 4) & (C["chh"] == 21) & (C["cmm"] == 0) + ref = pd.Series(np.where(ref_mask, c, np.nan)).ffill().values + dec = (C["cwd"] == 6) & (C["chh"] == 22) & (C["cmm"] == 0) + raw = np.full(n, np.nan) + di = np.where(dec & np.isfinite(ref))[0] + if direction == "always": + raw[di] = 1.0 + else: + g = c[di] / ref[di] - 1.0 + d = np.where(np.abs(g) >= thr, np.sign(g), 0.0) + if direction == "revert": + d = -d + raw[di] = d + reset = (C["wd"] == 0) & (C["hr"] == 12) & (C["mi"] == 0) + raw[reset & ~dec] = 0.0 + sig = pd.Series(raw).ffill().fillna(0.0).values + w = ((C["wd"] == 6) & (C["hr"] >= 22)) | ((C["wd"] == 0) & (C["hr"] < h_exit)) + return sig * _next(w) + + +def build_c(df: pd.DataFrame, level: str, mode: str) -> np.ndarray: + """Livelli del venerdi' COMPLETO (H/L/C noti da sab 00:00), segnali su sab/dom: + HL: rottura high -> +1 / low -> -1 (follow; fade opposto), hold fino a opposto. + C : sign(close - friC) per barra. H: +/-1 finche' close>friH. L: -/+1 finche' close fH)] = sgn + raw[wk & (c < fL)] = -sgn + elif level == "C": + m = wk & np.isfinite(fC) + raw[m] = sgn * np.sign(c[m] - fC[m]) + elif level == "H": + m = wk & np.isfinite(fH) + raw[m] = np.where(c[m] > fH[m], sgn, 0.0) + elif level == "L": + m = wk & np.isfinite(fL) + raw[m] = np.where(c[m] < fL[m], -sgn, 0.0) + sig = pd.Series(raw).ffill().fillna(0.0).values + w = wk | ((C["wd"] == 0) & (C["hr"] < 12)) + return sig * _next(w) + + +# =========================================================================== +# GRIGLIE (builder monoargomento -> compatibili con shift-test e causality_ok) +# =========================================================================== +def _one(fn, **kw): + """Builder monoargomento fn(df): signature a 1 parametro, cosi' altlib._call_target + (che ispeziona la signature) non prova a passare l'asset come 2o posizionale.""" + def g(df): + return fn(df, **kw) + return g + + +GRID = { + "a": {f"N{n}h-{m}": _one(build_a, n_hours=n, mode=m) + for n in (12, 24, 48) for m in ("follow", "fade")}, + "b": {**{f"g{thr * 100:.1f}%-{d}-ex{hx:02d}": _one(build_b, thr=thr, direction=d, h_exit=hx) + for thr in (0.005, 0.01, 0.02) for d in ("revert", "cont") for hx in (0, 8, 12)}, + "alwayslong-ex12": _one(build_b, thr=0.0, direction="always", h_exit=12)}, + "c": {f"{lvl}-{m}": _one(build_c, level=lvl, mode=m) + for lvl in ("HL", "C", "H", "L") for m in ("follow", "fade")}, +} + + +# =========================================================================== +# VALUTAZIONE (netto E lordo, split IS/HOLD, serie daily per DSR) +# =========================================================================== +def eval_cell(df: pd.DataFrame, tgt: np.ndarray, fee_side: float = al.FEE_SIDE) -> dict: + c = df["close"].values.astype(float) + r = al.simple_returns(c) + t = np.nan_to_num(np.asarray(tgt, float)) + pos = np.zeros(len(t)) + pos[1:] = t[:-1] # tenuta in barra i+1 (stessa convenzione di eval_weights) + gross_r = pos * r + turn = np.abs(np.diff(pos, prepend=0.0)) + idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + im = idx < al.HOLDOUT + hm = ~im + out = {} + for tag, f in (("net", fee_side), ("gross", 0.0)): + net = gross_r - f * turn + net[0] = 0.0 + out[tag] = dict(full=al._metrics_from_net(net, idx), + ins=al._metrics_from_net(net[im], idx[im]) if im.sum() > 10 else None, + hold=al._metrics_from_net(net[hm], idx[hm]) if hm.sum() > 10 else None) + if tag == "net": + out["yearly"] = al._yearly(net, idx) + out["dser"] = al._to_daily(pd.Series(net, index=idx)) + span_y = max((idx[-1] - idx[0]).days / 365.25, 1e-9) + n_ord = int((turn > 1e-12).sum()) + nz = turn[turn > 1e-12] + out["orders_per_week"] = n_ord / (span_y * 52.18) + out["frac_sub5_at600"] = float(np.mean(nz * 600.0 < 5.0)) if len(nz) else 0.0 + out["turnover_py"] = float(turn.sum() / span_y) + out["tim"] = float(np.mean(pos != 0)) + return out + + +def causality_tail_ok(builder, tf: str, tail: int = 80) -> dict: + """Come al.causality_ok ma ESCLUDE l'ultima barra del prefisso troncato: il gating di + finestra usa il calendario della barra SUCCESSIVA (deterministico, noto in anticipo in + deploy), che sul prefisso non esiste -> al.causality_ok segna diff=1 sull'ultima barra + anche senza alcun look-ahead sui PREZZI. Qui verifichiamo che TUTTE le altre barre del + tail coincidano (vero test di leak sui dati di mercato) e riportiamo a parte la diff + dell'ultima barra (attesa non-zero quando la finestra e' attiva).""" + worst = 0.0 + last = 0.0 + checked = 0 + for a in ASSETS: + df = al.get(a, tf) + full = np.nan_to_num(np.asarray(builder(df), float)) + n = len(df) + for cut in (int(n * 0.80), int(n * 0.92)): + sub = df.iloc[:cut].reset_index(drop=True) + s = np.nan_to_num(np.asarray(builder(sub), float)) + if len(s) != cut: + return dict(ok=False, reason="length-mismatch") + d = np.abs(s[cut - tail:cut - 1] - full[cut - tail:cut - 1]) + worst = max(worst, float(d.max()) if len(d) else 0.0) + last = max(last, float(abs(s[cut - 1] - full[cut - 1]))) + checked += 1 + return dict(ok=bool(worst <= 1e-9), max_tail_diff=round(worst, 9), + lastbar_gating_diff=round(last, 4), checked=checked) + + +def _fmt(x, nd=2): + if x is None or (isinstance(x, float) and not np.isfinite(x)): + return " nan" + return f"{x:+.{nd}f}" + + +def _seg_sh(ev, tag, seg): + d = ev[tag][seg] + return d["sharpe"] if d else float("nan") + + +def main() -> None: + print("=" * 100) + print(" WK-INTRA — strategie intraday nella finestra weekend (ven 20:00 -> lun 12:00 UTC)") + print(" BTC/ETH certificati, 1h + 15m | fee 0.10% RT | selezione in-sample (pre-2025) -> hold-out") + print("=" * 100) + for a in ASSETS: + for tf in TFS: + df = al.get(a, tf) + print(f" dati {a} {tf}: {len(df)} barre {df['datetime'].iloc[0]} .. {df['datetime'].iloc[-1]}") + + # ---- valuta TUTTE le celle -------------------------------------------------- + rows = [] + EVS = {} + for f, grid in GRID.items(): + for tf in TFS: + for a in ASSETS: + df = al.get(a, tf) + for cfg, builder in grid.items(): + ev = eval_cell(df, builder(df)) + EVS[(f, tf, cfg, a)] = ev + rows.append(dict( + filone=f, tf=tf, cfg=cfg, asset=a, + is_sh=_seg_sh(ev, "net", "ins"), hold_sh=_seg_sh(ev, "net", "hold"), + full_sh=_seg_sh(ev, "net", "full"), + is_shg=_seg_sh(ev, "gross", "ins"), hold_shg=_seg_sh(ev, "gross", "hold"), + full_shg=_seg_sh(ev, "gross", "full"), + dd=ev["net"]["full"]["maxdd"], opw=ev["orders_per_week"], + tim=ev["tim"], dsh_full=al._sh(ev["dser"]))) + R = pd.DataFrame(rows) + n_cells = len(R) + + # combo 50/50 daily per config (trials like-for-like per il DSR) + combo = {} + for (f, tf, cfg), _ in R.groupby(["filone", "tf", "cfg"]).size().items(): + b = EVS[(f, tf, cfg, "BTC")]["dser"] + e = EVS[(f, tf, cfg, "ETH")]["dser"] + J = pd.concat([b, e], axis=1, join="inner").fillna(0.0) + combo[(f, tf, cfg)] = 0.5 * (J.iloc[:, 0] + J.iloc[:, 1]) + family_combo_sh = [al._sh(s) for s in combo.values()] + + print(f"\n TRIAL DI FAMIGLIA: {n_cells} celle per-asset " + f"({len(combo)} configurazioni x 2 asset) su 3 sotto-filoni") + + # ---- report per filone ------------------------------------------------------ + FIL_DESC = {"a": "Donchian weekend-only (follow/fade, N=12/24/48h)", + "b": "gap dom22 vs ven21 UTC (CME) revert/cont, exit lun {0,8,12}", + "c": "livelli ven H/L/C, follow/fade dentro il weekend"} + for f in ("a", "b", "c"): + Rf = R[R.filone == f] + print("\n" + "=" * 100) + print(f" FILONE {f.upper()} — {FIL_DESC[f]} [{len(Rf)} celle]") + print("=" * 100) + + # banda su tutte le celle per-asset + for tag, k_is, k_h in (("NETTO", "is_sh", "hold_sh"), ("LORDO", "is_shg", "hold_shg")): + print(f" banda {tag}: IS Sharpe min/med/max = " + f"{Rf[k_is].min():+.2f} / {Rf[k_is].median():+.2f} / {Rf[k_is].max():+.2f}" + f" (celle IS>0: {(Rf[k_is] > 0).sum()}/{len(Rf)})") + print(f" HOLD Sharpe min/med/max = " + f"{Rf[k_h].min():+.2f} / {Rf[k_h].median():+.2f} / {Rf[k_h].max():+.2f}" + f" (celle HOLD>0: {(Rf[k_h] > 0).sum()}/{len(Rf)})") + + # selezione IN-SAMPLE-ONLY: max del min-asset IS Sharpe netto + agg = Rf.groupby(["tf", "cfg"]).agg(min_is=("is_sh", "min"), min_hold=("hold_sh", "min"), + min_isg=("is_shg", "min")).reset_index() + agg = agg.sort_values("min_is", ascending=False) + print("\n top-5 config per min-asset IS Sharpe NETTO (selezione solo in-sample):") + for _, rr in agg.head(5).iterrows(): + print(f" {rr['tf']:>3s} {rr['cfg']:<22s} minIS {rr['min_is']:+.2f} " + f"(lordo {rr['min_isg']:+.2f}) -> minHOLD {rr['min_hold']:+.2f}") + ch = agg.iloc[0] + ctf, ccfg = ch["tf"], ch["cfg"] + builder = GRID[f][ccfg] + if f == "b": # riferimento: drift incondizionato dom22->lun12 + print("\n riferimento always-long dom22->lun12 (drift incondizionato, netto):") + for a in ASSETS: + evr = EVS[(f, "1h", "alwayslong-ex12", a)] + print(f" {a} 1h: IS {_fmt(_seg_sh(evr, 'net', 'ins'))} " + f"FULL {_fmt(_seg_sh(evr, 'net', 'full'))} " + f"HOLD {_fmt(_seg_sh(evr, 'net', 'hold'))} " + f"(lordo IS {_fmt(_seg_sh(evr, 'gross', 'ins'))})") + print(f"\n CELLA SCELTA (in-sample): {ctf} {ccfg}") + for a in ASSETS: + ev = EVS[(f, ctf, ccfg, a)] + print(f" {a}: NETTO IS {_fmt(_seg_sh(ev,'net','ins'))} FULL {_fmt(_seg_sh(ev,'net','full'))} " + f"(ret {ev['net']['full']['ret']*100:+.1f}%, DD {ev['net']['full']['maxdd']*100:.1f}%) " + f"HOLD {_fmt(_seg_sh(ev,'net','hold'))} (ret {ev['net']['hold']['ret']*100:+.1f}%)" if ev['net']['hold'] else "") + print(f" LORDO IS {_fmt(_seg_sh(ev,'gross','ins'))} FULL {_fmt(_seg_sh(ev,'gross','full'))} " + f"HOLD {_fmt(_seg_sh(ev,'gross','hold'))} | TiM {ev['tim']*100:.1f}% " + f"ordini/sett {ev['orders_per_week']:.2f} quota<$5@600$ {ev['frac_sub5_at600']*100:.0f}%") + yr = " ".join(f"{y}:{d['ret']*100:+.1f}%" for y, d in ev["yearly"].items()) + print(f" per-anno (netto): {yr}") + + # combo 50/50 + DSR (filone e famiglia) + cd = combo[(f, ctf, ccfg)] + cd_is = cd[cd.index < al.HOLDOUT] + cd_h = cd[cd.index >= al.HOLDOUT] + sh_cd = al._sh(cd) + fil_sh = [al._sh(s) for (kf, ktf, kc), s in combo.items() if kf == f] + dsr_fam, sr0_fam = al.deflated_sharpe(sh_cd, family_combo_sh, cd) + dsr_fil, sr0_fil = al.deflated_sharpe(sh_cd, fil_sh, cd) + print(f"\n combo 50/50 daily: FULL {sh_cd:+.2f} IS {al._sh(cd_is):+.2f} HOLD {al._sh(cd_h):+.2f}") + print(f" deflated-Sharpe: vs filone ({len(fil_sh)} trial) DSR={dsr_fil:.3f} (null-max {sr0_fil:+.2f})" + f" | vs FAMIGLIA ({len(family_combo_sh)} trial) DSR={dsr_fam:.3f} (null-max {sr0_fam:+.2f})") + + # fee sweep sulla cella scelta + print(" fee sweep (Sharpe FULL/HOLD per fee/side):") + for a in ASSETS: + df = al.get(a, ctf) + tgt = builder(df) + parts = [] + for fe in FEE_SWEEP: + evw = al.eval_weights(df, tgt, fee_side=fe) + parts.append(f"{fe*100:.2f}%: {evw['full']['sharpe']:+.2f}/{evw['holdout'].get('sharpe', float('nan')):+.2f}") + print(f" {a}: " + " ".join(parts)) + + # shift-test +/-2/4h (il segnale vede il calendario shiftato, il backtest no) + print(" shift-test orario (Sharpe FULL netto per offset del calendario visto dal segnale):") + flip = False + for a in ASSETS: + df0 = al.get(a, ctf) + vals = {} + for off in OFFSETS: + tgt = builder(al._shift_calendar(df0, off)) + vals[off] = al.eval_weights(df0, tgt)["full"]["sharpe"] + base = vals[0] + if any(np.sign(v) != np.sign(base) and abs(v) > 0.15 and abs(base) > 0.15 + for o, v in vals.items() if o != 0): + flip = True + print(f" {a}: " + " ".join(f"{o:+d}h: {v:+.2f}" for o, v in vals.items())) + print(f" -> sign-flip a |offset|<=4h: {flip} " + f"({'ARTIFACT-RISK di etichettatura' if flip else 'nessun flip evidente'})") + + # causalita' + small-cap $600 + cz = causality_tail_ok(builder, ctf) + czr = al.causality_ok(builder, tf=ctf) + print(f" causalita' (tail escl. ultima barra del prefisso): {cz['ok']} " + f"(max diff {cz['max_tail_diff']}; diff ultima barra da gating-calendario " + f"{cz['lastbar_gating_diff']}; altlib.causality_ok grezzo: {czr['ok']})") + for a in ASSETS: + df = al.get(a, ctf) + sc = al.eval_weights_smallcap(df, builder(df), capital=600.0, min_order=5.0) + print(f" small-cap $600 {a}: Sharpe modellato {sc['modeled']['sharpe']:+.2f} -> " + f"realistico {sc['realistic']['sharpe']:+.2f} (haircut {sc['sharpe_haircut']:+.2f}), " + f"trade eseguiti {sc['n_executed_trades']}") + + # verdetto suggerito (finale nel diario/report) + if ch["min_is"] > 0.3 and ch["min_hold"] > 0 and (np.isfinite(dsr_fam) and dsr_fam >= 0.95): + v = "CANDIDATO (passa IS+HOLD+DSR: servono marginal scorer e scettico)" + elif ch["min_is"] > 0.3 and ch["min_hold"] > 0: + v = "LEAD DEBOLE (IS+HOLD>0 ma non sopravvive al deflated-Sharpe)" + elif ch["min_is"] > 0.3: + v = "SCARTATO (IS ok ma hold-out negativo)" + elif ch["min_isg"] <= 0: + v = "SCARTATO (nessun edge nemmeno LORDO in-sample)" + elif ch["min_isg"] - ch["min_is"] > 0.15 and ch["min_is"] <= 0.1: + v = (f"SCARTATO (lordo IS {ch['min_isg']:+.2f} debole e sotto soglia; " + f"le fee lo azzerano: netto {ch['min_is']:+.2f})") + else: + v = f"SCARTATO (edge in-sample netto {ch['min_is']:+.2f} sotto soglia = rumore)" + print(f"\n VERDETTO SUGGERITO filone {f.upper()}: {v}") + + # ---- riepilogo famiglia ----------------------------------------------------- + print("\n" + "=" * 100) + print(" RIEPILOGO FAMIGLIA WK-INTRA") + print("=" * 100) + print(f" trial totali: {n_cells} celle per-asset / {len(combo)} configurazioni combo") + best_key = max(combo, key=lambda k: al._sh(combo[k])) + print(f" miglior combo FULL (senno' di poi, NON selezione): {best_key} Sh {al._sh(combo[best_key]):+.2f}") + pos_is = int((R.is_sh > 0).sum()) + pos_h = int((R.hold_sh > 0).sum()) + posg_is = int((R.is_shg > 0).sum()) + print(f" celle con IS netto>0: {pos_is}/{n_cells} (lordo {posg_is}/{n_cells}); HOLD netto>0: {pos_h}/{n_cells}") + print(" nota b: la riapertura CME reale oscilla 22:00/23:00 UTC (DST) -> lo shift-test copre la banda.") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0717_wk_overlay.py b/scripts/research/r0717_wk_overlay.py new file mode 100644 index 0000000..1be212a --- /dev/null +++ b/scripts/research/r0717_wk_overlay.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python +"""r0717_wk_overlay — WK-OVERLAY: modificare l'esposizione WEEKEND di TP01 conviene? (2026-07-17) + +FILONE (lato ESEGUIBILE del book live Deribit, solo componente TP01): +uscire/ridurre/raddoppiare l'esposizione TP01 nel weekend (ven H_out -> lun H_in) migliora +Sharpe/DD FULL e HOLD-OUT al netto delle fee extra (~104 trade/anno se esci+rientri)? + +VARIANTI (target TP01 CANONICAL 1d mappato causalmente sulla griglia 1h): + a. WK-FLAT : posizione -> 0 dentro [ven H_out, lun H_in) + b. WK-HALF : x0.5 dentro la finestra + c. WK-BOOST : x1.5 dentro la finestra (controllo simmetrico; cap leva 2.0) + d. WK-ONLY : esposizione TP01 SOLO dentro la finestra, flat fuori +Griglia: H_out in {ven 12,16,20} x H_in in {lun 0,8} = 6 celle/variante. + +CONTROLLI OBBLIGATORI: + 1. Null de-levering (lezione DVOL 2026-06-26): con fee proporzionali net(k*tgt)=k*net(tgt) + ESATTO -> il de-levering realizzato lascia lo Sharpe daily INVARIATO; ogni taglio di DD + a Sharpe <= baseline e' replicabile con un semplice k (o target_vol ridotto) -> REFUTED. + Qui: sweep k, matching del DD della variante, confronto Sharpe/CAGR a pari DD. + 2. Fee nette 0.10% RT su ogni esposizione mossa + sweep fee 0.00-0.30% RT. + 3. Banda di timing completa (6 celle), selezione IN-SAMPLE-ONLY, deflated_sharpe sui trial. + 4. Per-anno + FULL vs HOLD-OUT (2025+). + 5. Esecuzione a $600: eval_weights_smallcap (min-order $5, capitale per-asset $300). + 6. Statistica onesta: bootstrap a blocchi SETTIMANALI appaiato sul delta di Sharpe + + placebo = 7 rotazioni giorno-della-settimana della stessa finestra (stessa lunghezza, + stesse fee, posizionamento calendario diverso). + +CAUSALITA': il target daily di TP01 (deciso a close del giorno D = D+1d 00:00) viene +assegnato alla barra 1h la cui close e' >= a quel momento (searchsorted su epoch-ms, +mai .view su indici tz-aware — lezione pandas 2026-07-01). Il moltiplicatore weekend e' +funzione deterministica del calendario della barra SUCCESSIVA (quella in cui la posizione +viene tenuta) -> causale per costruzione. Guard prefix-recompute (causality_ok) incluso. + +Book/pesi INVARIATI qualunque sia l'esito: questo e' research. Nessun file modificato. +Run: uv run python scripts/research/r0717_wk_overlay.py +""" +from __future__ import annotations + +import sys +from itertools import product +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "research" / "alt")) +import altlib as al # noqa: E402 + +from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402 + +HOLDOUT = al.HOLDOUT +FEE = al.FEE_SIDE +CAP = float(CANONICAL["leverage"]) +SEED = 20260717 +H_OUT = (12, 16, 20) # ora di uscita del venerdi' (UTC) +H_IN = (0, 8) # ora di rientro del lunedi' (UTC) +ASSETS = ("BTC", "ETH") + +_DATA: dict[str, tuple[pd.DataFrame, np.ndarray]] = {} + + +# =========================================================================== +# TP01 canonico (1d) -> griglia 1h, per tempo di decisione (causale) +# =========================================================================== +def tp01_target_1h(df1h: pd.DataFrame) -> np.ndarray: + """Target TP01 CANONICAL calcolato sul 1d e mappato sulle barre 1h. + La barra daily D (open-labeled) chiude a D+1d: il suo target e' noto da quel momento. + La barra 1h i (open T, close T+1h) porta l'ultimo target daily con close <= T+1h. + eval_weights poi tiene target[i] durante la barra i+1 -> stesso timing di trade del 1d.""" + d1 = resample_1d(df1h) + tp = TrendPortfolio(**CANONICAL) + tgt_d = np.nan_to_num(tp.target_series(d1)) + d_close_ms = d1["timestamp"].astype("int64").values + 86_400_000 + h_close_ms = df1h["timestamp"].astype("int64").values + 3_600_000 + j = np.searchsorted(d_close_ms, h_close_ms, side="right") - 1 + out = np.where(j >= 0, tgt_d[np.clip(j, 0, None)], 0.0) + return np.nan_to_num(out.astype(float)) + + +def window_mult(dt: pd.DatetimeIndex, start_how: int, end_how: int, + mult_in: float, mult_out: float = 1.0) -> np.ndarray: + """Moltiplicatore per finestra ciclica [start,end) in ore-della-settimana (lun0=0).""" + how = dt.dayofweek.values * 24 + dt.hour.values + if start_how < end_how: + inw = (how >= start_how) & (how < end_how) + else: + inw = (how >= start_how) | (how < end_how) + return np.where(inw, mult_in, mult_out) + + +def wk_target(df1h: pd.DataFrame, tp1h: np.ndarray, h_out: int, h_in: int, + mult_in: float, mult_out: float = 1.0, rot_days: int = 0) -> np.ndarray: + """Target = TP01 * moltiplicatore weekend. Il moltiplicatore e' valutato sull'OPEN + della barra SUCCESSIVA (= close della corrente): eval_weights tiene target[i] durante + la barra i+1, quindi la posizione dentro la finestra e' esattamente mult*TP01.""" + start = (4 * 24 + h_out + 24 * rot_days) % 168 + end = (0 * 24 + h_in + 24 * rot_days) % 168 + dt_next_open = pd.DatetimeIndex(pd.to_datetime(df1h["datetime"], utc=True)) + pd.Timedelta(hours=1) + m = window_mult(dt_next_open, start, end, mult_in, mult_out) + return np.clip(tp1h * m, -CAP, CAP) + + +# =========================================================================== +# valutazione book 50/50 (griglia 1h -> serie daily composta) +# =========================================================================== +def per_asset_net(tgts: dict[str, np.ndarray], fee_side: float = FEE): + ser, stats = {}, {} + for a in ASSETS: + df = _DATA[a][0] + ev = al.eval_weights(df, tgts[a], fee_side=fee_side) + ser[a] = pd.Series(ev["net"], index=ev["idx"]) + pos = np.zeros(len(tgts[a])); pos[1:] = tgts[a][:-1] + turn = np.abs(np.diff(pos, prepend=0.0)) + yrs = len(df) / 24 / 365.25 + stats[a] = dict(turnover_yr=float(turn.sum() / yrs), + trades_yr=float((turn > 1e-9).sum() / yrs), + tim=float(np.mean(pos != 0))) + return ser, stats + + +def combo_daily(tgts: dict[str, np.ndarray], fee_side: float = FEE): + ser, stats = per_asset_net(tgts, fee_side) + J = pd.concat(ser, axis=1, join="inner").fillna(0.0) + hourly = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]] + return al._to_daily(hourly), stats, hourly + + +def _cagr(d: pd.Series) -> float: + if len(d) < 2: + return float("nan") + tot = float(np.prod(1.0 + d.values)) + yrs = (d.index[-1] - d.index[0]).days / 365.25 + return tot ** (1 / yrs) - 1 if yrs > 0 and tot > 0 else float("nan") + + +def blk(d: pd.Series) -> dict: + if len(d) < 30: + return dict(sh=float("nan"), cagr=float("nan"), dd=float("nan")) + return dict(sh=al._sh(d), cagr=_cagr(d), dd=al._dd_ret(d)) + + +def fh(d: pd.Series) -> dict: + return dict(full=blk(d), hold=blk(d[d.index >= HOLDOUT]), + ins=blk(d[d.index < HOLDOUT])) + + +def fmt_fh(s: dict) -> str: + f, h = s["full"], s["hold"] + return (f"FULL Sh {f['sh']:+.3f} CAGR {f['cagr'] * 100:+6.2f}% DD {f['dd'] * 100:5.2f}% | " + f"HOLD Sh {h['sh']:+.3f} CAGR {h['cagr'] * 100:+6.2f}% DD {h['dd'] * 100:5.2f}%") + + +# =========================================================================== +# bootstrap a blocchi settimanali appaiato sul delta di Sharpe +# =========================================================================== +def paired_week_bootstrap(base_d: pd.Series, var_d: pd.Series, n: int = 2000, + seed: int = SEED, start=None) -> dict: + J = pd.concat({"b": base_d, "v": var_d}, axis=1, join="inner").dropna() + if start is not None: + J = J[J.index >= start] + wk = (J.index - pd.to_timedelta(J.index.dayofweek, unit="D")).normalize() + codes, uniq = pd.factorize(wk) + groups = [np.where(codes == g)[0] for g in range(len(uniq))] + rng = np.random.default_rng(seed) + b, v = J["b"].values, J["v"].values + deltas = np.empty(n) + for t in range(n): + pick = rng.integers(0, len(groups), size=len(groups)) + idx = np.concatenate([groups[p] for p in pick]) + bs, vs = b[idx], v[idx] + shb = bs.mean() / bs.std() * np.sqrt(365.25) if bs.std() > 0 else 0.0 + shv = vs.mean() / vs.std() * np.sqrt(365.25) if vs.std() > 0 else 0.0 + deltas[t] = shv - shb + real = al._sh(J["v"]) - al._sh(J["b"]) + return dict(real=float(real), p_le0=float(np.mean(deltas <= 0.0)), + ci_lo=float(np.percentile(deltas, 2.5)), + ci_hi=float(np.percentile(deltas, 97.5)), n_weeks=len(groups)) + + +# =========================================================================== +# main +# =========================================================================== +def main() -> None: + print("=" * 100) + print("WK-OVERLAY (r0717) — esposizione weekend di TP01: flat / half / boost / weekend-only") + print(f"griglia 1h, fee {2 * FEE * 100:.2f}% RT, HOLDOUT {HOLDOUT.date()}, book 50/50 BTC+ETH") + print("=" * 100) + + for a in ASSETS: + df = al.get(a, "1h") + _DATA[a] = (df, tp01_target_1h(df)) + print(f" {a}: {len(df)} barre 1h {df['datetime'].iloc[0]} -> {df['datetime'].iloc[-1]}") + + # ---- guard causalita' (prefix-recompute) sul target flat ven20->lun00 -------------- + def _guard_fn(df, asset): + return wk_target(df, tp01_target_1h(df), 20, 0, 0.0) + ca = al.causality_ok(_guard_fn, tf="1h") + print(f"\ncausality_ok (prefix-recompute, WK-FLAT ven20->lun00): ok={ca['ok']} " + f"max_tail_diff={ca['max_tail_diff']}") + + # ---- linearita' del de-levering (net(k*tgt) == k*net(tgt)) ------------------------- + a0 = ASSETS[0] + ev1 = al.eval_weights(_DATA[a0][0], _DATA[a0][1]) + evk = al.eval_weights(_DATA[a0][0], 0.5 * _DATA[a0][1]) + lin_err = float(np.max(np.abs(evk["net"] - 0.5 * ev1["net"]))) + print(f"linearita' de-levering: max|net(0.5*tgt) - 0.5*net(tgt)| = {lin_err:.2e} " + f"(atteso ~0 -> Sharpe daily k-invariante)") + + # ================= A. BASELINE ====================================================== + print("\n" + "=" * 100) + print("A. BASELINE — TP01 CANONICAL sul percorso 1h (ribilancio daily 00:00, identico al 1d)") + print("=" * 100) + base_tgts = {a: _DATA[a][1] for a in ASSETS} + base_d, base_stats, base_h = combo_daily(base_tgts) + base_s = fh(base_d) + print(f" baseline 1h-grid : {fmt_fh(base_s)} (IS Sh {base_s['ins']['sh']:+.3f})") + ref = al.tp01_baseline_daily() + print(f" riferimento 1d : FULL Sh {al._sh(ref):+.3f} DD {al._dd_ret(ref) * 100:.2f}% | " + f"HOLD Sh {al._sh(ref[ref.index >= HOLDOUT]):+.3f} (tp01_baseline_daily, sanity)") + for a in ASSETS: + st = base_stats[a] + print(f" {a}: turnover/anno {st['turnover_yr']:.1f} (fee drag ~{st['turnover_yr'] * FEE * 100:.2f}%/anno) " + f"trade/anno {st['trades_yr']:.0f} TIM {st['tim'] * 100:.0f}%") + + # attribuzione weekend del gross baseline (senza fee): quanto vive nel weekend? + print("\n Attribuzione GROSS baseline (pos*r, no fee), finestra 'weekend puro' ven20->lun00:") + gser = {} + for a in ASSETS: + df, tgt = _DATA[a] + c = df["close"].values.astype(float) + r = al.simple_returns(c) + pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] + gser[a] = pd.Series(pos * r, index=pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))) + G = pd.concat(gser, axis=1, join="inner").fillna(0.0) + gross = 0.5 * G[ASSETS[0]] + 0.5 * G[ASSETS[1]] + how = gross.index.dayofweek.values * 24 + gross.index.hour.values + wk_mask = how >= 116 # ven20 -> dom24 == fino a lun00 + tot, wk_part = float(gross.sum()), float(gross[wk_mask].sum()) + yrs_span = (gross.index[-1] - gross.index[0]).days / 365.25 + print(f" quota tempo weekend: {wk_mask.mean() * 100:.1f}% | contributo gross weekend: " + f"{wk_part * 100:+.1f}pp su {tot * 100:+.1f}pp totali ({wk_part / tot * 100 if tot else 0:.0f}%) " + f"[~{wk_part / yrs_span * 100:+.2f}pp/anno]") + by_year = gross.groupby(gross.index.year).apply(lambda s: float(s.sum())) + by_year_wk = gross[wk_mask].groupby(gross.index[wk_mask].year).apply(lambda s: float(s.sum())) + print(" per anno (gross pp: weekend / totale): " + " ".join( + f"{y}:{by_year_wk.get(y, 0.0) * 100:+.1f}/{by_year.get(y, 0.0) * 100:+.1f}" + for y in by_year.index)) + + # ================= B. GRIGLIA COMPLETA ============================================= + print("\n" + "=" * 100) + print("B. BANDA DI TIMING — tutte le 6 celle x 4 varianti (delta vs baseline, fee 0.10% RT)") + print("=" * 100) + VAR = {"a_FLAT": dict(mult_in=0.0, mult_out=1.0), + "b_HALF": dict(mult_in=0.5, mult_out=1.0), + "c_BOOST": dict(mult_in=1.5, mult_out=1.0), + "d_WKONLY": dict(mult_in=1.0, mult_out=0.0)} + cells = list(product(H_OUT, H_IN)) + res: dict[str, dict] = {} + all_full_sh: list[float] = [] + for vname, vm in VAR.items(): + res[vname] = {} + print(f"\n -- {vname} (mult_in={vm['mult_in']}, mult_out={vm['mult_out']}) --") + print(f" {'cella':<14} {'ISΔSh':>7} {'FULLΔSh':>8} {'HOLDΔSh':>8} {'ΔCAGR':>7} {'ΔDD':>7} " + f"{'Sh_full':>8} {'Sh_hold':>8} {'DD':>6} {'trade/y':>8}") + for ho, hi in cells: + tgts = {a: wk_target(_DATA[a][0], _DATA[a][1], ho, hi, **vm) for a in ASSETS} + d, st, _ = combo_daily(tgts) + s = fh(d) + tr = np.mean([st[a]["trades_yr"] for a in ASSETS]) + key = f"ven{ho:02d}->lun{hi:02d}" + res[vname][key] = dict(daily=d, s=s, tgts=tgts, stats=st, ho=ho, hi=hi) + all_full_sh.append(s["full"]["sh"]) + print(f" {key:<14} {s['ins']['sh'] - base_s['ins']['sh']:+7.3f} " + f"{s['full']['sh'] - base_s['full']['sh']:+8.3f} " + f"{s['hold']['sh'] - base_s['hold']['sh']:+8.3f} " + f"{(s['full']['cagr'] - base_s['full']['cagr']) * 100:+6.2f}pp " + f"{(s['full']['dd'] - base_s['full']['dd']) * 100:+6.2f}pp " + f"{s['full']['sh']:+8.3f} {s['hold']['sh']:+8.3f} {s['full']['dd'] * 100:5.1f}% {tr:8.0f}") + + # ================= C. SELEZIONE IN-SAMPLE + DSR ==================================== + print("\n" + "=" * 100) + print("C. SELEZIONE IN-SAMPLE-ONLY (max Sharpe pre-2025) + deflated Sharpe sui 24 trial") + print("=" * 100) + chosen: dict[str, dict] = {} + for vname in VAR: + best = max(res[vname].items(), key=lambda kv: kv[1]["s"]["ins"]["sh"]) + chosen[vname] = dict(key=best[0], **best[1]) + d = best[1]["daily"] + dsr, sr0 = al.deflated_sharpe(al._sh(d), all_full_sh, d.values) + chosen[vname]["dsr"] = dsr + s = best[1]["s"] + print(f" {vname:<9} cella IS-best={best[0]} {fmt_fh(s)}") + print(f" ΔSh full {s['full']['sh'] - base_s['full']['sh']:+.3f} / hold " + f"{s['hold']['sh'] - base_s['hold']['sh']:+.3f} | DSR(24 trial)={dsr:.3f} " + f"(null-max Sh {sr0:.2f}) — NB: per una famiglia di OVERLAY su TP01 il DSR eredita " + f"lo Sharpe di trend: il test discriminante e' il placebo/bootstrap, non il DSR") + + # ================= D. NULL DE-LEVERING ============================================= + print("\n" + "=" * 100) + print("D. NULL DE-LEVERING — TP01 scalato k a pari DD (lezione DVOL: se eguaglia/batte, REFUTED)") + print("=" * 100) + print(f" (net(k*tgt)=k*net(tgt) esatto -> Sharpe daily del de-levered ≈ baseline a OGNI k; " + f"CAGR/DD scendono con k)") + ks = np.arange(0.30, 1.5001, 0.0025) + print(" (per DD > baseline il null speculare e' il RE-levering uniforme k>1 a pari DD; " + "NB k>1 sfora il cap 2x in alcune barre -> null leggermente ottimista)") + for vname in VAR: + ch = chosen[vname] + v_dd = ch["s"]["full"]["dd"] + best_k, best_gap, best_blk = None, 9e9, None + for k in ks: + dk = al._to_daily(k * base_h) + bb = blk(dk) + gap = abs(bb["dd"] - v_dd) + if gap < best_gap: + best_k, best_gap, best_blk = k, gap, dict(full=bb, d=dk) + dl_full = best_blk["full"] + dl_hold = blk(best_blk["d"][best_blk["d"].index >= HOLDOUT]) + vs = ch["s"] + refuted = dl_full["sh"] >= vs["full"]["sh"] - 0.02 + print(f" {vname:<9} DD variante {v_dd * 100:.2f}% -> k={best_k:.3f} de-lever: " + f"Sh {dl_full['sh']:+.3f} CAGR {dl_full['cagr'] * 100:+.2f}% DD {dl_full['dd'] * 100:.2f}% " + f"(hold Sh {dl_hold['sh']:+.3f})") + print(f" variante: Sh {vs['full']['sh']:+.3f} CAGR " + f"{vs['full']['cagr'] * 100:+.2f}% DD {vs['full']['dd'] * 100:.2f}% (hold Sh {vs['hold']['sh']:+.3f})" + f" -> {'REFUTED (solo de-levering)' if refuted else 'batte il de-levering a pari DD'}") + + # ================= E. PLACEBO ROTAZIONI ============================================ + print("\n" + "=" * 100) + print("E. PLACEBO — 7 rotazioni giorno-della-settimana della finestra (stessa durata/fee)") + print("=" * 100) + for vname in ("a_FLAT", "c_BOOST", "d_WKONLY"): + ch = chosen[vname] + vm = VAR[vname] + print(f"\n -- {vname} cella {ch['key']} (rot 0 = weekend vero) --") + rows = [] + for rot in range(7): + tgts = {a: wk_target(_DATA[a][0], _DATA[a][1], ch["ho"], ch["hi"], + vm["mult_in"], vm["mult_out"], rot_days=rot) for a in ASSETS} + d, _, _ = combo_daily(tgts) + s = fh(d) + rows.append(dict(rot=rot, + d_ins=s["ins"]["sh"] - base_s["ins"]["sh"], + d_full=s["full"]["sh"] - base_s["full"]["sh"], + d_hold=s["hold"]["sh"] - base_s["hold"]["sh"], + sh_full=s["full"]["sh"])) + for r in rows: + tag = " <== WEEKEND" if r["rot"] == 0 else "" + print(f" rot+{r['rot']}g: ΔSh IS {r['d_ins']:+.3f} FULL {r['d_full']:+.3f} " + f"HOLD {r['d_hold']:+.3f} (Sh full {r['sh_full']:+.3f}){tag}") + for metric in ("d_ins", "d_full", "d_hold"): + vals = [r[metric] for r in rows] + rank = 1 + sum(1 for v in vals[1:] if v > vals[0]) + print(f" rank weekend su {metric}: {rank}/7 (1=migliore; P(rank 1 per caso)=0.14)") + + # ================= F. BOOTSTRAP SETTIMANALE ======================================== + print("\n" + "=" * 100) + print("F. BOOTSTRAP a blocchi SETTIMANALI appaiato — ΔSharpe variante-baseline (2000 draw)") + print("=" * 100) + for vname in VAR: + ch = chosen[vname] + bf = paired_week_bootstrap(base_d, ch["daily"]) + bh = paired_week_bootstrap(base_d, ch["daily"], start=HOLDOUT) + print(f" {vname:<9} FULL ΔSh {bf['real']:+.3f} CI95 [{bf['ci_lo']:+.3f},{bf['ci_hi']:+.3f}] " + f"P(Δ<=0)={bf['p_le0']:.3f} ({bf['n_weeks']} settimane)") + print(f" HOLD ΔSh {bh['real']:+.3f} CI95 [{bh['ci_lo']:+.3f},{bh['ci_hi']:+.3f}] " + f"P(Δ<=0)={bh['p_le0']:.3f} ({bh['n_weeks']} settimane)") + + # ================= G. FEE SWEEP ==================================================== + print("\n" + "=" * 100) + print("G. FEE SWEEP — ΔSh FULL vs baseline ALLA STESSA fee (0.00 / 0.10 / 0.20 / 0.30% RT)") + print("=" * 100) + fees = (0.0, 0.0005, 0.001, 0.0015) + hdr = " ".join(f"{2 * f * 100:.2f}%RT" for f in fees) + print(f" {'variante':<9} {hdr} (ogni colonna: ΔSh full / ΔSh hold)") + for vname in VAR: + ch = chosen[vname] + parts = [] + for f in fees: + bd, _, _ = combo_daily(base_tgts, fee_side=f) + vd, _, _ = combo_daily(ch["tgts"], fee_side=f) + bs_, vs_ = fh(bd), fh(vd) + parts.append(f"{vs_['full']['sh'] - bs_['full']['sh']:+.3f}/" + f"{vs_['hold']['sh'] - bs_['hold']['sh']:+.3f}") + print(f" {vname:<9} " + " ".join(parts)) + + # ================= H. SMALL-CAP $600 =============================================== + print("\n" + "=" * 100) + print("H. ESECUZIONE A $600 — eval_weights_smallcap (min-order $5; capitale per-asset $300)") + print("=" * 100) + yrs = {a: len(_DATA[a][0]) / 24 / 365.25 for a in ASSETS} + for label, tg in [("BASELINE", base_tgts)] + [(v, chosen[v]["tgts"]) for v in VAR]: + for a in ASSETS: + sc = al.eval_weights_smallcap(_DATA[a][0], tg[a], capital=300.0, min_order=5.0) + print(f" {label:<9} {a}: modellato Sh {sc['modeled']['sharpe']:+.3f} -> reale Sh " + f"{sc['realistic']['sharpe']:+.3f} (haircut {sc['sharpe_haircut']:+.3f}) " + f"trade eseguiti/anno {sc['n_executed_trades'] / yrs[a]:.0f} " + f"turnover eseguito/anno {sc['executed_turnover_per_year']:.1f}") + + # ================= I. PER-ANNO ===================================================== + print("\n" + "=" * 100) + print("I. PER-ANNO — ritorno % (baseline vs celle IS-best)") + print("=" * 100) + tab = {"BASE": al._yearly(base_d.values, base_d.index)} + for vname in VAR: + tab[vname] = al._yearly(chosen[vname]["daily"].values, chosen[vname]["daily"].index) + years = sorted(tab["BASE"]) + print(" anno " + "".join(f"{k:>10}" for k in tab)) + for y in years: + print(f" {y} " + "".join(f"{tab[k].get(y, {}).get('ret', float('nan')) * 100:+9.1f}%" for k in tab)) + print(" DDmax " + "".join( + f"{max(tab[k][y]['dd'] for y in tab[k]) * 100:9.1f}%" for k in tab)) + + print("\nFATTO. Book/pesi INVARIATI (research only).") + + +if __name__ == "__main__": + main()