#!/usr/bin/env python3
"""
Quant Research v2: Serious strategy development.

Lessons from v1:
- MultiTF confirmation is the strongest signal (IC=0.056)
- Signals are way too timid — need ATR-scaled conviction
- High hit-rate = predicting 0 too much (RSI, Bollinger)
- Equal-weight ensemble dilutes strong signals
- Need walk-forward: train on first 4 months, test on last 2

Usage:
    cd challenge && COORDINATOR_URL=http://localhost:8000 .venv/bin/python research/run_v2.py
"""
import sys, os, time, json, math
from collections import deque
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from vanta.backtest import BacktestRunner, BacktestResult
from vanta.tracker import TrackerBase
from vanta.indicators import (
    SMA, EMA, RSI, MACD, BollingerBands, ATR, VWAP, Returns,
)

# ═══════════════════════════════════════════════════════════════════════════════
# UTILITY: ATR-scaled signal sizing
# ═══════════════════════════════════════════════════════════════════════════════

class SignalScaler:
    """Normalize signals by realized volatility so conviction scales with regime.

    Instead of fixed ±0.005 caps, output = raw_signal × (target_vol / realized_vol).
    This means:
    - In low vol: signals are amplified (more conviction when it's quiet)
    - In high vol: signals are dampened (less conviction when it's noisy)
    """
    def __init__(self, atr_period=14, scale_period=60, target_risk=0.003):
        self.atr = ATR(period=atr_period)
        self.price_sma = SMA(period=atr_period)
        self.atr_sma = SMA(period=scale_period)  # long-term avg ATR
        self.target_risk = target_risk

    def update(self, high, low, close):
        self.atr.update(high, low, close)
        self.price_sma.update(close)
        if self.atr.ready:
            self.atr_sma.update(self.atr.value)

    @property
    def ready(self):
        return self.atr.ready and self.price_sma.ready and self.atr_sma.ready

    def scale(self, raw_signal, max_abs=0.02):
        """Scale a [-1, 1] raw signal to an expected_return."""
        if not self.ready or self.price_sma.value == 0:
            return raw_signal * self.target_risk

        # Normalized ATR (as fraction of price)
        norm_atr = self.atr.value / self.price_sma.value
        avg_norm_atr = self.atr_sma.value / self.price_sma.value

        if avg_norm_atr < 1e-9:
            return raw_signal * self.target_risk

        # Scale: in low vol amplify, in high vol dampen
        vol_ratio = avg_norm_atr / max(norm_atr, 1e-9)
        vol_ratio = max(0.3, min(3.0, vol_ratio))  # clamp

        scaled = raw_signal * self.target_risk * vol_ratio
        return max(-max_abs, min(max_abs, scaled))


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 1: Multi-Timeframe Trend Confluence (improved)
# ═══════════════════════════════════════════════════════════════════════════════

