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>
126 lines
6.1 KiB
Python
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
|