The portfolio data, risk and mean-CVaR optimisation service, prepared for students who run it on their own DGX Spark without root, Docker or Tailscale. - deploy/apptainer/qc.sh: install, start/stop/status/logs, cli/api/python, data import/export and Yahoo setup/update. cuOpt and quantcore run as two Apptainer instances of the user and talk over 127.0.0.1; the API and the test bench listen on 127.0.0.1 only. Builds quantcore.sif without root, or falls back to a plain Python image plus a virtualenv when the Spark allows no unprivileged builds. Detects taken ports and suggests free ones. - deploy/apptainer/quantcore.def + constraints.txt: the image, with the library versions of the verified production container pinned. - cuOpt under Apptainer: NVIDIA's image sets HOME=/opt/cuopt, read-only in a SIF, so the solver died on its first job; qc.sh gives it a writable home. - solvers: a cuOpt that disappears during a solve now falls back to HiGHS instead of answering 500 (requests' ConnectionError escaped the fallback). - client: idempotent GETs retry a dropped keep-alive connection, which made `quantcore-api update --wait` fail when a poll met uvicorn's 5 s timeout. - config: cuOpt defaults to http://127.0.0.1:5000 and `quantcore serve` to 127.0.0.1; the Docker images keep passing their own values. - docs/SPARK_APPTAINER.md: the students' guide; README, USAGE, ARCHITECTURE and SPARK_DEPLOYMENT point to it. Tested on a DGX Spark (GB10, Apptainer 1.5.4, unprivileged user) in both modes: selftest on every endpoint, optimisation on cuOpt, Yahoo update, port conflicts; 139 tests pass in the container against the live cuOpt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
13 KiB
Python
276 lines
13 KiB
Python
"""Weighted scenarios, tail-row generation and the exact Gaussian (outer-approximation) mode."""
|
|
|
|
import itertools
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from scipy.stats import norm
|
|
|
|
from quantcore.config import Settings
|
|
from quantcore.models import sampling as smp
|
|
from quantcore.optimization.gaussian_exact import solve_gaussian_exact
|
|
from quantcore.optimization.problem import (
|
|
MeanCVaRProblem,
|
|
OptimizationConstraints,
|
|
ProblemSpec,
|
|
TransactionCosts,
|
|
cvar_multiplier,
|
|
)
|
|
from quantcore.optimization.rowgen import restricted_spec, solve_by_row_generation
|
|
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 gaussian_inputs(seed=0, n=6):
|
|
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)
|
|
sectors = ["Tech", "Fin", "Health"] * (n // 3) + ["Tech"] * (n % 3)
|
|
return symbols, prices, mean, cov, sectors
|
|
|
|
|
|
def gaussian_scenarios(mean, cov, S, seed=1):
|
|
z = smp.moment_match(smp.standard_normal_sample(S, len(mean), "sobol", seed))
|
|
return mean + z @ smp.pca_root(cov).T
|
|
|
|
|
|
def test_weighted_scenarios_equal_duplicated_scenarios():
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs()
|
|
scen = gaussian_scenarios(mean, cov, 256)
|
|
dup = np.vstack([scen, scen[:64]]) # the first 64 scenarios counted twice
|
|
p = np.ones(256)
|
|
p[:64] = 2
|
|
p /= p.sum()
|
|
common = dict(symbols=symbols, prices=prices, sectors=sectors, cash=100_000.0, cvar_limit=3_000.0)
|
|
a = MeanCVaRProblem(ProblemSpec(scenarios=dup, **common))
|
|
b = MeanCVaRProblem(ProblemSpec(scenarios=scen, probabilities=p, **common))
|
|
ra, rb = highs(a.build()), highs(b.build())
|
|
assert ra.ok and rb.ok
|
|
da, db = a.decode(ra.x), b.decode(rb.x)
|
|
assert abs(ra.objective - rb.objective) < 1e-6 * abs(ra.objective)
|
|
np.testing.assert_array_equal(da["shares"], db["shares"])
|
|
assert abs(da["cvar"] - db["cvar"]) < 1e-6 and abs(da["expected_pnl"] - db["expected_pnl"]) < 1e-6
|
|
|
|
|
|
def test_expected_returns_drive_the_objective():
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs()
|
|
scen = gaussian_scenarios(mean, cov, 256)
|
|
common = dict(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=100_000.0, cvar_limit=3_000.0,
|
|
costs=TransactionCosts(proportional_buy=0.0))
|
|
shifted = mean + np.array([0.01, 0, 0, 0, 0, 0]) # the model says S0 earns 1 % more than the scenarios show
|
|
prob = MeanCVaRProblem(ProblemSpec(expected_returns=shifted, **common))
|
|
res = highs(prob.build())
|
|
d = prob.decode(res.x)
|
|
assert abs(d["expected_pnl"] - (shifted @ d["value"])) < 1e-6 # objective uses the model mean ...
|
|
assert d["expected_pnl"] > d["expected_pnl_scenarios"] + 0.005 * d["value"][0] - 1e-6 # ... not the sample mean
|
|
assert d["shares"][0] > 0
|
|
with pytest.raises(ValueError):
|
|
ProblemSpec(expected_returns=np.zeros(3), **common)
|
|
|
|
|
|
def test_row_generation_matches_full_milp():
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs(seed=2)
|
|
scen = gaussian_scenarios(mean, cov, 2048)
|
|
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))
|
|
full = MeanCVaRProblem(spec)
|
|
res_full = highs(full.build())
|
|
res_rg, problem_rg, info = solve_by_row_generation(spec, highs)
|
|
assert res_rg.ok and info.converged and info.iterations >= 1
|
|
assert max(info.working_set_sizes) < 0.5 * spec.n_scenarios # solved on a fraction of the rows
|
|
d_full, d_rg = full.decode(res_full.x), problem_rg.decode(res_rg.x)
|
|
assert abs(d_rg["expected_pnl"] - d_full["expected_pnl"]) <= 1e-5 * abs(d_full["expected_pnl"]) + 1e-6
|
|
assert d_rg["cvar"] <= 3_000.0 * (1 + 1e-7)
|
|
# the expanded solution vector is feasible for the full model: u_s >= L_s - alpha for every scenario
|
|
m = full.build()
|
|
Ax = m.A @ res_rg.x
|
|
assert (Ax >= m.row_lb - 1e-6).all() and (Ax <= m.row_ub + 1e-6).all()
|
|
# a restricted spec keeps the full-set probabilities and expected returns
|
|
sub = restricted_spec(spec, np.arange(10))
|
|
assert sub.n_scenarios == 10 and abs(sub.probabilities.sum() - 10 / 2048) < 1e-12
|
|
np.testing.assert_allclose(sub.expected_returns, spec.mean_returns)
|
|
|
|
|
|
def test_row_generation_rebalancing_mode():
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs(seed=3)
|
|
scen = gaussian_scenarios(mean, cov, 1024)
|
|
holdings = np.array([40, 0, 10, 0, 5, 0], dtype=float)
|
|
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=20_000.0,
|
|
holdings=holdings, cvar_limit=2_500.0, constraints=OptimizationConstraints(max_weight=0.3, max_sector_weight=0.6))
|
|
res, problem, info = solve_by_row_generation(spec, highs)
|
|
assert res.ok and info.converged
|
|
d = problem.decode(res.x)
|
|
np.testing.assert_array_equal(d["shares"], holdings + d["buys"] - d["sells"])
|
|
assert d["cvar"] <= 2_500.0 * (1 + 1e-7) and d["cash_end"] >= -1e-6
|
|
|
|
|
|
def brute_force_gaussian(prices, mean, cov, sectors, cash, limit, beta, max_weight, costs):
|
|
"""Enumerate all integer share vectors within the caps (small instance)."""
|
|
ub = np.floor(max_weight * cash / prices).astype(int)
|
|
k = cvar_multiplier(beta)
|
|
best, best_n = -np.inf, None
|
|
for shares in itertools.product(*[range(u + 1) for u in ub]):
|
|
s = np.array(shares, dtype=float)
|
|
v = s * prices
|
|
tc = costs * v.sum()
|
|
if v.sum() + tc > cash + 1e-9:
|
|
continue
|
|
pnl = mean @ v - tc
|
|
cvar = -pnl + k * np.sqrt(v @ cov @ v)
|
|
if cvar <= limit + 1e-9 and pnl > best:
|
|
best, best_n = pnl, s
|
|
return best, best_n
|
|
|
|
|
|
def test_gaussian_exact_matches_brute_force():
|
|
prices = np.array([400.0, 650.0, 900.0])
|
|
vol = np.array([0.03, 0.05, 0.04])
|
|
corr = np.array([[1, 0.5, 0.2], [0.5, 1, 0.3], [0.2, 0.3, 1]])
|
|
cov = corr * np.outer(vol, vol)
|
|
mean = np.array([0.003, 0.006, 0.004])
|
|
sectors = ["A", "B", "C"]
|
|
cash, limit, beta = 10_000.0, 500.0, 0.95
|
|
costs = TransactionCosts(proportional_buy=0.002)
|
|
spec = ProblemSpec(symbols=list("XYZ"), prices=prices, scenarios=None, sectors=sectors, cash=cash, cvar_limit=limit,
|
|
confidence=beta, gaussian_mean=mean, gaussian_cov=cov, costs=costs,
|
|
constraints=OptimizationConstraints(max_weight=0.6, max_sector_weight=1.0))
|
|
res, problem, info = solve_gaussian_exact(spec, highs, tol=1e-9)
|
|
assert res.ok and info.converged and info.cuts >= 2
|
|
d = problem.decode(res.x)
|
|
best, best_n = brute_force_gaussian(prices, mean, cov, sectors, cash, limit, beta, 0.6, 0.002)
|
|
np.testing.assert_array_equal(d["shares"], best_n)
|
|
assert abs(d["expected_pnl"] - best) < 1e-6
|
|
assert d["cvar"] <= limit * (1 + 1e-9)
|
|
# closed-form risk figures of the decoded portfolio
|
|
v = d["value"]
|
|
sigma = np.sqrt(v @ cov @ v)
|
|
assert abs(d["sigma"] - sigma) < 1e-9
|
|
assert abs(d["var"] - (-(mean @ v - d["total_cost"]) + norm.ppf(beta) * sigma)) < 1e-9
|
|
assert res.reproducible and "outer approximation" in res.message
|
|
|
|
|
|
def test_gaussian_exact_other_objectives_and_validation():
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs(seed=4)
|
|
base = dict(symbols=symbols, prices=prices, scenarios=None, sectors=sectors, cash=100_000.0,
|
|
gaussian_mean=mean, gaussian_cov=cov)
|
|
# min CVaR at a target expected P&L: converges, meets the target, epigraph equals the true CVaR
|
|
spec = ProblemSpec(objective="min_cvar", target_pnl=150.0, **base)
|
|
res, problem, info = solve_gaussian_exact(spec, highs)
|
|
d = problem.decode(res.x)
|
|
assert info.converged and d["expected_pnl"] >= 150.0 - 1e-6
|
|
assert abs(d["cvar"] - d["cvar_model"]) <= 1e-6 * max(d["cvar"], 1.0)
|
|
# utility objective
|
|
spec_u = ProblemSpec(objective="utility", risk_aversion=0.05, **base) # at 0.5 staying in cash is optimal
|
|
res_u, problem_u, info_u = solve_gaussian_exact(spec_u, highs)
|
|
du = problem_u.decode(res_u.x)
|
|
assert info_u.converged and du["invested"] > 0
|
|
# the CVaR-limited solution is on the mean-variance frontier: no feasible portfolio with the same
|
|
# expected P&L has a smaller sigma (checked on the continuous relaxation via the cut model)
|
|
spec_m = ProblemSpec(cvar_limit=3_000.0, **base)
|
|
res_m, problem_m, _ = solve_gaussian_exact(spec_m, highs)
|
|
dm = problem_m.decode(res_m.x)
|
|
assert dm["cvar"] <= 3_000.0 * (1 + 1e-6)
|
|
with pytest.raises(ValueError):
|
|
ProblemSpec(symbols=symbols, prices=prices, scenarios=None, sectors=sectors, cash=1.0, cvar_limit=1.0)
|
|
with pytest.raises(ValueError):
|
|
MeanCVaRProblem(spec_m).build() # no cuts yet
|
|
with pytest.raises(ValueError):
|
|
solve_by_row_generation(spec_m, highs)
|
|
|
|
|
|
def test_moment_matched_sobol_scenarios_reproduce_the_exact_optimum():
|
|
"""The sample-average approximation with Gaussian scenarios (used linearly) converges to the exact
|
|
mean-variance-equivalent optimum; plain Monte Carlo at the same size does not."""
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs(seed=6, n=8)
|
|
common = dict(symbols=symbols, prices=prices, sectors=sectors, cash=1_000_000.0, cvar_limit=30_000.0,
|
|
costs=TransactionCosts(proportional_buy=0.0),
|
|
constraints=OptimizationConstraints(max_weight=0.25, max_sector_weight=0.6))
|
|
exact_spec = ProblemSpec(scenarios=None, gaussian_mean=mean, gaussian_cov=cov, **common)
|
|
res_e, prob_e, _info = solve_gaussian_exact(exact_spec, highs, tol=1e-8)
|
|
d_e = prob_e.decode(res_e.x)
|
|
k = cvar_multiplier(0.95)
|
|
|
|
def true_cvar(v):
|
|
return -(mean @ v) + k * np.sqrt(v @ cov @ v)
|
|
|
|
# moment-matched Sobol' scenarios, model mean in the objective
|
|
scen = gaussian_scenarios(mean, cov, 4096, seed=3)
|
|
prob_s = MeanCVaRProblem(ProblemSpec(scenarios=scen, expected_returns=mean, **common))
|
|
d_s = prob_s.decode(highs(prob_s.build()).x)
|
|
assert abs(d_s["expected_pnl"] - d_e["expected_pnl"]) <= 0.01 * d_e["expected_pnl"]
|
|
assert true_cvar(d_s["value"]) <= 30_000.0 * 1.02
|
|
# plain Monte Carlo of the same size with the sample mean in the objective (the old default)
|
|
rng = np.random.default_rng(3)
|
|
mc = mean + rng.standard_normal((4096, 8)) @ np.linalg.cholesky(cov).T
|
|
prob_m = MeanCVaRProblem(ProblemSpec(scenarios=mc, **common))
|
|
d_m = prob_m.decode(highs(prob_m.build()).x)
|
|
true_pnl_mc = mean @ d_m["value"]
|
|
assert abs(true_pnl_mc - d_e["expected_pnl"]) > abs(d_s["expected_pnl"] - d_e["expected_pnl"])
|
|
assert d_m["expected_pnl"] > true_pnl_mc # in-sample optimism of the plain MC solution
|
|
|
|
|
|
def test_row_generation_stops_at_the_time_budget():
|
|
"""The time limit is the budget of the whole request, not of each pass.
|
|
|
|
Every pass is a MILP of its own, so a deadline that has already passed must end the loop with
|
|
the incumbent (feasible, not proven optimal) instead of starting another solve.
|
|
"""
|
|
import time
|
|
|
|
symbols, prices, mean, cov, sectors = gaussian_inputs(seed=2)
|
|
scen = gaussian_scenarios(mean, cov, 2048)
|
|
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))
|
|
calls = []
|
|
|
|
def counted(model):
|
|
calls.append(1)
|
|
return highs(model)
|
|
|
|
res, problem, info = solve_by_row_generation(spec, counted, deadline=time.monotonic() - 1.0)
|
|
assert len(calls) == 1 and info.iterations == 1 and not info.converged
|
|
assert res.status == "FeasibleFound" and "time limit" in res.message
|
|
d = problem.decode(res.x) # the incumbent is a real portfolio, measured on all scenarios
|
|
assert d["shares"].sum() > 0 and np.isfinite(d["cvar"])
|
|
# with a budget in the future the loop runs to convergence as before
|
|
res2, _, info2 = solve_by_row_generation(spec, highs, deadline=time.monotonic() + 600)
|
|
assert info2.converged and res2.status == "Optimal"
|
|
|
|
|
|
def test_the_solve_function_hands_on_the_remaining_budget():
|
|
from quantcore.optimization.service import OptimizationRequest, OptimizationService
|
|
|
|
svc = OptimizationService.__new__(OptimizationService) # no data needed for this
|
|
svc.settings = SETTINGS
|
|
req = OptimizationRequest(time_limit=30.0, solver="highs", cvar_limit=1.0)
|
|
limits = []
|
|
deadline = __import__("time").monotonic() + 30.0
|
|
|
|
class Recorder:
|
|
def __init__(self, settings):
|
|
pass
|
|
|
|
def solve(self, model, time_limit=None):
|
|
limits.append(time_limit)
|
|
return None
|
|
|
|
import quantcore.optimization.service as service_module
|
|
original = service_module.HighsSolver
|
|
service_module.HighsSolver = Recorder
|
|
try:
|
|
fn = svc._solve_fn(req, prefer_local=True, deadline=deadline)
|
|
fn(None)
|
|
fn(None)
|
|
finally:
|
|
service_module.HighsSolver = original
|
|
assert 0 < limits[1] <= limits[0] <= 30.0 # each call gets what is left, never the full limit again
|