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>
673 lines
35 KiB
Python
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())
|