diff --git a/.gitignore b/.gitignore index 64ff7e0..38fd015 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ data/paper_portfolio/ # output grezzo dello sweep di ricerca xsec (rigenerabile dagli script in runs/) scripts/research/xsec/runs/out/ + +# blind-signal derived data (regenerable via make_blind.py) +data/blind/ +scripts/research/blind/leaderboard.json diff --git a/docs/diary/2026-06-21-blind-signal-fleet.md b/docs/diary/2026-06-21-blind-signal-fleet.md new file mode 100644 index 0000000..3d38c93 --- /dev/null +++ b/docs/diary/2026-06-21-blind-signal-fleet.md @@ -0,0 +1,111 @@ +# 2026-06-21 — Blind signal fleet: 52 agenti "esperti di segnali" su curve anonime BTC/ETH + +## Obiettivo (richiesta utente) + +Far partire ~50 subagenti **esperti di segnali** a cui passare lo storico di **ETH e BTC +in forma ANONIMA** ("senza dire di cosa sono, con curve sovrapposte"): devono trovare come +**anticipare l'andamento**, liberi di scrivere script o reti neurali ad hoc. L'**orchestratore** +valuta la validità su **PnL e maxDD**. + +L'idea forte del setup cieco: se gli agenti non sanno che sono BTC/ETH, non possono +pattern-matchare a memoria il crash COVID 2020 / l'orso 2022 / l'halving 2024 — devono trovare +un timing **trasferibile**, non riconoscere l'era. È anche un test di onestà del metodo: l'edge +deve reggere su un hold-out che gli agenti non hanno mai visto. + +## Setup — harness cieco e leak-free (prima degli agenti) + +> 50 agenti su un harness che perde = 50 fantasie (lezione fondante del progetto). Quindi prima +> l'infrastruttura, poi la flotta. + +- `scripts/research/blind/make_blind.py` — esporta BTC/ETH **1d** (via il path certificato + `altlib.get`) come **"Series A" / "Series B"**: rebase a **100** (curve sovrapposte, il livello + non urla più "$60k bitcoin"), **calendario sintetico** dal 2001 (niente era-crypto da + riconoscere), volume normalizzato alla mediana. Split **70% train (visibile agli agenti) / 30% + test (solo orchestratore)**. Mapping A=BTC, B=ETH tenuto FUORI dal meta visibile. +- `scripts/research/blind/blindlib.py` — l'unico modulo che un agente importa. Evaluator + leak-free: la posizione decisa a `close[i]` è **shiftata** e tenuta nella barra `i+1` (impossibile + leakare moltiplicando un peso per il rendimento della stessa barra), fee su turnover (Deribit + 0.10% RT). Toolkit di indicatori causali ri-esportati da altlib. +- **Guardia di causalità automatica** (`causality_ok`): ri-chiama `signal()` su un **prefisso + troncato** e pretende che la coda combaci con `signal()` sull'array intero. Qualunque segnale che + sbircia il futuro (shift(-k), finestre centrate, fit globale, statistiche full-sample) **diverge → + squalificato**. È ciò che rende onesta anche la "rete neurale ad hoc": un modello fittato sul df + intero (che a test-time contiene il futuro) fallisce la guardia; passa solo l'expanding/walk-forward. +- `score_all.py` — il **giudice unico dell'orchestratore**: per ogni modulo gira la guardia, valuta + sul **test held-out** A e B, ordina per PnL/maxDD vs benchmark buy&hold. +- `verify_top.py` — secondo strato avversariale: corr al trend canonico TSMOM, fee-stress 0.20% RT, + jackknife drop-block. + +Verifica dell'harness: momentum onesto → causale ok, OOS +44% a 19% DD; segnale **deliberatamente +leaky** (guarda domani) → Sharpe 18 assurdo ma **correttamente squalificato**. Benchmark buy&hold +OOS sul tail = **−7% PnL, 68% DD, Sharpe 0.22** (il tail 2024-26 contiene un drawdown brutale → +anticipare il movimento ha spazio reale per vincere). + +## Flotta — 52 agenti, 52 ipotesi distinte + +Workflow `blind-signal-fleet` (52 agenti in parallelo, ~2h, 2.5M token, 971 tool-call). A ognuno +**un'ipotesi diversa** (per non riscoprire tutti il momentum): 11 famiglie — trend/TSMOM, +breakout (Donchian/Keltner/squeeze/pivot/volbreak), mean-rev/oscillatori (RSI/Bollinger/zrev/stoch/ +DPO/WillR), vol-regime (vol-target/regime-switch/ATR-ride/dd-derisk/**vol-of-vol**), struttura +(HHLL/channel-pos), statistici (Hurst/autocorr/efficiency/skew/entropy), ciclo (FFT/Kalman), +volume (OBV/PVT/vol-div), **8 ML** (Ridge, logistic, MLP-reg, MLP-clf, GBM, kNN-analog, RLS, +RandomForest) e 5 meta/ensemble. + +**Esito flotta: 52/52 riportati, 52/52 passano la guardia di causalità** (zero look-ahead — la +disciplina dell'harness ha tenuto su tutta la flotta, ML inclusi). + +## Risultati OOS (orchestratore — PnL & maxDD sul test held-out) + +Benchmark buy&hold OOS: **PnL −7%, maxDD 68%**. Top per Sharpe-min (peggiore tra A e B): + +| # | strategia | PnL_A | PnL_B | DD worst | Sh_min | famiglia | +|---|---|---|---|---|---|---| +| 1 | macd | +23% | +19% | **11%** | 0.84 | trend | +| 2 | accel | +40% | +22% | 12% | 0.79 | trend (2ª diff) | +| 3 | vol_of_vol | +30% | +32% | 21% | 0.69 | vol-regime | +| 4 | regime_switch | +25% | +46% | 20% | 0.63 | vol-regime | +| 5 | rf (ML) | +12% | +8% | **7%** | 0.62 | ML walk-fwd | +| 6 | obv | +22% | +20% | 16% | 0.60 | volume | + +Tutti i top sono varianti **trend/vol-regime**. Mean-reversion e ML (logistic/gbm/mlp) in fondo → +ri-conferma cieca di "mean-rev morto" e "ML walk-forward debole" del progetto. Lo **Sharpe OOS ~0.84 +decade dal train ~1.4** (firma classica di overfit/regime). Ma vs buy&hold (−7%/68% DD) i top trend +**ribaltano il segno e tagliano il DD ~3-6×**: è il valore reale, identico alla lezione TP01. + +## Verifica avversariale — 3 scettici indipendenti (REFUTE, non confirm) + +1. **Regime-luck** → **REFUTED ×3.** I top-5 bar su ~800 OOS forniscono il **67-102% di tutto il + PnL**; togliendo 10 bar la serie va **negativa**; `accel` crolla nel terzo finale (COMB Sharpe + **−1.21**); A e B non concordano su *quando* funziona. Edge concentrato, non distribuito. +2. **Trend-redundancy** → **REFUTED ×4.** Regressione `cand ~ α + β·TSMOM` (Newey-West HAC): + **t(α) = +0.92..+1.51, nessuno supera 1.96**. corr-al-trend 0.34-0.74, β 0.45-0.73; media residua + +0.05-0.08/anno = rumore. Sono TSMOM meglio tarati, **non alpha ortogonale**; contro il TP01 reale + (~1.3) il margine svanisce. +3. **Overfit/robustezza** → MACD **non-refuted** (plateau vero a un asse, 0% celle <0.5) ma Sharpe OOS + onesto **0.84, non 1.40** (numero da docstring = in-sample). `accel` **REFUTED** (il termine di + accelerazione, la sua tesi, **danneggia** l'OOS; LAG knife-edge: −20% → −63% Sharpe; corner + congiunti negativi). `vol_of_vol` **REFUTED** (gate threshold-fit: PCTL 0.80→0.60 distrugge il 73% + dello Sharpe OOS). Fee = drag secondario ~10%, non il killer; il killer è la sensibilità ai parametri. + +## Verdetto + +**52 agenti ciechi, orchestratore che valuta PnL e maxDD su hold-out, e NIENTE di nuovo +sopravvive alla verifica avversariale.** Ogni "vincitore" è trend-beta di due curve strutturalmente +rialziste; soffitto Sharpe OOS **~0.84** su questo singolo hold-out; nessun alpha statisticamente +distinguibile dal TSMOM. È una **ri-conferma INDIPENDENTE e CIECA del soffitto direzionale ~1.3** del +progetto e del pattern "TSMOM travestito" — raggiunta da agenti che non sapevano nemmeno fossero +BTC/ETH. Il più solido è **macd** (plateau vero, OOS Sharpe 0.84, DD 11%): classe-TP01, +**forward-monitor al più, non deploy**. Conferma le regole: (a) giudicare lo Sharpe **marginale vs +TP01**, non assoluto; (b) un hold-out corto premia chi è stato fortunato in pochi bar. + +### Valore metodologico (cosa resta) + +L'harness cieco riusabile: `data/blind/` + `blindlib`/`blind_eval`/`score_all`/`verify_top`. La +**guardia di causalità online** ha tenuto 52 strategie (ML incluso) leak-free senza intervento +manuale → strumento da riusare per ogni futura flotta. La pipeline "anonimizza → fan-out cieco → +giudice unico OOS → 3 scettici (regime-luck / trend-redundancy / overfit)" ha ucciso ogni falso +positivo che lo Sharpe assoluto avrebbe promosso. + +File: `scripts/research/blind/{make_blind,blindlib,blind_eval,score_all,verify_top}.py`, +`agents/agent_00..51_*.py` (52 moduli), `leaderboard.json`, `verify_top.json`, +`SKEPTIC_VERDICTS.json`. Dati rigenerabili: `data/blind/` (gitignored). diff --git a/scripts/research/blind/SKEPTIC_VERDICTS.json b/scripts/research/blind/SKEPTIC_VERDICTS.json new file mode 100644 index 0000000..af3b14c --- /dev/null +++ b/scripts/research/blind/SKEPTIC_VERDICTS.json @@ -0,0 +1,13 @@ +{ + "oos_benchmark_buyhold": {"pnl": -0.07, "maxdd": 0.68, "sharpe": 0.22}, + "top_survivors_oos": { + "agent_04_macd": {"pnl_A": 0.23, "pnl_B": 0.19, "maxdd": 0.11, "sharpe_min": 0.84, "corr_to_trend": 0.52}, + "agent_06_accel": {"pnl_A": 0.40, "pnl_B": 0.22, "maxdd": 0.12, "sharpe_min": 0.79, "corr_to_trend": 0.50}, + "agent_23_vol_of_vol":{"pnl_A": 0.30, "pnl_B": 0.32, "maxdd": 0.21, "sharpe_min": 0.69, "corr_to_trend": 0.46}, + "agent_44_obv": {"pnl_A": 0.22, "pnl_B": 0.20, "maxdd": 0.16, "sharpe_min": 0.60, "corr_to_trend": 0.31} + }, + "skeptic_regime_luck": "REFUTED x3 - top-5 of ~800 OOS bars supply 67-102% of PnL; drop-10 turns negative; accel COMB final-third Sharpe -1.21; A & B disagree on WHEN it works.", + "skeptic_trend_redundancy": "REFUTED x4 - Newey-West HAC alpha t-stats +0.92..+1.51 (none > 1.96); corr-to-trend 0.34-0.74, beta 0.45-0.73; residual mean +0.05-0.08/yr = noise. Better-tuned TSMOM, not orthogonal alpha.", + "skeptic_overfit": "MACD not-refuted (genuine one-axis plateau, OOS Sh 0.84 not train 1.40); ACCEL REFUTED (acceleration term HURTS OOS, LAG knife-edge -63% on -20%); VOV REFUTED (PCTL 0.80->0.60 destroys 73% of OOS Sharpe).", + "verdict": "52 blind agents, orchestrator scored all on OOS PnL & maxDD. NOTHING new survives. All winners are trend-beta of two up-trending curves; OOS Sharpe ceiling ~0.84 (decayed from train ~1.4); no statistically distinguishable alpha vs TSMOM. Independent BLIND re-confirmation of the project's ~1.3 directional ceiling. macd = least-bad, TP01-class, forward-monitor not deploy." +} diff --git a/scripts/research/blind/_skeptic_perturb.py b/scripts/research/blind/_skeptic_perturb.py new file mode 100644 index 0000000..9b085a7 --- /dev/null +++ b/scripts/research/blind/_skeptic_perturb.py @@ -0,0 +1,225 @@ +"""Adversarial parameter-perturbation harness for the 3 blind survivors. +Re-implements each signal parameterized; perturbs each key param +/-25% (and larger +jumps), re-evaluates OOS (test slice, A & B) and train. Reports min/median/max OOS +Sharpe across the grid and the train->test Sharpe decay. Also a fee bump to 0.20% RT. +""" +import sys +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/blind") +import blindlib as bl + +FEE_BASE = 0.0005 # 0.10% RT +FEE_BUMP = 0.001 # 0.20% RT + + +def _masks(series): + df = bl.load(series, "full") + cut = bl.split_cut(series) + test = np.zeros(len(df), bool); test[cut:] = True + train = np.zeros(len(df), bool); train[:cut] = True + return df, train, test + + +# ---------------- agent_04 MACD ---------------- +def macd_signal(df, FAST=26, SLOW=52, SIGNAL=9, SLOPE_W=0.20, SHORT_W=0.5, + TARGET_VOL=0.20, VOL_WIN=30, LEV_CAP=1.0): + c = df["close"].values.astype(float) + macd = bl.ema(c, FAST) - bl.ema(c, SLOW) + signal_line = bl.ema(macd, SIGNAL) + hist = macd - signal_line + base = np.where(np.sign(hist) == np.sign(macd), np.sign(macd), 0.0) + slope = np.sign(np.diff(hist, prepend=hist[0])) + raw = (1.0 - SLOPE_W) * base + SLOPE_W * slope + raw = np.clip(raw, -1.0, 1.0) + raw = np.where(raw < 0, raw * SHORT_W, raw) + raw = np.nan_to_num(raw, nan=0.0) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN, + leverage_cap=LEV_CAP) + return np.clip(pos, -1.0, 1.0) + + +# ---------------- agent_06 accel ---------------- +def _lagged_diff(x, lag): + out = np.zeros(len(x)) + if lag < len(x): + out[lag:] = x[lag:] - x[:-lag] + return out + + +def accel_signal(df, FAST=28, LAG=30, Z_WIN=200, KV=1.5, KA=1.5, W_VEL=0.4, + W_ACC=0.6, SHORT_W=0.0, TARGET_VOL=0.27, VOL_WIN=25, LEV_CAP=1.5): + c = df["close"].values.astype(float) + lr = np.zeros(len(c)); lr[1:] = np.log(c[1:] / c[:-1]) + vel = bl.ema(lr, FAST) + acc = _lagged_diff(vel, LAG) + zv = np.nan_to_num(bl.zscore(vel, Z_WIN), nan=0.0) + za = np.nan_to_num(bl.zscore(acc, Z_WIN), nan=0.0) + raw = W_VEL * np.tanh(KV * zv) + W_ACC * np.tanh(KA * za) + raw = np.clip(raw, -1.0, 1.0) + raw = np.where(raw >= 0.0, raw, raw * SHORT_W) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN, + leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) + + +# ---------------- agent_23 vol_of_vol ---------------- +def _expanding_pctl_rank(x, min_hist): + n = len(x); rank = np.full(n, np.nan); seen = [] + for i in range(n): + v = x[i] + if np.isfinite(v): + seen.append(v) + if len(seen) >= min_hist: + rank[i] = float(np.mean(np.asarray(seen) <= v)) + return rank + + +def _tsmom_sign(c, h): + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def _vol_of_vol(rv, win): + rv_s = pd.Series(rv) + logrv = np.log(rv_s.where(rv_s > 0)) + dlog = logrv.diff() + return dlog.rolling(win, min_periods=max(5, win // 2)).std().values + + +def vov_signal(df, RV_WIN=30, VOV_WIN=40, PCTL=0.80, HORIZONS=(25, 60, 120), + TARGET_VOL=0.22, VOL_WIN=45, LEV_CAP=1.5, MIN_HIST=60): + c = df["close"].values.astype(float) + bpy = bl.bars_per_day(df) * 365.25 + rv = bl.realized_vol(bl.simple_returns(c), RV_WIN, bpy) + vov = _vol_of_vol(rv, VOV_WIN) + rank = _expanding_pctl_rank(vov, MIN_HIST) + stable = np.isfinite(rank) & (rank <= PCTL) + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + raw = np.where(stable, sig, 0.0) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, vol_win_days=VOL_WIN, + leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) + + +def score(sig_fn, kwargs, fee=FEE_BASE): + """Return dict of train & test sharpe/pnl, averaged over A&B (min/mean).""" + out = {} + for s in ("A", "B"): + df, train, test = _masks(s) + tgt = sig_fn(df, **kwargs) + rtr = bl.eval_target(df, tgt, fee_side=fee, metric_mask=train) + rte = bl.eval_target(df, tgt, fee_side=fee, metric_mask=test) + out[s] = dict(tr_sh=rtr["sharpe"], tr_pnl=rtr["pnl"], + te_sh=rte["sharpe"], te_pnl=rte["pnl"], te_dd=rte["maxdd"]) + # combined: min across A,B (the agents tuned on sharpe_min) + te_sh_min = min(out["A"]["te_sh"], out["B"]["te_sh"]) + tr_sh_min = min(out["A"]["tr_sh"], out["B"]["tr_sh"]) + te_sh_mean = 0.5 * (out["A"]["te_sh"] + out["B"]["te_sh"]) + te_pnl_mean = 0.5 * (out["A"]["te_pnl"] + out["B"]["te_pnl"]) + return dict(out=out, te_sh_min=te_sh_min, tr_sh_min=tr_sh_min, + te_sh_mean=te_sh_mean, te_pnl_mean=te_pnl_mean) + + +def perturb_grid(sig_fn, base, grid): + """grid: {param: [values]}. Sweep one param at a time around base.""" + base_sc = score(sig_fn, base) + rows = [] + for p, vals in grid.items(): + for v in vals: + kw = dict(base); kw[p] = v + sc = score(sig_fn, kw) + rows.append(dict(param=p, val=v, te_sh_min=sc["te_sh_min"], + te_sh_mean=round(sc["te_sh_mean"], 3), + te_pnl_mean=round(sc["te_pnl_mean"], 3), + tr_sh_min=sc["tr_sh_min"])) + return base_sc, rows + + +if __name__ == "__main__": + import json + pd.set_option("display.width", 160) + pd.set_option("display.max_rows", 300) + + print("="*70) + print("AGENT 04 — MACD") + print("="*70) + base04 = dict(FAST=26, SLOW=52, SIGNAL=9, SLOPE_W=0.20, SHORT_W=0.5, + TARGET_VOL=0.20, VOL_WIN=30, LEV_CAP=1.0) + b, rows = perturb_grid(macd_signal, base04, dict( + FAST=[20, 22, 26, 30, 32, 39], # +/-25% + bigger + SLOW=[39, 45, 52, 60, 65, 78], + SIGNAL=[5, 7, 9, 11, 13, 18], + SLOPE_W=[0.10, 0.15, 0.20, 0.25, 0.30, 0.40], + SHORT_W=[0.0, 0.25, 0.375, 0.5, 0.625, 0.75, 1.0], + VOL_WIN=[15, 22, 30, 38, 45, 60], + TARGET_VOL=[0.15, 0.20, 0.25, 0.30], + )) + print("BASE:", json.dumps({k: b[k] for k in ("tr_sh_min","te_sh_min","te_sh_mean","te_pnl_mean")})) + print(" per-series base:", b["out"]) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n" + "="*70) + print("AGENT 06 — ACCEL") + print("="*70) + base06 = dict(FAST=28, LAG=30, Z_WIN=200, KV=1.5, KA=1.5, W_VEL=0.4, + W_ACC=0.6, SHORT_W=0.0, TARGET_VOL=0.27, VOL_WIN=25, LEV_CAP=1.5) + b, rows = perturb_grid(accel_signal, base06, dict( + FAST=[21, 24, 28, 32, 35, 42], + LAG=[20, 26, 30, 36, 40, 50], + Z_WIN=[140, 160, 200, 240, 260, 320], + KV=[1.0, 1.2, 1.5, 1.8, 2.0, 3.0], + KA=[1.0, 1.2, 1.5, 1.8, 2.0, 3.0], + W_ACC=[0.3, 0.45, 0.6, 0.75, 0.9, 1.0], + TARGET_VOL=[0.18, 0.22, 0.27, 0.32], + VOL_WIN=[18, 22, 25, 30, 35], + )) + print("BASE:", json.dumps({k: b[k] for k in ("tr_sh_min","te_sh_min","te_sh_mean","te_pnl_mean")})) + print(" per-series base:", b["out"]) + print(pd.DataFrame(rows).to_string(index=False)) + + print("\n" + "="*70) + print("AGENT 23 — VOL_OF_VOL") + print("="*70) + base23 = dict(RV_WIN=30, VOV_WIN=40, PCTL=0.80, HORIZONS=(25, 60, 120), + TARGET_VOL=0.22, VOL_WIN=45, LEV_CAP=1.5, MIN_HIST=60) + b, rows = perturb_grid(vov_signal, base23, dict( + RV_WIN=[22, 26, 30, 34, 38, 45], + VOV_WIN=[30, 35, 40, 45, 50, 60], + PCTL=[0.60, 0.70, 0.76, 0.80, 0.84, 0.90, 1.00], + TARGET_VOL=[0.18, 0.22, 0.26, 0.30], + VOL_WIN=[34, 40, 45, 55, 60], + MIN_HIST=[40, 60, 90], + )) + print("BASE:", json.dumps({k: b[k] for k in ("tr_sh_min","te_sh_min","te_sh_mean","te_pnl_mean")})) + print(" per-series base:", b["out"]) + # horizons sweep separately (tuple param) + hz_rows = [] + for hz in [(20,50,100),(25,60,120),(30,70,140),(20,40,80),(40,90,180),(15,30,60)]: + kw = dict(base23); kw["HORIZONS"] = hz + sc = score(vov_signal, kw) + hz_rows.append(dict(param="HORIZONS", val=str(hz), te_sh_min=sc["te_sh_min"], + te_sh_mean=round(sc["te_sh_mean"],3), + te_pnl_mean=round(sc["te_pnl_mean"],3), tr_sh_min=sc["tr_sh_min"])) + print(pd.DataFrame(rows + hz_rows).to_string(index=False)) + + # ---- FEE BUMP to 0.20% RT, base params ---- + print("\n" + "="*70) + print("FEE BUMP 0.10% -> 0.20% RT (base params)") + print("="*70) + for name, fn, base in [("MACD", macd_signal, base04), + ("ACCEL", accel_signal, base06), + ("VOV", vov_signal, base23)]: + lo = score(fn, base, fee=FEE_BASE) + hi = score(fn, base, fee=FEE_BUMP) + print(f"{name:6s} te_sh_min {lo['te_sh_min']:+.3f} -> {hi['te_sh_min']:+.3f} | " + f"te_sh_mean {lo['te_sh_mean']:+.3f} -> {hi['te_sh_mean']:+.3f} | " + f"te_pnl_mean {lo['te_pnl_mean']:+.3f} -> {hi['te_pnl_mean']:+.3f}") + print(f" per-series @0.20%: A te_sh {score(fn,base,fee=FEE_BUMP)['out']['A']['te_sh']} " + f"B te_sh {score(fn,base,fee=FEE_BUMP)['out']['B']['te_sh']}") diff --git a/scripts/research/blind/agents/_template.py b/scripts/research/blind/agents/_template.py new file mode 100644 index 0000000..265e010 --- /dev/null +++ b/scripts/research/blind/agents/_template.py @@ -0,0 +1,31 @@ +"""TEMPLATE for a blind-signal agent. COPY this, rename, implement `signal`. + +You are given two anonymized, overlaid price curves ("A" and "B"), rebased to 100. +You do NOT know what they are. Find a way to ANTICIPATE the next move. + +Rules (enforced automatically — break them and you are disqualified): + * `signal(df)` returns float array len(df). position[i] in [-1,+1] = how much of + equity to hold during the NEXT bar (sign=long/short, 0=flat). The evaluator + shifts it -> you trade bar i+1 with a decision made at close[i]. + * CAUSAL/ONLINE only: position[i] uses ONLY rows 0..i. No .shift(-k), no centered + windows, no fitting a model on the whole df then predicting the whole df. + If you train a model, use an EXPANDING/WALK-FORWARD scheme (refit using only + past rows) or fit once on an EARLY fixed warmup and freeze. + * Tune ONLY on split='train'. The held-out tail is scored by the orchestrator. + +Score it: + uv run python scripts/research/blind/blind_eval.py --module --split train +Make sure the output has "causality": {"ok": true, ...}. +""" +import numpy as np +import blindlib as bl + + +def signal(df): + c = df["close"].values.astype(float) + # --- EXAMPLE: vol-targeted dual-timescale momentum (replace with your idea) --- + fast = c / bl.sma(c, 20) - 1.0 + slow = c / bl.sma(c, 100) - 1.0 + raw = np.sign(fast) * 0.5 + np.sign(slow) * 0.5 # -1..1 direction + pos = bl.vol_target(raw, df, target_vol=0.20, vol_win_days=30, leverage_cap=1.0) + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_00_sma_trend.py b/scripts/research/blind/agents/agent_00_sma_trend.py new file mode 100644 index 0000000..7c13cf4 --- /dev/null +++ b/scripts/research/blind/agents/agent_00_sma_trend.py @@ -0,0 +1,44 @@ +"""agent_00_sma_trend — ANGLE: trend / single long SMA (long/flat). + +Idea (assigned angle): go LONG only while price is meaningfully above a single long +simple moving average, otherwise FLAT. The long SMA defines the macro trend; staying +flat below it is what cuts the asset's ~77% buy&hold drawdown to ~1/3. + +Tuned on split='train' only (both Series A and B, equal weight): + * window W = 150 (canonical long SMA; sits on a wide robust plateau W=135..165) + * band B = 0.02 (require close > 1.02*SMA -> avoids whipsaw chop near the line) + * vol-target the long exposure to 35% ann vol (vol_win=30d, cap 1.0). This is what + actually controls drawdown: long size shrinks when realized vol spikes (every + crypto-like crash is a vol spike), so we're never full-size into the worst bars. + +Everything is causal: SMA(close[..i]), realized vol(returns[..i]). No future rows. +The evaluator shifts position by one bar (decision at close[i] -> held bar i+1). + +Train (combined A&B): pnl_mean ~ 5.4, maxdd_worst ~ 0.30, sharpe_min ~ 1.36. +Honest note: this is a DEFENSIVE trend filter, not alpha — its value is converting a +high-PnL/high-DD uptrend into comparable risk-adjusted PnL at a MUCH smaller drawdown. +""" +import numpy as np +import blindlib as bl + +W = 150 # single long SMA window +BAND = 0.02 # long only when close > (1+BAND)*SMA(W) +TARGET_VOL = 0.35 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def signal(df): + c = df["close"].values.astype(float) + sma = bl.sma(c, W) # causal SMA up to i + + # long/flat gate vs the single long SMA, with a band to dodge whipsaw near the line + long_gate = np.where(c > sma * (1.0 + BAND), 1.0, 0.0) + long_gate[:W] = 0.0 # no signal before the SMA is defined + long_gate[~np.isfinite(sma)] = 0.0 + + # size the long with causal vol-targeting (shrinks into vol spikes -> cuts DD) + pos = bl.vol_target(long_gate, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_01_ema_cross.py b/scripts/research/blind/agents/agent_01_ema_cross.py new file mode 100644 index 0000000..32f2094 --- /dev/null +++ b/scripts/research/blind/agents/agent_01_ema_cross.py @@ -0,0 +1,40 @@ +"""Agent 01 — Dual EMA crossover (family=trend, slug=ema_cross). + +The angle: long/short on the sign of (fast EMA - slow EMA). The two spans are the +core tuned knobs. One refinement that survived a plateau check on split='train': +the two anonymized curves are strongly up-trending, so a SYMMETRIC short is pure +drag (it shorts the dips of a bull market). We keep the long/short crossover but +size the SHORT side down by `SHORT_W` — still a genuine long/short EMA cross, just +risk-asymmetric. Direction is then vol-targeted (causal trailing window) so the two +curves are sized comparably and the drawdown stays bounded. + +Tuning (train only): a broad plateau f in [18..30], s in [40..50], SHORT_W in +[0.1..0.3] all give sharpe_min ~1.3 / DD ~0.23. f=25, s=40, SHORT_W=0.25 sits in +the plateau interior (not on a grid edge) -> robust, not a lucky cell. + +CAUSAL: ema(c, span) is an online recursion (value at i uses rows 0..i only); +vol_target uses a trailing vol window. No look-ahead, no centered windows, no +global fit. Verified by causality_ok (max_diff 0.0). +""" +import numpy as np +import blindlib as bl + +# --- tuned ONLY on split='train' (plateau interior) --- +FAST_SPAN = 25 +SLOW_SPAN = 40 +SHORT_W = 0.25 # short side sized down (asymmetric L/S); 0 -> long-flat +TARGET_VOL = 0.20 +VOL_WIN = 30 +LEV_CAP = 1.0 + + +def signal(df): + c = df["close"].values.astype(float) + fast = bl.ema(c, FAST_SPAN) + slow = bl.ema(c, SLOW_SPAN) + # +1 when fast above slow, -SHORT_W when below: genuine EMA-cross direction, + # short side de-weighted because the curves are persistently up-trending. + raw = np.where(fast >= slow, 1.0, -SHORT_W) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN, leverage_cap=LEV_CAP) + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_02_tsmom_multi.py b/scripts/research/blind/agents/agent_02_tsmom_multi.py new file mode 100644 index 0000000..d4be3f0 --- /dev/null +++ b/scripts/research/blind/agents/agent_02_tsmom_multi.py @@ -0,0 +1,72 @@ +"""Agent 02 — TSMOM multi-horizon (family=trend, slug=tsmom_multi). + +The angle (assigned): time-series momentum over several lookback horizons. For each +horizon H in {~30, ~90, ~180} bars take the SIGN of the past-H-bar return (is the +asset up or down vs H bars ago?), average the three signs into a -1..+1 direction, +then size it with a causal vol-target so the two curves are risk-comparable and the +drawdown stays bounded. + +Why multi-horizon: a single lookback is regime-fragile (whipsaws when its window +straddles a chop). Averaging 1/3/6-month TSMOM signs is the classic TP01 trick — +the slow horizon carries the macro trend, the fast ones cut exposure early into a +turn. On these two persistently up-trending curves the net effect is to stay long +through the bull and de-risk (toward flat / light short) into the big declines, +turning a ~77-79% buy&hold drawdown into a much smaller one at comparable PnL. + +Long-short vs long-flat: a symmetric short bleeds in a structural bull (it shorts +the dips). Tuned on split='train', a lightly de-weighted short (SHORT_W<1) beats both +pure long-flat (misses the protection of going short the worst legs) and a symmetric +long-short (too much drag). SHORT_W=0.25 sits in the interior of a flat plateau. + +CAUSAL: each horizon return uses close[i]/close[i-H] (rows <= i only); vol_target +uses a trailing realized-vol window. No look-ahead, no centered windows, no global +fit. Verified by causality_ok (max_diff 0.0). + +Tuning (train only, combined A&B). A coarse->fine sweep found a WIDE plateau around +slow horizons ~ (1.5, 4.5, 8 months): the whole block H1 in [40..55], H2 in [120..130], +H3 = 240 gives sharpe_min 1.25..1.41 at DD 0.16..0.21. The chosen cell is interior on +every axis (all 8 H-neighbors, sw, vw within the plateau) -> robust, not a lucky spike: + horizons = (45, 130, 240) # ~1.5 / 4.5 / 8 months of daily bars + SHORT_W = 0.25 # asymmetric L/S; plateau sw in [0.0..0.5] + TARGET_VOL=0.30, VOL_WIN=45d, LEV_CAP=1.5 + -> train combined: pnl_mean ~3.2, maxdd_worst ~0.21, sharpe_min ~1.37. +A single fast lookback (e.g. 30) is regime-fragile here; the slow multi-horizon blend +is what both lifts the Sharpe and roughly halves the buy&hold (~77-79%) drawdown. +""" +import numpy as np +import blindlib as bl + +HORIZONS = (45, 130, 240) # ~1.5/4.5/8 months of daily bars (multi-horizon TSMOM) +SHORT_W = 0.25 # de-weight the short side (curves trend up); 0 -> long-flat +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 45 +LEV_CAP = 1.5 + + +def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: + """Sign of the past-h-bar return, causal. mom[i] = sign(c[i]/c[i-h] - 1). + Undefined (0) for i < h.""" + out = np.zeros(len(c)) + if h < len(c): + past = c[:-h] + cur = c[h:] + out[h:] = np.sign(cur / past - 1.0) + return out + + +def signal(df): + c = df["close"].values.astype(float) + + # average the SIGN of TSMOM over the three horizons -> direction in [-1, +1] + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + + # asymmetric long-short: keep the long full size, de-weight the short side + raw = np.where(sig >= 0.0, sig, sig * SHORT_W) + + # causal vol-targeting: shrinks size into vol spikes (every crash is a vol spike) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_03_ma_ribbon.py b/scripts/research/blind/agents/agent_03_ma_ribbon.py new file mode 100644 index 0000000..090dfb0 --- /dev/null +++ b/scripts/research/blind/agents/agent_03_ma_ribbon.py @@ -0,0 +1,68 @@ +"""Agent 03 — MA ribbon (family=trend, slug=ma_ribbon). + +The angle: a quad-EMA "ribbon" (fast -> slow). The position is the FRACTION of the +ribbon that is in the correct trend order. When the ribbon is perfectly stacked +bullish (each faster EMA above the next slower one) the trend is clean and aligned +-> position +1. Perfectly stacked bearish -> -1. A tangled ribbon (MAs crossing, +no clear order) -> small / flat: we only press the position when the whole trend +structure agrees. This is a GRADED-conviction trend filter, not a binary cross. + +Construction (all causal — value at i uses rows 0..i only): + * ribbon = 4 EMAs with spans SPANS (monotone fast->slow), the canonical "quad". + * For each adjacent pair (k, k+1) score +1 if ema_k > ema_{k+1} (bullish step), + -1 if below. ribbon score = mean of the K-1 step signs -> in [-1, +1]: + exactly "fraction of MAs in correct order" mapped to a signed conviction + (all-bullish -> +1, all-bearish -> -1, tangled half/half -> ~0). + * The two anonymized curves are persistently up-trending, so a symmetric short of + every partial-ribbon dip is pure drag. We de-weight the short side by SHORT_W + (still a genuine ribbon long/short, just risk-asymmetric). SHORT_W>0 helps a + little: a small short into a stacked-bearish ribbon trims the drawdown. + * Size with causal vol-targeting so Series A & B are risk-comparable and the + drawdown stays bounded (long size shrinks into vol spikes = every crash). + +Tuning (ONLY split='train', both A & B equal weight). The chosen cell sits in the +interior of a broad plateau, not on a grid edge: + * SPANS base in {5,6,7} x(2 ratio) -> sharpe_min 1.32-1.37 (6 is the interior). + * VOL_WIN 20-25 best; 25 interior. * SHORT_W 0.1-0.25 flat at sharpe_min ~1.37, + DD falling 0.26->0.24 as SHORT_W rises; 0.2 interior. +Train combined: pnl_mean ~3.20, maxdd_worst ~0.241, sharpe_min ~1.37, turnover ~11/yr. +Fee-robust: sharpe_min 1.39 at 0% RT -> 1.30 at 0.40% RT (low turnover = fee-insensitive). + +CAUSAL: ema is an online recursion, vol_target uses a trailing window -> no +look-ahead, no centered windows, no global fit. Verified by causality_ok (max_diff 0). + +Honest note: this is a DEFENSIVE trend filter (value = converting a high-PnL/~50-67%-DD +uptrend into comparable PnL at ~24% DD), not standalone alpha — like every long-biased +trend overlay it inherits the bull-market beta of the curves. +""" +import numpy as np +import blindlib as bl + +# --- tuned ONLY on split='train' (plateau interior, not a grid edge) --- +SPANS = (6, 12, 24, 48) # quad ribbon, fast -> slow (monotone) +SHORT_W = 0.2 # short side de-weighted (asymmetric L/S); 0 -> long/flat +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 25 +LEV_CAP = 1.0 + + +def _ribbon_score(c: np.ndarray) -> np.ndarray: + """Signed fraction of adjacent ribbon steps in bullish order, in [-1, +1].""" + emas = [bl.ema(c, s) for s in SPANS] + steps = [] + for k in range(len(emas) - 1): + # +1 where the faster EMA is above the next slower one (bullish step) + steps.append(np.where(emas[k] > emas[k + 1], 1.0, -1.0)) + score = np.mean(np.vstack(steps), axis=0) # mean of K-1 step signs in [-1,1] + score[: SPANS[-1]] = 0.0 # ribbon undefined before slowest span + return score + + +def signal(df): + c = df["close"].values.astype(float) + score = _ribbon_score(c) + # graded conviction: keep the full long fraction, de-weight the short fraction + raw = np.where(score >= 0.0, score, SHORT_W * score) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_04_macd.py b/scripts/research/blind/agents/agent_04_macd.py new file mode 100644 index 0000000..0f7fc7c --- /dev/null +++ b/scripts/research/blind/agents/agent_04_macd.py @@ -0,0 +1,75 @@ +"""Agent 04 — MACD (family=trend, slug=macd). + +The angle: MACD = EMA(fast) - EMA(slow); signal line = EMA(MACD, signal_span); +histogram = MACD - signal. Direction comes from the histogram SIGN reinforced by +its SLOPE, exactly as the angle prescribes. Concretely: + + * BASE direction = +1/-1 only when the histogram sign AGREES with the MACD-line + sign (MACD above its signal line AND above zero -> uptrend), else flat. Requiring + agreement kills the histogram-sign whipsaw that bleeds the naive 12/26/9 to fees + (turnover ~24/yr -> ~15/yr) and roughly halves the drawdown. + * SLOPE confirmation = sign of the histogram's backward diff (histogram rising = + momentum accelerating). Blended in at weight SLOPE_W; it trims the drawdown + further (~0.18 -> ~0.12) by stepping aside while momentum is decelerating. + +Refinements that survived a plateau check on split='train': + * Both anonymized curves are persistently up-trending, so a symmetric short bleeds + (it shorts the dips of a bull). We keep a genuine long/short MACD but size the + SHORT side down (SHORT_W=0.5). + * Direction is vol-targeted (causal trailing window) so the two curves are sized + comparably and the drawdown stays bounded. + +Tuning (train only) — broad plateau, chosen cell is the interior, not a grid edge: + fast in [24..28], slow in [50..56], signal=9, SHORT_W in [0.5..0.6], + SLOPE_W in [0.2..0.35], VOL_WIN in [20..60] all give sharpe_min ~1.35-1.45 at + DD ~0.10-0.13. Picked fast=26, slow=52, signal=9, SHORT_W=0.5, SLOPE_W=0.20. + Fee-robust: sharpe_min only 1.40 -> 1.29 as round-trip fee goes 0.10% -> 0.30%. + +Benchmark: long-only buy&hold on train is pnl ~6.7/23.0 but maxDD ~0.77/0.79 +(sharpe ~0.89/1.16). This MACD anticipates the trend at a MUCH smaller drawdown +(~0.12) with a higher risk-adjusted return (sharpe_min ~1.40). + +CAUSAL: ema(c, span) is an online recursion (value at i uses rows 0..i only); the +histogram slope is a backward diff; vol_target uses a trailing vol window. No +look-ahead, no centered windows, no global fit. Verified by causality_ok (max_diff 0). +""" +import numpy as np +import blindlib as bl + +# --- tuned ONLY on split='train' (plateau interior) --- +FAST_SPAN = 26 +SLOW_SPAN = 52 +SIGNAL_SPAN = 9 +SLOPE_W = 0.20 # weight of histogram-slope confirmation in the direction +SHORT_W = 0.5 # short side sized down (asymmetric L/S in a bull); 0 -> long-flat +TARGET_VOL = 0.20 +VOL_WIN = 30 +LEV_CAP = 1.0 + + +def _macd(c, fast, slow, sig): + macd = bl.ema(c, fast) - bl.ema(c, slow) + signal_line = bl.ema(macd, sig) + hist = macd - signal_line + return macd, signal_line, hist + + +def signal(df): + c = df["close"].values.astype(float) + macd, signal_line, hist = _macd(c, FAST_SPAN, SLOW_SPAN, SIGNAL_SPAN) + + # base direction: take a side only when the histogram sign and the MACD-line + # sign AGREE (MACD vs signal AND MACD vs zero point the same way), else flat. + base = np.where(np.sign(hist) == np.sign(macd), np.sign(macd), 0.0) + # slope confirmation: is the histogram rising or falling (causal backward diff)? + slope = np.sign(np.diff(hist, prepend=hist[0])) + + raw = (1.0 - SLOPE_W) * base + SLOPE_W * slope + raw = np.clip(raw, -1.0, 1.0) + # de-weight the short side (persistent up-trend -> symmetric short is drag) + raw = np.where(raw < 0, raw * SHORT_W, raw) + raw = np.nan_to_num(raw, nan=0.0) + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN, leverage_cap=LEV_CAP) + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_05_momz.py b/scripts/research/blind/agents/agent_05_momz.py new file mode 100644 index 0000000..3507628 --- /dev/null +++ b/scripts/research/blind/agents/agent_05_momz.py @@ -0,0 +1,79 @@ +"""Agent 05 — Momentum z-score (family=trend, slug=momz). + +The angle (assigned): take the N-bar return as a momentum signal, STANDARDIZE it with a +CAUSAL rolling z-score, then squash with tanh into a position in [-1,+1]. Tune N. + +Why z-score the momentum (not the raw return): the magnitude of an N-bar return drifts +with the volatility regime — a +5% N-bar move means "strong" in a calm market and mere +"noise" in a wild one. Dividing by the trailing std of that same N-bar momentum makes the +signal regime-stationary: the position grows when momentum is unusually strong vs its own +recent distribution and shrinks toward 0 when it is merely typical. tanh(K*z) gives a +smooth, saturating long/short sizing (no hard sign flips -> less turnover/fee churn than a +sign rule) that is already bounded in [-1,1]. + +Single N is regime-fragile here (a lone lookback's sharpe_min ricochets 0.4..1.1 across N +on the two train curves). The cure, staying true to the z-score angle, is to BLEND THE +Z-SCORES of a few momentum horizons (fast/mid/slow N) — the distinguishing feature is the +standardization; multi-horizon is just averaging the standardized momentum, the same trick +that stabilizes TSMOM. The blended z is the direction; a causal vol-target then sizes it so +the two curves are risk-comparable and the drawdown stays bounded (every crash is a vol +spike -> exposure shrinks into it). + +Long-flat, not long-short: the two curves trend up structurally and a tuning sweep on +split='train' is monotone — every bit of short weight ONLY adds drag and drawdown here +(SHORT_W 0->1 takes sharpe_min from ~1.4 down to ~0.85 and DD 0.17->0.33). So SHORT_W=0: +go long when blended momentum-z is positive, flat otherwise. (The short side is kept as a +parameter, not hard-removed, so the rule is explicit and re-tunable on a different regime.) + +CAUSAL: mom[i] = close[i]/close[i-N]-1 uses rows <= i; zscore uses a trailing window; +vol_target uses trailing realized vol. No shift(-k), no centered windows, no global fit. +Verified by causality_ok (max_diff 0.0). + +Tuning (train only, combined A&B; coarse->fine sweep). The chosen cell is INTERIOR on every +axis — all horizon-set neighbors, ZW in [200..280], VW in [30..40], K in [2.5..4] stay in +sharpe_min ~1.2..1.45 at DD ~0.16..0.24, so it's a plateau, not a lucky spike: + HORIZONS=(40,120,220) # ~fast/mid/slow N-bar momentum + Z_WIN=250 # window standardizing each N-bar momentum + K=3.0 # tanh gain (near-saturating; >=2.5 is flat) + SHORT_W=0.0 # long-flat (short only added drag here) + TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5 + -> train combined: pnl_mean ~2.77, maxdd_worst ~0.17, sharpe_min ~1.39 + (vs long-only buy&hold's ~7-23x PnL at ~70-80% DD — the z-momentum keeps a healthy + PnL while cutting the drawdown ~4-5x by de-risking into the big declines). +""" +import numpy as np +import blindlib as bl + +HORIZONS = (40, 120, 220) # N-bar momentum lookbacks (fast/mid/slow) — the "N" of the angle +Z_WIN = 250 # causal window standardizing each N-bar momentum +K = 3.0 # tanh gain on the blended z-score (near-saturating) +SHORT_W = 0.0 # de-weight the short side; 0 -> long-flat (best on train) +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def _mom(c: np.ndarray, n: int) -> np.ndarray: + """Causal N-bar return. mom[i] = c[i]/c[i-n] - 1, undefined (0) for i < n.""" + out = np.zeros(len(c)) + if n < len(c): + out[n:] = c[n:] / c[:-n] - 1.0 + return out + + +def signal(df): + c = df["close"].values.astype(float) + + # blend the z-scores of several momentum horizons -> regime-stationary direction + zsum = np.zeros(len(c)) + for n in HORIZONS: + z = bl.zscore(_mom(c, n), Z_WIN) # standardize vs own trailing distribution + zsum += np.nan_to_num(z, nan=0.0) + z = zsum / len(HORIZONS) + + raw = np.tanh(K * z) # smooth, saturating direction in [-1, 1] + raw = np.where(raw >= 0.0, raw, raw * SHORT_W) # de-weight short side (0 = long-flat) + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_06_accel.py b/scripts/research/blind/agents/agent_06_accel.py new file mode 100644 index 0000000..ae60683 --- /dev/null +++ b/scripts/research/blind/agents/agent_06_accel.py @@ -0,0 +1,97 @@ +"""Agent 06 — Acceleration / momentum-of-momentum (family=trend, slug=accel). + +The angle (assigned): 2nd difference / momentum-of-momentum. Go WITH an accelerating +trend, cut (de-risk toward flat) when the trend is decelerating. + +Construction (all causal): + 1. velocity v[i] = EMA(log-return, FAST) — a smoothed 1st derivative of log-price + (the local trend "speed", sign = up/down). + 2. acceleration a[i] = v[i] - v[i-LAG] — the momentum-OF-momentum (discrete 2nd + difference of log-price). a>0 = the up-move is speeding up / a down-move is + bottoming; a<0 = the up-move is rolling over / a down-move is accelerating. + 3. Standardize BOTH v and a with a causal rolling z-score so they are regime- + stationary (a "fast" velocity in a calm tape is "slow" in a wild one). + 4. Direction = the trend you ride GATED by acceleration: + dir = sign-ish(velocity) * gate(acceleration) + where the gate OPENS exposure when momentum is accelerating in the trend's + direction and CLOSES it (toward 0) when it decelerates. Concretely we combine + a velocity term (ride the trend) with an acceleration term (the angle's edge): + raw = tanh(KV * zv) * 0.5 + tanh(KA * za) * 0.5 + then de-weight the short side (these curves trend up structurally so a full + symmetric short bleeds shorting the dips) and vol-target so A and B are + risk-comparable and every crash (a vol spike) shrinks size into itself. + +Why acceleration adds over plain momentum: plain TSMOM is fully long through a long +top-formation and gives the gains back on the way down. The 2nd difference turns +NEGATIVE while price is still high but rolling over (momentum decelerating) — it cuts +risk EARLY, before the level-based trend flips. Symmetrically it re-engages when a +decline starts decelerating (bottoming). That earlier turn is the whole point of the +angle: comparable PnL to buy&hold at a much smaller drawdown. + +CAUSAL: EMA, rolling z-score, the v[i]-v[i-LAG] difference and vol_target all use rows +<= i only. No shift(-k), no centered windows, no global fit. Verified by causality_ok. + +Tuning (train only, combined A&B): a coarse->fine sweep over (FAST, LAG, weights, KV/KA, +short_w, Z_WIN, vol-target) picked a WIDE interior plateau, not a spike. The chosen cell +(FAST=28, LAG=30, Z_WIN=200, KV=KA=1.5, W_VEL=0.4/W_ACC=0.6, SHORT_W=0, vol25) is interior +on EVERY axis: FAST in [22..36] -> sh_min 1.50..1.52; LAG in [26..40] -> 1.41..1.52 +(peak 30); Z_WIN in [160..220] -> 1.52..1.56; W_ACC/KA/KV/vol all smooth & monotone. + -> train combined: pnl_mean ~2.3, maxdd_worst ~0.20, sharpe_min ~1.52. +SHORT_W=0 (long-flat) beat every short weight on train (sh_min collapses 1.31->0.43 as the +short side is turned on) — the deceleration gate ALREADY de-risks to flat at the top, so a +symmetric short just shorts the dips of a structural bull. The acceleration term is what +earns the carry over plain velocity: W_ACC=0 drops pnl_mean to ~0.6 (it ducks risk too +early); W_ACC~0.6 keeps the early de-risk while staying invested through the accelerating +legs. DD ~0.20 vs a ~77-79% buy&hold drawdown. +""" +import numpy as np +import blindlib as bl + +FAST = 28 # EMA span for the velocity (smoothed log-return / local slope) +LAG = 30 # horizon of the 2nd difference: accel = v[i] - v[i-LAG] +Z_WIN = 200 # causal window to standardize velocity & acceleration +KV = 1.5 # tanh gain on the velocity z (ride the trend) +KA = 1.5 # tanh gain on the acceleration z (the angle's edge) +W_VEL = 0.4 # weight on the velocity (trend) term +W_ACC = 0.6 # weight on the acceleration (momentum-of-momentum) term +SHORT_W = 0.0 # long-flat: the de-celeration gate already cuts to flat; a + # symmetric short only bleeds shorting the dips of a structural + # up-trend (train sweep: sh_min 1.31@0.0 -> 0.43@1.0). 0 = flat. +TARGET_VOL = 0.27 +VOL_WIN_DAYS = 25 +LEV_CAP = 1.5 + + +def _lagged_diff(x: np.ndarray, lag: int) -> np.ndarray: + """Causal discrete derivative: out[i] = x[i] - x[i-lag], 0 for i < lag.""" + out = np.zeros(len(x)) + if lag < len(x): + out[lag:] = x[lag:] - x[:-lag] + return out + + +def signal(df): + c = df["close"].values.astype(float) + lr = np.zeros(len(c)) + lr[1:] = np.log(c[1:] / c[:-1]) # causal log returns + + # 1) velocity: smoothed 1st derivative of log-price (local trend speed) + vel = bl.ema(lr, FAST) + # 2) acceleration: momentum-of-momentum = 2nd difference of the trend + acc = _lagged_diff(vel, LAG) + + # 3) standardize both vs their own trailing distribution (regime-stationary) + zv = np.nan_to_num(bl.zscore(vel, Z_WIN), nan=0.0) + za = np.nan_to_num(bl.zscore(acc, Z_WIN), nan=0.0) + + # 4) ride the trend, GATED/boosted by acceleration (the angle's edge) + raw = W_VEL * np.tanh(KV * zv) + W_ACC * np.tanh(KA * za) + raw = np.clip(raw, -1.0, 1.0) + + # asymmetric long-short: full long, de-weighted short (structural up-trend) + raw = np.where(raw >= 0.0, raw, raw * SHORT_W) + + # causal vol-targeting: shrink size into vol spikes (every crash is a vol spike) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_07_kama_eff.py b/scripts/research/blind/agents/agent_07_kama_eff.py new file mode 100644 index 0000000..506c241 --- /dev/null +++ b/scripts/research/blind/agents/agent_07_kama_eff.py @@ -0,0 +1,115 @@ +"""Agent 07 — KAMA / Kaufman efficiency ratio (family=trend, slug=kama_eff). + +The angle (assigned): an ADAPTIVE moving average driven by Kaufman's Efficiency +Ratio (ER). ER over a window of n bars is + + ER[i] = |close[i] - close[i-n]| / sum_{k=i-n+1..i} |close[k] - close[k-1]| + +i.e. net displacement / total path length, in [0, 1]. ER -> 1 when the move is a +clean straight trend (worth following); ER -> 0 in chop (the path wanders, net +displacement is small -> stay out). KAMA turns ER into an adaptive smoothing +constant SC = (ER*(fast-slow)+slow)^2 so the average snaps to price in a trend and +freezes in chop: + + KAMA[i] = KAMA[i-1] + SC[i] * (close[i] - KAMA[i-1]) + +DIRECTION: sign of the KAMA slope (KAMA[i] vs KAMA[i-k]) — KAMA is up-sloping in an +up-trend, flat/down in a decline. GATE: the efficiency ratio itself. We only take a +position when ER exceeds a causal, expanding-quantile threshold (trend is efficient +ENOUGH right now relative to this curve's own history); otherwise flat. This is the +literal statement of the angle: "trend-follow when efficiency high, flat when choppy". + +LONG-SHORT: the curves trend up structurally, so a full symmetric short bleeds +(it shorts the dips). We keep the long full size and de-weight the short side +(SHORT_W < 1) — the short is there to protect the big efficient DECLINES (which is +where flat-only leaves the worst drawdown on the table), not to fade every wiggle. + +SIZING: causal vol-target so A and B are risk-comparable and the drawdown stays +bounded (every crash is a vol spike -> exposure auto-shrinks). + +CAUSAL: ER, KAMA (a recursive EWMA-like filter), the slope, the expanding ER +threshold, and vol_target all use rows <= i only. No shift(-k), no centered window, +no global fit. Verified by causality_ok (max_diff ~0). + +Tuning (train only, combined A&B, coarse->fine). ER window ~ a month, KAMA fast/slow +the canonical (2,30), slope over a few bars, ER gate at an expanding quantile. A WIDE +interior plateau (every 1-axis neighbor holds sharpe_min 1.25-1.54 at dd 0.18-0.33, +no spike) sits around: + ER_WIN=30, FAST=2, SLOW=30, SLOPE=5, ER_Q=0.30 (expanding causal quantile), + SHORT_W=0.20, TARGET_VOL=0.30, VOL_WIN=35d, LEV_CAP=1.5 +-> train combined: pnl_mean ~4.75, maxdd_worst ~0.19, sharpe_min ~1.43 (causality.ok). +Notes: LEV_CAP is non-binding here (vol_target keeps |pos|<1 on these vol levels); +the ER gate is what de-risks chop, the de-weighted short protects the efficient +declines, and vol_target turns the ~77-79% buy&hold drawdown into ~19%. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +ER_WIN = 30 # efficiency-ratio lookback (~1 month of daily bars) +FAST = 2 # KAMA fast EMA constant +SLOW = 30 # KAMA slow EMA constant +SLOPE = 5 # bars to measure KAMA slope (direction) +ER_Q = 0.30 # expanding-quantile gate: trade only when ER above its own history +WARMUP = 60 # min bars before the expanding gate is trusted +SHORT_W = 0.20 # de-weight the short side (curves trend up); 0 -> long-flat +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def _efficiency_ratio(c: np.ndarray, n: int) -> np.ndarray: + """Kaufman efficiency ratio over n bars, causal. ER[i] uses close[i-n..i].""" + change = np.zeros(len(c)) + change[n:] = np.abs(c[n:] - c[:-n]) + d = np.abs(np.diff(c, prepend=c[0])) # |close[k]-close[k-1]| + volatility = pd.Series(d).rolling(n, min_periods=n).sum().values + er = np.where(volatility > 0, change / volatility, 0.0) + er[:n] = 0.0 + return np.nan_to_num(er, nan=0.0) + + +def _kama(c: np.ndarray, er: np.ndarray, fast: int, slow: int) -> np.ndarray: + """Kaufman Adaptive Moving Average. SC = (ER*(fast_sc-slow_sc)+slow_sc)^2. + Recursive (only uses past) -> fully causal.""" + fast_sc = 2.0 / (fast + 1.0) + slow_sc = 2.0 / (slow + 1.0) + sc = (er * (fast_sc - slow_sc) + slow_sc) ** 2 + kama = np.empty(len(c)) + kama[0] = c[0] + for i in range(1, len(c)): + kama[i] = kama[i - 1] + sc[i] * (c[i] - kama[i - 1]) + return kama + + +def _expanding_quantile(x: np.ndarray, q: float, warmup: int) -> np.ndarray: + """Causal expanding quantile: thr[i] = q-quantile of x[0..i]. For i= thr), 1.0, 0.0) + + raw = direction * active + # asymmetric long-short: keep long full size, de-weight the short side + raw = np.where(raw >= 0.0, raw, raw * SHORT_W) + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_08_signvote.py b/scripts/research/blind/agents/agent_08_signvote.py new file mode 100644 index 0000000..9df0c7d --- /dev/null +++ b/scripts/research/blind/agents/agent_08_signvote.py @@ -0,0 +1,95 @@ +"""Agent 08 — Sign-vote momentum ensemble (family=trend, slug=signvote). + +The angle (assigned): a SIGN-VOTE ENSEMBLE of momentum across MANY lookbacks. For a +dense ladder of horizons H in {10, 20, ..., 250} bars, each horizon casts a binary +vote: +1 if the asset is up vs H bars ago (close[i] > close[i-H]), -1 if down. The +raw direction is the MEAN of all the votes, a smooth number in [-1, +1]: + +1.0 = every horizon agrees the trend is up (full long) + 0.0 = the ladder is split (no agreement) (flat) + -1.0 = every horizon agrees the trend is down (full short) + +Why a dense vote-ladder beats a single (or 3-horizon) momentum: + * Robustness. No single lookback is special; the verdict is a consensus, so a chop + that whipsaws one window is outvoted by the others. The committee de-risks + GRADUALLY as horizons flip one by one — it doesn't lurch from full-long to + full-short on one window crossing a threshold. + * Anticipation. Near a top the FAST horizons flip down first while the slow ones + are still up, so the mean vote slides from +1 toward 0 BEFORE the slow trend + rolls over — exposure is cut into the turn, not after it. That is the whole point + of the assignment: "anticipate the next move". + +Long-short asymmetry: both curves trend up over the visible window, so a full-size +symmetric short bleeds (it shorts every dip). A de-weighted short side (SHORT_W < 1) +keeps the protection of going short the genuine, broad-consensus declines without the +drag of fighting every pullback. SHORT_W=0.35 sits in the interior of a flat plateau. + +Sizing: the consensus direction is fed to a causal vol-target so the two curves are +risk-comparable and exposure shrinks into vol spikes (every crash is a vol spike) — +this is what turns the ~77-79% buy&hold drawdown into a far smaller one at comparable +PnL. + +CAUSAL: every vote uses close[i]/close[i-H] (rows <= i only); the vol-target uses a +trailing realized-vol window. No .shift(-k), no centered windows, no global fit. +Verified by causality_ok (max_diff 0.0). + +Tuning (split='train' only, combined A&B). A coarse->fine sweep over the ladder span, +the step, SHORT_W, and the vol-target block found a WIDE plateau: + * Ladder = 10..250 step 10 (25 horizons). Denser steps or a different top move + sharpe_min by <0.05 -> the result is the consensus, not one cell. + * SHORT_W plateau 0.10..0.30; TARGET_VOL trades PnL<->DD monotonically (0.22->DD .16, + 0.28->DD .21) at ~constant Sharpe; VOL_WIN=60 is the interior best (50/75 ~-0.05 Sh); + LEV_CAP doesn't bind (vol-target rarely reaches the cap at these target vols). +Chosen cell (interior on every axis -> robust, not a lucky spike): + SHORT_W=0.15, TARGET_VOL=0.25, VOL_WIN=60, LEV_CAP=1.5 + -> train combined: pnl_mean ~1.68, maxdd_worst ~0.187, sharpe_min ~1.17. +TARGET_VOL=0.25 is the balanced pick: vs the 0.30 cell it keeps the Sharpe (~1.18) and +most of the PnL while cutting the worst drawdown 0.24->0.19 — the assignment's goal +("comparable PnL at a MUCH smaller drawdown"). A single fast lookback is regime-fragile +here; the dense sign-vote consensus both lifts the risk-adjusted return and roughly +thirds the ~77-79% buy&hold drawdown. +""" +import numpy as np +import blindlib as bl + +# Dense ladder of momentum lookbacks (daily bars): 10, 20, ..., 250 -> 25 horizons. +LOOKBACKS = tuple(range(10, 251, 10)) +SHORT_W = 0.15 # de-weight the short side (curves trend up); 0 -> long-flat +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 60 +LEV_CAP = 1.5 + + +def _vote(c: np.ndarray, h: int) -> np.ndarray: + """Binary momentum vote of horizon h, causal. +1 if up vs h bars ago, -1 if down. + Undefined (0) for i < h (not enough history to vote).""" + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + # MEAN of the sign-votes across the whole ladder -> consensus direction in [-1,1]. + # Each horizon that has enough history contributes its +/-1 vote; we average only + # over the horizons that are actually defined at bar i, so early bars (where the + # long horizons can't vote yet) still produce a sensible consensus of the short + # horizons rather than being diluted toward 0 by undefined long votes. + vote_sum = np.zeros(n) + vote_cnt = np.zeros(n) + for h in LOOKBACKS: + if h >= n: + continue + vote_sum[h:] += np.sign(c[h:] / c[:-h] - 1.0) + vote_cnt[h:] += 1.0 + sig = np.where(vote_cnt > 0, vote_sum / np.maximum(vote_cnt, 1.0), 0.0) + + # asymmetric long-short: keep the long full size, de-weight the short side + raw = np.where(sig >= 0.0, sig, sig * SHORT_W) + + # causal vol-targeting: shrinks size into vol spikes (every crash is a vol spike) + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_09_donchian.py b/scripts/research/blind/agents/agent_09_donchian.py new file mode 100644 index 0000000..7976d66 --- /dev/null +++ b/scripts/research/blind/agents/agent_09_donchian.py @@ -0,0 +1,65 @@ +"""agent_09_donchian — ANGLE: Donchian channel breakout (long / flat). + +Idea (assigned angle): a classic Donchian / turtle breakout trend-follower. ENTER LONG +when the close prints above the prior N-bar HIGH (an upside breakout) and EXIT (go FLAT) +when it prints below the prior X-bar LOW (a downside breakout). Hold the long between +those two events. Tune N (entry) and X (exit) on split='train' only. + +WHY LONG/FLAT, NOT LONG/SHORT (honest tuning result): + The textbook donchian is stop-and-reverse (short below the prior low). I tested it. + On BOTH series the SHORT leg is purely value-destroying: every short_size > 0 raised + the drawdown AND lowered Sharpe (the pair trends up, so downside breakouts are mostly + V-shaped bottoms / chop where the short gets whipsawed). So the breakout *exit* is + kept (a low-channel break flattens us, turtle-style), but we never flip short. The + donchian breakout EVENT is still what drives every entry and exit — the angle is intact. + +Tuned on split='train' (both Series A and B, equal weight) — broad plateau Nin 25..36 / +Xout 18..20, Sharpe_min ~1.20-1.27 throughout (not an isolated peak): + * N_ENTRY = 36 bars (prior-N high that defines an upside breakout) + * N_EXIT = 18 bars (shorter prior-low channel -> exit faster than we enter) + * vol-target the long to 30% ann vol (vol_win=30d, cap 1.0): long size shrinks into + vol spikes (every crash is a vol spike) -> caps the drawdown of late/whipsaw entries. + +Causality: bl.donchian shifts the rolling max/min by one bar, so the channel at i is +built from bars STRICTLY before i; a close[i] that breaks it is a real, tradeable event +at close[i]. The evaluator then holds the position during bar i+1. No future rows; the +state machine is a forward scan (uses only data <= i). causality_ok -> true. + +Train (combined A&B): pnl_mean ~3.43, maxdd_worst ~0.31, sharpe_min ~1.27. +Honest note: Donchian is pure trend-following, not alpha. Its value here is converting a +high-PnL / ~74%-DD uptrend into comparable PnL at ~31% drawdown (DD cut ~2.4x). The full +long/short donchian was MUCH worse (Sharpe_min ~0.2, DD ~74%); the edge is the FLAT side. +""" +import numpy as np +import blindlib as bl + +N_ENTRY = 36 # Donchian entry: long on break of prior N_ENTRY-bar high +N_EXIT = 18 # Donchian exit: flat on break of prior N_EXIT-bar low +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def signal(df): + c = df["close"].values.astype(float) + hi_entry, _ = bl.donchian(df, N_ENTRY) # prior N_ENTRY-bar high (shifted, causal) + _, lo_exit = bl.donchian(df, N_EXIT) # prior N_EXIT-bar low (shifted, causal) + + up = c > hi_entry # upside breakout -> enter/stay long + dn = c < lo_exit # downside breakout -> exit to flat + + # turtle long/flat state machine (forward scan, uses only data <= i) + n = len(c) + state = np.zeros(n) + s = 0.0 + for i in range(n): + if up[i]: + s = 1.0 + elif dn[i]: + s = 0.0 + state[i] = s + + # size the long with causal vol-targeting (shrinks into vol spikes -> caps DD) + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_10_keltner.py b/scripts/research/blind/agents/agent_10_keltner.py new file mode 100644 index 0000000..5db4205 --- /dev/null +++ b/scripts/research/blind/agents/agent_10_keltner.py @@ -0,0 +1,87 @@ +"""agent_10_keltner — ANGLE: Keltner channel breakout (long / flat). + +Idea (assigned angle): a Keltner channel is an EMA mid-line wrapped by an ATR band, + upper[i] = EMA_N(close)[i-1] + K * ATR_M[i-1] + lower[i] = EMA_N(close)[i-1] - K_EXIT * ATR_M[i-1] +Ride breakouts: go LONG when close[i] pierces the prior-bar UPPER band (an upside +breakout out of the channel); EXIT to FLAT when close[i] pierces the prior-bar LOWER +band. Hold the long between those two events (a turtle-style state machine) so we stay +in persistent trends and keep turnover (fees) low. Tune N, M, K, K_EXIT on train only. + +WHY LONG/FLAT, NOT LONG/SHORT (honest tuning result on split='train'): + The textbook Keltner breakout is stop-and-reverse (short below the lower band). I + tuned both. Long/SHORT tops out at sharpe_min ~1.04 (maxdd ~0.39); switching the short + leg to FLAT lifts sharpe_min to ~1.56 and cuts maxdd to ~0.28. On BOTH series the short + leg is value-destroying: the pair trends up, so downside breakouts are mostly V-shaped + bottoms / chop where a short gets whipsawed. So the breakout *exit* is kept (a lower- + band break flattens us) but we never flip short. The Keltner breakout EVENT still drives + every entry and exit — the angle is intact. + +Tuned on split='train' (Series A & B, equal weight). Broad plateau: 59/340 nearby cells +keep sharpe_min > 1.40, so the chosen point is a plateau CENTER, not an isolated peak: + * N_EMA = 20 (Keltner mid-line EMA span) + * N_ATR = 30 (ATR window for the band half-width) + * K = 1.0 (entry band multiplier: close above EMA + 1.0*ATR -> upside breakout) + * K_EXIT = 0.5 (exit band multiplier: close below EMA - 0.5*ATR -> flatten; tighter + than entry so we exit a failing trend faster than we re-enter) + * vol-target the long to 30% ann vol (vol_win=30d, cap 1.0): the long size shrinks into + vol spikes (every crash is a vol spike) -> caps the drawdown of late/whipsaw entries. + Sharpe is ~flat (1.55-1.56) across target_vol 0.20-0.40; target_vol only trades PnL + for DD (0.20 -> pnl 2.7/DD 0.19 ... 0.40 -> pnl 9.2/DD 0.34). 0.30 is the balance. + +Causality: the channel that close[i] is tested against is EMA/ATR evaluated at i-1 (one- +bar lag via .shift(1)), so it is built from bars STRICTLY before i; a close[i] that +pierces it is a real, tradeable event at close[i]. The state machine is a forward scan +(uses only data <= i). The evaluator then holds the position during bar i+1. No future +rows -> causality_ok = true. + +Train (combined A&B): pnl_mean ~5.55, maxdd_worst ~0.28, sharpe_min ~1.56. +Honest note: Keltner breakout is pure trend-following, not alpha. Its value here is +converting a high-PnL / ~77-79%-DD uptrend into comparable PnL at ~28% drawdown (DD cut +~2.7x). The full long/short Keltner was MUCH worse (sharpe_min ~1.04, DD ~0.39) — the +edge that matters is the FLAT side, exactly as for the sibling donchian breakout. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +N_EMA = 20 # Keltner mid-line EMA span +N_ATR = 30 # ATR window for the band half-width +K = 1.0 # entry band multiplier: break of EMA + K*ATR -> long +K_EXIT = 0.5 # exit band multiplier: break of EMA - K_EXIT*ATR -> flat +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def _keltner_band(df, n_ema, n_atr, k): + """Lagged Keltner upper/lower at multiplier k: EMA[i-1] +/- k*ATR[i-1].""" + c = df["close"].values.astype(float) + mid = pd.Series(bl.ema(c, n_ema)).shift(1).values # EMA built <= i-1 + band = pd.Series(bl.atr(df, n_atr)).shift(1).values # ATR built <= i-1 + return mid + k * band, mid - k * band + + +def signal(df): + c = df["close"].values.astype(float) + upper, _ = _keltner_band(df, N_EMA, N_ATR, K) # entry channel (wider) + _, lower = _keltner_band(df, N_EMA, N_ATR, K_EXIT) # exit channel (tighter) + + up = c > upper # upside breakout -> enter / stay long (tradeable at close[i]) + dn = c < lower # downside breakout of tighter band -> exit to flat + + # turtle long/flat state machine (forward scan, uses only data <= i). + n = len(c) + state = np.zeros(n) + s = 0.0 + for i in range(n): + if np.isfinite(upper[i]) and up[i]: + s = 1.0 + elif np.isfinite(lower[i]) and dn[i]: + s = 0.0 + state[i] = s + + # size the long with causal vol-targeting (shrinks into vol spikes -> caps DD). + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_11_squeeze.py b/scripts/research/blind/agents/agent_11_squeeze.py new file mode 100644 index 0000000..778979f --- /dev/null +++ b/scripts/research/blind/agents/agent_11_squeeze.py @@ -0,0 +1,134 @@ +"""agent_11_squeeze — ANGLE [family=breakout, slug=squeeze]. + +Range-compression (NR / Bollinger-squeeze) THEN expansion: after a low-volatility +"coil", price tends to break out and run. We (1) detect the squeeze causally, (2) wait +for the breakout out of the coil, (3) enter in the breakout direction, vol-targeted. + +Mechanics (all causal — value at i uses only rows 0..i): + * SQUEEZE detector: Bollinger bandwidth = (BB_upper - BB_lower) / mid, using a + rolling window ending at i. A bar is "coiled" when its bandwidth sits in the low + tail of its own EXPANDING history (causal percentile, no future). This is the + classic Bollinger-squeeze / NR proxy: bands pinch when realized vol compresses. + * BREAKOUT trigger: a Donchian channel built STRICTLY from bars < i (bl.donchian + shifts by 1). When close[i] pierces the prior N-bar high -> upside expansion; + pierces the prior N-bar low -> downside expansion. The break is only ARMED if we + were recently in a squeeze (coil within the last LOOKBACK bars) — that is the + whole thesis: expansion out of compression, not a random breakout. + * STATE machine: once a squeeze-armed breakout fires, carry that side (stop-and- + reverse on the opposite squeeze-armed breakout) so we ride the post-coil + expansion and keep turnover low. Decay to flat if the move stalls back inside + the channel for a while (the coil's energy is spent). + * SIZING: the +/-1 direction is vol-targeted (TP01-style) so exposure shrinks into + vol spikes -> caps drawdown on whipsaws / failed breakouts. + +Tuned ONLY on split='train' (Series A and B, equal weight). Causality verified by the +harness (signal on a prefix matches signal on the full array over its tail). + +Honest notes: + * Squeeze-breakout is trend-following with a regime filter. On these trending curves + it captures up-legs with ~3x less drawdown than buy&hold (DD ~29% vs ~70-80%) at + only ~25-33% time-in-market; the cost is failed-breakout whipsaws after a fake-out + coil. Value is risk-adjusted, not raw PnL. + * Shorts were dropped (SHORT_SCALE=0): on both train curves the downside-breakout leg + was a net loser (coils on an uptrend mostly fake out down -> V-bottoms), so the + long/flat version is strictly better on Sharpe AND drawdown. + * ABLATION CAVEAT: a pure Donchian breakout with the SAME hold/exit logic but NO coil + gate scores marginally HIGHER on train (Sh ~1.05 / PnL ~1.34) than the coil-gated + version. The squeeze gate trims turnover and DD but is NOT the source of the edge + here — the edge is the breakout + vol-target. Kept the coil gate because the + assigned angle is *squeeze*; it is a mild, honest improvement on risk, not magic. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- tuned on split='train' (broad plateau, see header / grid in commit) ------ +BB_WIN = 20 # Bollinger window for bandwidth +BB_K = 2.0 # Bollinger multiplier +SQ_PCTL = 0.45 # bandwidth below this expanding-percentile = coil (sub-median + # compression; tighter pctl over-filters and loses good breaks) +DON_WIN = 25 # Donchian breakout lookback +ARM_LOOKBACK = 15 # breakout must occur within this many bars of a coil +HOLD_BARS = 40 # ride the post-coil expansion for ~this many bars, then decay +STALL_BARS = 12 # if price falls back inside the channel this long, exit early +SHORT_SCALE = 0.0 # downside-breakout sizing (0 = long/flat; coils on these + # uptrends mostly fake out to the downside, so shorts bleed) +TARGET_VOL = 0.20 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def _expanding_pctl_rank(x: np.ndarray, min_n: int = 60) -> np.ndarray: + """Causal expanding percentile rank of x[i] within x[0..i]. rank in [0,1]. + rank = fraction of past (<=i) values that are <= x[i]. Uses only rows 0..i.""" + n = len(x) + out = np.full(n, np.nan) + # incremental sorted insertion would be O(n log n); n~2000 so an O(n^2) pass is + # fine (<30s). Keep it simple and obviously causal. + for i in range(n): + xi = x[i] + if not np.isfinite(xi): + continue + window = x[: i + 1] + valid = window[np.isfinite(window)] + if len(valid) < min_n: + continue + out[i] = float(np.mean(valid <= xi)) + return out + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + # 1) Bollinger bandwidth (causal) -> squeeze when bandwidth is in its low tail. + upper, mid, lower = bl.bbands(c, BB_WIN, BB_K) + with np.errstate(invalid="ignore", divide="ignore"): + bw = (upper - lower) / np.where(np.abs(mid) > 0, mid, np.nan) + bw_rank = _expanding_pctl_rank(bw, min_n=max(60, BB_WIN * 2)) + coil = np.nan_to_num(bw_rank, nan=1.0) <= SQ_PCTL # True where compressed + + # "recently coiled" = a coil within the last ARM_LOOKBACK bars (causal). + coil_recent = ( + pd.Series(coil.astype(float)).rolling(ARM_LOOKBACK, min_periods=1).max().values > 0 + ) + + # 2) Donchian breakout (prior-bar channel; bl.donchian already shifts by 1). + don_hi, don_lo = bl.donchian(df, DON_WIN) + up_break = np.isfinite(don_hi) & (c > don_hi) + dn_break = np.isfinite(don_lo) & (c < don_lo) + + # 3) state machine: arm breakouts only when they expand out of a recent coil. + # The thesis is that the EDGE lives in the expansion right after the coil, so + # we ride a fired breakout for HOLD_BARS then decay to flat (the coil's energy + # is spent). A fresh squeeze-armed breakout re-arms / re-times the hold. We + # exit early if price collapses back inside the channel (failed breakout). + state = np.zeros(n) + s = 0.0 + age = 0 # bars since the active breakout fired + inside_count = 0 # consecutive bars back inside the channel since trigger + for i in range(n): + armed = coil_recent[i] + fired = False + if armed and up_break[i]: + s = 1.0; age = 0; inside_count = 0; fired = True + elif armed and dn_break[i]: + s = -SHORT_SCALE; age = 0; inside_count = 0; fired = (SHORT_SCALE > 0) + + if not fired and s != 0.0: + age += 1 + # failed-breakout guard: price back inside the prior channel + in_channel = True + if np.isfinite(don_hi[i]) and c[i] > don_hi[i]: + in_channel = False + if np.isfinite(don_lo[i]) and c[i] < don_lo[i]: + in_channel = False + inside_count = inside_count + 1 if in_channel else 0 + if inside_count >= STALL_BARS or age >= HOLD_BARS: + s = 0.0; age = 0; inside_count = 0 + state[i] = s + + # 4) size by causal vol-targeting (shrinks into vol spikes -> caps DD). + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_12_pivot.py b/scripts/research/blind/agents/agent_12_pivot.py new file mode 100644 index 0000000..746ff2d --- /dev/null +++ b/scripts/research/blind/agents/agent_12_pivot.py @@ -0,0 +1,116 @@ +"""agent_12_pivot — ANGLE: rolling support/resistance PIVOT breakout + confirmation bar. + +Idea (assigned angle, family=breakout / slug=pivot): + Build dynamic SUPPORT and RESISTANCE from swing PIVOTS (fractal turning points), not + from a flat Donchian channel. A pivot HIGH at bar k is a local maximum with `LR` bars + higher-or-equal on each side; a pivot LOW the mirror. Resistance = the most recent + CONFIRMED pivot-high price; support = the most recent confirmed pivot-low price. + A BREAKOUT is close[i] printing above resistance (long) / below support (short). + We require a CONFIRMATION BAR: the breakout must hold for `CONFIRM` consecutive closes + (filters the one-bar wick fake-out) before we take the position. + +CAUSALITY — the crux of a pivot signal: + A pivot at bar k can only be CONFIRMED `LR` bars later (you need the `LR` right-side bars + to know k was a local extreme). So the resistance/support level available at bar i is the + newest pivot whose confirmation bar k+LR <= i. We build the level series with a forward + scan that, at each i, only looks at pivots already confirmed by bars <= i. No future rows + enter the level at i. The breakout test then compares close[i] (known at i) to that level, + and the evaluator holds the resulting position during bar i+1. causality_ok -> true. + +LONG/SHORT vs LONG/FLAT (honest tuning on split='train', both A & B equal weight): + Textbook pivot breakout is stop-and-reverse. On these two strongly up-trending curves the + SHORT leg destroys risk-adjusted value (downside pivot breaks are mostly V-bottoms / chop + that whipsaw a short). Best train Sharpe came from LONG on a confirmed resistance break, + going FLAT on a confirmed support break — keep the breakout EXIT, never flip short. Sized + with causal vol-targeting so the long shrinks into vol spikes (every crash is a vol spike), + which caps the drawdown of late / whipsaw entries. + +Tuned params — broad plateau on train (both A & B), NOT an isolated peak. Sharpe_min holds +~1.30-1.36 across LR 3..4, CONFIRM 3, target_vol 0.20..0.40, vol_win 20..45 (sweep in commit +notes): the edge is structural, not a fitted corner. Chosen for the best PnL-at-low-DD balance: + LR=4 (pivot half-window), CONFIRM=3 (closes the break must hold), vol-target 30% / 30d / cap 1. + -> train combined: pnl_mean ~4.40, maxdd_worst ~0.26, sharpe_min ~1.33. + +Honest note: like every breakout on a trending pair this is trend-following, not alpha. Its +value is converting a high-PnL / ~77%-DD uptrend into comparable PnL at ~26% drawdown (DD cut +~3x). The CONFIRMATION BAR is what separates it from a plain Donchian: it adds ~0.06-0.10 +Sharpe and trims the DD by ignoring one-bar wick breaks of the pivot level. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +LR = 4 # pivot half-window: local extreme vs LR bars each side +CONFIRM = 3 # breakout must hold this many consecutive closes (confirmation bar) +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def _pivot_levels(high, low, lr): + """Causal nearest-confirmed-pivot resistance & support. + + pivot high at k := high[k] == max(high[k-lr .. k+lr]) (>= neighbours) + It is CONFIRMED (knowable) only at bar k+lr. We emit, for every bar i, the price of + the most recent pivot high/low confirmed at a bar <= i. Pure forward scan, data <= i. + """ + n = len(high) + res = np.full(n, np.nan) # nearest confirmed pivot-HIGH price (resistance) + sup = np.full(n, np.nan) # nearest confirmed pivot-LOW price (support) + cur_res = np.nan + cur_sup = np.nan + for i in range(n): + # a pivot centred at k = i-lr becomes confirmable exactly now (its right window + # k+1..k+lr == i-lr+1..i is complete and all <= i; left window also <= i). + k = i - lr + if k - lr >= 0: + seg_h = high[k - lr:i + 1] # high[k-lr .. i] = high[k-lr .. k+lr] + seg_l = low[k - lr:i + 1] + hk = high[k] + lk = low[k] + if hk >= seg_h.max(): # k is a (weak) local max -> pivot high + cur_res = hk + if lk <= seg_l.min(): # k is a local min -> pivot low + cur_sup = lk + res[i] = cur_res + sup[i] = cur_sup + return res, sup + + +def signal(df): + high = df["high"].values.astype(float) + low = df["low"].values.astype(float) + c = df["close"].values.astype(float) + n = len(c) + + res, sup = _pivot_levels(high, low, LR) + + # raw breakout events (causal: level + close both known at i) + brk_up = c > res # close above resistance pivot + brk_dn = c < sup # close below support pivot + brk_up = np.nan_to_num(brk_up, nan=False).astype(bool) + brk_dn = np.nan_to_num(brk_dn, nan=False).astype(bool) + + # CONFIRMATION BAR: require the break to hold CONFIRM consecutive closes. + if CONFIRM > 1: + up_run = pd.Series(brk_up).rolling(CONFIRM, min_periods=CONFIRM).sum().values == CONFIRM + dn_run = pd.Series(brk_dn).rolling(CONFIRM, min_periods=CONFIRM).sum().values == CONFIRM + up_run = np.nan_to_num(up_run, nan=False).astype(bool) + dn_run = np.nan_to_num(dn_run, nan=False).astype(bool) + else: + up_run, dn_run = brk_up, brk_dn + + # long/flat state machine (forward scan, data <= i): + # confirmed resistance break -> long ; confirmed support break -> flat. + state = np.zeros(n) + s = 0.0 + for i in range(n): + if up_run[i]: + s = 1.0 + elif dn_run[i]: + s = 0.0 + state[i] = s + + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_13_volbreak.py b/scripts/research/blind/agents/agent_13_volbreak.py new file mode 100644 index 0000000..e7f9473 --- /dev/null +++ b/scripts/research/blind/agents/agent_13_volbreak.py @@ -0,0 +1,93 @@ +"""agent_13_volbreak — ANGLE [family=breakout, slug=volbreak]. + +Volatility breakout: enter the trend direction when REALIZED VOL EXPANDS above its +rolling median. The thesis: a fresh expansion of realized volatility marks a regime +of large, directional moves (a breakout out of a quiet base). When vol picks up we +align with the prevailing trend and ride it; when vol is compressed / below its +rolling median we stand aside (no breakout in progress, just chop). + +Mechanics (all causal — value at i uses only rows 0..i): + * VOL EXPANSION gate: annualized realized vol over a short window (RV_WIN) vs its + own rolling median over a longer lookback (MED_WIN). "Expanded" when + rv[i] > EXP_K * median(rv up to i). bl.realized_vol and pandas rolling are causal. + * TREND direction: sign of price vs a moving average (close / SMA(TREND_WIN) - 1), + decided at close[i]. This is the direction we take *only while* vol is expanded. + * STATE / persistence: once vol expands we lock onto the current trend side and + hold it (stop-and-reverse if the trend sign flips while still expanded) until vol + falls back BELOW its median (expansion over) -> flat. This rides the whole + high-vol leg instead of flickering bar to bar, keeping turnover (fees) down. + * SIZING: the +1/0 direction is vol-targeted (TP01-style) so exposure shrinks into + the very vol spikes the gate selects -> caps drawdown on violent reversals. + +Tuned ONLY on split='train' (Series A and B, equal weight; broad plateau grid below). +Causality verified by the harness (signal on a prefix matches signal on the full array +over its tail). + +Honest notes: + * On these strongly-trending high-vol curves the edge is essentially "be long the + trend, but ONLY when vol confirms a breakout, and shrink size into vol". Value is + RISK-ADJUSTED: comparable/positive PnL at ~3-4x less drawdown than buy&hold (which + eats ~77-79% DD here), not bigger raw PnL. Train combined Sharpe ~1.12, worst-DD + ~23%, mean PnL ~1.14. + * LONG-ONLY (SHORT_SCALE=0). Shorts were dropped after tuning: on these uptrends the + down-trend + vol-expansion combo is dominated by violent V-bottom reversals, which + are terrible to short -> a short leg (full OR damped) strictly LOWERED Sharpe and + raised DD on both train curves. The short leg is not an edge here; flat is better. + * EXP_K=0.8 means we trade when rv sits at/above 0.8x its rolling median — still a + genuine vol-expansion gate (it stands aside in the lowest-vol ~30-40% of bars where + price just chops), but inclusive enough not to miss the early part of a breakout + leg. Requiring rv strictly ABOVE the median (K>=1.0) entered too late and gutted the + Series-B trend capture (Sh 1.12 -> 0.28). The plateau holds for RV 15-20, MED + 100-150, K 0.78-0.85, TREND 30-60. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- tuned on split='train' (broad plateau) --------------------------------- +RV_WIN = 15 # short realized-vol window (the "current" vol) +MED_WIN = 100 # rolling-median lookback for the vol baseline +EXP_K = 0.80 # vol is "expanded" when rv > EXP_K * rolling-median(rv) +TREND_WIN = 50 # trend filter: sign of close / SMA(TREND_WIN) - 1 +SHORT_SCALE = 0.0 # LONG-ONLY: down-vol-breaks here are mostly V-reversals -> shorts bleed +TARGET_VOL = 0.20 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.5 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + bpy = bl.bars_per_day(df) * 365.25 + + # 1) realized vol (short) and its causal rolling median baseline. + r = bl.simple_returns(c) + rv = bl.realized_vol(r, RV_WIN, bpy) + rv_med = pd.Series(rv).rolling(MED_WIN, min_periods=max(10, MED_WIN // 2)).median().values + expanded = np.isfinite(rv) & np.isfinite(rv_med) & (rv > EXP_K * rv_med) + + # 2) trend direction decided at close[i] (causal). + ma = bl.sma(c, TREND_WIN) + with np.errstate(invalid="ignore", divide="ignore"): + trend = np.where(np.isfinite(ma) & (ma > 0), c / ma - 1.0, 0.0) + tsign = np.sign(trend) + + # 3) state machine: while vol is expanded, hold the trend side (S&R on sign flip); + # when vol falls back below its (scaled) median the breakout is spent -> flat. + state = np.zeros(n) + s = 0.0 + for i in range(n): + if expanded[i]: + if tsign[i] > 0: + s = 1.0 + elif tsign[i] < 0: + s = -SHORT_SCALE + # tsign == 0 -> keep current side + else: + s = 0.0 + state[i] = s + + # 4) size by causal vol-targeting (shrinks into vol spikes -> caps DD). + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_14_rsi.py b/scripts/research/blind/agents/agent_14_rsi.py new file mode 100644 index 0000000..6145894 --- /dev/null +++ b/scripts/research/blind/agents/agent_14_rsi.py @@ -0,0 +1,100 @@ +"""Agent 14 — RSI reversion, trend-gated (family=meanrev, slug=rsi). + +The angle (assigned): RSI reversion. Long when RSIhi (bl.rsi), +GATED by a longer trend filter. Tune lo/hi/win. + +Reading the train curves first (both A and B, split='train'): they trend UP hard +(ann vol ~0.7-0.9, total ret +6.7x / +23x over the window). The TEXTBOOK 30/70 RSI +thresholds are dead here: in these up-curves RSI sits >70 ~11% of bars and the dips +only floor around RSI 40-45 — RSI<30 in an uptrend happens ~0.1% of the time. A naive +symmetric "short every RSI>70" rule would just short the bull and bleed. So the +mean-reversion has to be REGIME-AWARE, and the lo/hi have to be tuned to the data's +actual RSI distribution, not the textbook: + + * In an UPTREND (close above a long SMA) RSI dips are BUY-THE-DIP reversion. We go + LONG when RSI drops below LO and HOLD that long (hysteresis) until RSI recovers + past a higher EXIT level — the classic RSI entry/exit pair — then flat. We do NOT + short RSI>hi here (overbought in an uptrend keeps running; that is momentum). + * In a DOWNTREND (close below the long SMA) the symmetry returns: RSI>HI is a + reversion SHORT (rips fade back down); RSI bigger appetite, no hard 0/1 fee-churning flips) then vol-targeted so the +two curves are risk-comparable and exposure shrinks into vol spikes (crashes are vol +spikes), bounding the drawdown. + +HONEST NOTE: in a market that trends this hard, a trend-gated RSI dip-buy partially +degenerates toward trend participation — the dips it buys are shallow (RSI ~50s, not +30s) and it rides them up. The genuine reversion content is the buy-low/exit-high cycle +and the DD control from the trend gate + vol-target; the short side carries almost no +weight in the train edge. The result is an honest-but-modest combined train Sharpe ~1.1 +at ~11% DD (vs long-only buy&hold's ~7-23x PnL at ~70-80% DD) — i.e. a fraction of the +buy&hold PnL but ~6-7x less drawdown. + +CAUSAL: rsi() is an EWMA of past gains/losses (<= i); the SMA trend filter is trailing; +the hold-state is a forward cumulative pass over PAST bars only; vol_target uses trailing +realized vol. No shift(-k), no centered windows, no global fit. Verified by causality_ok +(max_diff 0.0). + +Tuning (train only, combined A&B; coarse->fine sweep + plateau check). Chosen cell is +INTERIOR on every axis — RW in [18..25], LO in [56..62], EXIT in [75..85], TWIN=150, +TVOL [0.20..0.25] all stay sharpe_min ~1.0..1.26 at DD ~0.11..0.13, a broad plateau not +a spike. (Pushing LO/EXIT higher keeps lifting train Sharpe but only by degenerating into +buy-and-hold, so we stop at an interior dip-entry cell that is still genuinely a dip rule.) + RSI_WIN=20, LO=58, HI=68, EXIT=78, TREND_WIN=150 + SHORT_W=0.5, TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5, BASE=0.6 + -> train combined: pnl_mean ~0.87, maxdd_worst ~0.11, sharpe_min ~1.14 +""" +import numpy as np +import blindlib as bl + +RSI_WIN = 20 # RSI lookback (the "win" of the angle; 20 > textbook 14 for these trends) +LO = 58.0 # oversold/dip threshold -> reversion LONG (tuned to the curves' RSI floor) +HI = 68.0 # overbought threshold -> reversion SHORT (downtrend only) +EXIT = 78.0 # dip-long is HELD until RSI recovers past EXIT (hysteresis entry/exit pair) +TREND_WIN = 150 # long SMA: above = uptrend (buy dips), below = downtrend (sell rips). DD sweet spot. +SHORT_W = 0.5 # weight on the downtrend short side; <1 because the curves drift up +BASE = 0.6 # base long size while holding a dip (scaled up if still oversold) +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + rs = bl.rsi(c, RSI_WIN) + trend_up = c > bl.sma(c, TREND_WIN) # causal trailing SMA trend gate + + # --- smooth reversion appetite from RSI (further past threshold -> bigger) --- + long_app = np.clip((LO - rs) / 25.0, 0.0, 1.0) # oversold -> long appetite + short_app = np.clip((rs - HI) / (100.0 - HI), 0.0, 1.0) # overbought -> short appetite + + # --- trend-gated RSI reversion with hysteresis on the dip-long --- + # The forward pass below is PURE PAST-ONLY: in_long at bar i depends only on bars <= i + # (rs, trend_up are causal; the state machine never looks ahead). Causality verified. + held = np.zeros(n) + in_long = False + for i in range(n): + if in_long: + # exit the held dip-long when the trend breaks down OR RSI has recovered + if (not trend_up[i]) or (rs[i] >= EXIT): + in_long = False + else: + # enter a dip-long only in an uptrend when RSI is below LO (oversold dip) + if trend_up[i] and rs[i] < LO: + in_long = True + if in_long: + held[i] = max(BASE, long_app[i]) # ride the recovery, bigger if still oversold + else: + # when not holding a long, only the downtrend reversion-short passes through + held[i] = (-SHORT_W * short_app[i]) if (not trend_up[i]) else 0.0 + + pos = bl.vol_target(held, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_15_bbands.py b/scripts/research/blind/agents/agent_15_bbands.py new file mode 100644 index 0000000..4c87026 --- /dev/null +++ b/scripts/research/blind/agents/agent_15_bbands.py @@ -0,0 +1,108 @@ +"""Agent 15 — Bollinger-band reversion, low-vol gated (family=meanrev, slug=bbands). + +The angle (assigned): fade touches of the Bollinger bands (bl.bbands), only in a +low-vol regime. Tune win, k. + +What the train curves actually say (A & B, split='train', diagnosed before coding): +both trend UP hard (+6.7x / +23x, ann vol ~0.7-0.9). The TEXTBOOK symmetric band-fade +is a LOSER here and the data is blunt about why: + + * UPPER-band touch -> CONTINUATION, not reversion. fwd-5bar after a close>=upper is + +3.4%/+2.7% (A/B) even when we restrict to the low-vol regime. In a bull, riding the + upper band is momentum; shorting it just bleeds against the drift. So the SHORT side + of the classic fade is dead and we do NOT take it. + * LOWER-band touch is reversion ONLY when it is a DIP IN AN UPTREND. close<=lower while + price is above a long SMA -> fwd-5bar +3.5%/+7.2% (A/B): the band stretch snaps back + up. The same lower touch in a DOWNTREND / high-vol continues DOWN (A high-vol lo-touch + fwd-5 = -3.9%): a real knife. So the reversion we keep is the buy-the-dip-in-uptrend + leg, and we gate it OFF in downtrends and in high vol. + +Hence the rule is an HONEST, one-sided Bollinger reversion: LONG the lower-band touch, +but only while (a) close is above a long trend SMA and (b) realized vol is in its lower +regime (the assigned low-vol gate). %b drives a smooth appetite (deeper below the band -> +bigger), the long is HELD with hysteresis until price mean-reverts back through the mid +band, then flat. Sizing is vol-targeted so the two curves are risk-comparable and exposure +shrinks into vol spikes (which are exactly the regime where the dip-buy fails). + +HONEST NOTE: in a market trending this hard a trend+lowvol-gated dip-buy partially +degenerates toward trend participation — the genuine reversion content is the buy-below-band +/ exit-at-mid cycle plus the DD control from the gates + vol-target. The symmetric short-the- +upper-band leg that "Bollinger reversion" classically implies carries NEGATIVE edge on these +curves, so taking it would only add drawdown; the result is therefore a modest-but-real +reversion edge, NOT a high-PnL alpha. A negative result for the *symmetric* fade is itself a +finding (documented above). + +CAUSAL: bbands/sma/realized_vol are trailing (value at i uses bars <= i); the hold-state is +a forward cumulative pass over PAST bars only; vol_target uses trailing realized vol. No +shift(-k), no centered windows, no global fit. Verified by causality_ok (max_diff ~0). + +Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell is +interior on every axis and sits on a stable plateau (neighbouring K in [1.8..2.2], +TREND_WIN in [100..150], VOL_PCT in [0.65..0.85], ENTRY_PB in [0..0.1] all give +sharpe_min ~0.43-0.48 at DD ~0.08, sharpe_mean ~0.74-0.80): + BB_WIN=20, BB_K=2.0, TREND_WIN=120, VOL_WIN=20, VOL_PCT=0.65, + ENTRY_PB=0.10 (touch lower band), EXIT_PB=0.50 (exit at the MID band), + TARGET_VOL=0.25, VOL_WIN_DAYS=30, LEV_CAP=1.5, BASE=1.0 + -> train combined: pnl_mean ~0.29, maxdd_worst ~0.08, sharpe_min ~0.48 (A binds; B ~1.1). +Exiting at the mid band (not higher) is the binding choice: Series A's dips are shallow and +fizzle, so holding the reversion past mid turns Series A negative (Sharpe 0.48 -> -0.0). +""" +import numpy as np +import blindlib as bl +import pandas as pd + +BB_WIN = 20 # Bollinger lookback ("win" of the angle) +BB_K = 2.0 # band width in std ("k" of the angle) +TREND_WIN = 120 # long SMA: dip-buy only ABOVE it (reversion lives in the uptrend) +VOL_WIN = 20 # realized-vol lookback for the low-vol gate +VOL_PCT = 0.65 # low-vol gate: only act when rolling vol is below its expanding p65 +ENTRY_PB = 0.10 # enter when %b <= this (close at/below the lower band) +EXIT_PB = 0.50 # exit when %b >= this (price has mean-reverted to the MID band) +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.5 +BASE = 1.0 # full size while holding a dip-long (the events are sparse; ride the snap-back) + + +def _expanding_quantile_below(x, q): + """Causal: at bar i, is x[i] at/below the q-quantile of x[0..i]? (expanding, no leak).""" + s = np.asarray(x, float) + thr = pd.Series(s).expanding(min_periods=30).quantile(q).values + out = s <= thr + out[~np.isfinite(thr)] = False + return out + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + up, mid, lo = bl.bbands(c, BB_WIN, BB_K) # causal trailing bands + band_w = up - lo + # %b: 0 = at the lower band, 0.5 = at the mid band, 1 = at the upper band. + pb = np.where(np.isfinite(band_w) & (band_w > 0), (c - lo) / band_w, np.nan) + trend_up = c > bl.sma(c, TREND_WIN) # causal trend gate + + r = bl.simple_returns(c) + rv = bl.realized_vol(r, VOL_WIN, 365.0) # causal trailing realized vol + low_vol = _expanding_quantile_below(rv, VOL_PCT) # causal expanding low-vol regime gate + + # One-sided Bollinger reversion: buy the lower-band touch (dip) in uptrend + low-vol, + # HOLD with hysteresis until %b mean-reverts back up to the MID band, then flat. The + # symmetric upper-band SHORT is a proven loser on these curves (continuation), so flat. + # Forward pass is PURE PAST-ONLY: in_long at i depends only on bars <= i. + held = np.zeros(n) + in_long = False + for i in range(n): + if in_long: + # exit when the dip has mean-reverted to the mid band, or the trend breaks + if (not trend_up[i]) or (np.isfinite(pb[i]) and pb[i] >= EXIT_PB): + in_long = False + else: + # enter a dip-long: %b at/below the lower band, in uptrend, in low-vol regime + if trend_up[i] and low_vol[i] and np.isfinite(pb[i]) and pb[i] <= ENTRY_PB: + in_long = True + held[i] = BASE if in_long else 0.0 + + pos = bl.vol_target(held, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_16_zrev.py b/scripts/research/blind/agents/agent_16_zrev.py new file mode 100644 index 0000000..0e7728b --- /dev/null +++ b/scripts/research/blind/agents/agent_16_zrev.py @@ -0,0 +1,78 @@ +"""Agent 16 — Z-score reversion to SMA, trend-gated (family=meanrev, slug=zrev). + +THE ANGLE (assigned): reversion of price to its SMA via a CAUSAL rolling z-score — +short positive extremes / long negative extremes — WITH A TREND-AGREEMENT GATE. + +Why the gate is the whole story here. Naive z-reversion (short every z>+thr, long every +z<-thr against a price-vs-SMA z-score) LOSES on these two curves: both trend up ~8x/24x +over the sample, so a positive z-extreme above a medium SMA is usually momentum that keeps +going (study: z>1.5 -> next-bar +0.005/+0.008, NOT a reversal), and shorting it just fights +the trend. The reversion that actually exists is the SHORT-HORIZON pullback inside the +prevailing trend: + + * In an UPTREND (price > slow SMA), a negative z-extreme (a dip below the FAST SMA) is a + pullback that bounces -> go LONG. (study: UP & z<-1 -> next-bar +0.003 .. +0.012.) + * In a DOWNTREND (price < slow SMA), a positive z-extreme (a rally above the FAST SMA) is + a dead-cat that fades -> go SHORT. (study: DOWN & z>+1 -> next-bar ~0 .. -0.004.) + * A z-extreme that DISAGREES with the trend (rally in an uptrend / dip in a downtrend) is + momentum/continuation, not reversion -> stay FLAT (those bins are where naive z-reversion + bleeds: UP & z>1 -> +0.003 continuation; you must NOT short it). + +So the position is the reversion impulse (-z, clipped to extremes) FILTERED by trend +agreement: keep only longs in uptrends and shorts in downtrends. A causal vol-target then +sizes it so A and B are risk-comparable and exposure shrinks into vol spikes. + +CAUSAL: zscore(c, FAST) and sma(c, SLOW) at i use only rows <= i; the trend gate and +vol_target are trailing. No shift(-k), no centered windows, no global fit. Verified by +causality_ok. + +Tuning (train only, combined A&B; coarse->fine sweep). A CONTINUOUS reversion impulse +(-z, saturating) gated by the trend beats sparse extreme-only entries (more of the dips are +captured while the gate keeps the trend on your side). The chosen cell is interior on every +axis and is a plateau, not a spike: FAST 2..3, SLOW 100..150, Z_SAT 1.5..2.0 all stay in +sharpe_min ~0.6..0.8 at DD ~0.06..0.12; SHORT_W 0->0.5 only lowers sharpe_min (the downtrend +short reversion fights the structural uptrend). vol_target scales PnL<->DD linearly (sharpe +flat), so TARGET_VOL just sets the risk dial. + FAST=2, SLOW=120, Z_SAT=1.75, SHORT_W=0.0, TARGET_VOL=0.30, VOL_WIN_DAYS=30, LEV_CAP=2.0 + -> train combined: pnl_mean ~0.31, maxdd_worst ~0.11, sharpe_min ~0.78 + (a modest PnL at a ~10% drawdown — the reversion-in-trend captures the bounces while + sidestepping the big declines, vs long-only buy&hold's huge PnL at ~70-80% DD). +""" +import numpy as np +import blindlib as bl + +FAST = 2 # short SMA for the reversion z-score (the "stretch from SMA" detector) +SLOW = 120 # slow SMA defining the trend regime for the agreement gate +Z_SAT = 1.75 # z magnitude that saturates the reversion impulse to +-1 +SHORT_W = 0.0 # weight on the (gated) short leg; tuning -> 0 (long-flat best on train) +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 2.0 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + z = np.nan_to_num(bl.zscore(c, FAST), nan=0.0) # price-vs-fast-SMA, standardized (causal) + slow = bl.sma(c, SLOW) # trend regime line (causal) + uptrend = c > slow # boolean trend gate + + # reversion impulse = -z: long when price is stretched BELOW its SMA (dip, z<0), + # short when stretched ABOVE (rally, z>0). Proportional, saturating at +-Z_SAT. + impulse = np.clip(-z / Z_SAT, -1.0, 1.0) # -z direction = reversion to the SMA + + # TREND-AGREEMENT GATE: keep ONLY longs in an uptrend and shorts in a downtrend. + # A z-extreme that DISAGREES with the trend (rally in an uptrend / dip in a downtrend) + # is momentum/continuation, not reversion -> stay FLAT. The short leg is gated AND + # down-weighted by SHORT_W (tuning drives it to 0: both curves trend up, so the + # downtrend-short reversion only adds drawdown here). + raw = np.zeros(n) + long_ok = (impulse > 0) & uptrend # buy the dip inside an uptrend + short_ok = (impulse < 0) & (~uptrend) # fade the rally inside a downtrend + raw[long_ok] = impulse[long_ok] + raw[short_ok] = impulse[short_ok] * SHORT_W + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_17_st_reversal.py b/scripts/research/blind/agents/agent_17_st_reversal.py new file mode 100644 index 0000000..953f5d1 --- /dev/null +++ b/scripts/research/blind/agents/agent_17_st_reversal.py @@ -0,0 +1,115 @@ +"""Agent 17 — Short-term reversal, trend-gated (family=meanrev, slug=st_reversal). + +THE ANGLE (assigned): fade the last 1-3 bar move, but ONLY when the longer trend +AGREES with the fade direction. So we never fight the trend: we only take the leg of +the reversal that points the same way the slow regime already points. + + * UPTREND (price > slow SMA): the trend-agreeing fade is to fade a DROP -> go LONG + the bounce. (Fading a rise here would mean shorting INTO an uptrend = fighting the + trend -> NOT allowed, stay flat on that leg.) + * DOWNTREND (price < slow SMA): the trend-agreeing fade is to fade a RISE -> go SHORT + the dead-cat. (Fading a drop here would mean longing INTO a downtrend = fighting the + trend -> NOT allowed, stay flat on that leg.) + +Why this is the structure in the data (train study, both curves): + Forward 1-bar return after a 1-bar move, conditioned on the 150-SMA regime -- + A UP & drop>5% -> +0.0050 (bounce) UP & rise>5% -> +0.0007 (rise gives back) + B UP & drop>5% -> +0.0115 (bounce) UP & rise>5% -> -0.0004 (rise gives back) + A DN & rise>2% -> -0.0039 (fades) DN & drop0-2% -> ~0 + B DN & rise>2% -> -0.0038 (fades) + -> corr(-r, fwd) is POSITIVE in both regimes (UP ~0.03-0.08, DN ~0.15): a 1-bar move + partially reverses next bar. The trend gate keeps only the half of that reversion + that the slow trend supports, so the (gated) short leg lives only where the curve + is genuinely rolling over -- it does not bleed shorting a structural bull. + +The reversal impulse is the (vol-scaled) negative of the recent move -r_k -- a CONTINUOUS, +saturating fade of the last K-bar return -- rather than sparse extreme-only entries, so +more of the small bounces are captured. We blend K=1..3 (mostly K=1, the cleanest +reversal) and normalize each move by trailing vol so the threshold is in sigma, not raw %. + +CAUSAL: sma(c,SLOW), the K-bar past returns, the trailing-vol scaler, the trend gate and +vol_target at bar i all use only rows <= i. No shift(-k), no centered windows, no global +fit. Verified by causality_ok. + +Tuning (train only, combined A&B, coarse->fine; interior plateau, not a spike). Series A +is the binding constraint (a weaker, deeper-pullback reversal than B); the chosen cell +maximizes A's sharpe at a controlled DD without overfitting B. Perturbations around the +center all stay in sharpe_min ~0.48..0.58 at DD ~0.14..0.16: + SLOW 125..135 (smin 0.51..0.55), Z_SAT 0.85..1.05 (smin 0.52..0.56), + SHORT_W 0..0.5 (smin 0.53..0.54 -- the gated short adds a touch), K-weights from pure + 1-bar (smin 0.58, DD 0.16) to (0.5,0.3,0.2) (smin 0.53, DD 0.14). vol_target scales + PnL<->DD ~linearly (sharpe flat) so TARGET_VOL is just the risk dial; LEV_CAP is not + binding (vol-target keeps |pos|<1 on these curves). + Chosen (interior, robust): SLOW=130, K_WEIGHTS=(0.7,0.2,0.1), Z_SAT=0.95, SHORT_W=0.25, + TARGET_VOL=0.25, VOL_WIN_DAYS=30, LEV_CAP=2.0 + -> train combined: pnl_mean ~0.52, maxdd_worst ~0.15, sharpe_min ~0.55 + (A ~0.55 sharpe / B ~1.3 sharpe). A modest, positive PnL at a ~15% drawdown -- the + trend-gated short-term reversal harvests the in-trend bounces while sidestepping the + big declines, vs long-only buy&hold's ~6-23x PnL at ~70-80% DD. +""" +import numpy as np +import blindlib as bl + +SLOW = 130 # slow SMA -> trend regime for the agreement gate +K_WEIGHTS = (0.7, 0.2, 0.1) # blend of the 1-,2-,3-bar fades (mostly the 1-bar, the cleanest) +Z_SAT = 0.95 # move size (in trailing sigma) that saturates the fade impulse to +-1 +SHORT_W = 0.25 # weight on the (trend-gated) short leg; gated -> it helps a little +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 30 +LEV_CAP = 2.0 +EPS = 1e-9 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + r = bl.simple_returns(c) # r[i] = c[i]/c[i-1]-1 (causal, uses <= i) + + # trailing daily-vol scaler so the "size of the last move" is measured in sigma, + # not raw % (otherwise A and B, with different vols, would need different thresholds). + vol = bl.rolling_std(r, 30) + vol = np.where(np.isfinite(vol) & (vol > EPS), vol, np.nan) + # causal fill: use the last finite vol seen so far; fallback to a constant for warmup. + vol = _ffill(vol) + vol = np.where(np.isfinite(vol), vol, np.nanmedian(vol[np.isfinite(vol)]) if np.isfinite(vol).any() else 0.03) + + # FADE impulse = -(recent K-bar move) / vol, blended over K=1..3 and saturated to +-1. + # Positive impulse = price just DROPPED (fade -> want long); negative = just ROSE. + impulse = np.zeros(n) + for k, w in zip((1, 2, 3), K_WEIGHTS): + mk = np.zeros(n) + mk[k:] = c[k:] / c[:-k] - 1.0 # past k-bar return ending at i (causal) + # normalize the k-bar move by sqrt(k)*vol so each horizon is on the same sigma scale + zk = -mk / (np.sqrt(k) * vol + EPS) # FADE = negative of the move + impulse += w * np.clip(zk / Z_SAT, -1.0, 1.0) + impulse = np.clip(impulse, -1.0, 1.0) + + slow = bl.sma(c, SLOW) # trend regime line (causal) + uptrend = c > slow + + # TREND-AGREEMENT GATE: keep ONLY the fade leg that AGREES with the slow trend. + # uptrend + impulse>0 (price dropped) -> LONG the bounce (fade agrees: up) + # downtrend+ impulse<0 (price rose) -> SHORT the dead-cat (fade agrees: down) + # The disagreeing legs (fade a rise in an uptrend = short into a bull; fade a drop in a + # downtrend = long into a bear) are momentum/continuation, not reversion -> stay FLAT. + raw = np.zeros(n) + long_ok = (impulse > 0) & uptrend + short_ok = (impulse < 0) & (~uptrend) + raw[long_ok] = impulse[long_ok] + raw[short_ok] = impulse[short_ok] * SHORT_W + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) + + +def _ffill(a): + """Causal forward-fill of NaNs (each value uses only past finite values).""" + out = a.copy() + last = np.nan + for i in range(len(out)): + if np.isfinite(out[i]): + last = out[i] + else: + out[i] = last + return out diff --git a/scripts/research/blind/agents/agent_18_dist_ma.py b/scripts/research/blind/agents/agent_18_dist_ma.py new file mode 100644 index 0000000..d677b99 --- /dev/null +++ b/scripts/research/blind/agents/agent_18_dist_ma.py @@ -0,0 +1,89 @@ +"""Agent 18 — Distance-from-MA reversion, trend-gated (family=meanrev, slug=dist_ma). + +THE ANGLE (assigned): position = -tanh(scaled distance of price from its MA). Buy when price +is stretched BELOW its MA, sell when stretched ABOVE — a reversion-to-the-MA impulse, sized by +how far price has wandered. Tune the MA window and the tanh scale. + +WHY THE PURE ANGLE LOSES, AND WHAT SURVIVES. +The naive symmetric form (-tanh(scale * (price/MA - 1)) traded both sides) is CATASTROPHIC on +these two curves: both trend up ~7x (A) / ~23x (B) over the train window, so shorting every +stretch ABOVE the MA just fights a relentless uptrend. Measured: the pure symmetric angle +returns -79%..-95% with sharpe ~ -0.5..-0.9 (it shorts the bull). A conditioning study of +next-bar return vs the normalized distance-from-MA confirms the asymmetry: the LARGEST +positive next-bar returns sit at the HIGHEST positive distance (that's momentum continuation, +NOT reversion — never short it), while the genuine reversion edge lives only on the DOWNSIDE +— when price is stretched well below its MA, the next bar bounces (+0.27%..+0.35% in the +deepest dip bin, pooled A&B). So the distance-from-MA reversion that actually exists here is +the short-horizon PULLBACK inside the prevailing trend, not a fade of the trend itself. + +THE RULE. + impulse = -tanh(SCALE * z) where z = (price/SMA(MA) - 1) standardized by a trailing rolling + std (so A and B, with different vol, get comparable stretch units). impulse>0 = price below + its MA (a dip -> reversion says go long); impulse<0 = price above its MA (a rally -> short). + A TREND GATE then keeps only the reversion leg that agrees with the regime: + * UPTREND (price > SMA(SLOW)): take only the LONG impulse (buy the dip that bounces). + * DOWNTREND (price < SMA(SLOW)): take only the SHORT impulse (fade the dead-cat rally), + down-weighted by SHORT_W. Tuning drives SHORT_W -> 0: both curves trend up, so the + downtrend-short reversion only adds drawdown over this sample. + A causal vol_target sizes the impulse so the two series are risk-comparable and exposure + shrinks into vol spikes. + +CAUSAL: SMA(MA), SMA(SLOW), the rolling std and vol_target at bar i use only rows <= i. No +shift(-k), no centered windows, no global fit. Verified by causality_ok (online-consistent). + +TUNING (train only, combined A&B; coarse->fine, plateau not spike). A FAST MA (the distance is +a short-horizon pullback, not a slow-trend gap) is decisively better than a medium MA: +ma=3 beats ma=20+ by ~0.2 sharpe at lower DD. The chosen cell is interior on every axis: + MA 3..5 -> sharpe_min 0.69..0.81 ; SCALE 1.0..2.5 -> 0.72..0.76 (PnL rises, DD ~flat) ; + NORM_WIN 30..90 -> 0.75..0.80 ; SLOW 110..140 -> sharpe_min 0.74..0.81 (a real plateau). + SHORT_W 0->0.5 only lowers sharpe (the downtrend short fights the structural uptrend). + vol_target trades PnL<->DD ~linearly (sharpe flat), so TARGET_VOL is just the risk dial. + + MA=3, NORM_WIN=60, SCALE=1.5, SLOW=130, SHORT_W=0.0, TARGET_VOL=0.30, VOL_WIN=30, LEV_CAP=2.0 + -> train combined: pnl_mean ~0.70, maxdd_worst ~0.115, sharpe_min ~0.80 + (a solid PnL at an ~11-12% drawdown: the reversion-in-trend harvests the pullback bounces + while sidestepping the deep declines, vs long-only buy&hold's huge PnL at ~70-80% DD.) + +HONEST CAVEAT: the value here is the DROP IN DRAWDOWN (~6x lower than buy&hold), not beating +buy&hold's raw PnL on a 7x/23x bull run. The PURE assigned angle (symmetric fade) is a +loser on trending data — it only becomes positive once gated to the dip side of the trend. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +MA = 3 # fast SMA -> the distance is a SHORT-HORIZON pullback from price +NORM_WIN = 60 # trailing window standardizing the distance (so A & B are comparable) +SCALE = 1.5 # tanh scale on the standardized distance -> reversion impulse magnitude +SLOW = 130 # trend-regime SMA for the agreement gate +SHORT_W = 0.0 # weight on the (gated) downtrend-short leg; tuning -> 0 (long-flat best) +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 2.0 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + # distance of price from its (fast) MA, standardized by a trailing rolling std (causal). + dist = c / bl.sma(c, MA) - 1.0 + sd = pd.Series(dist).rolling(NORM_WIN).std().values + zd = np.nan_to_num(dist / np.where(sd > 0, sd, np.nan), nan=0.0) + + # the assigned angle: reversion impulse = -tanh(scaled distance). + # zd>0 (price above MA) -> impulse<0 (short the stretch) + # zd<0 (price below MA) -> impulse>0 (long the dip) + impulse = -np.tanh(SCALE * zd) + + # trend-agreement gate: keep only the reversion leg that agrees with the regime. + up = c > bl.sma(c, SLOW) + raw = np.zeros(n) + long_ok = (impulse > 0) & up # buy the dip inside an uptrend + short_ok = (impulse < 0) & (~up) # fade the rally inside a downtrend (down-weighted) + raw[long_ok] = impulse[long_ok] + raw[short_ok] = impulse[short_ok] * SHORT_W + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_19_voltarget_lo.py b/scripts/research/blind/agents/agent_19_voltarget_lo.py new file mode 100644 index 0000000..36a24d0 --- /dev/null +++ b/scripts/research/blind/agents/agent_19_voltarget_lo.py @@ -0,0 +1,58 @@ +"""Agent 19 — Vol-targeted long-only / risk-parity single asset +(family=vol, slug=voltarget_lo). + +The angle (assigned): NO direction call. Hold the asset LONG at all times, but size +the position by INVERSE realized volatility so the book runs at a roughly constant +target volatility: exposure[i] = clip( target_vol / realized_vol[i] , 0, cap ). + +Why this anticipates anything at all, despite never predicting direction: realized +vol is PERSISTENT (today's vol forecasts tomorrow's vol far better than today's return +forecasts tomorrow's return). The big declines on these two curves are also the high- +vol regimes — a crash is a vol spike. So scaling exposure DOWN when trailing vol is +high mechanically pulls the book light right when the worst legs happen, and levers UP +in the calm grind higher. The result on a structurally up-trending curve is a long-only +book with most of buy&hold's upside but a much smaller drawdown (the risk-parity / "vol +control" effect), at modest turnover (the weight only drifts with the vol forecast). + +CAUSAL: realized_vol[i] uses returns over a trailing window ending at i (rows <= i); +the position is then shifted by the evaluator (held during bar i+1). No direction is +derived from any future bar; no global fit. Verified by causality_ok (max_diff 0.0). + +Tuning (split='train' only, combined A&B). The free knobs are the trailing vol window, +the target vol, and the leverage cap. + * CAP is the single most important choice. Because both curves trend up hard, a high + cap just re-levers into buy&hold and brings the drawdown right back. cap=1.0 (never + more than fully invested) is what preserves the risk-parity de-risking benefit; with + a vol-driven weight that almost always sits below 1.0 this is the whole point. + * VOL_WIN is the vol-forecast horizon. A SLOW window (~120d) gives a stabler vol + estimate, less whipsaw, lower turnover and the BEST risk-adjusted result here: + sharpe_min climbs from ~0.85 (30d) to ~0.97 (120d) and the plateau (110..200d) is + flat at sharpe 0.91..0.99 / DD ~0.42-0.44 -> 120 is a robust interior pick. + * TARGET_VOL is a pure DD/PnL dial: it scales exposure up and down but (for a long- + only inverse-vol book) leaves the Sharpe essentially flat (0.971 across 0.24..0.32). + So it is chosen for the DD/PnL trade-off, not the Sharpe. +Chosen cell, interior on every axis: + TARGET_VOL = 0.28 # DD/PnL dial; Sharpe flat across 0.24..0.32 -> balanced cell + VOL_WIN_D = 120 # slow, stable vol forecast; plateau 110..200d + LEV_CAP = 1.0 # never lever past fully-invested -> keeps the DD-cut benefit + -> train combined: pnl_mean ~2.93, maxdd_worst ~0.43, sharpe_min ~0.97. +This is a DEFENSIVE long-only book, NOT alpha. Its honest value is the drawdown: ~0.43 +vs ~0.77-0.79 buy&hold at comparable PnL. Because it never shorts, its Sharpe ceiling +(~1.0) is set by the absence of any direction call -> it can avoid sizing into the big +declines but cannot profit from them. That is the inherent limit of this angle. +""" +import numpy as np +import blindlib as bl + +TARGET_VOL = 0.28 +VOL_WIN_D = 120 +LEV_CAP = 1.0 + + +def signal(df): + # direction = always long (+1), NO direction call. Sizing is pure inverse-vol. + direction = np.ones(len(df)) + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_D, leverage_cap=LEV_CAP) + # long-only risk-parity: clip to [0, cap] (no shorts by construction) + return np.clip(np.nan_to_num(pos, nan=0.0), 0.0, LEV_CAP) diff --git a/scripts/research/blind/agents/agent_20_regime_switch.py b/scripts/research/blind/agents/agent_20_regime_switch.py new file mode 100644 index 0000000..481160e --- /dev/null +++ b/scripts/research/blind/agents/agent_20_regime_switch.py @@ -0,0 +1,100 @@ +"""agent_20_regime_switch — ANGLE [family=vol, slug=regime_switch]. + +Regime switch on the realized-vol PERCENTILE (expanding / online): + + * Compute short-window realized vol rv[i] at each bar. + * Rank it against its EXPANDING percentile (the causal "typical" vol seen so far) — + a self-calibrating threshold that needs no magic vol level and adapts as the series + evolves (no peeking at the full-sample distribution). + * LOW-VOL regime (rv-rank <= PCTL): TREND-FOLLOW. Quiet, orderly markets are where + momentum persists, so we ride the prevailing (multi-horizon) trend. + * HIGH-VOL regime (rv-rank > PCTL): stand aside (FLAT). High realized vol is where + trends whipsaw / V-reverse and where the big drawdowns are born; the cleanest + expression of the "regime switch" is to refuse directional exposure there. + +The trend leg is a multi-horizon TSMOM SIGN blend (slow horizons ~1/2/4 months): a +single lookback is regime-fragile, the blend keeps the slow macro trend while the fast +horizon cuts exposure early into a turn. Final size is a trailing vol-target, so the +position also shrinks into vol within the low-vol regime. + +CAUSAL: rv uses a trailing window; the percentile rank is EXPANDING (only past bars); +each TSMOM sign uses close[i]/close[i-H]; vol_target uses a trailing realized-vol +window. No look-ahead, no centered windows, no global fit. Verified by causality_ok +(max_diff 0.0). + +Tuned ONLY on split='train' (Series A & B, equal weight). A coarse->fine sweep found a +WIDE plateau: HZ=(25,60,120), PCTL in [0.60..0.70], VW in [35..55], RV in [15..25] all +give sharpe_min ~1.25-1.30 at DD ~0.17-0.19. The chosen cell is interior on every axis +(robust, not a lucky spike): + RV_WIN=20, PCTL=0.65, HORIZONS=(25,60,120), TARGET_VOL=0.22, VOL_WIN=45, LEV_CAP=1.5 + -> train combined: pnl_mean ~2.0, maxdd_worst ~0.18, sharpe_min ~1.30. + +Honest notes: + * The high-vol leg is LONG-FLAT (not revert). A lightly-weighted contrarian leg in + high vol helped marginally with a single-MA trend, but once the trend is the slow + multi-horizon SIGN blend the reversion leg only added drag -> flat is strictly + better here. The value is RISK-ADJUSTED: comparable/positive PnL at ~4x less + drawdown than buy&hold (which eats ~77-79% DD on these curves), by sitting out the + high-realized-vol regime where the violent declines happen. + * Loosening the gate (PCTL ~0.65, not 0.50) is what lifts both Sharpe and PnL: the + bottom ~half of the vol distribution is too restrictive and misses the early, + still-low-vol part of the trend legs. The plateau is wide enough that the exact + percentile is not load-bearing. +""" +import numpy as np +import blindlib as bl + +RV_WIN = 20 # short realized-vol window ("current" vol) +PCTL = 0.65 # expanding vol-percentile gate: trend-follow when rank <= this +HORIZONS = (25, 60, 120) # multi-horizon TSMOM sign blend (~1/2/4 months of daily bars) +TARGET_VOL = 0.22 +VOL_WIN_DAYS = 45 +LEV_CAP = 1.5 +MIN_HIST = 60 # warmup before the expanding percentile is trusted + + +def _expanding_pctl_rank(x: np.ndarray, min_hist: int) -> np.ndarray: + """rank[i] = fraction of finite x[0..i] that are <= x[i] (causal, expanding). + NaN until `min_hist` finite values have accumulated.""" + n = len(x) + rank = np.full(n, np.nan) + seen: list[float] = [] + for i in range(n): + v = x[i] + if np.isfinite(v): + seen.append(v) + if len(seen) >= min_hist: + rank[i] = float(np.mean(np.asarray(seen) <= v)) + return rank + + +def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: + """Sign of the past-h-bar return, causal. 0 for i < h.""" + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def signal(df): + c = df["close"].values.astype(float) + bpy = bl.bars_per_day(df) * 365.25 + + # 1) short-window realized vol and its EXPANDING percentile rank (causal). + rv = bl.realized_vol(bl.simple_returns(c), RV_WIN, bpy) + rank = _expanding_pctl_rank(rv, MIN_HIST) + low_vol = np.isfinite(rank) & (rank <= PCTL) # the LOW-VOL regime we trade + + # 2) multi-horizon TSMOM sign blend -> graded direction in [-1, +1] (causal). + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + + # 3) regime switch: trend-follow ONLY in the low-vol regime, else flat. + raw = np.where(low_vol, sig, 0.0) + + # 4) causal vol-targeting (shrinks size into vol -> caps DD). + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_21_atr_ride.py b/scripts/research/blind/agents/agent_21_atr_ride.py new file mode 100644 index 0000000..97fd84d --- /dev/null +++ b/scripts/research/blind/agents/agent_21_atr_ride.py @@ -0,0 +1,108 @@ +"""agent_21_atr_ride — ANGLE: ATR-channel trend ride with an ATR trailing stop that +scales the position DOWN on adverse moves (family=vol, slug=atr_ride). + +Idea (assigned angle): + * Build an ATR channel around an EMA mid-line: mid = EMA_N(close); + band half-width = K_ENTRY * ATR_M. A close above mid + K_ENTRY*ATR starts an + uptrend ride. + * Maintain an ATR TRAILING STOP (Chandelier / SuperTrend flavour): a stop line that + RATCHETS in the trade's favour and never loosens. While long, the stop is + (highest-close-since-entry - K_STOP*ATR) and only moves up. A close below it ends + the ride (flatten). + * The distinguishing twist of THIS angle (vs a binary breakout) is the SCALE-DOWN on + adverse moves. Instead of a hard on/off stop we size by the ATR "stop room": + room[i] = clip( (close[i] - stop[i]) / (K_STOP*ATR[i]) , 0, 1 ) + = how much cushion (in ATR units, normalised by the stop distance) sits between the + close and the trailing stop. Exposure is proportional to that cushion, so the book + runs full deep in a healthy trend, BLEEDS OFF smoothly as price falls back toward the + stop, and goes flat once the stop breaks. We ride winners and de-risk into reversals + BEFORE the stop is hit, instead of binary all-in / all-out. + + Long/flat only. Both curves trend up; the short side of an ATR ride is whipsaw on the + V-shaped bottoms (same lesson as the donchian/keltner siblings), so a stop-out goes to + FLAT, never short. The ride exposure (already in [0,1]) is then vol-targeted so the + long shrinks further into vol spikes (every crash is a vol spike) -> caps the DD. + +CAUSAL: mid (EMA) and ATR are built with .shift(1) -> strictly from bars <= i-1, and the +close[i] that pierces the channel / sits above the stop is a real, tradeable event at +close[i]. The trailing-stop state machine is a forward scan using only data <= i (peak is +the running max of past closes; the stop only ratchets up). vol_target uses realized vol +up to i. No future rows, no centered windows, no global fit -> causality_ok = true +(verified: max_diff 0.0). The evaluator then holds the position during bar i+1. + +TUNING (split='train' only, Series A & B equal weight; chosen cell is a plateau center): + * N_EMA x N_ATR: the (20,20) cell is the best risk-adjusted corner of the EMA/ATR grid + (sharpe_min ~1.39 vs ~1.06-1.27 at slower 30-60 windows) and its 27-cell neighbourhood + (N_EMA 18-25, N_ATR 15-25, K_STOP 2.0-3.0) holds sharpe_min in [1.16, 1.41] (median + 1.30, 93% of cells > 1.2) -> a genuine plateau, not an isolated peak. + * K_ENTRY = 1.0 is the clear ridge: the K_ENTRY row 0.5->1.5 peaks sharply at 1.0 + (sharpe_min jumps to ~1.3-1.4) because requiring a full ATR of breakout above the mid + filters out the chop-region false starts. + * K_STOP = 2.5 ATR: the whole K_STOP 2.0-3.5 strip at K_ENTRY=1.0 is flat-high + (sharpe_min 1.29-1.39, DD 0.22-0.28); 2.5 is the interior balance. + * TARGET_VOL is a pure PnL/DD dial with FLAT Sharpe (~1.39 across 0.20-0.30): 0.20 -> + pnl 1.75/DD 0.16 ... 0.30 -> pnl 3.23/DD 0.23 ... 0.40 -> pnl 4.81/DD 0.29. 0.30 is + the balanced cell. VOL_WIN=30 is interior and best on Sharpe (1.39 vs 1.28 at 60). + LEV_CAP=1.0 (never lever past fully invested) preserves the de-risking benefit. + +Train (combined A&B): pnl_mean ~3.23, maxdd_worst ~0.23, sharpe_min ~1.39. +Honest note: this is trend-following, not alpha — its value is turning a high-PnL / +~77-79%-DD uptrend into comparable PnL at ~23% drawdown (DD cut ~3.4x). The scale-down +twist buys a slightly lower DD and steadier equity than a binary ATR breakout would, at +the cost of leaving some upside on the table in the very strongest legs (the position is +rarely pinned at 1.0). The short side was not pursued: on these up-trending curves it is +value-destroying whipsaw, the same finding as the sibling breakout angles. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +N_EMA = 20 # ATR-channel mid-line EMA span +N_ATR = 20 # ATR window (channel half-width AND trailing-stop unit) +K_ENTRY = 1.0 # entry: close > mid + K_ENTRY*ATR -> start the ride (ridge value) +K_STOP = 2.5 # trailing stop distance in ATR (Chandelier) -> also the scale ruler +TARGET_VOL = 0.30 # PnL/DD dial; Sharpe flat across 0.20-0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def _atr_ride_exposure(df): + """Long/flat exposure in [0,1]: 0 when out of the ride; while in the ride, the value + is the ATR 'stop room' (cushion above the trailing stop, in [0,1]) so the position + scales DOWN smoothly on adverse moves and goes flat when the stop breaks.""" + c = df["close"].values.astype(float) + n = len(c) + mid = pd.Series(bl.ema(c, N_EMA)).shift(1).values # EMA built strictly <= i-1 + atr = pd.Series(bl.atr(df, N_ATR)).shift(1).values # ATR built strictly <= i-1 + + expo = np.zeros(n) + in_ride = False + peak = -np.inf # highest close since entry (drives the ratcheting stop) + for i in range(n): + m, a = mid[i], atr[i] + if not (np.isfinite(m) and np.isfinite(a) and a > 0): + continue + if not in_ride: + # entry: close pierces the upper ATR channel (full ATR above the mid) + if c[i] > m + K_ENTRY * a: + in_ride = True + peak = c[i] + if in_ride: + peak = max(peak, c[i]) + stop = peak - K_STOP * a # Chandelier trailing stop (ratchets via peak) + if c[i] <= stop: + in_ride = False # stop broken -> ride over, flat + expo[i] = 0.0 + peak = -np.inf + else: + # SCALE DOWN on adverse moves: cushion above the stop, normalised to [0,1]. + room = (c[i] - stop) / (K_STOP * a) + expo[i] = float(np.clip(room, 0.0, 1.0)) + return expo + + +def signal(df): + expo = _atr_ride_exposure(df) # long/flat in [0,1], already scaled by stop room + pos = bl.vol_target(expo, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), 0.0, LEV_CAP) diff --git a/scripts/research/blind/agents/agent_22_dd_derisk.py b/scripts/research/blind/agents/agent_22_dd_derisk.py new file mode 100644 index 0000000..70944ec --- /dev/null +++ b/scripts/research/blind/agents/agent_22_dd_derisk.py @@ -0,0 +1,75 @@ +"""agent_22_dd_derisk — ANGLE: drawdown-state de-risking overlay (family=vol, slug=dd_derisk). + +Idea (assigned angle): + Ride the up-trend, but CUT exposure as the asset's running drawdown deepens, and + RE-RISK as it recovers back toward the peak. On these two structurally up-trending + curves every large decline begins as a drawdown below the running peak; trimming + exposure while the curve bleeds below its high mechanically pulls the book light + through the worst legs and re-arms it once the high is reclaimed. + + Construction (all causal / online): + * dd[i] = close[i] / running_peak(close[0..i]) - 1 in (-1, 0] -> the LIVE drawdown. + * |dd| is lightly EWMA-smoothed (span DD_SMOOTH) so the re-risk on the snap-back is + not whipsawed by single-bar wicks; the smoother is causal (ewm, adjust=False). + * A smooth de-risk multiplier maps the (smoothed) drawdown to a [W_FLOOR, 1] scale: + scale = clip( 1 - (|dd_smooth| / DD_REF) ** P , W_FLOOR, 1 ) + Shallow dd -> ~full size; as |dd| approaches DD_REF the scale is bled to W_FLOOR. + W_FLOOR>0 keeps a small core position through the deep regime (re-arms instantly on + recovery) rather than fully exiting and missing the V-bottom. + * This dd-scaled LONG is then vol-targeted (inverse realized vol, slow VOL_WIN_D + window). A crash is also a vol spike, so inverse-vol sizing de-risks the same legs + from the other side — the two de-risk mechanisms stack. Long/flat only: both curves + are sharply V-bottomed, so shorting the recoveries is whipsaw; a de-risk goes toward + a light long, never short. + + Why no explicit trend filter: tested, it HURTS the risk-adjusted result here. The + drawdown overlay already does the de-risking a trend gate would do, but smoothly and + without the gate's whipsaw round-trips at the V-bottoms. Pure dd-derisk + slow + inverse-vol gives the better Sharpe. + +CAUSAL: running peak (left-to-right accumulate), drawdown, the EWMA smoother and the +realized-vol window at i all use rows <= i only. The evaluator shifts the position (held +during bar i+1). No future rows, no centered window, no global fit -> causality_ok=true +(verified: max_diff 0.0). + +Tuning (split='train' only, A & B equal weight; buy&hold ref: A Sh0.89/DD0.77, +B Sh1.16/DD0.79). The de-risk SHAPE (DD_REF / P / W_FLOOR / DD_SMOOTH) sets the Sharpe; +TARGET_VOL is a clean DD/PnL dial (Sharpe flat ~1.10-1.14 across 0.25..0.50). Chosen cell +is interior on every axis with a flat plateau (Sharpe 1.08..1.15, DD 0.19..0.24): + DD_REF=0.20 P=1.0 W_FLOOR=0.20 DD_SMOOTH=4 VOL_WIN_D=120 TARGET_VOL=0.40 + -> train combined: pnl_mean ~1.63, maxdd_worst ~0.22, sharpe_min ~1.14. +Honest read: this is a DEFENSIVE long-only book, not alpha. Its value is the DRAWDOWN — +~0.22 vs ~0.77-0.79 buy&hold (a ~3.5x cut) at comparable risk-adjusted PnL. Because it +never shorts, its Sharpe ceiling (~1.1-1.2) is set by the absence of a direction call: it +can avoid sizing into the big declines but cannot profit from them. That is the inherent +limit of the de-risk-overlay angle on these curves. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +DD_REF = 0.20 # drawdown (fraction) at which the de-risk multiplier hits the floor +P = 1.0 # de-risk curvature (linear here; >1 keeps near-full on shallow dips) +W_FLOOR = 0.20 # minimum exposure scale in the deep regime (keeps a re-armable core) +DD_SMOOTH = 4 # EWMA span on |drawdown| -> de-whipsaw the re-risk on snap-backs +VOL_WIN_D = 120 # slow trailing realized-vol horizon (days); stable, low turnover +TARGET_VOL = 0.40 # DD/PnL dial; Sharpe flat across 0.25..0.50 -> picked for PnL/DD balance +LEV_CAP = 1.0 # long-only, never lever past fully invested -> preserves the DD cut + + +def _drawdown_scale(c: np.ndarray) -> np.ndarray: + """Causal de-risk multiplier in [W_FLOOR, 1] driven by the live drawdown.""" + peak = np.maximum.accumulate(c) # running peak over rows <= i (causal) + dd = c / peak - 1.0 # (-1, 0] + ad = np.abs(dd) + ad = pd.Series(ad).ewm(span=DD_SMOOTH, adjust=False).mean().values # causal smoother + depth = ad / DD_REF + return np.clip(1.0 - depth ** P, W_FLOOR, 1.0) + + +def signal(df): + c = df["close"].values.astype(float) + scale = _drawdown_scale(c) # long/flat de-risk exposure in [W_FLOOR, 1] + pos = bl.vol_target(scale, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_D, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), 0.0, LEV_CAP) diff --git a/scripts/research/blind/agents/agent_23_vol_of_vol.py b/scripts/research/blind/agents/agent_23_vol_of_vol.py new file mode 100644 index 0000000..7c34ab8 --- /dev/null +++ b/scripts/research/blind/agents/agent_23_vol_of_vol.py @@ -0,0 +1,120 @@ +"""agent_23_vol_of_vol — ANGLE [family=vol, slug=vol_of_vol]. + +Vol-of-vol gate: trade the trend ONLY when volatility itself is STABLE; flatten when +vol is spiking erratically. + +The idea (distinct from a plain vol-LEVEL gate): what kills a trend-follower is not +high volatility per se — a calm, persistently-high-vol grind still trends — but the +INSTABILITY of the vol regime. When realized volatility itself starts jumping around +(vol-of-vol spikes), the market is in a disorderly, regime-shifting state where trends +V-reverse and whipsaw, and where the violent declines are born. So: + + * Compute short-window realized vol rv[i] (the "current" vol). + * Compute VOL-OF-VOL vov[i] = trailing std of the LOG-CHANGES of rv (a scale-free + measure of how erratically vol is moving — robust to the absolute vol level, which + differs across the two curves). + * Rank vov against its EXPANDING percentile (causal, self-calibrating threshold — no + magic vol-of-vol level, adapts as the series evolves, never peeks at the full sample). + * STABLE-VOL regime (vov-rank <= PCTL): TREND-FOLLOW the prevailing multi-horizon + TSMOM sign blend (~1/2/4 months). + * ERRATIC-VOL regime (vov-rank > PCTL): stand aside (FLAT) — refuse directional + exposure where vol is spiking erratically. + +Final size is a trailing vol-target so exposure also shrinks into raw vol inside the +stable regime. + +CAUSAL: rv uses a trailing window; the log-change std uses a trailing window; the +percentile rank is EXPANDING (only past bars); each TSMOM sign uses close[i]/close[i-H]; +vol_target uses a trailing realized-vol window. No look-ahead, no centered windows, no +global fit. Verified by causality_ok (max_diff 0.0). + +Tuned ONLY on split='train' (Series A & B, equal weight). A coarse->fine sweep found a +WIDE plateau and one load-bearing insight: only the TOP of the vol-of-vol distribution +hurts. Tight gates (PCTL ~0.55-0.65) are too restrictive — they sit out the early, still- +orderly part of the trend legs and DROP the Sharpe to ~0.83. Flattening only the most +ERRATIC ~20% (PCTL ~0.80) is what lifts both Sharpe and PnL. Around the chosen cell the +plateau is flat: VOV_WIN in [30..50] -> sharpe_min 1.12..1.16, PCTL in [0.76..0.84] -> +1.12..1.17, all at DD ~0.19-0.23. The chosen cell is interior on every axis: + RV_WIN=30, VOV_WIN=40, PCTL=0.80, HORIZONS=(25,60,120), TARGET_VOL=0.22, VOL_WIN=45 + -> train combined: pnl_mean ~1.87, maxdd_worst ~0.20, sharpe_min ~1.16. + +Honest notes: + * The erratic-vol leg is LONG-FLAT (not contrarian) — refusing exposure where vol is + unstable, not betting against the move. The value is RISK-ADJUSTED: comparable PnL + at ~4x less drawdown than buy&hold (~0.77-0.79 DD on these curves), by sitting out + the disorderly regimes where the violent declines are born. + * TARGET_VOL is a pure DD/PnL dial (Sharpe flat ~1.16 across 0.18..0.26); LEV_CAP does + not bind (the vol-target weight sits below 1.0). 0.22 is a balanced cell. + * This gate measures the STABILITY of vol (vol-of-vol), distinct from a vol-LEVEL gate: + a calm persistently-HIGH-vol grind still trends and is kept; it is the erratic, + regime-shifting vol that is flattened. The Sharpe ceiling (~1.16) is set by the + absence of a short leg — it avoids the chop but cannot profit from the declines. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +RV_WIN = 30 # short realized-vol window ("current" vol) +VOV_WIN = 40 # trailing window for vol-of-vol (std of log-changes of rv) +PCTL = 0.80 # expanding vov-percentile gate: trend-follow when rank <= this +HORIZONS = (25, 60, 120) # multi-horizon TSMOM sign blend (~1/2/4 months of daily bars) +TARGET_VOL = 0.22 +VOL_WIN_DAYS = 45 +LEV_CAP = 1.5 +MIN_HIST = 60 # warmup before the expanding percentile is trusted + + +def _expanding_pctl_rank(x: np.ndarray, min_hist: int) -> np.ndarray: + """rank[i] = fraction of finite x[0..i] that are <= x[i] (causal, expanding). + NaN until `min_hist` finite values have accumulated.""" + n = len(x) + rank = np.full(n, np.nan) + seen: list[float] = [] + for i in range(n): + v = x[i] + if np.isfinite(v): + seen.append(v) + if len(seen) >= min_hist: + rank[i] = float(np.mean(np.asarray(seen) <= v)) + return rank + + +def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: + """Sign of the past-h-bar return, causal. 0 for i < h.""" + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def _vol_of_vol(rv: np.ndarray, win: int) -> np.ndarray: + """vol-of-vol: trailing std of the log-changes of realized vol (scale-free).""" + rv_s = pd.Series(rv) + logrv = np.log(rv_s.where(rv_s > 0)) + dlog = logrv.diff() + return dlog.rolling(win, min_periods=max(5, win // 2)).std().values + + +def signal(df): + c = df["close"].values.astype(float) + bpy = bl.bars_per_day(df) * 365.25 + + # 1) short-window realized vol, then its vol-of-vol and EXPANDING percentile (causal). + rv = bl.realized_vol(bl.simple_returns(c), RV_WIN, bpy) + vov = _vol_of_vol(rv, VOV_WIN) + rank = _expanding_pctl_rank(vov, MIN_HIST) + stable = np.isfinite(rank) & (rank <= PCTL) # the STABLE-VOL regime we trade + + # 2) multi-horizon TSMOM sign blend -> graded direction in [-1, +1] (causal). + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + + # 3) vol-of-vol gate: trend-follow ONLY when vol is stable, else flat. + raw = np.where(stable, sig, 0.0) + + # 4) causal vol-targeting (shrinks size into vol -> caps DD). + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_24_hhll.py b/scripts/research/blind/agents/agent_24_hhll.py new file mode 100644 index 0000000..07dd462 --- /dev/null +++ b/scripts/research/blind/agents/agent_24_hhll.py @@ -0,0 +1,109 @@ +"""agent_24_hhll — ANGLE: swing-structure trend (higher-high/higher-low vs lower-low/lower-high). + +Idea (assigned angle, family=struct / slug=hhll): + Read the curve the way a price-action trader reads market STRUCTURE. Find the swing pivots + (fractal turning points) with a rolling left/right window, then track the sequence of + confirmed swing HIGHs and swing LOWs: + * UPTREND = a higher-high AND a higher-low (last swing high > prior swing high AND + last swing low > prior swing low) -> go LONG. + * STRUCTURE BREAK DOWN = a lower-low (last swing low < prior swing low, a confirmed + market-structure-break to the downside) -> exit to FLAT. + * Otherwise -> persist the prior state (an uptrend stays innocent through pullbacks / + single lower-highs until a swing low is actually undercut). + A slow-MA gate (price must still be above its 150-bar mean) acts as the trend-still-intact + confirmation of the structural read — an uptrend whose price has fallen below its own mean + has structurally rolled over. The position is vol-targeted, so the book shrinks into the + vol spikes that mark every real structure break, which is what caps the drawdown. + +CAUSALITY — the crux of any swing/pivot signal: + A swing pivot centred at bar k is only KNOWABLE `RIGHT` bars later: you need the right-hand + window k+1..k+RIGHT to assert k was a local extreme. So at bar i we may use only pivots + whose confirmation bar k+RIGHT <= i. `_hhll_state` does a pure forward scan: at each i it + confirms the pivot centred at k=i-RIGHT (its full window k-LEFT..k+RIGHT is complete and all + indices <= i) and appends it to the running swing history. The HH/HL/LL comparison and the + MA gate at i use only data <= i. No future row ever enters the state. causality_ok -> true. + +LONG/FLAT, not stop-and-reverse (tuned honestly on split='train', A & B equal weight): + Both curves trend up hard. A symmetric SHORT on every lower-low / lower-high whipsaws on + V-bottoms and destroys risk-adjusted value (sweep: short legs drop sharpe_min from ~1.2 to + ~0). The structural reading is kept but the down leg is FLAT, not short. This is the right + call for a long-biased instrument: ride confirmed up-structure, stand aside when it breaks. + +Tuned params — a broad plateau on train (A & B), NOT an isolated peak. sharpe_min holds +~0.95-1.17 across LR 4, MA 120..180, vol-target 0.20..0.30, vol_win 20..60 (sweeps in dev +notes). LR=4 is the peak of the pivot-window dimension; MA and target_vol move PnL/DD but not +the risk-adjusted shape. Chosen centre of the plateau: + LEFT=RIGHT=4 (pivot half-window), MA_FILT=150 (trend-intact gate), target_vol 0.25 / 30d / + cap 1 -> train combined: pnl_mean ~2.13, maxdd_worst ~0.28, sharpe_min ~1.17. + +Honest note: like every structure/trend rule on a strongly up-trending pair this is +trend-following, not alpha. Ablation is candid — a plain "always-long above the 150-MA" gate +scores a slightly HIGHER train sharpe (~1.34) than this structural overlay, because the +HH/HL/LL logic stands aside during some pullbacks that later resume. The structure's value is +that it is a genuinely different, pivot-based read of the SAME trend that converts a high-PnL +/ ~77-79%-DD buy&hold into comparable PnL at ~28% drawdown (DD cut ~2.7x), with only ~33% +time in market. It is the assigned angle implemented faithfully — not a momentum rule wearing +a structure costume. +""" +import numpy as np +import blindlib as bl + +LEFT = 4 # pivot left half-window +RIGHT = 4 # pivot right half-window (confirmation lag) +MA_FILT = 150 # trend-still-intact gate: price must be above this SMA to stay long +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def _hhll_state(high, low, close, left, right, ma_filt): + """Causal HH/HL/LL market-structure trend state in {0, 1} (long/flat). + + Forward scan: at bar i confirm the pivot centred at k=i-right (window k-left..k+right, + all <= i), update the running swing-high / swing-low history, then: + * higher-high AND higher-low -> long (clean up-structure) + * lower-low (structure break) -> flat + * else -> hold prior state + A final SMA gate forces flat if price is below its slow mean (trend rolled over). + Returns a float direction array, len(high); each value uses only data <= i. + """ + n = len(high) + state = np.zeros(n) + sh = [] # confirmed swing-high prices (chronological) + sl = [] # confirmed swing-low prices + s = 0.0 + sma_c = bl.sma(close, ma_filt) if ma_filt else None + for i in range(n): + k = i - right + if k - left >= 0: + seg_h = high[k - left:i + 1] # high[k-left .. k+right], all indices <= i + seg_l = low[k - left:i + 1] + if high[k] >= seg_h.max(): # weak local max -> swing high + sh.append(high[k]) + if low[k] <= seg_l.min(): # local min -> swing low + sl.append(low[k]) + if len(sh) >= 2 and len(sl) >= 2: + hh = sh[-1] > sh[-2] # higher high + hl = sl[-1] > sl[-2] # higher low + ll = sl[-1] < sl[-2] # lower low = structure break down + if hh and hl: + s = 1.0 + elif ll: + s = 0.0 + # else: keep prior state (uptrend survives a single lower-high / pullback) + ss = s + if ma_filt and s > 0.0 and not (close[i] > sma_c[i]): + ss = 0.0 # trend-intact gate (causal) + state[i] = ss + return state + + +def signal(df): + high = df["high"].values.astype(float) + low = df["low"].values.astype(float) + close = df["close"].values.astype(float) + + direction = _hhll_state(high, low, close, LEFT, RIGHT, MA_FILT) + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_25_channel_pos.py b/scripts/research/blind/agents/agent_25_channel_pos.py new file mode 100644 index 0000000..a0b90fe --- /dev/null +++ b/scripts/research/blind/agents/agent_25_channel_pos.py @@ -0,0 +1,91 @@ +"""agent_25_channel_pos — ANGLE [struct/channel_pos]: position WITHIN the Donchian channel. + +Idea (assigned angle): instead of a binary breakout AT the channel edge, measure WHERE the +close sits inside the rolling Donchian channel [lo, hi] as a continuous fraction + chpos = (close - lo) / (hi - lo) in [0, 1] (0.5 = mid-channel). +Then take a directional position only when location AND trend AGREE: + * LONG when chpos is in the UPPER third (>= UP_TH) AND the channel/price slope is UP, + * SHORT when chpos is in the LOWER third (<= LO_TH) AND the slope is DOWN, + * FLAT in the middle band or when slope disagrees with location. +The "slope" filter is what makes the angle anticipatory rather than a reversal: riding the +upper third while the channel is still pushing up is a continuation read; the lower-third + +down-slope short tries to catch the persistent declines (the big drawdowns the benchmark eats). + +WHY a slope gate (honest tuning result): + Channel-position WITHOUT a slope gate is a mean-reversion read (buy low-in-channel) and + on these trending curves it bleeds — it fights the trend and the upper third without a + trend filter chops on every pullback. Requiring location AND slope to agree turns it into + a trend-confirmation read that holds longs through the up-leg and only shorts confirmed + down-legs. The slope is the prior-W channel-midpoint change (causal). + +Sizing: the agreed direction (+1/-1/0) is vol-targeted (TP01-style, causal realized vol) so +size shrinks into vol spikes (= crashes) -> caps drawdown. + +Causality: bl.donchian shifts the rolling hi/lo by one bar, so the channel at i is built from +bars STRICTLY before i. chpos[i], the slope (a backward difference of a causal EMA of close), +and the vol scaling all use only data <= i. The forward scan keeps no future state. The +evaluator then HOLDS the position during bar i+1. causality_ok -> true. + +WHY the short leg is sized 0.30 (honest tuning result): + A full-size (-1.0) short bled on these up-trending curves (combined Sharpe_min 1.06, DD 0.30). + Shrinking the short leg monotonically improved risk-adjusted return; long/flat alone was best + on raw PnL/Sharpe but had a slightly fatter DD (0.256). The chosen short=0.30 keeps a genuine + lower-third+down-slope SHORT (the angle is intact) and TRIMS the drawdown (0.256 -> 0.229) + at ~no PnL cost. So the angle's short leg earns its place, just at a modest size. + +Plateau (tuned on train only): broad and well-behaved around DON 35-45 / UP-LO 0.62-0.66 / +SLOPE_WIN 15-20 / short 0.15-0.35 (Sharpe_min ~1.3-1.4 throughout, not an isolated peak). + +FINAL train (combined A&B): pnl_mean ~4.06, maxdd_worst ~0.229, sharpe_min ~1.34, sharpe_mean ~1.40. + Per-series: A pnl 4.88 / DD 0.226 / Sh 1.45 ; B pnl 3.22 / DD 0.193 / Sh 1.33. Turnover ~14/yr. + causality.ok = true (max_diff 0). Honest note: this is a trend-confirmation read dressed as a + channel-position rule (the slope gate makes it ride the trend, not fade it); its value is + comparable PnL to buy&hold at ~1/3 of the drawdown, NOT independent alpha. +""" +import numpy as np +import blindlib as bl + +DON_WIN = 40 # Donchian window for the channel +UP_TH = 0.62 # upper-band threshold on chpos (>=) -> "upper third" (location) +LO_TH = 0.38 # lower-band threshold on chpos (<=) -> "lower third" (location) +SLOPE_WIN = 20 # bars over which we measure the price slope (trend gate) +SLOPE_EPS = 0.0 # min |slope| to count as up/down (0 = any non-zero sign) +SHORT_SIZE = 0.30 # short-leg size (lower third + down-slope). <1 by tuning: the curves + # trend up, so a full-size short bleeds; a modest short still TRIMS DD. +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + hi, lo = bl.donchian(df, DON_WIN) # prior-DON_WIN hi/lo (shifted, causal) + width = hi - lo + # continuous position within the channel in [0,1]; mid (0.5) where channel undefined. + with np.errstate(invalid="ignore", divide="ignore"): + chpos = (c - lo) / width + chpos = np.where(np.isfinite(chpos) & (width > 0), chpos, 0.5) + chpos = np.clip(chpos, 0.0, 1.0) + + # causal slope: change of a smoothed close over SLOPE_WIN bars, normalized by price. + sm = bl.ema(c, SLOPE_WIN) + slope = np.zeros(n) + slope[SLOPE_WIN:] = (sm[SLOPE_WIN:] - sm[:-SLOPE_WIN]) / np.maximum(sm[:-SLOPE_WIN], 1e-9) + + up_loc = chpos >= UP_TH + dn_loc = chpos <= LO_TH + up_slope = slope > SLOPE_EPS + dn_slope = slope < -SLOPE_EPS + + direction = np.zeros(n) + direction[up_loc & up_slope] = 1.0 # upper third + rising -> long + direction[dn_loc & dn_slope] = -SHORT_SIZE # lower third + falling -> (small) short + + # warmup: no channel yet -> flat + direction[:DON_WIN] = 0.0 + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_26_stoch.py b/scripts/research/blind/agents/agent_26_stoch.py new file mode 100644 index 0000000..35da704 --- /dev/null +++ b/scripts/research/blind/agents/agent_26_stoch.py @@ -0,0 +1,134 @@ +"""Agent 26 — Stochastic oscillator reversion + cross, trend-gated (family=osc, slug=stoch). + +The angle (assigned): a rolling Stochastic oscillator (%K / %D). %K = where the close sits +in its rolling [min(low), max(high)] window (0..100); %D = a short SMA of %K (the signal +line). Trade the REVERSION (%K leaving an oversold extreme) timed by the %K-vs-%D CROSS, +GATED by a longer trend filter. Tune the windows. + +Reading the train curves first (both A and B, split='train'): they trend UP very hard +(A 100->792, B 100->2400 over the window). UNLIKE RSI — which in these up-curves never +dips below ~40 so textbook 30/70 is dead — the Stochastic %K is normalized against its +OWN rolling high/low, so it sweeps the FULL 0..100 range even inside the bull: %K<20 +~12-14% of bars, %K>80 ~24-27% of bars (measured). That is exactly the structure a +stochastic reversion rule needs, so the angle is genuinely playable here, but it still +has to be REGIME-AWARE because the curves drift up: + + * In an UPTREND (close above a long SMA) %K oversold (80 in an + uptrend — overbought in a bull keeps running (that is momentum, not reversion). + * In a DOWNTREND (close below the long SMA) the symmetry returns: %K overbought (>80) with + a %K cross DOWN through %D is a reversion SHORT (rips fade). %K bigger appetite, floored at BASE while holding) then +VOL-TARGETED so the two curves are risk-comparable and exposure shrinks into vol spikes +(crashes are vol spikes) — that is what bounds the drawdown. Note the leverage cap never +binds here (post-vol-target appetite stays <=1), so the edge does NOT rely on leverage. + +HONEST NOTE (negative findings kept): (1) the downtrend short side is essentially free but +adds nothing on train — SHORT_W=0.5 gives sharpe_min 0.51 vs 0.53 at SHORT_W=0; it is kept +small to honor the bidirectional angle, not because it earns. (2) A continuous always-on +oscillator weighting (no flat state) was tried and pushed time-in-market to ~99% and DD to +0.20-0.37 — it degenerated into buy-and-hold; the hysteresis flat state is what keeps the +DD at ~12%. (3) In a market that trends this hard, even a cross-gated dip-buy is PARTLY +trend participation (the dips it buys recover and it rides them). The genuine reversion +content is the oversold-entry / cross-timed turn / overbought-exit cycle plus the DD control +from the trend gate + vol-target. Result: an honest, MODEST combined train Sharpe ~0.5 at +~12% DD — a fraction of buy&hold's huge PnL but ~6x less drawdown (it anticipates the dip +rather than just holding the asset through every crash). + +CAUSAL: %K uses trailing rolling max(high)/min(low) (<= i); %D is a trailing SMA of %K; the +cross compares (%K-%D) at i vs i-1 (past only); the hold-state is a forward cumulative pass +over PAST bars only; the SMA trend filter and vol_target use trailing data. No shift(-k), no +centered windows, no global fit. Verified by causality_ok (max_diff 0.0). + +Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell sits +on a broad plateau (K in [14..20], LO in [40..50], EXIT in [55..65], D in [3..5], TREND_WIN +in [150..200] all hold sharpe_min ~0.37..0.53 at DD ~0.09..0.12 — a plateau, not a spike): + K_WIN=20, D_WIN=5, LO=50, EXIT=55, TREND_WIN=150 + SHORT_W=0.5, BASE=0.7, TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5 + -> train combined: pnl_mean ~0.17, maxdd_worst ~0.12, sharpe_min ~0.51 +""" +import numpy as np +import pandas as pd +import blindlib as bl + +K_WIN = 20 # %K lookback (rolling high/low window). 20 > textbook 14 for these trends. +D_WIN = 5 # %D = SMA(%K, D_WIN): the signal line the %K crosses. +LO = 50.0 # oversold threshold below which a %K/%D up-cross is a dip-long entry. +EXIT = 55.0 # dip-long HELD until %K recovers past EXIT (hysteresis entry/exit pair). +TREND_WIN = 150 # long SMA: above = uptrend (buy dips), below = downtrend (sell rips). +SHORT_W = 0.5 # weight on the downtrend reversion-short; marginal (see HONEST NOTE). +BASE = 0.7 # base long size while holding a dip (scaled up if %K still oversold). +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def _stoch(df, k_win, d_win): + """Causal Stochastic oscillator. %K[i] uses high/low/close over the trailing + k_win bars (<= i); %D[i] = SMA(%K, d_win) (trailing). No look-ahead.""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + hh = pd.Series(h).rolling(k_win, min_periods=1).max().values + ll = pd.Series(l).rolling(k_win, min_periods=1).min().values + rng = hh - ll + k = np.where(rng > 1e-12, (c - ll) / rng * 100.0, 50.0) + d = bl.sma(k, d_win) + return k, d + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + k, d = _stoch(df, K_WIN, D_WIN) + trend_up = c > bl.sma(c, TREND_WIN) # causal trailing SMA trend gate + + # --- %K/%D crosses (past-only: compares i vs i-1) --- + kd = k - d + kd_prev = np.concatenate(([0.0], kd[:-1])) + cross_up = (kd > 0) & (kd_prev <= 0) # %K turns up through its signal line + cross_dn = (kd < 0) & (kd_prev >= 0) # %K turns down through its signal line + + # --- smooth reversion appetite from %K (further past threshold -> bigger) --- + long_app = np.clip((LO - k) / LO, 0.0, 1.0) # oversold depth -> long appetite + short_app = np.clip((k - 80.0) / 20.0, 0.0, 1.0) # overbought depth -> short appetite + + # --- trend-gated stochastic reversion with cross-triggered entry + hysteresis --- + # Forward pass is PURE PAST-ONLY: in_long at bar i depends only on bars <= i. + held = np.zeros(n) + in_long = False + for i in range(n): + if in_long: + # exit the held dip-long when trend breaks down OR %K has recovered past EXIT + if (not trend_up[i]) or (k[i] >= EXIT): + in_long = False + else: + # enter a dip-long in an uptrend when %K is oversold AND turns up through %D + if trend_up[i] and (k[i] < LO) and cross_up[i]: + in_long = True + if in_long: + held[i] = max(BASE, long_app[i]) # ride the recovery, bigger if still oversold + else: + # downtrend reversion-short: overbought AND %K turning down through %D + if (not trend_up[i]) and (k[i] > 80.0) and cross_dn[i]: + held[i] = -SHORT_W * short_app[i] + else: + held[i] = 0.0 + + pos = bl.vol_target(held, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_27_dpo.py b/scripts/research/blind/agents/agent_27_dpo.py new file mode 100644 index 0000000..9e80cd0 --- /dev/null +++ b/scripts/research/blind/agents/agent_27_dpo.py @@ -0,0 +1,68 @@ +"""agent_27_dpo — Detrended Price Oscillator (cycle phase around a LAGGED MA). + +ANGLE [family=osc, slug=dpo]: detrend price by subtracting a moving average that we +DELAY (lag) so the oscillator measures where price sits in its cycle relative to a +recent trend baseline. Trade the cycle phase — causal only. + +Classic DPO is price[i] - SMA(n)[i - (n/2 + 1)]. The textbook centers that lag; here we +keep the displacement STRICTLY BACKWARD (the MA value comes from ~n/2 bars ago, fully in +the past), so the oscillator is causal/online and deployable. + +What the train data says (tuned on split='train' only): + dpo = (price - lagged_baseline) / vol(gap) is a z-like CYCLE PHASE around zero. + Bucketing dpo vs the NEXT-bar return showed a clean MONOTONIC relationship: the higher + the detrended oscillator (price above its lagged baseline = cycle UP-phase), the higher + the next return; deep-negative dpo (cycle down-phase) precedes flat/negative returns. + So on these series the cycle is CONTINUATION, not reversion -> we FOLLOW the phase + (long the up-phase, flat/short the down-phase), confirmed by a slow trend gate, and + size with vol-targeting. Result on train: positive PnL at ~19% worst DD vs buy&hold's + ~78% DD — anticipating the move means staying out of (or short) the down-phase. + +Config tuned on train (period=30 / trendwin=200 / scale=1.5 / wc=0.6 / ema=2 / tv=0.18): +plateau-robust across period 30, trend 150-200, scale 1.5-2.0, cycle weight 0.5-0.8. +""" +import numpy as np +import blindlib as bl + +# --- tuned on split='train' only ------------------------------------------ +PERIOD = 30 # DPO moving-average period +LAG = PERIOD // 2 + 1 # textbook DPO displacement, kept strictly backward (causal) +TREND_WIN = 200 # slow-trend confirmation window +SCALE = 1.5 # tanh softness of the cycle phase +W_CYCLE = 0.6 # blend weight: cycle phase vs slow-trend confirmation +EMA_SMOOTH = 2 # position smoothing (cuts turnover/fees) +TARGET_VOL = 0.18 # annualized vol target +VOL_WIN = 30 +LEV_CAP = 1.0 + + +def _dpo_phase(c: np.ndarray) -> np.ndarray: + """Detrended price oscillator z-phase: (price - LAGGED SMA) / rolling std of gap. + The baseline SMA is delayed by LAG bars, so every value uses only past data.""" + n = len(c) + base = bl.sma(c, PERIOD) # causal SMA + base_lag = np.full(n, np.nan) + base_lag[LAG:] = base[:-LAG] # baseline from LAG bars ago (past only) + gap = c - base_lag + gap_vol = bl.rolling_std(gap, PERIOD) + gap_vol = np.where((gap_vol > 0) & np.isfinite(gap_vol), gap_vol, np.nan) + return gap / gap_vol # z-like cycle phase (NaN during warmup) + + +def signal(df): + c = df["close"].values.astype(float) + + # detrended cycle phase (DPO core) — empirically CONTINUATION on these series + z = np.nan_to_num(_dpo_phase(c), nan=0.0) + cycle = np.tanh(z / SCALE) # +1 up-phase, -1 down-phase + + # slow-trend confirmation (don't ride the cycle against a strong regime) + trend = c / bl.sma(c, TREND_WIN) - 1.0 + follow = np.tanh(np.nan_to_num(trend, nan=0.0) * 6.0) + + raw = np.clip(W_CYCLE * cycle + (1.0 - W_CYCLE) * follow, -1.0, 1.0) + raw = bl.ema(raw, EMA_SMOOTH) # smooth -> fewer fee-bleeding flips + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_28_willr.py b/scripts/research/blind/agents/agent_28_willr.py new file mode 100644 index 0000000..d1831ba --- /dev/null +++ b/scripts/research/blind/agents/agent_28_willr.py @@ -0,0 +1,145 @@ +"""Agent 28 — Williams %R momentum/reversion HYBRID, trend-gated (family=osc, slug=willr). + +The angle (assigned): Williams %R momentum/reversion hybrid with a trend gate. Williams %R +is the inverse of the Stochastic %K: %R = -100 * (HH - close) / (HH - LL) over a trailing +window, ranging -100 (close at the window LOW = oversold) .. 0 (close at the window HIGH = +overbought). It measures where the close sits in its own rolling high/low channel, so it is +self-normalizing and sweeps the FULL -100..0 range even inside a bull (measured on train: +%R<-80 ~14% of bars, %R>-20 ~26% of bars). That dual occupancy is what makes a HYBRID +(reversion on one leg + momentum on the other) genuinely playable here. + +Reading the train curves first (both A and B, split='train'): they trend UP very hard +(A 100->792, B 100->2400). A pure symmetric reversion ("short every %R>-20") would just +short the bull and bleed; a pure momentum rule rides crashes. The HYBRID + trend gate +resolves this by using %R DIFFERENTLY on each side of a long trend filter: + + REVERSION LEG (in an UPTREND, close above a long SMA): + %R dipping into oversold (< OS, e.g. -80) is a BUY-THE-DIP setup. To ANTICIPATE the + bounce instead of knife-catching a still-falling close, we require %R to TURN BACK UP + (cross up through a short signal line = SMA of %R, the standard stochastic-style + trigger). We then HOLD the long (hysteresis) until %R recovers past EXIT, then flat. + This is the reversion half of the hybrid. + + MOMENTUM LEG (in an UPTREND): once %R pushes into and STAYS overbought (> OB, e.g. -20), + in a hard bull that is NOT a fade signal — overbought persists and the trend runs. So + instead of shorting it (textbook reversion) we take a SMALLER continuation LONG + (MOM_W). This is the momentum half of the hybrid: %R>-20 in an uptrend = "trend is + strong, stay with it", the opposite trade to what reversion alone would do. This is + the key difference from the pure-reversion stochastic/RSI agents. + + DOWNTREND (close below the long SMA): the symmetry returns and %R is read as reversion + again — %R overbought (> OB) with a cross DOWN through its signal line is a reversion + SHORT (rips fade). %R oversold we stand flat (don't knife-catch long under a + downtrend). The short side is down-weighted (SHORT_W) because the drift is up; on + train it is marginal (see HONEST NOTE). + +So the gate does three jobs: (1) picks the reversion side (dip-long in up, rip-short in +down), (2) flips the overbought reading from "fade" to "ride" inside the bull (the hybrid), +(3) suppresses the side that fights the drift. Sizing is smooth (deeper extreme -> bigger +appetite, floored at BASE while holding) then VOL-TARGETED so the two curves are +risk-comparable and exposure shrinks into vol spikes (crashes are vol spikes) — that is +what bounds the drawdown. The leverage cap rarely binds, so the edge is NOT leverage. + +HONEST NOTE (negative findings kept): (1) The downtrend reversion-short is nearly free but +adds little on train; kept small to honor the bidirectional angle. (2) The momentum +continuation leg (MOM_W) is what distinguishes this from a pure-reversion oscillator — in a +market that trends this hard it earns by riding the overbought regime instead of fading it, +but it ALSO partly degenerates toward trend participation (the honest ceiling for any +direction-on-a-bull rule). The genuine oscillator content is the cross-timed dip entry + +overbought exit cycle plus the DD control from the trend gate + vol-target. (3) A pure +always-on %R weighting (no flat state) degenerated into buy-and-hold (DD blew out); the +hysteresis flat state is what keeps DD modest. Result: an honest, modest combined train +Sharpe at a small DD — a fraction of buy&hold PnL but several-x less drawdown (it +anticipates the dip / rides the strong trend rather than holding through every crash). + +CAUSAL: %R uses trailing rolling max(high)/min(low) (<= i); its signal line is a trailing +SMA of %R; the cross compares (%R - sig) at i vs i-1 (past only); the hold-state is a +forward cumulative pass over PAST bars only; the SMA trend filter and vol_target use +trailing data. No shift(-k), no centered windows, no global fit. Verified by causality_ok. + +Tuning (train only, combined A&B; coarse->fine sweep + plateau check). The chosen cell sits +on a broad plateau (OB in [-35..-25], MOM_W in [0.3..0.5], SIG_WIN=5, R_WIN in [20..28], +EXIT in [-50..-40], OS=-80, BASE/TVOL/VWD all hold sharpe_min ~1.1..1.29 at DD ~3.3..5.6% — +a plateau, not a spike; SHORT_W is nearly free / marginal): + R_WIN=20, SIG_WIN=5, OS=-80, OB=-35, EXIT=-45, TREND_WIN=150 + MOM_W=0.4, SHORT_W=0.4, BASE=0.6, TARGET_VOL=0.25, VOL_WIN_DAYS=35, LEV_CAP=1.5 + -> train combined: pnl_mean ~0.46, maxdd_worst ~0.045, sharpe_min ~1.22 +""" +import numpy as np +import pandas as pd +import blindlib as bl + +R_WIN = 20 # %R lookback (rolling high/low window). 20 > textbook 14 for these trends. +SIG_WIN = 5 # signal line = SMA(%R, SIG_WIN): the line %R crosses (stochastic-style trigger). +OS = -80.0 # oversold: %R below this in an uptrend + cross-up = dip-long entry. +OB = -35.0 # overbought: momentum-ride (uptrend) / reversion-short (downtrend) threshold. +EXIT = -45.0 # dip-long HELD until %R recovers past EXIT (hysteresis entry/exit pair). +TREND_WIN = 150 # long SMA: above = uptrend (dips=long, OB=ride), below = downtrend (OB=short). +MOM_W = 0.4 # weight on the uptrend overbought MOMENTUM-continuation long (the hybrid half). +SHORT_W = 0.4 # weight on the downtrend reversion-short; marginal (see HONEST NOTE). +BASE = 0.6 # base long size while holding a dip (scaled up if %R still oversold). +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def _willr(df, r_win, sig_win): + """Causal Williams %R + its signal line. %R[i] = -100*(HH-close)/(HH-LL) over the + trailing r_win bars (<= i); sig[i] = SMA(%R, sig_win) (trailing). No look-ahead.""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + hh = pd.Series(h).rolling(r_win, min_periods=1).max().values + ll = pd.Series(l).rolling(r_win, min_periods=1).min().values + rng = hh - ll + wr = np.where(rng > 1e-12, -100.0 * (hh - c) / rng, -50.0) + sig = bl.sma(wr, sig_win) + return wr, sig + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + wr, sig = _willr(df, R_WIN, SIG_WIN) + trend_up = c > bl.sma(c, TREND_WIN) # causal trailing SMA trend gate + + # --- %R / signal-line crosses (past-only: compares i vs i-1) --- + ds = wr - sig + ds_prev = np.concatenate(([0.0], ds[:-1])) + cross_up = (ds > 0) & (ds_prev <= 0) # %R turns up through its signal line + cross_dn = (ds < 0) & (ds_prev >= 0) # %R turns down through its signal line + + # --- smooth appetites (further past the extreme -> bigger) --- + # oversold depth: %R from OS down to -100 -> long appetite 0..1 + long_app = np.clip((OS - wr) / (100.0 + OS), 0.0, 1.0) + # overbought depth: %R from OB up to 0 -> 0..1 (used by both momentum-long & rev-short) + ob_app = np.clip((wr - OB) / (0.0 - OB), 0.0, 1.0) + + # --- trend-gated Williams %R momentum/reversion hybrid with hysteresis --- + # Forward pass is PURE PAST-ONLY: state at bar i depends only on bars <= i. + held = np.zeros(n) + in_long = False + for i in range(n): + if in_long: + # exit the held dip-long when trend breaks down OR %R has recovered past EXIT + if (not trend_up[i]) or (wr[i] >= EXIT): + in_long = False + else: + # enter a dip-long in an uptrend when %R is oversold AND turns up through its line + if trend_up[i] and (wr[i] < OS) and cross_up[i]: + in_long = True + if in_long: + held[i] = max(BASE, long_app[i]) # ride the recovery, bigger if still oversold + elif trend_up[i]: + # MOMENTUM half of the hybrid: overbought in an uptrend = ride the strong trend + held[i] = MOM_W * ob_app[i] + else: + # downtrend reversion-short: overbought AND %R turning down through its line + if (wr[i] > OB) and cross_dn[i]: + held[i] = -SHORT_W * ob_app[i] + else: + held[i] = 0.0 + + pos = bl.vol_target(held, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_29_ridge.py b/scripts/research/blind/agents/agent_29_ridge.py new file mode 100644 index 0000000..305784b --- /dev/null +++ b/scripts/research/blind/agents/agent_29_ridge.py @@ -0,0 +1,158 @@ +"""Agent 29 — Ridge regression return forecast (family=ml, slug=ridge). + +THE ANGLE (assigned): forecast the forward return with a RIDGE regression on lagged +returns + volatility features, refit on an EXPANDING window every ~20 bars, and turn the +forecast into a position. A genuine ML angle (linear model, L2 penalty), NOT a fixed +momentum sign rule — ridge *weights* the lags and lets vol modulate conviction. + +WHAT THE TRAIN DATA ACTUALLY SAYS (the honest finding, not the hoped-for one): + * NEXT-BAR return on these curves is unforecastable — the walk-forward forecast's next-bar + hit-rate is ~0.48-0.51 (coin flip). So I forecast a multi-bar FORWARD return (horizon + FWD_H), the autocorrelated/forecastable quantity, instead of bar-to-bar noise. + * The expanding ridge forecast is CONSISTENTLY, mildly *negatively* correlated with the + realized forward return (corr ~ -0.08..-0.22, same sign on BOTH series, ALL horizons). + i.e. on these strongly up-trending curves the model's most-bullish forecasts mark froth + that gives back, and its bearish forecasts precede the recoveries. This is a stable + property across the grid, not one lucky cell. + * SHORTING destroys value here (both raw-sign and inverted-sign books lose once shorts are + allowed — the curves only go up). The only honest edge a weak forecaster has on an + up-trend is WHEN TO HOLD vs. SIT IN CASH. + +THE RULE: use the (inverted, given the negative corr) ridge forecast as a LONG-ONLY +conviction — be long when the model is bearish (post-froth recovery), flat when it is +bullish — then vol-target and clip to [0, 1]. Result on train: a book that is in-market only +~16% of the time, tiny drawdown (~0.02 vs 0.77-0.79 buy&hold), Sharpe ~0.83. + +CAUSALITY (the whole game): + * Features at row i use ONLY returns up to and including bar i (rows <= i). + * Training TARGET for row j is the return over bar j -> j+FWD_H (needs close[j+FWD_H]). + Sitting at decision-row i we may only train on rows j with j+FWD_H <= i (their targets + are realized as of close[i]). We NEVER include row i's own unrealized target. + * Refit on an EXPANDING window of those realized (X,y) pairs every REFIT_EVERY bars; + coefficients frozen in between. No global fit, no future row touched. + -> Verified by causality_ok (prefix tail matches full-array tail, max_diff 0.0). + +TUNING (split='train' only, combined A & B): chosen cell is interior on every axis — + FWD_H 18-25 -> Sharpe ~0.83 flat; alpha 20-100 -> Sharpe ~0.81-0.84 flat; + refit 10-20 -> stable; gain 1.0-2.5 monotone DD/PnL dial. Picked the interior point. + +HONEST READ: alpha here is THIN. The forecastability is weak and the win is risk control, +not return generation — a low-exposure, low-DD long-only sleeve, NOT a PnL engine. The +inverted-sign edge is modest and could be regime-specific; the robust, defensible part is +"never short an up-trend; let the forecast tell you when to step out of the way." +""" +import numpy as np +import blindlib as bl + +# ---- tuned on split='train' only (interior of a flat plateau) ---- +RIDGE_ALPHA = 50.0 # L2 penalty (strong: the lag->return edge is tiny); plateau 20..100 +WARMUP = 150 # realized (X,y) pairs required before the first fit +REFIT_EVERY = 20 # expanding-window refit cadence (assigned ~20); stable 10..20 +LAGS = (1, 2, 3, 5, 10) # lagged-return features +MOM_WIN = 20 # trailing momentum feature window +VOL_WIN = 20 # trailing realized-vol feature window +FWD_H = 20 # forecast HORIZON (bars). Plateau 18..25. Next-BAR is noise; a + # multi-bar target is the autocorrelated, forecastable quantity. +GAIN = 1.5 # tanh conviction gain on the standardized forecast (DD/PnL dial) +INVERT = True # negative train corr (both series, all H) -> fade the forecast sign +LONG_ONLY = True # shorting an up-trend destroys value -> conviction is long-or-flat +TARGET_VOL = 0.20 # vol-target the directional book +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i. + Columns: lagged log-returns, trailing momentum, trailing realized vol.""" + n = len(c) + lr = np.zeros(n) + lr[1:] = np.log(c[1:] / c[:-1]) # lr[i] = return of bar ending at i (causal) + + cols = [] + # lagged returns: feature value at i is the return from k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] # lr shifted back by k -> uses past only + cols.append(f) + # trailing momentum: cumulative log-return over the last MOM_WIN bars (<= i) + mom = np.zeros(n) + csum = np.cumsum(lr) + mom[MOM_WIN:] = csum[MOM_WIN:] - csum[:-MOM_WIN] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + for i in range(VOL_WIN, n): + vol[i] = np.std(lr[i - VOL_WIN + 1 : i + 1]) + cols.append(vol) + + X = np.column_stack(cols) + return X, lr + + +def _ridge_fit(X, y, alpha): + """Closed-form ridge with a standardized design + intercept (no sklearn needed, + fully deterministic). Returns (mu, sd, beta0, beta) for prediction.""" + mu = X.mean(axis=0) + sd = X.std(axis=0) + sd[sd < 1e-12] = 1.0 + Xs = (X - mu) / sd + p = Xs.shape[1] + A = Xs.T @ Xs + alpha * np.eye(p) + b = Xs.T @ (y - y.mean()) + beta = np.linalg.solve(A, b) + beta0 = y.mean() + return mu, sd, beta0, beta + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr = _build_features(c) + + # target[j] = cumulative log-return over bar j -> j+FWD_H (needs close[j+FWD_H]); + # known (realized) only as of close[j+FWD_H]. + csum = np.cumsum(lr) + target = np.zeros(n) + target[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + + yhat = np.zeros(n) # forecast of the forward return, decided at close[i] + sig_y = np.ones(n) # scale of recent forecast targets (for standardization) + coef = None # frozen (mu, sd, beta0, beta) + + for i in range(n): + # at decision-row i we may train only on rows j whose target is realized, i.e. + # j + FWD_H <= i => j <= i - FWD_H. We NEVER include row i's own (unrealized) target. + first = max(LAGS) + MOM_WIN # earliest row with all features fully populated + last_train = i - FWD_H # target of last_train uses close[i], realized now + ntrain = last_train - first + 1 + + if ntrain >= WARMUP: + # refit every REFIT_EVERY bars (and on the very first eligible bar) + if coef is None or (i % REFIT_EVERY == 0): + Xtr = X[first : last_train + 1] + ytr = target[first : last_train + 1] + coef = _ridge_fit(Xtr, ytr, RIDGE_ALPHA) + s = np.std(ytr) + sig_y[i] = s if s > 1e-9 else 1.0 + else: + sig_y[i] = sig_y[i - 1] + mu, sd, beta0, beta = coef + xi = (X[i] - mu) / sd + yhat[i] = beta0 + xi @ beta + + # forecast -> bounded conviction (de-emphasize tiny/noisy forecasts, saturate strong ones) + s = np.where(sig_y > 1e-9, sig_y, 1.0) + direction = np.tanh(GAIN * yhat / s) + direction = np.nan_to_num(direction, nan=0.0) + if INVERT: + direction = -direction # train corr is negative on both series/all H + if LONG_ONLY: + direction = np.clip(direction, 0.0, 1.0) # never short an up-trend (shorts lose here) + + # vol-target the conviction so the DRAWDOWN is what we control + 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) diff --git a/scripts/research/blind/agents/agent_30_logistic.py b/scripts/research/blind/agents/agent_30_logistic.py new file mode 100644 index 0000000..6fcb8bc --- /dev/null +++ b/scripts/research/blind/agents/agent_30_logistic.py @@ -0,0 +1,189 @@ +"""Agent 30 — Logistic up/down classifier (family=ml, slug=logistic). + +THE ANGLE (assigned): a LOGISTIC REGRESSION that classifies "will the forward move be +up or down?" from technical features (momentum at several horizons, trailing realized +vol, RSI), refit on an EXPANDING walk-forward window every ~20 bars, and maps the class +probability p(up) into a position in [-1, +1]. + +WHY A CLASSIFIER (not a return-regressor): the per-bar *magnitude* of these curves is +dominated by noise — the sign of the forward move is the only thing with any persistence. +A logistic model targets exactly that (a Bernoulli up/down label), and its probability +output is a natural, bounded conviction: p≈0.5 → flat, p far from 0.5 → take the side. +The L2 penalty (C small) keeps the coefficients from chasing the (thin) edge into noise. + +CAUSALITY (the whole game): + * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log- + returns, multi-horizon trailing momentum, trailing realized vol, RSI. + * The LABEL for row j is sign of the cumulative return over bar j -> j+FWD_H, which + needs close[j+FWD_H]. So sitting at decision-row i we may train ONLY on rows whose + label is already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own label is + NEVER used. + * Model is refit on the EXPANDING window of those realized (X, y) pairs at most every + REFIT_EVERY bars; coefficients frozen in between. position[i] = frozen model's + p(up) at row i, mapped to a direction, then vol-targeted. + -> Verified by causality_ok (signal on a prefix must match signal on the full array). + +TUNING (split='train' only, combined A & B): C (inverse L2) small (~0.05-0.2) so the +weak edge isn't overfit; FWD_H ~ 5-10 (the forecastable horizon — next-bar sign is a +coin flip); WARMUP ~ 200 realized pairs; conviction = 2*(p-0.5) sharpened by a gain, +then vol-targeted (cap 1.0) so the DRAWDOWN, not the raw PnL, is what we optimise. + +HONEST READ: forward-sign forecastability here is weak; the realistic win is a vol- +controlled book that can flip short into declines, giving comparable PnL to long-only +at a much smaller drawdown — the de-risking is the alpha, not a strong classifier. +""" +import warnings +import numpy as np +import blindlib as bl + +warnings.filterwarnings("ignore") + +try: + from sklearn.linear_model import LogisticRegression + _HAVE_SK = True +except Exception: # pragma: no cover - sklearn expected present + _HAVE_SK = False + +# ---- tuned on split='train' only (interior of broad plateaus; see scan below) ---- +C_INV = 0.20 # inverse L2 strength (small = strong penalty); flat 0.05-1.0 +WARMUP = 200 # realized (X, y) pairs required before the first fit +REFIT_EVERY = 20 # expanding-window refit cadence (assigned ~20) +LAGS = (1, 2, 3, 5) # lagged log-return features +MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features +VOL_WIN = 20 # trailing realized-vol feature window +RSI_WIN = 14 # RSI feature window +FWD_H = 15 # label HORIZON: sign of cumulative return over next FWD_H bars. + # next-bar sign is a coin-flip; the multi-bar sign is the + # persistent, classifiable quantity. Plateau FWD 14-18. +DEADBAND = 0.04 # ignore |2p-1| below this (treat as no-conviction -> flat) +GAIN = 3.0 # conviction gain on the centered probability 2*(p-0.5) +SHORT_SCALE = 0.25 # asymmetric book: full long, only PARTIAL short. Both curves + # drift UP, so the classifier's real value is STEPPING ASIDE + # from declines; a full short fights the drift and adds DD. + # 0.25 keeps a genuine (small) short so it stays prob->position. +TARGET_VOL = 0.20 # vol-target the directional book +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i.""" + 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) + + cols = [] + # lagged returns: value at i is the return k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] + cols.append(f) + # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) + for w in MOM_WINS: + mom = np.zeros(n) + mom[w:] = csum[w:] - csum[:-w] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + cs2 = np.cumsum(lr * lr) + for i in range(VOL_WIN, n): + m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN + v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m + vol[i] = np.sqrt(max(v, 0.0)) + cols.append(vol) + # RSI (causal, from blindlib) + rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 + cols.append(rsi) + + X = np.column_stack(cols) + return X, lr, csum + + +def _fit(Xtr, ytr): + """Logistic fit on standardized features. Returns (mu, sd, model) or None if the + training labels are single-class (no fit possible yet).""" + mu = Xtr.mean(axis=0) + sd = Xtr.std(axis=0) + sd[sd < 1e-12] = 1.0 + Xs = (Xtr - mu) / sd + if len(np.unique(ytr)) < 2: + return None + if _HAVE_SK: + m = LogisticRegression(C=C_INV, solver="lbfgs", max_iter=200) + m.fit(Xs, ytr) + return (mu, sd, m) + # tiny fallback: penalized logistic via Newton steps (deterministic) + w = _logit_newton(Xs, ytr, C_INV) + return (mu, sd, w) + + +def _logit_newton(Xs, y, c_inv, iters=25): + n, p = Xs.shape + Xb = np.column_stack([np.ones(n), Xs]) + w = np.zeros(p + 1) + lam = 1.0 / max(c_inv, 1e-6) + R = np.eye(p + 1); R[0, 0] = 0.0 # don't penalize intercept + for _ in range(iters): + z = Xb @ w + pr = 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30))) + Wd = pr * (1 - pr) + 1e-6 + grad = Xb.T @ (pr - y) + lam * (R @ w) + H = Xb.T @ (Xb * Wd[:, None]) + lam * R + try: + w -= np.linalg.solve(H, grad) + except np.linalg.LinAlgError: + break + return w + + +def _predict_proba(coef, xi): + mu, sd, m = coef + xs = (xi - mu) / sd + if _HAVE_SK and not isinstance(m, np.ndarray): + return float(m.predict_proba(xs.reshape(1, -1))[0, 1]) + z = m[0] + xs @ m[1:] + return float(1.0 / (1.0 + np.exp(-np.clip(z, -30, 30)))) + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr, csum = _build_features(c) + + # label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0. + # realized (known) only as of close[j+FWD_H]. + fwd = np.zeros(n) + fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + label = (fwd > 0).astype(float) + + first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN) # first fully-featured row + prob = np.full(n, 0.5) + coef = None + + for i in range(n): + last_train = i - FWD_H # label of last_train uses close[i], realized now + ntrain = last_train - first + 1 + if ntrain >= WARMUP: + if coef is None or (i % REFIT_EVERY == 0): + Xtr = X[first : last_train + 1] + ytr = label[first : last_train + 1] + fit = _fit(Xtr, ytr) + if fit is not None: + coef = fit + if coef is not None: + prob[i] = _predict_proba(coef, X[i]) + + # probability -> bounded direction. centered conviction 2*(p-0.5) in [-1,1]; + # deadband kills no-conviction bars; tanh sharpens; the short side is scaled down + # (the up-drift makes full shorts a losing fight — we mainly want to step aside). + conv = 2.0 * prob - 1.0 + conv = np.where(np.abs(conv) < DEADBAND, 0.0, conv) + direction = np.tanh(GAIN * conv) + direction = np.where(direction < 0.0, direction * SHORT_SCALE, direction) + direction = np.nan_to_num(direction, nan=0.0) + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_31_mlp_reg.py b/scripts/research/blind/agents/agent_31_mlp_reg.py new file mode 100644 index 0000000..aac5bdb --- /dev/null +++ b/scripts/research/blind/agents/agent_31_mlp_reg.py @@ -0,0 +1,176 @@ +"""Agent 31 — Small MLPRegressor forward-return forecast (family=ml, slug=mlp_reg). + +THE ANGLE (assigned): a SMALL MLPRegressor (sklearn, one hidden layer) forecasting the +forward return from a causal feature vector, refit on an EXPANDING walk-forward window, +turned into a vol-targeted position. A genuine nonlinear ML angle (a tiny neural net) — it +can in principle pick up interactions the linear ridge/logistic models cannot — kept FAST +(small net, few iterations, infrequent refit) to stay under the time budget. + +WHAT THE TRAIN DATA ACTUALLY SAYS (the honest finding, mirroring ridge/logistic agents): + * NEXT-BAR return on these curves is unforecastable (hit-rate ~coin flip). I forecast a + multi-bar FORWARD return (horizon FWD_H), the autocorrelated/forecastable quantity. + * The MLP forecast carries a weak, regime-dependent signal. On these strongly up-trending + curves the robust, defensible win is RISK CONTROL — being long when the model is not + bearish, stepping to cash (and only cautiously short) when it is — NOT a PnL engine. + * The conviction is vol-targeted so the DRAWDOWN, not the raw forecast, is what we control. + +CAUSALITY (the whole game): + * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log- + returns, multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA. + * The TARGET for row j is the cumulative log-return over bar j -> j+FWD_H, which needs + close[j+FWD_H]. Sitting at decision-row i we may train ONLY on rows whose target is + already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own target is NEVER used. + * The MLP is refit on the EXPANDING window of those realized (X, y) pairs at most every + REFIT_EVERY bars; weights frozen in between. To keep refits deterministic AND fast we + use a fixed random_state, a single small hidden layer, and a capped iteration budget. + -> Verified by causality_ok (signal on a prefix must match signal on the full array). + +TUNING (split='train' only, combined A & B): small net (one layer 8 units) + strong L2 +(alpha=3) so the thin edge is not overfit; FWD_H=15 (next-bar is noise); WARMUP=200 realized +pairs; conviction = tanh(0.6 * zscored forecast) as a SMALL lean around a constant long base +(0.3), clipped, then vol-targeted at 0.18 (cap 1.0). I measured the walk-forward forecast's +correlation with the realized forward return directly: ~+0.01 on A, ~-0.05 on B, sign-hit +~0.48 — i.e. NEAR ZERO and inconsistent in sign across the two series and across horizons +10..40. So the forecast is treated as a weak modulation, not a directional engine. + +HONEST READ: forward-return forecastability here is essentially absent and an MLP does NOT +create it (corr ~0, sign-hit < 0.5). The defensible win is RISK CONTROL: a vol-targeted, +long-biased book whose drawdown is ~4x smaller than buy&hold (train DD ~0.20 vs ~0.77-0.79). +The MLP's contribution is marginal-but-positive on train — adding it to a flat long base lifts +Sharpe_min 0.844->0.899 and PnL 0.40->0.55 — but this is a small lean, not alpha. The bulk of +the result is the long bias + vol-targeting; the MLP forecast is a thin garnish. That thinness, +and the inconsistent forecast sign across series, are the honest caveats for this angle. +""" +import warnings +import numpy as np +import blindlib as bl + +warnings.filterwarnings("ignore") + +try: + from sklearn.neural_network import MLPRegressor + _HAVE_SK = True +except Exception: # pragma: no cover - sklearn expected present + _HAVE_SK = False + +# ---- tuned on split='train' only ---- +HIDDEN = (8,) # ONE small hidden layer (keep it tiny: edge is thin, refit fast) +MLP_ALPHA = 3.0 # L2 penalty (STRONG: the lag->return edge is tiny -> resist overfit) +MAX_ITER = 120 # capped optimizer iterations (speed; net is small so it converges) +WARMUP = 200 # realized (X, y) pairs required before the first fit +REFIT_EVERY = 40 # expanding-window refit cadence (infrequent -> MLP cost stays low) +LAGS = (1, 2, 3, 5, 10) # lagged log-return features +MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features +VOL_WIN = 20 # trailing realized-vol feature window +RSI_WIN = 14 # RSI feature window +MA_WIN = 50 # distance-from-MA feature window +FWD_H = 15 # forecast HORIZON (bars). Next-bar is noise; multi-bar is forecastable. +GAIN = 0.6 # tanh conviction gain on the standardized forecast (DD/PnL dial). LOW: + # the forecast is near-noise (train corr ~0), so it only LIGHTLY trims. +LONG_BASE = 0.30 # constant long bias the forecast modulates AROUND. The curves trend up + # and the forecast carries no reliable sign, so the defensible book is + # "mostly long, let the weak forecast lean it" — not "gate to cash on noise". +INVERT = False # sign of the train forecast<->forward-return correlation (set by tuning) +LONG_FLOOR = -0.30 # allow only shallow shorts (curves only trend up -> shorts mostly lose) +TARGET_VOL = 0.18 # vol-target the directional book +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i.""" + 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) + cs2 = np.cumsum(lr * lr) + + cols = [] + # lagged returns: value at i is the return k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] + cols.append(f) + # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) + for w in MOM_WINS: + mom = np.zeros(n) + mom[w:] = csum[w:] - csum[:-w] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + for i in range(VOL_WIN, n): + m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN + v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m + vol[i] = np.sqrt(max(v, 0.0)) + cols.append(vol) + # RSI (causal, from blindlib), centered to ~[-0.5, 0.5] + rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5 + cols.append(rsi) + # distance from a trailing MA (causal): log(close / sma) + ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0]) + ma[ma <= 0] = 1e-9 + dist = np.log(np.maximum(c, 1e-9) / ma) + dist[:MA_WIN] = 0.0 + cols.append(dist) + + X = np.column_stack(cols) + return X, lr, csum + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr, csum = _build_features(c) + + # target[j] = cumulative log-return over bar j -> j+FWD_H (needs close[j+FWD_H]); + # realized (known) only as of close[j+FWD_H]. + target = np.zeros(n) + target[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + + first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row + yhat = np.zeros(n) # forecast of the forward return, decided at close[i] + sig_y = np.ones(n) # scale of recent training targets (for standardization) + coef = None # frozen (mu, sd, model) + + for i in range(n): + last_train = i - FWD_H # target of last_train uses close[i], realized now + ntrain = last_train - first + 1 + if ntrain < WARMUP: + continue + if coef is None or (i % REFIT_EVERY == 0): + Xtr = X[first : last_train + 1] + ytr = target[first : last_train + 1] + mu = Xtr.mean(axis=0) + sd = Xtr.std(axis=0) + sd[sd < 1e-12] = 1.0 + Xs = (Xtr - mu) / sd + sy = ytr.std() + sy = sy if sy > 1e-9 else 1.0 + ys = ytr / sy # standardize target so the net trains stably + if _HAVE_SK: + m = MLPRegressor(hidden_layer_sizes=HIDDEN, activation="tanh", + alpha=MLP_ALPHA, solver="lbfgs", max_iter=MAX_ITER, + random_state=0) + m.fit(Xs, ys) + coef = (mu, sd, m, sy) + sig_y[i] = ytr.std() if ytr.std() > 1e-9 else 1.0 + else: + sig_y[i] = sig_y[i - 1] + if coef is not None: + mu, sd, m, sy = coef + xi = ((X[i] - mu) / sd).reshape(1, -1) + yhat[i] = float(m.predict(xi)[0]) * sy + + # forecast -> bounded conviction (de-emphasize tiny/noisy forecasts, saturate strong ones) + s = np.where(sig_y > 1e-9, sig_y, 1.0) + fc = np.tanh(GAIN * yhat / s) # weak MLP conviction (~noise) -> only a small lean + fc = np.nan_to_num(fc, nan=0.0) + if INVERT: + fc = -fc + # mostly-long book the forecast modulates around (NOT a gate-to-cash on a noisy forecast) + direction = np.clip(LONG_BASE + fc, LONG_FLOOR, 1.0) + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_32_mlp_clf.py b/scripts/research/blind/agents/agent_32_mlp_clf.py new file mode 100644 index 0000000..3d49574 --- /dev/null +++ b/scripts/research/blind/agents/agent_32_mlp_clf.py @@ -0,0 +1,193 @@ +"""Agent 32 — MLPClassifier up/down direction model (family=ml, slug=mlp_clf). + +THE ANGLE (assigned): a SMALL MLPClassifier (sklearn, one hidden layer) that classifies +"will the forward move be up or down?" from a causal technical feature vector, refit on an +EXPANDING walk-forward window every ~25 bars, and maps the class probability p(up) into a +position in [-1, +1]. This is the NONLINEAR cousin of agent_30 (logistic): a tiny neural net +can in principle pick up feature interactions a linear logit cannot, while staying a +classifier (sign is the only persistent quantity here, magnitude is noise). + +WHY A CLASSIFIER (not a return-regressor): the per-bar *magnitude* of these curves is +dominated by noise; only the SIGN of a multi-bar forward move has any persistence. The MLP +targets exactly that Bernoulli up/down label and emits a bounded probability — a natural +conviction: p~0.5 -> flat, p far from 0.5 -> take the side. Strong L2 (alpha) + a tiny net +keep it from chasing the thin edge into noise. + +CAUSALITY (the whole game): + * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log- + returns, multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA. + * The LABEL for row j is the sign of the cumulative return over bar j -> j+FWD_H, which + needs close[j+FWD_H]. Sitting at decision-row i we may train ONLY on rows whose label is + already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own label is NEVER used. + * The MLP is refit on the EXPANDING window of those realized (X, y) pairs at most every + REFIT_EVERY (~25) bars; weights frozen in between. position[i] = frozen model's p(up) at + row i, mapped to a direction, then vol-targeted. Deterministic (fixed random_state, + lbfgs, capped iters) so signal(prefix) == signal(full)[:cut]. + -> Verified by causality_ok (signal on a prefix must match signal on the full array). + +TUNING (split='train' only, combined A & B): tiny net (one layer) + strong alpha so the weak +edge isn't overfit; FWD_H in the forecastable band (next-bar sign is a coin-flip); WARMUP big +enough that the first fit sees a real sample; conviction = tanh(GAIN * (2p-1)) with a deadband +and an asymmetric short scale (both curves drift UP, so the classifier's real value is +STEPPING ASIDE from declines, not fighting the drift with full shorts); then vol-targeted +(cap 1.0) so the DRAWDOWN, not the raw forecast, is what we control. + +HONEST READ: forward-sign forecastability here is weak and an MLP does not manufacture it. +The realistic, defensible win is a vol-controlled, low-drawdown book that de-risks/flips into +declines — comparable PnL to long-only at a FRACTION of the ~77% buy&hold drawdown. The +de-risking is the alpha, not a strong classifier. A thin/negative result is the honest result. +""" +import warnings +import numpy as np +import blindlib as bl + +warnings.filterwarnings("ignore") + +try: + from sklearn.neural_network import MLPClassifier + _HAVE_SK = True +except Exception: # pragma: no cover - sklearn expected present + _HAVE_SK = False + +# ---- tuned on split='train' only (interior of broad plateaus; see scans below) ---- +# Train scans (combined A&B, ranked on the orchestrator's worst-case sharpe_min): +# FWD x HIDDEN x alpha -> winner FWD=10, HIDDEN=(6,), alpha=2.0 (shmin 0.68, ddw 0.21). +# refit cadence: RE=25 beats RE=20; FWD=10/12 plateau, FWD=8 fragile (B turns negative). +# short-scale ablation: shmin is MONOTONE-DECREASING in the short size — the classifier's +# real edge is STEPPING ASIDE (long/flat), not shorting the up-drift. SS=0.0 wins (shmin +# 0.81) but is a degenerate prob->position map; SS=0.10 keeps a genuine, small short so the +# mapping truly spans [-1,1] at little cost (shmin 0.76, ddw 0.20, pnl_mean 0.56). +HIDDEN = (6,) # ONE tiny hidden layer (edge is thin -> keep it small + fast) +MLP_ALPHA = 2.0 # L2 penalty (STRONG: the lag->sign edge is tiny -> resist overfit) +MAX_ITER = 200 # capped optimizer iterations (lbfgs on a tiny net converges fast) +WARMUP = 220 # realized (X, y) pairs required before the first fit +REFIT_EVERY = 25 # expanding-window refit cadence (assigned ~25; beats 20 on train) +LAGS = (1, 2, 3, 5) # lagged log-return features +MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features +VOL_WIN = 20 # trailing realized-vol feature window +RSI_WIN = 14 # RSI feature window +MA_WIN = 50 # distance-from-MA feature window +FWD_H = 10 # label HORIZON: sign of cumulative return over next FWD_H bars. + # next-bar sign is a coin-flip; the multi-bar sign is the persistent, + # classifiable quantity. Plateau FWD ~10-12 (FWD=8 fragile on B). +DEADBAND = 0.06 # ignore |2p-1| below this (no-conviction -> flat, saves fee churn) +GAIN = 2.0 # conviction gain on the centered probability 2*(p-0.5) +SHORT_SCALE = 0.10 # asymmetric book: full long, only a SMALL short. Curves drift UP, so + # the classifier's value is STEPPING ASIDE from declines; shorting the + # drift strictly worsens shmin/DD (ablation). 0.10 keeps a genuine + # (small) short so the mapping stays a real prob->[-1,1] position. +TARGET_VOL = 0.20 # vol-target the directional book +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i.""" + 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) + cs2 = np.cumsum(lr * lr) + + cols = [] + # lagged returns: value at i is the return k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] + cols.append(f) + # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) + for w in MOM_WINS: + mom = np.zeros(n) + mom[w:] = csum[w:] - csum[:-w] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + for i in range(VOL_WIN, n): + m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN + v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m + vol[i] = np.sqrt(max(v, 0.0)) + cols.append(vol) + # RSI (causal, from blindlib), centered to ~[-0.5, 0.5] + rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5 + cols.append(rsi) + # distance from a trailing MA (causal): log(close / sma) + ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0]) + ma[ma <= 0] = 1e-9 + dist = np.log(np.maximum(c, 1e-9) / ma) + dist[:MA_WIN] = 0.0 + cols.append(dist) + + X = np.column_stack(cols) + return X, lr, csum + + +def _fit(Xtr, ytr): + """MLPClassifier fit on standardized features. Returns (mu, sd, model) or None if the + training labels are single-class (no fit possible yet).""" + if len(np.unique(ytr)) < 2: + return None + mu = Xtr.mean(axis=0) + sd = Xtr.std(axis=0) + sd[sd < 1e-12] = 1.0 + Xs = (Xtr - mu) / sd + if _HAVE_SK: + m = MLPClassifier(hidden_layer_sizes=HIDDEN, activation="tanh", + alpha=MLP_ALPHA, solver="lbfgs", max_iter=MAX_ITER, + random_state=0) + m.fit(Xs, ytr) + return (mu, sd, m) + return None + + +def _predict_proba(coef, xi): + mu, sd, m = coef + xs = ((xi - mu) / sd).reshape(1, -1) + # class order from sklearn; index of the "up" (label 1.0) class + classes = list(m.classes_) + if 1.0 not in classes: + return 0.5 + j = classes.index(1.0) + return float(m.predict_proba(xs)[0, j]) + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr, csum = _build_features(c) + + # label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0. + # realized (known) only as of close[j+FWD_H]. + fwd = np.zeros(n) + fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + label = (fwd > 0).astype(float) + + first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row + prob = np.full(n, 0.5) + coef = None + + for i in range(n): + last_train = i - FWD_H # label of last_train uses close[i], realized now + ntrain = last_train - first + 1 + if ntrain >= WARMUP: + if coef is None or (i % REFIT_EVERY == 0): + Xtr = X[first : last_train + 1] + ytr = label[first : last_train + 1] + fit = _fit(Xtr, ytr) + if fit is not None: + coef = fit + if coef is not None: + prob[i] = _predict_proba(coef, X[i]) + + # probability -> bounded direction. centered conviction 2*(p-0.5) in [-1,1]; + # deadband kills no-conviction bars; tanh sharpens; the short side is scaled down + # (the up-drift makes full shorts a losing fight — we mainly want to step aside). + conv = 2.0 * prob - 1.0 + conv = np.where(np.abs(conv) < DEADBAND, 0.0, conv) + direction = np.tanh(GAIN * conv) + direction = np.where(direction < 0.0, direction * SHORT_SCALE, direction) + direction = np.nan_to_num(direction, nan=0.0) + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_33_gbm.py b/scripts/research/blind/agents/agent_33_gbm.py new file mode 100644 index 0000000..78179ad --- /dev/null +++ b/scripts/research/blind/agents/agent_33_gbm.py @@ -0,0 +1,186 @@ +"""Agent 33 — GradientBoostingClassifier up/down direction model (family=ml, slug=gbm). + +THE ANGLE (assigned): a GradientBoostingClassifier (sklearn) that classifies "will the +forward move be up or down?" from a causal technical feature vector, refit on an EXPANDING +walk-forward window on PAST rows only (periodic refit), and maps the class probability +p(up) into a probability-weighted position in [-1, +1]. This is the gradient-boosted-tree +cousin of agent_30 (logistic) / agent_32 (MLP): shallow additive trees can pick up +threshold/interaction effects (e.g. "high momentum AND low vol") a linear logit cannot, +while staying a classifier (sign is the only persistent quantity here, magnitude is noise). + +WHY A CLASSIFIER (not a return-regressor): the per-bar *magnitude* of these curves is +dominated by noise; only the SIGN of a multi-bar forward move has any persistence. The GBM +targets exactly that Bernoulli up/down label and emits a calibrated-ish probability — a +natural conviction: p~0.5 -> flat, p far from 0.5 -> take the side. Shallow stumps +(max_depth small), few estimators, a low learning_rate and subsampling keep the additive +model from carving the thin edge into noise. + +CAUSALITY (the whole game): + * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log- + returns, multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA. + * The LABEL for row j is the sign of the cumulative return over bar j -> j+FWD_H, which + needs close[j+FWD_H]. Sitting at decision-row i we may train ONLY on rows whose label is + already realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own label is NEVER used. + * The GBM is refit on the EXPANDING window of those realized (X, y) pairs at most every + REFIT_EVERY bars; the fitted model is frozen in between. position[i] = frozen model's + p(up) at row i, mapped to a direction, then vol-targeted. Deterministic (fixed + random_state, no shuffle) so signal(prefix) == signal(full)[:cut]. + -> Verified by causality_ok (signal on a prefix must match signal on the full array). + +TUNING (split='train' only, combined A & B): shallow trees (max_depth 2) + few estimators ++ low learning_rate + subsample<1 so the weak edge isn't overfit; FWD_H in the forecastable +band (next-bar sign is a coin-flip; multi-bar sign is the persistent quantity); WARMUP big +enough that the first fit sees a real sample; conviction = tanh(GAIN*(2p-1)) with a deadband +and an asymmetric short scale (both curves drift UP, so the classifier's real value is +STEPPING ASIDE from declines, not fighting the drift with full shorts); then vol-targeted +(cap 1.0) so the DRAWDOWN, not the raw forecast, is what we control. Refit cadence is COARSE +(~40 bars) because a GBM is ~100x slower to fit than a logit and the edge is slow-moving. + +HONEST READ: forward-sign forecastability here is weak and a GBM does not manufacture it. +The realistic, defensible win is a vol-controlled, low-drawdown book that de-risks/flips into +declines — comparable PnL to long-only at a FRACTION of the ~77% buy&hold drawdown. The +de-risking is the alpha, not a strong classifier. A thin/negative result is the honest result. +""" +import warnings +import numpy as np +import blindlib as bl + +warnings.filterwarnings("ignore") + +try: + from sklearn.ensemble import GradientBoostingClassifier + _HAVE_SK = True +except Exception: # pragma: no cover - sklearn expected present + _HAVE_SK = False + +# ---- tuned on split='train' only (interior of broad plateaus; see scans) ---- +N_EST = 120 # number of boosting stages (modest; heavy shrinkage on a thin edge) +MAX_DEPTH = 2 # shallow trees (stumps/pairs) -> capture interactions, resist overfit +LEARN_RATE = 0.03 # low learning rate (heavy shrinkage on a weak signal) +SUBSAMPLE = 0.7 # stochastic GB: subsample rows per stage -> regularize + decorrelate +MIN_LEAF = 30 # large min leaf -> no carving the noise into tiny leaves +WARMUP = 260 # realized (X, y) pairs required before the first fit +REFIT_EVERY = 40 # expanding-window refit cadence (COARSE: GBM is slow + edge is slow) +LAGS = (1, 2, 3, 5) # lagged log-return features +MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features +VOL_WIN = 20 # trailing realized-vol feature window +RSI_WIN = 14 # RSI feature window +MA_WIN = 50 # distance-from-MA feature window +FWD_H = 15 # label HORIZON: sign of cumulative return over next FWD_H bars. + # next-bar sign is a coin-flip; the multi-bar sign is the persistent, + # classifiable quantity. Plateau FWD ~12-20 (best at 15). +DEADBAND = 0.04 # ignore |2p-1| below this (no-conviction -> flat, saves fee churn) +GAIN = 3.0 # conviction gain on the centered probability 2*(p-0.5) +SHORT_SCALE = 0.0 # LONG-FLAT book. Both curves drift UP, so the classifier's real + # value is STEPPING ASIDE from declines, not shorting them — the + # train scan is unambiguous that a short side (even partial) only + # ADDS drawdown (it fights the up-drift) without improving PnL or + # Sharpe. p(up)<0.5 -> FLAT, not short. The de-risking is the alpha. +TARGET_VOL = 0.18 # vol-target the directional book (pure PnL/DD knob; Sharpe ~flat in it) +VOL_WIN_DAYS = 45 # vol-estimation window (45 > 30 cut the worst DD on the train scan) +LEV_CAP = 1.0 # never lever past fully invested -> preserve the DD cut + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i.""" + 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) + cs2 = np.cumsum(lr * lr) + + cols = [] + # lagged returns: value at i is the return k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] + cols.append(f) + # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) + for w in MOM_WINS: + mom = np.zeros(n) + mom[w:] = csum[w:] - csum[:-w] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + for i in range(VOL_WIN, n): + m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN + v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m + vol[i] = np.sqrt(max(v, 0.0)) + cols.append(vol) + # RSI (causal, from blindlib), centered to ~[-0.5, 0.5] + rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5 + cols.append(rsi) + # distance from a trailing MA (causal): log(close / sma) + ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0]) + ma[ma <= 0] = 1e-9 + dist = np.log(np.maximum(c, 1e-9) / ma) + dist[:MA_WIN] = 0.0 + cols.append(dist) + + X = np.column_stack(cols) + return X, lr, csum + + +def _fit(Xtr, ytr): + """GradientBoostingClassifier fit on raw features (trees are scale-invariant). + Returns the fitted model, or None if labels are single-class (no fit possible yet).""" + if len(np.unique(ytr)) < 2: + return None + if _HAVE_SK: + m = GradientBoostingClassifier( + n_estimators=N_EST, max_depth=MAX_DEPTH, learning_rate=LEARN_RATE, + subsample=SUBSAMPLE, min_samples_leaf=MIN_LEAF, random_state=0) + m.fit(Xtr, ytr) + return m + return None + + +def _predict_proba(m, xi): + classes = list(m.classes_) + if 1.0 not in classes: + return 0.5 + j = classes.index(1.0) + return float(m.predict_proba(xi.reshape(1, -1))[0, j]) + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr, csum = _build_features(c) + + # label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0. + # realized (known) only as of close[j+FWD_H]. + fwd = np.zeros(n) + fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + label = (fwd > 0).astype(float) + + first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row + prob = np.full(n, 0.5) + model = None + + for i in range(n): + last_train = i - FWD_H # label of last_train uses close[i], realized now + ntrain = last_train - first + 1 + if ntrain >= WARMUP: + if model is None or (i % REFIT_EVERY == 0): + Xtr = X[first : last_train + 1] + ytr = label[first : last_train + 1] + fit = _fit(Xtr, ytr) + if fit is not None: + model = fit + if model is not None: + prob[i] = _predict_proba(model, X[i]) + + # probability -> bounded direction. centered conviction 2*(p-0.5) in [-1,1]; + # deadband kills no-conviction bars; tanh sharpens; the short side is scaled down + # (the up-drift makes full shorts a losing fight — we mainly want to step aside). + conv = 2.0 * prob - 1.0 + conv = np.where(np.abs(conv) < DEADBAND, 0.0, conv) + direction = np.tanh(GAIN * conv) + direction = np.where(direction < 0.0, direction * SHORT_SCALE, direction) + direction = np.nan_to_num(direction, nan=0.0) + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_34_knn_analog.py b/scripts/research/blind/agents/agent_34_knn_analog.py new file mode 100644 index 0000000..5f70e81 --- /dev/null +++ b/scripts/research/blind/agents/agent_34_knn_analog.py @@ -0,0 +1,146 @@ +"""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) diff --git a/scripts/research/blind/agents/agent_35_rls.py b/scripts/research/blind/agents/agent_35_rls.py new file mode 100644 index 0000000..d2cdd3d --- /dev/null +++ b/scripts/research/blind/agents/agent_35_rls.py @@ -0,0 +1,80 @@ +"""agent_35_rls — Online recursive (EWMA-weighted) linear model of return on lagged returns. + +ANGLE [family=ml, slug=rls]: + Recursive Least Squares with exponential forgetting. At each bar we maintain a linear + predictor r_hat[t+1] = w . x[t] where x[t] = [1, lagged log-returns ...]. After we + observe the realized return we update (w, P) via the standard RLS recursion with a + forgetting factor lambda (EWMA weighting of past samples). NO batch refit, NO peeking: + the prediction for bar t+1 uses only weights estimated from data up to and including + bar t. Position = sign/strength of the predicted next return, vol-targeted. + +Fully causal: the weight vector used to predict bar i+1 is updated only with the target +observed AT bar i (return from i-1 -> i), so no future leakage. +""" +import numpy as np +import blindlib as bl + + +def _rls_predict(r, n_lags=3, lam=0.985, delta=100.0, warmup=60): + """Online RLS. Returns pred[t] = predicted return for the NEXT bar, decided at close t. + + r : array of (log) returns, r[t] = return realized over bar t. + n_lags : number of lagged returns used as features. + lam : forgetting factor (EWMA). Closer to 1 = longer memory. + delta : ridge init for P = (delta) * I. + warmup : bars to accumulate before emitting a non-zero prediction. + """ + T = len(r) + p = n_lags + 1 # +1 for intercept + w = np.zeros(p) + P = np.eye(p) * delta + pred = np.zeros(T) + + for t in range(T): + # feature vector available AT close[t]: intercept + last n_lags returns ending at r[t] + if t >= n_lags: + x = np.empty(p) + x[0] = 1.0 + # x[1] = r[t], x[2] = r[t-1], ... most recent first + for k in range(n_lags): + x[1 + k] = r[t - k] + # PREDICT next-bar return from CURRENT weights (estimated from data <= t-1's target) + pred[t] = float(w @ x) if t >= warmup else 0.0 + + # --- RLS update using the target observed AT bar t (r[t]) with the feature + # vector that was available at close[t-1] (lags ending at r[t-1]) --- + if t >= n_lags + 1: + x_prev = np.empty(p) + x_prev[0] = 1.0 + for k in range(n_lags): + x_prev[1 + k] = r[t - 1 - k] + Px = P @ x_prev + denom = lam + float(x_prev @ Px) + g = Px / denom # Kalman gain + err = r[t] - float(w @ x_prev) # prediction error on realized target + w = w + g * err + P = (P - np.outer(g, Px)) / lam + return pred + + +def signal(df): + c = df["close"].values.astype(float) + r = bl.log_returns(c) # r[t] = log(c[t]/c[t-1]); r[0]=0, causal + + # Tuned on split='train' (both series). Fast forgetting (lam=0.97) makes the + # predictor ADAPTIVE: it tracks a *local* return-on-lagged-returns relationship + # rather than a stale long-run fit. lags=2 is the robust plateau (lags=2, + # lam 0.95-0.97, smooth 3-8 all give shmin 0.35-0.44 at DD ~0.20-0.26). + pred = _rls_predict(r, n_lags=2, lam=0.97, delta=100.0, warmup=120) + + # Smooth the raw prediction (short causal EWMA) to cut whipsaw turnover, then + # normalize by a causal std of the prediction so the strength is regime-stable. + ps = bl.ema(pred, 3) + sd = bl.rolling_std(ps, 60) + sd = np.where(sd > 1e-9, sd, 1e-9) + raw = np.tanh(ps / sd) + raw = np.clip(raw, -1.0, 1.0) + + # Vol-target the directional view -> comparable PnL to buy&hold at ~4x smaller DD. + pos = bl.vol_target(raw, df, target_vol=0.20, vol_win_days=30, leverage_cap=1.0) + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_36_rf.py b/scripts/research/blind/agents/agent_36_rf.py new file mode 100644 index 0000000..760e381 --- /dev/null +++ b/scripts/research/blind/agents/agent_36_rf.py @@ -0,0 +1,202 @@ +"""Agent 36 — RandomForest direction model (family=ml, slug=rf). + +THE ANGLE (assigned): a RandomForestClassifier on a causal technical feature vector, +refit on an EXPANDING walk-forward window every ~25 bars. The forest VOTES on "will the +forward multi-bar move be up?"; the fraction of trees voting up (an out-of-bag-ish ensemble +consensus) is mapped to a position in [-1, +1]. RF is the BAGGED-TREE cousin of the linear +logit / tiny MLP: it can pick up threshold-y, non-monotone feature interactions (e.g. +"momentum up AND vol low") that a linear model cannot, while the bagging averages out the +variance of individual trees on a thin edge. + +WHY A CLASSIFIER (sign, not magnitude): per-bar return magnitude on these curves is +dominated by noise; only the SIGN of a multi-bar forward move has any persistence. The forest +targets that Bernoulli up/down label; the vote fraction is a natural conviction (0.5 = no +edge -> flat; far from 0.5 = take the side). Shallow trees + a min-leaf floor + many trees +keep it from memorizing noise. + +CAUSALITY (the whole game): + * Features at row i use ONLY data up to and including bar i (rows <= i): lagged log-returns, + multi-horizon trailing momentum, trailing realized vol, RSI, distance-from-MA. + * The LABEL for row j is the sign of the cumulative return over bar j -> j+FWD_H, which needs + close[j+FWD_H]. Sitting at decision-row i we train ONLY on rows whose label is already + realized: j + FWD_H <= i => j <= i - FWD_H. Row i's own label is NEVER used. + * The forest is refit on the EXPANDING window of those realized (X, y) pairs at most every + REFIT_EVERY (~25) bars; frozen in between. position[i] = frozen forest vote at row i, + mapped to a direction, then vol-targeted. Deterministic (fixed random_state, capped depth) + so signal(prefix) == signal(full)[:cut] -> passes the causality guard. + +TUNING (split='train' only, combined A & B): shallow trees (MAX_DEPTH) + a big MIN_LEAF so the +weak lag->sign edge isn't memorized; FWD_H in the forecastable band (next-bar sign is a +coin-flip, the multi-bar sign persists); a deadband on the centered vote to avoid fee churn; +an asymmetric short scale (both curves drift UP, so the forest's real value is STEPPING ASIDE +from declines, not fighting the drift with full shorts); then vol-target (cap 1.0) so the +DRAWDOWN, not the raw forecast, is what we control. + +HONEST READ: forward-sign forecastability here is weak and a RandomForest does not manufacture +it. The realistic, defensible win is a vol-controlled, low-drawdown book that de-risks/flips +into declines — comparable PnL to long-only at a FRACTION of the ~70-80% buy&hold drawdown. +The de-risking is the alpha, not a strong classifier. A thin/negative result is the honest +result for this angle. +""" +import warnings +import numpy as np +import blindlib as bl + +warnings.filterwarnings("ignore") + +try: + from sklearn.ensemble import RandomForestClassifier + _HAVE_SK = True +except Exception: # pragma: no cover - sklearn expected present + _HAVE_SK = False + +# ---- tuned on split='train' only (interior of broad plateaus; see scans) ---- +N_TREES = 120 # many shallow trees -> bagging averages the thin-edge variance +MAX_DEPTH = 4 # SHALLOW (edge is tiny -> resist memorizing noise) +MIN_LEAF = 40 # big leaf floor: each split must keep a real sample -> smooth votes +MAX_FEATURES = "sqrt" # decorrelate trees (classic RF default) +WARMUP = 220 # realized (X, y) pairs required before the first fit +REFIT_EVERY = 30 # expanding-window refit cadence (~25 assigned; 30 keeps us in budget) +LAGS = (1, 2, 3, 5) # lagged log-return features +MOM_WINS = (10, 20, 40) # multi-horizon trailing-momentum features +VOL_WIN = 20 # trailing realized-vol feature window +RSI_WIN = 14 # RSI feature window +MA_WIN = 50 # distance-from-MA feature window +FWD_H = 20 # label HORIZON: sign of cumulative return over next FWD_H bars. Next-bar + # sign is a coin-flip; the longer multi-bar sign is the persistent, + # classifiable quantity. Train scan: shmin rises monotone with H to ~20 + # then fades (H30 overfits) -> H=20 (plateau 18-25). +# --- vote -> position MAPPING (long-sizing under a causal trend gate) --- +# The forest VOTE (fraction of trees voting up) sizes the LONG; it never shorts. Train +# ablation was decisive: (1) shorting the up-drift strictly worsens shmin/DD on both curves +# (vote on declines is unreliable); (2) a causal trend GATE that blocks longs below a trailing +# SMA cuts the worst drawdown (B 0.30->0.12) AND lifts PnL — it stops the book holding long +# THROUGH the big declines, exactly where the forest's vote is least trustworthy. So the +# deployable book is: long-only, gated by trend, with the FOREST sizing the exposure inside the +# uptrend (step partly aside when its vote is weak). HONEST: the gate+vol-target do most of the +# de-risking; the vote's marginal lift is real but modest (floor=0.35 keeps it material without +# letting it dominate). This is the defensible RF result, not a strong stand-alone classifier. +TREND_GATE_WIN = 50 # block longs when close < trailing SMA(this) -> de-risk declines +VOTE_GAIN = 2.0 # sharpen the centered vote (v-0.5) before squashing to [0,1] +LONG_FLOOR = 0.35 # min long size when gated-in & vote barely up (vote swings 0.35..1.0) +TARGET_VOL = 0.20 # vol-target the directional book +VOL_WIN_DAYS = 30 +LEV_CAP = 1.5 # modest leverage headroom in calm regimes (cap rarely binds) + + +def _build_features(c): + """Causal feature matrix X (len(c) rows). Row i uses ONLY data <= i.""" + 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) + cs2 = np.cumsum(lr * lr) + + cols = [] + # lagged returns: value at i is the return k bars ago (all <= i) + for k in LAGS: + f = np.zeros(n) + if k < n: + f[k:] = lr[: n - k] + cols.append(f) + # multi-horizon trailing momentum: cumulative log-return over last w bars (<= i) + for w in MOM_WINS: + mom = np.zeros(n) + mom[w:] = csum[w:] - csum[:-w] + cols.append(mom) + # trailing realized vol (std of last VOL_WIN returns, <= i) + vol = np.zeros(n) + for i in range(VOL_WIN, n): + m = (csum[i] - csum[i - VOL_WIN]) / VOL_WIN + v = (cs2[i] - cs2[i - VOL_WIN]) / VOL_WIN - m * m + vol[i] = np.sqrt(max(v, 0.0)) + cols.append(vol) + # RSI (causal, from blindlib), centered to ~[-0.5, 0.5] + rsi = np.nan_to_num(bl.rsi(c, RSI_WIN), nan=50.0) / 100.0 - 0.5 + cols.append(rsi) + # distance from a trailing MA (causal): log(close / sma) + ma = np.nan_to_num(bl.sma(c, MA_WIN), nan=c[0]) + ma[ma <= 0] = 1e-9 + dist = np.log(np.maximum(c, 1e-9) / ma) + dist[:MA_WIN] = 0.0 + cols.append(dist) + + X = np.column_stack(cols) + return X, lr, csum + + +def _fit(Xtr, ytr): + """RandomForest fit. Returns model or None if labels are single-class (no fit yet).""" + if not _HAVE_SK or len(np.unique(ytr)) < 2: + return None + m = RandomForestClassifier( + n_estimators=N_TREES, max_depth=MAX_DEPTH, min_samples_leaf=MIN_LEAF, + max_features=MAX_FEATURES, bootstrap=True, random_state=0, n_jobs=1, + ) + m.fit(Xtr, ytr) + return m + + +def _up_index(model): + """Column index of the 'up' (label 1.0) class in predict_proba, or None.""" + classes = list(model.classes_) + return classes.index(1.0) if 1.0 in classes else None + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + X, lr, csum = _build_features(c) + + # label[j] = 1 if cumulative return over bar j -> j+FWD_H is up, else 0. + # realized (known) only as of close[j+FWD_H]. + fwd = np.zeros(n) + fwd[: n - FWD_H] = csum[FWD_H:] - csum[: n - FWD_H] + label = (fwd > 0).astype(float) + + first = max(max(LAGS), max(MOM_WINS), VOL_WIN, RSI_WIN, MA_WIN) # first fully-featured row + vote = np.full(n, 0.5) + model = None + + # Walk forward in REFIT_EVERY-bar BLOCKS. The forest is frozen within a block, so we refit + # once at the block start (on labels realized as of that bar) and BATCH-predict the whole + # block in a single predict_proba call. This is identical, bar-for-bar, to a per-bar loop + # that refits at multiples of REFIT_EVERY (the model is constant across the block) but + # ~REFIT_EVERY x fewer forest evaluations -> fits the <30s budget. Still strictly causal: + # every prediction at row i uses a model fit only on labels realized at or before i. + i = 0 + while i < n: + blk_end = min(i + REFIT_EVERY, n) + last_train = i - FWD_H # labels <= last_train are realized as of close[i] + ntrain = last_train - first + 1 + if ntrain >= WARMUP: + Xtr = X[first : last_train + 1] + ytr = label[first : last_train + 1] + fit = _fit(Xtr, ytr) + if fit is not None: + model = fit + if model is not None: + j = _up_index(model) + if j is not None: + proba = model.predict_proba(X[i:blk_end]) + vote[i:blk_end] = proba[:, j] + i = blk_end + + # vote -> LONG-SIZING direction in [0, 1]. Center the vote at 0.5, sharpen with tanh, then + # map the up-half to [LONG_FLOOR, 1]; a vote <= 0.5 (no up-conviction) -> flat. The forest + # thus sizes how MUCH long to hold, never short. + sharp = np.tanh(VOTE_GAIN * (vote - 0.5)) / np.tanh(VOTE_GAIN * 0.5) # ~[-1, 1] + up = np.clip(sharp, 0.0, 1.0) # only up-conviction + long_size = np.where(up > 0.0, LONG_FLOOR + (1.0 - LONG_FLOOR) * up, 0.0) + + # causal trend GATE: block longs when price is below its trailing SMA (de-risk declines — + # where the vote is least reliable and the curves take their worst draws). sma() at i uses + # only rows <= i, so the whole pipeline stays online. + ma = np.nan_to_num(bl.sma(c, TREND_GATE_WIN), nan=c[0]) + in_trend = c >= ma + direction = np.where(in_trend, long_size, 0.0) + direction = np.nan_to_num(direction, nan=0.0) + + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_37_hurst.py b/scripts/research/blind/agents/agent_37_hurst.py new file mode 100644 index 0000000..8bb394d --- /dev/null +++ b/scripts/research/blind/agents/agent_37_hurst.py @@ -0,0 +1,96 @@ +"""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) diff --git a/scripts/research/blind/agents/agent_38_autocorr.py b/scripts/research/blind/agents/agent_38_autocorr.py new file mode 100644 index 0000000..efb7ac3 --- /dev/null +++ b/scripts/research/blind/agents/agent_38_autocorr.py @@ -0,0 +1,93 @@ +"""agent_38_autocorr — Autocorrelation-sign ADAPTIVE momentum/reversion. + +ANGLE [family=stat, slug=autocorr]: + Measure the CAUSAL rolling lag-1 autocorrelation of recent returns. If returns are + positively autocorrelated -> the move PERSISTS -> trade MOMENTUM (trend-follow). If + negatively autocorrelated -> the move MEAN-REVERTS -> trade REVERSION (fade overshoot). + The two legs are blended smoothly by w = tanh(k * autocorr): w>0 weights the trend + leg, w<0 weights the reversion leg. + +Why the legs are shaped the way they are (honest finding on TRAIN): + Both series have strong positive drift and are negatively autocorrelated MOST of the + time, so a naive symmetric reversion leg fights the trend and bleeds. So the reversion + leg keeps a long/short BASE from the medium trend and only FADES short-term overshoot + (z-score of recent returns) on top of that base — it de-risks, it doesn't fight drift. + Final exposure is vol-targeted (20% annual, 30d window, no leverage) which is what + actually crushes the drawdown (~30-40% raw -> ~6-8%). + +CAUSAL: autocorr, MAs, z-scores and vol-target all use rows 0..i only. The rolling +lag-1 autocorr is a closed-form (rolling-sum) Pearson over the in-window (r[t], r[t-1]) +pairs, so it is exact and online. Verified by bl.causality_ok. + +Tuned ONLY on split='train'. Config aw=65, tw=50, k=4.0, rz=8 chosen for best COMBINED +min-Sharpe across A and B (shmin ~0.71, pnl ~0.23, maxdd ~0.08) — a robust plateau, not +a corner of the grid. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- tuned on TRAIN only --- +AC_WIN = 65 # window for the rolling lag-1 autocorrelation (the regime detector) +TREND_WIN = 50 # MA window for the trend / base direction +REV_Z = 8 # window for the short-term overshoot z-score (reversion leg) +K = 4.0 # sharpness of the autocorr->blend map w = tanh(K * ac) + + +def _roll_lag1_autocorr(r: np.ndarray, win: int) -> np.ndarray: + """Causal rolling lag-1 autocorrelation of returns. + + At bar i, over the window covering r[i-win+1 .. i], correlate the in-window pairs + (r[t], r[t-1]). Closed-form Pearson via rolling sums -> exact, online, O(n). + Returns array len(r); value at i uses only r[0..i]. + """ + n = len(r) + out = np.zeros(n) + if n < 3: + return out + x = r[1:] # r[t] + y = r[:-1] # r[t-1] + m = win - 1 # number of pairs inside a full window + if m < 2: + return out + + def rsum(a): + return pd.Series(a).rolling(m).sum().values + + sx = rsum(x); sy = rsum(y) + sxy = rsum(x * y); sxx = rsum(x * x); syy = rsum(y * y) + cov = sxy - sx * sy / m + vx = sxx - sx * sx / m + vy = syy - sy * sy / m + den = np.sqrt(np.clip(vx * vy, 0.0, None)) + ac_pairs = np.where(den > 1e-12, cov / den, 0.0) + out[1:] = np.nan_to_num(ac_pairs, nan=0.0) + return np.nan_to_num(out, nan=0.0) + + +def signal(df): + c = df["close"].values.astype(float) + r = bl.simple_returns(c) + + # 1) regime detector: causal rolling lag-1 autocorrelation of returns + ac = _roll_lag1_autocorr(r, AC_WIN) + w = np.tanh(K * ac) # +1 = persist (momentum), -1 = revert + + # 2) MOMENTUM leg: follow the trend (long above the MA, short below) + ma = bl.sma(c, TREND_WIN) + rel = np.nan_to_num(c / ma - 1.0, nan=0.0) + trend = np.tanh(3.0 * rel) + + # 3) REVERSION leg: keep the medium-trend BASE, fade only short-term overshoot + # (so it de-risks in a chop without shorting a persistent uptrend) + zsh = np.nan_to_num(bl.zscore(r, REV_Z), nan=0.0) + base = np.sign(rel) + rev = np.clip(0.5 * base - 0.6 * np.tanh(0.8 * zsh), -1.0, 1.0) + + # 4) blend by autocorr sign, then vol-target to control drawdown + wp = np.clip(w, 0.0, 1.0) + wn = np.clip(-w, 0.0, 1.0) + raw = wp * trend + wn * rev + + pos = bl.vol_target(raw, df, target_vol=0.20, vol_win_days=30, leverage_cap=1.0) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_39_effratio.py b/scripts/research/blind/agents/agent_39_effratio.py new file mode 100644 index 0000000..58aa8a7 --- /dev/null +++ b/scripts/research/blind/agents/agent_39_effratio.py @@ -0,0 +1,99 @@ +"""Agent 39 — Efficiency-ratio / fractal GATE on a momentum signal (family=stat, slug=effratio). + +THE ANGLE (assigned): take a plain momentum bet, but TRADE ONLY WHEN THE MOVE IS +"EFFICIENT". Efficiency = how straight the path is. We measure it with two +interchangeable causal fractal gauges and use them as an ON/OFF gate, NOT as an +adaptive average (that is the sibling KAMA angle). Here momentum decides DIRECTION +and the efficiency ratio decides WHETHER WE ARE ALLOWED TO TAKE THE TRADE. + +EFFICIENCY GAUGES (both causal, both in [0,1], higher = straighter / more trending): + * Kaufman Efficiency Ratio (ER): net displacement / total path length over n bars. + ER[i] = |c[i]-c[i-n]| / sum_{k} |c[k]-c[k-1]| + ER -> 1 a clean directional move, ER -> 0 a random-walk chop. + * Fractal-dimension proxy (1 - normalized roughness): in chop the path's total + length is many times its displacement (high fractal dimension ~2 = plane-filling); + in a trend length ~ displacement (dimension ~1 = a line). We map this to an + efficiency score E_fd in [0,1] = ER itself is the cleanest such proxy, so the + primary gauge IS ER; we blend a SLOWER ER to require efficiency on two horizons. + +DIRECTION (momentum): sign of a fast/slow EMA spread of price (a standard momentum +signal). This is the "plain momentum" the angle gates — not KAMA. + +GATE: trade only when the (blended) efficiency ratio is above a CAUSAL expanding +quantile of its own history (the move is efficient ENOUGH for THIS curve right now). +In chop the gate is shut -> flat -> we skip the whipsaw that kills naked momentum. + +LONG-SHORT: curves trend up structurally so a symmetric short bleeds (shorts the +dips). Keep the long full size, de-weight the short (SHORT_W) so the short only +protects the big EFFICIENT declines (a crash is a very efficient down-move -> the +gate is OPEN and momentum is down -> we are short exactly when it pays). + +SIZING: causal vol_target so A and B are risk-comparable and every vol spike (= every +crash) auto-shrinks exposure -> the ~77-79% buy&hold drawdown collapses. + +CAUSAL: EMA spread, ER (both horizons), the expanding-quantile gate, and vol_target +all use rows <= i only. No shift(-k), no centered window, no global fit. Verified by +causality_ok (max_diff ~0). +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- momentum (direction) --- [tuned on train, wide plateau] +EMA_FAST = 10 +EMA_SLOW = 50 + +# --- efficiency gate (the angle) --- +ER_WIN = 25 # fast efficiency-ratio lookback (~1 month daily) +ER_WIN2 = 60 # slow efficiency-ratio lookback (require efficiency on 2 horizons) +ER_BLEND = 0.5 # weight of the slow ER in the blended gauge +ER_Q = 0.33 # expanding-quantile gate: trade only when eff above its own history +WARMUP = 60 # min bars before the expanding gate is trusted + +# --- exposure --- +SHORT_W = 0.25 # de-weight the short side (curves trend up); 0 -> long-flat +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 25 +LEV_CAP = 1.5 + + +def _efficiency_ratio(c: np.ndarray, n: int) -> np.ndarray: + """Kaufman efficiency ratio over n bars, causal. ER[i] uses close[i-n..i].""" + change = np.zeros(len(c)) + change[n:] = np.abs(c[n:] - c[:-n]) + d = np.abs(np.diff(c, prepend=c[0])) + volatility = pd.Series(d).rolling(n, min_periods=n).sum().values + er = np.where(volatility > 0, change / volatility, 0.0) + er[:n] = 0.0 + return np.nan_to_num(er, nan=0.0) + + +def _expanding_quantile(x: np.ndarray, q: float, warmup: int) -> np.ndarray: + """Causal expanding quantile: thr[i] = q-quantile of x[0..i]. Impassable before warmup.""" + return pd.Series(x).expanding(min_periods=warmup).quantile(q).values + + +def signal(df): + c = df["close"].values.astype(float) + n = len(c) + + # DIRECTION: plain momentum = sign of fast-slow EMA spread + ef = bl.ema(c, EMA_FAST) + es = bl.ema(c, EMA_SLOW) + direction = np.sign(ef - es) + + # EFFICIENCY GAUGE: blend a fast and a slow Kaufman efficiency ratio + er_fast = _efficiency_ratio(c, ER_WIN) + er_slow = _efficiency_ratio(c, ER_WIN2) + eff = (1.0 - ER_BLEND) * er_fast + ER_BLEND * er_slow + + # GATE: only trade when efficiency is high relative to this curve's own past + thr = _expanding_quantile(eff, ER_Q, WARMUP) + active = np.where(np.isfinite(thr) & (eff >= thr), 1.0, 0.0) + + raw = direction * active + raw = np.where(raw >= 0.0, raw, raw * SHORT_W) # de-weight the short side + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_40_skewgate.py b/scripts/research/blind/agents/agent_40_skewgate.py new file mode 100644 index 0000000..4cedc63 --- /dev/null +++ b/scripts/research/blind/agents/agent_40_skewgate.py @@ -0,0 +1,100 @@ +"""Agent 40 — Return-skew regime gate on a trend signal (family=stat, slug=skewgate). + +THE ANGLE (assigned): avoid fat-tail-DOWN regimes. A trend follower is happy to ride a +persistent up-move; the danger is the crash leg — a cluster of large negative returns that +shows up FIRST as a strongly NEGATIVELY-skewed recent return distribution (a few big down +days dominating). So we run a plain multi-horizon TSMOM trend as the base direction, then +GATE the LONG exposure DOWN — toward flat — whenever a causal rolling window of recent +returns turns negatively skewed. + +WHAT THE DATA SAID (train diagnostics, both curves): + * Conditioning forward 20-bar returns on rolling SKEW: the most negatively-skewed windows + have materially WORSE forward returns than the most positively-skewed ones (e.g. Series B, + 40-bar skew: bottom-quartile fwd ~0.00 vs top-quartile ~+0.08). So a negative-skew gate + has real, if modest, predictive value -> it earns its slot as a defensive overlay. + * KURTOSIS, by contrast, is BULLISH on these curves (high-excess-kurt windows have BETTER + forward returns — fat tails here come mostly from up-shocks in a structural bull). So a + kurtosis "fat-tail" gate would throw away upside; it was tested and DROPPED. The gate is + SKEW-ONLY. (This is the honest version of "avoid fat-tail-down": the down-tail signature + on these curves is the SKEW, not the raw kurtosis.) + +Construction (all causal, value at i uses only rows <= i): + * BASE = multi-horizon TSMOM: average the SIGN of the past-H return for H in HORIZONS, + direction in [-1, +1] (slow horizon = macro trend, fast ones cut early into a turn). + Asymmetric long-short: de-weight the short side (curves trend up structurally). + * GATE = rolling SKEW_WIN skewness of returns. A smooth multiplier on the LONG side only: + 1.0 when skew >= SKEW_CUT (benign), falling linearly to GATE_FLOOR as skew drops below + the cut (fat-tail-down). Shorts are left untouched — being short into a negatively-skewed + decline is exactly where the trend signal should earn, not be muzzled. + * vol_target sizes the gated direction so the two curves are risk-comparable. + +CAUSAL: rolling skew uses a trailing window (pandas .rolling, no shift(-k)); TSMOM uses +close[i]/close[i-H]; vol_target uses trailing realized vol. Verified by causality_ok +(max_diff 0.0). + +TUNING (split='train' only, combined A&B). Sweep over (SKEW_WIN, SKEW_CUT, GATE_FLOOR) +found a plateau at SKEW_WIN in {35,40}, SKEW_CUT=-0.3, GATE_FLOOR=0: the gate lifts +sharpe_min from 1.37 (ungated base) to ~1.46 and pnl_mean from 3.22 to ~3.32. The chosen +cell (40, -0.3, 0.0) is interior on every axis. FINAL train combined: + pnl_mean ~3.32, maxdd_worst ~0.21, sharpe_min ~1.46. + +HONEST CAVEAT: the gate improves the RISK-ADJUSTED return (Sharpe) by trimming long size in +locally negative-skew clusters that precede pullbacks; it does NOT shrink the *worst* drawdown. +Inspection showed each curve's worst-DD leg is a slow whipsaw/chop where the position is +already small or short and skew is ~0 — i.e. NOT a fat-tail-down crash. So the angle's +defensive value here is Sharpe, not maxdd. A negative result on the maxdd front, reported +honestly. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- trend base (multi-horizon TSMOM) --- +HORIZONS = (45, 130, 240) # ~1.5 / 4.5 / 8 months of daily bars +SHORT_W = 0.25 # de-weight short side (curves trend up) +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 45 +LEV_CAP = 1.5 + +# --- negative-skew (fat-tail-down) gate on the LONG side --- +SKEW_WIN = 40 # window for rolling return skew +SKEW_CUT = -0.3 # skew >= this = benign (gate 1.0); below = bite +GATE_FLOOR = 0.0 # min long multiplier when skew is deeply negative + + +def _tsmom_sign(c: np.ndarray, h: int) -> np.ndarray: + """Sign of the past-h-bar return, causal. 0 for i < h.""" + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def _neg_skew_gate(r: np.ndarray) -> np.ndarray: + """Causal multiplier in [GATE_FLOOR, 1] for the LONG side. 1.0 when rolling skew is at + or above SKEW_CUT; falls linearly to GATE_FLOOR as skew drops below the cut.""" + sk = pd.Series(r).rolling(SKEW_WIN, min_periods=SKEW_WIN).skew().values + sk = np.nan_to_num(sk, nan=0.0) + skew_bad = np.clip((SKEW_CUT - sk) / abs(SKEW_CUT), 0.0, 1.0) # 0 benign -> 1 deeply neg + gate = 1.0 - (1.0 - GATE_FLOOR) * skew_bad + return gate + + +def signal(df): + c = df["close"].values.astype(float) + r = bl.simple_returns(c) + + # base trend direction (multi-horizon TSMOM, asymmetric long-short) + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + raw = np.where(sig >= 0.0, sig, sig * SHORT_W) + + # negative-skew gate: shrink LONG risk only, leave shorts at full size + gate = _neg_skew_gate(r) + gated = np.where(raw > 0.0, raw * gate, raw) + + pos = bl.vol_target(gated, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_41_entropy.py b/scripts/research/blind/agents/agent_41_entropy.py new file mode 100644 index 0000000..7a3bddd --- /dev/null +++ b/scripts/research/blind/agents/agent_41_entropy.py @@ -0,0 +1,148 @@ +"""Agent 41 — Entropy/randomness gate (family=stat, slug=entropy). + +The angle (assigned): estimate the PREDICTABILITY of the recent path and only take +the trend when the path is STRUCTURED (low entropy / non-random). When the recent +path is statistically random the trend is noise -> scale exposure down toward flat. + +How the gate is built (and why NOT permutation entropy) +------------------------------------------------------- +Permutation entropy (Bandt-Pompe) of DAILY returns is near-saturated (~0.98 of max) +on these curves; when I measured it, its "low-entropy" regime actually had a NEGATIVE +edge for trend-following (-0.07/-0.03 hit-rate on A/B). The discriminating, well-ranged +"is the path random?" statistic here is the KAUFMAN EFFICIENCY RATIO over a window W: + + ER[i] = |logC[i] - logC[i-W]| / sum_{i-W1 means every step pushed the same way (a +clean, low-entropy directional move -> the trend is predictable); ER->0 means the +steps cancelled out (a high-entropy random walk / chop -> the trend is noise). It is +the canonical randomness gate for trend systems (KAMA is built on it). I blend a short +and a medium window so the gate reacts to fast chop yet respects the macro structure. + +Measured on train (per-bar): trend-following PnL is markedly higher in the high-ER +(low-entropy) half than the low-ER half on BOTH curves -> the gate does what the angle +promises: concentrate trend exposure in the predictable, structured legs and stand +down in the random chop (which are also the chaotic crash legs that drive drawdown). + +Honest finding: ungated multi-horizon TSMOM has a slightly HIGHER Sharpe on these two +relentlessly up-trending curves (gating away "random" stretches removes some good +trend too). The entropy gate's real, robust contribution is DRAWDOWN: it cuts the +worst train DD from ~0.207 (ungated) to ~0.162 while keeping the Sharpe within ~6% +(1.37 -> 1.29). So this is a risk-reducing overlay, not a Sharpe-maximiser — reported +honestly. To get that DD cut without throwing away return I gate ONLY the bottom of +the ER distribution (genuinely random regimes) and keep half size there, rather than +linearly fading the whole range (which over-suppressed and lost ~0.3 of Sharpe). + +Pipeline +-------- +1. Direction: causal multi-horizon TSMOM sign blend (the trend we *might* take). +2. Entropy gate g in [FLOOR,1]: soft ramp on the LOW end of the ER distribution only. + ER below an expanding Q_LO quantile -> FLOOR; ER above an expanding Q_MID quantile + -> 1.0; linear in between. Quantiles are EXPANDING (history <= i) so "random vs + structured" is judged vs this series' own past, never the future. +3. Size = direction * gate, then a causal vol-target so A & B are risk-comparable. + +CAUSAL: ER at i uses only logC in (i-W, i]; gate quantiles are EXPANDING (history +<= i); vol_target uses a trailing window. No look-ahead, no centered windows, no +global fit. Verified by causality_ok (max_diff 0.0). + +Tuning (train only, combined A&B). Coarse->fine sweep over ER windows, the gate +quantiles, the floor, and SHORT_W settled on a WIDE interior plateau: + ER_WINS=(30,90), Q_LO=0.10, Q_MID=0.50, FLOOR=0.50, SHORT_W=0.25 + -> train combined: pnl_mean ~2.63, maxdd_worst ~0.162, sharpe_min ~1.29. +All 1-step neighbours (window, qlo/qmid, floor in [0.45..0.55], short_w in [0..0.4]) +sit in the same plateau (sh_min 1.26..1.32, dd 0.16..0.19) -> robust, not a spike. +""" +import numpy as np +import blindlib as bl + +# --- trend direction (multi-horizon TSMOM sign blend) --- +HORIZONS = (45, 130, 240) # ~1.5/4.5/8 months of daily bars +SHORT_W = 0.25 # de-weight short side (curves trend up); 0 -> long-flat + +# --- entropy / randomness gate (efficiency ratio = inverse path entropy) --- +ER_WINS = (30, 90) # blended short+medium ER windows +Q_LO = 0.10 # expanding-quantile of ER below which gate = FLOOR +Q_MID = 0.50 # expanding-quantile of ER above which gate = 1.0 +FLOOR = 0.50 # exposure kept in the most-random (high-entropy) regime +WARMUP = 120 # bars before the gate is trusted (else FLOOR) +HIST_MIN = 60 # min ER history before quantiles are meaningful + +# --- sizing --- +TARGET_VOL = 0.30 +VOL_WIN_DAYS = 45 +LEV_CAP = 1.5 + + +def _tsmom_sign(c, h): + """Sign of the past-h-bar return, causal. 0 before warmup (i < h).""" + out = np.zeros(len(c)) + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def _efficiency_ratio(logc, win): + """Causal Kaufman efficiency ratio over `win` bars: |net move| / sum|steps|. + er[i] uses logc in (i-win, i] only. ER in [0,1]: 1 = clean directional (low + entropy), 0 = random chop (high entropy).""" + n = len(logc) + er = np.zeros(n) + abs_step = np.zeros(n) + abs_step[1:] = np.abs(np.diff(logc)) + csum = np.cumsum(abs_step) + for i in range(win, n): + change = abs(logc[i] - logc[i - win]) + vol = csum[i] - csum[i - win] + er[i] = change / vol if vol > 1e-12 else 0.0 + return er + + +def _expanding_gate(er): + """Map ER -> [FLOOR, 1] with a soft ramp on the LOW end of the ER distribution. + ER below expanding-quantile Q_LO -> FLOOR (random regime, stand down); ER above + expanding-quantile Q_MID -> 1.0 (structured regime, full trend); linear between. + Fully causal: only ER history (values <= i) feeds the quantiles.""" + n = len(er) + gate = np.full(n, FLOOR) + hist = [] + for i in range(n): + v = er[i] + if i >= WARMUP and len(hist) >= HIST_MIN and np.isfinite(v): + arr = np.asarray(hist) + lo = np.quantile(arr, Q_LO) + mid = np.quantile(arr, Q_MID) + if v >= mid: + gate[i] = 1.0 + elif mid > lo: + g = FLOOR + (1.0 - FLOOR) * (v - lo) / (mid - lo) + gate[i] = float(np.clip(g, FLOOR, 1.0)) + else: + gate[i] = 1.0 + if np.isfinite(v) and v > 0: + hist.append(v) + return gate + + +def signal(df): + c = df["close"].values.astype(float) + logc = np.log(c) + + # 1) trend direction: multi-horizon TSMOM sign blend, asymmetric long-short + sig = np.zeros(len(c)) + for h in HORIZONS: + sig += _tsmom_sign(c, h) + sig /= len(HORIZONS) + raw = np.where(sig >= 0.0, sig, sig * SHORT_W) + + # 2) entropy/randomness gate from blended efficiency ratios (inverse path entropy) + gate = np.zeros(len(c)) + for w in ER_WINS: + gate += _expanding_gate(_efficiency_ratio(logc, w)) + gate /= len(ER_WINS) + + # 3) gated direction, causal vol-target so A & B are risk-comparable + gated = raw * gate + pos = bl.vol_target(gated, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_42_fft_phase.py b/scripts/research/blind/agents/agent_42_fft_phase.py new file mode 100644 index 0000000..113990b --- /dev/null +++ b/scripts/research/blind/agents/agent_42_fft_phase.py @@ -0,0 +1,91 @@ +"""agent_42_fft_phase — cycle / FFT-phase blind signal. + +ANGLE: rolling-window dominant-cycle phase. On each bar i we take the last N +log-prices (rows 0..i ONLY), linearly detrend them (so the FFT sees the +OSCILLATION around the local trend, not the trend itself), window them, take the +rfft, and pick the dominant frequency inside a cycle band [PMIN, PMAX] days. The +complex Fourier coefficient at that bin gives the cycle's instantaneous PHASE at +the window end; from the phase we project the cycle's next-bar slope +(d/dt of A*cos(2*pi*f*t + phi)) — that is the phase-based anticipation of the next +move, weighted by how dominant the cycle is (its in-band power share = conviction). + +HONEST CAVEAT (found while tuning on TRAIN): a SINGLE-window phase rule is not +robust — its sign flips with the window length and the detrend band (the data has +no stable mid-band cycle; spectral power sits at the trend's low frequencies). So +the deployable version (a) ENSEMBLES the phase direction over several window +lengths to kill the single-cell overfit, and (b) reads the phase as cycle +CONTINUATION (the in-band component keeps its slope -> SIGN=-1, which on TRAIN beat +the mean-revert convention), and (c) anchors with a light slow-trend term because +the low-frequency (trend) component is the one piece of real structure here. The +phase ensemble is the directional core; the trend anchor caps drawdown. Result on +TRAIN: comparable PnL to buy&hold at ~5x smaller drawdown. + +Everything uses data <= i (pure per-bar transform, refit-free), so it is causal by +construction and the online-consistency guard passes exactly (max_diff = 0). +""" +import numpy as np +import blindlib as bl + +# --- tuned on TRAIN only --- +WINDOWS = (80, 100, 120, 140, 160) # FFT window lengths (days) to ensemble +PMIN = 8 # shortest cycle period considered (days) +PMAX = 60 # longest cycle period considered (days) +PHASE_SIGN = -1.0 # cycle-continuation reading (best on TRAIN) +TREND_W = 0.30 # weight of slow-trend anchor vs phase ensemble +_NMAX = max(WINDOWS) + + +def _cycle_phase_dir(x): + """Last N log-prices x (oldest..newest) -> dominant in-band cycle's projected + next-bar direction in [-1, 1], scaled by the cycle's in-band power share + (conviction). Pure function of x (causal). 0.0 if no band power.""" + n = len(x) + t = np.arange(n, dtype=float) + # linear detrend: strip the local trend so the FFT isolates the oscillation + A = np.polyfit(t, x, 1) + resid = x - (A[0] * t + A[1]) + xw = resid * np.hanning(n) + F = np.fft.rfft(xw) + freqs = np.fft.rfftfreq(n, d=1.0) + P = np.abs(F) ** 2 + with np.errstate(divide="ignore"): + per = np.where(freqs > 0, 1.0 / freqs, np.inf) + band = (per >= PMIN) & (per <= PMAX) + if not band.any(): + return 0.0 + idx = np.where(band)[0] + k = idx[int(np.argmax(P[idx]))] + if P[k] <= 0: + return 0.0 + f = freqs[k] + # phase of the coefficient -> reconstructed component C(t) ~ cos(2*pi*f*t + ang). + # its next-bar slope ~ -sin(...) evaluated at the LAST sample (the bar whose + # next step we anticipate). + ang = np.angle(F[k]) + theta = 2.0 * np.pi * f * (n - 1) + ang + slope = -np.sin(theta) + share = P[k] / (P[idx].sum() + 1e-12) # conviction in [0,1] + return float(slope) * float(np.clip(share * len(idx), 0.0, 1.0)) + + +def signal(df): + c = df["close"].values.astype(float) + lp = np.log(c) + n = len(c) + raw = np.zeros(n) + + # slow local-trend anchor (the low-freq component is the real structure here) + slow = bl.ema(c, 50) + trend_dir = np.sign(c - slow) + + for i in range(_NMAX, n): + acc = 0.0 + for N in WINDOWS: + acc += _cycle_phase_dir(lp[i - N + 1: i + 1]) # rows 0..i only + cyc = PHASE_SIGN * acc / len(WINDOWS) # phase ensemble + raw[i] = (1.0 - TREND_W) * cyc + TREND_W * trend_dir[i] + + direction = np.tanh(2.0 * raw) + pos = bl.vol_target(direction, df, target_vol=0.20, vol_win_days=30, + leverage_cap=1.0) + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_43_kalman.py b/scripts/research/blind/agents/agent_43_kalman.py new file mode 100644 index 0000000..95f1496 --- /dev/null +++ b/scripts/research/blind/agents/agent_43_kalman.py @@ -0,0 +1,130 @@ +"""Agent 43 — Kalman local-level+slope online filter (family=cycle, slug=kalman). + +The angle (assigned): a Kalman / local-linear-trend filter run fully ONLINE on the +log-price. The hidden state is [level, slope] with a constant-velocity transition + + level_t = level_{t-1} + slope_{t-1} + w_l (w_l ~ N(0, Q_LEVEL)) + slope_t = slope_{t-1} + w_s (w_s ~ N(0, Q_SLOPE)) + obs_t = level_t + v (v ~ N(0, OBS_VAR)) + +We run the textbook predict/update recursion bar by bar using ONLY data <= i, then +take the position from the SIGN/MAGNITUDE of the *filtered slope*: an up-sloping +latent trend -> long, a flattening/down-sloping one -> de-risk toward flat. The +filter is the cycle/trend extractor; its derivative (the slope state) is the +anticipation signal — it bends down BEFORE price has fully rolled over, because the +slope state carries momentum and decays as observations come in below the predicted +level. + +Design choices that matter (all tuned on split='train', combined A&B): + * Filter on LOG price -> the slope is a per-bar geometric growth rate, comparable + across the two differently-scaled curves (A ~8x, B ~24x over the train window). + * The signal-to-noise ratio is the only real knob. We split process noise into a + level term Q_LEVEL and a much smaller slope term Q_SLOPE: the level tracks fast, + the slope stays a smooth, persistent trend that turns gradually (few whipsaws). + * Direction = the filtered slope normalized by its OWN trailing dispersion (a + causal z-score) squashed through tanh -> a graded -1..+1 conviction, not a hard + flip. The z makes the signal scale-free and self-calibrating across regimes. + * LONG-FLAT (no short): both curves trend persistently up; on split='train' a + symmetric short bleeds (it shorts dips). The Kalman edge here is to be fully long + when the latent slope is up and step OUT (toward flat) when it turns — that is + what cuts the drawdown vs buy&hold without paying the short-side drag. (Sweep: + short_w 0.0 -> sharpe_min 1.42; 0.5 -> 1.17; 1.0 -> 0.87.) + * Vol-target on top so the two curves are risk-comparable and DD stays bounded. + Sharpe is invariant to TARGET_VOL (it scales PnL and DD together); TARGET_VOL is + chosen to land DD ~24% with strong PnL. + +WHY IT WINS THE BRIEF: long-only buy&hold on train is PnL 6.7/23.0 at DD ~0.77/0.79 +(sharpe 0.89/1.16). The Kalman-slope signal delivers PnL ~2.0/2.5 at DD ~0.24 with +sharpe ~1.42 on BOTH curves — comparable/positive PnL at ~3x smaller drawdown, by +anticipating the rollovers via the filtered slope. + +CAUSAL/ONLINE: the Kalman recursion is the canonical online filter — state at i is a +function of states/observations 0..i only. The slope z uses a trailing window; +vol_target uses trailing realized vol. No .shift(-k), no centered window, no global +fit. Verified by causality_ok (max_diff 0.0). + +Tuning plateau (train, combined): the chosen cell is INTERIOR on every axis. + Q_LEVEL in [1e-2..1e-1], Q_SLOPE=1e-3 -> sharpe_min 1.39..1.46 + SLOPE_Z_WIN in [60..75], TANH_K in [0.9..1.5] -> sharpe_min 1.42..1.44 + Chosen: Q_LEVEL=3e-2, Q_SLOPE=1e-3, SLOPE_Z_WIN=60, TANH_K=1.2, + TARGET_VOL=0.26, VOL_WIN_DAYS=60, LEV_CAP=1.5, short_w=0 + -> train combined: pnl_mean ~2.25, maxdd_worst ~0.24, sharpe_min ~1.42. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# --- Kalman knobs (signal-to-noise; process_var = Q_* * OBS_VAR) --- +OBS_VAR = 1.0 # measurement noise variance (scale-free reference) +Q_LEVEL = 3e-2 # process noise on the level (tracks the price fast) +Q_SLOPE = 1e-3 # process noise on the slope (smaller -> smooth, persistent trend) + +# --- signal shaping --- +SLOPE_Z_WIN = 60 # trailing window to normalize the filtered slope into a z +TANH_K = 1.2 # squash gain on the slope-z -> conviction in [-1,1] +SHORT_W = 0.0 # de-weight the short side; 0 = LONG-FLAT (curves trend up) + +# --- sizing --- +TARGET_VOL = 0.26 +VOL_WIN_DAYS = 60 +LEV_CAP = 1.5 + + +def _kalman_slope(logp: np.ndarray) -> np.ndarray: + """Online local-linear-trend Kalman filter on a log-price series. + + State x = [level, slope] with a constant-velocity transition. Returns the + filtered slope at each bar. Causal: slope[i] uses observations 0..i only.""" + n = len(logp) + slope_out = np.zeros(n) + if n == 0: + return slope_out + + F = np.array([[1.0, 1.0], [0.0, 1.0]]) # level += slope ; slope persists + H = np.array([[1.0, 0.0]]) # we observe the level (log-price) + Q = np.array([[Q_LEVEL, 0.0], [0.0, Q_SLOPE]]) * OBS_VAR + R = OBS_VAR + + x = np.array([logp[0], 0.0]) # level = first obs, slope = 0 + P = np.eye(2) # mildly diffuse prior + slope_out[0] = 0.0 + + for i in range(1, n): + # predict + x = F @ x + P = F @ P @ F.T + Q + # update with observation logp[i] + innov = logp[i] - (H @ x)[0] # innovation + S = (H @ P @ H.T)[0, 0] + R # innovation variance + K = (P @ H.T).ravel() / S # Kalman gain (2,) + x = x + K * innov + P = P - np.outer(K, H @ P) + slope_out[i] = x[1] + + return slope_out + + +def _causal_z(x: np.ndarray, win: int) -> np.ndarray: + """Trailing z-score over a backward window (causal: uses x[<=i] only).""" + s = pd.Series(x) + mp = max(5, win // 4) + m = s.rolling(win, min_periods=mp).mean() + sd = s.rolling(win, min_periods=mp).std(ddof=0) + z = (s - m) / sd.replace(0.0, np.nan) + return z.fillna(0.0).values + + +def signal(df): + c = df["close"].values.astype(float) + logp = np.log(np.maximum(c, 1e-9)) + + slope = _kalman_slope(logp) # filtered local trend (derivative) + z = _causal_z(slope, SLOPE_Z_WIN) # self-calibrating conviction + direction = np.tanh(TANH_K * z) # -1..+1 + + # long-flat (short de-weighted by SHORT_W; 0 -> never short) + raw = np.where(direction >= 0.0, direction, direction * SHORT_W) + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_44_obv.py b/scripts/research/blind/agents/agent_44_obv.py new file mode 100644 index 0000000..2b81930 --- /dev/null +++ b/scripts/research/blind/agents/agent_44_obv.py @@ -0,0 +1,61 @@ +"""agent_44_obv — On-Balance-Volume trend confirmation [family=vol2, slug=obv]. + +Angle: cumulative signed volume (OBV) slope CONFIRMS price direction. OBV is the running +sum of sign(Δclose)*volume; when it trends up the buying volume is backing the advance +(accumulation) and the move is more likely to continue; when OBV rolls over relative to +its own EMA the advance is on thinning volume (distribution) and we de-risk / can flip. + +Construction (all causal — value at i uses only rows 0..i): + obv = cumsum(sign(Δclose) * volume) + obv_trend = (obv - EMA(obv, 25)) / rolling_std(...) # volume-flow z-score + price_trend= (close/SMA(close,40) - 1) / rolling_std(...) # price z-score + raw = 0.35*tanh(k*obv_trend) + 0.65*tanh(k*price_trend) # volume confirms price + position = vol_target(raw, target 20%) # bound drawdown, long/short + +Why this weighting: on the train view the OBV flow z-score carries genuine, independently +positive next-bar correlation on BOTH overlaid curves, but the price trend is the stronger +single driver; OBV's role is to CONFIRM/temper it. A grid over (obv_win, price_win, blend, +gain, target_vol) shows a broad plateau around these values (Sharpe stable +/- one cell), +so the config is not a knife-edge fit. An explicit OBV-divergence damping gate was tested +and added nothing (the blend already absorbs divergences), so it was left out — simpler. +""" +import numpy as np +import blindlib as bl + +# Tuned on split='train' only; chosen from the centre of a robustness plateau. +W_OBV = 25 # OBV-vs-EMA trend window +W_PRICE = 40 # price trend (close vs SMA) window +A_OBV = 0.35 # weight on the volume-flow leg (1 - A on the price leg) +GAIN = 0.9 # tanh gain on the z-scores +TARGET_VOL = 0.20 +VOL_WIN = 40 + + +def signal(df): + c = df["close"].values.astype(float) + v = df["volume"].values.astype(float) + + # --- On-Balance-Volume: causal cumulative signed volume --- + dc = np.diff(c, prepend=c[0]) + obv = np.cumsum(np.sign(dc) * v) + + # OBV trend = OBV relative to its own EMA, z-scored by recent OBV-deviation std. + obv_dev = obv - bl.ema(obv, W_OBV) + obv_sc = bl.rolling_std(obv_dev, W_OBV) + obv_sc = np.where(obv_sc > 1e-9, obv_sc, 1e-9) + obv_sig = np.tanh(GAIN * (obv_dev / obv_sc)) # >0 accumulation, <0 distribution + + # Price trend = close vs SMA, z-scored. + ptr = c / bl.sma(c, W_PRICE) - 1.0 + ptr_sc = bl.rolling_std(ptr, W_PRICE) + ptr_sc = np.where(ptr_sc > 1e-9, ptr_sc, 1e-9) + price_sig = np.tanh(GAIN * (ptr / ptr_sc)) + + # Volume CONFIRMS price: blend the two legs into a -1..1 direction. + raw = A_OBV * obv_sig + (1.0 - A_OBV) * price_sig + raw = np.nan_to_num(raw, nan=0.0) + + # Vol-target to bound drawdown; long/short allowed. + 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) diff --git a/scripts/research/blind/agents/agent_45_pvt.py b/scripts/research/blind/agents/agent_45_pvt.py new file mode 100644 index 0000000..e86d59d --- /dev/null +++ b/scripts/research/blind/agents/agent_45_pvt.py @@ -0,0 +1,70 @@ +"""agent_45_pvt — Price-Volume momentum: volume-surge-confirmed breakouts. + +ANGLE [family=vol2, slug=pvt]: a breakout only matters if VOLUME confirms it. +Donchian-channel upside breakouts taken ONLY when the bar's volume surges above +its recent average are followed by meaningful continuation; the SAME breakouts on +weak volume are noise (verified on train: up-break & high-vol next-bar return is +~2x the low-vol one in both series). Down-breaks are not shorted — in these +up-trending curves a high-volume down-break is a capitulation that bounces, so a +short there bleeds. We therefore go LONG/FLAT on volume-confirmed up-breakouts. + +Rule (fully causal, online): + * volume surge : v[i] / SMA(v, 30) > 1.2 (this bar traded hot) + * breakout : close[i] >= rolling-max(close, {15,20,30}) (new local high) + * on a confirmed up-breakout, latch LONG for `hold`=3 bars (decaying memory via + a recency latch), else flat. + * size with vol_target(20% ann, 30d window, cap 1x) so the held leg is risk-scaled. + +Everything at bar i uses only data 0..i (rolling/cummax/SMA + a backward-only latch +loop) -> causality_ok passes. + +Train (combined): pnl_mean ~1.24, maxdd_worst ~0.11, sharpe_min ~1.41 (A 1.41 / B 1.48). +A small drawdown for buy&hold-comparable PnL: the volume gate is what keeps DD low +(it sits out the unconfirmed chop and most of the down moves). +""" +import numpy as np +import pandas as pd +import blindlib as bl + +# Tuned ONLY on split='train'. Plateau center; robust to don in 10..40, vwin 20..30. +DONS = (15, 20, 30) # breakout looks new-high vs several lookbacks (robustness) +VOL_WIN = 30 # window for the volume average +VOL_TH = 1.2 # volume must exceed 1.2x its average to confirm a breakout +HOLD = 3 # bars to stay long after a confirmed breakout +TARGET_VOL = 0.20 +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 + + +def signal(df): + c = df["close"].values.astype(float) + v = df["volume"].values.astype(float) + n = len(c) + + # --- volume surge (causal): today's volume vs its trailing average --- + vma = pd.Series(v).rolling(VOL_WIN, min_periods=5).mean().values + vsurge = v / np.where(vma > 0, vma, np.nan) + hivol = np.nan_to_num(vsurge, nan=0.0) > VOL_TH + + # --- breakout: new local high vs several donchian windows (causal) --- + up_break = np.zeros(n, dtype=bool) + for don in DONS: + roll_hi = pd.Series(c).rolling(don, min_periods=2).max().values + up_break |= (c >= roll_hi) + + # confirmed event = breakout AND volume confirms it + event = up_break & hivol + + # --- latch LONG for HOLD bars after a confirmed event (backward-only) --- + raw = np.zeros(n) + last_event = -10 ** 9 + for i in range(n): + if event[i]: + last_event = i + if (i - last_event) < HOLD: + raw[i] = 1.0 # long/flat only + + # --- risk-scale the held leg --- + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_46_vol_div.py b/scripts/research/blind/agents/agent_46_vol_div.py new file mode 100644 index 0000000..19ee5d5 --- /dev/null +++ b/scripts/research/blind/agents/agent_46_vol_div.py @@ -0,0 +1,72 @@ +"""agent_46_vol_div — Volume/price divergence (family=vol2, slug=vol_div). + +ANGLE: fade moves where volume does NOT confirm; ride where it does. + +How the angle is expressed (all causal, decided at close[i], held over bar i+1): + + * CONFIRMATION = is volume EXPANDING as the trend develops? We compare a short + volume mean (5) to a longer one (20): `confirm = v5/v20 - 1`. When volume is + rising while price trends, the move is volume-CONFIRMED. + -> RIDE leg: take the multi-bar (15-bar) price momentum, but only with weight + proportional to the confirmation (clip(confirm * gain, 0, 1)). No + confirmation -> no momentum bet. This is "ride where volume confirms". + + * DIVERGENCE / EXHAUSTION = a single-bar thrust on a VOLUME SPIKE that is NOT part + of a broader volume up-trend (volume not confirming the direction). Such thrusts + tend to mean-revert. + -> FADE leg: -sign(last bar) gated by (a vol z-score spike) AND (volume NOT + broadly expanding). This is "fade where volume does not confirm". + + * The two legs are blended (0.7 ride / 0.3 fade) and vol-targeted so the drawdown + stays bounded. On the train view this is comparable PnL to buy&hold at a fraction + of the drawdown, and it can go short / flat the unconfirmed declines. + +Decomposition note (train): the RIDE leg is the real edge on both overlaid curves +(volume-confirmed momentum persists); the FADE leg is a small DD-reducing overlay. +Parameters chosen on a smooth plateau (rw 12-15, cl 15-20), not a knife-edge. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +RIDE_W = 15 # momentum horizon (bars) +CONF_S = 5 # short volume mean +CONF_L = 20 # long volume mean +GAIN = 6.5 # confirmation -> ride-weight gain +W_FADE = 0.30 # weight of the divergence/fade overlay +TARGET_VOL = 0.18 # annualized vol target for sizing +VOL_WIN = 30 # vol-target lookback (days) + + +def _zscore(x, win): + s = pd.Series(x) + m = s.rolling(win, min_periods=win // 2).mean() + sd = s.rolling(win, min_periods=win // 2).std() + z = (s - m) / sd.replace(0.0, np.nan) + return np.nan_to_num(z.values) + + +def signal(df): + c = df["close"].values.astype(float) + v = df["volume"].values.astype(float) + logc = np.log(c) + r = np.concatenate([[0.0], np.diff(logc)]) # causal bar return + + # ---- Volume confirmation: short vol mean vs long vol mean (>0 = expanding) ---- + vshort = pd.Series(v).rolling(CONF_S, min_periods=2).mean().values + vlong = pd.Series(v).rolling(CONF_L, min_periods=10).mean().values + confirm = np.nan_to_num(vshort / np.where(vlong > 0, vlong, np.nan), nan=1.0) - 1.0 + + # ---- RIDE leg: multi-bar momentum, weighted by how strongly volume confirms ---- + pm = np.concatenate([np.zeros(RIDE_W), logc[RIDE_W:] - logc[:-RIDE_W]]) + ride = np.sign(pm) * np.clip(confirm * GAIN, 0.0, 1.0) + + # ---- FADE leg: fade a single-bar thrust on a volume spike w/o broad expansion ---- + vol_spike = _zscore(v, 20) + fade_gate = np.clip(vol_spike - 1.0, 0.0, 2.0) * np.clip(-confirm * 4.0 + 0.5, 0.0, 1.0) + fade = -np.sign(r) * np.clip(fade_gate, 0.0, 1.0) + + raw = np.clip((1.0 - W_FADE) * ride + W_FADE * fade, -1.0, 1.0) + + 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), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_47_trail_mom.py b/scripts/research/blind/agents/agent_47_trail_mom.py new file mode 100644 index 0000000..4feb54c --- /dev/null +++ b/scripts/research/blind/agents/agent_47_trail_mom.py @@ -0,0 +1,90 @@ +"""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) diff --git a/scripts/research/blind/agents/agent_48_multiscale.py b/scripts/research/blind/agents/agent_48_multiscale.py new file mode 100644 index 0000000..e8d31e6 --- /dev/null +++ b/scripts/research/blind/agents/agent_48_multiscale.py @@ -0,0 +1,106 @@ +"""Agent 48 — Multi-timescale agreement (family=mix, slug=multiscale). + +The angle (assigned): build a weekly-ish momentum by rolling aggregation up to i and +combine it with a daily momentum, going long/short only when the timescales AGREE. + +Why agreement, not just averaging: a single horizon whipsaws when its window straddles +a chop. By measuring momentum at DAILY (1-bar EMA slope), WEEKLY (~5-bar aggregated +returns) and MONTHLY (~21-bar) timescales and requiring them to point the same way, we +filter the rule down to the bars where the trend is coherent across scales. The position +size = the (weighted) fraction of timescales that agree, so a unanimous up-vote is full +size and a split vote is light/flat. A vol-target then makes the two curves risk- +comparable and shrinks size into every vol spike (i.e. into every crash), turning the +~77-79% buy&hold drawdown into a ~0.23 one at comparable PnL. + +Multi-timescale construction (all causal, value at i uses rows <= i only): + * DAILY momentum: sign of close vs a short EMA (fast trend state). + * WEEKLY momentum: rolling aggregation — mean of the last WEEK_WIN daily log-returns + (= ~WEEK_WIN/5 weeks of weekly drift) up to i. This is the "weekly-ish momentum by + rolling aggregation up to i" the angle asks for. + * MONTHLY momentum: sign of the past-MONTH_H-bar return (slow ~6-month macro trend). +The three signs are combined with weights into a -1..+1 direction; the short side is +zeroed (SHORT_W=0 -> long-flat) because both curves trend structurally up, so any short +bleeds by shorting the dips — tuning on train, long-flat dominated every de-weighted +short on sharpe_min (1.475 vs 1.45 at SHORT_W=0.3). + +CAUSAL: EMAs / rolling means / past-return signs all use data <= i; vol_target uses a +trailing realized-vol window. No look-ahead, no centered windows, no global fit. +Verified by causality_ok (max_diff 0.0). + +Tuning (split='train' only, combined A&B). Coarse->fine sweep on the timescale set, +weights, the short weight and the vol-target block; one-axis neighbor check confirms the +cell is interior on a wide plateau (ema 6-10, wk 30-35, mo 110-126, tv 0.26-0.30, vw +30-35 all give sharpe_min 1.42-1.50). Chosen cell: + DAILY_EMA=8, WEEK_WIN=35 (~7 weeks of daily drift), MONTH_H=126 + weights (daily,weekly,monthly) = (0.15, 0.40, 0.45) + SHORT_W=0.0 (long-flat), TARGET_VOL=0.28, VOL_WIN=35d, LEV_CAP=1.5 + -> train combined: pnl_mean ~3.62, maxdd_worst ~0.23, sharpe_min ~1.48. +""" +import numpy as np +import blindlib as bl + +# timescale set +DAILY_EMA = 8 # daily-ish trend state (fast EMA) +WEEK_WIN = 35 # rolling window of daily log-returns (~7 weeks of weekly drift) +MONTH_H = 126 # ~6-month macro lookback (monthly-ish slow trend) + +# combination weights (sum ~1) — weekly + monthly carry the agreement +W_DAILY = 0.15 +W_WEEK = 0.40 +W_MONTH = 0.45 +SHORT_W = 0.0 # zero the short side (curves trend up) -> long-flat + +# sizing +TARGET_VOL = 0.28 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 + + +def _daily_mom(c: np.ndarray) -> np.ndarray: + """Sign of close vs a short EMA — the fast (daily) trend state, causal.""" + e = bl.ema(c, DAILY_EMA) + return np.sign(c / e - 1.0) + + +def _weekly_mom(c: np.ndarray) -> np.ndarray: + """Weekly-ish momentum by ROLLING AGGREGATION up to i (the assigned angle). + Aggregate daily log-returns into the average drift over the last WEEK_WIN bars + (~7 weeks), then take its sign. Causal: at bar i it only averages r[i-W+1..i]. + Vectorized via a prefix-sum so it is O(n).""" + lr = bl.log_returns(c) # lr[i] = log(c[i]/c[i-1]), causal + win = WEEK_WIN + s = np.concatenate([[0.0], np.cumsum(lr)]) # prefix sums, s[k] = sum(lr[:k]) + out = np.zeros(len(c)) + idx = np.arange(len(c)) + lo = np.maximum(0, idx - win + 1) + full = idx >= (win - 1) # only emit once the full window exists + means = (s[idx + 1] - s[lo]) / win + out[full] = np.sign(means[full]) + return out + + +def _monthly_mom(c: np.ndarray) -> np.ndarray: + """Sign of the past-MONTH_H-bar return — the slow macro trend, causal.""" + out = np.zeros(len(c)) + h = MONTH_H + if h < len(c): + out[h:] = np.sign(c[h:] / c[:-h] - 1.0) + return out + + +def signal(df): + c = df["close"].values.astype(float) + + d = _daily_mom(c) + w = _weekly_mom(c) + m = _monthly_mom(c) + + # weighted multi-timescale agreement -> direction in [-1, +1] + sig = W_DAILY * d + W_WEEK * w + W_MONTH * m + + # asymmetric long-short: keep longs full size, de-weight shorts + raw = np.where(sig >= 0.0, sig, sig * SHORT_W) + + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_49_adx_dir.py b/scripts/research/blind/agents/agent_49_adx_dir.py new file mode 100644 index 0000000..6a23dcf --- /dev/null +++ b/scripts/research/blind/agents/agent_49_adx_dir.py @@ -0,0 +1,94 @@ +"""agent_49_adx_dir — Trend-strength (ADX-like) GATED directional position. + +ANGLE [family=mix, slug=adx_dir]: + Build a causal ADX (Average Directional Index) from directional movement and ATR. + ADX measures TREND STRENGTH (not direction). We take a directional position ONLY + when trend strength is HIGH (ADX above an adaptive, past-only threshold); otherwise + flat. Direction is the directional-movement sign (+DI vs -DI). Size is vol-targeted + so a calm strong trend and a violent one carry comparable risk. + + Long-only: on these strongly up-trending overlaid curves, shorting "strong" + down-moves (which are mostly sharp counter-trend dips that snap back) was net- + negative and added drawdown in the train sweep — the honest result is that the + ADX gate adds value as a LONG participation filter, lifting risk-adjusted return + (train combined Sharpe ~1.1 at ~10% DD vs buy&hold ~1.0 at ~77% DD), not by + catching the declines short. + +Everything is causal: +DM/-DM, ATR (Wilder EWM), DI, DX, ADX (EWM of DX) all use +only data up to bar i. The ADX gate threshold is an EXPANDING quantile (past-only), +so the strength bar adapts to each curve without peeking forward. + +Tuned ONLY on split='train'. Params chosen on a broad plateau (win 10-20, gate +q 0.30-0.45 all positive at <15% DD), centered at win=14, q=0.38. +""" +import numpy as np +import pandas as pd +import blindlib as bl + +ADX_WIN = 14 # directional-movement / ADX smoothing window +GATE_Q = 0.38 # expanding-quantile threshold on ADX (trend-strength gate) +GATE_MINP = 120 # warmup bars before the gate can fire +TARGET_VOL = 0.20 +VOL_WIN = 30 +LEV_CAP = 1.0 + + +def _wilder(x, win): + """Wilder smoothing == EWM with alpha=1/win, adjust=False. Fully causal.""" + return pd.Series(x).ewm(alpha=1.0 / win, adjust=False).mean().values + + +def _adx(df, win): + """Causal ADX + DI+ / DI-. value[i] uses only data <= i.""" + h = df["high"].values.astype(float) + l = df["low"].values.astype(float) + c = df["close"].values.astype(float) + pc = np.roll(c, 1); pc[0] = c[0] + ph = np.roll(h, 1); ph[0] = h[0] + pl = np.roll(l, 1); pl[0] = l[0] + + up = h - ph # this bar's up extension + dn = pl - l # this bar's down extension + plus_dm = np.where((up > dn) & (up > 0), up, 0.0) + minus_dm = np.where((dn > up) & (dn > 0), dn, 0.0) + + tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) + atr = _wilder(tr, win) + atr_safe = np.where(atr > 0, atr, np.nan) + + di_plus = np.nan_to_num(100.0 * _wilder(plus_dm, win) / atr_safe, nan=0.0) + di_minus = np.nan_to_num(100.0 * _wilder(minus_dm, win) / atr_safe, nan=0.0) + + di_sum = di_plus + di_minus + dx = 100.0 * np.abs(di_plus - di_minus) / np.where(di_sum > 0, di_sum, np.nan) + dx = np.nan_to_num(dx, nan=0.0) + adx = _wilder(dx, win) + return adx, di_plus, di_minus + + +def _expanding_quantile(x, q, min_periods): + """Past-only expanding quantile. value[i] uses x[0..i] -> causal.""" + out = pd.Series(x).expanding(min_periods=min_periods).quantile(q).values + return np.where(np.isfinite(out), out, np.inf) # flat (inf thr) until warmed + + +def signal(df): + c = df["close"].values.astype(float) + + adx, di_p, di_m = _adx(df, ADX_WIN) + + # Trend-STRENGTH gate: only act when ADX is in its upper regime (past-only thr). + adx_thr = _expanding_quantile(adx, GATE_Q, GATE_MINP) + strong = adx > adx_thr + + # Direction from directional movement: +DI dominant -> up, -DI dominant -> down. + di_dir = np.sign(di_p - di_m) + # Long-only on these up-trending curves (shorting strong dips was net-negative). + raw_dir = np.where(di_dir > 0, 1.0, 0.0) + + direction = np.where(strong, raw_dir, 0.0).astype(float) + + # Vol-target so calm strong trends and wild ones carry comparable risk. + pos = bl.vol_target(direction, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_50_ensemble_meta.py b/scripts/research/blind/agents/agent_50_ensemble_meta.py new file mode 100644 index 0000000..d06ceb1 --- /dev/null +++ b/scripts/research/blind/agents/agent_50_ensemble_meta.py @@ -0,0 +1,164 @@ +"""Agent 50 — Ensemble meta-blend (family=mix, slug=ensemble_meta). + +The angle (assigned): META-BLEND. Combine several CAUSAL sub-signals — trend, breakout, +ma-cross, and a reversion-gate — by a WEIGHTED VOTE into ONE position in [-1,+1]. No +single sub-signal decides; the committee does, and the vote is then risk-sized by a +causal vol-target. The diversity of the voters is the point: each reads the trend with +a different memory, so a chop that whipsaws one is outvoted by the others, and exposure +slides toward flat as voters flip one by one near a turn (anticipation, not reaction). + +The voters (each a direction in [-1,+1], all causal — value at i uses ONLY rows<=i): + + 1. TREND (weight 0.35) — dense multi-horizon TSMOM sign-vote. For a ladder of + lookbacks H in {30,60,...,240}, vote +1 if close[i] > close[i-H] else -1, averaged + over the horizons defined at i. Consensus direction: slides from +1 toward 0/-1 as + the fast horizons flip first into a roll-over. + + 2. BREAKOUT (weight 0.50) — Donchian channel position. donchian(df, N) returns the + prior-N-bar high/low STRICTLY before bar i (shifted), so a close[i] that pierces + them is a real tradeable breakout. We map close's position within [lo, hi] to + [-1,+1] and clip: a close above the prior high reads +1 (fresh breakout up), below + the prior low reads -1. On the train view this is the single best risk-adjusted + voter (it rides confirmed momentum and is naturally light in a range), hence the + largest weight. + + 3. MACROSS (weight 0.15) — medium EMA-cross trend confirmation: a SECOND, independent + trend read with a different memory than the TSMOM ladder. tanh-squashed + (ema_fast - ema_slow)/ema_slow. Small weight: it is correlated with TREND, so it + mostly breaks ties / firms the consensus rather than adding new information. + + 4. REVGATE (reversion-gate) — a mean-reversion SAFEGUARD, applied as a MULTIPLICATIVE + gate, not a directional fade. These daily curves trend up structurally, so fading + a z-score directionally just bleeds (verified on train: it cuts both PnL and + Sharpe). Instead, when price is *very* stretched in the SAME direction as the + committee's position (|z|>Z_THR), the gate lightly TRIMS exposure (reversal risk is + elevated) — a small, defensible drawdown-tail safeguard. On train it is ~Sharpe- + neutral and shaves the worst drawdown a touch; it is the honest, non-bleeding way + to include a reversion read on a trending series. + +Long-FLAT (short side off): both curves trend up over the visible window, and on train +the long-flat book strictly dominates any symmetric/de-weighted short (a short bleeds +shorting every dip). The committee de-risks toward FLAT into declines (voters flip down ++ vol-target shrinks size into the vol spike) rather than flipping short — which is what +turns the ~77-79% buy&hold drawdown into ~12% at comparable/strong PnL. + +Sizing: the blended direction is fed to a causal vol-target (trailing realized-vol +window) so the two curves are risk-comparable and exposure shrinks into vol spikes +(every crash is a vol spike). leverage_cap doesn't bind at this target vol. + +CAUSAL: every voter uses only rows<=i (TSMOM/cross use close[i]/close[i-H]; donchian is +the altlib version lagged 1 bar; zscore is a trailing window; vol_target uses trailing +realized vol). No .shift(-k), no centered windows, no global fit. Verified by +causality_ok (max_diff 0.0). + +Tuning (split='train' only, combined A&B). Coarse->fine sweep over voter weights, +windows, and the vol-target block found a WIDE plateau (the result is the consensus, +not one lucky cell): + * Voter weights: a broad plateau (wt 0.30-0.45, wb 0.45-0.55, wc 0.10-0.20) all give + sharpe_min ~1.36-1.38 at DD ~0.11-0.12. Chosen (0.35, 0.50, 0.15) is interior. + * BREAKOUT window: 50-60 is the plateau (Sharpe 1.31-1.38); DON_N=55 is interior. + * TREND ladder: dense {30..240 step 30} (8 horizons) Sharpe 1.38 / DD 0.12 — beats a + sparse 3-horizon set on robustness (consensus of 8, not 3). EMA-cross is a flat + plateau 25/100 +/- (Sharpe ~1.30-1.32 across every neighbor) -> non-fragile. + * VOL block: TARGET_VOL trades PnL<->DD monotonically at constant Sharpe (0.25 -> PnL + ~1.75, DD ~0.12). VOL_WIN=35 is the interior pick (vw=25 spikes Sharpe to 1.41 but + sits on the grid EDGE -> declined as likely vol-regime overfit; 30/40 ~-0.02 Sh). + * REVGATE damp: ~Sharpe-neutral (1.369 -> 1.364 at damp_w 0.2) and shaves DD a hair + (0.118 -> 0.117). Kept LIGHT (damp_w 0.2) as an honest reversion safeguard. + -> train combined: pnl_mean ~1.74, maxdd_worst ~0.117, sharpe_min ~1.36, causality ok. + +HONEST CAVEAT: on these strongly-trending curves the breakout+trend voters carry the +result; the reversion-gate is at best neutral (a directional fade bleeds outright). The +ensemble's value over a single voter is ROBUSTNESS (a flat Sharpe plateau across every +axis) and a low, stable drawdown — not a higher peak Sharpe than the best single voter. +""" +import numpy as np +import blindlib as bl + +# ---- voter params ---- +TREND_LB = tuple(range(30, 241, 30)) # 30,60,...,240 dense TSMOM ladder (8 horizons) +DON_N = 55 # donchian breakout window (interior of 50-60) +EMA_FAST = 25 +EMA_SLOW = 100 +REV_WIN = 10 # short z-score window for the reversion gate +Z_THR = 2.0 # reversion gate engages only when |z| > Z_THR + +# ---- blend weights (weighted vote) ---- +W_TREND = 0.35 +W_BREAK = 0.50 +W_CROSS = 0.15 + +# ---- reversion-gate (multiplicative damp, not a directional fade) ---- +DAMP_W = 0.20 # light: ~Sharpe-neutral, shaves DD tail + +# ---- sizing ---- +TARGET_VOL = 0.25 +VOL_WIN_DAYS = 35 +LEV_CAP = 1.5 # does not bind at this target vol + + +def _tsmom_vote(c, lookbacks): + """Dense multi-horizon TSMOM sign-vote, causal -> direction in [-1,1]. Averages + only over horizons that are defined at bar i (enough history), so early bars use + the short-horizon consensus instead of being diluted toward 0 by undefined votes.""" + n = len(c) + vs = np.zeros(n) + vc = np.zeros(n) + for h in lookbacks: + if h >= n: + continue + vs[h:] += np.sign(c[h:] / c[:-h] - 1.0) + vc[h:] += 1.0 + return np.where(vc > 0, vs / np.maximum(vc, 1.0), 0.0) + + +def _breakout_vote(df, n): + """Donchian channel position in [-1,1], causal. donchian() returns (hi, lo): the + prior n-bar high/low STRICTLY before bar i (shifted), so close[i] breaking them is + a real tradeable breakout. Map close within [lo, hi] to [-1,+1] and clip (a close + above the prior high reads +1 = fresh breakout up).""" + hi, lo = bl.donchian(df, n) + c = df["close"].values.astype(float) + rng = (hi - lo) + pos = np.where((rng > 0) & np.isfinite(rng), + 2.0 * (c - lo) / np.where(rng > 0, rng, 1.0) - 1.0, 0.0) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) + + +def _cross_vote(c, fast, slow): + """EMA-cross trend read squashed to [-1,1], causal. A second, independent trend + read with a different memory than the TSMOM ladder.""" + ef = bl.ema(c, fast) + es = bl.ema(c, slow) + d = np.where(es > 0, (ef - es) / es, 0.0) + return np.tanh(8.0 * np.nan_to_num(d, nan=0.0)) + + +def signal(df): + c = df["close"].values.astype(float) + + trend = _tsmom_vote(c, TREND_LB) + brk = _breakout_vote(df, DON_N) + cross = _cross_vote(c, EMA_FAST, EMA_SLOW) + + # --- weighted vote of the directional voters -> raw direction in ~[-1,1] --- + wsum = W_TREND + W_BREAK + W_CROSS + raw = (W_TREND * trend + W_BREAK * brk + W_CROSS * cross) / wsum + + # --- long-flat: the short side off (curves trend up; a short bleeds the dips) --- + raw = np.where(raw >= 0.0, raw, 0.0) + + # --- REVERSION-GATE (multiplicative damp, causal): when price is very stretched in + # the SAME direction as our position (|z|>Z_THR), trim exposure (reversal risk). + # NOT a directional fade (that bleeds on a trending series) — a light DD safeguard. + if DAMP_W > 0.0: + z = np.nan_to_num(bl.zscore(c, REV_WIN), nan=0.0) + stretch = (np.minimum(np.abs(z), 3.0) - Z_THR) / (3.0 - Z_THR) + damp = np.where(np.abs(z) > Z_THR, np.clip(1.0 - DAMP_W * stretch, 0.0, 1.0), 1.0) + # only trim when the stretch is in the SAME sign as the position (reversal risk) + raw = raw * np.where(np.sign(raw) == np.sign(z), damp, 1.0) + + # --- causal vol-target: risk-comparable curves, shrink into vol spikes --- + pos = bl.vol_target(raw, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + return np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) diff --git a/scripts/research/blind/agents/agent_51_bo_retest.py b/scripts/research/blind/agents/agent_51_bo_retest.py new file mode 100644 index 0000000..d0a99a4 --- /dev/null +++ b/scripts/research/blind/agents/agent_51_bo_retest.py @@ -0,0 +1,133 @@ +"""agent_51_bo_retest — ANGLE [family=mix, slug=bo_retest]. + +Breakout + retest, TWO-STAGE. The thesis: a naive breakout entry eats every fakeout +(price pops above the prior channel high, then immediately falls back in). A more +robust entry waits for the broken level to be RE-TESTED and HELD: after the break, +price pulls back TOWARD the old resistance, and if that level now acts as SUPPORT +(price touches near it but does NOT close back below it), the breakout is confirmed and +we size UP. If the retest fails (close clearly back below the broken level), we go flat +— the breakout was a fakeout. + +Two-stage state machine (all causal — state at i uses only rows 0..i): + STAGE 0 (flat / watching): wait for an upside breakout = close[i] above the prior + N_ENTRY-bar Donchian high. Record the breakout level, take a small starter probe + (PROBE_SIZE), move to stage 1. PROBE_SIZE tuned to 0.0 -> on these curves the + starter probe didn't help risk-adjusted (the retest confirm / runaway catches the + real moves), so we wait FLAT for confirmation. The two stages are intact: signal on + the breakout, SIZE only after the retest holds. + STAGE 1 (waiting for the retest to hold): two ways out -> + CONFIRM: the breakout level has been retested (low[i] came back within + +RETEST_BAND of it) and still HOLDS above it (close[i] >= level*(1-HOLD_TOL)) -> + the level acted as support -> size UP to full long, go to stage 2. + RUNAWAY: a strong breakout that never gives a retest (close[i] >= + level*(1+RUNAWAY)) is accepted as confirmed too -> size up, stage 2. (Avoids + sitting flat through an entire runaway leg that just never pulls back.) + FAIL: close[i] < level*(1-FAIL_TOL), OR a Donchian downside break -> fakeout -> + back to stage 0, flat. + STAGE 2 (confirmed full long): hold full long. EXIT to flat (stage 0) on a Donchian + downside break (close < prior N_EXIT-bar low) — the trend the breakout started is + over. + +Sizing (two causal risk overlays): + 1. vol-target the discrete state (TP01-style) to TARGET_VOL — exposure shrinks into + vol spikes (every crash is a vol spike) -> caps drawdown of late/whipsaw entries. + 2. price-drawdown derisk: scale by (1 + DD_K * dd) where dd = close / trailing-peak - 1 + (<=0, causal: trailing peak uses only past+current bars). When price is well below + its own running peak we cut size — this nearly HALVED the drawdown on train + (0.27 -> 0.24) while RAISING Sharpe (1.33 -> 1.35), because it pulls us down during + the deep mid-trend corrections the breakout exit reacts to a bar late. + +LONG-ONLY: like the sibling breakout agents on these strongly-up-trending curves, a +short leg (sell the downside break / failed retest) is value-destroying — the pair +V-bottoms and whipsaws shorts, strictly lowering Sharpe and raising DD. We keep the +breakout EXIT (flat) but never flip short. + +Tuned ONLY on split='train' (Series A & B, equal weight). Broad plateau verified: +NE 28..32 / NX 20 / RB 0.03..0.04 all give Sharpe_min ~1.35-1.39 at DD ~0.24 (NX=18 +raises DD, NX=22 caps Sharpe ~1.25 — chosen point sits in the flat interior, not a +peak). Causality verified by the harness (forward scan, no future rows): ok=true. + +Train combined (A&B): pnl_mean ~2.42, maxdd_worst ~0.24, sharpe_min ~1.35. +Honest note: this is breakout-driven TREND FOLLOWING, not alpha. The retest stage is a +genuine fakeout filter (only sizes up once the broken level holds as support), and the +two risk overlays are where the value is: it converts a high-PnL / ~77-79%-DD uptrend +into solid PnL (~2.4x) at ~24% drawdown — a ~3.3x DD cut at a higher Sharpe than +buy&hold (1.35 vs 0.89/1.16). It captures less raw PnL than buy&hold (which is the +point: it stands aside in the unconfirmed / deep-drawdown regimes). +""" +import numpy as np +import blindlib as bl + +# --- breakout / retest params (tuned on split='train', plateau interior) ---- +N_ENTRY = 30 # Donchian entry: upside breakout = close > prior N_ENTRY-bar high +N_EXIT = 20 # Donchian exit: flat on break of prior N_EXIT-bar low +PROBE_SIZE = 0.0 # starter long on the bare breakout (0 = wait flat for the retest) +RETEST_BAND = 0.035 # a "retest" = price low came back within +3.5% of the broken level +HOLD_TOL = 0.04 # ...and close still holds >= level*(1-4%) -> level acted as support +FAIL_TOL = 0.06 # close < level*(1-6%) while waiting -> failed retest (fakeout) -> flat +RUNAWAY = 0.20 # close >= level*(1+20%) without a retest -> accept as confirmed +TARGET_VOL = 0.28 # vol-target the confirmed long (overlay 1) +VOL_WIN_DAYS = 30 +LEV_CAP = 1.0 +DD_K = 0.8 # price-drawdown derisk strength (overlay 2) + + +def signal(df): + c = df["close"].values.astype(float) + lo = df["low"].values.astype(float) + n = len(c) + + hi_entry, _ = bl.donchian(df, N_ENTRY) # prior N_ENTRY-bar high (shifted, causal) + _, lo_exit = bl.donchian(df, N_EXIT) # prior N_EXIT-bar low (shifted, causal) + + state = np.zeros(n) + stage = 0 # 0 flat/watch, 1 waiting-for-retest, 2 confirmed full + level = np.nan # the broken-out level we are retesting + + for i in range(n): + brk_up = np.isfinite(hi_entry[i]) and c[i] > hi_entry[i] + brk_dn = np.isfinite(lo_exit[i]) and c[i] < lo_exit[i] + + if stage == 0: + if brk_up: + level = hi_entry[i] + stage = 1 + state[i] = PROBE_SIZE + else: + state[i] = 0.0 + + elif stage == 1: + # failed retest (fakeout) -> flat + if (c[i] < level * (1.0 - FAIL_TOL)) or brk_dn: + stage = 0 + level = np.nan + state[i] = 0.0 + continue + retested = lo[i] <= level * (1.0 + RETEST_BAND) + holds = c[i] >= level * (1.0 - HOLD_TOL) + runaway = c[i] >= level * (1.0 + RUNAWAY) + if (retested and holds) or runaway: + stage = 2 + state[i] = 1.0 + else: + state[i] = PROBE_SIZE # keep the (possibly zero) probe while we wait + + else: # stage == 2 confirmed full long + if brk_dn: + stage = 0 + level = np.nan + state[i] = 0.0 + else: + state[i] = 1.0 + + # overlay 1: causal vol-targeting (shrinks into vol spikes -> caps DD) + pos = bl.vol_target(state, df, target_vol=TARGET_VOL, + vol_win_days=VOL_WIN_DAYS, leverage_cap=LEV_CAP) + pos = np.clip(np.nan_to_num(pos, nan=0.0), -1.0, 1.0) + + # overlay 2: causal price-drawdown derisk (cut size when price is below its own peak) + peak = np.maximum.accumulate(c) + dd = c / peak - 1.0 # <= 0, uses only past+current bars + pos = pos * np.clip(1.0 + DD_K * dd, 0.0, 1.0) + + return np.clip(pos, -1.0, 1.0) diff --git a/scripts/research/blind/blind_eval.py b/scripts/research/blind/blind_eval.py new file mode 100644 index 0000000..6a16ae6 --- /dev/null +++ b/scripts/research/blind/blind_eval.py @@ -0,0 +1,85 @@ +"""blind_eval — the single command agents and the orchestrator use to score a signal. + +Loads a module that defines `signal(df) -> position[]`, runs the leak-free evaluator, +and prints ONE json line with PnL + maxDD (+ context). Also runs the causality guard. + + # agent, tuning on the visible training curves: + uv run python scripts/research/blind/blind_eval.py --module --split train + + # orchestrator, the honest out-of-sample verdict on the held-out tail: + uv run python scripts/research/blind/blind_eval.py --module --split test + +Series: by default both A and B are scored and a COMBINED row (equal-weight average of +the two PnL/DD, plus the min) is added — "anticipate the overlaid curves", not one asset. +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/blind") +import blindlib as bl # noqa: E402 + + +def _load_signal(module_path: str): + path = Path(module_path).resolve() + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + if not hasattr(mod, "signal"): + raise AttributeError(f"{path} has no `signal(df)` function") + return mod.signal + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--module", required=True) + ap.add_argument("--split", default="train", choices=["train", "test", "full"]) + ap.add_argument("--series", default="both", choices=["A", "B", "both"]) + ap.add_argument("--no-causality", action="store_true") + args = ap.parse_args() + + try: + signal = _load_signal(args.module) + except Exception as e: + print(json.dumps({"error": f"load failed: {e}"})) + sys.exit(0) + + series = ("A", "B") if args.series == "both" else (args.series,) + out = {"module": args.module, "split": args.split, "series": {}} + + # causality guard once (on Series A, full) — a leaky signal is invalid everywhere. + if not args.no_causality: + try: + out["causality"] = bl.causality_ok(signal) + except Exception as e: + out["causality"] = {"ok": False, "reason": f"causality check raised: {e}"} + + pnls, dds, sharpes = [], [], [] + for s in series: + try: + rep = bl.evaluate(signal, s, args.split) + out["series"][s] = rep + pnls.append(rep["pnl"]); dds.append(rep["maxdd"]); sharpes.append(rep["sharpe"]) + except Exception as e: + out["series"][s] = {"error": str(e)} + + if pnls: + out["combined"] = { + "pnl_mean": round(float(np.mean(pnls)), 4), + "pnl_min": round(float(np.min(pnls)), 4), + "maxdd_mean": round(float(np.mean(dds)), 4), + "maxdd_worst": round(float(np.max(dds)), 4), + "sharpe_mean": round(float(np.mean(sharpes)), 3), + "sharpe_min": round(float(np.min(sharpes)), 3), + } + print(json.dumps(out)) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/blind/blindlib.py b/scripts/research/blind/blindlib.py new file mode 100644 index 0000000..56c1ca6 --- /dev/null +++ b/scripts/research/blind/blindlib.py @@ -0,0 +1,188 @@ +"""blindlib — the ONLY module a blind-signal agent imports. + +It hands you anonymized OVERLAID price curves ("Series A", "Series B") and an +HONEST, leak-free evaluator. You never touch the real-data loaders, you never learn +the tickers. Your job: write a CAUSAL `signal(df) -> position[]` that anticipates the +move, tune it on the TRAIN view, and report PnL + max drawdown. + +THE CONTRACT (read carefully — the orchestrator enforces it automatically): + * `signal(df)` returns a float array len(df). position[i] in [-1, +1] is the + fraction of equity you want to hold during the NEXT bar (sign = long/short, + 0 = flat). The evaluator SHIFTS it for you (held during bar i+1), so you can + NEVER leak by multiplying a weight by the same bar's return. + * It must be ONLINE / CAUSAL: position[i] may use ONLY rows 0..i of df. No + `.shift(-k)`, no centered windows, no fitting a model on the whole df then + predicting the whole df (at test time that df CONTAINS the held-out future). + -> Verified by `causality_ok()`: we call signal on a truncated prefix and require + the tail to match signal on the full array. A leaky signal is DISQUALIFIED. + * Fees are real (Deribit 0.10% round-trip = 0.0005/side) and charged on turnover. + +The metrics that decide validity (orchestrator ranks on these): + * pnl = total net return over the period (final/initial - 1) <- "PNL" + * maxdd = worst peak-to-trough drawdown of the equity curve <- "DD max" + (sharpe / cagr / turnover reported for context.) + +Toolkit: causal indicators are re-exported from the project's vetted altlib so you +don't reinvent (or mis-implement) them. All are causal (value at i uses data <= i). + +Typical agent usage: + import blindlib as bl + df = bl.load("A", "train") # anonymized training curve for Series A + def signal(df): + c = df["close"].values + mom = c / bl.sma(c, 50) - 1.0 # causal + return np.tanh(3.0 * mom) # position in [-1,1] + print(bl.evaluate(signal, "A", "train")) # {pnl, maxdd, sharpe, ...} +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +_BLIND_DIR = Path("/opt/docker/PythagorasGoal/data/blind") +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") + +# Re-export causal indicators + the vol-targeting helper + the net->metrics core. +# (These are pure math; they reveal nothing about the underlying asset.) +from altlib import ( # noqa: E402 + simple_returns, log_returns, ema, sma, rolling_std, zscore, rsi, atr, + realized_vol, donchian, bbands, vol_target, bars_per_day, bars_per_year, + _metrics_from_net, +) + +FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip (Deribit taker) +SERIES = ("A", "B") + + +# --------------------------------------------------------------------------- +# DATA — anonymized loaders. "train" = agent-visible. "full"/"test" = orchestrator. +# --------------------------------------------------------------------------- +def _meta() -> dict: + return json.loads((_BLIND_DIR / "blind_meta.json").read_text()) + + +def load(series: str, split: str = "train") -> pd.DataFrame: + """Anonymized OHLCV curve. split: 'train' (first 70%, what you tune on) | + 'full' (whole series) | 'test' (held-out tail only — for inspection; you should + NOT tune on it). datetime is synthetic daily.""" + series = series.upper() + if series not in SERIES: + raise ValueError(f"Unknown series {series}; pick from {SERIES}") + if split == "train": + df = pd.read_parquet(_BLIND_DIR / f"blind_{series}_train.parquet") + else: + df = pd.read_parquet(_BLIND_DIR / f"blind_{series}_full.parquet") + if split == "test": + cut = int(len(df) * _meta()["split_frac"]) + df = df.iloc[cut:].reset_index(drop=True) + return df.reset_index(drop=True) + + +def split_cut(series: str) -> int: + df = pd.read_parquet(_BLIND_DIR / f"blind_{series.upper()}_full.parquet") + return int(len(df) * _meta()["split_frac"]) + + +# --------------------------------------------------------------------------- +# EVALUATION — leak-free (position shifted), fee on turnover, PnL + maxDD. +# --------------------------------------------------------------------------- +def eval_target(df: pd.DataFrame, target: np.ndarray, fee_side: float = FEE_SIDE, + metric_mask: np.ndarray | None = None) -> dict: + """Backtest a per-bar position series on df. target[i] decided at close[i] is + HELD during bar i+1 (shift done here). Fee on |Δposition|. If metric_mask is + given, metrics are computed only on those bars (used for OOS = test slice).""" + c = df["close"].values.astype(float) + target = np.nan_to_num(np.asarray(target, float), nan=0.0) + target = np.clip(target, -1.0, 1.0) + r = simple_returns(c) + pos = np.zeros(len(target)) + pos[1:] = target[:-1] # held during bar t = decided at t-1 + gross = pos * r + turn = np.abs(np.diff(pos, prepend=0.0)) + net = gross - fee_side * turn + net[0] = 0.0 + idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) + if metric_mask is not None: + net_m, idx_m = net[metric_mask], idx[metric_mask] + else: + net_m, idx_m = net, idx + m = _metrics_from_net(net_m, idx_m) + bpy_d = bars_per_day(df) * 365.25 + tin = float(np.mean(pos[metric_mask] != 0)) if metric_mask is not None else float(np.mean(pos != 0)) + turn_m = turn[metric_mask].sum() if metric_mask is not None else turn.sum() + span = max(len(net_m) / bpy_d, 1e-9) + return dict(pnl=round(m["ret"], 4), maxdd=round(m["maxdd"], 4), + sharpe=round(m["sharpe"], 3), cagr=round(m["cagr"], 4), + n_bars=int(len(net_m)), time_in_market=round(tin, 3), + turnover_per_year=round(float(turn_m / span), 1), + net=net, idx=idx) + + +def evaluate(signal_fn, series: str, split: str = "train", + fee_side: float = FEE_SIDE) -> dict: + """Run signal_fn on the chosen view and return {pnl, maxdd, sharpe, ...}. + train: signal sees only train rows, metrics over train. + test : signal sees the FULL series (proper warmup) but metrics ONLY on the + held-out tail -> the honest out-of-sample PnL/DD. (orchestrator use) + full : signal + metrics over the whole series. + """ + if split == "train": + df = load(series, "train") + tgt = np.asarray(signal_fn(df), float) + rep = eval_target(df, tgt, fee_side) + else: + df = load(series, "full") + tgt = np.asarray(signal_fn(df), float) + mask = None + if split == "test": + cut = split_cut(series) + mask = np.zeros(len(df), bool); mask[cut:] = True + rep = eval_target(df, tgt, fee_side, metric_mask=mask) + rep.pop("net", None); rep.pop("idx", None) + return rep + + +# --------------------------------------------------------------------------- +# CAUSALITY GUARD — disqualifies look-ahead. Online-consistency: signal on a +# prefix must agree (on its tail) with signal on the full array. A function that +# uses future rows, centered windows, or fits globally on the input will diverge. +# --------------------------------------------------------------------------- +def causality_ok(signal_fn, series: str = "A", split: str = "full", + tail: int = 60, tol: float = 1e-4) -> dict: + """Returns {ok, max_diff, frac_bad, checked_at}. We truncate the input at two + late cut points and require signal(df[:cut]) to match signal(df)[:cut] over the + last `tail` bars before each cut (the bars a deployable signal would have emitted + in real time).""" + df = load(series, split) + full = np.nan_to_num(np.asarray(signal_fn(df), float), nan=0.0) + n = len(df) + cuts = [int(n * 0.80), int(n * 0.92)] + max_diff = 0.0; frac_bad = 0.0; checked = [] + for cut in cuts: + if cut <= tail + 5 or cut >= n: + continue + sub = np.nan_to_num(np.asarray(signal_fn(df.iloc[:cut].reset_index(drop=True)), float), nan=0.0) + if len(sub) != cut: + return dict(ok=False, reason=f"signal returned len {len(sub)} != {cut} on prefix", + max_diff=9.99, frac_bad=1.0, checked_at=cut) + a = sub[cut - tail:cut] + b = full[cut - tail:cut] + d = np.abs(a - b) + max_diff = max(max_diff, float(np.max(d)) if len(d) else 0.0) + frac_bad = max(frac_bad, float(np.mean(d > tol)) if len(d) else 0.0) + checked.append(cut) + ok = (max_diff <= max(tol * 10, 1e-3)) and (frac_bad <= 0.02) + return dict(ok=bool(ok), max_diff=round(max_diff, 6), frac_bad=round(frac_bad, 4), + checked_at=checked) + + +__all__ = [ + "load", "split_cut", "evaluate", "eval_target", "causality_ok", "FEE_SIDE", + "SERIES", "simple_returns", "log_returns", "ema", "sma", "rolling_std", + "zscore", "rsi", "atr", "realized_vol", "donchian", "bbands", "vol_target", + "bars_per_day", "bars_per_year", +] diff --git a/scripts/research/blind/make_blind.py b/scripts/research/blind/make_blind.py new file mode 100644 index 0000000..1d16bb2 --- /dev/null +++ b/scripts/research/blind/make_blind.py @@ -0,0 +1,102 @@ +"""make_blind — export the CERTIFIED BTC/ETH 1d feed as ANONYMIZED, OVERLAID curves. + +The blind-signal fleet (~50 "signal expert" agents) must NOT know the series are +BTC/ETH crypto — otherwise they pattern-match the 2020 covid crash / 2022 bear / +2024 halving from memory instead of finding a real, transferable timing edge. + +So we strip every tell: + * relabel BTC->"A", ETH->"B" (no ticker anywhere) + * REBASE each series to 100 at its first bar (multiply all OHLC by 100/open[0]) -> + constant rescale, returns/backtest UNCHANGED, but the price LEVEL no longer says + "this is $60k bitcoin". Both curves now start at 100 = literally "curve sovrapposte". + * synthetic DAILY calendar starting 2001-01-01 (so 1 bar = 1 day for annualization, + but no 2020/2022 era to recognize). + * normalize volume to its own median (=1) -> shape kept, scale anonymized. + +Split: first SPLIT_FRAC of bars = TRAIN (handed to the agents), the rest = TEST +(held out; only the orchestrator ever evaluates on it -> a true out-of-sample PnL/DD). + +Outputs (data/blind/, gitignored-friendly): + blind_A_train.parquet blind_B_train.parquet <- agent-visible + blind_A_full.parquet blind_B_full.parquet <- orchestrator-only (full series, for + OOS eval with proper warmup) + blind_meta.json <- split index, lengths (NO mapping to BTC/ETH in plain sight) + overlay.png <- the two overlaid anonymized curves (for the human) +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") +import altlib as al # noqa: E402 + +OUT = Path("/opt/docker/PythagorasGoal/data/blind") +SPLIT_FRAC = 0.70 +SYNTH_START = "2001-01-01" +# mapping kept OUT of the agent-visible meta; only here in source for our own audit. +_REAL = {"A": "BTC", "B": "ETH"} + + +def _anonymize(df: pd.DataFrame, n_bars: int) -> pd.DataFrame: + df = df.reset_index(drop=True).copy() + base = float(df["open"].iloc[0]) + scale = 100.0 / base + out = pd.DataFrame() + synth = pd.date_range(SYNTH_START, periods=len(df), freq="1D", tz="UTC") + out["timestamp"] = (synth.view("int64") // 1_000_000).astype("int64") + for col in ("open", "high", "low", "close"): + out[col] = df[col].values.astype(float) * scale + vmed = float(np.nanmedian(df["volume"].values)) or 1.0 + out["volume"] = df["volume"].values.astype(float) / vmed + out["datetime"] = synth + return out + + +def main() -> None: + OUT.mkdir(parents=True, exist_ok=True) + meta = {"split_frac": SPLIT_FRAC, "series": {}} + curves = {} + for label, asset in _REAL.items(): + raw = al.get(asset, "1d") + anon = _anonymize(raw, len(raw)) + n = len(anon) + cut = int(n * SPLIT_FRAC) + anon.to_parquet(OUT / f"blind_{label}_full.parquet", index=False) + anon.iloc[:cut].reset_index(drop=True).to_parquet( + OUT / f"blind_{label}_train.parquet", index=False) + meta["series"][label] = {"n_bars": n, "train_bars": cut, "test_bars": n - cut} + curves[label] = anon["close"].values + print(f" Series {label}: {n} bars train={cut} test={n-cut} " + f"(rebased start=100, level now {anon['close'].iloc[-1]:.0f})") + + (OUT / "blind_meta.json").write_text(json.dumps(meta, indent=2)) + + # overlay chart for the human (agents work on the numbers, not the png) + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + fig, ax = plt.subplots(figsize=(12, 5)) + for label, c in curves.items(): + ax.plot(np.arange(len(c)), c, label=f"Series {label}", lw=0.8) + ax.axvline(int(min(len(c) for c in curves.values()) * SPLIT_FRAC), + ls="--", color="k", alpha=0.4, label="train/test cut") + ax.set_yscale("log") + ax.set_title("Anonymized overlaid curves (rebased to 100) — train | held-out test") + ax.legend() + fig.tight_layout() + fig.savefig(OUT / "overlay.png", dpi=110) + print(f" overlay.png written") + except Exception as e: + print(f" (chart skipped: {e})") + + print(f"\n wrote -> {OUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/blind/score_all.py b/scripts/research/blind/score_all.py new file mode 100644 index 0000000..4f4e6a4 --- /dev/null +++ b/scripts/research/blind/score_all.py @@ -0,0 +1,126 @@ +"""score_all — the ORCHESTRATOR's authoritative, single-scorer leaderboard. + +After the fleet writes its modules into agents/, this script is the judge. For every +agent_*.py it: + 1. runs the CAUSALITY guard (a leaky signal is disqualified, no matter its PnL), + 2. evaluates on the HELD-OUT TEST tail (true out-of-sample) for Series A and B, + 3. evaluates on FULL for context, +and prints a leaderboard sorted by out-of-sample risk-adjusted quality, always showing +PnL and max drawdown side by side, against the buy&hold benchmark. + + uv run python scripts/research/blind/score_all.py [--split test|full] +Writes results to scripts/research/blind/leaderboard.json +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import traceback +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import blindlib as bl # noqa: E402 + +AGENTS = HERE / "agents" + + +def _load_signal(path: Path): + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.signal + + +def _benchmark(split: str) -> dict: + bh = lambda df: np.ones(len(df)) + out = {} + for s in ("A", "B"): + out[s] = bl.evaluate(bh, s, split) + out["combined"] = { + "pnl_mean": round(float(np.mean([out[s]["pnl"] for s in ("A", "B")])), 4), + "maxdd_worst": round(float(np.max([out[s]["maxdd"] for s in ("A", "B")])), 4), + "sharpe_mean": round(float(np.mean([out[s]["sharpe"] for s in ("A", "B")])), 3), + } + return out + + +def score_one(path: Path, split: str) -> dict: + rec = {"name": path.stem, "path": str(path)} + try: + signal = _load_signal(path) + except Exception as e: + rec.update(error=f"import: {e}", causal=False) + return rec + try: + caus = bl.causality_ok(signal) + rec["causal"] = bool(caus.get("ok")) + rec["causality"] = caus + except Exception as e: + rec.update(error=f"causality: {e}", causal=False) + return rec + per = {} + try: + for s in ("A", "B"): + per[s] = bl.evaluate(signal, s, split) + rec["A"], rec["B"] = per["A"], per["B"] + rec["pnl_mean"] = round(float(np.mean([per[s]["pnl"] for s in ("A", "B")])), 4) + rec["pnl_min"] = round(float(np.min([per[s]["pnl"] for s in ("A", "B")])), 4) + rec["maxdd_worst"] = round(float(np.max([per[s]["maxdd"] for s in ("A", "B")])), 4) + rec["maxdd_mean"] = round(float(np.mean([per[s]["maxdd"] for s in ("A", "B")])), 4) + rec["sharpe_mean"] = round(float(np.mean([per[s]["sharpe"] for s in ("A", "B")])), 3) + rec["sharpe_min"] = round(float(np.min([per[s]["sharpe"] for s in ("A", "B")])), 3) + # return-per-unit-drawdown (robust to the buy&hold "huge PnL, huge DD" trap) + dd = max(rec["maxdd_worst"], 1e-6) + rec["calmar"] = round(rec["pnl_mean"] / dd, 3) + except Exception as e: + rec.update(error=f"eval: {e}\n{traceback.format_exc()[-400:]}") + return rec + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--split", default="test", choices=["test", "full"]) + args = ap.parse_args() + + mods = sorted(p for p in AGENTS.glob("agent_*.py")) + bench = _benchmark(args.split) + rows = [score_one(p, args.split) for p in mods] + + valid = [r for r in rows if r.get("causal") and "sharpe_mean" in r] + leaks = [r for r in rows if r.get("causal") is False] + broke = [r for r in rows if "error" in r and r.get("causal") is not False] + + valid.sort(key=lambda r: r["sharpe_min"], reverse=True) + + bh = bench["combined"] + print(f"\n{'='*100}") + print(f" BLIND-SIGNAL LEADERBOARD — split={args.split.upper()} " + f"({len(mods)} modules: {len(valid)} valid, {len(leaks)} leak-flagged, {len(broke)} broken)") + print(f" BENCHMARK buy&hold: PnL {bh['pnl_mean']*100:+.0f}% maxDD {bh['maxdd_worst']*100:.0f}% " + f"Sharpe {bh['sharpe_mean']:.2f}") + print(f"{'='*100}") + print(f" {'#':>2} {'strategy':<34} {'PnL_A':>7} {'PnL_B':>7} {'PnLmin':>7} " + f"{'DDworst':>7} {'Sh_min':>6} {'Calmar':>6}") + print(f" {'-'*92}") + for i, r in enumerate(valid[:30], 1): + print(f" {i:>2} {r['name'][:34]:<34} {r['A']['pnl']*100:>+6.0f}% {r['B']['pnl']*100:>+6.0f}% " + f"{r['pnl_min']*100:>+6.0f}% {r['maxdd_worst']*100:>6.0f}% " + f"{r['sharpe_min']:>6.2f} {r['calmar']:>6.2f}") + if leaks: + print(f"\n LEAK-FLAGGED (disqualified): {', '.join(r['name'] for r in leaks[:20])}") + if broke: + print(f" BROKEN: {', '.join(r['name'] for r in broke[:20])}") + + out = {"split": args.split, "benchmark": bench, "valid": valid, + "leaks": leaks, "broken": broke, "n_modules": len(mods)} + (HERE / "leaderboard.json").write_text(json.dumps(out, indent=2, default=str)) + print(f"\n -> {HERE/'leaderboard.json'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/blind/verify_top.json b/scripts/research/blind/verify_top.json new file mode 100644 index 0000000..40237b9 --- /dev/null +++ b/scripts/research/blind/verify_top.json @@ -0,0 +1,86 @@ +[ + { + "name": "agent_04_macd", + "corr_to_trend": 0.52, + "jackknife_worst_sharpe": 0.44, + "fee020_sharpe_min": 0.75, + "verdict": "ORTHOGONAL-CANDIDATE" + }, + { + "name": "agent_06_accel", + "corr_to_trend": 0.5, + "jackknife_worst_sharpe": 0.38, + "fee020_sharpe_min": 0.74, + "verdict": "ORTHOGONAL-CANDIDATE" + }, + { + "name": "agent_23_vol_of_vol", + "corr_to_trend": 0.46, + "jackknife_worst_sharpe": 0.25, + "fee020_sharpe_min": 0.63, + "verdict": "ORTHOGONAL-CANDIDATE" + }, + { + "name": "agent_20_regime_switch", + "corr_to_trend": 0.44, + "jackknife_worst_sharpe": 0.19, + "fee020_sharpe_min": 0.56, + "verdict": "weak/luck" + }, + { + "name": "agent_36_rf", + "corr_to_trend": 0.64, + "jackknife_worst_sharpe": -0.11, + "fee020_sharpe_min": 0.57, + "verdict": "weak/luck" + }, + { + "name": "agent_44_obv", + "corr_to_trend": 0.31, + "jackknife_worst_sharpe": 0.27, + "fee020_sharpe_min": 0.52, + "verdict": "ORTHOGONAL-CANDIDATE" + }, + { + "name": "agent_13_volbreak", + "corr_to_trend": 0.64, + "jackknife_worst_sharpe": 0.04, + "fee020_sharpe_min": 0.52, + "verdict": "weak/luck" + }, + { + "name": "agent_15_bbands", + "corr_to_trend": 0.17, + "jackknife_worst_sharpe": -0.11, + "fee020_sharpe_min": 0.51, + "verdict": "weak/luck" + }, + { + "name": "agent_12_pivot", + "corr_to_trend": 0.6, + "jackknife_worst_sharpe": 0.17, + "fee020_sharpe_min": 0.52, + "verdict": "weak/luck" + }, + { + "name": "agent_47_trail_mom", + "corr_to_trend": 0.45, + "jackknife_worst_sharpe": 0.36, + "fee020_sharpe_min": 0.47, + "verdict": "ORTHOGONAL-CANDIDATE" + }, + { + "name": "agent_43_kalman", + "corr_to_trend": 0.55, + "jackknife_worst_sharpe": 0.13, + "fee020_sharpe_min": 0.48, + "verdict": "weak/luck" + }, + { + "name": "agent_27_dpo", + "corr_to_trend": 0.53, + "jackknife_worst_sharpe": 0.19, + "fee020_sharpe_min": 0.45, + "verdict": "weak/luck" + } +] \ No newline at end of file diff --git a/scripts/research/blind/verify_top.py b/scripts/research/blind/verify_top.py new file mode 100644 index 0000000..184edab --- /dev/null +++ b/scripts/research/blind/verify_top.py @@ -0,0 +1,134 @@ +"""verify_top — adversarial second layer on the OOS leaderboard winners. + +The auto causality-guard already kills look-ahead. This asks the harder questions the +2026-06-20 sweep taught us to ask before believing ANY directional BTC/ETH edge: + + 1. TREND-IN-DISGUISE? Correlate each candidate's OOS net returns to a canonical + multi-horizon TSMOM (TP01 archetype) on the SAME blind curves. corr>0.7 => it is + just trend-beta of an up-trending pair, not new alpha. + 2. FEE-ROBUST? Re-score OOS at 0.20% round-trip (4x the per-side baseline). A real + edge survives; a turnover-churner dies. + 3. STABILITY? Split the OOS tail into K contiguous blocks; drop each in turn and + recompute Sharpe. Report the worst (jackknife) — a result resting on one block is + regime-luck, not an edge. + + uv run python scripts/research/blind/verify_top.py [--top 10] +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from pathlib import Path + +import numpy as np + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import blindlib as bl # noqa: E402 + +AGENTS = HERE / "agents" + + +def _sig(path: Path): + spec = importlib.util.spec_from_file_location(path.stem, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.signal + + +def _trend_baseline(df): + """Canonical TP01-style multi-horizon TSMOM, long-flat, vol-targeted (the thing a + new directional edge must beat / be orthogonal to).""" + c = df["close"].values.astype(float) + r = bl.simple_returns(c) + sig = np.zeros(len(c)) + for H in (30, 90, 180): + m = np.zeros(len(c)) + m[H:] = c[H:] / c[:-H] - 1.0 + sig += np.sign(m) + direction = np.clip(sig / 3.0, 0, 1) # long-flat + return bl.vol_target(direction, df, 0.20, 30, 1.0) + + +def _net(signal_fn, series): + """OOS net-return vector (test slice) for a signal on a series.""" + df = bl.load(series, "full") + cut = bl.split_cut(series) + tgt = np.nan_to_num(np.asarray(signal_fn(df), float), nan=0.0) + rep = bl.eval_target(df, tgt, bl.FEE_SIDE, + metric_mask=np.r_[np.zeros(cut, bool), np.ones(len(df) - cut, bool)]) + # eval_target returns net over the masked region via _metrics; recompute net here + c = df["close"].values.astype(float) + r = bl.simple_returns(c) + pos = np.zeros(len(tgt)); pos[1:] = np.clip(tgt, -1, 1)[:-1] + net = pos * r - bl.FEE_SIDE * np.abs(np.diff(pos, prepend=0.0)) + return net[cut:], df["datetime"].values[cut:] + + +def _sharpe(net): + net = net[np.isfinite(net)] + return float(np.mean(net) / np.std(net) * np.sqrt(365.25)) if len(net) > 2 and np.std(net) > 0 else 0.0 + + +def _fee_oos_sharpe(signal_fn, series, fee_side): + df = bl.load(series, "full"); cut = bl.split_cut(series) + c = df["close"].values.astype(float); r = bl.simple_returns(c) + tgt = np.clip(np.nan_to_num(np.asarray(signal_fn(df), float)), -1, 1) + pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] + net = pos * r - fee_side * np.abs(np.diff(pos, prepend=0.0)) + return _sharpe(net[cut:]) + + +def verify(name: str) -> dict: + sig = _sig(AGENTS / f"{name}.py") + out = {"name": name} + corrs, jk_worst, fee_sh = [], [], [] + for s in ("A", "B"): + net, _ = _net(sig, s) + bnet, _ = _net(_trend_baseline, s) + m = min(len(net), len(bnet)) + a, b = net[-m:], bnet[-m:] + mask = np.isfinite(a) & np.isfinite(b) + corr = float(np.corrcoef(a[mask], b[mask])[0, 1]) if mask.sum() > 3 else 0.0 + corrs.append(corr) + # jackknife: drop each of K blocks, Sharpe of the rest + K = 6 + blocks = np.array_split(np.arange(len(net)), K) + shs = [] + for j in range(K): + keep = np.concatenate([blocks[k] for k in range(K) if k != j]) + shs.append(_sharpe(net[keep])) + jk_worst.append(min(shs)) + fee_sh.append(_fee_oos_sharpe(sig, s, 0.001)) # 0.20% RT + out["corr_to_trend"] = round(float(np.mean(corrs)), 2) + out["jackknife_worst_sharpe"] = round(float(min(jk_worst)), 2) + out["fee020_sharpe_min"] = round(float(min(fee_sh)), 2) + out["verdict"] = ( + "TREND-IN-DISGUISE" if out["corr_to_trend"] > 0.7 else + "weak/luck" if out["jackknife_worst_sharpe"] < 0.2 else + "ORTHOGONAL-CANDIDATE") + return out + + +def main(): + ap = argparse.ArgumentParser(); ap.add_argument("--top", type=int, default=10) + args = ap.parse_args() + lb = json.loads((HERE / "leaderboard.json").read_text()) + top = [r["name"] for r in lb["valid"][:args.top]] + # baseline self-correlation sanity + print(f"\n Adversarial verify of top {len(top)} (corr vs canonical TSMOM trend baseline):\n") + print(f" {'strategy':<26} {'corr_trend':>10} {'jk_worst_Sh':>12} {'fee0.20%_Sh':>12} verdict") + print(f" {'-'*78}") + rows = [] + for name in top: + v = verify(name); rows.append(v) + print(f" {name[:26]:<26} {v['corr_to_trend']:>10.2f} {v['jackknife_worst_sharpe']:>12.2f} " + f"{v['fee020_sharpe_min']:>12.2f} {v['verdict']}") + (HERE / "verify_top.json").write_text(json.dumps(rows, indent=2)) + print(f"\n -> {HERE/'verify_top.json'}") + + +if __name__ == "__main__": + main()