
The strategy trades 9 weighted currency pairs using a 3x MACD for trend signals, adjusting positions based on volatility thresholds, with daily rebalancing to adapt to market conditions.
ASSET CLASS: CFDs, futures | REGION: Global | FREQUENCY:
Daily | MARKET: currencies | KEYWORD: Time, Series, Momentum, Volatility, Filters, FOREX
I. STRATEGY IN A NUTSHELL
The strategy trades nine major currency pairs weighted by BIS trading volume, using a 3x MACD indicator (1/32, 1/61, 1/117) to identify trends. Daily volatility is measured via the Risk Metrics method, with thresholds optimized in the first year of data. In low-volatility environments, positions follow trend signals (long when short-term MA > long-term MA, short otherwise), while in high-volatility periods the signals are reversed. The portfolio is rebalanced daily to adapt to changing market conditions.
II. ECONOMIC RATIONALE
Periods of high volatility often coincide with frequent price reversals, which undermine trend-following strategies. By reversing trading rules during such periods, the strategy mitigates losses and aligns trades with prevailing market dynamics, improving overall performance.
III. SOURCE PAPER
Volatility Filters for Asset Management: An Application to Managed Futures [Click to Open PDF]
Dunis, Miao, Professor of Banking and Finance at Liverpool John Moores University and Director of CIBEF, Associate Researcher with CIBEF and currently working on his PhD thesis at Liverpool John Moores University
<Abstract>
Technical trading rules are known to perform poorly in periods when volatility is high. The objective of this paper is to study whether addition of volatility filters can improve model performance. Different from previous studies on technical trading rules which base their findings from an academic perspective, this paper tries to relate to the real world business: two portfolios, which are highly correlated with a managed futures index and a currency traders’ benchmark index are formed to replicate the performance of the typical managed futures and managed currency funds. The volatility filters proposed are then applied directly to these two portfolios with the hope that proposed techniques will then have both academic and industrial significance. Two volatility filters are proposed, namely a “no-trade” filter where all market positions are closed in volatile periods, and a “reverse” filter where signals from a simple Moving Average Convergence and Divergence (MACD) are reversed if market volatility is higher than a given threshold. To assess the consistency of model performance, the whole period (04/01/1999 to 31/12/2004) is split into 3 sub-periods. Our results show that the addition of the two volatility filters adds value to the models performance in terms of annualised return, maximum drawdown, risk-adjusted Sharpe ratio and Calmar ratio in all the 3 sub-periods.

IV. BACKTEST PERFORMANCE
| Annualised Return | 4.63% |
| Volatility | 5% |
| Beta | 0.004 |
| Sharpe Ratio | 0.93 |
| Sortino Ratio | N/A |
| Maximum Drawdown | -9.3% |
| Win Rate | 31% |
V. FULL PYTHON CODE
from AlgorithmImports import *
import datetime
import numpy as np
#endregion
class TimeSeriesMomentumCombinedwithVolatilityFiltersinFOREX(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2015, 1, 1)
self.SetCash(100000)
self.period = 12*21
self.SetWarmUp(self.period)
self.current_date = -1
self.symbols = ["USDJPY", "GBPUSD", "EURUSD", "USDCHF", "USDCAD", "AUDUSD", "EURGBP", "EURJPY", "EURCHF"]
self.data = {}
for symbol in self.symbols:
data = self.AddForex(symbol, Resolution.Minute, Market.FXCM)
data.SetFeeModel(CustomFeeModel())
if symbol not in self.data:
ma32 = SimpleMovingAverage(symbol, 32)
ma61 = SimpleMovingAverage(symbol, 61)
ma117 = SimpleMovingAverage(symbol, 117)
self.data[symbol] = SymbolData(symbol, self.period, ma32, ma61, ma117, self.GetWeight(symbol))
def OnData(self, data):
if self.Time.time() == datetime.time(0,0,0):
# Updating last day close.
for symbol in self.symbols:
if symbol in data and data[symbol]:
close = data[symbol].Value
self.data[symbol].update(self.Time, close)
# Trading one minute before day ends.
if (self.Time + timedelta(minutes=1)).date() == self.current_date:
return
self.current_date = (self.Time + timedelta(minutes=1)).date()
# Wait until data are warmed up.
if self.IsWarmingUp: return
# Trade execution
self.Liquidate()
for symbol in self.symbols:
if self.data[symbol].is_ready():
yearly_vol = self.data[symbol].volatility(self.period)
monthly_vol = self.data[symbol].volatility(21)
price = self.data[symbol].Price
ma32 = self.data[symbol].ma32()
ma61 = self.data[symbol].ma61()
ma117 = self.data[symbol].ma117()
traded_weight = 0
weight = self.data[symbol].Weight
# Trend startegy
if monthly_vol < yearly_vol:
#Long
if ma32 > ma61 and ma61 > ma117:
if price > ma32 and price > ma61 and price > ma117:
traded_weight = weight
elif price > ma61 and price > ma117:
traded_weight = weight * 2/3
elif price > ma117:
traded_weight = weight * 1/3
else:
continue
#Short
elif ma32 < ma61 and ma61 < ma117:
if price < ma32 and price < ma61 and price < ma117:
traded_weight = -weight
elif price < ma61 and price < ma117:
traded_weight = -weight * 2/3
elif price < ma117:
traded_weight = -weight * 1/3
else:
continue
self.SetHoldings(symbol, traded_weight)
# Counter-Trend startegy
elif monthly_vol > yearly_vol:
#Long
if ma32 < ma61 and ma61 < ma117:
if price < ma32 and price < ma61 and price < ma117:
traded_weight = weight
elif price < ma61 and price < ma117:
traded_weight = weight * 2/3
elif price < ma117:
traded_weight = weight * 1/3
else:
continue
#Short
elif ma32 > ma61 and ma61 > ma117:
if price > ma32 and price > ma61 and price > ma117:
traded_weight = -weight
elif price > ma61 and price > ma117:
traded_weight = -weight * 2/3
elif price > ma117:
traded_weight = -weight * 1/3
else:
continue
self.SetHoldings(symbol, traded_weight)
def GetWeight(self, argument):
switcher = {
"USDJPY": 0.2113,
"GBPUSD": 0.1749,
"EURUSD": 0.3576,
"USDCHF": 0.0557,
"USDCAD": 0.0507,
"AUDUSD": 0.0642,
"EURGBP": 0.0307,
"EURJPY": 0.0364,
"EURCHF": 0.0186,
}
return switcher.get(argument, "0.0")
class SymbolData:
def __init__(self, symbol, lookback, ma32, ma61, ma117, weight):
self.Symbol = symbol
self.Price = None
self.MA32 = ma32
self.MA61 = ma61
self.MA117 = ma117
self.Weight = weight
self.History = RollingWindow[float](lookback)
def update(self, time, value):
self.Price = value
self.History.Add(value)
self.MA32.Update(time, value)
self.MA61.Update(time, value)
self.MA117.Update(time, value)
def is_ready(self):
return self.MA32.IsReady and self.MA61.IsReady and self.MA32.IsReady and self.History.IsReady
def ma32(self):
return self.MA32.Current.Value
def ma61(self):
return self.MA61.Current.Value
def ma117(self):
return self.MA117.Current.Value
def volatility(self, period):
prices = np.array([x for x in self.History])[:period]
returns = (prices[:-1]-prices[1:])/prices[1:]
return np.std(returns) * np.sqrt(period)
# Custom fee model.
class CustomFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
return OrderFee(CashAmount(fee, "USD"))
VI. Backtest Performance