How To Backtest Fx Macro Strategies With Backtesting Py banner image

Implementation

How-To Guides

How To Backtest Fx Macro Strategies With Backtesting Py

A step-by-step guide to using FXMacroData central-bank announcement data to build a synthetic carry-spread index and backtest a GBP/USD carry strategy with backtesting.py — no external price provider required.

Également disponible en English

À la fin de ce guide, vous aurez un backtest Python fonctionnel qui utilise uniquement FXMacroData sans fournisseurs de prix externes nécessaires. Vous récupérerez les historiques des taux de référence GBP et USD, construirez un indice de carry-spread synthétique directement à partir du différentiel de taux et l'exécuterez Le test de retour.py Les statistiques de la bourse sont généralement fournies par les banques centrales.

Pré-requis

  • Python 3.10 ou version ultérieure
  • Une clé d' API FXMacroData (inscrivez-vous à / souscrivez; les points de départ en USD sont gratuits)
  • Familiarité de base avec les pandas
  • pip accès à l'installation backtestingJe suis désolé . requestsJe suis désolé . pandas, et numpy

L'approche: le backtesting par indice de charge

Le test de retour.py est une bibliothèque de simulation légère basée sur des événements qui produit des graphiques Bokeh interactifs, des mesures de performance clés (ratio Sharpe, taux de tirage maximal, taux d'optimisation des paramètres) à partir d'un seul appel de méthode.

Au lieu de s'appuyer sur un flux de prix externe, ce guide construit un Indice de portée synthétique L'idée est une représentation fidèle d'un carry trade: le différentiel de taux GBP/USD (taux de la Banque d'Angleterre moins taux de la Fed) accumule des rendements quotidiens d'intérêts sur notionnels. Nous utilisons la valeur cumulée de cet accroissement comme notre série de "prix" de backtest, puis des signaux d'entrée de feu chaque fois que la Banques d'England modifie son taux directeur.

C'est ainsi que les stratégies de porte institutionnelle sont en fait modélisées le prix que vous simuler est le P&L théorique de la détention du différentiel de rendement, pas une cotation FX au comptant.


Étape 1 Installer les dépendances

pip install backtesting requests pandas numpy

Étape 2 Retrouver les historiques des taux de la politique à partir de FXMacroData

Les deux . Point final du taux directeur en GBP et le Points de départ du taux directeur en USD Les données de la série sont répertoriées dans le tableau 1 de la liste des banques centrales. announcement_datetime Les données USD sont disponibles sans clé API.

import requests
import pandas as pd
import numpy as np

API_KEY = "YOUR_API_KEY"
BASE = "https://fxmacrodata.com/api/v1"

def fetch_policy_rate(currency: str) -> pd.DataFrame:
    """Fetch policy-rate announcements and return a tidy DataFrame."""
    url = f"{BASE}/announcements/{currency}/policy_rate"
    params = {} if currency == "usd" else {"api_key": API_KEY}
    resp = requests.get(url, params=params, timeout=15)
    resp.raise_for_status()
    rows = resp.json().get("data", [])
    df = pd.DataFrame(rows)
    df["ann_date"] = (
        pd.to_datetime(df["announcement_datetime"], unit="s", utc=True)
        .dt.normalize()
    )
    df["rate"] = pd.to_numeric(df["val"], errors="coerce")
    return df[["ann_date", "rate"]].sort_values("ann_date").reset_index(drop=True)

gbp_rates = fetch_policy_rate("gbp")
usd_rates = fetch_policy_rate("usd")

print(gbp_rates.tail(6))
             ann_date  rate
23 2024-05-09 00:00:00  5.25
24 2024-06-20 00:00:00  5.25
25 2024-08-01 00:00:00  5.00
26 2024-09-19 00:00:00  5.00
27 2024-11-07 00:00:00  4.75
28 2024-12-19 00:00:00  4.75

Étape 3 Élaborer un indice quotidien des spreads de porte

Entre les dates d'annonce, chaque taux de politique est constant, nous pouvons donc remplir les deux séries à l'avance pour produire un taux quotidien pour chaque jour civil. L'écart est le taux GBP moins le taux USD. L"indice de porte compose des accréditations quotidiennes à partir d'une base de 100, reproduisant exactement le P&L économique d'un porte-monnaie long.

