"""STA02 — Walk-forward Logistic Regression on TA features (1d). Idea: a logistic classifier is periodically re-fit on features {rsi, zscore_price, momentum, realized_vol} all computed causally. Predict P(next bar up) -> long if P > 0.5, else flat (long-only, no short). Causal contract --------------- At decision bar d (close[d] known): - features use data up to and including close[d] - we predict: will close[d+1] > close[d] ? - target[d] = position held during bar d+1 - altlib eval_weights shifts by 1 for us -> no double shift Feature construction (all using data <= close[d]): - rsi_14: RSI(14) at bar d - zscore_20: (close[d] - sma_20[d]) / std_20[d] - mom_10: log(close[d] / close[d-10]) (10-bar momentum) - rvol_20: realized annualized vol, 20-bar window Training label: - y[k] = 1 if close[k+1] > close[k], else 0 - Train on (X[k], y[k]) for k in [warmup .. d-1] Grid (4 configs x 1 TF = 4 total backtests <= 6 limit): - min_train_years: 1.0 or 2.0 - C (inverse regularization): 0.1 or 1.0 Best config by min(BTC, ETH) hold-out Sharpe. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import warnings warnings.filterwarnings("ignore") from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler def logistic_target(df, min_train_years: float = 1.0, C: float = 1.0) -> np.ndarray: """ Walk-forward logistic on {rsi14, zscore20, mom10, rvol20}. Returns vol-targeted position array (target[i] decided at close[i]). """ c = df["close"].values.astype(float) n = len(c) bpy = al.bars_per_year(df) bpd = al.bars_per_day(df) # --- build features (all causal at bar i) --- # RSI 14 feat_rsi = al.rsi(c, win=14) # Z-score of close over 20-bar window feat_zsc = al.zscore(c, win=20) # 10-bar log-momentum: log(close[i] / close[i-10]) # Using lag=10 bars; only valid for i >= 10 feat_mom = np.full(n, np.nan) lag = 10 feat_mom[lag:] = np.log(c[lag:] / c[:-lag]) # Realized annualized vol (20-bar) r = al.simple_returns(c) feat_rvol = al.realized_vol(r, win=20, bars_per_year=bpy) # Stack into feature matrix [n x 4] X_all = np.column_stack([feat_rsi, feat_zsc, feat_mom, feat_rvol]) # Label: 1 if next bar close > current close, else 0 # y[i] = 1 if close[i+1] > close[i] — shape (n,), y[n-1] is undefined y_all = np.zeros(n, dtype=float) y_all[:-1] = (c[1:] > c[:-1]).astype(float) min_train_bars = int(min_train_years * bpy) # Need at least warmup + lags for first valid sample first_valid = max(20, lag) # 20 for zscore/rvol, 10 for mom # first training sample k: k >= first_valid AND feature X[k] fully defined # first prediction at bar d: d >= first_valid + min_train_bars first_pred = first_valid + min_train_bars # Refit quarterly refit_every = max(1, int(bpy / 4)) direction = np.zeros(n, dtype=float) last_refit = -refit_every # force first refit model = LogisticRegression(C=C, solver="lbfgs", max_iter=500, random_state=42, class_weight="balanced") scaler = StandardScaler() trained = False for d in range(first_pred, n - 1): if d - last_refit >= refit_every: # Build training set: samples k in [first_valid, d-1] (predict y[k] from X[k]) # X[k] causal (uses data <= close[k]), y[k] requires close[k+1] (NOT at k, at k+1) # So the last valid training sample is k = d-1 (we know close[d] = close[(d-1)+1]) k_start = first_valid k_end = d # exclusive, so training on [k_start, d-1] if k_end - k_start < 30: continue X_tr = X_all[k_start:k_end] y_tr = y_all[k_start:k_end] # Drop rows with NaN features valid_mask = np.all(np.isfinite(X_tr), axis=1) & np.isfinite(y_tr) if valid_mask.sum() < 20: continue X_tr = X_tr[valid_mask] y_tr = y_tr[valid_mask] # Check both classes present if len(np.unique(y_tr)) < 2: continue try: scaler.fit(X_tr) X_tr_scaled = scaler.transform(X_tr) model.fit(X_tr_scaled, y_tr) trained = True last_refit = d except Exception: continue if not trained: continue # Predict at bar d: features X_all[d] x_d = X_all[d] if not np.all(np.isfinite(x_d)): continue x_scaled = scaler.transform(x_d.reshape(1, -1)) prob_up = model.predict_proba(x_scaled)[0] # class order: model.classes_ = [0, 1] idx_up = list(model.classes_).index(1) if 1 in model.classes_ else 1 p_up = prob_up[idx_up] # Long if P(up) > 0.5, else flat (long-only, no short) direction[d] = 1.0 if p_up > 0.5 else 0.0 # Vol-target the direction signal target = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return target def run_grid(): configs = [ dict(min_train_years=1.0, C=0.1), dict(min_train_years=1.0, C=1.0), dict(min_train_years=2.0, C=0.1), dict(min_train_years=2.0, C=1.0), ] best_rep = None best_holdout = -999.0 for cfg in configs: name = f"STA02(train={cfg['min_train_years']}y,C={cfg['C']})" print(f"\n--- Running {name} ---") rep = al.study_weights( name, lambda df, c=cfg: logistic_target(df, **c), tfs=("1d",) ) print(al.fmt(rep)) min_hold = rep["verdict"].get("best_holdout_sharpe", -999.0) if min_hold > best_holdout: best_holdout = min_hold best_rep = rep best_rep["_cfg"] = cfg return best_rep if __name__ == "__main__": best = run_grid() print("\n\n=== BEST CONFIG ===") print(al.fmt(best)) print("JSON:", al.as_json(best))