"""agent_22_dd_derisk — ANGLE: drawdown-state de-risking overlay (family=vol, slug=dd_derisk). Idea (assigned angle): Ride the up-trend, but CUT exposure as the asset's running drawdown deepens, and RE-RISK as it recovers back toward the peak. On these two structurally up-trending curves every large decline begins as a drawdown below the running peak; trimming exposure while the curve bleeds below its high mechanically pulls the book light through the worst legs and re-arms it once the high is reclaimed. Construction (all causal / online): * dd[i] = close[i] / running_peak(close[0..i]) - 1 in (-1, 0] -> the LIVE drawdown. * |dd| is lightly EWMA-smoothed (span DD_SMOOTH) so the re-risk on the snap-back is not whipsawed by single-bar wicks; the smoother is causal (ewm, adjust=False). * A smooth de-risk multiplier maps the (smoothed) drawdown to a [W_FLOOR, 1] scale: scale = clip( 1 - (|dd_smooth| / DD_REF) ** P , W_FLOOR, 1 ) Shallow dd -> ~full size; as |dd| approaches DD_REF the scale is bled to W_FLOOR. W_FLOOR>0 keeps a small core position through the deep regime (re-arms instantly on recovery) rather than fully exiting and missing the V-bottom. * This dd-scaled LONG is then vol-targeted (inverse realized vol, slow VOL_WIN_D window). A crash is also a vol spike, so inverse-vol sizing de-risks the same legs from the other side — the two de-risk mechanisms stack. Long/flat only: both curves are sharply V-bottomed, so shorting the recoveries is whipsaw; a de-risk goes toward a light long, never short. Why no explicit trend filter: tested, it HURTS the risk-adjusted result here. The drawdown overlay already does the de-risking a trend gate would do, but smoothly and without the gate's whipsaw round-trips at the V-bottoms. Pure dd-derisk + slow inverse-vol gives the better Sharpe. CAUSAL: running peak (left-to-right accumulate), drawdown, the EWMA smoother and the realized-vol window at i all use rows <= i only. The evaluator shifts the position (held during bar i+1). No future rows, no centered window, no global fit -> causality_ok=true (verified: max_diff 0.0). Tuning (split='train' only, A & B equal weight; buy&hold ref: A Sh0.89/DD0.77, B Sh1.16/DD0.79). The de-risk SHAPE (DD_REF / P / W_FLOOR / DD_SMOOTH) sets the Sharpe; TARGET_VOL is a clean DD/PnL dial (Sharpe flat ~1.10-1.14 across 0.25..0.50). Chosen cell is interior on every axis with a flat plateau (Sharpe 1.08..1.15, DD 0.19..0.24): DD_REF=0.20 P=1.0 W_FLOOR=0.20 DD_SMOOTH=4 VOL_WIN_D=120 TARGET_VOL=0.40 -> train combined: pnl_mean ~1.63, maxdd_worst ~0.22, sharpe_min ~1.14. Honest read: this is a DEFENSIVE long-only book, not alpha. Its value is the DRAWDOWN — ~0.22 vs ~0.77-0.79 buy&hold (a ~3.5x cut) at comparable risk-adjusted PnL. Because it never shorts, its Sharpe ceiling (~1.1-1.2) is set by the absence of a direction call: it can avoid sizing into the big declines but cannot profit from them. That is the inherent limit of the de-risk-overlay angle on these curves. """ import numpy as np import pandas as pd import blindlib as bl DD_REF = 0.20 # drawdown (fraction) at which the de-risk multiplier hits the floor P = 1.0 # de-risk curvature (linear here; >1 keeps near-full on shallow dips) W_FLOOR = 0.20 # minimum exposure scale in the deep regime (keeps a re-armable core) DD_SMOOTH = 4 # EWMA span on |drawdown| -> de-whipsaw the re-risk on snap-backs VOL_WIN_D = 120 # slow trailing realized-vol horizon (days); stable, low turnover TARGET_VOL = 0.40 # DD/PnL dial; Sharpe flat across 0.25..0.50 -> picked for PnL/DD balance LEV_CAP = 1.0 # long-only, never lever past fully invested -> preserves the DD cut def _drawdown_scale(c: np.ndarray) -> np.ndarray: """Causal de-risk multiplier in [W_FLOOR, 1] driven by the live drawdown.""" peak = np.maximum.accumulate(c) # running peak over rows <= i (causal) dd = c / peak - 1.0 # (-1, 0] ad = np.abs(dd) ad = pd.Series(ad).ewm(span=DD_SMOOTH, adjust=False).mean().values # causal smoother depth = ad / DD_REF return np.clip(1.0 - depth ** P, W_FLOOR, 1.0) def signal(df): c = df["close"].values.astype(float) scale = _drawdown_scale(c) # long/flat de-risk exposure in [W_FLOOR, 1] pos = bl.vol_target(scale, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN_D, leverage_cap=LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), 0.0, LEV_CAP)