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>
55 lines
2.0 KiB
Python
55 lines
2.0 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from quantcore.measurement import estimators as est
|
|
|
|
|
|
@pytest.fixture
|
|
def r():
|
|
rng = np.random.default_rng(3)
|
|
x = rng.standard_normal((600, 4)) @ np.array([[1, .5, .2, 0], [0, 1, .3, 0], [0, 0, 1, .4], [0, 0, 0, 1]]) * 0.01
|
|
return pd.DataFrame(x, index=pd.bdate_range("2020-01-01", periods=600), columns=list("ABCD"))
|
|
|
|
|
|
def test_rolling_cov_matches_numpy(r):
|
|
cov = est.rolling_cov(r, 100)
|
|
np.testing.assert_allclose(cov.values, np.cov(r.values[-100:], rowvar=False, ddof=1))
|
|
corr = est.rolling_corr(r, 100)
|
|
np.testing.assert_allclose(np.diag(corr.values), 1.0)
|
|
np.testing.assert_allclose(corr.values, np.corrcoef(r.values[-100:], rowvar=False), atol=1e-12)
|
|
|
|
|
|
def test_ewm_cov_diagonal_matches_pandas(r):
|
|
lam = 0.94
|
|
cov = est.ewm_cov(r, lam, max_obs=len(r))
|
|
pd_var = r.ewm(alpha=1 - lam, adjust=True).var(bias=True).iloc[-1]
|
|
np.testing.assert_allclose(np.diag(cov.values), pd_var.values, rtol=1e-8)
|
|
assert est.ewm_weights(10, lam).sum() == pytest.approx(1.0)
|
|
|
|
|
|
def test_ewm_sequence_recursion_converges_to_direct(r):
|
|
lam = 0.97
|
|
last = None
|
|
for date, cov_prev, _ in est.covariance_sequence(r, "ewm", lam=lam, warmup=100):
|
|
last = (date, cov_prev)
|
|
# Sigma_{T-1} from the recursion vs. the direct (normalised-weight) estimate at T-1
|
|
direct = est.ewm_cov(r.iloc[:-1], lam, max_obs=len(r))
|
|
assert last[0] == r.index[-1]
|
|
np.testing.assert_allclose(last[1], direct.values, rtol=0.05, atol=1e-7)
|
|
|
|
|
|
def test_rolling_sequence_matches_cov(r):
|
|
seq = list(est.covariance_sequence(r, "rolling", window=50))
|
|
date, cov_prev, ret = seq[-1]
|
|
assert date == r.index[-1]
|
|
np.testing.assert_allclose(cov_prev, np.cov(r.values[-51:-1], rowvar=False, ddof=1))
|
|
np.testing.assert_allclose(ret, r.values[-1])
|
|
|
|
|
|
def test_shrinkage_keeps_variances(r):
|
|
cov = est.rolling_cov(r, 200)
|
|
sh = est.shrink(cov, 0.3)
|
|
np.testing.assert_allclose(np.diag(sh.values), np.diag(cov.values))
|
|
assert abs(sh.values[0, 1]) == pytest.approx(0.7 * abs(cov.values[0, 1]))
|