Investor Sentiment and Momentum Effect in Currencies
Log in to collectAcademic paper
Investor Sentiment, Attention and Profitability of Currency Momentum Strategies
Pawee Maryniak
- Wrocław University of Science and Technology
- Wroclaw University of Economics and Business
- ?Uniwersytet Ekonomiczny we Wrocławiu
- ?Wroclaw University of Technology
Strategy in a nutshell
The strategy trades 71 currencies against USD, using past performance and the Baker-Wurgler sentiment index. Positions depend on sentiment: long Winners in low sentiment, short Losers in high sentiment, and both long/short in medium sentiment. The portfolio is equally weighted and rebalanced monthly.
Economic rationale
Investor sentiment drives currency momentum. Low sentiment pushes capital into safer assets, favoring long positions in foreign currencies, while high sentiment favors riskier assets, benefiting short positions. This behavior creates predictable patterns exploitable for returns.
Backtest performance
Annualised return5.8%
Volatility10.36%
Beta-0.02
Sharpe ratio0.56
Win rate37%
Full Python code
from AlgorithmImports import *
import numpy as np
from dateutil.relativedelta import relativedelta
#endregion
class InvestorSentiment(QCAlgorithm):
def Initialize(self):
self.SetStartDate(2011, 1, 1)
self.SetCash(100000)
self.period:int = 21
self.SetWarmUp(self.period, Resolution.Daily)
self.symbols = [
"USDAUD", "USDCAD", "USDCHF", "USDCZK", "USDDKK", "USDEUR",
"USDGBP", "USDHKD", "USDHUF", "USDJPY", "USDMXN", "USDPLN",
"USDNOK", "USDSAR", "USDSGD", "USDTHB", "USDTRY", "USDTWD",
"USDZAR", "USDSEK"
]
self.data:dict[Symbol, SymbolData] = {}
self.sentiment_warmup_period:int = 12
self.sentiment_history = RollingWindow[float](self.sentiment_warmup_period)
self.max_missing_days:int = 31
self.quantile:int = 6
for symbol in self.symbols:
data = self.AddForex(symbol, Resolution.Daily, Market.Oanda)
data.SetLeverage(5)
self.data[symbol] = SymbolData(symbol, self.period)
# Import custom data
self.sentimet_index:Symbol = self.AddData(SentimentData, "sentiment", Resolution.Daily).Symbol
self.recent_month:int = -1
def OnData(self, data:Slice) -> None:
perf:dict[str, float] = {}
# store daily prices
for symbol in self.symbols:
if symbol in data and data[symbol]:
self.data[symbol].Update(data[symbol].Value)
if self.recent_month != self.Time.month and not self.IsWarmingUp:
if self.data[symbol].IsReady():
perf[symbol] = self.data[symbol].Return()
if self.IsWarmingUp: return
# monthly rebalance
if self.recent_month == self.Time.month:
return
self.recent_month = self.Time.month
# check sentiment index data arrival
if self.Securities[self.sentimet_index].GetLastData() and (self.Time.date() - self.Securities[self.sentimet_index].GetLastData().Time.date()).days > self.max_missing_days:
if self.Portfolio.Invested:
self.Liquidate()
self.sentiment_history.Reset()
else:
sentiment_index:float = self.Securities[self.sentimet_index].Price
self.sentiment_history.Add(sentiment_index)
if not self.sentiment_history.IsReady: return
sorted_by_ret:List = sorted([x for x in self.data.items() if x[1].IsReady()], key=lambda x: x[1].Return(), reverse = True)
if len(sorted_by_ret) < self.quantile:
self.Liquidate()
return
quantile:int = int(len(self.symbols) / self.quantile)
winners:List[str] = [x[0] for x in sorted_by_ret[:quantile]]
losers:List[str] = [x[0] for x in sorted_by_ret[-quantile:]]
percentile_33:float = np.percentile(list(self.sentiment_history), 0.33)
percentile_66:float = np.percentile(list(self.sentiment_history), 0.66)
long:List[str] = []
short:List[str] = []
# When the sentiment level in the previous month is low then only long position in winners is taken. When the sentiment level is high then only short position is taken. When sentiment level is medium then both short and long positions are taken.
if sentiment_index < percentile_33:
long = winners
if sentiment_index > percentile_66:
short = losers
else:
long = winners
short = losers
# liquidate
invested = [x.Key.Value for x in self.Portfolio if x.Value.Invested]
for symbol in invested:
if symbol not in long + short:
self.Liquidate(symbol)
# market execution
for symbol in long:
self.SetHoldings(symbol, 1 / len(long))
for symbol in short:
self.SetHoldings(symbol, -1 / len(short))
class SymbolData:
def __init__(self, symbol:str, lookback:int) -> None:
self.Symbol:str = symbol
self.History:RollingWindow = RollingWindow[float](lookback)
self.Lookback = lookback
def Update(self, value:float) -> None:
self.History.Add(value)
def IsReady(self) -> bool:
return self.History.IsReady
# Monthly return
def Return(self) -> float:
prices:List[float] = list(self.History)
return (prices[0] / prices[self.Lookback - 1]) - 1
class SentimentData(PythonData):
def GetSource(self, config, date, isLiveMode):
return SubscriptionDataSource("data.quantpedia.com/backtesting_data/index/baker_wurgler_sentiment_index.csv", SubscriptionTransportMedium.RemoteFile, FileFormat.Csv)
def Reader(self, config, line, date, isLiveMode):
index = SentimentData()
index.Symbol = config.Symbol
try:
data = line.split(';')
index.Time = datetime.strptime(data[0], "%Y%m") + relativedelta(months=1)
index.Value = data[1]
except:
return None
return index