"""agent_25_channel_pos — ANGLE [struct/channel_pos]: position WITHIN the Donchian channel.

Idea (assigned angle): instead of a binary breakout AT the channel edge, measure WHERE the
close sits inside the rolling Donchian channel [lo, hi] as a continuous fraction
  chpos = (close - lo) / (hi - lo)   in [0, 1]   (0.5 = mid-channel).
Then take a directional position only when location AND trend AGREE:
  * LONG  when chpos is in the UPPER third (>= UP_TH)  AND the channel/price slope is UP,
  * SHORT when chpos is in the LOWER third (<= LO_TH)   AND the slope is DOWN,
  * FLAT  in the middle band or when slope disagrees with location.
The "slope" filter is what makes the angle anticipatory rather than a reversal: riding the
upper third while the channel is still pushing up is a continuation read; the lower-third +
down-slope short tries to catch the persistent declines (the big drawdowns the benchmark eats).

WHY a slope gate (honest tuning result):
  Channel-position WITHOUT a slope gate is a mean-reversion read (buy low-in-channel) and
  on these trending curves it bleeds — it fights the trend and the upper third without a
  trend filter chops on every pullback. Requiring location AND slope to agree turns it into
  a trend-confirmation read that holds longs through the up-leg and only shorts confirmed
  down-legs. The slope is the prior-W channel-midpoint change (causal).

Sizing: the agreed direction (+1/-1/0) is vol-targeted (TP01-style, causal realized vol) so
size shrinks into vol spikes (= crashes) -> caps drawdown.

Causality: bl.donchian shifts the rolling hi/lo by one bar, so the channel at i is built from
bars STRICTLY before i. chpos[i], the slope (a backward difference of a causal EMA of close),
and the vol scaling all use only data <= i. The forward scan keeps no future state. The
evaluator then HOLDS the position during bar i+1. causality_ok -> true.

WHY the short leg is sized 0.30 (honest tuning result):
  A full-size (-1.0) short bled on these up-trending curves (combined Sharpe_min 1.06, DD 0.30).
  Shrinking the short leg monotonically improved risk-adjusted return; long/flat alone was best
  on raw PnL/Sharpe but had a slightly fatter DD (0.256). The chosen short=0.30 keeps a genuine
  lower-third+down-slope SHORT (the angle is intact) and TRIMS the drawdown (0.256 -> 0.229)
  at ~no PnL cost. So the angle's short leg earns its place, just at a modest size.

Plateau (tuned on train only): broad and well-behaved around DON 35-45 / UP-LO 0.62-0.66 /
SLOPE_WIN 15-20 / short 0.15-0.35 (Sharpe_min ~1.3-1.4 throughout, not an isolated peak).

FINAL train (combined A&B): pnl_mean ~4.06, maxdd_worst ~0.229, sharpe_min ~1.34, sharpe_mean ~1.40.
  Per-series: A pnl 4.88 / DD 0.226 / Sh 1.45 ; B pnl 3.22 / DD 0.193 / Sh 1.33. Turnover ~14/yr.
  causality.ok = true (max_diff 0). Honest note: this is a trend-confirmation read dressed as a
  channel-position rule (the slope gate makes it ride the trend, not fade it); its value is
  comparable PnL to buy&hold at ~1/3 of the drawdown, NOT independent alpha.
"""
import numpy as np
import blindlib as bl

DON_WIN = 40         # Donchian window for the channel
UP_TH = 0.62         # upper-band threshold on chpos (>=) -> "upper third" (location)
LO_TH = 0.38         # lower-band threshold on chpos (<=) -> "lower third" (location)
SLOPE_WIN = 20       # bars over which we measure the price slope (trend gate)
SLOPE_EPS = 0.0      # min |slope| to count as up/down (0 = any non-zero sign)
SHORT_SIZE = 0.30    # short-leg size (lower third + down-slope). <1 by tuning: the curves
                     # trend up, so a full-size short bleeds; a modest short still TRIMS DD.
TARGET_VOL = 0.30
VOL_WIN_DAYS = 30
LEV_CAP = 1.0


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

    hi, lo = bl.donchian(df, DON_WIN)          # prior-DON_WIN hi/lo (shifted, causal)
    width = hi - lo
    # continuous position within the channel in [0,1]; mid (0.5) where channel undefined.
    with np.errstate(invalid="ignore", divide="ignore"):
        chpos = (c - lo) / width
    chpos = np.where(np.isfinite(chpos) & (width > 0), chpos, 0.5)
    chpos = np.clip(chpos, 0.0, 1.0)

    # causal slope: change of a smoothed close over SLOPE_WIN bars, normalized by price.
    sm = bl.ema(c, SLOPE_WIN)
    slope = np.zeros(n)
    slope[SLOPE_WIN:] = (sm[SLOPE_WIN:] - sm[:-SLOPE_WIN]) / np.maximum(sm[:-SLOPE_WIN], 1e-9)

    up_loc = chpos >= UP_TH
    dn_loc = chpos <= LO_TH
    up_slope = slope > SLOPE_EPS
    dn_slope = slope < -SLOPE_EPS

    direction = np.zeros(n)
    direction[up_loc & up_slope] = 1.0             # upper third + rising -> long
    direction[dn_loc & dn_slope] = -SHORT_SIZE     # lower third + falling -> (small) short

    # warmup: no channel yet -> flat
    direction[:DON_WIN] = 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)
