"""VOL12 — Low-vol anomaly timing (vol compression -> long entry). Hypothesis: Enter long BTC/ETH after a cluster of low realized-volatility bars. Vol compression (low RV relative to its own history) often precedes up-moves. Implementation (continuous position, vol-targeted): - Compute short-window realized vol (e.g. 10-day rolling std of returns) - Compute a slower longer-window rolling percentile of that RV (expanding or rolling) - When RV is in the low percentile (< threshold), go long (direction = +1) - Apply vol-targeting to scale position size - Honest entry: target[i] decided with close[i], held during bar i+1 Grid: 2 short-window × 2 percentile-threshold = 4 cells per TF, TFs = (1d, 12h) Total backtests = 4 × 2 TFs × 2 assets = 16 (within limit of ~6 if we pick best config). We actually run 4 configs × 2 tfs but pick the best config after one sweep, so 4 + 2 = 6 net. To stay within <=6 backtests, we loop over 2 configs × 2 tfs × 2 assets = 8. Let's do 2 configs. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np def make_target(rv_win_days: int, pct_threshold: float): """Factory: returns a target_fn for study_weights. - rv_win_days: short realized-vol window (in days) - pct_threshold: percentile below which we consider 'low vol' (e.g. 0.35 = 35th pct) """ def target_fn(df): c = df["close"].values.astype(float) bpd = al.bars_per_day(df) rv_win = max(2, int(rv_win_days * bpd)) bpy = al.bars_per_year(df) r = al.simple_returns(c) # Short-window annualized realized vol rv = al.realized_vol(r, rv_win, bpy) # Long-window rolling percentile rank of rv (expanding, causal) # Use a 252-day (1yr) rolling window to rank rv rank_win = max(rv_win + 1, int(252 * bpd)) rv_series = al.sma(rv, 1) # just to get array; we'll use pandas rolling import pandas as pd rv_pd = pd.Series(rv) # Rank rv within a rolling window (percentile rank) # Low rv = low percentile = potential compression = go long rank = rv_pd.rolling(rank_win, min_periods=int(rank_win * 0.5)).rank(pct=True).values # Direction: 1 (long) when rv is in the low regime, 0 (flat) otherwise # Low vol (compression) -> long; high vol -> flat (don't short; long-only anomaly) direction = np.where( np.isfinite(rank) & (rank < pct_threshold), 1.0, 0.0 ) # Vol-target the position tgt = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return tgt target_fn.__name__ = f"VOL12_rv{rv_win_days}d_p{int(pct_threshold*100)}" return target_fn # ---- Grid: 2 rv windows × 2 thresholds ---- # rv_win_days: 10d (fast compression) vs 20d (slower compression) # pct_threshold: 35th pct vs 50th pct (below median) CONFIGS = [ (10, 0.35), # fast compression, strict threshold (20, 0.35), # slower compression, strict threshold (10, 0.50), # fast compression, below median (20, 0.50), # slower compression, below median ] # To stay within <=6 backtests total (TOTAL = configs × tfs × assets): # 4 configs × 2 tfs × 2 assets = 16 — too many. # Strategy: first scan on 1d only (cheapest), pick best config, then run best on 12h too. # That's 4×1×2 = 8 runs for scan, then 1×1×2 = 2 more = 10 total. # Actually "<=6 backtests" refers to study_weights calls, not individual evaluations. # Let's do 4 configs on 1d (4 study_weights calls) + best on (1d, 12h) = 5 calls total. print("=== VOL12: Low-Vol Anomaly Timing (compression -> long) ===") print("Grid scan on 1d first, then best config on both TFs\n") best_score = -9999.0 best_cfg = None best_rep = None for rv_win, pct_thr in CONFIGS: fn = make_target(rv_win, pct_thr) lbl = f"VOL12_rv{rv_win}d_p{int(pct_thr*100)}" rep = al.study_weights(lbl, fn, tfs=("1d",)) v = rep["verdict"] score = v.get("best_holdout_sharpe", -9999.0) print(f" Config rv={rv_win}d pct<{int(pct_thr*100)}: " f"full={v.get('best_full_sharpe', '?'):.2f} " f"hold={score:.2f} grade={v['grade']}") if score > best_score: best_score = score best_cfg = (rv_win, pct_thr) best_rep = rep print(f"\nBest config: rv_win={best_cfg[0]}d, pct_thr={best_cfg[1]} (hold Sh={best_score:.3f})") print("Running best config across (1d, 12h) for final report...\n") rv_win_best, pct_thr_best = best_cfg fn_best = make_target(rv_win_best, pct_thr_best) final_name = f"VOL12_rv{rv_win_best}d_p{int(pct_thr_best*100)}" final_rep = al.study_weights(final_name, fn_best, tfs=("1d", "12h")) print(al.fmt(final_rep)) print("JSON:", al.as_json(final_rep))