Amber Quant Lab research
Overview / Strategies / labor turn duration
Research candidate · family rates_macro

Labor-turn duration timing: buy Treasuries when the labor market cracks, because the Fed cuts more than markets price

Module labor_turn_duration · from the research cycle of Oct 16, 2026

Rejected  The Skeptic showed the result isn't robust, or that a simpler alternative does as well.

Validation Sharpe0.38train 0.33
Validation CAGR3.4%train 3.4%
Max drawdown-16.1%train -20.4%
Correlation to 60/40-0.21validation
Deflated Sharpe82%only 1 trial counted, so not yet discounted
Walk-forward Sharpe0.26out of sample 2012–2021

Performance

Backtest after costs. Train is where the idea was developed; validation is where it was judged.

Growth of $1labor turn duration60/40SPY
trainvalidation$0.5$1$1.5$2.5$42008201020122014201620182020labor turn durationlabor turn dura… $1.6460/4060/40 $3.49SPYSPY $4.51
Drawdown from peaklabor turn duration60/40
-30%-20%-10%0%labor turn duration-20% (Mar 2010)60/402008201020122014201620182020
Calendar-year returnslabor turn duration60/40
-20%0%20%2007 labor turn duration: +9.2%2007 60/40: +7.5%’072008 labor turn duration: +21.2%2008 60/40: -17.9%’082009 labor turn duration: -16.6%2009 60/40: +12.8%’092010 labor turn duration: +8.3%2010 60/40: +13.5%’102011 labor turn duration: +14.7%2011 60/40: +7.7%’112012 labor turn duration: +2.1%2012 60/40: +11.2%’122013 labor turn duration: -6.7%2013 60/40: +15.6%’132014 labor turn duration: +5.6%2014 60/40: +11.8%’142015 labor turn duration: -2.4%2015 60/40: +1.7%’152016 labor turn duration: -0.1%2016 60/40: +7.7%’162017 labor turn duration: +4.3%2017 60/40: +13.7%’172018 labor turn duration: +0.7%2018 60/40: -2.0%’182019 labor turn duration: +12.1%2019 60/40: +22.0%’192020 labor turn duration: +12.2%2020 60/40: +16.4%’202021 labor turn duration: -7.6%2021 60/40: +15.0%’21
MetricTrain 2007–15Validation 2016–21
CAGR3.4%3.4%
Volatility9.1%6.9%
Sharpe0.330.38
Sortino0.460.55
Max drawdown-20.4%-16.1%
Calmar0.160.21
Worst month-9.6%-4.5%
Months up53%54%
Turnover / yr13.2×9.6×
Avg gross exposure1.05×1.05×
Correlation to SPY-0.45-0.38
Correlation to 60/40-0.27-0.21
Beta to SPY-0.19-0.15

Average holdings

SHYUS Treasuries 1–3y35.9%
IEFUS Treasuries 7–10y34.6%
TLTUS Treasuries 20y+34.6%

The idea

Hypothesis

Labor-market turns are non-linear. Once initial claims rise clearly off their cycle low, or unemployment rises 0.3pp off its 12-month low, the Fed's reaction function makes a cutting cycle very likely. Market rate expectations update too slowly because they anchor on Fed guidance and consensus forecasts. Duration therefore earns excess returns after labor turns. On the other side, when claims sit at cycle lows and the 2-year yield is rising faster than bills (markets pricing hikes), duration underperforms cash. Claims are published weekly with a 6-day lag, which makes them the fastest official labor data, so the signal arrives months before payroll revisions confirm a turn. Diversification: the long-duration states happen exactly when equities are under pressure, so the sleeve should be negatively correlated with SPY. Unlike 60/40, it is mostly in cash during hiking cycles.

Who is on the other side

Bond investors and forecasters anchored to the Fed's communicated path and the consensus rate forecast (the documented underreaction in Cieslak 2018). Also leveraged duration holders and banks that cut duration as rate-hike expectations build late in the cycle and are slow to add it back.

Inspiration

Cieslak (2018, RFS), 'Short-Rate Expectations and Unexpected Returns in Treasury Bonds'. Forecasters and markets have consistently underestimated how far the Fed cuts once unemployment starts rising, so bond excess returns are predictable from real activity. Related: Piazzesi & Swanson (2008) find that employment growth predicts excess returns on fed funds futures, and the Sahm rule identifies labor-market turning points. This is a carry/macro trade in rates, not a trend trade.

Expected Sharpe (before testing)

0.45

How it could fail

