Jim Slater’s Zulu Principle

To construct a portfolio based on Jim Slater’s principle

Photo Creds: This is MONEY

Preface

Jim Slater is one of the well-known U.K. investors. He used to write column articles of portfolio recommendation for Sunday Telegraph and was famous for earning around 68.9% return during the period between 1963 to 1965, while the U.K. stock market only grew at 3.6% at the same period. Jim Slater’s ‘Zulu Principle’ stresses the importance of focusing on your own niche field, where you can take advantage of. Therefore, Jim Slater especially pays attention to small and medium size firms that have long-term profit growth and relative strength and adopts price/earnings to growth ratio (PEG ratio) as an important standard for valuation, as the following shown:

  • EPS growth exceeds 15% in four consecutive years
  • PEG ratio is less than 1.2 and the expected PE ratio is less than 20
  • High liquidity, Low debt ratio, abundant cash flow and positive profit in five consecutive years
  • Firms that have competitive edge, like having a dominant brand
  • Turnaround and thematic stocks, such as having a new product or new management team
  • Small market capitalization, high relative strength of stock price and high insider stock ownership

The Editing Environment and Modules Required

We use Windows OS and Jupyter Notebook in this article

import pandas as pd
import numpy as np
import tejapi
import matplotlib.pyplot as plt
tejapi.ApiConfig.api_key = "Your Key"
tejapi.ApiConfig.ignoretz = True

The Highlights of the Article

  • Quantify Jim Slater’s investing principles
  • Stocks selection and return calculation

Quantitative Indicators

Since Jim Slater’s stock-picking standards include some subjective judgement factors, we present the following objective and quantitative indicators based on them and also adjust them by considering Taiwan stock market environments

Fundamental Conditions

Condition1: Firm market value < Market average market value

Condition2: Positive net incomes in the past five years

Condition3: Net income growth > 15% in three consecutive years

Condition4: Expected net income growth ≥ 15%

Condition5: 5-year average cash flow from operating > 5-year average net income

Condition6: Recent cash flow from operating > recent net income

Condition7: Recent operating margin ≥ 10%

Condition8: Recent return on capital employed ≥ 10%

Condition9: Recent debt/equity ratio < 50%

Condition10: Recent shareholding ratio of directors and supervisors ≥ 20% or recent increase in this ratio

Market-side conditions

Condition11: Expected PE ratio ≤ 20

Condition12: Expected PE ratio/Expected net income growth ≤ 1.2

Condition13: Monthly excess return > 0

Paid Database Used

Investing Strategy in Practice

  • Stock-picking conditions: at least 9 fundamental conditions should be fulfilled, but market-side conditions should all be satisfied
  • Portfolio holding period: to avoid look-ahead bias, we adopt the approach proposed in【Application(1)】The investing strategy of Benjamin Graham — the founder of securities analysis, which takes Q4 financial reports announcement date as the portfolio formation date. For example, we assume complete 2004 financial data will be disclosed in 2005/03/31, so the portfolio based on it will be held between 2005/03/31 ~ 2006/3/31
  • Portfolio return calculation: portfolio should be held in exact one year to calculate yearly return which also considers transaction costs

Step 1. Obtain the codes of TSE listed common stocks

security = tejapi.get('TWN/ANPRCSTD',
paginate = True)
stock_list = security[(security['mkt'] == 'TSE') & (security['stype'] == 'STOCK')]['coid'].tolist()

Step 2. Obtain all the data we need and create condition column

#container for outputs 
data = pd.DataFrame() 

