"""Performance analytics for backtesting results."""

from __future__ import annotations

import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Tuple


@dataclass
class Metrics:
    """Computed performance metrics from a backtest."""

    # Returns
    total_return: float = 0.0
    total_return_pct: float = 0.0
    cagr: float = 0.0

    # Risk-adjusted
    sharpe_ratio: float = 0.0
    sortino_ratio: float = 0.0
    calmar_ratio: float = 0.0

    # Drawdown
    max_drawdown: float = 0.0
    max_drawdown_pct: float = 0.0
    max_drawdown_duration_secs: float = 0.0

    # Trades
    total_trades: int = 0
    win_rate: float = 0.0
    profit_factor: float = 0.0
    expectancy: float = 0.0
    avg_win: float = 0.0
    avg_loss: float = 0.0
    largest_win: float = 0.0
    largest_loss: float = 0.0
    total_fees: float = 0.0

    # Prediction market specific
    brier_score: float | None = None
    avg_edge: float | None = None
    log_loss: float | None = None
    ece: float | None = None  # Expected Calibration Error

    # Prediction market advanced
    resolution_pnl: float | None = None  # PnL from positions held to resolution
    adverse_selection_5: float | None = None  # Avg adverse price move 5 ticks post-fill
    edge_capture: float | None = None  # Ratio of realized vs theoretical edge


@dataclass
class RoundTrip:
    """A matched round-trip trade (long: buy→sell, short: sell→buy)."""

    market_id: str
    direction: str  # "long" or "short"
    entry_price: float
    exit_price: float
    size: float
    entry_fee: float
    exit_fee: float
    entry_timestamp: float
    exit_timestamp: float

    @property
    def pnl(self) -> float:
        """Net PnL after fees."""
        if self.direction == "long":
            return self.size * (self.exit_price - self.entry_price) - self.entry_fee - self.exit_fee
        return self.size * (self.entry_price - self.exit_price) - self.entry_fee - self.exit_fee

    @property
    def hold_time(self) -> float:
        return self.exit_timestamp - self.entry_timestamp


@dataclass
class TradeAnalysis:
    """Result of FIFO trade matching: round-trips + open positions."""

    round_trips: list[RoundTrip] = field(default_factory=list)
    # Unmatched positions: market_id -> [(price, size, fee, timestamp), ...]
    open_longs: dict = field(default_factory=dict)
    open_shorts: dict = field(default_factory=dict)


