Skip to content
All library documents

Backtesting a USD/JPY EMA Crossover with FX Costs and Rollover

Code NautilusTrader

Summary

This tutorial demonstrates a simulated USD/JPY strategy that compares 10-period and 20-period exponential moving averages on internally aggregated five-minute bid bars. A cross upward closes shorts and opens a long; a cross downward closes longs and opens a short. The simulation uses one-minute bid and ask data, a margin account with hedging, market and maker-taker costs, probabilistic fills with slippage, and daily FX rollover interest based on sample short-term rate data. It also describes report generation for account state, fills, and closed positions.

The example reports a 28-day run with 8,065 five-minute bars and 234 closed cycles. It records 70 profitable cycles, realized losses of 1,326,300 JPY, and commissions of 871,300 JPY, illustrating how frequent reversals in a noisy series can be costly. The author explicitly presents the crossover as a teaching example without an edge. Results depend on the sample period, synthetic quote construction, and assumed execution model; the tutorial suggests slower signals, a range-based regime filter, and comparing bar aggregation methods, but supplies no evidence those changes improve performance.

Key ideas

  • The strategy reverses direction when a 10-period EMA crosses a 20-period EMA on five-minute USD/JPY bid bars.
  • The simulation models fees, probabilistic fills, possible one-tick slippage, and daily rollover interest.
  • The reported run has 234 closed cycles, of which 70 are profitable, and ends with a realized JPY loss.
  • Frequent crossovers on noisy bars can create a whipsaw pattern and substantial transaction costs.
  • The author treats the system as an educational example and proposes testing slower signals and regime filters.

Tags

Full text
# backtest_fx_bars.py


```py
# %% [markdown]
# # Backtest with FX Bar Data
#
# Run an EMA cross strategy on USD/JPY 1-minute bid/ask bars with FX rollover
# interest and a probabilistic fill model. The data comes from the
# NautilusTrader test data: a source checkout reads it locally, and other
# installs download it from GitHub on each run, so they need network access.
#
# [View source on GitHub](https://github.com/nautechsystems/nautilus_trader/blob/develop/docs/tutorials/backtest_fx_bars.py).

# %% [markdown]
# ## Introduction
#
# The strategy is `EMACross`, a teaching example that compares a fast EMA
# against a slow EMA on bar closes:
#
# - **Fast EMA crosses above slow EMA**: any short position is closed and a new
#   long is opened.
# - **Fast EMA crosses below slow EMA**: any long position is closed and a new
#   short is opened.
#
# The venue is a simulated FX ECN with a `MARGIN` account, `HEDGING` OMS, and
# multi-currency starting balances of 1,000,000 USD and 10,000,000 JPY. A
# `FillModel` introduces a 50% probability of one-tick slippage, and the
# `FXRolloverInterestModule` applies daily rollover at the relevant short-term
# interest differential.
#
# `EMACross` is a teaching strategy and has no edge.
#
# ```mermaid
# flowchart LR
#     subgraph Inputs ["Data streams"]
#         B["1-minute BID bar (FXCM)"]
#         A["1-minute ASK bar (FXCM)"]
#     end
#
#     subgraph Wrangler ["QuoteTickDataWrangler"]
#         Q["QuoteTick stream"]
#     end
#
#     subgraph Engine ["Backtest engine"]
#         AGG["5-minute BID INTERNAL aggregator"]
#         BAR["Bar close"]
#         F1(("EMA(10)"))
#         F2(("EMA(20)"))
#     end
#
#     subgraph Decision ["Crossover decision"]
#         X{{"fast >= slow"}}
#         Y{{"fast < slow"}}
#     end
#
#     subgraph Orders ["Orders"]
#         L["Close shorts -> BUY market"]
#         S["Close longs  -> SELL market"]
#     end
#
#     B --> Q
#     A --> Q
#     Q --> AGG --> BAR
#     BAR --> F1 --> X
#     BAR --> F2 --> X
#     F1 --> Y
#     F2 --> Y
#     X -->|cross up| L
#     Y -->|cross down| S
# ```

