After the one-line installation, following the README on a student Spark failed: `quantcore ...` was "command not found" (the software lives in the container) and the laptop route's `pip install -e .` stopped with Ubuntu's "externally-managed-environment". - install.sh writes `quantcore` and `quantcore-api` into ~/.local/bin next to `qc`: they run the CLI and the API client inside the container; `quantcore serve` starts the background service, `setup`/`update` go through the running API; a user's own file of that name is kept - install.sh adds ~/.local/bin to PATH in ~/.bashrc (once, guarded): Ubuntu's ~/.profile only adds it when it existed at login - the closing message says to open a new terminal and shows commands to try - install.sh and `qc status` flag a data set built from the old Wikipedia candidates and give the --rebuild-data command - README: a numbered Spark quick start (install, new terminal, use) with the commands that work there, and the laptop quick start marked as such, with a virtual environment; SPARK_APPTAINER.md and USAGE.md match - cli: `risk` and `performance` round only numeric columns (pandas warned about the date rows in every call) Tested on a stripped-down machine: after the installation, a new interactive shell ran every Spark command of the README (quantcore risk, performance, optimize on cuOpt, quantcore-api health, quantcore serve); qc upgrade rebuilt and restarted; 142 tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
quantcore — Portfolio Risk Measurement & Optimisation
The deterministic quant core of the AI-Augmented Portfolio Intelligence Platform (AISE502): data, risk and performance measurement, stock-price models, Black–Litterman expected returns and mean–CVaR portfolio optimisation in whole shares, solved as a MILP on NVIDIA cuOpt on the DGX Spark.
Everything here is deterministic — same input, same output — and contains no LLM call. The AI layer of the platform (news → insights → views) consumes these services through their contracts and never computes a number itself.
| Service | Module | What it does |
|---|---|---|
| SetupService | quantcore.universe |
Builds the investment universe once: largest 500 US (S&P 500) and 500 European (STOXX 600; EU, CH, NO, UK) stocks, Yahoo metadata (name, country, listing currency, exchange, GICS sector/industry, market cap, shares outstanding), full price history since 2004, FX rates; keeps only stocks with prices since 2005. |
| DataDownloadService | quantcore.data |
Fetches the newest Yahoo prices and FX rates (incremental with dividend-adjustment splicing, or full), snapshots for reproducible demos. |
| MarketDataService | quantcore.data.market_data |
Aligned adjusted closes and log-returns, daily/weekly/monthly, in CHF or local currency; market caps, cap-weighted broad market index. |
| RiskService | quantcore.measurement.risk |
Volatility, correlation, covariance, VaR and CVaR (95 %, 99 %) per asset and per share-based portfolio — moving windows and exponentially weighted (EWM), Gaussian and historical, for a day / week / month, as absolute CHF amounts. |
| PerformanceService | quantcore.measurement.performance |
Annualised return, volatility, Sharpe, Sortino, drawdown, Calmar, skew/kurtosis, hit ratio; rolling and EWM series; portfolios in shares. |
| Stock-price models | quantcore.models.simulation |
Geometric Brownian motion (multivariate normal) and the empirical-innovation model eps_t = Sigma_{t-1}^{-1/2} r_t, `r(j |
| Expected returns | quantcore.models.black_litterman |
Market-cap equilibrium returns (broad-market average, market weights), Black–Litterman on a 10-year weekly covariance with Idzorek confidence views (diagonal Ω), sentiment → view helper. |
| OptimizationService | quantcore.optimization |
Mean–CVaR MILP in integer share numbers: new portfolio (from cash) or rebalancing (existing holdings, transaction costs); constraints: max 10 % per stock, max per sector, no shorts, cost/turnover limits, optional cardinality; scenario sets with variance reduction (Sobol', moment matching, market-direction importance sampling) that stay identical when risk limits, constraints, costs or views change, tail-row generation for large sets, and an exact Gaussian (mean–variance-equivalent) mode; solved on cuOpt (GPU, over HTTP) with a HiGHS fallback (CPU, compiled into SciPy — runs in-process, nothing to deploy). |
| HTTP API | quantcore.api |
FastAPI app (/docs) exposing all of the above; setup/update as background jobs. Runs as a container on the DGX Spark next to cuOpt. |
Documentation: docs/USAGE.md (start here if you are building on this package: the API, the
Python client, the quantcore-api command line, the conventions and the traps),
docs/ARCHITECTURE.md (services, contracts, data flow, resilience),
docs/METHODOLOGY.md (every formula and convention),
docs/optimization_model.pdf (derivation of the MILP from the Rockafellar–Uryasev
model in weights to whole shares with rebalancing and costs; sampling error of scenario sets, quasi-Monte Carlo,
moment matching, importance sampling, row generation, the exact Gaussian/mean–variance case and reproducibility;
source docs/optimization_model.tex),
docs/measurement_and_models.pdf (risk and performance measures, rolling/EWM
estimators and shrinkage, GBM and empirical-innovation scenarios, Black–Litterman with Idzorek confidence;
source docs/measurement_and_models.tex),
docs/SPARK_APPTAINER.md (students: running it on your own DGX Spark with
Apptainer, no root), docs/SPARK_DEPLOYMENT.md (the Docker setup on a Spark with root).
Quick start on your DGX Spark (students)
The service and cuOpt run as two Apptainer containers on your Spark and talk over 127.0.0.1.
No root rights, no Docker, no git, nothing exposed to the network.
1. Install. One line in a terminal on the Spark installs everything, builds the market data from Yahoo Finance (~15 min) and starts the service:
wget -qO- https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash
Without wget: curl -fsSL https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash.
With a prepared data set: wget -qO- https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management/raw/branch/main/install.sh | bash -s -- --data /path/to/quantcore_data.tar.gz.
2. Open a new terminal. The installer adds the commands qc, quantcore and
quantcore-api to ~/.local/bin; a terminal that was open during the installation does not know
them yet.
3. Use it. The test bench is at http://127.0.0.1:5001/ui/ in the Spark's browser, the API
documentation at http://127.0.0.1:5001/docs. Your platform calls the API at
http://127.0.0.1:5001. On the command line:
qc status # what runs, cuOpt, how much data
quantcore risk NESN.SW AAPL ASML.AS --horizon week --window 200 --lam 0.94
quantcore performance NESN.SW AAPL --freq weekly
quantcore optimize --cash 1000000 --cvar-limit 40000 --confidence 0.95 --horizon week --max-assets 100
quantcore-api health # the same service through its HTTP API
quantcore update # newest prices / FX
qc stop / qc start # the service runs in the background; after a reboot: qc start
qc upgrade # newest version of this repository
On the Spark, quantcore and quantcore-api run inside the container. Do not run pip install
there: the software is already installed in the container, and Ubuntu refuses system-wide
pip install anyway ("externally-managed-environment"). The full guide, with settings, several
users on one Spark and troubleshooting, is docs/SPARK_APPTAINER.md.
Quick start on your own computer (not the Spark)
With Python 3.11+ on a laptop or workstation, install the package into a virtual environment. Not needed on a Spark that ran the installer above.
git clone https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management.git
cd Portfolio_Risk_Management
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env # optional: data dir, cuOpt URL, ...
quantcore setup # once: universe + full history (~15 min, ~1100 Yahoo tickers)
quantcore status # what is stored, cuOpt health
quantcore update # later: newest prices / FX (incremental)
quantcore snapshot --name demo # freeze the data for a reproducible demo
quantcore risk NESN.SW AAPL ASML.AS --horizon week --window 200 --lam 0.94
quantcore performance NESN.SW AAPL --freq weekly
quantcore optimize --cash 1000000 --cvar-limit 40000 --confidence 0.95 --horizon week --max-assets 100
quantcore serve # HTTP API on :5001 -> http://localhost:5001/docs
# and the test bench -> http://localhost:5001/ui/
Against a running service -- from any machine, without the data directory -- use the API client
instead (quantcore-api, or quantcore.client.QuantcoreClient from Python):
export QUANTCORE_API_URL=http://127.0.0.1:5001 # or the address of the machine that runs it
quantcore-api health
quantcore-api selftest # call every endpoint, pass/fail table, exit code
quantcore-api endpoints # the API surface, from its own OpenAPI schema
quantcore-api risk NESN.SW AAPL --horizon week --drift zero --space value
quantcore-api optimize --cash 1000000 --cvar-limit 40000 --horizon week --risk-drift zero
quantcore setup fetches the candidate lists from the web (Wikipedia S&P 500, iShares STOXX 600
holdings); --offline uses the snapshots shipped in quantcore/data/static/. All data lands in
QUANTCORE_DATA_DIR (default ./data): universe/universe.csv, prices/adj_close.parquet,
fx/fx_chf.parquet, manifest.json, snapshots/.
Using the services from Python
from quantcore import get_settings
from quantcore.data.market_data import MarketDataService
from quantcore.measurement.risk import RiskService
from quantcore.measurement.performance import PerformanceService
from quantcore.optimization.service import OptimizationService, OptimizationRequest
settings = get_settings()
md = MarketDataService(settings) # or MarketDataService(settings, snapshot="demo")
risk = RiskService(md)
risk.asset_risk(["NESN.SW", "AAPL"], horizon="week", window=200, lam=0.94) # per-asset table
risk.correlation(["NESN.SW", "AAPL", "ASML.AS"], method="ewm", lam=0.99)
risk.portfolio_risk({"NESN.SW": 500, "AAPL": 100}, cash=20_000, horizon="month", method="historical")
PerformanceService(md).asset_performance(["NESN.SW", "AAPL"], freq="weekly")
opt = OptimizationService(settings, md)
res = opt.optimize(OptimizationRequest(
mode="new", cash=1_000_000, max_assets=100, horizon="week", confidence=0.95, cvar_limit=40_000,
scenario_model="empirical", covariance={"method": "ewm", "lam": 0.99},
views=[{"symbol": "NESN.SW", "sentiment": 0.7, "confidence": 0.6, "source": "insight-1234"}],
))
res.positions_frame() # shares, value, weight, trade per stock
res.cvar, res.expected_pnl # CHF over the horizon
res.scenario_hash, res.reproducibility # same inputs -> same scenarios; 'exact' (HiGHS) | 'within_gap' (cuOpt)
res.scenario_key["id"] # varying the risk limit, constraints, costs or views keeps this id: one
# scenario set for a whole family of runs (and it is cached in the service)
# scenario options: 4096 moment-matched Sobol' scenarios by default; importance sampling for the tail
# switches on automatically at beta = 0.99; large sets are solved by tail-row generation
res99 = opt.optimize(OptimizationRequest(cash=1_000_000, confidence=0.99, cvar_limit=60_000, n_scenarios=16_384))
# the exact Gaussian (mean-variance-equivalent) solution: no scenarios, no sampling error
res_g = opt.optimize(OptimizationRequest(cash=1_000_000, cvar_limit=40_000, scenario_model="gaussian_exact"))
# n_scenarios defaults to 16 per candidate (4096-16384); the result warns if that is low for the
# candidate count or if the covariance estimate is ill-conditioned for it
# rebalancing: existing holdings, transaction costs, turnover limit
res2 = opt.optimize(OptimizationRequest(
mode="rebalance", cash=res.cash_end, holdings={p["symbol"]: p["shares"] for p in res.positions},
cvar_limit=30_000, costs={"proportional_buy": 0.001, "proportional_sell": 0.001, "fixed_per_trade": 10},
constraints={"max_weight": 0.10, "max_sector_weight": 0.25, "max_turnover": 0.3},
))
HTTP API (the platform's contract)
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
store status, cuOpt health |
| GET | /universe?region=§or=&country= · /universe/{symbol} |
universe with metadata |
| GET | /prices?symbols=A,B&freq=weekly¤cy=base · /returns?... |
prices / log-returns |
| GET | /market/index?freq=weekly · /market/weights |
cap-weighted market index, weights |
| POST | /risk/assets (space: log|value, drift: estimated|zero) · /risk/series · /risk/covariance · /risk/correlation · /risk/portfolio |
risk measurement |
| POST | /performance/assets · /performance/rolling · /performance/portfolio |
performance measurement |
| GET | /data/symbols?region=§or=&country=&search= |
one table of the universe (metadata, market cap, price) plus the filter facets |
| POST | /model/expected-returns · /model/covariance · /model/scenarios |
Black–Litterman posterior, the estimator the models use, scenario set (+ the P&L distribution of a portfolio) |
| POST | /optimize |
mean–CVaR MILP (new / rebalance; a rebalancing also reports the starting portfolio under before) |
| POST | /jobs/setup · /jobs/update?full= · GET /jobs/{id} |
background setup / data update |
| POST/GET | /snapshots |
freeze / list data snapshots |
Example:
curl -s http://127.0.0.1:5001/optimize -H 'Content-Type: application/json' -d '{
"mode": "new", "cash": 1000000, "max_assets": 100, "horizon": "week",
"confidence": 0.95, "cvar_limit": 40000, "scenario_model": "empirical",
"views": [{"symbol": "NESN.SW", "sentiment": 0.7, "confidence": 0.6}]
}' | python -m json.tool
Test bench (browser UI)
http://127.0.0.1:5001/ui/ serves a single-page test bench — the
one place to drive every service by hand and judge what it produces. It is a pure client of the HTTP
API above: no numbers are computed in the browser.
Because it is a client and not a part of the API, it can be left out of a deployment:
QUANTCORE_ENABLE_UI=false (read once at startup) serves the JSON API and /docs only, /ui/
answers 404 and / no longer redirects. The same image therefore also suits an installation whose
users build their own front end against these endpoints.
The left panel holds the state every tab shares — the candidate stocks, the portfolio (new or rebalancing, cash, holdings) and the models (frequency, horizon, stock-price model, scenario count, seed, sampling, tail tilt, covariance estimator, expected-return model). Five tabs use it:
| Tab | What it answers |
|---|---|
| Stocks & data | Which stocks exist (filter by region, sector, country, name) and what their history looks like: indexed prices, drawdowns, risk/return, market caps, and a per-stock table of volatility, VaR/CVaR (normal and historical), Sharpe, drawdown, skew and kurtosis. |
| Risk & scenarios | Per-stock scenario moments and quantiles in price returns (bounded below by −100 %) or, with one click, in the log-returns the model works in — a quarterly log-return of −2.0 is a price return of −86 %, and mixing the two is the easiest way to misread a simulation. The covariance the optimiser actually sees: correlation heat map, volatilities, eigenvalue spectrum, shrinkage and conditioning warnings. Then the scenario set: the horizon P&L distribution of a chosen portfolio (cap-weighted, equally weighted, your holdings or the last proposal) with VaR/CVaR markers, per-stock moments and quantiles, a single-stock distribution, the scenario hash, and a sampling-noise check across three seeds. |
| Expected returns | The market-implied (equilibrium) returns from reverse optimisation, the views you add (sentiment or explicit, with Idzorek confidence), and the posterior — side by side, with the change per stock and how each view was translated into q and ω. |
| Constraints | Weight, sector, cardinality, turnover, cost and investment-band limits, transaction costs, plus a feasibility check against the candidate set (are there enough names, do the sector caps leave room, is any stock unaffordable at one share?) and the sector composition against the caps. |
| Optimisation | Objective, confidence, CVaR limit, the risk convention (risk_drift), solver and time limit; the proposal in integer shares with trades and costs; for a rebalancing the before and after of the same portfolio on the same scenarios; the weights and sector exposure against the limits; the provenance (scenario key and hash, solver, MIP gap, row-generation passes, reproducibility); a run history and a risk-limit sweep that traces the risk/return trade-off. |
A third: the CVaR limit can stop constraining anything over a long horizon. CVaR₉₉ ≈ −μₕ + 2.67 σₕ,
so with a one-year horizon and an expected return of 40 % against a portfolio volatility of ~13 %, the whole
1 % tail is a gain, the limit is slack and mean-CVaR degenerates into "buy the highest forecast returns up to
the weight caps". The result then warns, and risk_drift="zero" measures the CVaR on the centred scenarios
(the risk figure is then independent of the return forecast) while the objective keeps it. The historical
expected-return model makes this extreme: on this data it gives up to 821 % p.a. to a single stock.
Two things to know while using it. The time limit is the budget of the whole run, not of each solver call: row generation and the Gaussian outer approximation solve a sequence of MILPs, and each one gets what is left of the budget (the default is 300 s; an empty field means no limit, and HiGHS will then run until it can prove optimality -- hours on a large candidate set). And the service answers one request at a time while a solve runs: the MILP holds the interpreter, so the other tabs wait until it finishes. The progress panel says both, and a run can be cancelled.
Typical checks it is built for: put 1 m or 50 m CHF into a new portfolio and see how the composition changes with wealth (integer shares start to matter); restrict the universe to European stocks and compare; add a view on one stock and watch the posterior and the proposal move; switch the stock-price model from GBM to empirical innovations (or to the exact Gaussian mode, which has no sampling error at all) and see the risk figures change; run the same request twice and confirm that the scenario hash and the shares are identical.
On the DGX Spark
There are two setups. Both run the same code and give the same results.
- Apptainer, no root (the students' Sparks):
deploy/apptainer/qc.shruns cuOpt and the API as two Apptainer instances of the user, bound to127.0.0.1. See docs/SPARK_APPTAINER.md. - Docker Compose, with root (the lecturer's Spark):
deploy/deploy_to_spark.sh --with-dataadds aquantcoreservice next to thecuoptservice of an existing compose file and reaches cuOpt athttp://cuopt:5000. See docs/SPARK_DEPLOYMENT.md.
Tests
pytest # 127 tests: reference values for VaR/CVaR, Idzorek closed form vs. numeric, brute-force MILP optimality,
# exact Gaussian mode vs. brute force, row generation vs. full MILP, sampling/moment matching/tilt,
# weighted quantiles and scenario distributions, API (incl. the before/after of a rebalancing),
# the HTTP client and every `quantcore-api` command against the app in-process
The cuOpt round-trip test runs only when the server is reachable.
Layout
quantcore/
config.py settings (env QUANTCORE_*, .env)
universe/ candidate sources, Yahoo symbol mapping, SetupService
data/ YahooClient (timeouts/retry/breaker), FX, DataStore, DataDownloadService, MarketDataService, static/
measurement/ returns, rolling & EWM estimators, risk (VaR/CVaR), performance
models/ CovarianceModel + matrix roots, GBM & empirical-innovation simulators, Black-Litterman/Idzorek
optimization/ MILP builder (problem.py), cuOpt/HiGHS solvers, OptimizationService
api/ FastAPI app + pydantic schemas
api/ui/ the browser test bench (static HTML/CSS/ES modules, no build step, no CDN);
QUANTCORE_ENABLE_UI=false serves the JSON API and /docs only
client.py QuantcoreClient -- the API as a Python object (needs only a URL)
cli.py `quantcore` command (in-process services, needs the data directory)
apicli.py `quantcore-api` command (HTTP client of a running service, incl. `selftest`)
install.sh one-step installer/updater for a DGX Spark (wget ... | bash): code, images, data, start
deploy/apptainer/ qc.sh (install/start/stop/cli on a Spark without root), quantcore.def, pinned constraints.txt
deploy/ Dockerfile, compose service blocks (Spark + a bench-free one), deploy_to_spark.sh
docs/ USAGE.md, ARCHITECTURE.md, METHODOLOGY.md, SPARK_APPTAINER.md, SPARK_DEPLOYMENT.md
tests/ pytest suite (synthetic data store fixture)