def _analyze_trades(trades: list) -> TradeAnalysis:
    """Single-pass FIFO matching into round-trips with short-side support.

    Handles both long (buy→sell) and short (sell→buy) round-trips.
    Tracks unmatched open positions for resolution PnL.
    """
    from collections import defaultdict, deque

    # Per-market queues for unmatched fills
    longs: dict[str, deque] = defaultdict(deque)   # unmatched buys
    shorts: dict[str, deque] = defaultdict(deque)  # unmatched sells
    result = TradeAnalysis()

    for fill in trades:
        market_id = getattr(fill, "market_id", "")
        price = getattr(fill, "price", 0.0)
        size = getattr(fill, "size", 0.0)
        fee = getattr(fill, "fee", 0.0) or 0.0
        timestamp = getattr(fill, "timestamp", 0.0)
        side_str = str(getattr(fill, "order_side", "")).lower()
        is_buy = "buy" in side_str

        if is_buy:
            # First try to close existing shorts
            remaining = size
            remaining_fee = fee
            while remaining > 1e-12 and shorts[market_id]:
                s_price, s_size, s_fee, s_ts = shorts[market_id][0]
                matched = min(remaining, s_size)
                entry_fee = s_fee * (matched / s_size) if s_size > 0 else 0.0
                exit_fee = remaining_fee * (matched / remaining) if remaining > 0 else 0.0
                result.round_trips.append(RoundTrip(
                    market_id=market_id,
                    direction="short",
                    entry_price=s_price,
                    exit_price=price,
                    size=matched,
                    entry_fee=entry_fee,
                    exit_fee=exit_fee,
                    entry_timestamp=s_ts,
                    exit_timestamp=timestamp,
                ))
                remaining_fee -= exit_fee
                remaining -= matched
                if matched >= s_size:
                    shorts[market_id].popleft()
                else:
                    leftover_fee = s_fee * ((s_size - matched) / s_size) if s_size > 0 else 0.0
                    shorts[market_id][0] = (s_price, s_size - matched, leftover_fee, s_ts)
            # Remaining buy goes to longs queue
            if remaining > 1e-12:
                longs[market_id].append((price, remaining, max(remaining_fee, 0.0), timestamp))
        else:
            # First try to close existing longs
            remaining = size
            remaining_fee = fee
            while remaining > 1e-12 and longs[market_id]:
                b_price, b_size, b_fee, b_ts = longs[market_id][0]
                matched = min(remaining, b_size)
                entry_fee = b_fee * (matched / b_size) if b_size > 0 else 0.0
                exit_fee = remaining_fee * (matched / remaining) if remaining > 0 else 0.0
                result.round_trips.append(RoundTrip(
                    market_id=market_id,
                    direction="long",
                    entry_price=b_price,
                    exit_price=price,
                    size=matched,
                    entry_fee=entry_fee,
                    exit_fee=exit_fee,
                    entry_timestamp=b_ts,
                    exit_timestamp=timestamp,
                ))
                remaining_fee -= exit_fee
                remaining -= matched
                if matched >= b_size:
                    longs[market_id].popleft()
                else:
                    leftover_fee = b_fee * ((b_size - matched) / b_size) if b_size > 0 else 0.0
                    longs[market_id][0] = (b_price, b_size - matched, leftover_fee, b_ts)
            # Remaining sell goes to shorts queue
            if remaining > 1e-12:
                shorts[market_id].append((price, remaining, max(remaining_fee, 0.0), timestamp))

    # Store open positions
    result.open_longs = {k: list(v) for k, v in longs.items() if v}
    result.open_shorts = {k: list(v) for k, v in shorts.items() if v}
    return result


def _compute_resolution_pnl(
    analysis: TradeAnalysis,
    outcomes: dict[str, float],
) -> float:
    """Compute PnL from unmatched positions resolved at outcome prices.

    In prediction markets, positions held at expiry resolve at 0 or 1.
    """
    pnl = 0.0
    for market_id, positions in analysis.open_longs.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                pnl += size * (outcome - price) - fee
    for market_id, positions in analysis.open_shorts.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                pnl += size * (price - outcome) - fee
    return pnl


def _compute_adverse_selection(
    trades: list,
    mid_prices: list[tuple[float, float]],
    lookback_ticks: int = 5,
) -> float:
    """Average mark-to-market PnL N ticks after each fill.

    Negative = adversely selected (price moves against you after fill).
    Positive = favorable selection.
    """
    import bisect

    if not mid_prices or not trades:
        return 0.0

    ts_list = [ts for ts, _ in mid_prices]
    price_list = [p for _, p in mid_prices]
    n = len(ts_list)

    scores: list[float] = []
    for fill in trades:
        fill_ts = getattr(fill, "timestamp", 0.0)
        fill_price = getattr(fill, "price", 0.0)
        side_str = str(getattr(fill, "order_side", "")).lower()

        idx = bisect.bisect_left(ts_list, fill_ts)
        if idx >= n:
            continue
        future_idx = min(idx + lookback_ticks, n - 1)
        if future_idx == idx:
            continue
        future_mid = price_list[future_idx]

        if "buy" in side_str:
            scores.append(future_mid - fill_price)
        else:
            scores.append(fill_price - future_mid)

    return _safe_float(sum(scores) / len(scores)) if scores else 0.0


def _compute_edge_capture(
    analysis: TradeAnalysis,
    outcomes: dict[str, float],
) -> float | None:
    """Ratio of realized PnL to theoretical edge.

    Theoretical edge = sum(size * |outcome - entry_price|) for all round-trips
    with known outcomes. Edge capture < 1 means you're leaving edge on the table
    (closing too early, adverse selection, fees).
    """
    realized = 0.0
    theoretical = 0.0
    for rt in analysis.round_trips:
        if rt.market_id not in outcomes:
            continue
        outcome = outcomes[rt.market_id]
        realized += rt.pnl
        if rt.direction == "long":
            theoretical += rt.size * (outcome - rt.entry_price)
        else:
            theoretical += rt.size * (rt.entry_price - outcome)

    # Include open positions
    for market_id, positions in analysis.open_longs.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                edge = size * (outcome - price) - fee
                realized += edge
                theoretical += size * (outcome - price)
    for market_id, positions in analysis.open_shorts.items():
        if market_id in outcomes:
            outcome = outcomes[market_id]
            for price, size, fee, _ts in positions:
                edge = size * (price - outcome) - fee
                realized += edge
                theoretical += size * (price - outcome)

    if abs(theoretical) < 1e-12:
        return None
    return _safe_float(realized / theoretical)


