Backtesting a USD/JPY EMA Crossover with FX Costs and Rollover
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.
#
# 
#
# **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.*
#
# 
#
# **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.*
#
# 
#
# **Figure 3.** *Cumulative JPY pnl before commissions across all closed
# cycles. Marker color encodes per-cycle pnl: blue = positive, red = negative.*
#
# 
#
# **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.