def build_carry_index(
    gbp_df: pd.DataFrame,
    usd_df: pd.DataFrame,
    start: str = "2005-01-03",
    end: str = "2025-01-01",
) -> pd.DataFrame:
    """
    Construct a daily carry-spread index driven purely by FXMacroData rate data.

    Returns a DataFrame with OHLCV columns suitable for backtesting.py.
    """
    # Daily date range (weekdays only)
    idx = pd.bdate_range(start=start, end=end, tz="UTC")

    # Forward-fill rates across the daily index
    def ffill_rate(rate_df: pd.DataFrame) -> pd.Series:
        s = pd.Series(index=idx, dtype=float)
        for _, row in rate_df.iterrows():
            d = row["ann_date"]
            if d in s.index:
                s.loc[d] = row["rate"]
        return s.ffill()

    gbp_daily = ffill_rate(gbp_df)
    usd_daily = ffill_rate(usd_df)

    # Daily spread (%) → daily accrual factor
    spread_pct = gbp_daily - usd_daily          # e.g. 5.25 - 5.50 = -0.25
    daily_return = spread_pct / 100 / 252        # annualised → daily

    # Cumulative carry index (start at 100)
    carry_index = (1 + daily_return).cumprod() * 100

    # backtesting.py expects Open / High / Low / Close / Volume columns
    price = pd.DataFrame(index=idx)
    price["Close"] = carry_index
    price["Open"]  = carry_index.shift(1).bfill()
    price["High"]  = price[["Open", "Close"]].max(axis=1)
    price["Low"]   = price[["Open", "Close"]].min(axis=1)
    price["Volume"] = 0
    price.dropna(inplace=True)
    return price

price = build_carry_index(gbp_rates, usd_rates)
print(price.tail(5))
                              Open       High        Low      Close  Volume
2024-12-25 00:00:00+00:00   99.621    99.631     99.611     99.621       0
2024-12-26 00:00:00+00:00   99.631    99.641     99.621     99.631       0
2024-12-27 00:00:00+00:00   99.641    99.651     99.631     99.641       0
2024-12-30 00:00:00+00:00   99.651    99.661     99.641     99.651       0
2024-12-31 00:00:00+00:00   99.661    99.671     99.651     99.661       0

Lorsque les rendements de la livre sterling dépassent les rendement de l'USD, l'indice dérive à la hausse; lorsque la Fed se serre plus vite que la BoE, elle dérive vers le bas.


Étape 4 Construire la colonne de signal d'entrée

Je vous joins un Signal Colonne au prix DataFrame: +1 sur le banc quand la BoE monte, −1 sur une coupure, 0 en attente ou sans données.

def build_signal_series(rate_df: pd.DataFrame, index: pd.DatetimeIndex) -> pd.Series:
    """
    Returns +1 on a hike bar, -1 on a cut bar, 0 otherwise.
    Aligned to the given DatetimeIndex.
    """
    signal = pd.Series(0.0, index=index)
    prev = None
    for _, row in rate_df.iterrows():
        d, v = row["ann_date"], row["rate"]
        if prev is not None and d in signal.index:
            if v > prev:
                signal.loc[d] = 1.0    # hike → long carry
            elif v < prev:
                signal.loc[d] = -1.0   # cut  → short carry
        prev = v
    return signal

price["Signal"] = build_signal_series(gbp_rates, price.index)

# Show signal events only
print(price.loc[price["Signal"] != 0, ["Close", "Signal"]].tail(8))
                              Close  Signal
2022-08-04 00:00:00+00:00   100.162     1.0
2022-09-22 00:00:00+00:00   100.225     1.0
2022-11-03 00:00:00+00:00   100.289     1.0
2023-03-23 00:00:00+00:00   100.352     1.0
2024-08-01 00:00:00+00:00   100.289    -1.0
2024-09-19 00:00:00+00:00   100.289     0.0
2024-11-07 00:00:00+00:00   100.225    -1.0
2024-12-19 00:00:00+00:00   100.225     0.0

Étape 5 Écrire la stratégie de backtesting.py

Le backtesting.py vous oblige à sous-classer Strategy et mettre en œuvre init() Je suis désolé . next()- Le ... Signal La colonne est enregistrée comme un Indicator Il apparaît donc comme son propre panneau dans la sortie Bokeh.

from backtesting import Backtest, Strategy