for coid in stock_list: 
    #Obtain financial data
    finance = tejapi.get('TWN/AIM1A',   
                    coid = coid,
                    mdate = {'gte':'2000-01-01'},
                    opts = {'pivot':True, 'columns':['coid','mdate','MV','R531','R405','7210','R106','2402','0010','1100','R504']},
                    paginate = True,)
    #Select data in December to annualize the data
    finance = finance[finance['mdate'].dt.month == 12].reset_index(drop=True)
    
    #Add 'match year' column for the later combination purpose
    finance['MatchYear'] = finance['mdate'].dt.year
    
    #Calculate expected net income growth by using 5-year moving average of net income growth 
    finance['ExpectedNetIncomeGrowth'] = finance['R531'].rolling(5).mean()
    
    #Calculate 5-year moving average of cash flow from operating activities
    finance['5_YearAverageOperatingCashFlow'] = finance['7210'].rolling(5).mean()
    
    #Calculate 5-year moving average of net income
    finance['5_YearAverageNetIncome'] = finance['R531'].rolling(5).mean()
    
    #Condition2: Positive net incomes in the past five years                      
    finance['positive_profit'] = np.where(finance['R531'] > 0, 1, 0)
    finance['condition2'] = np.where(finance['positive_profit'].rolling(5).sum() == 5, 1, 0)
    
    #Condition3: Net income growth > 15% in three consecutive years
    finance['net_income_growth_higher_than_15'] = np.where(finance['R405'] >= 15, 1, 0)
    finance['condition3'] = np.where(finance['net_income_growth_higher_than_15'].rolling(3).sum() == 3, 1, 0)
    
    #Condition4: Expected net income growth ≥ 15% 
    finance['condition4'] = np.where(finance['ExpectedNetIncomeGrowth'] >= 15, 1, 0)
    
    #Condition5: 5-year average cash flow from operating > 5-year average net income
    finance['condition5'] = np.where(finance['5_YearAverageOperatingCashFlow'] > finance['5_YearAverageNetIncome'], 1, 0)
    
    #Condition6: Recent cash flow from operating > recent net income
    finance['condition6'] = np.where(finance['7210'] > finance['R531'], 1, 0)
    
    #Condition7: Recent operating margin ≥ 10%
    finance['condition7'] = np.where(finance['R106'] >= 10, 1, 0)
    
    #Condition8: Recent return on capital employed ≥ 10%
    finance['ROCE'] = (finance['2402']/(finance['0010']-finance['1100']))*100
    finance['condition8'] = np.where(finance['ROCE'] >= 10, 1, 0)
    
    #Condition9: Recent debt/equity ratio < 50%
    finance['condition9'] = np.where(finance['R504'] < 50, 1, 0)
    
    #Remove NaN data
    finance = finance.dropna().reset_index(drop=True)
   
    #Obtain shareholding ratio data
    chip  = tejapi.get('TWN/ABSTN1',
                        coid = coid,
                        mdate = {'gte':'2000-01-01'},
                        opts = {'columns':['coid','mdate','fld005']},
                        paginate = True)
    
    #Check if the latest shareholding ratio of insider increases
    chip['if_shareholding_ratio_increase'] = np.where(chip['fld005'] > chip['fld005'].shift(1),1,0)
    
    #Select February data, since the latest shareholding data we have at portfolio formation data is February data
    chip = chip[(chip['mdate'].dt.month == 2)].reset_index(drop=True)
    
    #Condition10: Recent shareholding ratio of directors and supervisors ≥ 20% or recent increase in this ratio
    chip['condition10'] = np.where((chip['fld005'] >= 20) | (chip['if_shareholding_ratio_increase'] == 1),1,0)
    
    #Add 'match year' column for the later combination purpose, the reason of 'minus 1' is because this ratio data matches last year's financial data
    chip['MatchYear'] = chip['mdate'].dt.year - 1
    
    #Creat a temporary dataframe by merging finance data with ratio data on firm's code and matchyear
    temp = finance.merge(chip, on = ['coid','MatchYear'])
    
    #Store this temporary dataframe in data 
    data = data.append(temp).reset_index(drop = True)

Each loop deals with each stock. Because of the huge amount of data obtained, it takes around 30 ~ 40 minutes. The procedure includes data frequency adjustment, using np.where() to create condition columns, combining data from different databases and storing the outputs in data. Besides, since the index might be disordered after selecting, adding, sorting or removing data, we usually add reset_index(drop=True). The following is the final content of data:

data_cp = data.copy()

To avoid modifying the raw data, we copy data and proceed it with data_cp

#Calculate market average market value
avg_mv = data_cp.groupby(by = 'mdate_x')['MV'].mean()#Map market average market value to data_cp by date column
data_cp['MarketAverageMarketValue'] = data_cp['mdate_x'].map(avg_mv)#Condition1: Firm market value < Market average market value
data_cp['condition1'] = np.where(data_cp['MV'] < data_cp['MarketAverageMarketValue'], 1, 0)

Once we have the market value of whole firms, we can now create condition1 column. Here we use map() to map market average market value to data_cp by its date column.

#We choose the columns we need
data_cp = data_cp[['coid','mdate_x','MV','ExpectedNetIncomeGrowth','condition1','condition2','condition3','condition4','condition5','condition6','condition7','condition8','condition9','condition10']]
#Calculate the score and select those higher than 9
data_cp['score'] = data_cp['condition1'] + data_cp['condition2'] + data_cp['condition3'] + data_cp['condition4'] + data_cp['condition5'] + data_cp['condition6'] + data_cp['condition7'] + data_cp['condition8'] + data_cp['condition9'] + data_cp['condition10']
data_cp = data_cp[data_cp['score'] >= 9].sort_values(by = 'mdate_x').reset_index(drop=True)

Step 3. Market-side condition and portfolio return calculation

Panel = pd.DataFrame()    #To store complete data for each stock  
Return = pd.DataFrame()   #To store portfolio return   
date_list = pd.DatetimeIndex(data_cp['mdate_x'].unique()) #Transform unique date into DatatimeIndex list

