"""CPZ Risk Analytics — comprehensive quantitative risk computation.

Pure math module: data in, results out. No DB access, no API calls.
numpy/scipy for proper statistical computations with pure-Python fallback.
Risk-free rate injected at runtime (from FRED via CPZClient).

Usage in strategies:
    from cpz import CPZClient
    client = CPZClient()

    snapshot = client.risk.compute(daily_returns, spy_returns, weights)
    mc = client.risk.monte_carlo(daily_returns, num_simulations=10000)
    sizing = client.risk.position_size(portfolio_value=100000, daily_returns=rets)
    inference = client.risk.sharpe_inference(daily_returns)
    tail = client.risk.tail_risk(daily_returns)
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple

from .models import (
    DrawdownAnalysis,
    FactorExposure,
    MonteCarloResult,
    PositionSizeResult,
    RiskSnapshot,
    RollingMetrics,
    SharpeInference,
    StrategyRisk,
    StressScenario,
    TailRiskMetrics,
)

try:
    import numpy as np
    from scipy import stats as sp_stats

    HAS_SCIPY = True
except ImportError:
    HAS_SCIPY = False

TRADING_DAYS_PER_YEAR = 252
ANNUALIZATION_FACTOR = math.sqrt(TRADING_DAYS_PER_YEAR)
TRADING_DAYS_PER_MONTH = 21

HISTORICAL_SCENARIOS: List[StressScenario] = [
    StressScenario(name="COVID-19 Crisis", equity_shock=-0.34, bond_shock=0.05, commodity_shock=-0.20, crypto_shock=-0.50, period="Feb-Jul 2020", severity="extreme"),
    StressScenario(name="2008 Financial Crisis", equity_shock=-0.57, bond_shock=0.15, commodity_shock=-0.35, period="Sep 2007-Mar 2009", severity="extreme"),
    StressScenario(name="Dot-com Bubble", equity_shock=-0.49, bond_shock=0.10, commodity_shock=-0.10, period="Mar 2000-Oct 2002", severity="high"),
    StressScenario(name="Geopolitical Tensions 2022", equity_shock=-0.24, bond_shock=-0.10, commodity_shock=0.25, crypto_shock=-0.55, period="Feb-Jul 2022", severity="medium"),
    StressScenario(name="Black Monday 1987", equity_shock=-0.22, bond_shock=0.04, period="Oct 1987", severity="extreme"),
    StressScenario(name="Euro Debt Crisis", equity_shock=-0.20, bond_shock=0.08, period="2011-2012", severity="high"),
    StressScenario(name="Flash Crash 2010", equity_shock=-0.09, period="May 2010", severity="medium"),
]

# Asset class keywords for stress test routing
_CRYPTO_SYMBOLS = frozenset(["BTC", "ETH", "DOGE", "SOL", "AVAX", "MATIC", "ADA", "XRP", "DOT", "LINK", "SHIB", "UNI", "AAVE"])
_BOND_SYMBOLS = frozenset(["TLT", "IEF", "SHY", "AGG", "BND", "LQD", "HYG", "TIP", "GOVT", "VCSH", "VCLT"])
_COMMODITY_SYMBOLS = frozenset(["GLD", "SLV", "USO", "UNG", "DBA", "DBC", "PDBC", "GSG", "CPER"])


@dataclass
class ScoringConfig:
    """Configurable scoring parameters — no hardcoded thresholds."""

    portfolio_weights: Dict[str, float] = field(default_factory=lambda: {
        "var": 0.20, "sharpe": 0.15, "drawdown": 0.20,
        "volatility": 0.15, "beta": 0.10, "correlation": 0.10, "concentration": 0.10,
    })


class RiskAnalytics:
    """Stateless quantitative risk analytics.

    All methods are pure: data in, results out. No DB access, no API calls.

    Categories:
        Core:    compute(), sharpe(), sortino(), beta(), alpha(), volatility()
        VaR:     parametric_var(), historical_var(), monte_carlo_var()
        DD:      max_drawdown(), drawdown_analysis()
        Sharpe:  sharpe_inference() — Lopez de Prado (2012, 2018) framework
        Rolling: rolling_metrics()
        Tail:    tail_risk() — skewness, kurtosis, Cornish-Fisher VaR
        Sizing:  position_size() — Kelly, volatility-targeting
        Stress:  stress_test(), stress_test_all()
        Factor:  factor_exposure() — OLS decomposition
        Corr:    correlation_matrix(), correlation_risk()
    """

    def __init__(
        self,
        risk_free_rate: float = 0.0,
        scoring_config: Optional[ScoringConfig] = None,
    ) -> None:
        self.risk_free_rate = risk_free_rate
        self._rf_daily = risk_free_rate / TRADING_DAYS_PER_YEAR
        self.scoring = scoring_config or ScoringConfig()

    def set_risk_free_rate(self, annual_rate: float) -> None:
        self.risk_free_rate = annual_rate
        self._rf_daily = annual_rate / TRADING_DAYS_PER_YEAR

    def _z_score(self, confidence: float) -> float:
        """Compute z-score from confidence level using scipy, never hardcoded."""
        if HAS_SCIPY:
            return float(sp_stats.norm.ppf(confidence))
        # Fallback: precomputed from standard normal CDF (mathematical constants)
        _z_table = {0.90: 1.2816, 0.95: 1.6449, 0.99: 2.3263, 0.995: 2.5758, 0.999: 3.0902}
        return _z_table.get(round(confidence, 3), 1.6449)

    # ═══════════════════════════════════════════════════════════════════
    #  CORE METRICS
    # ═══════════════════════════════════════════════════════════════════

    def compute(
        self,
        daily_returns: List[float],
        benchmark_returns: List[float],
        position_weights: Dict[str, float],
        strategy_returns: Optional[Dict[str, List[float]]] = None,
        strategy_metadata: Optional[Dict[str, Dict]] = None,
        total_exposure: float = 0.0,
        long_exposure: float = 0.0,
        short_exposure: float = 0.0,
    ) -> RiskSnapshot:
        if not daily_returns or len(daily_returns) < 2:
            return RiskSnapshot(
                risk_free_rate=self.risk_free_rate,
                position_weights=position_weights,
                total_exposure=total_exposure,
                long_exposure=long_exposure,
                short_exposure=short_exposure,
            )

        r = _to_array(daily_returns)
        b = _to_array(benchmark_returns) if benchmark_returns else _to_array([0.0] * len(r))
        n = min(len(r), len(b))
        r, b = r[-n:], b[-n:]

        vol_daily = _std(r)
        vol_ann = vol_daily * ANNUALIZATION_FACTOR
        avg_ret = _mean(r)
        sharpe = self.sharpe(daily_returns)
        sortino = self.sortino(daily_returns)
        md = _max_drawdown(r)
        beta, alpha = _beta_alpha(r, b, self._rf_daily)

        calmar = (avg_ret * TRADING_DAYS_PER_YEAR) / md if md > 0 else 0.0

        excess = r - b if HAS_SCIPY else [r[i] - b[i] for i in range(n)]
        te = _std(excess)
        ir = (_mean(excess) / te * ANNUALIZATION_FACTOR) if te > 0 else 0.0

        treynor = ((avg_ret - self._rf_daily) * TRADING_DAYS_PER_YEAR / beta) if abs(beta) > 1e-6 else 0.0

        z95 = self._z_score(0.95)
        z99 = self._z_score(0.99)
        var95 = z95 * vol_daily * 100 if total_exposure > 0 else 0.0
        var99 = z99 * vol_daily * 100 if total_exposure > 0 else 0.0

        hhi = sum(w ** 2 for w in position_weights.values())

        strat_risks: List[StrategyRisk] = []
        corr_matrix: Dict[str, Dict[str, float]] = {}
        corr_risk = 0.0

        if strategy_returns:
            strat_risks = self._compute_strategy_risks(strategy_returns, strategy_metadata or {})
            corr_matrix, corr_risk = self._compute_correlation_matrix(strategy_returns)

        risk_score = self._composite_risk_score(var95, sharpe, md, vol_ann, beta, corr_risk, hhi)

        return RiskSnapshot(
            risk_free_rate=self.risk_free_rate,
            portfolio_var_95=round(var95, 2),
            portfolio_var_99=round(var99, 2),
            sharpe_ratio=round(sharpe, 2),
            sortino_ratio=round(sortino, 2),
            calmar_ratio=round(calmar, 2),
            max_drawdown=round(md * 100, 1),
            volatility=round(vol_ann * 100, 1),
            beta=round(beta, 2),
            alpha=round(alpha, 3),
            information_ratio=round(ir, 2),
            treynor_ratio=round(treynor, 3),
            correlation_risk=round(corr_risk, 1),
            concentration_hhi=round(hhi, 4),
            total_exposure=round(total_exposure, 2),
            long_exposure=round(long_exposure, 2),
            short_exposure=round(short_exposure, 2),
            risk_score=round(risk_score),
            position_weights=position_weights,
            correlation_matrix=corr_matrix,
            strategy_risks=strat_risks,
        )

    def sharpe(self, daily_returns: List[float]) -> float:
        r = _to_array(daily_returns)
        vol = _std(r)
        return float((_mean(r) - self._rf_daily) / vol * ANNUALIZATION_FACTOR) if vol > 0 else 0.0

    def sortino(self, daily_returns: List[float]) -> float:
        r = _to_array(daily_returns)
        dsd = _downside_dev(r, self._rf_daily)
        return float((_mean(r) - self._rf_daily) / dsd * ANNUALIZATION_FACTOR) if dsd > 0 else 0.0

    def beta(self, daily_returns: List[float], benchmark_returns: List[float]) -> float:
        b, _ = _beta_alpha(_to_array(daily_returns), _to_array(benchmark_returns), self._rf_daily)
        return round(b, 4)

    def alpha(self, daily_returns: List[float], benchmark_returns: List[float]) -> float:
        _, a = _beta_alpha(_to_array(daily_returns), _to_array(benchmark_returns), self._rf_daily)
        return round(a, 4)

    def volatility(self, daily_returns: List[float]) -> float:
        return round(_std(_to_array(daily_returns)) * ANNUALIZATION_FACTOR * 100, 2)

    def max_drawdown(self, daily_returns: List[float]) -> float:
        return round(_max_drawdown(_to_array(daily_returns)) * 100, 2)

    # ═══════════════════════════════════════════════════════════════════
    #  VALUE AT RISK
    # ═══════════════════════════════════════════════════════════════════

    def parametric_var(self, daily_returns: List[float], confidence: float = 0.95) -> float:
        return round(self._z_score(confidence) * _std(_to_array(daily_returns)) * 100, 2)

    def historical_var(self, daily_returns: List[float], confidence: float = 0.95) -> float:
        if HAS_SCIPY:
            r = np.array(daily_returns, dtype=np.float64)
            return round(-float(np.percentile(r, (1 - confidence) * 100)) * 100, 2)
        sorted_r = sorted(daily_returns)
        idx = int(len(sorted_r) * (1 - confidence))
        return round(-sorted_r[max(0, idx)] * 100, 2)

    def monte_carlo_var(
        self, daily_returns: List[float], num_simulations: int = 10000,
        horizon_days: int = 1, seed: Optional[int] = None,
    ) -> MonteCarloResult:
        if not HAS_SCIPY:
            raise ImportError("Monte Carlo requires numpy/scipy: pip install cpz[risk]")

        rng = np.random.default_rng(seed)
        r = np.array(daily_returns, dtype=np.float64)
        mu, sigma = float(np.mean(r)), float(np.std(r, ddof=1))

        simulated = rng.normal(mu, sigma, (num_simulations, horizon_days))
        terminal = np.prod(1 + simulated, axis=1) - 1

        var_95 = float(np.percentile(terminal, 5))
        var_99 = float(np.percentile(terminal, 1))

        return MonteCarloResult(
            num_simulations=num_simulations, horizon_days=horizon_days,
            var_95=round(var_95 * 100, 2), var_99=round(var_99 * 100, 2),
            expected_shortfall_95=round(float(np.mean(terminal[terminal <= var_95])) * 100, 2),
            expected_shortfall_99=round(float(np.mean(terminal[terminal <= var_99])) * 100, 2),
            mean_return=round(float(np.mean(terminal)) * 100, 2),
            median_return=round(float(np.median(terminal)) * 100, 2),
            worst_case=round(float(np.min(terminal)) * 100, 2),
            best_case=round(float(np.max(terminal)) * 100, 2),
            percentiles={
                str(p): round(float(np.percentile(terminal, p)) * 100, 2)
                for p in [1, 5, 10, 25, 50, 75, 90, 95, 99]
            },
        )

    # ═══════════════════════════════════════════════════════════════════
    #  SHARPE INFERENCE — Lopez de Prado (2012, 2018) framework
    # ═══════════════════════════════════════════════════════════════════

    def sharpe_inference(
        self, daily_returns: List[float],
        benchmark_sharpe: float = 0.0, num_trials: int = 1,
    ) -> SharpeInference:
        """Full statistical inference on the Sharpe ratio.

        Implements all 5 corrections from Lopez de Prado:
        1. Non-normality: SE adjusted for skewness/kurtosis (Bailey & LdP 2012, Eq. 4)
        2. Serial correlation: Lo (2002) autocorrelation adjustment
        3. Probabilistic Sharpe Ratio (PSR): P(true SR > benchmark)
        4. Deflated Sharpe Ratio (DSR): multiple-testing correction
        5. Minimum Track Record Length (MinTRL)
        """
        r = _to_array(daily_returns)
        n = len(r)
        if n < 10:
            return SharpeInference(num_observations=n, num_trials=num_trials)

        sr_daily = float(_mean(r) / _std(r)) if _std(r) > 0 else 0.0
        sr_ann = sr_daily * ANNUALIZATION_FACTOR

        # Distribution shape
        if HAS_SCIPY:
            skew = float(sp_stats.skew(r))
            kurt = float(sp_stats.kurtosis(r))
        else:
            m, s = _mean(r), _std(r)
            skew = float(sum((x - m) ** 3 for x in r) / (n * s ** 3)) if s > 0 else 0.0
            kurt = float(sum((x - m) ** 4 for x in r) / (n * s ** 4) - 3) if s > 0 else 0.0

        # 1. SE with non-normality correction (Bailey & LdP 2012, Eq. 4)
        se_iid = math.sqrt(max(0, 1 + 0.5 * sr_daily ** 2) / max(n - 1, 1))
        se_non_normal = math.sqrt(
            max(0, 1 - skew * sr_daily + ((kurt - 1) / 4) * sr_daily ** 2) / max(n - 1, 1)
        )

        # 2. Lo (2002) autocorrelation adjustment
        rho1 = _autocorrelation(r)
        rho1_clamped = max(-0.5, min(0.5, rho1))
        eta_sq = 1.0
        for k in range(1, min(10, TRADING_DAYS_PER_YEAR)):
            eta_sq += 2 * (1 - k / TRADING_DAYS_PER_YEAR) * (rho1_clamped ** k)
        eta_sq = max(0.25, min(4.0, eta_sq))
        eta = math.sqrt(eta_sq)
        lo_adjusted_sr = sr_ann / eta

        se_ann = se_non_normal * ANNUALIZATION_FACTOR
        t_stat = sr_ann / se_ann if se_ann > 0 else 0.0

        if HAS_SCIPY:
            p_value = float(2 * (1 - sp_stats.t.cdf(abs(t_stat), df=n - 1)))
        else:
            p_value = 0.5

        ci_95 = [round(sr_ann - 1.96 * se_ann, 3), round(sr_ann + 1.96 * se_ann, 3)]
        ci_99 = [round(sr_ann - 2.576 * se_ann, 3), round(sr_ann + 2.576 * se_ann, 3)]

        # 3. Probabilistic Sharpe Ratio
        psr_z = (sr_ann - benchmark_sharpe) / se_ann if se_ann > 0 else 0.0
        psr = float(sp_stats.norm.cdf(psr_z)) if HAS_SCIPY else 0.5 + 0.5 * math.erf(psr_z / math.sqrt(2))

        # 4. Deflated Sharpe Ratio
        gamma_em = 0.5772156649
        if num_trials > 1:
            ln_n = math.log(num_trials)
            expected_max_sr = (
                math.sqrt(2 * ln_n) * se_ann * (1 - gamma_em / (2 * ln_n))
                + gamma_em * se_ann / math.sqrt(2 * ln_n)
            )
        else:
            expected_max_sr = 0.0

        dsr_z = (sr_ann - expected_max_sr) / se_ann if se_ann > 0 else 0.0
        dsr = float(sp_stats.norm.cdf(dsr_z)) if HAS_SCIPY else 0.5 + 0.5 * math.erf(dsr_z / math.sqrt(2))

        # 5. Minimum Track Record Length
        if abs(sr_ann) > 0.01:
            min_trl_months = n * (1.96 * se_ann / abs(sr_ann)) ** 2 / TRADING_DAYS_PER_MONTH
        else:
            min_trl_months = float("inf")

        return SharpeInference(
            observed_sharpe=round(sr_daily, 4), annualized_sharpe=round(sr_ann, 3),
            t_statistic=round(t_stat, 3), p_value=round(p_value, 4),
            confidence_interval_95=ci_95, confidence_interval_99=ci_99,
            num_observations=n, is_significant_95=p_value < 0.05, is_significant_99=p_value < 0.01,
            sharpe_se=round(se_iid * ANNUALIZATION_FACTOR, 4),
            sharpe_se_non_normal=round(se_ann, 4),
            autocorrelation_adjustment=round(eta, 4), lo_adjusted_sharpe=round(lo_adjusted_sr, 3),
            psr=round(psr, 4), psr_benchmark=benchmark_sharpe,
            deflated_sharpe=round(dsr, 4), num_trials=num_trials,
            expected_max_sharpe_under_null=round(expected_max_sr, 3),
            min_track_record_months=round(min_trl_months, 1) if min_trl_months < 1000 else 999.0,
            skewness=round(skew, 3), kurtosis=round(kurt + 3, 3),
        )

    # ═══════════════════════════════════════════════════════════════════
    #  DRAWDOWN ANALYSIS
    # ═══════════════════════════════════════════════════════════════════

    def drawdown_analysis(self, daily_returns: List[float]) -> DrawdownAnalysis:
        if len(daily_returns) < 2:
            return DrawdownAnalysis()

        dd_series = _drawdown_series(daily_returns)
        dd_list = list(dd_series) if HAS_SCIPY else dd_series

        periods: List[Dict[str, object]] = []
        in_dd, start_idx, peak_dd = False, 0, 0.0
        threshold = 1e-4

        for i, d in enumerate(dd_list):
            if d > threshold and not in_dd:
                in_dd, start_idx, peak_dd = True, i, d
            elif in_dd:
                peak_dd = max(peak_dd, d)
                if d < threshold:
                    periods.append({"start_day": start_idx, "end_day": i, "duration": i - start_idx, "depth_pct": round(peak_dd * 100, 2)})
                    in_dd = False

        if in_dd:
            periods.append({"start_day": start_idx, "end_day": len(dd_list) - 1, "duration": len(dd_list) - 1 - start_idx, "depth_pct": round(peak_dd * 100, 2), "ongoing": True})

        durations = [p["duration"] for p in periods if isinstance(p.get("duration"), int)]
        underwater = sum(1 for d in dd_list if d > threshold) / max(len(dd_list), 1) * 100

        return DrawdownAnalysis(
            current_drawdown=round(float(dd_list[-1]) * 100, 2) if dd_list else 0.0,
            max_drawdown=round(float(max(dd_list)) * 100, 2) if dd_list else 0.0,
            max_drawdown_duration_days=max(durations) if durations else 0,
            avg_drawdown=round(_safe_mean(dd_list) * 100, 2),
            avg_recovery_days=round(_safe_mean(durations), 1),
            num_drawdowns=len(periods),
            drawdown_periods=periods[:20],
            underwater_pct=round(underwater, 1),
        )

    # ═══════════════════════════════════════════════════════════════════
    #  ROLLING METRICS
    # ═══════════════════════════════════════════════════════════════════

    def rolling_metrics(
        self, daily_returns: List[float], benchmark_returns: Optional[List[float]] = None, window: int = 20,
    ) -> RollingMetrics:
        if len(daily_returns) < window + 1:
            return RollingMetrics(window=window)

        r = _to_array(daily_returns)
        b = _to_array(benchmark_returns) if benchmark_returns else None
        n = len(r)
        z95 = self._z_score(0.95)

        sharpes, vols, mds, betas, vars95 = [], [], [], [], []
        dates = [str(i) for i in range(window, n)]

        for i in range(window, n):
            w = r[i - window: i] if HAS_SCIPY else list(r)[i - window: i]
            vol = _std(w)
            sharpes.append(round((_mean(w) - self._rf_daily) / vol * ANNUALIZATION_FACTOR if vol > 0 else 0, 3))
            vols.append(round(vol * ANNUALIZATION_FACTOR * 100, 2))
            mds.append(round(_max_drawdown(w) * 100, 2))
            vars95.append(round(z95 * vol * 100, 2))

            if b is not None and len(b) >= i:
                bw = b[i - window: i] if HAS_SCIPY else list(b)[i - window: i]
                beta_val, _ = _beta_alpha(w, bw, self._rf_daily)
                betas.append(round(beta_val, 3))
            else:
                betas.append(0.0)

        return RollingMetrics(window=window, dates=dates, sharpe=sharpes, volatility=vols, max_drawdown=mds, beta=betas, var_95=vars95)

    # ═══════════════════════════════════════════════════════════════════
    #  TAIL RISK
    # ═══════════════════════════════════════════════════════════════════

    def tail_risk(self, daily_returns: List[float]) -> TailRiskMetrics:
        r = _to_array(daily_returns)
        n = len(r)
        if n < 10:
            return TailRiskMetrics()

        if HAS_SCIPY:
            skew = float(sp_stats.skew(r))
            kurt = float(sp_stats.kurtosis(r))
            jb_stat, jb_p = sp_stats.jarque_bera(r)
        else:
            m, s = _mean(r), _std(r)
            skew = float(sum((x - m) ** 3 for x in r) / (n * s ** 3)) if s > 0 else 0.0
            kurt = float(sum((x - m) ** 4 for x in r) / (n * s ** 4) - 3) if s > 0 else 0.0
            jb_stat, jb_p = 0.0, 1.0

        vol = _std(r)
        z95, z99 = self._z_score(0.95), self._z_score(0.99)

        # Cornish-Fisher expansion for non-normal VaR
        cf_z95 = z95 + (z95 ** 2 - 1) * skew / 6 + (z95 ** 3 - 3 * z95) * kurt / 24 - (2 * z95 ** 3 - 5 * z95) * skew ** 2 / 36
        cf_z99 = z99 + (z99 ** 2 - 1) * skew / 6 + (z99 ** 3 - 3 * z99) * kurt / 24 - (2 * z99 ** 3 - 5 * z99) * skew ** 2 / 36

        r_list = list(r) if HAS_SCIPY else r
        return TailRiskMetrics(
            skewness=round(skew, 3), kurtosis=round(kurt + 3, 3), excess_kurtosis=round(kurt, 3),
            jarque_bera_stat=round(float(jb_stat), 3), jarque_bera_p=round(float(jb_p), 4),
            is_normal=float(jb_p) > 0.05,
            historical_var_95=self.historical_var(daily_returns, 0.95),
            historical_var_99=self.historical_var(daily_returns, 0.99),
            cornish_fisher_var_95=round(cf_z95 * vol * 100, 2),
            cornish_fisher_var_99=round(cf_z99 * vol * 100, 2),
            max_daily_loss=round(min(r_list) * 100, 2) if r_list else 0.0,
            max_daily_gain=round(max(r_list) * 100, 2) if r_list else 0.0,
        )

    # ═══════════════════════════════════════════════════════════════════
    #  POSITION SIZING
    # ═══════════════════════════════════════════════════════════════════

    def position_size(
        self, portfolio_value: float, daily_returns: List[float],
        win_rate: Optional[float] = None, avg_win: Optional[float] = None,
        avg_loss: Optional[float] = None, risk_per_trade_pct: float = 1.0,
        target_volatility_pct: float = 15.0,
    ) -> PositionSizeResult:
        r = _to_array(daily_returns)
        vol_ann = _std(r) * ANNUALIZATION_FACTOR

        r_list = list(r) if HAS_SCIPY else r
        wins = [x for x in r_list if x > 0]
        losses = [x for x in r_list if x < 0]

        wr = win_rate if win_rate is not None else (len(wins) / max(len(r_list), 1))
        aw = avg_win if avg_win is not None else (abs(_safe_mean(wins)) if wins else 0.0)
        al = avg_loss if avg_loss is not None else (abs(_safe_mean(losses)) if losses else 0.0)

        # Kelly criterion: f* = W - (1-W)/R where W=win_rate, R=avg_win/avg_loss
        win_loss_ratio = aw / al if al > 0 else 1.0
        kelly = max(0.0, min(1.0, wr - (1 - wr) / win_loss_ratio)) if win_loss_ratio > 0 else 0.0

        # Volatility-targeted fraction: scale to target vol
        vol_pct = vol_ann * 100
        vol_fraction = target_volatility_pct / vol_pct if vol_pct > 0 else 1.0
        vol_fraction = min(vol_fraction, 2.0)

        # Use the more conservative of half-Kelly and vol-target
        conservative_fraction = min(kelly / 2, vol_fraction)
        recommended = portfolio_value * max(0, conservative_fraction)

        return PositionSizeResult(
            method="min(half_kelly, vol_target)",
            recommended_size=round(recommended, 2),
            recommended_pct=round(recommended / portfolio_value * 100, 2) if portfolio_value > 0 else 0,
            max_loss=round(portfolio_value * risk_per_trade_pct / 100, 2),
            risk_per_trade=risk_per_trade_pct,
            kelly_fraction=round(kelly, 4),
            half_kelly=round(kelly / 2, 4),
        )

    # ═══════════════════════════════════════════════════════════════════
    #  FACTOR EXPOSURE
    # ═══════════════════════════════════════════════════════════════════

    def factor_exposure(self, daily_returns: List[float], factor_returns: Dict[str, List[float]]) -> FactorExposure:
        if not HAS_SCIPY:
            raise ImportError("Factor analysis requires numpy/scipy: pip install cpz[risk]")

        y = np.array(daily_returns, dtype=np.float64)
        factor_names = list(factor_returns.keys())
        X = np.column_stack([np.array(factor_returns[f], dtype=np.float64)[:len(y)] for f in factor_names])
        X = np.column_stack([np.ones(len(y)), X])

        n = min(len(y), X.shape[0])
        y, X = y[:n], X[:n]

        try:
            betas = np.linalg.lstsq(X, y, rcond=None)[0]
        except np.linalg.LinAlgError:
            return FactorExposure()

        residuals = y - X @ betas
        ss_res = float(np.sum(residuals ** 2))
        ss_tot = float(np.sum((y - np.mean(y)) ** 2))
        r_squared = max(0.0, 1 - ss_res / ss_tot) if ss_tot > 0 else 0.0

        residual_vol = float(np.std(residuals, ddof=1)) * ANNUALIZATION_FACTOR

        factors = {name: round(float(betas[i + 1]), 4) for i, name in enumerate(factor_names)}
        factors["alpha_daily"] = round(float(betas[0]), 6)

        return FactorExposure(
            factors=factors, r_squared=round(r_squared, 4),
            residual_vol=round(residual_vol * 100, 2),
            systematic_risk_pct=round(r_squared * 100, 1),
            idiosyncratic_risk_pct=round((1 - r_squared) * 100, 1),
        )

    # ═══════════════════════════════════════════════════════════════════
    #  STRESS TESTING
    # ═══════════════════════════════════════════════════════════════════

    def stress_test(self, position_weights: Dict[str, float], scenario: StressScenario) -> float:
        """Apply scenario shocks to positions by asset class. Uses all shock types."""
        impact = 0.0
        for sym, weight in position_weights.items():
            upper = sym.upper().replace("/", "").replace("-", "")
            if any(c in upper for c in _CRYPTO_SYMBOLS):
                shock = scenario.crypto_shock or scenario.equity_shock
            elif upper in _BOND_SYMBOLS:
                shock = scenario.bond_shock or 0.0
            elif upper in _COMMODITY_SYMBOLS:
                shock = scenario.commodity_shock or 0.0
            else:
                shock = scenario.equity_shock
            impact += abs(weight) * shock
        return round(impact * 100, 2)

    def stress_test_all(self, position_weights: Dict[str, float]) -> Dict[str, float]:
        return {s.name: self.stress_test(position_weights, s) for s in HISTORICAL_SCENARIOS}

    # ═══════════════════════════════════════════════════════════════════
    #  CORRELATION
    # ═══════════════════════════════════════════════════════════════════

    def correlation_matrix(self, return_series: Dict[str, List[float]]) -> Dict[str, Dict[str, float]]:
        matrix, _ = self._compute_correlation_matrix(return_series)
        return matrix

    def correlation_risk(self, return_series: Dict[str, List[float]]) -> float:
        _, risk = self._compute_correlation_matrix(return_series)
        return round(risk, 1)

    # ═══════════════════════════════════════════════════════════════════
    #  INTERNALS
    # ═══════════════════════════════════════════════════════════════════

    def _compute_strategy_risks(self, strategy_returns: Dict[str, List[float]], strategy_metadata: Dict[str, Dict]) -> List[StrategyRisk]:
        results: List[StrategyRisk] = []
        for sid, rets in strategy_returns.items():
            if len(rets) < 2:
                continue
            r = _to_array(rets)
            vol = float(_std(r) * ANNUALIZATION_FACTOR)
            avg = float(_mean(r))
            s_std = _std(r)
            s_sharpe = (avg - self._rf_daily) / s_std * ANNUALIZATION_FACTOR if s_std > 0 else 0.0
            dsd = _downside_dev(r, self._rf_daily)
            s_sortino = (avg - self._rf_daily) / dsd * ANNUALIZATION_FACTOR if dsd > 0 else 0.0
            md = _max_drawdown(r)
            perf = avg * TRADING_DAYS_PER_YEAR * 100
            meta = strategy_metadata.get(sid, {})
            status = meta.get("status", "unknown")
            results.append(StrategyRisk(
                id=sid, name=meta.get("name", sid[:8]), status=status,
                sharpe_ratio=round(s_sharpe, 2), sortino_ratio=round(s_sortino, 2),
                max_drawdown=round(md * 100, 1), volatility=round(vol * 100, 1),
                performance=round(perf, 1),
                risk_score=_strategy_risk_score(s_sharpe, md, vol, status),
            ))
        return results

    def _compute_correlation_matrix(self, strategy_returns: Dict[str, List[float]]) -> Tuple[Dict[str, Dict[str, float]], float]:
        ids = list(strategy_returns.keys())
        matrix: Dict[str, Dict[str, float]] = {}
        corr_sum, corr_count = 0.0, 0
        for a in ids:
            matrix[a] = {}
            for b in ids:
                if a == b:
                    matrix[a][b] = 1.0
                else:
                    c = _correlation(_to_array(strategy_returns[a]), _to_array(strategy_returns[b]))
                    matrix[a][b] = round(c, 3)
                    corr_sum += abs(c)
                    corr_count += 1
        corr_risk = (corr_sum / corr_count * 100) if corr_count > 0 else 0.0
        return matrix, corr_risk

    def _composite_risk_score(self, var95, sharpe, max_dd, vol, beta, corr_risk, hhi) -> float:
        w = self.scoring.portfolio_weights
        var_s = min(100, var95 * 10)
        sharpe_s = max(0, 100 - sharpe * 40)
        dd_s = min(100, max_dd * 400)
        vol_s = min(100, vol * 200)
        beta_s = min(100, abs(beta) * 50)
        corr_s = min(100, corr_risk)
        conc_s = min(100, hhi * 200)
        return (
            var_s * w.get("var", 0.2) + sharpe_s * w.get("sharpe", 0.15) +
            dd_s * w.get("drawdown", 0.2) + vol_s * w.get("volatility", 0.15) +
            beta_s * w.get("beta", 0.1) + corr_s * w.get("correlation", 0.1) +
            conc_s * w.get("concentration", 0.1)
        )


# ═══════════════════════════════════════════════════════════════════════
#  PURE MATH (numpy when available, stdlib fallback)
# ═══════════════════════════════════════════════════════════════════════

def _to_array(data):
    return np.array(data, dtype=np.float64) if HAS_SCIPY else list(data)

def _safe_mean(arr) -> float:
    if not arr:
        return 0.0
    return float(np.mean(arr)) if HAS_SCIPY and isinstance(arr, np.ndarray) else sum(arr) / len(arr)

def _mean(arr) -> float:
    if HAS_SCIPY:
        return float(np.mean(arr)) if len(arr) > 0 else 0.0
    return sum(arr) / len(arr) if len(arr) > 0 else 0.0

def _std(arr) -> float:
    if HAS_SCIPY:
        return float(np.std(arr, ddof=1)) if len(arr) > 1 else 0.0
    if len(arr) < 2:
        return 0.0
    m = _mean(arr)
    return math.sqrt(sum((x - m) ** 2 for x in arr) / (len(arr) - 1))

def _downside_dev(arr, threshold=0.0) -> float:
    if HAS_SCIPY:
        below = arr[arr < threshold]
        return float(np.sqrt(np.mean((below - threshold) ** 2))) if len(below) > 0 else 0.0
    below = [(x - threshold) ** 2 for x in arr if x < threshold]
    return math.sqrt(sum(below) / len(arr)) if below else 0.0

def _max_drawdown(returns) -> float:
    if HAS_SCIPY:
        if len(returns) == 0:
            return 0.0
        curve = np.cumprod(1 + returns)
        peak = np.maximum.accumulate(curve)
        dd = (peak - curve) / peak
        return float(np.max(dd))
    if not returns:
        return 0.0
    curve = [1.0]
    for r in returns:
        curve.append(curve[-1] * (1 + r))
    pk, mdd = curve[0], 0.0
    for v in curve:
        if v > pk:
            pk = v
        dd = (pk - v) / pk
        if dd > mdd:
            mdd = dd
    return mdd

def _drawdown_series(daily_returns):
    if HAS_SCIPY:
        r = np.array(daily_returns, dtype=np.float64)
        curve = np.cumprod(1 + r)
        peak = np.maximum.accumulate(curve)
        return (peak - curve) / peak
    curve = [1.0]
    for ret in daily_returns:
        curve.append(curve[-1] * (1 + ret))
    curve = curve[1:]
    pk = curve[0]
    dd = []
    for v in curve:
        if v > pk:
            pk = v
        dd.append((pk - v) / pk)
    return dd

def _correlation(a, b) -> float:
    if HAS_SCIPY:
        n = min(len(a), len(b))
        if n < 2:
            return 0.0
        c = np.corrcoef(a[:n], b[:n])
        val = float(c[0, 1])
        return val if not math.isnan(val) else 0.0
    n = min(len(a), len(b))
    if n < 2:
        return 0.0
    ma, mb, sa, sb = _mean(a[:n]), _mean(b[:n]), _std(a[:n]), _std(b[:n])
    if sa == 0 or sb == 0:
        return 0.0
    return sum((a[i] - ma) * (b[i] - mb) for i in range(n)) / ((n - 1) * sa * sb)

def _autocorrelation(arr) -> float:
    if HAS_SCIPY:
        r = np.array(arr, dtype=np.float64) if not isinstance(arr, np.ndarray) else arr
        if len(r) < 3:
            return 0.0
        demeaned = r - np.mean(r)
        autocov = float(np.sum(demeaned[1:] * demeaned[:-1])) / (len(r) - 1)
        var_r = float(np.var(r, ddof=1))
        return autocov / var_r if var_r > 0 else 0.0
    if len(arr) < 3:
        return 0.0
    m = _mean(arr)
    autocov = sum((arr[i] - m) * (arr[i - 1] - m) for i in range(1, len(arr))) / (len(arr) - 1)
    var_r = _std(arr) ** 2
    return autocov / var_r if var_r > 0 else 0.0

def _beta_alpha(portfolio, benchmark, rf_daily) -> Tuple[float, float]:
    if HAS_SCIPY:
        p = np.array(portfolio, dtype=np.float64) if not isinstance(portfolio, np.ndarray) else portfolio
        b = np.array(benchmark, dtype=np.float64) if not isinstance(benchmark, np.ndarray) else benchmark
        n = min(len(p), len(b))
        if n < 2:
            return 1.0, 0.0
        p, b = p[:n], b[:n]
        var_b = float(np.var(b, ddof=1))
        if var_b == 0:
            return 1.0, 0.0
        beta = float(np.cov(p, b, ddof=1)[0, 1]) / var_b
        alpha = (float(np.mean(p)) - rf_daily - beta * (float(np.mean(b)) - rf_daily)) * TRADING_DAYS_PER_YEAR
        return beta, alpha
    n = min(len(portfolio), len(benchmark))
    if n < 2:
        return 1.0, 0.0
    mb, mp = _mean(benchmark[:n]), _mean(portfolio[:n])
    var_b = _std(benchmark[:n]) ** 2
    if var_b == 0:
        return 1.0, 0.0
    cov = sum((portfolio[i] - mp) * (benchmark[i] - mb) for i in range(n)) / (n - 1)
    beta = cov / var_b
    alpha = (mp - rf_daily - beta * (mb - rf_daily)) * TRADING_DAYS_PER_YEAR
    return beta, alpha

def _strategy_risk_score(sharpe: float, max_dd: float, vol: float, status: str) -> int:
    score = 50
    if sharpe < 0: score += 25
    elif sharpe < 0.5: score += 15
    elif sharpe < 1: score += 5
    elif sharpe > 2: score -= 15
    elif sharpe > 1: score -= 5
    if max_dd > 0.2: score += 20
    elif max_dd > 0.1: score += 10
    elif max_dd < 0.05: score -= 10
    if vol > 0.3: score += 15
    elif vol > 0.2: score += 5
    elif vol < 0.1: score -= 5
    if status == "warning": score += 15
    return min(100, max(0, score))
