Large-scale driver is the default strategy for scenario problems

strategy='auto' now routes every scenario-mode CVaR optimisation through
quantcore.optimization.large_scale (LP relaxation on the tail rows, screening,
round-and-repair, certified against the relaxation bound, escalation to the
integer MILP otherwise); strategy='milp' is the explicit opt-out, and
row_generation applies to that path only. When the escalated MILP proves the
rounded portfolio optimal within its gap, the result is Optimal even where the
relaxation bound is loose (small portfolios with a real integrality gap).

UI: a Strategy selector and the driver's bound, gap, screened size, rows and
solver calls in the provenance card. Deploy env examples document the new
cuOpt settings (presolve off, dual simplex for LPs, 50 ms polling). The
service-equivalence test asks for strategy='milp' where it checks the
integer row-generation path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
herzogflorian 2026-11-04 10:03:30 +01:00
parent fac41ffdfc
commit 356017e689
12 changed files with 64 additions and 42 deletions

View File

@ -2,6 +2,9 @@
QUANTCORE_DATA_DIR=./data # where the setup/download services persist everything QUANTCORE_DATA_DIR=./data # where the setup/download services persist everything
QUANTCORE_CUOPT_URL=http://127.0.0.1:5000 # cuOpt on this machine (Apptainer setup); a remote one: http://<host>:5000 QUANTCORE_CUOPT_URL=http://127.0.0.1:5000 # cuOpt on this machine (Apptainer setup); a remote one: http://<host>:5000
QUANTCORE_CUOPT_TIME_LIMIT=120 # seconds, MILP time limit QUANTCORE_CUOPT_TIME_LIMIT=120 # seconds, MILP time limit
QUANTCORE_CUOPT_PRESOLVE=false # off: no 5-25 s GPU-heuristic wait per MILP (cuOpt 26.08)
QUANTCORE_CUOPT_LP_METHOD=2 # pure LPs on cuOpt's dual simplex (exact; concurrent may return PDLP's point)
QUANTCORE_CUOPT_POLL_INTERVAL=0.05 # seconds between polls of a submitted job (a latency floor per solve)
QUANTCORE_BASE_CURRENCY=CHF QUANTCORE_BASE_CURRENCY=CHF
QUANTCORE_RISK_FREE_RATE=0.0 # annual, CHF QUANTCORE_RISK_FREE_RATE=0.0 # annual, CHF
QUANTCORE_HISTORY_START=2004-12-01 # first date requested from Yahoo QUANTCORE_HISTORY_START=2004-12-01 # first date requested from Yahoo

View File