def compute_metrics(
    equity_curve: list[tuple[float, float]],
    trades: list,
    initial_capital: float,
    outcomes: dict[str, float] | None = None,
    mid_prices: list[tuple[float, float]] | None = None,
) -> Metrics:
    """Compute all performance metrics from backtest results.

    Args:
        equity_curve: List of (timestamp, equity) tuples.
        trades: List of Fill objects from the engine.
        initial_capital: Starting capital.
        outcomes: Optional dict of market_id -> outcome (1.0 or 0.0) for Brier score.

    Returns:
        Metrics dataclass with all computed values.
    """
    m = Metrics()

    if not equity_curve:
        return m

    # --- Returns ---
    final_equity = equity_curve[-1][1]
    m.total_return = final_equity - initial_capital
    m.total_return_pct = (m.total_return / initial_capital) * 100.0 if initial_capital > 0 else 0.0

    # CAGR - only meaningful for durations >= 1 day
    duration_secs = equity_curve[-1][0] - equity_curve[0][0]
    if duration_secs >= 86400 and initial_capital > 0 and final_equity > 0:
        years = duration_secs / (365.25 * 86400)
        try:
            m.cagr = (final_equity / initial_capital) ** (1.0 / years) - 1.0
        except (OverflowError, ValueError):
            m.cagr = 0.0

    # --- Drawdown ---
    peak = initial_capital
    max_dd = 0.0
    max_dd_pct = 0.0
    dd_start_time = equity_curve[0][0]
    max_dd_duration = 0.0
    in_drawdown = False

    for ts, equity in equity_curve:
        if equity >= peak:
            if in_drawdown:
                dd_dur = ts - dd_start_time
                max_dd_duration = max(max_dd_duration, dd_dur)
            peak = equity
            in_drawdown = False
        else:
            if not in_drawdown:
                dd_start_time = ts
                in_drawdown = True
            dd = peak - equity
            dd_pct = (dd / peak) * 100.0 if peak > 0 else 0.0
            if dd > max_dd:
                max_dd = dd
                max_dd_pct = dd_pct

    # If still in drawdown at end, count that duration too
    if in_drawdown:
        dd_dur = equity_curve[-1][0] - dd_start_time
        max_dd_duration = max(max_dd_duration, dd_dur)

    m.max_drawdown = max_dd
    m.max_drawdown_pct = max_dd_pct
    m.max_drawdown_duration_secs = max_dd_duration

    # --- Sharpe / Sortino (from equity returns) ---
    if len(equity_curve) >= 2:
        returns = []
        for i in range(1, len(equity_curve)):
            prev_eq = equity_curve[i - 1][1]
            curr_eq = equity_curve[i][1]
            if prev_eq > 0:
                returns.append((curr_eq - prev_eq) / prev_eq)

        if returns:
            mean_ret = sum(returns) / len(returns)
            denom = len(returns) - 1 if len(returns) > 1 else 1
            variance = sum((r - mean_ret) ** 2 for r in returns) / denom
            std_ret = math.sqrt(variance)

            # Annualization factor: estimate periods per year from data
            avg_interval = duration_secs / len(returns) if duration_secs > 0 else 1.0
            periods_per_year = (365.25 * 86400) / avg_interval if avg_interval > 0 else 1.0
            ann_factor = math.sqrt(periods_per_year)

            if std_ret > 0:
                m.sharpe_ratio = (mean_ret / std_ret) * ann_factor

            # Sortino: only downside deviation
            downside = [r for r in returns if r < 0]
            if downside:
                down_var = sum(r ** 2 for r in downside) / len(returns)
                down_std = math.sqrt(down_var)
                if down_std > 0:
                    m.sortino_ratio = (mean_ret / down_std) * ann_factor

    # Calmar
    if m.max_drawdown_pct > 0 and m.cagr != 0:
        m.calmar_ratio = m.cagr / (m.max_drawdown_pct / 100.0)

    # --- Trade analytics (single-pass FIFO matching) ---
    analysis = _analyze_trades(trades)
    trade_pnls = [rt.pnl for rt in analysis.round_trips]
    m.total_trades = len(trade_pnls)

    if trade_pnls:
        wins = [p for p in trade_pnls if p > 0]
        losses = [p for p in trade_pnls if p < 0]

        m.win_rate = len(wins) / len(trade_pnls) * 100.0

        m.avg_win = sum(wins) / len(wins) if wins else 0.0
        m.avg_loss = sum(losses) / len(losses) if losses else 0.0
        m.largest_win = max(wins) if wins else 0.0
        m.largest_loss = min(losses) if losses else 0.0

        gross_profit = sum(wins)
        gross_loss = abs(sum(losses))
        m.profit_factor = gross_profit / gross_loss if gross_loss > 0 else 9999.99 if gross_profit > 0 else 0.0

        m.expectancy = sum(trade_pnls) / len(trade_pnls)

    # Fees
    m.total_fees = sum(getattr(t, "fee", 0.0) or 0.0 for t in trades)

    # --- Prediction market metrics ---
    if outcomes:
        m.brier_score = _compute_brier_score(trades, outcomes)
        m.avg_edge = _compute_avg_edge(trades, outcomes)
        m.resolution_pnl = _compute_resolution_pnl(analysis, outcomes)
        m.edge_capture = _compute_edge_capture(analysis, outcomes)

        # Use Rust analytics for log-loss and ECE when available
        try:
            from horizon._horizon import calibration_curve as _rust_calibration
            from horizon._horizon import log_loss as _rust_log_loss

            predictions, outcome_vals = _extract_predictions_outcomes(trades, outcomes)
            if predictions:
                m.log_loss = _rust_log_loss(predictions, outcome_vals)
                cal = _rust_calibration(predictions, outcome_vals, 10)
                m.ece = cal.ece
        except (ImportError, Exception):
            pass  # Rust analytics not available, skip

    # --- Adverse selection (requires mid_prices from backtest) ---
    if mid_prices:
        m.adverse_selection_5 = _compute_adverse_selection(trades, mid_prices, lookback_ticks=5)

    return m


