research(blind): 52 agenti ciechi su curve anonime BTC/ETH — orchestratore valuta PnL/maxDD, niente di nuovo regge

Flotta di 52 subagenti "esperti di segnali" su storico BTC/ETH ANONIMIZZATO (Series A/B
rebased a 100, calendario sintetico, split 70/30) — non sanno cosa siano. Ognuno scrive un
signal(df)->position causale (script o ML), tunato solo sul train. Orchestratore valuta su
PnL e maxDD nel test held-out.

Harness cieco leak-free (riusabile):
- make_blind.py: export anonimo + overlay; blindlib.py: evaluator con shift della posizione +
  GUARDIA DI CAUSALITA' online (squalifica ogni look-ahead, ML incluso); blind_eval.py CLI;
  score_all.py giudice OOS; verify_top.py (corr-al-trend, fee-stress, jackknife).
- 52/52 passano la guardia (zero leak su tutta la flotta).

Esito OOS (benchmark buy&hold: -7% PnL, 68% DD):
- top = macd (+21%, DD 11%, Sh 0.84), accel, vol_of_vol, regime_switch, rf, obv — tutti
  trend/vol-regime. Sharpe OOS ~0.84 decade dal train ~1.4. Mean-rev e ML in fondo.
- 3 scettici indipendenti: REFUTED. regime-luck (top-5 bar = 67-102% del PnL); trend-redundancy
  (HAC alpha t=+0.9..+1.5, nessuno >1.96 — TSMOM travestito); overfit (accel/vov knife-edge).

Verdetto: ri-conferma CIECA e indipendente del soffitto direzionale ~1.3. macd = classe-TP01,
forward-monitor non deploy. Diario 2026-06-21-blind-signal-fleet.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-21 07:05:04 +00:00
parent f5d30d88b9
commit 1afb1014c9
63 changed files with 6660 additions and 0 deletions
+4
View File
@@ -56,3 +56,7 @@ data/paper_portfolio/
# output grezzo dello sweep di ricerca xsec (rigenerabile dagli script in runs/) # output grezzo dello sweep di ricerca xsec (rigenerabile dagli script in runs/)
scripts/research/xsec/runs/out/ scripts/research/xsec/runs/out/
# blind-signal derived data (regenerable via make_blind.py)
data/blind/
scripts/research/blind/leaderboard.json
+111
View File
@@ -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).
@@ -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."
}
+225
View File
@@ -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']}")
@@ -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 <this file> --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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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<warmup the
gate is impassable (we don't trust an early sample) so we stay flat early."""
s = pd.Series(x)
thr = s.expanding(min_periods=warmup).quantile(q).values
return thr
def signal(df):
c = df["close"].values.astype(float)
n = len(c)
er = _efficiency_ratio(c, ER_WIN)
kama = _kama(c, er, FAST, SLOW)
# DIRECTION: sign of the KAMA slope over SLOPE bars
slope = np.zeros(n)
slope[SLOPE:] = kama[SLOPE:] - kama[:-SLOPE]
direction = np.sign(slope)
# GATE: only trade when efficiency is high relative to this curve's own past
thr = _expanding_quantile(er, ER_Q, WARMUP)
active = np.where(np.isfinite(thr) & (er >= 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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -0,0 +1,100 @@
"""Agent 14 — RSI reversion, trend-gated (family=meanrev, slug=rsi).
The angle (assigned): RSI reversion. Long when RSI<lo, short when RSI>hi (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<LO we stand flat (don't knife-catch
long against a downtrend). The short side is weighted < 1 because the curves drift
up — on train it adds a touch of PnL with no DD cost but is not where the edge is.
The long trend filter does two jobs: it picks WHICH side of the RSI book is reversion
(buy dips in up-trend / sell rips in down-trend) and it suppresses the side that fights
the drift. TREND_WIN=150 is the DD sweet spot on train (DD 0.11 vs 0.16-0.21 at 100/200)
— the gate is what keeps the drawdown small. Sizing is smooth (further past the
threshold -> 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)
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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 (<LO) is a BUY-THE-DIP setup, and we
require %K to CROSS BACK UP through its signal line %D — the standard stochastic long
trigger — before going LONG. That waits for the dip to actually TURN (anticipating the
bounce) instead of knife-catching while %K is still falling. We HOLD the long
(hysteresis) until %K recovers into EXIT, then go flat. We do NOT short %K>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<LO 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).
WHY THE CROSS MATTERS (the "anticipation" the angle asks for): entering the instant %K
prints <LO is usually early — %K is still falling. Waiting for the %K/%D up-cross times the
turn, which on train is the difference between a coin-flip dip rule and a positive one: with
the cross the dip-long sits at ~9-12% DD with a clean positive Sharpe; without it the same
thresholds bleed. The cross also cuts whipsaw turnover (~5-6 round-trips/yr, fee-cheap).
The trend gate does two jobs: it picks WHICH side of the oscillator is reversion (buy dips
in up-trend / sell rips in down-trend) and it suppresses the side that fights the drift.
Sizing is smooth (deeper oversold -> 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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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-W<k<=i} |Δ logC[k]| in [0,1]
ER is exactly an INVERSE path-entropy: ER->1 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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
+85
View File
@@ -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 <path.py> --split train
# orchestrator, the honest out-of-sample verdict on the held-out tail:
uv run python scripts/research/blind/blind_eval.py --module <path.py> --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()
+188
View File
@@ -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",
]
+102
View File
@@ -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()
+126
View File
@@ -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()
+86
View File
@@ -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"
}
]
+134
View File
@@ -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()