Monthly Rebalancing of ETFs with Fixed Initial Weights in QSTrader

Monthly Rebalancing of ETFs with Fixed Initial Weights in QSTrader

Many institutional global asset managers are constrained by the need to invest in long-only strategies with zero or minimal leverage. This means that their strategies are often highly correlated to "the market" (usually the S&P500 index). While it is difficult to minimise this correlation without applying a short market hedge, it can be reduced by investing in non-equities based ETFs.

These strategies usually possess an infrequent rebalance rate, often weekly or monthly, but sometimes annually. Thus they differ substantially from a classic intraday stat-arb quant strategy, but are often nevertheless fully systematic in their approach.

The current version of QuantStart's open-source backtesting and live trading software, QSTrader, did not have native support for portfolio rebalancing until last week. Instead users were required to determine their own rebalancing logic and build it into the Strategy and PositionSizer classes themselves.

In this article a basic fixed proportion equities/bonds ETF mix strategy is presented to demonstrate the new functionality of QSTrader. The strategy itself is not novel—it is an example of the classic "60/40" equities/bonds mix—but the rebalancing mechanic within QSTrader allows significantly more flexibility than this. It should provide motivation to try some interesting long-only ETF mixes.

The Trading Strategy

The strategy requires trading a couple of Exchange Traded Funds (ETF), which broadly track the performance of US equities (SPY) and US investment-grade bonds (AGG):

  • SPY - SPDR S&P500 ETF
  • AGG - iShares Core US Aggregate Bond ETF

The strategy simply goes long in both ETFs at the first ending day of the month after commencing, such that 60% of the initial capital is invested in SPY and the remaining 40% is invested in AGG. At every subsequent ending day of the month the portfolio is fully liquidated and rebalanced to ensure that 60% of the current equity is used for SPY and the remaining 40% used for AGG.

Data

In order to carry out this strategy it is necessary to have OHLCV pricing data for the period covered by this backtest. In particular it is necessary to download the following:

  • SPY - For the period 1st November 2006 to 12th October 2016 (link here)
  • AGG - For the period 1st November 2006 to 12th October 2016 (link here)

This data will need to placed in the directory specified by the QSTrader settings file if you wish to replicate the results.

Python QSTrader Implementation

Note that the full code can be found at the bottom of the article. I will only display snippets here. In addition, this strategy is actually a full example in the QSTrader codebase, which can be executed from a fresh install of QSTrader, assuming the data has been obtained separately.

There are two new components required to make this monthly rebalance logic work. The first is a subclass of the Strategy base class, namely MonthlyLiquidateRebalanceStrategy. The second is a subclass of the PositionSizer base class, namely LiquidateRebalancePositionSizer.

Some new methods have been added to MonthlyLiquidateRebalanceStrategy. The first is _end_of_month(...). It simply uses the Python calendar module along with the monthrange method to determine if the date passed to it is an end-of-month day:

def _end_of_month(self, cur_time):
    """
    Determine if the current day is at the end of the month.
    """
    cur_day = cur_time.day
    end_day = calendar.monthrange(cur_time.year, cur_time.month)[1]
    return cur_day == end_day

The second method is _create_invested_list, which simply creates a dictionary comprehension of all tickers as keys and boolean False as values. This tickers_invested dict is used for "housekeeping" to check whether an asset has been purchased at the point of carrying out subsequent trading logic.

This is necessary because on the first run through of the code the liquidation of the entire portfolio is not needed since there is nothing in it to liquidate!

def _create_invested_list(self):
    """
    Create a dictionary with each ticker as a key, with
    a boolean value depending upon whether the ticker has
    been "invested" yet. This is necessary to avoid sending
    a liquidation signal on the first allocation.
    """
    tickers_invested = {ticker: False for ticker in self.tickers}
    return tickers_invested

The core logic is encapsulated in calculate_signals. This code initially checks whether this is an end-of-month day for the current OHLCV bar. It then determines whether the portfolio has been purchased, and if so, whether it should liquidate it fully prior to rebalancing.

Irrespective, it simply sends a long signal ("BOT") to the events queue and then updates the tickers_invested dict to show that this ticker has now been purchased at least once:

