"""Time-series momentum with volatility targeting (long-only spot variant).""" from __future__ import annotations import numpy as np import pandas as pd from ktrade.strategy.base import ParamSpec, Strategy, StrategyMeta, log_returns, vol_target_scale class TimeSeriesMomentum(Strategy): meta = StrategyMeta( name="tsmom", label="Time-series momentum (vol-targeted)", family="trend", hypothesis="Crypto returns exhibit medium-horizon autocorrelation (Moskowitz–Ooi–Pedersen style). Go long when the trailing " "lookback return is positive and the fast EMA is above the slow EMA; scale by inverse realized volatility; stay in cash otherwise. " "Spot-only: no shorting, so the strategy captures only the long leg of the premium.", experimental=True, allows_short=False, min_bars=120, params=[ ParamSpec(name="lookback", type="int", default=48, min=5, max=2000, description="Bars for the trailing return signal"), ParamSpec(name="ema_fast", type="int", default=20, min=2, max=500), ParamSpec(name="ema_slow", type="int", default=80, min=5, max=2000), ParamSpec(name="vol_lookback", type="int", default=48, min=5, max=1000), ParamSpec(name="target_vol", type="float", default=0.40, min=0.01, max=3.0, description="Annualized vol target"), ParamSpec(name="max_leverage", type="float", default=1.0, min=0.0, max=1.0, description="Max exposure (spot → ≤1)"), ], failure_modes=["Whipsaw in range-bound regimes", "Fee drag at high turnover on short intervals", "Vol targeting lags regime shifts", "Only long leg available on spot"], references=["Moskowitz, Ooi, Pedersen (2012) Time series momentum, JFE"], ) def features(self, df: pd.DataFrame) -> pd.DataFrame: p = self.params f = pd.DataFrame(index=df.index) f["ts"] = df["ts"].values f["close"] = df["close"].values f["ret"] = log_returns(df["close"]) f["mom"] = np.log(df["close"] / df["close"].shift(p["lookback"])) f["ema_fast"] = df["close"].ewm(span=p["ema_fast"], adjust=False, min_periods=p["ema_fast"]).mean() f["ema_slow"] = df["close"].ewm(span=p["ema_slow"], adjust=False, min_periods=p["ema_slow"]).mean() bars_per_year = 365 * 24 * 60 / float(df.attrs.get("interval_min", 60)) f["vol_scale"] = vol_target_scale(f["ret"], p["vol_lookback"], p["target_vol"], bars_per_year, cap=p["max_leverage"]) return f def target(self, feats: pd.DataFrame) -> pd.Series: signal = ((feats["mom"] > 0) & (feats["ema_fast"] > feats["ema_slow"])).astype(float) return (signal * feats["vol_scale"]).fillna(0.0) def uncertainty(self, feats: pd.DataFrame) -> pd.Series | None: # |momentum| in units of rolling vol → weak signals near zero are the uncertain ones z = feats["mom"] / (feats["ret"].rolling(self.params["lookback"], min_periods=5).std(ddof=1) * np.sqrt(self.params["lookback"])) return (1.0 / (1.0 + z.abs())).fillna(1.0) class MovingAverageEnsemble(Strategy): meta = StrategyMeta( name="ma_ensemble", label="Moving-average crossover ensemble", family="trend", hypothesis="An ensemble of EMA crossover rules at several horizons (vote fraction) reduces the parameter-fragility of a single " "crossover while keeping trend exposure. Exposure = fraction of rules that are bullish, volatility-scaled.", experimental=True, min_bars=200, params=[ ParamSpec(name="fast_spans", type="choice", default="8,16,32", choices=["4,8,16", "8,16,32", "10,20,40"]), ParamSpec(name="slow_mult", type="int", default=3, min=2, max=10), ParamSpec(name="vol_lookback", type="int", default=48, min=5, max=1000), ParamSpec(name="target_vol", type="float", default=0.40, min=0.01, max=3.0), ], failure_modes=["Lag at turning points", "Choppy markets"], ) def features(self, df: pd.DataFrame) -> pd.DataFrame: p = self.params f = pd.DataFrame(index=df.index) f["ts"] = df["ts"].values f["close"] = df["close"].values f["ret"] = log_returns(df["close"]) votes = [] for s in [int(x) for x in p["fast_spans"].split(",")]: fast = df["close"].ewm(span=s, adjust=False, min_periods=s).mean() slow = df["close"].ewm(span=s * p["slow_mult"], adjust=False, min_periods=s * p["slow_mult"]).mean() col = f"x_{s}" f[col] = (fast > slow).astype(float).where(slow.notna()) votes.append(col) f["vote"] = f[votes].mean(axis=1) bars_per_year = 365 * 24 * 60 / float(df.attrs.get("interval_min", 60)) f["vol_scale"] = vol_target_scale(f["ret"], p["vol_lookback"], p["target_vol"], bars_per_year) return f def target(self, feats: pd.DataFrame) -> pd.Series: return (feats["vote"] * feats["vol_scale"]).fillna(0.0)