# %% [markdown]
# ## Prerequisites
#
# - Python 3.13+
# - [NautilusTrader](https://pypi.org/project/nautilus_trader/) 2.x installed
#   (`pip install -U --pre nautilus_trader`). The `visualization` extra is only
#   needed if you also want to regenerate the panels at the end of the tutorial.
# - pandas (`pip install pandas`). The wheel declares no runtime dependencies.
# - The sibling
#   [`ema_cross.py`](https://github.com/nautechsystems/nautilus_trader/blob/develop/docs/tutorials/ema_cross.py)
#   file. Keep it next to this tutorial when downloading or converting it with
#   Jupytext.

# %%
from decimal import Decimal

from nautilus_trader.common import LogLevel
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.backtest import BacktestEngine
from nautilus_trader.backtest import FXRolloverInterestModule
from nautilus_trader.backtest import InterestRateRecord
from nautilus_trader.config import LoggerConfig
from nautilus_trader.config import RiskEngineConfig
from nautilus_trader.execution import MakerTakerFeeModel
from nautilus_trader.execution import ProbabilisticFillModel
from nautilus_trader.model import AccountType
from nautilus_trader.model import BarType
from nautilus_trader.model import Currency
from nautilus_trader.model import Money
from nautilus_trader.model import OmsType
from nautilus_trader.model import TraderId
from nautilus_trader.model import Venue
from nautilus_trader.testkit.providers import TestDataProvider
from nautilus_trader.testkit.providers import TestInstrumentProvider

from ema_cross import EMACross
from ema_cross import EMACrossConfig


JPY = Currency.from_str("JPY")
USD = Currency.from_str("USD")


# %% [markdown]
# ## Engine setup
#
# Pre-trade risk checks are bypassed so the strategy's market orders flow
# straight through to the matching engine.

# %%
config = BacktestEngineConfig(
    trader_id=TraderId.from_str("BACKTESTER-001"),
    logging=LoggerConfig(stdout_level=LogLevel.ERROR),
    risk_engine=RiskEngineConfig(bypass=True),
)
engine = BacktestEngine(config=config)

# %% [markdown]
# ## Simulation modules
#
# `FXRolloverInterestModule` charges or credits rollover interest on open
# positions at a fixed 17:00 New York cutover, using the sample
# `short-term-interest.csv` rates from the OECD short-term interest series.
# Without it a backtest spanning many sessions ignores carry.

# %%
provider = TestDataProvider()
interest_rate_data = provider.read_csv("short-term-interest.csv")
interest_rate_records = [
    InterestRateRecord(location=row.LOCATION, time=row.TIME, value=row.Value)
    for row in interest_rate_data.itertuples(index=False)
]
fx_rollover_interest = FXRolloverInterestModule(records=interest_rate_records)

# %% [markdown]
# ## Fill model
#
# Limit orders fill on a 20% probability per tick when their price is reached,
# and any market or marketable order draws a one-tick slip on a 50% coin flip.
# The seed makes the run reproducible.

# %%
fill_model = ProbabilisticFillModel(
    prob_fill_on_limit=0.2,
    prob_slippage=0.5,
    random_seed=42,
)

# %% [markdown]
# ## Venue
#
# `OmsType.HEDGING` lets the strategy carry concurrent long and short positions
# in the same instrument and have the venue assign position IDs. The account is
# multi-currency so PnL on USD/JPY accrues in JPY rather than being converted
# on every fill.

# %%
SIM = Venue("SIM")
engine.add_venue(
    venue=SIM,
    oms_type=OmsType.HEDGING,
    account_type=AccountType.MARGIN,
    base_currency=None,
    starting_balances=[Money(1_000_000, USD), Money(10_000_000, JPY)],
    fill_model=fill_model,
    fee_model=MakerTakerFeeModel(
        maker_rate=Decimal("0.00002"),
        taker_rate=Decimal("0.00002"),
    ),
    modules=[fx_rollover_interest],
)

# %% [markdown]
# ## Instrument and data
#
# `TestDataProvider.quotes_from_fxcm_bars` synthesizes quote ticks from each
# minute's open, high, low, and close in the sample FXCM bid and ask CSVs.
# The strategy declares `5-MINUTE-BID-INTERNAL`, so the engine builds 5-minute
# BID bars from the quote stream internally.

