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

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]))