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

92 lines
4.4 KiB
Python

import numpy as np
import pandas as pd
import pytest
from quantcore.models.black_litterman import (
BlackLittermanModel, View, idzorek_omega, idzorek_omega_numeric, view_from_sentiment,
)
@pytest.fixture
def model():
symbols = ["A", "B", "C", "D"]
vols = np.array([0.20, 0.25, 0.15, 0.30]) / np.sqrt(52)
corr = np.array([[1, .5, .3, .2], [.5, 1, .4, .1], [.3, .4, 1, .3], [.2, .1, .3, 1]])
cov = pd.DataFrame(np.outer(vols, vols) * corr, index=symbols, columns=symbols)
w = pd.Series([0.4, 0.3, 0.2, 0.1], index=symbols)
return BlackLittermanModel(cov, w, market_return=0.07 / 52, risk_free=0.01 / 52, tau=0.05, periods_per_year=52)
def test_equilibrium_is_consistent(model):
# the market portfolio earns exactly the market excess return under the implied returns
assert model.w @ model.pi == pytest.approx(model.market_return - model.risk_free)
assert model.delta > 0
res = model.posterior([])
pd.testing.assert_series_equal(res.posterior, res.prior)
# market weights are recovered as the unconstrained MV weights of the prior (tau-adjusted cov ignored here)
w_mv = np.linalg.solve(model.delta * model.sigma, model.pi)
np.testing.assert_allclose(w_mv, model.w)
def test_idzorek_closed_form_equals_numeric(model):
p = np.array([0.0, 1.0, 0.0, 0.0])
q = (0.12 - 0.01) / 52
for c in (0.1, 0.35, 0.5, 0.8, 0.99):
omega_cf = idzorek_omega(p, model.tau * model.sigma, c)
omega_num = idzorek_omega_numeric(p, q, model.pi, model.sigma, model.tau, model.delta, c)
assert omega_cf == pytest.approx(omega_num, rel=1e-4)
def test_confidence_moves_weights_by_fraction(model):
view = View({"B": 1.0}, q=0.15, confidence=0.4)
res = model.posterior([view])
w_post = np.linalg.solve(model.delta * model.sigma, res.posterior.values)
full = model.posterior([View({"B": 1.0}, q=0.15, confidence=1.0)])
w_full = np.linalg.solve(model.delta * model.sigma, full.posterior.values)
tilt_partial = w_post - model.w
tilt_full = w_full - model.w
np.testing.assert_allclose(tilt_partial, 0.4 * tilt_full, atol=1e-10)
def test_full_confidence_reproduces_view(model):
res = model.posterior([View({"B": 1.0}, q=0.15, confidence=1.0)])
assert res.posterior_ann["B"] == pytest.approx(0.15, abs=1e-9)
def test_positive_view_raises_asset_and_relative_view(model):
res = model.posterior([View({"A": 1.0}, q=0.20, confidence=0.6)])
assert res.posterior_ann["A"] > res.prior_ann["A"]
rel = model.posterior([View({"C": 1.0, "D": -1.0}, q=0.05, confidence=0.5, label="C beats D")])
assert (rel.posterior_ann["C"] - rel.posterior_ann["D"]) > (rel.prior_ann["C"] - rel.prior_ann["D"])
assert rel.views[0]["label"] == "C beats D"
assert rel.posterior_cov.shape == (4, 4)
def test_view_from_sentiment(model):
v = view_from_sentiment(model, "C", sentiment=1.0, confidence=0.7, strength=0.25)
assert v.q == pytest.approx(model.prior_ann["C"] + 0.25 * 0.15, rel=1e-6)
neg = view_from_sentiment(model, "C", sentiment=-1.0, confidence=0.7)
assert neg.q < model.prior_ann["C"]
with pytest.raises(ValueError):
View({"A": 1.0}, q=0.1, confidence=0.0)
def test_documentation_example_numbers(model):
"""docs/measurement_and_models.tex, Example 7.2 (the fixture is that four-asset model)."""
assert model.market_var * 52 == pytest.approx(0.02502, abs=1e-5)
assert model.delta == pytest.approx(2.3986, abs=1e-4)
np.testing.assert_allclose(model.prior_ann.values, [0.0736, 0.0880, 0.0435, 0.0550], atol=5e-5)
assert model.w @ model.prior_ann.values == pytest.approx(0.07, abs=1e-9)
res = model.posterior([View({"B": 1.0}, q=0.15, confidence=0.4)])
p = np.array([0.0, 1.0, 0.0, 0.0])
base = p @ (model.tau * model.sigma) @ p
assert base == pytest.approx(6.0096e-5, rel=1e-4)
assert res.omega[0] == pytest.approx(1.5 * base, rel=1e-12)
assert res.omega[0] == pytest.approx(9.0144e-5, rel=1e-4)
np.testing.assert_allclose(res.posterior_ann.values, [0.0835, 0.1128, 0.0494, 0.0580], atol=5e-5)
w_post = np.linalg.solve(model.delta * model.sigma, res.posterior.values)
full = model.posterior([View({"B": 1.0}, q=0.15, confidence=1.0)])
w_100 = np.linalg.solve(model.delta * model.sigma, full.posterior.values)
assert w_100[1] == pytest.approx(0.7139, abs=5e-5) and w_post[1] == pytest.approx(0.4656, abs=5e-5)
np.testing.assert_allclose(w_post[[0, 2, 3]], model.w[[0, 2, 3]], atol=1e-10)