Fade market growth scares that the physical economy does not confirm
Module fear_vs_fundamentals · 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.7% | 3.3% |
| Volatility | 6.4% | 5.1% |
| Sharpe | 0.63 | 0.47 |
| Sortino | 0.73 | 0.52 |
| Max drawdown | -11.8% | -9.3% |
| Calmar | 0.40 | 0.35 |
| Worst month | -5.3% | -5.3% |
| Months up | 69% | 56% |
| Turnover / yr | 3.2× | 2.7× |
| Avg gross exposure | 0.97× | 1.00× |
| Correlation to SPY | 0.33 | 0.35 |
| Correlation to 60/40 | 0.33 | 0.35 |
| Beta to SPY | 0.10 | 0.10 |
Average holdings
The idea
Hypothesis
Markets regularly price recession probabilities well above what the real economy later delivers. In cross-asset prices this shows up as cyclicals and commodities sharply underperforming Treasuries and gold. If hard physical data (vehicle sales, industrial production, housing starts, rail carloads, truck tonnage) and fast labor data (claims) are still expanding at that moment, the scare is more likely sentiment and forced deleveraging than fundamentals, and cyclical assets tend to recover over the next 1–3 months. When hard data does confirm (2008), the filter keeps us out. Trend-following and vol-targeting money sells mechanically into these drawdowns. That flow is the source of the discount we buy, so the sleeve should do well exactly when trend-following struggles (choppy, V-shaped scares), and that is the diversification the lab needs.
Who is on the other side
Mechanical de-risking flows: CTAs and trend followers, volatility-targeting and risk-parity funds that cut exposure when realised vol rises, and retail and macro investors who extrapolate headline recession narratives. They sell for risk-management reasons, not because hard data shows a downturn.
Inspiration
Fundamental funds use real-economy data (rail, trucking, vehicle sales) to judge whether a market move reflects fundamentals. Also the literature on overreaction to macro fear: equity drawdowns without a recession recover fast (2010, 2011, 2015–16, 2018 Q4, 2019). This is a contrarian, conditional strategy: it uses physical-economy data to tell growth scares apart from real recessions. It is the opposite in kind to trend following, which sells into these moves.
Expected Sharpe (before testing)
0.4
How it could fail
(1) A scare that turns into a recession before the lagged hard data catches up. The 35–95 day lags mean D can read 'expanding' for 1–3 months into a real downturn (early 2008). The claims guard is the main protection, and the Skeptic should check entries in late 2007 and 2008 one by one. (2) Exogenous shocks such as March 2020: claims explode, the guard trips, and the sleeve misses the fastest rebound in the sample. Correct behaviour, but a missed opportunity. (3) Few episodes (about 6–12), so the Sharpe will have wide error bars. Judge per-episode consistency and the subperiod split, not the headline number. (4) Revisions: today's vintages of INDPRO, HOUST and the rail/truck data may show 'expansion' that the live data didn't. ALFRED vintages are needed before any capital. (5) Mostly in cash, so the standalone Sharpe looks modest and the value lies in low correlation. If correlation to SPY over its time in market is close to 1 and the episode win rate is below about 60%, the idea is just buying dips with extra steps and should be dropped.
Data & rules
Data
Market-implied growth M, from prices: SPY, IWM, EEM, DBC (cyclical basket) vs TLT, GLD (defensive basket). Hard-data diffusion, five indicators (all revised; lags applied by the engine):
- TOTALSA (lag 35d)
- INDPRO (lag 48d)
- HOUST (lag 50d)
- RAILFRTCARLOADSD11 (lag 95d)
- TRUCKD11 (lag 95d)
Rail and truck arrive about 3 months late, so they only confirm the medium-term trend. The fast guard is ICSA (lag 6d). Cash: DTB3/SHY. WEI is excluded (pre-2020 values are a backcast).
Rules
Compute daily; act weekly (each Friday).
Market growth M = 63-day return of the equal-weight cyclical basket (SPY, IWM, EEM, DBC) minus 63-day return of the equal-weight defensive basket (TLT, GLD). Scare = M at or below the 20th percentile of its own trailing 3-year (756-day) distribution.
Hard-data diffusion D = number of the five indicators whose latest published 3-month average exceeds its 12-month average (0–5).
Claims guard = ICSA 4-week average < 1.15 × its 52-week minimum.
Entry: Scare AND D ≥ 3 AND claims guard holds. Buy the cyclical sleeve: 50% SPY, 25% IWM, 15% EEM, 10% HYG, scaled to 12% target vol (63-day vol), max gross 1.0.
Hold for 63 trading days. Exit early if the claims guard breaks or D falls to ≤ 1. A new qualifying signal during the hold restarts the clock.
Outside entries: 100% SHY. This is a standalone sleeve meant to sit next to 60/40 and trend.
Pre-declared trial budget (3 trials):
1. Base case.
2. Scare percentile 10th instead of 20th.
3. Hold period 42 instead of 63 days.
Report all of the following:
- number of episodes and win rate per episode (expect about 6–12 episodes in 2007–2021)
- time in market
- correlation to SPY, 60/40 and the TrendFollowing baseline
- the blended portfolio 80% SixtyForty + 20% this sleeve vs SixtyForty alone
Data review revise
COVERAGE (blocking as written): M needs a 63-day return plus a 756-day trailing percentile, so 819 trading days of history. DBC starts Feb 2006, so M is first valid about May 2009. That misses every 2007–2008 entry, which are exactly the claims-guard cases failure mode 1 says the Skeptic must check. Without DBC, prices start in 2005 (GLD Nov 2004), and a full 756-day window would still only be valid around Apr 2008. HYG (Apr 2007) in the sleeve is fine from mid-2007. RAILFRTCARLOADSD11 and TRUCKD11 start in 2000, so their coverage is fine.
HARD-DATA DIFFUSION (half of it is uninformative): change_3m vs SPY next-month rank IC:
- TOTALSA +0.20 (+0.20/+0.13), supportive
- HOUST +0.14 (+0.18/+0.14), supportive
- INDPRO (12m) +0.02, noise with a sign flip
- RAILFRTCARLOADSD11 −0.08 (−0.07/−0.09), wrong sign
- TRUCKD11 −0.13 (−0.22/−0.02), wrong sign
With a 95-day lag, rail and truck describe the economy 3–4 months ago. They are near zero or contrarian for equities, and they are exactly the components that will still read 'expanding' early in a real recession (failure mode 1). Two of five votes in D are stale.
CLAIMS GUARD (strong): ICSA change_3m vs SPY −0.32 (−0.31/−0.34, t −3.5); vs EEM −0.19. The fast guard is the best-supported part of the design.
WEI cannot be profiled (history starts 2008-01, pre-2020 values are a backcast). The exclusion is correct. All five hard-data series are latest-vintage revised.
Guidance to the engineer
Two required fixes. Declare both in writing before trial 1.
(1) Price coverage. Compute the Scare percentile on an expanding window with a 504-day minimum, capped at 756 days. Keep DBC in the cyclical basket only if M is still valid by mid-2007. Otherwise define the cyclical basket as SPY/IWM/EEM for M. Use the same definition in every trial. Confirm the first valid Scare date is no later than Jul 2007 and print it.
(2) D composition. Either (a) keep all 5 as declared and report D also without rail and truck, or (b), my recommendation, base D on the timely indicators TOTALSA (35-day lag), HOUST (50-day) and INDPRO (48-day), with entry D≥2 of 3 and exit D=0, and keep rail and truck as report-only confirmation. Be clear that (b) is informed by this profile. Log it as a design choice in the family, not as a free choice.
Transforms: for each indicator, 3-month mean vs 12-month mean on release-aligned data, where month m is usable only from its release date. Claims guard: ICSA 4-week mean < 1.15 × 52-week min, 6-day lag. Act on Fridays using data available Friday morning.
Reports:
- An entry-by-entry table, especially Q3 2007–Q1 2009, showing D, the claims ratio, and the 63-day forward SPY return.
- Episode count, win rate, and time in market.
- Correlation to SPY while in market, to 60/40, and to TrendFollowing.
- The 80/20 blend vs SixtyForty.
Drop rule as stated: if in-market SPY correlation is about 1 and the win rate is below 60%, drop the idea.
Implementation · implemented
Built with the Data Scientist's two required fixes, declared before the run. Evaluated once with default parameters (trial 1 of the 3 declared). No look-ahead or sandbox problems.
Fixes:
1. DBC is removed from M. M = equal-weight 63-day return of SPY/IWM/EEM minus equal-weight 63-day return of TLT/GLD. The Scare percentile uses a rolling 756-day window with a 504-day minimum. DBC (Feb 2006) plus 63+504 days would not be valid until about May 2008, which fails the "valid by mid-2007" test. With prices from 2005, M should first be valid around Apr 2007. I cannot print the first valid date from inside the sandbox, so the Skeptic needs to confirm it from the weights. The 2007 return (+7.4%) suggests the sleeve was live in 2007.
2. D uses option (b), and this choice was informed by the data profile: TOTALSA, HOUST and INDPRO, each with its 3-month mean vs 12-month mean on release-aligned points. Entry needs D≥2 of 3 and exit is D=0. Rail and truck are not in the signal. They were supposed to be reported as confirmation, but that report is not implemented.
Other details:
- The claims guard is ICSA 4-week mean < 1.15 × 52-week min.
- Decisions are made on the last trading day of each week.
- The hold is 63 trading days, restarts on a new signal and is checked weekly, so it can overrun by up to 4 days.
- The sleeve is 50 SPY / 25 IWM / 15 EEM / 10 HYG, scaled to 12% vol over 63 days, with gross capped at 1.0. The rest of the risk allocation goes to SHY. The spec only said "100% SHY outside entries", so putting the in-market remainder in SHY is my interpretation.
- Before HYG has 63 days of history, the sleeve falls back to SHY.
- Trial 2 is {"scare_pct": 0.10}. Trial 3 is {"hold": 42}.
Results:
| | Sharpe | CAGR | Vol | MaxDD | corr SPY | corr 60/40 | Turnover |
|---|---|---|---|---|---|---|---|
| Train | 0.63 | 4.7% | 6.4% | −11.8% | 0.33 | 0.33 | 3.2x/yr |
| Validation | 0.47 | 3.3% | 5.1% | −9.3% | 0.35 | 0.35 | 2.7x/yr |
- Average weights: SHY 79%, SPY 9.6%, IWM 4.8%, EEM 2.9%, HYG 1.9%.
- Annual returns: 2008 +6.6% (the guard appears to have kept it out of the crash), 2015 −3.6%, 2018 −1.4%, 2019 +19%, 2021 −0.7%.
- Part of the return is SHY carry, so the Sharpe is not purely the signal.
Still needed for the Skeptic (none of it is produced by the engine):
- the entry-by-entry table for Q3 2007–Q1 2009
- episode count and win rate
- time in market and SPY correlation while in market
- correlation to TrendFollowing
- the 80/20 blend vs SixtyForty
The drop rule (in-market SPY correlation near 1 and win rate below 60%) cannot be applied until those are produced.
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
| 100.0 | |
|---|---|
| 0 | 0.53 |
Median Sharpe 0.53, range 0.53–0.53; 100% 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.40
- CAGR
- 3.1%
- Max drawdown
- -16.8%
- Period
- 2012–2021
Settings chosen: 2012: 126/0.2, 2013: 126/0.2, 2014: 126/0.2, 2015: 126/0.2, 2016: 126/0.1, 2017: 126/0.1, 2018: 126/0.1, 2019: 126/0.1, 2020: 126/0.1, 2021: 126/0.1
Trading costs
| Costs | CAGR | Sharpe |
|---|---|---|
| 0x costs | 3.4% | 0.49 |
| 1x costs | 3.3% | 0.47 |
| 2x costs | 3.2% | 0.46 |
| 3x costs | 3.1% | 0.44 |
Turnover 3.0× a year. Validation period.
Crises
| Episode | Strategy | SPY | 60/40 |
|---|---|---|---|
| 2008 financial crisis | 3.0% | -46.4% | -28.2% |
| 2011 euro crisis / US downgrade | -2.2% | -17.8% | -7.4% |
| 2013 taper tantrum | -0.3% | -5.4% | -5.1% |
| 2015–16 China / oil crash | -13.2% | -11.1% | -4.4% |
| Q4 2018 selloff | -8.4% | -18.9% | -10.3% |
| COVID crash | 2.1% | -33.4% | -18.6% |
Correlation to baselines: BuyHold 0.35, SixtyForty 0.35, TrendFollowing 0.14, TrendFollowing -0.00, AssetRotation 0.17, RiskParity 0.24, MacroRegime 0.44. Rolling one-year Sharpe: median 0.69, worst -1.69, negative 27% of the time.
Skeptic's verdict Needs work
Key evidence. Validation Sharpe 0.47, but 2019 = +19% and 2016/17/18/20/21 = +0.9/+0.3/−1.4/+3.0/−0.7%. Walk-forward OOS Sharpe 0.40, max drawdown −16.8%. 2015–16: −13.2% vs SPY −11.1%; Q4 2018: −8.4%. Unfiltered dip-buy control: train Sharpe 0.53 vs 0.63, same max drawdown. Grid 0.35–0.74 (plateau). Sharpe 0.44 at 3x costs. To move up to promising: (a) an episode table with entry date, signal state and 63- and 126-day sleeve return vs SPY, with win rate at least 60% across at least 8 episodes; (b) the unfiltered dip-buy control run on validation and walk-forward, where the D/claims filter must add at least 0.15 Sharpe out of sample; (c) validation excluding 2019, which must stay above SHY; (d) the pre-registered rules (5 indicators including rail/truck, D≥3) run once as declared; (e) the 80% 60/40 + 20% sleeve blend vs 60/40 alone, on Sharpe and max drawdown; (f) ALFRED vintages for INDPRO/HOUST/TOTALSA before any paper trading. No more parameter searching: the family already has about 15 trials.
Positives: the parameter grid is a plateau, not a spike (train Sharpe 0.35–0.74, all 9 points above 0.3, spike ratio 1.17). Costs barely matter (3x costs still gives Sharpe 0.44). Walk-forward OOS is positive (Sharpe 0.40), and the selection settled on stable choices (hold 126, scare percentile 10–20%). Problems: (1) One year carries validation. 2019 returned +19%. The other five validation years sum to about +2% total, below SHY, so excess return is negative without 2019. (2) It loses in the scares it is designed to fade: −13.2% in 2015–16 (worse than SPY's −11.1%) and −8.4% in Q4 2018. The walk-forward drawdown is −16.8% (Feb 2016), with 1,088 days underwater, skew −0.41 and kurtosis 13. (3) The hard-data filter adds little. With the filter mostly switched off (d_entry=0, no claims guard), train Sharpe is 0.53 vs 0.63, with the same −11.8% max drawdown. So most of the result is vol-targeted dip buying, and the 'fundamentals' part is worth about 0.1 Sharpe on revised data. (4) Overall correlation to SPY is 0.35 while only about 20% is invested on average. That implies correlation near 1 while in the market, which is the drop condition the hypothesis set for itself if win rate is also below 60%. Correlation to MacroRegime is 0.44. (5) The code differs from the pre-registered rules: 3 hard-data indicators instead of 5, entry at D≥2 instead of D≥3, DBC dropped from M, exit at D≤0 instead of D≤1. These changes were not tested as declared trials.
Code
The exact strategy file the Engineer wrote, after passing the sandbox and the look-ahead detector.
fear_vs_fundamentals.py
from amberlab.strategies.base import Strategy, month_ends, rebalance_on, trailing_return, realized_vol
import numpy as np, pandas as pd
def _releases(s):
s = s.dropna()
return s[s.ne(s.shift())]
class FearVsFundamentals(Strategy):
family = "macro_divergence"
description = ("Contrarian growth-scare sleeve, decided weekly (last trading day of each week). Market growth "
"M = 63d return of equal-weight SPY/IWM/EEM minus 63d return of equal-weight TLT/GLD (DBC dropped "
"from M for coverage). Scare = M <= trailing 20th percentile (rolling window 756d, min 504d). "
"Hard-data diffusion D = count of TOTALSA, HOUST, INDPRO whose release-aligned 3-month mean > "
"12-month mean (0-3). Claims guard = ICSA 4wk mean < 1.15 x 52wk min. Entry: Scare & D>=2 & guard -> "
"50% SPY/25% IWM/15% EEM/10% HYG scaled to 12% vol (63d), gross <= 1, remainder SHY. Hold 63 trading "
"days (checked weekly), restart on new signal, exit early if guard breaks or D=0. Otherwise 100% SHY.")
hypothesis = ("Markets often price recessions the physical economy does not deliver; mechanical de-risking "
"(CTAs, vol-targeters, risk parity) sells cyclicals into growth scares. If timely hard data and claims "
"still expand, the scare is likely sentiment and cyclicals recover over 1-3 months. Fails when a scare "
"becomes a recession before lagged hard data turns (early 2008), on exogenous shocks that trip the "
"claims guard (2020), and has few episodes so wide error bars.")
macro_ids = ("TOTALSA", "HOUST", "INDPRO", "ICSA")
@classmethod
def default_params(cls):
return {"scare_pct": 0.20, "hold": 63, "ret_days": 63, "pct_window": 756, "pct_min": 504,
"d_entry": 2, "claims_mult": 1.15, "target_vol": 0.12, "vol_days": 63}
def target_weights(self, data):
p = self.params
idx = data.dates
close = data.close
# Market-implied growth
r = trailing_return(close[["SPY", "IWM", "EEM", "TLT", "GLD"]], p["ret_days"])
M = r[["SPY", "IWM", "EEM"]].mean(axis=1, skipna=False) - r[["TLT", "GLD"]].mean(axis=1, skipna=False)
q = M.rolling(p["pct_window"], min_periods=p["pct_min"]).quantile(p["scare_pct"])
scare = (M <= q) & q.notna()
# Hard-data diffusion on release-aligned monthly points
D = pd.Series(0.0, index=idx)
dvalid = pd.Series(True, index=idx)
for sid in ("TOTALSA", "HOUST", "INDPRO"):
s = _releases(data.macro(sid))
up = (s.rolling(3, min_periods=3).mean() > s.rolling(12, min_periods=12).mean())
up = up.where(s.rolling(12, min_periods=12).mean().notna())
up = up.reindex(idx).ffill()
dvalid &= up.notna()
D = D + up.fillna(0).astype(float)
icsa = _releases(data.macro("ICSA"))
cratio = (icsa.rolling(4, min_periods=4).mean() / icsa.rolling(52, min_periods=52).min()).reindex(idx).ffill()
guard = cratio < p["claims_mult"]
valid = q.notna() & dvalid & cratio.notna()
# Weekly decision days
s_idx = idx.to_series()
dec_days = pd.DatetimeIndex(s_idx.groupby(idx.to_period("W-FRI")).max().values)
isdec = pd.Series(idx.isin(dec_days), index=idx)
entry = scare & (D >= p["d_entry"]) & guard & valid & isdec
exit_ = ((~guard) | (D <= 0)) & valid & isdec
pos = pd.Series(np.arange(len(idx), dtype=float), index=idx)
last_entry = pos.where(entry).ffill()
last_exit = pos.where(exit_).ffill()
in_mkt = last_entry.notna() & ((pos - last_entry) < p["hold"]) & (last_exit.isna() | (last_exit < last_entry))
# Vol-targeted cyclical sleeve
base = pd.Series({"SPY": 0.50, "IWM": 0.25, "EEM": 0.15, "HYG": 0.10})
rets = close[list(base.index)].pct_change()
sleeve_ret = (rets * base).sum(axis=1, min_count=len(base))
svol = sleeve_ret.rolling(p["vol_days"], min_periods=p["vol_days"]).std() * np.sqrt(252)
scale = (p["target_vol"] / svol).clip(upper=1.0)
w = pd.DataFrame(0.0, index=idx, columns=["SPY", "IWM", "EEM", "HYG", "SHY"])
on = in_mkt & scale.notna()
for sym, bw in base.items():
w.loc[on, sym] = bw * scale[on]
w["SHY"] = 1.0 - w[["SPY", "IWM", "EEM", "HYG"]].sum(axis=1)
w[~valid] = np.nan
return rebalance_on(w, dec_days).reindex(idx)