"""Tests for the methods package — portfolio optimization methods from research notebooks.""" import numpy as np import pytest from methods import ( equal_weight, hrp_weights, hybrid_pipeline_weights, markowitz_max_sharpe, min_variance, qubo_sa_weights, target_return_frontier, vqe_weights, ) from methods.hybrid_pipeline import HybridPipelineInfo def _sample_data(): """15-asset dataset matching notebook 05 (seeded for reproducibility).""" np.random.seed(2025) n = 15 mu = np.array( [ 0.18, 0.20, 0.15, 0.22, 0.17, 0.35, 0.12, 0.13, 0.16, 0.18, 0.08, 0.14, 0.10, 0.10, 0.09, ] ) sigma_vec = np.array( [ 0.28, 0.25, 0.24, 0.30, 0.35, 0.42, 0.20, 0.22, 0.22, 0.24, 0.16, 0.18, 0.20, 0.22, 0.24, ] ) corr = np.full((n, n), 0.30) np.fill_diagonal(corr, 1.0) for start, size in [(0, 6), (6, 4), (10, 3), (13, 2)]: for i in range(start, start + size): for j in range(start, start + size): if i != j: corr[i, j] = 0.68 noise = np.random.uniform(-0.04, 0.04, (n, n)) noise = (noise + noise.T) / 2 np.fill_diagonal(noise, 0) corr = np.clip(corr + noise, -0.95, 0.95) np.fill_diagonal(corr, 1.0) Sigma = np.outer(sigma_vec, sigma_vec) * corr eig = np.linalg.eigvalsh(Sigma) if np.any(eig < 0): Sigma += (-eig.min() + 1e-6) * np.eye(n) return mu, Sigma class TestWeightsSumToOne: """All methods must return weights summing to ~1.""" @pytest.fixture def data(self): return _sample_data() def test_equal_weight(self, data): mu, Sigma = data w = equal_weight(mu, Sigma) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) def test_markowitz_max_sharpe(self, data): mu, Sigma = data w = markowitz_max_sharpe(mu, Sigma) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) def test_min_variance(self, data): mu, Sigma = data w = min_variance(mu, Sigma) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) def test_hrp_weights(self, data): mu, Sigma = data w = hrp_weights(mu, Sigma) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) def test_qubo_sa_weights(self, data): mu, Sigma = data w = qubo_sa_weights(mu, Sigma, K=6) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) assert np.sum(w > 0) == 6 def test_vqe_weights(self, data): mu, Sigma = data w = vqe_weights(mu, Sigma, n_restarts=3) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) def test_hybrid_pipeline_weights(self, data): mu, Sigma = data w, info = hybrid_pipeline_weights(mu, Sigma, K_screen=10, K_select=5) assert np.isclose(w.sum(), 1.0) assert len(w) == len(mu) assert np.sum(w > 0) == 5 class TestTargetReturnFrontier: """target_return_frontier returns correct structure.""" def test_frontier_structure(self): mu, Sigma = _sample_data() frontier = target_return_frontier(mu, Sigma, n_points=5) assert isinstance(frontier, list) assert len(frontier) >= 1 for pt in frontier: assert "target_return" in pt assert "volatility" in pt assert "sharpe" in pt assert "weights" in pt assert len(pt["weights"]) == len(mu) assert np.isclose(sum(pt["weights"]), 1.0) class TestHybridPipelineInfo: """hybrid_pipeline_weights returns (weights, info) with correct shapes.""" def test_info_keys(self): mu, Sigma = _sample_data() w, info = hybrid_pipeline_weights(mu, Sigma, K_screen=10, K_select=5) assert len(info.stage1_screened_idx) == 10 assert info.stage1_ic is not None assert len(info.stage2_selected_idx) == 5 assert hasattr(info, "stage2_qubo_obj") assert hasattr(info, "stage3_sharpe") def test_info_indices_valid(self): mu, Sigma = _sample_data() w, info = hybrid_pipeline_weights(mu, Sigma, K_screen=8, K_select=4) assert all(isinstance(i, int) for i in info.stage1_screened_idx) assert all(isinstance(i, int) for i in info.stage2_selected_idx) assert all(0 <= i < len(mu) for i in info.stage1_screened_idx) assert all(0 <= i < len(mu) for i in info.stage2_selected_idx) class TestSmoke: """Basic smoke test: run each method on the 15-asset dataset.""" def test_all_methods_run(self): mu, Sigma = _sample_data() methods = [ lambda: equal_weight(mu, Sigma), lambda: markowitz_max_sharpe(mu, Sigma), lambda: min_variance(mu, Sigma), lambda: hrp_weights(mu, Sigma), lambda: qubo_sa_weights(mu, Sigma, K=6), lambda: vqe_weights(mu, Sigma, n_restarts=2), lambda: hybrid_pipeline_weights(mu, Sigma, K_screen=10, K_select=5)[0], ] for fn in methods: w = fn() assert w is not None assert isinstance(w, np.ndarray) assert len(w) == 15 assert np.all(w >= -1e-6) assert np.isclose(w.sum(), 1.0, rtol=1e-5)