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>
174 lines
8.8 KiB
Python
174 lines
8.8 KiB
Python
"""Importance sampling for the mean-CVaR LP (weights): two-stage exponential tilt along the loss direction of a
|
|
first-pass solution, defensive mixture with the nominal density, self-normalised weights, weighted moment matching,
|
|
exact mean in the objective. Compared with Sobol+mm at equal S against the exact Gaussian optimum."""
|
|
import sys, time
|
|
import numpy as np, pandas as pd
|
|
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 OptimizationConstraints
|
|
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(",")]; N_SEEDS = int(sys.argv[3])
|
|
BETAS = [float(x) for x in sys.argv[4].split(",")]
|
|
W = 1_000_000.0; LAMBDA = 0.05 * W; H = 5; WMAX = 0.10; SMAX = 0.25
|
|
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])
|
|
sector_rows = [(np.array([s == sec for s in sectors], dtype=float)) for sec in sorted(set(sectors))]
|
|
|
|
|
|
def k_of(beta):
|
|
return norm.pdf(norm.ppf(beta)) / (1 - beta)
|
|
|
|
|
|
def true_stats(n, beta):
|
|
v = n * prices; mean = v @ mu_h; sd = np.sqrt(v @ cov_h @ v)
|
|
return mean, -mean + k_of(beta) * sd, sd
|
|
|
|
|
|
def weighted_mm(z, p):
|
|
m = p @ z
|
|
zc = z - m
|
|
c = (zc * p[:, None]).T @ zc
|
|
return zc @ symmetric_inv_sqrt(c)
|
|
|
|
|
|
def solve_lp(scen, p, beta):
|
|
"""max mu_h'(n*P) s.t. RU-CVaR with scenario probabilities p <= Lambda, budget, caps, sectors; n continuous."""
|
|
S = len(scen)
|
|
nv = N + 1 + S
|
|
c = np.zeros(nv); c[:N] = -(prices * mu_h) # exact model mean in the objective
|
|
G = scen * prices[None, :]
|
|
rows = [np.repeat(np.arange(S), N), np.arange(S), np.arange(S)]
|
|
cols = [np.tile(np.arange(N), S), np.full(S, N), N + 1 + np.arange(S)]
|
|
vals_ = [G.ravel(), np.ones(S), np.ones(S)]
|
|
lb = [np.zeros(S)]; ub = [np.full(S, np.inf)]
|
|
ri = S
|
|
rows.append(np.concatenate([[ri], np.full(S, ri)])); cols.append(np.concatenate([[N], N + 1 + np.arange(S)]))
|
|
vals_.append(np.concatenate([[1.0], p / (1 - beta)])); lb.append([-np.inf]); ub.append([LAMBDA]); ri += 1
|
|
rows.append(np.full(N, ri)); cols.append(np.arange(N)); vals_.append(prices); lb.append([-np.inf]); ub.append([W]); ri += 1
|
|
for mask in sector_rows:
|
|
rows.append(np.full(N, ri)); cols.append(np.arange(N)); vals_.append(prices * mask); lb.append([-np.inf]); ub.append([SMAX * W]); ri += 1
|
|
Am = sps.csr_matrix((np.concatenate(vals_), (np.concatenate(rows), np.concatenate(cols))), shape=(ri, nv))
|
|
vlb = np.zeros(nv); vub = np.full(nv, np.inf); vub[:N] = WMAX * W / prices; vlb[N] = -np.inf
|
|
res = milp(c, constraints=LinearConstraint(Am, np.concatenate(lb), np.concatenate(ub)), integrality=np.zeros(nv),
|
|
bounds=Bounds(vlb, vub))
|
|
n = res.x[:N]; alpha = res.x[N]
|
|
return n, alpha
|
|
|
|
|
|
def scenarios_sobol(S, seed):
|
|
z = norm.ppf(qmc.Sobol(d=N, scramble=True, seed=seed).random(S))
|
|
p = np.full(S, 1.0 / S)
|
|
return mu_h + weighted_mm(z, p) @ A.T, p
|
|
|
|
|
|
def scenarios_is(S, seed, beta, n1, eps=0.5, shift=None):
|
|
"""Mixture proposal: first eps*S Sobol points shifted by theta along the loss direction of n1, rest nominal."""
|
|
v = n1 * prices
|
|
a = A.T @ v # loss = -v'mu - a'Z
|
|
d = -a / np.linalg.norm(a) # unit direction of increasing loss in Z-space
|
|
theta = (shift if shift is not None else norm.ppf(beta)) * d
|
|
z = norm.ppf(qmc.Sobol(d=N, scramble=True, seed=seed).random(S))
|
|
m = int(round(eps * S))
|
|
z[:m] += theta
|
|
# likelihood ratio f/g with g = (1-eps) f + eps f(.-theta): 1 / ((1-eps) + eps exp(theta'z - |theta|^2/2))
|
|
lr = 1.0 / ((1 - eps) + eps * np.exp(z @ theta - theta @ theta / 2))
|
|
p = lr / lr.sum()
|
|
ess = 1.0 / (p ** 2).sum()
|
|
return mu_h + weighted_mm(z, p) @ A.T, p, ess
|
|
|
|
|
|
def scenarios_is_multi(S, seed, n1, shifts=(1.6449, 2.3263), eps=(0.25, 0.25)):
|
|
"""Multi-component defensive mixture: nominal + one shifted block per shift, all along the loss direction of n1."""
|
|
v = n1 * prices
|
|
a = A.T @ v
|
|
d = -a / np.linalg.norm(a)
|
|
z = norm.ppf(qmc.Sobol(d=N, scramble=True, seed=seed).random(S))
|
|
thetas = [sh * d for sh in shifts]
|
|
start = 0
|
|
for th, e in zip(thetas, eps):
|
|
m = int(round(e * S)); z[start:start + m] += th; start += m
|
|
dens = (1 - sum(eps)) * np.ones(S)
|
|
for th, e in zip(thetas, eps):
|
|
dens = dens + e * np.exp(z @ th - th @ th / 2)
|
|
lr = 1.0 / dens
|
|
p = lr / lr.sum()
|
|
return mu_h + weighted_mm(z, p) @ A.T, p, 1.0 / (p ** 2).sum()
|
|
|
|
|
|
def exact_reference(beta, tol=1e-7):
|
|
k = k_of(beta)
|
|
cuts = []
|
|
ub = WMAX * W / prices
|
|
c = -(prices * mu_h)
|
|
base_rows = [prices] + [prices * m for m in sector_rows]
|
|
base_ub = [W] + [SMAX * W] * len(sector_rows)
|
|
for it in range(500):
|
|
Am = np.vstack(base_rows + [g for g, _ in cuts]); hi = np.array(base_ub + [b for _, b in cuts])
|
|
res = milp(c, constraints=LinearConstraint(Am, np.full(len(hi), -np.inf), hi), integrality=np.zeros(N), bounds=Bounds(np.zeros(N), ub))
|
|
x = res.x; mean, cvar, sd = true_stats(x, beta)
|
|
if cvar <= LAMBDA * (1 + tol):
|
|
return x, mean
|
|
v = x * prices
|
|
grad = -prices * mu_h + k * prices * (cov_h @ v) / sd
|
|
cuts.append((grad, LAMBDA - cvar + grad @ x))
|
|
raise RuntimeError
|
|
|
|
|
|
market_w = md.market_weights(symbols).reindex(symbols).fillna(0.0).values
|
|
market_n = market_w * W / prices # cap-weighted candidate portfolio (continuous shares)
|
|
KINDS = sys.argv[5].split(",") if len(sys.argv) > 5 else ["sobol+mm", "sobol+mm+IS(eps=.5)", "IS-market(z_b)", "IS-market(2.33)"]
|
|
pd.set_option("display.width", 250); pd.set_option("display.float_format", lambda v: f"{v:10.2f}")
|
|
for beta in BETAS:
|
|
x_ref, mean_ref = exact_reference(beta)
|
|
print(f"\n===== beta={beta}: exact optimum E[P&L]={mean_ref:.2f} CHF, CVaR limit {LAMBDA:.0f}")
|
|
for S in SIZES:
|
|
results = {}
|
|
for kind in KINDS:
|
|
rows = []
|
|
t0 = time.time()
|
|
for seed in range(N_SEEDS):
|
|
if kind == "sobol+mm":
|
|
scen, p = scenarios_sobol(S, seed); ess = S
|
|
n, alpha = solve_lp(scen, p, beta)
|
|
elif kind == "IS-market(mix)":
|
|
scen, p, ess = scenarios_is_multi(S, seed + 1000, market_n)
|
|
n, alpha = solve_lp(scen, p, beta)
|
|
elif kind.startswith("IS-market"):
|
|
shift = norm.ppf(beta) if "z_b" in kind else 2.33
|
|
scen, p, ess = scenarios_is(S, seed + 1000, beta, market_n, eps=0.5, shift=shift)
|
|
n, alpha = solve_lp(scen, p, beta)
|
|
else:
|
|
eps = 0.5 if ".5" in kind else 0.3
|
|
scen1, p1 = scenarios_sobol(S, seed) # stage 1: direction from a plain solve
|
|
n1, _ = solve_lp(scen1, p1, beta)
|
|
scen, p, ess = scenarios_is(S, seed + 1000, beta, n1, eps=eps)
|
|
n, alpha = solve_lp(scen, p, beta)
|
|
mean_t, cvar_t, _ = true_stats(n, beta)
|
|
L = -(scen * prices) @ n
|
|
rows.append({"epnl_true": mean_t, "cvar_true": cvar_t, "w": n * prices / W, "ess": ess,
|
|
"tail_pts": int((L > alpha).sum())})
|
|
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] = {
|
|
"true E[P&L] mean": df.epnl_true.mean(), "loss vs optimum (CHF)": mean_ref - df.epnl_true.mean(),
|
|
"true CVaR excess (%)": 100 * (df.cvar_true.mean() / LAMBDA - 1), "true CVaR sd (% of limit)": 100 * df.cvar_true.std() / LAMBDA,
|
|
"turnover between seeds (%)": 100 * np.mean(pair), "turnover to optimum (%)": 100 * np.mean(np.abs(ws - x_ref * prices / W).sum(axis=1)),
|
|
"scenarios beyond VaR": df.tail_pts.mean(), "ESS": df.ess.mean(), "seconds": time.time() - t0,
|
|
}
|
|
print(f"--- S={S}")
|
|
print(pd.DataFrame(results).to_string())
|