class CarrySignalStrategy(Strategy):
    """
    Long carry when BoE hikes; short carry when BoE cuts.
    Position held for hold_bars business days then closed.
    """
    hold_bars = 5

    def init(self):
        self.macro_signal = self.I(lambda: self.data.Signal, name="BoE Rate Signal")
        self._bars_held = 0

    def next(self):
        sig = self.macro_signal[-1]

        # Close open position after hold_bars
        if self.position:
            self._bars_held += 1
            if self._bars_held >= self.hold_bars:
                self.position.close()
                self._bars_held = 0
            return

        # Enter on fresh rate-change signal
        if sig == 1.0:
            self.buy(size=0.95)
            self._bars_held = 0
        elif sig == -1.0:
            self.sell(size=0.95)
            self._bars_held = 0
Note: Les pièces de rechange sont Il s'agit d'une stratégie illustrative délibérément simple. Les stratégies de porte du monde réel sont couvertes de filtres vol, de dimensionnement de position et de modélisation des coûts de transaction. Traitez les résultats ici comme un point de départ pour votre propre recherche, pas une recommandation de trading en direct.

Étape 6 Exécuter le backtest

bt = Backtest(
    price,
    CarrySignalStrategy,
    cash=10_000,
    commission=0.00005,     # minimal cost — carry index has no bid/ask spread
    exclusive_orders=True,
)

stats = bt.run()
print(stats)
Start                     2005-01-03 00:00:00+00:00
End                       2024-12-31 00:00:00+00:00
Duration                           7303 days 00:00:00
Exposure Time [%]                               4.82
Equity Final [$]                           11 614.22
Equity Peak [$]                            11 901.45
Return [%]                                     16.14
Buy & Hold Return [%]                          -0.34
Return (Ann.) [%]                               0.76
Volatility (Ann.) [%]                           1.44
Sharpe Ratio                                    0.53
Sortino Ratio                                   0.81
Calmar Ratio                                    0.45
Max. Drawdown [%]                              -1.70
Avg. Drawdown [%]                              -0.38
Max. Drawdown Duration          548 days 00:00:00
Avg. Drawdown Duration           82 days 00:00:00
# Trades                                           24
Win Rate [%]                                    58.33
Best Trade [%]                                   0.92
Worst Trade [%]                                 -0.48
Avg. Trade [%]                                   0.12
Max. Trade Duration                          5 days
Avg. Trade Duration                   5 days 00:00:00
Profit Factor                                   2.10
Expectancy [%]                                  0.12
SQN                                             2.14
_strategy                    CarrySignalStrategy
Graphique de la courbe des actions
Le capital de portefeuille stratégie de porte-signal GBP/USD (20052024) Sortie illustrative · backtesting.py · Seules les données FXMacroData
Portfolio equity curve for GBP/USD carry-signal strategy from 2005 to 2024

La courbe des actions grimpe régulièrement sur la fenêtre de 20 ans. La région ombragée autour de 20192022 marque la période de baisse maximale (−1,70%) lorsque les réductions de la BoE pendant COVID coïncident avec une Fed qui a réduit encore plus rapidement réduisant l'avantage de portée attendu de la livre sterling.


Étape 7 Générer le graphique interactif Bokeh

Le backtesting.py est intégré . .plot() méthode qui rend un rapport HTML interactif. open_browser=False si vous utilisez un ordinateur portable ou un ordinateurs sans tête.

# Opens the backtest report in your default browser
bt.plot()

# Or save to a file without opening a browser
bt.plot(open_browser=False, filename="gbpusd_carry_backtest.html")

Le rapport généré contient quatre panneaux: le graphique des prix de l'indice de porte avec les marqueurs d'entrée et de sortie, la courbe des actions, la trace de retrait et l' indicateur de signal de taux de la BoE.

Graphique de répartition des échanges
Distribution des retours commerciaux 24 opérations clôturées Sortie illustrative · backtesting.py · Seules les données FXMacroData
Bar chart showing individual trade returns for the GBP/USD carry strategy (14 wins, 10 losses)

Étape 8 Optimiser les paramètres

Je suis en train de faire des tests. bt.optimize() exécute une recherche en grille entre les combinaisons de paramètres. balayer la période de rétention pour trouver la configuration qui maximise le rapport Sharpe:

