herzogflorian fac41ffdfc Large-scale strategy: relaxation, tail rows, screening, round-and-repair
The solver benchmark (GPU_Optimization/cuopt_benchmark, November 2026) showed
that the integer mean-CVaR MILP is an LP in disguise: HiGHS closes it at the
root node, only the tail scenario rows bind, and the cardinality constraint is
the only hard combinatorics. quantcore.optimization.large_scale exploits that:
LP relaxation by tail-row generation (evaluated at the continuous point, its
objective a valid bound), screening to the top names under a cardinality limit,
a boxed round-and-repair MILP, certification against the bound with escalation
to the integer row generation otherwise. Solver choice by size (HiGHS below
16 000 rows, cuOpt above).

Request option `strategy` ('auto' | 'milp' | 'large_scale'; auto from 200
candidates or 32 768 scenarios) in the service, the API schema and both CLIs.
Settings from the benchmark: cuOpt presolve off (its asynchronous GPU heuristic
otherwise holds every MILP for 5-25 s), dual simplex for pure LPs (concurrent
returns PDLP's inexact point on the L40S), 50 ms polling. rowgen takes an
initial working set.

Measured with HiGHS (M4 Max): N=300 S=4096 5.2 s -> 0.7 s, N=300 S=16384
45 s -> 2.9 s, N=750 S=16384 15.6 s -> 7.4 s, K=40 of 750 24.6 s -> 7.3 s,
objectives within the 1e-4 gap. Tests in tests/test_large_scale.py; benchmark
script scripts/experiments/large_scale.py; docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 09:58:32 +01:00

300 lines
13 KiB
Python

"""Command-line interface: ``quantcore <command>``.
quantcore setup [--offline] [--max-per-region N] build the universe once (candidates,
metadata, full price + FX history)
quantcore reselect re-run only the selection step
quantcore clean re-apply price cleaning to stored data
quantcore update [--full] fetch the newest prices / FX
quantcore snapshot [--name NAME] freeze the data for reproducible demos
quantcore status what the store holds, cuOpt health
quantcore risk SYMBOL [SYMBOL ...] [--horizon week] [--freq daily] [--window 200] [--lam 0.94]
quantcore performance SYMBOL [SYMBOL ...] [--freq weekly]
quantcore optimize [--cash 1e6] [--cvar-limit 50000] [--confidence 0.95] [--horizon week] ...
quantcore serve [--host 127.0.0.1] [--port 5001] run the HTTP API (+ the test bench at /ui/)
These commands run the services in this process and need the data directory locally. To drive a
*running* service over HTTP instead -- from any machine, without the data -- use ``quantcore-api``
(:mod:`quantcore.apicli`), whose ``selftest`` also checks a deployment endpoint by endpoint.
The commands that write the data (setup, reselect, update, clean, snapshot) take the store's write
lock, so they never run at the same time as each other or as a data job of the API; one that finds
the lock taken exits with status 3 (on Kubernetes the CronJob retries it).
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
import pandas as pd
from quantcore.config import Settings, get_settings
log = logging.getLogger("quantcore.cli")
def _settings(args) -> Settings:
s = get_settings()
if getattr(args, "data_dir", None):
s = s.model_copy(update={"data_dir": args.data_dir})
if getattr(args, "cuopt_url", None):
s = s.model_copy(update={"cuopt_url": args.cuopt_url})
return s
def _write_lock(s: Settings, command: str):
from quantcore.data.store import DataStore
return DataStore(s).write_lock(f"quantcore {command}")
def cmd_setup(args) -> int:
from quantcore.universe.setup_service import SetupService
s = _settings(args)
with _write_lock(s, "setup"):
report = SetupService(s).run(allow_web=not args.offline, max_per_region=args.max_per_region)
print(json.dumps(report.as_dict(), indent=2, default=str))
return 0
def cmd_reselect(args) -> int:
from quantcore.universe.setup_service import SetupService
s = _settings(args)
with _write_lock(s, "reselect"):
report = SetupService(s).rebuild_selection()
print(json.dumps(report.as_dict(), indent=2, default=str))
return 0
def cmd_update(args) -> int:
from quantcore.data.download_service import DataDownloadService
s = _settings(args)
with _write_lock(s, "update"):
report = DataDownloadService(s).update(full=args.full)
print(json.dumps(report.as_dict(), indent=2, default=str))
return 0
def cmd_clean(args) -> int:
from quantcore.data.download_service import DataDownloadService
s = _settings(args)
with _write_lock(s, "clean"):
report = DataDownloadService(s).clean()
print(json.dumps(report.as_dict(), indent=2, default=str))
return 0
def _rounded(table: pd.DataFrame, digits: int) -> pd.DataFrame:
"""Round the numeric columns only: pandas warns when asked to round the date columns too."""
out = table.copy()
numeric = out.select_dtypes("number").columns
out[numeric] = out[numeric].round(digits)
return out
def cmd_snapshot(args) -> int:
from quantcore.data.store import DataStore
s = _settings(args)
with _write_lock(s, "snapshot"):
path = DataStore(s).create_snapshot(args.name)
print(path)
return 0
def cmd_status(args) -> int:
from quantcore.data.store import DataStore
from quantcore.optimization.solvers import CuOptSolver
s = _settings(args)
out = {"store": DataStore(s).status(), "cuopt": CuOptSolver(s).health(), "cuopt_url": s.cuopt_url}
print(json.dumps(out, indent=2, default=str))
return 0
def cmd_risk(args) -> int:
from quantcore.data.market_data import MarketDataService
from quantcore.measurement.risk import RiskService
md = MarketDataService(_settings(args), snapshot=args.snapshot)
table = RiskService(md).asset_risk(args.symbols, freq=args.freq, window=args.window, lam=args.lam,
horizon=args.horizon, currency=args.currency)
with pd.option_context("display.width", 200, "display.max_columns", 30):
print(_rounded(table, 5).T)
return 0
def cmd_performance(args) -> int:
from quantcore.data.market_data import MarketDataService
from quantcore.measurement.performance import PerformanceService
s = _settings(args)
md = MarketDataService(s, snapshot=args.snapshot)
table = PerformanceService(md, s.risk_free_rate).asset_performance(args.symbols, freq=args.freq,
currency=args.currency, start=args.start)
with pd.option_context("display.width", 200, "display.max_columns", 30):
print(_rounded(table, 4).T)
return 0
def cmd_optimize(args) -> int:
from quantcore.data.market_data import MarketDataService
from quantcore.optimization.service import OptimizationRequest, OptimizationService
s = _settings(args)
md = MarketDataService(s, snapshot=args.snapshot)
kwargs = {}
if args.request:
kwargs = json.loads(open(args.request).read())
kwargs.setdefault("cash", args.cash)
kwargs.setdefault("horizon", args.horizon)
kwargs.setdefault("confidence", args.confidence)
if args.cvar_limit is not None:
kwargs["cvar_limit"] = args.cvar_limit
if args.symbols:
kwargs["symbols"] = args.symbols
kwargs.setdefault("max_assets", args.max_assets)
kwargs.setdefault("scenario_model", args.scenario_model)
if args.n_scenarios is not None:
kwargs.setdefault("n_scenarios", args.n_scenarios)
kwargs.setdefault("sampling", args.sampling)
kwargs.setdefault("importance_sampling", args.importance_sampling)
kwargs.setdefault("row_generation", args.row_generation)
kwargs.setdefault("strategy", args.strategy)
kwargs.setdefault("solver", args.solver)
if args.holdings:
kwargs["holdings"] = json.loads(args.holdings)
kwargs["mode"] = "rebalance"
req = OptimizationRequest(**kwargs)
res = OptimizationService(s, md).optimize(req)
if args.json:
print(json.dumps(res.as_dict(), indent=2, default=str))
else:
print(f"status={res.status} solver={res.solver} reproducible={res.reproducibility} scenarios={res.scenario_hash} "
f"key={res.scenario_key['id'] if res.scenario_key else None} "
f"wealth={res.wealth:,.0f} invested={res.invested:,.0f} cash_end={res.cash_end:,.0f} cost={res.total_cost:,.0f}")
print(f"E[P&L]={res.expected_pnl:,.0f} ({res.expected_return:.3%}) VaR={res.var:,.0f} CVaR={res.cvar:,.0f} "
f"limit={res.cvar_limit} @ {res.confidence:.0%} over {res.horizon} ({res.horizon_periods} periods)")
if res.positions:
df = pd.DataFrame(res.positions)[["symbol", "name", "sector", "price", "shares", "trade", "value", "weight"]]
with pd.option_context("display.width", 200, "display.max_rows", 500):
print(df.round(3).to_string(index=False))
print("sectors:", {k: round(v, 3) for k, v in res.sector_exposure.items()})
print("timings:", res.timings, "warnings:", res.warnings)
return 0 if res.status in ("Optimal", "FeasibleFound") else 2
def cmd_serve(args) -> int:
import uvicorn
s = _settings(args)
uvicorn.run("quantcore.api.app:app", host=args.host or s.api_host, port=args.port or s.api_port, reload=False)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="quantcore", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--data-dir", type=Path, help="override QUANTCORE_DATA_DIR")
p.add_argument("--cuopt-url", help="override QUANTCORE_CUOPT_URL")
sub = p.add_subparsers(dest="command", required=True)
sp = sub.add_parser("setup", help="build the universe and download all history (run once)")
sp.add_argument("--offline", action="store_true", help="use the static candidate lists shipped with the package")
sp.add_argument("--max-per-region", type=int, default=None, help="limit candidates per region (testing)")
sp.set_defaults(func=cmd_setup)
sub.add_parser("reselect", help="re-run only the universe selection on stored data").set_defaults(func=cmd_reselect)
up = sub.add_parser("update", help="fetch the newest prices and FX rates")
up.add_argument("--full", action="store_true", help="re-download the complete history")
up.set_defaults(func=cmd_update)
sub.add_parser("clean", help="re-apply the price cleaning to the stored history").set_defaults(func=cmd_clean)
sn = sub.add_parser("snapshot", help="freeze universe/prices/fx under snapshots/<name>")
sn.add_argument("--name", default=None)
sn.set_defaults(func=cmd_snapshot)
sub.add_parser("status", help="store status and cuOpt health").set_defaults(func=cmd_status)
rk = sub.add_parser("risk", help="per-asset volatility, VaR and CVaR")
rk.add_argument("symbols", nargs="+")
rk.add_argument("--freq", default="daily", choices=["daily", "weekly", "monthly"])
rk.add_argument("--horizon", default="day")
rk.add_argument("--window", type=int, default=200)
rk.add_argument("--lam", type=float, default=0.94)
rk.add_argument("--currency", default="base", choices=["base", "local"])
rk.add_argument("--snapshot", default=None)
rk.set_defaults(func=cmd_risk)
pf = sub.add_parser("performance", help="per-asset performance statistics")
pf.add_argument("symbols", nargs="+")
pf.add_argument("--freq", default="daily", choices=["daily", "weekly", "monthly"])
pf.add_argument("--currency", default="base", choices=["base", "local"])
pf.add_argument("--start", default=None)
pf.add_argument("--snapshot", default=None)
pf.set_defaults(func=cmd_performance)
op = sub.add_parser("optimize", help="mean-CVaR MILP optimisation")
op.add_argument("--request", help="JSON file with an OptimizationRequest (fields override the flags)")
op.add_argument("--cash", type=float, default=1_000_000.0)
op.add_argument("--holdings", help='JSON dict {"NESN.SW": 100, ...} -> rebalancing mode')
op.add_argument("--symbols", nargs="*", default=None)
op.add_argument("--max-assets", type=int, default=100)
op.add_argument("--horizon", default="week")
op.add_argument("--confidence", type=float, default=0.95)
op.add_argument("--cvar-limit", type=float, default=None, help="max CVaR in CHF over the horizon")
op.add_argument("--scenario-model", default="empirical", choices=["gbm", "empirical", "gaussian_exact"])
op.add_argument("--n-scenarios", type=int, default=None,
help="default: 16 per candidate within [4096, 16384]; rounded up to a power of two")
op.add_argument("--sampling", default="sobol", choices=["sobol", "mc", "antithetic", "lhs", "balanced"])
from quantcore.optimization.service import IMPORTANCE_SAMPLING_MODES # lazy, like every command's
op.add_argument("--importance-sampling", default="auto",
choices=list(IMPORTANCE_SAMPLING_MODES),
help="'market': tilt towards the tail of the cap-weighted portfolio; "
"'adaptive': towards a first-pass solution")
op.add_argument("--row-generation", default="auto", choices=["auto", "on", "off"])
op.add_argument("--strategy", default="auto", choices=["auto", "milp", "large_scale"],
help="large_scale: relaxation, tail rows, screening, round-and-repair (large universes)")
op.add_argument("--solver", default="auto", choices=["auto", "cuopt", "highs"])
op.add_argument("--snapshot", default=None)
op.add_argument("--json", action="store_true", help="print the full result as JSON")
op.set_defaults(func=cmd_optimize)
sv = sub.add_parser("serve", help="run the HTTP API and the browser test bench (/ui/)")
sv.add_argument("--host", default=None)
sv.add_argument("--port", type=int, default=None)
sv.set_defaults(func=cmd_serve)
return p
def main(argv: list[str] | None = None) -> int:
from quantcore.data.store import StoreBusyError
args = build_parser().parse_args(argv)
try:
return args.func(args)
except StoreBusyError as exc:
print(f"error: {exc}.\n Wait for it to finish (quantcore-api jobs lists the API's jobs).", file=sys.stderr)
return 3
except FileNotFoundError as exc:
if "run the setup service first" not in str(exc):
raise
# no traceback for the commonest first-day mistake: a command before the data exists
print(f"error: no market data yet ({str(exc).split(';')[0]}).\n"
" Build it first: quantcore setup (on a DGX Spark: qc setup --offline),\n"
" or import a prepared data set: qc data-import FILE", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())