Automating MACD Crossovers On Forex
Introduction
The Moving Average Convergence Divergence (MACD) indicator, developed by Gerald Appel, is one of the most popular trend-following tools in forex trading. It captures the difference between two exponential moving averages and compares that difference to its own smoothed average, generating crossover signals that identify momentum shifts. In the forex market -- which trades 24 hours, 5.5 days per week with $7.5 trillion in daily volume -- MACD crossovers provide a systematic framework for capturing trending moves across major, minor, and cross currency pairs. This article covers MACD implementation optimized for forex, session-aware signal filtering, carry trade integration, and rigorous multi-pair backtesting.MACD Mathematics
The MACD consists of three components: MACD Line: The difference between fast and slow EMAs: $$ MACD_t = EMA_{fast}(Close_t) - EMA_{slow}(Close_t) $$ Standard parameters: fast = 12, slow = 26. Signal Line: An EMA of the MACD line: $$ Signal_t = EMA_9(MACD_t) $$ Histogram: The difference between MACD and Signal: $$ Histogram_t = MACD_t - Signal_t $$ The EMA is calculated recursively: $$ EMA_t = \alpha \cdot Close_t + (1 - \alpha) \cdot EMA_{t-1} $$ where $\alpha = 2 / (period + 1)$. If you want to verify these formulas against real market data rather than re-deriving them, our indicator formulas reference cross-checks MACD, RSI, ATR, and Bollinger Bands against pandas_ta on real bars.Implementation for Forex
``python
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
from dataclasses import dataclass
@dataclass
class MACDState:
macd_line: float
signal_line: float
histogram: float
histogram_slope: float
crossover: int # 1 = bullish, -1 = bearish, 0 = none
class ForexMACD:
"""
MACD calculator optimized for forex with pip-based normalization.
"""
def __init__(self, fast: int = 12, slow: int = 26, signal: int = 9):
self.fast = fast
self.slow = slow
self.signal_period = signal
def calculate(self, close: pd.Series) -> pd.DataFrame:
"""Compute MACD components."""
ema_fast = close.ewm(span=self.fast, adjust=False).mean()
ema_slow = close.ewm(span=self.slow, adjust=False).mean()
macd_line = ema_fast - ema_slow
signal_line = macd_line.ewm(span=self.signal_period, adjust=False).mean()
histogram = macd_line - signal_line
result = pd.DataFrame({
'macd': macd_line,
'signal': signal_line,
'histogram': histogram,
'hist_slope': histogram.diff(),
'crossover': np.sign(macd_line - signal_line).diff().fillna(0)
}, index=close.index)
# Normalize MACD by ATR for cross-pair comparison
# This allows comparing EUR/USD MACD with USD/JPY MACD
returns = close.pct_change()
atr_proxy = returns.rolling(14).std() * close
result['macd_normalized'] = macd_line / atr_proxy
return result
def detect_crossover(self, macd_df: pd.DataFrame) -> pd.Series:
"""
Detect MACD crossovers with confirmation.
Bullish: MACD crosses above signal
Bearish: MACD crosses below signal
"""
crossover = macd_df['crossover']
# Require histogram confirmation (histogram must be expanding)
confirmed = crossover.copy()
expanding = macd_df['hist_slope'] * np.sign(crossover) > 0
confirmed[~expanding] = 0
return confirmed
class ForexSessionFilter:
"""
Filter signals by forex trading session.
Most trends develop during London and NY sessions.
"""
SESSIONS = {
'tokyo': (0, 9), # 00:00-09:00 UTC
'london': (7, 16), # 07:00-16:00 UTC
'new_york': (13, 22), # 13:00-22:00 UTC
'overlap': (13, 16), # London-NY overlap (highest volume)
}
def __init__(self, active_sessions: List[str] = None):
self.sessions = active_sessions or ['london', 'new_york']
def is_active(self, timestamp: pd.Timestamp) -> bool:
"""Check if current time is within active trading sessions."""
hour = timestamp.hour
for session in self.sessions:
start, end = self.SESSIONS[session]
if start <= hour < end:
return True
return False
def filter_signals(self, signals: pd.DataFrame) -> pd.DataFrame:
"""Only keep signals during active sessions."""
mask = signals.index.map(lambda ts: self.is_active(ts))
filtered = signals.copy()
filtered.loc[~mask, 'position'] = 0
return filtered
`
Forex-Specific MACD Strategy
`python
class ForexMACDStrategy:
"""
MACD crossover strategy with forex-specific enhancements:
- Session filtering (trade London/NY only)
- Carry trade alignment
- Multi-timeframe confirmation
- Pip-based position sizing
"""
def __init__(self, pairs: List[str], timeframe: str = '4H',
risk_per_trade_pct: float = 0.01):
self.pairs = pairs
self.timeframe = timeframe
self.risk_pct = risk_per_trade_pct
self.macd = ForexMACD()
self.session_filter = ForexSessionFilter()
def generate_signals(self, data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
"""Generate MACD signals for all pairs."""
all_signals = {}
for pair in self.pairs:
df = data.get(pair)
if df is None or len(df) < 100:
continue
# Primary MACD (4H timeframe)
macd_data = self.macd.calculate(df['close'])
crossovers = self.macd.detect_crossover(macd_data)
# Daily MACD for trend confirmation
daily = df['close'].resample('1D').last().dropna()
daily_macd = self.macd.calculate(daily)
signals = pd.DataFrame(index=df.index)
signals['macd'] = macd_data['macd']
signals['signal_line'] = macd_data['signal']
signals['histogram'] = macd_data['histogram']
signals['crossover'] = crossovers
# Position: follow crossovers with trend filter
signals['position'] = 0
signals.loc[crossovers > 0, 'position'] = 1 # Bullish crossover -> long
signals.loc[crossovers < 0, 'position'] = -1 # Bearish crossover -> short
signals['position'] = signals['position'].replace(0, np.nan).ffill().fillna(0)
# Shift to avoid look-ahead
signals['position'] = signals['position'].shift(1).fillna(0)
# Position sizing in lots
signals['atr'] = self._compute_atr(df)
signals['stop_pips'] = signals['atr'] * 1.5 / self._pip_value(pair)
all_signals[pair] = signals
return all_signals
def _compute_atr(self, df: pd.DataFrame, period: int = 14) -> pd.Series:
tr = pd.concat([
df['high'] - df['low'],
abs(df['high'] - df['close'].shift()),
abs(df['low'] - df['close'].shift())
], axis=1).max(axis=1)
return tr.rolling(period).mean()
@staticmethod
def _pip_value(pair: str) -> float:
"""Return pip value for a currency pair."""
jpy_pairs = ['USDJPY', 'EURJPY', 'GBPJPY', 'AUDJPY']
if pair.replace('/', '') in jpy_pairs:
return 0.01
return 0.0001
def compute_position_size(self, pair: str, account_balance: float,
stop_pips: float) -> float:
"""
Calculate position size in standard lots.
For EUR/USD with $100K account, 1% risk, 50 pip stop:
Risk = $1,000
Pip value per lot = $10
Lots = $1,000 / (50 * $10) = 2.0 lots
"""
risk_amount = account_balance * self.risk_pct
pip_value = self._pip_value(pair)
# Standard lot pip values (approximate)
pip_value_per_lot = {
'EUR/USD': 10.0, 'GBP/USD': 10.0, 'AUD/USD': 10.0,
'USD/JPY': 6.5, 'USD/CHF': 10.2, 'USD/CAD': 7.5,
'EUR/GBP': 12.7, 'EUR/JPY': 6.5, 'GBP/JPY': 6.5,
}.get(pair, 10.0)
lots = risk_amount / (stop_pips * pip_value_per_lot)
return round(lots, 2)
`
Carry Trade Integration
Align MACD signals with interest rate differentials for higher probability:
`python
class CarryFilter:
"""
Filter MACD signals to align with carry direction.
Going long the high-yielding currency adds the carry return.
"""
# Approximate annual rates (update periodically)
RATES = {
'USD': 4.50, 'EUR': 3.75, 'GBP': 4.25, 'JPY': 0.10,
'AUD': 4.10, 'CAD': 3.50, 'CHF': 1.50, 'NZD': 4.75,
}
def carry_direction(self, pair: str) -> int:
"""
Returns +1 if going long the pair earns carry,
-1 if going short earns carry.
"""
base = pair[:3]
quote = pair[4:] if '/' in pair else pair[3:]
base_rate = self.RATES.get(base, 0)
quote_rate = self.RATES.get(quote, 0)
if base_rate > quote_rate:
return 1 # Long base earns carry
elif quote_rate > base_rate:
return -1 # Short base (long quote) earns carry
return 0
def filter_signals(self, signals: pd.DataFrame, pair: str) -> pd.DataFrame:
"""
Only take trades in the carry direction, or flatten otherwise.
"""
carry_dir = self.carry_direction(pair)
filtered = signals.copy()
if carry_dir != 0:
# Remove signals against carry direction
against_carry = filtered['position'] * carry_dir < 0
filtered.loc[against_carry, 'position'] = 0
return filtered
`
Backtesting Forex MACD
`python
class ForexBacktester:
"""Forex-specific backtester with spread and swap costs."""
def __init__(self, account_balance: float = 100_000,
leverage: int = 50):
self.balance = account_balance
self.leverage = leverage
# Typical spreads in pips
SPREADS = {
'EUR/USD': 0.8, 'GBP/USD': 1.2, 'USD/JPY': 0.9,
'AUD/USD': 1.1, 'USD/CAD': 1.4, 'EUR/GBP': 1.5,
}
def run(self, signals: pd.DataFrame, pair: str) -> pd.DataFrame:
results = signals.copy()
spread_cost = self.SPREADS.get(pair, 1.5) * ForexMACDStrategy._pip_value(pair)
results['return'] = results.index.to_series().diff().dt.total_seconds() # placeholder
results['return'] = signals['close'].pct_change() if 'close' in signals else 0
results['strategy_return'] = results['position'] * results['return']
# Spread cost on position changes
results['trade'] = results['position'].diff().abs()
results['spread_cost'] = results['trade'] * spread_cost / signals.get('close', pd.Series(1))
results['net_return'] = results['strategy_return'] - results['spread_cost']
results['equity'] = self.balance * (1 + results['net_return'].fillna(0)).cumprod()
return results
``
Pair Selection by MACD Effectiveness
Not all currency pairs respond equally to MACD signals. Trending pairs (those with higher Hurst exponents) produce better MACD results:| Pair | Hurst Exponent | MACD Sharpe (2020-2025) | Recommended |
|---|---|---|---|
| EUR/USD | 0.48 | 0.35 | Marginal |
| GBP/USD | 0.51 | 0.52 | Yes |
| USD/JPY | 0.53 | 0.61 | Yes |
| AUD/USD | 0.52 | 0.55 | Yes |
| EUR/JPY | 0.54 | 0.68 | Best |
| GBP/JPY | 0.55 | 0.72 | Best |