import itertools import numpy as np import pytest from quantcore.config import Settings from quantcore.measurement.risk import scenario_cvar from quantcore.optimization.problem import MeanCVaRProblem, OptimizationConstraints, ProblemSpec, TransactionCosts from quantcore.optimization.solvers import HighsSolver SETTINGS = Settings(cuopt_url="http://127.0.0.1:9") def make_spec(seed=0, n=6, S=300, **kw): rng = np.random.default_rng(seed) symbols = [f"S{i}" for i in range(n)] prices = rng.uniform(20, 400, n) scen = rng.standard_normal((S, n)) * 0.03 + rng.uniform(-0.002, 0.006, n) sectors = ["Tech", "Fin", "Health"] * (n // 3) + ["Tech"] * (n % 3) base = dict(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=100_000.0, confidence=0.95, cvar_limit=4_000.0) base.update(kw) return ProblemSpec(**base) def solve(spec): problem = MeanCVaRProblem(spec) model = problem.build() res = HighsSolver(SETTINGS).solve(model, time_limit=60) assert res.ok, res return problem, model, res, problem.decode(res.x) def test_new_portfolio_respects_all_constraints(): # with a 4000 limit the 10 % caps bind first (CVaR ~1040); at 800 the CVaR limit must bind spec = make_spec(cvar_limit=800.0, constraints=OptimizationConstraints(max_weight=0.10, max_sector_weight=0.25)) problem, model, res, d = solve(spec) W = spec.wealth assert (d["shares"] >= 0).all() and np.issubdtype(d["shares"].dtype, np.integer) assert d["cash_end"] >= -1e-6 assert (d["weights"] <= 0.10 + 1e-9).all() for sec in set(spec.sectors): assert d["value"][np.array(spec.sectors) == sec].sum() <= 0.25 * W + 1e-6 # the CVaR of the decoded portfolio (recomputed from the scenarios) is within the limit and binding # up to the granularity of whole shares; alpha is *a* minimiser of the RU function, not necessarily the VaR assert d["cvar"] <= spec.cvar_limit + 1e-6 assert d["cvar"] >= 0.98 * spec.cvar_limit assert d["var"] <= d["cvar"] # objective equals expected P&L net of costs assert res.objective == pytest.approx(d["expected_pnl"], rel=1e-6) assert d["total_cost"] == pytest.approx(0.001 * d["invested"], rel=1e-9) def test_brute_force_optimality_two_assets(): spec = make_spec(n=2, S=80, cash=5_000.0, cvar_limit=300.0, constraints=OptimizationConstraints(max_weight=0.6, max_sector_weight=1.0)) _, _, res, d = solve(spec) p, scen = spec.prices, spec.scenarios best, best_obj = None, -np.inf max_n = (0.6 * spec.wealth / p).astype(int) + 1 for n0, n1 in itertools.product(range(max_n[0] + 1), range(max_n[1] + 1)): n = np.array([n0, n1]) cost = 0.001 * (n * p).sum() if (n * p).sum() + cost > spec.cash + 1e-9 or (n * p > 0.6 * spec.wealth + 1e-9).any(): continue pnl = scen @ (n * p) - cost _, cvar = scenario_cvar(-pnl, 0.95) if cvar <= 300.0 + 1e-6 and pnl.mean() > best_obj: best, best_obj = n, pnl.mean() assert best_obj == pytest.approx(res.objective, rel=1e-6) assert (d["shares"] == best).all() def test_rebalancing_links_trades_and_costs(): holdings = np.array([50, 0, 20, 0, 10, 0], dtype=float) spec = make_spec(cash=20_000.0, holdings=holdings, cvar_limit=3_000.0, costs=TransactionCosts(0.002, 0.003, fixed_per_trade=25.0), constraints=OptimizationConstraints(max_weight=0.15, max_sector_weight=0.4, max_turnover=0.5)) problem, model, res, d = solve(spec) assert problem.rebalancing and problem.use_trade_bin np.testing.assert_array_equal(d["shares"], holdings.astype(int) + d["buys"] - d["sells"]) assert not ((d["buys"] > 0) & (d["sells"] > 0)).any() # never buy and sell the same stock n_trades = int(((d["buys"] + d["sells"]) > 0).sum()) expected_cost = 0.002 * (d["buys"] * spec.prices).sum() + 0.003 * (d["sells"] * spec.prices).sum() + 25.0 * n_trades assert d["total_cost"] == pytest.approx(expected_cost) assert d["cash_end"] == pytest.approx(spec.cash + (d["sells"] * spec.prices).sum() - (d["buys"] * spec.prices).sum() - d["total_cost"]) assert d["cash_end"] >= -1e-6 assert d["turnover"] <= 0.5 + 1e-9 assert d["cvar"] <= 3_000.0 + 1e-6 def test_objectives_min_cvar_and_utility(): spec = make_spec(objective="min_cvar", cvar_limit=None, target_pnl=40.0) _, _, res, d = solve(spec) assert d["expected_pnl"] >= 40.0 - 1e-6 assert res.objective == pytest.approx(d["cvar"], rel=1e-6) spec_u = make_spec(objective="utility", cvar_limit=None, risk_aversion=0.5) _, _, res_u, d_u = solve(spec_u) assert res_u.objective == pytest.approx(d_u["expected_pnl"] - 0.5 * d_u["cvar"], rel=1e-6) def test_cardinality_and_min_position(): spec = make_spec(cvar_limit=5_000.0, constraints=OptimizationConstraints( max_weight=0.3, max_sector_weight=1.0, max_positions=2, min_position_value=5_000.0)) problem, model, res, d = solve(spec) assert problem.use_pos_bin held = d["value"] > 0 assert held.sum() <= 2 assert (d["value"][held] >= 5_000.0 - 1e-6).all() def test_spec_validation(): with pytest.raises(ValueError): make_spec(cvar_limit=None) # max_return needs a limit with pytest.raises(ValueError): make_spec(holdings=np.array([-1, 0, 0, 0, 0, 0])) with pytest.raises(ValueError): make_spec(objective="min_cvar", cvar_limit=None) def test_legacy_position_above_cap_is_sold_down_and_invested_bound(): holdings = np.zeros(6) holdings[0] = 1000.0 # far above the 10 % cap spec = make_spec(cash=10_000.0, holdings=holdings, cvar_limit=50_000.0, constraints=OptimizationConstraints(max_weight=0.10, max_sector_weight=1.0)) _, model, res, d = solve(spec) assert d["weights"][0] <= 0.10 + 1e-9 and d["sells"][0] > 0 # the single-stock bound is min(max_weight, max_invested) * W, not their product spec2 = make_spec(cvar_limit=50_000.0, constraints=OptimizationConstraints(max_weight=0.10, max_sector_weight=1.0, max_invested=0.5)) problem = MeanCVaRProblem(spec2) np.testing.assert_array_equal(problem._max_shares(), np.floor(0.10 * spec2.wealth / spec2.prices + 1e-9)) spec3 = make_spec(cvar_limit=50_000.0, constraints=OptimizationConstraints(max_weight=0.6, max_sector_weight=1.0, max_invested=0.5)) np.testing.assert_array_equal(MeanCVaRProblem(spec3)._max_shares(), np.floor(0.5 * spec3.wealth / spec3.prices + 1e-9)) def test_documentation_example_rounding_fails(): """The two-stock instance of docs/optimization_model.tex, Example 4.1 (numbers as printed there).""" from scipy.optimize import Bounds, LinearConstraint, milp prices = np.array([572.0, 2208.0]) scen = np.array([[0.005, -0.082], [0.014, -0.052], [-0.069, -0.020], [-0.002, 0.055], [-0.009, 0.083], [0.048, 0.064], [0.044, 0.028], [-0.011, 0.042]]) spec = ProblemSpec(symbols=["A", "B"], prices=prices, scenarios=scen, sectors=["X", "Y"], cash=10_000.0, confidence=0.75, cvar_limit=250.0, costs=TransactionCosts(0.0, 0.0), constraints=OptimizationConstraints(max_weight=0.7, max_sector_weight=1.0)) problem = MeanCVaRProblem(spec) model = problem.build() # LP relaxation: (2.6605, 1.7876), objective 62.02, CVaR binding lp = milp(-model.c, constraints=LinearConstraint(model.A, model.row_lb, model.row_ub), integrality=np.zeros(model.n_vars), bounds=Bounds(model.var_lb, model.var_ub)) np.testing.assert_allclose(lp.x[:2], [2.6605, 1.7876], atol=1e-4) assert -lp.fun == pytest.approx(62.02, abs=0.01) def evaluate(n): pnl = scen @ (n * prices) return pnl.mean(), scenario_cvar(-pnl, 0.75)[1] assert evaluate(np.array([3, 2]))[1] == pytest.approx(280.13, abs=0.01) # nearest rounding: infeasible assert evaluate(np.array([2, 1]))[0] == pytest.approx(35.43, abs=0.01) # rounding down: feasible, poor res = HighsSolver(SETTINGS).solve(model) assert res.ok d = problem.decode(res.x) np.testing.assert_array_equal(d["shares"], [7, 1]) # the integer optimum has a different structure assert d["expected_pnl"] == pytest.approx(42.58, abs=0.01) and d["cvar"] == pytest.approx(240.74, abs=0.01)