class MTFTrendV2(TrackerBase):
    """Multi-timeframe trend with ATR-scaled signals and momentum confirmation.

    Hypothesis: When 5m, 15m, and 1h trends all agree, 5min forward returns
    are strongly directional. Use MACD histogram slope for momentum timing.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.004)
        # 5m trend
        self.ema_5m_fast = EMA(period=8)
        self.ema_5m_slow = EMA(period=21)
        # 15m trend
        self.ema_15m_fast = EMA(period=5)
        self.ema_15m_slow = EMA(period=13)
        # 1h trend direction
        self.ema_1h_fast = EMA(period=5)
        self.ema_1h_slow = EMA(period=13)
        # 1m MACD for entry timing
        self.macd = MACD(fast=8, slow=21, signal=5)
        self.prev_hist = 0.0

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            self.macd.update(p)
            self.scaler.update(float(c["high"]), float(c["low"]), p)
        for c in data.get("candles_5m", []):
            p = float(c["close"])
            self.ema_5m_fast.update(p)
            self.ema_5m_slow.update(p)
        for c in data.get("candles_15m", []):
            p = float(c["close"])
            self.ema_15m_fast.update(p)
            self.ema_15m_slow.update(p)
        for c in data.get("candles_1h", []):
            p = float(c["close"])
            self.ema_1h_fast.update(p)
            self.ema_1h_slow.update(p)

    def predict(self, symbol, horizon_seconds, step_seconds):
        if not (self.ema_5m_fast.ready and self.ema_15m_fast.ready
                and self.ema_1h_fast.ready and self.macd.ready):
            return {"expected_return": 0.0}

        # Trend scores: normalized spread
        def trend(fast, slow):
            return (fast.value - slow.value) / slow.value if slow.value else 0

        t5 = trend(self.ema_5m_fast, self.ema_5m_slow)
        t15 = trend(self.ema_15m_fast, self.ema_15m_slow)
        t1h = trend(self.ema_1h_fast, self.ema_1h_slow)

        # All three must agree on direction
        if not ((t5 > 0 and t15 > 0 and t1h > 0) or (t5 < 0 and t15 < 0 and t1h < 0)):
            return {"expected_return": 0.0}

        # MACD momentum confirmation: histogram should be accelerating
        hist = self.macd.value.histogram
        hist_accel = hist - self.prev_hist
        self.prev_hist = hist

        direction = 1.0 if t5 > 0 else -1.0

        # Only enter if MACD histogram is accelerating in the trend direction
        if direction * hist_accel <= 0:
            return {"expected_return": 0.0}

        # Signal strength: min of the three trend strengths (weakest link)
        strength = min(abs(t5), abs(t15), abs(t1h))
        raw = direction * min(strength * 50, 1.0)  # normalize to [-1, 1]

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 2: Mean Reversion at Extremes (aggressive)
# ═══════════════════════════════════════════════════════════════════════════════

class ExtremeMeanReversion(TrackerBase):
    """Only trade at statistical extremes, but with high conviction.

    Hypothesis: Price deviations > 2σ from short-term mean revert within 5min
    with high probability. Don't trade the middle — only the tails.
    """
    def __init__(self, lookback=30, entry_z=2.0, exit_z=0.5):
        super().__init__()
        self.lookback = lookback
        self.entry_z = entry_z
        self.exit_z = exit_z
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.005)
        self.prices = deque(maxlen=lookback)
        self.volumes = deque(maxlen=lookback)

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            self.prices.append(float(c["close"]))
            self.volumes.append(float(c["volume"]))
            self.scaler.update(float(c["high"]), float(c["low"]), float(c["close"]))

    def predict(self, symbol, horizon_seconds, step_seconds):
        if len(self.prices) < self.lookback or not self.scaler.ready:
            return {"expected_return": 0.0}

        prices = list(self.prices)
        mean = sum(prices) / len(prices)
        var = sum((p - mean) ** 2 for p in prices) / len(prices)
        std = math.sqrt(var) if var > 0 else 1e-9
        z_score = (prices[-1] - mean) / std

        # Volume confirmation: high volume at extremes increases confidence
        volumes = list(self.volumes)
        vol_mean = sum(volumes) / len(volumes)
        vol_ratio = volumes[-1] / vol_mean if vol_mean > 0 else 1.0

        if abs(z_score) < self.entry_z:
            return {"expected_return": 0.0}

        # Fade the extreme — signal proportional to how extreme
        raw = -z_score / 4.0  # z=2 → raw=-0.5, z=3 → raw=-0.75
        raw = max(-1.0, min(1.0, raw))

        # Boost if volume confirms (high volume extremes revert more reliably)
        if vol_ratio > 1.5:
            raw *= 1.3

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 3: Momentum Breakout (volatility expansion)
# ═══════════════════════════════════════════════════════════════════════════════

class VolBreakout(TrackerBase):
    """Trade volatility expansions: when ATR breaks above its mean, ride the move.

    Hypothesis: Volatility clusters. When ATR spikes above its 60-period average,
    the current direction (measured by short EMA) will continue for 5 minutes.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.004)
        self.atr = ATR(period=14)
        self.atr_sma = SMA(period=60)
        self.ema_fast = EMA(period=3)
        self.ema_slow = EMA(period=10)
        self.returns_5 = Returns(period=5)

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            self.atr.update(float(c["high"]), float(c["low"]), p)
            self.ema_fast.update(p)
            self.ema_slow.update(p)
            self.returns_5.update(p)
            self.scaler.update(float(c["high"]), float(c["low"]), p)
            if self.atr.ready:
                self.atr_sma.update(self.atr.value)

    def predict(self, symbol, horizon_seconds, step_seconds):
        if not (self.atr.ready and self.atr_sma.ready and self.ema_fast.ready
                and self.returns_5.ready and self.atr_sma.value > 0):
            return {"expected_return": 0.0}

        vol_ratio = self.atr.value / self.atr_sma.value

        # Only trade in volatility expansion (ATR > 1.5× average)
        if vol_ratio < 1.5:
            return {"expected_return": 0.0}

        # Direction from short-term momentum
        trend = (self.ema_fast.value - self.ema_slow.value) / self.ema_slow.value
        mom = self.returns_5.value

        # Both must agree
        if not ((trend > 0 and mom > 0) or (trend < 0 and mom < 0)):
            return {"expected_return": 0.0}

        direction = 1.0 if trend > 0 else -1.0
        # Signal strength proportional to vol expansion
        strength = min((vol_ratio - 1.5) / 1.5, 1.0)
        raw = direction * strength

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 4: RSI Divergence (price vs momentum disagreement)
# ═══════════════════════════════════════════════════════════════════════════════

