73d74c5e53
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>
320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""r0702_slow_clock.py — FILONE: clock più lenti del daily + banded rebalancing per TP01.
|
||
|
||
Due idee di TIMING DI ESECUZIONE (non di segnale) sul TP01 CANONICAL (PORT LF1d):
|
||
(A) CLOCK LENTI — segnale calcolato daily, posizione aggiornata solo ogni N giorni
|
||
(N in {2,3,5,7}). ⚠ timing luck: si riportano TUTTE le N fasi (min/med/max) e
|
||
l'ENSEMBLE delle fasi (media dei libri sfasati), MAI la fase migliore da sola.
|
||
(B) BANDE DI ISTERESI — decisione daily, si esegue solo se |target − posizione| > banda
|
||
(banda in frazione di equity PER ASSET, in {0, .025, .05, .10, .20}); quando si
|
||
esegue si va al target pieno.
|
||
|
||
Onestà:
|
||
- selezione cella SOLO in-sample pre-2025 (pattern al.select_cell_insample); l'hold-out
|
||
della cella scelta si RIPORTA, non si sceglie.
|
||
- deflated Sharpe (al.deflated_sharpe) su TUTTI i trial esplorati (fasi incluse).
|
||
- Sharpe LORDO (fee=0) accanto al netto: una variante di esecuzione onesta ha lordo
|
||
~uguale al canonico e netto >= (il guadagno è meccanico-di-costo, non fitting).
|
||
- executability: replica di eval_weights_smallcap a capitale 600/2000/10000 (min order
|
||
$5, capitale per-asset = C/2) per baseline vs variante scelta — a $600 la banda
|
||
implicita min-order è 5/(600/2) ≈ 0.0167 di peso per asset.
|
||
- causalità: target TP01 causale (verificato altrove); i filtri di esecuzione usano solo
|
||
stato passato; eval_weights fa lo shift +1; check prefix-consistency inline sulla
|
||
cella scelta. Nessun ffill mixed-TF, nessun .view("int64") su tz-aware.
|
||
|
||
Convenzione (stessa di eval_weights/TrendPortfolio): il peso resta costante tra i
|
||
ribilanciamenti (fee solo su |Δpeso|); il drift del peso intra-periodo non è modellato
|
||
(secondo ordine a N<=7 giorni) — dichiarato nei caveat.
|
||
|
||
Run: uv run python scripts/research/r0702_slow_clock.py
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
|
||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||
import altlib as al # noqa: E402
|
||
|
||
import numpy as np # noqa: E402
|
||
import pandas as pd # noqa: E402
|
||
|
||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio # noqa: E402
|
||
|
||
HOLDOUT = al.HOLDOUT
|
||
FEE = al.FEE_SIDE
|
||
EPOCH = pd.Timestamp("1970-01-01", tz="UTC")
|
||
|
||
CLOCK_NS = (2, 3, 5, 7)
|
||
BANDS = (0.0, 0.025, 0.05, 0.10, 0.20)
|
||
CAPITALS = (600.0, 2000.0, 10000.0)
|
||
MIN_ORDER = 5.0
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# targets & execution filters (tutti causali: stato = solo passato)
|
||
# ---------------------------------------------------------------------------
|
||
def daily_targets() -> dict[str, tuple[pd.DataFrame, np.ndarray]]:
|
||
tp = TrendPortfolio(**CANONICAL)
|
||
out = {}
|
||
for a in al.CERTIFIED:
|
||
df = al.get(a, "1d")
|
||
out[a] = (df, tp.target_series(df))
|
||
return out
|
||
|
||
|
||
def epoch_days(df: pd.DataFrame) -> np.ndarray:
|
||
dt = pd.to_datetime(df["datetime"], utc=True)
|
||
return ((dt.dt.floor("D") - EPOCH) // pd.Timedelta(days=1)).values.astype(int)
|
||
|
||
|
||
def slow_clock_exec(df: pd.DataFrame, tgt: np.ndarray, N: int, phase: int) -> np.ndarray:
|
||
"""Aggiorna la posizione solo nei giorni con epoch_day % N == phase (ancoraggio a
|
||
calendario -> prefix-consistent, entrambe le gambe ribilanciano lo stesso giorno)."""
|
||
days = epoch_days(df)
|
||
out = np.empty(len(tgt))
|
||
cur = 0.0
|
||
for i in range(len(tgt)):
|
||
if days[i] % N == phase:
|
||
cur = tgt[i]
|
||
out[i] = cur
|
||
return out
|
||
|
||
|
||
def band_exec(tgt: np.ndarray, band: float) -> np.ndarray:
|
||
"""Esegue (va al target pieno) solo se |target − posizione corrente| > band."""
|
||
out = np.empty(len(tgt))
|
||
cur = 0.0
|
||
for i in range(len(tgt)):
|
||
if abs(tgt[i] - cur) > band:
|
||
cur = tgt[i]
|
||
out[i] = cur
|
||
return out
|
||
|
||
|
||
def smallcap_exec(tgt: np.ndarray, capital_per_asset: float,
|
||
min_order: float = MIN_ORDER) -> np.ndarray:
|
||
"""Replica della logica di al.eval_weights_smallcap (serve la SERIE, non solo le
|
||
metriche): un Δpeso il cui nozionale < min_order NON si esegue."""
|
||
out = np.empty(len(tgt))
|
||
cur = 0.0
|
||
for i in range(len(tgt)):
|
||
if abs(tgt[i] - cur) * capital_per_asset >= min_order:
|
||
cur = tgt[i]
|
||
out[i] = cur
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# valutazione book 50/50 (netto + lordo)
|
||
# ---------------------------------------------------------------------------
|
||
def _series(df: pd.DataFrame, et: np.ndarray, fee_side: float) -> pd.Series:
|
||
ev = al.eval_weights(df, et, fee_side=fee_side)
|
||
return pd.Series(ev["net"], index=ev["idx"])
|
||
|
||
|
||
def book_eval(pairs: dict[str, tuple[pd.DataFrame, np.ndarray]]) -> dict:
|
||
"""pairs: {asset: (df, exec_target)} -> metriche book 50/50 nette e lorde."""
|
||
net_s, gro_s = {}, {}
|
||
turn_book = 0.0
|
||
orders_y = 0.0
|
||
for a, (df, et) in pairs.items():
|
||
net_s[a] = _series(df, et, FEE)
|
||
gro_s[a] = _series(df, et, 0.0)
|
||
years = (df["datetime"].iloc[-1] - df["datetime"].iloc[0]).total_seconds() / 86400 / 365.25
|
||
pos = np.zeros(len(et)); pos[1:] = et[:-1]
|
||
turn = np.abs(np.diff(pos, prepend=0.0))
|
||
turn_book += 0.5 * turn.sum() / years # in unità di equity del book
|
||
orders_y += float(np.sum(turn > 1e-12) / years) # ordini reali (entrambe le gambe)
|
||
NET = pd.concat(net_s, axis=1, join="inner").fillna(0.0)
|
||
GRO = pd.concat(gro_s, axis=1, join="inner").fillna(0.0)
|
||
assets = list(pairs)
|
||
net = 0.5 * NET[assets[0]] + 0.5 * NET[assets[1]]
|
||
gro = 0.5 * GRO[assets[0]] + 0.5 * GRO[assets[1]]
|
||
|
||
def _hold(s): return s[s.index >= HOLDOUT]
|
||
def _ins(s): return s[s.index < HOLDOUT]
|
||
|
||
eqn = np.cumprod(1.0 + np.clip(net.values, -0.99, None))
|
||
span_y = (net.index[-1] - net.index[0]).total_seconds() / 86400 / 365.25
|
||
cagr = eqn[-1] ** (1 / span_y) - 1 if span_y > 0 else 0.0
|
||
return dict(
|
||
sh_full_net=al._sh(net), sh_hold_net=al._sh(_hold(net)), sh_ins_net=al._sh(_ins(net)),
|
||
sh_full_gro=al._sh(gro), sh_hold_gro=al._sh(_hold(gro)),
|
||
maxdd=al._dd_ret(net), cagr=cagr,
|
||
turnover_y=turn_book, orders_y=orders_y, net=net, gross=gro,
|
||
)
|
||
|
||
|
||
def row(tag: str, m: dict) -> str:
|
||
return (f"{tag:<26} | net F {m['sh_full_net']:5.2f} H {m['sh_hold_net']:5.2f} "
|
||
f"(IS {m['sh_ins_net']:5.2f}) | gross F {m['sh_full_gro']:5.2f} "
|
||
f"H {m['sh_hold_gro']:5.2f} | DD {m['maxdd']*100:4.1f}% | CAGR {m['cagr']*100:5.1f}% "
|
||
f"| turn/y {m['turnover_y']:5.1f} | ord/y {m['orders_y']:6.1f}")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
def main() -> None:
|
||
pairs = daily_targets()
|
||
|
||
# ---- sanity check: riproduci al.tp01_baseline_daily() -------------------
|
||
base = book_eval(pairs)
|
||
ref = al.tp01_baseline_daily()
|
||
common = base["net"].index.intersection(ref.index)
|
||
diff = float(np.max(np.abs(base["net"].reindex(common).values - ref.reindex(common).values)))
|
||
print("=" * 118)
|
||
print("SANITY — baseline daily vs al.tp01_baseline_daily():",
|
||
f"max|Δdaily ret| = {diff:.2e}",
|
||
f"(Sharpe qui {base['sh_full_net']:.3f} / ref {al._sh(ref):.3f})")
|
||
assert diff < 1e-9, "baseline non riprodotta!"
|
||
print(row("BASELINE daily band=0", base))
|
||
|
||
all_trial_sharpes: list[float] = [base["sh_full_net"]]
|
||
candidates: dict[str, dict] = {"baseline_daily": base}
|
||
|
||
# ---- (A) CLOCK LENTI: tutte le fasi + ensemble ---------------------------
|
||
print("\n" + "=" * 118)
|
||
print("(A) CLOCK LENTI — TP01 daily-signal, ribilanciamento ogni N giorni "
|
||
"(tutte le fasi: min/med/max; ensemble = media dei libri sfasati)")
|
||
print("=" * 118)
|
||
clock_tbl = {}
|
||
for N in CLOCK_NS:
|
||
phase_ms, phase_nets, phase_gros = [], [], []
|
||
for p in range(N):
|
||
pp = {a: (df, slow_clock_exec(df, tgt, N, p)) for a, (df, tgt) in pairs.items()}
|
||
m = book_eval(pp)
|
||
phase_ms.append(m)
|
||
phase_nets.append(m["net"]); phase_gros.append(m["gross"])
|
||
all_trial_sharpes.append(m["sh_full_net"])
|
||
ens_net = pd.concat(phase_nets, axis=1, join="inner").mean(axis=1)
|
||
ens_gro = pd.concat(phase_gros, axis=1, join="inner").mean(axis=1)
|
||
eqn = np.cumprod(1.0 + np.clip(ens_net.values, -0.99, None))
|
||
span_y = (ens_net.index[-1] - ens_net.index[0]).total_seconds() / 86400 / 365.25
|
||
ens = dict(
|
||
sh_full_net=al._sh(ens_net),
|
||
sh_hold_net=al._sh(ens_net[ens_net.index >= HOLDOUT]),
|
||
sh_ins_net=al._sh(ens_net[ens_net.index < HOLDOUT]),
|
||
sh_full_gro=al._sh(ens_gro), sh_hold_gro=al._sh(ens_gro[ens_gro.index >= HOLDOUT]),
|
||
maxdd=al._dd_ret(ens_net), cagr=eqn[-1] ** (1 / span_y) - 1,
|
||
turnover_y=float(np.mean([m["turnover_y"] for m in phase_ms])),
|
||
orders_y=float(np.mean([m["orders_y"] for m in phase_ms])),
|
||
net=ens_net, gross=ens_gro,
|
||
)
|
||
all_trial_sharpes.append(ens["sh_full_net"])
|
||
candidates[f"clock_N{N}_ensemble"] = ens
|
||
clock_tbl[N] = (phase_ms, ens)
|
||
|
||
fn = [m["sh_full_net"] for m in phase_ms]
|
||
hn = [m["sh_hold_net"] for m in phase_ms]
|
||
fg = [m["sh_full_gro"] for m in phase_ms]
|
||
hg = [m["sh_hold_gro"] for m in phase_ms]
|
||
dd = [m["maxdd"] for m in phase_ms]
|
||
oy = [m["orders_y"] for m in phase_ms]
|
||
print(f"N={N} fasi ({N}): net FULL min/med/max {min(fn):.2f}/{np.median(fn):.2f}/{max(fn):.2f}"
|
||
f" HOLD {min(hn):.2f}/{np.median(hn):.2f}/{max(hn):.2f}"
|
||
f" | gross FULL {min(fg):.2f}/{np.median(fg):.2f}/{max(fg):.2f}"
|
||
f" HOLD {min(hg):.2f}/{np.median(hg):.2f}/{max(hg):.2f}"
|
||
f" | DD {min(dd)*100:.1f}-{max(dd)*100:.1f}% | ord/y {min(oy):.0f}-{max(oy):.0f}")
|
||
print(row(f" N={N} ENSEMBLE", ens))
|
||
|
||
# ---- (B) BANDE DI ISTERESI ----------------------------------------------
|
||
print("\n" + "=" * 118)
|
||
print("(B) BANDE DI ISTERESI — decisione daily, esecuzione solo se |target−pos| > banda "
|
||
"(frazione di equity per asset); si va al target pieno")
|
||
print("=" * 118)
|
||
band_tbl = {}
|
||
for b in BANDS:
|
||
pp = {a: (df, band_exec(tgt, b)) for a, (df, tgt) in pairs.items()}
|
||
m = book_eval(pp)
|
||
band_tbl[b] = m
|
||
all_trial_sharpes.append(m["sh_full_net"])
|
||
if b > 0:
|
||
candidates[f"band_{b:g}"] = m
|
||
saved = base["turnover_y"] - m["turnover_y"]
|
||
print(row(f"banda {b:5.3f}", m) +
|
||
f" | turn risparmiato {saved:5.1f}/y (fee ~{saved*FEE*100*2:.2f}%/y su RT)")
|
||
|
||
# ---- selezione IN-SAMPLE (pre-2025) e hold-out riportato -----------------
|
||
print("\n" + "=" * 118)
|
||
print("SELEZIONE CELLA — solo in-sample pre-2025 (l'hold-out si riporta, non si sceglie)")
|
||
print("=" * 118)
|
||
ranked = sorted(candidates.items(), key=lambda kv: kv[1]["sh_ins_net"], reverse=True)
|
||
for name, m in ranked:
|
||
print(f" IS {m['sh_ins_net']:5.3f} | HOLD {m['sh_hold_net']:5.3f} | FULL {m['sh_full_net']:5.3f} {name}")
|
||
chosen_name, chosen = ranked[0]
|
||
n_trials = len(all_trial_sharpes)
|
||
dsr, sr0 = al.deflated_sharpe(chosen["sh_full_net"], all_trial_sharpes, chosen["net"])
|
||
print(f"\nCELLA SCELTA IN-SAMPLE: {chosen_name}")
|
||
print(row(" scelta", chosen))
|
||
print(f" trials totali esplorati: {n_trials} (fasi singole incluse)")
|
||
print(f" deflated Sharpe (vs {n_trials} trial): DSR={dsr:.3f}, null-max atteso={sr0:.3f} "
|
||
f"(NB: candidato = variante di TP01, correlatissima al baseline — l'asticella "
|
||
f"giusta è lordo~uguale/netto-migliore, non earns_slot)")
|
||
dgro = chosen["sh_full_gro"] - base["sh_full_gro"]
|
||
dnet = chosen["sh_full_net"] - base["sh_full_net"]
|
||
dgro_h = chosen["sh_hold_gro"] - base["sh_hold_gro"]
|
||
dnet_h = chosen["sh_hold_net"] - base["sh_hold_net"]
|
||
print(f" Δ vs baseline — FULL: gross {dgro:+.3f} / net {dnet:+.3f} "
|
||
f"HOLD: gross {dgro_h:+.3f} / net {dnet_h:+.3f}")
|
||
print(f" fee drag baseline: turn {base['turnover_y']:.1f}/y × {2*FEE*100:.2f}%RT "
|
||
f"≈ {base['turnover_y']*FEE*100:.2f}%/y di equity — questo è il TETTO del guadagno meccanico")
|
||
|
||
# ---- prefix-consistency (causalità dell'exec filter) ---------------------
|
||
ok = True
|
||
for a, (df, tgt) in pairs.items():
|
||
if chosen_name.startswith("band"):
|
||
b = float(chosen_name.split("_")[1])
|
||
full_e = band_exec(tgt, b)
|
||
cut = int(len(df) * 0.8)
|
||
sub = df.iloc[:cut].reset_index(drop=True)
|
||
sub_t = TrendPortfolio(**CANONICAL).target_series(sub)
|
||
sub_e = band_exec(sub_t, b)
|
||
elif chosen_name.startswith("clock"):
|
||
N = int(chosen_name.split("_")[1][1:])
|
||
full_e = slow_clock_exec(df, tgt, N, 0)
|
||
cut = int(len(df) * 0.8)
|
||
sub = df.iloc[:cut].reset_index(drop=True)
|
||
sub_t = TrendPortfolio(**CANONICAL).target_series(sub)
|
||
sub_e = slow_clock_exec(sub, sub_t, N, 0)
|
||
else:
|
||
continue
|
||
d = float(np.max(np.abs(sub_e[-60:] - full_e[cut - 60:cut])))
|
||
ok &= d < 1e-9
|
||
print(f" prefix-consistency exec-filter (fase 0 per i clock): {'OK' if ok else 'FAIL'}")
|
||
|
||
# ---- (6) EXECUTABILITY small-cap a 600 / 2000 / 10000 --------------------
|
||
print("\n" + "=" * 118)
|
||
print("(6) EXECUTABILITY — min order $5, capitale per-asset = C/2 "
|
||
"(banda implicita = 5/(C/2) in peso per asset)")
|
||
print("=" * 118)
|
||
|
||
def chosen_exec(a, df, tgt):
|
||
if chosen_name.startswith("band"):
|
||
return band_exec(tgt, float(chosen_name.split("_")[1]))
|
||
if chosen_name.startswith("clock"):
|
||
N = int(chosen_name.split("_")[1][1:])
|
||
# deploy reale = UNA fase; usiamo fase 0 e dichiariamo la timing luck
|
||
return slow_clock_exec(df, tgt, N, 0)
|
||
return tgt.copy()
|
||
|
||
for C in CAPITALS:
|
||
cpa = C / 2.0
|
||
implicit = MIN_ORDER / cpa
|
||
print(f"\ncapitale ${C:.0f} (banda implicita min-order = {implicit:.4f} peso/asset)")
|
||
for label, mk in (("baseline daily", lambda a, df, t: t.copy()),
|
||
(f"variante [{chosen_name}]", chosen_exec)):
|
||
pp = {a: (df, smallcap_exec(mk(a, df, tgt), cpa))
|
||
for a, (df, tgt) in pairs.items()}
|
||
m = book_eval(pp)
|
||
# cross-check con l'utility ufficiale (per-asset, solo full)
|
||
hc = {a: al.eval_weights_smallcap(df, mk(a, df, tgt), capital=cpa)["sharpe_haircut"]
|
||
for a, (df, tgt) in pairs.items()}
|
||
print(row(f" {label}", m) +
|
||
f" | haircut/asset vs modellato: " +
|
||
", ".join(f"{a} {h:+.3f}" for a, h in hc.items()))
|
||
|
||
print("\nNOTA: se la banda ottimale ≈ banda implicita a $600 (0.0167), il vincolo "
|
||
"small-cap del libro live sta GIÀ facendo il lavoro della banda.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|