On a student Spark the installer's data step printed hundreds of red yfinance "Quote not found for symbol" lines and built a universe of 652 instead of ~745 stocks. Cause: the iShares holdings download (German site) answers 404 since October 2026, and setup fell back to Wikipedia's STOXX 600 table, whose Reuters-style codes give Yahoo symbols that do not exist (BNPP.PA for BNP.PA, NOV-N.SW for NOVN.SW, VESTAS.CO for VWS.CO). - sources: the iShares file is read from the Swiss site (same German CSV, same parser), the German address stays as a spare; when iShares fails, setup uses the snapshot shipped in quantcore/data/static and not the Wikipedia guesses (now a last resort when no snapshot ships); only a primary source refreshes the snapshot - yahoo: yfinance's per-symbol ERROR lines are silenced during the metadata step and summarised in one line - install.sh: builds the data with `qc setup --offline`, so every student gets the universe of the shipped lists (the course data set's); new --rebuild-data moves the existing data aside and builds it anew - qc.sh: the image build attempt logs to ~/quantcore/build.log; a Spark that allows no build shows one plain line, then the no-build setup - tests for the source order, the address fallback and the summary Tested on a stripped-down machine (no git/curl/wget/python3, broken APPTAINER_TMPDIR): install, then install.sh --rebuild-data built 746 stocks (382 US, 364 Europe) from the 1117 shipped candidates in under 10 minutes with no yfinance error line; self-test passes; qc upgrade rebuilt and restarted. 142 tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
16 KiB
Markdown
292 lines
16 KiB
Markdown
# quantcore on your DGX Spark (Apptainer, no root)
|
||
|
||
This guide sets up the quant service of the project on your own DGX Spark. The service covers
|
||
market data, risk measurement, Black–Litterman expected returns and the mean-CVaR optimiser.
|
||
Everything runs on the Spark itself:
|
||
|
||
- **cuOpt**, NVIDIA's GPU solver, runs in its own Apptainer container.
|
||
- **quantcore**, this repository, runs in a second Apptainer container. It calls cuOpt at
|
||
`http://127.0.0.1:5000`.
|
||
- The API and the browser test bench listen on `http://127.0.0.1:5001`. You reach them from the
|
||
Spark: its desktop browser, a terminal, a notebook, or the platform you build. They are not
|
||
reachable from the network.
|
||
|
||
You need no root rights, no Docker and no VPN. One script, `deploy/apptainer/qc.sh`, does
|
||
everything.
|
||
|
||
## What you need
|
||
|
||
| Requirement | Check |
|
||
|---|---|
|
||
| Apptainer 1.1 or newer | `apptainer --version` |
|
||
| The GPU | `nvidia-smi -L` lists the NVIDIA GB10. Without it, optimisation runs on HiGHS on the CPU. |
|
||
| About 5 GB in your home directory | The cuOpt image is 3.3 GB, quantcore 0.2 GB, the data 40 MB. Pulling cuOpt needs about 12 GB for a few minutes. |
|
||
| Internet access | Docker Hub for the images, PyPI for the Python libraries, Yahoo Finance for market data. |
|
||
|
||
## Quick start
|
||
|
||
Open a terminal on the Spark and run this one line:
|
||
|
||
```bash
|
||
wget -qO- https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash
|
||
```
|
||
|
||
If the Spark has no `wget`, one of these does the same:
|
||
|
||
```bash
|
||
curl -fsSL https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash
|
||
python3 -c "import urllib.request as u;print(u.urlopen('https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh').read().decode())" | bash
|
||
```
|
||
|
||
The installer takes 20 to 30 minutes the first time and prints each step:
|
||
|
||
1. It checks Apptainer and the GPU. It also repairs Apptainer's temporary and cache folders if
|
||
your account points them at a folder that does not exist.
|
||
2. It downloads the code to `~/Portfolio_Risk_Management`. If the Spark has no git, it uses git
|
||
from a small container.
|
||
3. It gets the cuOpt image and the quantcore image.
|
||
4. It builds the market data from Yahoo Finance, which takes about 15 minutes. The candidate
|
||
stocks come from the list shipped in the repository, so every student gets the same universe.
|
||
Companies that Yahoo no longer lists are left out, and setup names them in one summary line.
|
||
5. It starts the service, runs the self-test and installs the command `qc`.
|
||
|
||
With a prepared data set from your lecturer, the data step takes seconds instead. Pass the file
|
||
with `--data`:
|
||
|
||
```bash
|
||
wget -qO- https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash -s -- --data ~/Downloads/quantcore_data.tar.gz
|
||
```
|
||
|
||
Running the installer again is safe. It skips what is done and updates the rest.
|
||
|
||
When it is done, open **http://127.0.0.1:5001/ui/** in the Spark's browser. The interactive API
|
||
documentation is at **http://127.0.0.1:5001/docs**.
|
||
|
||
Working on the Spark over SSH from your laptop? Forward the port, then open the same address in
|
||
the laptop's browser. Nothing else on the network can reach the service:
|
||
|
||
```bash
|
||
ssh -L 5001:127.0.0.1:5001 <your-user>@<your-spark>
|
||
```
|
||
|
||
The installer puts the command `qc` into `~/.local/bin`. Open a new terminal once so that the
|
||
shell finds it. `qc` is the same as `deploy/apptainer/qc.sh` in the code folder.
|
||
|
||
## Daily use
|
||
|
||
```bash
|
||
qc start # cuOpt + API; after a reboot, run it again
|
||
qc status # what runs, whether cuOpt is reachable, how much data there is
|
||
qc logs # API log; `qc logs cuopt` for the solver, add -f to follow
|
||
qc update # the newest prices and FX rates from Yahoo Finance
|
||
qc upgrade # the newest version of the code: download, rebuild, restart
|
||
~/Portfolio_Risk_Management/install.sh --rebuild-data # set the data aside and build it anew
|
||
qc stop # stop both containers
|
||
```
|
||
|
||
The containers keep running after you log out. They stop when the Spark reboots.
|
||
|
||
## Using the service
|
||
|
||
**In the browser.** The test bench at `http://127.0.0.1:5001/ui/` shows every service: the
|
||
stock universe, risk and scenarios, expected returns and views, constraints, and the
|
||
optimisation with a before/after comparison.
|
||
|
||
**From your own code.** This is how the platform you build uses the service. The API speaks
|
||
JSON over HTTP, so any language works. Your code reaches it at `http://127.0.0.1:5001`. That
|
||
address also works inside your own Apptainer containers, because Apptainer containers share
|
||
the Spark's network. For Python there is a ready-made client:
|
||
|
||
```python
|
||
from quantcore.client import QuantcoreClient
|
||
|
||
qc = QuantcoreClient("http://127.0.0.1:5001")
|
||
print(qc.health()["cuopt"]["reachable"])
|
||
risk = qc.asset_risk(["NESN.SW", "AAPL"], horizon="week")
|
||
```
|
||
|
||
Install the client into your own environment with
|
||
`pip install "git+https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management.git"`.
|
||
[USAGE.md](USAGE.md) describes every endpoint and the request fields.
|
||
|
||
**From the command line, through the API.** `qc api` runs the API client against your
|
||
service:
|
||
|
||
```bash
|
||
qc api health
|
||
qc api selftest # calls every endpoint, pass/fail table
|
||
qc api risk NESN.SW AAPL --horizon week
|
||
qc api optimize --cash 1000000 --cvar-limit 40000 --horizon week
|
||
```
|
||
|
||
**From the command line, without the API.** `qc cli` runs the services directly in the
|
||
container. The API does not need to be running:
|
||
|
||
```bash
|
||
qc cli status
|
||
qc cli risk NESN.SW AAPL --horizon week
|
||
qc cli optimize --cash 1000000 --cvar-limit 40000 --horizon week --max-assets 50
|
||
```
|
||
|
||
## Settings
|
||
|
||
`qc install` writes the settings file `~/quantcore/quantcore.env`. Edit it, then run
|
||
`qc restart`. A value set in the shell wins over the file, for example
|
||
`QC_API_PORT=5011 qc start`.
|
||
|
||
| Setting | Default | Meaning |
|
||
|---|---|---|
|
||
| `QC_API_PORT` | `5001` | Port of the API and the test bench. |
|
||
| `QC_CUOPT_PORT` | `5000` | Port of the cuOpt server. |
|
||
| `QC_CUOPT_START` | `auto` | `auto` uses a cuOpt that already answers on the port, or starts one. `yes` always starts its own. `no` never starts one, so HiGHS solves every model. |
|
||
| `QC_CUOPT_IMAGE` | `docker://nvidia/cuopt:26.8.0-cuda13.3-py3.14` | Where the cuOpt image comes from. Set the path of a `.sif` file if your Spark already provides one. |
|
||
| `QC_IMAGE` | `build` | How quantcore gets its container; see the next section. |
|
||
| `QUANTCORE_*` | | Passed into the container unchanged. Examples are `QUANTCORE_CUOPT_TIME_LIMIT` and `QUANTCORE_ENABLE_UI`. All of them are listed in `quantcore/config.py`. |
|
||
|
||
**Several users on one Spark.** Ports belong to the whole machine. If someone else already uses
|
||
port 5001, `qc start` stops and suggests a free port to put into your settings file. A cuOpt
|
||
that another user already runs on port 5000 is simply used: cuOpt keeps no state between
|
||
requests.
|
||
|
||
## How quantcore gets its container
|
||
|
||
`QC_IMAGE=build` is the default. `qc install` then builds the image `quantcore.sif` from this
|
||
repository with Apptainer's unprivileged build. Some Spark installations do not allow
|
||
unprivileged builds. In that case `qc install` falls back by itself to a setup that needs no
|
||
build. It pulls a plain Python image and installs quantcore into a virtual environment in
|
||
`~/quantcore/venv`. The libraries and the results are the same, because both setups install the
|
||
pinned versions in `deploy/apptainer/constraints.txt`. You can choose the fallback directly
|
||
with `QC_IMAGE=venv`.
|
||
|
||
`QC_IMAGE` also accepts the path of a prebuilt `quantcore.sif`, or a `docker://` or `oras://`
|
||
address to pull one from.
|
||
|
||
**New versions of the code** come with `qc upgrade`. It downloads the changes, rebuilds quantcore
|
||
and restarts the service.
|
||
|
||
## How it works
|
||
|
||
```
|
||
DGX Spark, your user account, no root
|
||
┌────────────────────────────────────────────────────────────────────────────┐
|
||
│ browser / your platform / qc api │
|
||
│ │ HTTP │
|
||
│ ▼ │
|
||
│ 127.0.0.1:5001 instance "quantcore-api" quantcore.sif │
|
||
│ FastAPI + test bench ~/quantcore/data -> /data │
|
||
│ │ HTTP (msgpack) │
|
||
│ ▼ │
|
||
│ 127.0.0.1:5000 instance "quantcore-cuopt" cuopt.sif, started with --nv │
|
||
│ cuOpt server ──► GB10 GPU │
|
||
└────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- An Apptainer **instance** is a container that runs in the background, like a service.
|
||
`apptainer instance list` shows yours.
|
||
- Apptainer containers use the Spark's network directly, with no port mapping. A server inside a
|
||
container that listens on `127.0.0.1` is therefore reachable from the Spark, and only from it.
|
||
- The images are read-only. Everything that changes lives in `~/quantcore/data`, which is
|
||
mounted into the container as `/data`.
|
||
- The containers start with `--cleanenv`. Variables from your shell, such as an activated
|
||
virtual environment or `PYTHONPATH`, cannot interfere. `qc.sh` passes only the settings above,
|
||
plus proxy variables if you have them.
|
||
- NVIDIA's cuOpt image sets its home directory to `/opt/cuopt`. That folder is writable under
|
||
Docker but read-only in an Apptainer image, and the solver would crash on its first job when
|
||
CuPy writes its kernel cache there. `qc.sh` therefore starts the cuOpt server with its home
|
||
directory in `~/quantcore/cuopt-home`.
|
||
- When cuOpt is not reachable, or stops during a solve, every optimisation falls back to HiGHS on
|
||
the CPU. The result says which solver produced it.
|
||
|
||
| Path | Content |
|
||
|---|---|
|
||
| `~/quantcore/quantcore.env` | your settings |
|
||
| `~/quantcore/data/` | universe, prices, FX, snapshots |
|
||
| `~/quantcore/images/cuopt.sif` | the cuOpt image |
|
||
| `~/quantcore/images/quantcore.sif` | the quantcore image; in the fallback, `python.sif` plus `~/quantcore/venv/` |
|
||
| `~/quantcore/cuopt-home/` | cuOpt's caches (CuPy kernels, CUDA) |
|
||
| `~/.apptainer/instances/logs/` | instance logs, also shown by `qc logs` |
|
||
| `~/.apptainer/cache/` | downloaded image layers; `qc clean-cache` frees the space |
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Cause and fix |
|
||
|---|---|
|
||
| `port 5001 is taken` | Another user or program uses the port. Set the suggested `QC_API_PORT` in `~/quantcore/quantcore.env`, then run `qc start`. |
|
||
| `qc status` shows cuOpt as not answering | Run `qc logs cuopt`. Check that `nvidia-smi` works. Then `qc restart`. Optimisations keep working on HiGHS in the meantime. |
|
||
| The cuOpt log says `Read-only file system: '/opt/cuopt/.cupy'` | cuOpt was started without `qc.sh`. Start it with `qc start`, or give it a writable home directory as described in "Running cuOpt by hand". |
|
||
| `/optimize` answers 503, "no market data yet" | Run `qc data-import <file>` or `qc setup`. |
|
||
| Yahoo errors during `qc setup` or `qc update` | Yahoo limits how fast it answers. Run the command again later; stored data stays intact. |
|
||
| `FATAL: ... failed to create build parent dir: stat /scratch/...` | Your account sets `APPTAINER_TMPDIR` to a folder that does not exist. The installer and `qc` now detect this, use a folder that works, and save the choice in `~/quantcore/quantcore.env`. Run the installer again. |
|
||
| `git: command not found` | Use the one-line installer. It needs no git. |
|
||
| Hundreds of red `Quote not found for symbol` lines during setup | Your installation predates the fix. Its setup used guessed symbols, so many European companies are missing from your data. Run `qc upgrade`, then `~/Portfolio_Risk_Management/install.sh --rebuild-data`. |
|
||
| `qc: command not found` right after the installation | Open a new terminal, or run `export PATH="$HOME/.local/bin:$PATH"`. |
|
||
| `qc install` stops with "no 'instance run'" | The Spark's Apptainer is older than 1.1. Ask the administrator to update it. |
|
||
| The cuOpt pull fails with "no space left on device" | Apptainer unpacks images in `/tmp`. Point it at a larger directory: `APPTAINER_TMPDIR=~/quantcore/tmp qc pull-cuopt`. |
|
||
| Model status `FeasibleFound` instead of `Optimal` | The time limit was reached. Raise `time_limit` in the request or `QUANTCORE_CUOPT_TIME_LIMIT`, or reduce `n_scenarios` or `max_assets`. |
|
||
|
||
## Running cuOpt by hand
|
||
|
||
`qc start` does this for you. If you want to run the cuOpt image yourself, for example for another
|
||
project, start it like this:
|
||
|
||
```bash
|
||
apptainer pull cuopt.sif docker://nvidia/cuopt:26.8.0-cuda13.3-py3.14
|
||
mkdir -p ~/cuopt-home
|
||
apptainer instance run --nv --cleanenv --bind ~/cuopt-home cuopt.sif my-cuopt \
|
||
env HOME=$HOME/cuopt-home python -m cuopt_server.cuopt_service -i 127.0.0.1 -p 5000
|
||
curl http://127.0.0.1:5000/cuopt/health
|
||
apptainer instance stop my-cuopt
|
||
```
|
||
|
||
## For the lecturer
|
||
|
||
**Data set for the students.** Many students running `qc setup` at the same time from one
|
||
network can hit Yahoo's rate limits. A prepared data set avoids that, and every student then
|
||
works on the same data. Pack it on a machine that has the data:
|
||
|
||
```bash
|
||
qc data-export quantcore_data.tar.gz # or, from any data directory:
|
||
tar -czf quantcore_data.tar.gz -C <data dir> --exclude ./jobs --exclude '*.log' .
|
||
```
|
||
|
||
The archive is about 30 MB. It is not part of the public repository: Yahoo's terms do not allow
|
||
redistributing their data.
|
||
|
||
**Prebuilt image.** Build `quantcore.sif` on any aarch64 Linux machine where you can build
|
||
images, then hand out the file:
|
||
|
||
```bash
|
||
apptainer build quantcore.sif deploy/apptainer/quantcore.def # from the repository root
|
||
```
|
||
|
||
Students set `QC_IMAGE=/path/to/quantcore.sif` in their settings file. A copy built and tested on
|
||
the lecturer's Spark is described in the verification note below.
|
||
|
||
**The lecturer's own Spark** runs the Docker setup in [SPARK_DEPLOYMENT.md](SPARK_DEPLOYMENT.md).
|
||
That setup needs root and Docker. Students do not need it.
|
||
|
||
**Verification.** The setup was tested end to end on 7 October 2026 on a DGX Spark: NVIDIA GB10,
|
||
driver 580.159, Ubuntu 24.04, Apptainer 1.5.4, as a user without root. That Spark allowed no
|
||
unprivileged image builds, so `qc install` took the virtualenv fallback by itself. The image mode
|
||
was then tested with a prebuilt `quantcore.sif`. In both modes:
|
||
|
||
- `qc api selftest --full` passed on every endpoint, and the optimisation ran on cuOpt on the GPU.
|
||
- `qc update` fetched the newest Yahoo prices through the running API.
|
||
- A taken API port stopped `qc start` with a suggested free port, and that port worked.
|
||
- The test suite passed inside the container, including the round trip to the live cuOpt server.
|
||
|
||
The one-line installer was then tested on a stripped-down machine. It had no git, curl, wget or
|
||
python3, a home folder named like a domain account (`/home/name@edu.local`), and `APPTAINER_TMPDIR`
|
||
pointing to a `/scratch` folder that does not exist. The installer repaired the folders, fetched the
|
||
code with git from a container, installed, started and passed the self-test. `qc upgrade` picked up
|
||
a new commit, rebuilt quantcore and restarted it in under a minute. `qc setup` built a data set from
|
||
scratch inside the container.
|
||
|
||
**Where the candidate stocks come from.** The installer runs `qc setup --offline`. It takes the
|
||
candidates from the lists shipped in `quantcore/data/static/`: the S&P 500 and the iShares STOXX
|
||
Europe 600 holdings of September 2026, from which the course data set was built. `qc setup` without
|
||
`--offline` fetches the current lists instead. In October 2026 iShares moved the German download, so
|
||
setup now reads the same file from iShares' Swiss site. If that fails too, setup uses the shipped
|
||
list. It no longer uses Wikipedia's STOXX 600 table, whose codes mostly do not exist on Yahoo, for
|
||
example `BNPP.PA` instead of `BNP.PA`.
|