"""STA04 — K-means regime -> trend gating. IDEA: cluster causal (vol, return, range) features using K-means with expanding statistics (z-scored causally), then enable TSMOM only in the historically-bullish/ trending cluster. No future labels. Fully causal. APPROACH: - Features (causal at bar i): 1. realized_vol (30-day annualized) 2. momentum return (lookback days) 3. normalized range = ATR / close (relative range) - Expanding z-score: we don't know the distribution of features ahead of time. We compute expanding mean/std up to bar i for each feature, then z-score. This is causal: uses data[0..i] only. - K-means: we run offline K-means on the TRAINING portion (full history up to a burn-in), then use the fitted centroids to classify new bars causally. Strategy: classify each bar, determine which cluster(s) historically have been bullish/trending (positive mean return), gate TSMOM only in those clusters. - TSMOM signal: sign of 3-month return, vol-targeted. GRID (<=4 combos to keep total backtests <=6 with 2 TFs): - (n_clusters=3, lookback_months=3) <- canonical - (n_clusters=4, lookback_months=3) <- more granular clusters Keep TFs = (1d, 12h). """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd from sklearn.cluster import KMeans def expanding_zscore(x: np.ndarray, min_periods: int = 30) -> np.ndarray: """Causal expanding z-score: at bar i, use data[0..i] to compute mean/std.""" out = np.full(len(x), np.nan) for i in range(min_periods, len(x)): window = x[:i+1] m = np.nanmean(window) s = np.nanstd(window) if s > 0: out[i] = (x[i] - m) / s else: out[i] = 0.0 return out def build_features(df: pd.DataFrame, lookback_months: int) -> np.ndarray: """Build causal feature matrix [vol_z, momentum_z, range_z] for each bar.""" c = df["close"].values.astype(float) bpd = al.bars_per_day(df) bpy = bpd * 365.25 # Feature 1: realized vol (30d) r = al.simple_returns(c) rv = al.realized_vol(r, max(2, 30 * bpd), bpy) # Feature 2: momentum return over lookback_months lb_bars = int(lookback_months * 30.44 * bpd) mom = np.zeros(len(c)) for i in range(lb_bars, len(c)): mom[i] = c[i] / c[i - lb_bars] - 1.0 # Feature 3: normalized range (ATR / close) at = al.atr(df, win=max(2, 14)) rng = np.where(c > 0, at / c, 0.0) # Expanding z-score (causal) rv_z = expanding_zscore(rv, min_periods=60) mom_z = expanding_zscore(mom, min_periods=60) rng_z = expanding_zscore(rng, min_periods=60) feat = np.column_stack([rv_z, mom_z, rng_z]) return feat def make_target(df: pd.DataFrame, n_clusters: int, lookback_months: int, train_frac: float = 0.5) -> np.ndarray: """ K-means regime-gated TSMOM. 1. Build causal features. 2. Use the first train_frac of valid data to fit K-means. 3. Label each cluster: positive if mean forward return (in training) is positive. 4. Gate TSMOM: position = vol_targeted_tsmom * in_bullish_cluster. """ c = df["close"].values.astype(float) bpd = al.bars_per_day(df) n = len(df) # Build features feat = build_features(df, lookback_months) # Identify valid (non-NaN) rows valid_mask = np.all(np.isfinite(feat), axis=1) # TSMOM signal: sign of lookback_months return, vol-targeted, long-only (flat on negative) lb_bars = int(lookback_months * 30.44 * bpd) tsmom_dir = np.zeros(n) for i in range(lb_bars, n): ret = c[i] / c[i - lb_bars] - 1.0 tsmom_dir[i] = 1.0 if ret > 0 else 0.0 # long-flat (no short, consistent with TP01) tsmom_pos = al.vol_target(tsmom_dir, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) # Find the training cutoff (first train_frac of valid bars) valid_idx = np.where(valid_mask)[0] if len(valid_idx) < n_clusters * 20: # Not enough data, return raw tsmom return tsmom_pos train_end_idx = valid_idx[int(len(valid_idx) * train_frac)] # Fit K-means on training portion train_feat = feat[valid_idx[valid_idx <= train_end_idx]] if len(train_feat) < n_clusters * 10: return tsmom_pos km = KMeans(n_clusters=n_clusters, n_init=10, random_state=42) km.fit(train_feat) # Determine cluster "bullishness" from training data: # For each training bar, check if the next bar's return is positive. # A cluster is "bullish" if mean(next_return | cluster) > 0. r = al.simple_returns(c) train_labels = km.labels_ train_valid_indices = valid_idx[valid_idx <= train_end_idx] cluster_returns = {k: [] for k in range(n_clusters)} for i_pos, idx_i in enumerate(train_valid_indices): if idx_i + 1 < n: cluster_returns[train_labels[i_pos]].append(r[idx_i + 1]) bullish_clusters = set() for k, rets in cluster_returns.items(): if len(rets) > 5 and np.mean(rets) > 0: bullish_clusters.add(k) # If no bullish cluster found, use all clusters (fall back to pure TSMOM) if not bullish_clusters: bullish_clusters = set(range(n_clusters)) # Classify ALL valid bars causally using fitted centroids all_valid_feat = feat[valid_mask] all_labels = km.predict(all_valid_feat) # Build gate array gate = np.zeros(n) for i_pos, idx_i in enumerate(np.where(valid_mask)[0]): if all_labels[i_pos] in bullish_clusters: gate[idx_i] = 1.0 # Final position: TSMOM gated by regime target = tsmom_pos * gate target = np.nan_to_num(target, nan=0.0) return target def run_config(n_clusters: int, lookback_months: int): name = f"STA04_k{n_clusters}_lb{lookback_months}m" fn = lambda df: make_target(df, n_clusters=n_clusters, lookback_months=lookback_months) rep = al.study_weights(name, fn, tfs=("1d", "12h")) return rep if __name__ == "__main__": # Grid: 2 configs x 2 TFs = 4 backtests per asset x 2 assets = 8 backtests total. # Keep it small: just 2 configs. configs = [ (3, 3), # 3 clusters, 3-month lookback (4, 3), # 4 clusters, 3-month lookback ] best_rep = None best_score = -999.0 for n_clusters, lookback_months in configs: print(f"\n{'='*60}") print(f"CONFIG: n_clusters={n_clusters}, lookback_months={lookback_months}") print('='*60) rep = run_config(n_clusters, lookback_months) print(al.fmt(rep)) print("JSON:", al.as_json(rep)) score = rep.get("verdict", {}).get("best_holdout_sharpe", -999.0) or -999.0 if score > best_score: best_score = score best_rep = rep print("\n" + "="*60) print("BEST CONFIG:") print("="*60) print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))