@dataclass
class DrawdownRecord:
    """A single drawdown period."""

    start_ts: float = 0.0
    end_ts: float = 0.0
    recovery_ts: float | None = None
    depth_pct: float = 0.0
    duration_secs: float = 0.0


@dataclass
class Tearsheet:
    """Full performance tearsheet."""

    # Monthly returns: "YYYY-MM" -> return %
    monthly_returns: dict[str, float] = field(default_factory=dict)

    # Rolling Sharpe (30-period window): list of (timestamp, sharpe)
    rolling_sharpe: list[Tuple[float, float]] = field(default_factory=list)

    # Rolling Sortino (30-period window): list of (timestamp, sortino)
    rolling_sortino: list[Tuple[float, float]] = field(default_factory=list)

    # Top 5 drawdowns: (start_ts, end_ts, recovery_ts, depth_pct, duration_secs)
    drawdowns: list[DrawdownRecord] = field(default_factory=list)

    # Trade-level stats
    avg_win: float = 0.0
    avg_loss: float = 0.0
    largest_win: float = 0.0
    largest_loss: float = 0.0
    avg_hold_time: float = 0.0
    win_streak: int = 0
    loss_streak: int = 0

    # Time-of-day analysis: hour (0-23) -> avg return for that hour
    time_of_day: dict[int, float] = field(default_factory=dict)

    # Consecutive stats
    max_consecutive_wins: int = 0
    max_consecutive_losses: int = 0

    # Tail ratio: 95th percentile gain / abs(5th percentile loss)
    tail_ratio: float = 0.0


def _safe_float(val: float) -> float:
    """Ensure a float value is finite. Replace NaN/Inf with 0.0."""
    if math.isfinite(val):
        return val
    return 0.0


