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>
223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""The HTTP client and the ``quantcore-api`` CLI, against the app running in this process.
|
|
|
|
``TestClient`` satisfies the transport contract of :class:`QuantcoreClient` (``request(method, url,
|
|
params=, json=, timeout=)``), so the same code paths that talk to the Spark are exercised here
|
|
without a server: the URLs, the query parameters, the request bodies and the error handling.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import quantcore.api.app as app_module
|
|
from quantcore import apicli
|
|
from quantcore.client import QuantcoreClient, QuantcoreError
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def api(settings, store):
|
|
app_module.state = app_module.State(settings)
|
|
with TestClient(app_module.app) as transport:
|
|
yield QuantcoreClient("http://testserver", session=transport)
|
|
|
|
|
|
@pytest.fixture
|
|
def cli(api, monkeypatch):
|
|
"""Run the CLI against the in-process app: every command builds its client through ``_client``."""
|
|
monkeypatch.setattr(apicli, "_client", lambda args: api)
|
|
return apicli.main
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# client
|
|
# ------------------------------------------------------------------------------------------------
|
|
def test_the_client_covers_the_api(api):
|
|
health = api.health()
|
|
assert health["status"] == "ok" and health["store"]["prices"] is True
|
|
|
|
assert len(api.universe(region="Europe")) == 6
|
|
assert api.symbol("S0")["symbol"] == "S0"
|
|
table = api.symbols_table(limit=5)
|
|
assert len(table["symbols"]) == 5 and "sectors" in table["facets"]
|
|
assert table["n_universe"] >= table["n_matched"] >= 5
|
|
|
|
px = api.prices(["S0", "S1"], freq="weekly")
|
|
assert px["columns"] == ["S0", "S1"] and px["freq"] == "weekly"
|
|
assert api.returns(["S0"])["kind"] == "log_returns"
|
|
assert api.market_index(freq="weekly")["n"] > 100
|
|
assert sum(api.market_weights()["weights"].values()) == pytest.approx(1.0)
|
|
|
|
rows = api.asset_risk(["S0", "S1"], horizon="week")
|
|
assert len(rows) == 2 and rows[0]["cvar95_hist"] > rows[0]["var95_hist"]
|
|
assert api.risk_series("S0", horizon="week")["index"]
|
|
assert api.covariance(["S0", "S1"])["columns"] == ["S0", "S1"]
|
|
assert api.correlation(["S0", "S1"])["data"][0][0] == pytest.approx(1.0)
|
|
assert api.portfolio_risk({"S0": 100, "S1": 50}, cash=1000.0, horizon="week")["value"] > 0
|
|
|
|
assert len(api.asset_performance(["S0", "S1"])) == 2
|
|
assert api.rolling_performance("S0", window=252)["index"]
|
|
assert api.portfolio_performance({"S0": 100})
|
|
|
|
assert api.expected_returns(max_assets=6)["market_return_ann"] != 0
|
|
assert api.model_covariance(["S0", "S1"])["conditioning"]["n_assets"] == 2
|
|
scen = api.scenarios(["S0", "S1"], model="gbm", n_scenarios=1024, holdings={"S0": 10})
|
|
assert scen["description"]["n_scenarios"] == 1024 and scen["portfolio"]["invested"] > 0
|
|
|
|
# order-independent: another module may have created a snapshot on the session-scoped store
|
|
assert isinstance(api.jobs(), list) and isinstance(api.snapshots(), list)
|
|
|
|
|
|
def test_the_client_optimises(api):
|
|
res = api.optimize(cash=1_000_000, max_assets=6, horizon="week", scenario_model="gaussian_exact",
|
|
solver="highs", time_limit=120)
|
|
assert res["status"] in ("Optimal", "FeasibleFound")
|
|
assert res["solver"] == "highs" and res["positions"]
|
|
# a rebalancing run carries the starting point, so before/after can be compared
|
|
holdings = {p["symbol"]: p["shares"] for p in res["positions"][:2]}
|
|
reb = api.optimize(mode="rebalance", holdings=holdings, cash=10_000, max_assets=6, horizon="week",
|
|
scenario_model="gaussian_exact", solver="highs", time_limit=120)
|
|
assert reb["before"] is not None and reb["before"]["invested"] > 0
|
|
|
|
|
|
def test_none_arguments_are_left_to_the_api(api):
|
|
"""Optional arguments that are ``None`` must not be sent, or the API would see "None"."""
|
|
sent: dict = {}
|
|
original = api.session.request
|
|
|
|
def spy(method, url, **kw):
|
|
sent.update({"params": kw.get("params"), "json": kw.get("json")})
|
|
return original(method, url, **kw)
|
|
|
|
api.session.request = spy
|
|
try:
|
|
api.prices(["S0"], start=None, end=None)
|
|
assert "start" not in (sent["params"] or {}) and "end" not in (sent["params"] or {})
|
|
api.expected_returns(symbols=None, max_assets=6)
|
|
assert "symbols" not in (sent["json"] or {})
|
|
finally:
|
|
api.session.request = original
|
|
|
|
|
|
def test_the_client_reports_api_errors(api):
|
|
with pytest.raises(QuantcoreError) as exc:
|
|
api.symbol("NOPE")
|
|
assert exc.value.status_code == 404 and not exc.value.unreachable
|
|
|
|
with pytest.raises(QuantcoreError) as exc: # 422: the field name must reach the caller
|
|
api.asset_risk(["S0"], window=1)
|
|
assert exc.value.status_code == 422 and "window" in str(exc.value)
|
|
assert "greater than or equal to 10" in str(exc.value)
|
|
|
|
|
|
def test_the_client_reports_an_unreachable_server():
|
|
with QuantcoreClient("http://127.0.0.1:9", timeout=2.0) as qc, pytest.raises(QuantcoreError) as exc:
|
|
qc.health()
|
|
assert exc.value.unreachable and "127.0.0.1:9" in str(exc.value)
|
|
|
|
|
|
def test_the_client_retries_dropped_gets_but_never_posts():
|
|
"""A poll on a kept-alive connection the server just closed must not abort `update --wait`;
|
|
a POST (optimisation, job start) must never be sent twice."""
|
|
with QuantcoreClient("http://127.0.0.1:9") as qc:
|
|
retry = qc.session.get_adapter("http://127.0.0.1:9/health").max_retries
|
|
assert retry.read >= 1 and "GET" in retry.allowed_methods and "POST" not in retry.allowed_methods
|
|
|
|
|
|
def test_wait_for_job_polls_until_the_job_ends():
|
|
states = iter(["queued", "running", "running", "done"])
|
|
|
|
class Stub:
|
|
def request(self, method, url, **kw):
|
|
class Res:
|
|
status_code = 200
|
|
|
|
@staticmethod
|
|
def json():
|
|
return {"job_id": "abc", "status": next(states)}
|
|
return Res()
|
|
|
|
qc = QuantcoreClient("http://stub", session=Stub())
|
|
seen: list[str] = []
|
|
record = qc.wait_for_job("abc", poll=0.0, on_poll=lambda r: seen.append(r["status"]))
|
|
assert record["status"] == "done" and seen == ["queued", "running", "running"]
|
|
|
|
with pytest.raises(QuantcoreError): # a job that never finishes hits the deadline
|
|
QuantcoreClient("http://stub", session=type("S", (), {
|
|
"request": lambda self, m, u, **k: type("R", (), {
|
|
"status_code": 200, "json": staticmethod(lambda: {"status": "running"})})()})()
|
|
).wait_for_job("abc", poll=0.0, deadline=-1.0)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------------
|
|
# CLI
|
|
# ------------------------------------------------------------------------------------------------
|
|
def test_every_documented_command_parses():
|
|
parser = apicli.build_parser()
|
|
for argv in (
|
|
["health"], ["endpoints"], ["universe", "--region", "Europe", "--sector", "Health Care"],
|
|
["symbol", "S0"], ["prices", "S0", "S1", "--freq", "weekly", "--tail", "5"],
|
|
["returns", "S0", "--currency", "local"], ["market", "weights", "--max-assets", "10"],
|
|
["risk", "S0", "S1", "--horizon", "week", "--drift", "zero", "--space", "value"],
|
|
["risk-portfolio", "--holdings", '{"S0": 10}'], ["correlation", "S0", "S1"],
|
|
["covariance", "S0", "S1", "--method", "rolling"], ["performance", "S0"],
|
|
["expected-returns", "--max-assets", "50", "--view", "S0:0.6:0.4"],
|
|
["model-covariance", "S0", "S1"], ["scenarios", "S0", "--model", "gbm", "--n-scenarios", "1024"],
|
|
["optimize", "--cash", "1000000", "--cvar-limit", "40000", "--risk-drift", "zero"],
|
|
["optimize", "--request", "r.json", "--json"], ["jobs", "--job-id", "abc"],
|
|
["setup", "--offline", "--wait"], ["update", "--full", "--wait"],
|
|
["snapshots", "--create", "--name", "demo"], ["selftest", "--full", "--write"],
|
|
):
|
|
args = parser.parse_args(argv)
|
|
assert callable(args.func), argv
|
|
|
|
|
|
def test_sentiment_views_are_parsed():
|
|
assert apicli._views(["S0:0.6"]) == [{"symbol": "S0", "sentiment": 0.6}]
|
|
assert apicli._views(["S0:0.6:0.4:0.3"]) == [
|
|
{"symbol": "S0", "sentiment": 0.6, "confidence": 0.4, "strength": 0.3}]
|
|
with pytest.raises(SystemExit):
|
|
apicli._views(["S0"])
|
|
|
|
|
|
@pytest.mark.parametrize("argv", [
|
|
["health"], ["endpoints"], ["universe", "--region", "Europe"], ["symbol", "S0"],
|
|
["prices", "S0", "S1", "--tail", "3"], ["returns", "S0", "--tail", "3"], ["market", "index"],
|
|
["market", "weights"], ["risk", "S0", "S1", "--drift", "zero"],
|
|
["risk-portfolio", "--holdings", '{"S0": 10}'], ["correlation", "S0", "S1"],
|
|
["covariance", "S0", "S1"], ["performance", "S0"], ["expected-returns", "--max-assets", "6"],
|
|
["model-covariance", "S0", "S1"], ["scenarios", "S0", "S1", "--n-scenarios", "1024"],
|
|
["jobs"], ["snapshots"],
|
|
])
|
|
def test_the_cli_runs_every_read_only_command(cli, capsys, argv):
|
|
assert cli(argv) == 0
|
|
assert capsys.readouterr().out.strip()
|
|
|
|
|
|
def test_the_cli_optimises_and_prints_a_summary(cli, capsys):
|
|
assert cli(["optimize", "--max-assets", "6", "--scenario-model", "gaussian_exact",
|
|
"--solver", "highs", "--time-limit", "120"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "status Optimal" in out and "E[P&L]" in out and "CVaR" in out
|
|
|
|
|
|
def test_the_selftest_passes_against_a_healthy_service(cli, capsys):
|
|
"""The selftest is the deployment check: every endpoint, one table, exit 0 when all answered."""
|
|
assert cli(["selftest", "--n-symbols", "2"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "FAIL" not in out
|
|
assert "0 failed" in out and "3 skipped" in out # the writing endpoints stay untouched
|
|
for endpoint in ("GET /health", "POST /risk/portfolio", "POST /optimize", "GET /snapshots"):
|
|
assert endpoint in out
|
|
|
|
|
|
def test_the_selftest_fails_loudly_when_the_service_is_down(capsys, monkeypatch):
|
|
monkeypatch.setattr(apicli, "_client", lambda args: QuantcoreClient("http://127.0.0.1:9", timeout=1.0))
|
|
assert apicli.main(["selftest"]) == 1
|
|
assert "not usable" in capsys.readouterr().out
|
|
|
|
|
|
def test_the_cli_exits_non_zero_when_the_api_is_unreachable(capsys):
|
|
assert apicli.main(["--url", "http://127.0.0.1:9", "--timeout", "1", "health"]) == 1
|
|
assert "is the service running?" in capsys.readouterr().err
|