"""Alpha combination: IC-weighted blending, z-score normalisation, decay-adjusted, fundamental law.

Implements the optimal signal combination from Grinold-Kahn.
"""

from __future__ import annotations

from typing import Dict, List, Optional

import numpy as np
from pydantic import BaseModel, Field

EPSILON: float = 1e-15


class FundLawResult(BaseModel):
    """Fundamental Law of Active Management analysis."""
    expected_ir: float = 0.0
    expected_alpha_bps: float = 0.0
    required_ic_for_target_ir: float = 0.0
    ic: float = 0.0
    breadth: float = 0.0
    transfer_coefficient: float = 1.0


def ic_weighted_blend(
    signals: Dict[str, Dict[str, float]],
    ic_values: Dict[str, float],
) -> Dict[str, float]:
    """Combine multiple alpha signals weighted by their IC.

    This is the Grinold-Kahn optimal combination: weight each signal
    proportionally to its information coefficient.

    Args:
        signals: ``{signal_name: {asset: signal_value}}`` — multiple signals.
        ic_values: ``{signal_name: information_coefficient}`` — IC for each signal.

    Returns:
        ``{asset: combined_signal}`` — IC-weighted combined signal.
    """
    total_ic = sum(abs(ic_values.get(name, 0.0)) for name in signals)
    if total_ic < EPSILON:
        return equal_weight_blend(signals)

    all_assets: set = set()
    for sig in signals.values():
        all_assets.update(sig.keys())

    combined: Dict[str, float] = {a: 0.0 for a in all_assets}
    for name, sig in signals.items():
        ic = ic_values.get(name, 0.0)
        weight = abs(ic) / total_ic
        for asset, val in sig.items():
            combined[asset] += weight * val

    return {a: round(v, 8) for a, v in combined.items()}


def equal_weight_blend(
    signals: Dict[str, Dict[str, float]],
) -> Dict[str, float]:
    """Simple average of standardised signals. Robust baseline.

    Each signal is z-score normalised before averaging so that
    signals with different scales contribute equally.

    Args:
        signals: ``{signal_name: {asset: signal_value}}`` — multiple signals.

    Returns:
        ``{asset: combined_signal}``.
    """
    n_signals = len(signals)
    if n_signals == 0:
        return {}

    normalised = {}
    for name, sig in signals.items():
        vals = np.array(list(sig.values()), dtype=np.float64)
        mean = np.mean(vals)
        std = np.std(vals, ddof=1)
        if std < EPSILON:
            normalised[name] = {a: 0.0 for a in sig}
        else:
            normalised[name] = {a: (v - mean) / std for a, v in sig.items()}

    all_assets: set = set()
    for sig in normalised.values():
        all_assets.update(sig.keys())

    combined: Dict[str, float] = {a: 0.0 for a in all_assets}
    for sig in normalised.values():
        for a, v in sig.items():
            combined[a] += v / n_signals

    return {a: round(v, 8) for a, v in combined.items()}


def zscore_blend(
    signals: Dict[str, Dict[str, float]],
    *,
    weights: Optional[Dict[str, float]] = None,
    winsorize_std: float = 3.0,
) -> Dict[str, float]:
    """Z-score normalise each signal, then weighted average.

    Args:
        signals: ``{signal_name: {asset: signal_value}}``.
        weights: ``{signal_name: weight}`` — custom weights (default equal).
        winsorize_std: Clip z-scores at +/- this many standard deviations
                       to limit outlier influence.

    Returns:
        ``{asset: combined_signal}``.
    """
    n_signals = len(signals)
    if n_signals == 0:
        return {}

    if weights is None:
        weights = {name: 1.0 / n_signals for name in signals}

    total_w = sum(abs(weights.get(name, 0.0)) for name in signals)
    if total_w < EPSILON:
        total_w = 1.0

    all_assets: set = set()
    for sig in signals.values():
        all_assets.update(sig.keys())

    combined: Dict[str, float] = {a: 0.0 for a in all_assets}

    for name, sig in signals.items():
        w = weights.get(name, 1.0 / n_signals) / total_w
        vals = np.array(list(sig.values()), dtype=np.float64)
        mean = np.mean(vals)
        std = np.std(vals, ddof=1)

        for a, v in sig.items():
            z = (v - mean) / std if std > EPSILON else 0.0
            z = np.clip(z, -winsorize_std, winsorize_std)
            combined[a] += w * z

    return {a: round(v, 8) for a, v in combined.items()}


def decay_adjusted_blend(
    signals: Dict[str, Dict[str, float]],
    decay_rates: Dict[str, float],
    *,
    horizon: int = 5,
) -> Dict[str, float]:
    """Weight signals by their predicted IC at the target horizon.

    Accounts for different signal decay rates — fast-decaying signals
    get less weight at longer horizons.

    Args:
        signals: ``{signal_name: {asset: signal_value}}``.
        decay_rates: ``{signal_name: half_life_in_days}`` — signal half-life.
        horizon: Target holding period in days.

    Returns:
        ``{asset: combined_signal}``.
    """
    predicted_ic: Dict[str, float] = {}
    for name in signals:
        hl = decay_rates.get(name, 20.0)
        if hl > EPSILON:
            predicted_ic[name] = np.exp(-np.log(2.0) * horizon / hl)
        else:
            predicted_ic[name] = 0.0

    return ic_weighted_blend(signals, predicted_ic)


def fundamental_law_analysis(
    ic: float,
    breadth_val: float,
    *,
    tc: float = 1.0,
    active_risk_pct: float = 5.0,
    target_ir: float = 0.5,
) -> FundLawResult:
    """IR = IC * sqrt(BR) * TC — the Grinold-Kahn Fundamental Law.

    Args:
        ic: Information Coefficient (average rank IC).
        breadth_val: Number of independent bets per period.
        tc: Transfer Coefficient (1.0 = no constraints eroding alpha).
        active_risk_pct: Active risk (tracking error) in percent.
        target_ir: Target IR for computing required IC.

    Returns:
        ``FundLawResult`` with expected IR, alpha, and required IC.
    """
    expected_ir = ic * np.sqrt(max(breadth_val, 0)) * tc
    expected_alpha = expected_ir * active_risk_pct

    required_ic = target_ir / (np.sqrt(max(breadth_val, 1)) * max(tc, EPSILON))

    return FundLawResult(
        expected_ir=round(expected_ir, 4),
        expected_alpha_bps=round(expected_alpha * 100, 2),
        required_ic_for_target_ir=round(required_ic, 6),
        ic=ic,
        breadth=breadth_val,
        transfer_coefficient=tc,
    )
