"""Proper CTA / managed-futures trend backtest — sector risk budgeting + portfolio vol target.

The "trend equation" assembled from the literature (see `domain/cta_trend.py` for signal provenance):

  1. Per instrument: signed conviction in ~[-1,1] (cta_conviction) sized inverse-vol -> w_i = conv_i/σ_i.
     Risk contribution is then |conv_i| (the σ cancels) — that identity is what makes the sector layer
     covariance-free.
  2. Sector risk budget: normalize each ACTIVE sector's total risk (Σ|conv|) to 1/n_sectors, so the 12
     commodity/ag markets cannot dominate the 4 equity ones (the bug that sank the naive TrendRunner).
  3. Portfolio vol target: lever the whole (unlevered) book by σ_TGT / trailing-realized-vol, capped at
     max_leverage (Baltas-Kosowski constant-vol overlay). Strictly past-only (no look-ahead).
  4. Long/short by default (crisis alpha lives in the short leg). Held between rebalances.

A "price panel" = dict[str, dict[int, float]] = {symbol: {epoch_day: close}}. Returns the same
`TrendRunResult` shape as TrendRunner so it plugs into the gauntlet and the book allocator unchanged.
"""
from __future__ import annotations

import math
from collections import defaultdict
from typing import Any

import numpy as np

from fxhnt.application.trend_runner import TrendRunResult, _json_floats
from fxhnt.domain.backtest import compute_stats
from fxhnt.domain.cta_trend import DEFAULT_HORIZONS, SECTOR_MAP, cta_conviction, ewma_daily_vol
from fxhnt.domain.gauntlet.core import evaluate, per_period_sharpe


class CtaRunner:
    def __init__(self, prices: dict[str, dict[int, float]], *,
                 horizons: tuple[int, ...] = DEFAULT_HORIZONS,
                 sector_map: dict[str, str] | None = None,
                 target_vol: float = 0.15, com: float = 60.0, vol_lookback: int = 60,
                 max_leverage: float = 4.0, min_history: int = 300, cost_bps: float = 5.0,
                 rebalance_every: int = 5, long_short: bool = True,
                 periods_per_year: int = 252) -> None:
        self._p = prices
        self._horizons = horizons
        self._sector = sector_map or SECTOR_MAP
        self._target_vol = target_vol
        self._com = com
        self._vol_lookback = vol_lookback
        self._max_lev = max_leverage
        self._min_history = min_history
        self._cost = cost_bps / 1e4
        self._rebalance_every = max(1, rebalance_every)
        self._long_short = long_short
        self._ppy = periods_per_year

    def _closes_asof(self, sym: str, day: int) -> list[float]:
        series = self._p[sym]
        return [series[d] for d in sorted(series) if d <= day]

    def _targets(self, day: int) -> dict[str, float]:
        """Sector-risk-budgeted UNLEVERED signed weights as of `day` (uses only closes <= day)."""
        conv: dict[str, float] = {}
        sigma: dict[str, float] = {}
        for sym, series in self._p.items():
            if day not in series:
                continue
            closes = self._closes_asof(sym, day)
            if len(closes) < self._min_history:
                continue
            c = cta_conviction(closes, horizons=self._horizons, com=self._com)
            dv = ewma_daily_vol(closes, com=self._com)
            if c is None or dv is None:
                continue
            if not self._long_short:
                c = max(0.0, c)
            if c == 0.0:
                continue
            conv[sym] = c
            sigma[sym] = dv * math.sqrt(self._ppy)        # annualized ex-ante vol
        if not conv:
            return {}
        # group risk (Σ|conv|) by sector; equalize each active sector to 1/n_sectors of total risk
        by_sector: dict[str, list[str]] = defaultdict(list)
        for sym in conv:
            by_sector[self._sector.get(sym, "other")].append(sym)
        n_sectors = len(by_sector)
        weights: dict[str, float] = {}
        for syms in by_sector.values():
            sector_risk = sum(abs(conv[s]) for s in syms)
            if sector_risk <= 0.0:
                continue
            sector_scale = (1.0 / n_sectors) / sector_risk
            for s in syms:
                weights[s] = (conv[s] / sigma[s]) * sector_scale
        return weights

    def run(self) -> TrendRunResult:
        all_days = sorted({d for s in self._p.values() for d in s})
        # pass 1: unlevered held positions + unlevered realized returns
        unlev: list[float] = []
        snaps: list[dict[str, float]] = []
        dates: list[int] = []
        pos: dict[str, float] = {}
        assets_seen = 0
        for i in range(len(all_days) - 1):
            d, nxt = all_days[i], all_days[i + 1]
            if i % self._rebalance_every == 0:
                pos = self._targets(d)
            realized = 0.0
            for sym, w in pos.items():
                series = self._p[sym]
                if d in series and nxt in series and series[d] > 0 and series[nxt] > 0:
                    realized += w * (series[nxt] / series[d] - 1.0)
            unlev.append(realized)
            snaps.append(dict(pos))
            dates.append(int(nxt))
            assets_seen += len(pos)
        n = len(unlev)
        if n == 0:
            return TrendRunResult(returns=np.zeros(0), n_assets_avg=0.0, n_rebalances=0, dates=[])
        unlev_arr = np.asarray(unlev, dtype=float)
        # pass 2: portfolio vol-target overlay (trailing realized vol, strictly past) + turnover on levered book
        tgt_daily = self._target_vol / math.sqrt(self._ppy)
        rets: list[float] = []
        prev_lev_pos: dict[str, float] = {}
        for t in range(n):
            if t >= self._vol_lookback:
                rv = float(np.std(unlev_arr[t - self._vol_lookback:t]))
                lev = min(self._max_lev, tgt_daily / rv) if rv > 0 else 1.0
            else:
                lev = 1.0
            lev_pos = {s: w * lev for s, w in snaps[t].items()}
            turnover = sum(abs(lev_pos.get(s, 0.0) - prev_lev_pos.get(s, 0.0))
                           for s in set(lev_pos) | set(prev_lev_pos)) * self._cost / 2.0
            rets.append(lev * unlev_arr[t] - turnover)
            prev_lev_pos = lev_pos
        return TrendRunResult(
            returns=np.asarray(rets, dtype=float),
            n_assets_avg=(assets_seen / n) if n else 0.0,
            n_rebalances=n,
            dates=dates,
        )


