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>
11 KiB
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 asscenario_hash; bit-identical within one environment, while across environments the key matches and the digest need not (§9 ofdocs/METHODOLOGY.mdmeasures 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:5000in the Apptainer setup, where both containers share the host's network, andhttp://cuopt:5000inside 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 thequantcorecontainer (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 (seedocs/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
ResearchAgentinsights (ticker, sentiment ∈ [−1, 1], degree of belief) become Black–Litterman views throughview_from_sentiment/ theviewsfield of/optimize— the numbers (view return, Ω, posterior) are computed here, the agent only supplies the assessment and its provenance.RiskAgent/OptimizationAgentcall/risk/*and/optimizeand 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
quantcorecontainer next tocuopt,gurobi,jupyter(seedocs/SPARK_DEPLOYMENT.md); data lives in~/services/work/quantcore_data.