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>
33 KiB
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 withquantcore 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.4is 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 thanmax_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 ofk·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.05reversed 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 currencyc, from YahoocCHF=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
hin periods: day = 1, week = 5, month = 21 (daily data); at weekly frequency week = 1, month = 4. Returns are assumed i.i.d., soh-period returns are sums. - Portfolio log-return with weights
wfixed 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
wobservations (default 200 days): sample mean, standard deviation (ddof 1), covariancenp.cov, correlation. - Exponentially weighted with decay
λ(defaults 0.94 for measurement, 0.99 for the slow optimisation covariance): weight of the observationkperiods back ∝λ^k, normalised to one (pandasewm(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 everyt(needed by the innovation filter): the rolling estimator recomputes the window, the EWM estimator uses the exact centred recursionm_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(Σ)withseither given or"auto": the Schäfer–Strimmer / Ledoit–Wolf intensitys* = Σ_{i≠j} Var̂(s_ij) / Σ_{i≠j} s_ij²(clipped to [0, 1]) computed on the effective estimation window (wobservations, or3/(1−λ)for EWM). It is ≈ 0 for a long, strongly correlated sample and → 1 whenNapproaches 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 lastmax(5w, 750)observations aggregated to overlappingh-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 weightswat 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): withSequiprobable losses sorted descending andk = (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 withA 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_twithΣ_{t−1}estimated from data up tot−1(streamed sequence, symmetric rootV Λ^{−1/2} V'— Cholesky optional for largeN); forward simulationr(j | T) = hm + Σ_T^{1/2} Σ_{k=1..h} ε_{j_k}with the datesj_kdrawn 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 (needsT ≥ 3N). The scenario covariance then reproduceshΣ_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 largeNis 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σ/√Sper asset — 3–13 % p.a. forS = 2000— which the optimiser maximises (measured: two seeds give portfolios differing by 100 % of wealth). Therefore: scrambled Sobol' points (Srounded up to a power of two), moment matchingz̃ = C^{-1/2}(z − z̄)so that the (probability-weighted) scenario mean and covariance are exactlyhmandhΣ_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ô-correctedm_i = ln(1 + hμ_i)/h − σ_i²/2, so thatE[e^{r_h} − 1] = hμexactly (drift_convention="arithmetic");"log"usesμas the log-drift and addsσ²/2to 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 directiondof the cap-weighted candidate portfolio; two blocks of 25 % of the scenarios are shifted along it byz_0.95andz_0.99(GBM:z ↦ z + c_k d; empirical: dates drawn with exponentially tilted probabilitiesq_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 by1/(1 − Σ mixture)) as its probabilityp_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 reproduceshΣ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_alignmentin 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_hashis unchanged). ScenarioSetreportsexpected_simple(the expectation the optimiser uses),probabilities, the effective sample size and a SHA-256 fingerprinthashof 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};qis 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ω_kfor which the unconstrained posterior weights move the fractionC_kof the way fromwto 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 asidzorek_omega_numericand 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 assessments ∈ [−1, 1]with degree of beliefCon stockibecomes the absolute viewq = π_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
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
(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 |