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

210 lines
12 KiB
Python

import numpy as np
import pytest
from fastapi.testclient import TestClient
import quantcore.api.app as app_module
@pytest.fixture(scope="module")
def client(settings, store):
app_module.state = app_module.State(settings)
with TestClient(app_module.app) as c:
yield c
def test_health_and_universe(client):
h = client.get("/health").json()
assert h["status"] == "ok" and h["store"]["prices"] is True and h["cuopt"]["reachable"] is False
u = client.get("/universe", params={"region": "Europe"}).json()
assert len(u) == 6 and all(row["region"] == "Europe" for row in u)
assert client.get("/universe/S0").json()["symbol"] == "S0"
assert client.get("/universe/NOPE").status_code == 404
def test_prices_returns_and_market(client):
p = client.get("/prices", params={"symbols": "S0,S1", "freq": "weekly", "start": "2020-01-01"}).json()
assert p["columns"] == ["S0", "S1"] and len(p["index"]) > 100
r = client.get("/returns", params={"symbols": "S0", "currency": "local"}).json()
assert r["kind"] == "log_returns"
m = client.get("/market/index", params={"freq": "weekly"}).json()
assert "mean_ann" in m and m["n"] > 100
w = client.get("/market/weights").json()["weights"]
assert abs(sum(w.values()) - 1) < 1e-6
def test_risk_and_performance_endpoints(client):
body = {"symbols": ["S0", "S1", "S2"], "horizon": "week", "confidence": [0.95, 0.99]}
rows = client.post("/risk/assets", json=body).json()
assert len(rows) == 3 and rows[0]["cvar99_hist"] > rows[0]["var99_hist"]
corr = client.post("/risk/correlation", json={"symbols": ["S0", "S1"], "method": "ewm", "lam": 0.99}).json()
assert corr["data"][0][0] == pytest.approx(1.0)
pr = client.post("/risk/portfolio", json={"holdings": {"S0": 10, "S3": 5}, "cash": 500, "horizon": "month"}).json()
assert pr["confidence"]["0.95"]["cvar_money"] > 0 and pr["horizon_periods"] == 21
series = client.post("/risk/series", json={"symbol": "S0", "window": 100}).json()
assert "vol_ewm_ann" in series["columns"]
perf = client.post("/performance/assets", json={"symbols": ["S0"], "freq": "weekly"}).json()
assert "sharpe" in perf[0]
pp = client.post("/performance/portfolio", json={"holdings": {"S0": 10, "S1": 20}}).json()
assert "max_drawdown" in pp
roll = client.post("/performance/rolling", json={"symbol": "S1", "window": 60}).json()
assert "sharpe" in roll["columns"]
assert client.post("/risk/assets", json={"symbols": ["NOPE"]}).status_code == 422
def test_models_and_optimize(client):
er = client.post("/model/expected-returns", json={"symbols": ["S0", "S1", "S2", "S3"],
"views": [{"symbol": "S1", "sentiment": 0.8, "confidence": 0.6}]}).json()
assert er["posterior_ann"]["S1"] > er["prior_ann"]["S1"] and len(er["views"]) == 1
sc = client.post("/model/scenarios", json={"symbols": ["S0", "S1"], "model": "gbm", "n_scenarios": 500}).json()
assert sc["description"]["n_scenarios"] == 512 and sc["description"]["n_requested"] == 500 # Sobol': power of two
assert sc["description"]["sampling"] == "sobol" and sc["description"]["moment_matched"] and len(sc["summary"]) == 2
assert len(sc["description"]["hash"]) == 16
body = {"mode": "new", "cash": 200000, "symbols": ["S0", "S1", "S2", "S3", "S4", "S5"], "cvar_limit": 8000,
"n_scenarios": 400, "scenario_model": "gbm", "solver": "highs", "horizon": "week",
"importance_sampling": "off"}
res = client.post("/optimize", json=body).json()
assert res["status"] == "Optimal" and res["solver"] == "highs" and res["reproducible"] is True
assert res["cvar"] <= 8000 + 1e-3 and res["cash_end"] >= 0
assert all(p["shares"] >= 0 for p in res["positions"])
# identical request -> identical scenario hash and identical portfolio
again = client.post("/optimize", json=body).json()
assert again["scenario_hash"] == res["scenario_hash"] and again["positions"] == res["positions"]
# importance sampling and the exact Gaussian mode are accepted and give proposals of the same kind
tilted = client.post("/optimize", json={**body, "importance_sampling": "market"}).json()
assert tilted["status"] == "Optimal" and tilted["model"]["scenarios"]["weighted"] is True
assert tilted["model"]["scenarios"]["importance_sampling"]["direction"] == "market"
adaptive = client.post("/optimize", json={**body, "importance_sampling": "adaptive"}).json()
assert adaptive["status"] == "Optimal"
assert adaptive["solver_info"]["importance_sampling"]["stage1_status"] == "Optimal"
# the market tilt does not depend on the risk limit: same scenario key and hash at another limit
other = client.post("/optimize", json={**body, "importance_sampling": "market", "cvar_limit": 5000}).json()
assert other["scenario_key"]["id"] == tilted["scenario_key"]["id"]
assert other["scenario_hash"] == tilted["scenario_hash"] and other["cvar"] <= 5000 + 1e-3
exact = client.post("/optimize", json={**body, "scenario_model": "gaussian_exact"}).json()
assert exact["status"] == "Optimal" and exact["reproducible"] and exact["scenario_hash"] is None
assert exact["solver_info"]["outer_approximation"]["converged"] and exact["cvar"] <= 8000 * (1 + 1e-5)
hold = {p["symbol"]: p["shares"] for p in res["positions"]}
reb = client.post("/optimize", json={**body, "mode": "rebalance", "holdings": hold, "cash": res["cash_end"],
"cvar_limit": 5000}).json()
assert reb["status"] == "Optimal" and reb["mode"] == "rebalance"
# the starting portfolio is reported on the same scenarios, so before and after are comparable
before = reb["before"]
assert res["before"] is None # a new portfolio has nothing to compare against
assert before["n_positions"] == sum(1 for v in hold.values() if v > 0)
assert before["invested"] == pytest.approx(res["invested"], rel=1e-9)
assert before["cvar"] > 5000 > reb["cvar"] # the rebalancing is what brings the risk under the new limit
assert before["expected_pnl"] > reb["expected_pnl"] # ... at the cost of expected return
assert sum(before["sector_exposure"].values()) == pytest.approx(before["invested"] / reb["wealth"], rel=1e-6)
def test_jobs_and_snapshots(client):
snaps = client.get("/snapshots").json()
assert isinstance(snaps, list)
created = client.post("/snapshots", json={"name": "test-snap"})
assert created.status_code == 201
assert "test-snap" in client.get("/snapshots").json()
assert client.get("/jobs").json() == []
assert client.get("/jobs/nope").status_code == 404
def test_asset_risk_in_value_space(client):
body = {"symbols": ["S0", "S1"], "horizon": "month", "confidence": [0.95, 0.99]}
log = client.post("/risk/assets", json=body).json()
val = client.post("/risk/assets", json={**body, "space": "value"}).json()
for a, b in zip(log, val):
# a VaR is a quantile: it carries over through 1 - e^(-x) (up to the interpolation between
# order statistics, which does not commute with a non-linear map)
assert b["var95_hist"] < a["var95_hist"]
assert b["var95_hist"] == pytest.approx(1 - np.exp(-a["var95_hist"]), rel=1e-4)
assert b["var95_normal_ewm"] == pytest.approx(1 - np.exp(-a["var95_normal_ewm"]), rel=1e-8)
# a CVaR is a tail mean: computed in value space it is strictly below the naive conversion
for col in ("cvar95_normal_ewm", "cvar99_normal_ewm"):
assert b[col] < 1 - np.exp(-a[col])
# this fixture drifts upwards, so at a monthly horizon the estimated mean can push the
# parametric VaR below zero -- the reason the UI asks for drift="zero"
assert a["var95_normal_ewm"] < 0
assert b["cvar99_hist"] < 1.0 # a share of the value can never reach 100 %
assert b["vol_ewm_ann"] == a["vol_ewm_ann"] # only the loss columns change
# the market-risk convention: no drift extrapolated into the horizon
zero = client.post("/risk/assets", json={**body, "drift": "zero"}).json()
for a, z in zip(log, zero):
assert z["var95_normal_ewm"] != a["var95_normal_ewm"]
assert z["var95_hist"] == a["var95_hist"] # historical columns keep their window
def test_symbol_table_and_facets(client):
t = client.get("/data/symbols", params={"limit": 5}).json()
assert t["n_universe"] == 12 and t["n_matched"] == 12 and len(t["symbols"]) == 5
caps = [row["market_cap"] for row in t["symbols"]]
assert caps == sorted(caps, reverse=True) # largest first
assert set(t["facets"]["regions"]) == {"US", "Europe"} and sum(t["facets"]["regions"].values()) == 12
row = t["symbols"][0]
assert row["price"] > 0 and 0 < row["market_weight"] <= 1 and row["sector"]
eu = client.get("/data/symbols", params={"region": "Europe"}).json()
assert eu["n_matched"] == 6 and all(r["region"] == "Europe" for r in eu["symbols"])
one = client.get("/data/symbols", params={"search": "S1"}).json()
assert {r["symbol"] for r in one["symbols"]} >= {"S1", "S10", "S11"}
def test_model_covariance_endpoint(client):
body = {"symbols": ["S0", "S1", "S2", "S3"], "freq": "daily", "horizon": "week",
"covariance": {"method": "ewm", "lam": 0.99, "shrinkage": "auto"}}
c = client.post("/model/covariance", json=body).json()
assert c["correlation"]["columns"] == body["symbols"]
assert c["correlation"]["data"][0][0] == pytest.approx(1.0)
assert 0 <= c["shrinkage_applied"] <= 1 and c["conditioning"]["n_assets"] == 4
assert c["conditioning"]["rank_deficient"] is False
assert sum(c["eigenvalues"]) == pytest.approx(1.0)
# the covariance is scaled to the horizon, the volatility to the year
vol_h = c["volatility_horizon"]["S0"]
assert c["covariance"]["data"][0][0] == pytest.approx(vol_h ** 2, rel=1e-9)
assert c["volatility_ann"]["S0"] == pytest.approx(vol_h * (252 / 5) ** 0.5, rel=1e-9)
def test_scenario_distribution_of_a_portfolio(client):
body = {"symbols": ["S0", "S1", "S2"], "model": "gbm", "n_scenarios": 1024, "horizon": "week",
"weights": {"S0": 0.5, "S1": 0.3, "S2": 0.2}, "value": 1_000_000, "bins": 30,
"focus": ["S0"], "importance_sampling": "off"}
d = client.post("/model/scenarios", json=body).json()
p = d["portfolio"]
assert p["value"] == 1_000_000 and p["invested"] == pytest.approx(1_000_000)
assert len(p["histogram"]["mass"]) == 30 and sum(p["histogram"]["mass"]) == pytest.approx(1.0)
assert p["risk"]["0.99"]["cvar"] > p["risk"]["0.95"]["cvar"] > p["risk"]["0.95"]["var"]
# the quantiles and the VaR are the same statistic seen from two sides (the VaR is the order
# statistic the Rockafellar-Uryasev rows use, the quantile interpolates - one rank apart)
assert p["quantiles"]["0.05"] == pytest.approx(-p["risk"]["0.95"]["var"], rel=1e-2)
assert p["min"] <= p["quantiles"]["0.01"] <= p["quantiles"]["0.5"] <= p["quantiles"]["0.99"] <= p["max"]
assert sum(d["asset_histograms"]["S0"]["mass"]) == pytest.approx(1.0)
# a tilted set reports the same model quantities, with probability weights
tilted = client.post("/model/scenarios", json={**body, "importance_sampling": "market"}).json()
assert tilted["description"]["weighted"] is True
assert tilted["description"]["effective_sample_size"] < tilted["description"]["n_scenarios"]
assert tilted["portfolio"]["risk"]["0.95"]["cvar"] == pytest.approx(p["risk"]["0.95"]["cvar"], rel=0.05)
# shares instead of weights
shares = client.post("/model/scenarios", json={**body, "weights": {}, "holdings": {"S0": 10, "S1": 5}}).json()
assert shares["portfolio"]["invested"] > 0 and len(shares["portfolio"]["exposure"]) == 2
def test_the_ui_is_served(client):
page = client.get("/ui/")
assert page.status_code == 200 and "quantcore" in page.text
assert client.get("/ui/js/main.js").status_code == 200
assert client.get("/", follow_redirects=False).status_code in (307, 308)
def test_the_ui_can_be_switched_off(settings):
"""``QUANTCORE_ENABLE_UI=false`` ships the same image with the JSON API and /docs only."""
from fastapi import FastAPI
off = FastAPI()
assert app_module.mount_test_bench(off, settings.model_copy(update={"enable_ui": False})) is False
with TestClient(off) as c:
assert c.get("/ui/").status_code == 404
assert c.get("/", follow_redirects=False).status_code == 404
on = FastAPI()
assert app_module.mount_test_bench(on, settings.model_copy(update={"enable_ui": True})) is True
with TestClient(on) as c:
assert c.get("/ui/").status_code == 200
assert c.get("/", follow_redirects=False).headers["location"] == "/ui/"