for date in date_list: 
    #Each year has a dataframe
    table = data_cp[data_cp['mdate_x'].dt.year == date.year].reset_index(drop=True)
    
    #Get the previous year's PE ratios at portfolio formation date
    stocks = table['coid'].tolist()
    pe_ratio = tejapi.get('TWN/APRCD1',
                             coid = stocks,
                             opts = {'columns':['coid','mdate','per_tse']}, 
                             mdate = {'gte': date + pd.Timedelta(days = 120 - 365) , 'lte': date + pd.Timedelta(days = 120)},
                             paginate = True)
    
    #Calculate one-year average PE ratio as expected PE ratio
    pe_estimate = pe_ratio.groupby(by = 'coid').mean().reset_index() 
    
    #Obtain the excess monthly return data
    relative_performance = tejapi.get('TWN/APRCD2',
                             coid = stocks,
                             opts = {'columns':['coid','mdate','rois_m']}, 
                             mdate = {'gte': date + pd.Timedelta(days = 120 - 5), 'lte': date + pd.Timedelta(days = 120)}, #We just need the data closest to portfolio date
                             paginate = True)
    
    #Pick the last data for each stock
    relative = relative_performance.groupby(by = 'coid').last().reset_index()
    
    #Combine expected PE with stock relative performance
    merge = pe_estimate.merge(relative, on = 'coid')
    
    #Alter column name to combine with table 
    merge = merge.rename(columns = {'mdate':'mdate_x', 'per_tse':'ExpectedPE_ratio'})
    merge = table.merge(merge[['coid', 'ExpectedPE_ratio','rois_m']], on = 'coid')
    
    #Calculate PEG ratio
    merge['PEG_ratio'] = merge['ExpectedPE_ratio']/merge['ExpectedNetIncomeGrowth']
    
    #Filter by market-side conditions, get the final constituent stocks
    final = merge[(merge['ExpectedPE_ratio'] <= 20)&(merge['PEG_ratio'] <= 1.2) & (merge['rois_m'] > 0)].reset_index(drop=True)
    
    #Calculate the weight by firm's market value
    final['weight']  = final['MV']/ final['MV'].sum() 
    
    #Obtain return data (including market as benchmark)
    stocks = final['coid'].tolist()
    ret = tejapi.get('TWN/APRCD2',
            coid = stocks + ['Y9997'], 
            paginate = True,
            opts = {'columns':['coid','mdate','roi_y']}, 
            mdate = {'gte': date + pd.Timedelta(days = 120), 'lte': date + pd.Timedelta(days = 120+365)})
    
    #Period Return: get the last return data of each stock, which is yearly return
    period_ret = ret.groupby(by = 'coid')['roi_y'].last().reset_index()
    
    #Assign date for later combination
    period_ret['mdate_x'] = date 
    
    #Combine it with final, outer can make sure market return data is not removed
    temp = final.merge(period_ret,on = ['coid','mdate_x'], how = 'outer')
    
    #Store this year's complete data
    Panel = Panel.append(temp).reset_index(drop=True)
    
    #Hold the portfolio since 2005(built on 2004 data), 2020 year is excluded because its portfolio hasn't been held for a year
    if 2020 > date.year >= 2004:  
        #Calculate portfolio return and market return
        fee = 0.1425*2 + 0.3
        eq_port = temp.loc[:,'roi_y'].values[:-1].mean() - fee           #Equally-weighted portfolio return (excluding market return)
        val_port = (temp['weight']*temp['roi_y']).dropna().sum() - fee   #Market-Cap-weighted portfolio return (excluding market return)
        mkt = temp['roi_y'].values[-1]                                   #Market return 
        #Store whole return
        Return = Return.append(pd.DataFrame(np.array([date,eq_port,val_port,mkt]).reshape((1,4)),columns = ['mdate_x','Equally-Weighted Portfolio Return','Market-Cap-Weighted Portfolio Return','Market Return'])).reset_index(drop=True)

Here we use each year as one loop and make the second filtering by market-side conditions and calculate portfolio return. It’s worth noting that the time window of PE ratio we obtain is the previous year at portfolio formation date. For example, if mdate_x is ‘2020–12–01’, it means we’ll construct the portfolio on ‘2021–03–31’(120 days later) and we need PE ratio from ‘2020–03–31’ to ‘2021–03–31’). Since this portfolio will be held until ‘2022–03–31’ which hasn’t happened yet, we will exclude 2020-based portfolio while calculating portfolio return.

Panel
Return

Step 4. Visualize cumulative return (Code can be found in Source Code)

Step 5. Performance (Code can be found in Source Code)

Step 6. Constituent stocks of 2020-based portfolio (Code can be found in Source Code)

Conclusion

Due to the huge data that covers financial, shareholding status and market-side data, we suggest readers begin with fewer firms, shorter time windows or fewer stock selection standards. If the output meets your expectation, then you can examine it from a longer period. In addition to time length, the quality and various data are required. Therefore, we recommend readers to purchase different databases in TEJ E Shop, in order to put those well-known investors’ investing philosophy into practice!

The content of this webpage does not constitute any offer or solicitation to offer or recommendation of any investment product. There may be survivor bias existing since we use current listed companies. It is for learning purposes only and does not take into account your individual needs, investment objectives and specific financial circumstances. Investment involves risk. Past performance is not indicative of future performance. Readers are requested to use their personal independent thinking skills to make investment decisions on their own. If losses are incurred due to relevant suggestions, it will not be involved with the author.

Related Link

Back
Procesing