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

315 lines
16 KiB
Python

"""HTTP client of the quant-core API -- the package seen from the outside.
The services of :mod:`quantcore` can be used in two ways. In-process, by importing
``MarketDataService``, ``RiskService``, ``OptimizationService`` and friends, which needs the data
directory on the same machine. Or over HTTP, against a running ``quantcore serve`` (or the container
on the DGX Spark), which needs nothing but a URL -- this module.
:class:`QuantcoreClient` has one method per endpoint, passes the request bodies through unchanged
(the field names are exactly those of ``quantcore.api.schemas``, so the interactive docs at
``/docs`` are its reference) and returns the parsed JSON. It adds no logic and no defaults of its
own beyond the ones the API already applies: what comes back is what the services computed.
from quantcore.client import QuantcoreClient
qc = QuantcoreClient("http://spark-6d4a:5001")
qc.health()["store"]["last_price_date"]
qc.asset_risk(["NESN.SW", "AAPL"], horizon="week", drift="zero")
res = qc.optimize(cash=1_000_000, cvar_limit=40_000, horizon="week", risk_drift="zero")
res["status"], res["cvar"], len(res["positions"])
Errors: any answer that is not 2xx raises :class:`QuantcoreError` carrying the status code and the
API's ``detail`` message; an unreachable server raises it too, naming the URL that was tried. The
methods that start background work (``start_setup``, ``start_update``) return immediately with a job
id -- :meth:`QuantcoreClient.wait_for_job` polls it to completion.
The transport is injectable (``session=``): anything with a ``request(method, url, params=,
json=, timeout=)`` method that returns an object with ``status_code``, ``json()`` and ``text``.
``requests.Session`` is the default; ``fastapi.testclient.TestClient`` satisfies it too, which is
how the tests exercise this client against the app in-process.
"""
from __future__ import annotations
import os
import time
from typing import Any, Self
import requests
DEFAULT_URL = "http://127.0.0.1:5001"
DEFAULT_TIMEOUT = 900.0 # an optimisation may legitimately run for minutes
JOB_STATES_DONE = ("done", "failed")
def default_url() -> str:
"""``QUANTCORE_API_URL`` if set, else ``http://127.0.0.1:5001``."""
return os.environ.get("QUANTCORE_API_URL", DEFAULT_URL)
class QuantcoreError(RuntimeError):
"""An API answer that is not 2xx, or a server that could not be reached.
``status_code`` is ``None`` when the request never got an answer (connection refused, timeout,
DNS failure) -- distinguishing "the service said no" from "there is no service there".
"""
def __init__(self, message: str, *, status_code: int | None = None, method: str = "", path: str = ""):
super().__init__(message)
self.status_code = status_code
self.method = method
self.path = path
@property
def unreachable(self) -> bool:
return self.status_code is None
def _retrying_session() -> requests.Session:
"""A session that retries an idempotent request (GET/HEAD/OPTIONS) when the connection drops
before the answer arrives -- typically a kept-alive connection that the server closed between two
calls: uvicorn closes idle connections after 5 s, which is also the default job poll interval.
POSTs are never retried after they were sent: an optimisation or a job must not run twice."""
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(total=2, connect=2, read=2, status=0, redirect=0, other=0, backoff_factor=0.2,
allowed_methods=frozenset({"GET", "HEAD", "OPTIONS"}), raise_on_status=False)
session = requests.Session()
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
return session
class QuantcoreClient:
"""Client of one quant-core API instance.
``base_url``: e.g. ``http://127.0.0.1:5001`` or ``http://spark-6d4a:5001``; a trailing slash and
a trailing ``/ui`` are stripped, so a URL copied out of the browser works.
``timeout``: seconds per request, overridable per call (``timeout=`` on any method).
"""
def __init__(self, base_url: str | None = None, timeout: float = DEFAULT_TIMEOUT, session: Any = None):
self.base_url = (base_url or default_url()).rstrip("/").removesuffix("/ui")
self.timeout = float(timeout)
self._session = session
self._owns_session = session is None
# -- transport ------------------------------------------------------------------------------
@property
def session(self) -> Any:
if self._session is None:
self._session = _retrying_session()
return self._session
def close(self) -> None:
if self._owns_session and self._session is not None:
self._session.close()
self._session = None
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def request(self, method: str, path: str, *, params: dict | None = None, json: Any = None,
timeout: float | None = None) -> Any:
"""One call against the API. ``params``/``json`` entries that are ``None`` are dropped, so a
method can pass every optional argument and let the API apply its own defaults."""
if params:
params = {k: v for k, v in params.items() if v is not None}
if isinstance(json, dict):
json = {k: v for k, v in json.items() if v is not None}
url = f"{self.base_url}{path}"
try:
res = self.session.request(method, url, params=params, json=json,
timeout=timeout if timeout is not None else self.timeout)
except requests.RequestException as exc:
raise QuantcoreError(f"cannot reach the quant-core API at {url}: {exc}",
method=method, path=path) from exc
if not 200 <= res.status_code < 300:
raise QuantcoreError(f"{method} {path} -> {res.status_code}: {_detail(res)}",
status_code=res.status_code, method=method, path=path)
return res.json()
def get(self, path: str, **kw: Any) -> Any:
return self.request("GET", path, **kw)
def post(self, path: str, **kw: Any) -> Any:
return self.request("POST", path, **kw)
# -- system ---------------------------------------------------------------------------------
def health(self, **kw: Any) -> dict:
"""Store status (symbols, days, last price date), cuOpt reachability, base currency."""
return self.get("/health", **kw)
def openapi(self, **kw: Any) -> dict:
"""The OpenAPI schema -- the machine-readable list of every endpoint and field."""
return self.get("/openapi.json", **kw)
# -- data -----------------------------------------------------------------------------------
def universe(self, region: str | None = None, sector: str | None = None, country: str | None = None,
**kw: Any) -> list[dict]:
"""The investable universe, one row per stock (metadata, market cap, coverage)."""
return self.get("/universe", params={"region": region, "sector": sector, "country": country}, **kw)
def symbol(self, symbol: str, **kw: Any) -> dict:
"""One universe row."""
return self.get(f"/universe/{symbol}", **kw)
def symbols_table(self, region: str | None = None, sector: str | None = None, country: str | None = None,
search: str | None = None, limit: int = 200, sort: str = "market_cap", **kw: Any) -> dict:
"""The universe as a table plus the filter facets (counts per region, sector, country) --
one call for a stock picker."""
return self.get("/data/symbols", params={"region": region, "sector": sector, "country": country,
"search": search, "limit": limit, "sort": sort}, **kw)
def prices(self, symbols: list[str], start: str | None = None, end: str | None = None,
currency: str = "base", freq: str = "daily", **kw: Any) -> dict:
"""Adjusted closes as ``{"index": [...], "columns": [...], "data": [[...]]}``."""
return self.get("/prices", params={"symbols": ",".join(symbols), "start": start, "end": end,
"currency": currency, "freq": freq}, **kw)
def returns(self, symbols: list[str], start: str | None = None, end: str | None = None,
currency: str = "base", freq: str = "daily", **kw: Any) -> dict:
"""Log-returns in the same frame layout as :meth:`prices`."""
return self.get("/returns", params={"symbols": ",".join(symbols), "start": start, "end": end,
"currency": currency, "freq": freq}, **kw)
def market_index(self, start: str | None = None, end: str | None = None, freq: str = "weekly",
**kw: Any) -> dict:
"""The cap-weighted broad-market index: its return series and annualised mean/volatility."""
return self.get("/market/index", params={"start": start, "end": end, "freq": freq}, **kw)
def market_weights(self, max_assets: int | None = None, **kw: Any) -> dict:
"""Current market-capitalisation weights (the Black-Litterman equilibrium portfolio)."""
return self.get("/market/weights", params={"max_assets": max_assets}, **kw)
# -- risk -----------------------------------------------------------------------------------
def asset_risk(self, symbols: list[str], **body: Any) -> list[dict]:
"""Per-stock volatility, VaR and CVaR (normal and historical, rolling and EWM).
``horizon``, ``freq``, ``window``, ``lam``, ``confidence``, ``currency``, and:
``drift="zero"`` leaves the estimated mean out of the parametric figures (the market-risk
convention, and the only sane choice at long horizons), ``space="value"`` reports them as a
fraction of the position's value instead of as log-returns.
"""
return self.post("/risk/assets", json={"symbols": symbols, **body})
def risk_series(self, symbol: str, **body: Any) -> dict:
"""Rolling and EWM volatility / VaR / CVaR through time for one stock."""
return self.post("/risk/series", json={"symbol": symbol, **body})
def covariance(self, symbols: list[str], **body: Any) -> dict:
"""Covariance matrix as measured (``method="rolling"|"ewm"``, ``window``, ``lam``,
``shrinkage``). For the estimator the *models* use, see :meth:`model_covariance`."""
return self.post("/risk/covariance", json={"symbols": symbols, **body})
def correlation(self, symbols: list[str], **body: Any) -> dict:
"""Correlation matrix, same parameters as :meth:`covariance`."""
return self.post("/risk/correlation", json={"symbols": symbols, **body})
def portfolio_risk(self, holdings: dict[str, float], **body: Any) -> dict:
"""VaR/CVaR of a portfolio held in *share numbers* (plus ``cash``), normal or historical."""
return self.post("/risk/portfolio", json={"holdings": holdings, **body})
# -- performance ----------------------------------------------------------------------------
def asset_performance(self, symbols: list[str], **body: Any) -> list[dict]:
"""Annualised return, volatility, Sharpe, Sortino, drawdown, Calmar, skew, kurtosis."""
return self.post("/performance/assets", json={"symbols": symbols, **body})
def rolling_performance(self, symbol: str, **body: Any) -> dict:
"""Rolling (``window``) or exponentially weighted (``lam``) performance series."""
return self.post("/performance/rolling", json={"symbol": symbol, **body})
def portfolio_performance(self, holdings: dict[str, float], **body: Any) -> dict:
"""Performance of a share-based portfolio, rebalanced or buy-and-hold."""
return self.post("/performance/portfolio", json={"holdings": holdings, **body})
# -- models ---------------------------------------------------------------------------------
def expected_returns(self, **body: Any) -> dict:
"""Black-Litterman expected returns: equilibrium ``pi``, the posterior, and every
intermediate quantity (``delta``, ``tau``, the calibrated ``omega``). ``views`` are the same
objects ``/optimize`` takes, so a view can be inspected before it is optimised on."""
return self.post("/model/expected-returns", json=body)
def model_covariance(self, symbols: list[str], **body: Any) -> dict:
"""The covariance the scenario generator and the optimiser use, with its conditioning
(eigenvalues, applied shrinkage, effective observations)."""
return self.post("/model/covariance", json={"symbols": symbols, **body})
def scenarios(self, symbols: list[str], **body: Any) -> dict:
"""A scenario set and its statistics: per-stock moments, the P&L distribution of a
portfolio (``holdings`` or ``weights`` + ``value``), histograms, VaR/CVaR, and the
fingerprint (``hash``, ``shock_hash``) that identifies the draws."""
return self.post("/model/scenarios", json={"symbols": symbols, **body})
# -- optimisation ---------------------------------------------------------------------------
def optimize(self, **body: Any) -> dict:
"""Mean-CVaR MILP in integer shares. ``mode="new"`` invests ``cash``; ``mode="rebalance"``
starts from ``holdings`` and pays transaction ``costs``, and the result carries a ``before``
block so the two states can be compared.
The result names the solver, the status, the MIP gap, the reproducibility level, the
scenario key and hash, and the provenance of every model input. Can run for minutes: pass
``time_limit`` (seconds, the budget of the whole request) and a generous ``timeout=``.
"""
return self.post("/optimize", json=body)
# -- jobs and snapshots ---------------------------------------------------------------------
def start_setup(self, offline: bool = False, max_per_region: int | None = None, **kw: Any) -> dict:
"""Build the universe and download all history (~15 min). Returns a job id at once."""
return self.post("/jobs/setup", params={"offline": offline, "max_per_region": max_per_region}, **kw)
def start_update(self, full: bool = False, **kw: Any) -> dict:
"""Fetch the newest prices and FX rates. Returns a job id at once."""
return self.post("/jobs/update", params={"full": full}, **kw)
def jobs(self, **kw: Any) -> list[dict]:
return self.get("/jobs", **kw)
def job(self, job_id: str, **kw: Any) -> dict:
"""One job: ``queued`` | ``running`` | ``done`` | ``failed``, with its report or error."""
return self.get(f"/jobs/{job_id}", **kw)
def wait_for_job(self, job_id: str, poll: float = 5.0, deadline: float | None = None,
on_poll: Any = None) -> dict:
"""Poll a job until it is ``done`` or ``failed``; returns the final job record.
``deadline``: seconds to wait before giving up (``None``: forever). ``on_poll``: a callable
invoked with each intermediate record, for progress output.
"""
started = time.monotonic()
while True:
record = self.job(job_id)
if record.get("status") in JOB_STATES_DONE:
return record
if deadline is not None and time.monotonic() - started > deadline:
raise QuantcoreError(f"job {job_id} still {record.get('status')} after {deadline:.0f} s",
method="GET", path=f"/jobs/{job_id}")
if on_poll is not None:
on_poll(record)
time.sleep(poll)
def snapshots(self, **kw: Any) -> list[str]:
return self.get("/snapshots", **kw)
def create_snapshot(self, name: str | None = None, **kw: Any) -> dict:
"""Freeze universe, prices and FX under a name, for reproducible demos and grading."""
return self.post("/snapshots", json={"name": name}, **kw)
def _detail(res: Any) -> str:
"""The API's error message: FastAPI puts it in ``detail``, validation errors in a list."""
try:
body = res.json()
except Exception: # noqa: BLE001 (not JSON: a proxy error page, a truncated answer)
return (getattr(res, "text", "") or "")[:500]
detail = body.get("detail", body) if isinstance(body, dict) else body
if isinstance(detail, list): # 422: [{"loc": [...], "msg": ..., "type": ...}, ...]
return "; ".join(f"{'.'.join(str(p) for p in d.get('loc', []))}: {d.get('msg')}"
for d in detail if isinstance(d, dict))[:500]
return str(detail)[:500]