"""agent_42_fft_phase — cycle / FFT-phase blind signal.

ANGLE: rolling-window dominant-cycle phase. On each bar i we take the last N
log-prices (rows 0..i ONLY), linearly detrend them (so the FFT sees the
OSCILLATION around the local trend, not the trend itself), window them, take the
rfft, and pick the dominant frequency inside a cycle band [PMIN, PMAX] days. The
complex Fourier coefficient at that bin gives the cycle's instantaneous PHASE at
the window end; from the phase we project the cycle's next-bar slope
(d/dt of A*cos(2*pi*f*t + phi)) — that is the phase-based anticipation of the next
move, weighted by how dominant the cycle is (its in-band power share = conviction).

HONEST CAVEAT (found while tuning on TRAIN): a SINGLE-window phase rule is not
robust — its sign flips with the window length and the detrend band (the data has
no stable mid-band cycle; spectral power sits at the trend's low frequencies). So
the deployable version (a) ENSEMBLES the phase direction over several window
lengths to kill the single-cell overfit, and (b) reads the phase as cycle
CONTINUATION (the in-band component keeps its slope -> SIGN=-1, which on TRAIN beat
the mean-revert convention), and (c) anchors with a light slow-trend term because
the low-frequency (trend) component is the one piece of real structure here. The
phase ensemble is the directional core; the trend anchor caps drawdown. Result on
TRAIN: comparable PnL to buy&hold at ~5x smaller drawdown.

Everything uses data <= i (pure per-bar transform, refit-free), so it is causal by
construction and the online-consistency guard passes exactly (max_diff = 0).
"""
import numpy as np
import blindlib as bl

# --- tuned on TRAIN only ---
WINDOWS = (80, 100, 120, 140, 160)   # FFT window lengths (days) to ensemble
PMIN = 8                             # shortest cycle period considered (days)
PMAX = 60                            # longest cycle period considered (days)
PHASE_SIGN = -1.0                    # cycle-continuation reading (best on TRAIN)
TREND_W = 0.30                       # weight of slow-trend anchor vs phase ensemble
_NMAX = max(WINDOWS)


def _cycle_phase_dir(x):
    """Last N log-prices x (oldest..newest) -> dominant in-band cycle's projected
    next-bar direction in [-1, 1], scaled by the cycle's in-band power share
    (conviction). Pure function of x (causal). 0.0 if no band power."""
    n = len(x)
    t = np.arange(n, dtype=float)
    # linear detrend: strip the local trend so the FFT isolates the oscillation
    A = np.polyfit(t, x, 1)
    resid = x - (A[0] * t + A[1])
    xw = resid * np.hanning(n)
    F = np.fft.rfft(xw)
    freqs = np.fft.rfftfreq(n, d=1.0)
    P = np.abs(F) ** 2
    with np.errstate(divide="ignore"):
        per = np.where(freqs > 0, 1.0 / freqs, np.inf)
    band = (per >= PMIN) & (per <= PMAX)
    if not band.any():
        return 0.0
    idx = np.where(band)[0]
    k = idx[int(np.argmax(P[idx]))]
    if P[k] <= 0:
        return 0.0
    f = freqs[k]
    # phase of the coefficient -> reconstructed component C(t) ~ cos(2*pi*f*t + ang).
    # its next-bar slope ~ -sin(...) evaluated at the LAST sample (the bar whose
    # next step we anticipate).
    ang = np.angle(F[k])
    theta = 2.0 * np.pi * f * (n - 1) + ang
    slope = -np.sin(theta)
    share = P[k] / (P[idx].sum() + 1e-12)           # conviction in [0,1]
    return float(slope) * float(np.clip(share * len(idx), 0.0, 1.0))


def signal(df):
    c = df["close"].values.astype(float)
    lp = np.log(c)
    n = len(c)
    raw = np.zeros(n)

    # slow local-trend anchor (the low-freq component is the real structure here)
    slow = bl.ema(c, 50)
    trend_dir = np.sign(c - slow)

    for i in range(_NMAX, n):
        acc = 0.0
        for N in WINDOWS:
            acc += _cycle_phase_dir(lp[i - N + 1: i + 1])   # rows 0..i only
        cyc = PHASE_SIGN * acc / len(WINDOWS)               # phase ensemble
        raw[i] = (1.0 - TREND_W) * cyc + TREND_W * trend_dir[i]

    direction = np.tanh(2.0 * raw)
    pos = bl.vol_target(direction, df, target_vol=0.20, vol_win_days=30,
                        leverage_cap=1.0)
    return np.clip(pos, -1.0, 1.0)