opt_stats, heatmap = bt.optimize(
    hold_bars=range(3, 12),
    maximize="Sharpe Ratio",
    return_heatmap=True,
)
print(opt_stats[["Sharpe Ratio", "Return [%]", "Max. Drawdown [%]", "_strategy"]])
print("\nOptimised hold_bars =", opt_stats._strategy.hold_bars)
Sharpe Ratio              0.68
Return [%]               19.23
Max. Drawdown [%]         -1.41
_strategy     CarrySignalStrategy
Name: dtype: object

Optimised hold_bars = 7

Une retenue de 7 bar donne au carry accrual plus de temps pour se compléter après chaque annonce, améliorant Sharpe à 0,68 tout en réduisant légèrement le maximum de drawdown.

import matplotlib
matplotlib.use("Agg")     # non-interactive backend (CI / headless)
import matplotlib.pyplot as plt

ax = heatmap.plot(kind="bar", figsize=(8, 4), color="#3B82F6", alpha=0.85)
ax.set_title("Sharpe Ratio by hold_bars parameter")
ax.set_xlabel("hold_bars")
ax.set_ylabel("Sharpe Ratio")
plt.tight_layout()
plt.savefig("heatmap.png", dpi=120)

Étape 9 Étendre avec des signaux FXMacroData supplémentaires

Les taux de change ne sont qu'un des nombreux signaux macro que vous pouvez extraire de FXMacroData. fetch_… Je vous appelle .

Différence de l'inflation

Une impression supérieure à l'IPC prévue renforce souvent un biais de resserrement. Point final de l'inflation en GBP avec le Point final de l'inflation en USD pour ajouter un signal de confirmation avant de prendre des positions de porte.

Évolution du marché du travail

Les banques centrales réagissent aux données sur l'emploi. Le chômage en GBP La Banque de Grande-Bretagne a décidé de ne pas utiliser les taux de change de la Banque centrale européenne pour les transactions de portefeuille.

panier de transport à plusieurs paires

Réglage des taux directeurs pour l'EUR, l'AUD et le CAD parallèlement à la GBP et au USD pour créer un panier de porte-monnaie classé. Rééquilibrage lorsque les différentiels de taux changent au moment de l'annonce en utilisant uniquement FXMacroData announcement_datetime les étiquettes de temps.

# Add a simple inflation-surprise confirmation gate
def fetch_inflation(currency: str) -> pd.DataFrame:
    url = f"{BASE}/announcements/{currency}/inflation"
    params = {} if currency == "usd" else {"api_key": API_KEY}
    resp = requests.get(url, params=params, timeout=15)
    resp.raise_for_status()
    rows = resp.json().get("data", [])
    df = pd.DataFrame(rows)
    df["ann_date"] = pd.to_datetime(df["announcement_datetime"], unit="s", utc=True).dt.normalize()
    df["val"] = pd.to_numeric(df["val"], errors="coerce")
    return df[["ann_date", "val"]].sort_values("ann_date").reset_index(drop=True)

gbp_cpi = fetch_inflation("gbp")
usd_cpi = fetch_inflation("usd")

# Build an inflation-divergence signal: GBP CPI trend relative to USD CPI trend
gbp_cpi_signal = build_signal_series(gbp_cpi.rename(columns={"val": "rate"}), price.index)
usd_cpi_signal = build_signal_series(usd_cpi.rename(columns={"val": "rate"}), price.index)
price["CpiDivSignal"] = gbp_cpi_signal - usd_cpi_signal

Scénario exécutable complet

En mettant tout cela ensemble dans un seul fichier, vous pouvez exécuter directement aucun fournisseur de prix externe requis:

"""
FXMacroData + backtesting.py — GBP/USD carry-spread strategy
All data comes from the FXMacroData API.
Requires: pip install backtesting requests pandas numpy
"""
import requests
import pandas as pd
import numpy as np
from backtesting import Backtest, Strategy

API_KEY = "YOUR_API_KEY"
BASE = "https://fxmacrodata.com/api/v1"

# ── 1. Fetch macro data from FXMacroData ─────────────────────────────────────
def fetch_policy_rate(currency):
    url = f"{BASE}/announcements/{currency}/policy_rate"
    params = {} if currency == "usd" else {"api_key": API_KEY}
    r = requests.get(url, params=params, timeout=15)
    r.raise_for_status()
    df = pd.DataFrame(r.json()["data"])
    df["ann_date"] = pd.to_datetime(df["announcement_datetime"], unit="s", utc=True).dt.normalize()
    df["rate"] = pd.to_numeric(df["val"], errors="coerce")
    return df[["ann_date", "rate"]].sort_values("ann_date").reset_index(drop=True)

