How Does Investment Clock Work?

Richard L
9 min readAug 20, 2021

This research is inspired by a more detailed discussion made by Marco Ops: https://macro-ops.com/the-investment-clock/

Introduction: What is Investment Clock?

As per Marco Ops’ article, the Investment Clock is a Marco Investment Strategy first introduced by Merrill Lynch. It is a simple yet useful framework for understanding the various phases of a business cycle and finding the best-performing asset classes in each phase.

The Investment Clock splits the business cycle into four phases. Each phase is comprised of the direction of growth and inflation relative to their trends.

Reflation — both the GDP growth and inflation are falling or lower than the trend. The stocks are suffering in a bear market but bonds suppose to be the most welcomed asset because of the generous monetary and fiscal support from governments and central banks (bail-out, cutting rates, and stimulus programs).

Recovery —Growth starts to back on track while inflation still remains low. Stocks regain attractions with very attractive valuation and improving earnings. It is, of course, the most favorable asset at this stage.

Overheat — Growth reaches the peak and slows down and inflation is rising. (does it sound familiar if you follow these days’ headlines?) Both stocks and bonds won’t perform well, but betting on commodities will be a proliferating and profitable strategy.

Stagflation — Inflation is way out of control and that severely hurt consumer confidence. Central banks are forced to hike rates, and stocks, as one of the leading indicators of the economy, already fell. However, this stage of the cycle doesn’t happen that often in the last few decades, thanks to Fed’s “remarkable” economic interference policy, that is to print out enormous money to stimulate the economy meanwhile artificially set interest rates low to control the inflation (however we just don’t know how long it could last). Flying to safety asset, cash, is the best choice given the circumstance.

Step 1: Define and Download Asset Pools

My sense is that Investment Clock should work the best within an Asset Allocation framework. But still, I’m going to reuse the sector ETF data that I’ve always been using in several of my previous experiments.

The ML research suggests the following sector rotation based on Investment Clock theory:

Reflation — Financials, Consumer Staples, Healthcare, and Consumer Discretionary;
Recovery — Telecom, Tech, Basic Materials, and Consumer Discretionary;
Overheat —Industrials, Tech, Basic Materials, and Energy;
Stagflation — Utilities, Consumer Staples, Healthcare, and Energy.

Sector rotation defined based on iShares ETFs:

def AssetPoolFixed():
return {
"Reflection": ['IYF','IYH','IYK','IYC'],
"Recovery": ['IYZ','IYW','IYM','IYC'],
"Overheat": ['IYJ','IYW','IYM','IYE'],
"Stagflation": ['IDU','IYH','IYK','IYE']
}

Now we can download the historical prices using the codes I described in my previous blog.

Step 2: Download Economic Data

Our next step is to download the GDP and inflation data using Fred API:

from fredapi import Fred
fred = Fred(api_key="your_own_key")
signals = pd.DataFrame(
dict(
GDP = fred.get_series(‘GDPC1’),# Real GDP
CPI = fred.get_series(‘CPILFESL’), # Core CPI
)
).fillna(method=’ffill’).pct_change(12).dropna()*100

The above code downloads the US real GDP (quarterly) and Core CPI (monthly), then converted to YoY changes in percent.

Step 3: Define Investment Clock Phases

I used a very simple algorithm to define investment clock themes. For example, when real GDP Growth >2.5% p.a., it’s High Growth, otherwise, it’s Low Growth. Similarly, when Core Inflation >3% p.a., it’s High Inflation, otherwise, it’s Low Inflation. The Four phases of Investment Clock are separately defined based on the different combinations of Growth and Inflation status:

def InvestmentClockFixed(x,cuts={‘GDP’:2.5,’CPI’:3}):

x_ = x.copy().assign(Growth=None,Inflation=None,Theme=None)

# define high and low growth
x_.loc[x[‘GDP’]<=cuts[“GDP”],’Growth’] = ‘low’
x_.loc[x[‘GDP’]>cuts[“GDP”],’Growth’] = ‘high’

# define high and low inflation
x_.loc[x[‘CPI’]<=cuts[“CPI”],’Inflation’] = ‘low’
x_.loc[x[‘CPI’]>cuts[“CPI”],’Inflation’] = ‘high’