class RSIDivergence(TrackerBase):
    """Trade divergences between price and RSI momentum.

    Hypothesis: When price makes new highs but RSI doesn't (bearish divergence),
    or price makes new lows but RSI doesn't (bullish divergence), reversal is likely.
    """
    def __init__(self, rsi_period=14, lookback=20):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.004)
        self.rsi = RSI(period=rsi_period)
        self.lookback = lookback
        self.price_history = deque(maxlen=lookback)
        self.rsi_history = deque(maxlen=lookback)

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            self.rsi.update(p)
            self.scaler.update(float(c["high"]), float(c["low"]), p)
            if self.rsi.ready:
                self.price_history.append(p)
                self.rsi_history.append(self.rsi.value)

    def predict(self, symbol, horizon_seconds, step_seconds):
        if len(self.price_history) < self.lookback or not self.scaler.ready:
            return {"expected_return": 0.0}

        prices = list(self.price_history)
        rsis = list(self.rsi_history)

        curr_price = prices[-1]
        curr_rsi = rsis[-1]
        max_price = max(prices[:-1])
        min_price = min(prices[:-1])
        max_rsi = max(rsis[:-1])
        min_rsi = min(rsis[:-1])

        raw = 0.0

        # Bearish divergence: price at new high but RSI isn't
        if curr_price >= max_price and curr_rsi < max_rsi - 5:
            divergence = (max_rsi - curr_rsi) / max_rsi
            raw = -divergence * 2.0

        # Bullish divergence: price at new low but RSI isn't
        elif curr_price <= min_price and curr_rsi > min_rsi + 5:
            divergence = (curr_rsi - min_rsi) / (100 - min_rsi) if min_rsi < 100 else 0
            raw = divergence * 2.0

        if abs(raw) < 0.1:
            return {"expected_return": 0.0}

        raw = max(-1.0, min(1.0, raw))
        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 5: VWAP + Volume Spike Reversion
# ═══════════════════════════════════════════════════════════════════════════════

