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>
116 lines
5.9 KiB
Python
116 lines
5.9 KiB
Python
"""Tail-row generation for the RU mean-CVaR MILP: solve on a working set of scenarios, add violated ones, repeat.
|
|
|
|
Full problem (new mode, proportional buy costs c): max (g_full - c p)'n s.t.
|
|
g_s'n - c p'n + alpha + u_s >= 0 (all s) [u_s >= L_s - alpha, L_s = -(g_s - c p)'n]
|
|
alpha + sum_s u_s / ((1-beta) S) <= Lambda
|
|
(1 + c) p'n <= W, p_i n_i <= w_max W, sector caps, n integer >= 0
|
|
Restricted problem: rows only for s in the working set (u_s = 0 elsewhere) -> a relaxation; optimal for the
|
|
full problem as soon as no omitted scenario has L_s > alpha*.
|
|
"""
|
|
import sys, time
|
|
import numpy as np
|
|
import scipy.sparse as sps
|
|
from scipy.optimize import milp, LinearConstraint, Bounds
|
|
from scipy.stats import norm, qmc
|
|
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 MeanCVaRProblem, ProblemSpec, OptimizationConstraints, TransactionCosts
|
|
from quantcore.optimization.solvers import HighsSolver
|
|
from quantcore.models.covariance import CovarianceModel, symmetric_inv_sqrt
|
|
|
|
N_ASSETS = int(sys.argv[1]); SIZES = [int(x) for x in sys.argv[2].split(",")]
|
|
FULL = len(sys.argv) > 3 and sys.argv[3] == "full"
|
|
BETA = 0.95; W = 1_000_000.0; LAMBDA = 0.05 * W; H = 5; C = 0.001; WMAX = 0.10; SMAX = 0.25; GAP = 1e-4
|
|
settings = get_settings(); md = MarketDataService(settings); svc = OptimizationService(settings, md)
|
|
req = OptimizationRequest(max_assets=N_ASSETS, 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)
|
|
vals, vecs = np.linalg.eigh(cov_h); o = np.argsort(vals)[::-1]; A = vecs[:, o] * np.sqrt(vals[o])
|
|
|
|
|
|
def scen_sobol_mm(S, seed=0):
|
|
z = norm.ppf(qmc.Sobol(d=N, scramble=True, seed=seed).random(S))
|
|
z = z - z.mean(0); z = z @ symmetric_inv_sqrt(z.T @ z / S)
|
|
return np.expm1(mu_h + z @ A.T) # simple returns, as in production
|
|
|
|
|
|
def solve_restricted(gain, work, S_full, mean_gain):
|
|
"""MILP on the working set `work` (indices into gain rows). Returns n, alpha, objective, seconds."""
|
|
K = len(work)
|
|
p = prices
|
|
# variables: n (N), alpha (1), u (K)
|
|
nv = N + 1 + K
|
|
c = np.zeros(nv); c[:N] = -(mean_gain - C * p) # minimise negative expected P&L (net of costs)
|
|
rows, cols, vals_ = [], [], []
|
|
lb, ub = [], []
|
|
ri = 0
|
|
G = gain[work] - C * p[None, :] # net gain per share in the working scenarios
|
|
# scenario rows: G_s' n + alpha + u_s >= 0
|
|
rows.extend(np.repeat(np.arange(K), N)); cols.extend(np.tile(np.arange(N), K)); vals_.extend(G.ravel())
|
|
rows.extend(np.arange(K)); cols.extend([N] * K); vals_.extend([1.0] * K)
|
|
rows.extend(np.arange(K)); cols.extend(N + 1 + np.arange(K)); vals_.extend([1.0] * K)
|
|
lb.extend([0.0] * K); ub.extend([np.inf] * K); ri = K
|
|
# cvar row: alpha + sum u / ((1-beta) S_full) <= Lambda
|
|
rows.append(ri); cols.append(N); vals_.append(1.0)
|
|
rows.extend([ri] * K); cols.extend(N + 1 + np.arange(K)); vals_.extend([1.0 / ((1 - BETA) * S_full)] * K)
|
|
lb.append(-np.inf); ub.append(LAMBDA); ri += 1
|
|
# budget (1+c) p'n <= W
|
|
rows.extend([ri] * N); cols.extend(range(N)); vals_.extend((1 + C) * p); lb.append(-np.inf); ub.append(W); ri += 1
|
|
# sector caps
|
|
for sec in sorted(set(sectors)):
|
|
idx = [i for i, s in enumerate(sectors) if s == sec]
|
|
rows.extend([ri] * len(idx)); cols.extend(idx); vals_.extend(p[idx]); lb.append(-np.inf); ub.append(SMAX * W); ri += 1
|
|
Am = sps.csr_matrix((vals_, (rows, cols)), shape=(ri, nv))
|
|
vlb = np.zeros(nv); vub = np.full(nv, np.inf); vub[:N] = np.floor(WMAX * W / p + 1e-9); vlb[N] = -np.inf
|
|
integrality = np.zeros(nv); integrality[:N] = 1
|
|
t0 = time.time()
|
|
res = milp(c, constraints=LinearConstraint(Am, np.array(lb), np.array(ub)), integrality=integrality,
|
|
bounds=Bounds(vlb, vub), options={"mip_rel_gap": GAP, "time_limit": 1800})
|
|
n = np.rint(res.x[:N]); alpha = res.x[N]
|
|
return n, alpha, -res.fun, time.time() - t0
|
|
|
|
|
|
def row_generation(gain, verbose=True):
|
|
S = len(gain)
|
|
mean_gain = gain.mean(0)
|
|
p = prices
|
|
# initial working set: the worst 2 (1-beta) S scenarios of the equal-weight (cap-respecting) portfolio
|
|
n0 = np.floor(W / N / p)
|
|
L0 = -(gain - C * p) @ n0
|
|
K0 = int(2 * (1 - BETA) * S)
|
|
work = np.argsort(L0)[-K0:]
|
|
t_solve = 0.0
|
|
for it in range(1, 30):
|
|
n, alpha, obj, dt = solve_restricted(gain, work, S, mean_gain)
|
|
t_solve += dt
|
|
L = -(gain - C * p) @ n
|
|
viol = np.setdiff1d(np.where(L > alpha + 1e-9)[0], work)
|
|
if verbose:
|
|
print(f" iter {it}: |work|={len(work)}, MILP {dt:.1f}s, obj {obj:.2f}, alpha {alpha:.1f}, violated {len(viol)}", flush=True)
|
|
if len(viol) == 0:
|
|
u = np.maximum(L - alpha, 0)
|
|
cvar = alpha + u.sum() / ((1 - BETA) * S)
|
|
return n, obj, cvar, it, t_solve
|
|
work = np.union1d(work, viol)
|
|
raise RuntimeError("no convergence")
|
|
|
|
|
|
print(f"N={N}, beta={BETA}, gap={GAP}")
|
|
for S in SIZES:
|
|
gain = scen_sobol_mm(S) * prices[None, :]
|
|
t0 = time.time()
|
|
n, obj, cvar, iters, t_solve = row_generation(gain)
|
|
t_rg = time.time() - t0
|
|
print(f"S={S:>7}: row generation {iters} iterations, {t_rg:.1f}s total ({t_solve:.1f}s in MILPs), E[P&L]={obj:.2f}, CVaR={cvar:.1f}, positions={(n>0).sum()}", flush=True)
|
|
if FULL:
|
|
t0 = time.time()
|
|
n_f, alpha_f, obj_f, dt = solve_restricted(gain, np.arange(S), S, gain.mean(0))
|
|
print(f"S={S:>7}: full MILP {dt:.1f}s, E[P&L]={obj_f:.2f}, same shares: {np.array_equal(n, n_f)}, max |dn| = {np.abs(n - n_f).max():.0f}", flush=True)
|