"""Métricas de §6 contra cálculos independientes (biblioteca `statistics` y aritmética a mano)."""

import math
import statistics
from datetime import date

import numpy as np
import pytest
from estudio01 import engine, metrics

R = [0.01, -0.02, 0.015, 0.0, 0.03, -0.01, 0.005, -0.004]


def test_sharpe_uses_the_sample_standard_deviation_and_sqrt_252():
    per_bar = statistics.mean(R) / statistics.stdev(R)  # stdev usa el divisor n - 1
    assert metrics.sharpe_per_bar(R) == pytest.approx(per_bar, rel=1e-14)
    assert metrics.sharpe_annualized(R) == pytest.approx(per_bar * math.sqrt(252), rel=1e-14)


@pytest.mark.parametrize("returns", [[0.0] * 10, [0.002] * 10, [0.01], []])
def test_sharpe_is_zero_without_variation_or_data(returns):
    assert metrics.sharpe_per_bar(returns) == 0.0
    assert metrics.sharpe_annualized(returns) == 0.0


def test_skewness_and_kurtosis_use_moments_without_correction():
    mean = sum(R) / len(R)
    m2 = sum((x - mean) ** 2 for x in R) / len(R)
    m3 = sum((x - mean) ** 3 for x in R) / len(R)
    m4 = sum((x - mean) ** 4 for x in R) / len(R)
    skew, kurt = metrics.skew_kurtosis(R)
    assert skew == pytest.approx(m3 / m2**1.5, rel=1e-12)
    assert kurt == pytest.approx(m4 / m2**2, rel=1e-12)
    normal_like = [-1.0, 1.0] * 500  # curtosis de Pearson de una variable de dos puntos = 1
    assert metrics.skew_kurtosis(normal_like) == (pytest.approx(0.0, abs=1e-12), pytest.approx(1.0))


def test_skewness_needs_variance():
    with pytest.raises(ValueError, match="varianza"):
        metrics.skew_kurtosis([0.0] * 5)
    with pytest.raises(ValueError, match="varianza"):
        metrics.skew_kurtosis([0.002] * 10)  # constante distinta de cero: el ruido no cuenta


def test_a_constant_nonzero_series_has_zero_standard_deviation_not_float_noise():
    """Con una serie constante el ruido de punto flotante no debe convertirse en un Sharpe enorme.

    La media de diez 0.002 puede no ser exactamente 0.002, y entonces np.std da una desviación
    diminuta pero no nula. El protocolo manda reportar 0 cuando la desviación es 0.
    """
    assert metrics.sharpe_per_bar([0.002] * 10) == 0.0
    assert metrics.sharpe_per_bar([-0.003] * 37) == 0.0


def _noisy_constant(rows: int) -> float:
    """Un valor v tal que np.std de v repetido `rows` veces NO da exactamente 0 (ruido de float)."""
    for candidate in (0.002, 0.003, 0.0007, 0.0123, 0.1, 0.37, 0.0001):
        if float(np.std(np.full(rows, candidate), ddof=1)) != 0.0:
            return candidate
    pytest.skip("en esta plataforma np.std de una constante da 0 exacto")


def test_is_constant_means_all_values_identical():
    assert metrics.is_constant([0.0] * 5) and metrics.is_constant([0.002] * 10)
    assert metrics.is_constant([1.0]) and metrics.is_constant([])  # sin datos suficientes
    assert not metrics.is_constant([0.002, 0.002, 0.0020000000000000005])


def test_a_constant_column_has_sharpe_zero_even_when_numpy_std_is_float_noise():
    """El protocolo manda Sharpe 0 con desviación 0; np.std de una constante puede dar ~1e-19."""
    value = _noisy_constant(160)
    rng = np.random.default_rng(5)
    selected = np.column_stack([np.full(160, value), rng.normal(0.0, 0.01, 160)])
    sharpes = metrics.sharpe_columns(selected)
    assert sharpes[0] == 0.0
    assert sharpes[1] != 0.0


def test_pbo_with_a_constant_nonzero_column_equals_pbo_with_a_zero_column():
    """Las dos columnas tienen Sharpe 0 en todas las combinaciones, así que el PBO es idéntico."""
    value = _noisy_constant(160)  # 160 = 8 bloques de 20 filas
    noise = np.random.default_rng(3).normal(0.0, 0.01, size=(320, 3))
    with_constant = np.column_stack([np.full(320, value), noise])
    with_zeros = np.column_stack([np.zeros(320), noise])
    assert metrics.pbo_cscv(with_constant) == metrics.pbo_cscv(with_zeros)


