Portfolio_Risk_Management/tests/test_service_equivalence.py
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

282 lines
18 KiB
Python

"""Service-level checks: the scenario optimiser reproduces the exact Gaussian (mean-variance-equivalent)
optimum when the scenarios are moment-matched Sobol' points, the drift convention is arithmetic, and
importance sampling / row generation leave the optimum unchanged."""
import numpy as np
import pandas as pd
import pytest
from quantcore.optimization.problem import MeanCVaRProblem
from quantcore.optimization.service import OptimizationRequest, OptimizationService
from tests.conftest import SYMBOLS
BASE = dict(symbols=SYMBOLS, cash=500_000.0, cvar_limit=12_000.0, solver="highs", horizon="week",
costs={"proportional_buy": 0.0, "proportional_sell": 0.0}, importance_sampling="off",
constraints={"max_weight": 0.15, "max_sector_weight": 0.4})
@pytest.fixture(scope="module")
def svc(settings, md):
return OptimizationService(settings, md)
def gaussian_cvar_of(svc, req, result):
"""Closed-form CVaR of a proposal under the Gaussian model of the request."""
symbols = svc.candidate_symbols(req)
mu_ann, _, _ = svc.expected_returns(req, symbols)
spec, _ = svc.build_gaussian_spec(req, symbols, mu_ann)
shares = {p["symbol"]: p["shares"] for p in result.positions}
value = np.array([shares.get(s, 0) for s in symbols]) * spec.prices
return MeanCVaRProblem(spec).gaussian_risk(value, result.total_cost)
def test_sobol_moment_matched_scenarios_reproduce_the_exact_optimum(svc):
exact = svc.optimize(OptimizationRequest(scenario_model="gaussian_exact", **BASE))
assert exact.status == "Optimal" and exact.reproducible and exact.solver_info["outer_approximation"]["converged"]
scen = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **BASE))
assert scen.status == "Optimal" and scen.model["scenarios"]["moment_matched"]
# The GBM scenarios are lognormal in simple returns while the exact mode is normal: at weekly
# volatilities of 4-5 % the lognormal left tail is ~2-3 % thinner, so the scenario optimum takes
# ~3 % more exposure. Within that model difference the two agree (the sharp test of the sampling
# itself, with Gaussian scenarios used linearly, is in test_rowgen_gaussian.py).
assert abs(scen.expected_pnl - exact.expected_pnl) <= 0.05 * exact.expected_pnl
_, cvar_true, _ = gaussian_cvar_of(svc, OptimizationRequest(scenario_model="gaussian_exact", **BASE), scen)
assert cvar_true <= BASE["cvar_limit"] * 1.05
# the expected horizon return reported per position is the model's (arithmetic) expectation
mu_ann, _, _ = svc.expected_returns(OptimizationRequest(**BASE), SYMBOLS)
for p in scen.positions:
assert abs(p["expected_return_horizon"] - mu_ann[p["symbol"]] / 252 * 5) < 1e-12
# plain Monte Carlo without moment matching: the sample mean disagrees with the model mean
mc = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, sampling="mc", moment_match=False, **BASE))
assert mc.status == "Optimal"
dev_mc = abs(mc.solver_info["objective"] - mc.expected_pnl) # objective uses the model mean either way
assert dev_mc < 1e-6 # (the objective is always the model expectation) ...
scen_mean_mc = svc.build_spec(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, sampling="mc",
moment_match=False, **BASE), SYMBOLS,
svc.scenarios(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, sampling="mc",
moment_match=False, **BASE), SYMBOLS, mu_ann))
# ... but the sample mean of a plain MC set is visibly off the model mean, a moment-matched one is not
err_mc = np.abs(scen_mean_mc.scenarios.mean(axis=0) - scen_mean_mc.expected_returns).max()
sobol_spec = svc.build_spec(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **BASE), SYMBOLS,
svc.scenarios(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **BASE), SYMBOLS, mu_ann))
err_sobol = np.abs(sobol_spec.scenarios.mean(axis=0) - sobol_spec.expected_returns).max()
assert err_sobol < 0.1 * err_mc
def test_importance_sampling_and_row_generation_keep_the_optimum(svc):
plain = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **BASE))
tilted = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096,
**{**BASE, "importance_sampling": "market"}))
assert tilted.status == "Optimal" and tilted.model["scenarios"]["weighted"]
assert tilted.model["scenarios"]["effective_sample_size"] > 2000
assert abs(tilted.expected_pnl - plain.expected_pnl) <= 0.02 * plain.expected_pnl
exact_req = OptimizationRequest(scenario_model="gaussian_exact", **BASE)
assert gaussian_cvar_of(svc, exact_req, tilted)[1] <= BASE["cvar_limit"] * 1.05
rg = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **{**BASE, "row_generation": "on"}))
assert rg.status == "Optimal" and rg.solver_info["row_generation"]["converged"]
assert rg.scenario_hash == plain.scenario_hash # same scenario set ...
assert abs(rg.expected_pnl - plain.expected_pnl) <= 1e-4 * plain.expected_pnl # ... same optimum
assert max(rg.solver_info["row_generation"]["working_set_sizes"]) < 4096
def test_empirical_model_options(svc):
req = OptimizationRequest(scenario_model="empirical", n_scenarios=2048, **{**BASE, "importance_sampling": "market"})
res = svc.optimize(req)
assert res.status == "Optimal" and res.model["scenarios"]["model"] == "empirical"
assert all(t > 0 for t in res.model["scenarios"]["importance_sampling"]["theta"])
assert res.model["scenarios"]["importance_sampling"]["direction"] == "market"
assert res.cvar <= BASE["cvar_limit"] * (1 + 1e-6)
bal = svc.optimize(OptimizationRequest(scenario_model="empirical", n_scenarios=1000, sampling="balanced", **BASE))
assert bal.status == "Optimal" and bal.model["scenarios"]["n_scenarios"] == 1000
with pytest.raises(ValueError):
OptimizationRequest(scenario_model="bogus", **BASE)
with pytest.raises(ValueError):
OptimizationRequest(**{**BASE, "importance_sampling": "maybe"})
def test_one_scenario_set_for_a_family_of_runs(svc):
"""Risk level, constraints, costs, objective, cash and holdings must not change the scenarios;
a different expected-return model must change only their drift, not their shocks."""
svc.clear_scenario_cache()
base = dict(BASE, importance_sampling="market", scenario_model="gbm", n_scenarios=2048)
ref = svc.optimize(OptimizationRequest(**base))
variants = {
"other CVaR limit": dict(base, cvar_limit=6_000.0),
"limit as a fraction": dict(base, cvar_limit=None, cvar_limit_fraction=0.02),
"confidence 99 %": dict(base, confidence=0.99, cvar_limit=20_000.0),
"tighter caps": dict(base, constraints={"max_weight": 0.10, "max_sector_weight": 0.3}),
"with costs": dict(base, costs={"proportional_buy": 0.002, "proportional_sell": 0.002}),
"min-CVaR objective": dict(base, objective="min_cvar", cvar_limit=None, target_return=0.001),
"utility objective": dict(base, objective="utility", cvar_limit=None, risk_aversion=0.2),
"more cash": dict(base, cash=2_000_000.0),
"rebalancing": dict(base, mode="rebalance", cash=50_000.0,
holdings={p["symbol"]: p["shares"] for p in ref.positions[:3]}),
}
for label, kwargs in variants.items():
res = svc.optimize(OptimizationRequest(**kwargs))
assert res.status == "Optimal", label
assert res.scenario_key["id"] == ref.scenario_key["id"], label
assert res.scenario_hash == ref.scenario_hash, label
assert res.solver_info["scenario_cache"]["reused"], label # and it was not regenerated
# a different expected-return model shifts the scenarios but leaves the shocks alone
symbols = svc.candidate_symbols(OptimizationRequest(**base))
req = OptimizationRequest(**base)
mu_a, _, _ = svc.expected_returns(req, symbols)
scen_a = svc.scenarios(req, symbols, mu_a)
req_views = OptimizationRequest(**dict(base, views=[{"symbol": SYMBOLS[0], "sentiment": 0.9, "confidence": 0.8}]))
mu_b, _, _ = svc.expected_returns(req_views, symbols)
scen_b = svc.scenarios(req_views, symbols, mu_b)
assert scen_b.hash != scen_a.hash and scen_b.shock_hash == scen_a.shock_hash
np.testing.assert_allclose(scen_b.shocks, scen_a.shocks, atol=1e-15)
assert (scen_b.expected_simple[0] > scen_a.expected_simple[0]) # the view moved the drift
# what *must* change the set: symbols, horizon, covariance model, seed, sampling, scenario count
for label, kwargs in {
"fewer symbols": dict(base, symbols=SYMBOLS[:8]),
"monthly horizon": dict(base, horizon="month"),
"other covariance": dict(base, covariance={"method": "rolling", "window": 250}),
"other seed": dict(base, seed=7),
"plain Monte Carlo": dict(base, sampling="mc"),
"more scenarios": dict(base, n_scenarios=4096),
"no tilt": dict(base, importance_sampling="off"),
}.items():
other = OptimizationRequest(**kwargs)
assert svc.scenario_key(other, svc.candidate_symbols(other))["id"] != ref.scenario_key["id"], label
# the adaptive tilt is honest about depending on the risk parameters
ad = OptimizationRequest(**dict(base, importance_sampling="adaptive"))
ad_other = OptimizationRequest(**dict(base, importance_sampling="adaptive", cvar_limit=6_000.0))
assert svc.scenario_key(ad, symbols)["id"] != svc.scenario_key(ad_other, symbols)["id"]
assert "adaptive_tilt" in svc.scenario_key(ad, symbols)
def test_tilt_is_driven_by_the_covariance_and_reports_its_alignment(svc):
"""The tilt shifts each asset by its covariance with the reference portfolio (not by its own
volatility), leaves the model correlations exactly intact, and the result says how well the
solution's loss lines up with the tilted direction."""
from scipy.stats import norm
from quantcore.models import sampling as smp
from quantcore.models.covariance import CovarianceModel
from quantcore.models.simulation import GBMSimulator, TiltSpec
from quantcore.optimization.problem import OptimizationConstraints, ProblemSpec, TransactionCosts
req = OptimizationRequest(**dict(BASE, scenario_model="gbm", n_scenarios=2048, importance_sampling="market"))
symbols = svc.candidate_symbols(req)
r, cov_model = svc._returns_and_cov_model(req, symbols)
h = req.horizon_periods
cov_h = cov_model.estimate(r).values * h
root = smp.pca_root(cov_h)
reference = svc.market_exposure(symbols) * 1e6
# 1. the shift of a tilted block is exactly -z_beta * Sigma v / sigma: proportional to each
# asset's covariance with the reference, i.e. to its beta, not to its own volatility
z = norm.ppf(0.95)
direction = smp.loss_direction(root, reference)
shift = z * (root @ direction)
sigma = np.sqrt(reference @ cov_h @ reference)
np.testing.assert_allclose(shift, -z * (cov_h @ reference) / sigma, atol=1e-12)
beta_i = (cov_h @ reference) / (reference @ cov_h @ reference)
assert np.corrcoef(shift, beta_i)[0, 1] < -0.999 # perfectly ordered by beta
if np.std(np.sqrt(np.diag(cov_h))) > 0: # ... and only loosely by own volatility
assert np.corrcoef(shift, np.sqrt(np.diag(cov_h)))[0, 1] > -0.999
# 2. the weighted tilted sample has exactly the model covariance (and correlations)
mu_ann, _, _ = svc.expected_returns(req, symbols)
sim = GBMSimulator(cov_model)
tilted = sim.simulate(r, n_scenarios=2048, horizon_periods=h, mu=mu_ann / 252, seed=1,
tilt=TiltSpec(exposure=reference))
w = tilted.weights
centred = tilted.log_returns - w @ tilted.log_returns
cov_t = (centred * w[:, None]).T @ centred
np.testing.assert_allclose(cov_t, cov_h, rtol=1e-8, atol=1e-14)
unweighted = np.cov(tilted.log_returns, rowvar=False)
assert np.abs(unweighted - cov_h).max() > 1e-6 # the raw tilted sample is *not* the model
# 3. alignment: 1 for the reference itself, negative for a portfolio built to oppose it
spec = ProblemSpec(scenarios=tilted.simple_returns(), probabilities=tilted.probabilities,
expected_returns=tilted.expected_simple, **svc._spec_common(req, symbols))
assert abs(svc.tilt_alignment(spec, reference, svc.market_exposure(symbols)) - 1) < 1e-9 # itself
opposed = np.zeros(len(symbols))
opposed[np.argsort(beta_i)[:2]] = 1e5 # the two assets least exposed to the reference
assert svc.tilt_alignment(spec, opposed, svc.market_exposure(symbols)) < 0.9
# a real run reports the figure and stays well above the warning threshold
res = svc.optimize(req)
assert res.solver_info["tilt_alignment"] > 0.5 and not res.warnings
def test_risk_drift_zero_keeps_the_limit_binding_when_the_forecast_is_large(md, settings):
"""Over a long horizon a big expected return shifts the whole tail into profit, and a CVaR limit
measured on the drifted scenarios stops constraining anything. The zero-drift convention measures
risk on the centred scenarios -- so the risk figure does not depend on the return forecast at all
-- while the objective still uses it."""
svc = OptimizationService(settings, md)
base = dict(symbols=SYMBOLS[:8], cash=1_000_000.0, horizon=252, freq="daily", confidence=0.99,
cvar_limit_fraction=0.12, scenario_model="gbm", n_scenarios=2048, solver="highs",
expected_returns="zero", row_generation="off", importance_sampling="off")
symbols = svc.candidate_symbols(OptimizationRequest(**base))
v = np.full(len(symbols), 125_000.0) # an equally weighted portfolio of the whole wealth
risk = {}
for mu_val in (0.8, 1.5): # 80 % and 150 % p.a. forecasts
for mode in ("model", "zero"):
req = OptimizationRequest(**{**base, "risk_drift": mode})
spec = svc.build_spec(req, symbols, svc.scenarios(req, symbols, pd.Series(mu_val, index=symbols)))
risk[(mu_val, mode)] = OptimizationService.portfolio_risk_on_scenarios(spec, v / spec.prices, 0.99)
if mode == "zero":
np.testing.assert_allclose(spec.mean_returns, svc.build_spec(
OptimizationRequest(**{**base, "risk_drift": "model"}), symbols,
svc.scenarios(OptimizationRequest(**base), symbols, pd.Series(mu_val, index=symbols))
).mean_returns) # the objective sees the forecast either way
# the zero-drift risk is the same whatever the forecast; the drifted one is not
assert risk[(0.8, "zero")]["cvar"] == pytest.approx(risk[(1.5, "zero")]["cvar"], rel=1e-12)
assert risk[(1.5, "model")]["cvar"] < 0 < risk[(1.5, "zero")]["cvar"] # the tail became a gain
assert risk[(0.8, "model")]["cvar"] < risk[(0.8, "zero")]["cvar"]
# the drift enters multiplicatively (e^{r+d} - 1), so the gap is well below mu * V
gap = risk[(1.5, "zero")]["cvar"] - risk[(1.5, "model")]["cvar"]
assert 0.3 * 1.5e6 < gap < 1.5e6
res = svc.optimize(OptimizationRequest(**{**base, "expected_returns": "historical"}))
if res.cvar <= 0: # the historical forecast is large enough to make the limit vacuous
assert any("constrains nothing" in w for w in res.warnings)
def test_an_all_cash_utility_solution_says_why(md, settings):
"""objective='utility' with a large lambda is optimal in cash -- which must not look like a bug."""
from quantcore.optimization.service import OptimizationRequest, OptimizationService
svc = OptimizationService(settings, md)
common = dict(cash=1_000_000.0, max_assets=6, horizon="week", scenario_model="gaussian_exact",
solver="highs", time_limit=120, objective="utility")
big = svc.optimize(OptimizationRequest(risk_aversion=50.0, **common))
assert big.status in ("Optimal", "FeasibleFound") and big.invested == 0
assert any("nothing was invested" in w and "risk_aversion" in w for w in big.warnings)
small = svc.optimize(OptimizationRequest(risk_aversion=0.0, **common))
assert small.invested > 0 and not any("nothing was invested" in w for w in small.warnings)
def test_a_weighted_cvar_objective_is_routed_to_highs_and_says_so(md, settings):
"""cuOpt aborts when the CVaR is the objective *and* the scenarios carry probabilities.
The routing is that narrow on purpose: the same objective with uniform weights solves on the GPU,
so only the weighted case may be diverted, and only under solver='auto'.
"""
from quantcore.optimization.service import OptimizationRequest, OptimizationService
svc = OptimizationService(settings, md)
common = dict(cash=1_000_000.0, max_assets=6, horizon="week", scenario_model="gbm",
n_scenarios=1024, solver="auto", time_limit=120, risk_drift="zero")
weighted = svc.optimize(OptimizationRequest(objective="min_cvar", target_return=0.0005,
importance_sampling="market", **common))
assert weighted.solver == "highs"
assert any("probability-weighted" in w for w in weighted.warnings)
# uniform weights: no diversion (cuOpt is unreachable in the tests, so it still lands on HiGHS --
# through the fallback, which says something different)
plain = svc.optimize(OptimizationRequest(objective="min_cvar", target_return=0.0005,
importance_sampling="off", **common))
assert not any("probability-weighted" in w for w in plain.warnings)
# the constrained form is never diverted either
other = svc.optimize(OptimizationRequest(objective="max_return", cvar_limit_fraction=0.05,
importance_sampling="market", **common))
assert not any("probability-weighted" in w for w in other.warnings)