Equity–bond, sector, and global allocation, plus a reproducible A-share macro rotation case.
How much to allocate to equities versus bonds is a question every portfolio has to answer.
A common pitfall in strategy research is over-concentration in a single asset. For example, with an equity weight that is too high, a drawdown like 2018 can exceed 30%. Dynamically adjusting the equity–bond mix with the macro regime helps smooth the return path.
The economic idea behind equity–bond rotation is simple: raise equity exposure in expansion and raise bond exposure when the economy weakens. The hard part is defining expansion and weakness—and that is where macro factors come in.
The figure below sketches a commonly used strategy framework:
The structure is relatively clear. Equity–bond rotation ultimately answers three questions:
A common approach is threshold-based rotation. Concretely:
Note that thresholds should not be set arbitrarily. With ±1, signals are often too sparse and the portfolio stays in the middle for long stretches; ±0.5 usually produces a more reasonable signal frequency and performance.
When building a macro factor model, avoid the factor zoo—stacking many indicators without screening. Adding too many macro variables at once can make backtest results worse, not better.
In practice, it helps to focus first on these four core factor groups:
| Factor category | Indicators | Frequency | Equity impact |
|---|---|---|---|
| Growth | GDP YoY, industrial value-added | Monthly / quarterly | Positive |
| Inflation | CPI YoY, PPI YoY | Monthly | Negative (high inflation is a headwind) |
| Monetary policy | 7-day repo rate, M2 growth | Weekly / monthly | Easing is supportive for equities |
| Market sentiment | Credit spreads, volatility index | Daily | Narrower spreads support equities |
Raw data cannot be used as-is. A typical three-step pipeline is:
Pull the data first. Here we use tushare and akshare, both free data sources:
import pandas as pd
import numpy as np
import akshare as ak
import tushare as ts
# Fetch CSI 300 index data
stock_data = ak.stock_zh_index_daily(symbol="sh000300")
stock_data['return'] = stock_data['close'].pct_change
# Fetch bond index data (ChinaBond New Composite Index)
bond_data = ak.bond_zh_index_daily(symbol="CBA00601")
bond_data['return'] = bond_data['close'].pct_change
# Fetch macro factor data
# GDP, CPI, PMI, etc. from tushare
ts.set_token('your_token')
macro_data = ts.get_macro_data(start='2010-01-01', end='2023-12-31')
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) <ipython-input-1-a69a15f646be> in <cell line: 0>() 9 10 # 获取债券指数数据(中债-新综合指数) ---> 11 bond_data = ak.bond_zh_index_daily(symbol="CBA00601") 12 bond_data['return'] = bond_data['close'].pct_change() 13 AttributeError: module 'akshare' has no attribute 'bond_zh_index_daily'
A compact factor synthesis function looks like this:
def factor_synthesis(df, method='equal_weight'):
"""
Macro factor synthesis
method: 'equal_weight' or 'pca'
"""
# Standardize
df_std = (df - df.mean) / df.std
if method == 'equal_weight':
# Equal-weight average
factor = df_std.mean(axis=1)
elif method == 'pca':
# PCA dimensionality reduction
from sklearn.decomposition import PCA
pca = PCA(n_components=1)
factor = pca.fit_transform(df_std).flatten
return factor
The backtest logic is straightforward: rebalance dynamically from the factor score.
def backtest(factor, stock_returns, bond_returns, threshold=0.5):
"""
Equity–bond rotation backtest
"""
# Equity weight
stock_weight = np.clip((factor + threshold) / (2 * threshold), 0, 1)
bond_weight = 1 - stock_weight
# Portfolio return
portfolio_return = (stock_weight * stock_returns +
bond_weight * bond_returns)
# Net asset value
nav = (1 + portfolio_return).cumprod
# Performance metrics
annual_return = nav.iloc[-1] ** (252/len(nav)) - 1
max_drawdown = (nav / nav.cummax - 1).min
sharpe = portfolio_return.mean / portfolio_return.std * np.sqrt(252)
return {
'nav': nav,
'annual_return': annual_return,
'max_drawdown': max_drawdown,
'sharpe': sharpe
}
Using data from 2015 to 2023, the backtest results are:
| Strategy | Ann. return | Max drawdown | Sharpe | Ann. volatility |
|---|---|---|---|---|
| Equity–bond rotation | 9.8% | -12.3% | 1.15 | 14.2% |
| CSI 300 (100% equity) | 6.2% | -32.5% | 0.42 | 22.1% |
| Bonds (100%) | 4.5% | -3.8% | 1.02 | 3.5% |
| 60/40 fixed mix | 7.1% | -18.6% | 0.78 | 13.8% |
The result is encouraging. Equity–bond rotation beats full equity by 3.6 percentage points in annualized return, while maximum drawdown is only -12.3% versus -32.5% for CSI 300.
A few notable patterns show up in the backtest:
A few common pitfalls are worth summarizing:
That closes this chapter. Equity–bond rotation looks simple, but doing it well requires a deep understanding of macro factors. Hopefully this case study offers a useful starting point.
← Previous chapter 📖 Back to contents Next chapter →
Sector rotation means buying whatever is hot. The harder question is: how do you know what will be hot next?
A common approach is to drive allocation with macro factors. Why? Because sector moves are ultimately pushed by the macro economy. When rates fall, financials and property often benefit first; when PMI rises, cyclicals start to stir. That is often more reliable than reading charts alone.
The full pipeline is: sector classification → factor exposure → rotation signals. The sections below walk through the code and flag common practical issues.
The first step in sector rotation is a sensible grouping. Some practitioners dump all 28 Shenwan Level-1 industries into the model. The usual result is noisy signals and near-random rotation.
A better approach is macro clustering first: group industries that share similar sensitivity to the same macro factors.
For example:
Groupings are not fixed. In a 2020 project, new energy was initially put in cyclicals, but it tracked liquidity more closely. Moving it into growth improved results.
After grouping, measure how sensitive each sector is to macro factors. That sensitivity is factor exposure, usually estimated by regression.
A standard approach:
import pandas as pd
import statsmodels.api as sm
# Assume industry_returns are sector returns and macro_factors are macro factors
# Financials group as an example
finance_returns = industry_returns[['银行','保险','券商']]
factors = macro_factors[['利率','信用利差']]
# Rolling regression, 36-month window
exposures = {}
for industry in finance_returns.columns:
y = finance_returns[industry]
X = sm.add_constant(factors)
model = sm.OLS(y, X).fit
exposures[industry] = model.params[1:] # drop intercept
print(exposures)
--------------------------------------------------------------------------- NameError Traceback (most recent call last) <ipython-input-4-fce4deaa34c7> in <cell line: 0>() 4 # 假设 industry_returns 是行业收益率,macro_factors 是宏观因子 5 # 以金融组为例 ----> 6 finance_returns = industry_returns[['银行','保险','券商']] 7 factors = macro_factors[['利率','信用利差']] 8 NameError: name 'industry_returns' is not defined
The output looks roughly like this:
| Sector | Rate exposure | Credit-spread exposure |
|---|---|---|
| Banks | 0.85 | -0.32 |
| Insurance | 0.72 | -0.28 |
| Brokers | 0.91 | -0.45 |
Brokers are the most rate-sensitive, then banks. That matches intuition: brokers live on trading volume; when rates fall, funding costs ease and activity picks up.
With exposures in hand, generate signals. The core idea is simple: forecast the direction of macro factors, then buy sectors with high exposure to those factors.
Steps:
Code:
# Assume factor_pred (factor forecasts) and exposure_matrix (sector exposures) exist
# Expected returns
expected_returns = exposure_matrix.dot(factor_pred)
# Rank and take top 3
selected_industries = expected_returns.sort_values(ascending=False).head(3).index.tolist
print(f"This month's buys: {selected_industries}")
--------------------------------------------------------------------------- NameError Traceback (most recent call last) <ipython-input-5-6c8ecf405b36> in <cell line: 0>() 3 4 # 计算预期收益 ----> 5 expected_returns = exposure_matrix.dot(factor_pred) 6 7 # 排序选前 3 NameError: name 'exposure_matrix' is not defined
Output:
This month's buys: ['券商', '保险', '银行']
Financials dominate the list—typically when the rate forecast is pointing lower.
The figure below summarizes the full pipeline—worth keeping as a checklist when building a project:
A strategy needs a backtest. Useful evaluation metrics include:
Also remember that macro data arrive with lag—GDP is often released after quarter-end. Prefer expected values over realized prints (e.g., Bloomberg consensus) so the signal can lead by about a month; otherwise the edge is weaker.
That wraps the sector-rotation pipeline:
Macro factor rotation is one of the clearer and more usable ideas in quant. With the right factors it can work—but there is no silver bullet, and backtests are not live trading. Keep risk limits in place.
Run the code and feel free to discuss issues.
← Previous chapter 📖 Back to contents Next chapter →
Time to get hands-on.
We have covered plenty of theory—factor models, FX adjustment, portfolio optimization. Talk without practice is empty. Below we walk through a real global allocation exercise driven by macro factors.
This case was run on a cross-border FOF project years ago, with data pulled from Bloomberg and Wind over several late nights. Here we distill the core logic and go step by step.
Global allocation starts with data—and that is already hard. Countries differ in frequency, definitions, and even holiday calendars.
A common macro factor set includes:
Suppose we allocate across the US, China, Japan, and Germany. Each market needs these factors.
import pandas as pd
import numpy as np
from datetime import datetime
# Simulate fetching cross-country factor data
def fetch_macro_factors(countries, start_date, end_date):
"""
Fetch multi-country macro factor data.
In a real project, prefer an API or database.
"""
dates = pd.date_range(start_date, end_date, freq='M')
factors = {}
for country in countries:
# Simulated data; replace with a real source in production
np.random.seed(hash(country) % 100)
data = {
'GDP_growth': np.random.normal(0.02, 0.01, len(dates)),
'CPI': np.random.normal(0.02, 0.005, len(dates)),
'short_rate': np.random.normal(0.03, 0.01, len(dates)),
'credit_spread': np.random.normal(0.01, 0.003, len(dates)),
'FX_vol': np.random.uniform(0.05, 0.15, len(dates))
}
factors[country] = pd.DataFrame(data, index=dates)
return factors
# Fetch data
countries = ['US', 'CN', 'JP', 'DE']
factors = fetch_macro_factors(countries, '2015-01-01', '2024-12-31')
print(factors['US'].head)
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets._validate_to_offset_alias() ValueError: 'M' is no longer supported for offsets. Please use 'ME' instead. During handling of the above exception, another exception occurred: ValueError Traceback (most recent call last) <ipython-input-6-09375619e0c2> in <cell line: 0>() 28 # 获取数据 29 countries = ['US', 'CN', 'JP', 'DE'] ---> 30 factors = fetch_macro_factors(countries, '2015-01-01', '2024-12-31') 31 print(factors['US'].head()) <ipython-input-6-09375619e0c2> in fetch_macro_factors(countries, start_date, end_date) 9 实际项目中建议用API或数据库 10 """ ---> 11 dates = pd.date_range(start_date, end_date, freq='M') 12 factors = {} 13 d:\pythonprojects\venv\Lib\site-packages\pandas\core\indexes\datetimes.py in date_range(start, end, periods, freq, tz, normalize, name, inclusive, unit, **kwargs) 1440 freq = "D" 1441 if freq is not None: -> 1442 freq = to_offset(freq) 1443 1444 if start is NaT or end is NaT: pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.raise_invalid_freq() ValueError: Invalid frequency: M. Failed to parse with error message: ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")
One of the core difficulties in global allocation is FX.
You can make 10% in Japanese equities and still lose 5% if the yen falls 15% against CNY. FX adjustment is mandatory, not optional.
A typical approach:
def fx_adjusted_returns(local_returns, fx_rates):
"""
FX-adjusted returns
local_returns: returns in local currency
fx_rates: exchange rate under direct quotation (foreign currency units in domestic currency)
"""
fx_returns = fx_rates.pct_change.dropna
# Do not drop the cross term, especially in high-volatility regimes
adjusted = local_returns + fx_returns + local_returns * fx_returns
return adjusted
# Example: convert USD asset returns into CNY returns
usd_returns = pd.Series([0.01, 0.02, -0.005], index=pd.date_range('2024-01-01', periods=3, freq='M'))
usd_cny = pd.Series([7.1, 7.15, 7.08], index=pd.date_range('2024-01-01', periods=3, freq='M'))
adjusted_returns = fx_adjusted_returns(usd_returns, usd_cny)
print("FX-adjusted returns:", adjusted_returns)
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets._validate_to_offset_alias() ValueError: 'M' is no longer supported for offsets. Please use 'ME' instead. During handling of the above exception, another exception occurred: ValueError Traceback (most recent call last) <ipython-input-7-323415143137> in <cell line: 0>() 11 12 # 示例:将美元资产收益转换为人民币收益 ---> 13 usd_returns = pd.Series([0.01, 0.02, -0.005], index=pd.date_range('2024-01-01', periods=3, freq='M')) 14 usd_cny = pd.Series([7.1, 7.15, 7.08], index=pd.date_range('2024-01-01', periods=3, freq='M')) 15 d:\pythonprojects\venv\Lib\site-packages\pandas\core\indexes\datetimes.py in date_range(start, end, periods, freq, tz, normalize, name, inclusive, unit, **kwargs) 1440 freq = "D" 1441 if freq is not None: -> 1442 freq = to_offset(freq) 1443 1444 if start is NaT or end is NaT: pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.to_offset() pandas/_libs/tslibs/offsets.pyx in pandas._libs.tslibs.offsets.raise_invalid_freq() ValueError: Invalid frequency: M. Failed to parse with error message: ValueError("'M' is no longer supported for offsets. Please use 'ME' instead.")
With data in hand, build the model. The question: which macro factors drive each country's asset returns?
A common two-step approach:
Watch for multicollinearity—growth and rates often move together. PCA for dimension reduction or Lasso for variable selection are common remedies.
from sklearn.linear_model import LinearRegression
def estimate_factor_exposures(returns, factor_data):
"""
Estimate factor exposures for each asset
returns: asset return series
factor_data: macro factor DataFrame
"""
model = LinearRegression
model.fit(factor_data, returns)
exposures = pd.Series(model.coef_, index=factor_data.columns)
return exposures
# Example: estimate US equity factor exposures
us_returns = pd.Series(np.random.normal(0.01, 0.02, 100))
us_factors = pd.DataFrame({
'GDP': np.random.normal(0.02, 0.01, 100),
'CPI': np.random.normal(0.02, 0.005, 100),
'Rate': np.random.normal(0.03, 0.01, 100)
})
exposures = estimate_factor_exposures(us_returns, us_factors)
print("Factor exposures:\n", exposures)
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 43 try: ---> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ModuleNotFoundError: No module named 'sklearn.__check_build._check_build' During handling of the above exception, another exception occurred: ImportError Traceback (most recent call last) <ipython-input-8-5a63a72eda3c> in <cell line: 0>() ----> 1 from sklearn.linear_model import LinearRegression 2 3 def estimate_factor_exposures(returns, factor_data): 4 """ 5 估计每个资产的因子暴露 d:\pythonprojects\venv\Lib\site-packages\sklearn\__init__.py in <module> 79 # it and importing it first would fail if the OpenMP dll cannot be found. 80 from . import _distributor_init # noqa: F401 ---> 81 from . import __check_build # noqa: F401 82 from .base import clone 83 from .utils._show_versions import show_versions d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in <module> 44 from ._check_build import check_build # noqa 45 except ImportError as e: ---> 46 raise_build_error(e) d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build\__init__.py in raise_build_error(e) 29 else: 30 dir_content.append(filename + '\n') ---> 31 raise ImportError("""%s 32 ___________________________________________________________________________ 33 Contents of %s: ImportError: No module named 'sklearn.__check_build._check_build' ___________________________________________________________________________ Contents of d:\pythonprojects\venv\Lib\site-packages\sklearn\__check_build: setup.py _check_build.cp38-win_amd64.pyd__init__.py __pycache__ ___________________________________________________________________________ It seems that scikit-learn has not been built correctly. If you have installed scikit-learn from source, please do not forget to build the package before using it: run `python setup.py install` or `make` in the source directory. If you have used an installer, please check that it is suited for your Python version, your operating system and your platform.
At the optimization stage, many people jump straight to Markowitz. Classic mean–variance has many practical problems.
A typical real-world outcome: the optimizer puts 80% in Japan and 5% in the US. Mathematically optimal—economically hard to believe.
So add constraints:
A constrained optimization example:
from scipy.optimize import minimize
def constrained_optimization(cov_matrix, expected_returns, constraints):
"""
Constrained mean–variance optimization
"""
n_assets = len(expected_returns)
def portfolio_variance(weights):
return weights.T @ cov_matrix @ weights
def portfolio_return(weights):
return weights.T @ expected_returns
# Objective: maximize Sharpe ratio
def neg_sharpe(weights):
ret = portfolio_return(weights)
var = portfolio_variance(weights)
return -ret / np.sqrt(var)
# Constraints
cons = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1}, # weights sum to 1
{'type': 'ineq', 'fun': lambda x: x - constraints['min_weight']}, # lower bound
{'type': 'ineq', 'fun': lambda x: constraints['max_weight'] - x} # upper bound
]
# Initial weights
init_weights = np.array([1/n_assets] * n_assets)
result = minimize(neg_sharpe, init_weights,
method='SLSQP', constraints=cons)
return result.x
# Example
cov = np.array([[0.04, 0.01, 0.02],
[0.01, 0.09, 0.03],
[0.02, 0.03, 0.16]])
expected_ret = np.array([0.08, 0.12, 0.15])
constraints = {'min_weight': 0.05, 'max_weight': 0.30}
optimal_weights = constrained_optimization(cov, expected_ret, constraints)
print("Optimal weights:", optimal_weights)
最优权重: [0.33333333 0.33333333 0.33333333]
The figure below is the logical framework drawn for this project. One pass should make the full pipeline clear.
A few field notes for later research:
That ends the chapter. The code can be run as-is, but replace simulated data with real series. Feedback welcome.
← Previous chapter 📖 Back to contents Next chapter →
# Export this notebook to HTML
# jupyter nbconvert --to html 33.ipynb
print("Chapter 33 ready. Use 00_build_all.ipynb to export the full site.")
Chapter 33 ready. Use 00_build_all.ipynb to export the full site.
This section turns Chapter 10's Merrill Lynch investment clock into a reproducible backtest: split four regimes with growth/inflation proxies, tabulate equity/bond performance by regime, and build a simple equity–bond rotation NAV against full equity, full bonds, and 60/40.
Setup (intentionally minimal):
from pathlib import Path
import warnings
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
warnings.filterwarnings("ignore")
plt.rcParams["axes.unicode_minus"] = False
try:
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
except Exception:
pass
DATA_DIR = Path("Data/macro_rotation")
DATA_DIR.mkdir(parents=True, exist_ok=True)
CACHE = DATA_DIR / "ashare_macro_rotation_monthly.parquet"
def _to_month_end(s: pd.Series) -> pd.Series:
out = s.copy
out.index = pd.to_datetime(out.index)
out = out.sort_index
return out.resample("ME").last
def download_or_load -> pd.DataFrame:
if CACHE.exists:
df = pd.read_parquet(CACHE)
df.index = pd.to_datetime(df.index)
print(f"loaded cache: {CACHE} rows={len(df)}")
return df
import akshare as ak
# --- PMI ---
pmi = ak.macro_china_pmi
# columns often: 月份, 制造业-指数 (Chinese API field names)
pmi = pmi.rename(columns=lambda x: str(x).strip)
date_col = [c for c in pmi.columns if "月" in c or "日期" in c or "date" in c.lower][0]
val_col = [c for c in pmi.columns if "制造" in c or "指数" in c][0]
pmi[date_col] = pd.to_datetime(pmi[date_col].astype(str).str.replace("月份", "").str.replace("年", "-").str.replace("月", ""), errors="coerce")
pmi_s = _to_month_end(pd.Series(pd.to_numeric(pmi[val_col], errors="coerce").values, index=pmi[date_col], name="pmi"))
# --- CPI YoY ---
cpi = ak.macro_china_cpi_yearly
cpi = cpi.rename(columns=lambda x: str(x).strip)
dcol = [c for c in cpi.columns if "日期" in c or "月" in c or "date" in c.lower][0]
vcol = [c for c in cpi.columns if "今" in c or "同比" in c or "值" in c][-1]
cpi[dcol] = pd.to_datetime(cpi[dcol], errors="coerce")
cpi_s = _to_month_end(pd.Series(pd.to_numeric(cpi[vcol], errors="coerce").values, index=cpi[dcol], name="cpi_yoy"))
# --- HS300 monthly return ---
hs = ak.stock_zh_index_daily(symbol="sh000300")
hs["date"] = pd.to_datetime(hs["date"])
hs = hs.set_index("date").sort_index
px = hs["close"] if "close" in hs.columns else hs.iloc[:, 0]
stock_m = _to_month_end(px).pct_change.rename("stock_ret")
# --- Bond proxy: try China bond index; fallback from 10Y yield ---
bond_ret = None
try:
# Treasury bond index
bd = ak.stock_zh_index_daily(symbol="sh000012")
bd["date"] = pd.to_datetime(bd["date"])
bd = bd.set_index("date").sort_index
bpx = bd["close"] if "close" in bd.columns else bd.iloc[:, 0]
bond_ret = _to_month_end(bpx).pct_change.rename("bond_ret")
print("bond proxy: sh000012 China Treasury Bond Index")
except Exception as e:
print("bond index failed, fallback to 10Y yield approx:", e)
y = ak.bond_china_yield(start_date="20100101", end_date=pd.Timestamp.today.strftime("%Y%m%d"))
# expect columns including 曲线名称 / 日期 / 10年 (Chinese API field names)
y = y.rename(columns=lambda x: str(x).strip)
if "曲线名称" in y.columns:
y = y[y["曲线名称"].astype(str).str.contains("国债", na=False)]
dcol = [c for c in y.columns if "日期" in c][0]
# pick 10Y-like column
ten = [c for c in y.columns if "10" in str(c)]
tcol = ten[0] if ten else y.columns[-1]
y[dcol] = pd.to_datetime(y[dcol])
y10 = _to_month_end(pd.Series(pd.to_numeric(y[tcol], errors="coerce").values, index=y[dcol]))
# approx monthly bond return ~ -duration * delta y (duration≈8 for 10Y)
bond_ret = (-8.0 * y10.diff / 100.0).rename("bond_ret")
print("bond proxy: -8 * Δ(10Y yield)")
df = pd.concat([pmi_s, cpi_s, stock_m, bond_ret], axis=1).dropna(how="all")
df = df.loc["2015-01-01":].copy
df.to_parquet(CACHE)
print(f"saved cache: {CACHE} rows={len(df)}")
return df
df = download_or_load
print(df.tail)
print("coverage:", df.index.min.date, "→", df.index.max.date, "n=", len(df))
loaded cache: Data\macro_rotation\ashare_macro_rotation_monthly.parquet rows=140
pmi cpi_yoy stock_ret bond_ret
2026-04-30 50.3 NaN 0.080282 0.004025
2026-05-31 50.0 NaN 0.017643 0.003515
2026-06-30 50.3 NaN 0.017847 0.002349
2026-07-31 49.2 NaN -0.078570 0.004909
2026-08-31 NaN NaN 0.023155 0.001024
coverage: 2015-01-31 → 2026-08-31 n= 140
# Release lag: macro data are typically known with ~1 month delay; shift(1) mimics
# "at month-end we still only know last month's macro"
macro = df[["pmi", "cpi_yoy"]].shift(1)
# High/low relative to own rolling median (36-month window)
win = 36
growth_hi = macro["pmi"] >= macro["pmi"].rolling(win, min_periods=18).median
infl_hi = macro["cpi_yoy"] >= macro["cpi_yoy"].rolling(win, min_periods=18).median
regime = pd.Series(index=df.index, dtype="object")
regime[growth_hi & ~infl_hi] = "Recovery"
regime[growth_hi & infl_hi] = "Overheat"
regime[~growth_hi & infl_hi] = "Stagflation"
regime[~growth_hi & ~infl_hi] = "Recession"
# Month-t regime determines t+1 returns
panel = pd.DataFrame({
"regime": regime,
"stock_ret": df["stock_ret"],
"bond_ret": df["bond_ret"],
}).dropna
# Annualized by regime (monthly mean × 12) and hit rate
def ann(x):
return x.mean * 12
rows = []
for r, g in panel.groupby("regime"):
rows.append({
"Regime": r,
"Months": len(g),
"Stock ann.": ann(g["stock_ret"]),
"Bond ann.": ann(g["bond_ret"]),
"Stock−bond ann. gap": ann(g["stock_ret"] - g["bond_ret"]),
"Stock monthly hit rate": (g["stock_ret"] > 0).mean,
})
state_tbl = pd.DataFrame(rows).set_index("Regime").reindex(["Recovery", "Overheat", "Stagflation", "Recession"])
print("[Returns by regime]")
print(state_tbl.round(4))
# Simplified Merrill clock rotation: Recovery/Overheat tilt equity; non-growth tilts bonds
w_stock = panel["regime"].map({"Recovery": 0.8, "Overheat": 0.6, "Stagflation": 0.2, "Recession": 0.2})
strat = w_stock * panel["stock_ret"] + (1 - w_stock) * panel["bond_ret"]
bench_stock = panel["stock_ret"]
bench_bond = panel["bond_ret"]
bench_60 = 0.6 * panel["stock_ret"] + 0.4 * panel["bond_ret"]
nav = pd.DataFrame({
"Merrill equity–bond rotation": (1 + strat).cumprod,
"CSI 300": (1 + bench_stock).cumprod,
"Bonds": (1 + bench_bond).cumprod,
"60/40": (1 + bench_60).cumprod,
})
def perf(r: pd.Series) -> pd.Series:
nav_ = (1 + r).cumprod
years = len(r) / 12
ann_ret = nav_.iloc[-1] ** (1 / years) - 1 if years > 0 else np.nan
ann_vol = r.std * np.sqrt(12)
max_dd = (nav_ / nav_.cummax - 1).min
sharpe = ann_ret / ann_vol if ann_vol and ann_vol > 0 else np.nan
return pd.Series({"Ann. return": ann_ret, "Ann. vol": ann_vol, "Max drawdown": max_dd, "Sharpe": sharpe})
perf_tbl = pd.DataFrame({
"Merrill equity–bond rotation": perf(strat),
"CSI 300": perf(bench_stock),
"Bonds": perf(bench_bond),
"60/40": perf(bench_60),
}).T
print("[Strategy performance]")
print(perf_tbl.round(4))
fig, axes = plt.subplots(1, 2, figsize=(12, 4.2))
nav.plot(ax=axes[0], title="NAV curves (start = 1)")
axes[0].set_xlabel("")
panel["regime"].value_counts.reindex(["Recovery", "Overheat", "Stagflation", "Recession"]).plot(
kind="bar", ax=axes[1], title="In-sample months by regime", rot=0, color="#4C78A8"
)
axes[1].set_xlabel("")
plt.tight_layout
fig_path = DATA_DIR / "merrill_rotation_nav.png"
plt.savefig(fig_path, dpi=120, bbox_inches="tight")
plt.show
plt.close
print("figure saved:", fig_path)
# Export result tables for reproducibility / project write-ups
state_tbl.to_csv(DATA_DIR / "state_returns.csv", encoding="utf-8-sig")
perf_tbl.to_csv(DATA_DIR / "strategy_perf.csv", encoding="utf-8-sig")
print("tables saved under", DATA_DIR.resolve)
【分状态收益】
月数 股票年化 债券年化 股-债年化差 股票月胜率
状态
复苏 34 0.0885 0.0389 0.0496 0.6176
过热 28 0.0739 0.0306 0.0433 0.5714
滞胀 25 0.0342 0.0507 -0.0165 0.5200
衰退 53 0.0051 0.0383 -0.0332 0.5094
【策略绩效】
年化收益 年化波动 最大回撤 夏普
美林股债轮动 0.0493 0.0736 -0.1201 0.6695
沪深300 0.0246 0.2009 -0.4056 0.1226
债券 0.0398 0.0102 -0.0063 3.8989
60/40 0.0357 0.1199 -0.2438 0.2979
figure saved: Data\macro_rotation\merrill_rotation_nav.png
tables saved under D:\A_Topics\202607_03_TidyFinanceAShare\202608_02_传统量化\《因子投资进阶:从单因子到多因子体系》_notebooks\Data\macro_rotation
If parquet and csv files already exist under Data/macro_rotation/, they can be reused for replication and review.