"""VOL01 — DVOL z-score risk on/off. IDEA: Use Deribit DVOL (implied vol index) as a regime filter. - When DVOL z-score (expanding window, causal) < threshold => "calm" => go LONG vol-targeted - When DVOL z-score >= threshold => "high vol / fear" => flat History starts 2021-03 (DVOL only available from then). Strategy type: CONTINUOUS position (weights), long-flat, vol-targeted at 20%. Grid: test two z-score thresholds (0 and 0.5) x two DVOL smoothing windows (30d, 60d). Total cells: 4 param sets x 2 TFs (1d, 12h) x 2 assets = 16 backtests — within budget. Pick best config by min-asset hold-out Sharpe. """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np # ------------------------------------------------------------------ # DVOL z-score signal builder # ------------------------------------------------------------------ def make_vol01(zscore_thresh: float, dvol_smooth_days: int): """ Returns a target_fn(df) for VOL01. Signal logic: 1. Get DVOL for the asset (causal, aligned to bar timestamps). 2. Smooth DVOL with an EMA of dvol_smooth_days bars. 3. Compute an EXPANDING z-score of the smoothed DVOL. Expanding (not rolling) = fully causal, uses all history up to i. 4. Direction = +1 if z-score < zscore_thresh, else 0 (flat). 5. Apply vol_target scaling to direction. The expanding z-score naturally adapts to regime: low DVOL vs the full history = calm = invest; high DVOL vs history = fear = sideline. """ def target_fn(df): # Step 1: get raw DVOL (causal forward-fill from daily Deribit data) # Detect which asset this df belongs to by checking close price range # We need to pass asset name — infer from close magnitude # BTC >> 1000, ETH >> 100 but < BTC. Use DVOL from both and pick best match. # Actually al.dvol needs the asset name. We'll pass it via closure. raise NotImplementedError("Asset name needed — use make_vol01_asset instead") return target_fn def make_vol01_asset(asset: str, zscore_thresh: float, dvol_smooth_days: int): """VOL01 target function for a specific asset.""" def target_fn(df): bpd = al.bars_per_day(df) # Step 1: get DVOL causally aligned to df bars dv = al.dvol(df, asset) # float array, NaN before 2021-03 # Step 2: smooth DVOL with EMA to reduce noise smooth_bars = dvol_smooth_days * bpd dv_smooth = al.ema(np.where(np.isfinite(dv), dv, np.nan), max(2, smooth_bars)) # Step 3: expanding z-score (causal — uses all history up to i) s = pd.Series(dv_smooth) exp_mean = s.expanding(min_periods=30).mean() exp_std = s.expanding(min_periods=30).std() z = ((s - exp_mean) / exp_std.replace(0, np.nan)).values # Step 4: direction — long when z < threshold, flat otherwise direction = np.where( np.isfinite(z) & (z < zscore_thresh), 1.0, 0.0 ) # Step 5: vol-target scaling pos = al.vol_target(direction, df, target_vol=0.20, vol_win_days=30, leverage_cap=2.0) return pos return target_fn # Need pandas for expanding z-score in the closure import pandas as pd # ------------------------------------------------------------------ # Small grid: 2 thresholds x 2 smoothing windows # ------------------------------------------------------------------ param_grid = [ (0.0, 30), # strict: only enter below median DVOL, 30d smooth (0.5, 30), # relaxed: enter below +0.5 sigma, 30d smooth (0.0, 60), # strict: 60d smooth (0.5, 60), # relaxed: 60d smooth ] TFS = ("1d", "12h") print("=== VOL01: DVOL z-score risk on/off ===") print(f"Grid: {len(param_grid)} param sets x {len(TFS)} TFs x 2 assets") print() all_reps = [] for (zt, sd) in param_grid: name = f"VOL01_z{zt:.1f}_s{sd}d" # We need per-asset target functions since al.study_weights calls target_fn(df) # but doesn't pass asset name. Solution: run BTC and ETH separately using a # custom wrapper that uses asset-specific target functions. # Custom study that handles per-asset target functions: def run_study(name, zt=zt, sd=sd): cells = [] for tf in TFS: per_asset = {} fee_ok_all = True for a in al.CERTIFIED: df = al.get(a, tf) tgt_fn = make_vol01_asset(a, zt, sd) tgt = tgt_fn(df) base = al.eval_weights(df, tgt, fee_side=al.FEE_SIDE) sweep = {f"{2*f*100:.2f}%RT": al.eval_weights(df, tgt, fee_side=f)["full"]["sharpe"] for f in al.FEE_SWEEP} fee_ok = sweep.get("0.20%RT", -9) > 0 fee_ok_all = fee_ok_all and fee_ok per_asset[a] = dict( full=base["full"], holdout=base["holdout"], tim=base["time_in_market"], turnover=base["turnover_per_year"], fee_sweep=sweep, yearly=base["yearly"] ) min_full = min(per_asset[a]["full"]["sharpe"] for a in al.CERTIFIED) min_hold = min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in al.CERTIFIED) avg_full = np.mean([per_asset[a]["full"]["sharpe"] for a in al.CERTIFIED]) cells.append(dict( tf=tf, per_asset=per_asset, min_asset_full_sharpe=round(min_full, 3), min_asset_holdout_sharpe=round(min_hold, 3), full_sharpe=round(float(avg_full), 3), fee_survives=fee_ok_all )) # compute verdict verdict = al._verdict(cells) return dict(name=name, kind="weights", cells=cells, verdict=verdict) rep = run_study(name) all_reps.append((zt, sd, rep)) print(al.fmt(rep)) print() # ------------------------------------------------------------------ # Pick best config by min-asset hold-out Sharpe across best TF # ------------------------------------------------------------------ best_entry = max(all_reps, key=lambda x: x[2]["verdict"].get("best_holdout_sharpe", -99)) best_zt, best_sd, best_rep = best_entry print("=" * 60) print(f"BEST CONFIG: z_thresh={best_zt}, smooth={best_sd}d") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))