def evaluate_cta(prices: dict[str, dict[int, float]], *, periods_per_year: int = 252,
                 configs: list[dict[str, Any]] | None = None, oos_fraction: float = 0.40,
                 dsr_min: float = 0.95, oos_min_sharpe: float = 0.0,
                 max_is_oos_decay: float = 0.50) -> dict[str, Any]:
    """Run CtaRunner for each config and gauntlet every cell. Mirrors evaluate_trend's structure."""
    configs = configs or [{"label": "cta-default"}]
    results: dict[str, np.ndarray] = {}
    labels: list[str] = []
    for idx, cfg in enumerate(configs):
        kwargs = {k: v for k, v in cfg.items() if k != "label"}
        label = str(cfg.get("label", f"cfg{idx}"))
        labels.append(label)
        results[label] = CtaRunner(prices, periods_per_year=periods_per_year, **kwargs).run().returns
    n_trials = len(configs)
    is_sharpes = [per_period_sharpe(r[:int((1.0 - oos_fraction) * len(r))])
                  for r in results.values() if len(r) > 3]
    sr_variance = float(np.var(is_sharpes)) if len(is_sharpes) > 1 else 0.0
    out: dict[str, Any] = {"cells": {}, "n_trials": n_trials,
                           "settings": {"oos_fraction": oos_fraction, "periods_per_year": periods_per_year}}
    for label in labels:
        r = results[label]
        stats = compute_stats(r, periods_per_year=periods_per_year)
        if len(r) < 3:
            out["cells"][label] = {"config_label": label, "stats": _json_floats(stats.model_dump()),
                                   "verdict": {"passed": False, "dsr": 0.0, "is_sharpe": 0.0,
                                               "oos_sharpe": 0.0, "n_trials": n_trials,
                                               "reasons": ["insufficient data"], "pvalue": 1.0}}
            continue
        split = int((1.0 - oos_fraction) * len(r))
        v = evaluate(r[:split], r[split:], n_trials=n_trials, sr_variance=sr_variance,
                     dsr_min=dsr_min, oos_min_sharpe=oos_min_sharpe,
                     max_is_oos_decay=max_is_oos_decay, has_economic_rationale=True)
        out["cells"][label] = {"config_label": label, "stats": _json_floats(stats.model_dump()),
                               "verdict": _json_floats(v.model_dump())}
    return out
