Back to Community
multi-factor long-short equity w/ RSI-based short_term_reversal risk factor nullification

Here's an example of using an RSI factor to reduce the short_term_reversal risk. Will post tear sheets next.

Clone Algorithm
143
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
from quantopian.algorithm import attach_pipeline, pipeline_output, order_optimal_portfolio
from quantopian.pipeline import Pipeline
from quantopian.pipeline.factors import CustomFactor, RollingLinearRegressionOfReturns
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data import Fundamentals, psychsignal
from quantopian.pipeline.classifiers.fundamentals import Sector
from quantopian.pipeline.factors import Latest, Returns, AverageDollarVolume
import quantopian.optimize as opt
from sklearn import preprocessing
from quantopian.pipeline.experimental import QTradableStocksUS
from scipy.stats.mstats import winsorize

import numpy as np
import pandas as pd

# Liquidity screen parameters
LIQUIDITY_LOOKBACK_LENGTH = 100
UNIVERSE_SIZE = 1200

# Constraint Parameters
MAX_GROSS_EXPOSURE = 1.0
NUM_TOTAL_POSITIONS = 600
NUM_LONG_POSITIONS = NUM_TOTAL_POSITIONS/2
NUM_SHORT_POSITIONS = NUM_LONG_POSITIONS

MAX_LONG_POSITION_SIZE = 2.0/NUM_TOTAL_POSITIONS
MAX_SHORT_POSITION_SIZE = MAX_LONG_POSITION_SIZE

# Risk Exposures
MAX_SECTOR_EXPOSURE = 0.005
MAX_BETA_EXPOSURE = 0.05

# Factor preprocessing settings
WIN_LIMIT = 0.01  # factor preprocess winsorize limit
        
def make_factors():
   
    class mean_rev(CustomFactor):   
        inputs = [USEquityPricing.open,USEquityPricing.high,USEquityPricing.low,USEquityPricing.close]
        window_length = 5
        def compute(self, today, assets, out, open, high, low, close):

            p = (open+high+low+close)/4
            rng = (high-low)/close

            m = len(p)
            a = np.zeros(m)

            for k in range(1,m+1):
                a = preprocess(np.mean(p[-k:,:],axis=0)/close[-1,:])
                w = np.nanmean(rng[-k:,:],axis=0)
                a += w*a

            out[:] = preprocess(a)
    
    class fcf(CustomFactor):
        inputs = [Fundamentals.fcf_yield]
        window_length = 1
        def compute(self, today, assets, out, fcf_yield):
            out[:] = preprocess(fcf_yield)
            
    class earn_yield(CustomFactor):
        inputs = [Fundamentals.earning_yield]
        window_length = 1
        def compute(self, today, assets, out, earn_yield):
            out[:] = preprocess(earn_yield)
            
    class sentiment(CustomFactor):
        inputs = [psychsignal.stocktwits.bull_minus_bear]
        window_length = 1
        def compute(self, today, assets, out, sentiment):
            out[:] = preprocess(sentiment)
            
    class MessageSum(CustomFactor):
        inputs = [psychsignal.stocktwits.bull_scored_messages, psychsignal.stocktwits.bear_scored_messages]
        window_length = 5
        def compute(self, today, assets, out, bull, bear):
            out[:] = preprocess(np.nansum(bear-bull, axis=0))
            
    class Volatility(CustomFactor):    
        inputs = [USEquityPricing.high,USEquityPricing.low,USEquityPricing.close]
        window_length = 21
        def compute(self, today, assets, out, high, low, close):
            p = (high-low)/close
            out[:] = preprocess(-np.nansum(p,axis=0))
            
    class Direction(CustomFactor):    
        inputs = [USEquityPricing.open, USEquityPricing.close]
        window_length = 21
        def compute(self, today, assets, out, open, close):
            p = (close-open)/close
            out[:] = preprocess(-np.nansum(p,axis=0))
            
    class RSI(CustomFactor):
        window_length = 15
        inputs = (USEquityPricing.close,)

        def compute(self, today, assets, out, closes):
            diffs = np.diff(closes, axis=0)
            ups = np.nanmean(np.clip(diffs, 0, np.inf), axis=0)
            downs = abs(np.nanmean(np.clip(diffs, -np.inf, 0), axis=0))
            rs = ups/downs
            rsi = 100.0-100.0/(1.0+rs)
            out[:] = preprocess(rsi)
            
    return {
            'MeanRev':              mean_rev,
            'FCF':                  fcf,
            'Yield':                earn_yield,
            'Sentiment':            sentiment,
            'MessageSum':           MessageSum,
            # 'Volatility':           Volatility,
            'Direction':            Direction,
            'RSI':                  RSI,
        }


