How to Build a Yield Spread Pair Trading Strategy with FXMacroData banner image

Implementation

How-To Guides

How to Build a Yield Spread Pair Trading Strategy with FXMacroData

A complete Python walkthrough for building a rule-based FX strategy driven by government bond yield differentials — from fetching 10-year yields via the FXMacroData API to backtesting spread signals on EUR/USD.

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:

  1. Bond yield fetch/announcements/{currency}/gov_bond_10y and /announcements/{currency}/gov_bond_2y supply the raw material with second-level announcement timestamps
  2. 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
  3. 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
  4. Live alerts — the structured SpreadAlert output 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.