"""Agent 29 — Ridge regression return forecast (family=ml, slug=ridge). THE ANGLE (assigned): forecast the forward return with a RIDGE regression on lagged returns + volatility features, refit on an EXPANDING window every ~20 bars, and turn the forecast into a position. A genuine ML angle (linear model, L2 penalty), NOT a fixed momentum sign rule — ridge *weights* the lags and lets vol modulate conviction. WHAT THE TRAIN DATA ACTUALLY SAYS (the honest finding, not the hoped-for one): * NEXT-BAR return on these curves is unforecastable — the walk-forward forecast's next-bar hit-rate is ~0.48-0.51 (coin flip). So I forecast a multi-bar FORWARD return (horizon FWD_H), the autocorrelated/forecastable quantity, instead of bar-to-bar noise. * The expanding ridge forecast is CONSISTENTLY, mildly *negatively* correlated with the realized forward return (corr ~ -0.08..-0.22, same sign on BOTH series, ALL horizons). i.e. on these strongly up-trending curves the model's most-bullish forecasts mark froth that gives back, and its bearish forecasts precede the recoveries. This is a stable property across the grid, not one lucky cell. * SHORTING destroys value here (both raw-sign and inverted-sign books lose once shorts are allowed — the curves only go up). The only honest edge a weak forecaster has on an up-trend is WHEN TO HOLD vs. SIT IN CASH. THE RULE: use the (inverted, given the negative corr) ridge forecast as a LONG-ONLY conviction — be long when the model is bearish (post-froth recovery), flat when it is bullish — then vol-target and clip to [0, 1]. Result on train: a book that is in-market only ~16% of the time, tiny drawdown (~0.02 vs 0.77-0.79 buy&hold), Sharpe ~0.83. CAUSALITY (the whole game): * Features at row i use ONLY returns up to and including bar i (rows <= i). * Training TARGET for row j is the return over bar j -> j+FWD_H (needs close[j+FWD_H]). Sitting at decision-row i we may only train on rows j with j+FWD_H <= i (their targets are realized as of close[i]). We NEVER include row i's own unrealized target. * Refit on an EXPANDING window of those realized (X,y) pairs every REFIT_EVERY bars; coefficients frozen in between. No global fit, no future row touched. -> Verified by causality_ok (prefix tail matches full-array tail, max_diff 0.0). TUNING (split='train' only, combined A & B): chosen cell is interior on every axis — FWD_H 18-25 -> Sharpe ~0.83 flat; alpha 20-100 -> Sharpe ~0.81-0.84 flat; refit 10-20 -> stable; gain 1.0-2.5 monotone DD/PnL dial. Picked the interior point. HONEST READ: alpha here is THIN. The forecastability is weak and the win is risk control, not return generation — a low-exposure, low-DD long-only sleeve, NOT a PnL engine. The inverted-sign edge is modest and could be regime-specific; the robust, defensible part is "never short an up-trend; let the forecast tell you when to step out of the way." """ import numpy as np import blindlib as bl # ---- tuned on split='train' only (interior of a flat plateau) ---- RIDGE_ALPHA = 50.0 # L2 penalty (strong: the lag->return edge is tiny); plateau 20..100 WARMUP = 150 # realized (X,y) pairs required before the first fit REFIT_EVERY = 20 # expanding-window refit cadence (assigned ~20); stable 10..20 LAGS = (1, 2, 3, 5, 10) # lagged-return features MOM_WIN = 20 # trailing momentum feature window VOL_WIN = 20 # trailing realized-vol feature window FWD_H = 20 # forecast HORIZON (bars). Plateau 18..25. Next-BAR is noise; a # multi-bar target is the autocorrelated, forecastable quantity. GAIN = 1.5 # tanh conviction gain on the standardized forecast (DD/PnL dial) INVERT = True # negative train corr (both series, all H) -> fade the forecast sign LONG_ONLY = True # shorting an up-trend destroys value -> conviction is long-or-flat TARGET_VOL = 0.20 # vol-target the directional book VOL_WIN_DAYS = 30 LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut def _build_features(c): """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i. Columns: lagged log-returns, trailing momentum, trailing realized vol.""" n = len(c) lr = np.zeros(n) lr[1:] = np.log(c[1:] / c[:-1]) # lr[i] = return of bar ending at i (causal) cols = [] # lagged returns: feature value at i is the return from k bars ago (all <= i) for k in LAGS: f = np.zeros(n) if k < n: f[k:] = lr[: n - k] # lr shifted back by k -> uses past only cols.append(f) # trailing momentum: cumulative log-return over the last MOM_WIN bars (<= i) mom = np.zeros(n) csum = np.cumsum(lr) mom[MOM_WIN:] = csum[MOM_WIN:] - csum[:-MOM_WIN] cols.append(mom) # trailing realized vol (std of last VOL_WIN returns, <= i) vol = np.zeros(n) for i in range(VOL_WIN, n): vol[i] = np.std(lr[i - VOL_WIN + 1 : i + 1]) cols.append(vol) X = np.column_stack(cols) return X, lr def _ridge_fit(X, y, alpha): """Closed-form ridge with a standardized design + intercept (no sklearn needed, fully deterministic). Returns (mu, sd, beta0, beta) for prediction.""" mu = X.mean(axis=0) sd = X.std(axis=0) sd[sd < 1e-12] = 1.0 Xs = (X - mu) / sd p = Xs.shape[1] A = Xs.T @ Xs + alpha * np.eye(p) b = Xs.T @ (y - y.mean()) beta = np.linalg.solve(A, b) beta0 = y.mean() return mu, sd, beta0, beta def signal(df): c = df["close"].values.astype(float) n = len(c) X, lr = _build_features(c) # target[j] = cumulative log-return over bar j -> j+FWD_H (needs close[j+FWD_H]); # known (realized) only as of close[j+FWD_H]. csum = np.cumsum(lr) target = np.zeros(n) target[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] yhat = np.zeros(n) # forecast of the forward return, decided at close[i] sig_y = np.ones(n) # scale of recent forecast targets (for standardization) coef = None # frozen (mu, sd, beta0, beta) for i in range(n): # at decision-row i we may train only on rows j whose target is realized, i.e. # j + FWD_H <= i => j <= i - FWD_H. We NEVER include row i's own (unrealized) target. first = max(LAGS) + MOM_WIN # earliest row with all features fully populated last_train = i - FWD_H # target of last_train uses close[i], realized now ntrain = last_train - first + 1 if ntrain >= WARMUP: # refit every REFIT_EVERY bars (and on the very first eligible bar) if coef is None or (i % REFIT_EVERY == 0): Xtr = X[first : last_train + 1] ytr = target[first : last_train + 1] coef = _ridge_fit(Xtr, ytr, RIDGE_ALPHA) s = np.std(ytr) sig_y[i] = s if s > 1e-9 else 1.0 else: sig_y[i] = sig_y[i - 1] mu, sd, beta0, beta = coef xi = (X[i] - mu) / sd yhat[i] = beta0 + xi @ beta # forecast -> bounded conviction (de-emphasize tiny/noisy forecasts, saturate strong ones) s = np.where(sig_y > 1e-9, sig_y, 1.0) direction = np.tanh(GAIN * yhat / s) direction = np.nan_to_num(direction, nan=0.0) if INVERT: direction = -direction # train corr is negative on both series/all H if LONG_ONLY: direction = np.clip(direction, 0.0, 1.0) # never short an up-trend (shorts lose here) # vol-target the conviction so the DRAWDOWN is what we control pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) if LONG_ONLY: pos = np.clip(pos, 0.0, LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0)