"""agent_01_session_overlay — SESSION OVERLAY on the daily TSMOM trend (TF=1h). ANGLE [family=session, slug=session_overlay]: be in the daily trend position only during the strongest session (Asia/EU/US blocks); reduce/flat in the weak session. Causal session-return estimates. MINIMIZE flips. STRUCTURAL BASIS (measured, both BTC & ETH): crypto drift is concentrated in EU+US hours; Asia hours (UTC 0-7) carry ~0 mean return but full variance -> bad reward/risk. So holding the trend through dead Asia hours adds vol without return. The overlay down-weights the session that is causally the weakest. FEE DISCIPLINE: a naive in/out-per-session flip churns ~700x/yr = fee-death. We keep turnover bounded by (a) a SLOW trend (TP01 horizons 30/90/180d -> monthly flips), and (b) modulating exposure across only 2 levels with a session weight that itself changes slowly (a causal EXPANDING ranking of sessions, re-evaluated, not per-bar noise). CAUSAL: the session strength is an expanding mean of past per-session hourly returns (data strictly < current bar). No full-sample calendar fit. """ 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 # Session blocks in UTC (8h each). Asia tends to be the dead block for crypto. # 0=Asia(0-7), 1=EU(8-15), 2=US(16-23) def _session_id(hours: np.ndarray) -> np.ndarray: return np.where(hours < 8, 0, np.where(hours < 16, 1, 2)).astype(int) def _causal_session_weak(r: np.ndarray, sess: np.ndarray, bpd: int, warmup_days: int = 180) -> np.ndarray: """For each bar i, return the id of the session that is CAUSALLY weakest by expanding mean hourly return using data strictly before i. Before warmup -> -1 (no opinion). Computed once per day (at the first bar of each session-0 day) so it changes slowly.""" n = len(r) weak = np.full(n, -1, dtype=int) # running sums per session ssum = np.zeros(3) scnt = np.zeros(3) warm = warmup_days * bpd # We update the running stats with bar i-1 before deciding for bar i (strictly causal). cur_weak = -1 for i in range(1, n): s_prev = sess[i - 1] ssum[s_prev] += r[i - 1] scnt[s_prev] += 1 if i >= warm and scnt.min() > 0: means = ssum / scnt cur_weak = int(np.argmin(means)) weak[i] = cur_weak return weak def target(df: pd.DataFrame) -> np.ndarray: c = df["close"].values.astype(float) dt = pd.to_datetime(df["datetime"], utc=True) hours = dt.dt.hour.values bpd = al.bars_per_day(df) # 24 at 1h # --- TP01-style slow trend direction (long-flat) ------------------------- horizons = tuple(d * bpd for d in (30, 90, 180)) nbar = len(c) acc = np.zeros(nbar); cnt = np.zeros(nbar) for h in horizons: s = np.full(nbar, np.nan) s[h:] = np.sign(c[h:] / c[:-h] - 1.0) v = np.isfinite(s) acc[v] += s[v]; cnt[v] += 1 direction = np.zeros(nbar) nz = cnt > 0 direction[nz] = acc[nz] / cnt[nz] direction = np.clip(direction, 0, None) # long-flat like TP01 # vol-target (TP01 canonical) base = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) # --- session overlay ----------------------------------------------------- r = al.simple_returns(c) sess = _session_id(hours) weak = _causal_session_weak(r, sess, bpd, warmup_days=180) # weight: full exposure outside the causally-weak session, reduced during it. # NOTE (honest, after a full sweep): every step away from 1.0 (i.e. MORE overlay) # strictly degrades both Sharpe and turnover vs plain TP01 — the dead-Asia effect is # already captured by TP01's vol-targeting, and gating removes good trend days too. # 0.9 is the least-harmful overlay. The angle does NOT earn a slot (see report notes). w_weak = 0.9 sess_w = np.where(sess == weak, w_weak, 1.0) sess_w[weak < 0] = 1.0 # no opinion -> full (TP01 behavior) return base * sess_w