""" Unit tests for the FX risk engine (multi-currency). These run fully offline on synthetic correlated return series — no network, no live rates — so the maths is validated deterministically. python3 -m unittest -v # or: python3 test_risk_engine.py """ import math import random import unittest import risk_engine as risk CCY = ["EUR", "SEK"] def make_returns(n=250, sigma_eur=0.004, sigma_sek=0.006, rho=0.8, seed=1): """Generate n days of correlated EUR/SEK log returns via Cholesky.""" rng = random.Random(seed) cov = [ [sigma_eur ** 2, rho * sigma_eur * sigma_sek], [rho * sigma_eur * sigma_sek, sigma_sek ** 2], ] L = risk.cholesky(cov) eur, sek = [], [] for _ in range(n): r = risk._matvec(L, [rng.gauss(0, 1), rng.gauss(0, 1)]) eur.append(r[0]); sek.append(r[1]) return {"EUR": eur, "SEK": sek} class TestLinearAlgebra(unittest.TestCase): def test_cholesky_reconstructs_matrix(self): M = [[4.0, 2.0], [2.0, 3.0]] L = risk.cholesky(M) recon = [[sum(L[i][k] * L[j][k] for k in range(2)) for j in range(2)] for i in range(2)] for i in range(2): for j in range(2): self.assertAlmostEqual(recon[i][j], M[i][j], places=10) def test_cholesky_lower_triangular(self): self.assertEqual(risk.cholesky([[4.0, 2.0], [2.0, 3.0]])[0][1], 0.0) def test_covariance_symmetric(self): cov = risk.covariance_matrix(make_returns(), CCY) self.assertAlmostEqual(cov[0][1], cov[1][0], places=12) def test_correlation_diagonal_and_bounds(self): corr = risk.correlation_matrix(risk.covariance_matrix(make_returns(), CCY)) self.assertAlmostEqual(corr[0][0], 1.0, places=9) self.assertTrue(-1.0 <= corr[0][1] <= 1.0) def test_correlation_recovers_input(self): corr = risk.correlation_matrix(risk.covariance_matrix(make_returns(n=2000, rho=0.8), CCY)) self.assertAlmostEqual(corr[0][1], 0.8, delta=0.05) def test_percentile_interpolates(self): xs = [0.0, 1.0, 2.0, 3.0, 4.0] self.assertAlmostEqual(risk._percentile(xs, 0.5), 2.0) self.assertAlmostEqual(risk._percentile(xs, 0.0), 0.0) self.assertAlmostEqual(risk._percentile(xs, 1.0), 4.0) def test_cholesky_handles_three_assets(self): M = [[4, 2, 1], [2, 3, 0.5], [1, 0.5, 2]] L = risk.cholesky(M) recon = [[sum(L[i][k] * L[j][k] for k in range(3)) for j in range(3)] for i in range(3)] for i in range(3): for j in range(3): self.assertAlmostEqual(recon[i][j], M[i][j], places=9) class TestVaR(unittest.TestCase): def setUp(self): self.returns = make_returns(n=2000, rho=0.8) self.cov = risk.covariance_matrix(self.returns, CCY) self.exposure = [60.0, 40.0] def test_var99_exceeds_var95(self): p = risk.parametric_var(self.exposure, self.cov, CCY) self.assertGreater(p["levels"]["0.99"], p["levels"]["0.95"]) def test_component_var_sums_to_total(self): # Euler/additive property — compare within the 4-dp rounding tolerance. p = risk.parametric_var(self.exposure, self.cov, CCY) self.assertAlmostEqual(sum(p["components"].values()), p["levels"]["0.95"], delta=1e-3) def test_parametric_and_montecarlo_agree(self): p = risk.parametric_var(self.exposure, self.cov, CCY) mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=40_000, seed=7) rel = abs(p["levels"]["0.95"] - mc["levels"]["0.95"]) / p["levels"]["0.95"] self.assertLess(rel, 0.07) def test_expected_shortfall_exceeds_var(self): mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=40_000, seed=3) self.assertGreaterEqual(mc["es"]["0.95"], mc["levels"]["0.95"]) def test_horizon_scales_as_sqrt(self): p1 = risk.parametric_var(self.exposure, self.cov, CCY, horizon=1) p10 = risk.parametric_var(self.exposure, self.cov, CCY, horizon=10) self.assertAlmostEqual(p10["levels"]["0.95"] / p1["levels"]["0.95"], math.sqrt(10), delta=0.01) def test_historical_var_positive(self): self.assertGreater(risk.historical_var(self.exposure, self.returns, CCY)["levels"]["0.95"], 0) def test_montecarlo_histogram_shape(self): mc = risk.monte_carlo_var(self.exposure, self.cov, n_sims=20_000, bins=31) self.assertEqual(len(mc["histogram"]["centers"]), 31) self.assertEqual(sum(mc["histogram"]["counts"]), 20_000) class TestHedge(unittest.TestCase): def setUp(self): self.cov = risk.covariance_matrix(make_returns(n=1500, rho=0.8), CCY) def test_effectiveness_bounded(self): h = risk.best_hedge([60.0, 40.0], self.cov, CCY)["best_single"] self.assertTrue(0.0 <= h["effectiveness_pct"] <= 100.0) def test_residual_below_unhedged(self): h = risk.best_hedge([60.0, 40.0], self.cov, CCY) self.assertLessEqual(h["best_single"]["residual_sigma"], h["unhedged_sigma"]) def test_single_currency_book_fully_hedged(self): # A pure-EUR book is perfectly hedged by the EUR/USD forward. h = risk.best_hedge([100.0, 0.0], self.cov, CCY)["best_single"] self.assertEqual(h["instrument"], "EUR") self.assertAlmostEqual(h["effectiveness_pct"], 100.0, delta=0.01) class TestStress(unittest.TestCase): def test_negative_shock_gives_loss(self): results = risk.stress_test([60.0, 40.0], CCY) gfc = next(r for r in results if "GFC" in r["name"]) self.assertLess(gfc["impact_usd"], 0) def test_riskon_gives_gain(self): results = risk.stress_test([60.0, 40.0], CCY) self.assertGreater(next(r for r in results if "Risk-on" in r["name"])["impact_usd"], 0) def test_jpy_safe_haven_in_gfc(self): # JPY exposure should *gain* in the GFC scenario (safe-haven rally). results = risk.stress_test([100.0], ["JPY"]) gfc = next(r for r in results if "GFC" in r["name"]) self.assertGreater(gfc["impact_usd"], 0) if __name__ == "__main__": unittest.main(verbosity=2)