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>
133 lines
5.6 KiB
Python
133 lines
5.6 KiB
Python
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from quantcore.data.cleaning import (
|
|
CleaningReport,
|
|
clean_fx,
|
|
clean_prices,
|
|
find_spike_blocks,
|
|
fx_scale_exponent,
|
|
longest_missing_run,
|
|
)
|
|
|
|
|
|
def test_spike_blocks_and_genuine_moves():
|
|
idx = pd.bdate_range("2024-01-01", periods=40)
|
|
p = pd.Series(100.0, index=idx)
|
|
p.iloc[5] = 200.0 # single wrong tick (2x, reverses next day)
|
|
p.iloc[10:12] = 210.0 # two-day block
|
|
p.iloc[20:] = 40.0 # genuine crash that stays
|
|
p.iloc[30:33] = 45.0 # ordinary small moves
|
|
assert find_spike_blocks(p) == [5, 10, 11]
|
|
|
|
|
|
def test_clean_prices_report_and_gaps():
|
|
idx = pd.bdate_range("2024-01-01", periods=40)
|
|
a = pd.Series(np.linspace(50, 60, 40), index=idx)
|
|
b = a.copy()
|
|
b.iloc[3:8] = -1.0 # broken adjustment stretch
|
|
b.iloc[25] = 300.0 # spike
|
|
from quantcore.data.cleaning import CleaningReport
|
|
|
|
rep = CleaningReport()
|
|
out = clean_prices(pd.DataFrame({"A": a, "B": b}), report=rep)
|
|
assert rep.n_nonpositive == 5 and rep.symbols_nonpositive == {"B": 5}
|
|
assert rep.n_spikes == 1 and rep.symbols_spikes == {"B": 1}
|
|
assert out["A"].notna().all()
|
|
assert out["B"].isna().sum() == 6
|
|
runs = longest_missing_run(out)
|
|
assert runs["A"] == 0 and runs["B"] == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# FX quoting-unit changes (the NOK/CHF artefact: 96 observations returned 100x too small)
|
|
# ---------------------------------------------------------------------------------------------
|
|
def _fx_series(n=500, seed=0):
|
|
rng = np.random.default_rng(seed)
|
|
idx = pd.bdate_range("2016-01-01", periods=n)
|
|
return pd.Series(0.12 * np.exp(np.cumsum(rng.standard_normal(n) * 0.004)), index=idx)
|
|
|
|
|
|
def test_a_quoting_unit_change_is_undone_wherever_it_sits():
|
|
"""A stretch quoted 100x too small is repaired, whatever part of the series it covers."""
|
|
good = _fx_series()
|
|
for lo, hi in ((70, 140), (0, 60), (440, 500)): # middle, start, end
|
|
broken = good.copy()
|
|
broken.iloc[lo:hi] /= 100.0
|
|
fixed = clean_fx(broken.to_frame("NOK"))["NOK"]
|
|
assert np.allclose(fixed.values, good.values), (lo, hi)
|
|
# the artefact is what made the volatility absurd; the repair takes it back
|
|
vol = lambda x: float(np.log(x).diff().std() * np.sqrt(252))
|
|
assert vol(broken) > 2.0 and vol(fixed) == pytest.approx(vol(good), rel=1e-9)
|
|
|
|
|
|
def test_several_unit_changes_and_other_powers_of_ten():
|
|
good = _fx_series()
|
|
broken = good.copy()
|
|
broken.iloc[30:60] /= 100.0
|
|
broken.iloc[200:210] *= 10.0
|
|
broken.iloc[300:301] /= 1000.0
|
|
fixed = clean_fx(broken.to_frame("NOK"))["NOK"]
|
|
assert np.allclose(fixed.values, good.values)
|
|
assert set(fx_scale_exponent(broken).unique()) == {0, -2, 1, -3}
|
|
|
|
|
|
def test_genuine_moves_are_never_rescaled():
|
|
"""A devaluation is a price move, not a unit change: nothing short of a factor of ten is touched.
|
|
|
|
The 6.1x case is real: ABVX.PA gained 510 % on its trial results (2025-07-23). A loose tolerance
|
|
reads that as a decimal shift and rewrites the year of history that follows it.
|
|
"""
|
|
good = _fx_series()
|
|
for factor in (0.75, 1.4, 0.5, 2.0, 3.0, 6.101, 6.421, 1 / 6.101, 8.0):
|
|
moved = good.copy()
|
|
moved.iloc[250:] *= factor
|
|
fixed = clean_fx(moved.to_frame("TRY"))["TRY"]
|
|
assert np.allclose(fixed.values, moved.values), factor
|
|
|
|
|
|
def test_clean_fx_is_idempotent_and_reports_per_currency():
|
|
good = _fx_series()
|
|
frame = pd.DataFrame({"CHF": 1.0, "EUR": good * 8, "NOK": good})
|
|
frame.iloc[100:180, frame.columns.get_loc("NOK")] /= 100.0
|
|
report = CleaningReport()
|
|
fixed = clean_fx(frame, report=report)
|
|
assert report.n_fx_rescaled == 80 and report.fx_rescaled == {"NOK": 80}
|
|
assert np.allclose(fixed["NOK"].values, good.values)
|
|
assert fixed["EUR"].equals(frame["EUR"]) and fixed["CHF"].equals(frame["CHF"])
|
|
again = CleaningReport()
|
|
assert clean_fx(fixed, report=again).equals(fixed) and again.n_fx_rescaled == 0
|
|
|
|
|
|
def test_a_short_series_and_missing_values_are_handled():
|
|
idx = pd.bdate_range("2024-01-01", periods=6)
|
|
assert fx_scale_exponent(pd.Series([0.1], index=idx[:1])).empty is False
|
|
gappy = pd.Series([0.1, np.nan, 0.001, 0.001, np.nan, 0.1], index=idx)
|
|
fixed = clean_fx(gappy.to_frame("NOK"))["NOK"]
|
|
assert fixed.dropna().round(6).tolist() == [0.1, 0.1, 0.1, 0.1]
|
|
assert fixed.isna().tolist() == [False, True, False, False, True, False]
|
|
|
|
|
|
def test_fx_spikes_are_removed_but_a_real_regime_break_is_kept():
|
|
"""A reversing tick is a quote error; 2015-01-16 (the SNB floor) is not, and must survive."""
|
|
good = _fx_series(n=400)
|
|
broken = good.copy()
|
|
broken.iloc[120] *= 1.09 # one-day spike, reverses next day
|
|
broken.iloc[250:] *= 0.81 # a permanent 19 % repricing: never reversed
|
|
fixed = clean_fx(broken.to_frame("EUR"))["EUR"]
|
|
assert np.isnan(fixed.iloc[120]) # the tick is gone
|
|
assert fixed.iloc[249] == broken.iloc[249] and fixed.iloc[250] == broken.iloc[250]
|
|
assert fixed.drop(fixed.index[120]).equals(broken.drop(broken.index[120]))
|
|
|
|
|
|
def test_the_unit_repair_runs_before_the_spike_pass():
|
|
"""A 100x stretch must be rescaled, not mistaken for two enormous spikes at its edges."""
|
|
good = _fx_series(n=300)
|
|
broken = good.copy()
|
|
broken.iloc[100:160] /= 100.0
|
|
report = CleaningReport()
|
|
fixed = clean_fx(broken.to_frame("NOK"), report=report)["NOK"]
|
|
assert report.n_fx_rescaled == 60 and report.n_fx_spikes == 0
|
|
assert np.allclose(fixed.values, good.values)
|