herzogflorian fe62154fc6 Spark: the README's quantcore commands work as written
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>
2026-10-07 14:52:57 +02:00

345 lines
19 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.

# Using quantcore
`quantcore` is the **deterministic quant core** your advisory platform builds on: market data, risk
and performance measurement, stock-price models, Black–Litterman expected returns and mean–CVaR
portfolio optimisation in whole shares.
It contains **no LLM call and makes no decisions**. It computes numbers from data and from the inputs
you hand it, and every answer carries the provenance of those inputs. Your platform supplies the
judgement — which news matters, how strongly to believe it, what to tell the client — and calls in
here for every figure it quotes.
You never have to modify this package. Treat it as a service with a contract.
---
## 1. Two ways to use it
| | **HTTP API** | **Python library** |
|---|---|---|
| you need | a URL | the package *and* the data directory on the same machine |
| import | nothing — `requests`, or `quantcore.client` | `from quantcore.measurement.risk import RiskService` |
| language | any | Python |
| good for | the normal case: your platform calls a running service | notebooks, experiments, running your own backtest loop |
Start with the API. Everything in this guide is available both ways, and the library classes are
documented in `README.md` and in `docs/ARCHITECTURE.md` if you need them.
## 2. Install
**On a DGX Spark, skip this section.** The installer of [SPARK_APPTAINER.md](SPARK_APPTAINER.md) runs
the service in containers and provides both commands below, `quantcore` and `quantcore-api`; they
run inside the container. Install the package yourself only where your own code imports it, for
example for `quantcore.client`, and then in a virtual environment:
```bash
git clone https://gitea.fhgr.ch/herzogfloria/Portfolio_Risk_Management.git
cd Portfolio_Risk_Management
python3 -m venv .venv && source .venv/bin/activate # Python 3.11+
pip install -e .
```
This gives you two commands:
| command | what it is |
|---|---|
| `quantcore` | runs the services **in this process**; needs the data directory locally |
| `quantcore-api` | talks to a **running** service over HTTP; needs only its URL |
## 3. Point at a service, or run your own
**On your DGX Spark** (the usual case): the service and cuOpt run as Apptainer containers of your
user, reachable at `127.0.0.1` on the Spark only. [SPARK_APPTAINER.md](SPARK_APPTAINER.md) has the details:
```bash
deploy/apptainer/qc.sh install && deploy/apptainer/qc.sh data-import quantcore_data.tar.gz
deploy/apptainer/qc.sh start # API at http://127.0.0.1:5001 (the client's default URL)
deploy/apptainer/qc.sh api health
```
**Someone else runs it:** point the client at that machine.
```bash
export QUANTCORE_API_URL=http://<host>:5001
quantcore-api health
```
**You run it yourself.** The data has to be built once, which downloads about 1 100 tickers from
Yahoo Finance and takes ~15 minutes:
```bash
quantcore setup # once
quantcore serve # HTTP API on :5001, interactive docs at /docs
```
Or as a container, which is how it runs in production:
```bash
docker build -f deploy/Dockerfile -t quantcore:0.1 .
docker run -p 5001:5001 -v $PWD/data:/data \
-e QUANTCORE_CUOPT_URL=http://<your-cuopt-host>:5000 quantcore:0.1
```
Configuration is environment variables with a `QUANTCORE_` prefix (see `.env.example`); the ones you
are likely to touch:
| variable | default | meaning |
|---|---|---|
| `QUANTCORE_API_URL` | `http://127.0.0.1:5001` | which service `quantcore-api` and `QuantcoreClient` talk to |
| `QUANTCORE_DATA_DIR` | `./data` | where the prices and the universe live |
| `QUANTCORE_CUOPT_URL` | `http://127.0.0.1:5000` | the GPU MILP solver (a cuOpt container on the same machine); unreachable is fine, see §9 |
| `QUANTCORE_BASE_CURRENCY` | `CHF` | reporting currency of every figure |
| `QUANTCORE_ENABLE_UI` | `true` | `false` serves the JSON API and `/docs` only |
## 4. Check that it works
```bash
quantcore-api selftest
```
This calls **every endpoint** with a small request and prints what each one answered:
```
endpoint result ms evidence
0 GET /health ok 260 745 stocks, last 2026-09-21, cuOpt up
5 GET /prices ok 28 5667 dates x 3 symbols
9 POST /risk/assets ok 10 3 rows, cvar95_hist 0.1259
21 POST /optimize (gaussian_exact) ok 63 Optimal on highs, 7 positions, CVaR 44,551
...
24 ok, 0 failed, 3 skipped in 1.0 s against http://127.0.0.1:8765
```
Exit code 0 means the deployment is sound. Run this first whenever something behaves oddly — it
separates "the service is broken" from "my request is wrong" in one second. The three skipped probes
write to the store or download data; `--write` includes them. `--full` optimises on a real scenario
set instead of the fast Gaussian mode.
`quantcore-api endpoints` lists the API surface, and `<url>/docs` is the interactive reference with
every field of every request. **That is the authoritative contract** — this guide is the tour.
## 5. Conventions you have to know
These are not negotiable per request; they are how the whole package thinks.
* **Returns are natural logarithms.** `ln(P_t / P_{t-1})`. A CHF return of a foreign stock is the
local log-return plus the FX log-return — they add.
* **Money is CHF**, always, whatever the listing currency.
* **Positions are integer share numbers**, never percentages. That is the point of the MILP: you
cannot buy 0.3 of a share, and at CHF 1 m the difference matters.
* **A horizon is `"day"`, `"week"`, `"month"`, `"quarter"`, `"year"`, or an integer number of
periods** of the chosen frequency. `"week"` at daily frequency is 5 trading days.
* **Risk is CVaR** (expected shortfall) at 95 % and 99 %, expressed as an absolute amount over the
horizon. VaR comes along for reference.
* **Two knobs on every parametric risk figure**, and the defaults are not always what you want:
* `drift`: `"estimated"` subtracts the window's mean return; `"zero"` uses volatility only. Use
`"zero"` for anything longer than a week — over a quarter the estimated mean is the noisiest
input in the calculation and it can halve the reported risk.
* `space`: `"log"` reports the figure as a log-return; `"value"` as a fraction of the position's
value (`1 - e^{-x}`), which cannot exceed 100 %. Use `"value"` in anything a client reads.
## 6. The endpoints
| method | path | what it answers |
|---|---|---|
| GET | `/health` | is the service up, what data does it hold, is the GPU solver reachable |
| GET | `/universe`, `/universe/{symbol}` | the investable stocks and their metadata |
| GET | `/data/symbols` | the universe as one table plus filter facets — feeds a stock picker |
| GET | `/prices`, `/returns` | adjusted closes and log-returns, daily/weekly/monthly, CHF or local |
| GET | `/market/index`, `/market/weights` | the cap-weighted broad market and its weights |
| POST | `/risk/assets` | per-stock volatility, VaR, CVaR (normal and historical, rolling and EWM) |
| POST | `/risk/series` | those figures through time, for a chart |
| POST | `/risk/covariance`, `/risk/correlation` | the matrices as measured |
| POST | `/risk/portfolio` | VaR/CVaR of a portfolio held in shares |
| POST | `/performance/assets`, `/performance/rolling`, `/performance/portfolio` | return, Sharpe, Sortino, drawdown, Calmar, skew, kurtosis |
| POST | `/model/expected-returns` | Black–Litterman: equilibrium π, your views, the posterior |
| POST | `/model/covariance` | the covariance the models use, with its conditioning |
| POST | `/model/scenarios` | a scenario set, its moments, a portfolio's P&L distribution |
| POST | `/optimize` | the mean–CVaR MILP: a new portfolio or a rebalancing |
| POST | `/jobs/setup`, `/jobs/update`, GET `/jobs`, `/jobs/{id}` | background data work |
| GET/POST | `/snapshots` | freeze the data so a demo stays reproducible |
## 7. From Python
```python
from quantcore.client import QuantcoreClient
qc = QuantcoreClient("http://127.0.0.1:5001") # or omit: $QUANTCORE_API_URL, else this default
qc.health()["store"]["last_price_date"] # '2026-09-21'
# what can I invest in?
swiss = qc.universe(country="Switzerland")
health = [s["symbol"] for s in qc.universe(region="Europe", sector="Health Care")]
# what is one stock's risk over a week, as a client would read it?
row = qc.asset_risk(["NESN.SW"], horizon="week", drift="zero", space="value")[0]
row["cvar95_normal_ewm"] # 0.0665 -> 6.7 % of the position in the worst 5 %
# what is my client's current portfolio risking?
qc.portfolio_risk({"NESN.SW": 500, "AAPL": 100}, cash=20_000, horizon="month")
# the research agent found something positive on Apple, with middling confidence
views = [{"symbol": "AAPL", "sentiment": 0.6, "confidence": 0.4}]
post = qc.expected_returns(max_assets=100, views=views)
# propose a portfolio for CHF 1 m that risks at most CHF 30 000 in the worst 5 % of weeks
res = qc.optimize(cash=1_000_000, horizon="week", confidence=0.95, cvar_limit=30_000,
views=views, risk_drift="zero", time_limit=300)
res["status"] # 'Optimal'
res["cvar"] # 30000.00 -- the limit binds exactly
for p in res["positions"]:
print(p["symbol"], p["shares"], round(p["weight"], 4))
```
Every method passes its keyword arguments through to the API unchanged, so the field names in
`/docs` are the field names here. A non-2xx answer raises `QuantcoreError` with the API's own
message; an unreachable server raises it too, with `unreachable = True`.
## 8. From the command line
The same surface, for looking at things and for scripting:
```bash
quantcore-api universe --region Europe --sector "Health Care"
quantcore-api prices NESN.SW AAPL --freq weekly --tail 5
quantcore-api risk NESN.SW AAPL --horizon quarter --drift zero --space value
quantcore-api correlation NESN.SW NOVN.SW ROG.SW
quantcore-api risk-portfolio --holdings '{"NESN.SW": 500, "AAPL": 100}' --cash 20000
quantcore-api expected-returns --max-assets 50 --view AAPL:0.6:0.4
quantcore-api scenarios NESN.SW AAPL --model gbm --n-scenarios 4096
quantcore-api optimize --cash 1000000 --cvar-limit 30000 --horizon week --risk-drift zero
quantcore-api optimize --request my_request.json --json > result.json
quantcore-api update --wait # refresh prices and follow the job
```
`--view SYMBOL:SENTIMENT[:CONFIDENCE[:STRENGTH]]` is the shorthand for a sentiment view, repeatable.
`--json` prints the raw answer (pipe it into `jq`, or save it as a fixture); it works before or after
the subcommand. `--request FILE` takes a full JSON request, which is the right way to keep a
complicated setup reproducible — its fields win over the flags.
## 9. The five things the project actually needs
**Pick a universe.** `/data/symbols` gives you rows *and* the counts per region, sector and country
in one call, so a picker needs no second request. Pass the resulting symbols as `symbols` to
`/optimize` to restrict the candidates; leave it out and the largest `max_assets` by market cap are
used.
**Measure what the client holds.** `/risk/portfolio` with their share numbers and cash. Use
`method="historical"` alongside the default `"normal"` and show both — where they disagree, the
distribution is not Gaussian, and that is worth saying out loud.
**Turn an assessment into a view.** A view is a statement about an *annual expected return* with a
degree of belief. Either explicit (`{"assets": {"NESN.SW": 1.0}, "q": 0.09}`: "Nestlé will return
9 %") or from sentiment (`{"symbol": "NESN.SW", "sentiment": 0.6, "confidence": 0.4}`). Sentiment in
[−1, 1] scales `strength` (default 25 % p.a. at full sentiment); `confidence` in (0, 1] is the
Idzorek confidence that sets Ω. Call `/model/expected-returns` *before* optimising to see what your
view did to the posterior — that is the number you will have to defend.
**Propose a new portfolio.** `/optimize` with `mode="new"` and `cash`. Set `cvar_limit` (absolute
CHF) or `cvar_limit_fraction` (of wealth). Constraints live in `constraints`, costs in `costs`:
```json
{"mode": "new", "cash": 1000000, "horizon": "week", "confidence": 0.95, "cvar_limit": 30000,
"risk_drift": "zero",
"constraints": {"max_weight": 0.10, "max_sector_weight": 0.25, "max_positions": 25,
"min_position_value": 5000, "min_invested": 0.95},
"costs": {"proportional_buy": 0.001, "proportional_sell": 0.001, "fixed_per_trade": 0}}
```
**Rebalance an existing one.** `mode="rebalance"` with `holdings` (shares) and the `cash` available.
Transaction costs are charged and the result carries a `before` block, so you can show the client
both states and what the trade bought them:
```python
res = qc.optimize(mode="rebalance", holdings={"NESN.SW": 500, "AAPL": 100}, cash=50_000,
horizon="week", cvar_limit=30_000, risk_drift="zero",
constraints={"max_turnover": 200_000})
res["before"]["cvar"], res["cvar"] # risk before and after
res["total_cost"], res["turnover"] # what the change costs
[p for p in res["positions"] if p["trade"] != 0] # the trades to place
```
## 10. Reading a result
Every optimisation answers with its own provenance. The fields you will quote:
| field | meaning |
|---|---|
| `status` | `Optimal` \| `FeasibleFound` (time limit — check the gap) \| `Infeasible` \| `Error` |
| `solver` | `cuopt` (GPU) or `highs` (CPU) — which one actually produced this |
| `reproducibility` | `exact` (HiGHS) \| `within_gap` (cuOpt, 1e-4) \| `none` |
| `expected_pnl`, `expected_return` | over the horizon, in CHF and as a fraction |
| `var`, `cvar`, `cvar_limit`, `confidence` | the risk figures and the limit they had to respect |
| `positions` | per stock: `shares`, `shares_before`, `trade`, `value`, `weight`, `cost`, `expected_return_ann` |
| `sector_exposure`, `invested`, `cash_end`, `total_cost`, `turnover` | the resulting portfolio |
| `model` | covariance estimator, scenario model and seed, expected-return model, the views and the Ω that was calibrated from them |
| `scenario_key`, `scenario_hash` | what the scenarios depend on, and a fingerprint of the draws |
| `warnings` | **read these** — see below |
| `timings` | seconds per stage |
So a sentence in your advisory output can be backed end to end: *"CVaR 95 % = CHF 29 999 over one
week (mean–CVaR MILP, EWM λ = 0.99, GBM scenarios, seed 42, solved to optimality on HiGHS)."*
**`warnings` is not decoration.** It is where the service tells you that the answer is technically
correct and economically doubtful: a risk limit that could not bind, a covariance estimate too
singular for the number of candidates, too few scenarios per candidate, an expected return above
60 % p.a. on a stock it decided to hold. Surface them or handle them; do not drop them.
**Determinism.** The same request gives the same scenarios (`scenario_hash`) and the same portfolio,
on the same machine. Across machines — a different numpy or pandas — the `scenario_key` still
matches while the digest may not, because floating-point results agree to 13–14 digits rather than
bit for bit; the figures agree to well under a franc. Compare runs by `scenario_key`. For a demo
that must be identical next week, freeze the data with a snapshot (`POST /snapshots`).
## 11. Things that will bite you
* **The service answers one request at a time while a solve runs.** The MILP holds the interpreter.
Do not build a UI that fires an optimisation and then polls something else — it will look hung.
* **`time_limit` is the budget of the whole request**, not of each solver call (row generation and
the Gaussian mode solve a *sequence* of MILPs). Without it, a large candidate set on HiGHS can run
for hours. Set it. 300 s is a sane default.
* **The GPU solver is optional.** `solver="auto"` uses cuOpt if it answers and HiGHS otherwise; the
result says which, and adds a warning. A cuOpt outage degrades speed, never correctness.
* **`risk_drift="model"` (the default) measures the CVaR on scenarios that include the expected
return.** At long horizons the drift grows with *h* while the spread grows with *√h*, so a bullish
forecast can push the whole loss tail above zero and the risk limit stops binding — the optimiser
will then happily propose something absurd. Use `risk_drift="zero"` for anything beyond a few
weeks; the objective still maximises expected return, but the risk measurement no longer believes
the forecast.
* **Scenario count drives stability.** The default is 16 per candidate (within 4 096…16 384). The
reported figures are stable from ~2 000 on, but the *composition* moves between near-equivalent
portfolios; about 64 per candidate pins it. The result warns below 8.
* **`scenario_model="gaussian_exact"` is fast but bounds the CVaR from below.** It replaces the
scenarios with linear cuts, and until those converge the limit can be overshot by a hair (a franc
or two on a million, reported in `warnings`). Use it to explore; use `"gbm"` or `"empirical"` when
the limit has to hold exactly.
* **`Infeasible` means your constraints contradict each other**, most often `min_invested` against a
tight `cvar_limit`, or a `max_turnover` that cannot reach the risk budget. Relax one and re-run.
## 12. Troubleshooting
| symptom | what it means |
|---|---|
| `cannot reach the quant-core API at ...` | no service there. `quantcore serve`, or fix `--url` / `QUANTCORE_API_URL` |
| `503 no market data yet` | the store is empty: `POST /jobs/setup` (~15 min) or mount a prepared data directory |
| `422` with a field name | your request does not match the schema; the message names the field. `/docs` has the valid values |
| `/health` shows `cuopt.reachable: false` | the GPU solver is down; optimisations fall back to HiGHS and say so |
| `FeasibleFound` instead of `Optimal` | the time limit hit; raise `time_limit`, or lower `n_scenarios` / `max_assets` |
| an optimisation "seems stuck" | it is solving, and the service is single-threaded. `timings` afterwards shows where the time went |
| numbers differ slightly from a colleague's | different environment, same inputs: compare `scenario_key`, not `scenario_hash` (§10) |
| Yahoo errors during a data job | rate limiting; the client retries and opens a circuit. Re-run later, the stored data is intact |
Everything failing at once? `quantcore-api selftest` tells you whether it is the service or you.
## 13. Where the details are
| document | what is in it |
|---|---|
| `<url>/docs` | every endpoint and field, interactive — the contract |
| `README.md` | the services, the library API, quick start |
| `docs/ARCHITECTURE.md` | how the pieces fit, what depends on what, where the AI layer plugs in |
| `docs/METHODOLOGY.md` | every formula, estimator and convention, with the measured numbers |
| `docs/optimization_model.pdf` | the MILP derived from Rockafellar–Uryasev to whole shares, and the sampling theory |
| `docs/measurement_and_models.pdf` | risk and performance measures, GBM and empirical innovations, Black–Litterman with Idzorek confidence |