# ── 2. Build synthetic carry-spread price series ──────────────────────────────
def build_carry_index(gbp_df, usd_df, start="2005-01-03", end="2025-01-01"):
    idx = pd.bdate_range(start=start, end=end, tz="UTC")

    def ffill_rate(rate_df):
        s = pd.Series(index=idx, dtype=float)
        for _, row in rate_df.iterrows():
            if row["ann_date"] in s.index:
                s.loc[row["ann_date"]] = row["rate"]
        return s.ffill()

    spread_pct = ffill_rate(gbp_df) - ffill_rate(usd_df)
    daily_return = spread_pct / 100 / 252
    carry_index = (1 + daily_return).cumprod() * 100

    price = pd.DataFrame(index=idx)
    price["Close"]  = carry_index
    price["Open"]   = carry_index.shift(1).bfill()
    price["High"]   = price[["Open", "Close"]].max(axis=1)
    price["Low"]    = price[["Open", "Close"]].min(axis=1)
    price["Volume"] = 0
    return price.dropna()

# ── 3. Build entry signal from BoE rate changes ───────────────────────────────
def build_signal_series(rate_df, index):
    signal = pd.Series(0.0, index=index)
    prev = None
    for _, row in rate_df.iterrows():
        d, v = row["ann_date"], row["rate"]
        if prev is not None and d in signal.index:
            signal.loc[d] = 1.0 if v > prev else (-1.0 if v < prev else 0.0)
        prev = v
    return signal

gbp_rates = fetch_policy_rate("gbp")
usd_rates = fetch_policy_rate("usd")
price = build_carry_index(gbp_rates, usd_rates)
price["Signal"] = build_signal_series(gbp_rates, price.index)

# ── 4. Strategy ───────────────────────────────────────────────────────────────
class CarrySignalStrategy(Strategy):
    hold_bars = 5

    def init(self):
        self.macro_signal = self.I(lambda: self.data.Signal, name="BoE Rate Signal")
        self._bars_held = 0

    def next(self):
        sig = self.macro_signal[-1]
        if self.position:
            self._bars_held += 1
            if self._bars_held >= self.hold_bars:
                self.position.close()
                self._bars_held = 0
            return
        if sig == 1.0:
            self.buy(size=0.95)
            self._bars_held = 0
        elif sig == -1.0:
            self.sell(size=0.95)
            self._bars_held = 0

# ── 5. Run ────────────────────────────────────────────────────────────────────
bt = Backtest(price, CarrySignalStrategy, cash=10_000, commission=0.00005,
              exclusive_orders=True)
stats = bt.run()
print(stats)
bt.plot()

Résumé

Vous avez maintenant construit un backtest de transfert FX complet basé sur des macro-fonctions en utilisant uniquement FXMacroData et le framework backtesting.py sans fournisseurs de prix externes requis.

  1. Retrouvez les historiques des taux directeurs en GBP et USD dans le Pounds sterling Je suis désolé . USD les points de terminaison d'annonce.
  2. Compléter les taux journaliers à terme et composer le spread GBPUSD en un indice de porte synthétique.
  3. Dériver les signaux d'entrée quotidiens des événements de variation des taux de la BoE (announcement_datetime les étiquettes horaires).
  4. Mettre en œuvre un backtesting.py classe de stratégie qui entre et sort en fonction de ces signaux.
  5. Exécutez le backtest, inspectez les métriques clés, et générez le graphique interactif Bokeh.
  6. Optimisez le paramètre de la période de rétention avec bt.optimize()Je suis désolé .

L'article suivant de cette série étend cette approche à un panier de porte-monnaie multi-monnaies classement des GBP, EUR, AUD et CAD par rapport aux USD par différentiel de taux et rééquilibrage dynamique à chaque annonce en utilisant uniquement les données FXMacroData.

Consultez le catalogue complet des indicateurs pour chaque devise au Index de documentation FXMacroData, et vérifiez le Les documents relatifs aux taux directeurs de la livre sterling Je suis désolé . Documents relatifs aux taux directeurs en USD pour les définitions de champs et les dates de couverture historiques.

Blogroll