mirror of
https://github.com/Saauc/fx-risk-terminal.git
synced 2026-07-27 18:47:52 +00:00
a3817dc462
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>
151 lines
6.0 KiB
Python
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)
|