Value-Quality Multi Factor Strategy#

Restricted data: You may not have access to the data needed for this notebook to run. To check your current data access, view Data. If you would like to access more data, please contact sales@sigtech.com.

Changes will not be saved: Edits made to SigTech’s example notebooks like this will only persist for the duration of your session. When you restart the Research Environment, this notebook will have been restored to its original state, and any changes you have made will have been lost. To make permanent changes, copy and paste the content to a new notebook in one of your workspaces.

Introduction#

The purpose of this notebook is to show how to create a Value and Quality multi factor strategy based of a selection of single stocks from the SPX Index.

Environment#

This section will import relevant internal and external libraries, as well as setting up the platform environment.

[ ]:
import pandas as pd
import datetime as dtm

import sigtech.framework as sig
from sigtech.framework.infra import cal

env = sig.init(env_date=dtm.date(2022, 2, 28))
env[sig.config.ALLOWED_MISSING_CA_DATA] = True

Universe#

The SchedulePeriodic class is used to generate a custom EOM rebalance schedule.

Our universe is evaluated on each rebalance date via the EquityUniverseFilter, which generates a stock universe starting with the S&P500 Index.

A reinvestment strategy is built for each unique stock in our universe via sig.get_single_stock_strategy

[ ]:
CURRENCY='USD'

START_DATE = dtm.date(2021, 1, 4)
[ ]:
rebal_schedule = sig.SchedulePeriodic(START_DATE, env.asofdate, 'NYSE(T) CALENDAR', frequency='EOM', bdc=cal.BDC_FOLLOWING).all_data_dates()

equity_filter = sig.EquityUniverseFilter('SPX INDEX', use_add_order=True)
equity_filter.add('marketcap', 'top', 8, 'daily')

UNIVERSE = equity_filter.apply_filter(rebal_schedule)
UNIVERSE = UNIVERSE.apply(lambda u: [s for s in u if s not in [f'{i}.SINGLE_STOCK.TRADABLE' for i in [1011740, 1023525]]], 1)
UNIVERSE
[ ]:
unique_stocks = UNIVERSE.explode().unique()
unique_stocks
[ ]:
rs = sig.get_single_stock_strategy(unique_stocks, build=True)

UNIVERSE_MAPPING = {s: f'{s} REINV STRATEGY' for s in unique_stocks}

Factors Definition#

Our factors are defined within custom methods which will be called for each stock. These methods will all accept a string parameter rs, which is the reinvestment strategy’s name.

We define four quality and four value factors. The eight methods are then added to a sig.FactorExposures object,, which will be passed into our strategy.

Quality#

