herzogflorian 54721801ba One data folder for API and CLI; README blocks safe to paste
On a student Spark the API answered with 746 stocks while `quantcore
risk` found no data, and after a restart the test bench answered 503
"no market data yet". Cause: `install.sh --rebuild-data` renamed the data
folder; a container that still has a folder mounted keeps using it under
its new name, and the data jobs ran inside the API -- so the rebuilt data
went into ~/quantcore/data.<date> while the commands read the new, empty
~/quantcore/data. Pasting the README's command block made it visible: it
contained `qc stop / qc start`, which stopped the service.

- install.sh --rebuild-data stops the service, checks that it stopped,
  and moves the folder's contents, never the folder itself
- data jobs (setup, update) always run in a fresh container on the data
  folder; a running API is restarted afterwards (stopped during setup);
  `qc data-import` restarts it too; stop waits and forces if needed
- `qc status` warns when the API serves a different data set than the
  folder, and lists earlier data sets with the command to restore one;
  `qc start` does the same when the folder is empty
- the CLI prints three lines instead of a traceback when there is no data
- README and guide: command blocks only read, so they can be pasted whole;
  start, stop, update, upgrade and --rebuild-data are in tables
- lookups that may find nothing are guarded under pipefail (one ended
  `qc status` silently)

Tested on a stripped-down machine: the README block pasted whole keeps the
service running; `quantcore update` restarts the API and status agrees;
the broken state is detected and restored with `qc data-import`;
`install.sh --rebuild-data` with the service running rebuilt 745 stocks,
and API, CLI and folder agree afterwards. 142 tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:06:59 +02:00

271 lines
12 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 _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
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(_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("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)
try:
return args.func(args)
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())