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

137 lines
6.6 KiB
Python

"""Sampling noise of the mean-CVaR LP (weights) under the empirical-innovation model with different resampling schemes.
'truth' = a common evaluation set of 500k plain bootstrap draws (sampling error of CVaR for a fixed portfolio ~0.1%).
"""
import sys, time
import numpy as np, pandas as pd
from scipy.stats import 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_sqrt, symmetric_inv_sqrt
from quantcore.models.simulation import EmpiricalInnovationSimulator
from quantcore.measurement.risk import scenario_cvar
N_ASSETS = int(sys.argv[1]) if len(sys.argv) > 1 else 30
S = int(sys.argv[2]) if len(sys.argv) > 2 else 2048
N_SEEDS = int(sys.argv[3]) if len(sys.argv) > 3 else 8
BETA = float(sys.argv[4]) if len(sys.argv) > 4 else 0.95
W, H = 1_000_000.0, 5
LAMBDA = 0.05 * W
S_EVAL = 500_000
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_model = CovarianceModel.from_dict(req.covariance)
sim = EmpiricalInnovationSimulator(cov_model, max_history=req.innovation_history, standardize=req.innovation_standardize)
t0 = time.time()
eps = sim.innovations(r)
e = eps.values - eps.values.mean(axis=0)
e, info = sim._restandardize(e)
T, N = e.shape
cov = cov_model.estimate(r).values
A = symmetric_sqrt(cov)
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()
print(f"N={N} assets, T={T} innovations ({time.time()-t0:.0f}s), S={S}, h={H}, beta={BETA}, {info}")
# market-factor ordering of the innovation dates (projection on the first eigenvector of Sigma_T)
w1 = np.linalg.eigh(cov)[1][:, -1]
factor = e @ (A @ w1) # innovation of the dominant direction
rank_order = np.argsort(factor) # dates sorted from worst to best market innovation
def moment_match(x, target_var):
x = x - x.mean(axis=0)
c = x.T @ x / len(x)
return x @ symmetric_inv_sqrt(c) * np.sqrt(target_var)
def summed_innovations(kind, seed):
rng = np.random.default_rng(seed)
if kind == "bootstrap": # current implementation
idx = rng.integers(0, T, size=(S, H))
return e[idx].sum(axis=1)
if kind == "bootstrap+mm":
idx = rng.integers(0, T, size=(S, H))
return moment_match(e[idx].sum(axis=1), H)
if kind == "balanced+mm": # every date used equally often
reps = int(np.ceil(S * H / T))
idx = rng.permutation(np.tile(np.arange(T), reps))[: S * H].reshape(S, H)
return moment_match(e[idx].sum(axis=1), H)
if kind == "lhs-ranks+mm": # stratified on the market-factor rank, per day
u = qmc.LatinHypercube(d=H, scramble=True, seed=seed).random(S)
idx = rank_order[np.minimum((u * T).astype(int), T - 1)]
return moment_match(e[idx].sum(axis=1), H)
if kind == "sobol-ranks+mm":
u = qmc.Sobol(d=H, scramble=True, seed=seed).random(S)
idx = rank_order[np.minimum((u * T).astype(int), T - 1)]
return moment_match(e[idx].sum(axis=1), H)
if kind == "windows+mm": # deterministic: all overlapping h-day windows
idx = np.arange(T - H + 1)[:, None] + np.arange(H)[None, :]
return moment_match(e[idx].sum(axis=1), H)
if kind == "windows":
idx = np.arange(T - H + 1)[:, None] + np.arange(H)[None, :]
return e[idx].sum(axis=1)
raise ValueError(kind)
def scenarios(kind, seed):
return mu_h + summed_innovations(kind, seed) @ A.T
def solve_lp(scen):
spec = ProblemSpec(symbols=symbols, prices=prices, scenarios=scen, sectors=sectors, cash=W, holdings=np.zeros(N),
confidence=BETA, cvar_limit=LAMBDA, objective="max_return", constraints=OptimizationConstraints(),
costs=TransactionCosts(proportional_buy=0.0, proportional_sell=0.0))
prob = MeanCVaRProblem(spec)
model = prob.build()
model.var_types = ["C"] * len(model.var_types)
sol = HighsSolver().solve(model, time_limit=300)
n = sol.x[prob.i_n]
pnl = scen @ (n * prices)
return n, float(pnl.mean()), scenario_cvar(-pnl, BETA)[1]
# common evaluation set (plain bootstrap, independent seed)
rng_eval = np.random.default_rng(987654321)
eval_scen = mu_h + e[rng_eval.integers(0, T, size=(S_EVAL, H))].sum(axis=1) @ A.T
print(f"evaluation set: {S_EVAL} draws, mean of column means {eval_scen.mean():.2e}")
def true_stats(n):
pnl = eval_scen @ (n * prices)
return float(pnl.mean()), scenario_cvar(-pnl, BETA)[1]
results = {}
KINDS = sys.argv[5].split(",") if len(sys.argv) > 5 else ["bootstrap", "bootstrap+mm", "balanced+mm", "lhs-ranks+mm", "sobol-ranks+mm"]
for kind in KINDS:
rows, t0 = [], time.time()
for seed in range(1 if kind.startswith("windows") else N_SEEDS):
n, epnl_in, cvar_in = solve_lp(scenarios(kind, seed))
epnl_t, cvar_t = true_stats(n)
rows.append({"epnl_in": epnl_in, "cvar_in": cvar_in, "epnl_true": epnl_t, "cvar_true": cvar_t, "n_pos": int((n > 1e-6).sum()), "w": n * prices / W})
df = pd.DataFrame(rows)
ws = np.vstack(df["w"].values)
pair = [np.abs(ws[i] - ws[j]).sum() for i in range(len(ws)) for j in range(i + 1, len(ws))]
results[kind] = {
"E[P&L] in-sample mean": df.epnl_in.mean(), "E[P&L] in-sample sd": df.epnl_in.std(),
"E[P&L] true mean": df.epnl_true.mean(), "E[P&L] true sd": df.epnl_true.std(),
"true CVaR mean": df.cvar_true.mean(), "true CVaR sd": df.cvar_true.std(),
"CVaR excess over limit (%)": 100 * (df.cvar_true.mean() / LAMBDA - 1),
"positions": df.n_pos.mean(), "turnover between seeds (%)": 100 * np.mean(pair) if pair else float("nan"), "seconds": time.time() - t0,
}
print(f"{kind:15s} done in {time.time()-t0:.0f}s")
out = pd.DataFrame(results).T
pd.set_option("display.width", 250); pd.set_option("display.float_format", lambda v: f"{v:10.2f}")
print(f"\nEMPIRICAL model, continuous (weights) problem, CVaR limit {LAMBDA:.0f} CHF, beta={BETA}")
print(out.T.to_string())