"""Agent 34 — kNN analog matching (family=ml, slug=knn_analog). THE ANGLE (assigned): find the PAST windows most similar to the CURRENT window and predict the average forward move from how those analogs played out — fully causal. HOW IT WORKS * At each decision row i, build a normalized "shape" descriptor of the recent window (the last W bars of standardized log-returns) plus a couple of slow-context features (trailing momentum & realized vol). This is the QUERY. * The DATABASE of analogs is every past anchor j whose forward outcome is already realized as of close[i] (i.e. j + FWD_H <= i). Each anchor stores its descriptor and its realized forward log-return over j -> j+FWD_H. * Distance = Euclidean on the standardized descriptors. Take the K nearest analogs, weight them by 1/(eps+dist), and the forecast is the weighted-average forward return of those neighbors. "What happened next, the last K times the tape looked like this." * Forecast -> bounded conviction (tanh of the standardized forecast). CAUSALITY (the whole game): * The query descriptor at i uses ONLY returns up to and including bar i. * An anchor j is admissible ONLY if its forward window is complete as of i (j + FWD_H <= i). We never peek at row i's own unrealized future, nor any j past i. * Descriptor standardization uses each window's own mean/std (self-contained), so no global statistics leak across the cut. -> Verified by causality_ok (signal on a prefix matches the full-array tail). WHAT THE TRAIN DATA SAYS (honest): next-bar direction on these curves is a coin flip, so analogs are matched on SHAPE and asked for a multi-bar forward move (FWD_H). Like the other ML angles on these strongly up-trending curves, shorting destroys value (the tape only goes up), so the analog forecast is used as a LONG-vs-FLAT conviction with vol-targeting to cap the drawdown — the win is risk control / staying out of the froth, not return generation. """ import numpy as np import blindlib as bl # ---- tuned on split='train' only ---- W = 10 # window length (bars) of the shape descriptor; interior opt (6/14/18 worse) FWD_H = 15 # forward horizon predicted by the analogs (bars); interior (8/12 much worse) K = 30 # number of nearest neighbors; flat plateau 20..50, K=30 = best DD MOM_WIN = 40 # trailing-momentum context feature window; flat 40..60 VOL_WIN = 20 # trailing realized-vol context feature window CTX_WEIGHT = 2.0 # weight of slow-context (regime) features vs the micro shape window. # The REGIME analog (where in the trend, what vol) carries most of the # edge here; up-weighting it lifts PnL 0.71->1.31 AND cuts DD. Flat 1.5..2.5. WARMUP = 200 # min anchors in the database before we trust the forecast GAIN = 8.0 # tanh conviction gain on the standardized forecast; smooth DD/PnL dial LONG_ONLY = True # shorting an up-trend loses -> conviction is long-or-flat TARGET_VOL = 0.20 VOL_WIN_DAYS = 30 LEV_CAP = 1.0 def _descriptors(c): """Causal feature matrix. Row i's descriptor uses ONLY data <= i. Columns: W standardized log-returns of the trailing window + 2 context features.""" n = len(c) lr = np.zeros(n) lr[1:] = np.log(c[1:] / c[:-1]) # lr[i] = return of bar ending at i (causal) csum = np.cumsum(lr) # trailing momentum over MOM_WIN bars (<= i), trailing vol over VOL_WIN bars (<= i) mom = np.zeros(n) mom[MOM_WIN:] = csum[MOM_WIN:] - csum[:-MOM_WIN] vol = np.zeros(n) for i in range(VOL_WIN, n): vol[i] = np.std(lr[i - VOL_WIN + 1 : i + 1]) D = W + 2 desc = np.full((n, D), np.nan) for i in range(W, n): win = lr[i - W + 1 : i + 1] # last W returns, all <= i s = np.std(win) if s < 1e-12: s = 1.0 desc[i, :W] = (win - np.mean(win)) / s # standardized shape (location/scale free) desc[i, W] = mom[i] desc[i, W + 1] = vol[i] return desc, lr def signal(df): c = df["close"].values.astype(float) n = len(c) desc, lr = _descriptors(c) # forward log-return target[j] over bar j -> j+FWD_H (needs close[j+FWD_H]); realized # (admissible) only once i >= j+FWD_H. csum = np.cumsum(lr) fwd = np.full(n, np.nan) fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] first = W # earliest fully-formed descriptor yhat = np.zeros(n) scale = np.ones(n) # CAUSAL trailing scale of the forecast (expanding std) # online over admissible anchors so the shape window (already unit-scale) and context # are comparable; computed causally. for i in range(first, n): last_anchor = i - FWD_H # anchors j <= last_anchor have realized fwd if last_anchor < first + WARMUP: continue # admissible anchor descriptors & their realized forward returns Xj = desc[first : last_anchor + 1] yj = fwd[first : last_anchor + 1] ok = np.isfinite(Xj).all(axis=1) & np.isfinite(yj) if ok.sum() < WARMUP: continue Xj = Xj[ok] yj = yj[ok] q = desc[i].copy() if not np.isfinite(q).all(): continue # scale the 2 context columns by their (causal) std across the anchor set so they # don't dominate / vanish vs the W unit-scale shape columns. ctx_sd = np.std(Xj[:, W:], axis=0) ctx_sd[ctx_sd < 1e-12] = 1.0 Xs = Xj.copy() qs = q.copy() Xs[:, W:] = (Xj[:, W:] / ctx_sd) * CTX_WEIGHT qs[W:] = (q[W:] / ctx_sd) * CTX_WEIGHT d = np.sqrt(np.sum((Xs - qs) ** 2, axis=1)) # Euclidean distance to every anchor k = min(K, len(d)) idx = np.argpartition(d, k - 1)[:k] # K nearest (unordered ok) dk = d[idx] wk = 1.0 / (1e-6 + dk) # inverse-distance weights yhat[i] = np.sum(wk * yj[idx]) / np.sum(wk) # weighted-avg forward move # CAUSAL forecast scale: the realized-forward-return std over the SAME admissible # anchor set (rows <= i-FWD_H). Self-contained, uses no future row. This is what # standardizes the conviction without leaking a global statistic. s = float(np.std(yj)) scale[i] = s if s > 1e-9 else 1.0 # standardize each forecast by its own causal trailing scale -> bounded conviction. direction = np.tanh(GAIN * yhat / scale) direction = np.nan_to_num(direction, nan=0.0) if LONG_ONLY: direction = np.clip(direction, 0.0, 1.0) pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) if LONG_ONLY: pos = np.clip(pos, 0.0, LEV_CAP) return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0)