"""RSK07 — Drawdown-scaled exposure HYPOTHESIS: Exposure proportional to (1 - recent rolling drawdown) on a long-only base. De-risk into weakness: when the asset is in a large drawdown, reduce position size. Style: al.study_weights (continuous position / vol-targeted) Idea: - Compute the rolling drawdown over a lookback window. - Target exposure = (1 - drawdown_fraction) where drawdown_fraction in [0, 1]. - Apply vol-targeting on top to keep risk constant. - Long-only base (no shorting). The rolling drawdown at bar i = (rolling_max(close, dd_win) - close[i]) / rolling_max(close, dd_win) This is causal: uses close[i] and prior highs. Exposure(i) = max(0, 1 - drawdown(i)) With vol-targeting, this scales by (target_vol / realized_vol). Small grid (<=4 configs, total backtests = 4 * 2 assets <= 8): A: dd_win=20, vol_target=0.20 B: dd_win=60, vol_target=0.20 C: dd_win=120, vol_target=0.20 D: dd_win=60, vol_target=0.15 TFs tested: 1d, 12h (2 TFs * 4 configs * 2 assets = 16 total — but study_weights runs per config, so we do 4 configs across 2 TFs = 8 backtest calls) """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np # --------------------------------------------------------------------------- # Core target function # --------------------------------------------------------------------------- def make_target(df, dd_win: int = 60, target_vol: float = 0.20) -> np.ndarray: """ Long-only drawdown-scaled exposure with vol-targeting. Steps: 1. Compute rolling max of close over dd_win bars (causal: max(close[i-dd_win:i+1])) 2. Drawdown fraction = (rolling_max - close) / rolling_max 3. Raw exposure = max(0, 1 - drawdown_fraction) in [0, 1] 4. Apply vol-target scaling: multiply by (target_vol / realized_vol), cap at 2x 5. Result: long-only position in [0, 2], decided with data <= close[i] """ c = df["close"].values.astype(float) n = len(c) # Causal rolling maximum: max of close over [i-dd_win+1 .. i] # Use pandas rolling with min_periods=1 c_series = df["close"].astype(float) roll_max = c_series.rolling(dd_win, min_periods=1).max().values # Drawdown fraction (0 = at high-water mark, 1 = fully drawn down) dd_frac = np.where(roll_max > 0, (roll_max - c) / roll_max, 0.0) dd_frac = np.clip(dd_frac, 0.0, 1.0) # Raw direction/size: (1 - drawdown), always long [0, 1] raw_exposure = 1.0 - dd_frac # 1.0 at HWM, 0.0 at full drawdown # Vol-targeting: scale so expected volatility = target_vol # Use al.vol_target with direction=raw_exposure (already in [0,1]) # But al.vol_target expects direction in {-1, 0, 1}; we'll do manual vol-scaling # Realized vol: rolling std of log returns log_ret = np.diff(np.log(c), prepend=np.nan) vol_win = int(30 * al.bars_per_day(df)) vol_win = max(vol_win, 5) r_series = pd.Series(log_ret) if False else __import__('pandas').Series(log_ret) # Realized vol: annualized log_ret_arr = al.log_returns(c) bpy = al.bars_per_year(df) rv = al.realized_vol(log_ret_arr, vol_win, bpy) # Vol-target scaling lev = np.where((rv > 0) & np.isfinite(rv), target_vol / rv, 1.0) lev = np.clip(lev, 0.0, 2.0) # Final target: drawdown-scaled exposure * vol lever target = raw_exposure * lev # Cap at 2.0 (leverage cap) target = np.clip(target, 0.0, 2.0) # First few bars: NaN until we have enough data warmup = max(dd_win, vol_win) target[:warmup] = np.nan return target # --------------------------------------------------------------------------- # Grid search # --------------------------------------------------------------------------- import pandas as pd # noqa: E402 (needed above via __import__, explicit now) GRID = [ {"dd_win": 20, "target_vol": 0.20, "label": "dd=20 vol=20%"}, {"dd_win": 60, "target_vol": 0.20, "label": "dd=60 vol=20%"}, {"dd_win": 120, "target_vol": 0.20, "label": "dd=120 vol=20%"}, {"dd_win": 60, "target_vol": 0.15, "label": "dd=60 vol=15%"}, ] best_rep = None best_score = -999.0 best_label = "" for params in GRID: dd_win = params["dd_win"] target_vol = params["target_vol"] label = f"RSK07 {params['label']}" print(f"\n--- Testing {label} ---") rep = al.study_weights( label, lambda df, dw=dd_win, tv=target_vol: make_target(df, dd_win=dw, target_vol=tv), tfs=("1d", "12h"), ) print(al.fmt(rep)) # Score by min-asset hold-out Sharpe best_cell = max(rep["cells"], key=lambda c: c.get("min_asset_holdout_sharpe", -9)) score = best_cell.get("min_asset_holdout_sharpe", -9.0) if score > best_score: best_score = score best_rep = rep best_label = label # --------------------------------------------------------------------------- # Final report # --------------------------------------------------------------------------- print("\n" + "=" * 60) print(f"BEST CONFIG: {best_label}") print(al.fmt(best_rep)) print("JSON:", al.as_json(best_rep))