"""agent_44_obv — On-Balance-Volume trend confirmation [family=vol2, slug=obv]. Angle: cumulative signed volume (OBV) slope CONFIRMS price direction. OBV is the running sum of sign(Δclose)*volume; when it trends up the buying volume is backing the advance (accumulation) and the move is more likely to continue; when OBV rolls over relative to its own EMA the advance is on thinning volume (distribution) and we de-risk / can flip. Construction (all causal — value at i uses only rows 0..i): obv = cumsum(sign(Δclose) * volume) obv_trend = (obv - EMA(obv, 25)) / rolling_std(...) # volume-flow z-score price_trend= (close/SMA(close,40) - 1) / rolling_std(...) # price z-score raw = 0.35*tanh(k*obv_trend) + 0.65*tanh(k*price_trend) # volume confirms price position = vol_target(raw, target 20%) # bound drawdown, long/short Why this weighting: on the train view the OBV flow z-score carries genuine, independently positive next-bar correlation on BOTH overlaid curves, but the price trend is the stronger single driver; OBV's role is to CONFIRM/temper it. A grid over (obv_win, price_win, blend, gain, target_vol) shows a broad plateau around these values (Sharpe stable +/- one cell), so the config is not a knife-edge fit. An explicit OBV-divergence damping gate was tested and added nothing (the blend already absorbs divergences), so it was left out — simpler. """ import numpy as np import blindlib as bl # Tuned on split='train' only; chosen from the centre of a robustness plateau. W_OBV = 25 # OBV-vs-EMA trend window W_PRICE = 40 # price trend (close vs SMA) window A_OBV = 0.35 # weight on the volume-flow leg (1 - A on the price leg) GAIN = 0.9 # tanh gain on the z-scores TARGET_VOL = 0.20 VOL_WIN = 40 def signal(df): c = df["close"].values.astype(float) v = df["volume"].values.astype(float) # --- On-Balance-Volume: causal cumulative signed volume --- dc = np.diff(c, prepend=c[0]) obv = np.cumsum(np.sign(dc) * v) # OBV trend = OBV relative to its own EMA, z-scored by recent OBV-deviation std. obv_dev = obv - bl.ema(obv, W_OBV) obv_sc = bl.rolling_std(obv_dev, W_OBV) obv_sc = np.where(obv_sc > 1e-9, obv_sc, 1e-9) obv_sig = np.tanh(GAIN * (obv_dev / obv_sc)) # >0 accumulation, <0 distribution # Price trend = close vs SMA, z-scored. ptr = c / bl.sma(c, W_PRICE) - 1.0 ptr_sc = bl.rolling_std(ptr, W_PRICE) ptr_sc = np.where(ptr_sc > 1e-9, ptr_sc, 1e-9) price_sig = np.tanh(GAIN * (ptr / ptr_sc)) # Volume CONFIRMS price: blend the two legs into a -1..1 direction. raw = A_OBV * obv_sig + (1.0 - A_OBV) * price_sig raw = np.nan_to_num(raw, nan=0.0) # Vol-target to bound drawdown; long/short allowed. 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, nan=0.0), -1.0, 1.0)