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>
47 lines
2.4 KiB
Python
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")
|