def calculate_signals(self, event):
    """
    For a particular received BarEvent, determine whether
    it is the end of the month (for that bar) and generate
    a liquidation signal, as well as a purchase signal,
    for each ticker.
    """
    if (
        event.type in [EventType.BAR, EventType.TICK] and
        self._end_of_month(event.time)
    ):
        ticker = event.ticker
        if self.tickers_invested[ticker]:
            liquidate_signal = SignalEvent(ticker, "EXIT")
            self.events_queue.put(liquidate_signal)
        long_signal = SignalEvent(ticker, "BOT")
        self.events_queue.put(long_signal)
        self.tickers_invested[ticker] = True

This wraps up the code for the MonthlyLiquidateRebalanceStrategy. The next object is the LiquidateRebalancePositionSizer.

In the initialisation of the object it is necessary to pass a dictionary containing the initial weights of the tickers:

def __init__(self, ticker_weights):
    self.ticker_weights = ticker_weights

The ticker_weights dict, for this strategy, has the following structure:

ticker_weights = {
    "SPY": 0.6,
    "AGG": 0.4
}

It is easy to see how this can be expanded to have any initial set of ETFs/equities with various weights. At this stage, while not a hard requirement, the code is set up to handle allocations only when the weights add up to 1.0. A weighting in excess of 1.0 would indicate the use of leverage/margin, which is not currently handled in QSTrader.

The main code for LiquidateRebalancePositionSizer is given in the size_order method. It initially asks whether the order received has an "EXIT" (liquidate) action or whether it is a "BOT" long action. This determines how the order is modified.

If it is a liquidate signal then the current quantity is determined and an opposing signal is created to net out the quantity of the position to zero. If instead it is a long signal to purchase shares then the current price of the asset must be determined. This is achieved by querying portfolio.price_handler.tickers[ticker]["adj_close"].

Once the current price of the asset is determined, the full portfolio equity must also be ascertained. With these two values it is possible to calcualte the new dollar-weighting for that particular asset by multiplying the full equity by the proportion weight of the asset. This is finally converted into an integer value of shares to purchase.

Note that the market price and equity value must be divided by PriceParser.PRICE_MULTIPLIER to avoid round-off errors. This feature was described in a previous post and I won't dwell on it here.

def size_order(self, portfolio, initial_order):
    """
    Size the order to reflect the dollar-weighting of the
    current equity account size based on pre-specified
    ticker weights.
    """
    ticker = initial_order.ticker
    if initial_order.action == "EXIT":
        # Obtain current quantity and liquidate
        cur_quantity = portfolio.positions[ticker].quantity
        if cur_quantity > 0:
            initial_order.action = "SLD"
            initial_order.quantity = cur_quantity
        elif cur_quantity < 0:
            initial_order.action = "BOT"
            initial_order.quantity = cur_quantity
        else:
            initial_order.quantity = 0
    else:
        weight = self.ticker_weights[ticker]
        # Determine total portfolio value, work out dollar weight
        # and finally determine integer quantity of shares to purchase
        price = portfolio.price_handler.tickers[ticker]["adj_close"]
        price /= PriceParser.PRICE_MULTIPLIER
        equity = portfolio.equity / PriceParser.PRICE_MULTIPLIER
        dollar_weight = weight * equity
        weighted_quantity = int(floor(dollar_weight / price))
        # Update quantity
        initial_order.quantity = weighted_quantity
    return initial_order

The final component of the code is encapsulated in the monthly_liquidate_rebalance_backtest.py file. The full code for this is provided at the end of this article. There is nothing "remarkable" in this file compared to any other backtest setup beyond the specification of the ticker_weights stock weighting dictionary.

Finally, we can run the strategy by downloading the data into the correct directory and typing the following into the terminal:

$ python monthly_liquidate_rebalance_backtest.py --tickers=SPY,AGG

I'd like to once again thank the many volunteer developers, particularly @ryankennedyio, @femtotrader and @nwillemse, for giving their time so freely to the project and producing some great features.

Strategy Results

The tearsheet for the strategy is given below:

Click the image for a larger view.

The benchmark is provided by a buy-and-hold portfolio (i.e. no monthly rebalancing) solely of the SPY ETF.

While the backtest shares a similar Sharpe Ratio of 0.3 compared to 0.32 for the benchmark, it has a lower CAGR at 3.31% compared to 4.59%.

