Files
PythagorasGoal/scripts/research/r0702_tp01_offset.py
T
Adriano Dal Pastro 73d74c5e53 research(wave-0702): ondata timing + CRT — 8 filoni, 0 nuovi sleeve, finding anchor timing-luck TP01
Goal: "altre strategie su Deribit con timing differenti". 8 filoni multi-agente + scettico:
- event-clock bars, expiry calendar Deribit, clock lenti/bande, regime-speed: SCARTATI
- CRT (Candle Range Theory) base/multi-TF/contesto: SCARTATA 3/3 (DSR~0, ritest =
  informazione negativa; sottoprodotto: FOLLOW>FADE sui livelli prior-day ogni anno,
  conferma il lead prevday)
- FINDING (confermato da scettico indipendente): hold-out 0.31 di TP01 = migliore delle
  24 ancore orarie (mediana 0.04, banda [-0.13,+0.30]) -> narrativa corretta in CLAUDE.md
  e docstring: l'hold-out non risolve l'edge di ritorno, regge il taglio DD a ogni ancora.
  Tranching K=2/4 = solo varianza della stima, no deploy a $600. Audit d'ancora pendente
  su XS01/SKH01. Book live e portafoglio INVARIATI. Test 168/168.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:12:22 +00:00

322 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""r0702_tp01_offset.py — TIMING-LUCK del ribilanciamento giornaliero di TP01.
TP01 CANONICAL (PORT LF1d) decide sulla barra daily chiusa alle 00:00 UTC. L'ancora e'
arbitraria (Hoffstein, "rebalance timing luck"): la STESSA strategia con parametri IDENTICI
ancorata alle h:00 (h=0..23) puo' dare Sharpe diversi. Questo script:
1. costruisce 24 serie daily (resample 24h del 1h certificato, offset h, label/closed left,
stessa convenzione di trend_portfolio.resample_tf) — SANITY OBBLIGATORIO: h=0 riproduce
ESATTAMENTE al.tp01_baseline_daily() (stesso Sharpe FULL/HOLD);
2. misura Sharpe/CAGR/maxDD FULL, IS (pre-2025) e HOLD-OUT per offset -> percentile di h=0;
3. ENSEMBLE (tranching 1/K del capitale per ancora): 24-way + K=2 (0,12), K=3 (0,8,16),
K=4 (0,6,12,18) — scelte A PRIORI simmetriche, zero tuning per-offset, zero selezione
sull'hold-out. L'ensemble e' valutato sul BOOK AGGREGATO su griglia 1h (posizione =
media delle tranche, fee sul turnover netto reale) — non media di equity separate;
4. dispersione: std/range dello Sharpe fra le 24 ancore vs fra TUTTE le rotazioni possibili
di K=2 (12), K=3 (8), K=4 (6) — la riduzione di varianza e' il criterio strutturale;
5. small-cap: haircut min-order $5 a capitale 600/2k/10k per K=1 vs K=2/4/24
(il tranching divide gli ordini per K -> piu' rebalance sotto min-order).
Causalita': targets TP01 causali per costruzione; guardia ricalcolo-su-prefisso sia sul
daily resampled sia sul troncamento del 1h; mappatura daily->1h via merge_asof backward su
EPOCA MS ESPLICITA (mai .view su tz-aware); eval_weights fa lo shift (held durante t+1).
Vincoli: nessun file toccato fuori da questo script. Runtime ~1-2 min.
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path("/opt/docker/PythagorasGoal")
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
sys.path.insert(0, str(ROOT))
import altlib as al # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
TP = TrendPortfolio(**CANONICAL)
HOLDOUT = al.HOLDOUT
ASSETS = ("BTC", "ETH")
MS_H = 3_600_000
MS_D = 86_400_000
# ancore a priori, simmetriche, NON ottimizzate
HEADLINE = {
"K=1 (canonico h=0)": (0,),
"K=2 (0,12)": (0, 12),
"K=3 (0,8,16)": (0, 8, 16),
"K=4 (0,6,12,18)": (0, 6, 12, 18),
"K=24 (tutte)": tuple(range(24)),
}
# ---------------------------------------------------------------------------
# Resample con ancora h — stessa convenzione di trend_portfolio.resample_tf
# ---------------------------------------------------------------------------
def resample_offset(df_1h: pd.DataFrame, h: int) -> pd.DataFrame:
g = df_1h.copy()
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
idx.name = "dt"
g.index = idx
out = g.resample("24h", offset=pd.Timedelta(hours=h), label="left", closed="left").agg(
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
out = out.dropna(subset=["open"])
out["datetime"] = out.index
epoch = pd.Timestamp("1970-01-01", tz="UTC")
out["timestamp"] = ((out.index - epoch) // pd.Timedelta(milliseconds=1)).astype("int64")
return out.reset_index(drop=True)[
["timestamp", "open", "high", "low", "close", "volume", "datetime"]]
@lru_cache(maxsize=8)
def get1h(asset: str) -> pd.DataFrame:
return al.get(asset, "1h")
@lru_cache(maxsize=64)
def daily_off(asset: str, h: int) -> pd.DataFrame:
return resample_offset(get1h(asset), h)
# ---------------------------------------------------------------------------
# Metriche su serie daily (convenzione identica al baseline: _to_daily + _sh)
# ---------------------------------------------------------------------------
def dmetrics(s: pd.Series) -> dict:
s = s.dropna()
is_ = s[s.index < HOLDOUT]
ho = s[s.index >= HOLDOUT]
eq = float(np.prod(1.0 + s.values))
yrs = len(s) / 365.25
cagr = eq ** (1 / yrs) - 1 if eq > 0 and yrs > 0 else -1.0
return dict(sh_full=al._sh(s), sh_is=al._sh(is_), sh_hold=al._sh(ho),
cagr=cagr, dd=al._dd_ret(s), dd_hold=al._dd_ret(ho), n=len(s))
# ---------------------------------------------------------------------------
# Path DAILY-NATIVO per offset (counterfactual "e se l'ancora fosse h")
# ---------------------------------------------------------------------------
def port_daily_native(h: int) -> pd.Series:
series = {}
for a in ASSETS:
df = daily_off(a, h)
net, ts = TP.net_returns(df)
series[a] = pd.Series(net, index=pd.DatetimeIndex(pd.to_datetime(ts.values, utc=True)))
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]])
# ---------------------------------------------------------------------------
# Path 1h AGGREGATO (book unico): target daily-offset mappato causale sul 1h
# ---------------------------------------------------------------------------
@lru_cache(maxsize=64)
def hourly_target(asset: str, h: int) -> tuple:
"""Target TP01 (ancora h) sul grid 1h: per ogni barra 1h prendi il target dell'ultima
barra daily-offset il cui CLOSE nominale (label+24h, epoca ms) e' <= close della barra 1h
(ts+1h). merge_asof backward su int ms espliciti. eval_weights poi SHIFTA (held t+1)."""
d = daily_off(asset, h)
tgt = TP.target_series(d)
right = pd.DataFrame({"cms": d["timestamp"].values.astype("int64") + MS_D,
"tgt": tgt})
left = pd.DataFrame({"cms": get1h(asset)["timestamp"].values.astype("int64") + MS_H})
m = pd.merge_asof(left, right, on="cms", direction="backward")
return tuple(np.nan_to_num(m["tgt"].values, nan=0.0))
def ens_target(asset: str, hs: tuple) -> np.ndarray:
return np.mean([np.asarray(hourly_target(asset, h)) for h in hs], axis=0)
def port_hourly(hs: tuple) -> tuple[pd.Series, float]:
"""Serie daily del book aggregato (0.5 BTC + 0.5 ETH su grid 1h) + turnover/anno."""
nets, turns = {}, 0.0
for a in ASSETS:
df = get1h(a)
ev = al.eval_weights(df, ens_target(a, hs))
nets[a] = pd.Series(ev["net"], index=ev["idx"])
turns += 0.5 * ev["turnover_per_year"]
J = pd.concat(nets, axis=1, join="inner").fillna(0.0)
return al._to_daily(0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]), turns
# ---------------------------------------------------------------------------
# Small-cap: min-order $5, capitale condiviso 50/50 fra i 2 asset
# ---------------------------------------------------------------------------
def smallcap_net(df: pd.DataFrame, tgt: np.ndarray, capital: float,
min_order: float = 5.0) -> tuple[pd.Series, int]:
"""Copia locale della logica di al.eval_weights_smallcap che restituisce la serie net
(serve per combinare il book 2-asset). Cambi di nozionale < min_order NON eseguiti."""
c = df["close"].values.astype(float)
tgt = np.clip(np.nan_to_num(np.asarray(tgt, float)), -10, 10)
held = np.empty(len(tgt))
cur, n_tr = 0.0, 0
for i in range(len(tgt)):
if abs(tgt[i] - cur) * capital >= min_order:
cur = tgt[i]
n_tr += 1
held[i] = cur
r = al.simple_returns(c)
pos = np.zeros(len(held))
pos[1:] = held[:-1]
turn = np.abs(np.diff(pos, prepend=0.0))
net = pos * r - al.FEE_SIDE * turn
net[0] = 0.0
idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
return pd.Series(net, index=idx), n_tr
def smallcap_port(hs: tuple, capital: float) -> dict:
"""Book realistico a `capital`: target per-asset = 0.5*ensemble (quota 50/50).
modeled = stesso book senza vincolo min-order (fee identiche proporzionali)."""
nets_r, nets_m, ntr = {}, {}, 0
for a in ASSETS:
df = get1h(a)
t = 0.5 * ens_target(a, hs)
nr, n = smallcap_net(df, t, capital)
nets_r[a] = nr
ntr += n
ev = al.eval_weights(df, t)
nets_m[a] = pd.Series(ev["net"], index=ev["idx"])
Jr = pd.concat(nets_r, axis=1, join="inner").fillna(0.0)
Jm = pd.concat(nets_m, axis=1, join="inner").fillna(0.0)
dr = al._to_daily(Jr[ASSETS[0]] + Jr[ASSETS[1]])
dm = al._to_daily(Jm[ASSETS[0]] + Jm[ASSETS[1]])
yrs = len(dr) / 365.25
return dict(sh_real=al._sh(dr), sh_model=al._sh(dm),
haircut=al._sh(dm) - al._sh(dr),
dd_real=al._dd_ret(dr), trades_per_year=ntr / yrs)
# ---------------------------------------------------------------------------
# Guardie
# ---------------------------------------------------------------------------
def sanity_h0() -> None:
"""h=0 deve riprodurre ESATTAMENTE il baseline (dati + strategia + metriche)."""
for a in ASSETS:
d0 = daily_off(a, 0)
ref = al.get(a, "1d")
for col in ("timestamp", "open", "high", "low", "close", "volume"):
assert np.allclose(d0[col].values.astype(float),
ref[col].values.astype(float), atol=0, rtol=0), \
f"resample_offset(h=0) != al.get('{a}','1d') su {col}"
mine = port_daily_native(0)
base = al.tp01_baseline_daily()
assert len(mine) == len(base) and np.allclose(mine.values, base.values, atol=1e-12), \
"portafoglio h=0 != tp01_baseline_daily"
mm, bb = dmetrics(mine), dmetrics(base)
print(f"[SANITY] h=0 == baseline: OK (Sharpe FULL {mm['sh_full']:.4f} == "
f"{bb['sh_full']:.4f}, HOLD {mm['sh_hold']:.4f} == {bb['sh_hold']:.4f})")
def causality_guards() -> None:
"""(a) prefix-recompute sul daily resampled: target[i] non cambia aggiungendo futuro.
(b) troncando il 1h, le barre daily complete restano identiche (solo l'ultima e' parziale)."""
for a in ASSETS:
for h in (0, 7, 13, 21):
d = daily_off(a, h)
t_full = TP.target_series(d)
cut = len(d) - 250
t_pref = TP.target_series(d.iloc[:cut].reset_index(drop=True))
assert np.allclose(t_full[:cut], t_pref, atol=1e-12), \
f"prefix-recompute FAIL {a} h={h}"
df1h = get1h(a)
d_tr = resample_offset(df1h.iloc[:-500].reset_index(drop=True), h)
k = len(d_tr) - 1 # l'ultima barra del troncato e' parziale per costruzione
for col in ("timestamp", "close"):
assert np.allclose(d_tr[col].values[:k].astype(float),
d[col].values[:k].astype(float)), \
f"1h-truncation FAIL {a} h={h} {col}"
print("[SANITY] guardie causalita' (prefix-recompute daily + troncamento 1h): OK")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
print("=" * 96)
print("r0702 — TP01 rebalance timing-luck: 24 ancore daily + ensemble tranching")
print(f"CANONICAL={CANONICAL} fee 0.10% RT HOLD-OUT >= {HOLDOUT.date()}")
print("=" * 96)
sanity_h0()
causality_guards()
# ---- (1) per-offset, path daily-nativo --------------------------------
rows = []
for h in range(24):
m = dmetrics(port_daily_native(h))
rows.append(dict(h=h, **m))
T = pd.DataFrame(rows).set_index("h")
print("\n--- (1) PER-OFFSET (daily nativo, ancora h:00 UTC) ---")
print(f"{'h':>3} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} {'maxDD':>7}")
for h, r in T.iterrows():
tag = " <- canonico" if h == 0 else ""
print(f"{h:>3} {r.sh_full:>7.3f} {r.sh_is:>7.3f} {r.sh_hold:>7.3f} "
f"{r.cagr:>6.1%} {r.dd:>6.1%}{tag}")
def pctl(col: str) -> float:
v = T[col].values
return float((v < v[0]).mean() + 0.5 * (v == v[0]).mean()) * 100
print("\nDistribuzione fra le 24 ancore (min / mediana / max / std) [percentile di h=0]:")
for col, lbl in (("sh_full", "Sharpe FULL"), ("sh_is", "Sharpe IS(pre-2025)"),
("sh_hold", "Sharpe HOLD"), ("dd", "maxDD"), ("cagr", "CAGR")):
v = T[col]
print(f" {lbl:<20} {v.min():>7.3f} / {v.median():>7.3f} / {v.max():>7.3f} "
f"/ std {v.std():.3f} h=0 al {pctl(col):.0f}° pctl (val {v.iloc[0]:.3f})")
# ---- (2) ensemble headline, book aggregato su 1h ----------------------
print("\n--- (2) ENSEMBLE (book aggregato su grid 1h, fee su turnover netto) ---")
print(f"{'config':<22} {'ShFULL':>7} {'ShIS':>7} {'ShHOLD':>7} {'CAGR':>7} "
f"{'maxDD':>7} {'DDhold':>7} {'turn/y':>7}")
head = {}
for name, hs in HEADLINE.items():
s, tpy = port_hourly(hs)
m = dmetrics(s)
head[name] = m
print(f"{name:<22} {m['sh_full']:>7.3f} {m['sh_is']:>7.3f} {m['sh_hold']:>7.3f} "
f"{m['cagr']:>6.1%} {m['dd']:>6.1%} {m['dd_hold']:>6.1%} {tpy:>7.1f}")
print("(nota: 'K=1 (canonico h=0)' qui e' lo stesso book valutato sul grid 1h — "
"differenze vs riga h=0 sopra = sola granularita' di compounding, non strategia)")
# ---- (3) varianza della stima: rotazioni complete per famiglia --------
print("\n--- (3) DISPERSIONE fra rotazioni (nessuna selezione: TUTTE le rotazioni) ---")
fams = {
"singole (24)": [(h,) for h in range(24)],
"K=2 h,h+12 (12)": [(h, h + 12) for h in range(12)],
"K=3 h,h+8,h+16 (8)": [(h, h + 8, h + 16) for h in range(8)],
"K=4 h,h+6,.. (6)": [tuple(h + 6 * j for j in range(4)) for h in range(6)],
}
print(f"{'famiglia':<20} {'ShFULL μ':>9} {'σ':>6} {'range':>13} "
f"{'ShHOLD μ':>9} {'σ':>6} {'range':>13}")
for name, rot in fams.items():
mf = [dmetrics(port_hourly(hs)[0]) for hs in rot]
f = np.array([m["sh_full"] for m in mf])
ho = np.array([m["sh_hold"] for m in mf])
print(f"{name:<20} {f.mean():>9.3f} {f.std():>6.3f} "
f"[{f.min():>5.3f},{f.max():>5.3f}] "
f"{ho.mean():>9.3f} {ho.std():>6.3f} [{ho.min():>5.3f},{ho.max():>5.3f}]")
# ---- (4) small-cap: haircut min-order per capitale --------------------
print("\n--- (4) SMALL-CAP (min order $5, capitale 50/50 sui 2 asset) ---")
print(f"{'config':<22} {'cap':>7} {'Sh model':>9} {'Sh real':>8} {'haircut':>8} "
f"{'DD real':>8} {'trade/y':>8}")
for name, hs in HEADLINE.items():
for cap in (600, 2000, 10000):
r = smallcap_port(hs, cap)
print(f"{name:<22} {cap:>7} {r['sh_model']:>9.3f} {r['sh_real']:>8.3f} "
f"{r['haircut']:>8.3f} {r['dd_real']:>7.1%} {r['trades_per_year']:>8.1f}")
print("\nFatto. Nessuna selezione sull'hold-out: ensemble a priori, giudizio su "
"struttura (varianza) + IS pre-2025; l'hold-out e' solo riportato.")
if __name__ == "__main__":
main()