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

82 lines
4.6 KiB
Python

import numpy as np
import pandas as pd
import pytest
from quantcore.data.download_service import splice_adjusted
from quantcore.data.fx import FXConverter
from quantcore.data.yahoo import apply_price_scale, normalise_info
from quantcore.universe.sources import parse_ishares_holdings
from quantcore.universe.symbols import normalise_share_class_name, to_yahoo_symbol
def test_fx_conversion_and_log_return_identity():
idx = pd.bdate_range("2024-01-01", periods=5)
fx = pd.DataFrame({"USD": [0.85, 0.86, 0.84, 0.85, 0.87], "CHF": 1.0}, index=idx)
conv = FXConverter(fx, base="CHF")
px = pd.DataFrame({"A": [10.0, 11.0, 10.5, 10.8, 11.2], "B": [100.0, 101.0, 99.0, 98.0, 100.0]}, index=idx)
cur = pd.Series({"A": "USD", "B": "CHF"})
chf = conv.convert_prices(px, cur)
np.testing.assert_allclose(chf["A"], px["A"] * fx["USD"])
np.testing.assert_allclose(chf["B"], px["B"])
# CHF log-return = local log-return + FX log-return
r_chf = np.log(chf).diff().iloc[1:]
r_loc = np.log(px).diff().iloc[1:]
r_fx = conv.fx_log_returns(cur, idx).iloc[1:]
np.testing.assert_allclose(r_chf.values, (r_loc + r_fx).values)
with pytest.raises(KeyError):
conv.convert_prices(px, pd.Series({"A": "JPY", "B": "CHF"}))
def test_gbp_pence_normalisation():
info = normalise_info("HSBA.L", {"currency": "GBp", "marketCap": 1e11, "regularMarketPrice": 1533.8,
"firstTradeDateMilliseconds": 584089200000, "quoteType": "EQUITY"})
assert info["currency"] == "GBP" and info["price_scale"] == 100.0
assert info["last_price"] == pytest.approx(15.338)
assert info["first_trade_date"] == "1988-07-05"
px = pd.DataFrame({"HSBA.L": [1500.0, 1600.0], "NESN.SW": [80.0, 81.0]})
scaled = apply_price_scale(px, pd.Series({"HSBA.L": 100.0}))
assert scaled["HSBA.L"].iloc[0] == 15.0 and scaled["NESN.SW"].iloc[1] == 81.0
def test_splice_rescales_history_on_dividend():
idx = pd.bdate_range("2024-01-01", periods=10)
stored = pd.DataFrame({"A": np.linspace(100, 109, 10), "B": np.linspace(50, 59, 10)}, index=idx)
fresh_idx = pd.bdate_range("2024-01-08", periods=7) # overlaps the last 5 stored days, adds 2 new
fresh = pd.DataFrame({"A": np.concatenate([stored["A"].iloc[5:].values * 0.98, [110 * 0.98, 111 * 0.98]]),
"B": np.concatenate([stored["B"].iloc[5:].values, [60.0, 61.0]])}, index=fresh_idx)
out, rescaled = splice_adjusted(stored, fresh)
assert rescaled == ["A"]
assert len(out) == 12
np.testing.assert_allclose(out["A"].iloc[:5], stored["A"].iloc[:5] * 0.98) # history rescaled
np.testing.assert_allclose(out["A"].iloc[5:], fresh["A"]) # fresh window authoritative
np.testing.assert_allclose(out["B"], np.concatenate([stored["B"].values, [60.0, 61.0]]))
def test_symbol_mapping_rules():
assert to_yahoo_symbol("NOVO B", "Omx Nordic Exchange Copenhagen A/S") == "NOVO-B.CO"
assert to_yahoo_symbol("BT.A", "London Stock Exchange") == "BT-A.L"
assert to_yahoo_symbol("RR.", "London Stock Exchange") == "RR.L"
assert to_yahoo_symbol("NESN", "SIX Swiss Exchange") == "NESN.SW"
assert to_yahoo_symbol("BRK.B", country="United States") == "BRK-B"
assert to_yahoo_symbol("NDA FI", "Nasdaq Omx Helsinki Ltd.") == "NDA-FI.HE"
with pytest.raises(ValueError):
to_yahoo_symbol("X", "Mars Exchange")
assert normalise_share_class_name("Alphabet Inc. Class A") == normalise_share_class_name("Alphabet Inc. Class C")
assert normalise_share_class_name("Investor AB ser. B") == normalise_share_class_name("Investor Class A")
def test_parse_ishares_holdings():
text = ('Fondsposition per,"18.Sept.2026"\n \nEmittententicker,Name,Sektor,Anlageklasse,Marktwert,'
'Gewichtung (%),Nominalwert,Nominale,Kurs,Standort,Börse,Marktwährung\n'
'"ASML","ASML HOLDING","IT","Aktien","438.829.188,00","4,23","438.829.188,00","303.478,00","1.446,00",'
'"Niederlande","Euronext Amsterdam","EUR"\n'
'"NOVO B","NOVO NORDISK CLASS B","Gesundheitsversorgung","Aktien","1,00","1,00","1,00","1,00","1,00",'
'"Dänemark","Omx Nordic Exchange Copenhagen A/S","DKK"\n'
'"XEUR","EUR CASH","Barmittel und/oder Derivate","Geldmarkt","1,00","0,10","1,00","1,00","1,00",'
'"Deutschland","-","EUR"\n')
df = parse_ishares_holdings(text)
assert list(df["symbol"]) == ["ASML.AS", "NOVO-B.CO"]
assert df.iloc[0]["gics_sector"] == "Information Technology" and df.iloc[0]["country"] == "Netherlands"
assert df.iloc[0]["index_weight"] == pytest.approx(4.23)
assert df.iloc[0]["source_date"] == "2026-09-18"