@ -17,7 +17,7 @@ never computes a number itself.
| **PerformanceService** | `quantcore.measurement.performance` | Annualised return, volatility, Sharpe, Sortino, drawdown, Calmar, skew/kurtosis, hit ratio; rolling and EWM series; portfolios in shares. | | **PerformanceService** | `quantcore.measurement.performance` | Annualised return, volatility, Sharpe, Sortino, drawdown, Calmar, skew/kurtosis, hit ratio; rolling and EWM series; portfolios in shares. |
| **Stock-price models** | `quantcore.models.simulation` | Geometric Brownian motion (multivariate normal) and the empirical-innovation model `eps_t = Sigma_{t-1}^{-1/2} r_t`, `r(j|T) = Sigma_T^{1/2} eps_j` with the latest rolling or EWM covariance. | | **Stock-price models** | `quantcore.models.simulation` | Geometric Brownian motion (multivariate normal) and the empirical-innovation model `eps_t = Sigma_{t-1}^{-1/2} r_t`, `r(j|T) = Sigma_T^{1/2} eps_j` with the latest rolling or EWM covariance. |
| **Expected returns** | `quantcore.models.black_litterman` | Market-cap equilibrium returns (broad-market average, market weights), Black–Litterman on a 10-year weekly covariance with **Idzorek confidence** views (diagonal Ω), sentiment → view helper. | | **Expected returns** | `quantcore.models.black_litterman` | Market-cap equilibrium returns (broad-market average, market weights), Black–Litterman on a 10-year weekly covariance with **Idzorek confidence** views (diagonal Ω), sentiment → view helper. |
| **OptimizationService** | `quantcore.optimization` | Mean–CVaR MILP in integer share numbers: **new portfolio** (from cash) or **rebalancing** (existing holdings, transaction costs); constraints: max 10 % per stock, max per sector, no shorts, cost/turnover limits, optional cardinality; scenario sets with variance reduction (Sobol', moment matching, market-direction importance sampling) that stay identical when risk limits, constraints, costs or views change, tail-row generation for large sets, a relax-screen-round driver for large universes (`strategy`), and an exact Gaussian (mean–variance-equivalent) mode; solved on cuOpt (GPU, over HTTP) with a HiGHS fallback (CPU, compiled into SciPy — runs in-process, nothing to deploy). | | **OptimizationService** | `quantcore.optimization` | Mean–CVaR MILP in integer share numbers: **new portfolio** (from cash) or **rebalancing** (existing holdings, transaction costs); constraints: max 10 % per stock, max per sector, no shorts, cost/turnover limits, optional cardinality; scenario sets with variance reduction (Sobol', moment matching, market-direction importance sampling) that stay identical when risk limits, constraints, costs or views change, solved by default through a relax-screen-round driver certified against the relaxation bound (`strategy`; the integer MILP with tail-row generation on request), and an exact Gaussian (mean–variance-equivalent) mode; solved on cuOpt (GPU, over HTTP) with a HiGHS fallback (CPU, compiled into SciPy — runs in-process, nothing to deploy). |
| **HTTP API** | `quantcore.api` | FastAPI app (`/docs`) exposing all of the above; setup/update as background jobs. Runs as a container on the DGX Spark next to cuOpt, or as a Kubernetes Deployment. | | **HTTP API** | `quantcore.api` | FastAPI app (`/docs`) exposing all of the above; setup/update as background jobs. Runs as a container on the DGX Spark next to cuOpt, or as a Kubernetes Deployment. |
Documentation: **[docs/USAGE.md](docs/USAGE.md) (start here if you are building on this package: the API, the Documentation: **[docs/USAGE.md](docs/USAGE.md) (start here if you are building on this package: the API, the

View File

@ -33,6 +33,9 @@ QC_IMAGE=build
# --- passed unchanged into the quantcore container (see quantcore/config.py) ------------------- # --- passed unchanged into the quantcore container (see quantcore/config.py) -------------------
QUANTCORE_CUOPT_TIME_LIMIT=300 # seconds per MILP QUANTCORE_CUOPT_TIME_LIMIT=300 # seconds per MILP
QUANTCORE_CUOPT_PRESOLVE=false # off: no 5-25 s GPU-heuristic wait per MILP (cuOpt 26.08)
QUANTCORE_CUOPT_LP_METHOD=2 # pure LPs on cuOpt's dual simplex (exact)
QUANTCORE_CUOPT_POLL_INTERVAL=0.05 # seconds between polls of a submitted job
QUANTCORE_ENABLE_UI=true # false: JSON API and /docs only, no test bench at /ui/ QUANTCORE_ENABLE_UI=true # false: JSON API and /docs only, no test bench at /ui/
QUANTCORE_LOG_LEVEL=INFO QUANTCORE_LOG_LEVEL=INFO
#QUANTCORE_BASE_CURRENCY=CHF #QUANTCORE_BASE_CURRENCY=CHF

View File

@ -264,15 +264,15 @@ Sizes: `S + N(+2N) + 1` variables, `S + N + K + O(1)` rows, `≈ S·(N+2)` non-z
linearly in `S` (HiGHS LP ≈ `S^1.9`, cuOpt ≈ `S^1.2–1.6`): 2 000 → 100 000 scenarios costs ≈ 45× on linearly in `S` (HiGHS LP ≈ `S^1.9`, cuOpt ≈ `S^1.2–1.6`): 2 000 → 100 000 scenarios costs ≈ 45× on
cuOpt. cuOpt.
**Tail-row generation** (`quantcore.optimization.rowgen`, `row_generation="auto"` from 8 192 **Tail-row generation** (`quantcore.optimization.rowgen`; with `strategy="milp"`,
scenarios on): only tail scenarios have `u_s > 0` at the optimum, so the MILP is solved on a working `row_generation="auto"` from 8 192 scenarios on; the default strategy below always works on tail rows): 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 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 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 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. MIP gap). Measured: working sets ≈ 11 % of `S`, 2–3 iterations, 6–7× faster at 32 k–100 k scenarios.
**Large-scale strategy** (`quantcore.optimization.large_scale`, `strategy="auto"` from 200 candidates **Large-scale strategy** (`quantcore.optimization.large_scale`; `strategy="auto"`, the default for
or 32 768 scenarios on, `"large_scale"` to force it): the solver benchmark of November 2026 every scenario problem; `"milp"` opts out): the solver benchmark of November 2026
(`GPU_Optimization/cuopt_benchmark`) showed that HiGHS closes every mean–CVaR MILP at the root node — (`GPU_Optimization/cuopt_benchmark`) showed that HiGHS closes every mean–CVaR MILP at the root node —
share counts in the hundreds round with a loss far below the MIP gap, so the branch-and-bound is paid share counts in the hundreds round with a loss far below the MIP gap, so the branch-and-bound is paid
for on every call and almost never used — and that the only hard combinatorics is the cardinality for on every call and almost never used — and that the only hard combinatorics is the cardinality

View File

@ -302,12 +302,12 @@ that must be identical next week, freeze the data with a snapshot (`POST /snapsh
for hours. Set it. 300 s is a sane default. 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 * **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. result says which, and adds a warning. A cuOpt outage degrades speed, never correctness.
* **Large universes do not need the integer MILP.** `strategy="auto"` switches to the large-scale * **The CVaR problem is solved by the large-scale driver by default**, not by the integer MILP:
driver (LP relaxation on the tail rows, screening for a cardinality limit, round-and-repair, LP relaxation on the tail rows, screening for a cardinality limit, round-and-repair, certified
certified against the relaxation bound) from 200 candidates or 32 768 scenarios on; against the relaxation bound and escalated to the MILP only when the gap is above `mip_rel_gap`.
`strategy="large_scale"` forces it, `"milp"` keeps the integer MILP. The result's `strategy="milp"` restores the integer MILP (then `row_generation` applies). The result's
`solver_info["large_scale"]["gap"]` is the proven gap; a `FeasibleFound` status there means the gap `solver_info["large_scale"]["gap"]` is the proven gap; a `FeasibleFound` status there means the gap
is above `mip_rel_gap`, not that a time limit was hit. stayed above `mip_rel_gap`, not that a time limit was hit.
* **`risk_drift="model"` (the default) measures the CVaR on scenarios that include the expected * **`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 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 forecast can push the whole loss tail above zero and the risk limit stops binding — the optimiser

View File

@ -204,12 +204,11 @@ class OptimizeRequest(BaseModel):
"return; 'zero': on the centred scenarios, so a high return forecast " "return; 'zero': on the centred scenarios, so a high return forecast "
"cannot relax the risk limit (the usual market-risk convention)") "cannot relax the risk limit (the usual market-risk convention)")
row_generation: str = Field("auto", pattern="^(auto|on|off)$", row_generation: str = Field("auto", pattern="^(auto|on|off)$",
description="solve on the tail rows only (large scenario sets)") description="strategy 'milp' only: solve on the tail rows only (large scenario sets)")
strategy: str = Field("auto", pattern="^(auto|milp|large_scale)$", strategy: str = Field("auto", pattern="^(auto|milp|large_scale)$",
description="'large_scale': relaxation, tail rows, screening for cardinality, " description="'auto' (= 'large_scale', the default): LP relaxation on the tail rows, "
"round-and-repair certified against the relaxation bound (large universes " "screening for a cardinality limit, round-and-repair, certified against the "
"or scenario sets); 'milp': the integer MILP; 'auto': large_scale from " "relaxation bound; 'milp': the integer MILP (row_generation applies)")
"200 candidates or 32 768 scenarios on")
covariance: dict = Field(default_factory=lambda: {"method": "ewm", "lam": 0.99, "shrinkage": "auto"}) covariance: dict = Field(default_factory=lambda: {"method": "ewm", "lam": 0.99, "shrinkage": "auto"})
expected_returns: str = Field("black_litterman", pattern="^(black_litterman|historical|zero)$") expected_returns: str = Field("black_litterman", pattern="^(black_litterman|historical|zero)$")
views: list[ViewSpec] = Field(default_factory=list) views: list[ViewSpec] = Field(default_factory=list)

