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

209 lines
12 KiB
Python

import numpy as np
import pandas as pd
import pytest
from scipy import stats
from quantcore.measurement import risk as rk
def test_normal_var_cvar_reference():
# sigma = 1 %, mu = 0: VaR95 = 1.6449 %, CVaR95 = 2.0627 %, VaR99 = 2.3263 %, CVaR99 = 2.6652 %
assert rk.normal_var(0.0, 0.01, 0.95) == pytest.approx(0.016449, abs=1e-6)
assert rk.normal_cvar(0.0, 0.01, 0.95) == pytest.approx(0.020627, abs=1e-6)
assert rk.normal_var(0.0, 0.01, 0.99) == pytest.approx(0.023263, abs=1e-6)
assert rk.normal_cvar(0.0, 0.01, 0.99) == pytest.approx(0.026652, abs=1e-6)
# a positive mean lowers the loss quantile one-for-one
assert rk.normal_var(0.001, 0.01, 0.95) == pytest.approx(0.015449, abs=1e-6)
def test_historical_var_cvar_reference():
r = pd.Series(np.arange(-0.10, 0.10, 0.001)) # 200 equally spaced returns
var95 = rk.historical_var(r, 0.95)
assert var95 == pytest.approx(-np.quantile(r, 0.05))
cvar95 = rk.historical_cvar(r, 0.95)
tail = r[r <= np.quantile(r, 0.05)]
assert cvar95 == pytest.approx(-tail.mean())
assert cvar95 > var95
def test_scenario_cvar_is_tail_mean():
losses = np.array([1.0, 5.0, 3.0, 9.0, 7.0, 2.0, 8.0, 4.0, 6.0, 10.0]) # S = 10
var, cvar = rk.scenario_cvar(losses, 0.80) # k = 2 -> mean of the two largest
assert cvar == pytest.approx((10 + 9) / 2)
assert var == pytest.approx(9.0)
var, cvar = rk.scenario_cvar(losses, 0.95) # k = 0.5 -> fractional tail: 0.5 * 10 / 0.5
assert cvar == pytest.approx(10.0)
var, cvar = rk.scenario_cvar(losses, 0.85) # k = 1.5 -> (10 + 0.5 * 9) / 1.5
assert cvar == pytest.approx((10 + 0.5 * 9) / 1.5)
assert var == pytest.approx(9.0)
def test_scenario_cvar_matches_ru_minimisation():
rng = np.random.default_rng(0)
losses = rng.standard_normal(500)
beta = 0.95
_, cvar = rk.scenario_cvar(losses, beta)
grid = np.linspace(-1, 4, 20001)
ru = np.min([a + np.maximum(losses - a, 0).mean() / (1 - beta) for a in grid])
assert cvar == pytest.approx(ru, abs=1e-3)
def test_loss_to_money():
assert rk.loss_to_money(0.0, 1000.0) == 0.0
assert rk.loss_to_money(np.log(1 / 0.9), 1000.0) == pytest.approx(100.0)
def test_asset_risk_table_columns_and_scaling():
rng = np.random.default_rng(1)
r = pd.DataFrame(rng.standard_normal((1500, 2)) * 0.01, index=pd.bdate_range("2019-01-01", periods=1500), columns=["A", "B"])
day = rk.asset_risk_table(r, "daily", 200, 0.94, (0.95,), "day")
week = rk.asset_risk_table(r, "daily", 200, 0.94, (0.95,), "week")
assert {"vol_rolling_ann", "vol_ewm_ann", "var95_normal_rolling", "cvar95_hist"} <= set(day.columns)
assert week["horizon_periods"].iloc[0] == 5
# sqrt(5) scaling of the Gaussian VaR for a zero-mean window (approximately, mean is small)
ratio = week["var95_normal_rolling"] / day["var95_normal_rolling"]
assert ratio.between(2.0, 2.5).all()
assert (day["cvar95_normal_ewm"] > day["var95_normal_ewm"]).all()
def test_portfolio_risk_normal_vs_scenarios(md):
svc = rk.RiskService(md)
holdings = {"S0": 100, "S1": 50, "S2": 200}
pr = svc.portfolio_risk(holdings, cash=1000.0, horizon="week", method="normal")
assert pr.value > 0 and set(pr.confidence) == {"0.95", "0.99"}
assert pr.confidence["0.99"]["cvar_money"] > pr.confidence["0.95"]["cvar_money"] > 0
# historical estimate is of the same order of magnitude
ph = svc.portfolio_risk(holdings, cash=1000.0, horizon="week", method="historical")
assert 0.3 < ph.confidence["0.95"]["cvar_money"] / pr.confidence["0.95"]["cvar_money"] < 3.0
# scenarios: Gaussian scenarios with the same covariance reproduce the normal CVaR
rng = np.random.default_rng(0)
r = md.returns(list(holdings), currency="base", freq="daily")
from quantcore.measurement import estimators as est
cov = est.rolling_cov(r, 200)
mu_h = r.iloc[-200:].mean().values * 5
scen = rng.multivariate_normal(mu_h, cov.values * 5, size=200_000)
ps = svc.portfolio_risk(holdings, cash=1000.0, horizon="week", method="scenarios", scenarios=scen)
# differences: sampling noise and the exact money mapping (exp) vs. the log-return closed form
assert ps.confidence["0.95"]["cvar_money"] == pytest.approx(pr.confidence["0.95"]["cvar_money"], rel=0.06)
def test_a_one_asset_portfolio_is_a_valid_request(md):
"""``np.cov`` of a single column returns a 0-d array; the rolling estimator has to keep it 2-d.
The simplest call anyone makes -- the risk of a single position -- went through this path.
"""
svc = rk.RiskService(md)
from quantcore.measurement import estimators as est
r = md.returns(["S0"], currency="base", freq="daily")
assert est.rolling_cov(r, 200).shape == (1, 1)
assert est.ewm_cov(r, 0.94).shape == (1, 1)
for method in ("normal", "historical"):
for cov_method in ("rolling", "ewm"):
pr = svc.portfolio_risk({"S0": 100}, cash=1000.0, horizon="week", method=method,
cov_method=cov_method)
assert pr.value > 0 and pr.confidence["0.95"]["cvar_money"] > 0
# a one-asset portfolio is the asset: the parametric VaR is z * sd_h - mean_h exactly
one = svc.portfolio_risk({"S0": 100}, cash=0.0, horizon="week", method="normal", cov_method="rolling")
sd_week = float(np.sqrt(est.rolling_cov(r, 200).iloc[0, 0] * 5))
mean_week = float(r.iloc[-200:].mean().iloc[0] * 5)
assert one.confidence["0.95"]["var"] == pytest.approx(1.6448536269514722 * sd_week - mean_week, rel=1e-9)
def test_weekly_reference_values_from_documentation():
"""docs/measurement_and_models.tex, Example 4.1: sigma = 1 % daily, one week, V0 = 100 000."""
s_h = 0.01 * np.sqrt(5)
assert rk.normal_var(0.0, s_h, 0.95) == pytest.approx(0.036780, abs=1e-6)
assert rk.normal_cvar(0.0, s_h, 0.95) == pytest.approx(0.046124, abs=1e-6)
assert rk.normal_var(0.0, s_h, 0.99) == pytest.approx(0.052019, abs=1e-6)
assert rk.normal_cvar(0.0, s_h, 0.99) == pytest.approx(0.059596, abs=1e-6)
v0 = 100_000.0
assert rk.loss_to_money(rk.normal_var(0.0, s_h, 0.95), v0) == pytest.approx(3611.2, abs=0.1)
assert rk.loss_to_money(rk.normal_cvar(0.0, s_h, 0.95), v0) == pytest.approx(4507.6, abs=0.1)
assert rk.loss_to_money(rk.normal_var(0.0, s_h, 0.99), v0) == pytest.approx(5068.9, abs=0.1)
assert rk.loss_to_money(rk.normal_cvar(0.0, s_h, 0.99), v0) == pytest.approx(5785.5, abs=0.1)
def test_weighted_quantile_matches_numpy_and_handles_weights():
rng = np.random.default_rng(3)
x = rng.standard_normal(5000)
qs = (0.01, 0.05, 0.5, 0.95)
np.testing.assert_allclose(rk.weighted_quantile(x, qs), np.quantile(x, qs), rtol=1e-12)
# equal weights reproduce the unweighted result (the weighted convention places the
# observations at the midpoints of their probability steps, so the tails differ by half a rank)
w = np.full(len(x), 1 / len(x))
np.testing.assert_allclose(rk.weighted_quantile(x, qs, w), np.quantile(x, qs), atol=0.01)
# column-wise on a matrix
m = rng.standard_normal((2000, 3))
np.testing.assert_allclose(rk.weighted_quantile(m, (0.1, 0.9)), np.quantile(m, (0.1, 0.9), axis=0), rtol=1e-12)
# a sample that over-represents the left tail, corrected by its weights, recovers the standard normal
tilted = np.concatenate([rng.standard_normal(4000), rng.standard_normal(4000) - 2.0])
p = np.concatenate([np.full(4000, 1.0), stats.norm.pdf(tilted[4000:]) / stats.norm.pdf(tilted[4000:] + 2.0)])
p = p / p.sum()
for q in (0.01, 0.05, 0.5):
assert rk.weighted_quantile(tilted, q, p)[0] == pytest.approx(stats.norm.ppf(q), abs=0.12)
def test_value_loss_maps_a_log_loss_to_a_share_of_the_position():
assert rk.value_loss(0.0) == pytest.approx(0.0)
assert rk.value_loss(0.30) == pytest.approx(1 - np.exp(-0.30)) # 25.9 %, not 30 %
assert rk.value_loss(5.0) < 1.0 # can never reach 100 %
np.testing.assert_allclose(rk.value_loss([0.01, 0.1]), [1 - np.exp(-0.01), 1 - np.exp(-0.1)])
# a money loss is the value-space loss times the position
assert rk.loss_to_money(0.30, 1_000.0) == pytest.approx(1_000.0 * rk.value_loss(0.30))
def test_value_space_var_transforms_exactly_but_cvar_needs_the_sigma_squared_term():
"""``1 - e^{-x}`` is monotone, so a VaR carries over unchanged; a CVaR is a tail *mean* and
the concavity costs a ``sigma^2 / 2`` term -- the same correction as the Ito drift."""
m, s, beta = 0.01, 0.30, 0.95
r = np.random.default_rng(1).normal(m, s, 2_000_000)
loss = 1 - np.exp(r)
q = np.quantile(loss, beta)
assert rk.normal_value_var(m, s, beta) == pytest.approx(q, abs=2e-3)
assert rk.normal_value_var(m, s, beta) == pytest.approx(rk.value_loss(rk.normal_var(m, s, beta)), rel=1e-12)
exact = rk.normal_value_cvar(m, s, beta)
assert exact == pytest.approx(loss[loss >= q].mean(), abs=2e-3)
naive = rk.value_loss(rk.normal_cvar(m, s, beta)) # what converting afterwards would give
assert naive > exact # concavity: the naive figure overstates
assert 0.002 < naive - exact < 0.005
# the correction vanishes as sigma -> 0 and grows with sigma
assert rk.value_loss(rk.normal_cvar(m, 0.01, beta)) - rk.normal_value_cvar(m, 0.01, beta) < 1e-5
assert rk.value_loss(rk.normal_cvar(m, 0.6, beta)) - rk.normal_value_cvar(m, 0.6, beta) > 0.005
assert rk.normal_value_cvar(m, 3.0, beta) < 1.0 # never a total loss, whatever the volatility
def test_asset_risk_table_in_value_space():
rng = np.random.default_rng(5)
r = pd.DataFrame(rng.standard_normal((1500, 2)) * np.array([0.012, 0.03]),
index=pd.bdate_range("2019-01-01", periods=1500), columns=["A", "B"])
log = rk.asset_risk_table(r, "daily", horizon="month", confidence=(0.95,))
val = rk.asset_risk_table(r, "daily", horizon="month", confidence=(0.95,), space="value")
for col in ("var95_normal_ewm", "var95_hist"):
np.testing.assert_allclose(val[col], rk.value_loss(log[col]), rtol=1e-7) # quantiles: exact
for col in ("cvar95_normal_ewm", "cvar95_hist"):
assert (val[col] < rk.value_loss(log[col])).all() # tail means: corrected
assert (val[col] < 1.0).all() and (val[col] > 0).all()
np.testing.assert_allclose(val["vol_ewm_ann"], log["vol_ewm_ann"]) # only the losses move
with pytest.raises(ValueError):
rk.asset_risk_table(r, "daily", space="nonsense")
def test_zero_drift_removes_the_extrapolated_mean_from_the_parametric_risk():
"""At a quarterly horizon the estimated mean dominates the VaR: a stock with a strong recent
rally would otherwise look *less* risky than a quiet one."""
rng = np.random.default_rng(11)
r = pd.DataFrame({"trend": rng.standard_normal(1500) * 0.02 + 0.004, # strong drift
"quiet": rng.standard_normal(1500) * 0.02},
index=pd.bdate_range("2019-01-01", periods=1500))
est_ = rk.asset_risk_table(r, "daily", horizon="quarter", confidence=(0.95,))
zero = rk.asset_risk_table(r, "daily", horizon="quarter", confidence=(0.95,), drift="zero")
assert est_.loc["trend", "var95_normal_rolling"] < est_.loc["quiet", "var95_normal_rolling"]
assert zero.loc["trend", "var95_normal_rolling"] == pytest.approx(
zero.loc["quiet", "var95_normal_rolling"], rel=0.05) # same volatility -> same risk
assert zero.loc["trend", "var95_normal_rolling"] > est_.loc["trend", "var95_normal_rolling"]
# the historical columns keep the drift of their window either way
assert zero.loc["trend", "var95_hist"] == est_.loc["trend", "var95_hist"]
with pytest.raises(ValueError):
rk.asset_risk_table(r, "daily", drift="wishful")