"""Agent 31 — Small MLPRegressor forward-return forecast (family=ml, slug=mlp_reg). THE ANGLE (assigned): a SMALL MLPRegressor (sklearn, one hidden layer) forecasting the forward return from a causal feature vector, refit on an EXPANDING walk-forward window, turned into a vol-targeted position. A genuine nonlinear ML angle (a tiny neural net) — it can in principle pick up interactions the linear ridge/logistic models cannot — kept FAST (small net, few iterations, infrequent refit) to stay under the time budget. WHAT THE TRAIN DATA ACTUALLY SAYS (the honest finding, mirroring ridge/logistic agents): * NEXT-BAR return on these curves is unforecastable (hit-rate ~coin flip). I forecast a multi-bar FORWARD return (horizon FWD_H), the autocorrelated/forecastable quantity. * The MLP forecast carries a weak, regime-dependent signal. On these strongly up-trending curves the robust, defensible win is RISK CONTROL — being long when the model is not bearish, stepping to cash (and only cautiously short) when it is — NOT a PnL engine. * The conviction is vol-targeted so the DRAWDOWN, not the raw forecast, is what we control. CAUSALITY (the whole game): * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log- returns, multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA. * The TARGET for row j is the cumulative log-return over bar j -> j+FWD_H, which needs close[j+FWD_H]. Sitting at decision-row i we may train ONLY on rows whose target is already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own target is NEVER used. * The MLP is refit on the EXPANDING window of those realized (X, y) pairs at most every REFIT_EVERY bars; weights frozen in between. To keep refits deterministic AND fast we use a fixed random_state, a single small hidden layer, and a capped iteration budget. -> Verified by causality_ok (signal on a prefix must match signal on the full array). TUNING (split='train' only, combined A & B): small net (one layer 8 units) + strong L2 (alpha=3) so the thin edge is not overfit; FWD_H=15 (next-bar is noise); WARMUP=200 realized pairs; conviction = tanh(0.6 * zscored forecast) as a SMALL lean around a constant long base (0.3), clipped, then vol-targeted at 0.18 (cap 1.0). I measured the walk-forward forecast's correlation with the realized forward return directly: ~+0.01 on A, ~-0.05 on B, sign-hit ~0.48 — i.e. NEAR ZERO and inconsistent in sign across the two series and across horizons 10..40. So the forecast is treated as a weak modulation, not a directional engine. HONEST READ: forward-return forecastability here is essentially absent and an MLP does NOT create it (corr ~0, sign-hit < 0.5). The defensible win is RISK CONTROL: a vol-targeted, long-biased book whose drawdown is ~4x smaller than buy&hold (train DD ~0.20 vs ~0.77-0.79). The MLP's contribution is marginal-but-positive on train — adding it to a flat long base lifts Sharpe_min 0.844->0.899 and PnL 0.40->0.55 — but this is a small lean, not alpha. The bulk of the result is the long bias + vol-targeting; the MLP forecast is a thin garnish. That thinness, and the inconsistent forecast sign across series, are the honest caveats for this angle. """ import warnings import numpy as np import blindlib as bl warnings.filterwarnings("ignore") try: from sklearn.neural_network import MLPRegressor _HAVE_SK = True except Exception: # pragma: no cover - sklearn expected present _HAVE_SK = False # ---- tuned on split='train' only ---- HIDDEN = (8,) # ONE small hidden layer (keep it tiny: edge is thin, refit fast) MLP_ALPHA = 3.0 # L2 penalty (STRONG: the lag->return edge is tiny -> resist overfit) MAX_ITER = 120 # capped optimizer iterations (speed; net is small so it converges) WARMUP = 200 # realized (X, y) pairs required before the first fit REFIT_EVERY = 40 # expanding-window refit cadence (infrequent -> MLP cost stays low) LAGS = (1, 2, 3, 5, 10) # lagged log-return features MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features VOL_WIN = 20 # trailing realized-vol feature window RSI_WIN = 14 # RSI feature window MA_WIN = 50 # distance-from-MA feature window FWD_H = 15 # forecast HORIZON (bars). Next-bar is noise; multi-bar is forecastable. GAIN = 0.6 # tanh conviction gain on the standardized forecast (DD/PnL dial). LOW: # the forecast is near-noise (train corr ~0), so it only LIGHTLY trims. LONG_BASE = 0.30 # constant long bias the forecast modulates AROUND. The curves trend up # and the forecast carries no reliable sign, so the defensible book is # "mostly long, let the weak forecast lean it" — not "gate to cash on noise". INVERT = False # sign of the train forecast<->forward-return correlation (set by tuning) LONG_FLOOR = -0.30 # allow only shallow shorts (curves only trend up -> shorts mostly lose) TARGET_VOL = 0.18 # 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.""" n = len(c) lr = np.zeros(n) lr[1:] = np.log(c[1:] / c[:-1]) # lr[i] = return of bar ending at i (causal) csum = np.cumsum(lr) cs2 = np.cumsum(lr * lr) cols = [] # lagged returns: value at i is the return k bars ago (all <= i) for k in LAGS: f = np.zeros(n) if k < n: f[k:] = lr[: n - k] cols.append(f) # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) for w in MOM_WINS: mom = np.zeros(n) mom[w:] = csum[w:] - csum[:-w] cols.append(mom) # trailing realized vol (std of last VOL_WIN returns, <= i) vol = np.zeros(n) for i in range(VOL_WIN, n): m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m vol[i] = np.sqrt(max(v, 0.0)) cols.append(vol) # RSI (causal, from blindlib), centered to ~[-0.5, 0.5] rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5 cols.append(rsi) # distance from a trailing MA (causal): log(close / sma) ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0]) ma[ma <= 0] = 1e-9 dist = np.log(np.maximum(c, 1e-9) / ma) dist[:MA_WIN] = 0.0 cols.append(dist) X = np.column_stack(cols) return X, lr, csum def signal(df): c = df["close"].values.astype(float) n = len(c) X, lr, csum = _build_features(c) # target[j] = cumulative log-return over bar j -> j+FWD_H (needs close[j+FWD_H]); # realized (known) only as of close[j+FWD_H]. target = np.zeros(n) target[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row yhat = np.zeros(n) # forecast of the forward return, decided at close[i] sig_y = np.ones(n) # scale of recent training targets (for standardization) coef = None # frozen (mu, sd, model) for i in range(n): last_train = i - FWD_H # target of last_train uses close[i], realized now ntrain = last_train - first + 1 if ntrain < WARMUP: continue if coef is None or (i % REFIT_EVERY == 0): Xtr = X[first : last_train + 1] ytr = target[first : last_train + 1] mu = Xtr.mean(axis=0) sd = Xtr.std(axis=0) sd[sd < 1e-12] = 1.0 Xs = (Xtr - mu) / sd sy = ytr.std() sy = sy if sy > 1e-9 else 1.0 ys = ytr / sy # standardize target so the net trains stably if _HAVE_SK: m = MLPRegressor(hidden_layer_sizes=HIDDEN, activation="tanh", alpha=MLP_ALPHA, solver="lbfgs", max_iter=MAX_ITER, random_state=0) m.fit(Xs, ys) coef = (mu, sd, m, sy) sig_y[i] = ytr.std() if ytr.std() > 1e-9 else 1.0 else: sig_y[i] = sig_y[i - 1] if coef is not None: mu, sd, m, sy = coef xi = ((X[i] - mu) / sd).reshape(1, -1) yhat[i] = float(m.predict(xi)[0]) * sy # forecast -> bounded conviction (de-emphasize tiny/noisy forecasts, saturate strong ones) s = np.where(sig_y > 1e-9, sig_y, 1.0) fc = np.tanh(GAIN * yhat / s) # weak MLP conviction (~noise) -> only a small lean fc = np.nan_to_num(fc, nan=0.0) if INVERT: fc = -fc # mostly-long book the forecast modulates around (NOT a gate-to-cash on a noisy forecast) direction = np.clip(LONG_BASE + fc, LONG_FLOOR, 1.0) pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0)