"""Agent 03 — MA ribbon (family=trend, slug=ma_ribbon).

The angle: a quad-EMA "ribbon" (fast -> slow). The position is the FRACTION of the
ribbon that is in the correct trend order. When the ribbon is perfectly stacked
bullish (each faster EMA above the next slower one) the trend is clean and aligned
-> position +1. Perfectly stacked bearish -> -1. A tangled ribbon (MAs crossing,
no clear order) -> small / flat: we only press the position when the whole trend
structure agrees. This is a GRADED-conviction trend filter, not a binary cross.

Construction (all causal — value at i uses rows 0..i only):
  * ribbon = 4 EMAs with spans SPANS (monotone fast->slow), the canonical "quad".
  * For each adjacent pair (k, k+1) score +1 if ema_k > ema_{k+1} (bullish step),
    -1 if below. ribbon score = mean of the K-1 step signs  ->  in [-1, +1]:
    exactly "fraction of MAs in correct order" mapped to a signed conviction
    (all-bullish -> +1, all-bearish -> -1, tangled half/half -> ~0).
  * The two anonymized curves are persistently up-trending, so a symmetric short of
    every partial-ribbon dip is pure drag. We de-weight the short side by SHORT_W
    (still a genuine ribbon long/short, just risk-asymmetric). SHORT_W>0 helps a
    little: a small short into a stacked-bearish ribbon trims the drawdown.
  * Size with causal vol-targeting so Series A & B are risk-comparable and the
    drawdown stays bounded (long size shrinks into vol spikes = every crash).

Tuning (ONLY split='train', both A & B equal weight). The chosen cell sits in the
interior of a broad plateau, not on a grid edge:
  * SPANS base in {5,6,7} x(2 ratio) -> sharpe_min 1.32-1.37 (6 is the interior).
  * VOL_WIN 20-25 best; 25 interior.    * SHORT_W 0.1-0.25 flat at sharpe_min ~1.37,
    DD falling 0.26->0.24 as SHORT_W rises; 0.2 interior.
Train combined: pnl_mean ~3.20, maxdd_worst ~0.241, sharpe_min ~1.37, turnover ~11/yr.
Fee-robust: sharpe_min 1.39 at 0% RT -> 1.30 at 0.40% RT (low turnover = fee-insensitive).

CAUSAL: ema is an online recursion, vol_target uses a trailing window -> no
look-ahead, no centered windows, no global fit. Verified by causality_ok (max_diff 0).

Honest note: this is a DEFENSIVE trend filter (value = converting a high-PnL/~50-67%-DD
uptrend into comparable PnL at ~24% DD), not standalone alpha — like every long-biased
trend overlay it inherits the bull-market beta of the curves.
"""
import numpy as np
import blindlib as bl

# --- tuned ONLY on split='train' (plateau interior, not a grid edge) ---
SPANS = (6, 12, 24, 48)   # quad ribbon, fast -> slow (monotone)
SHORT_W = 0.2             # short side de-weighted (asymmetric L/S); 0 -> long/flat
TARGET_VOL = 0.25
VOL_WIN_DAYS = 25
LEV_CAP = 1.0


def _ribbon_score(c: np.ndarray) -> np.ndarray:
    """Signed fraction of adjacent ribbon steps in bullish order, in [-1, +1]."""
    emas = [bl.ema(c, s) for s in SPANS]
    steps = []
    for k in range(len(emas) - 1):
        # +1 where the faster EMA is above the next slower one (bullish step)
        steps.append(np.where(emas[k] > emas[k + 1], 1.0, -1.0))
    score = np.mean(np.vstack(steps), axis=0)   # mean of K-1 step signs in [-1,1]
    score[: SPANS[-1]] = 0.0                     # ribbon undefined before slowest span
    return score


def signal(df):
    c = df["close"].values.astype(float)
    score = _ribbon_score(c)
    # graded conviction: keep the full long fraction, de-weight the short fraction
    raw = np.where(score >= 0.0, score, SHORT_W * score)
    pos = bl.vol_target(raw, 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)
