"""kelly_sizer.py — Kelly Criterion position sizing for single-trade risk allocation.

Implements the full Kelly formula:

    f* = (b × p - q) / b

where:
    p = win rate (probability of winning trade)
    q = 1 - p (probability of losing trade)
    b = odds ratio = avg_win_amount / avg_loss_amount

The raw Kelly fraction f* represents the theoretically optimal fraction of
account equity to risk on a single trade under IID binary-outcome assumptions.

Because financial markets violate the IID assumption (serial correlation, fat
tails, regime shifts) and parameters are estimated with error, this module
defaults to **Half-Kelly** (f = 0.5 × f*) with additional discount layers for
small samples, elevated volatility, and loss streaks.

Design principles:
    - Pure functions: no I/O, no state, no config reads — trivially testable.
    - Full Kelly f* is computed for audit trail, but f_final applies all discounts.
    - Negative edge (f* ≤ 0) returns f_final = 0.0.
    - All discounts are multiplicative; the caller receives the full chain in
      ``discounts_applied`` for ledger transparency.

Usage::

    from src.kelly_sizer import compute_kelly_fraction, compute_kelly_lots

    result = compute_kelly_fraction(
        win_rate=0.55,
        avg_win=160.0,
        avg_loss=80.0,
        sl_pips=40.0,
        tp_pips=80.0,
    )
    lots = compute_kelly_lots(result["kelly_final"], account_equity=10000.0)
"""

from __future__ import annotations


def compute_kelly_fraction(
    win_rate: float,
    avg_win: float | None = None,
    avg_loss: float | None = None,
    *,
    sl_pips: float = 40.0,
    tp_pips: float = 80.0,
    kelly_fraction_mult: float = 0.5,
    min_samples_for_kelly: int = 10,
    sample_size: int = 0,
    loss_streak: int = 0,
    loss_streak_discount: float = 0.15,
    vol_ratio: float = 1.0,
    max_kelly_fraction: float = 0.06,
    enable_vol_discount: bool = True,
) -> dict:
    """Compute the Kelly-optimal fraction of account equity to risk.

    Args:
        win_rate: Estimated win probability p ∈ [0.01, 0.99].
        avg_win: Mean profit per winning trade (absolute currency). If None,
            the odds ratio b is derived from structural SL/TP instead.
        avg_loss: Mean loss per losing trade (absolute value, positive). If
            None or zero, structural b is used as fallback.
        sl_pips: Stop-loss distance in pips (structural floor for loss).
        tp_pips: Take-profit distance in pips (structural ceiling for win).
        kelly_fraction_mult: Fraction of full Kelly to apply. 0.5 = Half-Kelly,
            0.25 = Quarter-Kelly, 1.0 = Full Kelly.
        min_samples_for_kelly: Minimum number of closed trades before the
            estimated p/b are considered statistically reliable. Below this
            threshold, a linear sample-size discount is applied.
        sample_size: Number of closed trades used to estimate win_rate.
        loss_streak: Current consecutive loss count. Each loss in the streak
            reduces the applied fraction by ``loss_streak_discount``.
        loss_streak_discount: Per-loss multiplicative penalty applied to the
            Kelly fraction when loss_streak > 0.
        vol_ratio: GARCH volatility regime ratio (>1.0 = elevated vol,
            <1.0 = low vol). When >1.0, applied as 1.0/vol_ratio discount.
        max_kelly_fraction: Hard ceiling on the final applied fraction,
            protecting against parameter over-estimation.
        enable_vol_discount: Whether to apply the volatility discount layer.

    Returns:
        dict with keys:
            kelly_f_star        — raw full-Kelly fraction (before discounts)
            kelly_f_applied     — after all discounts applied
            kelly_final         — clipped to [0, max_kelly_fraction]
            discounts_applied   — list of "name:value" strings for audit
            p, b, q             — calibrated parameters used in the formula
            sample_size_used    — sample_size as passed in
            insufficient_data   — True when sample_size < min_samples_for_kelly
    """
    # ── Guard: clip win_rate to avoid division-by-zero / infinity ──
    p = max(0.01, min(0.99, float(win_rate)))
    q = 1.0 - p

    # ── Calibrate odds ratio b ──
    # Prefer empirical avg_win / avg_loss from actual trade history.
    # Fall back to structural SL/TP ratio when empirical data is absent.
    if (
        avg_win is not None
        and avg_loss is not None
        and avg_win > 0
        and avg_loss > 0
    ):
        b = avg_win / avg_loss
    else:
        # Structural odds: how many units of profit per unit of risk
        b = tp_pips / max(0.1, sl_pips)

    # Clamp b to a reasonable range to prevent extreme f* values
    b = max(0.5, min(5.0, b))

    # ── Full Kelly formula ──
    # f* = (b × p - q) / b
    numerator = b * p - q
    if numerator <= 0.0:
        # Negative or zero edge — no bet
        return {
            "kelly_f_star": 0.0,
            "kelly_f_applied": 0.0,
            "kelly_final": 0.0,
            "discounts_applied": ["edge_negative:0.0"],
            "p": round(p, 4),
            "b": round(b, 4),
            "q": round(q, 4),
            "sample_size_used": sample_size,
            "insufficient_data": sample_size < min_samples_for_kelly,
        }

    f_star = numerator / b
    # Full Kelly should never exceed 1.0 in practice, but guard anyway
    f_star = min(f_star, 1.0)

    # ── Apply discount chain ──
    discounts: list[str] = []
    f = f_star

    # Layer 1: Half-Kelly (or configured fraction)
    if kelly_fraction_mult != 1.0:
        f *= kelly_fraction_mult
    discounts.append(f"half_kelly:{kelly_fraction_mult:.2f}")

    # Layer 2: Sample-size discount (linear ramp from 0 to 1.0)
    insufficient_data = sample_size < min_samples_for_kelly
    if insufficient_data and min_samples_for_kelly > 0:
        ratio = sample_size / max(1, min_samples_for_kelly)
        f *= ratio
        discounts.append(f"sample_size:{ratio:.2f}")

    # Layer 3: Volatility discount (GARCH vol_ratio)
    if enable_vol_discount and vol_ratio > 1.0:
        vol_disc = max(0.30, 1.0 / vol_ratio)
        f *= vol_disc
        discounts.append(f"volatility:{vol_disc:.2f}")

    # Layer 4: Loss-streak discount
    if loss_streak > 0 and loss_streak_discount > 0:
        streak_mult = max(0.30, 1.0 - loss_streak * loss_streak_discount)
        f *= streak_mult
        discounts.append(f"loss_streak:{streak_mult:.2f}")

    # ── Hard cap ──
    f_applied = f
    f_final = min(f, max_kelly_fraction)

    return {
        "kelly_f_star": round(f_star, 6),
        "kelly_f_applied": round(f_applied, 6),
        "kelly_final": round(f_final, 6),
        "discounts_applied": discounts,
        "p": round(p, 4),
        "b": round(b, 4),
        "q": round(q, 4),
        "sample_size_used": sample_size,
        "insufficient_data": insufficient_data,
    }