def _rolling_sharpe_sortino(
    equity_curve: list[tuple[float, float]],
    window: int = 30,
) -> tuple[list[Tuple[float, float]], list[Tuple[float, float]]]:
    """Compute rolling Sharpe and Sortino over a sliding window of returns.

    Returns:
        (rolling_sharpe, rolling_sortino) as lists of (timestamp, value).
    """
    if len(equity_curve) < 2:
        return [], []

    # Compute period returns
    timestamps: list[float] = []
    returns: list[float] = []
    for i in range(1, len(equity_curve)):
        prev_eq = equity_curve[i - 1][1]
        curr_eq = equity_curve[i][1]
        ts = equity_curve[i][0]
        if prev_eq > 0:
            returns.append((curr_eq - prev_eq) / prev_eq)
            timestamps.append(ts)
        else:
            returns.append(0.0)
            timestamps.append(ts)

    if len(returns) < window:
        return [], []

    rolling_sharpe: list[Tuple[float, float]] = []
    rolling_sortino: list[Tuple[float, float]] = []

    for i in range(window - 1, len(returns)):
        w = returns[i - window + 1: i + 1]
        ts = timestamps[i]

        mean_ret = sum(w) / len(w)
        denom = len(w) - 1 if len(w) > 1 else 1
        variance = sum((r - mean_ret) ** 2 for r in w) / denom
        std_ret = math.sqrt(variance) if variance > 0 else 0.0

        if std_ret > 0:
            sharpe = _safe_float(mean_ret / std_ret)
        else:
            sharpe = 0.0
        rolling_sharpe.append((ts, sharpe))

        # Sortino: downside deviation using full window length denominator
        downside = [r for r in w if r < 0]
        if downside:
            down_var = sum(r ** 2 for r in downside) / len(w)
            down_std = math.sqrt(down_var) if down_var > 0 else 0.0
            if down_std > 0:
                sortino = _safe_float(mean_ret / down_std)
            else:
                sortino = 0.0
        else:
            sortino = 0.0
        rolling_sortino.append((ts, sortino))

    return rolling_sharpe, rolling_sortino


def _compute_drawdown_periods(
    equity_curve: list[tuple[float, float]],
    top_n: int = 5,
) -> list[DrawdownRecord]:
    """Identify individual drawdown periods from the equity curve.

    Returns the top N drawdowns sorted by depth (deepest first).
    """
    if len(equity_curve) < 2:
        return []

    drawdowns: list[DrawdownRecord] = []
    peak = equity_curve[0][1]
    dd_start_ts = equity_curve[0][0]
    in_drawdown = False
    max_depth_pct = 0.0
    trough_ts = equity_curve[0][0]

    for ts, equity in equity_curve:
        if equity >= peak:
            if in_drawdown:
                # Drawdown ended - record it
                duration = ts - dd_start_ts
                drawdowns.append(DrawdownRecord(
                    start_ts=dd_start_ts,
                    end_ts=trough_ts,
                    recovery_ts=ts,
                    depth_pct=_safe_float(max_depth_pct),
                    duration_secs=duration,
                ))
                in_drawdown = False
                max_depth_pct = 0.0
            peak = equity
        else:
            if not in_drawdown:
                dd_start_ts = ts
                in_drawdown = True
                max_depth_pct = 0.0
            dd_pct = ((peak - equity) / peak) * 100.0 if peak > 0 else 0.0
            if dd_pct > max_depth_pct:
                max_depth_pct = dd_pct
                trough_ts = ts

    # If still in drawdown at end
    if in_drawdown:
        duration = equity_curve[-1][0] - dd_start_ts
        drawdowns.append(DrawdownRecord(
            start_ts=dd_start_ts,
            end_ts=trough_ts,
            recovery_ts=None,
            depth_pct=_safe_float(max_depth_pct),
            duration_secs=duration,
        ))

    # Sort by depth descending, take top N
    drawdowns.sort(key=lambda d: d.depth_pct, reverse=True)
    return drawdowns[:top_n]


