"""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)