def test_variance_of_the_sharpe_ratios_uses_n_minus_one():
    sharpes = [0.01, 0.03, -0.02, 0.05]
    assert metrics.sample_variance(sharpes) == pytest.approx(statistics.variance(sharpes))


def test_expected_max_sharpe_scales_with_the_square_root_of_the_variance():
    assert metrics.expected_max_sharpe(0.0, 4) == 0.0
    one = metrics.expected_max_sharpe(0.0001, 4)
    assert metrics.expected_max_sharpe(0.0004, 4) == pytest.approx(2.0 * one, rel=1e-14)
    assert metrics.expected_max_sharpe(0.0001, 10) > one  # más pruebas, umbral más alto


def test_dsr_with_zero_variance_equals_the_psr_against_zero():
    result = metrics.deflated_sharpe(0.05, 1000, 0.1, 3.5, 0.0, 4)
    assert result.sr0 == 0.0
    assert result.dsr == result.psr0


def test_the_dsr_radicand_must_be_positive():
    with pytest.raises(ValueError, match="radicando"):
        metrics.probabilistic_sharpe(1.0, 0.0, 100, 10.0, 3.0)


def test_average_rank_gives_ties_the_mean_rank():
    assert [metrics.average_rank([3.0, 1.0, 2.0, 4.0], i) for i in range(4)] == [3.0, 1.0, 2.0, 4.0]
    assert [metrics.average_rank([1.0, 1.0, 2.0, 3.0], i) for i in range(4)] == [1.5, 1.5, 3.0, 4.0]
    assert metrics.average_rank([2.0, 2.0, 2.0, 1.0], 0) == 3.0


def test_equity_curve_and_maximum_drawdown():
    curve = metrics.equity_curve([0.2, -0.25, 0.1111111111111111, 0.5, -0.5])
    assert curve[0] == 1.0
    assert curve == pytest.approx([1.0, 1.2, 0.9, 1.0, 1.5, 0.75])
    assert metrics.max_drawdown(curve) == pytest.approx(0.5)  # de 1.5 a 0.75
    assert metrics.max_drawdown([1.0, 1.2, 1.5]) == 0.0
    assert metrics.max_drawdown([1.0, 0.9]) == pytest.approx(0.1)  # el capital inicial cuenta


def test_total_return_is_the_compounded_product():
    assert metrics.total_return(R) == pytest.approx(math.prod(1.0 + x for x in R) - 1.0)
    assert metrics.total_return([]) == 0.0


def _run(dates, returns, sides, directions) -> engine.Run:
    zeros = tuple(0.0 for _ in dates)
    return engine.Run(
        dates=tuple(dates),
        prices=tuple(1.0 for _ in dates),
        d=tuple(directions),
        k_minus=zeros,
        k_plus=zeros,
        swap=zeros,
        cost=zeros,
        ret=tuple(returns),
        sides=tuple(sides),
    )


def test_run_metrics_for_the_whole_window():
    dates = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3), date(2020, 7, 1)]
    run = _run(dates, [-0.01, 0.02, 0.01, -0.005], [1, 0, 2, 0], [1, 1, -1, -1])
    got = metrics.run_metrics(run)
    assert got.n_returns == 4
    assert got.total_return == pytest.approx(math.prod(1.0 + x for x in run.ret) - 1.0)
    assert got.executions == 2  # dos barras con operación, aunque una pague dos lados
    assert got.fraction_in_position == 1.0
    years = (date(2020, 7, 1) - date(2020, 1, 1)).days / 365.25
    assert got.fixes_per_year == pytest.approx(4 / years)
    assert got.sharpe_annualized == pytest.approx(
        statistics.mean(run.ret) / statistics.stdev(run.ret) * math.sqrt(252)
    )
    assert got.max_drawdown == pytest.approx(0.01)  # la primera barra cae 1 % desde el capital 1


