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>
92 lines
4.4 KiB
Python
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)
|