
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.
ASSET CLASS: stocks | REGION: Europe | FREQUENCY:
Daily | MARKET: equities | KEYWORD: DAX
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 Return | 5.61% |
| Volatility | 6.76% |
| Beta | 0.01 |
| Sharpe Ratio | 0.83 |
| Sortino Ratio | N/A |
| Maximum Drawdown | N/A |
| Win Rate | 60% |
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