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>
345 lines
19 KiB
Markdown
345 lines
19 KiB
Markdown
# 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 |
|