# %%
USDJPY_SIM = TestInstrumentProvider.default_fx_ccy("USD/JPY", SIM)
engine.add_instrument(USDJPY_SIM)

ticks = provider.quotes_from_fxcm_bars(
    instrument=USDJPY_SIM,
    bid_csv="fxcm/usdjpy-m1-bid-2013.csv",
    ask_csv="fxcm/usdjpy-m1-ask-2013.csv",
)
engine.add_data(ticks)

# %% [markdown]
# ## Strategy
#
# Trade size is one million USD per order. EMACross cancels and replaces the
# position on every crossover, so the strategy is in some position for nearly
# the whole month.

# %%
strategy_config = EMACrossConfig(
    instrument_id=USDJPY_SIM.id,
    bar_type=BarType.from_str("USD/JPY.SIM-5-MINUTE-BID-INTERNAL"),
    fast_ema_period=10,
    slow_ema_period=20,
    trade_size=Decimal(1_000_000),
)
strategy = EMACross(config=strategy_config)
engine.add_strategy(strategy=strategy)

# %% [markdown]
# ## Run
#
# The engine processes every quote tick and bar in timestamp order, then
# returns when the data is exhausted.

# %%
engine.run()

# %% [markdown]
# ## Reports
#
# `engine.generate_*` returns DataFrames covering the account state, the
# fills, and the closed positions.

# %%
engine.generate_account_report(SIM)

# %%
engine.generate_order_fills_report()

# %%
engine.generate_positions_report()

# %% [markdown]
# ## What the run produces
#
# A 28-day run prints 8,065 5-minute bars and triggers 234 closed cycles
# across 468 fills (every crossover after the first emits a closing fill on
# the previous position and an opening fill on the new one). 70 of the 234
# cycles are profitable. Realized PnL ends at -1,326,300 JPY, of which
# 871,300 JPY is commission: a textbook whipsaw signature on a noisy 5-minute
# series.
#
# ![USD/JPY 5-minute close with EMAs across the month](./assets/backtest_fx_bars/panel_a_price_overview.png)
#
# **Figure 1.** *USD/JPY BID close at 5-minute resolution across 2013-02 with
# EMA(10) and EMA(20) overlaid. Long flat patches are weekend gaps in the FXCM
# bid feed.*
#
# ![Three-day zoom on crossovers](./assets/backtest_fx_bars/panel_b_zoom.png)
#
# **Figure 2.** *Zoom on 2013-02-12 to 2013-02-15 UTC. Each marker is a
# crossover entry: triangles up are long, triangles down are short.*
#
# ![Cumulative realized pnl](./assets/backtest_fx_bars/panel_c_pnl_curve.png)
#
# **Figure 3.** *Cumulative JPY pnl before commissions across all closed
# cycles. Marker color encodes per-cycle pnl: blue = positive, red = negative.*
#
# ![Hold-time and pnl distributions](./assets/backtest_fx_bars/panel_d_distributions.png)
#
# **Figure 4.** *Cycle hold time and per-cycle pnl distributions. Most cycles
# hold for under three hours; the pnl distribution is roughly symmetric and
# heavily concentrated near zero.*

# %% [markdown]
# ### Regenerate the panels
#
# The panels above are produced by a self-contained renderer that re-runs the
# backtest, pulls bars and fills from the engine cache, and writes PNGs using
# the shared `nautilus_dark` tearsheet theme.
#
# After building NautilusTrader from source, run these commands from the repository root:
#
# ```bash
# make sync
# uv run --project python --no-sync \
#     python docs/tutorials/assets/backtest_fx_bars/render_panels.py
# ```

# %% [markdown]
# ## Next steps
#
# - **Slow the signal**. The default 10/20 EMAs whip in low-trend sessions.
#   Try 20/60 on the same bars or move to 15-minute bars to cut the cycle
#   count.
# - **Add a regime filter**. Suppress entries when realized range is below
#   a threshold so the strategy only trades sessions with directional movement.
# - **Compare aggregations**. Build the bars from raw tick data via
#   `BarType.from_str("USD/JPY.SIM-5-MINUTE-BID-INTERNAL")` against an
#   externally aggregated dataset to confirm both paths agree.

```

Shown in full with attribution under the source's licence. Licence: LGPL-3.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.