class VWAPVolumeReversion(TrackerBase):
    """Reversion to VWAP on volume spikes.

    Hypothesis: Large volume spikes that push price away from VWAP tend to
    mean-revert. High volume = institutional flow = temporary imbalance.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.004)
        self.vwap = VWAP()
        self.vol_sma = SMA(period=30)
        self.price = 0.0
        self._bar_count = 0

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            v = float(c["volume"])
            self.price = p
            self.vwap.update(p, v)
            self.vol_sma.update(v)
            self.scaler.update(float(c["high"]), float(c["low"]), p)
            self._bar_count += 1

        # Reset VWAP every 60 bars (1 hour) to keep it responsive
        if self._bar_count >= 60:
            self.vwap.reset()
            self._bar_count = 0

    def predict(self, symbol, horizon_seconds, step_seconds):
        if not (self.vwap.ready and self.vol_sma.ready and self.scaler.ready):
            return {"expected_return": 0.0}

        deviation = (self.price - self.vwap.value) / self.vwap.value

        # Need meaningful deviation
        if abs(deviation) < 0.001:
            return {"expected_return": 0.0}

        # Fade the deviation
        raw = -deviation * 30  # 0.1% deviation → raw=3.0 → clamp to 1.0
        raw = max(-1.0, min(1.0, raw))

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 6: Hourly Momentum (longer timeframe signal)
# ═══════════════════════════════════════════════════════════════════════════════

class HourlyMomentum(TrackerBase):
    """Use 1h candles for macro direction, 5m for timing.

    Hypothesis: 1h trends persist for multiple 5-min intervals.
    Enter when 5m pullback occurs within a strong 1h trend.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.004)
        # 1h trend
        self.ema_1h_fast = EMA(period=5)
        self.ema_1h_slow = EMA(period=13)
        self.returns_1h = Returns(period=3)  # 3-hour momentum
        # 5m pullback detection
        self.rsi_5m = RSI(period=10)

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            self.scaler.update(float(c["high"]), float(c["low"]), float(c["close"]))
        for c in data.get("candles_5m", []):
            self.rsi_5m.update(float(c["close"]))
        for c in data.get("candles_1h", []):
            p = float(c["close"])
            self.ema_1h_fast.update(p)
            self.ema_1h_slow.update(p)
            self.returns_1h.update(p)

    def predict(self, symbol, horizon_seconds, step_seconds):
        if not (self.ema_1h_fast.ready and self.returns_1h.ready
                and self.rsi_5m.ready and self.scaler.ready):
            return {"expected_return": 0.0}

        trend = (self.ema_1h_fast.value - self.ema_1h_slow.value) / self.ema_1h_slow.value
        mom = self.returns_1h.value

        # Need clear 1h trend
        if abs(trend) < 0.001:
            return {"expected_return": 0.0}

        direction = 1.0 if trend > 0 else -1.0

        # Pullback entry: RSI should be showing a pullback in the opposite direction
        if direction > 0 and self.rsi_5m.value > 60:
            return {"expected_return": 0.0}  # not pulled back enough in uptrend
        if direction < 0 and self.rsi_5m.value < 40:
            return {"expected_return": 0.0}  # not pulled back enough in downtrend

        # Momentum confirmation
        if direction * mom <= 0:
            return {"expected_return": 0.0}

        strength = min(abs(trend) * 20, 1.0)
        raw = direction * strength

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 7: Dual Z-Score Mean Reversion (short + medium lookback)
# ═══════════════════════════════════════════════════════════════════════════════