View File

@ -50,7 +50,7 @@ export const DEFAULT_STATE = {
opt: { opt: {
objective: "max_return", confidence: 0.95, cvar_mode: "fraction", cvar_limit_fraction: 0.05, objective: "max_return", confidence: 0.95, cvar_mode: "fraction", cvar_limit_fraction: 0.05,
cvar_limit: null, risk_aversion: 1, target_return: null, solver: "auto", time_limit: 300, cvar_limit: null, risk_aversion: 1, target_return: null, solver: "auto", time_limit: 300,
row_generation: "auto", auto_scenarios: true, risk_drift: "model", strategy: "auto", row_generation: "auto", auto_scenarios: true, risk_drift: "model",
}, },
data: { start: "", end: "", freq: "daily" }, data: { start: "", end: "", freq: "daily" },
}; };
@ -151,7 +151,8 @@ export function optimizePayload(extra = {}) {
objective: o.objective, risk_aversion: Number(o.risk_aversion), objective: o.objective, risk_aversion: Number(o.risk_aversion),
scenario_model: m.scenario_model, seed: Number(m.seed), sampling: m.sampling, scenario_model: m.scenario_model, seed: Number(m.seed), sampling: m.sampling,
moment_match: !!m.moment_match, importance_sampling: m.importance_sampling, moment_match: !!m.moment_match, importance_sampling: m.importance_sampling,
drift_convention: m.drift_convention, row_generation: o.row_generation, risk_drift: o.risk_drift, drift_convention: m.drift_convention, strategy: o.strategy || "auto", row_generation: o.row_generation,
risk_drift: o.risk_drift,
covariance: covarianceSpec(), expected_returns: m.expected_returns, views: viewPayload(), covariance: covarianceSpec(), expected_returns: m.expected_returns, views: viewPayload(),
bl_tau: Number(m.bl_tau), bl_years: Number(m.bl_years), bl_omega_method: m.bl_omega_method || "idzorek", bl_tau: Number(m.bl_tau), bl_years: Number(m.bl_years), bl_omega_method: m.bl_omega_method || "idzorek",
constraints: constraintPayload(), costs: { constraints: constraintPayload(), costs: {

View File

@ -157,8 +157,13 @@ function inputsCard(out, rerender) {
o.risk_drift === "model" o.risk_drift === "model"
? "over a long horizon a large forecast can push the whole tail into profit" ? "over a long horizon a large forecast can push the whole tail into profit"
: "the risk figure does not depend on the return forecast"), : "the risk figure does not depend on the return forecast"),
field("Strategy", select([["auto", "relax, round and certify (default)"], ["milp", "integer MILP"]],
o.strategy || "auto", set("strategy")),
(o.strategy || "auto") === "milp"
? "branch-and-bound on the full integer model; tail rows per the setting below"
: "LP relaxation on the tail rows, round and repair, certified against the relaxation bound"),
field("Tail rows", select([["auto", "auto"], ["on", "always"], ["off", "never"]], o.row_generation, set("row_generation")), field("Tail rows", select([["auto", "auto"], ["on", "always"], ["off", "never"]], o.row_generation, set("row_generation")),
"solve on the worst scenarios only, then verify"), "integer MILP only: solve on the worst scenarios only, then verify"),
el("div", { class: "field" }, el("div", { class: "field" },
el("label", { text: "Scenario count" }), el("label", { text: "Scenario count" }),
segmented([[true, "automatic"], [false, `fixed (${fmt.int(state.model.n_scenarios)})`]], o.auto_scenarios, segmented([[true, "automatic"], [false, `fixed (${fmt.int(state.model.n_scenarios)})`]], o.auto_scenarios,
@ -414,7 +419,7 @@ function positionsTable(positions, rebalancing, wealthValue) {
function provenanceCard(r) { function provenanceCard(r) {
const m = r.model || {}, s = r.solver_info || {}; const m = r.model || {}, s = r.solver_info || {};
const scen = m.scenarios || {}; const scen = m.scenarios || {};
const rg = s.row_generation; const rg = s.row_generation, ls = s.large_scale;
return card("How this number was produced", "everything needed to reproduce the run", return card("How this number was produced", "everything needed to reproduce the run",
el("div", { class: "grid three" }, el("div", { class: "grid three" },
el("div", {}, el("h3", { text: "Scenarios" }), el("div", {}, el("h3", { text: "Scenarios" }),
@ -433,7 +438,14 @@ function provenanceCard(r) {
["solver", r.solver], ["status", r.status], ["objective", Number.isFinite(s.objective) ? fmt.money2(s.objective) : null], ["solver", r.solver], ["status", r.status], ["objective", Number.isFinite(s.objective) ? fmt.money2(s.objective) : null],
["MIP gap", Number.isFinite(s.mip_gap) ? s.mip_gap.toExponential(1) : s.mip_gap], ["MIP gap", Number.isFinite(s.mip_gap) ? s.mip_gap.toExponential(1) : s.mip_gap],
["variables", fmt.int(m.milp?.n_vars)], ["variables", fmt.int(m.milp?.n_vars)],
["rows solved", rg ? `${fmt.int(rg.rows_final)} of ${fmt.int(scen.n_scenarios)}` : null], ["strategy", ls ? (ls.certified ? "relax + round, certified" : ls.escalated ? "relax + round, then MILP" : "relax + round")
: s.outer_approximation ? "Gaussian cuts" : "integer MILP"],
["relaxation bound", Number.isFinite(ls?.bound) ? fmt.money2(ls.bound) : null],
["gap to bound", Number.isFinite(ls?.gap) ? ls.gap.toExponential(1) : null],
["screened to", ls?.screened_to ? `${fmt.int(ls.screened_to)} names` : null],
["rows solved", ls ? `${fmt.int(ls.working_set)} of ${fmt.int(scen.n_scenarios)}`
: rg ? `${fmt.int(rg.rows_final)} of ${fmt.int(scen.n_scenarios)}` : null],
["solver calls", ls?.solver_calls],
["row-generation passes", rg?.iterations], ["cuts (Gaussian)", s.outer_approximation?.iterations], ["row-generation passes", rg?.iterations], ["cuts (Gaussian)", s.outer_approximation?.iterations],
["solve time", fmt.secs(s.solve_time_s)], ["total", fmt.secs(r.timings?.total_s)], ["solve time", fmt.secs(s.solve_time_s)], ["total", fmt.secs(r.timings?.total_s)],
["reproducibility", r.reproducibility], ["reproducibility", r.reproducibility],

View File

@ -364,7 +364,12 @@ def solve_large_scale(spec: ProblemSpec, solve_small: SolveFn, solve_large: Solv
return finish(res, problem_r, idx, status=status, return finish(res, problem_r, idx, status=status,
message=f"integer row generation ({rg.iterations} passes) after rounding") message=f"integer row generation ({rg.iterations} passes) after rounding")
if rep is not None: if rep is not None:
return finish(rep, problem_rep, idx, message="rounded; the escalated MILP did not improve on it") # the MILP found nothing better: its optimality proof (within its own gap) covers the rounded
# portfolio too, so the result is certified by the MILP even where the relaxation bound is loose
proven = res.ok and res.status == "Optimal"
return finish(rep, problem_rep, idx, status=("Optimal" if proven else None),
message=("rounded; the escalated MILP proved it optimal within its gap" if proven
else "rounded; the escalated MILP did not improve on it"))
info.total_time = time.monotonic() - t0 info.total_time = time.monotonic() - t0
return (replace(res, x=(embed(full, MeanCVaRProblem(restricted_spec(sub_spec, np.arange(sub_spec.n_scenarios))), return (replace(res, x=(embed(full, MeanCVaRProblem(restricted_spec(sub_spec, np.arange(sub_spec.n_scenarios))),
res.x, idx) if res.x is not None else None), res.x, idx) if res.x is not None else None),

View File

@ -13,9 +13,10 @@ Pipeline (deterministic given the seed; the result carries the scenario hash and
solution (``importance_sampling``), or -- ``scenario_model='gaussian_exact'`` -- no scenarios solution (``importance_sampling``), or -- ``scenario_model='gaussian_exact'`` -- no scenarios
at all but the exact Gaussian (mean-variance-equivalent) problem by outer approximation; at all but the exact Gaussian (mean-variance-equivalent) problem by outer approximation;
4. the mean-CVaR MILP (:mod:`quantcore.optimization.problem`) in "new" or "rebalancing" mode, 4. the mean-CVaR MILP (:mod:`quantcore.optimization.problem`) in "new" or "rebalancing" mode,
for large scenario sets by tail-row generation (:mod:`quantcore.optimization.rowgen`), and for solved by default through the large-scale driver (:mod:`quantcore.optimization.large_scale`:
large universes or scenario sets by relaxation, screening and rounding LP relaxation on the tail rows, screening, round-and-repair, certified against the relaxation
(:mod:`quantcore.optimization.large_scale`, ``strategy``); bound) or, with ``strategy='milp'``, as the integer MILP with tail-row generation for large
scenario sets (:mod:`quantcore.optimization.rowgen`);
5. solved on cuOpt (HiGHS fallback), decoded into shares, trades, cash, costs, risk figures. 5. solved on cuOpt (HiGHS fallback), decoded into shares, trades, cash, costs, risk figures.
""" """
@ -60,12 +61,11 @@ log = logging.getLogger(__name__)
SCENARIO_MODELS = ("gbm", "empirical", "gaussian_exact") SCENARIO_MODELS = ("gbm", "empirical", "gaussian_exact")
IMPORTANCE_SAMPLING_MODES = ("auto", "market", "adaptive", "off") IMPORTANCE_SAMPLING_MODES = ("auto", "market", "adaptive", "off")
ROW_GENERATION_MIN_SCENARIOS = 8192 # 'auto': row generation from this size on ROW_GENERATION_MIN_SCENARIOS = 8192 # strategy 'milp', row_generation 'auto': row generation from this size on
# strategy 'auto': the relax / round-and-repair driver (quantcore.optimization.large_scale) from this # strategy 'auto' is the relax / round-and-repair driver (quantcore.optimization.large_scale) for every
# many candidates or scenarios on; below, the integer MILP as before. Measured (cuopt_benchmark, 2026-11): # scenario problem. Measured (cuopt_benchmark, 2026-11): HiGHS closes every CVaR MILP at the root, so the
# HiGHS closes every CVaR MILP at the root, so the driver loses nothing and saves the tree search. # driver loses nothing (it certifies against the relaxation bound and escalates to the MILP otherwise) and
LARGE_SCALE_MIN_ASSETS = 200 # saves the tree search: 45 s -> 2.9 s at N=300, S=16384.
LARGE_SCALE_MIN_SCENARIOS = 32768
LARGE_SCALE_CUOPT_ROWS = 16_000 # the driver sends models with at least this many rows to cuOpt LARGE_SCALE_CUOPT_ROWS = 16_000 # the driver sends models with at least this many rows to cuOpt
IMPORTANCE_SAMPLING_MIN_TAIL = 128 # 'auto': tilt when fewer than this many scenarios would be in the tail IMPORTANCE_SAMPLING_MIN_TAIL = 128 # 'auto': tilt when fewer than this many scenarios would be in the tail
STAGE1_SCENARIOS = 2048 # 'adaptive': first-pass solve that provides the tilt direction STAGE1_SCENARIOS = 2048 # 'adaptive': first-pass solve that provides the tilt direction
@ -127,12 +127,11 @@ class OptimizationRequest:
# stops binding. 'zero': the risk rows use the centred (driftless) scenarios while the # stops binding. 'zero': the risk rows use the centred (driftless) scenarios while the
# objective keeps the expected return, which is the usual market-risk convention. # objective keeps the expected return, which is the usual market-risk convention.
risk_drift: str = "model" # 'model' | 'zero' risk_drift: str = "model" # 'model' | 'zero'
row_generation: str = "auto" # 'auto' | 'on' | 'off': solve on the tail rows only # 'auto' (= 'large_scale'): relaxation, tail rows, screening for cardinality, round and repair,
# 'large_scale': relaxation, tail rows, screening for cardinality, round and repair, certified # certified against the relaxation bound (quantcore.optimization.large_scale) -- the default for
# against the relaxation bound (quantcore.optimization.large_scale); 'milp': the integer MILP # every scenario problem; 'milp': the integer MILP, with row generation per `row_generation`
# (with row generation per `row_generation`); 'auto': 'large_scale' from LARGE_SCALE_MIN_ASSETS
# candidates or LARGE_SCALE_MIN_SCENARIOS scenarios on
strategy: str = "auto" # 'auto' | 'milp' | 'large_scale' strategy: str = "auto" # 'auto' | 'milp' | 'large_scale'
row_generation: str = "auto" # strategy 'milp' only -- 'auto' | 'on' | 'off': solve on the tail rows only
covariance: dict = field(default_factory=lambda: {"method": "ewm", "lam": 0.99, "shrinkage": "auto"}) covariance: dict = field(default_factory=lambda: {"method": "ewm", "lam": 0.99, "shrinkage": "auto"})
expected_returns: str = "black_litterman" # 'black_litterman' | 'historical' | 'zero' expected_returns: str = "black_litterman" # 'black_litterman' | 'historical' | 'zero'
views: list[dict] = field(default_factory=list) views: list[dict] = field(default_factory=list)
@ -186,10 +185,8 @@ class OptimizationRequest:
return self.row_generation == "auto" and n_scenarios >= ROW_GENERATION_MIN_SCENARIOS return self.row_generation == "auto" and n_scenarios >= ROW_GENERATION_MIN_SCENARIOS
def uses_large_scale(self, n_assets: int, n_scenarios: int) -> bool: def uses_large_scale(self, n_assets: int, n_scenarios: int) -> bool:
if self.strategy == "large_scale": """The large-scale driver is the default for every scenario problem; 'milp' opts out."""
return True return self.strategy != "milp"
return self.strategy == "auto" and (n_assets >= LARGE_SCALE_MIN_ASSETS
or n_scenarios >= LARGE_SCALE_MIN_SCENARIOS)
def tilt_mode(self, n_scenarios: int) -> str: def tilt_mode(self, n_scenarios: int) -> str:
"""``'market'``, ``'adaptive'`` or ``'off'`` for this request. """``'market'``, ``'adaptive'`` or ``'off'`` for this request.

View File

@ -179,7 +179,7 @@ def test_service_routes_the_strategy(settings, md):
assert ls.status == "Optimal" and ls.reproducible assert ls.status == "Optimal" and ls.reproducible
assert abs(ls.expected_pnl - milp.expected_pnl) <= 1e-4 * abs(milp.expected_pnl) + 1e-6 assert abs(ls.expected_pnl - milp.expected_pnl) <= 1e-4 * abs(milp.expected_pnl) + 1e-6
assert ls.cvar <= 12_000.0 * (1 + 1e-7) assert ls.cvar <= 12_000.0 * (1 + 1e-7)
req = OptimizationRequest(symbols=SYMBOLS, **BASE) assert OptimizationRequest(symbols=SYMBOLS, **BASE).uses_large_scale(12, 2048) # the default path
assert not req.uses_large_scale(12, 2048) and req.uses_large_scale(200, 2048) and req.uses_large_scale(12, 32768) assert not OptimizationRequest(symbols=SYMBOLS, strategy="milp", **BASE).uses_large_scale(750, 65536)
with pytest.raises(ValueError): with pytest.raises(ValueError):
OptimizationRequest(symbols=SYMBOLS, strategy="bogus", **BASE) OptimizationRequest(symbols=SYMBOLS, strategy="bogus", **BASE)

View File

@ -72,7 +72,9 @@ def test_importance_sampling_and_row_generation_keep_the_optimum(svc):
assert abs(tilted.expected_pnl - plain.expected_pnl) <= 0.02 * plain.expected_pnl assert abs(tilted.expected_pnl - plain.expected_pnl) <= 0.02 * plain.expected_pnl
exact_req = OptimizationRequest(scenario_model="gaussian_exact", **BASE) exact_req = OptimizationRequest(scenario_model="gaussian_exact", **BASE)
assert gaussian_cvar_of(svc, exact_req, tilted)[1] <= BASE["cvar_limit"] * 1.05 assert gaussian_cvar_of(svc, exact_req, tilted)[1] <= BASE["cvar_limit"] * 1.05
rg = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096, **{**BASE, "row_generation": "on"})) # the integer MILP path with tail-row generation (the default strategy is the large-scale driver)
rg = svc.optimize(OptimizationRequest(scenario_model="gbm", n_scenarios=4096,
**{**BASE, "strategy": "milp", "row_generation": "on"}))
assert rg.status == "Optimal" and rg.solver_info["row_generation"]["converged"] assert rg.status == "Optimal" and rg.solver_info["row_generation"]["converged"]
assert rg.scenario_hash == plain.scenario_hash # same scenario set ... assert rg.scenario_hash == plain.scenario_hash # same scenario set ...
assert abs(rg.expected_pnl - plain.expected_pnl) <= 1e-4 * plain.expected_pnl # ... same optimum assert abs(rg.expected_pnl - plain.expected_pnl) <= 1e-4 * plain.expected_pnl # ... same optimum