The underperformance compared to the benchmark is due to two factors. Namely, that the strategy carries out a full liquidation and repurchase at the end of every month generating transaction costs. In addition the AGG bond fund, while cushioning the blow in 2008, has detracted from the performance of SPY in the last five years.

This motivates two avenues of research.

The first involves minimising transaction costs by avoiding a full liquidation and only rebalancing when necessary. This could be set as some form of thresholding whereby if the 60/40 split deviates too much from this proportion it is necessary to carry out a rebalance. This is in opposition to a rebalance carried out every month irrespective of current proportionality.

The second involves adjusting the ETF mix to increase the Sharpe Ratio. There are a vast number of ETFs out there, with many portfolios beating the benchmark a posteriori. Our task is obviously to decide how to construct such portfolios a priori!

Next Steps

The current implementation of QSTrader only supports rebalancing of long-only portfolios with fixed initial weights. Therefore two necessary improvements are to ensure that portfolios with short weightings via margin are supported as well as allowing dynamic adjustments of portfolio weightings through time.

In addition QSTrader will need to support rebalancing at other frequencies including weekly and yearly. Daily rebalancing is harder to support as it involves having access to intraday market data. However it is a planned feature.

Despite the current lack of weekly or yearly rebalancing, the monthly rebalance is extremely flexible. It will allow replication of many long-only strategies currently in use by large asset managers, under the assumption of availability of data.

Many future posts will be devoted to such strategies and, as with this post, they will all contain the full code necessary to replicate the strategies.

Full Code

# monthly_liquidate_rebalance_strategy.py

import calendar

from .base import AbstractStrategy
from ..event import (SignalEvent, EventType)


class MonthlyLiquidateRebalanceStrategy(AbstractStrategy):
    """
    A generic strategy that allows monthly rebalancing of a
    set of tickers, via full liquidation and dollar-weighting
    of new positions.

    Must be used in conjunction with the
    LiquidateRebalancePositionSizer object to work correctly.
    """
    def __init__(self, tickers, events_queue):
        self.tickers = tickers
        self.events_queue = events_queue
        self.tickers_invested = self._create_invested_list()

    def _end_of_month(self, cur_time):
        """
        Determine if the current day is at the end of the month.
        """
        cur_day = cur_time.day
        end_day = calendar.monthrange(cur_time.year, cur_time.month)[1]
        return cur_day == end_day

    def _create_invested_list(self):
        """
        Create a dictionary with each ticker as a key, with
        a boolean value depending upon whether the ticker has
        been "invested" yet. This is necessary to avoid sending
        a liquidation signal on the first allocation.
        """
        tickers_invested = {ticker: False for ticker in self.tickers}
        return tickers_invested

    def calculate_signals(self, event):
        """
        For a particular received BarEvent, determine whether
        it is the end of the month (for that bar) and generate
        a liquidation signal, as well as a purchase signal,
        for each ticker.
        """
        if (
            event.type in [EventType.BAR, EventType.TICK] and
            self._end_of_month(event.time)
        ):
            ticker = event.ticker
            if self.tickers_invested[ticker]:
                liquidate_signal = SignalEvent(ticker, "EXIT")
                self.events_queue.put(liquidate_signal)
            long_signal = SignalEvent(ticker, "BOT")
            self.events_queue.put(long_signal)
            self.tickers_invested[ticker] = True
# rebalance_position_sizer.py

from math import floor

from .base import AbstractPositionSizer
from qstrader.price_parser import PriceParser