class DualZScore(TrackerBase):
    """Mean reversion using two z-scores at different lookbacks.

    Hypothesis: When BOTH short-term (10m) and medium-term (60m) z-scores
    show extremes, the reversion signal is much stronger than either alone.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.005)
        self.short_prices = deque(maxlen=10)
        self.med_prices = deque(maxlen=60)

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            self.short_prices.append(p)
            self.med_prices.append(p)
            self.scaler.update(float(c["high"]), float(c["low"]), p)

    def _z_score(self, prices):
        if len(prices) < 3:
            return 0.0
        lst = list(prices)
        mean = sum(lst) / len(lst)
        var = sum((p - mean) ** 2 for p in lst) / len(lst)
        std = math.sqrt(var) if var > 0 else 1e-9
        return (lst[-1] - mean) / std

    def predict(self, symbol, horizon_seconds, step_seconds):
        if len(self.med_prices) < 60 or not self.scaler.ready:
            return {"expected_return": 0.0}

        z_short = self._z_score(self.short_prices)
        z_med = self._z_score(self.med_prices)

        # Both must be extreme AND agree on direction
        if abs(z_short) < 1.5 or abs(z_med) < 1.5:
            return {"expected_return": 0.0}
        if z_short * z_med < 0:
            return {"expected_return": 0.0}  # disagree

        # Fade: use the weaker z-score as signal strength (conservative)
        z = z_short if abs(z_short) < abs(z_med) else z_med
        raw = -z / 3.0
        raw = max(-1.0, min(1.0, raw))

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# STRATEGY 8: Return Autocorrelation
# ═══════════════════════════════════════════════════════════════════════════════

class ReturnAutocorrelation(TrackerBase):
    """Exploit short-term return autocorrelation structure.

    Hypothesis: 1m returns show negative autocorrelation (microstructure bounce)
    but 5m returns show positive autocorrelation (momentum). Trade accordingly.
    """
    def __init__(self):
        super().__init__()
        self.scaler = SignalScaler(atr_period=14, scale_period=60, target_risk=0.003)
        self.returns_1m = deque(maxlen=20)
        self.returns_5m = deque(maxlen=12)  # last hour of 5m returns
        self.prev_1m_price = None
        self.prev_5m_price = None

    def tick(self, data):
        super().tick(data)
        for c in data.get("candles_1m", []):
            p = float(c["close"])
            self.scaler.update(float(c["high"]), float(c["low"]), p)
            if self.prev_1m_price is not None and self.prev_1m_price > 0:
                ret = (p - self.prev_1m_price) / self.prev_1m_price
                self.returns_1m.append(ret)
            self.prev_1m_price = p
        for c in data.get("candles_5m", []):
            p = float(c["close"])
            if self.prev_5m_price is not None and self.prev_5m_price > 0:
                ret = (p - self.prev_5m_price) / self.prev_5m_price
                self.returns_5m.append(ret)
            self.prev_5m_price = p

    def predict(self, symbol, horizon_seconds, step_seconds):
        if len(self.returns_1m) < 10 or len(self.returns_5m) < 6 or not self.scaler.ready:
            return {"expected_return": 0.0}

        # 1m return autocorrelation signal: fade recent 1m moves (negative autocorr)
        recent_1m = sum(list(self.returns_1m)[-3:]) / 3  # avg of last 3 1m returns
        mean_revert_signal = -recent_1m * 100  # scale up

        # 5m momentum signal: continue recent 5m direction (positive autocorr)
        recent_5m = sum(list(self.returns_5m)[-3:]) / 3
        momentum_signal = recent_5m * 50

        # Combine: 60% mean-revert (short-term), 40% momentum (medium-term)
        raw = 0.6 * mean_revert_signal + 0.4 * momentum_signal
        raw = max(-1.0, min(1.0, raw))

        if abs(raw) < 0.15:
            return {"expected_return": 0.0}

        return {"expected_return": self.scaler.scale(raw)}


# ═══════════════════════════════════════════════════════════════════════════════
# ENSEMBLE: Performance-Weighted (the real ensemble)
# ═══════════════════════════════════════════════════════════════════════════════

class PerformanceWeightedEnsemble(TrackerBase):
    """Dynamically weight sub-strategies by their recent Sortino ratio.

    Maintains a rolling window of each strategy's recent predictions and scores.
    Strategies with higher recent risk-adjusted returns get more weight.
    """
    def __init__(self):
        super().__init__()
        self.strategies = {
            "mtf_trend": MTFTrendV2(),
            "extreme_mr": ExtremeMeanReversion(),
            "vol_break": VolBreakout(),
            "rsi_div": RSIDivergence(),
            "vwap_vol": VWAPVolumeReversion(),
            "hourly_mom": HourlyMomentum(),
            "dual_z": DualZScore(),
            "ret_autocorr": ReturnAutocorrelation(),
        }
        # Track recent performance per strategy
        self.signal_history: dict[str, deque] = {
            name: deque(maxlen=200) for name in self.strategies
        }
        self.actual_returns = deque(maxlen=200)
        self.prices_1m = deque(maxlen=10)

    def tick(self, data):
        super().tick(data)
        # Forward tick to all sub-strategies
        for strat in self.strategies.values():
            strat.tick(data)

        # Track prices for computing actuals
        for c in data.get("candles_1m", []):
            self.prices_1m.append(float(c["close"]))

    def predict(self, symbol, horizon_seconds, step_seconds):
        # Collect signals from all strategies
        signals = {}
        for name, strat in self.strategies.items():
            out = strat.predict(symbol, horizon_seconds, step_seconds)
            sig = out.get("expected_return", 0.0)
            signals[name] = sig
            self.signal_history[name].append(sig)

        # Compute retrospective actual return (5 bars back, approximate)
        if len(self.prices_1m) >= 6:
            actual = (self.prices_1m[-1] - self.prices_1m[-6]) / self.prices_1m[-6]
            self.actual_returns.append(actual)

        # Compute weights based on recent IC (correlation of signal with actual)
        weights = self._compute_weights()

        # Weighted average signal
        total_weight = sum(abs(weights.get(name, 0)) for name in signals)
        if total_weight < 1e-9:
            # Fallback: equal weight only on non-zero signals
            active = {n: s for n, s in signals.items() if abs(s) > 1e-9}
            if not active:
                return {"expected_return": 0.0}
            avg = sum(active.values()) / len(active)
            return {"expected_return": avg}

        weighted_signal = sum(
            signals[name] * max(weights.get(name, 0), 0)
            for name in signals
        ) / total_weight

        return {"expected_return": weighted_signal}

    def _compute_weights(self):
        """Compute per-strategy weight based on rolling IC."""
        if len(self.actual_returns) < 30:
            return {name: 1.0 for name in self.strategies}

        actuals = list(self.actual_returns)
        weights = {}

        for name in self.strategies:
            hist = list(self.signal_history[name])
            n = min(len(hist), len(actuals))
            if n < 20:
                weights[name] = 0.5
                continue

            sigs = hist[-n:]
            acts = actuals[-n:]

            # Compute IC (Pearson correlation)
            ic = _correlation(sigs, acts)

            # Convert IC to weight: positive IC → more weight, negative → zero
            weights[name] = max(ic, 0.0)  # only use strategies with positive IC

        return weights


# ═══════════════════════════════════════════════════════════════════════════════
# Utilities
# ═══════════════════════════════════════════════════════════════════════════════

def _correlation(x, y):
    n = len(x)
    if n < 2:
        return 0.0
    mx = sum(x) / n
    my = sum(y) / n
    cov = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) / n
    sx = math.sqrt(sum((xi - mx) ** 2 for xi in x) / n)
    sy = math.sqrt(sum((yi - my) ** 2 for yi in y) / n)
    if sx < 1e-12 or sy < 1e-12:
        return 0.0
    return cov / (sx * sy)


# ═══════════════════════════════════════════════════════════════════════════════
# RUNNER: Walk-forward test
# ═══════════════════════════════════════════════════════════════════════════════

def run():
    strategies = [
        ("MTF_Trend_v2", MTFTrendV2()),
        ("Extreme_MR", ExtremeMeanReversion()),
        ("Vol_Breakout", VolBreakout()),
        ("RSI_Divergence", RSIDivergence()),
        ("VWAP_Vol_Rev", VWAPVolumeReversion()),
        ("Hourly_Momentum", HourlyMomentum()),
        ("Dual_ZScore", DualZScore()),
        ("Ret_Autocorr", ReturnAutocorrelation()),
        ("PW_Ensemble", PerformanceWeightedEnsemble()),
    ]

    periods = [
        ("FULL (Jul-Dec)", "2025-07-01", "2026-01-01"),
    ]

    for subject in ["BTCUSDT"]:
        for period_name, start, end in periods:
            print(f"\n{'='*100}")
            print(f"  {subject} | {period_name} | {start} → {end}")
            print(f"{'='*100}")

            results = {}
            for name, model_template in strategies:
                # Fresh model instance for each run
                model = type(model_template)() if not isinstance(model_template, PerformanceWeightedEnsemble) else PerformanceWeightedEnsemble()
                t0 = time.time()
                try:
                    runner = BacktestRunner(model=model)
                    result = runner.run(
                        subject=subject, start=start, end=end,
                        window_size=120, prediction_interval_seconds=60,
                        horizon_seconds=300,
                    )
                    elapsed = time.time() - t0
                    results[name] = result
                    m = result.metrics
                    print(f"  ✓ {name:22s}  mean={m.get('mean_return',0):+.10f}  "
                          f"hit={m.get('hit_rate',0):.3f}  "
                          f"sortino={m.get('sortino_ratio',0):+.4f}  "
                          f"ic={m.get('ic',0):+.4f}  "
                          f"dd={m.get('max_drawdown',0):+.4f}  ({elapsed:.1f}s)")
                except Exception as e:
                    print(f"  ✗ {name:22s}  ERROR: {e}")

            # Summary
            if results:
                print(f"\n  Leaderboard by IC:")
                sorted_by_ic = sorted(results, key=lambda n: results[n].metrics.get("ic", 0), reverse=True)
                for rank, name in enumerate(sorted_by_ic, 1):
                    m = results[name].metrics
                    print(f"    {rank:2d}. {name:22s}  IC={m.get('ic',0):+.4f}  "
                          f"Sortino={m.get('sortino_ratio',0):+.4f}  "
                          f"Hit={m.get('hit_rate',0):.3f}  "
                          f"DD={m.get('max_drawdown',0):+.4f}")

    # Save final results
    os.makedirs("research/output", exist_ok=True)
    print(f"\n{'='*100}")
    print("  DONE")
    print(f"{'='*100}")


if __name__ == "__main__":
    run()
