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>
82 lines
4.6 KiB
Python
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"
|