"""agent_46_vol_div — Volume/price divergence (family=vol2, slug=vol_div). ANGLE: fade moves where volume does NOT confirm; ride where it does. How the angle is expressed (all causal, decided at close[i], held over bar i+1): * CONFIRMATION = is volume EXPANDING as the trend develops? We compare a short volume mean (5) to a longer one (20): `confirm = v5/v20 - 1`. When volume is rising while price trends, the move is volume-CONFIRMED. -> RIDE leg: take the multi-bar (15-bar) price momentum, but only with weight proportional to the confirmation (clip(confirm * gain, 0, 1)). No confirmation -> no momentum bet. This is "ride where volume confirms". * DIVERGENCE / EXHAUSTION = a single-bar thrust on a VOLUME SPIKE that is NOT part of a broader volume up-trend (volume not confirming the direction). Such thrusts tend to mean-revert. -> FADE leg: -sign(last bar) gated by (a vol z-score spike) AND (volume NOT broadly expanding). This is "fade where volume does not confirm". * The two legs are blended (0.7 ride / 0.3 fade) and vol-targeted so the drawdown stays bounded. On the train view this is comparable PnL to buy&hold at a fraction of the drawdown, and it can go short / flat the unconfirmed declines. Decomposition note (train): the RIDE leg is the real edge on both overlaid curves (volume-confirmed momentum persists); the FADE leg is a small DD-reducing overlay. Parameters chosen on a smooth plateau (rw 12-15, cl 15-20), not a knife-edge. """ import numpy as np import pandas as pd import blindlib as bl RIDE_W = 15 # momentum horizon (bars) CONF_S = 5 # short volume mean CONF_L = 20 # long volume mean GAIN = 6.5 # confirmation -> ride-weight gain W_FADE = 0.30 # weight of the divergence/fade overlay TARGET_VOL = 0.18 # annualized vol target for sizing VOL_WIN = 30 # vol-target lookback (days) def _zscore(x, win): s = pd.Series(x) m = s.rolling(win, min_periods=win // 2).mean() sd = s.rolling(win, min_periods=win // 2).std() z = (s - m) / sd.replace(0.0, np.nan) return np.nan_to_num(z.values) def signal(df): c = df["close"].values.astype(float) v = df["volume"].values.astype(float) logc = np.log(c) r = np.concatenate([[0.0], np.diff(logc)]) # causal bar return # ---- Volume confirmation: short vol mean vs long vol mean (>0 = expanding) ---- vshort = pd.Series(v).rolling(CONF_S, min_periods=2).mean().values vlong = pd.Series(v).rolling(CONF_L, min_periods=10).mean().values confirm = np.nan_to_num(vshort / np.where(vlong > 0, vlong, np.nan), nan=1.0) - 1.0 # ---- RIDE leg: multi-bar momentum, weighted by how strongly volume confirms ---- pm = np.concatenate([np.zeros(RIDE_W), logc[RIDE_W:] - logc[:-RIDE_W]]) ride = np.sign(pm) * np.clip(confirm * GAIN, 0.0, 1.0) # ---- FADE leg: fade a single-bar thrust on a volume spike w/o broad expansion ---- vol_spike = _zscore(v, 20) fade_gate = np.clip(vol_spike - 1.0, 0.0, 2.0) * np.clip(-confirm * 4.0 + 0.5, 0.0, 1.0) fade = -np.sign(r) * np.clip(fade_gate, 0.0, 1.0) raw = np.clip((1.0 - W_FADE) * ride + W_FADE * fade, -1.0, 1.0) pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN, leverage_cap=1.0) return np.clip(np.nan_to_num(pos), -1.0, 1.0)