Growth x inflation quadrant allocation from point-in-time hard data and breakevens
Module macro_quadrant · from the research cycle of Oct 16, 2026
Needs work Something real may be here, but the evidence isn't strong enough yet. Specific follow-up tests are listed.
Performance
Backtest after costs. Train is where the idea was developed; validation is where it was judged.
| Metric | Train 2007–15 | Validation 2016–21 |
|---|---|---|
| CAGR | 4.8% | 9.5% |
| Volatility | 8.7% | 7.6% |
| Sharpe | 0.50 | 1.11 |
| Sortino | 0.60 | 1.44 |
| Max drawdown | -10.1% | -9.6% |
| Calmar | 0.47 | 0.99 |
| Worst month | -5.9% | -7.0% |
| Months up | 68% | 69% |
| Turnover / yr | 4.8× | 8.0× |
| Avg gross exposure | 0.98× | 1.43× |
| Correlation to SPY | 0.41 | 0.11 |
| Correlation to 60/40 | 0.44 | 0.22 |
| Beta to SPY | 0.17 | 0.05 |
Average holdings
The idea
Hypothesis
Each asset class does best in a different regime. Equities and high-yield credit do best when growth is rising and inflation is falling. Commodities, emerging markets and TIPS do best when both are rising. Gold and TIPS do best when growth falls and inflation rises. Long Treasuries do best when both fall. Regimes last for months, and the hard data that identifies them (claims, vehicle sales, industrial production, CPI) comes out with a known delay. A strategy that reads the current regime from published data and holds that regime's assets should beat a static mix on a risk-adjusted basis. It should also be less correlated with 60/40, because in growth-down regimes it moves out of equities entirely. Counterparty: static, benchmark-bound allocators (balanced funds and target-date funds) that keep fixed weights through regimes, and investors who extrapolate the last regime (for example, staying in long bonds after inflation has turned).
Who is on the other side
Static balanced and target-date allocators that rebalance to fixed weights whatever the regime; and investors who anchor on the previous regime's winners. We are not betting on beating their forecasts, only on reacting systematically to published regime changes that they ignore by mandate.
Inspiration
Bridgewater's 'economic machine' and All Weather: asset returns depend on whether growth and inflation come in above or below what was expected. The MacroRegime baseline only moves equity exposure up and down in response to stress. This strategy rotates which assets it holds, using both a growth axis and an inflation axis, so it can earn money in four kinds of environment instead of only stepping back in one.
Expected Sharpe (before testing)
0.5
How it could fail
(1) Fast V-shaped regimes such as 2020: the monthly hard data plus the 35–48 day lags mean the strategy enters G− after the selloff and leaves after the rebound, paying twice. (2) Regime labels that are right while assets misbehave. Example: the 2013 taper tantrum, when growth was up and inflation down, yet TIPS and EM fell together. (3) Within 2007–2021 the inflation axis hardly varies (low, stable inflation), so the I+ baskets are tested on few and short episodes, which means low statistical power for half the quadrants. (4) Revised growth data: live values for claims and IP were noisier than today's vintage. (5) The one long G− stretch in train (2008–09) carries a lot of weight in train results. Validation (2016–2021) has only the 2020 episode and mini-slowdowns in 2016 and 2019, so validation tests G+ basket selection more than crisis protection.
Data & rules
Data
Growth axis: ICSA (weekly, lag 6d, revised), TOTALSA (monthly, lag 35d, revised), INDPRO (monthly, lag 48d, revised). Inflation axis: T5YIE (daily, lag 1d, final; starts 2003), CPIAUCSL (monthly, lag 45d). Cash: DTB3. Prices: SPY, IWM, EFA, EEM, HYG, DBC, TIP, GLD, TLT, IEF, SHY (HYG starts Apr 2007; DBC Feb 2006). WEI is deliberately left out of the primary rules. The NY Fed launched it in 2020, so everything before 2020 is a backcast that nobody could have used live. The Data Scientist should profile it before anyone uses it. Revised series (ICSA, TOTALSA, INDPRO) are latest-vintage, so the backtest flatters live performance slightly. ALFRED vintages are the upgrade path.
Rules
Daily signal computed from lagged data. Rebalance on the last trading day of each month.
Growth score G = mean of three z-scores, each standardised on a rolling 5-year window:
(a) −(ICSA 4-week average / ICSA 52-week average − 1)
(b) TOTALSA 3-month average / 12-month average − 1
(c) INDPRO 6-month % change
Inflation score I = mean of two z-scores, same 5-year window:
(a) 63-trading-day change in T5YIE
(b) CPI 3-month annualised change minus CPI 12-month change
Hysteresis: the regime flips only when a score crosses ±0.25. Otherwise the previous sign is kept.
Baskets:
- G+/I−: SPY, IWM, EFA, HYG
- G+/I+: SPY, EEM, DBC, TIP
- G−/I+: GLD, TIP, SHY
- G−/I−: TLT, IEF, GLD
Within a basket, weight by inverse 63-day volatility. Scale the portfolio to 10% annualised volatility (63-day estimate), with max gross 1.5. Any unused weight goes to SHY. Trading starts once every input has its full 5-year window. Costs, financing and borrow are on as usual.
Pre-declared trial budget (3 trials):
1. Base case as above.
2. z-window 3 years instead of 5.
3. No hysteresis.
Report against SixtyForty, RiskParity and MacroRegime. Also report the share of time spent in each quadrant and the return earned per quadrant.
Data review revise
COVERAGE (blocking as written): T5YIE starts 2003-01-02. A full 5-year z-window on a 63-day change is first valid about April 2008. Under the 'start when every input has its full window' rule, base case trading would start about Apr 2008. That cuts train to about 7.7 years and misses the whole 2007–Q1 2008 onset of the one big G− episode in train. The 3-year variant would be valid from about Apr 2006 on this input. The other binding input is HYG (Apr 2007, plus 63 days of vol, so about Jul 2007). ICSA, TOTALSA, INDPRO and CPI have decades of history. No issue there.
GROWTH AXIS (good): ICSA change_3m vs SPY next-month rank IC −0.32 (halves −0.31/−0.34, t −3.5), the strongest signal I found. ICSA zscore_1y vs SPY −0.21 (−0.27/−0.15, t −2.2). TOTALSA change_3m vs SPY +0.20 (+0.20/+0.13, t 2.1), EEM +0.09. INDPRO 12m % change vs SPY +0.02 with a sign flip (−0.10/+0.17), so it is noise for equities. Vs DBC it is +0.14 with the same sign in both halves (+0.19/+0.13). The growth composite is supported, mainly by claims and vehicle sales.
INFLATION AXIS (weak): T5YIE change_3m vs DBC +0.08 but flips (+0.09/−0.11). Vs SPY +0.14, also flips (+0.30/−0.07). Vs GLD −0.19 with the same sign in both halves (−0.27/−0.20). So rising breakevens were followed by weaker gold in train, the opposite of what the I+ baskets assume. CPI change_3m vs DBC +0.08 (halves −0.04/+0.00, noise). Vs SPY −0.13 (−0.18/−0.11, consistent). The inflation axis does not show the commodity/gold payoff the I+ baskets depend on, consistent with failure mode 3 (little inflation variation in 2007–2015).
OTHER: WEI cannot be profiled. The tool rejects it because history starts 2008-01-05 and it is a backcast before 2020. Keep it out, as proposed. In late 2008 T5YIE fell below zero because TIPS were illiquid after Lehman (a liquidity problem, not a real inflation signal). The I− reading then (TLT/IEF/GLD) is partly that artifact. CPIAUCSL is flagged 'final' but seasonal factors revise slightly. ICSA, TOTALSA and INDPRO are latest-vintage revised.
Guidance to the engineer
Fix coverage before trial 1, and declare the fix in writing before running. Standardise every z-score on an expanding window with a 3-year minimum, capped at 5 years (min_periods = 756 trading days, or 36 months for monthly series; max = 1260 days, or 60 months). With that, the earliest valid date is set by HYG (about Jul 2007), so 2007–08 falls inside train. Trial 2 (3-year window) and trial 3 (no hysteresis) stay as declared. Do not change the baskets because of the GLD finding: that would be fitting to the profile. Do report GLD's return contribution per quadrant so the Skeptic can judge it.
Series and transforms. Growth:
(a) −(ICSA 4-week mean / 52-week mean − 1), weekly, 6-day lag, forward-filled to daily
(b) TOTALSA 3-month mean / 12-month mean − 1, 35-day lag
(c) INDPRO 6-month % change, 48-day lag
Inflation:
(a) T5YIE 63-trading-day difference, 1-day lag
(b) CPIAUCSL 3-month annualised minus 12-month % change, 45-day lag
Lag rules: compute every rolling statistic on the release-date-aligned series, never on observation dates. For monthly data the observation for month m is usable only from its lag date. Rebalance at month-end using only values available that day.
Report: time in each quadrant and the number of quadrant switches per year (expect few I+ months, so flag any quadrant with fewer than 12 months as low power). Report results with and without 2008-Q4 as well. Revised-data caveat: ICSA, TOTALSA and INDPRO are latest vintage.
Implementation · implemented
Implemented as specified, with the Data Scientist's coverage fix declared before trial 1: z-scores use an expanding window with a 756-trading-day minimum, capped at 1260. Monthly and weekly transforms (ICSA 4w/52w means, TOTALSA 3m/12m, INDPRO 6m, CPI 3m-ann minus 12m) are built from the lagged series as known on each day. Each daily value is paired with the value known k months or k weeks earlier (an as-of lookup that only looks backward), so nothing uses an observation before its release date. Hysteresis is ±0.25 on daily scores. Before the first band crossing the plain sign is used. Baskets are unchanged: no edit for the GLD finding. Inverse-63d-vol within the basket. The portfolio is scaled to 10% vol using the 63d covariance of the basket at each month-end, capped at 1.5x gross. Unused weight goes to SHY. Trading starts once G, I and every basket asset's 63d vol are valid (HYG binds, about Jul 2007). Trial 1, defaults: train Sharpe 0.50 (CAGR 4.8%, vol 8.7%, maxDD -10.1%, corr SPY 0.41, corr 60/40 0.44). Validation Sharpe 1.11 (CAGR 9.5%, vol 7.6%, maxDD -9.6%, corr SPY 0.11, corr 60/40 0.22). DSR 0.996 at 1 trial. Look-ahead check passed. Caveats for the Skeptic: (1) validation average gross is 1.43 vs 0.98 in train. Low-vol baskets (TIP/SHY/GLD, TLT/IEF/GLD) get levered close to the 1.5 cap, so the validation result depends heavily on levered TIP/duration in a bond bull market. TIP was the largest average holding at 25%. (2) 2020 (+20%) and 2021 (+15%) supply much of the validation result. Check which quadrant was held then. (3) 2015 was the only losing year (-1.8%). (4) The evaluate tool does not output quadrant time shares, switches per year, per-quadrant returns, GLD contribution or the ex-2008Q4 result. These still need to be produced for the Skeptic. Remaining declared trials: 3-year z-window (z_window=756) and no hysteresis (band=0).
Robustness
The Skeptic's tests: is the result a plateau or a lucky spike, does it hold out of sample, does it survive higher costs, and how did it behave in crises?
Parameter stability
| 0.0 | 0.25 | 0.5 | |
|---|---|---|---|
| 756 | 0.26 | 0.31 | 0.27 |
| 1260 | 0.64 | 0.50 | 0.48 |
| 1764 | 0.56 | 0.32 | 0.40 |
Median Sharpe 0.40, range 0.26–0.64; 78% of settings above 0.3. An even grid means the idea doesn't depend on one lucky setting.
Walk-forward test
Each year, pick the best settings using only the past, then trade the next year blind.
- Out-of-sample Sharpe
- 0.74
- CAGR
- 6.4%
- Max drawdown
- -13.6%
- Period
- 2012–2021
Settings chosen: 2012: 0.25/756, 2013: 0.25/756, 2014: 0.0/1260, 2015: 0.0/1260, 2016: 0.0/1260, 2017: 0.0/1260, 2018: 0.0/1260, 2019: 0.0/756, 2020: 0.0/756, 2021: 0.0/756
Trading costs
| Costs | CAGR | Sharpe |
|---|---|---|
| 0x costs | 9.8% | 1.14 |
| 1x costs | 9.5% | 1.11 |
| 2x costs | 9.2% | 1.07 |
| 3x costs | 8.9% | 1.04 |
Turnover 6.0× a year. Validation period.
Crises
| Episode | Strategy | SPY | 60/40 |
|---|---|---|---|
| 2008 financial crisis | -0.4% | -46.4% | -28.2% |
| 2011 euro crisis / US downgrade | -7.6% | -17.8% | -7.4% |
| 2013 taper tantrum | -8.1% | -5.4% | -5.1% |
| 2015–16 China / oil crash | 0.4% | -11.1% | -4.4% |
| Q4 2018 selloff | -1.1% | -18.9% | -10.3% |
| COVID crash | 1.6% | -33.4% | -18.6% |
Correlation to baselines: BuyHold 0.32, SixtyForty 0.38, TrendFollowing 0.55, TrendFollowing 0.35, AssetRotation 0.60, RiskParity 0.55, MacroRegime 0.43. Rolling one-year Sharpe: median 0.38, worst -1.12, negative 20% of the time.
Skeptic's verdict Needs work
Key evidence. Validation Sharpe 1.11 (CAGR 9.5%, vol 7.6%, max drawdown −9.6%, avg gross 1.43). Walk-forward out-of-sample Sharpe for 2012–2021: 0.74 (max drawdown −13.6% in June 2013, 991 days underwater; chosen z-window flipped between 3 and 5 years). Train grid (z-window × band, 9 points): median Sharpe 0.40, min 0.26, max 0.64; the 3-year window scored ≈0.3 at every band. Costs: Sharpe 1.14, 1.11, 1.07, 1.04 at 0x, 1x, 2x, 3x. Crisis returns: 2008 −0.4%, COVID +1.6%, Q4 2018 −1.1%, 2015–16 +0.4%, 2013 taper −8.1% (60/40 −5.1%). Correlation: 0.11 to SPY and 0.22 to 60/40 in validation; 0.55 to long-only Trend, 0.55 to RiskParity, 0.60 to AssetRotation. Median rolling 1-year Sharpe 0.38. Returns 2016–18 ≈2–5% a year, against 12.7%, 20.3% and 15.0% for 2019–21.
CODE REVIEW: I found no leakage. Every monthly and weekly transform reads the publication-lagged series through a backward-only as-of shift. Z-scores are trailing. Rebalancing is at month-end. The baskets are textbook All Weather, not picked with knowledge of 2008 or 2020. Two problems with the design. (a) The z-score was changed from the declared 5-year window to an expanding window with a 756-day minimum, capped at 1,260 days. It was declared before trial 1, but it is still a deviation from the pre-registered rule. (b) Inverse-vol weighting inside low-vol baskets (G-/I+ holds SHY; G-/I- holds IEF) plus a 10% vol target pins gross exposure near the 1.5 cap. Average gross in validation was 1.43, and average weights are 31% SHY and 25% TIP. So part of the return is leveraged short and medium duration during a 15-year bond bull market.
WHAT IS GOOD: (1) The walk-forward selection process earned an out-of-sample Sharpe of 0.74 over 2012–2021 (CAGR 6.4%, vol 8.1%). That is a fair estimate of live skill, and it is lower than the 1.11 default validation figure. (2) Costs barely matter. Validation Sharpe goes from 1.14 at zero cost to 1.04 at 3x, on turnover of about 6x per year. (3) The strategy protected capital in most crises: 2008 −0.4% (SPY −46%), 2015–16 +0.4%, Q4 2018 −1.1% (SPY −19%), COVID +1.6% (SPY −33%). (4) Validation correlation is 0.11 to SPY and 0.22 to 60/40. (5) The pre-declared base case (band 0.25) is not the best point on the train grid; no hysteresis scored higher. So the base case does not look tuned.
WHY NOT PROMISING YET: (1) Validation returns are concentrated in one stretch. The 2016–2018 returns were 1.9%, 2.8% and 5.4%, roughly cash plus 1–3%. The years 2019–2021 (+12.7%, +20.3%, +15.0%) produce most of the 1.11 Sharpe. The median rolling 1-year Sharpe is only 0.38, and it is negative 20% of the time. 2020 (+20%) probably comes largely from levered Treasuries and gold in the G− quadrant after the claims shock: one episode that happened to suit a bond-heavy basket. (2) Stability is only a moderate plateau. On the train grid the median Sharpe is 0.40 (range 0.26–0.64, spike ratio 1.58). Every 3-year window scored 0.26–0.31, so the result depends on the z-window. Walk-forward flipped between the 3-year and 5-year windows (3-year chosen in 2012–13 and 2019–21), so the parameter is not settled. (3) Failure mode 2 from the hypothesis showed up: the taper tantrum cost −8.1%, worse than 60/40 (−5.1%), and it is the out-of-sample max drawdown (−13.6%, 991 days underwater). 2011 was −7.6%, the same as 60/40. (4) Overlap with existing baselines: correlation is 0.55 to long-only TrendFollowing, 0.55 to RiskParity and 0.60 to AssetRotation. It diversifies away from SPY, but it is partly a mix of risk parity and rotation. (5) The growth inputs (ICSA, TOTALSA, INDPRO) are latest-vintage revised data, and the two inflation-up quadrants have very little statistical power over 2007–2021. Family trials are now 14 (1 + 9 grid points + 4 walk-forward), so the deflated Sharpe has to be recomputed. The reported DSR of 0.996 assumed 1 trial.
REQUIRED BEFORE RE-REVIEW: (a) Report the share of time and the return earned in each quadrant, for train and validation separately. The hypothesis promised this. Show which quadrant produced the 2019–2021 profits. (b) Run the same rules with max_gross 1.0 (no leverage), and run a version without SHY in the G−/I+ basket. These show how much of the Sharpe comes from leveraged duration. (c) Report validation Sharpe excluding 2020, and Sharpe for 2016–2018 alone. (d) Regress the strategy on RiskParity and long-only Trend and report the residual alpha and its t-stat. If the residual Sharpe is below about 0.3, the idea adds little. (e) Replace ICSA and INDPRO with ALFRED vintages, or at least do a sensitivity test with added noise, before any paper trading. (f) Fix the z-window at 1,260 days by pre-registration; do not reselect it. Add a written shutdown rule for an inflation-shock regime that the sample cannot test.
Code
The exact strategy file the Engineer wrote, after passing the sandbox and the look-ahead detector.
macro_quadrant.py
from amberlab.strategies.base import Strategy, month_ends, rebalance_on, trailing_return, realized_vol
import numpy as np
import pandas as pd
def _asof_shift(s, months=0, days=0):
"""Value of the (already publication-lagged, ffilled) series as it was KNOWN `months`/`days` earlier.
Purely backward-looking: for each date d returns s at the last index <= d - offset."""
idx = s.index
target = idx
if months:
target = target - pd.DateOffset(months=months)
if days:
target = target - pd.Timedelta(days=days)
pos = idx.searchsorted(target, side="right") - 1
vals = np.where(pos >= 0, s.values[np.clip(pos, 0, None)], np.nan)
return pd.Series(vals, index=idx)
def _monthly_mean(s, n):
return sum(_asof_shift(s, months=k) for k in range(n)) / n
def _weekly_stat(s, n, how):
shifts = [_asof_shift(s, days=7 * k) for k in range(n)]
df = pd.concat(shifts, axis=1)
return df.mean(axis=1, skipna=False) if how == "mean" else df.min(axis=1, skipna=False)
def _z(x, win, minp):
m = x.rolling(win, min_periods=minp).mean()
sd = x.rolling(win, min_periods=minp).std()
return (x - m) / sd
def _hysteresis(score, band):
st = pd.Series(np.nan, index=score.index)
st[score > band] = 1.0
st[score < -band] = -1.0
st = st.ffill()
# before the first band crossing, fall back to the plain sign
fallback = np.sign(score).replace(0, 1.0)
return st.fillna(fallback).where(score.notna())
class MacroQuadrant(Strategy):
family = "macro_quadrant"
description = (
"Growth x inflation quadrant rotation. Growth score G = mean z of (a) -(ICSA 4w mean/52w mean - 1), "
"(b) TOTALSA 3m mean/12m mean - 1, (c) INDPRO 6m % change. Inflation score I = mean z of (a) 63d change "
"in T5YIE, (b) CPI 3m annualised minus 12m % change. All monthly/weekly transforms are computed on the "
"publication-lagged series as known on each day (value known k months/weeks earlier). Z-scores use an "
"expanding window, min 756 trading days, capped at 1260 (Data Scientist coverage fix, declared before trial 1). "
"Regime sign flips only when a score crosses +/-0.25 (hysteresis). Baskets: G+/I- SPY,IWM,EFA,HYG; "
"G+/I+ SPY,EEM,DBC,TIP; G-/I+ GLD,TIP,SHY; G-/I- TLT,IEF,GLD. Inverse 63d vol within basket, portfolio "
"scaled to 10% vol (63d covariance), max gross 1.5, unused weight to SHY. Month-end rebalance."
)
hypothesis = (
"Asset classes earn their returns in different growth/inflation regimes (Bridgewater's economic machine); "
"regimes persist for months and are identifiable from published hard data, so holding the current regime's "
"assets should beat static mixes risk-adjusted and decorrelate from 60/40. Counterparty: static balanced / "
"target-date allocators and investors anchored to last regime's winners. Fails in fast V-shaped regimes "
"(2020: lagged data whipsaw), when labels are right but assets misbehave (2013 taper), and the I+ baskets "
"have little test power in 2007-2021. Growth inputs are latest-vintage revised data (flatters backtest)."
)
macro_ids = ("ICSA", "TOTALSA", "INDPRO", "T5YIE", "CPIAUCSL")
@classmethod
def default_params(cls):
return {"z_window": 1260, "z_min": 756, "band": 0.25, "vol_days": 63,
"target_vol": 0.10, "max_gross": 1.5}
BASKETS = {
(1, -1): ["SPY", "IWM", "EFA", "HYG"],
(1, 1): ["SPY", "EEM", "DBC", "TIP"],
(-1, 1): ["GLD", "TIP", "SHY"],
(-1, -1): ["TLT", "IEF", "GLD"],
}
def target_weights(self, data):
p = self.params
win, minp = int(p["z_window"]), int(min(p["z_min"], p["z_window"]))
icsa = data.macro("ICSA")
tot = data.macro("TOTALSA")
ip = data.macro("INDPRO")
bei = data.macro("T5YIE")
cpi = data.macro("CPIAUCSL")
g_a = -(_weekly_stat(icsa, 4, "mean") / _weekly_stat(icsa, 52, "mean") - 1)
g_b = _monthly_mean(tot, 3) / _monthly_mean(tot, 12) - 1
g_c = ip / _asof_shift(ip, months=6) - 1
i_a = bei - bei.shift(63)
i_b = (cpi / _asof_shift(cpi, months=3)) ** 4 - 1 - (cpi / _asof_shift(cpi, months=12) - 1)
G = pd.concat([_z(g_a, win, minp), _z(g_b, win, minp), _z(g_c, win, minp)], axis=1).mean(axis=1, skipna=False)
I = pd.concat([_z(i_a, win, minp), _z(i_b, win, minp)], axis=1).mean(axis=1, skipna=False)
g_state = _hysteresis(G, p["band"])
i_state = _hysteresis(I, p["band"])
close = data.close
universe = sorted({s for b in self.BASKETS.values() for s in b} | {"SHY"})
vol = realized_vol(close[universe], p["vol_days"])
rets = close[universe].pct_change()
valid = g_state.notna() & i_state.notna() & vol.notna().all(axis=1)
if not valid.any():
return pd.DataFrame(np.nan, index=data.dates, columns=universe)
start = valid[valid].index[0]
rdates = [d for d in month_ends(data.dates) if d >= start and valid.loc[d]]
W = pd.DataFrame(np.nan, index=data.dates, columns=universe)
nd = int(p["vol_days"])
for d in rdates:
basket = self.BASKETS[(int(g_state.loc[d]), int(i_state.loc[d]))]
iv = 1.0 / vol.loc[d, basket]
w = iv / iv.sum()
pos = rets.index.get_loc(d)
R = rets.iloc[max(0, pos - nd + 1):pos + 1][basket].dropna()
cov = R.cov().values * 252
pv = float(np.sqrt(max(w.values @ cov @ w.values, 1e-12)))
scale = min(p["target_vol"] / pv, p["max_gross"])
row = pd.Series(0.0, index=universe)
row[basket] = w * scale
if scale < 1:
row["SHY"] += 1 - scale
W.loc[d] = row
return W