Cross-Currency Swaps as Holonomy: Pricing with Gauge Theory¶
A cross-currency swap (CCS) is one of the most heavily-traded OTC derivatives, used by multinationals, central banks, and hedge funds to manage funding across currencies. Yet the standard pricing approach is geometrically opaque: it patches in a basis spread by hand — a number with no first-principles explanation.
This tutorial shows a different path.
EconIAC identifies the cross-currency basis as the holonomy of the combined (FX × IR) connection around the swap's cash flow rectangle. This is not just a restatement — it has concrete consequences:
| Standard approach | EconIAC approach | |
|---|---|---|
| Basis spread | Ad hoc adjustment, unexplained | Holonomy of the (FX × IR) connection |
| Greeks | Finite-difference bumping | jax.grad — exact, one call |
| Cross-gamma | 16 repricing calls | jax.hessian — exact, one call |
| 3-currency consistency | Manual triangular check | curvature(connection()) |
| CIP violation source | "Market convention" | Non-zero connection curvature |
The framework is grounded in the Pacioli manifold: the space of all consistent multi-currency present values, where exchange rates are connection coefficients and CIP is the flatness condition.
References:
- Paper 295: Buckley (2026) Currency Bundles on the Pacioli Manifold — doi:10.5281/zenodo.20242355
- Paper 296: Buckley (2026) Term Structure Bundles — doi:10.5281/zenodo.20244445
- Paper 299: Buckley (2026) XVA as Curvature (in preparation)
Empirical background: Du, Tepper, Verdelhan (2018) Deviations from Covered Interest Rate Parity, JF 73(3), documented that post-2008 CIP violations are persistent, systematic, and proportional to regulatory balance sheet cost — exactly what non-zero holonomy predicts.
# Uncomment to install in Colab or a fresh environment
# !pip install econiac jax[cpu] matplotlib
import jax
import jax.numpy as jnp
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from econiac.finance.fx import FXMarket, cip_residual
from econiac.finance.curves import YieldCurve
from econiac.core.connections import Connection, wilson_loop, log_holonomy, curvature
plt.rcParams.update({'figure.dpi': 120, 'font.size': 11})
jax.config.update('jax_enable_x64', True)
print(f'JAX version: {jax.__version__}')
print(f'Default backend: {jax.default_backend()}')
1. Market Setup: USD/EUR¶
We build a realistic USD/EUR market as of late 2024 / early 2025:
- USD curve: flat at 5.3% (Federal Funds effective rate)
- EUR curve: flat at 3.5% (ECB deposit facility rate)
- Spot rate: 1.08 USD per EUR
Both curves are bootstrapped from flat zero rates — a simplification that keeps the geometry transparent. Real curves would be bootstrapped from OIS swaps and work identically.
maturities = jnp.array([0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0])
# USD curve: Fed Funds ~5.30% (continuously compounded zero rates)
r_usd = 0.053
usd_curve = YieldCurve(
maturities=maturities,
discount_factors=jnp.exp(-r_usd * maturities),
)
# EUR curve: ECB deposit ~3.50%
r_eur = 0.035
eur_curve = YieldCurve(
maturities=maturities,
discount_factors=jnp.exp(-r_eur * maturities),
)
# Spot rate matrix (n x n): spot_rates[i,j] = units of j per i
# USD=0, EUR=1: spot_rates[0,1] = 1.08 (1 USD buys 1/1.08 EUR, or 1 EUR costs 1.08 USD)
spot_usd_eur = jnp.array([[1.0, 1/1.08], [1.08, 1.0]])
market = FXMarket(
currencies=["USD", "EUR"],
spot_rates=spot_usd_eur,
ir_curves=[usd_curve, eur_curve],
)
print(market)
print()
print(f'USD curve: {usd_curve}')
print(f'EUR curve: {eur_curve}')
# Forward rates at each tenor (CIP, zero basis)
tenors = [0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0]
spot = 1.08 # USD/EUR (USD per 1 EUR)
print('USD/EUR CIP forward rates (spot = 1.08 USD/EUR, basis = 0):')
print(f' {"Tenor":>6} {"F_CIP":>10} {"EUR disc":>10} {"USD disc":>10}')
print(f' {"-"*48}')
for T in tenors:
# CIP: F(T) = S * P_EUR(T) / P_USD(T)
# market.forward_rate(i,j,T): F from currency i to j
# i=1 (EUR), j=0 (USD): F_{EUR/USD}(T) = spot_EUR/USD * P_USD / P_EUR
P_usd = float(usd_curve.discount(T))
P_eur = float(eur_curve.discount(T))
# F expressed as USD per EUR: higher USD rates -> USD weakens -> EUR worth more USD forward
F_cip = spot * P_eur / P_usd
print(f' {T:>6.2f}y {F_cip:>10.5f} {P_eur:>10.6f} {P_usd:>10.6f}')
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Zero rate curves
ax = axes[0]
T_grid = np.array(tenors)
usd_zeros = np.array([float(usd_curve.zero_rates[i]) * 100
for i in range(len(maturities))])
eur_zeros = np.array([float(eur_curve.zero_rates[i]) * 100
for i in range(len(maturities))])
ax.plot(np.array(maturities), usd_zeros, 'o-', color='steelblue', label='USD', linewidth=2)
ax.plot(np.array(maturities), eur_zeros, 's--', color='forestgreen', label='EUR', linewidth=2)
ax.set_xlabel('Maturity (years)')
ax.set_ylabel('Zero rate (%)')
ax.set_title('Yield curves')
ax.legend()
ax.set_ylim(0, 7)
ax.yaxis.set_major_formatter(mticker.FormatStrFormatter('%.1f%%'))
# CIP forward curve
ax = axes[1]
cip_fwds = np.array([spot * float(eur_curve.discount(T)) / float(usd_curve.discount(T))
for T in T_grid])
ax.plot(T_grid, cip_fwds, 'o-', color='darkorange', linewidth=2, label='CIP forward')
ax.axhline(spot, color='gray', linestyle=':', linewidth=1.5, label=f'Spot = {spot}')
ax.set_xlabel('Tenor (years)')
ax.set_ylabel('USD / EUR')
ax.set_title('USD/EUR CIP forward curve\n(USD rates > EUR rates → EUR appreciates forward)')
ax.legend()
plt.tight_layout()
plt.show()
2. CIP and the Geometry of No-Arbitrage¶
Covered Interest Parity is not just a formula — it is the flatness condition on the (FX × IR) connection.
Consider the rectangle in (currency, time) space spanned by a round-trip:
borrow USD at r_USD ──────────────────► repay USD at T
│ │
convert to EUR convert back to USD
at spot S at forward F(T)
│ │
▼ ▲
invest EUR at r_EUR ──────────────────► receive EUR at T
The log-holonomy of this rectangle is:
$$\phi(T) = \log F_{\text{market}}(T) - \underbrace{\log S - (r_{\text{EUR}} - r_{\text{USD}}) \cdot T}_{\log F_{\text{CIP}}(T)}$$
CIP holds iff $\phi = 0$ — the connection is flat. Post-2008, $\phi < 0$ for EUR/USD (EUR commands a funding premium — it is cheaper to borrow EUR directly than to synthesise EUR via the FX swap market). This is the cross-currency basis.
Du, Tepper, Verdelhan (2018) document that this basis is persistent, reaches −50bp during stress periods, and correlates with regulatory balance sheet cost. From the gauge-theory perspective: regulations that constrain bank leverage prevent the arbitrage that would flatten the connection.
# Post-2008: EUR/USD basis is typically negative (-10 to -30bp)
# Negative basis = EUR funding premium (pay more to get USD synthetically)
# market_forward = CIP_forward * exp(basis * T) [in log-space: basis is the holonomy per year]
basis_bps = -25.0
basis = basis_bps / 10_000.0
tenors_cip = np.array([0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0])
cip_forwards = np.array([spot * np.exp(-r_usd * T) / np.exp(-r_eur * T)
for T in tenors_cip])
# Market forwards embed the basis
market_forwards = cip_forwards * np.exp(basis * tenors_cip)
# Log-holonomy = log(market_fwd / CIP_fwd) = basis * T (per the CIP deviation formula)
deviations_bps = np.array([
float(cip_residual(spot, r_usd, r_eur, mkt_fwd, T)) * 10_000
for mkt_fwd, T in zip(market_forwards, tenors_cip)
])
print('CIP deviation (log-holonomy) at each tenor:')
print(f' {"Tenor":>6} {"F_CIP":>10} {"F_market":>10} {"Basis (bp)":>10}')
print(f' {"-"*50}')
for T, F_cip, F_mkt, dev in zip(tenors_cip, cip_forwards, market_forwards, deviations_bps):
print(f' {T:>6.2f}y {F_cip:>10.5f} {F_mkt:>10.5f} {dev:>10.2f}')
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# Forward curves: CIP vs market
ax = axes[0]
ax.plot(tenors_cip, cip_forwards, 'o-', color='steelblue', linewidth=2, label='CIP forward (flat basis)')
ax.plot(tenors_cip, market_forwards, 's--', color='crimson', linewidth=2, label=f'Market forward ({basis_bps:.0f}bp basis)')
ax.set_xlabel('Tenor (years)')
ax.set_ylabel('USD / EUR')
ax.set_title('CIP vs market forward curves')
ax.legend()
# Basis term structure (= log-holonomy per tenor)
ax = axes[1]
ax.fill_between(tenors_cip, deviations_bps, 0, alpha=0.3, color='crimson')
ax.plot(tenors_cip, deviations_bps, 'o-', color='crimson', linewidth=2)
ax.axhline(0, color='black', linewidth=0.8)
ax.axhline(basis_bps, color='gray', linestyle=':', linewidth=1.5,
label=f'Input basis = {basis_bps:.0f}bp')
ax.set_xlabel('Tenor (years)')
ax.set_ylabel('Log-holonomy (basis points)')
ax.set_title('Cross-currency basis term structure\n= holonomy of (FX × IR) connection')
ax.legend()
plt.tight_layout()
plt.show()
print('\nInterpretation:')
print(' The basis is the log-holonomy φ(T) = log F_mkt - log F_CIP.')
print(' Negative basis (EUR funding premium) = non-zero connection curvature.')
print(' A flat connection (CIP holds) would give φ(T) = 0 at all tenors.')
3. Pricing a 3-Year USD/EUR Cross-Currency Swap¶
A standard (mark-to-market) CCS exchanges:
- USD leg: receive USD floating (≈ OIS), quarterly, plus return notional $N_{\text{USD}}$ at maturity
- EUR leg: pay EUR floating (≈ OIS), quarterly, plus return notional $N_{\text{EUR}} = N_{\text{USD}} / S$ at maturity
In a CIP world (flat connection), this swap has NPV = 0 at par — the two legs are worth exactly the same when discounted at their respective curves. With a non-zero basis $b$, the EUR discounting shifts: the EUR discount curve is adjusted by $e^{b \cdot T}$, creating a non-zero NPV.
Geometrically: the NPV of a CCS is the holonomy integral $\int_0^T \phi(t) \cdot \text{(EUR annuity factor)}\, dt$, where $\phi$ is the curvature.
We implement the NPV as a pure JAX function so that jax.grad can differentiate it.
def ccs_npv(
spot: jax.Array, # USD/EUR spot (USD per 1 EUR)
r_usd: jax.Array, # USD flat continuously compounded rate
r_eur: jax.Array, # EUR flat continuously compounded rate
basis: jax.Array, # cross-currency basis (log-space, per year)
notional_usd: float = 1_000_000.0,
maturity: float = 3.0,
n_periods: int = 12, # quarterly payments over 3 years
) -> jax.Array:
"""
NPV of a USD/EUR cross-currency swap (receive USD floating, pay EUR floating).
The basis shifts the EUR discount curve: P_EUR_adj(T) = P_EUR(T) * exp(basis * T).
This is exactly the holonomy correction to the flat-connection price.
NPV > 0 → receiving USD (paying EUR synthetically) is profitable when basis < 0.
NPV = 0 → fair value (basis is at par for this swap).
"""
dt = maturity / n_periods
payment_times = jnp.arange(1, n_periods + 1) * dt # shape (n_periods,)
notional_eur = notional_usd / spot # EUR notional at inception
# USD discount factors (unaffected by basis — USD is the domestic leg)
P_usd = jnp.exp(-r_usd * payment_times)
# EUR discount factors adjusted by basis: P_EUR_adj(T) = exp(-(r_eur - basis) * T)
# basis < 0 → P_EUR_adj < P_EUR (EUR is more expensive to fund)
P_eur_adj = jnp.exp(-(r_eur - basis) * payment_times)
# USD leg: receive fixed at r_usd (simplified floating = fixed at par)
usd_coupons = notional_usd * r_usd * dt * P_usd
usd_principal = notional_usd * P_usd[-1]
usd_npv = jnp.sum(usd_coupons) + usd_principal
# EUR leg: pay fixed at r_eur, converted to USD at current spot
# (mark-to-market: notional re-exchanged at spot each reset, simplified here)
eur_coupons = notional_eur * r_eur * dt * P_eur_adj * spot
eur_principal = notional_eur * P_eur_adj[-1] * spot
eur_npv = jnp.sum(eur_coupons) + eur_principal
return usd_npv - eur_npv
spot_val = jnp.array(1.08)
r_usd_val = jnp.array(r_usd)
r_eur_val = jnp.array(r_eur)
basis_zero = jnp.array(0.0)
basis_val = jnp.array(basis) # -25bp
npv_cip = ccs_npv(spot_val, r_usd_val, r_eur_val, basis_zero)
npv_basis = ccs_npv(spot_val, r_usd_val, r_eur_val, basis_val)
print('3-year USD/EUR CCS (receive USD, pay EUR), notional = $1,000,000')
print(f' NPV (CIP, zero basis): ${float(npv_cip):>12,.0f}')
print(f' NPV (-25bp EUR/USD basis): ${float(npv_basis):>12,.0f}')
print(f' Basis value (PnL from basis):${float(npv_basis) - float(npv_cip):>12,.0f}')
print()
print('Interpretation:')
print(f' The -25bp basis makes receiving USD (paying EUR synthetically) profitable.')
print(f' The ${float(npv_basis) - float(npv_cip):,.0f} is the "basis annuity" — the holonomy integrated over the swap lifecycle.')
# NPV as a function of basis: the basis PnL profile
basis_grid_bps = np.linspace(-50, 10, 100)
npv_grid = np.array([
float(ccs_npv(spot_val, r_usd_val, r_eur_val, jnp.array(b / 10_000)))
for b in basis_grid_bps
])
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(basis_grid_bps, npv_grid / 1_000, color='steelblue', linewidth=2)
ax.axvline(0, color='black', linewidth=0.8, linestyle=':')
ax.axhline(0, color='black', linewidth=0.8)
ax.axvline(basis_bps, color='crimson', linestyle='--', linewidth=1.5, label=f'Current basis = {basis_bps:.0f}bp')
ax.scatter([basis_bps], [float(npv_basis) / 1_000], color='crimson', s=80, zorder=5)
ax.set_xlabel('Cross-currency basis (bp)')
ax.set_ylabel('NPV ($k)')
ax.set_title('CCS NPV vs. EUR/USD basis (receive USD, pay EUR, 3Y, $1MM notional)')
ax.legend()
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
4. Exact Greeks via jax.grad — the EconIAC Advantage¶
A standard risk system computes Greeks by bumping each input by 1bp and repricing:
- 4 inputs → 4 function calls for first-order Greeks (DV01, FX delta, basis sensitivity)
- 4 × 4 = 16 calls for the full cross-gamma matrix
- Each call is subject to discretisation error (what bump size?)
- Bumping is not parallelisable across inputs in the same way
Because ccs_npv is a pure JAX function, jax.grad gives all Greeks in a single
backward pass — exact to machine precision, no bump size ambiguity.
The Hessian (cross-gammas) requires one additional call to jax.hessian.
Total: 2 calls (grad + hessian) vs 20 calls (finite difference). Exact vs approximate.
# First-order Greeks: all four in one backward pass
grad_fn = jax.grad(ccs_npv, argnums=(0, 1, 2, 3))
d_spot, d_rusd, d_reur, d_basis = grad_fn(
spot_val, r_usd_val, r_eur_val, basis_val
)
print('First-order Greeks (exact, via jax.grad at basis = -25bp):')
print(f' FX Delta ∂NPV/∂S = ${float(d_spot):>12,.0f} per 1 USD/EUR move')
print(f' USD DV01 ∂NPV/∂r_USD = ${float(d_rusd)/100:>12,.0f} per 1bp rise in USD rates')
print(f' EUR DV01 ∂NPV/∂r_EUR = ${float(d_reur)/100:>12,.0f} per 1bp rise in EUR rates')
print(f' Basis sens ∂NPV/∂basis = ${float(d_basis)/100:>12,.0f} per 1bp tightening of basis')
print()
print('Interpretation:')
print(f' FX Delta: receiving USD means we are long EUR discounted cash flows — spot affects them.')
print(f' USD DV01 > 0: higher USD rates → USD leg worth less → our received USD cash flows fall.')
print(f' EUR DV01 < 0: higher EUR rates → EUR leg worth less → our paid EUR cash flows fall (profit).')
print(f' Basis sens > 0: if basis narrows (less negative), our receive-USD position gains.')
# Second-order Greeks (cross-gammas): the full Hessian in one call
def ccs_npv_vec(x: jax.Array) -> jax.Array:
"""Wrapper for Hessian: x = [spot, r_usd, r_eur, basis]."""
return ccs_npv(x[0], x[1], x[2], x[3])
hessian_fn = jax.hessian(ccs_npv_vec)
x0 = jnp.array([1.08, r_usd, r_eur, basis])
H = hessian_fn(x0) # shape (4, 4)
labels = ['FX spot', 'r_USD', 'r_EUR', 'basis']
units = ['per USD²', 'per bp²', 'per bp²', 'per bp²']
print('Cross-gamma matrix ∂²NPV/∂xᵢ∂xⱼ (scaled to per-bp² or per-spot² units):')
print()
# Scale: rows/cols 1,2,3 are rates — divide by 100² to convert to per-bp²
H_scaled = np.array(H)
for i in range(1, 4):
H_scaled[i, :] /= 100
H_scaled[:, i] /= 100
header = f' {"":12}' + ''.join(f' {lb:>12}' for lb in labels)
print(header)
print(' ' + '-' * (12 + 14 * 4))
for i, lb in enumerate(labels):
row = f' {lb:12}' + ''.join(f' {H_scaled[i, j]:>12,.0f}' for j in range(4))
print(row)
print()
print('Standard approach: 4×4 = 16 finite-difference repricing calls + discretisation error.')
print('jax.hessian: 1 call, exact to machine precision.')
# Visualise the cross-gamma matrix as a heatmap
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(np.abs(H_scaled), cmap='Blues', aspect='auto')
ax.set_xticks(range(4))
ax.set_xticklabels(labels, fontsize=10)
ax.set_yticks(range(4))
ax.set_yticklabels(labels, fontsize=10)
ax.set_title('|Cross-gamma| matrix\n(scaled units: spot in $/USD, rates in $/bp)')
for i in range(4):
for j in range(4):
val = H_scaled[i, j]
ax.text(j, i, f'{val:,.0f}', ha='center', va='center',
fontsize=9, color='white' if abs(val) > 0.4 * abs(H_scaled).max() else 'black')
plt.colorbar(im, ax=ax)
plt.tight_layout()
plt.show()
5. Basis Sensitivity as Curvature¶
This is the geometric punchline.
The basis sensitivity $\partial \text{NPV} / \partial b$ is not just a Greek — it is the integral of the curvature 2-form over the swap's cash flow rectangle in (currency, time) space.
Specifically, for a CCS with maturity $T$ and notional $N$:
$$\frac{\partial \text{NPV}}{\partial b} \approx N_{\text{EUR}} \cdot S \cdot \int_0^T t \cdot P_{\text{EUR}}(t)\, dt$$
This is the dollar duration of the EUR leg — which equals the integral of the curvature 2-form $\mathbf{F}$ over the swap rectangle. In gauge-theory language:
$$\frac{\partial \text{NPV}}{\partial b} = \int_{\text{swap rectangle}} \mathbf{F}$$
A flat connection ($b = 0$, CIP holds) means no curvature, no basis, zero NPV. Non-zero curvature IS the basis.
We verify this numerically: the jax.grad result should match the
EUR-leg dollar duration computed directly.
maturity_ccs = 3.0
n_periods_ccs = 12 # quarterly
dt_ccs = maturity_ccs / n_periods_ccs
notional_usd_val = 1_000_000.0
notional_eur_val = notional_usd_val / float(spot_val)
payment_times_ccs = np.arange(1, n_periods_ccs + 1) * dt_ccs
# EUR leg dollar duration: sum_t [ t * P_EUR(t) * notional_EUR * spot * dt ]
# (this is ∂(EUR annuity PV)/∂r_eur, then converted to basis sensitivity)
# More precisely: ∂NPV/∂basis = ∂(EUR leg PV)/∂basis
# EUR leg PV = sum_t [ N_EUR * r_eur * dt * exp(-(r_eur - basis) * t) * spot ]
# + N_EUR * exp(-(r_eur - basis) * T) * spot
# ∂(EUR PV)/∂basis = sum_t [ N_EUR * r_eur * dt * t * P_EUR_adj(t) * spot ]
# + N_EUR * T * P_EUR_adj(T) * spot
basis_float = float(basis_val)
P_eur_adj_t = np.exp(-(r_eur - basis_float) * payment_times_ccs)
eur_basis_sens = float(notional_eur_val) * r_eur * dt_ccs * np.sum(
payment_times_ccs * P_eur_adj_t
) * 1.08 # convert to USD
eur_basis_sens += float(notional_eur_val) * maturity_ccs * P_eur_adj_t[-1] * 1.08
print('Basis sensitivity: jax.grad vs analytic EUR-leg duration')
print(f' jax.grad ∂NPV/∂basis = ${float(d_basis):>12,.2f}')
print(f' EUR leg dollar duration (USD) = ${eur_basis_sens:>12,.2f}')
print(f' Difference = ${float(d_basis) - eur_basis_sens:>12,.2f}')
print(f' Match (within $1): {abs(float(d_basis) - eur_basis_sens) < 1.0}')
print()
print('The basis sensitivity IS the integral of the curvature 2-form over the swap rectangle.')
print('Curvature = basis spread; the integral = duration-weighted exposure.')
# Show the curvature integrand: t * P_EUR(t) * spot (the basis sensitivity density)
t_fine = np.linspace(0.01, maturity_ccs, 300)
P_eur_adj_fine = np.exp(-(r_eur - basis_float) * t_fine)
integrand = t_fine * P_eur_adj_fine * notional_eur_val * 1.08 # in USD
fig, ax = plt.subplots(figsize=(10, 4))
ax.fill_between(t_fine, integrand / 1_000, alpha=0.4, color='darkorange')
ax.plot(t_fine, integrand / 1_000, color='darkorange', linewidth=2)
ax.scatter(payment_times_ccs,
payment_times_ccs * P_eur_adj_t * notional_eur_val * 1.08 / 1_000,
color='crimson', s=50, zorder=5, label='Payment dates')
ax.set_xlabel('Time (years)')
ax.set_ylabel('Curvature integrand (\$k)')
ax.set_title(
r'$\partial NPV / \partial b$ integrand: $t \cdot P_{EUR}(t) \cdot N_{EUR} \cdot S$'
'\n= curvature density of the (FX × IR) connection'
)
ax.legend()
total_area = np.trapz(integrand / 1_000, t_fine)
ax.text(0.05, 0.85, f'Integral ≈ ${total_area:,.1f}k (= basis sensitivity / 1)',
transform=ax.transAxes, fontsize=10,
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.tight_layout()
plt.show()
6. Three-Currency Extension: USD / EUR / GBP¶
Extending to three currencies reveals a structural constraint that standard finance misses: basis spreads are not independent.
If you know the USD/EUR basis and the EUR/GBP basis, the USD/GBP basis is determined by triangular consistency (closure of the curvature 2-form). Simple addition gives the wrong answer — there is a curvature correction.
EconIAC exposes this via curvature(market.connection()):
a non-zero curvature tensor means the three bases are inconsistent —
there exists a triangular FX arbitrage.
First we build a consistent (flat) 3-currency market, then introduce basis violations and measure the resulting curvature.
# 3-currency market: USD=0, EUR=1, GBP=2
r_gbp = 0.048 # approximate Bank of England rate 2024/2025
S_eur_gbp = 0.855 # EUR/GBP: 1 EUR = 0.855 GBP
gbp_curve = YieldCurve(
maturities=maturities,
discount_factors=jnp.exp(-r_gbp * maturities),
)
# Consistent spot rate matrix
S_usd_eur = 1.08 # 1 USD = 1/1.08 EUR, equivalently 1 EUR = 1.08 USD
S_usd_gbp = S_usd_eur * S_eur_gbp # = 0.9234: 1 USD = 0.9234 GBP
# spot_rates[i,j] = units of currency j per unit of currency i
S3 = jnp.array([
[1.0, 1 / S_usd_eur, 1 / S_usd_gbp ], # USD row: USD→EUR, USD→GBP
[S_usd_eur, 1.0, 1 / S_eur_gbp ], # EUR row: EUR→USD, EUR→GBP
[S_usd_gbp, S_eur_gbp, 1.0 ], # GBP row: GBP→USD, GBP→EUR
])
market3 = FXMarket(
currencies=["USD", "EUR", "GBP"],
spot_rates=S3,
ir_curves=[usd_curve, eur_curve, gbp_curve],
)
print(market3)
print()
print(f'Spot rates:')
print(f' USD/EUR: {S_usd_eur:.4f} (1 USD = {1/S_usd_eur:.4f} EUR)')
print(f' USD/GBP: {S_usd_gbp:.4f} (1 USD = {1/S_usd_gbp:.4f} GBP)')
print(f' EUR/GBP: {S_eur_gbp:.4f} (1 EUR = {S_eur_gbp:.4f} GBP)')
print()
tri_arb = market3.triangular_arbitrage(0, 1, 2)
print(f'Triangular arbitrage USD→EUR→GBP→USD: {float(tri_arb)*10_000:.4f} bps')
print(f'(Should be 0: consistent spot rates imply flat FX connection)')
# Now introduce basis violations: USD/EUR = -25bp, EUR/GBP = -15bp
# Question: what is the USD/GBP basis required for triangular consistency?
# In log-space, around a triangle: φ_{USD/EUR} + φ_{EUR/GBP} + φ_{GBP/USD} = 0
# (the curvature must integrate to zero around a closed loop if the connection is flat)
# So: φ_{USD/GBP} = φ_{USD/EUR} + φ_{EUR/GBP} [naive simple sum]
# But this is only exact in log-space at a single tenor.
# With term structures the relationship is more complex.
basis_usd_eur_bps = -25.0
basis_eur_gbp_bps = -15.0
basis_usd_eur = basis_usd_eur_bps / 10_000
basis_eur_gbp = basis_eur_gbp_bps / 10_000
# Naive triangular closure (log-space additive)
basis_usd_gbp_naive_bps = basis_usd_eur_bps + basis_eur_gbp_bps
print('Three-currency basis consistency:')
print(f' USD/EUR basis: {basis_usd_eur_bps:.0f} bp')
print(f' EUR/GBP basis: {basis_eur_gbp_bps:.0f} bp')
print(f' USD/GBP naive (sum): {basis_usd_gbp_naive_bps:.0f} bp')
print()
print('In log-space, the triangular closure condition is exactly:')
print(' φ_USD/GBP = φ_USD/EUR + φ_EUR/GBP')
print(f' → φ_USD/GBP = {basis_usd_eur_bps:.0f} + {basis_eur_gbp_bps:.0f} = {basis_usd_gbp_naive_bps:.0f} bp')
print()
print('Any deviation from this means a triangular CIP arbitrage exists.')
print('EconIAC quantifies the deviation via the curvature tensor.')
# Demonstrate: if we set USD/GBP basis ≠ φ_USD/EUR + φ_EUR/GBP,
# the curvature tensor is non-zero (triangular arbitrage exists)
def triangle_curvature_bps(phi_ue, phi_eg, phi_ug_claimed):
"""
Compute the curvature residual for a triangle of bases.
phi_XX in log-space (not bps).
Returns residual in bps.
"""
# Triangle: USD→EUR→GBP→USD
# Holonomy = phi_ue + phi_eg + phi_gu = phi_ue + phi_eg - phi_ug_claimed
holonomy = phi_ue + phi_eg - phi_ug_claimed
return holonomy * 10_000
# Test: various claimed USD/GBP bases and the resulting triangular inconsistency
claimed_bases_bps = [-35, -38, -40, -42, -45]
consistent_usd_gbp = basis_usd_eur + basis_eur_gbp # should give zero residual
print('Triangular consistency residual for different claimed USD/GBP bases:')
print(f' (Consistent value = {consistent_usd_gbp*10_000:.0f} bp)')
print()
print(f' {"USD/GBP basis":>15} {"Residual (bp)":>14} {"Arbitrage?"}' )
print(f' {"-"*50}')
for b_bps in claimed_bases_bps:
residual = triangle_curvature_bps(basis_usd_eur, basis_eur_gbp, b_bps / 10_000)
arb = "YES" if abs(residual) > 0.01 else "no"
marker = " <-- consistent" if b_bps == int(consistent_usd_gbp * 10_000) else ""
print(f' {b_bps:>15} bp {residual:>14.2f} bp {arb}{marker}')
print()
print('EconIAC makes this structural: the curvature tensor F[USD,EUR,GBP]')
print('must vanish for all consistent basis term structures.')
# Curvature surface of the 3-currency connection
curv_tensor = market3.arbitrage_surface() # shape (3, 3, 3)
max_curv = market3.max_arbitrage()
print(f'3-currency (USD/EUR/GBP) FX curvature tensor:')
print(f' Max curvature: {float(max_curv)*10_000:.6f} bps (≈ 0 for consistent spot rates)')
print()
# Off-diagonal elements of the reduced (n×n) curvature matrix
from econiac.core.connections import curvature_matrix
conn3 = market3.connection()
C = curvature_matrix(conn3) # (3,3): C[i,k] = sum_j F[i,j,k]
print('Curvature matrix C[i,k] = Σⱼ F[i,j,k] (in log-space):')
ccy_labels = ['USD', 'EUR', 'GBP']
print(f' {"":6}' + ''.join(f' {lb:>8}' for lb in ccy_labels))
for i, row_lb in enumerate(ccy_labels):
row = f' {row_lb:6}' + ''.join(f' {float(C[i,j])*10_000:>8.4f}' for j in range(3))
print(row)
print(f' (values in bps; all ≈ 0 for flat/arbitrage-free connection)')
7. Summary: Standard Finance vs EconIAC¶
| Concept | Standard approach | EconIAC |
|---|---|---|
| Basis spread | Ad hoc adjustment, unexplained | Holonomy of (FX × IR) connection |
| CIP violation | "Market convention" or regulatory friction | Non-zero connection curvature |
| FX delta | Bump spot by 1%, reprice | jax.grad — exact, same cost as pricing |
| USD DV01 | Bump USD curve by 1bp, reprice | jax.grad — in same backward pass |
| EUR DV01 | Bump EUR curve by 1bp, reprice | jax.grad — in same backward pass |
| Basis sensitivity | Bump basis by 1bp, reprice | jax.grad — all 4 Greeks in 1 call |
| Cross-gamma matrix | 16 finite-difference repricing calls | jax.hessian — exact, 1 call |
| 3-currency consistency | Manual triangular check | curvature(connection()) |
| Where does the basis come from? | No answer | First Chern class of the FX bundle |
| Model | Per-instrument add-on | Unified gauge field on Pacioli manifold |
What we built¶
- USD/EUR market from realistic 2024/2025 rates — flat curves at 5.3% (USD) and 3.5% (EUR)
- CIP geometry — visualised the basis as log-holonomy of the (FX × IR) connection rectangle
- CCS pricing in JAX — a pure differentiable function enabling exact Greeks
- First-order Greeks via
jax.grad— FX delta, USD/EUR DV01, basis sensitivity in one call - Cross-gamma matrix via
jax.hessian— 16 quantities in one call, exact to machine precision - Basis = curvature integral — verified that
∂NPV/∂basis = EUR leg dollar duration - Three-currency consistency — triangular closure constrains the third basis given the first two
References¶
- Paper 295: Buckley (2026) Currency Bundles on the Pacioli Manifold — doi:10.5281/zenodo.20242355
- Paper 296: Buckley (2026) Term Structure Bundles — doi:10.5281/zenodo.20244445
- Paper 299: Buckley (2026) XVA as Curvature (in preparation)
- Du, Tepper, Verdelhan (2018): Deviations from Covered Interest Rate Parity, JF 73(3)
- Economic Gauge Theory: doi:10.5281/zenodo.20259495
Related tutorials¶
- Supply Chain RST: the same
curvature()machinery applied to production networks - GEMMES: climate damage as curvature on the balance sheet manifold
- GL Model PC: sectoral balance flows as parallel transport