The strategy involves sorting stocks by overnight returns, going long on the top decile (winners) and short on the bottom decile (losers). Positions are held overnight, rebalanced monthly.

I. STRATEGY IN A NUTSHELL

This strategy trades NYSE, AMEX, and NASDAQ stocks based on overnight returns. Stocks are sorted monthly into deciles by prior month’s overnight performance, going long on the top decile and short on the bottom decile, held overnight, with value-weighted portfolios rebalanced monthly.

II. ECONOMIC RATIONALE

Momentum arises from investors’ underreaction to news. Overnight returns are stronger due to institutional intraday trading, which often works against momentum, creating exploitable patterns for the strategy.

III. SOURCE PAPER

A Tug of War: Overnight Versus Intraday Expected Returns [Click to Open PDF]

Dong Lou, Department of Finance, London School of Economics, London WC2A 2AE, UK and CEP; Christopher Polk, Department of Finance, London School of Economics, London WC2A 2AE, UK and CEPR; Spyros Skouras, Athens University of Economics and Business

<Abstract>

We show that momentum profi…ts accrue entirely overnight while pro…fits on all other trading strategies studied occur entirely intraday. Indeed, for four-factor anomalies, intraday returns are particular large as there is a partially-offsetting overnight premium of the opposite sign. We link cross-sectional and time-series variation in our decomposition of momentum expected returns to variation in institutional momentum trading, generating variation in overnight-minus-intraday momentum returns of approximately 2 percent per month. An overnight/intraday decomposition of momentum returns in nine non-US markets is consistent with our US …findings. Finally, we document strong and persistent overnight momentum, intraday momentum, and cross-period reversal effects.

IV. BACKTEST PERFORMANCE

Annualised Return50.58%
Volatility11.24%
Beta-0.629
Sharpe Ratio4.04
Sortino Ratio-0.2
Maximum DrawdownN/A
Win Rate48%

V. FULL PYTHON CODE

