Back to Community
Factor Relative Strength Tilting

G'Day Mates,

Listening to the latest episode of Bloomberg's MIB podcast, I wanted to see if I could create a 'factor tilting' framework, as discussed by Andrew Ang (head of factor investing at Blackrock) in the episode. Unfortunately my python programming skills are not good enough to be able to do this on my own, so I thought I'd reach out to the community to see if someone is able and willing to help create such a framework.

The attached strategy has four very basic factors based on Value, Quality, Momentum, and Growth, and are currently (statically) equally weighted at 25% each (after winsorized and normalized/zscored). If I understand it correctly, a 'relative strength factor tilting' would give more/less weight based on each factors relative strength as compared to the other three factors. Perhaps by using average rolling Returns of each factor during some look-back window (say last 6 months) as a measure of 'strength'? Each factor's relative strength can then be used as either over-weight/tilt or under-weigth/tilt, depending on if one believes in factor strength Momentum or Reversal.

I'm not sure if the Q built-in RSI factor can be used for this purpose, or if creating a specific CustomFactor is needed, or if it's better to create individual CustomFactors for the above four factors, and then use them as input in the factor relative strength calculation (inside or outside of a Pipeline?), similar to what @Grant does in his Cluster of Factors algo?

If anyone would be willing to help creating this 'relative strength factor tilting' framework, I'd be very grateful.

Clone Algorithm
5
Loading...
Backtest from to with initial capital
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 quantopian.algorithm as algo
import quantopian.optimize as opt
from quantopian.pipeline import Pipeline
from quantopian.pipeline.factors import SimpleMovingAverage, CustomFactor, Returns, RSI
import pandas as pd
import numpy as np
from quantopian.pipeline.filters import QTradableStocksUS
from quantopian.pipeline.experimental import risk_loading_pipeline
from quantopian.pipeline.data import Fundamentals as msf
from quantopian.pipeline.data.factset import Fundamentals as fsf
from quantopian.pipeline.data.builtin import USEquityPricing

MAX_GROSS_LEVERAGE = 1.0
TOTAL_POSITIONS = 600

MAX_SHORT_POSITION_SIZE = 1.0 / TOTAL_POSITIONS
MAX_LONG_POSITION_SIZE = 1.0 / TOTAL_POSITIONS

def initialize(context):

    algo.attach_pipeline(make_pipeline(), 'long_short_equity_template')
    algo.attach_pipeline(risk_loading_pipeline(), 'risk_factors')

    algo.schedule_function(func=rebalance,
                           date_rule=algo.date_rules.every_day(),#week_start(),
                           time_rule=algo.time_rules.market_open(hours=0, minutes=30),
                           half_days=True)

    algo.schedule_function(func=record_vars,
                           date_rule=algo.date_rules.every_day(),
                           time_rule=algo.time_rules.market_close(),
                           half_days=True)
    

def make_pipeline():

    universe = QTradableStocksUS()
    
    value = msf.ebit.latest / msf.enterprise_value.latest
    quality = msf.roic.latest
    momentum = AdvMomentum252(mask=universe)
    growth = msf.equity_per_share_growth.latest
    
    value_w = value.winsorize(0.02,0.98)
    quality_w = quality.winsorize(0.02,0.98)
    momentum_w = momentum.winsorize(0.02,0.98)
    growth_w = growth.winsorize(0.02,0.98)


    combined_factor = (
        
        value_w.zscore(mask=universe)
       + quality_w.zscore(mask=universe)
       + momentum_w.zscore(mask=universe)
       + growth_w.zscore(mask=universe)

    )

    # longs = combined_factor.top(TOTAL_POSITIONS//2, mask=universe)
    # shorts = combined_factor.bottom(TOTAL_POSITIONS//2, mask=universe)

    long_short_screen = universe #& (longs | shorts)

    pipe = Pipeline(
        columns={
            # 'longs': longs,
            # 'shorts': shorts,
            'combined_factor': combined_factor
        },
        screen=long_short_screen
    )
    return pipe


def before_trading_start(context, data):

    context.pipeline_data = algo.pipeline_output('long_short_equity_template')
    context.risk_loadings = algo.pipeline_output('risk_factors')