# define investment clock phases
x_.loc[(x_.Growth==‘low’)&(x_.Inflation==‘low’),’Theme’] =
'Reflection’
x_.loc[(x_.Growth==’high’)&(x_.Inflation==‘low’),’Theme’] = ‘Recovery’
x_.loc[(x_.Growth==’high’)&(x_.Inflation==’high’),’Theme’] = ‘Overheat’
x_.loc[(x_.Growth==‘low’)&(x_.Inflation==’high’),’Theme’] = ‘Stagflation’
return x_.dropna()

How does the history look like based on our defined phases? I have some test-run (see chart below). Indeed, stagflation is the least observed phase with only a few cases that happened between 70’s and 90’s, but none has been observed within the latest 30 years.

For your interest, the code to generate the above plot is here:

themes = InvestmentClockFixed(signals,cuts={‘GDP’:2.5,’CPI’:3})fig = plt.figure(figsize=(12,8))
fig.suptitle(‘Investment Clock Themes with Fixed Cuts’,fontsize=16)
ax1 = fig.add_axes((0,0.5,1,0.45))
ax2 = fig.add_axes((0,0,0.3,0.4))
ax3 = fig.add_axes((0.35,0,0.3,0.4))
ax4 = fig.add_axes((0.7,0,0.3,0.4))
y_lim = (-10,15)colors = dict(
Overheat=’#f4d19e’,
Recovery=’#87ca9d’,
Reflection=’#dbdbdb’,
Stagflation=’#ffb3ba’
)
p1 = themes[[‘GDP’,’CPI’]].plot(ax=ax1)
for t in ['Overheat','Recovery','Reflection','Stagflation']:
ax1.fill_between(
themes.index, y_lim[0],y_lim[1],
where=(themes.Theme==t),
interpolate = True,
facecolor=colors[t],
alpha = 0.5
)
growth_counts=themes.Growth.value_counts()
ax2.pie(growth_counts,autopct=’%.1f %%’,
labels=growth_counts.index)
ax2.set_title(‘Growth’)
inflation_counts=themes.Inflation.value_counts()
ax3.pie(inflation_counts,autopct=’%.1f %%’,
labels=inflation_counts.index)
ax3.set_title(‘Inflation’)
theme_counts=themes.Theme.value_counts().sort_index(ascending=True)
ax4.pie(theme_counts,autopct=’%.1f %%’,
labels=theme_counts.index,colors=colors.values())
ax4.set_title(‘Theme’)
plt.show()

Step 4: Building the Investment Clock BT function

It’s now the exciting moment to build our core backtest function! Friends following my blog know that I always use BT — the Flexible Backtesting Toolkit for Python to run all the strategy simulations, a.k.a. backtesting. It is surprisingly easy:

import bt# step 1: build Investment Clock Class
class InvestmentClock(bt.Algo):
def __init__(self, themes,
lag = pd.DateOffset(days=0),
pool = AssetPoolFixed()):
super(InvestmentClock, self).__init__()
self.Themes = themes
self.lag = lag
self.pool = pool
def __call__(self, target):
t0 = target.now — self.lag
Theme = self.Themes[:t0][-1]
target.temp[‘selected’] = self.pool[Theme]
return True
# step 2: build Investment Clock backtest
def bt_InvestmentClock(name,tickers,prices,signals,
cuts={‘GDP’:2.5,’CPI’:3}):

themes = InvestmentClockFixed(signals,cuts)
asset_pool = AssetPoolFixed()

s = bt.Strategy(
name,
algos = [
bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.SelectThese(tickers),
InvestmentClock( themes.Theme,
lag=pd.DateOffset(days=0),
pool = asset_pool ),
bt.algos.WeighEqually(),
bt.algos.Rebalance()
],
)
return bt.Backtest(s, prices)

For comparison purpose, I also have a passive backtest function that runs S&P500 and Sector Equal-Weight strategies:

def bt_passive(name,tickers,prices):
s = bt.Strategy(name,
algos = [
bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.SelectThese(tickers),
bt.algos.WeighEqually(),
bt.algos.Rebalance()
],
)
return bt.Backtest(s, prices)

Step 5: Running the Investment Clock Backtest

Now I run the investment clock backtest with the fixed cuts of GDP at 2.5 (YoY % p.a.) and CPI at 3( YoY % p.a.):

