Portfolio_Risk_Management/tests/test_large_scale.py
herzogflorian fac41ffdfc Large-scale strategy: relaxation, tail rows, screening, round-and-repair
The solver benchmark (GPU_Optimization/cuopt_benchmark, November 2026) showed
that the integer mean-CVaR MILP is an LP in disguise: HiGHS closes it at the
root node, only the tail scenario rows bind, and the cardinality constraint is
the only hard combinatorics. quantcore.optimization.large_scale exploits that:
LP relaxation by tail-row generation (evaluated at the continuous point, its
objective a valid bound), screening to the top names under a cardinality limit,
a boxed round-and-repair MILP, certification against the bound with escalation
to the integer row generation otherwise. Solver choice by size (HiGHS below
16 000 rows, cuOpt above).

Request option `strategy` ('auto' | 'milp' | 'large_scale'; auto from 200
candidates or 32 768 scenarios) in the service, the API schema and both CLIs.
Settings from the benchmark: cuOpt presolve off (its asynchronous GPU heuristic
otherwise holds every MILP for 5-25 s), dual simplex for pure LPs (concurrent
returns PDLP's inexact point on the L40S), 50 ms polling. rowgen takes an
initial working set.

Measured with HiGHS (M4 Max): N=300 S=4096 5.2 s -> 0.7 s, N=300 S=16384
45 s -> 2.9 s, N=750 S=16384 15.6 s -> 7.4 s, K=40 of 750 24.6 s -> 7.3 s,
objectives within the 1e-4 gap. Tests in tests/test_large_scale.py; benchmark
script scripts/experiments/large_scale.py; docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 09:58:32 +01:00

186 lines
9.5 KiB
Python

"""The large-scale driver (relaxation, tail rows, screening, round and repair) against the full MILP."""
import numpy as np
import pytest
from quantcore.config import Settings
from quantcore.models import sampling as smp
from quantcore.optimization.large_scale import (
exposure_and_cost,
relative_gap,
relaxed,
solve_large_scale,
)
from quantcore.optimization.problem import MeanCVaRProblem, OptimizationConstraints, ProblemSpec
from quantcore.optimization.rowgen import solve_by_row_generation
from quantcore.optimization.service import OptimizationRequest, OptimizationService
from quantcore.optimization.solvers import HighsSolver
SETTINGS = Settings(cuopt_url="http://127.0.0.1:9", mip_rel_gap=1e-6)
def highs(model):
return HighsSolver(SETTINGS).solve(model, time_limit=120)
def inputs(seed=0, n=12, S=2048):
"""A synthetic universe with moment-matched Sobol' scenarios and a fat-tailed scale mixture."""
rng = np.random.default_rng(seed)
symbols = [f"S{i}" for i in range(n)]
prices = rng.uniform(20, 400, n)
vol = rng.uniform(0.02, 0.06, n)
corr = 0.4 * np.ones((n, n)) + 0.6 * np.eye(n)
cov = corr * np.outer(vol, vol)
mean = rng.uniform(0.0, 0.006, n)
z = smp.moment_match(smp.standard_normal_sample(S, n, "sobol", seed))
w = np.sqrt(4.0 / rng.chisquare(4.0, size=(S, 1))) * np.sqrt(0.5) # Student-t(4) scale mixture
scen = mean + (z @ smp.pca_root(cov).T) * w
sectors = ["Tech", "Fin", "Health"] * (n // 3) + ["Tech"] * (n % 3)
return symbols, prices, scen, sectors
def check_feasible(spec, problem, x, tol=1e-6):
"""The returned vector satisfies the full model, and the decoded portfolio the business limits."""
m = problem.build()
Ax = m.A @ x
assert (Ax >= m.row_lb - tol).all() and (Ax <= m.row_ub + tol).all()
assert (x >= m.var_lb - tol).all() and (x <= m.var_ub + tol).all()
ints = [i for i, t in enumerate(m.var_types) if t == "I"]
assert np.abs(x[ints] - np.rint(x[ints])).max() < 1e-9
d = problem.decode(x)
assert d["cash_end"] >= -tol
if spec.cvar_limit is not None:
assert d["cvar"] <= spec.cvar_limit * (1 + 1e-7)
return d
def test_matches_full_milp_and_certifies_the_gap():
# a portfolio of realistic size: share counts in the thousands, so rounding costs far less than the gap
symbols, prices, scen, sectors = inputs(seed=2)
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=5_000_000.0,
cvar_limit=150_000.0, constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
full = MeanCVaRProblem(spec)
res_full = highs(full.build())
res, problem, info = solve_large_scale(spec, highs, mip_gap=1e-4)
assert res.ok and res.status == "Optimal" and info.certified and not info.escalated
assert info.relaxation_converged and info.working_set < 0.5 * spec.n_scenarios
assert info.integer_stage == "repair"
d = check_feasible(spec, problem, res.x)
d_full = full.decode(res_full.x)
# the relaxation bounds the MILP optimum, and the rounded portfolio is within the gap of it
assert info.bound >= d_full["expected_pnl"] - 1e-6
assert d["expected_pnl"] >= d_full["expected_pnl"] - 1e-4 * abs(info.bound) - 1e-6
assert d["expected_pnl"] <= d_full["expected_pnl"] * (1 + 1e-6) + 1e-6
assert res.mip_gap == info.gap and info.gap <= 1e-4
assert res.raw["large_scale"]["solver_calls"] == len(info.calls)
def test_relaxation_is_a_valid_bound_at_the_continuous_point():
symbols, prices, scen, sectors = inputs(seed=5, n=9, S=1024)
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=50_000.0,
cvar_limit=2_000.0, constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
full = MeanCVaRProblem(spec)
lp = highs(relaxed(full.build())) # the LP relaxation on all rows
res, _, info = solve_large_scale(spec, highs, mip_gap=1e-4)
assert res.ok and info.relaxation_converged
assert abs(info.bound - lp.objective) <= 1e-6 * max(1.0, abs(lp.objective))
value, cost = exposure_and_cost(full, lp.x)
assert value.shape == (9,) and cost > 0
def test_cardinality_screening_is_bounded():
symbols, prices, scen, sectors = inputs(seed=7, n=30, S=1024)
cons = OptimizationConstraints(max_weight=0.2, max_sector_weight=0.6, max_positions=5)
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=200_000.0,
cvar_limit=6_000.0, constraints=cons)
full = MeanCVaRProblem(spec)
res_full = highs(full.build())
d_full = full.decode(res_full.x)
res, problem, info = solve_large_scale(spec, highs, mip_gap=1e-4, screen_min=10, screen_multiple=2)
assert res.ok and info.screened_to == 10
d = check_feasible(spec, problem, res.x)
assert int((d["shares"] > 0).sum()) <= 5
# the heuristic never beats the true optimum, and the reported gap covers the real one
assert d["expected_pnl"] <= d_full["expected_pnl"] * (1 + 1e-6) + 1e-6
true_gap = relative_gap(info.bound, d["expected_pnl"], maximize=True)
assert info.gap >= true_gap - 1e-9
assert info.gap >= relative_gap(d_full["expected_pnl"], d["expected_pnl"], maximize=True) - 1e-9
def test_min_cvar_objective_and_rebalancing():
symbols, prices, scen, sectors = inputs(seed=3, n=9, S=1024)
holdings = np.array([40, 0, 10, 0, 5, 0, 0, 8, 0], dtype=float)
target = 0.001 * (20_000.0 + holdings @ prices) # a third of what a fully invested portfolio expects
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=20_000.0,
holdings=holdings, objective="min_cvar", target_pnl=target,
constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
full = MeanCVaRProblem(spec)
res_full = highs(full.build())
d_full = full.decode(res_full.x)
res, problem, info = solve_large_scale(spec, highs, mip_gap=1e-4)
assert res.ok and info.relaxation_converged
d = check_feasible(spec, problem, res.x)
np.testing.assert_array_equal(d["shares"], holdings + d["buys"] - d["sells"])
assert d["expected_pnl"] >= target - 1e-6
assert info.bound <= d_full["cvar"] + 1e-6 # a lower bound on the minimal CVaR
assert d["cvar"] <= d_full["cvar"] + 1e-4 * max(1.0, abs(info.bound)) + 1e-6
def test_escalates_to_the_integer_milp_when_not_certified():
symbols, prices, scen, sectors = inputs(seed=11, n=12, S=1024)
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=100_000.0,
cvar_limit=3_000.0, constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
res_rg, problem_rg, _ = solve_by_row_generation(spec, highs)
d_rg = problem_rg.decode(res_rg.x)
res, problem, info = solve_large_scale(spec, highs, mip_gap=1e-12) # nothing rounds this well
assert res.ok and info.escalated and info.integer_stage == "milp"
d = check_feasible(spec, problem, res.x)
assert abs(d["expected_pnl"] - d_rg["expected_pnl"]) <= 1e-6 * abs(d_rg["expected_pnl"]) + 1e-6
res_no, _, info_no = solve_large_scale(spec, highs, mip_gap=1e-12, escalate=False)
assert res_no.ok and not info_no.escalated and res_no.status == "FeasibleFound" and not info_no.certified
def test_solver_selection_by_size():
symbols, prices, scen, sectors = inputs(seed=4, n=9, S=512)
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=50_000.0,
cvar_limit=2_000.0, constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
seen = []
def small(model):
seen.append(("small", model.n_rows))
return highs(model)
def large(model):
seen.append(("large", model.n_rows))
return highs(model)
res, _, info = solve_large_scale(spec, small, large, large_rows=100)
assert res.ok
assert all(w == ("large" if rows >= 100 else "small") for w, rows in seen)
assert {c["stage"] for c in info.calls} <= {"relaxation", "repair", "milp"}
with pytest.raises(ValueError):
gspec = ProblemSpec(symbols=symbols[:3], prices=prices[:3], scenarios=None, sectors=sectors[:3], cash=1e4,
cvar_limit=500.0, gaussian_mean=np.zeros(3), gaussian_cov=np.eye(3) * 1e-4)
solve_large_scale(gspec, highs)
BASE = {"cash": 500_000.0, "cvar_limit": 12_000.0, "solver": "highs", "horizon": "week", "scenario_model": "gbm",
"n_scenarios": 2048, "importance_sampling": "off", "costs": {"proportional_buy": 0.0, "proportional_sell": 0.0},
"constraints": {"max_weight": 0.15, "max_sector_weight": 0.4}}
def test_service_routes_the_strategy(settings, md):
from tests.conftest import SYMBOLS
svc = OptimizationService(settings, md)
milp = svc.optimize(OptimizationRequest(symbols=SYMBOLS, strategy="milp", **BASE))
ls = svc.optimize(OptimizationRequest(symbols=SYMBOLS, strategy="large_scale", **BASE))
assert "large_scale" not in milp.solver_info and ls.solver_info["large_scale"]["certified"]
assert ls.status == "Optimal" and ls.reproducible
assert abs(ls.expected_pnl - milp.expected_pnl) <= 1e-4 * abs(milp.expected_pnl) + 1e-6
assert ls.cvar <= 12_000.0 * (1 + 1e-7)
req = OptimizationRequest(symbols=SYMBOLS, **BASE)
assert not req.uses_large_scale(12, 2048) and req.uses_large_scale(200, 2048) and req.uses_large_scale(12, 32768)
with pytest.raises(ValueError):
OptimizationRequest(symbols=SYMBOLS, strategy="bogus", **BASE)