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

438 lines
33 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.

# Methodology
Every formula the services implement, with the conventions and default parameters. Section
numbers follow the project brief ("Portfolio Risk Measurement and Optimization").
## 1. Investment universe (`quantcore.universe`)
* **Candidates.** US: the S&P 500 constituents (Wikipedia). Europe (EU, CH, NO, UK): the STOXX
Europe 600 constituents, read from the iShares STOXX Europe 600 UCITS ETF holdings file, which
gives issuer ticker, exchange, listing currency, country and GICS sector. Domiciles outside
EU/CH/NO/UK (e.g. Bermuda, Israel) are excluded. Both lists are snapshotted into
`quantcore/data/static/` for offline use.
* **Yahoo symbols.** `<code><suffix>` per exchange (`.SW .L .DE .PA .AS .MI .MC .ST .CO .OL .HE
.BR .VI .WA .IR .LS .AT`), share classes with a dash (`NOVO-B.CO`, `BRK-B`), London codes lose
the trailing dot (`RR.` → `RR.L`). All 1 117 symbols were verified against Yahoo, and the
European prices were cross-checked against the ETF file (after FX) — zero mismatches.
* **Metadata** from `Ticker.info`: long name, domicile, listing currency (GBp → GBP with a price
scale of 100), exchange, Yahoo sector/industry, market cap, shares outstanding, first trade date.
* **Selection.** One listing per company (share classes collapsed on a normalised name, largest cap
kept) → rank by market cap in CHF within each region → keep the largest 500 US and 500 European
→ drop stocks whose first price is after `required_history_from` (2005-01-31) or whose coverage
since then is below 90 % of union-calendar days. The result has fewer than 1 000 names.
* **Market capitalisation** (for index weights) at any date: shares outstanding (today's, held
constant) × adjusted close × FX to CHF.
* **Price cleaning** (`quantcore.data.cleaning`, applied by the setup and download services and
re-runnable with `quantcore clean`): non-positive adjusted closes (broken dividend/rights
adjustments, e.g. ABI.BR before 2008) → NaN; *spike blocks* — at most three days whose entry
move `|r| > 0.4` is reversed within the block (stale pre-split ticks on exchange holidays, e.g.
2× ticks in NOVO-B.CO) → NaN. Genuine jumps are not reversed and therefore untouched. Stocks
whose cleaned series has a gap longer than `max_missing_run` (20 business days) after
2005-01-31 are excluded from the universe. On the 2026-09-21 data: 1 347 non-positive values in
2 symbols, 28 spike ticks in 8 symbols; final universe 745 stocks (382 US, 363 European).
* **FX quoting-unit repair** (`clean_fx`, same module and the same two entry points): Yahoo returns
a cross rate in the wrong unit for whole stretches of days. NOK/CHF came back **100× too small on
96 observations** — 2016-08-15 to 2017-01-09 in three runs, plus 2022-11-04 — which put the
measured volatility of the *currency* at 337 % a year instead of 12 %, and with it every Norwegian
stock (a CHF return is the local return plus the FX return, so EQNR.OL measured 338 % against its
true 31 %, and its Black–Litterman implied return came out above 100 % a year). A day-on-day
log-change within 0.15 of `k·ln(10)` is treated as a unit change, accumulated, and the series is
put back on the scale that most of its observations share (ties go to the scale it ends on).
The tolerance is tight on purpose: a unit change is *exactly* a power of ten apart from the day's
own move, while a loose tolerance swallows real events — at 0.5 it flagged ABVX.PA's +510 %
trial-result day (ratio 6.1) as a decimal shift. A scan of all 1 117 price series with the tight
rule finds one further case, ROSE.L (11 observations quoted in pence rather than GBP), which is
not in the investable universe.
* **FX spike ticks** (same function, thresholds an order of magnitude tighter than for prices:
entry move `|r| > 0.05` reversed within 0.02 over at most three days). Five ticks in twenty-one
years: EUR, GBP and USD on 2009-02-06 (one bad CHF quote hits every cross that day), SEK on
2010-09-13, NOK on 2022-10-10. Removing them takes the lag-1 autocorrelation of daily FX
log-returns to about zero — EUR −0.053 → −0.005, GBP −0.040 → −0.001, USD −0.034 → −0.002 — which
is what a clean daily close series looks like. The one violent day in the sample, 2015-01-16
(the SNB abandons the euro floor, every CHF cross moves ~18 %), does not reverse and is kept.
**Why the rates are taken directly against CHF and not crossed through USD.** USD pairs are the most
liquid instruments, so triangulating `CUR/CHF = (USD/CHF) / (USD/CUR)` looks attractive; measured on
this feed it is worse, and the package therefore uses the direct pair wherever it has history (the
USD cross remains the fallback, and is what PLN uses, since `PLNCHF=X` only starts in 2026):
| | direct `CURCHF=X` | crossed through USD |
|---|---|---|
| EUR volatility p.a. / lag-1 AC | 7.49 % / −0.005 | 9.99 % / **−0.219** |
| NOK volatility p.a. / lag-1 AC | 11.45 % / −0.047 | 17.14 % / **−0.330** |
| GBP volatility p.a. | 10.10 % | 10.35 % |
| reversing bad ticks, 2005–2026 | 5 over all currencies | 8, five of them in `EUR=X` alone |
The cross combines two series snapped at different times, and the non-synchronicity shows up as a
strongly negative lag-1 autocorrelation and inflated variance — a portfolio risk figure built on it
would be overstated. It also has its own bad ticks (`NOK=X` falls 33 % on 2020-03-20 and rises 42 %
back on 2020-03-23; `EUR=X` moves −16 % then +14 % on 2008-12-08/09). Triangulation buys only the
removal of stale quotes (SEK 3.9 % of days unchanged, DKK 1.6 %), which costs far less than it adds.
## 2. Prices, FX and log-returns (`quantcore.data`, `quantcore.measurement.returns`)
* Adjusted closes `P_i(t)` in the listing currency on the union calendar of all exchanges, holidays
forward-filled (≤ 5 days; leading NaNs untouched).
* FX frame `X_c(t)` = CHF per unit of currency `c`, from Yahoo `cCHF=X`, crossed through USD when
the direct pair lacks history (`c/CHF = (USD/CHF)/(USD/c)`, e.g. PLN).
* Log-returns `r_i(t) = ln P_i(t) − ln P_i(t−1)`; CHF returns add the FX log-return:
`r_i^CHF(t) = r_i(t) + ln X_{c(i)}(t) − ln X_{c(i)}(t−1)`.
* Weekly returns: Friday-to-Friday closes; monthly: month-end to month-end.
* Horizon `h` in periods: day = 1, week = 5, month = 21 (daily data); at weekly frequency week = 1,
month = 4. Returns are assumed i.i.d., so `h`-period returns are sums.
* Portfolio log-return with weights `w` fixed at the start of the period (exact aggregation):
`r_p = ln(1 + Σ_i w_i (e^{r_i} − 1))`.
## 3. Estimators (`quantcore.measurement.estimators`)
Two families, both centred (the corresponding mean is subtracted):
* **Moving window** of `w` observations (default 200 days): sample mean, standard deviation
(ddof 1), covariance `np.cov`, correlation.
* **Exponentially weighted** with decay `λ` (defaults 0.94 for measurement, 0.99 for the slow
optimisation covariance): weight of the observation `k` periods back ∝ `λ^k`, normalised to one
(pandas `ewm(alpha=1−λ, adjust=True)`); covariance
`Σ = Σ_t ω_t (r_t − m)(r_t − m)'`, `m = Σ_t ω_t r_t`. Effective window ≈ `1/(1−λ)`.
* Streamed sequences `Σ_{t−1}` for every `t` (needed by the innovation filter): the rolling
estimator recomputes the window, the EWM estimator uses the exact centred recursion
`m_t = m_{t−1} + α d`, `Σ_t = λ Σ_{t−1} + α d (r_t − m_t)'`, `d = r_t − m_{t−1}`, `α = 1 − λ`.
* Linear shrinkage towards the diagonal `(1−s)Σ + s·diag(Σ)` with `s` either given or `"auto"`:
the Schäfer–Strimmer / Ledoit–Wolf intensity
`s* = Σ_{i≠j} Var̂(s_ij) / Σ_{i≠j} s_ij²` (clipped to [0, 1]) computed on the effective estimation
window (`w` observations, or `3/(1−λ)` for EWM). It is ≈ 0 for a long, strongly correlated
sample and → 1 when `N` approaches the number of observations — exactly when the sample
covariance becomes ill-conditioned. `"auto"` is the default of the optimisation covariance
(≈ 0.05 for 100 stocks / λ = 0.99, ≈ 0.11 for the full universe).
* Annualisation: means × periods per year (252 / 52 / 12), volatilities × √(periods per year).
## 4. Risk measures (`quantcore.measurement.risk`)
Losses are positive. For a log-return `r` over the horizon the loss is `−r` in return space and
`V₀(1 − e^{r})` in CHF (exact, monotone mapping — the CHF VaR/CVaR are obtained by applying it to
the return-space figures, or directly from money P&L for scenario sets).
* **Gaussian (GBM)**: with per-period mean `μ` and volatility `σ` from either estimator,
`μ_h = hμ`, `σ_h = √h σ`,
`VaR_β = −μ_h + z_β σ_h`, `CVaR_β = −μ_h + σ_h φ(z_β)/(1−β)`.
Reference: `σ = 1 %`, `μ = 0`: VaR₉₅ = 1.645 %, CVaR₉₅ = 2.063 %, VaR₉₉ = 2.326 %, CVaR₉₉ = 2.665 %.
* **Historical**: `VaR_β = −q_{1−β}(r_h)`, `CVaR_β = −E[r_h | r_h ≤ q_{1−β}]` on the last
`max(5w, 750)` observations aggregated to overlapping `h`-period sums.
* **Time series** (`risk_series`): rolling / EWM volatilities and the Gaussian VaR/CVaR built from
them, plus rolling historical quantiles and tail means.
* **Portfolio** (`portfolio_risk`): holdings in shares (+ cash) → value weights `w` at today's CHF
prices; Gaussian with `σ_p² = w'Σw`, historical on the realised portfolio return, or on a scenario
set (`method="scenarios"`).
* **Scenario CVaR** (`scenario_cvar`): with `S` equiprobable losses sorted descending and
`k = (1−β)S`, `CVaR_β = (Σ_{s≤⌊k⌋} L_(s) + (k−⌊k⌋) L_(⌊k⌋+1)) / k` — exactly what the
Rockafellar–Uryasev LP reproduces; `VaR_β = L_(⌈k⌉)`.
## 5. Performance (`quantcore.measurement.performance`)
Cumulative return `e^{Σr} − 1`; annualised log-return `ppy · mean(r)` and its geometric version
`e^{ppy·mean} − 1`; annualised volatility; Sharpe `(ann. return − r_f)/ann. vol` with the CHF
risk-free rate (`QUANTCORE_RISK_FREE_RATE`, default 0); Sortino (downside deviation); drawdown of
the cumulative value path, max drawdown, Calmar; skewness, excess kurtosis, hit ratio, best/worst
period. Rolling (window) and EWM (λ) versions as time series. Portfolios in shares: constant-mix
(today's value weights) or buy-and-hold.
## 6. Stock-price models (`quantcore.models.simulation`)
Both assume i.i.d. one-period log-returns; `Σ_T` is the latest matrix of the chosen
`CovarianceModel` (e.g. 200-day equally weighted, or EWM λ = 0.99).
* **Geometric Brownian motion**: `r_h ~ N(hm, hΣ_T)`, sampled with `A z`, `A A' = Σ_T`
(eigen-root with eigenvalues descending by default, so the first draw coordinate drives the market
factor; symmetric and Cholesky roots optional).
* **Empirical innovations** (filtered historical simulation):
`ε_t = Σ_{t−1}^{−1/2} r_t` with `Σ_{t−1}` estimated from data up to `t−1` (streamed sequence,
symmetric root `V Λ^{−1/2} V'` — Cholesky optional for large `N`); forward simulation
`r(j | T) = hm + Σ_T^{1/2} Σ_{k=1..h} ε_{j_k}` with the dates `j_k` drawn with replacement from
the history (scrambled Sobol' points on the market-factor ranks by default, `"mc"` uniform or
`"balanced"` optional). Innovations are centred so the scenario mean equals the supplied drift.
**Re-standardisation**: a lagged volatility filter leaves the innovations with variance above
one (the forecast is too low right after every shock — 1.1 for 10 stocks, 1.5 for 100 with
λ = 0.99), so the centred sample is rescaled before resampling: `standardize="diagonal"`
(default) divides each asset's innovations by their standard deviation, `"full"` whitens with
the innovations' sample covariance (needs `T ≥ 3N`). The scenario covariance then reproduces
`hΣ_T` (per-asset volatility within ±2 %, exactly with full whitening) while skewness, fat
tails and non-linear co-movements of the data are preserved. Innovations beyond ±8 standard
deviations are winsorised (a numerical guard affecting ≈ 0.2 % of the values); ill-conditioned
`Σ_{t−1}` for large `N` is handled by the automatic shrinkage of Section 3.
* **Sampling and moment matching** (`quantcore.models.sampling`): the optimiser is a sample-average
approximation and the sample mean of a plain Monte-Carlo set is off by `σ/√S` per asset — 3–13 %
p.a. for `S = 2000` — which the optimiser maximises (measured: two seeds give portfolios differing
by 100 % of wealth). Therefore: scrambled Sobol' points (`S` rounded up to a power of two),
**moment matching** `z̃ = C^{-1/2}(z − z̄)` so that the (probability-weighted) scenario mean and
covariance are *exactly* `hm` and `hΣ_T`, and the model expected returns in the optimiser's
objective. Only the shape of the tail remains sampled.
* **Drift convention**: the expected-return model delivers *arithmetic* expected returns `μ`
(per period, annual / 252). The log-drift is the Itô-corrected `m_i = ln(1 + hμ_i)/h − σ_i²/2`,
so that `E[e^{r_h} − 1] = hμ` exactly (`drift_convention="arithmetic"`); `"log"` uses `μ` as the
log-drift and adds `σ²/2` to the expected simple return (5 % p.a. for a typical stock, 34 % for
the most volatile one — an unintended tilt to volatile stocks).
* **Importance sampling** (`importance_sampling="auto"` = `"market"`): the loss direction `d` of
the *cap-weighted candidate portfolio*; two blocks of 25 % of the scenarios are shifted along it
by `z_0.95` and `z_0.99` (GBM: `z ↦ z + c_k d`; empirical: dates drawn with exponentially tilted
probabilities `q_t ∝ exp(ϑ_k d'ε_t)`), so one set serves both confidence levels. Every scenario
carries the self-normalised likelihood ratio of the defensive mixture (bounded by
`1/(1 − Σ mixture)`) as its probability `p_s`. Tail noise falls 2–4×; 2 048 tilted scenarios ≈
8 192 plain ones at β = 0.99. The shift is driven by the *covariance*: in return space it is
`−c_k Σv/√(v'Σv)`, i.e. each asset moves by its covariance with the reference portfolio (beta),
not by its own volatility — on the top 30 the correlation of the shift with beta is −1.00 versus
−0.93 with own volatility, and a negatively correlated stock is shifted *up*. The weighted
tilted set still reproduces `hΣ` and all correlations exactly (the unweighted one does not). The
gain a given portfolio gets scales with the correlation between its loss and the reference's
(`tilt_alignment` in the result: 5.3× at 1.00, 2.3× at 0.90 — the usual value for the optimiser's
solutions — and nothing below ~0.3, where a warning is raised). `"adaptive"` tilts towards a first-pass solution (sharper at 99 %,
but the set then depends on the risk parameters); `"off"` disables the tilt.
* **One scenario set per family of runs**: the scenario *key* (`OptimizationService.scenario_key`,
reported with every result) contains only model inputs — candidates, as-of date, frequency and
horizon, covariance model, scenario model, `n_scenarios`, sampling, moment matching, seed, tilt
mode, innovation settings. The CVaR limit, confidence, objective, constraints, costs, cash,
holdings and the expected-return model are *not* in it: varying them reuses the same scenarios
(cached in the service), so differences between runs are the effect of the parameter and not
sampling noise. The drift is applied after the random part (`ScenarioSet.with_drift`), so a new
view shifts the scenarios without touching the shocks (`shock_hash` is unchanged).
* `ScenarioSet` reports `expected_simple` (the expectation the optimiser uses), `probabilities`,
the effective sample size and a SHA-256 fingerprint `hash` of the matrix.
## 7. Expected returns — Black–Litterman with Idzorek confidence (`quantcore.models.black_litterman`)
Weekly CHF log-returns of the candidate stocks over the last 10 years (`bl_years`):
* `Σ` = 10-year sample covariance of weekly returns; `w` = current market-cap weights of the
candidates; `μ_M` = average weekly log-return of the **cap-weighted broad market index** of the
whole universe (weights drift with prices between rebalancings, today's shares held constant);
`r_f` = CHF risk-free rate per week.
* Risk aversion `δ = (μ_M − r_f)/(w'Σw)`; equilibrium excess returns `π = δ Σ w` (so the market
portfolio earns exactly `μ_M − r_f`).
* Views `P μ = Q + ε`, `ε ~ N(0, Ω)`, Ω diagonal. A view is `{assets: {ticker: weight}, q, confidence}`;
`q` is the annual total return of the view portfolio (converted to weekly excess internally;
the risk-free part cancels for relative views).
* **Idzorek confidence** `C_k ∈ (0, 1]`: the view's uncertainty is the `ω_k` for which the
unconstrained posterior weights move the fraction `C_k` of the way from `w` to the weights implied
by the view at 100 % confidence. For a diagonal Ω that calibration has the closed form
`ω_k = (1 − C_k)/C_k · p_k (τΣ) p_k'`; the tilt-matching minimisation of the paper is implemented
as `idzorek_omega_numeric` and the tests show both agree to 1e-4 (Idzorek 2005; the closed form is
due to Walters 2014).
* Posterior `μ_BL = π + τΣP'(PτΣP' + Ω)^{-1}(Q − Pπ)`,
`Σ_BL = Σ + τΣ − τΣP'(PτΣP' + Ω)^{-1}PτΣ`; `τ` default 0.05.
* Sentiment → view (`view_from_sentiment`): an assessment `s ∈ [−1, 1]` with degree of belief `C`
on stock `i` becomes the absolute view `q = π_i^{ann} + r_f + s · strength · σ_i^{ann}`,
`strength` = 0.25 by default (a fully positive view = +¼ annual volatility above equilibrium).
* Output: annual and per-period posterior total returns (used as scenario drift), Ω, δ, `μ_M`.
Alternatives: `expected_returns="historical"` (10-year means) or `"zero"`.
## 8. Mean–CVaR optimisation as a MILP (`quantcore.optimization.problem`)
Scenarios `R_{s,i}` = simple horizon returns `e^{r_{s,i}} − 1` in CHF (Section 6, with the drift of
Section 7), prices `P_i`, wealth `W = cash₀ + Σ_i n⁰_i P_i`.
Variables: shares `n_i ∈ ℤ₊` (no shorts); rebalancing adds buys `b_i`, sells `s_i ∈ ℤ₊` with
`n_i = n⁰_i + b_i − s_i`; `α`, `u_s ≥ 0` bounded by the worst gain/loss attainable within the share
caps (free variables make the CVaR objective numerically hard — cuOpt's concurrent method aborts on
such models; the client then retries once with its dual simplex); binaries `y_i` (cardinality / minimum position) and
`t_i` (fixed cost per trade) only when those options are used.
Transaction costs `TC = Σ_i (c_b P_i b_i + c_s P_i s_i) + F Σ_i t_i` (defaults 10 bp, no fixed cost).
Loss in scenario `s`: `L_s = TC − Σ_i P_i R_{s,i} n_i`. CVaR (Rockafellar–Uryasev):
```
u_s ≥ L_s − α (S rows)
CVaR_β = α + 1/(1−β) Σ_s p_s u_s (p_s = 1/S unless importance sampling is used)
```
Objectives: `max_return` — maximise `E[P&L] = Σ_i P_i μ_i n_i − TC` (model expected simple returns
`μ_i` over the horizon; the probability-weighted scenario mean if none are given) s.t. `CVaR_β ≤ L`
(the absolute CHF risk budget for the period, e.g. "CVaR₉₅ over a week ≤ 40 000"); `min_cvar`
s.t. `E[P&L] ≥ target`; `utility` — maximise `E[P&L] − λ·CVaR_β`.
Constraints: budget `cash₀ + Σ P_i s_i − Σ P_i b_i − TC ≥ 0` (cash may stay uninvested); single-stock
`P_i n_i ≤ w_max W` (10 %, implemented as tight variable bounds `n_i ≤ ⌊w_max W / P_i⌋`); sector
`Σ_{i∈k} P_i n_i ≤ w_k W` (default 25 %, per-sector dict possible); `TC ≤ TC_max`; turnover
`Σ P_i (b_i + s_i) ≤ τ W`; min/max invested fraction; cardinality `Σ y_i ≤ K`, `n_i ≤ M y_i`;
minimum position `P_i n_i ≥ v_min y_i`; `b_i + s_i ≤ M t_i`.
The reported expected P&L (from the model means), VaR and CVaR of the decoded integer portfolio are
recomputed from the weighted scenario set and agree with the solver's values (`tests/test_problem.py`,
including a brute-force optimality check). Note that `α` is only *a* minimiser of the RU function —
it equals the VaR only when the CVaR constraint is tight.
Sizes: `S + N(+2N) + 1` variables, `S + N + K + O(1)` rows, `≈ S·(N+2)` non-zeros; with the default
`N = 100`, `S = 4096` about 420 k non-zeros, solved by cuOpt in ~10 s. Solve times grow faster than
linearly in `S` (HiGHS LP ≈ `S^1.9`, cuOpt ≈ `S^1.2–1.6`): 2 000 → 100 000 scenarios costs ≈ 45× on
cuOpt.
**Tail-row generation** (`quantcore.optimization.rowgen`, `row_generation="auto"` from 8 192
scenarios on): only tail scenarios have `u_s > 0` at the optimum, so the MILP is solved on a working
set (the `2(1−β)S` worst scenarios of a reference portfolio), the losses of all scenarios are
checked, violated ones (`L_s > α*`) are added, and the loop stops when none is violated — the
restricted problem is a relaxation, so the final solution is optimal for the full set (within the
MIP gap). Measured: working sets ≈ 11 % of `S`, 2–3 iterations, 6–7× faster at 32 k–100 k scenarios.
**Exact Gaussian mode** (`scenario_model="gaussian_exact"`, `quantcore.optimization.gaussian_exact`):
with normal horizon returns `R ~ N(m, C)` the CVaR is `TC − m'v + k_β √(v'Cv)` (`v = P∘n`,
`k_β = φ(z_β)/(1−β)`); the mean–CVaR problem then coincides with the mean–variance problem (the
CVaR bound corresponds to an implicit variance bound `σ̄ = (Λ + m'v*)/k_β`). In integer shares this is
a convex MISOCP, solved by outer approximation: tangent cuts `t ≥ TC + (k_β C v_k/σ_k − m)'v` on the
same MILP with an epigraph variable `t` (`cvar`), a new cut at each solution until the true CVaR equals
`t` (20–70 cuts; identical to Gurobi's direct MISOCP solution to the cent). No scenarios, no sampling
error; the reference for the sampling tests.
## 9. Solvers (`quantcore.optimization.solvers`)
Both solvers receive the *same* `MILPModel` (objective, CSR matrix, row and variable bounds,
variable types); `solve_with_fallback` picks between them and the result always names the one that
produced it.
**cuOpt** — NVIDIA's GPU optimisation library, used through its self-hosted REST server
(`nvidia/cuopt`, version 26.08 here) running as the `cuopt` container on the DGX Spark:
`POST /cuopt/request` with a msgpack body, then poll `GET /cuopt/solution/{id}`; `"inf"/"ninf"` for
infinite bounds, `variable_types`, a mandatory `time_limit`. Statuses `Optimal` / `FeasibleFound`
(time limit — check `mip_gap`) / `Infeasible`. Docs: <https://docs.nvidia.com/cuopt/>. It is the
only part of the pipeline that uses the GPU.
**HiGHS** — the open-source LP/MILP/QP solver of ERGO-Code, University of Edinburgh (MIT licence,
<https://highs.dev>, source <https://github.com/ERGO-Code/HiGHS>, reference: Huangfu & Hall,
*Parallelizing the dual revised simplex method*, Math. Prog. Comp. 10, 119–142, 2018). quantcore
neither installs nor starts it separately: **HiGHS ships compiled inside SciPy**, and
`scipy.optimize.milp()` is a thin wrapper around it. It therefore runs **in-process, on the CPU, in
whichever process calls quantcore** — no network, no daemon, no separate container — and its version
follows the installed SciPy:
| where | Python | SciPy | HiGHS | notes |
|---|---|---|---|---|
| `quantcore` container on the Spark (production) | 3.12.14 | 1.18.1 | 1.12.0 (`4f96ee8`) | aarch64 Linux, 4 threads (`OMP/OPENBLAS/MKL_NUM_THREADS=4`); numpy 2.5.3, pandas 3.0.6 |
| developer machine used for this work | 3.11.11 | 1.13.1 | 1.2.0 | arm64 macOS; numpy 1.26.4, pandas 2.1.4 |
The container tracks the newest releases (nothing is pinned beyond the lower bounds in
`pyproject.toml`), so a rebuild can move it a long way from the developer machine — the build of
2026-09-23 jumped it to pandas 3, where the month-end resampling alias is `'ME'` and the old `'M'`
is gone (`quantcore.measurement.returns.MONTH_END` resolves this at import time, and is the reason
monthly frequency works on both).
Verify it in any environment with
`python -c "import scipy.optimize as so, numpy as np, scipy.sparse as sp; print(so.milp(np.array([-3.,-2.]), constraints=so.LinearConstraint(sp.csr_matrix([[2.,3.],[4.,1.]]), [-np.inf]*2, [12.,10.]), integrality=np.ones(2), bounds=so.Bounds([0,0],[np.inf]*2), options={'disp': True}))"`,
which prints the HiGHS banner with its version.
HiGHS is used in four places: `solver="highs"`; as the fallback when cuOpt is unreachable or errors
(`solver="auto"`); for the cut MILPs of the exact Gaussian mode, which are small and numerous; and as
the oracle in the tests (brute-force optimality checks).
**Scenario count and candidate count**: `n_scenarios` defaults to 16 per candidate within
[4 096, 16 384]. The reported figures (expected P&L, CVaR) are stable at any size from ~2 000 on
(sd < 0.1 %), but the *composition* moves between near-equivalent portfolios in proportion to N/S —
3.5 % of wealth between seeds at N = 30, S = 2 048 against 12.5 % at N = 100 — and ~64 per candidate
is needed to hold it near 5 %. The result warns below 8 per candidate.
**Covariance rank**: an estimator with `T_eff` effective observations (rolling: the window; EWM:
`3/(1−λ)`) cannot support more than `T_eff` assets. A 200-day window with N = 300 gives 101 zero
eigenvalues and a portfolio with *negative* estimated variance; `shrinkage="auto"` (the default)
raises the intensity with N/T (0.033 at N = 30 → 0.128 at N = 745) and keeps the smallest eigenvalue
above 2 % of the average variance in every case. `CovarianceModel.conditioning()` reports the
figures and the optimisation warns when the estimate is singular or ill-conditioned for the size of
the candidate set. Long-only with a 10 % cap limits the damage in any case: the optimised portfolio's
out-of-sample/in-sample volatility ratio exceeds the cap-weighted portfolio's by only 0.89–1.18×
across N = 30…745, with or without shrinkage.
**cuOpt transport**: the model travels as one msgpack body (9.9 M non-zeros = 108 MB at N = 300,
S = 32 768), accepted at ~10 MB/s, so transport is not the constraint. The client uses a payload-scaled
timeout and a *fresh connection per attempt* — retrying on a pooled connection appends a second body and
the server rejects it with "unpack received extra data" (422) — and `solver="auto"` falls back to HiGHS
on failure. cuOpt's concurrent method also aborts on some CVaR-objective models; the client retries once
with its dual simplex (`method=2`). With row generation only the working set is sent.
**Which models go to cuOpt.** cuOpt 26.08 aborts on one specific combination: the CVaR in the
*objective* (`objective="min_cvar"`) **and** probability-weighted scenarios. The cause is the scaling
of the objective vector. In the min-CVaR formulation the coefficient on each excess `u_s` is
`p_s/(1−β)`, so the scenario probabilities *are* the objective coefficients; importance sampling
spreads them over about six orders of magnitude. Measured on a 4 137-variable / 4 106-row model
(40 candidates, 4 096 scenarios, β = 99 %):
| scenarios | objective `|c|` range | ratio | cuOpt |
|---|---|---|---|
| importance-sampled | 1.1e-06 … 1.0 | 8.8e5 | aborts in ~5 s |
| uniform (`importance_sampling="off"`) | 2.4e-02 … 1.0 | 4.1e1 | **Optimal in 3.8 s**, 5 runs out of 5, matching HiGHS to 7e-5 |
Relaxing the integrality does not help — the pure LP aborts too, so it is the LP and not the branch
and bound — and multiplying the objective by a constant does not either, since that leaves the ratio
unchanged. In the `max_return` formulation the same probabilities sit in a *constraint row* instead
(the objective is then just 40 expected returns, range 1e3), where cuOpt's row scaling copes, and it
solves at either weighting. Its methods are not interchangeable either: `method=1` (PDLP) returned the
weighted optimum once in 4.9 s and then aborted three times on the identical model, while `method=2`
and `method=3` always abort.
So `solver="auto"` diverts exactly that combination to HiGHS (1.7 s for that model) and the result
says why — otherwise a green cuOpt health pill next to `solver: "highs"` looks like the GPU being
ignored. Everything else keeps the GPU path. An explicit `solver="cuopt"` is always honoured and
returns cuOpt's own answer, abort included; an abort under `"auto"` is retried down
`CUOPT_RETRY_METHODS = (1, 2)` before the fallback.
**cuOpt vs HiGHS, both on the Spark** (end-to-end through the API: scenario generation, model build,
solve, decode; GBM model, weekly, β = 0.95, no tilt, 600 s solver time limit):
| case | cuOpt (GPU) | HiGHS (CPU, 4 threads) |
|---|---|---|
| N = 30, S = 8 192 | 15.6 s | 14.5 s |
| N = 30, S = 32 768 | 171.4 s | 171.0 s |
| N = 100, S = 4 096 | 10.0 s | 8.9 s |
| N = 100, S = 32 768 | 606.9 s (time limit, `FeasibleFound`) | 350.7 s (`Optimal`) |
| N = 300, S = 8 192 (row generation) | 139 s | 64 s |
HiGHS is at least as fast in every case measured and 2× faster on the two largest. That is not a
statement about GPUs in general: cuOpt's published speedups are for *LP* with its PDLP first-order
method, while [its own documentation](https://docs.nvidia.com/cuopt/user-guide/latest/milp-features.html)
states that for MILP the GPU runs primal heuristics and **branch and bound runs on the CPU**, that the
MIP solver is beta, and that "proving feasible solutions optimal remains under active development".
Our models have an easy LP relaxation and a small integrality gap, so the work sits almost entirely in
proving optimality — the part cuOpt does not accelerate — while HiGHS' dual simplex warm-starts across
branch-and-bound nodes. HiGHS is also a strong reference point: in the [Mittelmann MILP benchmark](https://plato.asu.edu/ftp/milp.html)
(7 July 2026) HiGHS 1.15.1 solves 179 of 240 instances at a scaled shifted geometric mean of 5.44,
against 1.72 for COPT 8.0.3 and 9.93 for SCIP. Caveats: cuOpt's `time_limit` covers only its internal
solve, it ignores the MIP-gap settings sent over REST (so it may prove optimality to a tighter
tolerance), and this is one problem family on one machine, not a benchmark.
**Reproducibility**: the scenario set depends only on the seed (`scenario_hash` in every result), and
the hash is identical for two runs **in the same environment**. Across environments it is not, and the
reason is the fingerprint rather than the mathematics. Measured between the developer Mac (numpy 1.26.4,
pandas 2.1.4, scipy 1.13.1) and the Spark container (numpy 2.5.3, pandas 3.0.6, scipy 1.18.1) on the
same 100 stocks and the same stored prices: scrambled Sobol' points and the numpy RNG stream are
bit-identical, the estimated covariance agrees to `max |dC| = 1.7e-18` (entries ~5e-4), its
eigenvalues to 1.4e-17 and the columns of `pca_root` to 4e-13 relative — no sign flips, no rotation.
The scenario matrices therefore agree to 13–14 significant digits. But `scenario_hash` fingerprints
`np.round(scenarios, 12)`, and on a 4096 × 100 matrix of weekly log-returns a *relative* perturbation
of 1e-15 already pushes ~3 entries of 409 600 across a rounding boundary (1e-14 pushes ~47), which
changes the digest. The figures are unaffected: the same request optimised on both machines gave
E[P&L] 1 888.65 against 1 888.76 and CVaR 39 999.71 against 39 999.82 on a CHF 1 M portfolio.
The portable identity is the **scenario key**, not the hash: it is built from the request and the
model description, so two environments that agree on the inputs produce the same `key` (verified:
`7162939ddf75ff74` on both machines for the run above) even where the digest of the realised matrix
differs. Read `scenario_hash` as "these two results used bit-identical scenarios" — a statement about
one machine — and `scenario_key` as "these two results were asked for the same scenarios".
HiGHS' branch-and-bound is deterministic → identical shares for the same inputs *and the same HiGHS
build* (`reproducibility="exact"`); across SciPy versions (1.2.0 on the developer machine against
1.12.0 in the container) the search can end on a different optimal vertex among ties, with the same
objective. cuOpt 26.08
ignores the MIP-gap settings sent over REST and stops within its default relative gap of 1e-4: the
objective, expected P&L and CVaR of two runs agree to that gap (≈ 0.2 CHF/week on CHF 1 M) while
near-tied share counts can differ by a few shares (`reproducibility="within_gap"`). The Gaussian exact
mode runs on HiGHS by default.
## 10. Default parameters
| Parameter | Default | Where |
|---|---|---|
| history start / required first price | 2004-12-01 / ≤ 2005-01-31, coverage ≥ 90 % | `Settings` |
| base currency, risk-free | CHF, 0 % | `Settings` |
| measurement window / λ | 200 periods / 0.94 | `RiskService` |
| optimisation covariance | EWM λ = 0.99 (≈ 100-day effective window) | `OptimizationRequest.covariance` |
| scenarios | empirical innovations, 4 096 scrambled Sobol' draws on the market-factor ranks, moment matched, arithmetic drift, last 2 500 days, diagonal re-standardisation, seed 42 | `OptimizationRequest` |
| importance sampling / row generation | `auto` = market tilt (blocks at z₀.₉₅, z₀.₉₉, 25 % each); row generation from 8 192 scenarios | `OptimizationRequest` |
| MIP gap | 1e-4 relative (HiGHS; cuOpt uses its own default) | `Settings.mip_rel_gap` |
| Black–Litterman | 10 years weekly, τ = 0.05, sentiment strength 0.25 | `OptimizationRequest`, `view_from_sentiment` |
| constraints | 10 % per stock, 25 % per sector, no shorts, 10 bp costs | `OptimizationConstraints`, `TransactionCosts` |
| CVaR limit | explicit CHF amount or fraction (5 % of wealth if nothing is given) | `OptimizationRequest` |
| cuOpt time limit | 120 s (`QUANTCORE_CUOPT_TIME_LIMIT`) | `Settings` |