mirror of
https://github.com/Saauc/fx-risk-terminal.git
synced 2026-08-19 13:48:07 +00:00
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>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user