"""Agent 30 — Logistic up/down classifier (family=ml, slug=logistic).

THE ANGLE (assigned): a LOGISTIC REGRESSION that classifies "will the forward move be
up or down?" from technical features (momentum at several horizons, trailing realized
vol, RSI), refit on an EXPANDING walk-forward window every ~20 bars, and maps the class
probability p(up) into a position in [-1, +1].

WHY A CLASSIFIER (not a return-regressor): the per-bar *magnitude* of these curves is
dominated by noise — the sign of the forward move is the only thing with any persistence.
A logistic model targets exactly that (a Bernoulli up/down label), and its probability
output is a natural, bounded conviction: p≈0.5 → flat, p far from 0.5 → take the side.
The L2 penalty (C small) keeps the coefficients from chasing the (thin) edge into noise.

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.
  * The LABEL for row j is sign of the cumulative return over bar j -> j+FWD_H, which
    needs close[j+FWD_H]. So sitting at decision-row i we may train ONLY on rows whose
    label is already realized: j + FWD_H <= i  =>  j <= i - FWD_H. Row i's own label is
    NEVER used.
  * Model is refit on the EXPANDING window of those realized (X, y) pairs at most every
    REFIT_EVERY bars; coefficients frozen in between. position[i] = frozen model's
    p(up) at row i, mapped to a direction, then vol-targeted.
  -> Verified by causality_ok (signal on a prefix must match signal on the full array).

TUNING (split='train' only, combined A & B): C (inverse L2) small (~0.05-0.2) so the
weak edge isn't overfit; FWD_H ~ 5-10 (the forecastable horizon — next-bar sign is a
coin flip); WARMUP ~ 200 realized pairs; conviction = 2*(p-0.5) sharpened by a gain,
then vol-targeted (cap 1.0) so the DRAWDOWN, not the raw PnL, is what we optimise.

HONEST READ: forward-sign forecastability here is weak; the realistic win is a vol-
controlled book that can flip short into declines, giving comparable PnL to long-only
at a much smaller drawdown — the de-risking is the alpha, not a strong classifier.
"""
import warnings
import numpy as np
import blindlib as bl

warnings.filterwarnings("ignore")

try:
    from sklearn.linear_model import LogisticRegression
    _HAVE_SK = True
except Exception:                       # pragma: no cover - sklearn expected present
    _HAVE_SK = False

# ---- tuned on split='train' only (interior of broad plateaus; see scan below) ----
C_INV = 0.20            # inverse L2 strength (small = strong penalty); flat 0.05-1.0
WARMUP = 200            # realized (X, y) pairs required before the first fit
REFIT_EVERY = 20        # expanding-window refit cadence (assigned ~20)
LAGS = (1, 2, 3, 5)     # 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
FWD_H = 15              # label HORIZON: sign of cumulative return over next FWD_H bars.
                        # next-bar sign is a coin-flip; the multi-bar sign is the
                        # persistent, classifiable quantity. Plateau FWD 14-18.
DEADBAND = 0.04         # ignore |2p-1| below this (treat as no-conviction -> flat)
GAIN = 3.0              # conviction gain on the centered probability 2*(p-0.5)
SHORT_SCALE = 0.25      # asymmetric book: full long, only PARTIAL short. Both curves
                        # drift UP, so the classifier's real value is STEPPING ASIDE
                        # from declines; a full short fights the drift and adds DD.
                        # 0.25 keeps a genuine (small) short so it stays prob->position.
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."""
    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)

    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)
    cs2 = np.cumsum(lr * lr)
    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)
    rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0
    cols.append(rsi)

    X = np.column_stack(cols)
    return X, lr, csum


def _fit(Xtr, ytr):
    """Logistic fit on standardized features. Returns (mu, sd, model) or None if the
    training labels are single-class (no fit possible yet)."""
    mu = Xtr.mean(axis=0)
    sd = Xtr.std(axis=0)
    sd[sd < 1e-12] = 1.0
    Xs = (Xtr - mu) / sd
    if len(np.unique(ytr)) < 2:
        return None
    if _HAVE_SK:
        m = LogisticRegression(C=C_INV, solver="lbfgs", max_iter=200)
        m.fit(Xs, ytr)
        return (mu, sd, m)
    # tiny fallback: penalized logistic via Newton steps (deterministic)
    w = _logit_newton(Xs, ytr, C_INV)
    return (mu, sd, w)


def _logit_newton(Xs, y, c_inv, iters=25):
    n, p = Xs.shape
    Xb = np.column_stack([np.ones(n), Xs])
    w = np.zeros(p + 1)
    lam = 1.0 / max(c_inv, 1e-6)
    R = np.eye(p + 1); R[0, 0] = 0.0     # don't penalize intercept
    for _ in range(iters):
        z = Xb @ w
        pr = 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))
        Wd = pr * (1 - pr) + 1e-6
        grad = Xb.T @ (pr - y) + lam * (R @ w)
        H = Xb.T @ (Xb * Wd[:, None]) + lam * R
        try:
            w -= np.linalg.solve(H, grad)
        except np.linalg.LinAlgError:
            break
    return w


def _predict_proba(coef, xi):
    mu, sd, m = coef
    xs = (xi - mu) / sd
    if _HAVE_SK and not isinstance(m, np.ndarray):
        return float(m.predict_proba(xs.reshape(1, -1))[0, 1])
    z = m[0] + xs @ m[1:]
    return float(1.0 / (1.0 + np.exp(-np.clip(z, -30, 30))))


def signal(df):
    c = df["close"].values.astype(float)
    n = len(c)
    X, lr, csum = _build_features(c)

    # label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0.
    # realized (known) only as of close[j+FWD_H].
    fwd = np.zeros(n)
    fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H]
    label = (fwd > 0).astype(float)

    first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN)  # first fully-featured row
    prob = np.full(n, 0.5)
    coef = None

    for i in range(n):
        last_train = i - FWD_H            # label of last_train uses close[i], realized now
        ntrain = last_train - first + 1
        if ntrain >= WARMUP:
            if coef is None or (i % REFIT_EVERY == 0):
                Xtr = X[first : last_train + 1]
                ytr = label[first : last_train + 1]
                fit = _fit(Xtr, ytr)
                if fit is not None:
                    coef = fit
            if coef is not None:
                prob[i] = _predict_proba(coef, X[i])

    # probability -> bounded direction. centered conviction 2*(p-0.5) in [-1,1];
    # deadband kills no-conviction bars; tanh sharpens; the short side is scaled down
    # (the up-drift makes full shorts a losing fight — we mainly want to step aside).
    conv = 2.0 * prob - 1.0
    conv = np.where(np.abs(conv) < DEADBAND, 0.0, conv)
    direction = np.tanh(GAIN * conv)
    direction = np.where(direction < 0.0, direction * SHORT_SCALE, direction)
    direction = np.nan_to_num(direction, nan=0.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)
