diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 959e6d8..44a296d 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -10,6 +10,7 @@ from quant_engine.metrics import ( TRADING_DAYS_PER_YEAR, annualized_return, annualized_volatility, + benchmark_summary, calmar_ratio, max_drawdown, sharpe_ratio, @@ -91,3 +92,50 @@ def test_short_and_empty_series_return_zero() -> None: 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"]) diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py index b41ff88..0923d27 100644 --- a/tests/test_research_pipeline.py +++ b/tests/test_research_pipeline.py @@ -265,3 +265,29 @@ def test_factor_backtest_starts_at_first_signal_instead_of_price_warmup() -> Non pd.Series([1.0, 1.1, 1.2], index=dates[2:], name="nav"), ) assert result.stats()["n_days"] == 3 + + +def test_factor_backtest_exposes_net_benchmark_metrics() -> None: + dates = _calendar() + scores = pd.DataFrame({"A": [1.0]}, index=dates[:1]) + prices = pd.DataFrame({"A": [10.0, 10.0, 11.0, 11.0]}, index=dates) + result = run_factor_backtest_research( + scores, + prices, + prices, + top_k=1, + execution_price_field="open", + valuation_price_field="close", + config=ExecutionConfig( + commission_bps=0, + stamp_tax_bps=0, + slippage_bps=0, + min_trade_amount=0, + ), + ) + benchmark = pd.Series([0.0, 0.01, -0.01, 0.0], index=dates) + + relative = result.benchmark_stats(benchmark) + + assert relative["n_observations"] == len(result.returns) + assert relative["tracking_error"] > 0