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

126 lines
6.1 KiB
Python

"""Central configuration (environment variables with the ``QUANTCORE_`` prefix, or a ``.env`` file).
Every service receives a :class:`Settings` instance -- nothing reads the environment on its own,
which keeps the services testable (tests pass a ``Settings(data_dir=tmp_path)``).
"""
from __future__ import annotations
import logging
from functools import lru_cache
from pathlib import Path
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime settings of the quant core."""
model_config = SettingsConfigDict(env_prefix="QUANTCORE_", env_file=".env", extra="ignore")
# --- storage ---------------------------------------------------------------------------
data_dir: Path = Field(default=Path("data"), description="Root of all persisted data.")
# --- universe --------------------------------------------------------------------------
n_us: int = Field(default=500, description="Largest N US stocks (S&P 500 candidates).")
n_europe: int = Field(default=500, description="Largest N European stocks (STOXX 600 candidates).")
history_start: str = Field(default="2004-12-01", description="First date requested from Yahoo.")
required_history_from: str = Field(
default="2005-01-31",
description="A stock is kept only if it has a price on or before this date ('data since 2005').",
)
min_price_coverage: float = Field(
default=0.90, description="Minimum share of union-calendar days with a price since the first date."
)
max_missing_run: int = Field(
default=20, description="Longest tolerated gap (business days) in a price series since the required date."
)
# --- market conventions ----------------------------------------------------------------
base_currency: str = Field(default="CHF", description="Portfolio / reporting currency.")
home_market: str = Field(default="Switzerland", description="Home market of the investor.")
risk_free_rate: float = Field(default=0.0, description="Annual risk-free rate in the base currency.")
trading_days_per_year: int = 252
weeks_per_year: int = 52
# --- Yahoo access ----------------------------------------------------------------------
yahoo_batch_size: int = Field(default=100, description="Tickers per bulk download request.")
yahoo_timeout: float = Field(default=30.0, description="Seconds per HTTP request.")
yahoo_max_retries: int = 4
yahoo_backoff: float = Field(default=2.0, description="Base of the exponential back-off (seconds).")
yahoo_info_pause: float = Field(default=0.15, description="Pause between ticker-info requests (s).")
# --- cuOpt -----------------------------------------------------------------------------
cuopt_url: str = Field(
default="http://127.0.0.1:5000",
description="cuOpt REST endpoint. The default is a cuOpt container on the same machine (the "
"standalone DGX Spark setup, deploy/apptainer/); point it elsewhere for a remote one.")
cuopt_time_limit: float = Field(default=120.0, description="MILP time limit in seconds.")
cuopt_poll_interval: float = 1.0
cuopt_timeout: float = Field(default=900.0, description="Max seconds to wait for a solution.")
cuopt_request_timeout: float = Field(default=60.0, description="Seconds per HTTP call to cuOpt.")
cuopt_upload_seconds_per_mb: float = Field(
default=1.0, description="Added to the submit timeout per MB of request body. cuOpt accepts about "
"10 MB/s, so this is a 10x margin; a client-side timeout during the upload "
"would otherwise surface as an aborted job.")
solver_fallback: bool = Field(
default=True, description="Fall back to the local HiGHS MILP solver (scipy) when cuOpt is unreachable."
)
cuopt_threads: int | None = Field(default=None, description="CPU threads of the cuOpt MIP solver (None: server default).")
cuopt_msgpack: bool = Field(default=True, description="Send cuOpt requests as msgpack when the package is available.")
# --- MILP tolerances (reproducibility) ---------------------------------------------------
mip_rel_gap: float = Field(default=1e-4, description="Relative MIP gap for HiGHS and cuOpt.")
mip_abs_gap: float = Field(default=0.01, description="Absolute MIP gap in money units (cuOpt).")
# --- misc ------------------------------------------------------------------------------
log_level: str = "INFO"
api_host: str = Field(
default="127.0.0.1",
description="Interface `quantcore serve` binds to: localhost only by default; the Docker images "
"pass 0.0.0.0 to publish the port.")
api_port: int = 5001
enable_ui: bool = Field(
default=True,
description="Serve the static test bench at /ui/ (and redirect / to it). Set false for a "
"deployment that only exposes the JSON API and /docs -- the same image then "
"serves a front end built by someone else.",
)
# derived paths -------------------------------------------------------------------------
@property
def universe_dir(self) -> Path:
return self.data_dir / "universe"
@property
def prices_dir(self) -> Path:
return self.data_dir / "prices"
@property
def fx_dir(self) -> Path:
return self.data_dir / "fx"
@property
def snapshots_dir(self) -> Path:
return self.data_dir / "snapshots"
@property
def jobs_dir(self) -> Path:
return self.data_dir / "jobs"
def ensure_dirs(self) -> None:
for d in (self.universe_dir, self.prices_dir, self.fx_dir, self.snapshots_dir, self.jobs_dir):
d.mkdir(parents=True, exist_ok=True)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Process-wide settings (cached). Tests construct :class:`Settings` directly instead."""
settings = Settings()
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
return settings