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

673 lines
35 KiB
Python

"""Command-line client of the quant-core API: ``quantcore-api <command>``.
``quantcore`` (the other entry point) runs the services *in this process* and needs the data
directory locally. ``quantcore-api`` talks to a *running* service over HTTP and needs only its URL,
so it works from any machine on the network and is the quickest way to see what the API returns --
and, with ``selftest``, to check that a deployment answers correctly on every endpoint.
quantcore-api --url http://spark-6d4a:5001 health
quantcore-api selftest # exercise every endpoint, pass/fail table
quantcore-api endpoints # the API surface, from its own OpenAPI schema
quantcore-api universe --region Europe --sector "Health Care"
quantcore-api prices NESN.SW AAPL --freq weekly --tail 5
quantcore-api risk NESN.SW AAPL --horizon week --drift zero
quantcore-api correlation NESN.SW NOVN.SW ROG.SW
quantcore-api expected-returns --max-assets 50 --view AAPL:0.6:0.4
quantcore-api scenarios NESN.SW AAPL --model gbm --n-scenarios 4096
quantcore-api optimize --cash 1000000 --cvar-limit 40000 --horizon week --risk-drift zero
quantcore-api optimize --request my_request.json --json
quantcore-api update --wait # start the data-refresh job and follow it
The base URL comes from ``--url``, else ``QUANTCORE_API_URL``, else ``http://127.0.0.1:5001``.
Every command takes ``--json`` to print the raw answer instead of a table; that is the form to pipe
into ``jq`` or to save as a fixture.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
import time
from typing import Any
from quantcore.client import QuantcoreClient, QuantcoreError, default_url
WIDE = {"display.width": 200, "display.max_columns": 40, "display.max_rows": 200}
# ------------------------------------------------------------------------------------------------
# output helpers
# ------------------------------------------------------------------------------------------------
def _json(obj: Any) -> int:
print(json.dumps(obj, indent=2, default=str))
return 0
def _records(rows: list[dict], transpose: bool = False, round_to: int = 4) -> int:
"""A list of JSON objects as a table; transposed when the rows are wide (risk tables)."""
import pandas as pd
if not rows:
print("(no rows)")
return 0
df = pd.DataFrame(rows)
if transpose:
df = df.set_index(df.columns[0]).T
with pd.option_context(*[x for kv in WIDE.items() for x in kv]):
print(df.round(round_to).to_string())
return 0
def _frame(body: dict, tail: int | None = None) -> int:
"""A ``{"index", "columns", "data"}`` answer as a table, optionally only its last rows."""
import pandas as pd
df = pd.DataFrame(body.get("data") or [], index=body.get("index") or [], columns=body.get("columns") or [])
head = {k: v for k, v in body.items() if k not in ("index", "columns", "data")}
if head:
print(" · ".join(f"{k}={v}" for k, v in head.items()))
print(f"{len(df)} rows x {len(df.columns)} columns" + (f", last {tail}" if tail and len(df) > tail else ""))
if tail:
df = df.tail(tail)
with pd.option_context(*[x for kv in WIDE.items() for x in kv]):
print(df.round(6).to_string())
return 0
def _matrix(body: dict, key: str) -> int:
inner = body.get(key, body)
return _frame(inner if isinstance(inner, dict) and "data" in inner else body)
def _client(args: argparse.Namespace) -> QuantcoreClient:
return QuantcoreClient(args.url, timeout=args.timeout)
def _views(specs: list[str] | None) -> list[dict]:
"""``--view SYMBOL:SENTIMENT[:CONFIDENCE[:STRENGTH]]`` -> the API's view objects.
``--view AAPL:0.6:0.4`` reads "positive news on Apple (sentiment 0.6), moderate confidence".
"""
views = []
for spec in specs or []:
parts = spec.split(":")
if len(parts) < 2:
raise SystemExit(f"--view needs SYMBOL:SENTIMENT[:CONFIDENCE[:STRENGTH]], got {spec!r}")
view: dict[str, Any] = {"symbol": parts[0], "sentiment": float(parts[1])}
if len(parts) > 2:
view["confidence"] = float(parts[2])
if len(parts) > 3:
view["strength"] = float(parts[3])
views.append(view)
return views
# ------------------------------------------------------------------------------------------------
# commands
# ------------------------------------------------------------------------------------------------
def cmd_health(args) -> int:
body = _client(args).health()
if args.json:
return _json(body)
store, cuopt = body.get("store") or {}, body.get("cuopt") or {}
print(f"{args.url or default_url()} version {body.get('version')} currency {body.get('base_currency')}")
print(f"store : {store.get('n_universe')} stocks in the universe, {store.get('n_symbols')} with prices, "
f"{store.get('n_days')} days, last {store.get('last_price_date')}")
print(f"cuOpt : {'reachable, ' + str(cuopt.get('version')) if cuopt.get('reachable') else 'NOT reachable'}"
f"{'' if cuopt.get('reachable') else ' - optimisations fall back to HiGHS on the CPU'}")
return 0
def cmd_endpoints(args) -> int:
schema = _client(args).openapi()
rows = [{"method": method.upper(), "path": path, "summary": op.get("summary") or op.get("operationId", ""),
"tag": (op.get("tags") or [""])[0]}
for path, ops in sorted((schema.get("paths") or {}).items())
for method, op in ops.items() if method in ("get", "post", "put", "delete")]
if args.json:
return _json(rows)
print(f"{schema.get('info', {}).get('title', 'quantcore')} -- {len(rows)} endpoints "
f"(interactive docs at {(args.url or default_url()).rstrip('/')}/docs)")
return _records(rows, round_to=0)
def cmd_universe(args) -> int:
rows = _client(args).universe(region=args.region, sector=args.sector, country=args.country)
if args.json:
return _json(rows)
keep = ["symbol", "name", "region", "country", "gics_sector", "listing_currency", "market_cap_base"]
rows = sorted(rows, key=lambda r: -(r.get("market_cap_base") or 0))[: args.limit]
print(f"{len(rows)} of the matching stocks, largest first")
return _records([{k: r.get(k) for k in keep} for r in rows], round_to=0)
def cmd_symbol(args) -> int:
return _json(_client(args).symbol(args.symbol))
def cmd_prices(args) -> int:
body = _client(args).prices(args.symbols, start=args.start, end=args.end, currency=args.currency,
freq=args.freq)
return _json(body) if args.json else _frame(body, args.tail)
def cmd_returns(args) -> int:
body = _client(args).returns(args.symbols, start=args.start, end=args.end, currency=args.currency,
freq=args.freq)
return _json(body) if args.json else _frame(body, args.tail)
def cmd_market(args) -> int:
qc = _client(args)
if args.what == "weights":
body = qc.market_weights(max_assets=args.max_assets)
if args.json:
return _json(body)
weights = body.get("weights") or {}
print(f"as of {body.get('as_of')}, {len(weights)} stocks, sum {sum(weights.values()):.6f}")
return _records([{"symbol": k, "weight": v} for k, v in list(weights.items())[: args.limit]], round_to=6)
body = qc.market_index(start=args.start, end=args.end, freq=args.freq)
if args.json:
return _json(body)
print(f"cap-weighted index, {body.get('freq')}: mean_ann {body.get('mean_ann'):.4%}, "
f"vol_ann {body.get('vol_ann'):.4%}, {body.get('n')} observations")
return 0
def cmd_risk(args) -> int:
rows = _client(args).asset_risk(args.symbols, horizon=args.horizon, freq=args.freq, window=args.window,
lam=args.lam, currency=args.currency, drift=args.drift, space=args.space,
confidence=args.confidence)
if args.json:
return _json(rows)
print(f"horizon {args.horizon} · {args.freq} · window {args.window} · lambda {args.lam} · "
f"drift {args.drift} · space {args.space}")
return _records(rows, transpose=True, round_to=6)
def cmd_risk_portfolio(args) -> int:
body = _client(args).portfolio_risk(json.loads(args.holdings), cash=args.cash, horizon=args.horizon,
method=args.method, confidence=args.confidence)
return _json(body)
def cmd_matrix(args) -> int:
qc = _client(args)
call = qc.correlation if args.command == "correlation" else qc.covariance
body = call(args.symbols, method=args.method, window=args.window, lam=args.lam, freq=args.freq)
return _json(body) if args.json else _frame(body)
def cmd_performance(args) -> int:
rows = _client(args).asset_performance(args.symbols, freq=args.freq, currency=args.currency, start=args.start)
return _json(rows) if args.json else _records(rows, transpose=True)
def cmd_expected_returns(args) -> int:
body = _client(args).expected_returns(max_assets=args.max_assets, symbols=args.symbols, tau=args.tau,
years=args.years, omega_method=args.omega_method,
views=_views(args.view))
if args.json:
return _json(body)
rows = body.get("expected_returns") or body.get("returns") or []
for k, v in body.items():
if isinstance(v, (int, float, str)):
print(f"{k:24} {v}")
if isinstance(rows, dict):
rows = [{"symbol": k, **(v if isinstance(v, dict) else {"value": v})} for k, v in rows.items()]
return _records(rows[: args.limit] if isinstance(rows, list) else [], round_to=6)
def cmd_model_covariance(args) -> int:
body = _client(args).model_covariance(args.symbols, freq=args.freq, horizon=args.horizon,
covariance=_cov_spec(args))
if args.json:
return _json(body)
print(f"model {body.get('model')} · as of {body.get('as_of')} · shrinkage applied "
f"{body.get('shrinkage_applied')}")
print("conditioning:", json.dumps(body.get("conditioning"), default=str))
return _matrix(body, "covariance")
def cmd_scenarios(args) -> int:
body = _client(args).scenarios(args.symbols, model=args.model, n_scenarios=args.n_scenarios,
horizon=args.horizon, freq=args.freq, seed=args.seed,
sampling=args.sampling, covariance=_cov_spec(args),
expected_returns=args.expected_returns,
importance_sampling=args.importance_sampling,
holdings=json.loads(args.holdings) if args.holdings else None,
value=args.value, confidence=args.confidence)
if args.json:
return _json(body)
desc, port = body.get("description") or {}, body.get("portfolio") or {}
print(f"{desc.get('model')} · {desc.get('n_scenarios')} scenarios x {desc.get('n_assets')} assets · "
f"horizon {desc.get('horizon_periods')} periods · seed {desc.get('seed')}")
print(f"hash {desc.get('hash')} · shocks {desc.get('shock_hash')} · weighted {desc.get('weighted')} · "
f"effective sample size {desc.get('effective_sample_size')}")
if port:
print(f"portfolio: invested {port.get('invested'):,.0f}")
for level, fig in sorted((port.get("risk") or {}).items()):
print(f" {float(level):.0%} VaR {fig.get('var'):>14,.2f} CVaR {fig.get('cvar'):>14,.2f}")
return _records(body.get("summary") or [], transpose=True, round_to=6)
def cmd_optimize(args) -> int:
body: dict[str, Any] = json.loads(pathlib.Path(args.request).read_text()) if args.request else {}
flags = {"cash": args.cash, "max_assets": args.max_assets, "horizon": args.horizon,
"confidence": args.confidence, "cvar_limit": args.cvar_limit, "objective": args.objective,
"scenario_model": args.scenario_model, "n_scenarios": args.n_scenarios, "seed": args.seed,
"solver": args.solver, "time_limit": args.time_limit, "risk_drift": args.risk_drift,
"expected_returns": args.expected_returns, "importance_sampling": args.importance_sampling,
"row_generation": args.row_generation, "freq": args.freq, "symbols": args.symbols}
for k, v in flags.items(): # the request file wins over a flag default
if v is not None and k not in body:
body[k] = v
if args.holdings:
body["holdings"] = json.loads(args.holdings)
body.setdefault("mode", "rebalance")
if args.view:
body["views"] = _views(args.view)
if args.max_weight is not None or args.max_sector_weight is not None:
constraints = dict(body.get("constraints") or {})
if args.max_weight is not None:
constraints["max_weight"] = args.max_weight
if args.max_sector_weight is not None:
constraints["max_sector_weight"] = args.max_sector_weight
body["constraints"] = constraints
t0 = time.monotonic()
res = _client(args).optimize(**body)
elapsed = time.monotonic() - t0
if args.json:
return _json(res)
print(f"status {res.get('status')} · solver {res.get('solver')} · reproducibility "
f"{res.get('reproducibility')} · {elapsed:.1f} s wall clock")
info = res.get("solver_info") or {}
if info.get("mip_gap") is not None:
print(f"mip gap {info['mip_gap']:.2e}" + (f" · {info.get('message')}" if info.get("message") else ""))
key = (res.get("scenario_key") or {}).get("id")
print(f"scenario key {key} · hash {res.get('scenario_hash')} · timings {res.get('timings')}")
print(f"wealth {res.get('wealth', 0):,.0f} · invested {res.get('invested', 0):,.0f} · "
f"cash left {res.get('cash_end', 0):,.0f} · cost {res.get('total_cost', 0):,.0f} · "
f"turnover {res.get('turnover', 0):,.0f}")
print(f"E[P&L] {res.get('expected_pnl', 0):,.0f} ({res.get('expected_return', 0):.3%}) · "
f"VaR {res.get('var', 0):,.0f} · CVaR {res.get('cvar', 0):,.0f} "
f"(limit {res.get('cvar_limit')}) at {res.get('confidence', 0):.0%} over {res.get('horizon')}")
for warning in res.get("warnings") or []:
print(f" ! {warning}")
positions = res.get("positions") or []
if positions and not args.quiet:
keep = [k for k in ("symbol", "name", "sector", "price", "shares", "trade", "value", "weight")
if k in positions[0]]
_records([{k: p.get(k) for k in keep} for p in positions], round_to=3)
print("sectors:", {k: round(v, 3) for k, v in (res.get("sector_exposure") or {}).items()})
return 0 if res.get("status") in ("Optimal", "FeasibleFound") else 2
def cmd_jobs(args) -> int:
qc = _client(args)
if args.job_id:
return _json(qc.job(args.job_id))
return _json(qc.jobs())
def cmd_setup(args) -> int:
return _start_job(args, _client(args).start_setup(offline=args.offline, max_per_region=args.max_per_region))
def cmd_update(args) -> int:
return _start_job(args, _client(args).start_update(full=args.full))
def _start_job(args, started: dict) -> int:
job_id = started.get("job_id") or started.get("id")
print(f"job {job_id} started ({started.get('status', 'queued')})")
if not args.wait:
print(f"follow it with: quantcore-api --url {args.url or default_url()} jobs --job-id {job_id}")
return 0
record = _client(args).wait_for_job(job_id, poll=args.poll,
on_poll=lambda r: print(f" ... {r.get('status')}", flush=True))
_json(record)
return 0 if record.get("status") == "done" else 2
def cmd_snapshots(args) -> int:
qc = _client(args)
if args.create:
return _json(qc.create_snapshot(args.name))
return _json(qc.snapshots())
def _cov_spec(args) -> dict:
return {"method": args.cov_method, "lam": args.lam, "window": args.window, "shrinkage": args.shrinkage}
# ------------------------------------------------------------------------------------------------
# selftest -- exercise every endpoint against a running service
# ------------------------------------------------------------------------------------------------
def cmd_selftest(args) -> int:
"""Call every endpoint with a small request and report what each one answered.
Read-only by default: the two background jobs and the snapshot endpoint only run with
``--write``, because they download data and write to the store. Exit code 1 if any probe failed.
"""
qc = _client(args)
results: list[dict] = []
def probe(name: str, call: Any, evidence: Any = None, skip: str = "") -> Any:
"""Run one endpoint. ``call()`` performs it, ``evidence(value)`` says what came back."""
if skip:
results.append({"endpoint": name, "result": "skipped", "ms": 0, "evidence": skip})
return None
t0 = time.monotonic()
try:
value = call()
note = evidence(value) if evidence else ""
except QuantcoreError as exc:
note, value, ok = str(exc)[:120], None, False
except Exception as exc: # noqa: BLE001 (a probe's own assertion, or a malformed answer)
note, value, ok = f"{type(exc).__name__}: {exc}"[:120], None, False
else:
ok = True
results.append({"endpoint": name, "result": "ok" if ok else "FAIL",
"ms": round((time.monotonic() - t0) * 1000), "evidence": note})
return value
def health() -> dict:
body = qc.health()
if not (body.get("store") or {}).get("prices"):
raise AssertionError("the store has no prices - run the setup job first")
return body
body = probe("GET /health", health, lambda b: (
f"{b['store'].get('n_universe')} stocks, last {b['store'].get('last_price_date')}, "
f"cuOpt {'up' if (b.get('cuopt') or {}).get('reachable') else 'down'}"))
if body is None:
_records(results, round_to=0)
print("\nthe service is not usable - stopping here")
return 1
rows = probe("GET /universe", qc.universe, lambda r: f"{len(r)} rows") or []
syms = args.symbols or [r["symbol"] for r in
sorted(rows, key=lambda r: -(r.get("market_cap_base") or 0))[: args.n_symbols]]
if not syms:
print("no symbols to probe with")
return 1
print(f"probing with {', '.join(syms)}\n")
holdings = {syms[0]: 10}
probe("GET /openapi.json", qc.openapi, lambda d: f"{len(d.get('paths') or {})} paths")
probe("GET /universe/{symbol}", lambda: qc.symbol(syms[0]),
lambda d: f"{d.get('symbol')}: {d.get('name')}")
probe("GET /data/symbols", lambda: qc.symbols_table(limit=10),
lambda d: f"{len(d.get('symbols') or [])} of {d.get('n_matched')} rows, "
f"facets {list((d.get('facets') or {}).keys())}")
probe("GET /prices", lambda: qc.prices(syms, start=args.start),
lambda d: f"{len(d.get('index') or [])} dates x {len(d.get('columns') or [])} symbols")
probe("GET /returns", lambda: qc.returns(syms, start=args.start),
lambda d: f"{d.get('kind')}, {len(d.get('index') or [])} dates")
probe("GET /market/index", qc.market_index,
lambda d: f"mean_ann {d.get('mean_ann'):.2%}, vol_ann {d.get('vol_ann'):.2%}")
probe("GET /market/weights", lambda: qc.market_weights(max_assets=20),
lambda d: f"{len(d.get('weights') or {})} weights, sum {sum((d.get('weights') or {}).values()):.4f}")
probe("POST /risk/assets", lambda: qc.asset_risk(syms, horizon="week"),
lambda r: f"{len(r)} rows, cvar95_hist {r[0].get('cvar95_hist'):.4f}")
probe("POST /risk/assets (drift=zero, space=value)",
lambda: qc.asset_risk(syms, horizon="week", drift="zero", space="value"),
lambda r: f"cvar95_normal_ewm {r[0].get('cvar95_normal_ewm'):.4f}")
probe("POST /risk/series", lambda: qc.risk_series(syms[0], horizon="week"),
lambda d: f"{len(d.get('index') or [])} dates")
probe("POST /risk/covariance", lambda: qc.covariance(syms),
lambda d: f"{len(d.get('columns') or [])} assets")
probe("POST /risk/correlation", lambda: qc.correlation(syms),
lambda d: f"diagonal {d['data'][0][0]:.3f}")
probe("POST /risk/portfolio", lambda: qc.portfolio_risk(holdings, cash=10_000, horizon="week"),
lambda d: f"value {d.get('value', 0):,.0f}, "
f"CVaR95 {((d.get('confidence') or {}).get('0.95') or {}).get('cvar_money', 0):,.0f}")
probe("POST /performance/assets", lambda: qc.asset_performance(syms),
lambda r: f"{len(r)} rows, sharpe {r[0].get('sharpe')}")
probe("POST /performance/rolling", lambda: qc.rolling_performance(syms[0], window=252),
lambda d: f"{len(d.get('index') or [])} dates")
probe("POST /performance/portfolio", lambda: qc.portfolio_performance(holdings),
lambda d: f"{len(d)} figures")
probe("POST /model/expected-returns",
lambda: qc.expected_returns(max_assets=args.n_symbols, symbols=args.symbols),
lambda d: f"market return {(d.get('market_return_ann') or 0):.2%}")
probe("POST /model/covariance", lambda: qc.model_covariance(syms),
lambda d: f"shrinkage {d.get('shrinkage_applied')}")
probe("POST /model/scenarios",
lambda: qc.scenarios(syms, model="gbm", n_scenarios=1024, holdings=holdings),
lambda d: f"{(d.get('description') or {}).get('n_scenarios')} scenarios, "
f"hash {(d.get('description') or {}).get('hash')}")
def optimise() -> dict:
body = {"cash": 1_000_000, "max_assets": max(len(syms), 12), "horizon": "week",
"symbols": args.symbols, "solver": args.solver, "time_limit": args.time_limit}
body.update({"scenario_model": "gbm", "n_scenarios": 4096, "cvar_limit_fraction": 0.05}
if args.full else {"scenario_model": "gaussian_exact"})
res = qc.optimize(**{k: v for k, v in body.items() if v is not None})
if res.get("status") not in ("Optimal", "FeasibleFound"):
raise AssertionError(f"status {res.get('status')}: {'; '.join(res.get('warnings') or [])}"[:120])
return res
probe(f"POST /optimize ({'gbm, 4096 scenarios' if args.full else 'gaussian_exact'})", optimise,
lambda r: f"{r.get('status')} on {r.get('solver')}, {r.get('n_positions')} positions, "
f"CVaR {r.get('cvar', 0):,.0f}")
probe("GET /jobs", qc.jobs, lambda r: f"{len(r)} jobs")
probe("GET /snapshots", qc.snapshots, lambda r: f"{len(r)} snapshots")
probe("POST /snapshots", lambda: qc.create_snapshot(f"selftest-{int(time.time())}"),
lambda d: str(d.get("path"))[:60],
skip="" if args.write else "writes to the store (--write)")
probe("POST /jobs/update", qc.start_update, lambda d: f"job {d.get('job_id')}",
skip="" if args.write else "downloads data (--write)")
probe("POST /jobs/setup", lambda: qc.start_setup(offline=True), lambda d: f"job {d.get('job_id')}",
skip="" if args.write else "rebuilds the universe (--write)")
if args.json:
return _json(results)
_records(results, round_to=0)
failed = [r for r in results if r["result"] == "FAIL"]
ok = [r for r in results if r["result"] == "ok"]
skipped = [r for r in results if r["result"] == "skipped"]
print(f"\n{len(ok)} ok, {len(failed)} failed, {len(skipped)} skipped "
f"in {sum(r['ms'] for r in results) / 1000:.1f} s against {qc.base_url}")
for r in failed:
print(f" FAIL {r['endpoint']}: {r['evidence']}")
return 1 if failed else 0
# ------------------------------------------------------------------------------------------------
# parser
# ------------------------------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="quantcore-api", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--url", default=None, help=f"API base URL (default: $QUANTCORE_API_URL or {default_url()})")
p.add_argument("--timeout", type=float, default=900.0, help="seconds per request")
p.add_argument("--json", action="store_true", help="print the raw JSON answer instead of a table")
sub = p.add_subparsers(dest="command", required=True)
# the same three options after the subcommand, where anyone would try them first
# ('quantcore-api optimize --json'). SUPPRESS keeps the value parsed before the subcommand
# unless the flag is repeated after it.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--url", default=argparse.SUPPRESS)
common.add_argument("--timeout", type=float, default=argparse.SUPPRESS)
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
def add(name: str, help_: str, func: Any) -> argparse.ArgumentParser:
sp = sub.add_parser(name, help=help_, parents=[common])
sp.set_defaults(func=func)
return sp
def symbols_arg(sp: argparse.ArgumentParser) -> argparse.ArgumentParser:
sp.add_argument("symbols", nargs="+", help="Yahoo symbols, e.g. NESN.SW AAPL ASML.AS")
return sp
def window_args(sp: argparse.ArgumentParser) -> argparse.ArgumentParser:
sp.add_argument("--window", type=int, default=200, help="moving-window length in periods")
sp.add_argument("--lam", type=float, default=0.94, help="EWM decay")
return sp
add("health", "store status, cuOpt reachability, version", cmd_health)
add("endpoints", "list every endpoint from the service's own OpenAPI schema", cmd_endpoints)
up = add("universe", "the investable universe (filter by region, sector, country)", cmd_universe)
up.add_argument("--region"), up.add_argument("--sector"), up.add_argument("--country")
up.add_argument("--limit", type=int, default=25)
sy = add("symbol", "one universe row", cmd_symbol)
sy.add_argument("symbol")
for name, func, what in (("prices", cmd_prices, "adjusted closes"),
("returns", cmd_returns, "log-returns")):
sp = symbols_arg(add(name, what, func))
sp.add_argument("--freq", default="daily", choices=["daily", "weekly", "monthly"])
sp.add_argument("--currency", default="base", choices=["base", "local"])
sp.add_argument("--start"), sp.add_argument("--end")
sp.add_argument("--tail", type=int, default=10, help="print only the last N rows (0: all)")
mk = add("market", "the cap-weighted broad market: 'index' or 'weights'", cmd_market)
mk.add_argument("what", nargs="?", default="index", choices=["index", "weights"])
mk.add_argument("--freq", default="weekly", choices=["daily", "weekly", "monthly"])
mk.add_argument("--start"), mk.add_argument("--end")
mk.add_argument("--max-assets", type=int, default=None), mk.add_argument("--limit", type=int, default=25)
rk = window_args(symbols_arg(add("risk", "per-stock volatility, VaR and CVaR", cmd_risk)))
rk.add_argument("--horizon", default="week", help="'day' | 'week' | 'month' | 'quarter' | 'year' | periods")
rk.add_argument("--freq", default="daily", choices=["daily", "weekly", "monthly"])
rk.add_argument("--currency", default="base", choices=["base", "local"])
rk.add_argument("--drift", default="estimated", choices=["estimated", "zero"],
help="'zero': volatility only, the market-risk convention")
rk.add_argument("--space", default="log", choices=["log", "value"],
help="'value': as a fraction of the position's value, which cannot exceed 100 %%")
rk.add_argument("--confidence", type=float, nargs="+", default=[0.95, 0.99])
rp = add("risk-portfolio", "VaR/CVaR of a portfolio held in share numbers", cmd_risk_portfolio)
rp.add_argument("--holdings", required=True, help='JSON: {"NESN.SW": 500, "AAPL": 100}')
rp.add_argument("--cash", type=float, default=0.0)
rp.add_argument("--horizon", default="week")
rp.add_argument("--method", default="normal", choices=["normal", "historical"])
rp.add_argument("--confidence", type=float, nargs="+", default=[0.95, 0.99])
for name in ("correlation", "covariance"):
sp = window_args(symbols_arg(add(name, f"{name} matrix as measured", cmd_matrix)))
sp.add_argument("--method", default="ewm", choices=["rolling", "ewm"])
sp.add_argument("--freq", default="daily", choices=["daily", "weekly", "monthly"])
pf = symbols_arg(add("performance", "annualised return, Sharpe, drawdown, skew, kurtosis", cmd_performance))
pf.add_argument("--freq", default="weekly", choices=["daily", "weekly", "monthly"])
pf.add_argument("--currency", default="base", choices=["base", "local"])
pf.add_argument("--start")
er = add("expected-returns", "Black-Litterman equilibrium and posterior returns", cmd_expected_returns)
er.add_argument("--symbols", nargs="*", default=None)
er.add_argument("--max-assets", type=int, default=100)
er.add_argument("--tau", type=float, default=0.05)
er.add_argument("--years", type=int, default=10)
er.add_argument("--omega-method", default="idzorek", choices=["idzorek", "he_litterman"])
er.add_argument("--view", action="append", metavar="SYM:SENTIMENT[:CONF[:STRENGTH]]",
help="a sentiment view; repeatable")
er.add_argument("--limit", type=int, default=25)
mc = window_args(symbols_arg(add("model-covariance", "the covariance the models use, with its "
"conditioning", cmd_model_covariance)))
mc.add_argument("--cov-method", default="ewm", choices=["rolling", "ewm"])
mc.add_argument("--shrinkage", default="auto")
mc.add_argument("--freq", default="daily", choices=["daily", "weekly"])
mc.add_argument("--horizon", default="week")
sc = window_args(symbols_arg(add("scenarios", "a scenario set and its statistics", cmd_scenarios)))
sc.add_argument("--model", default="gbm", choices=["gbm", "empirical"])
sc.add_argument("--n-scenarios", type=int, default=4096)
sc.add_argument("--horizon", default="week")
sc.add_argument("--freq", default="daily", choices=["daily", "weekly"])
sc.add_argument("--seed", type=int, default=42)
sc.add_argument("--sampling", default="sobol", choices=["sobol", "mc", "antithetic", "lhs", "balanced"])
sc.add_argument("--cov-method", default="ewm", choices=["rolling", "ewm"])
sc.add_argument("--shrinkage", default="auto")
sc.add_argument("--expected-returns", default="zero", choices=["zero", "historical", "black_litterman"])
sc.add_argument("--importance-sampling", default="auto", choices=["auto", "market", "adaptive", "off"])
sc.add_argument("--holdings", help='JSON: {"NESN.SW": 500}; else the set is reported per stock')
sc.add_argument("--value", type=float, default=1_000_000.0)
sc.add_argument("--confidence", type=float, nargs="+", default=[0.95, 0.99])
op = add("optimize", "mean-CVaR MILP in integer shares", cmd_optimize)
op.add_argument("--request", help="JSON file with the full request; its fields win over the flags")
op.add_argument("--cash", type=float, default=1_000_000.0)
op.add_argument("--holdings", help='JSON dict -> rebalancing mode')
op.add_argument("--symbols", nargs="*", default=None, help="candidates; default: largest by market cap")
op.add_argument("--max-assets", type=int, default=100)
op.add_argument("--horizon", default="week")
op.add_argument("--freq", default="daily", choices=["daily", "weekly"])
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("--objective", default="max_return", choices=["max_return", "min_cvar", "utility"])
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("--seed", type=int, default=42)
op.add_argument("--expected-returns", default="black_litterman",
choices=["black_litterman", "historical", "zero"])
op.add_argument("--risk-drift", default="model", choices=["model", "zero"],
help="'zero': measure the CVaR on centred scenarios, so a bullish forecast "
"cannot relax the risk limit")
op.add_argument("--importance-sampling", default="auto", choices=["auto", "market", "adaptive", "off"])
op.add_argument("--row-generation", default="auto", choices=["auto", "on", "off"])
op.add_argument("--max-weight", type=float, default=None, help="cap per stock (default 0.10)")
op.add_argument("--max-sector-weight", type=float, default=None, help="cap per GICS sector (default 0.25)")
op.add_argument("--view", action="append", metavar="SYM:SENTIMENT[:CONF[:STRENGTH]]", help="repeatable")
op.add_argument("--solver", default="auto", choices=["auto", "cuopt", "highs"])
op.add_argument("--time-limit", type=float, default=300.0, help="seconds for the whole request")
op.add_argument("--quiet", action="store_true", help="summary only, no position table")
jb = add("jobs", "list background jobs, or one with --job-id", cmd_jobs)
jb.add_argument("--job-id", default=None)
st = add("setup", "start the universe/history build (background job)", cmd_setup)
st.add_argument("--offline", action="store_true", help="use the candidate lists shipped in the package")
st.add_argument("--max-per-region", type=int, default=None)
st.add_argument("--wait", action="store_true"), st.add_argument("--poll", type=float, default=10.0)
ud = add("update", "start the price/FX refresh (background job)", cmd_update)
ud.add_argument("--full", action="store_true")
ud.add_argument("--wait", action="store_true"), ud.add_argument("--poll", type=float, default=5.0)
sn = add("snapshots", "list snapshots, or --create one", cmd_snapshots)
sn.add_argument("--create", action="store_true"), sn.add_argument("--name", default=None)
se = add("selftest", "call every endpoint and report what it answered", cmd_selftest)
se.add_argument("--full", action="store_true", help="optimise on a real scenario set, not gaussian_exact")
se.add_argument("--write", action="store_true", help="also probe the endpoints that write or download")
se.add_argument("--symbols", nargs="*", default=None, help="default: the largest few in the universe")
se.add_argument("--n-symbols", type=int, default=3)
se.add_argument("--start", default=None, help="first date for the price/return probes")
se.add_argument("--solver", default="auto", choices=["auto", "cuopt", "highs"])
se.add_argument("--time-limit", type=float, default=120.0)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
return args.func(args)
except QuantcoreError as exc:
print(f"error: {exc}", file=sys.stderr)
if exc.unreachable:
print("is the service running? start it with 'quantcore serve', or pass --url", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("interrupted", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())