Portfolio_Risk_Management/tests/test_cleaning.py
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

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)