(1) Inflation shocks where labor weakens but the Fed hikes or holds: the long-duration state loses. Our sample has almost none, so this risk is under-tested by construction and must go in the shutdown rules. (2) Zero lower bound (Dec 2008–2015 and 2020–21): the front end is pinned, P carries little information and long yields can still rise (2009, 2013 taper). (3) Few independent episodes: real labor turns in sample are about 2007–08 and 2020, plus false alarms in 2011, 2016 and 2019. Statistical power is low, and the Skeptic should require a positive result in each subperiod rather than trusting the aggregate Sharpe. (4) Claims are distorted by seasonal-adjustment problems, hurricanes and strikes. The 4-week average and the 10% threshold reduce this but don't remove it. (5) Revisions: today's claims vintage is smoother than what was seen live.

Data & rules

Data

ICSA (weekly, lag 6d, revised: seasonal factors are revised every year; the 2020 spike is real). UNRATE (monthly, lag 38d, revised slightly). DGS2, DTB3, T10Y3M (daily, lag 1d, final). Prices: IEF, TLT, SHY. All series cover 2007–2021 in full, with history before 2007 for warm-up.

Rules

Rebalance weekly, on the first trading day after the claims release becomes usable (the engine applies the 6-day lag).
Labor signal L = 1 if either holds:

- ICSA 4-week average > 1.10 × its 52-week minimum, or

- UNRATE 3-month average − its 12-month minimum ≥ 0.30pp.
Otherwise L = 0.
Policy-path signal P = 1 if (DGS2 − DTB3) has fallen over the last 63 trading days, meaning the market is starting to price cuts. Otherwise 0.
Late-cycle hike state H = 1 if ICSA 4-week average < 1.03 × its 52-week minimum AND (DGS2 − DTB3) has risen over 63 days.
Duration basket = 50% IEF / 50% TLT. Basket weight:

- 0.0 if H = 1

- 1.25 if L = 1 and P = 1

- 0.75 if exactly one of L or P is 1

- 0.35 otherwise
The rest goes to SHY. Max gross 1.25; financing cost is charged on the excess.
Pre-declared trial budget (3 trials):
1. Base case.
2. Claims threshold 1.15 instead of 1.10.
3. Base case without P (labor signal only), to check whether the policy-path term adds anything.
Benchmark that the timing must beat: a constant 0.6 duration basket + 0.4 SHY, i.e. similar average exposure. 2007–2021 was a 15-year bond bull market, so beating SHY proves nothing. The test is whether timing adds Sharpe over static duration. Also report correlation to SPY and to 60/40, and returns during SPY's worst 5% of months.

Data review go

COVERAGE AND LAGS: fine. ICSA (from 1967, weekly, 6-day lag), UNRATE (from 1948, 38-day lag), DGS2 (from 1976), DTB3 and T10Y3M (daily, final). The 52-week and 12-month warm-ups are available well before 2007. IEF, TLT and SHY all predate 2005.
PREDICTIVE POWER FOR DURATION (weak, unstable):

- ICSA change_3m vs IEF: rank IC +0.05 (halves +0.14/−0.07)

- ICSA zscore_1y vs IEF: +0.04 (+0.09/−0.11)

- UNRATE change_3m vs IEF: +0.08 (−0.15/+0.26)

- DGS2 change_3m vs IEF: +0.04 (−0.08/+0.26)
The sign is the hypothesised one on average, but every series flips sign between halves and no t-stat reaches 1. Claims mainly predict equities, not bonds: ICSA change_3m vs SPY −0.32, vs EEM −0.19. A linear monthly IC is a poor test of a threshold rule that fires in about 2 real episodes plus about 3 false alarms, so this is a warning, not a kill.
STRUCTURAL ISSUES:

(1) ZLB. DGS2−DTB3 was compressed for most of Dec 2008–2015 and 2020–21, so P is close to a coin flip in those years (about 60% of the sample).

(2) 2020 artifact, a predictable validation loss. The 52-week ICSA minimum and the 12-month UNRATE minimum stay at pre-COVID levels until about Mar 2021. L therefore stays 1 from Mar 2020 into early 2021, while 10-year yields rose from about 0.5% to about 1.7% (Aug 2020–Mar 2021). With P mixed, the rule will hold 0.75–1.25 duration through that selloff.

(3) ICSA revisions: seasonal factors are re-estimated every year. The 2020 level spike is real.

Guidance to the engineer

Go with the 3 declared trials and the static benchmark (0.6 duration + 0.4 SHY).
Inputs:

- ICSA: 4-week mean and 52-week min on the release-aligned series (6-day lag).

- UNRATE: 3-month mean minus 12-month min, available 38 days after the reference month. Do not let the weekly rebalance read an UNRATE month before its release date.

- P and H: (DGS2 − DTB3) today minus 63 trading days ago, 1-day lag on both.

- Execution: rebalance on the first trading day on or after each claims release date plus lag.
The Skeptic will need these reports:

