"""Agent 18 — Distance-from-MA reversion, trend-gated (family=meanrev, slug=dist_ma).

THE ANGLE (assigned): position = -tanh(scaled distance of price from its MA). Buy when price
is stretched BELOW its MA, sell when stretched ABOVE — a reversion-to-the-MA impulse, sized by
how far price has wandered. Tune the MA window and the tanh scale.

WHY THE PURE ANGLE LOSES, AND WHAT SURVIVES.
The naive symmetric form (-tanh(scale * (price/MA - 1)) traded both sides) is CATASTROPHIC on
these two curves: both trend up ~7x (A) / ~23x (B) over the train window, so shorting every
stretch ABOVE the MA just fights a relentless uptrend. Measured: the pure symmetric angle
returns -79%..-95% with sharpe ~ -0.5..-0.9 (it shorts the bull). A conditioning study of
next-bar return vs the normalized distance-from-MA confirms the asymmetry: the LARGEST
positive next-bar returns sit at the HIGHEST positive distance (that's momentum continuation,
NOT reversion — never short it), while the genuine reversion edge lives only on the DOWNSIDE
— when price is stretched well below its MA, the next bar bounces (+0.27%..+0.35% in the
deepest dip bin, pooled A&B). So the distance-from-MA reversion that actually exists here is
the short-horizon PULLBACK inside the prevailing trend, not a fade of the trend itself.

THE RULE.
  impulse = -tanh(SCALE * z) where z = (price/SMA(MA) - 1) standardized by a trailing rolling
  std (so A and B, with different vol, get comparable stretch units). impulse>0 = price below
  its MA (a dip -> reversion says go long); impulse<0 = price above its MA (a rally -> short).
  A TREND GATE then keeps only the reversion leg that agrees with the regime:
    * UPTREND (price > SMA(SLOW)):   take only the LONG impulse (buy the dip that bounces).
    * DOWNTREND (price < SMA(SLOW)): take only the SHORT impulse (fade the dead-cat rally),
      down-weighted by SHORT_W. Tuning drives SHORT_W -> 0: both curves trend up, so the
      downtrend-short reversion only adds drawdown over this sample.
  A causal vol_target sizes the impulse so the two series are risk-comparable and exposure
  shrinks into vol spikes.

CAUSAL: SMA(MA), SMA(SLOW), the rolling std and vol_target at bar i use only rows <= i. No
shift(-k), no centered windows, no global fit. Verified by causality_ok (online-consistent).

TUNING (train only, combined A&B; coarse->fine, plateau not spike). A FAST MA (the distance is
a short-horizon pullback, not a slow-trend gap) is decisively better than a medium MA:
ma=3 beats ma=20+ by ~0.2 sharpe at lower DD. The chosen cell is interior on every axis:
  MA 3..5 -> sharpe_min 0.69..0.81 ;  SCALE 1.0..2.5 -> 0.72..0.76 (PnL rises, DD ~flat) ;
  NORM_WIN 30..90 -> 0.75..0.80 ;     SLOW 110..140 -> sharpe_min 0.74..0.81 (a real plateau).
  SHORT_W 0->0.5 only lowers sharpe (the downtrend short fights the structural uptrend).
  vol_target trades PnL<->DD ~linearly (sharpe flat), so TARGET_VOL is just the risk dial.

  MA=3, NORM_WIN=60, SCALE=1.5, SLOW=130, SHORT_W=0.0, TARGET_VOL=0.30, VOL_WIN=30, LEV_CAP=2.0
  -> train combined: pnl_mean ~0.70, maxdd_worst ~0.115, sharpe_min ~0.80
     (a solid PnL at an ~11-12% drawdown: the reversion-in-trend harvests the pullback bounces
      while sidestepping the deep declines, vs long-only buy&hold's huge PnL at ~70-80% DD.)

HONEST CAVEAT: the value here is the DROP IN DRAWDOWN (~6x lower than buy&hold), not beating
buy&hold's raw PnL on a 7x/23x bull run. The PURE assigned angle (symmetric fade) is a
loser on trending data — it only becomes positive once gated to the dip side of the trend.
"""
import numpy as np
import pandas as pd
import blindlib as bl

MA = 3              # fast SMA -> the distance is a SHORT-HORIZON pullback from price
NORM_WIN = 60       # trailing window standardizing the distance (so A & B are comparable)
SCALE = 1.5         # tanh scale on the standardized distance -> reversion impulse magnitude
SLOW = 130          # trend-regime SMA for the agreement gate
SHORT_W = 0.0       # weight on the (gated) downtrend-short leg; tuning -> 0 (long-flat best)
TARGET_VOL = 0.30
VOL_WIN_DAYS = 30
LEV_CAP = 2.0


def signal(df):
    c = df["close"].values.astype(float)
    n = len(c)

    # distance of price from its (fast) MA, standardized by a trailing rolling std (causal).
    dist = c / bl.sma(c, MA) - 1.0
    sd = pd.Series(dist).rolling(NORM_WIN).std().values
    zd = np.nan_to_num(dist / np.where(sd > 0, sd, np.nan), nan=0.0)

    # the assigned angle: reversion impulse = -tanh(scaled distance).
    #   zd>0 (price above MA) -> impulse<0 (short the stretch)
    #   zd<0 (price below MA) -> impulse>0 (long the dip)
    impulse = -np.tanh(SCALE * zd)

    # trend-agreement gate: keep only the reversion leg that agrees with the regime.
    up = c > bl.sma(c, SLOW)
    raw = np.zeros(n)
    long_ok = (impulse > 0) & up           # buy the dip inside an uptrend
    short_ok = (impulse < 0) & (~up)       # fade the rally inside a downtrend (down-weighted)
    raw[long_ok] = impulse[long_ok]
    raw[short_ok] = impulse[short_ok] * SHORT_W

    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)
