"""
25 improvement ideas for SPY overnight positioning (research grid).

Each idea returns a **signal on day t** (True = buy close t, exit next session).
Default PnL uses close→close; ideas tagged ``exit=open`` use close→next open.

No lookahead: features/signals use data through close of ``t``.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Callable

import numpy as np
import pandas as pd

from RenTech.strategy_stack.spy_overnight_vix_calm import (
    calm_score,
    fear_score,
    hard_calm_rules_signal,
    hard_fear_rules_signal,
    next_close_to_close_return,
    signal_to_strategy_returns,
    top_frac_score_signal,
)


@dataclass(frozen=True)
class Idea:
    id: str
    name: str
    family: str
    exit: str  # "c2c" | "c2o"
    build: Callable[[pd.DataFrame, pd.DataFrame, pd.DataFrame], pd.Series]
    note: str = ""


def _c2c(spy: pd.DataFrame) -> pd.Series:
    return spy["close"].pct_change()


def _fwd_c2c(spy: pd.DataFrame) -> pd.Series:
    return next_close_to_close_return(spy)


def _fwd_c2o(spy: pd.DataFrame) -> pd.Series:
    """Return from today's close to tomorrow's open (indexed on signal day t)."""
    return spy["open"].shift(-1) / spy["close"] - 1.0


def _book(sig: pd.Series, fwd: pd.Series) -> pd.Series:
    return signal_to_strategy_returns(sig.fillna(False).astype(bool), fwd)


def _sma(s: pd.Series, n: int) -> pd.Series:
    return s.rolling(n, min_periods=max(20, n // 2)).mean()


def _ibs(spy: pd.DataFrame) -> pd.Series:
    rng = (spy["high"] - spy["low"]).replace(0, np.nan)
    return (spy["close"] - spy["low"]) / rng


def _is_fri(idx: pd.DatetimeIndex) -> pd.Series:
    return pd.Series(idx.dayofweek == 4, index=idx)


def _turn_of_month(idx: pd.DatetimeIndex) -> pd.Series:
    """Last session of month + first 2 of next (signal days to hold overnight)."""
    m = pd.Series(idx, index=idx).dt.to_period("M")
    last = m != m.shift(-1)
    # first 2 of month: hold overnight into day 2/3
    rank = m.groupby(m).cumcount()
    first2 = rank < 2
    return (last | first2).fillna(False)


def _vol_target_weights(spy: pd.DataFrame, target_ann: float = 0.10) -> pd.Series:
    """Fractional size for always-in overnight; clipped to [0, 1.5]."""
    r = _c2c(spy)
    vol = r.rolling(20, min_periods=10).std() * np.sqrt(252.0)
    w = (target_ann / vol.replace(0, np.nan)).clip(0.0, 1.5)
    return w.fillna(0.0)


def _sized_returns(weights_on_t: pd.Series, fwd: pd.Series) -> pd.Series:
    """weights on signal day t → PnL on t+1 = w[t] * fwd[t]."""
    w = weights_on_t.shift(1).fillna(0.0)
    ret = fwd.shift(1)
    out = pd.Series(0.0, index=weights_on_t.index, dtype=float)
    mask = ret.notna() & (w != 0.0)
    out.loc[mask] = (w.loc[mask] * ret.loc[mask]).astype(float)
    return out


# --- Idea builders: (spy, feats, fwd_unused) -> signal bool Series ---


def _i01_fear_rules(spy, feats, _):
    return hard_fear_rules_signal(feats)


def _i02_calm_rules(spy, feats, _):
    return hard_calm_rules_signal(feats)


def _i03_fear_c2o(spy, feats, _):
    return hard_fear_rules_signal(feats)


def _i04_fear_uptrend(spy, feats, _):
    return hard_fear_rules_signal(feats) & (feats["spy_above_sma200"] > 0.5)


def _i05_fear_vix_cap(spy, feats, _):
    return hard_fear_rules_signal(feats) & (feats["vix"] < 40.0)


def _i06_vix_rsi70(spy, feats, _):
    return feats["vix_rsi14"] >= 70.0


def _i07_spy_5d_down2(spy, feats, _):
    return feats["spy_ret_5d"] <= -0.02


def _i08_three_down(spy, feats, _):
    r = _c2c(spy)
    return (r < 0) & (r.shift(1) < 0) & (r.shift(2) < 0)


def _i09_low10(spy, feats, _):
    c = spy["close"]
    return c <= c.rolling(10, min_periods=10).min()


def _i10_fear_and_3down(spy, feats, _):
    return hard_fear_rules_signal(feats) & _i08_three_down(spy, feats, _)


def _i11_fear_top5(spy, feats, _):
    return top_frac_score_signal(fear_score(feats), frac=0.05)


def _i12_fear_top15(spy, feats, _):
    return top_frac_score_signal(fear_score(feats), frac=0.15)


def _i13_vix_spike_fade(spy, feats, _):
    """After VIX jumps ≥10% today, hold overnight (fade)."""
    return feats["vix"].pct_change() >= 0.10


def _i14_vix_cooling_high(spy, feats, _):
    """VIX still elevated but RSI rolling over."""
    rsi = feats["vix_rsi14"]
    return (feats["vix_pct_252"] >= 0.60) & (rsi < rsi.shift(1)) & (rsi >= 45.0)


def _i15_contango_always(spy, feats, _):
    return feats["vix_term_ratio"] < 1.0


def _i16_backwardation_fade(spy, feats, _):
    return feats["vix_term_ratio"] > 1.05


def _i17_vvix_high_spy_down(spy, feats, _):
    return (feats["vvix_pct_252"] >= 0.70) & (feats["spy_ret_5d"] < 0.0)


def _i18_turnaround_tue(spy, feats, _):
    """Signal Monday down → hold Mon close→Tue close (signal on Monday)."""
    r = _c2c(spy)
    return (spy.index.dayofweek == 0) & (r < 0)


def _i19_friday_weekend(spy, feats, _):
    """Fri close → Mon close (signal Friday; multi-day hold booked via C2C chain).

    Approximated as signal on Friday only; return uses close[Fri]→close[Mon]
    constructed as product of intervening C2C if needed — here we use
    close[t+1]/close[t] when next session is Monday (weekend gap in C2C).
    """
    return _is_fri(spy.index)


def _i20_turn_of_month(spy, feats, _):
    return _turn_of_month(spy.index)


def _i21_vol_target_always(spy, feats, _):
    """Special: returns weights not bool — handled in runner."""
    return pd.Series(True, index=spy.index)


def _i22_skip_vix_gap_events(spy, feats, _):
    """Always overnight except skip nights after huge VIX day-moves (event risk)."""
    vchg = feats["vix"].pct_change().abs()
    return ~(vchg >= 0.20)


def _i23_ibs_low(spy, feats, _):
    return _ibs(spy) < 0.20


def _i24_calm_or_mild_fear(spy, feats, _):
    mild_fear = (feats["vix_pct_252"] >= 0.55) & (feats["spy_ret_5d"] < 0)
    return hard_calm_rules_signal(feats) | mild_fear


def _i25_ensemble_vote2(spy, feats, _):
    votes = (
        _i08_three_down(spy, feats, _).astype(int)
        + hard_fear_rules_signal(feats).astype(int)
        + (feats["spy_ret_5d"] < -0.01).astype(int)
        + (feats["vix_rsi14"] >= 65.0).astype(int)
        + (_i09_low10(spy, feats, _).astype(int))
    )
    return votes >= 2


IDEAS: list[Idea] = [
    Idea("I01", "Fear hard rules (baseline)", "baseline", "c2c", _i01_fear_rules),
    Idea("I02", "Calm hard rules", "baseline", "c2c", _i02_calm_rules),
    Idea("I03", "Fear rules → exit next open (C2O)", "exit", "c2o", _i03_fear_c2o),
    Idea(
        "I04",
        "Fear + SPY above SMA200",
        "filter",
        "c2c",
        _i04_fear_uptrend,
        "Fear only in uptrend",
    ),
    Idea(
        "I05",
        "Fear + VIX < 40 cap",
        "filter",
        "c2c",
        _i05_fear_vix_cap,
        "Skip crash-regime extremes",
    ),
    Idea("I06", "VIX RSI(14) ≥ 70", "feature", "c2c", _i06_vix_rsi70),
    Idea("I07", "SPY 5d return ≤ −2%", "feature", "c2c", _i07_spy_5d_down2),
    Idea("I08", "Three down days (QS)", "qs", "c2c", _i08_three_down),
    Idea("I09", "Close at 10d low (QS)", "qs", "c2c", _i09_low10),
    Idea("I10", "Fear ∩ three-down", "combo", "c2c", _i10_fear_and_3down),
    Idea("I11", "Fear score top 5%", "score", "c2c", _i11_fear_top5),
    Idea("I12", "Fear score top 15%", "score", "c2c", _i12_fear_top15),
    Idea("I13", "VIX +10% day → fade overnight", "feature", "c2c", _i13_vix_spike_fade),
    Idea("I14", "Elevated VIX + RSI cooling", "feature", "c2c", _i14_vix_cooling_high),
    Idea("I15", "Contango only (VIX/VIX3M < 1)", "term", "c2c", _i15_contango_always),
    Idea("I16", "Backwardation fade", "term", "c2c", _i16_backwardation_fade),
    Idea("I17", "VVIX high + SPY 5d down", "feature", "c2c", _i17_vvix_high_spy_down),
    Idea("I18", "Turnaround Tuesday (Mon down)", "seasonality", "c2c", _i18_turnaround_tue),
    Idea("I19", "Friday overnight only", "seasonality", "c2c", _i19_friday_weekend),
    Idea("I20", "Turn-of-month overnight", "seasonality", "c2c", _i20_turn_of_month),
    Idea(
        "I21",
        "Always-in vol-target 10% ann",
        "sizing",
        "c2c",
        _i21_vol_target_always,
        "Fractional size; not binary",
    ),
    Idea(
        "I22",
        "Always-in skip |ΔVIX|≥20% days",
        "filter",
        "c2c",
        _i22_skip_vix_gap_events,
    ),
    Idea("I23", "IBS < 0.20", "feature", "c2c", _i23_ibs_low),
    Idea("I24", "Calm OR mild fear union", "combo", "c2c", _i24_calm_or_mild_fear),
    Idea("I25", "Ensemble ≥2 of 5 stress votes", "combo", "c2c", _i25_ensemble_vote2),
]


def evaluate_idea(
    idea: Idea,
    spy: pd.DataFrame,
    feats: pd.DataFrame,
    *,
    capital: float = 100_000.0,
) -> dict:
    from RenTech.strategy_stack.run_qs_top_ideas_backtest import _metrics

    fwd = _fwd_c2o(spy) if idea.exit == "c2o" else _fwd_c2c(spy)
    if idea.id == "I21":
        w = _vol_target_weights(spy, 0.10)
        ret = _sized_returns(w, fwd)
        invested = float((w > 0).mean() * 100.0)
        avg_w = float(w.mean())
        sig = w > 0
    else:
        sig = idea.build(spy, feats, fwd).fillna(False).astype(bool)
        ret = _book(sig, fwd)
        invested = float(sig.mean() * 100.0)
        avg_w = float(sig.mean())

    m = _metrics(ret, capital)
    x = fwd.loc[sig.fillna(False) & fwd.notna()] if idea.id != "I21" else fwd.dropna()
    # For I21 conditional stats on nights with w>0 use signal-day weights
    if idea.id == "I21":
        w = _vol_target_weights(spy, 0.10)
        x = fwd.loc[(w > 0) & fwd.notna()]

    cond_mean = float(x.mean()) if len(x) else float("nan")
    hit = float((x > 0).mean()) if len(x) else float("nan")
    all_mean = float(fwd.dropna().mean()) if fwd.notna().any() else float("nan")

    return {
        "id": idea.id,
        "name": idea.name,
        "family": idea.family,
        "exit": idea.exit,
        "note": idea.note,
        "coverage_pct": round(invested if idea.id != "I21" else float((w > 0).mean() * 100), 2),
        "avg_weight": round(avg_w, 4),
        "cond_mean_bps": round(cond_mean * 1e4, 2) if cond_mean == cond_mean else None,
        "hit_rate_pct": round(hit * 100.0, 1) if hit == hit else None,
        "edge_vs_all_bps": round((cond_mean - all_mean) * 1e4, 2)
        if cond_mean == cond_mean and all_mean == all_mean
        else None,
        **m,
    }
