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>
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user