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:
@@ -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)
|
||||
Reference in New Issue
Block a user