Files

314 lines
13 KiB
Python
Raw Permalink Normal View History

2026-06-16 22:12:00 -04:00
"""
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,
}