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

47 lines
2.4 KiB
Python

"""Exact Gaussian mean-CVaR problem in integer shares as a MISOCP with Gurobi (validation of the cutting planes)."""
import sys, time
import numpy as np, gurobipy as gp
from scipy.stats import norm
from quantcore.config import get_settings
from quantcore.data.market_data import MarketDataService
from quantcore.optimization.service import OptimizationService, OptimizationRequest
from quantcore.optimization.problem import OptimizationConstraints
from quantcore.models.covariance import CovarianceModel
N_ASSETS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
BETA = float(sys.argv[2]) if len(sys.argv) > 2 else 0.95
W, H = 1_000_000.0, 5
LAMBDA = 0.05 * W
settings = get_settings(); md = MarketDataService(settings); svc = OptimizationService(settings, md)
req = OptimizationRequest(max_assets=N_ASSETS, scenario_model="gbm", solver="highs", cash=W)
symbols = svc.candidate_symbols(req)
mu_ann, _, _ = svc.expected_returns(req, symbols)
r = md.returns(symbols, currency="base", freq="daily").dropna()
cov_h = CovarianceModel.from_dict(req.covariance).estimate(r).values * H
mu_h = (mu_ann / 252).reindex(symbols).values * H
prices = md.latest_prices(symbols, currency="base").reindex(symbols).values
sectors = md.sectors(symbols).fillna("Unknown").tolist()
N = len(symbols); k = norm.pdf(norm.ppf(BETA)) / (1 - BETA)
L = np.linalg.cholesky(cov_h)
for integer in (True, False):
t0 = time.time()
m = gp.Model(); m.Params.OutputFlag = 0; m.Params.MIPGap = 1e-9; m.Params.MIPGapAbs = 1e-6
ub = np.floor(0.10 * W / prices)
n = m.addMVar(N, vtype="I" if integer else "C", lb=0, ub=ub)
t = m.addVar(lb=0)
y = m.addMVar(N, lb=-gp.GRB.INFINITY) # y = L' v -> ||y|| = sqrt(v' Sigma v)
v = prices * n
m.addConstr(y == L.T @ v)
m.addConstr(y @ y <= t * t) # second-order cone
m.addConstr(k * t <= LAMBDA + mu_h @ v) # Gaussian CVaR <= Lambda
m.addConstr(prices @ n <= W)
for sec in sorted(set(sectors)):
mask = np.array([s == sec for s in sectors], dtype=float)
m.addConstr((prices * mask) @ n <= OptimizationConstraints().max_sector_weight * W)
m.setObjective(mu_h @ v, gp.GRB.MAXIMIZE)
m.optimize()
nn = n.X; vv = prices * nn; sd = np.sqrt(vv @ cov_h @ vv)
print(f"{'integer' if integer else 'continuous':10s} N={N} beta={BETA}: status {m.Status}, E[P&L]={mu_h @ vv:.2f} CHF, "
f"CVaR={-mu_h @ vv + k * sd:.2f}, positions={(nn > 0.5).sum()}, {time.time()-t0:.1f}s")