The strategy involves trading stocks listed on Deutsche Börse’s Prime Standard, based on monthly custom rankings. Positions are adjusted after the market reacts to index promotions and demotions on announcement days.

I. STRATEGY IN A NUTSHELL

The strategy trades stocks listed on Deutsche Börse’s Prime Standard and continuously traded on Xetra. On index announcement days (third trading day of each month), it goes long on stocks expected to be added to indices and short on stocks expected to be removed. Portfolio weights are based on custom rankings from Bloomberg and Compustat data, and the portfolio is rebalanced the following day to capture price reactions from promotions and demotions.

II. ECONOMIC RATIONALE

Abnormal returns arise from shifts in investor attention and demand. Added stocks attract purchases from index-tracking funds, driving prices up, while removed stocks face lower demand and increased supply, pushing prices down. Some of the returns may also reflect a risk premium for predicting index changes, but short-selling constraints do not appear to be a primary driver.

III. SOURCE PAPER

 Forecasting index changes in the German DAX family [Click to Open PDF]

Friedrich‑Carl Franz

<Abstract>

Combining market data with a publicly available monthly snapshot of Deutsche Börse’s index ranking list, I create a model that predicts index changes in the DAX, MDAX, SDAX, and TecDAX from 2010 to 2019 before they are officially announced. Even though I empirically show that index changes are predictable, they still earn sizeable post-announcement 1-day abnormal returns up to 1.42% and − 1.54% for promotions and demotions, respectively. While abnormal returns are larger in smaller stocks, I find no evidence that they are related to funding constraints or additional risk for trading on wrong predictions. A trading strategy that trades according to my model yields an annualized Sharpe ratio of 0.83 while being invested for just 4 days a year.

IV. BACKTEST PERFORMANCE

Annualised Return5.61%
Volatility6.76%
Beta0.01
Sharpe Ratio0.83
Sortino RatioN/A
Maximum DrawdownN/A
Win Rate60%

V. FULL PYTHON CODE

from AlgorithmImports import *
from data_tools import GermanStocks, CustomFeeModel, SymbolData
import bz2
import pickle
import base64
# endregion
class ForecastingIndexChangesInTheGermanDAXFamily(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2000, 1, 1)
        self.SetCash(100000)
        self.leverage:int = 5
        self.total_DAX_stocks:int = 30  # number of DAX constituents up until 03/09/2021
        self.prev_DAX_stocks:list[Symbol] = []
        self.change_date:datetime.date = datetime(2021, 9, 3).date()    # date when DAX includes 40 stocks instead of 30
        self.rebalance_day:int = 5      # nth day in month
        self.rebalance_flag:bool = False
        self.trading_days_counter:int = 0
        self.rebalance_months:list[int] = [12, 3, 6, 9] # DAX rebalance months
        self.data:dict[str, SymbolData] = {}
        self.top_size_symbol_count:int = 437 # max number of german stocks is 437
        # Source: https://www.xetra.com/xetra-en/instruments/shares/list-of-tradable-shares/xetra/4750!search?state=H4sIAAAAAAAAADWKsQoCMRAFf0W2TqFtPsDKIuBhH5IXDawJ7m6Q47h_9xDSzTCzUY6Gq_Q3-TaY3d-XPq3EBFPy235wFbUbzCAzv6ppgIT4BPnL2VFtiUfGvRp0Tr3xGnIhXyIrHH0GZCVP5Eigg-1R8Z2zdrGj6VKNcYqaaP8BsKzzjqQAAAA&sort=sTitle+asc&hitsPerPage=10&pageNum=1
        tickers_file_str:str = self.Download('data.quantpedia.com/backtesting_data/equity/german_stocks/german_tickers.csv')
        self.tickers:List[str] = tickers_file_str.split('\r\n')[:self.top_size_symbol_count]
        basic_shares_file_str:str = self.Download('data.quantpedia.com/backtesting_data/economic/average_basic_shares/german_average_basic_shares.json')
        basic_shares_data:dict = json.loads(basic_shares_file_str)
        for index, data in enumerate(basic_shares_data):
            year = datetime.strptime(data['date'], '%d.%m.%Y').year
            for key, value in data.items():
                if key not in self.tickers:
                    continue
                if key not in self.data:
                    data = self.AddData(GermanStocks, key, Resolution.Daily)
                    data.SetFeeModel(CustomFeeModel())
                    data.SetLeverage(self.leverage)
                    self.data[key] = SymbolData(data.Symbol)
                
                self.data[key].update_basic_shares(year, value)
    def OnData(self, data: Slice):
        if self.Portfolio.Invested:
            # liquidate on next trading day
            self.Liquidate()
            return
        if self.Time.month not in self.rebalance_months:
            self.rebalance_flag = True
            return
        
        if self.rebalance_flag:
            self.trading_days_counter += 1
            if self.trading_days_counter >= self.rebalance_day:
                if self.Time.date() > self.change_date:
                    self.total_DAX_stocks = 40
                # forbid rebalance until next rebalance month
                self.rebalance_flag = False
                self.trading_days_counter = 0
                market_cap:dict[Symbol, float] = {}
                for ticker, symbol_data in self.data.items():
                    symbol:Symbol = symbol_data.symbol
                    if not data.ContainsKey(symbol) or data[symbol].Value == 0:
                        continue
                    price:float = data[symbol].Value
                    basic_shares:int = float(symbol_data.get_basic_share(self.Time.year))
                    market_cap_value:float = price * basic_shares
                    market_cap[symbol_data.symbol] = market_cap_value
                if len(market_cap) == 0:
                    self.Liquidate()
                    return
                sorted_by_cap:list[Symbol] = [x[0] for x in sorted(market_cap.items(), key=lambda item: item[1])]
                top_n_symbols:list[Symbol] = sorted_by_cap[-self.total_DAX_stocks:]
                if len(self.prev_DAX_stocks) != self.total_DAX_stocks:
                    self.prev_DAX_stocks = top_n_symbols
                    self.Liquidate()
                    return
                long_leg:list[Symbol] = list(filter(lambda symbol: symbol not in self.prev_DAX_stocks, top_n_symbols))
                short_leg:list[Symbol] = [] #list(filter(lambda symbol: symbol not in top_n_symbols, self.prev_DAX_stocks))
                long_length:int = len(long_leg)
                short_length:int = len(short_leg)
                for symbol in long_leg:
                    self.SetHoldings(symbol, 1 / long_length)
                for symbol in short_leg:
                    self.SetHoldings(symbol, -1 / short_length)
                self.prev_DAX_stocks = top_n_symbols

Leave a Reply

Discover more from Quant Buffet

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

Continue reading