def make_pipeline():
    
    pricing = USEquityPricing.close.latest
    base_universe = QTradableStocksUS() & (pricing > 5)
    ev = Latest(inputs=[Fundamentals.enterprise_value], mask=base_universe)
    ev_positive = ev > 0
    ebitda = Latest(inputs=[Fundamentals.ebitda], mask=ev_positive)
    ebitda_positive = ebitda > 0
    market_cap = Latest(inputs=[Fundamentals.market_cap])
    market_cap_top = market_cap.top(UNIVERSE_SIZE, mask=ebitda_positive)
    universe = (
        AverageDollarVolume(window_length=LIQUIDITY_LOOKBACK_LENGTH)
        .top(NUM_TOTAL_POSITIONS, mask=market_cap_top)
    )
    
    sector = Sector(mask=universe)  # sector needed to construct portfolio
    # ===============================================
    
    factors = make_factors()
    
    combined_alpha = None
    for name, f in factors.iteritems():
        if combined_alpha == None:
            combined_alpha = f(mask=universe)
        else:
            combined_alpha += f(mask=universe)

    longs = combined_alpha.top(NUM_LONG_POSITIONS)
    shorts = combined_alpha.bottom(NUM_SHORT_POSITIONS)

    long_short_screen = (longs | shorts)
    
    beta = 0.66*RollingLinearRegressionOfReturns(
                    target=sid(8554),
                    returns_length=5,
                    regression_length=260,
                    mask=long_short_screen
                    ).beta + 0.33*1.0

# Create pipeline
    pipe = Pipeline(columns = {
        'combined_alpha':combined_alpha,
        'sector':sector,
        'market_beta':beta
    },
    screen = long_short_screen)
    return pipe

def initialize(context):

    context.spy = sid(8554)

    attach_pipeline(make_pipeline(), 'long_short_equity_template')

    # Schedule my rebalance function
    schedule_function(func=rebalance,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_open(minutes=60),
                      half_days=True)
    # record my portfolio variables at the end of day
    schedule_function(func=recording_statements,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_close(),
                      half_days=True)
    
    # set_commission(commission.PerShare(cost=0, min_trade_cost=0))
    # set_slippage(slippage.FixedSlippage(spread=0))
    
def before_trading_start(context, data):

    context.pipeline_data = pipeline_output('long_short_equity_template')

def recording_statements(context, data):

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

