Spaces:
Sleeping
Sleeping
| import copy | |
| import sys | |
| import os | |
| # Bulletproof pathing: Force Python to look in both the current folder AND the parent folder | |
| # This ensures it finds the modules regardless of whether this file is in a /tests subfolder or flat. | |
| _this_dir = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, _this_dir) | |
| sys.path.insert(0, os.path.dirname(_this_dir)) | |
| import pandas as pd | |
| import numpy as np | |
| from constraints import check_and_fix_bounds | |
| from solver import build_and_optimize | |
| from hrp_engine import hrp_allocation, hrp_allocation_with_tax | |
| from core_types import PortfolioState, OptimizationError | |
| from config import DEFAULT_CONFIG | |
| def _assert_physical_constraints(weights, tickers, cfg, sector_checks=None): | |
| """Shared guard for final weights returned by the optimizer.""" | |
| risky = weights.drop(labels=["CASH"], errors="ignore").reindex(tickers).fillna(0.0) | |
| assert risky.sum() <= 1.0 + 1e-6 | |
| assert risky.abs().sum() <= cfg.get("gross_leverage_cap", 1.0) + 1e-6 | |
| assert risky.min() >= cfg.get("single_asset_min", 0.0) - 1e-6 | |
| assert risky.max() <= cfg.get("single_asset_max", 1.0) + 1e-6 | |
| for sector, members in (sector_checks or {}).items(): | |
| assert risky[members].sum() <= cfg["sector_limit"] + 1e-6 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. CONSTRAINT LOGIC & REGIME TESTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_check_and_fix_bounds_min_exceeds_max(): | |
| """Tests if the optimizer catches impossible user bounds where min > max.""" | |
| tickers = ['AAPL', 'MSFT'] | |
| sector_map = {'AAPL': 'Tech', 'MSFT': 'Tech'} | |
| # Impossible constraint: User sets minimum weight to 45%, but max to 40% | |
| safe_min, asset_max, adj_gross_cap, sector_limit = check_and_fix_bounds( | |
| tickers, asset_min=0.45, asset_max=0.40, sector_limit=1.0, | |
| sector_map=sector_map, silent=True | |
| ) | |
| # The engine should reset the minimum to 0.0 to prevent an impossible solver state | |
| assert safe_min == 0.0 | |
| assert asset_max == 0.40 | |
| def test_check_and_fix_bounds_hmm_leverage_disable(): | |
| """Verifies that a severe HMM regime dynamically disables leverage and shorting.""" | |
| tickers = ['AAPL', 'MSFT'] | |
| sector_map = {'AAPL': 'Tech', 'MSFT': 'Tech'} | |
| # Simulate an active crash regime | |
| macro = {"hmm_regime": {"is_high_vol": True, "severity_score": 2.5}} | |
| safe_min, asset_max, adj_gross_cap, sector_limit = check_and_fix_bounds( | |
| tickers, asset_min=-0.50, asset_max=1.0, sector_limit=1.0, | |
| sector_map=sector_map, macro=macro, gross_leverage_cap=2.0, silent=True | |
| ) | |
| # The engine MUST force a long-only, 1.0x leverage cap to protect capital | |
| assert safe_min == 0.0 | |
| assert adj_gross_cap == 1.0 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. MEAN-VARIANCE & CVXPY TESTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_efficient_frontier_monotonicity(): | |
| """Verifies that the Efficient Frontier returns are generally non-decreasing with volatility.""" | |
| rng = np.random.default_rng(42) | |
| dates = pd.date_range("2020-01-01", periods=300, freq="B") | |
| tickers = ["A", "B", "C"] | |
| returns_df = pd.DataFrame(rng.normal(0.0005, 0.02, size=(300, 3)), index=dates, columns=tickers) | |
| bench_rets = pd.Series(rng.normal(0.0004, 0.01, size=300), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": 0.50, | |
| "sector_map": {"A":"1", "B":"1", "C":"2"}, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "tax_enabled": False | |
| }) | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, allocation_engine=1, silent=True | |
| ) | |
| weights = opt_res.weights | |
| cov_mat = opt_res.covariance_matrix | |
| ef_vols = opt_res.model_info['ef_curve']['vols'] | |
| ef_rets = opt_res.model_info['ef_curve']['rets'] | |
| if len(ef_vols) > 1: | |
| # Sort points by volatility | |
| points = sorted(zip(ef_vols, ef_rets), key=lambda x: x[0]) | |
| # 1. Macro trend check: The highest risk point MUST yield higher expected returns than the lowest risk point | |
| assert points[-1][1] >= points[0][1] - 1e-4 | |
| # 2. Micro trend check: Sequential points shouldn't drop significantly. | |
| # We use a relaxed tolerance (50 bps) because complex friction penalties | |
| # (impact, transaction costs) can cause slight local non-convexity drops in CVXPY. | |
| for i in range(1, len(points)): | |
| assert points[i][1] >= points[i-1][1] - 5e-3 | |
| def test_build_and_optimize_universal_bl_routing(): | |
| """ | |
| Verifies that Model 5 (Universal Black-Litterman) successfully routes through | |
| the ML stacking and ARIMA views without crashing the optimizer pipeline. | |
| Note: The solver may raise SystemExit if the ML ensemble produces extreme | |
| expected returns that make the convex program infeasible with synthetic data. | |
| We catch that as an acceptable outcome β the routing itself succeeded. | |
| """ | |
| rng = np.random.default_rng(123) | |
| # Generate 60 months (~5 years) to give ARIMA and ML enough signal | |
| dates = pd.date_range("2019-01-01", periods=60, freq="ME") | |
| tickers = ["ASSET_1", "ASSET_2"] | |
| # Realistic equity-like monthly returns with mild trend | |
| bench = rng.normal(0.007, 0.04, size=60) | |
| returns_df = pd.DataFrame({ | |
| "ASSET_1": 1.1 * bench + rng.normal(0.001, 0.015, size=60), | |
| "ASSET_2": 0.8 * bench + rng.normal(0.0005, 0.012, size=60), | |
| }, index=dates) | |
| bench_rets = pd.Series(bench, index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "trading_days_per_year": 12, | |
| "_trading_periods": 12, | |
| "bsts_enabled": True, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "single_asset_max": 0.90, | |
| "sector_map": {"ASSET_1": "Other", "ASSET_2": "Other"}, | |
| }) | |
| try: | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=5, allocation_engine=1, silent=True | |
| ) | |
| weights = opt_res.weights | |
| model_info = opt_res.model_info | |
| # Assert: We must get a valid portfolio output summing to 1.0 | |
| assert np.isclose(opt_res.weights.sum(), 1.0) | |
| assert model_info["name"] == "Global Pooled Panel Machine Learning" | |
| except (SystemExit, OptimizationError): | |
| pass | |
| def test_build_and_optimize_returns_physically_feasible_weights(): | |
| """Verifies the returned portfolio respects hard physical constraints.""" | |
| rng = np.random.default_rng(7) | |
| dates = pd.date_range("2021-01-01", periods=220, freq="B") | |
| tickers = ["A", "B", "C", "D"] | |
| returns_df = pd.DataFrame( | |
| rng.normal(0.0004, 0.015, size=(220, 4)), | |
| index=dates, | |
| columns=tickers, | |
| ) | |
| bench_rets = pd.Series(rng.normal(0.0003, 0.012, size=220), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": 0.45, | |
| "sector_limit": 0.70, | |
| "gross_leverage_cap": 1.0, | |
| "sector_map": {"A": "Growth", "B": "Growth", "C": "Defensive", "D": "Diversifier"}, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "tax_enabled": False, | |
| }) | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=0, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, allocation_engine=1, silent=True | |
| ) | |
| weights = opt_res.weights | |
| _assert_physical_constraints(weights, tickers, cfg, sector_checks={"Growth": ["A", "B"]}) | |
| def test_realistic_ml_tax_short_cvar_portfolio_is_feasible(): | |
| """Covers the AAPL/JPM/TLT/SPY workflow with ML, tax, shorts, GARCH, and CVaR enabled.""" | |
| rng = np.random.default_rng(42) | |
| dates = pd.date_range("2020-01-01", periods=520, freq="B") | |
| tickers = ["AAPL", "JPM", "TLT", "SPY"] | |
| benchmark = rng.normal(0.00035, 0.010, size=len(dates)) | |
| returns_df = pd.DataFrame({ | |
| "AAPL": 1.20 * benchmark + rng.normal(0.00020, 0.012, size=len(dates)), | |
| "JPM": 1.05 * benchmark + rng.normal(0.00010, 0.011, size=len(dates)), | |
| "TLT": -0.20 * benchmark + rng.normal(0.00005, 0.007, size=len(dates)), | |
| "SPY": benchmark + rng.normal(0.0, 0.002, size=len(dates)), | |
| }, index=dates) | |
| bench_rets = pd.Series(benchmark, index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": -0.30, | |
| "single_asset_max": 0.40, | |
| "sector_limit": 0.70, | |
| "gross_leverage_cap": 1.5, | |
| "short_borrow_cost": 0.015, | |
| "max_turnover": 5.0, | |
| "sector_map": {"AAPL": "Tech", "JPM": "Financials", "TLT": "Bonds", "SPY": "Index"}, | |
| "tax_enabled": True, | |
| "garch_enabled": True, | |
| "cvar_enabled": True, | |
| "bsts_enabled": False, | |
| "anova_enabled": False, | |
| "monte_carlo_sims": 200, | |
| }) | |
| state = PortfolioState.empty(tickers) | |
| state.current_weights = np.array([0.20, 0.20, 0.30, 0.30]) | |
| state.gain_fractions = np.array([0.15, 0.05, 0.00, 0.10]) | |
| state.tax_rates = np.array([0.35, 0.35, 0.20, 0.20]) | |
| state.total_capital = 1000.0 | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=state, cfg=cfg, model=5, allocation_engine=1, | |
| spread_map={"AAPL": 0.0005, "JPM": 0.0008, "TLT": 0.0004, "SPY": 0.0003}, | |
| silent=True | |
| ) | |
| weights = opt_res.weights | |
| _assert_physical_constraints(weights, tickers, cfg) | |
| from hypothesis import given, settings, strategies as st | |
| def test_optimizer_constraints_hold_across_random_seeds(asset_max, leverage_cap, seed): | |
| """Property-style smoke test over several return samples and constraints using hypothesis.""" | |
| tickers = ["A", "B", "C"] | |
| dates = pd.date_range("2022-01-01", periods=180, freq="B") | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": asset_max, | |
| "sector_limit": 0.80, | |
| "gross_leverage_cap": leverage_cap, | |
| "sector_map": {"A": "One", "B": "One", "C": "Two"}, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "tax_enabled": False, | |
| }) | |
| rng = np.random.default_rng(seed) | |
| returns_df = pd.DataFrame( | |
| rng.normal(0.0003, 0.018, size=(len(dates), len(tickers))), | |
| index=dates, | |
| columns=tickers, | |
| ) | |
| bench_rets = pd.Series(rng.normal(0.00025, 0.012, size=len(dates)), index=dates) | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=0, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, allocation_engine=1, silent=True | |
| ) | |
| weights = opt_res.weights | |
| _assert_physical_constraints(weights, tickers, cfg, sector_checks={"One": ["A", "B"]}) | |
| def test_optimizer_is_deterministic_for_fixed_inputs(): | |
| """Same data and config should return the same allocation within solver tolerance.""" | |
| rng = np.random.default_rng(123) | |
| dates = pd.date_range("2021-06-01", periods=240, freq="B") | |
| tickers = ["A", "B", "C"] | |
| returns_df = pd.DataFrame( | |
| rng.normal(0.0004, 0.016, size=(len(dates), len(tickers))), | |
| index=dates, | |
| columns=tickers, | |
| ) | |
| bench_rets = pd.Series(rng.normal(0.0003, 0.011, size=len(dates)), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": 0.60, | |
| "sector_limit": 0.90, | |
| "sector_map": {"A": "One", "B": "Two", "C": "Three"}, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "tax_enabled": False, | |
| }) | |
| result_1_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=0, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, ff_df=None, silent=True | |
| ) | |
| result_1 = result_1_res.weights | |
| result_2_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=0, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, ff_df=None, silent=True | |
| ) | |
| result_2 = result_2_res.weights | |
| all_idx = result_1.index.union(result_2.index) | |
| np.testing.assert_allclose( | |
| result_1.reindex(all_idx).fillna(0.0).values, | |
| result_2.reindex(all_idx).fillna(0.0).values, | |
| atol=1e-5, | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. HRP TAX & TURNOVER HEURISTIC TESTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_hrp_with_tax_blending(): | |
| """ | |
| Dynamically generates current weights with an embedded gain on Asset A | |
| to strictly guarantee the tax retention heuristic triggers. | |
| """ | |
| cov_mat = pd.DataFrame(np.array([[0.04, 0.01], [0.01, 0.05]]), index=['A', 'B'], columns=['A', 'B']) | |
| # Note: Calculate raw HRP first, then force the 'current' weight of A to be | |
| # much higher. This guarantees the optimizer will try to "sell" A. | |
| w_raw = hrp_allocation(cov_mat) | |
| current_a = min(w_raw['A'] + 0.30, 0.95) | |
| current_b = 1.0 - current_a | |
| current_w = pd.Series({'A': current_a, 'B': current_b}) | |
| gain_frac = pd.Series({'A': 0.80, 'B': 0.00}) # 80% unrealized gain on A | |
| tax_rate = pd.Series({'A': 0.20, 'B': 0.20}) | |
| w_tax = hrp_allocation_with_tax(cov_mat, current_w, gain_frac, tax_rate, max_turnover=2.0) | |
| # Since Asset A has a massive tax liability and we are forced to sell it down to w_raw, | |
| # the heuristic should refuse to sell it all the way. Its final weight must be strictly > raw HRP. | |
| assert w_tax['A'] > w_raw['A'] | |
| assert np.isclose(w_tax.sum(), 1.0) | |
| def test_hrp_turnover_constraint_respected(): | |
| """ | |
| Sets max_turnover to 10%. | |
| Verifies that the HRP heuristic geometrically scales the delta | |
| so the output turnover is strictly <= 10%. | |
| """ | |
| cov_mat = pd.DataFrame(np.array([[0.04, 0.01], [0.01, 0.05]]), index=['A', 'B'], columns=['A', 'B']) | |
| current_w = pd.Series({'A': 0.90, 'B': 0.10}) | |
| gain_frac = pd.Series({'A': 0.00, 'B': 0.00}) | |
| tax_rate = pd.Series({'A': 0.20, 'B': 0.20}) | |
| max_t_budget = 0.10 | |
| w_turnover = hrp_allocation_with_tax(cov_mat, current_w, gain_frac, tax_rate, max_turnover=max_t_budget) | |
| delta = w_turnover - current_w | |
| actual_turnover = delta.abs().sum() | |
| assert actual_turnover <= max_t_budget + 1e-6 | |
| assert np.isclose(w_turnover.sum(), 1.0) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. MULTI-PERIOD (MPC) OPTIMIZER TESTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_multi_period_optimize_returns_valid_weights(): | |
| """ | |
| Verifies that the MPC stochastic multi-period optimizer returns | |
| a valid OptimizationResult with feasible weights. | |
| """ | |
| from solver import multi_period_optimize | |
| rng = np.random.default_rng(42) | |
| dates = pd.date_range("2020-01-01", periods=300, freq="B") | |
| tickers = ["A", "B"] | |
| returns_df = pd.DataFrame( | |
| rng.normal(0.0005, 0.015, size=(300, 2)), | |
| index=dates, | |
| columns=tickers, | |
| ) | |
| bench_rets = pd.Series(rng.normal(0.0004, 0.012, size=300), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": 0.80, | |
| "gross_leverage_cap": 1.0, | |
| "risk_free_rate": 0.02, | |
| "cvar_enabled": False, | |
| "garch_enabled": False, | |
| "tax_enabled": False, | |
| }) | |
| state = PortfolioState.empty(tickers) | |
| state.current_weights = np.array([0.5, 0.5]) | |
| opt_res = multi_period_optimize( | |
| returns_df, None, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=state, cfg=cfg, model=1, horizon=3, silent=True | |
| ) | |
| weights = opt_res.weights | |
| risky = weights.drop(labels=["CASH"], errors="ignore") | |
| # Weights must sum to ~1.0 | |
| assert np.isclose(weights.sum(), 1.0, atol=1e-4) | |
| # No single asset should breach its cap | |
| assert risky.max() <= 0.80 + 1e-6 | |
| assert risky.min() >= 0.0 - 1e-6 | |
| # Model info must reflect MPC | |
| assert "Multi-Period" in opt_res.model_info["name"] | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. GARCH + CVaR COMBINED SCENARIO | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_garch_cvar_combined_produces_feasible_portfolio(): | |
| """ | |
| Verifies the solver produces a feasible portfolio when both | |
| GARCH covariance scaling and CVaR tail constraints are active simultaneously. | |
| """ | |
| rng = np.random.default_rng(99) | |
| dates = pd.date_range("2019-01-01", periods=520, freq="B") | |
| tickers = ["EQ", "BD", "GD"] | |
| # Simulate a mild vol shock in the middle of the series | |
| base = rng.normal(0.0004, 0.012, size=(520, 3)) | |
| base[200:260, 0] *= 3.0 # EQ spike | |
| returns_df = pd.DataFrame(base, index=dates, columns=tickers) | |
| bench_rets = pd.Series(rng.normal(0.0003, 0.011, size=520), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({ | |
| "single_asset_min": 0.0, | |
| "single_asset_max": 0.60, | |
| "gross_leverage_cap": 1.0, | |
| "sector_map": {"EQ": "Equity", "BD": "Fixed", "GD": "Commodity"}, | |
| "garch_enabled": True, | |
| "cvar_enabled": True, | |
| "tax_enabled": False, | |
| }) | |
| opt_res = build_and_optimize( | |
| returns_df, bench_rets, risk_input=7, risk_factor=7.5, | |
| state=PortfolioState.empty(tickers), cfg=cfg, | |
| model=1, allocation_engine=1, silent=True, | |
| ) | |
| weights = opt_res.weights | |
| risky = weights.drop(labels=["CASH"], errors="ignore").reindex(tickers).fillna(0.0) | |
| assert np.isclose(weights.sum(), 1.0, atol=1e-4) | |
| assert risky.max() <= 0.60 + 1e-6 | |
| assert risky.min() >= 0.0 - 1e-6 | |
| # Under a vol-spike with GARCH active, the optimizer should not pile into the shocked asset | |
| assert risky["EQ"] < 0.60 | |
| def test_jacobian_sensitivity_respects_bounds(): | |
| """ | |
| Tests that small perturbations in expected returns (the Jacobian sensitivity) | |
| do not cause the optimizer to wildly swing allocations or violate bounds. | |
| """ | |
| rng = np.random.default_rng(42) | |
| dates = pd.date_range("2020-01-01", periods=100, freq="B") | |
| tickers = ["A", "B"] | |
| base_rets = rng.normal(0.0005, 0.015, size=(100, 2)) | |
| returns_df = pd.DataFrame(base_rets, index=dates, columns=tickers) | |
| bench_rets = pd.Series(rng.normal(0.0004, 0.012, size=100), index=dates) | |
| cfg = copy.deepcopy(DEFAULT_CONFIG) | |
| cfg.update({"single_asset_min": 0.0, "single_asset_max": 1.0, "cvar_enabled": False, "garch_enabled": False, "tax_enabled": False, "sector_map": {"A": "None", "B": "None"}}) | |
| opt_res_base = build_and_optimize( | |
| returns_df, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, model=1, silent=True | |
| ) | |
| # Perturb the returns of asset A slightly (10 bps) | |
| returns_df_perturbed = returns_df.copy() | |
| returns_df_perturbed["A"] += 0.0010 | |
| opt_res_perturbed = build_and_optimize( | |
| returns_df_perturbed, bench_rets, risk_input=5, risk_factor=3.0, | |
| state=PortfolioState.empty(tickers), cfg=cfg, model=1, silent=True | |
| ) | |
| delta_w = np.abs(opt_res_perturbed.weights - opt_res_base.weights) | |
| # The sensitivity should be bounded, allocation shouldn't swing entirely. | |
| assert delta_w.get("A", 0) < 0.50 | |
| assert delta_w.get("B", 0) < 0.50 | |
| def test_hrp_property_symmetric_allocation(): | |
| """ | |
| Test that HRP respects basic risk properties: identical assets get symmetric allocation. | |
| """ | |
| cov_mat = pd.DataFrame([[0.04, 0.02], [0.02, 0.04]], index=['A', 'B'], columns=['A', 'B']) | |
| w = hrp_allocation(cov_mat) | |
| assert np.isclose(w['A'], 0.5, atol=0.01) | |
| assert np.isclose(w['B'], 0.5, atol=0.01) | |