from AlgorithmImports import *
import numpy as np
from typing import List, Dict
from pandas.core.frame import dataframe
#endregion
class OvernightMomentumStrategy(QCAlgorithm):
    def Initialize(self) -> None:
        self.SetStartDate(2015, 1, 1)
        self.SetCash(100_000)
        
        self.exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']	
        self.period: int = 21 # need n of ovenight returns
        market: Symbol = self.AddEquity('SPY', Resolution.Minute).Symbol
        
        self.data: Dict[Symbol, SymbolData] = {} # storing objects of SymbolData under stocks symbols
        self.quantile: int = 10
        self.leverage: int = 20
        self.min_share_price: int = 5
        
        self.traded_quantity: Dict[Symbol, float] = {}
        
        self.fundamental_count: int = 100
        self.fundamental_sorting_key = lambda x: x.DollarVolume
        self.selection_flag: bool = False
        self.UniverseSettings.Leverage = self.leverage
        self.UniverseSettings.Resolution = Resolution.Minute
        self.AddUniverse(self.FundamentalSelectionFunction)
        self.settings.daily_precise_end_time = False
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.
        self.Schedule.On(self.DateRules.MonthStart(market), self.TimeRules.BeforeMarketClose(market, 1), self.Selection)
        self.Schedule.On(self.DateRules.EveryDay(market), self.TimeRules.BeforeMarketClose(market, 20), self.MarketClose)
    def OnSecuritiesChanged(self, changes: SecurityChanges) -> None:
        for security in changes.AddedSecurities:
            security.SetFeeModel(CustomFeeModel())
    def FundamentalSelectionFunction(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update overnight prices on daily basis
        for stock in fundamental:
            symbol: Symbol = stock.Symbol
            
            if symbol in self.data:
                # store current stock price
                self.data[symbol].current_price = stock.AdjustedPrice
                
                # get history prices
                history: dataframe = self.History(symbol, 1, Resolution.Daily)
                # update overnight returns based on history prices
                self.UpdateOvernightReturns(symbol, history)
        
        # monthly rebalance
        if not self.selection_flag:
            return Universe.Unchanged
        self.selection_flag = False
        
        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.HasFundamentalData 
            and x.Market == 'usa'
            and x.MarketCap != 0
            and x.Price > self.min_share_price
            and x.SecurityReference.ExchangeId in self.exchange_codes
        ]
        
        if len(selected) > self.fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self.fundamental_count]]
        
        # warm up overnight returns
        for stock in selected:
            symbol: Symbol = stock.Symbol
            if symbol in self.data and self.data[symbol].is_overnight_returns_ready():
                # get overnight returns from RollingWindow object and reverse it's list for simplier calculation of returns accumulation
                overnight_returns: List[float] = [x for x in self.data[symbol].overnight_returns]
                overnight_returns.reverse()
                # calculate accumulated returns
                accumulated_returns = np.prod([(1 + x) for x in overnight_returns]) - 1
                # update returns accumulated for last month
                self.data[symbol].returns_accumulated_last_month = accumulated_returns
                
                # go to next iteration, because there is no need for warm up overnight returns
                continue
            
            # initialize SymbolData object for current symbol
            self.data[symbol] = SymbolData(self.period)
            # get history of n + 1 days
            history: dataframe = self.History(symbol, self.period + 1, Resolution.Daily)
            # update overnight returns based on history prices
            self.UpdateOvernightReturns(symbol, history)
                
        market_cap: Dict[Symbol, float] = {} # storing stocks market capitalization
        last_accumulated_returns: Dict[Symbol, float] = {} # storing stocks last accumuldated returns
        
        for stock in selected:
            symbol = stock.Symbol
            if not self.data[symbol].is_ready():
                continue
            # store stock's market capitalization
            market_cap[symbol] = stock.MarketCap
            # store stock's last accumulated returns
            last_accumulated_returns[symbol] = self.data[symbol].returns_accumulated_last_month
        
        # not enough data for decile selection     
        if len(last_accumulated_returns) < self.quantile:
            return Universe.Unchanged
        
        # overnight returns sorting
        quantile: int = int(len(last_accumulated_returns) / self.quantile)
        sorted_by_last_acc_ret: List[Symbol] = [x[0] for x in sorted(last_accumulated_returns.items(), key=lambda item: item[1])]
        
        # long winners 
        long: List[Symbol] = sorted_by_last_acc_ret[-quantile:]
        # short losers
        short: List[Symbol] = sorted_by_last_acc_ret[:quantile]
        
        # market cap weighting
        for i, portfolio in enumerate([long, short]):
            mc_sum: float = sum(list(map(lambda x: market_cap[x], portfolio)))
            for symbol in portfolio:
                if self.data[symbol].current_price != 0:
                    current_price: float = self.data[symbol].current_price
                    w: float = market_cap[symbol] / mc_sum
                    quantity: int = ((-1)**i) * np.floor((self.Portfolio.TotalPortfolioValue * w) / current_price)
                    self.traded_quantity[symbol] = quantity
        return list(self.traded_quantity.keys())
    
    def MarketClose(self) -> None:
        # send market on open and on close orders before market closes
        for symbol, q in self.traded_quantity.items():
            self.MarketOnCloseOrder(symbol, q)
            self.MarketOnOpenOrder(symbol, -q)
        
    def UpdateOvernightReturns(self, symbol: Symbol, history: dataframe) -> None:
        # calculate overnight returns only if history isn't empty
        if history.empty:
            return
        
        # get open and close prices
        opens = history.loc[symbol].open
        closes = history.loc[symbol].close
        
        # calculate overnight return for each day
        for (_, close_price), (_, open_price) in zip(closes.items(), opens.items()):
            # check if previous close price isn't None
            if self.data[symbol].prev_close_price:
                # calculate overnight return
                overnight_return = (open_price / self.data[symbol].prev_close_price) - 1
                # store overnight return
                self.data[symbol].update(overnight_return)
            
            # change value of prev close price for next calculation
            self.data[symbol].prev_close_price = close_price
        
    def Selection(self) -> None:
        self.selection_flag = True
        self.traded_quantity.clear()
        
class SymbolData():
    def __init__(self, period: int) -> None:
        self.overnight_returns: RollingWindow = RollingWindow[float](period)
        self.returns_accumulated_last_month: Union[None, float] = None
        self.prev_close_price: Union[None, float] = None
        self.current_price: float = 0.
        
    def update(self, overnight_return: float) -> None:
        self.overnight_returns.Add(overnight_return)
        
    def is_ready(self) -> bool:
        return self.returns_accumulated_last_month
        
    def is_overnight_returns_ready(self) -> bool:
        return self.overnight_returns.IsReady
        
# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))

Leave a Reply

Discover more from Quant Buffet

Subscribe now to keep reading and get access to the full archive.

Continue reading