def record_vars(context, data):

    algo.record(num_positions=len(context.portfolio.positions))


def rebalance(context, data):

    pipeline_data = context.pipeline_data
    risk_loadings = context.risk_loadings

    # Changed from MaximizeAlpha to normalized weigths using TargetWeights instead:
    alpha_weight = pipeline_data['combined_factor']
    alpha_weight_norm = alpha_weight / alpha_weight.abs().sum() 
    
    alpha_weight_norm = alpha_weight_norm[pd.notnull(alpha_weight_norm)]
    
    # objective = opt.TargetWeights(alpha_weight_norm)
    objective = opt.MaximizeAlpha(alpha_weight_norm)
    
    constraints = []
    
    constraints.append(opt.MaxGrossExposure(MAX_GROSS_LEVERAGE))

    constraints.append(opt.DollarNeutral())

    neutralize_risk_factors = opt.experimental.RiskModelExposure(
        risk_model_loadings=risk_loadings,
        version=0
    )
    constraints.append(neutralize_risk_factors)

    constraints.append(
        opt.PositionConcentration.with_equal_bounds(
            min=-MAX_SHORT_POSITION_SIZE,
            max=MAX_LONG_POSITION_SIZE
        ))

    algo.order_optimal_portfolio(
        objective=objective,
        constraints=constraints
    )
    
class AdvMomentum252(CustomFactor):
        inputs = [USEquityPricing.close,
                  Returns(window_length=126)]
        window_length = 252

        def compute(self, today, assets, out, prices, returns):
            out[:] = ((prices[-21] - prices[-252])/prices[-252] -
                      (prices[-1] - prices[-21])/prices[-21]) / np.nanstd(returns, axis=0)
There was a runtime error.
3 responses

Bumping, hoping someone is able to help with this.

@Joakim,
Just changed your "Value" factor to one with more bang-for-the-buck...fcf_yield...
A bit better returns.
alan

Clone Algorithm
3
Loading...
Backtest from to with initial capital
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 quantopian.algorithm as algo
import quantopian.optimize as opt
from quantopian.pipeline import Pipeline
from quantopian.pipeline.factors import SimpleMovingAverage, CustomFactor, Returns, RSI
import pandas as pd
import numpy as np
from quantopian.pipeline.filters import QTradableStocksUS
from quantopian.pipeline.experimental import risk_loading_pipeline
from quantopian.pipeline.data import Fundamentals as msf
from quantopian.pipeline.data.factset import Fundamentals as fsf
from quantopian.pipeline.data.builtin import USEquityPricing

from sklearn import preprocessing
from scipy.stats.mstats import winsorize
WIN_LIMIT=0.02

MAX_GROSS_LEVERAGE = 1.0
TOTAL_POSITIONS = 600

MAX_SHORT_POSITION_SIZE = 1.0 / TOTAL_POSITIONS
MAX_LONG_POSITION_SIZE = 1.0 / TOTAL_POSITIONS

def initialize(context):

    algo.attach_pipeline(make_pipeline(), 'long_short_equity_template')
    algo.attach_pipeline(risk_loading_pipeline(), 'risk_factors')

    algo.schedule_function(func=rebalance,
                           date_rule=algo.date_rules.every_day(),#week_start(),
                           time_rule=algo.time_rules.market_open(hours=0, minutes=30),
                           half_days=True)

    algo.schedule_function(func=record_vars,
                           date_rule=algo.date_rules.every_day(),
                           time_rule=algo.time_rules.market_close(),
                           half_days=True)

