|
| 1 | +"""ETF sleeves: the strategies that held up in both 2006-2014 and 2015-2026. |
| 2 | +
|
| 3 | +Each sleeve turns ETF closes into target weights decided at the close of day t |
| 4 | +(traded at the next open). They are the same rules as the research strategy |
| 5 | +zoo (``qtrade.research.strategies``), rewritten to return positions instead of |
| 6 | +returns so they can be traded; ``tests/test_sleeves.py`` checks the two agree. |
| 7 | +
|
| 8 | +Sleeves (2015-2026 net Sharpe in the strategy zoo): |
| 9 | +
|
| 10 | + spy_vol_managed SPY scaled by 1 / GJR-GARCH volatility forecast 0.84 |
| 11 | + cross_asset_mom top 3 of 8 asset classes by 6-month return 0.78 |
| 12 | + spy_ma200 SPY above its 200-day average 0.79 |
| 13 | + risk_parity 8 asset classes weighted by 1 / volatility 0.70 |
| 14 | + spy_tsmom_12m SPY when its 12-month return is positive 0.69 |
| 15 | + broad_trend_12m 12-month trend over ~30 ETFs, 10% vol each 0.54 |
| 16 | + spy_turn_of_month SPY over the turn of the month 0.48 |
| 17 | + multi_asset_trend 12-month trend over 8 asset classes 0.42 |
| 18 | +
|
| 19 | +They are combined with weights 1 / trailing volatility of each sleeve's own |
| 20 | +returns (walk-forward), then scaled to a target volatility. |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import numpy as np |
| 25 | +import pandas as pd |
| 26 | + |
| 27 | +ASSETS = ["SPY", "EFA", "EEM", "TLT", "IEF", "GLD", "DBC", "VNQ"] |
| 28 | +BROAD = ["USO", "UNG", "DBA", "DBB", "SLV", "GLD", "UUP", "FXE", "FXY", "FXA", "SHY", "IEF", "TLT", "TIP", |
| 29 | + "LQD", "HYG", "EWJ", "EWG", "EWU", "FXI", "EWZ", "EWC", "EWA", "EWY", "EWT", "SPY", "QQQ", "IWM", |
| 30 | + "VNQ", "EEM"] |
| 31 | +ETF_UNIVERSE = list(dict.fromkeys(ASSETS + BROAD)) |
| 32 | +ETF_COST = 1e-4 |
| 33 | +DEFAULT_SLEEVES = ["spy_vol_managed", "cross_asset_mom", "spy_ma200", "risk_parity", "spy_tsmom_12m", |
| 34 | + "broad_trend_12m", "spy_turn_of_month", "multi_asset_trend"] |
| 35 | + |
| 36 | + |
| 37 | +def _month_ends(idx: pd.DatetimeIndex) -> pd.Series: |
| 38 | + s = pd.Series(idx, index=idx) |
| 39 | + return s.groupby([idx.year, idx.month]).transform("max") == s |
| 40 | + |
| 41 | + |
| 42 | +def _spy(close: pd.DataFrame, w: pd.Series) -> pd.DataFrame: |
| 43 | + return pd.DataFrame({"SPY": w}, index=close.index) |
| 44 | + |
| 45 | + |
| 46 | +def spy_ma200(close: pd.DataFrame) -> pd.DataFrame: |
| 47 | + spy = close["SPY"] |
| 48 | + return _spy(close, (spy > spy.rolling(200).mean()).astype(float)) |
| 49 | + |
| 50 | + |
| 51 | +def spy_tsmom_12m(close: pd.DataFrame) -> pd.DataFrame: |
| 52 | + spy = close["SPY"] |
| 53 | + return _spy(close, (spy / spy.shift(252) - 1 > 0).astype(float)) |
| 54 | + |
| 55 | + |
| 56 | +def spy_vol_managed(close: pd.DataFrame, fit_end: str | None = None) -> pd.DataFrame: |
| 57 | + """Leverage = median variance / forecast variance for the next day, square-rooted, capped at 2. |
| 58 | +
|
| 59 | + ``fit_end``: estimate the GARCH parameters on returns before this date only |
| 60 | + (the research used 2015-01-01); by default all but the last year.""" |
| 61 | + from ..research.garch import fit_garch, garch_filter |
| 62 | + |
| 63 | + r = close["SPY"].pct_change(fill_method=None) |
| 64 | + x = r.to_numpy() |
| 65 | + end = pd.Timestamp(fit_end) if fit_end else r.index[-1] - pd.Timedelta(days=365) |
| 66 | + train = np.asarray(r.index < end) & np.isfinite(x) |
| 67 | + p = fit_garch(np.where(train, x, np.nan)[: np.flatnonzero(train)[-1] + 1], gjr=True) |
| 68 | + # one extra step: the variance forecast for the day after each close |
| 69 | + s2f = garch_filter(np.append(x, np.nan), p["w"], p["a"], p["b"], p["g"], p["s0"]) |
| 70 | + lev = np.clip(np.sqrt(np.nanmedian(s2f[: len(x)][train])) / np.sqrt(s2f[1:]), 0, 2) |
| 71 | + return _spy(close, pd.Series(lev, index=r.index)) |
| 72 | + |
| 73 | + |
| 74 | +def spy_turn_of_month(close: pd.DataFrame) -> pd.DataFrame: |
| 75 | + """Hold SPY over the last trading day of a month and the first three of the next. |
| 76 | +
|
| 77 | + The calendar is known ahead, so the weight at close t is the holding for day |
| 78 | + t+2 (the convention every sleeve shares: decided at t, entered at the open of |
| 79 | + t+1, earning day t+2's close-to-close return in the backtest).""" |
| 80 | + from pandas.tseries.holiday import USFederalHolidayCalendar |
| 81 | + from pandas.tseries.offsets import CustomBusinessDay |
| 82 | + |
| 83 | + cbd = CustomBusinessDay(calendar=USFederalHolidayCalendar()) |
| 84 | + idx = close.index |
| 85 | + ext = idx.append(pd.DatetimeIndex([idx[-1] + k * cbd for k in (1, 2, 3)])) |
| 86 | + month = np.asarray(ext.month) |
| 87 | + is_last = np.append(month[1:] != month[:-1], False) |
| 88 | + nth = pd.Series(1, index=ext).groupby([ext.year, ext.month]).cumsum().to_numpy() |
| 89 | + tom = np.append(False, is_last[:-1]) | (nth <= 3) |
| 90 | + hold = pd.Series(tom.astype(float), index=ext).shift(-2).reindex(idx) |
| 91 | + return _spy(close, hold) |
| 92 | + |
| 93 | + |
| 94 | +def risk_parity(close: pd.DataFrame) -> pd.DataFrame: |
| 95 | + c = close[ASSETS] |
| 96 | + vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252) |
| 97 | + return (1 / vol).div((1 / vol).sum(axis=1), axis=0) |
| 98 | + |
| 99 | + |
| 100 | +def cross_asset_mom(close: pd.DataFrame) -> pd.DataFrame: |
| 101 | + c = close[ASSETS] |
| 102 | + top = (c / c.shift(126) - 1).rank(axis=1, ascending=False) <= 3 |
| 103 | + return (top.astype(float) / 3).where(_month_ends(c.index)).ffill() |
| 104 | + |
| 105 | + |
| 106 | +def multi_asset_trend(close: pd.DataFrame) -> pd.DataFrame: |
| 107 | + c = close[ASSETS] |
| 108 | + vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252) |
| 109 | + return (np.sign(c / c.shift(252) - 1) * 0.10 / vol).clip(-2, 2) / len(ASSETS) |
| 110 | + |
| 111 | + |
| 112 | +def broad_trend_12m(close: pd.DataFrame) -> pd.DataFrame: |
| 113 | + c = close[[t for t in BROAD if t in close.columns]] |
| 114 | + vol = c.pct_change(fill_method=None).rolling(63, min_periods=40).std() * np.sqrt(252) |
| 115 | + sign = np.sign(c / c.shift(252) - 1) |
| 116 | + return (sign * 0.10 / vol).clip(-2, 2).div(c.notna().sum(axis=1).clip(lower=1), axis=0) |
| 117 | + |
| 118 | + |
| 119 | +SLEEVES = {f.__name__: f for f in (spy_vol_managed, cross_asset_mom, spy_ma200, risk_parity, spy_tsmom_12m, |
| 120 | + broad_trend_12m, spy_turn_of_month, multi_asset_trend)} |
| 121 | + |
| 122 | + |
| 123 | +def sleeve_returns(w: pd.DataFrame, close: pd.DataFrame, cost: float = ETF_COST) -> pd.Series: |
| 124 | + """Daily net return of holding ``w`` (decided at the close of t-2) over day t, as in the research.""" |
| 125 | + r = close[w.columns].pct_change(fill_method=None).fillna(0) |
| 126 | + p = w.shift(2) |
| 127 | + return ((p * r).sum(axis=1) - p.diff().abs().sum(axis=1).fillna(0) * cost).fillna(0.0) |
| 128 | + |
| 129 | + |
| 130 | +def combine(close: pd.DataFrame, sleeves: list[str] | None = None, target_vol: float = 0.10, |
| 131 | + vol_window: int = 756, max_gross: float = 1.5, fit_end: str | None = None) -> dict: |
| 132 | + """Sleeves weighted by 1 / trailing volatility of their own returns, scaled to ``target_vol`` |
| 133 | + with the trailing year's realised volatility of the blend. Everything uses data up to the |
| 134 | + close the weights are decided at.""" |
| 135 | + names = sleeves or DEFAULT_SLEEVES |
| 136 | + W = {} |
| 137 | + for n in names: |
| 138 | + W[n] = SLEEVES[n](close, fit_end) if n == "spy_vol_managed" else SLEEVES[n](close) |
| 139 | + R = pd.DataFrame({n: sleeve_returns(w, close) for n, w in W.items()}) |
| 140 | + vol = R.rolling(vol_window, min_periods=126).std() |
| 141 | + sw = (1 / vol).div((1 / vol).sum(axis=1), axis=0) # sleeve weights at each close |
| 142 | + blend = (sw.shift(2) * R).sum(axis=1) # the blend as it would have been held |
| 143 | + real = blend.rolling(252, min_periods=126).std() * np.sqrt(252) |
| 144 | + lev = (target_vol / real).clip(0, 3).fillna(0) |
| 145 | + cols = sorted({c for w in W.values() for c in w.columns}) |
| 146 | + book = pd.DataFrame(0.0, index=close.index, columns=cols) |
| 147 | + for n, w in W.items(): |
| 148 | + book = book.add(w.reindex(columns=cols).fillna(0).mul(sw[n].fillna(0), axis=0), fill_value=0) |
| 149 | + book = book.mul(lev, axis=0) |
| 150 | + gross = book.abs().sum(axis=1) |
| 151 | + book = book.mul((max_gross / gross).clip(upper=1).fillna(0), axis=0) |
| 152 | + return {"weights": book, "sleeve_weights": sw, "sleeve_returns": R, "returns": sleeve_returns(book, close)} |
| 153 | + |
| 154 | + |
| 155 | +def needed_tickers(sleeves: list[str]) -> list[str]: |
| 156 | + need = [] |
| 157 | + for n in sleeves: |
| 158 | + need += BROAD if n == "broad_trend_12m" else ASSETS if n in ("cross_asset_mom", "risk_parity", |
| 159 | + "multi_asset_trend") else ["SPY"] |
| 160 | + return list(dict.fromkeys(need)) |
| 161 | + |
| 162 | + |
| 163 | +def etf_targets(cfg, end=None) -> tuple[dict[str, float], dict[str, float], pd.Timestamp, pd.DataFrame]: |
| 164 | + """Today's ETF book: weights (as a fraction of the ETF capital), as-traded prices, the |
| 165 | + date of the last close used, and the sleeve weights over the last days (for the log).""" |
| 166 | + from ..data import get_market_data |
| 167 | + |
| 168 | + m = cfg.multi |
| 169 | + md = get_market_data(needed_tickers(m.sleeves), m.history_start, None, cfg.data.cache_dir + "/live_etf", |
| 170 | + cfg.data.batch_size) |
| 171 | + if end is not None: |
| 172 | + md = md.subset(end=end) |
| 173 | + out = combine(md.close, m.sleeves, m.target_vol, m.vol_window, m.max_gross) |
| 174 | + w = out["weights"].iloc[-1] |
| 175 | + prices = {t: float(p) for t, p in md.traded_close.iloc[-1].items() if np.isfinite(p)} |
| 176 | + return ({t: float(v) for t, v in w.items() if abs(v) > 1e-6}, prices, md.dates[-1], |
| 177 | + out["sleeve_weights"].tail(5)) |
0 commit comments