def compute_kelly_lots(
    kelly_fraction: float,
    account_equity: float,
    *,
    sl_pips: float = 40.0,
    pip_value_per_lot: float = 1.0,
    min_lot: float = 0.01,
    lot_step: float = 0.01,
) -> float:
    """Convert a Kelly fraction into MT5 lot size.

    The conversion formula:

        risk_amount = account_equity × kelly_fraction
        lots = risk_amount / (sl_pips × pip_value_per_lot)

    For XAUUSD with a standard lot (100 oz), 1 pip ≈ $10, so:
        pip_value_per_lot = 1.0  means $1 per pip per 0.01 lot step
    For a micro-lot (0.01), 1 pip ≈ $0.10.

    The caller should calibrate pip_value_per_lot to match their broker's
    convention and the lot_step they use.

    Args:
        kelly_fraction: Fraction of equity to risk (from compute_kelly_fraction).
        account_equity: Current account equity in deposit currency.
        sl_pips: Stop-loss distance in pips.
        pip_value_per_lot: Monetary value of 1 pip per 1.0 lot for the symbol.
            XAUUSD default: 10.0 (standard lot). For micro-lot step: 1.0.
        min_lot: Minimum lot size (MT5 minimum is typically 0.01).
        lot_step: Lot size increment (MT5 step for XAUUSD is 0.01).

    Returns:
        Lot size rounded to the nearest lot_step, clamped to >= min_lot.
    """
    if kelly_fraction <= 0.0 or account_equity <= 0.0 or sl_pips <= 0.0:
        return 0.0

    risk_amount = account_equity * kelly_fraction
    raw_lots = risk_amount / (sl_pips * pip_value_per_lot)

    # Round to nearest lot_step
    lots = round(raw_lots / lot_step) * lot_step

    # Clamp to minimum
    return max(min_lot, lots)