class LiquidateRebalancePositionSizer(AbstractPositionSizer):
    """
    Carries out a periodic full liquidation and rebalance of
    the Portfolio.

    This is achieved by determining whether an order type type
    is "EXIT" or "BOT/SLD".

    If the former, the current quantity of shares in the ticker
    is determined and then BOT or SLD to net the position to zero.

    If the latter, the current quantity of shares to obtain is
    determined by prespecified weights and adjusted to reflect
    current account equity.
    """
    def __init__(self, ticker_weights):
        self.ticker_weights = ticker_weights

    def size_order(self, portfolio, initial_order):
        """
        Size the order to reflect the dollar-weighting of the
        current equity account size based on pre-specified
        ticker weights.
        """
        ticker = initial_order.ticker
        if initial_order.action == "EXIT":
            # Obtain current quantity and liquidate
            cur_quantity = portfolio.positions[ticker].quantity
            if cur_quantity > 0:
                initial_order.action = "SLD"
                initial_order.quantity = cur_quantity
            elif cur_quantity < 0:
                initial_order.action = "BOT"
                initial_order.quantity = cur_quantity
            else:
                initial_order.quantity = 0
        else:
            weight = self.ticker_weights[ticker]
            # Determine total portfolio value, work out dollar weight
            # and finally determine integer quantity of shares to purchase
            price = portfolio.price_handler.tickers[ticker]["adj_close"]
            price /= PriceParser.PRICE_MULTIPLIER
            equity = portfolio.equity / PriceParser.PRICE_MULTIPLIER
            dollar_weight = weight * equity
            weighted_quantity = int(floor(dollar_weight / price))
            # Update quantity
            initial_order.quantity = weighted_quantity
        return initial_order
# monthly_liquidate_rebalance_strategy.py

import click

from qstrader import settings
from qstrader.compat import queue
from qstrader.price_parser import PriceParser
from qstrader.price_handler.yahoo_daily_csv_bar import YahooDailyCsvBarPriceHandler
from qstrader.strategy.monthly_liquidate_rebalance import MonthlyLiquidateRebalanceStrategy
from qstrader.strategy import Strategies, DisplayStrategy
from qstrader.position_sizer.rebalance import LiquidateRebalancePositionSizer
from qstrader.risk_manager.example import ExampleRiskManager
from qstrader.portfolio_handler import PortfolioHandler
from qstrader.compliance.example import ExampleCompliance
from qstrader.execution_handler.ib_simulated import IBSimulatedExecutionHandler
from qstrader.statistics.tearsheet import TearsheetStatistics
from qstrader.trading_session.backtest import Backtest


def run(config, testing, tickers, filename):

    # Set up variables needed for backtest
    events_queue = queue.Queue()
    csv_dir = config.CSV_DATA_DIR
    initial_equity = PriceParser.parse(500000.00)

    # Use Yahoo Daily Price Handler
    price_handler = YahooDailyCsvBarPriceHandler(
        csv_dir, events_queue, tickers
    )

    # Use the monthly liquidate and rebalance strategy
    strategy = MonthlyLiquidateRebalanceStrategy(tickers, events_queue)
    strategy = Strategies(strategy, DisplayStrategy())

    # Use the liquidate and rebalance position sizer
    # with prespecified ticker weights
    ticker_weights = {
        "SPY": 0.6,
        "AGG": 0.4,
    }
    position_sizer = LiquidateRebalancePositionSizer(ticker_weights)

    # Use an example Risk Manager
    risk_manager = ExampleRiskManager()

    # Use the default Portfolio Handler
    portfolio_handler = PortfolioHandler(
        initial_equity, events_queue, price_handler,
        position_sizer, risk_manager
    )

    # Use the ExampleCompliance component
    compliance = ExampleCompliance(config)

    # Use a simulated IB Execution Handler
    execution_handler = IBSimulatedExecutionHandler(
        events_queue, price_handler, compliance
    )

    # Use the default Statistics
    title = ["US Equities/Bonds 60/40 ETF Strategy"]
    benchmark = "SPY"
    statistics = TearsheetStatistics(
        config, portfolio_handler, title, benchmark
    )

    # Set up the backtest
    backtest = Backtest(
        price_handler, strategy,
        portfolio_handler, execution_handler,
        position_sizer, risk_manager,
        statistics, initial_equity
    )
    results = backtest.simulate_trading(testing=testing)
    statistics.save(filename)
    return results


@click.command()
@click.option('--config', default=settings.DEFAULT_CONFIG_FILENAME, help='Config filename')
@click.option('--testing/--no-testing', default=False, help='Enable testing mode')
@click.option('--tickers', default='SPY', help='Tickers (use comma)')
@click.option('--filename', default='', help='Pickle (.pkl) statistics filename')
def main(config, testing, tickers, filename):
    tickers = tickers.split(",")
    config = settings.from_file(config, testing)
    run(config, testing, tickers, filename)


if __name__ == "__main__":
    main()