(a) Trading days and returns in each state: H, L&P, one of L/P, neither.

(b) Subperiods 2007–09, 2010–15 (ZLB), 2016–19, 2020–21, each against the static benchmark.

(c) Mar 2020–Jun 2021 on its own (expected loss from the stale-minimum artifact).

(d) Correlation to SPY and 60/40, and returns in SPY's worst 5% of months.
Do not add a fix for the 2020 artifact (such as a cap on how long a turn can last) after seeing results. If it is wanted, it must be declared as a new trial before running.

Implementation · implemented

Built as specified, evaluated once with default parameters (trial 1 of the 3 declared). No fixes were needed.

Implementation:

- ICSA and UNRATE statistics are computed on release-aligned points: the first trading day each new lagged value appears. ICSA uses the 4-week mean vs the 52-week min. UNRATE uses the 3-month mean minus the 12-month min of that 3-month mean (Sahm style), and the threshold is 0.30pp.

- P is the 63-trading-day change in DGS2−DTB3 being below 0. H is ICSA ratio < 1.03 AND that spread change > 0.

- Duration basket weight is 0 if H, 1.25 if L&P, 0.75 if one of L/P, 0.35 otherwise. The basket is split 50/50 IEF/TLT, and SHY = max(0, 1−b).

- Rebalances on each claims-release day.

- Trial 3 is available as {"use_policy": false}, which sets P=0. Under it the tiers become 0.75 when L=1 and 0.35 otherwise, and H still applies. Trial 2 is {"claims_mult": 1.15}.

- Caveat: if two consecutive weekly claims prints are exactly equal, release detection misses one point. This is rare.

Results:
| | Sharpe | CAGR | Vol | MaxDD | corr SPY | corr 60/40 |
|---|---|---|---|---|---|---|
| Train | 0.33 | 3.4% | 9.1% | −20.4% | −0.45 | −0.27 |
| Validation | 0.38 | 3.4% | 6.9% | −16.1% | −0.38 | −0.21 |

- Turnover is 13x/yr in train and 9.6x/yr in validation.

- Average duration weight is 0.69, slightly above the 0.6 static benchmark.

- Annual returns: 2008 +21%, 2009 −17%, 2013 −7%, 2019 +12%, 2020 +12%, 2021 −7.6%. The 2021 loss is consistent with the predicted stale-minimum artifact.

Not yet done:

- I have not run the static 0.6 IEF/TLT + 0.4 SHY benchmark, because it would cost a trial. Until it is run we cannot say whether the timing adds anything beyond the bond bull market. The Skeptic should run it.

- The engine output does not include the per-state, subperiod, or worst-5%-SPY-month reports.

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

hike_multw_bothw_onew_noneSharpe
0.00.60.60.60.61

Median Sharpe 0.61, range 0.61–0.61; 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.26
CAGR
2.0%
Max drawdown
-16.1%
Period
2012–2021

Settings chosen: 2012: 1.05/False, 2013: 1.05/False, 2014: 1.05/True, 2015: 1.05/False, 2016: 1.05/False, 2017: 1.05/False, 2018: 1.05/False, 2019: 1.1/False, 2020: 1.1/True, 2021: 1.1/True

Trading costs

CostsCAGRSharpe
0x costs3.7%0.42
1x costs3.4%0.38
2x costs3.1%0.34
3x costs2.8%0.30

Turnover 11.8× a year. Validation period.

Crises

EpisodeStrategySPY60/40
2008 financial crisis3.9%-46.4%-28.2%
2011 euro crisis / US downgrade14.9%-17.8%-7.4%
2013 taper tantrum-2.2%-5.4%-5.1%
2015–16 China / oil crash3.0%-11.1%-4.4%
Q4 2018 selloff5.8%-18.9%-10.3%
COVID crash9.0%-33.4%-18.6%

Correlation to baselines: BuyHold -0.43, SixtyForty -0.25, TrendFollowing 0.46, TrendFollowing 0.40, AssetRotation 0.08, RiskParity 0.27, MacroRegime -0.35. Rolling one-year Sharpe: median 0.38, worst -2.02, negative 35% of the time.

Skeptic's verdict Rejected

Key evidence. Train Sharpe: constant 0.6 duration basket 0.61 vs timing 0.33–0.59 (6/6 below). The P term costs 0.11–0.12 Sharpe at every claims setting. Walk-forward OOS Sharpe 0.26, max drawdown −16.1%. Validation Sharpe 0.38 at 1x costs, 0.30 at 3x. Turnover 11.8x/yr. Correlation to SPY −0.43, to trend +0.46.

