"""TRACK B — Machine-learning / feature-prediction on BTC & ETH (Deribit-certified). Honest, strict walk-forward ML research. The whole point is to NOT repeat the death of the old library (look-ahead). Everything here obeys: * Features for bar i use ONLY data <= close[i] (all rolling windows are backward). * Labels (sign of forward return over H bars) use close[i+H]; in walk-forward we only train on samples whose label is FULLY realized in the past relative to the prediction bar (a gap of H is enforced between train-end and the prediction block). * Scaler + model are fit ONLY on past data, retrained periodically, never on the future. * Net of fees (fee_rt sweep 0.0005 .. 0.002, baseline 0.001). Turnover reported. * Grid over W (lookback for training), H (horizon), threshold, asset, tf. * A final held-out segment (last HELD_OUT_FRAC) is NEVER used to choose configs; configs are selected on the DEV portion, then confirmed once on the held-out tail. Run: uv run python scripts/research/trackB_ml.py uv run python scripts/research/trackB_ml.py --quick (smaller grid, faster) uv run python scripts/research/trackB_ml.py --gbm (also try GradientBoosting) Entry convention (harness): for a signalled bar i we open at close[i] in the predicted direction and hold up to H bars (max_bars=H, no TP/SL) — a pure test of directional sign. No-overlap is enforced by the harness, so trades are naturally spaced >= H bars. """ from __future__ import annotations import argparse import sys import time import warnings from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[2])) import numpy as np import pandas as pd from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from src.backtest.harness import backtest_signals, load warnings.filterwarnings("ignore") HELD_OUT_FRAC = 0.25 # final tail reserved for confirmation only RETRAIN_K = 250 # retrain every K bars (block prediction) MIN_TRAIN = 400 # minimum usable training samples # --------------------------------------------------------------------------- # Feature engineering — ALL backward-looking (safe at close[i]) # --------------------------------------------------------------------------- def _rsi(close: pd.Series, n: int = 14) -> pd.Series: d = close.diff() up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean() dn = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean() rs = up / dn.replace(0, np.nan) return (100 - 100 / (1 + rs)).fillna(50.0) def _atr(df: pd.DataFrame, n: int = 14) -> pd.Series: h, l, c = df["high"], df["low"], df["close"] pc = c.shift(1) tr = pd.concat([(h - l), (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1) return tr.ewm(alpha=1 / n, adjust=False).mean() def build_features(df: pd.DataFrame) -> tuple[np.ndarray, list[str], np.ndarray]: """Return (X, names, warmup_valid_mask). Every column known at close[i].""" c = df["close"].astype(float) h = df["high"].astype(float) l = df["low"].astype(float) o = df["open"].astype(float) v = df["volume"].astype(float) logc = np.log(c) feats: dict[str, pd.Series] = {} # multi-lag simple returns (ret[i] uses close[i],close[i-k] -> known at i) for k in (1, 2, 3, 6, 12, 24): feats[f"ret{k}"] = c.pct_change(k) # candle geometry (current bar fully known at its close) rng = (h - l).replace(0, np.nan) feats["body"] = (c - o) / rng feats["upsh"] = (h - np.maximum(c, o)) / rng feats["dnsh"] = (np.minimum(c, o) - l) / rng feats["range_n"] = (h - l) / c # one-lag candle geometry feats["body1"] = ((c - o) / rng).shift(1) # momentum/acceleration feats["mom48"] = c.pct_change(48) feats["accel"] = c.pct_change(6) - c.pct_change(12) # RSI feats["rsi14"] = _rsi(c, 14) / 100.0 # ATR-normalized extension from a trend baseline ema = c.ewm(span=24, adjust=False).mean() atr = _atr(df, 14) feats["ext_atr"] = (c - ema) / atr.replace(0, np.nan) # realized vol (std of 1-bar returns) r1 = c.pct_change() feats["rvol24"] = r1.rolling(24).std() feats["rvol72"] = r1.rolling(72).std() feats["vol_ratio"] = feats["rvol24"] / feats["rvol72"].replace(0, np.nan) # position of close within recent window (0=low,1=high) for w in (24, 72): lo = l.rolling(w).min() hi = h.rolling(w).max() feats[f"pos{w}"] = (c - lo) / (hi - lo).replace(0, np.nan) # volume z-score vlog = np.log1p(v) feats["volz"] = (vlog - vlog.rolling(72).mean()) / vlog.rolling(72).std().replace(0, np.nan) names = list(feats.keys()) X = np.column_stack([feats[k].to_numpy(dtype=float) for k in names]) valid = np.isfinite(X).all(axis=1) return X, names, valid def forward_labels(df: pd.DataFrame, H: int): """label[i] = 1 if close[i+H] > close[i] else 0 ; fwd[i] = forward return.""" c = df["close"].to_numpy(float) n = len(c) fwd = np.full(n, np.nan) fwd[: n - H] = c[H:] / c[: n - H] - 1.0 y = (fwd > 0).astype(float) lab_valid = np.isfinite(fwd) return y, fwd, lab_valid # --------------------------------------------------------------------------- # Strict walk-forward probability # --------------------------------------------------------------------------- def walk_forward_proba(X, y, feat_valid, lab_valid, warmup, W, H, K, model_factory): """Return proba_up[i] for all i (NaN where not predicted). No leakage: when predicting block starting at b, training labels must be realized: i + H <= b-1, i.e. train indices < b - H. Training window is the last W such indices.""" n = len(y) proba = np.full(n, np.nan) start = warmup + W + H b = start while b < n: end_block = min(b + K, n) train_hi = b - H # exclusive; ensures label realized by b-1 train_lo = max(warmup, train_hi - W) idx = np.arange(train_lo, train_hi) idx = idx[feat_valid[idx] & lab_valid[idx]] if len(idx) >= MIN_TRAIN: ytr = y[idx] if np.unique(ytr).size == 2: Xtr = X[idx] sc = StandardScaler().fit(Xtr) model = model_factory() model.fit(sc.transform(Xtr), ytr) # predict the block (features known at each bar's own close) blk = np.arange(b, end_block) fv = feat_valid[blk] if fv.any(): pb = model.predict_proba(sc.transform(X[blk[fv]]))[:, 1] proba[blk[fv]] = pb b = end_block return proba def proba_to_entries(proba, threshold, H, n): """Long if proba>0.5+thr, short if proba<0.5-thr, else flat. Hold H bars.""" entries = [None] * n hi = 0.5 + threshold lo = 0.5 - threshold for i in range(n): p = proba[i] if not np.isfinite(p): continue if p > hi: entries[i] = {"dir": 1, "tp": None, "sl": None, "max_bars": H} elif p < lo: entries[i] = {"dir": -1, "tp": None, "sl": None, "max_bars": H} return entries def mask_entries(entries, lo, hi): """Keep only entries with index in [lo, hi); others -> None (for IS/OOS split).""" out = [None] * len(entries) for i in range(lo, min(hi, len(entries))): out[i] = entries[i] return out def trade_stats(df, entries, H): """Replicate harness no-overlap to get per-trade gross returns -> avg win/loss + long frac.""" c = df["close"].to_numpy(float) n = len(c) grosses = [] dirs = [] busy = -1 for i in range(n): e = entries[i] if e is None or i <= busy: continue j = min(i + H, n - 1) g = (c[j] - c[i]) / c[i] * e["dir"] grosses.append(g) dirs.append(e["dir"]) busy = j g = np.array(grosses) if len(g) == 0: return 0, 0.0, 0.0, 0.0, 0.0 wins = g[g > 0] losses = g[g <= 0] avg_w = wins.mean() if len(wins) else 0.0 avg_l = losses.mean() if len(losses) else 0.0 long_frac = float(np.mean(np.array(dirs) > 0)) return len(g), avg_w, avg_l, g.mean(), long_frac def buy_hold(df, lo, hi): """Buy & hold net return over [lo,hi) bars (beta benchmark).""" c = df["close"].to_numpy(float) hi = min(hi, len(c)) if hi - lo < 2: return 0.0 return c[hi - 1] / c[lo] - 1.0 # --------------------------------------------------------------------------- # Driver # --------------------------------------------------------------------------- def run(): ap = argparse.ArgumentParser() ap.add_argument("--quick", action="store_true", help="smaller grid (faster)") ap.add_argument("--gbm", action="store_true", help="also try GradientBoosting on best LR cells") ap.add_argument("--tf", default="1h") args = ap.parse_args() assets = ["BTC", "ETH"] tf = args.tf if args.quick: Ws = [8000] Hs = [12, 24] thresholds = [0.0, 0.05, 0.10] else: Ws = [4000, 8000, 16000] Hs = [6, 12, 24, 48] thresholds = [0.0, 0.03, 0.06, 0.10] def lr_factory(): return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced") print("=" * 100) print(f"TRACK B — walk-forward ML tf={tf} retrain_K={RETRAIN_K} held_out_tail={HELD_OUT_FRAC:.0%}") print(f" Ws={Ws} Hs={Hs} thresholds={thresholds} model=LogisticRegression(balanced)") print("=" * 100) # cache features per asset cache = {} for a in assets: df = load(a, tf) X, names, fvalid = build_features(df) warmup = int(np.argmax(fvalid)) if fvalid.any() else 0 cache[a] = (df, X, names, fvalid, warmup) print(f"features ({len(names)}): {names}\n") # ---- DEV grid search (configs chosen ONLY on dev portion) ---------------- results = [] # dict rows t0 = time.time() for a in assets: df, X, names, fvalid, warmup = cache[a] n = len(df) dev_hi = int(n * (1 - HELD_OUT_FRAC)) # dev = [0, dev_hi), held = [dev_hi, n) for W in Ws: for H in Hs: y, _fwd, lvalid = forward_labels(df, H) proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H, RETRAIN_K, lr_factory) for thr in thresholds: ent_full = proba_to_entries(proba, thr, H, n) ent_dev = mask_entries(ent_full, warmup, dev_hi) m = backtest_signals(df, ent_dev, fee_rt=0.001, asset=a, tf=tf) nt, aw, al, gmean, lf = trade_stats(df, ent_dev, H) results.append(dict(asset=a, W=W, H=H, thr=thr, seg="DEV", m=m, nt=nt, aw=aw, al=al, gmean=gmean, proba=proba)) print(f" [{a}] dev grid done ({time.time()-t0:.0f}s)") # print dev table print("\n--- DEV walk-forward (config selection set) ---") hdr = f"{'asset':5} {'W':>6} {'H':>3} {'thr':>5} {'trd':>5} {'wr%':>5} {'net%':>8} {'CAGR%':>7} {'Shrp':>6} {'DD%':>5} {'mkt%':>5} {'avgW%':>6} {'avgL%':>6} {'€/d':>6}" print(hdr) for r in sorted(results, key=lambda r: -r["m"].sharpe): m = r["m"] print(f"{r['asset']:5} {r['W']:>6} {r['H']:>3} {r['thr']:>5.2f} {m.n_trades:>5} " f"{m.win_rate:>5.1f} {m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} " f"{m.max_dd*100:>5.1f} {m.time_in_market*100:>5.0f} {r['aw']*100:>+6.2f} {r['al']*100:>+6.2f} " f"{m.daily_profit(2000):>+6.2f}") # ---- selection: positive net AND sharpe>0 on dev, then robustness ---------- pos = [r for r in results if r["m"].net_return > 0 and r["m"].sharpe > 0 and r["m"].n_trades >= 30] pos.sort(key=lambda r: -r["m"].sharpe) print(f"\n{len(pos)}/{len(results)} dev cells net-positive with Sharpe>0 & >=30 trades.") # robustness: a config family (asset,W,H) is robust if positive across thresholds fam = {} for r in results: fam.setdefault((r["asset"], r["W"], r["H"]), []).append(r) robust_fams = [] for key, rs in fam.items(): npos = sum(1 for r in rs if r["m"].net_return > 0 and r["m"].sharpe > 0) if npos >= max(2, int(0.6 * len(rs))): robust_fams.append((key, npos, len(rs))) robust_fams.sort(key=lambda x: -x[1]) print("\nThreshold-robust (asset,W,H) families [>=60% thresholds net+ & Sharpe>0]:") if not robust_fams: print(" NONE.") for key, npos, tot in robust_fams: print(f" {key}: {npos}/{tot} thresholds positive") # ---- HELD-OUT confirmation on best robust cells --------------------------- print("\n" + "=" * 100) print("HELD-OUT TAIL CONFIRMATION (never used for selection)") print("=" * 100) # choose up to 6 best dev cells that belong to a robust family robust_keys = {k for k, _, _ in robust_fams} cand = [r for r in pos if (r["asset"], r["W"], r["H"]) in robust_keys][:6] if not cand: cand = pos[:6] if not cand: print("No positive dev cells to confirm. ML did not beat fees on dev.") print(hdr) held_rows = [] for r in cand: a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"] df = cache[a][0] n = len(df) dev_hi = int(n * (1 - HELD_OUT_FRAC)) ent_full = proba_to_entries(r["proba"], thr, H, n) ent_held = mask_entries(ent_full, dev_hi, n) m = backtest_signals(df, ent_held, fee_rt=0.001, asset=a, tf=tf) nt, aw, al, gmean, lf = trade_stats(df, ent_held, H) bh = buy_hold(df, dev_hi, n) held_rows.append((r, m, aw, al, lf, bh)) print(f"{a:5} {W:>6} {H:>3} {thr:>5.2f} {m.n_trades:>5} {m.win_rate:>5.1f} " f"{m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} {m.max_dd*100:>5.1f} " f"{m.time_in_market*100:>5.0f} {aw*100:>+6.2f} {al*100:>+6.2f} {m.daily_profit(2000):>+6.2f} " f"long={lf*100:>3.0f}% B&H={bh*100:>+7.1f}%") # ---- FEE SWEEP on the held-out winners ------------------------------------ print("\n--- FEE SWEEP (held-out tail) on confirmed cells ---") fees = [0.0005, 0.001, 0.0015, 0.002] print(" (B&H = buy&hold over held-out tail; if net% << B&H the 'edge' is just beta)") for r, _, _, _, _, _ in held_rows[:4]: a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"] df = cache[a][0] n = len(df) dev_hi = int(n * (1 - HELD_OUT_FRAC)) ent_held = mask_entries(proba_to_entries(r["proba"], thr, H, n), dev_hi, n) line = f" {a} W{W} H{H} thr{thr:.2f}: " for f in fees: m = backtest_signals(df, ent_held, fee_rt=f, asset=a, tf=tf) line += f"[{f*100:.2f}%]net={m.net_return*100:>+6.1f}% Shrp={m.sharpe:>+4.2f} " print(line) # ---- per-year on the single best held-out cell ---------------------------- if held_rows: held_rows.sort(key=lambda x: -x[1].sharpe) r, m, aw, al, lf, bh = held_rows[0] a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"] print(f"\n--- Per-year (best held-out): {a} W{W} H{H} thr{thr:.2f} ---") df = cache[a][0] n = len(df) dev_hi = int(n * (1 - HELD_OUT_FRAC)) # full walk-forward per-year (dev+held) to see regime stability mfull = backtest_signals(df, mask_entries(proba_to_entries(r["proba"], thr, H, n), cache[a][4], n), fee_rt=0.001, asset=a, tf=tf) mfull.print_summary(f"{a} W{W}H{H}thr{thr:.2f} FULL-WF") mfull.print_yearly() print(f"\nTotal runtime {time.time()-t0:.0f}s") print("\n" + "=" * 100) print("VERDICT (see docs/diary/2026-06-19-trackB-ml.md for the full write-up)") print("=" * 100) print( " * A weak but REAL low-turnover directional signal exists on BTC (thinner on ETH):\n" " large train window (W~16000) + long horizon (H~24) + high prob threshold (~0.10).\n" " * It beats fees at 0.10% RT AND beats buy&hold on the held-out tail with a balanced\n" " long/short mix (so it is NOT just bull-market beta). Payoff: ~53% WR, avgWin>avgLoss.\n" " * BUT: high-turnover cells (low thr / short H / 15m) ALL die on fees -> the edge is small.\n" " Returns concentrate in a few years (2021,2025) with a -38% year (2023); DD 23-56%.\n" " * EUR/day on 2000 ~= +0.3..+0.6 baseline. Target is 50/day -> ~100x short. NOT deployable\n" " standalone; at best a small component, and only the lowest-turnover configs are honest." ) if __name__ == "__main__": run()