def make_pipeline():
 
    universe = QTradableStocksUS()
    
    #value = msf.ebit.latest / msf.enterprise_value.latest
    value = msf.fcf_yield.latest
    quality = msf.roic.latest
    momentum = AdvMomentum252(mask=universe)
    growth = msf.equity_per_share_growth.latest
    
    value_w = value.winsorize(0.02,0.98)
    quality_w = quality.winsorize(0.02,0.98)
    momentum_w = momentum.winsorize(0.02,0.98)
    growth_w = growth.winsorize(0.02,0.98)


    combined_factor = (
        
        value_w.zscore(mask=universe)
       + quality_w.zscore(mask=universe)
       + momentum_w.zscore(mask=universe)
       + growth_w.zscore(mask=universe)

    )

    # longs = combined_factor.top(TOTAL_POSITIONS//2, mask=universe)
    # shorts = combined_factor.bottom(TOTAL_POSITIONS//2, mask=universe)

    long_short_screen = universe #& (longs | shorts)

    pipe = Pipeline(
        columns={
            # 'longs': longs,
            # 'shorts': shorts,
            'combined_factor': combined_factor
        },
        screen=long_short_screen
    )
    return pipe


def before_trading_start(context, data):

    context.pipeline_data = algo.pipeline_output('long_short_equity_template')
    context.risk_loadings = algo.pipeline_output('risk_factors')


def record_vars(context, data):

    algo.record(num_positions=len(context.portfolio.positions))


def rebalance(context, data):

    pipeline_data = context.pipeline_data
    risk_loadings = context.risk_loadings

    # Changed from MaximizeAlpha to normalized weigths using TargetWeights instead:
    alpha_weight = pipeline_data['combined_factor']
    alpha_weight_norm = alpha_weight / alpha_weight.abs().sum() 
    
    alpha_weight_norm = alpha_weight_norm[pd.notnull(alpha_weight_norm)]
    
    # objective = opt.TargetWeights(alpha_weight_norm)
    objective = opt.MaximizeAlpha(alpha_weight_norm)
    
    constraints = []
    
    constraints.append(opt.MaxGrossExposure(MAX_GROSS_LEVERAGE))

    constraints.append(opt.DollarNeutral())

    neutralize_risk_factors = opt.experimental.RiskModelExposure(
        risk_model_loadings=risk_loadings,
        version=0
    )
    constraints.append(neutralize_risk_factors)

    constraints.append(
        opt.PositionConcentration.with_equal_bounds(
            min=-MAX_SHORT_POSITION_SIZE,
            max=MAX_LONG_POSITION_SIZE
        ))

    algo.order_optimal_portfolio(
        objective=objective,
        constraints=constraints
    )
    
class AdvMomentum252(CustomFactor):
        inputs = [USEquityPricing.close,
                  Returns(window_length=126)]
        window_length = 252

        def compute(self, today, assets, out, prices, returns):
            out[:] = ((prices[-21] - prices[-252])/prices[-252] -
                      (prices[-1] - prices[-21])/prices[-21]) / np.nanstd(returns, axis=0)
There was a runtime error.

@Joakim,

In addition to what Alan did:

Replaced AdvMomentum252 by simple one.

momentum = Returns(window_length = 253) - Returns(window_length = 22)  

Removed winsorize().
Replaced zscore() by rank().

def make_pipeline():

    universe = QTradableStocksUS()  
    momentum = Returns(window_length = 253) - Returns(window_length = 22)  
    growth = msf.equity_per_share_growth.latest  
    quality = msf.roic.latest  
    # value = msf.ebit.latest / msf.enterprise_value.latest  
    value = msf.fcf_yield.latest

    screen = (universe  
            & momentum.notnan()  
            & growth.notnan()  
            & quality.notnan()  
            & value.notnan()  
            )  
    combined_factor = (momentum.rank(mask = screen)  
                       + growth.rank(mask = screen)  
                       + quality.rank(mask = screen)  
                       + value.rank(mask = screen)  
                       )  
    pipe = Pipeline(columns={'combined_factor': combined_factor, }, screen = screen)

    return pipe


Total Returns
19.05%
Benchmark Returns
76.04%
Alpha
0.05
Beta
-0.02
Sharpe
1.28
Sortino
1.90
Volatility
0.04
Max Drawdown
-5.42%

Reduced TOTAL_POSITIONS to 300.

Total Returns
27.86%
Benchmark Returns
76.04%
Alpha
0.08
Beta
-0.03
Sharpe
1.46
Sortino
2.20
Volatility
0.05
Max Drawdown
-5.53%

Unfortunately I unable to attach backtest more then 2 years.