The hypothesis set its own test: the timing has to beat a constant basket of 0.6 duration (IEF/TLT) plus 0.4 SHY. It fails that test. On train, the constant basket scores Sharpe 0.61. All 6 timing variants score lower (0.33–0.59), and the default scores 0.33. The policy-path term P, the part meant to capture Cieslak-style underreaction, lowers Sharpe at every claims threshold, by 0.11–0.12. Walk-forward out of sample (2012–2021) gives Sharpe 0.26, with a −16% drawdown that bottoms in March 2021 and 722 days underwater. Validation Sharpe is 0.38 and drops to 0.30 at 3x costs. Turnover of about 12x a year comes from flipping the weight between 0 and 1.25. The crisis protection is real (+3.9% in 2008, +14.9% in 2011, +9.0% in COVID, −0.43 correlation to SPY). But that protection comes from owning duration in a bond bull market, and the constant basket gets it too. Correlation to long-only trend is 0.46. Code bug: _releases drops a monthly UNRATE print when it equals the previous one. That happens often, so the '3-month' and '12-month' windows quietly cover longer spans. It does not leak future data, but the signal is not the one specified. Results by year depend on very few labor turns: +21% in 2008, −16.6% in 2009, −7.6% in 2021. 2021 is the failure mode the strategy itself listed: a stale 52-week minimum keeps L=1 while bonds sell off.

Code

The exact strategy file the Engineer wrote, after passing the sandbox and the look-ahead detector.

labor_turn_duration.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):
    """Release-aligned points of a forward-filled macro series: keep the first day each new value appears."""
    s = s.dropna()
    return s[s.ne(s.shift())]


class LaborTurnDuration(Strategy):
    family = "rates_macro"
    description = ("Weekly duration timing. Labor signal L=1 if ICSA 4-week mean > claims_mult x its 52-week min "
                   "(release-aligned, 6d lag) or UNRATE 3-month mean minus its 12-month min >= 0.30pp (38d lag). "
                   "Policy signal P=1 if DGS2-DTB3 fell over 63 trading days. Hike state H=1 if ICSA 4wk mean < "
                   "1.03 x 52wk min and DGS2-DTB3 rose over 63 days. Duration basket (50% IEF/50% TLT) weight: 0 if H, "
                   "1.25 if L&P, 0.75 if one of L/P, 0.35 otherwise; remainder (if positive) in SHY. Rebalance on each "
                   "new claims release day. use_policy=False sets P=0 everywhere (trial 3: labor only).")
    hypothesis = ("Cieslak (2018): markets and forecasters underestimate how far the Fed cuts once the labor market "
                  "turns, so duration earns excess returns after claims/unemployment rise off cycle lows. Counterparty: "
                  "investors anchored on Fed guidance and late-cycle duration sellers. Fails in inflation shocks where "
                  "the Fed hikes into labor weakness, at the ZLB (P uninformative), and in 2020-21 when stale 52-week "
                  "minima keep L=1 during a bond selloff.")
    macro_ids = ("ICSA", "UNRATE", "DGS2", "DTB3")

    @classmethod
    def default_params(cls):
        return {"claims_mult": 1.10, "unrate_gap": 0.30, "hike_mult": 1.03, "policy_days": 63,
                "use_policy": True, "w_both": 1.25, "w_one": 0.75, "w_none": 0.35}

    def target_weights(self, data):
        p = self.params
        idx = data.dates

        icsa = _releases(data.macro("ICSA"))
        c4 = icsa.rolling(4, min_periods=4).mean()
        cmin = icsa.rolling(52, min_periods=52).min()
        cratio = (c4 / cmin).reindex(idx).ffill()

        ur = _releases(data.macro("UNRATE"))
        u3 = ur.rolling(3, min_periods=3).mean()
        ugap = (u3 - u3.rolling(12, min_periods=12).min()).reindex(idx).ffill()

        spread = (data.macro("DGS2") - data.macro("DTB3")).reindex(idx)
        dspread = spread - spread.shift(p["policy_days"])

        valid = cratio.notna() & ugap.notna() & dspread.notna()

        L = ((cratio > p["claims_mult"]) | (ugap >= p["unrate_gap"] - 1e-9)).astype(float)
        P = (dspread < 0).astype(float)
        if not p["use_policy"]:
            P = P * 0.0
        H = ((cratio < p["hike_mult"]) & (dspread > 0)).astype(float)

        n = L + P
        b = pd.Series(np.where(n >= 2, p["w_both"], np.where(n == 1, p["w_one"], p["w_none"])), index=idx)
        b[H >= 1] = 0.0

        w = pd.DataFrame({"IEF": b * 0.5, "TLT": b * 0.5, "SHY": (1.0 - b).clip(lower=0.0)}, index=idx)
        w[~valid] = np.nan

        rel_days = icsa.index.intersection(idx)
        return rebalance_on(w, rel_days).reindex(idx)