[ ]:
def leverage(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    total_liabilities = single_stock.fundamental_series('Total Liabilities', 'Quarterly')
    current_assets = single_stock.fundamental_series('Current Assets - Total', 'Quarterly')
    ebit = single_stock.fundamental_series('EBIT', 'Quarterly')

    leverage = (total_liabilities - current_assets) / ebit

    return 1 / leverage
[ ]:
def cash_ratio(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    cash_pct =  single_stock.fundamental_series('Cash and Equivalents % Total Current Assets', 'Quarterly')
    current_assets = single_stock.fundamental_series('Current Assets - Total', 'Quarterly')
    current_liabilities = single_stock.fundamental_series('Current Liabilities - Total', 'Quarterly')

    return cash_pct * current_assets / current_liabilities
[ ]:
def return_on_capital(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    ebit = single_stock.fundamental_series('EBIT', 'Quarterly')
    current_liabilities = single_stock.fundamental_series('Current Assets - Total', 'Quarterly')
    total_assets = single_stock.fundamental_series('Total Assets', 'Quarterly')

    return ebit / (total_assets - current_liabilities)
[ ]:
def ebit_margin(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    ebit = single_stock.fundamental_series('EBIT', 'Quarterly')
    net_sales = single_stock.fundamental_series('Net Sales or Revenues', 'Quarterly')

    return ebit / net_sales

Value#

[ ]:
def price_to_book(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    bvps = single_stock.fundamental_series('Book Value Per Outstanding Share - Fiscal', frequency='Quarterly')

    price = single_stock.history()

    price_to_book = price / bvps

    return  1 / price_to_book
[ ]:
def price_to_fcf(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    fcfps = single_stock.fundamental_series('Free Cash Flow Per Share', frequency='Annual')

    price = single_stock.history()

    pfcf = price / fcfps

    return 1 / pfcf
[ ]:
def price_to_earnings(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    return 1 / single_stock.fundamental_series('Price/Earnings Ratio', 'Quarterly')
[ ]:
def dividend_yield(rs):

    single_stock = sig.obj.get(sig.obj.get(rs).underlyer)

    return single_stock.fundamental_series('Dividend Yield', 'Quarterly')

Factor Exposure Object#

[ ]:
multi_factor_definition = sig.FactorExposures()

multi_factor_definition.add_raw_factor_timeseries('leverage', method=leverage)
multi_factor_definition.add_raw_factor_timeseries('liquidity', method=cash_ratio)
multi_factor_definition.add_raw_factor_timeseries('roc', method=return_on_capital)
multi_factor_definition.add_raw_factor_timeseries('ebit margin', method=ebit_margin)
multi_factor_definition.add_raw_factor_timeseries('pb', method=price_to_book)
multi_factor_definition.add_raw_factor_timeseries('pfcf', method=price_to_fcf)
multi_factor_definition.add_raw_factor_timeseries('pe', method=price_to_earnings)
multi_factor_definition.add_raw_factor_timeseries('div yield', method=dividend_yield)

Factors to Weights#

This method will be called on each rebalance date with the calculated individual factor values, and defines how the factors are combined to select the stocks for our portfolio.

The method accepts a dataframe with factor values for each reinvestment strategy indexed by factor name:

STOCK 1 REINV STRATEGY

STOCK 2 REINV STRATEGY

leverage

0.5

0.5

liquidity

100

100

And returns a series indexed by stocks with signal values:

STOCK 1 REINV STRATEGY

1

STOCK 2 REINV STRATEGY

1

STOCK 3 REINV STRATEGY

1

This factors to weights functions combines factors by z_score within each factor type, before combining the combined “Quality” and combined “Value” scores with variable weights (default 50-50).

The top 20% of stocks ranked by the final combined score are then selected for our portfolio and assigned a signal value of 1.

[ ]:
quality_factors = ['leverage', 'liquidity', 'roc', 'ebit margin']
value_factors = ['pb', 'pfcf', 'pe', 'div yield']

def combine_factors(factors, factor_weights={'Quality': 0.5, 'Value': 0.5}, **kwargs):

    factors = factors.dropna(axis=1)

    z_scores = {'Quality': {}, 'Value': {}}

    # Calculate each stock's z score for each factor
    for factor_name in factors.index:

        factor_values = factors.loc[factor_name]

        z_score = (factor_values - factor_values.mean()) / factor_values.std()

        if factor_name in quality_factors:
            z_scores['Quality'][factor_name] = z_score

        elif factor_name in value_factors:
            z_scores['Value'][factor_name] = z_score

    factor_scores = {}

    # Combine z scores within factor type
    for factor_type, factor_z_scores in z_scores.items():
        factor_scores[factor_type] = pd.DataFrame(factor_z_scores).sum(axis=1)


    # Apply weightings to different factor types for final combined scores
    combined_scores = (factor_scores['Quality'] * factor_weights['Quality']) \
                        + (factor_scores['Value'] * factor_weights['Value'])

    # Allocate signal value of 1 to top 20% ranked stocks
    n = int(len(combined_scores) * .2)
    portfolio = pd.Series(1, index=combined_scores.nlargest(n).index)

    return portfolio

Portfolio#

The EquityFactorBasket building block combines our universe, factor definitions and factors to weights function to construct our portfolio on each rebalance date: * Evaluate universe via universe_filter * Calculate defined factors from factor object via factor_exposure_generator * Combine the factors via factors_to_weight_function * Allocates to the results from the factors_to_weight_function via the allocation_function

The universe_mapping maps single stocks in our universe to their already built reinvestment strategies for a faster backtest.

A EOM rebalance frequency is specified. A default allocation function NORMALIZE_ZERO_FILLED is applied to equally weight our top 20% stocks, all containing signal values of 1 from our factors_to_weight_function.

[ ]:
efbs = sig.EquityFactorBasket(
    currency=CURRENCY,
    start_date=START_DATE,
    universe_filter=UNIVERSE,
    universe_mapping=UNIVERSE_MAPPING,
    factor_exposure_generator=multi_factor_definition,
    factors_to_weight_function=combine_factors,
    rebalance_frequency='EOM',
    allocation_function=sig.EquityFactorBasket.AVAILABLE_ALLOCATION_FUNCTIONS.NORMALIZE_ZERO_FILLED
)

efbs.build()

Performance#

[ ]:
sig.PerformanceReport(efbs.history()).report()