Portfolio_Risk_Management/docs/SPARK_DEPLOYMENT.md
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

111 lines
5.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Running the quant service on the DGX Spark (Docker, with root)
> This is the setup of the lecturer's Spark, which has root rights, Docker Compose and Tailscale.
> **Students** run the service on their own Spark with Apptainer and without root:
> see [SPARK_APPTAINER.md](SPARK_APPTAINER.md).
The Spark (`spark-6d4a`, reachable over Tailscale) already runs cuOpt, Gurobi and JupyterLab as
Docker containers defined in `~/services/docker-compose.yml`. The quant service joins that
compose file as the container **`quantcore`** on port **5001**; it reaches cuOpt over the compose
network (`http://cuopt:5000`) and keeps its data on the host in `~/services/work/quantcore_data`.
| Service | Address | What it is |
|---|---|---|
| **quantcore** | `http://spark-6d4a:5001` (`/docs` for Swagger, `/ui/` for the test bench) | portfolio data, risk/performance measurement, models, mean-CVaR MILP |
| cuOpt | `http://spark-6d4a:5000` | the GPU MILP solver the service uses |
## Deploy (from the Mac)
```bash
deploy/deploy_to_spark.sh --with-data # first time: also ships the locally built data set
deploy/deploy_to_spark.sh # later: code only
```
The script rsyncs the repository to `~/services/work/Portfolio_Risk_Optimization`, appends the
service block from `deploy/docker-compose.quantcore.yml` to the compose file (once; a backup of the
compose file is kept), builds the image (`python:3.12-slim`, aarch64) and starts the container.
The block is appended **only if `quantcore:` is not already in `~/services/docker-compose.yml`**, so
later edits to `deploy/docker-compose.quantcore.yml` (a new environment variable, a changed port) do
not travel with a redeploy — apply them to the Spark's compose file by hand, or delete the block
there and re-run the script. Code and image changes need no such step.
Without `--with-data` the container starts empty; run the setup once through the API:
```bash
curl -X POST http://spark-6d4a:5001/jobs/setup # ~15 min: 1 117 tickers' metadata + history
curl http://spark-6d4a:5001/jobs/<id> # status: queued | running | done | failed
```
## Operate
```bash
ssh user_fh_fhgr@spark-6d4a
cd ~/services
docker compose ps quantcore # status (healthcheck on /health)
docker compose logs -f quantcore # live logs
docker compose restart quantcore
docker compose up -d --build quantcore # rebuild after a code change (or re-run the deploy script)
# inside the container: the CLI works against the same data volume
docker exec -it quantcore quantcore status
docker exec -it quantcore quantcore update # newest prices (incremental)
docker exec -it quantcore quantcore snapshot --name demo
```
Nightly data refresh (host cron on the Spark), e.g. `crontab -e`:
```
15 23 * * 1-5 curl -s -X POST http://localhost:5001/jobs/update >/dev/null
```
## Use from anywhere on the tailnet
```bash
curl http://spark-6d4a:5001/health
curl "http://spark-6d4a:5001/universe?region=Europe&sector=Health%20Care"
curl -s http://spark-6d4a:5001/risk/assets -H 'Content-Type: application/json' \
-d '{"symbols": ["NESN.SW", "ROP.SW", "AAPL"], "horizon": "week"}'
curl -s http://spark-6d4a:5001/optimize -H 'Content-Type: application/json' \
-d '{"cash": 1000000, "max_assets": 100, "cvar_limit": 40000, "horizon": "week"}'
```
The test bench is served from the same container (`QUANTCORE_ENABLE_UI`, default `true`, in the
compose block). Set it to `"false"` and rebuild for a deployment that exposes the JSON API and
`/docs` only — for users who build their own front end against these endpoints.
`deploy/docker-compose.students.yml` is that deployment, standalone and with the bench already
off; `docs/USAGE.md` is the guide its users need.
Python clients simply point `QUANTCORE_CUOPT_URL` / the API base URL at the Spark; the package
itself also runs on a laptop (`quantcore serve`) and uses the Spark's cuOpt remotely
(`QUANTCORE_CUOPT_URL=http://spark-6d4a:5000`), falling back to HiGHS — which needs no service of its
own: it is compiled into SciPy inside the `quantcore` image (SciPy 1.18.1 → HiGHS 1.12.0) and runs on
the container's CPU — when the
Spark is unreachable.
## Sizing
| Candidates | Scenarios | MILP size (vars / rows / nnz) | cuOpt solve |
|---|---|---|---|
| 100 | 2 000 | 2 101 / 2 013 / 206 k | ~6–10 s |
| 300 | 2 000 | 2 301 / 2 013 / 607 k | ~12 s |
| 745 (all) | 1 000 | 1 746 / 1 013 / 749 k | ~17 s |
| 100, rebalancing with fixed costs | 2 000 | 2 401 / 2 215 / 808 k (400 integers) | ~33 s |
The time limit (`QUANTCORE_CUOPT_TIME_LIMIT`, request field `time_limit`) bounds the solve; a
`FeasibleFound` status with a small `mip_gap` is a valid, slightly sub-optimal answer.
BLAS threads are pinned to 4 (`OPENBLAS_NUM_THREADS`): the empirical-innovation filter performs
thousands of small eigendecompositions, which OpenBLAS runs ~50× slower with all 20 cores.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `/health` shows `cuopt.reachable: false` | cuOpt container down or unhealthy: `docker compose restart cuopt` (see the Spark README §5). Optimisations fall back to HiGHS and say so in `solver` / `warnings`. |
| `/optimize` answers 503 "no market data yet" | run `POST /jobs/setup` or deploy with `--with-data`. |
| Yahoo errors in the setup/update job | rate limiting; the client retries with back-off and opens a circuit after 5 failures. Re-run the job later; stored data stays intact. |
| `FeasibleFound` instead of `Optimal` | time limit hit; raise `time_limit` or reduce `n_scenarios` / `max_assets`. |
| `Infeasible` | the constraints contradict each other (e.g. `min_invested` with a tiny CVaR limit, or a rebalancing turnover cap that prevents reaching the risk budget). |
| Tailscale SSH asks for a browser check | `ssh user_fh_fhgr@spark-6d4a` prints a login URL; open it once, then re-run the deploy script. |