Back to Community
Quantopian Lecture Series: Mean Reversion on Futures

Mean reversion is an oft-covered topic in algorithmic trading and quantitative finance in general. Here we look at some examples of how mean reversion can apply in futures trading. Due to specific structures in contracts and between multiple futures, there are a lot of interesting ways to construct trades that bet on various different quantities or properties. We examine potentially mean-reverting spreads between multiple futures as well as mean-reverting relationships between futures and equities.

All of our lectures can be found here:
quantopian.com/lectures

Loading notebook preview...
Disclaimer

The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by Quantopian. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. No information contained herein should be regarded as a suggestion to engage in or refrain from any investment-related course of action as none of Quantopian nor any of its affiliates is undertaking to provide investment advice, act as an adviser to any plan or entity subject to the Employee Retirement Income Security Act of 1974, as amended, individual retirement account or individual retirement annuity, or give advice in a fiduciary capacity with respect to the materials presented herein. If you are an individual retirement or other investor, contact your financial advisor or other fiduciary unrelated to Quantopian about whether any given investment idea, strategy, product or service described herein may be appropriate for your circumstances. All investments involve risk, including loss of principal. Quantopian makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances.

14 responses

This is a pairs trading algo on a few futures contracts.

Clone Algorithm
Loading...
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
Returns 1 Month 3 Month 6 Month 12 Month
Alpha 1 Month 3 Month 6 Month 12 Month
Beta 1 Month 3 Month 6 Month 12 Month
Sharpe 1 Month 3 Month 6 Month 12 Month
Sortino 1 Month 3 Month 6 Month 12 Month
Volatility 1 Month 3 Month 6 Month 12 Month
Max Drawdown 1 Month 3 Month 6 Month 12 Month
import itertools

import numpy as np
import pandas as pd
import scipy as sp
from statsmodels.tsa.stattools import coint

from quantopian.algorithm import order_optimal_portfolio
import quantopian.experimental.optimize as opt

month_idx = 0

