"""agent_35_rls — Online recursive (EWMA-weighted) linear model of return on lagged returns. ANGLE [family=ml, slug=rls]: Recursive Least Squares with exponential forgetting. At each bar we maintain a linear predictor r_hat[t+1] = w . x[t] where x[t] = [1, lagged log-returns ...]. After we observe the realized return we update (w, P) via the standard RLS recursion with a forgetting factor lambda (EWMA weighting of past samples). NO batch refit, NO peeking: the prediction for bar t+1 uses only weights estimated from data up to and including bar t. Position = sign/strength of the predicted next return, vol-targeted. Fully causal: the weight vector used to predict bar i+1 is updated only with the target observed AT bar i (return from i-1 -> i), so no future leakage. """ import numpy as np import blindlib as bl def _rls_predict(r, n_lags=3, lam=0.985, delta=100.0, warmup=60): """Online RLS. Returns pred[t] = predicted return for the NEXT bar, decided at close t. r : array of (log) returns, r[t] = return realized over bar t. n_lags : number of lagged returns used as features. lam : forgetting factor (EWMA). Closer to 1 = longer memory. delta : ridge init for P = (delta) * I. warmup : bars to accumulate before emitting a non-zero prediction. """ T = len(r) p = n_lags + 1 # +1 for intercept w = np.zeros(p) P = np.eye(p) * delta pred = np.zeros(T) for t in range(T): # feature vector available AT close[t]: intercept + last n_lags returns ending at r[t] if t >= n_lags: x = np.empty(p) x[0] = 1.0 # x[1] = r[t], x[2] = r[t-1], ... most recent first for k in range(n_lags): x[1 + k] = r[t - k] # PREDICT next-bar return from CURRENT weights (estimated from data <= t-1's target) pred[t] = float(w @ x) if t >= warmup else 0.0 # --- RLS update using the target observed AT bar t (r[t]) with the feature # vector that was available at close[t-1] (lags ending at r[t-1]) --- if t >= n_lags + 1: x_prev = np.empty(p) x_prev[0] = 1.0 for k in range(n_lags): x_prev[1 + k] = r[t - 1 - k] Px = P @ x_prev denom = lam + float(x_prev @ Px) g = Px / denom # Kalman gain err = r[t] - float(w @ x_prev) # prediction error on realized target w = w + g * err P = (P - np.outer(g, Px)) / lam return pred def signal(df): c = df["close"].values.astype(float) r = bl.log_returns(c) # r[t] = log(c[t]/c[t-1]); r[0]=0, causal # Tuned on split='train' (both series). Fast forgetting (lam=0.97) makes the # predictor ADAPTIVE: it tracks a *local* return-on-lagged-returns relationship # rather than a stale long-run fit. lags=2 is the robust plateau (lags=2, # lam 0.95-0.97, smooth 3-8 all give shmin 0.35-0.44 at DD ~0.20-0.26). pred = _rls_predict(r, n_lags=2, lam=0.97, delta=100.0, warmup=120) # Smooth the raw prediction (short causal EWMA) to cut whipsaw turnover, then # normalize by a causal std of the prediction so the strength is regime-stable. ps = bl.ema(pred, 3) sd = bl.rolling_std(ps, 60) sd = np.where(sd > 1e-9, sd, 1e-9) raw = np.tanh(ps / sd) raw = np.clip(raw, -1.0, 1.0) # Vol-target the directional view -> comparable PnL to buy&hold at ~4x smaller DD. pos = bl.vol_target(raw, df, target_vol=0.20, vol_win_days=30, leverage_cap=1.0) return np.clip(pos, -1.0, 1.0)