def _compute_monthly_returns(
    equity_curve: list[tuple[float, float]],
) -> dict[str, float]:
    """Compute monthly returns from the equity curve.

    Groups equity snapshots by calendar month (UTC), computes return %
    from first equity of the month to last equity of the month.
    """
    if len(equity_curve) < 2:
        return {}

    # Group by YYYY-MM
    monthly_data: dict[str, list[tuple[float, float]]] = {}
    for ts, equity in equity_curve:
        try:
            dt = datetime.fromtimestamp(ts, tz=timezone.utc)
            key = f"{dt.year:04d}-{dt.month:02d}"
        except (OSError, ValueError, OverflowError):
            continue
        if key not in monthly_data:
            monthly_data[key] = []
        monthly_data[key].append((ts, equity))

    monthly_returns: dict[str, float] = {}
    for key, points in sorted(monthly_data.items()):
        if len(points) < 2:
            monthly_returns[key] = 0.0
            continue
        first_eq = points[0][1]
        last_eq = points[-1][1]
        if first_eq > 0:
            monthly_returns[key] = _safe_float(((last_eq - first_eq) / first_eq) * 100.0)
        else:
            monthly_returns[key] = 0.0

    return monthly_returns


def _compute_time_of_day_returns(
    trades: list,
) -> dict[int, float]:
    """Compute average return by hour of day (0-23, UTC).

    Uses FIFO-matched round-trip trades. Each round-trip is attributed
    to the hour of the exit (closing) trade.
    """
    from collections import defaultdict

    analysis = _analyze_trades(trades)
    hour_returns: dict[int, list[float]] = defaultdict(list)

    for rt in analysis.round_trips:
        try:
            dt = datetime.fromtimestamp(rt.exit_timestamp, tz=timezone.utc)
            hour = dt.hour
        except (OSError, ValueError, OverflowError):
            hour = 0
        hour_returns[hour].append(rt.pnl)

    result: dict[int, float] = {}
    for hour, pnls in sorted(hour_returns.items()):
        if pnls:
            result[hour] = _safe_float(sum(pnls) / len(pnls))
        else:
            result[hour] = 0.0
    return result


def _compute_trade_stats(
    trades: list,
) -> tuple[float, float, float, float, float, int, int, int, int]:
    """Compute detailed trade-level statistics using centralized round-trip matching.

    Returns:
        (avg_win, avg_loss, largest_win, largest_loss, avg_hold_time,
         win_streak, loss_streak, max_consecutive_wins, max_consecutive_losses)
    """
    analysis = _analyze_trades(trades)
    if not analysis.round_trips:
        return 0.0, 0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0

    pnls = [rt.pnl for rt in analysis.round_trips]
    hold_times = [rt.hold_time for rt in analysis.round_trips if rt.hold_time >= 0]

    wins = [p for p in pnls if p > 0]
    losses = [p for p in pnls if p < 0]

    avg_win = _safe_float(sum(wins) / len(wins)) if wins else 0.0
    avg_loss = _safe_float(sum(losses) / len(losses)) if losses else 0.0
    largest_win = max(wins) if wins else 0.0
    largest_loss = min(losses) if losses else 0.0
    avg_hold_time = _safe_float(sum(hold_times) / len(hold_times)) if hold_times else 0.0

    # Consecutive win/loss streaks
    max_wins = 0
    max_losses = 0
    current_wins = 0
    current_losses = 0
    for pnl in pnls:
        if pnl > 0:
            current_wins += 1
            current_losses = 0
            max_wins = max(max_wins, current_wins)
        elif pnl < 0:
            current_losses += 1
            current_wins = 0
            max_losses = max(max_losses, current_losses)
        else:
            current_wins = 0
            current_losses = 0

    return (
        avg_win, avg_loss, largest_win, largest_loss, avg_hold_time,
        max_wins, max_losses, max_wins, max_losses,
    )