tickers = ['IYC','IDU','IYZ','IYW','IYJ','IYH',
'IYF','IYE','IYK','IYM','IYR']
backtest_sp50 = bt_passive(‘SP50’,[‘IVV’],prices)
backtest_spew = bt_passive(‘SPEW’,tickers,prices)
backtest_spic = bt_InvestmentClock(‘SPIC’,tickers,prices,signals,
cuts={‘GDP’:2.5,’CPI’:3})
report = bt.run(backtest_sp50, backtest_spew,backtest_spic)
report.display()

The simulated results show that SPIC (Investment Clock) only slightly outperformed SPEW (Sector Equal-Weight) over the mid to long horizons, but underperformed within one year. Still very encouraging given such a simple setting it has!

Stat                 SP50        SPEW        SPIC
------------------- ---------- ---------- ----------
Start 2002-07-29 2002-07-29 2002-07-29
End 2021-08-19 2021-08-19 2021-08-19
Risk-free rate 0.00% 0.00% 0.00%

Total Return 604.44% 562.83% 579.59%
Daily Sharpe 0.63 0.61 0.64
Daily Sortino 0.98 0.95 0.99
CAGR 10.79% 10.43% 10.58%
Max Drawdown -55.25% -55.02% -54.83%
Calmar Ratio 0.20 0.19 0.19

MTD 0.32% -0.51% -2.46%
3m 7.42% 4.08% 0.25%
6m 13.55% 11.21% 4.27%
YTD 18.43% 17.37% 8.39%
1Y 32.46% 31.92% 24.21%
3Y (ann.) 17.64% 13.23% 13.50%
5Y (ann.) 17.23% 12.69% 13.68%
10Y (ann.) 16.86% 13.71% 15.25%
Since Incep. (ann.) 10.79% 10.43% 10.58%

Avg. Drawdown -1.74% -1.95% -2.17%
Avg. Drawdown Days 21.91 22.26 25.66
Avg. Up Month 3.07% 3.07% 3.33%
Avg. Down Month -3.60% -3.68% -3.39%
Win Year % 89.47% 84.21% 84.21%
Win 12m % 87.21% 84.47% 85.39%

Step 6: Hyperparameter Tuning

How will you interpret a YoY real GDP growth reading of 2.5%? Is it still too low or high enough? I’ll have the same question for inflation. Economics is not a mere black and white natural scientific subject, but a social science topic with enormous human expectations embedded. We can still leverage on scientific methods to improve our predictions.

Scikit-learn has the built-in grid search module GridSearchCV, however I haven’t learned how to apply that to my customized pediction model. So I decided to build my own.

1. construct return histories for each of the 4 Investment Clock themes:

pool = AssetPoolFixed()bt_reflection=bt_passive(‘Reflection’,pool[‘Reflection’],prices)
bt_recovery=bt_passive(‘Recovery’,pool[‘Recovery’],prices)
bt_overheat=bt_passive(‘Overheat’,pool[‘Overheat’],prices)
bt_stagflation=bt_passive(‘Stagflation’,pool[‘Stagflation’],prices)
report_cv = bt.run(backtest_sp50,
bt_reflection,bt_recovery,bt_overheat,bt_stagflation)

2. build object function to score Investment Clock themes:

def objective(cut_gdp=2,cut_cpi=2,freq=’q’):    themes = InvestmentClockFixed(signals,
cuts={‘GDP’:cut_gdp,’CPI’:cut_cpi})\
.resample(freq).first()\
.reset_index().rename(columns={‘index’:’Date’})
scores = pd.DataFrame({k:report_cv[k].prices
for k in asset_pool.keys()})\
.div(report[‘SP50’].prices,axis=0)\
.resample(freq).last().pct_change().dropna()\
.reset_index().rename(columns={‘index’:’Date’})\
.melt(id_vars=’Date’,var_name=’Theme’,value_name=’score’)
return pd.merge(scores,themes,on=[‘Date’,’Theme’]).score.sum()

3. run the exhaustive grid search:

(Just be mindful that it will be slow especially on my pi4)

output = pd.DataFrame()for gdp_ in [1+.25*n for n in range(0,13)]:
for cpi_ in [1+.25*n for n in range(0,13)]:
output = output.append(
dict(
gdp=gdp_, cpi=cpi_,
score=objective(gdp_,cpi_,’q’)
),
ignore_index=True
)

