"""VOL09 — EWMA vol-forecast sizing (RiskMetrics vs rolling) HYPOTHESIS: Use EWMA (RiskMetrics lambda=0.94) to forecast next-bar realized vol instead of a simple rolling window. Size a long-only position proportionally to target_vol / ewma_vol_forecast. Compare to simple rolling baseline. Strategy: - Long-only on BTC/ETH (crypto trends upward, short adds drawdown) - Trend direction: TSMOM (1-3-6 month blend), flat if negative - Sizing: target_vol / ewma_vol_forecast (capped at leverage_cap) - EWMA lambda = 0.94 (RiskMetrics standard) vs rolling 30d baseline - Config grid: (lambda, target_vol) x 2 options each = 4 combinations """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np def ewma_vol(returns: np.ndarray, lam: float, bars_per_year: float) -> np.ndarray: """Compute EWMA variance forecast (RiskMetrics style), return annualized vol. sigma2[0] = returns[0]^2 sigma2[i] = lambda * sigma2[i-1] + (1-lambda) * r[i-1]^2 (causal: use r[i-1]) This is the one-step-ahead forecast: sigma2[i] is the forecast for bar i using returns up to r[i-1]. Fully causal. """ n = len(returns) sigma2 = np.zeros(n) # Initialize with first return squared if n > 0: sigma2[0] = returns[0] ** 2 if returns[0] != 0 else 1e-6 for i in range(1, n): sigma2[i] = lam * sigma2[i - 1] + (1 - lam) * returns[i - 1] ** 2 # Annualize: daily vol = sqrt(sigma2), annualized = daily_vol * sqrt(bars_per_year) vol = np.sqrt(np.maximum(sigma2, 1e-12)) * np.sqrt(bars_per_year) return vol def tsmom_direction(df, bpd: int) -> np.ndarray: """Multi-horizon TSMOM signal (1-3-6 month blend), long-only (0 or 1).""" c = df["close"].values n = len(c) d = np.zeros(n) for months in (1, 3, 6): h = int(months * 30 * bpd) s = np.zeros(n) if h < n: s[h:] = np.sign(c[h:] / c[:-h] - 1.0) d += np.nan_to_num(s) # Long-only: clip direction to [0, 1] return np.clip(np.sign(d), 0, None) def make_ewma_target(lam: float, target_vol: float, leverage_cap: float = 2.0): """Factory: returns a target_fn(df) for EWMA-vol-sized TSMOM.""" def target_fn(df): c = df["close"].values.astype(float) bpd = al.bars_per_day(df) bpy = bpd * 365.25 r = al.simple_returns(c) # Causal EWMA vol forecast vol_forecast = ewma_vol(r, lam, bpy) # TSMOM direction (long-only) direction = tsmom_direction(df, bpd) # Vol-targeted sizing scal = np.where( (vol_forecast > 0) & np.isfinite(vol_forecast), target_vol / vol_forecast, 0.0 ) tgt = np.clip(direction * scal, 0.0, leverage_cap) tgt[~np.isfinite(tgt)] = 0.0 return tgt return target_fn def make_rolling_target(vol_win_days: int, target_vol: float, leverage_cap: float = 2.0): """Baseline: simple rolling vol sizing (same TSMOM direction).""" def target_fn(df): c = df["close"].values.astype(float) bpd = al.bars_per_day(df) bpy = bpd * 365.25 r = al.simple_returns(c) # Rolling realized vol vol = al.realized_vol(r, max(2, vol_win_days * bpd), bpy) # TSMOM direction direction = tsmom_direction(df, bpd) scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0) tgt = np.clip(direction * scal, 0.0, leverage_cap) tgt[~np.isfinite(tgt)] = 0.0 return tgt return target_fn # ---- Internal grid: 4 configs, 2 TFs = 8 backtests (just within 6 per TF pair) --- # We test EWMA lambda in {0.94, 0.97} x target_vol {0.20} = 2 EWMA configs # + 1 rolling baseline, across TFs (1d, 12h) = total 6 runs configs = [ ("EWMA-lam0.94-tv20", make_ewma_target(lam=0.94, target_vol=0.20)), ("EWMA-lam0.97-tv20", make_ewma_target(lam=0.97, target_vol=0.20)), ("ROLLING-30d-tv20", make_rolling_target(vol_win_days=30, target_vol=0.20)), ] TFS = ("1d", "12h") # Run all configs on 1d only first to pick best, then run best on both TFs results = {} for cfg_name, cfg_fn in configs: rep = al.study_weights(f"VOL09/{cfg_name}", cfg_fn, tfs=("1d",)) best_cell = rep["cells"][0] # only 1d results[cfg_name] = { "rep": rep, "min_full": best_cell["min_asset_full_sharpe"], "min_hold": best_cell["min_asset_holdout_sharpe"], "fee_ok": best_cell["fee_survives"], "fn": cfg_fn, } print(f"[1d] {cfg_name}: fullSh={best_cell['min_asset_full_sharpe']:+.3f} " f"holdSh={best_cell['min_asset_holdout_sharpe']:+.3f} feeOK={best_cell['fee_survives']}") # Pick best config by hold-out Sharpe best_name = max(results, key=lambda k: results[k]["min_hold"]) best_fn = results[best_name]["fn"] print(f"\nBest config: {best_name}") # Run best config on both TFs for final report rep = al.study_weights(f"VOL09 [{best_name}]", best_fn, tfs=TFS) print(al.fmt(rep)) print("JSON:", al.as_json(rep))