def rebalance(context, data):
    
    pipeline_data = context.pipeline_data

    risk_factor_exposures = pd.DataFrame({
            'market_beta':pipeline_data.market_beta.fillna(1.0)
        })
    
    denom = np.nansum(np.absolute(pipeline_data.combined_alpha.values))
    objective = opt.MaximizeAlpha(pipeline_data.combined_alpha/denom)
    
    constraints = []

    constraints.append(opt.MaxGrossExposure(MAX_GROSS_EXPOSURE))
    constraints.append(opt.DollarNeutral())
    constraints.append(
        opt.NetGroupExposure.with_equal_bounds(
            labels=pipeline_data.sector,
            min=-MAX_SECTOR_EXPOSURE,
            max=MAX_SECTOR_EXPOSURE,
        ))
    neutralize_risk_factors = opt.FactorExposure(
        loadings=risk_factor_exposures,
        min_exposures={'market_beta':-MAX_BETA_EXPOSURE},
        max_exposures={'market_beta':MAX_BETA_EXPOSURE}
        )
    constraints.append(neutralize_risk_factors)
    constraints.append(
        opt.PositionConcentration.with_equal_bounds(
            min=-MAX_SHORT_POSITION_SIZE,
            max=MAX_LONG_POSITION_SIZE
        ))

    try:
        order_optimal_portfolio(
        objective=objective,
        constraints=constraints,
    )
    except:
        return

def preprocess(a):
    
    a = np.nan_to_num(a - np.nanmean(a))
    a = winsorize(a,limits=(WIN_LIMIT,WIN_LIMIT))
    a = a/np.sum(np.absolute(a))
    
    return preprocessing.scale(a)
There was a runtime error.
7 responses

Here's the tear sheet for the backtest above. Note that if the RSI factor is not included, the short_term_reversal will be well outside the +/- 0.4 bound, per the new contest constraints.

The size factor is at -0.19, which can probably be fixed by fiddling with the universe or perhaps by adding another factor (a homework assignment...please post your result).

Loading notebook preview...

Awesome @Grant...methinks you win the contest and we all go home!!!

Thanks Alan -

Rather than "winning" I'm aiming for "not being disqualified" at this point.

I'd be interested in comments/questions/improvements (not so much how to improve performance, e.g. via better factors, but how to improve the template/framework).

Dan Whitnable provides the outline of an alternate (and probably better) approach here:

https://www.quantopian.com/posts/hedging-against-factors-for-algos

His example is for momentum which would be replaced with the RSI-based short_term_reversal for my algo above.

Here's an update without the RSI hack to correct the short_term_reversal, and instead using the new Q Risk Factor Extractor . Note that I don't yet have the new slippage model incorporated.

Clone Algorithm
143
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
from quantopian.algorithm import attach_pipeline, pipeline_output, order_optimal_portfolio
from quantopian.pipeline import Pipeline
from quantopian.pipeline.factors import CustomFactor, RollingLinearRegressionOfReturns
from quantopian.pipeline.data.builtin import USEquityPricing
from quantopian.pipeline.data import Fundamentals, psychsignal
from quantopian.pipeline.classifiers.fundamentals import Sector
from quantopian.pipeline.factors import Latest, Returns, AverageDollarVolume
import quantopian.optimize as opt
from sklearn import preprocessing
from quantopian.pipeline.experimental import QTradableStocksUS, risk_loading_pipeline
from scipy.stats.mstats import winsorize

import numpy as np
import pandas as pd

# Liquidity screen parameters
LIQUIDITY_LOOKBACK_LENGTH = 100
UNIVERSE_SIZE = 1200

# Constraint Parameters
MAX_GROSS_EXPOSURE = 1.0
NUM_TOTAL_POSITIONS = 600
NUM_LONG_POSITIONS = NUM_TOTAL_POSITIONS/2
NUM_SHORT_POSITIONS = NUM_LONG_POSITIONS

MAX_LONG_POSITION_SIZE = 2.0/NUM_TOTAL_POSITIONS
MAX_SHORT_POSITION_SIZE = MAX_LONG_POSITION_SIZE

# Risk Exposures
MAX_SECTOR_EXPOSURE = 0.005
MAX_BETA_EXPOSURE = 0.05

# Factor preprocessing settings
WIN_LIMIT = 0.01  # factor preprocess winsorize limit
        