def initialize(context):
    # Quantopian backtester specific variables
    
    context.futures_pairs = [
        (
            continuous_future(
                'LC',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
            continuous_future(
                'FC',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
        ),
        (
            continuous_future(
                'CL',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
            continuous_future(
                'XB',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
        ),
        (
            continuous_future(
                'SM',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
            continuous_future(
                'BO',
                offset=month_idx,
                roll='calendar',
                adjustment='mul',
            ),
        ),
    ]
    
    context.futures_list = list(itertools.chain.from_iterable(context.futures_pairs))
    
    context.num_pairs = len(context.futures_pairs)
    # strategy specific variables
    context.long_ma = 63
    context.short_ma = 5
    
    context.inLong = {
        (pair[0].root_symbol, pair[1].root_symbol): False for pair in context.futures_pairs
    }
    context.inShort = {
        (pair[0].root_symbol, pair[1].root_symbol): False for pair in context.futures_pairs
    }
    
    context.long_term_weights = {cont_future.root_symbol: 0 for cont_future in context.futures_list}
    context.current_weights = {}
    
    schedule_function(func=rebalance_pairs, date_rule=date_rules.every_day(), time_rule=time_rules.market_open(minutes=30))
    
# Will be called on every trade event for the securities you specify. 
def handle_data(context, data):
    # Our work is now scheduled in check_pair_status
    pass

def rebalance_pairs(context, data):
    if get_open_orders():
        return
    
    prices = data.history(context.futures_list, 'price', context.long_ma, '1d')
     
    
    for future_y, future_x in context.futures_pairs:
        Y = prices[future_y]
        X = prices[future_x]
        
        pvalue = coint(Y, X)[1]
        if pvalue > 0.10:
            log.info(
                '({} {}) no longer cointegrated, no new positions.'.format(
                    future_y.root_symbol,
                    future_x.root_symbol,
                ),
            )
            continue
            
        y_returns = Y.pct_change()[1:]
        x_returns = X.pct_change()[1:]
        regression = sp.stats.linregress(
            x_returns[-context.long_ma:],
            y_returns[-context.long_ma:],
        )
        
        spreads = y_returns - (regression.slope * x_returns)

        zscore = (
            np.mean(spreads[-context.short_ma]) - np.mean(spreads)
        ) / np.std(spreads, ddof=1)
            
        future_y_contract, future_x_contract = data.current(
            [future_y, future_x],
            'contract',
        )
        
        context.current_weights[future_y_contract] = context.long_term_weights[future_y_contract.root_symbol]
        context.current_weights[future_x_contract] = context.long_term_weights[future_x_contract.root_symbol]
        
        
        hedge_ratio = (
            np.abs(np.corrcoef(y_returns, x_returns)[0][1]) * 
            (np.std(y_returns) / np.std(x_returns)) * 
            ((Y[-1] * future_y_contract.multiplier) / (X[-1] * future_x_contract.multiplier))
        )

        if context.inShort[(future_y.root_symbol, future_x.root_symbol)] and zscore < 0.0:
            context.long_term_weights[future_y_contract.root_symbol] = 0
            context.long_term_weights[future_x_contract.root_symbol] = 0
            context.current_weights[future_y_contract] = context.long_term_weights[future_y_contract.root_symbol]
            context.current_weights[future_x_contract] = context.long_term_weights[future_x_contract.root_symbol]
                
            context.inLong[(future_y.root_symbol, future_x.root_symbol)] = False
            context.inShort[(future_y.root_symbol, future_x.root_symbol)] = False
            continue

        if context.inLong[(future_y.root_symbol, future_x.root_symbol)] and zscore > 0.0:
            context.long_term_weights[future_y_contract.root_symbol] = 0
            context.long_term_weights[future_x_contract.root_symbol] = 0
            context.current_weights[future_y_contract] = context.long_term_weights[future_y_contract.root_symbol]
            context.current_weights[future_x_contract] = context.long_term_weights[future_x_contract.root_symbol]
                
            context.inLong[(future_y.root_symbol, future_x.root_symbol)] = False
            context.inShort[(future_y.root_symbol, future_x.root_symbol)] = False
            continue

        if zscore < -1.0 and (not context.inLong[(future_y.root_symbol, future_x.root_symbol)]):
            # Only trade if NOT already in a trade
            y_target_contracts = 1
            x_target_contracts = hedge_ratio
            context.inLong[(future_y.root_symbol, future_x.root_symbol)] = True
            context.inShort[(future_y.root_symbol, future_x.root_symbol)] = False

            (y_target_pct, x_target_pct) = computeHoldingsPct(
                y_target_contracts,
                x_target_contracts, 
                future_y_contract.multiplier * Y[-1],
                future_x_contract.multiplier * X[-1]
            )
            
            context.long_term_weights[future_y_contract.root_symbol] = y_target_pct
            context.long_term_weights[future_x_contract.root_symbol] = -x_target_pct
            context.current_weights[future_y_contract] = context.long_term_weights[future_y_contract.root_symbol]
            context.current_weights[future_x_contract] = context.long_term_weights[future_x_contract.root_symbol]
            continue

        if zscore > 1.0 and (not context.inShort[(future_y.root_symbol, future_x.root_symbol)]):
            # Only trade if NOT already in a trade
            y_target_contracts = 1
            x_target_contracts = hedge_ratio
     
            context.inLong[(future_y.root_symbol, future_x.root_symbol)] = False
            context.inShort[(future_y.root_symbol, future_x.root_symbol)] = True
            
            (y_target_pct, x_target_pct) = computeHoldingsPct(
                y_target_contracts,
                x_target_contracts, 
                future_y_contract.multiplier * Y[-1],
                future_x_contract.multiplier * X[-1]
            )
            
            context.long_term_weights[future_y_contract.root_symbol] = -y_target_pct
            context.long_term_weights[future_x_contract.root_symbol] = x_target_pct
            context.current_weights[future_y_contract] = context.long_term_weights[future_y_contract.root_symbol]
            context.current_weights[future_x_contract] = context.long_term_weights[future_x_contract.root_symbol]
            continue
    
    adjusted_weights = pd.Series({
        k: v / (len(context.futures_pairs)) for k, v in context.current_weights.items()
    })
    
    order_optimal_portfolio(
        opt.TargetPortfolioWeights(adjusted_weights),
        constraints=[
            opt.MaxGrossLeverage(1.0),
        ],
        universe=context.current_weights.keys(),
    )
    log.info('weights: ', adjusted_weights)
        
    
def computeHoldingsPct(yShares, xShares, yPrice, xPrice):
    yDol = yShares * yPrice
    xDol = xShares * xPrice
    notionalDol =  abs(yDol) + abs(xDol)
    y_target_pct = yDol / notionalDol
    x_target_pct = xDol / notionalDol
    return (y_target_pct, x_target_pct)
There was a runtime error.

Welcome, Futures Workshop Attendees!

Disclaimer

The material on this website is provided for informational purposes only and does not constitute an offer to sell, a solicitation to buy, or a recommendation or endorsement for any security or strategy, nor does it constitute an offer to provide investment advisory services by Quantopian. In addition, the material offers no opinion with respect to the suitability of any security or specific investment. No information contained herein should be regarded as a suggestion to engage in or refrain from any investment-related course of action as none of Quantopian nor any of its affiliates is undertaking to provide investment advice, act as an adviser to any plan or entity subject to the Employee Retirement Income Security Act of 1974, as amended, individual retirement account or individual retirement annuity, or give advice in a fiduciary capacity with respect to the materials presented herein. If you are an individual retirement or other investor, contact your financial advisor or other fiduciary unrelated to Quantopian about whether any given investment idea, strategy, product or service described herein may be appropriate for your circumstances. All investments involve risk, including loss of principal. Quantopian makes no guarantees as to the accuracy or completeness of the views expressed in the website. The views are subject to change, and may have become unreliable for various reasons, including changes in market conditions or economic circumstances.

This is amazing work chaps. A whole world of trading opportunities!

Thanks, Dan!

I modified the template algorithm to trade only on the crush spread.

Clone Algorithm
Loading...
Total Returns
--
Alpha
--
Beta
--
Sharpe
--
Sortino
--
Max Drawdown
--
Benchmark Returns
--
Volatility
--
Returns 1 Month 3 Month 6 Month 12 Month
Alpha 1 Month 3 Month 6 Month 12 Month
Beta 1 Month 3 Month 6 Month 12 Month
Sharpe 1 Month 3 Month 6 Month 12 Month
Sortino 1 Month 3 Month 6 Month 12 Month
Volatility 1 Month 3 Month 6 Month 12 Month
Max Drawdown 1 Month 3 Month 6 Month 12 Month
import itertools

import numpy as np
import pandas as pd
import scipy as sp
from statsmodels.tsa.stattools import coint

from quantopian.algorithm import order_optimal_portfolio
import quantopian.experimental.optimize as opt

month_idx = 0

def initialize(context):
    # Quantopian backtester specific variables
    
    context.crush = [
        continuous_future(
            'SY',
            offset=0,
            roll='volume',
            adjustment='mul'
        ),
        continuous_future(
            'SM',
            offset=month_idx,
            roll='calendar',
            adjustment='mul',
        ),
        continuous_future(
            'BO',
            offset=month_idx,
            roll='calendar',
            adjustment='mul',
        ),  
    ]
    
    # strategy specific variables
    context.long_ma = 63
    context.short_ma = 5
    
    context.inLong = False
    context.inShort = True
    
    context.long_term_weights = {cont_future.root_symbol: 0 for cont_future in context.crush}
    context.current_weights = {}
    
    schedule_function(func=rebalance_pairs, date_rule=date_rules.every_day(), time_rule=time_rules.market_open(minutes=30))
    
# Will be called on every trade event for the securities you specify. 
def handle_data(context, data):
    # Our work is now scheduled in check_pair_status
    pass

def rebalance_pairs(context, data):
    if get_open_orders():
        return
    
    prices = data.history(context.crush, 'price', context.long_ma, '1d')
    
    soy_price = prices[context.crush[0]]
    soy_meal_price = prices[context.crush[1]]
    soy_oil_price = prices[context.crush[2]]
        
    pvalue = coint(soy_price, (soy_meal_price + soy_oil_price))[1]
    if pvalue > 0.10:
        log.info(
            'No longer cointegrated, no new positions.'
        )
            
    soy_returns = soy_price.pct_change()[1:]
    refined_returns = (soy_meal_price + soy_oil_price).pct_change()[1:]
    regression = sp.stats.linregress(
        soy_returns[-context.long_ma:],
        refined_returns[-context.long_ma:],
    )
        
    spreads = soy_returns - (regression.slope * refined_returns)

    zscore = (
        np.mean(spreads[-context.short_ma]) - np.mean(spreads)
    ) / np.std(spreads, ddof=1)
            
    soy_contract, soy_meal_contract, soy_oil_contract = data.current(
        [context.crush[0], context.crush[1], context.crush[2]],
        'contract',
    )
        
    context.current_weights[soy_contract] = context.long_term_weights['SY']
    context.current_weights[soy_meal_contract] = context.long_term_weights['SM']
    context.current_weights[soy_oil_contract] = context.long_term_weights['BO']
        
        
    hedge_ratio = (
        np.abs(np.corrcoef(soy_returns, refined_returns)[0][1]) * 
        (np.std(soy_returns) / np.std(refined_returns)) * 
        ((soy_price[-1]*soy_contract.multiplier) / (soy_meal_price[-1]*soy_meal_contract.multiplier + soy_oil_price[-1]*soy_oil_contract.multiplier))
    )

    if context.inShort and zscore < 0.0:
        context.long_term_weights['SY'] = 0
        context.long_term_weights['SM'] = 0
        context.long_term_weights['BO'] = 0
        context.current_weights[soy_contract] = context.long_term_weights['SY']
        context.current_weights[soy_meal_contract] = context.long_term_weights['SM']
        context.current_weights[soy_oil_contract] = context.long_term_weights['BO']
            
        context.inLong = False
        context.inShort = False

    if context.inLong and zscore > 0.0:
        context.long_term_weights['SY'] = 0
        context.long_term_weights['SM'] = 0
        context.long_term_weights['BO'] = 0
        context.current_weights[soy_contract] = context.long_term_weights['SY']
        context.current_weights[soy_meal_contract] = context.long_term_weights['SM']
        context.current_weights[soy_oil_contract] = context.long_term_weights['BO'] 
        
        context.inLong = False
        context.inShort = False

    if zscore < -1.0 and (not context.inLong):
        # Only trade if NOT already in a trade
        soy_target_contracts = 1
        soy_meal_target_contracts = hedge_ratio
        soy_oil_target_contracts = hedge_ratio
        context.inLong = True
        context.inShort = False
            
        soy_target_pct = (
            soy_target_contracts*soy_price[-1]*soy_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
             soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        soy_meal_target_pct = (
            soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        soy_oil_target_pct = (
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        context.long_term_weights['SY'] = soy_target_pct
        context.long_term_weights['SM'] = -soy_meal_target_pct
        context.long_term_weights['BO'] = -soy_oil_target_pct
            
        context.current_weights[soy_contract] = context.long_term_weights['SY']
        context.current_weights[soy_meal_contract] = context.long_term_weights['SM']
        context.current_weights[soy_oil_contract] = context.long_term_weights['BO']

    if zscore > 1.0 and (not context.inShort):
            # Only trade if NOT already in a trade
        soy_target_contracts = 1
        soy_meal_target_contracts = hedge_ratio
        soy_oil_target_contracts = hedge_ratio
        context.inLong = False
        context.inShort = True
            
        soy_target_pct = (
            soy_target_contracts*soy_price[-1]*soy_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        soy_meal_target_pct = (
            soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        soy_oil_target_pct = (
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier /
            (soy_meal_target_contracts*soy_meal_price[-1]*soy_meal_contract.multiplier +
            soy_oil_target_contracts*soy_oil_price[-1]*soy_oil_contract.multiplier)
        )
            
        context.long_term_weights['SY'] = -soy_target_pct
        context.long_term_weights['SM'] = soy_meal_target_pct
        context.long_term_weights['BO'] = soy_oil_target_pct
            
        context.current_weights[soy_contract] = context.long_term_weights['SY']
        context.current_weights[soy_meal_contract] = context.long_term_weights['SM']
        context.current_weights[soy_oil_contract] = context.long_term_weights['BO']
    
    
    order_optimal_portfolio(
        opt.TargetPortfolioWeights(context.current_weights),
        constraints=[
            opt.MaxGrossLeverage(1.0),
        ],
        universe=context.current_weights.keys(),
    )
    log.info('weights: ', context.current_weights)
        

There was a runtime error.

Good catch! I adapted this from the template I wrote earlier and forgot to replace the continue with something relevant. A good substitute would be a return statement after logging.

Thank you for pointing that out.

how do we know our portfolio won't go to margin? the equity curve is only close-to-close. am i right?

Currently, margin is not modeled on Quantopian. To control the spending of your algorithm, you should look at limiting the total target weight of any new portfolio, or better yet, consider the CVaR of your target portfolio. With futures, the term 'margin' is used for the cash that you have to put up when opening positions. In case you haven't seen it, margin is covered in more detail in the Introduction to Futures lecture.

Hi,
I have done some test and the output of coint is not the p-value of adf of the residuals of the regression. So i get that some pairs are cointegrated even if coint gives an output higher that 0.05. I checked the code of coint and is not just using adf p-value but also doing something else. Is coint result reliable?

It is definitely reliable! The adfuller and coint functions (from statsmodels) are performing tests on two different things. Augmented Dickey-Fuller tests the unit roots of the observed variables, while the coint function is going to test the residuals from an estimated cointegrating relationship. Oftentimes, the results of the tests will coincide (and in those cases where they don't, it is definitely hard to make a decision), but the two tests have different critical values associated with them so they won't always have the same result.

These are the exercises for this lecture.

Loading notebook preview...

Hi Max,

spreads = y_returns - (regression.slope * x_returns)

Shouldn't this be:

spreads = y_returns - (regression.slope * x_returns + regression.intercept)

Also, why is this called "spread"? The name sounds terribly misleading to me, when I hear spread I immediately think about the difference between prices, especially because in that other futures pair trading notebook you have a "currently_long_the_spread" variable which does actually refer to the difference in prices. Instead, that quantity represents the difference between return realised and return predicted by the linear regression. It would better be named maybe fit_error or something like that.

I'm trying to trade Y - \beta * X, as that's the linear combination between the two series. That's the spread the I expect to be stationary, centered around alpha + noise.

I am using "spread" to be consistent across templates, though I recognize that here it is definitely a little confusing. I will take that into account when I finish fixing the algo.

Thanks for coming back Max. Right, I think I understand what's going on. Computing

spreads = y_returns - (regression.slope * x_returns + regression.intercept)

instead of

spreads = y_returns - (regression.slope * x_returns)

just centers the "spread" around zero. But the actual signal is the zscore which is invariant under addition of an overall constant, so the two definitions of "spread" should lead to the same algorithm behaviour.