"""Mathematical contracts for the standard performance metrics.""" from __future__ import annotations import numpy as np import pandas as pd import pytest from quant_engine.metrics import ( TRADING_DAYS_PER_YEAR, annualized_return, annualized_volatility, benchmark_summary, calmar_ratio, max_drawdown, sharpe_ratio, sortino_ratio, summary, win_rate, ) def test_annualized_return_uses_compounded_simple_returns() -> None: returns = pd.Series([0.10, -0.10]) expected = 0.99 ** (TRADING_DAYS_PER_YEAR / 2) - 1.0 assert annualized_return(returns) == pytest.approx(expected) def test_annualized_volatility_uses_sample_standard_deviation() -> None: returns = pd.Series([0.01, 0.03, 0.02]) assert annualized_volatility(returns) == pytest.approx( returns.std() * np.sqrt(TRADING_DAYS_PER_YEAR) ) def test_sharpe_ratio_subtracts_annual_risk_free_rate() -> None: returns = pd.Series([0.01, -0.005, 0.02, 0.0]) result = sharpe_ratio(returns, rf=0.02) assert result == pytest.approx( (annualized_return(returns) - 0.02) / annualized_volatility(returns) ) def test_zero_volatility_metrics_return_zero() -> None: returns = pd.Series([0.0, 0.0, 0.0]) assert sharpe_ratio(returns) == 0.0 assert sortino_ratio(returns) == 0.0 assert calmar_ratio(returns) == 0.0 def test_sortino_ratio_uses_all_sessions_for_downside_deviation() -> None: returns = pd.Series([0.02, -0.01, 0.0, -0.03]) downside = np.minimum(returns.to_numpy(), 0.0) downside_deviation = np.sqrt(np.mean(np.square(downside))) * np.sqrt( TRADING_DAYS_PER_YEAR ) assert sortino_ratio(returns) == pytest.approx( annualized_return(returns) / downside_deviation ) def test_max_drawdown_includes_loss_from_initial_capital() -> None: returns = pd.Series([-0.20, 0.0]) assert max_drawdown(returns) == pytest.approx(-0.20) def test_max_drawdown_tracks_peak_to_trough_loss() -> None: returns = pd.Series([0.10, -0.20, 0.05]) assert max_drawdown(returns) == pytest.approx(-0.20) def test_metrics_clean_nan_and_infinite_values() -> None: returns = pd.Series([0.10, np.nan, np.inf, -0.05, -np.inf]) assert win_rate(returns) == 0.5 assert summary(returns)["n_days"] == 2 def test_summary_aliases_match_canonical_fields() -> None: result = summary(pd.Series([0.01, -0.02, 0.03])) assert result["annual_yield"] == result["ann_return"] assert result["annual_sd"] == result["ann_volatility"] assert result["drawback"] == result["max_drawdown"] @pytest.mark.parametrize( "metric", [ annualized_return, annualized_volatility, sharpe_ratio, sortino_ratio, max_drawdown, calmar_ratio, win_rate, ], ) def test_metrics_reject_non_series_input(metric) -> None: with pytest.raises(TypeError, match=r"expected pd\.Series"): metric([0.01, 0.02]) def test_short_and_empty_series_return_zero() -> None: assert annualized_return(pd.Series(dtype=float)) == 0.0 assert annualized_volatility(pd.Series([0.01])) == 0.0 assert max_drawdown(pd.Series([0.01])) == 0.0 assert win_rate(pd.Series(dtype=float)) == 0.0 def test_benchmark_summary_uses_aligned_active_returns_and_regression() -> None: dates = pd.date_range("2026-01-05", periods=4, freq="B") benchmark = pd.Series([-0.01, 0.0, 0.01, 0.02], index=dates) portfolio = 0.001 + 1.5 * benchmark active = portfolio - benchmark result = benchmark_summary(portfolio, benchmark) assert result["n_observations"] == 4 assert result["tracking_error"] == pytest.approx( active.std() * np.sqrt(TRADING_DAYS_PER_YEAR) ) assert result["information_ratio"] == pytest.approx( active.mean() / active.std() * np.sqrt(TRADING_DAYS_PER_YEAR) ) assert result["beta"] == pytest.approx(1.5) assert result["alpha"] == pytest.approx(1.001**TRADING_DAYS_PER_YEAR - 1.0) def test_benchmark_summary_rejects_silent_calendar_alignment() -> None: portfolio = pd.Series([0.01, 0.02], index=pd.date_range("2026-01-05", periods=2)) benchmark = pd.Series([0.01, 0.02], index=pd.date_range("2026-01-06", periods=2)) with pytest.raises(ValueError, match="matching indexes"): benchmark_summary(portfolio, benchmark) def test_benchmark_summary_rejects_missing_observations() -> None: dates = pd.date_range("2026-01-05", periods=2) portfolio = pd.Series([0.01, np.nan], index=dates) benchmark = pd.Series([0.0, 0.01], index=dates) with pytest.raises(ValueError, match="finite"): benchmark_summary(portfolio, benchmark) def test_benchmark_summary_marks_constant_benchmark_regression_unestimable() -> None: dates = pd.date_range("2026-01-05", periods=3) portfolio = pd.Series([0.01, -0.01, 0.02], index=dates) benchmark = pd.Series([0.0, 0.0, 0.0], index=dates) result = benchmark_summary(portfolio, benchmark) assert np.isnan(result["alpha"]) assert np.isnan(result["beta"])