def make_factors():
   
    class mean_rev(CustomFactor):   
        inputs = [USEquityPricing.open,USEquityPricing.high,USEquityPricing.low,USEquityPricing.close]
        window_length = 5
        def compute(self, today, assets, out, open, high, low, close):

            p = (open+high+low+close)/4
            rng = (high-low)/close

            m = len(p)
            a = np.zeros(m)

            for k in range(1,m+1):
                a = preprocess(np.mean(p[-k:,:],axis=0)/close[-1,:])
                w = np.nanmean(rng[-k:,:],axis=0)
                a += w*a

            out[:] = preprocess(a)
    
    class fcf(CustomFactor):
        inputs = [Fundamentals.fcf_yield]
        window_length = 1
        def compute(self, today, assets, out, fcf_yield):
            out[:] = preprocess(fcf_yield)
            
    class earn_yield(CustomFactor):
        inputs = [Fundamentals.earning_yield]
        window_length = 1
        def compute(self, today, assets, out, earn_yield):
            out[:] = preprocess(earn_yield)
            
    class sentiment(CustomFactor):
        inputs = [psychsignal.stocktwits.bull_minus_bear]
        window_length = 1
        def compute(self, today, assets, out, sentiment):
            out[:] = preprocess(sentiment)
            
    class MessageSum(CustomFactor):
        inputs = [psychsignal.stocktwits.bull_scored_messages, psychsignal.stocktwits.bear_scored_messages]
        window_length = 5
        def compute(self, today, assets, out, bull, bear):
            out[:] = preprocess(np.nansum(bear-bull, axis=0))
            
    class Volatility(CustomFactor):    
        inputs = [USEquityPricing.high,USEquityPricing.low,USEquityPricing.close]
        window_length = 21
        def compute(self, today, assets, out, high, low, close):
            p = (high-low)/close
            out[:] = preprocess(-np.nansum(p,axis=0))
            
    class Direction(CustomFactor):    
        inputs = [USEquityPricing.open, USEquityPricing.close]
        window_length = 21
        def compute(self, today, assets, out, open, close):
            p = (close-open)/close
            out[:] = preprocess(-np.nansum(p,axis=0))
            
    class RSI(CustomFactor):
        window_length = 15
        inputs = (USEquityPricing.close,)

        def compute(self, today, assets, out, closes):
            diffs = np.diff(closes, axis=0)
            ups = np.nanmean(np.clip(diffs, 0, np.inf), axis=0)
            downs = abs(np.nanmean(np.clip(diffs, -np.inf, 0), axis=0))
            rs = ups/downs
            rsi = 100.0-100.0/(1.0+rs)
            out[:] = preprocess(rsi)
            
    return {
            'MeanRev':              mean_rev,
            'FCF':                  fcf,
            'Yield':                earn_yield,
            'Sentiment':            sentiment,
            'MessageSum':           MessageSum,
            # 'Volatility':           Volatility,
            'Direction':            Direction,
            # 'RSI':                  RSI,
        }


def make_pipeline():
    
    pricing = USEquityPricing.close.latest
    base_universe = QTradableStocksUS() & (pricing > 5)
    ev = Latest(inputs=[Fundamentals.enterprise_value], mask=base_universe)
    ev_positive = ev > 0
    ebitda = Latest(inputs=[Fundamentals.ebitda], mask=ev_positive)
    ebitda_positive = ebitda > 0
    market_cap = Latest(inputs=[Fundamentals.market_cap])
    market_cap_top = market_cap.top(UNIVERSE_SIZE, mask=ebitda_positive)
    universe = (
        AverageDollarVolume(window_length=LIQUIDITY_LOOKBACK_LENGTH)
        .top(NUM_TOTAL_POSITIONS, mask=ebitda_positive)
    )
    
    sector = Sector(mask=universe)  # sector needed to construct portfolio
    # ===============================================
    
    factors = make_factors()
    
    combined_alpha = None
    for name, f in factors.iteritems():
        if combined_alpha == None:
            combined_alpha = f(mask=universe)
        else:
            combined_alpha += f(mask=universe)

    longs = combined_alpha.top(NUM_LONG_POSITIONS)
    shorts = combined_alpha.bottom(NUM_SHORT_POSITIONS)

    long_short_screen = (longs | shorts)
    
    beta = 0.66*RollingLinearRegressionOfReturns(
                    target=sid(8554),
                    returns_length=5,
                    regression_length=260,
                    mask=long_short_screen
                    ).beta + 0.33*1.0

