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>
254 lines
11 KiB
Python
254 lines
11 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
|
|
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 cmd_setup(args) -> int:
|
|
from quantcore.universe.setup_service import SetupService
|
|
|
|
report = SetupService(_settings(args)).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
|
|
|
|
report = SetupService(_settings(args)).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
|
|
|
|
report = DataDownloadService(_settings(args)).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
|
|
|
|
report = DataDownloadService(_settings(args)).clean()
|
|
print(json.dumps(report.as_dict(), indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
def cmd_snapshot(args) -> int:
|
|
from quantcore.data.store import DataStore
|
|
|
|
path = DataStore(_settings(args)).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(table.round(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(table.round(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("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", 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("--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:
|
|
args = build_parser().parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|