def _compute_tail_ratio(equity_curve: list[tuple[float, float]]) -> float:
    """Compute tail ratio: 95th percentile gain / abs(5th percentile loss).

    Uses equity curve period returns.
    """
    if len(equity_curve) < 2:
        return 0.0

    returns: list[float] = []
    for i in range(1, len(equity_curve)):
        prev_eq = equity_curve[i - 1][1]
        curr_eq = equity_curve[i][1]
        if prev_eq > 0:
            returns.append((curr_eq - prev_eq) / prev_eq)

    if not returns:
        return 0.0

    returns_sorted = sorted(returns)
    n = len(returns_sorted)

    def percentile(data: list[float], pct: float) -> float:
        """Linear interpolation percentile."""
        if len(data) == 1:
            return data[0]
        k = (pct / 100.0) * (len(data) - 1)
        f = math.floor(k)
        c = min(f + 1, len(data) - 1)
        if f == c:
            return data[f]
        return data[f] + (k - f) * (data[c] - data[f])

    p95 = percentile(returns_sorted, 95.0)
    p5 = percentile(returns_sorted, 5.0)

    if abs(p5) > 0:
        return _safe_float(p95 / abs(p5))
    elif p95 > 0:
        # No downside tail at all
        return 9999.99
    return 0.0


def generate_tearsheet(
    equity_curve: list[tuple[float, float]],
    trades: list,
    initial_capital: float,
) -> Tearsheet:
    """Generate a full performance tearsheet.

    Args:
        equity_curve: List of (timestamp, equity) tuples.
        trades: List of Fill objects from the engine.
        initial_capital: Starting capital.

    Returns:
        Tearsheet dataclass with all computed values.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    ts = Tearsheet()

    # Monthly returns
    ts.monthly_returns = _compute_monthly_returns(equity_curve)

    # Rolling Sharpe / Sortino
    ts.rolling_sharpe, ts.rolling_sortino = _rolling_sharpe_sortino(equity_curve, window=30)

    # Drawdown analysis
    ts.drawdowns = _compute_drawdown_periods(equity_curve, top_n=5)

    # Trade-level stats
    (
        ts.avg_win,
        ts.avg_loss,
        ts.largest_win,
        ts.largest_loss,
        ts.avg_hold_time,
        ts.win_streak,
        ts.loss_streak,
        ts.max_consecutive_wins,
        ts.max_consecutive_losses,
    ) = _compute_trade_stats(trades)

    # Time-of-day analysis
    ts.time_of_day = _compute_time_of_day_returns(trades)

    # Tail ratio
    ts.tail_ratio = _compute_tail_ratio(equity_curve)

    return ts


def _compute_trade_pnls(trades: list) -> list[float]:
    """FIFO matching to compute round-trip PnLs (delegates to _analyze_trades).

    Supports both long (buy→sell) and short (sell→buy) round-trips.
    """
    analysis = _analyze_trades(trades)
    return [rt.pnl for rt in analysis.round_trips]


def _compute_brier_score(trades: list, outcomes: dict[str, float]) -> float:
    """Compute Brier score: mean((forecast - outcome)^2).

    Only considers buy trades as forecasts (the price paid = implied probability).
    """
    forecasts = []
    for fill in trades:
        market_id = getattr(fill, "market_id", "")
        if market_id not in outcomes:
            continue
        side_str = str(getattr(fill, "order_side", "")).lower()
        if "buy" not in side_str:
            continue
        price = getattr(fill, "price", 0.0)
        outcome = outcomes[market_id]
        forecasts.append((price - outcome) ** 2)

    return sum(forecasts) / len(forecasts) if forecasts else 0.0


def _compute_avg_edge(trades: list, outcomes: dict[str, float]) -> float:
    """Compute average edge: mean(outcome - price_paid) for buy trades."""
    edges = []
    for fill in trades:
        market_id = getattr(fill, "market_id", "")
        if market_id not in outcomes:
            continue
        side_str = str(getattr(fill, "order_side", "")).lower()
        if "buy" not in side_str:
            continue
        price = getattr(fill, "price", 0.0)
        outcome = outcomes[market_id]
        edges.append(outcome - price)

    return sum(edges) / len(edges) if edges else 0.0


def _extract_predictions_outcomes(
    trades: list, outcomes: dict[str, float]
) -> tuple[list[float], list[float]]:
    """Extract (predictions, outcomes) vectors from buy trades for Rust analytics."""
    predictions = []
    outcome_vals = []
    for fill in trades:
        market_id = getattr(fill, "market_id", "")
        if market_id not in outcomes:
            continue
        side_str = str(getattr(fill, "order_side", "")).lower()
        if "buy" not in side_str:
            continue
        predictions.append(getattr(fill, "price", 0.0))
        outcome_vals.append(outcomes[market_id])
    return predictions, outcome_vals
