"""agent_47_trail_mom — momentum entry with ACTIVE TRAILING-STOP position management. Angle [family=mix, slug=trail_mom]: * Enter LONG/SHORT on multi-horizon momentum (the "trend is your friend" entry). * Then actively MANAGE the position with a trailing stop measured in ATR units from the best favourable price seen since the trade opened: - adverse excursion (price pulls back toward the trail) -> REDUCE exposure, - follow-through (new favourable extreme) -> ADD exposure back, up to full size. * Vol-target the whole thing so DD stays bounded. CAUSAL: every value at bar i uses only rows 0..i. The trailing state machine is a pure forward loop (no future peek). The evaluator shifts the position, so position[i] is the weight held during bar i+1 — decided from data up to close[i]. """ import numpy as np import blindlib as bl def _mom_dir(c): """Multi-horizon momentum direction in [-1,1] (causal). Equal-weight 20/50/100.""" d = np.zeros(len(c)) for w, wt in ((20, 0.34), (50, 0.33), (100, 0.33)): m = c / bl.sma(c, w) - 1.0 d += wt * np.tanh(8.0 * m) return np.clip(d, -1.0, 1.0) def signal(df): c = df["close"].values.astype(float) h = df["high"].values.astype(float) l = df["low"].values.astype(float) n = len(c) direction = _mom_dir(c) # desired sign + conviction a = bl.atr(df, 14) # causal ATR (vol unit for trail) a = np.where(np.isfinite(a) & (a > 0), a, np.nan) # ---- trailing-stop state machine (pure causal forward loop) ------------- TRAIL_K = 4.0 # trail distance in ATR from the favourable extreme REDUCE_K = 0.8 # adverse excursion (ATR) at which we start shrinking sized = np.zeros(n) # managed exposure scalar in [0,1] cur_sign = 0.0 best = np.nan # best favourable price since entry (max if long, min if short) expo = 0.0 # current exposure fraction in [0,1] for i in range(n): d = direction[i] sgn = np.sign(d) if abs(d) > 0.20 else 0.0 # dead-zone: avoid chop flip ai = a[i] if not np.isfinite(ai): sized[i] = 0.0 continue # entry / flip: reset trailing state, start at conviction-scaled exposure if sgn != 0.0 and sgn != cur_sign: cur_sign = sgn best = c[i] expo = min(1.0, abs(d)) elif sgn == 0.0: cur_sign = 0.0 expo = 0.0 best = np.nan if cur_sign != 0.0 and np.isfinite(best): # update favourable extreme if cur_sign > 0: best = max(best, h[i]) adverse = (best - c[i]) / ai # how far pulled back (ATR units) else: best = min(best, l[i]) adverse = (c[i] - best) / ai # trailing management: if adverse >= TRAIL_K: expo = 0.0 # stopped out elif adverse >= REDUCE_K: # linearly reduce between REDUCE_K and TRAIL_K frac = 1.0 - (adverse - REDUCE_K) / (TRAIL_K - REDUCE_K) target = min(1.0, abs(d)) * max(0.0, frac) expo = min(expo, target) # reduce only on adverse else: # follow-through region -> add back toward full conviction target = min(1.0, abs(d)) expo = expo + 0.34 * (target - expo) # ease back up sized[i] = cur_sign * expo else: sized[i] = 0.0 # ---- vol-target the managed directional series -------------------------- pos = bl.vol_target(sized, df, target_vol=0.20, vol_win_days=30, leverage_cap=1.0) return np.clip(pos, -1.0, 1.0)