Quant BuffetRelax, Not Over Thinking

Investor Sentiment and Momentum Effect in Currencies

Log in to collect

Academic paper

Investor Sentiment, Attention and Profitability of Currency Momentum Strategies

AuthorsPawee Maryniak

Institute
  • 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