This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user