# How to Build a Yield Spread Pair Trading Strategy with FXMacroData

Canonical: https://fxmacrodata.com/articles/how-to-yield-spread-pair-trading
Author: Robert Tidball
Published: 2026-04-16T12:00:00Z

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](/api-data-docs/usd/gov_bond_10y)

- 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](/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`](/api-data-docs/usd/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](/api-data-docs/eur/gov_bond_10y) — 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`](/api-data-docs/usd/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](/api-data-docs/usd/gov_bond_2y) and[10-year](/api-data-docs/usd/gov_bond_10y) 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](/api-data-docs/usd/gov_bond_10y) ). 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](/api-data-docs/usd/gov_bond_10y) 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_10y`](/api-data-docs/usd/gov_bond_10y) and[`/announcements/{currency}/gov_bond_2y`](/api-data-docs/usd/gov_bond_2y) supply 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`SpreadAlert`output is easy to route into any notification, logging, or execution pipeline

Natural extensions include combining yield spreads with[policy rate differentials](/api-data-docs/usd/policy_rate) and[CPI differentials](/api-data-docs/usd/inflation) into a composite macro score, or using the[breakeven inflation rate](/api-data-docs/usd/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[/documentation/reference](/documentation/reference) . To get your API key, visit[/subscribe](/subscribe) .

Source: FXMacroData
