herzogflorian b5da424dfe quantcore: standalone Apptainer setup for students' DGX Sparks
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>
2026-10-07 09:55:28 +02:00

167 lines
8.3 KiB
Python

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)