def test_the_final_segment_is_a_cut_of_the_same_run_with_its_own_drawdown_curve():
    dates = [
        date(2024, 9, 30),
        date(2024, 10, 1),
        date(2024, 10, 2),
        date(2026, 9, 30),
        date(2026, 10, 1),
    ]
    run = _run(dates, [0.1, -0.2, 0.05, 0.03, 0.5], [1, 0, 2, 1, 0], [1, 1, -1, 0, 0])
    got = metrics.run_metrics(run, date(2024, 10, 1), date(2026, 9, 30))
    assert got.n_returns == 3  # ambas fechas extremas se incluyen; lo posterior no
    assert got.total_return == pytest.approx(0.8 * 1.05 * 1.03 - 1.0)
    assert got.max_drawdown == pytest.approx(0.2)  # curva reiniciada en 1 al inicio del tramo
    assert got.executions == 2  # solo cuentan las barras del tramo (lados 0, 2 y 1)
    assert got.fraction_in_position == pytest.approx(2 / 3)
    assert got.sharpe_annualized == pytest.approx(
        statistics.mean([-0.2, 0.05, 0.03]) / statistics.stdev([-0.2, 0.05, 0.03]) * math.sqrt(252)
    )
    assert metrics.run_metrics(run, date(2030, 1, 1), date(2030, 12, 31)).n_returns == 0


def test_run_metrics_flags_a_window_without_deviation_and_only_that():
    """§6: con desviación 0 el Sharpe se reporta como 0 y la corrida se marca para declararla."""
    dates = [date(2020, 1, 1), date(2020, 1, 2), date(2020, 1, 3), date(2020, 1, 6)]
    sides, directions = [0, 0, 0, 0], [0, 0, 0, 0]
    constant = metrics.run_metrics(_run(dates, [0.002] * 4, sides, directions))
    assert constant.zero_deviation is True
    assert constant.sharpe_annualized == 0.0  # y no el 4e15 de comparar la desviación con 0.0
    assert metrics.run_metrics(_run(dates, [0.0] * 4, sides, directions)).zero_deviation is True
    varied = metrics.run_metrics(_run(dates, [0.0, 0.0, 0.0, 0.001], sides, directions))
    assert varied.zero_deviation is False
    # el tramo final se juzga con sus propias barras: constante dentro, con variación fuera
    run = _run(dates, [0.01, 0.002, 0.002, -0.03], [1, 0, 0, 1], [1, 1, 1, 0])
    assert metrics.run_metrics(run).zero_deviation is False
    assert metrics.run_metrics(run, dates[1], dates[2]).zero_deviation is True
    assert metrics.run_metrics(run, date(2030, 1, 1), date(2030, 12, 31)).zero_deviation is True


def test_pbo_input_checks():
    with pytest.raises(ValueError, match="T x N"):
        metrics.pbo_cscv(np.zeros(100))
    with pytest.raises(ValueError, match="suficientes"):
        metrics.pbo_cscv(np.zeros((10, 4)))


def _two_configs_equal_in_the_first_eight_blocks() -> np.ndarray:
    """Configuración 0 y 1 idénticas en los bloques 0-7; la 1 rinde más en los bloques 8-15."""
    rng = np.random.default_rng(21)
    base = rng.normal(0.0, 0.01, size=(16, 20))
    second = base.copy()
    second[8:] += 0.01
    return np.stack([base, second], axis=2)  # (16 bloques, 20 retornos, 2 configuraciones)


def test_an_in_sample_tie_is_won_by_the_lowest_variant_number():
    blocks = _two_configs_equal_in_the_first_eight_blocks()
    # dentro de muestra empatan (datos idénticos): gana la 0; fuera de muestra la 0 queda segunda
    # de dos (rango 1 de N + 1 = 3), no primera
    assert metrics.combination_omega(blocks, range(8)) == pytest.approx(1 / 3)
    swapped = blocks[:, :, ::-1]  # ahora la primera columna es la que rinde más fuera de muestra
    assert metrics.combination_omega(swapped, range(8)) == pytest.approx(2 / 3)


def test_the_best_in_sample_configuration_wins_when_there_is_no_tie():
    blocks = _two_configs_equal_in_the_first_eight_blocks()
    # dentro de muestra {8..15} la configuración 1 rinde más: gana la 1 y fuera de muestra
    # (bloques 0-7, idénticos) empata con la 0, así que su rango es el promedio 1.5
    assert metrics.combination_omega(blocks, range(8, 16)) == pytest.approx(1.5 / 3)
