Files
fx-risk-terminal/test_risk_engine.py
sauc a3817dc462 Initial commit: FX Risk Terminal
Multi-currency FX risk engine + browser dashboard:
- Live USD valuation of a multi-currency equity book (ECB rates, no API key)
- Value-at-Risk by 3 methods (parametric, historical, Monte Carlo)
- Expected Shortfall, component VaR, diversification ratio
- Monte Carlo via from-scratch Cholesky (pure Python, no numpy)
- Historical stress testing + minimum-variance hedge search
- Interactive in-browser portfolio builder (stateless, localStorage)
- 20 offline unit tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:12:00 -04:00

151 lines
6.0 KiB
Python

"""
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)