"""CMB03 — Multi-TF trend confirm (4h fast + 1d slow agreement). HYPOTHESIS: On the 4h TF, go long only when the 1d trend (TSMOM or SMA50) agrees (is bullish). The intuition is that a fast-TF TSMOM signal might have more noise; filtering by the slow TF trend reduces false signals. CAUSAL ALIGNMENT (critical - see obs 4866): - 1d bar at timestamp T closes at end of day T. The 4h bar that CLOSES at the same time or later (within day T+1 onwards) can use it causally. - We compute the 1d signal on the 1d dataframe, then merge_asof onto 4h using the 1d bar CLOSE timestamp -> the 4h bar is valid only AFTER the 1d bar has fully closed (direction="forward" with offset to avoid using the still-open 1d bar). - Implementation: for each 1d bar at timestamp T_close, the signal becomes available at T_close (the bar just closed). We map it to 4h bars whose open timestamp >= T_close (i.e. the NEXT 4h bar after the 1d bar closed). This means we use pandas merge_asof with left=4h open timestamps and right=1d close timestamps, direction="backward" — the 4h bar at open T gets the most recent 1d signal where 1d_close <= 4h_open. GRID (4 configs x 2 assets x 1 TF = 8 backtests): A: 4h fast TSMOM (1m,3m) + 1d confirm SMA50 (price>SMA50) B: 4h fast TSMOM (1m,3m) + 1d confirm TSMOM (1m,3m,6m) C: 4h SMA crossover (20>50) + 1d confirm SMA50 D: 4h SMA crossover (20>50) + 1d confirm TSMOM (1m,3m,6m) All configs: long-only (0 or +1 direction), vol-targeted (20%, cap 2x). """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd # --------------------------------------------------------------------------- # Helper: compute 1d trend signal and align causally to 4h bars # --------------------------------------------------------------------------- def _1d_tsmom_signal(df_1d: pd.DataFrame) -> np.ndarray: """TSMOM on 1d bars: long if majority of 1m/3m/6m horizons are positive. Returns array in {0, +1} (long-flat, no short). Decision at bar i uses close[i] (causal). Array indexed by 1d bar.""" c = df_1d["close"].values.astype(float) bpd = al.bars_per_day(df_1d) # should be ~1 for 1d horizons = [30 * bpd, 90 * bpd, 180 * bpd] votes = np.zeros(len(c)) for h in horizons: h = int(h) sig = np.full(len(c), np.nan) if h < len(c): sig[h:] = np.sign(c[h:] / c[:-h] - 1.0) votes += np.nan_to_num(sig, nan=0.0) # Long when majority (>=1 out of 3) positive return np.where(votes > 0, 1.0, 0.0) def _1d_sma50_signal(df_1d: pd.DataFrame) -> np.ndarray: """SMA50 trend on 1d: long when close > SMA50. Returns {0, +1}.""" c = df_1d["close"].values.astype(float) sma50 = al.sma(c, 50) return np.where(c > sma50, 1.0, 0.0) def _align_1d_to_4h(df_1d: pd.DataFrame, signal_1d: np.ndarray, df_4h: pd.DataFrame) -> np.ndarray: """Map 1d signal onto 4h bars CAUSALLY. A 1d bar at timestamp T (which is the bar's OPEN time in ms) closes at T + 86400000ms. We expose the signal AFTER the 1d bar has fully closed, i.e. it's available to 4h bars whose open time >= T + 86400000ms (the start of the next day). Procedure: 1. Build a series: (1d_close_timestamp, signal_1d) 1d_close_ts = df_1d["timestamp"] + 86400000 (next bar open = this bar closed) 2. For each 4h bar (open timestamp), take the most recent 1d signal where 1d_close_ts <= 4h_open_ts (merge_asof backward). 3. Forward-fill NaN (no signal yet = 0). """ # 1d bar open timestamps + period offset = close timestamp = next 4h eligible # Compute 1d bar period in ms: use median diff of timestamps ts_1d = df_1d["timestamp"].values.astype(np.int64) diffs_1d = np.diff(ts_1d) period_ms = int(np.median(diffs_1d)) if len(diffs_1d) > 0 else 86_400_000 # 1d_close_ts: the moment this 1d bar closed (= open of the NEXT bar) close_ts_1d = ts_1d + period_ms # available after this timestamp right = pd.DataFrame({ "close_ts": close_ts_1d, "sig": signal_1d.astype(float), }).sort_values("close_ts") ts_4h = df_4h["timestamp"].values.astype(np.int64) left = pd.DataFrame({"open_ts": ts_4h}) merged = pd.merge_asof( left, right.rename(columns={"close_ts": "open_ts"}), on="open_ts", direction="backward", ) out = merged["sig"].values.astype(float) # NaN = no 1d bar has closed yet -> be conservative, no position out = np.nan_to_num(out, nan=0.0) return out # --------------------------------------------------------------------------- # Fast-TF (4h) signals # --------------------------------------------------------------------------- def _4h_tsmom(df_4h: pd.DataFrame) -> np.ndarray: """TSMOM on 4h: long if 1m and 3m horizons agree (majority of 2).""" c = df_4h["close"].values.astype(float) bpd = al.bars_per_day(df_4h) # ~6 for 4h h1m = int(30 * bpd) h3m = int(90 * bpd) votes = np.zeros(len(c)) for h in [h1m, h3m]: sig = np.full(len(c), np.nan) if h < len(c): sig[h:] = np.sign(c[h:] / c[:-h] - 1.0) votes += np.nan_to_num(sig, nan=0.0) # Long when net positive (at least 1 of 2) return np.where(votes > 0, 1.0, 0.0) def _4h_sma_cross(df_4h: pd.DataFrame, fast=20, slow=50) -> np.ndarray: """SMA crossover on 4h: long when SMA(fast) > SMA(slow).""" c = df_4h["close"].values.astype(float) sma_f = al.sma(c, fast) sma_s = al.sma(c, slow) return np.where(sma_f > sma_s, 1.0, 0.0) # --------------------------------------------------------------------------- # Combined target functions (4h TF, 1d confirm) # --------------------------------------------------------------------------- def make_target(asset: str, fast_type: str, slow_type: str): """Return a target_fn(df_4h) -> position array. Because altlib calls target_fn(df) with the chosen TF df, we fetch the 1d df inside the closure (cached by altlib.get). """ def target_fn(df_4h: pd.DataFrame) -> np.ndarray: # 1d dataframe for same asset (cached) df_1d = al.get(asset, "1d") # Compute 1d confirmation signal if slow_type == "sma50": sig_1d = _1d_sma50_signal(df_1d) elif slow_type == "tsmom": sig_1d = _1d_tsmom_signal(df_1d) else: raise ValueError(f"Unknown slow_type: {slow_type}") # Align 1d signal onto 4h bars (causal) confirm_4h = _align_1d_to_4h(df_1d, sig_1d, df_4h) # Compute 4h fast signal if fast_type == "tsmom": fast_4h = _4h_tsmom(df_4h) elif fast_type == "sma_cross": fast_4h = _4h_sma_cross(df_4h) else: raise ValueError(f"Unknown fast_type: {fast_type}") # Combined: long only when BOTH signals agree direction = np.where((fast_4h > 0) & (confirm_4h > 0), 1.0, 0.0) # Vol-target (20%, cap 2x) return al.vol_target(direction, df_4h, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return target_fn # --------------------------------------------------------------------------- # Grid: 4 configs # --------------------------------------------------------------------------- CONFIGS = [ dict(fast="tsmom", slow="sma50", label="tsmom4h_sma50_1d"), dict(fast="tsmom", slow="tsmom", label="tsmom4h_tsmom_1d"), dict(fast="sma_cross", slow="sma50", label="smacross4h_sma50_1d"), dict(fast="sma_cross", slow="tsmom", label="smacross4h_tsmom_1d"), ] print("=== CMB03: Multi-TF trend confirm (4h fast + 1d slow) ===") print(f"Grid: {len(CONFIGS)} configs x 2 assets x 1 TF = {len(CONFIGS)*2} backtests\n") results = [] for cfg in CONFIGS: label = cfg["label"] fast = cfg["fast"] slow = cfg["slow"] # Build per-asset target functions # study_weights calls target_fn(df) for each asset, but we need to know # WHICH asset to fetch the 1d df for. We use a workaround: wrap in a # function that identifies the asset by calling al.get for BTC then ETH # and matching timestamps. # # Cleaner approach: run each asset separately and combine. # altlib.study_weights iterates assets internally, so we need target_fn(df) # to know the asset. We do this by checking df timestamps against cached dfs. def _target_fn(df_4h, _fast=fast, _slow=slow): # Identify asset by matching df timestamps to known cached dfs ts = df_4h["timestamp"].values[0] # Try BTC first, then ETH for _asset in ("BTC", "ETH"): try: _df_check = al.get(_asset, "4h") if _df_check["timestamp"].values[0] == ts: return make_target(_asset, _fast, _slow)(df_4h) except Exception: pass # Fallback: try matching by length or first close c0 = df_4h["close"].values[0] for _asset in ("BTC", "ETH"): _df_check = al.get(_asset, "4h") if abs(_df_check["close"].values[0] - c0) / c0 < 0.01: return make_target(_asset, _fast, _slow)(df_4h) # Last resort return make_target("BTC", _fast, _slow)(df_4h) rep = al.study_weights( f"CMB03-{label}", _target_fn, tfs=("4h",), ) print(al.fmt(rep)) print(f" JSON: {al.as_json(rep)}\n") results.append((rep, cfg)) # --------------------------------------------------------------------------- # Pick best config by min_asset_holdout_sharpe # --------------------------------------------------------------------------- def best_holdout(item): rep = item[0] cells = rep.get("cells", []) if not cells: return -99.0 return max(c.get("min_asset_holdout_sharpe", -99.0) for c in cells) results.sort(key=best_holdout, reverse=True) best_rep, best_cfg = results[0] print("\n" + "=" * 60) print(f"BEST CONFIG: {best_cfg['label']}") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))