Trade the Gap Between Yields, Not the Yields Themselves
Government bond yield differentials between two economies are among the most reliable structural forces in FX markets. When the US 10-year yield moves materially above the German Bund equivalent, capital tends to flow toward USD assets — and EUR/USD faces sustained selling pressure. When that spread compresses, the pair recovers. The relationship is not mechanical, but it is persistent enough to build a rule-based trading strategy around it.
This guide walks you through building a complete yield-spread pair-trading strategy using the FXMacroData API. By the end you will have a working Python system that:
- Fetches government bond yield time series for two currencies via the FXMacroData bond yield endpoints
- Computes the yield spread and its rolling mean and standard deviation
- Generates statistically driven long/short FX signals using Z-score mean reversion
- Tracks open positions, applies basic risk controls, and surfaces entry/exit alerts
Core Thesis
Yield spreads mean-revert around structurally stable equilibria. When a spread widens sharply beyond its recent average, the corresponding FX pair is statistically over-extended and likely to retrace. Entering in the direction of mean reversion — with a defined exit when the spread normalises — is the foundation of this approach.
Prerequisites
Before you start, make sure you have the following ready:
- Python 3.9+ — all snippets use standard type annotations
- FXMacroData API key — sign up at /subscribe and copy your key from the account dashboard
- Python packages:
requests,pandas,numpy
pip install requests pandas numpy
Store your API key as an environment variable — never hard-code it in source files:
export FXMACRO_API_KEY="YOUR_API_KEY"
Step 1: Fetch Government Bond Yield Data
The foundation of this strategy is reliable bond yield time series. FXMacroData provides 10-year government bond yields for all major currency blocs via the gov_bond_10y endpoint. Each observation carries a date, a val (yield in percent), and an announcement_datetime for the second-level release timestamp.
Here we pull USD and EUR 10-year yields, then assemble them into a single DataFrame aligned on date:
import os
import requests
import pandas as pd
from datetime import datetime, timedelta
BASE_URL = "https://fxmacrodata.com/api/v1"
API_KEY = os.environ["FXMACRO_API_KEY"]
def fetch_yield(currency: str, tenor: str = "gov_bond_10y", start: str = "2022-01-01") -> pd.Series:
"""Fetch a bond yield series from FXMacroData and return as a dated Series."""
resp = requests.get(
f"{BASE_URL}/announcements/{currency}/{tenor}",
params={"api_key": API_KEY, "start": start},
timeout=15,
)
resp.raise_for_status()
records = resp.json()["data"]
if not records:
raise ValueError(f"No data returned for {currency}/{tenor}")
series = pd.Series(
{r["date"]: r["val"] for r in records},
name=f"{currency.upper()}_10Y",
dtype=float,
)
series.index = pd.to_datetime(series.index)
return series.sort_index()
# Fetch USD and EUR 10-year yields
usd_10y = fetch_yield("usd")
eur_10y = fetch_yield("eur")
# Align on a shared date index (inner join drops days missing in either series)
yields = pd.DataFrame({"USD_10Y": usd_10y, "EUR_10Y": eur_10y}).dropna()
print(yields.tail())
# Output:
# USD_10Y EUR_10Y
# 2025-04-08 4.41 2.71
# 2025-04-09 4.39 2.69
# 2025-04-10 4.49 2.70
# 2025-04-11 4.52 2.73
# 2025-04-14 4.47 2.70
You can use any pair of currencies supported by the gov_bond_10y endpoint — popular combinations include USD/JPY, AUD/USD, and GBP/USD spreads.
US vs EUR 10-Year Yield — Illustrative
USD yields rose faster than EUR equivalents through 2022–2024, creating a persistently wide spread that weighed on EUR/USD throughout the cycle.
Step 2: Compute the Yield Spread and Z-Score
The raw spread (USD minus EUR yield) tells you which direction capital is structurally biased. The Z-score normalises that spread against its recent history, giving you a dimensionless signal that is directly comparable across time periods and currency pairs.
A Z-score above +1.5 indicates the spread has widened unusually far — a potential short EUR/USD (long USD) setup. A Z-score below −1.5 indicates abnormal compression — a potential long EUR/USD setup.
def compute_spread_signal(
yields_df: pd.DataFrame,
base_col: str,
quote_col: str,
lookback: int = 60,
entry_z: float = 1.5,
exit_z: float = 0.3,
) -> pd.DataFrame:
"""
Compute yield spread, rolling Z-score, and directional signals.
A positive spread means base currency yields are higher → base currency
is structurally favoured → signal to be long base / short quote FX pair.
Mean reversion: enter when Z-score is extreme, exit when it normalises.
"""
df = yields_df.copy()
df["spread"] = df[base_col] - df[quote_col]
# Rolling statistics over the lookback window
roll = df["spread"].rolling(lookback, min_periods=lookback // 2)
df["spread_mean"] = roll.mean()
df["spread_std"] = roll.std()
# Z-score: how many standard deviations from the rolling mean
df["zscore"] = (df["spread"] - df["spread_mean"]) / df["spread_std"].replace(0, float("nan"))
# Signal: +1 = long base/short quote, -1 = short base/long quote, 0 = flat
df["signal"] = 0
df.loc[df["zscore"] > entry_z, "signal"] = -1 # spread too wide → expect compression → short base pair
df.loc[df["zscore"] < -entry_z, "signal"] = +1 # spread too tight → expect widening → long base pair
# Exit (override) when Z-score returns toward zero
df.loc[df["zscore"].abs() < exit_z, "signal"] = 0
return df.dropna(subset=["zscore"])
analysis = compute_spread_signal(yields, base_col="USD_10Y", quote_col="EUR_10Y")
print(analysis[["spread", "spread_mean", "zscore", "signal"]].tail(10))
USD–EUR 10Y Spread and Z-Score — Illustrative
Z-scores beyond ±1.5 historically flagged episodes where the spread had moved too far too fast and tended to mean-revert over the following weeks.
Step 3: Extend to Multiple Pairs
A single spread strategy on EUR/USD is useful, but the real power emerges when you run the same logic across several pairs simultaneously. Combining USD/JPY, AUD/USD, and GBP/USD spread signals gives you a diversified macro-driven portfolio where each position is sized independently.
You can also use shorter-tenor yields — the gov_bond_2y endpoint is especially sensitive to policy rate expectations, making it a leading indicator compared with the 10-year series:
PAIRS = [
# (base_currency, quote_currency, fx_pair_label)
("usd", "eur", "EUR/USD"),
("usd", "jpy", "USD/JPY"),
("aud", "usd", "AUD/USD"),
("gbp", "usd", "GBP/USD"),
]
TENOR = "gov_bond_10y" # swap for gov_bond_2y for rate-expectation signals
results = {}
for base_ccy, quote_ccy, fx_label in PAIRS:
try:
base_series = fetch_yield(base_ccy, tenor=TENOR)
quote_series = fetch_yield(quote_ccy, tenor=TENOR)
df = pd.DataFrame({
f"{base_ccy.upper()}_10Y": base_series,
f"{quote_ccy.upper()}_10Y": quote_series,
}).dropna()
signal_df = compute_spread_signal(
df,
base_col=f"{base_ccy.upper()}_10Y",
quote_col=f"{quote_ccy.upper()}_10Y",
)
latest = signal_df.iloc[-1]
results[fx_label] = {
"spread_pct": round(latest["spread"], 3),
"zscore": round(latest["zscore"], 2),
"signal": int(latest["signal"]),
}
print(f"{fx_label}: spread={latest['spread']:.3f}%, z={latest['zscore']:+.2f}, signal={int(latest['signal']):+d}")
except Exception as exc:
print(f"{fx_label}: skipped — {exc}")
# Example output:
# EUR/USD: spread=1.770%, z=+1.21, signal=0
# USD/JPY: spread=4.050%, z=+2.18, signal=-1
# AUD/USD: spread=0.340%, z=-0.55, signal=0
# GBP/USD: spread=0.890%, z=-1.62, signal=+1
Signal Reference
- +1: spread too compressed — expect widening — long base-currency leg (e.g., long GBP/USD)
- −1: spread too wide — expect compression — short base-currency leg (e.g., short USD/JPY is long JPY)
- 0: spread within normal range — no directional edge — stay flat
Step 4: Add a 2-Year vs 10-Year Slope Overlay
The shape of the yield curve adds a second dimension to the strategy. A steepening curve (long-end yields rising faster than short-end) typically signals improving growth expectations and supports the currency. An inverted or flattening curve often precedes slowdowns and central bank pivots.
Pull both the 2-year and 10-year yields to compute the slope, and use it as a regime filter: only take a spread signal in the direction aligned with the home-currency curve slope.
def fetch_curve_slope(currency: str, start: str = "2022-01-01") -> pd.Series:
"""Return the 10Y–2Y slope for a currency (positive = normal/steep, negative = inverted)."""
y10 = fetch_yield(currency, tenor="gov_bond_10y", start=start)
y2 = fetch_yield(currency, tenor="gov_bond_2y", start=start)
slope = (y10 - y2).dropna()
slope.name = f"{currency.upper()}_slope"
return slope
def apply_curve_filter(
signal_df: pd.DataFrame,
base_slope: pd.Series,
quote_slope: pd.Series,
) -> pd.DataFrame:
"""
Suppress signals that contradict the curve-slope regime.
Long base / short quote (signal=+1) is only taken when:
- base curve is steep (positive slope) AND
- quote curve is flat or inverted (slope < base_slope)
Short base / long quote (signal=-1) is only taken when:
- quote curve is steep relative to base
"""
df = signal_df.copy()
df = df.join(base_slope.rename("base_slope"), how="left")
df = df.join(quote_slope.rename("quote_slope"), how="left")
df[["base_slope", "quote_slope"]] = df[["base_slope", "quote_slope"]].ffill()
# Slope differential: positive → base is steeper → supportive for base
df["slope_diff"] = df["base_slope"] - df["quote_slope"]
# Filter: suppress longs when slope_diff is negative (quote steeper)
df.loc[(df["signal"] == +1) & (df["slope_diff"] < 0), "signal"] = 0
# Filter: suppress shorts when slope_diff is positive (base steeper)
df.loc[(df["signal"] == -1) & (df["slope_diff"] > 0), "signal"] = 0
return df
# Example for EUR/USD
usd_slope = fetch_curve_slope("usd")
eur_slope = fetch_curve_slope("eur")
analysis_filtered = apply_curve_filter(analysis, base_slope=usd_slope, quote_slope=eur_slope)
print(analysis_filtered[["zscore", "signal", "slope_diff"]].tail(8))
USD and EUR Curve Slope (10Y–2Y) — Illustrative
Both curves inverted in 2022–2023; the relative slope differential still provided a tradeable signal even when absolute slopes were negative.
Step 5: Generate Alerts and Build a Live Monitor
The last step assembles the signal logic into a monitor that you can run on a schedule (daily close, hourly, or triggered immediately after a bond yield announcement via the FXMacroData release calendar). When a new signal fires, the monitor prints a structured alert you can route to Slack, email, or a trading webhook.
from dataclasses import dataclass
from typing import Literal
SignalType = Literal["LONG", "SHORT", "EXIT", "HOLD"]
@dataclass
class SpreadAlert:
fx_pair: str
signal: SignalType
spread_pct: float
zscore: float
slope_diff: float
timestamp: str
def latest_signal(
base_ccy: str,
quote_ccy: str,
fx_pair: str,
tenor: str = "gov_bond_10y",
lookback: int = 60,
) -> SpreadAlert:
base_yields = fetch_yield(base_ccy, tenor=tenor)
quote_yields = fetch_yield(quote_ccy, tenor=tenor)
df = pd.DataFrame({
"base": base_yields,
"quote": quote_yields,
}).dropna()
base_col, quote_col = "base", "quote"
df = compute_spread_signal(
df.rename(columns={"base": base_col, "quote": quote_col}),
base_col=base_col,
quote_col=quote_col,
lookback=lookback,
)
# Curve filter
base_slope = fetch_curve_slope(base_ccy)
quote_slope = fetch_curve_slope(quote_ccy)
df = apply_curve_filter(df, base_slope, quote_slope)
row = df.iloc[-1]
sig_map = {1: "LONG", -1: "SHORT", 0: "HOLD"}
return SpreadAlert(
fx_pair=fx_pair,
signal=sig_map[int(row["signal"])],
spread_pct=round(float(row["spread"]), 3),
zscore=round(float(row["zscore"]), 2),
slope_diff=round(float(row.get("slope_diff", float("nan"))), 3),
timestamp=datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
)
# Run for all pairs
for base_ccy, quote_ccy, fx_label in PAIRS:
try:
alert = latest_signal(base_ccy, quote_ccy, fx_label)
print(
f"[{alert.timestamp}] {alert.fx_pair:8s} | {alert.signal:5s} | "
f"spread={alert.spread_pct:+.3f}% z={alert.zscore:+.2f} slope_diff={alert.slope_diff:+.3f}"
)
except Exception as exc:
print(f"{fx_label}: error — {exc}")
# Example output:
# [2025-04-14T08:32:11Z] EUR/USD | HOLD | spread=+1.770% z=+1.21 slope_diff=+0.210
# [2025-04-14T08:32:14Z] USD/JPY | SHORT | spread=+4.050% z=+2.18 slope_diff=+0.580
# [2025-04-14T08:32:17Z] AUD/USD | HOLD | spread=+0.340% z=-0.55 slope_diff=-0.120
# [2025-04-14T08:32:20Z] GBP/USD | LONG | spread=+0.890% z=-1.62 slope_diff=-0.340
Scheduling Tip
Bond yield data is updated when governments publish new issuance results and when central banks release policy decisions. Rather than polling on a fixed timer, query the FXMacroData release calendar to find the next scheduled bond auction or policy announcement, and trigger your signal refresh immediately after that event fires.
Signal Distribution Across Pairs — Illustrative
With Z-score thresholds of ±1.5 on a 60-day rolling window, a significant majority of days fall in the flat zone, concentrating capital deployment to high-conviction setups.
Summary and Next Steps
You now have a complete yield-spread pair-trading framework. The key building blocks are:
- Bond yield fetch —
/announcements/{currency}/gov_bond_10yand/announcements/{currency}/gov_bond_2ysupply the raw material with second-level announcement timestamps - Spread and Z-score — mean reversion around a 60-day rolling window generates objective entry and exit levels without curve-fitting on a single threshold
- Curve-slope filter — the 10Y–2Y differential acts as a regime gate, suppressing signals that run against the structural bias of each currency's own yield curve
- Live alerts — the structured
SpreadAlertoutput is easy to route into any notification, logging, or execution pipeline
Natural extensions include combining yield spreads with policy rate differentials and CPI differentials into a composite macro score, or using the breakeven inflation rate to shift from nominal to real yield spreads for inflation-adjusted positioning.
Full documentation for all available yield and rate endpoints is at /api-reference. To get your API key, visit /subscribe.