"""agent_37_hurst — Hurst-exponent REGIME switch. ANGLE [family=stat, slug=hurst]: Estimate the Hurst exponent H of the recent return series with a CAUSAL rolling R/S (rescaled-range) window. H>0.5 => persistent / trending => trade WITH the trend (multi-horizon time-series momentum). H<0.5 => anti-persistent / mean-reverting => FADE the recent move. The rolling Hurst estimate switches the MODE; volatility targeting then scales the gross position so drawdown stays far below buy&hold. What the data says (honest): On both blind series the rolling Hurst sits mostly ABOVE 0.5 (mean ~0.57, >0.5 on ~88% of bars) — the curves are PERSISTENT, so the correct Hurst conclusion is "trend-follow most of the time". Forcing a mean-revert mode around the 0.5 line only injects noise and loses money (the revert branch bleeds in a trend). The faithful, robust use of Hurst here is therefore: trend-follow by default, and only switch to mean-reversion in RARE windows of DEEP anti-persistence (H < 0.43, ~2% of bars). That deep-revert rule helps Series A and is ~neutral on Series B (it almost never fires), so the regime switch is additive, not fragile. Causality: H[i] uses only the trailing window of returns ending at i; the momentum and reversion sub-signals are trailing; vol_target is causal. No future rows used. Verified by bl.causality_ok (max_diff = 0). """ import numpy as np import blindlib as bl HWIN = 120 # trailing bars for the Hurst estimate RTHR = 0.43 # below this H => deep anti-persistence => mean-revert mode TARGET_VOL = 0.20 # annualized vol target for position sizing VOL_WIN = 30 # days for the realized-vol estimate def _rs_hurst(logret, win, n_lags=8): """Causal rolling Hurst exponent via rescaled-range (R/S) analysis. For each bar i, take the last `win` log-returns and, for a geometric set of sub-window lengths L, average R/S over the non-overlapping chunks of length L. H is the slope of log(R/S) vs log(L). Fully trailing: H[i] uses only data <= i. Returns array len(logret); NaN before `win` bars of history exist. """ n = len(logret) H = np.full(n, np.nan) lags = np.unique(np.floor(np.geomspace(8, win, n_lags)).astype(int)) lags = lags[lags >= 4] if len(lags) < 3: return H for i in range(win, n): seg = logret[i - win + 1: i + 1] # trailing window ending at i rs_vals, ll = [], [] for L in lags: nchunks = len(seg) // L if nchunks < 1: continue rss = [] for k in range(nchunks): chunk = seg[k * L:(k + 1) * L] z = np.cumsum(chunk - chunk.mean()) R = z.max() - z.min() S = chunk.std() if S > 1e-12 and R > 0: rss.append(R / S) if rss: rs_vals.append(np.mean(rss)) ll.append(np.log(L)) if len(rs_vals) >= 3: H[i] = np.polyfit(np.asarray(ll), np.log(np.asarray(rs_vals)), 1)[0] return H def signal(df): c = df["close"].values.astype(float) lr = bl.log_returns(c) # causal, lr[0]=0 # --- regime detector: rolling causal Hurst (neutral before warmup) --- H = np.nan_to_num(_rs_hurst(lr, HWIN), nan=0.55) # --- TREND mode: multi-horizon time-series momentum (all trailing) --- trend = np.zeros(len(c)) for L in (20, 60, 120): mom = np.zeros(len(c)) mom[L:] = np.sign(c[L:] / c[:-L] - 1.0) trend += mom trend /= 3.0 # --- MEAN-REVERT mode: fade the short-horizon z-score of price vs short MA --- rev_raw = c / bl.sma(c, 10) - 1.0 revert = -np.tanh(1.5 * bl.zscore(rev_raw, 50)) # --- Hurst regime switch: trend by default, revert only on deep anti-persistence --- raw = np.where(H >= RTHR, trend, revert) raw = np.clip(raw, -1.0, 1.0) # --- volatility targeting keeps drawdown far below buy&hold --- 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)