# Create pipeline
    pipe = Pipeline(columns = {
        'combined_alpha':combined_alpha,
        'sector':sector,
        'market_beta':beta
    },
    screen = long_short_screen)
    return pipe

def initialize(context):

    context.spy = sid(8554)

    attach_pipeline(make_pipeline(), 'long_short_equity_template')
    attach_pipeline(risk_loading_pipeline(), 'risk_loading_pipeline')

    # Schedule my rebalance function
    schedule_function(func=rebalance,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_open(minutes=60),
                      half_days=True)
    # record my portfolio variables at the end of day
    schedule_function(func=recording_statements,
                      date_rule=date_rules.every_day(),
                      time_rule=time_rules.market_close(),
                      half_days=True)
    
    # set_commission(commission.PerShare(cost=0, min_trade_cost=0))
    # set_slippage(slippage.FixedSlippage(spread=0))
    
def before_trading_start(context, data):

    context.pipeline_data = pipeline_output('long_short_equity_template')
    context.risk_loading_pipeline = pipeline_output('risk_loading_pipeline')

def recording_statements(context, data):

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

def rebalance(context, data):
    
    pipeline_data = context.pipeline_data

    risk_factor_exposures = pd.DataFrame({
            'market_beta':pipeline_data.market_beta.fillna(1.0)
        })
    
    denom = np.nansum(np.absolute(pipeline_data.combined_alpha.values))
    objective = opt.MaximizeAlpha(pipeline_data.combined_alpha/denom)
    
    constraints = []

    constraints.append(opt.MaxGrossExposure(MAX_GROSS_EXPOSURE))
    constraints.append(opt.DollarNeutral())
    constraints.append(
        opt.NetGroupExposure.with_equal_bounds(
            labels=pipeline_data.sector,
            min=-MAX_SECTOR_EXPOSURE,
            max=MAX_SECTOR_EXPOSURE,
        ))
    neutralize_risk_factors = opt.FactorExposure(
        loadings=risk_factor_exposures,
        min_exposures={'market_beta':-MAX_BETA_EXPOSURE},
        max_exposures={'market_beta':MAX_BETA_EXPOSURE}
        )
    constraints.append(neutralize_risk_factors)
    constraints.append(
        opt.PositionConcentration.with_equal_bounds(
            min=-MAX_SHORT_POSITION_SIZE,
            max=MAX_LONG_POSITION_SIZE
        ))
    
    constrain_sector_style_risk = opt.experimental.RiskModelExposure(
        context.risk_loading_pipeline,
        version=opt.Newest,
    )
    
    constraints.append(constrain_sector_style_risk)

    try:
        order_optimal_portfolio(
        objective=objective,
        constraints=constraints,
    )
    except:
        return

def preprocess(a):
    
    a = np.nan_to_num(a - np.nanmean(a))
    a = winsorize(a,limits=(WIN_LIMIT,WIN_LIMIT))
    a = a/np.sum(np.absolute(a))
    
    return preprocessing.scale(a)
There was a runtime error.

Here's the tearsheet analysis for the backtest immediately above. Interestingly, the Q Risk Factor Extractor did not completely beat down the size risk factor. Maybe because I am tilting toward large caps?

Loading notebook preview...

Well, this is frustrating. Q changed something (unless I made a mistake).. The tear sheet analysis now indicates that backtest 5a3405c165f573459338aab3 has too much short_term_reversal risk, even though it should be constrained.

Loading notebook preview...