"""Proper CTA / managed-futures trend signal — vol-normalized momentum with a saturating response.

Grounded in the trend-following literature:
- Per-instrument ex-ante vol: EWMA of squared daily log-returns, center-of-mass 60d (Moskowitz-Ooi-
  Pedersen, "Time Series Momentum", JFE 2012).
- Signal: risk-adjusted momentum over a lookback = log-return / (daily_vol * sqrt(L)) ≈ the realized
  Sharpe over the window (Baltas-Kosowski TREND rule).
- Response: tanh(x / 0.89) saturating nonlinearity (Lempérière-Deremble-Seager-Potters-Bouchaud,
  "Two centuries of trend following", 2014 — verbatim s* = 0.89).
- Blend: equal-weight the per-horizon convictions over 1/3/12-month (MOP / AQR "A Century of Evidence").

Pure functions on an ascending-by-day closes list. No look-ahead: every value uses only closes <= the
as-of day. The runner (`application/cta_runner.py`) adds sector risk budgeting + a portfolio vol-target.
"""
from __future__ import annotations

import math

import numpy as np

# Asset-class sector map for the CME continuous-front roots (`futures_fetch.DEFAULT_ROOTS`). The sector
# layer equalizes risk across these groups so the 12 commodity/ag markets cannot drown the 4 equity ones.
SECTOR_MAP: dict[str, str] = {
    "ES": "equity", "NQ": "equity", "YM": "equity", "RTY": "equity",
    "ZN": "rates", "ZB": "rates", "ZF": "rates", "ZT": "rates",
    "6E": "fx", "6J": "fx", "6B": "fx", "6A": "fx", "6C": "fx",
    "GC": "metals", "SI": "metals", "HG": "metals",
    "CL": "energy", "NG": "energy", "RB": "energy",
    "ZC": "ags", "ZS": "ags", "ZW": "ags",
}

_S_STAR = 0.89          # Lempérière-Bouchaud saturation constant (verbatim)
DEFAULT_HORIZONS = (21, 63, 252)   # 1 / 3 / 12 month in trading days (MOP / AQR standard)


def _ewma(values: np.ndarray, com: float) -> np.ndarray:
    """Exponentially-weighted moving average, center-of-mass `com` (alpha = 1/(1+com)). Full series."""
    alpha = 1.0 / (1.0 + com)
    out = np.empty(len(values), dtype=float)
    acc = float(values[0])
    out[0] = acc
    for i in range(1, len(values)):
        acc = alpha * float(values[i]) + (1.0 - alpha) * acc
        out[i] = acc
    return out


def ewma_daily_vol(closes: list[float], *, com: float = 60.0) -> float | None:
    """Ex-ante daily return vol = sqrt(EWMA of squared demeaned daily log-returns), com=60d (MOP 2012).

    Returns None if there is too little history or the estimate is non-positive.
    """
    c = np.asarray(closes, dtype=float)
    if len(c) < int(com) // 2 + 3 or np.any(c <= 0):
        return None
    r = np.diff(np.log(c))
    if len(r) < 2:
        return None
    m = _ewma(r, com)
    v = _ewma((r - m) ** 2, com)
    sd = math.sqrt(max(float(v[-1]), 0.0))
    return sd if sd > 0.0 else None


def risk_adjusted_momentum(closes: list[float], lookback: int, daily_vol: float) -> float | None:
    """Log-return over `lookback` days scaled by the window vol = log(p_t/p_{t-L}) / (daily_vol*sqrt(L)).

    Dimensionless (~ realized Sharpe over the window), so comparable across instruments. None if short.
    """
    c = np.asarray(closes, dtype=float)
    if len(c) <= lookback or daily_vol <= 0.0 or c[-1] <= 0.0 or c[-1 - lookback] <= 0.0:
        return None
    mom = math.log(c[-1] / c[-1 - lookback])
    return mom / (daily_vol * math.sqrt(lookback))


def cta_conviction(closes: list[float], *, horizons: tuple[int, ...] = DEFAULT_HORIZONS,
                   com: float = 60.0, s_star: float = _S_STAR) -> float | None:
    """Signed conviction in ~[-1, 1]: mean over horizons of tanh(risk_adjusted_momentum / s_star).

    None when the daily-vol estimate or every horizon's momentum is unavailable (insufficient history).
    """
    dv = ewma_daily_vol(closes, com=com)
    if dv is None:
        return None
    convs: list[float] = []
    for lookback in horizons:
        m = risk_adjusted_momentum(closes, lookback, dv)
        if m is not None:
            convs.append(math.tanh(m / s_star))
    if not convs:
        return None
    return sum(convs) / len(convs)
