dc2b5697da
- trackA trend, trackB ML, trackC mean-rev, trackD trend-portfolio, trackE xsec/ensemble - VERDICT: Track D vol-targeted BTC+ETH trend portfolio is the one robust deployable earner (Sharpe 1.0-1.32, DD 13-19%, positive every year 2019-2026) - mean-reversion confirmed dead on clean data; weak-but-real ML/trend residuals - honest: EUR50/day on 2000 in 1-2y is not reachable (needs ~137k capital or ruinous DD)
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""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()
|