research(weekend): ondata ven->lun SCARTATA 4/4 — famiglia calendario SATURA; weekend = 38% del gross TP01 (mai de-esporre)

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 <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-07-18 15:03:44 +00:00
parent 67f7b89e4c
commit 7534b08be0
7 changed files with 1584 additions and 0 deletions
+334
View File
@@ -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()
+247
View File
@@ -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()
+451
View File
@@ -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<lo -> -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<friL.
Flat forzato da lun 12:00."""
C = _cal(df)
c = df["close"].values.astype(float)
dates = C["dt"].dt.normalize()
frim = C["wd"] == 4
sub = pd.DataFrame({"d": dates[frim].values,
"h": df["high"].values[frim].astype(float),
"l": df["low"].values[frim].astype(float),
"c": c[frim]})
g = sub.groupby("d").agg(H=("h", "max"), L=("l", "min"), Cl=("c", "last"))
dsf = (C["wd"] - 4) % 7 # giorni dal venerdi'
fri_date = pd.Series((dates - pd.to_timedelta(dsf, unit="D")).values)
fH = fri_date.map(g["H"]).values
fL = fri_date.map(g["L"]).values
fC = fri_date.map(g["Cl"]).values
wk = (C["wd"] == 5) | (C["wd"] == 6) # solo sab/dom: livello CAUSALE
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
with np.errstate(invalid="ignore"):
if level == "HL":
raw[wk & (c > 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()
+418
View File
@@ -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()