Merrill Lynch Investment Clock, regime switching, and Bayesian timing.
The Merrill Lynch Investment Clock uses two dimensions—growth and inflation—to partition the economy into four quadrants and rank preferred asset classes.
| Regime | Growth | Inflation | Typical asset preference | Intuition |
|---|---|---|---|---|
| Recovery | High / rising | Low / falling | Equities | Policy still easy, earnings improving, commodities still soft |
| Overheat | High / rising | High / rising | Commodities | Capacity near limits; central banks start tightening |
| Stagflation | Low / falling | High / rising | Cash (relative) | Earnings under pressure and easing constrained; stocks and bonds both struggle |
| Recession | Low / falling | Low / falling | Bonds | Rate-cut path opens; defensive assets dominate |
In the classic narrative, growth leads and inflation follows—prices are often the consequence of growth. The clock therefore rotates Recovery → Overheat → Stagflation → Recession. In reality, cycles need not complete all four cells and can jump or reverse (e.g., stagflation back to recovery).
When applying the framework to China, keep three points in mind:
Asset rotation shares a common driver: rates and liquidity. The money–credit framework rewrites the cycle as monetary ease/tightness × credit ease/tightness, mapping roughly one-to-one to the Investment Clock:
| Investment Clock | Money–credit analogue | Asset intuition |
|---|---|---|
| Recession | Easy money + tight credit | Bonds strong; risk assets weak |
| Recovery | Easy money + easy credit | Equities (and some risk assets) improve |
| Overheat | Tight money + easy credit | Commodities / cyclicals preferred |
| Stagflation | Tight money + tight credit | Cash relatively preferred; stocks and bonds pressured |
Measurement:
A highly simplified rule of thumb: easy money → bond-friendly; easy credit → equity-friendly. Commodities depend on whether credit flows more to supply or demand—the pattern is noisier.
Relative to the Investment Clock, money–credit language is often preferred in China because it matches a bank-dominated funding system, indicators can lead, and quantity/price data are more timely. Even after 2013, when classic cycles weakened, money–credit language still explained many rotations.
Cycles can also be translated into yield-curve shapes:
Bear flatteners / bear steepeners / bull flatteners / bull steepeners are often mapped to the four quadrants. Curve inversion is a common overseas recession warning; in China it must be read with the policy rate corridor, term premia, and regulatory factors—mechanical application is unsafe.
The three languages relate as:
Growth × Inflation (Investment Clock)
≈ Money × Credit (common in China)
≈ Short end × Long end (yield curve)
They are different projections of the same macro cycle. Chapter 11's asset rotation is, in essence, tradable rules on one (or a blend) of these languages.
Thus the Investment Clock provides a map; money–credit and the curve provide a more timely compass. Markov regimes and Bayesian updating then model state uncertainty explicitly. The next section returns to statistical regime methods.
Practitioners rarely use real-time GDP. Common proxies are:
A simple reproducible rule: map growth and inflation to high/low via rolling percentiles or deviations from their own trends, then assign the four quadrants. Form the signal in month (t) and apply it to month (t+1) asset returns to avoid look-ahead from release lags. The A-share macro-rotation replication at the end of Chapter 11 follows this idea for an equity–bond benchmark.
Beyond rule-based states such as the Investment Clock, statistical models can estimate latent regimes and rotate style factors accordingly.
Earlier chapters covered factor construction and portfolio methods. Factors are not universal, and their efficacy is not stable over long horizons.
In one backtest, the value factor performed well in 2017 with annualized excess returns above 15%; from 2018 it drew down continuously, with a maximum drawdown above 20%. Identifying how factor performance varies with macro regimes can improve allocation.
That is the core problem of factor timing.
Factor timing means dynamically adjusting factor weights to the current market state—raising momentum in trending markets, low-volatility when risk appetite falls, and value in recovery. That is the basic idea of factor rotation.
Start with stylized patterns of factor performance across macro environments.
| Macro regime | Stronger factors | Weaker factors | Economic logic |
|---|---|---|---|
| Expansion | Value, momentum | Low-vol, quality | High risk appetite; chase returns |
| Recession | Low-vol, quality | Value, small-cap | Risk-off; demand for certainty |
| Rising inflation | Commodities, value | Growth, momentum | Real assets benefit; rich valuations pressured |
| Falling rates | Growth, momentum | Value, dividend | Lower discount rates revalue distant cash flows |
| High-volatility | Low-vol, quality | Small-cap, momentum | Panic; capital clusters in quality |
These are useful heuristics, not laws. In the March 2020 pandemic shock, mechanically overweighting low-vol could have missed the subsequent growth rebound. The timing of regime switches is often more important than the label itself.
The natural next question: how do we identify the current macro regime?
Traditional practice uses thresholds—e.g., GDP growth above 3% as expansion and below 0% as recession. That is coarse: GDP is quarterly and lagged, so it rarely supports timely decisions.
A better approach is the Markov Regime Switching Model.
The core idea: market state is latent and must be inferred from observables, with regimes transitioning according to a probability matrix.
The figure below helps build intuition:
Each circle is a market state; arrow numbers are transition probabilities. For example, (P_{12}=0.05) means that if the current state is a bull market, the chance of switching to a bear market next month is only 5%. (P_{11}=0.93) means the bull regime persists with 93% probability.
That is the essence of Markov regimes: states persist, but transition with some probability.
Next, implement a simple two-state Markov regime-switching model, illustrated with CSI 300 daily returns.
import numpy as np
import pandas as pd
from scipy.optimize import minimize
import matplotlib.pyplot as plt
class RegimeSwitchingModel:
"""Two-state Markov regime-switching model"""
def __init__(self, n_regimes=2):
self.n_regimes = n_regimes
self.params = None
def fit(self, returns):
"""Estimate parameters with the EM algorithm"""
# Initialize parameters
mu = np.array([0.001, -0.001]) # means in each regime
sigma = np.array([0.01, 0.02]) # volatilities in each regime
P = np.array([[0.95, 0.05], # transition matrix
[0.10, 0.90]])
# EM iterations
for iteration in range(100):
# E-step: smoothed probabilities
smooth_prob = self._smooth_prob(returns, mu, sigma, P)
# M-step: update parameters
mu_new = self._update_mu(returns, smooth_prob)
sigma_new = self._update_sigma(returns, smooth_prob, mu_new)
P_new = self._update_P(smooth_prob)
# Convergence check
if np.allclose(mu, mu_new, rtol=1e-4):
break
mu, sigma, P = mu_new, sigma_new, P_new
self.params = {'mu': mu, 'sigma': sigma, 'P': P}
return self
def predict_state(self, returns):
"""Predict the most likely current state"""
smooth_prob = self._smooth_prob(
returns,
self.params['mu'],
self.params['sigma'],
self.params['P']
)
return np.argmax(smooth_prob[-1])
def _smooth_prob(self, returns, mu, sigma, P):
"""Compute filtered/smoothed probabilities (simplified)"""
T = len(returns)
n = self.n_regimes
# Likelihoods
likelihood = np.zeros((T, n))
for i in range(n):
likelihood[:, i] = 1/(np.sqrt(2*np.pi)*sigma[i]) * \
np.exp(-0.5*((returns - mu[i])/sigma[i])**2)
# Forward recursion (simplified)
filter_prob = np.zeros((T, n))
filter_prob[0] = [0.5, 0.5] # initial probabilities
for t in range(1, T):
pred_prob = filter_prob[t-1] @ P
filter_prob[t] = pred_prob * likelihood[t]
filter_prob[t] /= filter_prob[t].sum
return filter_prob
def _update_mu(self, returns, smooth_prob):
"""Update mean parameters"""
return np.array([
np.sum(smooth_prob[:, i] * returns) / np.sum(smooth_prob[:, i])
for i in range(self.n_regimes)
])
def _update_sigma(self, returns, smooth_prob, mu):
"""Update volatility parameters"""
return np.array([
np.sqrt(np.sum(smooth_prob[:, i] * (returns - mu[i])**2) /
np.sum(smooth_prob[:, i]))
for i in range(self.n_regimes)
])
def _update_P(self, smooth_prob):
"""Update transition matrix"""
T = len(smooth_prob)
P = np.zeros((self.n_regimes, self.n_regimes))
for i in range(self.n_regimes):
for j in range(self.n_regimes):
numerator = np.sum(smooth_prob[1:, j] * smooth_prob[:-1, i])
denominator = np.sum(smooth_prob[:-1, i])
P[i, j] = numerator / denominator if denominator > 0 else 0
# Ensure each row sums to 1
P = P / P.sum(axis=1, keepdims=True)
return P
# Example usage
if __name__ == "__main__":
# Simulate data
np.random.seed(42)
T = 1000
# Regime 1: low-vol bull
regime1 = np.random.normal(0.001, 0.01, T//2)
# Regime 2: high-vol bear
regime2 = np.random.normal(-0.001, 0.02, T//2)
returns = np.concatenate([regime1, regime2])
# Fit model
model = RegimeSwitchingModel(n_regimes=2)
model.fit(returns)
# Predict state
current_state = model.predict_state(returns[-100:])
print(f"Current state: {'Bull' if current_state == 0 else 'Bear'}")
print(f"Regime parameters: {model.params}")
当前状态:牛市
状态参数:{'mu': array([0.00029928, 0.00040345]), 'sigma': array([0.00937264, 0.01961894]), 'P': array([[0.50880041, 0.49119959],
[0.47113172, 0.52886828]])}
With a state identifier, a factor-rotation strategy follows. The basic steps:
A complete strategy skeleton:
class FactorRotationStrategy:
"""Factor rotation strategy based on regime switching"""
def __init__(self, factors, regime_model):
self.factors = factors # factor list
self.regime_model = regime_model
self.regime_weights = {
0: {'momentum': 0.4, 'value': 0.3, 'quality': 0.2, 'low_vol': 0.1},
1: {'momentum': 0.1, 'value': 0.2, 'quality': 0.3, 'low_vol': 0.4}
}
def get_weights(self, returns):
"""Get factor weights for the current regime"""
state = self.regime_model.predict_state(returns)
return self.regime_weights[state]
def rebalance(self, portfolio, returns, threshold=0.3):
"""Dynamic rebalancing"""
current_weights = self.get_weights(returns)
# Deviation between holdings and target weights
deviation = sum(abs(portfolio['weight'] - current_weights[f])
for f in self.factors)
# Trade only if deviation exceeds the threshold
if deviation > threshold:
return current_weights
else:
return portfolio['weight']
Besides statistical models, factor performance can be understood through the business cycle. The Merrill Lynch Investment Clock is a classic frame:
| Cycle stage | Growth | Inflation | Preferred factor | Secondary factor |
|---|---|---|---|---|
| Recovery | Rising | Falling | Value | Momentum |
| Overheat | Rising | Rising | Commodities | Value |
| Stagflation | Falling | Rising | Low-vol | Quality |
| Recession | Falling | Falling | Quality | Low-vol |
Simple as it is, the framework is practical. In one 2022 strategy, diagnosing stagflation and overweighting low-vol helped avoid a large growth-stock drawdown.
Finally, common live-research pitfalls:
That closes this section. Factor timing is challenging—and also among the highest-value topics in quantitative investing. Hopefully these notes are useful starting points.
WeChat official account: Blue Ocean Data Digging Camp; WeChat ID: deep3321
← Previous chapter 📖 Back to contents Next chapter →
Factor timing asks which factor to use now. Early in quant research it is tempting to treat factors as universal—find a good factor and harvest stable returns. Markets often falsify that view quickly. Factor performance varies over time, so timing matters.
Traditional timing tools such as rolling-window regression have a fatal flaw: extreme sensitivity to outliers. One black swan can warp the whole model. Bayesian methods help here.
A prior encodes prior beliefs about factor performance. A useful habit is to ask three questions first:
For example, consider momentum. Historical experience suggests a positive long-run premium with nontrivial volatility. A prior might look like:
import numpy as np
import pymc3 as pm
# Prior hyperparameters
prior_mean = 0.05 # 5% annualized return
prior_std = 0.15 # 15% annualized volatility
# Normal prior
with pm.Model as factor_model:
# Prior on factor mean return
mu = pm.Normal('mu', mu=prior_mean, sigma=prior_std)
# Prior on volatility (half-normal)
sigma = pm.HalfNormal('sigma', sigma=0.1)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) <ipython-input-3-38ebaaa64a4f> in <cell line: 0>() 1 import numpy as np ----> 2 import pymc3 as pm 3 4 # 设定先验参数 5 prior_mean = 0.05 # 年化收益5% ModuleNotFoundError: No module named 'pymc3'
With a prior in place, update to the posterior—combine the prior with observed data via Bayes' rule.
Priors alone make the model stubborn; data alone make it fickle. Bayes is the compromise.
# Simulate observations (daily factor returns)
np.random.seed(42)
observed_returns = np.random.normal(0.02, 0.12, size=252) # one year of data
# Posterior update
with factor_model:
# Likelihood
returns = pm.Normal('returns', mu=mu, sigma=sigma, observed=observed_returns)
# Sampling
trace = pm.sample(2000, tune=1000, return_inferencedata=False)
# Inspect posterior
posterior_mean = trace['mu'].mean
posterior_std = trace['mu'].std
print(f"Posterior mean: {posterior_mean:.4f}")
print(f"Posterior std: {posterior_std:.4f}")
--------------------------------------------------------------------------- NameError Traceback (most recent call last) <ipython-input-4-268be2c55dad> in <cell line: 0>() 4 5 # 后验更新 ----> 6 with factor_model: 7 # 似然函数 8 returns = pm.Normal('returns', mu=mu, sigma=sigma, observed=observed_returns) NameError: name 'factor_model' is not defined
Running this code, the posterior mean typically lies between the prior mean and the sample mean. That is Bayesian shrinkage—when data are scarce the model trusts the prior more; as data accumulate it moves toward the sample.
Given a posterior, how do we time?
One approach: from the posterior, compute expected return and uncertainty, then activate the factor only when expected return is high enough and uncertainty low enough relative to chosen thresholds.
def bayesian_factor_timing(trace, threshold=0.02, confidence_level=0.95):
"""
Bayesian factor-timing decision rule
Parameters:
trace: posterior samples
threshold: return threshold
confidence_level: required probability mass above threshold
Returns:
signal: 1 = activate factor, 0 = deactivate
"""
# Extract posterior samples
mu_samples = trace['mu']
# Posterior mean
expected_return = mu_samples.mean
# Probability that return exceeds the threshold
prob_above_threshold = (mu_samples > threshold).mean
# Decision rule
if prob_above_threshold >= confidence_level:
signal = 1
else:
signal = 0
return signal, expected_return, prob_above_threshold
# Example
signal, exp_ret, prob = bayesian_factor_timing(trace)
print(f"Timing signal: {'Activate' if signal else 'Deactivate'}")
print(f"Expected return: {exp_ret:.4f}")
print(f"P(return > threshold): {prob:.4f}")
--------------------------------------------------------------------------- NameError Traceback (most recent call last) <ipython-input-5-7cc063828785> in <cell line: 0>() 29 30 # 示例 ---> 31 signal, exp_ret, prob = bayesian_factor_timing(trace) 32 print(f"择时信号: {'启用' if signal else '不启用'}") 33 print(f"预期收益: {exp_ret:.4f}") NameError: name 'trace' is not defined
The figure below summarizes the core Bayesian factor-timing logic at a glance:
Several pitfalls are worth noting—lessons paid for in live capital:
Finally, Bayesian methods are not a silver bullet. Their greatest value is enforced humility under uncertainty. Markets keep changing; the job is to keep updating beliefs.
← Previous chapter 📖 Back to contents Next chapter →
# Export this notebook to HTML
# jupyter nbconvert --to html 32.ipynb
print("Chapter 32 ready. Use 00_build_all.ipynb to export the full site.")
Chapter 32 ready. Use 00_build_all.ipynb to export the full site.