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>
210 lines
12 KiB
Python
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/"
|