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

126 lines
11 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.

# Architecture
`quantcore` is the **deterministic core** of the platform sketched in the AISE502 project brief:
a set of services with plain contracts, no LLM inside, resilient against external data outages,
and reproducible through pinned data snapshots. The AI layer (news ingestion, `ResearchAgent`,
`AdvisorAgent`, …) sits *on top* and only reads these services through their contracts.
```
┌──────────────────────────────────────────────────────────────────┐
HTTP/JSON │ quantcore.api (FastAPI) – thin, no logic, background jobs │
└───────┬───────────────┬───────────────┬──────────────┬───────────┘
│ │ │ │
┌─────────────────▼──┐ ┌─────────▼─────────┐ ┌──▼──────────┐ ┌▼────────────────────┐
│ SetupService │ │ RiskService │ │ Performance │ │ OptimizationService │
│ DataDownloadService │ │ (measurement.risk) │ │ Service │ │ (optimization) │
└─────────┬───────────┘ └─────────┬─────────┘ └──┬──────────┘ └┬───────────┬────────┘
│ │ │ │ │
┌─────────▼───────────┐ ┌─────────▼───────────────▼──────────┐ │ ┌───────▼────────────┐
│ YahooClient (ext.) │ │ MarketDataService │◄──┘ │ models: │
│ timeout · retry · │ │ prices · FX → CHF · returns · │ │ CovarianceModel │
│ circuit breaker │ │ market caps · cap-weighted index │ │ GBM / innovations │
└─────────┬───────────┘ └─────────┬──────────────────────────┘ │ Black-Litterman │
│ │ └───────┬────────────┘
┌─────────▼────────────────────────▼──────────┐ ┌────────────────▼────────────┐
│ DataStore (parquet/csv under data_dir, │ │ MeanCVaRProblem → MILPModel │
│ manifest, snapshots) │ │ CuOptSolver ⇄ HighsSolver │
└─────────────────────────────────────────────┘ └──────────────┬──────────────┘
│ REST
┌─────────────▼─────────────┐
│ cuOpt server (DGX Spark) │
└───────────────────────────┘
```
## Bounded contexts → modules
| Context | Module | Contract (inputs → outputs) |
|---|---|---|
| Universe | `quantcore.universe` | web candidate lists → `universe.csv` (symbol, name, region, country, currency, exchange, GICS sector, Yahoo sector/industry, market cap local & CHF, shares outstanding, first price date, coverage) |
| Market data | `quantcore.data` | Yahoo → `adj_close.parquet` (date × symbol, listing currency), `fx_chf.parquet` (date × currency); `MarketDataService.prices/returns(symbols, start, end, currency, freq)` → aligned frames |
| Measurement | `quantcore.measurement` | returns frame → risk tables, series, matrices, `PortfolioRisk`; performance tables |
| Models | `quantcore.models` | returns frame → covariance, `ScenarioSet` (S × N horizon log-returns, optional probabilities, fingerprint; `sampling`: Sobol'/moment matching/importance sampling), `BlackLittermanResult` |
| Optimisation | `quantcore.optimization` | `OptimizationRequest` → `OptimizationResult` (shares, trades, cash, costs, CVaR, exposures, provenance of every model input, scenario hash, reproducibility level); `problem` (MILP, weighted scenarios or Gaussian cuts), `rowgen` (tail-row generation), `gaussian_exact` (outer approximation), `solvers` |
| Interface | `quantcore.api`, `quantcore.cli` | JSON/CLI surfaces of the above; no computation of their own |
| Test bench | `quantcore/api/ui` | a static single-page client of the JSON API (`/ui/`): stock data, covariance and scenarios, views, constraints, optimisation with before/after. Static HTML + ES modules, no build step and no CDN, so it also runs in the offline container. It formats and charts what the services return and computes nothing itself |
Dependencies point downwards only: `api → optimization → models → measurement → data → config`.
`measurement` and `models` never touch storage or the network; they operate on DataFrames handed
to them, which is what makes them pure and testable with reference vectors.
## Deterministic by construction
* Every stochastic step takes an explicit `seed` (scenario generation: scrambled Sobol' points,
moment matched — the same request yields the same scenario matrix, fingerprinted as
`scenario_hash`; bit-identical within one environment, while across environments the *key* matches
and the digest need not (§9 of `docs/METHODOLOGY.md` measures why); the *scenario key* lists
exactly what it depends on — risk limits, constraints,
costs, objective, cash, holdings and views are not part of it, so a family of runs is compared on
one set, which the service also caches) and the same portfolio with HiGHS (`reproducibility="exact"`); on cuOpt the
figures agree within its MIP gap of 1e-4 (`"within_gap"`). `scenario_model="gaussian_exact"` needs
no scenarios at all.
* Every result carries the *provenance* of its inputs: covariance model, scenario model and seed,
expected-return model (incl. the views and their calibrated Ω), constraints, costs, solver, status,
gap, timings. An advisor can cite “CVaR 95 % = CHF 38 210 (OptimizationService, empirical model,
EWM λ=0.99, seed 42)”.
* Pinned snapshots (`DataStore.create_snapshot`, `MarketDataService(settings, snapshot=...)`) freeze
universe, prices and FX so that demos and grading are reproducible even if Yahoo misbehaves.
## Where the solving happens
The MILP is built in `quantcore.optimization.problem` and handed to one of two solvers, both fed the
same `MILPModel`:
* **cuOpt** on the GPU, over HTTP to the cuOpt container on the same Spark (`QUANTCORE_CUOPT_URL`):
`http://127.0.0.1:5000` in the Apptainer setup, where both containers share the host's network,
and `http://cuopt:5000` inside the Docker compose network. The only GPU component; an external
dependency, hence the timeouts, retries and circuit breaker.
* **HiGHS** on the CPU, *in the same process as the service* — it is compiled into SciPy
(`scipy.optimize.milp`), so there is nothing to deploy or start, and no network hop. In production
that means inside the `quantcore` container (SciPy 1.18.1 → HiGHS 1.12.0, 4 threads).
`solver="auto"` tries cuOpt and falls back to HiGHS; the result names the solver that produced it and
how reproducible it is. See `docs/METHODOLOGY.md` §9 for versions and the measured comparison.
## Resilience on every external call
| External dependency | Pattern | Fallback |
|---|---|---|
| Yahoo Finance (`YahooClient`) | per-request timeout, 4 retries with exponential back-off, circuit breaker (5 failures → 60 s open) | the stored data / a snapshot; the API keeps serving from the store |
| Candidate lists (Wikipedia, iShares) | timeout, one source after the other | static snapshots shipped in `quantcore/data/static/` |
| cuOpt (`CuOptSolver`) | submit with retry/back-off, polling with client-side deadline, circuit breaker, results deleted server-side | local HiGHS MILP (scipy) — the result says `solver: "highs"` and carries a warning |
## Data model conventions
* prices: adjusted close (splits & dividends), one column per Yahoo symbol, union calendar of all
exchanges, holidays forward-filled (≤ 5 days); pence-quoted London lines converted to GBP.
* FX: cross rates against the base currency, repaired for quoting-unit changes (`clean_fx`) —
a rate returned 100× off for a stretch of days would otherwise be read as a 100× move of
every stock in that currency (see `docs/METHODOLOGY.md` §1).
* returns: natural logarithms; CHF return = local return + FX return (additive).
* frequencies: daily, weekly (Friday close), monthly (month end); horizons: day = 1, week = 5,
month = 21 trading days (or 1 / 4 weeks at weekly frequency).
* money: everything in the base currency (CHF); positions are **integer share numbers**.
## Where the AI layer plugs in
* `ResearchAgent` insights (ticker, sentiment ∈ [−1, 1], degree of belief) become Black–Litterman
views through `view_from_sentiment` / the `views` field of `/optimize` — the numbers (view return,
Ω, posterior) are computed here, the agent only supplies the assessment and its provenance.
* `RiskAgent` / `OptimizationAgent` call `/risk/*` and `/optimize` and *explain* the returned figures.
* The ontology guard can validate tickers and sectors against `/universe`.
## Judging the system by hand
The test bench at `/ui/` exists so that every model choice can be exercised without writing code:
the same scenario set can be seen as a distribution, as per-stock moments and as the risk figures of
an optimised portfolio, and a run's provenance (scenario key and hash, solver, gap, reproducibility)
is shown next to its result. It is also the quickest way to see the invariants hold — two identical
requests share a scenario hash and produce identical shares, and a family of runs that varies only
the risk limit or the constraints reuses one scenario set.
## Operations
* `quantcore setup` (once), `quantcore update` (scheduled, e.g. nightly), `quantcore snapshot`.
* On the Spark the API runs as the `quantcore` container next to `cuopt`, `gurobi`, `jupyter`
(see `docs/SPARK_DEPLOYMENT.md`); data lives in `~/services/work/quantcore_data`.