4. build a pivot table to visualize the results:

(The results are percentile ranked for ease of interpretation)

pivot = output
.assign(rank=output.score.rank(pct=True,ascending=True))\
.pivot(‘cpi’,’gdp’,’rank’)\
.sort_index(ascending=False)
sns.heatmap(pivot, linewidths = .5, annot=True , cmap=’jet’)
plt.title(‘Model Scores Based on Percentile Rank’)
plt.show()

If you enlarge it you can see that GDP=2.5 and CPI=2 seem to provide us the best result (1 is the best spot with the maximized return of the entire backtest history).

5. is it true? run backtest on optimized params and test it yourself!

backtest_best = bt_InvestmentClock(‘BEST’,tickers,prices,signals,
cuts={‘GDP’:2.5,’CPI’:2})
report2 = bt.run( backtest_sp50, backtest_spew,
backtest_spic, backtest_best )
report2.plot()
plt.title(‘Investment Clock Strategy with Optimal Fixed Cuts’)
plt.show()
Stat                 SP50        SPEW        SPIC        BEST
------------------- ---------- ---------- ---------- ----------
Start 2002-07-29 2002-07-29 2002-07-29 2002-07-29
End 2021-08-19 2021-08-19 2021-08-19 2021-08-19
Risk-free rate 0.00% 0.00% 0.00% 0.00%

Total Return 604.44% 562.83% 579.59% 831.42%
Daily Sharpe 0.63 0.61 0.64 0.72
Daily Sortino 0.98 0.95 0.99 1.14
CAGR 10.79% 10.43% 10.58% 12.42%
Max Drawdown -55.25% -55.02% -54.83% -47.49%
Calmar Ratio 0.20 0.19 0.19 0.26

MTD 0.32% -0.51% -2.46% -2.46%
3m 7.42% 4.08% 0.25% 0.63%
6m 13.55% 11.21% 4.27% 6.66%
YTD 18.43% 17.37% 8.39% 10.88%
1Y 32.46% 31.92% 24.21% 27.06%
3Y (ann.) 17.64% 13.23% 13.50% 12.52%
5Y (ann.) 17.23% 12.69% 13.68% 11.73%
10Y (ann.) 16.86% 13.71% 15.25% 14.80%
Since Incep. (ann.) 10.79% 10.43% 10.58% 12.42%

Avg. Drawdown -1.74% -1.95% -2.17% -2.20%
Avg. Drawdown Days 21.91 22.26 25.66 23.09
Avg. Up Month 3.07% 3.07% 3.33% 3.23%
Avg. Down Month -3.60% -3.68% -3.39% -3.33%
Win Year % 89.47% 84.21% 84.21% 89.47%
Win 12m % 87.21% 84.47% 85.39% 88.13%

One Last Word

The Investment Clock strategy is obviously very interesting to me at this moment. Almost all of the major central banks in the developed world are explicitly or implicitly deployed their Modern Monetary Theory (MMT) — they endorse the overwhelmingly excessive borrowing & spending without worrying about the burst of government debt.

Is there really such a “free lunch”? There will be multiple losers: First is the creditors (government bond buyers). Second is the emerging market exporters, as the developed world doesn’t produce. Also, don’t forget that it will be more and more difficult for younger generations (X, Y, Z) to buy houses because flooded liquidity already shot up the prices but governments are deliberately controlling the wages (inflation). Eventually, there might be greater and greater wealth inequality and social unrest (well, we both know it but let the next elected governor deal with it).

The MMT topic might be too far away, but let’s focus back on reality. In the coming years, the Fed will find it more and more difficult to put the goldilocks of growth and inflation targets under control. The market will see more and more surprises and any naive buy and hold strategy will be heavily disadvantaged. Fortify your investment (with active strategy) and prepare for the battle (on the capital market).

My model is not finalized and is just an experiment for the interest of data science and finance. It should not be used for any investment suggestion. I plan to publish more researches in the future to refine this strategy. Maybe lead to a more comprehensive strategic and tactic model that is similar to Bridgewater's famed “all-weather” strategy, but I’m free to share.

Stay tuned!

--

--

Richard L

to make data tell the story; to make code do the work