Files
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

314 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
FX risk engine — Value-at-Risk, Expected Shortfall, Monte Carlo, stress
testing and minimum-variance hedging. Generalised to N currencies.
Everything here is pure Python (stdlib only): the covariance pipeline, the
Cholesky decomposition used to correlate Monte-Carlo shocks, and the three
independent VaR methodologies. No numpy, no pandas — the maths is on show.
Conventions
-----------
* The currency order is fixed by the caller (a sorted list) and every vector /
matrix is built in that order.
* "Exposure" is the signed USD value of each currency sleeve. A +1% move in
CCY/USD changes that sleeve's USD value by exposure * 0.01.
* Returns are daily log returns. VaR / ES are positive loss numbers.
* Horizon defaults to 1 trading day; multi-day scales by sqrt(h).
"""
import math
import random
from typing import Sequence
# Standard-normal quantiles for the confidence levels we report.
Z = {0.95: 1.6448536269514722, 0.99: 2.3263478740408408}
# ── Linear algebra ────────────────────────────────────────────────────────────
def _mean(xs: Sequence[float]) -> float:
return sum(xs) / len(xs) if xs else 0.0
def covariance_matrix(returns: dict[str, list[float]], currencies: list[str]) -> list[list[float]]:
series = [returns[c] for c in currencies]
if not series:
return []
n = min(len(s) for s in series)
series = [s[-n:] for s in series]
means = [_mean(s) for s in series]
k = len(series)
cov = [[0.0] * k for _ in range(k)]
if n < 2:
return cov
for i in range(k):
for j in range(k):
cov[i][j] = sum((series[i][t] - means[i]) * (series[j][t] - means[j])
for t in range(n)) / (n - 1)
return cov
def correlation_matrix(cov: list[list[float]]) -> list[list[float]]:
k = len(cov)
corr = [[0.0] * k for _ in range(k)]
for i in range(k):
for j in range(k):
d = math.sqrt(cov[i][i] * cov[j][j])
corr[i][j] = cov[i][j] / d if d > 0 else 0.0
return corr
def cholesky(matrix: list[list[float]]) -> list[list[float]]:
"""Lower-triangular L with L·Lᵀ = matrix. Jitters non-PSD diagonals."""
n = len(matrix)
L = [[0.0] * n for _ in range(n)]
for i in range(n):
for j in range(i + 1):
s = sum(L[i][k] * L[j][k] for k in range(j))
if i == j:
diag = matrix[i][i] - s
L[i][j] = math.sqrt(diag if diag > 0 else 1e-18)
else:
L[i][j] = (matrix[i][j] - s) / L[j][j] if L[j][j] else 0.0
return L
def _matvec(M, v):
return [sum(M[i][k] * v[k] for k in range(len(v))) for i in range(len(M))]
def _percentile(sorted_xs: list[float], q: float) -> float:
if not sorted_xs:
return 0.0
if len(sorted_xs) == 1:
return sorted_xs[0]
pos = q * (len(sorted_xs) - 1)
lo, hi = math.floor(pos), math.ceil(pos)
if lo == hi:
return sorted_xs[int(pos)]
frac = pos - lo
return sorted_xs[lo] * (1 - frac) + sorted_xs[hi] * frac
# ── Exposure ──────────────────────────────────────────────────────────────────
def exposure_vector(portfolio: dict, currencies: list[str]) -> list[float]:
exp = portfolio.get("exposure_usd", {})
return [float(exp.get(c, 0.0)) for c in currencies]
# ── Parametric VaR + component VaR ────────────────────────────────────────────
def parametric_var(exposure, cov, currencies, horizon=1) -> dict:
sig = _matvec(cov, exposure)
var_usd = sum(exposure[i] * sig[i] for i in range(len(exposure)))
sigma = math.sqrt(max(var_usd, 0.0)) * math.sqrt(horizon)
out = {"sigma_usd": round(sigma, 4), "levels": {}, "components": {}}
for cl, z in Z.items():
out["levels"][str(cl)] = round(z * sigma, 4)
z95 = Z[0.95]
if sigma > 0 and var_usd > 0:
denom = math.sqrt(var_usd)
for i, c in enumerate(currencies):
out["components"][c] = round(exposure[i] * sig[i] / denom * z95 * math.sqrt(horizon), 4)
else:
for c in currencies:
out["components"][c] = 0.0
return out
# ── Historical-simulation VaR ─────────────────────────────────────────────────
def historical_var(exposure, returns, currencies, horizon=1) -> dict:
series = [returns[c] for c in currencies]
n = min((len(s) for s in series), default=0)
series = [s[-n:] for s in series]
pnl = []
for t in range(n):
p = sum(exposure[i] * (math.exp(series[i][t]) - 1) for i in range(len(exposure)))
pnl.append(p * math.sqrt(horizon))
pnl.sort()
out = {"levels": {}, "es": {}}
for cl in Z:
vq = -_percentile(pnl, 1 - cl)
tail = [x for x in pnl if x <= -vq]
out["levels"][str(cl)] = round(vq, 4)
out["es"][str(cl)] = round(-_mean(tail) if tail else vq, 4)
return out
# ── Monte Carlo VaR ───────────────────────────────────────────────────────────
def monte_carlo_var(exposure, cov, horizon=1, n_sims=50_000, seed=42, bins=41) -> dict:
rng = random.Random(seed)
L = cholesky(cov)
k = len(exposure)
h = math.sqrt(horizon)
pnl = []
for _ in range(n_sims):
z = [rng.gauss(0, 1) for _ in range(k)]
shock = _matvec(L, z)
pnl.append(sum(exposure[i] * (math.exp(shock[i] * h) - 1) for i in range(k)))
pnl.sort()
out = {"levels": {}, "es": {}, "n_sims": n_sims}
for cl in Z:
vq = -_percentile(pnl, 1 - cl)
tail = [x for x in pnl if x <= -vq]
out["levels"][str(cl)] = round(vq, 4)
out["es"][str(cl)] = round(-_mean(tail) if tail else vq, 4)
lo, hi = pnl[0], pnl[-1]
width = (hi - lo) / bins if hi > lo else 1.0
counts = [0] * bins
for p in pnl:
counts[min(int((p - lo) / width), bins - 1)] += 1
out["histogram"] = {
"centers": [round(lo + (i + 0.5) * width, 4) for i in range(bins)],
"counts": counts,
"var95": out["levels"]["0.95"], "var99": out["levels"]["0.99"],
"mean": round(_mean(pnl), 4),
}
return out
# ── Hedging: best single-instrument min-variance hedge ────────────────────────
def best_hedge(exposure, cov, currencies) -> dict:
"""
Find the single FX forward that, sold against the book, minimises residual
variance — and report its effectiveness (R² = variance reduction).
For instrument j the optimal short notional is
H* = Cov(book, r_j) / Var(r_j) = (Σe)_j / Σ_jj
and the variance reduction equals Corr(book, r_j)².
"""
sig = _matvec(cov, exposure) # Σe
unhedged_var = sum(exposure[i] * sig[i] for i in range(len(exposure)))
best = None
for j, c in enumerate(currencies):
var_j = cov[j][j]
if var_j <= 0:
continue
cov_bj = sig[j] # Cov(book, r_j)
h_star = cov_bj / var_j
residual_var = unhedged_var - cov_bj ** 2 / var_j
eff = (1 - residual_var / unhedged_var) if unhedged_var > 0 else 0.0
cand = {
"instrument": c,
"notional_short_usd": round(h_star, 2),
"effectiveness_pct": round(eff * 100, 2),
"residual_sigma": round(math.sqrt(max(residual_var, 0)), 4),
}
if best is None or cand["effectiveness_pct"] > best["effectiveness_pct"]:
best = cand
# Direct full hedge: short each sleeve in its own pair → residual ≈ 0.
direct = {c: round(exposure[i], 2) for i, c in enumerate(currencies)}
return {
"unhedged_sigma": round(math.sqrt(max(unhedged_var, 0)), 4),
"best_single": best or {},
"direct_hedge": direct,
}
# ── Stress testing ────────────────────────────────────────────────────────────
# Stylised one-shot FX shocks (% move in each CCY/USD) drawn from notable
# historical regimes. Note the safe-haven behaviour of JPY/CHF (they rally as
# the dollar bid hits EM/high-beta currencies) — that is the educational point.
STRESS_SCENARIOS = [
{"name": "GFC — Oct 2008", "note": "Lehman aftermath, USD funding squeeze",
"shocks": {"EUR": -8.5, "SEK": -13, "GBP": -10, "CHF": -2, "BRL": -20, "MXN": -15, "JPY": 12, "KRW": -25}},
{"name": "Euro debt crisis 2011", "note": "Peripheral spreads blow out; SNB caps CHF",
"shocks": {"EUR": -4.5, "SEK": -3, "GBP": -2, "CHF": 8, "BRL": -10, "MXN": -8, "JPY": 5, "KRW": -4}},
{"name": "USD bull 201415", "note": "Fed exit vs. global easing; broad dollar rally",
"shocks": {"EUR": -10, "SEK": -10, "GBP": -5, "CHF": -8, "BRL": -15, "MXN": -12, "JPY": -12, "KRW": -7}},
{"name": "COVID crash — Mar 2020", "note": "Dash for dollars; EM liquidity drain",
"shocks": {"EUR": -3, "SEK": -7.5, "GBP": -10, "CHF": -2, "BRL": -15, "MXN": -20, "JPY": 2, "KRW": -6}},
{"name": "USD surge — Sep 2022", "note": "Fed hiking into energy shock; gilt crisis; super-peso",
"shocks": {"EUR": -6.5, "SEK": -9, "GBP": -8, "CHF": -3, "BRL": 2, "MXN": -1, "JPY": -10, "KRW": -8}},
{"name": "Risk-on rally", "note": "Soft landing / broad USD weakness",
"shocks": {"EUR": 4, "SEK": 6, "GBP": 4, "CHF": -1, "BRL": 8, "MXN": 6, "JPY": -3, "KRW": 5}},
]
def stress_test(exposure, currencies) -> list[dict]:
e = dict(zip(currencies, exposure))
total = sum(e.values())
out = []
for sc in STRESS_SCENARIOS:
impact = sum(e[c] * (sc["shocks"].get(c, 0) / 100) for c in currencies)
out.append({
"name": sc["name"], "note": sc["note"],
"shocks": {c: sc["shocks"].get(c, 0) for c in currencies},
"impact_usd": round(impact, 2),
"impact_pct": round(impact / total * 100, 3) if total else None,
})
return out
# ── Orchestration ─────────────────────────────────────────────────────────────
def full_risk_report(portfolio: dict, returns: dict[str, list[float]], horizon: int = 1) -> dict:
currencies = portfolio.get("currencies") or sorted(portfolio.get("exposure_usd", {}))
exposure = exposure_vector(portfolio, currencies)
cov = covariance_matrix(returns, currencies)
corr = correlation_matrix(cov) if cov else []
total = sum(exposure)
param = parametric_var(exposure, cov, currencies, horizon)
hist = historical_var(exposure, returns, currencies, horizon)
mc = monte_carlo_var(exposure, cov, horizon)
hedge = best_hedge(exposure, cov, currencies)
port_vol_ann = param["sigma_usd"] * math.sqrt(252) / total * 100 if total else None
# Diversification ratio: weighted-avg sleeve vol / portfolio vol. >1 means
# correlations are doing work; ~1 means the book moves as one bet.
div_ratio = None
if total and cov:
w_sigma = sum(abs(exposure[i]) * math.sqrt(cov[i][i]) for i in range(len(exposure)))
p_sigma = param["sigma_usd"]
div_ratio = round(w_sigma / p_sigma, 3) if p_sigma > 0 else None
def pct(v): return round(v / total * 100, 3) if total else None
return {
"horizon_days": horizon,
"currencies": currencies,
"total_exposure_usd": round(total, 2),
"portfolio_vol_annual_pct": round(port_vol_ann, 2) if port_vol_ann else None,
"diversification_ratio": div_ratio,
"covariance": cov,
"correlation": corr,
"var": {
"parametric": {
"var95": param["levels"]["0.95"], "var99": param["levels"]["0.99"],
"var95_pct": pct(param["levels"]["0.95"]), "var99_pct": pct(param["levels"]["0.99"]),
},
"historical": {
"var95": hist["levels"]["0.95"], "var99": hist["levels"]["0.99"],
"es95": hist["es"]["0.95"], "es99": hist["es"]["0.99"],
"var95_pct": pct(hist["levels"]["0.95"]), "var99_pct": pct(hist["levels"]["0.99"]),
},
"monte_carlo": {
"var95": mc["levels"]["0.95"], "var99": mc["levels"]["0.99"],
"es95": mc["es"]["0.95"], "es99": mc["es"]["0.99"],
"var95_pct": pct(mc["levels"]["0.95"]), "var99_pct": pct(mc["levels"]["0.99"]),
"n_sims": mc["n_sims"],
},
},
"component_var95": param["components"],
"es95": mc["es"]["0.95"],
"distribution": mc["histogram"],
"hedge": hedge,
}