integra(TP01): merge ricerca branch strategy-research-2026-06 (squash) — strategia vincente + harness + track A-E
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py (le 5 track della ricerca)
- scripts/live/paper_trend.py paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)
Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
"""TRACK C — Mean-reversion / range re-examination on CLEAN BTC/ETH (Deribit mainnet).
|
||||
|
||||
HONEST harness only. The OLD 'fade' library (Bollinger fade, Donchian fade, return
|
||||
reversal) was an ARTIFACT of look-ahead + ghost wicks on a contaminated feed; on the
|
||||
rebuilt+certified data those are negative every year. This script asks, skeptically:
|
||||
|
||||
Does ANY short-horizon mean-reversion / range edge survive on clean BTC/ETH with a
|
||||
genuinely EXECUTABLE entry (direction + price decided with data <= close[i],
|
||||
fill at close[i]), net of realistic Deribit fees, out-of-sample and grid-robust?
|
||||
|
||||
Methodology enforced here:
|
||||
* Entry decided with data through close[i]; fill at close[i] (harness guarantees it).
|
||||
No entering "at the band edge" / candle extreme only known intrabar.
|
||||
* NET fees fee_rt=0.001 baseline + sweep {0.0005, 0.0015, 0.002}.
|
||||
* OOS 65/35 split + parameter grid across BOTH BTC & ETH.
|
||||
* Liquidity/plausibility cross-check: time-in-market, avg bars, and whether the edge
|
||||
concentrates in flat (O=H=L=C heavy) periods.
|
||||
|
||||
Run:
|
||||
uv run python scripts/research/trackC_meanrev.py # full (slow, all TFs)
|
||||
uv run python scripts/research/trackC_meanrev.py --quick # 1h + 15m only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals, oos_split, Metrics
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Indicator helpers — ALL causal: value at index i uses ONLY data through i.
|
||||
# ===========================================================================
|
||||
def zscore(close: np.ndarray, lookback: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
ma = s.rolling(lookback).mean()
|
||||
sd = s.rolling(lookback).std(ddof=0)
|
||||
z = (s - ma) / sd
|
||||
return z.values, ma.values, sd.values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, period: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
d = s.diff()
|
||||
up = d.clip(lower=0.0)
|
||||
dn = (-d).clip(lower=0.0)
|
||||
# Wilder smoothing via ewm alpha=1/period (causal)
|
||||
ru = up.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rd = dn.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rs = ru / rd.replace(0, np.nan)
|
||||
out = 100 - 100 / (1 + rs)
|
||||
return out.values
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, period: int) -> np.ndarray:
|
||||
h, l, c = df["high"].values, df["low"].values, df["close"].values
|
||||
pc = np.roll(c, 1)
|
||||
pc[0] = c[0]
|
||||
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
|
||||
return pd.Series(tr).ewm(alpha=1.0 / period, adjust=False).mean().values
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Signal generators — each returns a list[dict|None] length len(df).
|
||||
# Direction/levels decided strictly with data through close[i].
|
||||
# ===========================================================================
|
||||
def sig_zfade(df, lookback=20, z=2.0, tp_mode="mean", tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=24, atr_p=14):
|
||||
"""Bollinger / z-score fade. z<-thr -> long (reversion up); z>+thr -> short.
|
||||
TP at the moving mean (tp_mode='mean') or at tp_atr*ATR toward the mean.
|
||||
SL at sl_atr*ATR beyond entry. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
z_arr, ma, _ = zscore(c, lookback)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(lookback, n):
|
||||
zi = z_arr[i]
|
||||
if not np.isfinite(zi) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if zi <= -z:
|
||||
direction = 1
|
||||
tp = ma[i] if tp_mode == "mean" else px + tp_atr * a[i]
|
||||
sl = px - sl_atr * a[i] if sl_atr else None
|
||||
elif zi >= z:
|
||||
direction = -1
|
||||
tp = ma[i] if tp_mode == "mean" else px - tp_atr * a[i]
|
||||
sl = px + sl_atr * a[i] if sl_atr else None
|
||||
else:
|
||||
continue
|
||||
# guardrail: never set TP on wrong side of entry
|
||||
if direction == 1 and tp <= px:
|
||||
tp = px + tp_atr * a[i]
|
||||
if direction == -1 and tp >= px:
|
||||
tp = px - tp_atr * a[i]
|
||||
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_rsi2(df, period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12,
|
||||
atr_p=14, sma_filter=0):
|
||||
"""RSI(2)-style oversold/overbought reversion. RSI<lo -> long, RSI>hi -> short.
|
||||
Optional trend filter: only long above SMA(sma_filter), only short below."""
|
||||
c = df["close"].values
|
||||
r = rsi(c, period)
|
||||
a = atr(df, atr_p)
|
||||
sma = pd.Series(c).rolling(sma_filter).mean().values if sma_filter else None
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(max(period, atr_p, sma_filter), n):
|
||||
ri = r[i]
|
||||
if not np.isfinite(ri) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if ri <= lo:
|
||||
if sma is not None and not (px > sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif ri >= hi:
|
||||
if sma is not None and not (px < sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_retrev(df, ret_lb=1, thr_sigma=2.0, vol_lb=50, tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=6, atr_p=14):
|
||||
"""Return reversal: fade an extreme cumulative return over the last ret_lb bars.
|
||||
Extreme = |ret| > thr_sigma * rolling std of that return. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
s = pd.Series(c)
|
||||
ret = np.log(s / s.shift(ret_lb))
|
||||
sd = ret.rolling(vol_lb).std(ddof=0)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
rv = ret.values
|
||||
sv = sd.values
|
||||
for i in range(vol_lb + ret_lb, n):
|
||||
if not np.isfinite(rv[i]) or not np.isfinite(sv[i]) or sv[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = rv[i] / sv[i]
|
||||
px = c[i]
|
||||
if z <= -thr_sigma:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr_sigma:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_vwap(df, sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12, atr_p=14):
|
||||
"""Rolling-VWAP distance reversion. Distance in std-of-distance units over a
|
||||
rolling session window. Far above VWAP -> short, far below -> long. Entry close[i]."""
|
||||
c = df["close"].values
|
||||
v = df["volume"].values.astype(float)
|
||||
tp = (df["high"].values + df["low"].values + c) / 3.0
|
||||
pv = pd.Series(tp * v)
|
||||
vol = pd.Series(v)
|
||||
vwap = (pv.rolling(sess_bars).sum() / vol.rolling(sess_bars).sum()).values
|
||||
dist = pd.Series(c - vwap)
|
||||
dsd = dist.rolling(sess_bars).std(ddof=0).values
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(sess_bars * 2, n):
|
||||
if not np.isfinite(vwap[i]) or not np.isfinite(dsd[i]) or dsd[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = (c[i] - vwap[i]) / dsd[i]
|
||||
px = c[i]
|
||||
if z <= -thr:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Evaluation utilities
|
||||
# ===========================================================================
|
||||
def flat_fraction(df: pd.DataFrame) -> float:
|
||||
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
|
||||
return float(((h == l) & (o == c)).mean())
|
||||
|
||||
|
||||
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0):
|
||||
"""Run full / IS / OOS for a single config. Returns (full, is_, oos)."""
|
||||
entries = sigfn(df, **params)
|
||||
cut = oos_split(df, 0.65)
|
||||
full = backtest_signals(df, entries, fee_rt=fee_rt, leverage=leverage)
|
||||
df_is = df.iloc[:cut].reset_index(drop=True)
|
||||
df_oos = df.iloc[cut:].reset_index(drop=True)
|
||||
is_ = backtest_signals(df_is, sigfn(df_is, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
oos = backtest_signals(df_oos, sigfn(df_oos, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
return full, is_, oos
|
||||
|
||||
|
||||
def hdr(title):
|
||||
print("\n" + "=" * 92)
|
||||
print(title)
|
||||
print("=" * 92)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="1h+15m only (skip slow 5m)")
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.time()
|
||||
tfs = ["1h", "15m"] if args.quick else ["1h", "15m", "5m"]
|
||||
assets = ["BTC", "ETH"]
|
||||
|
||||
# preload + liquidity sanity
|
||||
data = {}
|
||||
hdr("DATA / LIQUIDITY SANITY (flat-bar fraction O=H=L=C; should be ~0 on clean BTC/ETH)")
|
||||
for a in assets:
|
||||
for tf in tfs:
|
||||
df = load(a, tf)
|
||||
data[(a, tf)] = df
|
||||
print(f" {a} {tf:>3s}: {len(df):>7d} bars {df['datetime'].iloc[0].date()}→"
|
||||
f"{df['datetime'].iloc[-1].date()} flat={flat_fraction(df)*100:5.2f}%")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 1 — broad screen per family on 1h, both assets (IS/OOS).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 1 — FAMILY SCREEN on 1h (honest entry, fee_rt=0.001, lev=1). "
|
||||
"Look for OOS>0 on BOTH assets.")
|
||||
families = {
|
||||
"ZFADE z2/mean ": (sig_zfade, dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z2.5/atr": (sig_zfade, dict(lookback=20, z=2.5, tp_mode="atr", tp_atr=1.5, sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z3/mean ": (sig_zfade, dict(lookback=40, z=3.0, tp_mode="mean", sl_atr=3.0, max_bars=48)),
|
||||
"RSI2 10/90 ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
"RSI2 5/95 ": (sig_rsi2, dict(period=2, lo=5, hi=95, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"RSI2 +trend ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12, sma_filter=200)),
|
||||
"RETREV 2sig/6b ": (sig_retrev, dict(ret_lb=1, thr_sigma=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=6)),
|
||||
"RETREV 3sig/12b": (sig_retrev, dict(ret_lb=3, thr_sigma=3.0, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"VWAP 2/sess24": (sig_vwap, dict(sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
}
|
||||
for name, (fn, params) in families.items():
|
||||
line = f" {name} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
full, is_, oos = run_split(df, fn, params)
|
||||
line += (f"{a}: IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(tr={oos.n_trades:>4d} wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f} "
|
||||
f"mkt={oos.time_in_market*100:>3.0f}% ab={oos.avg_bars:>4.1f}) ")
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 2 — parameter GRID on the two most-promising families (z-fade, rsi2),
|
||||
# require OOS>0 on BOTH assets to count a cell as "surviving".
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 2 — GRID ROBUSTNESS (1h). A cell 'survives' only if OOS net>0 on BOTH BTC AND ETH.")
|
||||
|
||||
def grid(fn, base, sweep, tf="1h"):
|
||||
keys = list(sweep.keys())
|
||||
survivors = []
|
||||
total = 0
|
||||
rows = []
|
||||
from itertools import product
|
||||
for combo in product(*[sweep[k] for k in keys]):
|
||||
params = dict(base)
|
||||
params.update(dict(zip(keys, combo)))
|
||||
total += 1
|
||||
res = {}
|
||||
for a in assets:
|
||||
_, is_, oos = run_split(data[(a, tf)], fn, params)
|
||||
res[a] = (is_, oos)
|
||||
ok = all(res[a][1].net_return > 0 for a in assets)
|
||||
both_oos = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
rows.append((params, res, ok))
|
||||
if ok:
|
||||
survivors.append((params, res))
|
||||
print(f" {fn.__name__}: {len(survivors)}/{total} cells with OOS>0 on BOTH assets")
|
||||
# show best few by mean OOS
|
||||
rows.sort(key=lambda r: np.mean([r[1][a][1].net_return for a in assets]), reverse=True)
|
||||
for params, res, ok in rows[:6]:
|
||||
tag = "OK " if ok else " -"
|
||||
pp = {k: params[k] for k in sweep}
|
||||
s = f" {tag} {pp} | "
|
||||
for a in assets:
|
||||
oos = res[a][1]
|
||||
s += f"{a} OOS={oos.net_return*100:>+6.0f}% (wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f}) "
|
||||
print(s)
|
||||
return survivors
|
||||
|
||||
zsurv = grid(sig_zfade,
|
||||
dict(tp_mode="mean", max_bars=24),
|
||||
dict(lookback=[20, 40, 60], z=[2.0, 2.5, 3.0], sl_atr=[2.0, 3.0]))
|
||||
rsurv = grid(sig_rsi2,
|
||||
dict(period=2, tp_atr=1.0),
|
||||
dict(lo=[5, 10, 15], hi=[85, 90, 95], sl_atr=[2.0, 3.0], max_bars=[6, 12]))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 3 — FEE SWEEP on whatever looks least-bad (z-fade z2/mean) to show fee
|
||||
# sensitivity (MR is high-frequency: fees are first-order).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 3 — FEE SWEEP (z-fade lookback=20 z=2 mean, 1h). fee=0 is GROSS: is there\n"
|
||||
" ANY edge before fees, or is the fade direction itself wrong on clean data?")
|
||||
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
|
||||
base = dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
line = f" {a}: "
|
||||
for f in fees:
|
||||
full, is_, oos = run_split(df, sig_zfade, base, fee_rt=f)
|
||||
line += f"fee={f*1000:.1f}bp→ full={full.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 4 — faster TFs (15m, 5m) on the canonical z-fade, to test the "more MR
|
||||
# opportunities" hypothesis vs the "fee death" reality.
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 4 — z-fade across timeframes (lookback=20 z=2 mean). Faster TF = more fees.")
|
||||
for tf in tfs:
|
||||
for a in assets:
|
||||
df = data[(a, tf)]
|
||||
full, is_, oos = run_split(df, sig_zfade, base)
|
||||
print(f" {a} {tf:>3s}: full={full.net_return*100:>+7.0f}% IS={is_.net_return*100:>+7.0f}% "
|
||||
f"OOS={oos.net_return*100:>+7.0f}% tr={full.n_trades:>5d} wr={full.win_rate:>4.1f}% "
|
||||
f"shrp={full.sharpe:>+4.1f} mkt={full.time_in_market*100:>3.0f}% €/d={full.daily_profit(2000):>+5.2f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 5 — SESSION / overnight effect (UTC hour-of-day) on 1h returns.
|
||||
# Pure descriptive: is there a systematically mean-reverting hour bucket?
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 5 — UTC hour-of-day next-bar return autocorrelation (descriptive, no trade).")
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
c = df["close"].values
|
||||
ret = pd.Series(np.log(c[1:] / c[:-1])) # ret[k] = log(c[k+1]/c[k])
|
||||
prev = ret.shift(1)
|
||||
hours = df["datetime"].dt.hour.values[1:1 + len(ret)]
|
||||
tmp = pd.DataFrame({"h": hours[:len(ret)], "r": ret.values, "p": prev.values}).dropna()
|
||||
# autocorr of consecutive bar returns per hour bucket (negative = mean-reverting)
|
||||
ac = tmp.groupby("h").apply(lambda g: g["r"].corr(g["p"]) if len(g) > 30 else np.nan)
|
||||
worst = ac.nsmallest(3)
|
||||
best = ac.nlargest(3)
|
||||
print(f" {a}: most mean-reverting UTC hours (neg autocorr): "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in worst.items())
|
||||
+ " | most trending: "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in best.items()))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# VERDICT
|
||||
# -------------------------------------------------------------------
|
||||
hdr("VERDICT")
|
||||
n_surv = len(zsurv) + len(rsurv)
|
||||
if n_surv == 0:
|
||||
print(" No grid cell produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
|
||||
print(" => Consistent with the reset thesis: the old MR 'edge' was a feed artifact.")
|
||||
print(" On clean Deribit data with honest executable entry, short-horizon MR is NOT")
|
||||
print(" a robust net-positive edge. (See per-pass tables above for the evidence.)")
|
||||
else:
|
||||
print(f" {n_surv} grid cell(s) survived OOS>0 on both assets. Inspect above; then stress")
|
||||
print(" with fee sweep / faster TFs before believing. Surviving configs:")
|
||||
for params, res in (zsurv + rsurv):
|
||||
ms = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
print(f" {params} meanOOS={ms:+.0f}%")
|
||||
print(f"\n (elapsed {time.time()-t0:.0f}s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user