research tracks H+I: volume/vol/range + alt-momentum/reversal (both NEGATIVE for alpha)
- trackH volume_vol: no uncorrelated additive edge; profitable signals are trend-in-disguise (corr 0.6-0.75); MR/declining-volume fade dead even at fee 0; OBV-up filter is a defensive DD overlay only (13.3->10.1% DD but -CAGR), not new alpha - trackI momentum/reversal: no formulation beats 1-3-6m sign-blend OOS on both assets; z-score continuous momentum = same edge (corr 0.96), lower DD 8.4% but lower CAGR; long-horizon reversal not bankable (negative/flat standalone). ~1.3 Sharpe ceiling holds. - TP01 (12h sign-blend) remains the deployable winner
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
# Track H — Volume, Range & Volatility-Regime signals (BTC/ETH, certified, >=12h)
|
||||||
|
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Script:** `scripts/research/trackH_volume_vol.py` (runnable, self-contained)
|
||||||
|
**Question:** does any volume / range / volatility-regime signal ADD to the deployed winner
|
||||||
|
TP01 (vol-targeted trend portfolio, 12h, Sharpe ~1.32) — i.e. net-positive OOS on BOTH BTC &
|
||||||
|
ETH AND uncorrelated (|corr|<~0.3) — OR work as a regime filter that lifts TP01's Sharpe / cuts
|
||||||
|
its DD?
|
||||||
|
|
||||||
|
## Method (honest)
|
||||||
|
- Same causal per-bar engine as `TrendPortfolio.net_returns`: build a continuous TARGET decided
|
||||||
|
with data `<= close[i]`, HOLD it during bar `i+1` (`pos_held[t]=target[t-1]`), gross = pos×ret,
|
||||||
|
fee on `|Δpos|`. Identical in spirit to `harness.backtest_signals` (decide≤close[i], fill at
|
||||||
|
close[i]); two discrete signals cross-checked through `backtest_signals` directly.
|
||||||
|
- All features (volume z-score, OBV, ranges, realized vol) use prior/rolling windows shifted so
|
||||||
|
bar `i` sees only `<= i`. 12h/1d resampled from certified 1h via `resample_tf` (label='left'),
|
||||||
|
consumed index-based with the +1 hold → no open-label leak.
|
||||||
|
- Fee 0.10% RT baseline + sweep 0.00–0.40% RT. OOS 65/35 + per-year. Grid on BOTH assets.
|
||||||
|
Turnover and correlation-to-TP01 reported for every signal.
|
||||||
|
- **>=12h only** (12h + 1d). Sub-12h excluded per the standing lesson (fees + HF-noise overfit +
|
||||||
|
the 4h open-label look-ahead trap).
|
||||||
|
|
||||||
|
## Signals tested
|
||||||
|
VT-long (volatility-managed long), VolBreakout (volume-z-confirmed Donchian), OBV-trend,
|
||||||
|
VW-mom (volume-weighted momentum), RangeExpand (range-expansion breakout), NR7-break
|
||||||
|
(narrowest-range breakout), DeclVolRev (declining-volume fade/reversal). Plus regime overlays on
|
||||||
|
TP01: keep-low-vol, keep-high-vol, vol-managed ×1.5, OBV-up confirmation.
|
||||||
|
|
||||||
|
## Results (12h headline, fee 0.10% RT)
|
||||||
|
| signal | corr→TP01 | OOS Sharpe BTC/ETH | note |
|
||||||
|
|---|---|---|---|
|
||||||
|
| VT-long | 0.66 / 0.69 | 0.80 / 0.14 | trend-in-disguise; weak OOS ETH |
|
||||||
|
| VolBreakout | 0.69 / 0.71 | 0.54 / 0.49 | profitable but correlated |
|
||||||
|
| OBV-trend | 0.61 / 0.63 | 0.96 / 0.68 | profitable but correlated; turnover ~75/yr |
|
||||||
|
| VW-mom | 0.64 / 0.67 | 0.98 / 0.74 | basically TSMOM; correlated |
|
||||||
|
| RangeExpand | 0.48 / 0.49 | 0.37 / 1.04 | lower corr but BTC weak; ETH negative on 1d |
|
||||||
|
| NR7-break | 0.48 / 0.49 | 0.79 / 0.02 | fails OOS on ETH |
|
||||||
|
| DeclVolRev | -0.15 / -0.11 | -1.15 / -0.44 | **negative even at zero fee** |
|
||||||
|
|
||||||
|
Grid robustness (12h, % cells positive full+OOS on both assets): VW-mom 100%, VT-long 100%,
|
||||||
|
VolBreakout 96%, RangeExpand 96%, OBV-trend 75% — but the robust ones are precisely the ones
|
||||||
|
that are highly correlated to TP01. Fee sweep: trend-family signals survive to 0.40% RT;
|
||||||
|
DeclVolRev gets worse with fees (it trades constantly).
|
||||||
|
|
||||||
|
## Regime filters on TP01 (12h, 50/50 portfolio)
|
||||||
|
| variant | full Sharpe | OOS Sharpe | maxDD | CAGR | turn/y |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| **TP01 baseline** | **1.32** | 0.90 | 13.3% | 16.2% | 11.5 |
|
||||||
|
| × keep LOW-vol | 0.94 | 1.11 | 14.1% | 7.7% | 9.5 |
|
||||||
|
| × keep HIGH-vol | 0.98 | 0.18 | 9.9% | 7.9% | 4.9 |
|
||||||
|
| × vol-managed ×1.5 | 1.33 | 0.96 | 17.9% | 18.1% | 15.4 |
|
||||||
|
| × OBV-up only | 1.49 | 1.04 | 10.1% | 14.4% | 18.2 |
|
||||||
|
|
||||||
|
OBV-up filter across EMA span: full Sharpe 1.49–1.52 (span 15–30), DD 7–10%, but OOS gain is
|
||||||
|
marginal (0.90→1.04 at span 30) and fades for span≥45 (OOS 0.69–0.73). It cuts ~2pp CAGR and
|
||||||
|
raises turnover ~60%.
|
||||||
|
|
||||||
|
## Verdict (honest)
|
||||||
|
- **No uncorrelated additive edge exists.** Every *profitable* volume/range/vol signal is trend
|
||||||
|
in disguise (corr 0.61–0.75 to TP01) → cannot raise the 50/50 portfolio Sharpe. The genuinely
|
||||||
|
lower-corr signals (RangeExpand, NR7 ~0.48) fail OOS on at least one asset.
|
||||||
|
- **Mean-reversion / declining-volume fade is dead** — negative net AND at zero fee on both
|
||||||
|
assets. Reconfirms the v2.0.0 contamination lesson; MR is not a real edge on certified data.
|
||||||
|
- **Vol-regime gating hurts** (keep-low / keep-high both drop Sharpe to ~0.95). The vol-managed
|
||||||
|
overlay is Sharpe-neutral but DD-worse.
|
||||||
|
- **The only non-harmful overlay is OBV-up trend-confirmation:** it cuts DD (13.3%→10.1%) and
|
||||||
|
nudges full Sharpe to ~1.49, but it is trend double-confirmation (de-risking), not new alpha;
|
||||||
|
it costs CAGR, raises turnover, and the OOS Sharpe gain is within noise and span-sensitive. It
|
||||||
|
is worth keeping in mind as a **defensive DD overlay**, not as a Sharpe improver.
|
||||||
|
- **Bottom line:** the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only **holds**. TP01 stays the
|
||||||
|
deployable winner. Volume/range/vol add nothing uncorrelated.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Track I — Alternative momentum formulations + long-horizon reversal (2026-06-19)
|
||||||
|
|
||||||
|
**Script:** `scripts/research/trackI_momentum_reversal.py` (self-contained, runnable).
|
||||||
|
**Universe:** BTC & ETH only. **TF:** 12h + 1d (sub-12h excluded by rule). **Harness:** identical
|
||||||
|
honest machinery to TP01 — direction decided `<= close[i]`, positions held next bar (`pos_held[1:]
|
||||||
|
= tgt[:-1]`), vol-target by inverse PAST-ONLY realized vol (target 20%, lev cap 2x), NET fee 0.10%
|
||||||
|
RT on turnover, 50/50 BTC+ETH. OOS 65/35 + per-year + fee sweep (0.00–0.40% RT). Correlation to
|
||||||
|
TP01 net returns reported for every candidate.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
(A) A momentum formulation that BEATS or DIVERSIFIES the canonical 1-3-6m sign-blend (TP01,
|
||||||
|
Sharpe ~1.32). (B) Does the classic LONG-HORIZON REVERSAL (fade ~12m winners) give an
|
||||||
|
uncorrelated positive overlay?
|
||||||
|
|
||||||
|
## PART A — momentum formulations (12h, long-flat, vs TP01 Sharpe 1.32 / OOS 0.90 / DD 13.3%)
|
||||||
|
|
||||||
|
| formulation | Sharpe | IS | **OOS** | CAGR | maxDD | corr→TP01 | BTC | ETH |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| baseline sign-blend 1-3-6m | 1.32 | 1.54 | 0.90 | +16% | 13.3% | 1.00 | 1.15 | 1.10 |
|
||||||
|
| (i) z-score cum-return (tanh) | **1.35** | 1.63 | 0.85 | +12% | **8.4%** | 0.96 | 1.30 | 1.00 |
|
||||||
|
| (ii) risk-adjusted momentum | 1.27 | 1.49 | 0.84 | +13% | 9.5% | 0.97 | 1.21 | 1.00 |
|
||||||
|
| (iii) EMA-cross trend | 0.81 | 0.91 | 0.62 | +11% | 25.1% | 0.85 | 0.89 | 0.53 |
|
||||||
|
| (iii-b) MACD (calendar spans) | **1.50** | **1.87** | 0.74 | +22% | 17.7% | 0.69 | 1.30 | 1.32 |
|
||||||
|
| (iv) Donchian breakout | 1.10 | 1.36 | 0.57 | +17% | 25.0% | 0.86 | 1.08 | 0.82 |
|
||||||
|
| (v) acceleration (Δ-momentum) | 1.28 | 1.82 | 0.35 | +14% | 14.2% | 0.66 | 1.25 | 0.81 |
|
||||||
|
| (vi) 12-1 skip momentum | 0.67 | 0.79 | 0.47 | +9% | 24.5% | 0.68 | 0.70 | 0.49 |
|
||||||
|
|
||||||
|
Results are essentially identical at 1d. Read-out:
|
||||||
|
|
||||||
|
- **Nothing cleanly beats the sign-blend OOS on both assets.** The headline-Sharpe leaders are
|
||||||
|
artefacts of in-sample fit: **MACD** posts IS 1.87 but OOS collapses to 0.74 (gap = overfit) with
|
||||||
|
a worse DD (17.7%); **acceleration** IS 1.82 → OOS **0.35** (worst OOS decay of all). Both fail.
|
||||||
|
- **(i) z-score continuous momentum** is the one mild, honest refinement: Sharpe 1.35 (≈baseline)
|
||||||
|
but **maxDD 8.4% vs 13.3%** — the continuous score scales down position when the cumulative move
|
||||||
|
is statistically small, de-risking the tails. OOS 0.85 (slightly below baseline 0.90), CAGR drops
|
||||||
|
16%→12%. It's a smoother sibling of TP01, **not a new edge** (corr 0.96).
|
||||||
|
- (vi) 12-1 skip (classic equity "12-1" momentum) **does NOT help crypto**: skipping the recent
|
||||||
|
month removes the strongest part of the signal here → Sharpe 0.67, corr 0.68. Crypto momentum
|
||||||
|
lives in the recent window, opposite to the equity stylised fact.
|
||||||
|
- Breakout/Donchian and EMA-cross are strictly worse (high DD, weak OOS).
|
||||||
|
|
||||||
|
## PART B — long-horizon reversal (fade past winners), 12h
|
||||||
|
|
||||||
|
Long-short reversal (short ~12/18/24m winners, long losers, vol-targeted):
|
||||||
|
|
||||||
|
| reversal LS | Sharpe | OOS | CAGR | maxDD | corr→TP01 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 12m | -0.77 | -1.15 | -14% | 73% | -0.51 |
|
||||||
|
| 18m | -0.36 | -0.75 | -8% | 58% | -0.47 |
|
||||||
|
| 24m | **+0.04** | -0.07 | -1% | 43% | **-0.32** |
|
||||||
|
| 12-18-24m | -0.46 | -0.72 | -8% | 57% | -0.54 |
|
||||||
|
|
||||||
|
- **Long-horizon reversal is NOT a standalone edge.** Standalone it LOSES money (12m/18m strongly
|
||||||
|
negative; only 24m is ~flat at Sharpe 0.04, OOS −0.07, and even that fails "net-positive OOS on
|
||||||
|
both assets": BTC +0.10 / ETH −0.03). Fading crypto winners over a year just shorts the trend.
|
||||||
|
- It IS genuinely negatively correlated to TP01 (24m: corr −0.32; 12-18-24: −0.54), as expected
|
||||||
|
(it's the opposite sign of medium-term momentum).
|
||||||
|
- **Momentum + reversal blend** (long 1-6m momentum, brake on very-long extension): the variant
|
||||||
|
`mom(1-3-6) − 0.5·rev(12-24)` is the most interesting single-strategy result — Sharpe **1.38**,
|
||||||
|
**OOS 0.98** (> baseline 0.90), **maxDD 10.6%** (< 13.3%), both assets positive (BTC 1.25/ETH
|
||||||
|
1.05), corr 0.91, fee-robust (1.43→1.22 across 0.00–0.40% RT). CAGR drops 16%→12%. It is TP01
|
||||||
|
with a long-term-extension brake: a modest *risk-adjusted* improvement, not more return.
|
||||||
|
|
||||||
|
## COMBINED — TP01 + best diversifier (blend net returns)
|
||||||
|
|
||||||
|
TP01 alone: Sharpe 1.321, CAGR +16%, maxDD 13.3%, OOS 0.90.
|
||||||
|
|
||||||
|
| combo | Sharpe | CAGR | maxDD | OOS | corr |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TP01 + 20% reversal-24m (LS) | **1.411** | +13% | 11.5% | **1.06** | -0.32 |
|
||||||
|
| TP01 + 30% reversal-24m (LS) | 1.366 | +12% | 11.8% | 1.06 | -0.32 |
|
||||||
|
| TP01 + 20% reversal-12-18-24 (LS) | 1.350 | +11% | 10.6% | 0.84 | -0.54 |
|
||||||
|
| TP01 + 50% z-score | 1.348 | +14% | 9.5% | 0.89 | +0.96 |
|
||||||
|
|
||||||
|
- Adding a small slice of **reversal-24m long-short** lifts portfolio Sharpe 1.32→1.41 and OOS
|
||||||
|
0.90→1.06 while cutting DD to 11.5%. **But be skeptical:** the overlay is a ~zero-mean stream
|
||||||
|
(standalone Sharpe 0.04). The benefit is almost entirely **variance reduction from the negative
|
||||||
|
correlation, not added alpha** — and it COSTS return (CAGR 16%→13%). With a true-zero-edge
|
||||||
|
diversifier this Sharpe bump is fragile (it leans on the −0.32 correlation persisting OOS, and the
|
||||||
|
OOS sample is one 2022-24 crypto cycle). I would NOT deploy capital on a standalone-losing sleeve
|
||||||
|
to chase a 0.09 Sharpe point that is really de-risking.
|
||||||
|
|
||||||
|
## Fee sweep (12h portfolio Sharpe)
|
||||||
|
baseline 1.37→1.18, z-score 1.38→1.24, MACD 1.52→1.45 (lowest turnover), blend 1.43→1.22,
|
||||||
|
reversal-24m 0.07→−0.02 (0.00→0.40% RT). All trend formulations survive realistic fees; reversal
|
||||||
|
has no positive margin to survive on.
|
||||||
|
|
||||||
|
## VERDICT (honest)
|
||||||
|
- **Is there a momentum formulation that beats the 1-3-6m sign-blend? No — not OOS, not on both
|
||||||
|
assets.** MACD/acceleration look better in-sample but decay OOS (overfit + higher DD). The only
|
||||||
|
honest refinement is **continuous z-score momentum**, which matches the Sharpe with materially
|
||||||
|
lower drawdown (8.4% vs 13.3%) — a smoother variant of the SAME edge, not a new one (corr 0.96).
|
||||||
|
- **Does long-horizon reversal give an uncorrelated positive overlay? No, not a real one.** It is
|
||||||
|
uncorrelated/negatively-correlated (good) but **not positive** standalone (it loses, or at best is
|
||||||
|
flat at 24m and fails the both-assets bar). The combined-Sharpe lift (→1.41) is variance reduction
|
||||||
|
from a near-zero-mean stream and sacrifices CAGR — fragile, not bankable alpha.
|
||||||
|
- **The ~1.3 structural Sharpe ceiling on BTC/ETH-only holds.** TP01 remains the deployable winner.
|
||||||
|
If anything, swap the sign-blend for the **z-score continuous score** (or the `mom − 0.5·rev`
|
||||||
|
brake) for a lower-DD profile at equal Sharpe — a risk-management tweak, not a return upgrade.
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""TRACK H — VOLUME, RANGE & VOLATILITY-REGIME signals on CLEAN BTC/ETH (Deribit mainnet).
|
||||||
|
|
||||||
|
The single question: net of realistic Deribit fees, OOS-robust on BOTH BTC & ETH, on >=12h
|
||||||
|
timeframes (the only honest regime — sub-12h is fees + HF-noise overfit + the open-label
|
||||||
|
look-ahead trap), is there ANY volume / range / volatility-regime signal that is
|
||||||
|
|
||||||
|
(a) net-positive OOS on both assets standalone, AND
|
||||||
|
(b) uncorrelated (|corr| < ~0.3) to the deployed winner TP01, AND/OR
|
||||||
|
(c) usable as a REGIME FILTER that lifts TP01's Sharpe above ~1.32 or cuts its DD?
|
||||||
|
|
||||||
|
HONESTY / NO LOOK-AHEAD:
|
||||||
|
* Everything runs on the SAME causal per-bar engine used by TP01 (net_returns): we build a
|
||||||
|
continuous TARGET position decided with data <= close[i], then HOLD it during bar i+1
|
||||||
|
(pos_held[t] = target[t-1]). Gross = pos_held * simple_return[t]; fee charged on |Δpos|.
|
||||||
|
This is identical in spirit to the harness `backtest_signals` (decide<=close[i], fill at
|
||||||
|
close[i]); we cross-check two discrete signals through `backtest_signals` too.
|
||||||
|
* Volume / range / vol features for bar i use ONLY bars <= i (rolling, prior-window, shift).
|
||||||
|
* 12h / 1d frames are resampled from the certified 1h feed via resample_tf (label='left',
|
||||||
|
closed='left') and consumed index-based with the +1 bar hold -> the open-label is never
|
||||||
|
leaked (verified in trackD_lookahead_audit.py: Sharpe is label-invariant under this hold).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
uv run python scripts/research/trackH_volume_vol.py # full (12h + 1d)
|
||||||
|
uv run python scripts/research/trackH_volume_vol.py --quick # 12h only, fewer grids
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_tf
|
||||||
|
|
||||||
|
ASSETS = ["BTC", "ETH"]
|
||||||
|
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT (Deribit taker)
|
||||||
|
OOS_FRAC = 0.65
|
||||||
|
TF_BPD = {"12h": 2, "1d": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Causal feature helpers (all use data <= i)
|
||||||
|
# ===========================================================================
|
||||||
|
def simple_returns(c: np.ndarray) -> np.ndarray:
|
||||||
|
r = np.zeros(len(c))
|
||||||
|
r[1:] = c[1:] / c[:-1] - 1.0
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def realized_vol(r: np.ndarray, win: int, bpy: float) -> np.ndarray:
|
||||||
|
return pd.Series(r).rolling(win, min_periods=max(2, win // 2)).std().values * np.sqrt(bpy)
|
||||||
|
|
||||||
|
|
||||||
|
def roll_max_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||||
|
"""Max over the PRIOR `win` bars (excludes current bar i)."""
|
||||||
|
return pd.Series(x).shift(1).rolling(win, min_periods=win).max().values
|
||||||
|
|
||||||
|
|
||||||
|
def roll_min_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||||
|
return pd.Series(x).shift(1).rolling(win, min_periods=win).min().values
|
||||||
|
|
||||||
|
|
||||||
|
def roll_mean_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||||
|
return pd.Series(x).shift(1).rolling(win, min_periods=win).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def vol_zscore(vol: np.ndarray, win: int) -> np.ndarray:
|
||||||
|
"""z-score of current volume vs PRIOR `win` bars (uses <= i)."""
|
||||||
|
s = pd.Series(vol)
|
||||||
|
m = s.shift(1).rolling(win, min_periods=win).mean()
|
||||||
|
sd = s.shift(1).rolling(win, min_periods=win).std()
|
||||||
|
return ((s - m) / sd).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
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Per-bar net-returns engine (causal, fee on turnover) — identical to TP01.net_returns
|
||||||
|
# ===========================================================================
|
||||||
|
def net_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
|
||||||
|
"""target[i] decided with data <= close[i] -> HELD during bar i+1."""
|
||||||
|
target = np.nan_to_num(target, nan=0.0)
|
||||||
|
pos = np.zeros(len(target))
|
||||||
|
pos[1:] = target[:-1]
|
||||||
|
gross = pos * r
|
||||||
|
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||||
|
net = gross - fee_side * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
net = np.clip(net, -0.99, None)
|
||||||
|
return net, pos, turn
|
||||||
|
|
||||||
|
|
||||||
|
def metrics(net: np.ndarray, idx: pd.DatetimeIndex, turn: np.ndarray, bpy: float) -> dict:
|
||||||
|
rr = net[np.isfinite(net)]
|
||||||
|
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||||
|
equity = np.cumprod(1.0 + np.clip(net, -0.99, None))
|
||||||
|
peak = np.maximum.accumulate(equity)
|
||||||
|
dd = float(np.max((peak - equity) / peak)) if len(equity) else 0.0
|
||||||
|
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||||
|
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||||
|
total = equity[-1] / equity[0] if len(equity) else 1.0
|
||||||
|
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||||
|
ann_turn = float(np.sum(turn)) / years if years > 0 else 0.0
|
||||||
|
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
|
||||||
|
ann_turnover=ann_turn, equity=equity, years=years)
|
||||||
|
|
||||||
|
|
||||||
|
def per_year(net: np.ndarray, idx: pd.DatetimeIndex) -> dict:
|
||||||
|
eq = pd.Series(np.cumprod(1.0 + np.clip(net, -0.99, None)), index=idx)
|
||||||
|
out = {}
|
||||||
|
for y, g in eq.groupby(eq.index.year):
|
||||||
|
if len(g) > 1 and g.iloc[0] > 0:
|
||||||
|
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# SIGNALS — each returns a continuous TARGET array (frac of equity, +/-), causal.
|
||||||
|
# ===========================================================================
|
||||||
|
def sig_vt_long(df, bpd, target_vol=0.20, vol_win_days=30, lev=2.0, **_):
|
||||||
|
"""Volatility-managed LONG: always long, sized to a vol target (no trend at all).
|
||||||
|
Tests Moreira-Muir 'volatility-managed' alpha vs plain buy-and-hold."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
vol = realized_vol(r, vol_win_days * bpd, bpy)
|
||||||
|
tgt = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||||
|
return np.clip(tgt, 0, lev)
|
||||||
|
|
||||||
|
|
||||||
|
def sig_vol_breakout(df, bpd, don=20, zwin=20, zk=1.0, long_short=False, **_):
|
||||||
|
"""Volume-confirmed Donchian breakout (continuation). Long when close > prior-`don`-bar high
|
||||||
|
AND volume z-score > zk; stay long until close < prior-`don`-bar low (then flat/short)."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
vol = df["volume"].values.astype(float)
|
||||||
|
hi = roll_max_prior(h, don)
|
||||||
|
lo = roll_min_prior(l, don)
|
||||||
|
z = vol_zscore(vol, zwin)
|
||||||
|
up = (c > hi) & (z > zk)
|
||||||
|
dn = (c < lo) & (z > zk)
|
||||||
|
state = np.zeros(len(c))
|
||||||
|
s = 0.0
|
||||||
|
for i in range(len(c)):
|
||||||
|
if up[i]:
|
||||||
|
s = 1.0
|
||||||
|
elif dn[i]:
|
||||||
|
s = -1.0 if long_short else 0.0
|
||||||
|
elif s == 1.0 and c[i] < lo[i]: # trailing exit for longs
|
||||||
|
s = -1.0 if long_short else 0.0
|
||||||
|
elif s == -1.0 and c[i] > hi[i]:
|
||||||
|
s = 1.0
|
||||||
|
state[i] = s
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def sig_obv_trend(df, bpd, ma=30, long_short=False, **_):
|
||||||
|
"""OBV trend: OBV = cumsum(sign(ret)*volume); long when OBV > its EMA(ma), else flat/short."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
vol = df["volume"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
obv = np.cumsum(np.sign(r) * vol)
|
||||||
|
ema = pd.Series(obv).ewm(span=ma, adjust=False).mean().values
|
||||||
|
d = np.where(obv > ema, 1.0, (-1.0 if long_short else 0.0))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def sig_vw_momentum(df, bpd, mom_win=30, vol_win_days=30, target_vol=0.20, lev=2.0,
|
||||||
|
long_only=True, **_):
|
||||||
|
"""Volume-weighted momentum: sign of volume-weighted mean return over `mom_win` bars,
|
||||||
|
vol-targeted. Compare to plain TSMOM (does weighting by volume add anything?)."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
vol = df["volume"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
rw = r * vol
|
||||||
|
num = pd.Series(rw).rolling(mom_win, min_periods=mom_win).sum().values
|
||||||
|
den = pd.Series(vol).rolling(mom_win, min_periods=mom_win).sum().values
|
||||||
|
vwret = np.where(den > 0, num / den, 0.0)
|
||||||
|
direction = np.sign(vwret)
|
||||||
|
if long_only:
|
||||||
|
direction = np.clip(direction, 0, None)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
rv = realized_vol(r, vol_win_days * bpd, bpy)
|
||||||
|
scal = np.where((rv > 0) & np.isfinite(rv), target_vol / rv, 0.0)
|
||||||
|
return np.clip(direction * scal, -lev, lev)
|
||||||
|
|
||||||
|
|
||||||
|
def sig_range_expansion(df, bpd, rng_win=20, k=1.5, hold=5, long_short=False, **_):
|
||||||
|
"""Range-expansion breakout: when today's range > k * avg(prior `rng_win` ranges) and the
|
||||||
|
bar closed in the upper/lower half, go with the close direction; hold `hold` bars."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
rng = h - l
|
||||||
|
avg = roll_mean_prior(rng, rng_win)
|
||||||
|
expand = rng > k * avg
|
||||||
|
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
|
||||||
|
long_trig = expand & (pos_in_bar > 0.6)
|
||||||
|
short_trig = expand & (pos_in_bar < 0.4)
|
||||||
|
state = np.zeros(len(c))
|
||||||
|
hold_left = 0
|
||||||
|
cur = 0.0
|
||||||
|
for i in range(len(c)):
|
||||||
|
if hold_left > 0:
|
||||||
|
hold_left -= 1
|
||||||
|
else:
|
||||||
|
cur = 0.0
|
||||||
|
if long_trig[i]:
|
||||||
|
cur = 1.0
|
||||||
|
hold_left = hold
|
||||||
|
elif short_trig[i] and long_short:
|
||||||
|
cur = -1.0
|
||||||
|
hold_left = hold
|
||||||
|
state[i] = cur
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def sig_nr_breakout(df, bpd, nr=7, hold=5, long_short=False, **_):
|
||||||
|
"""NR-N breakout (daily-style): when the current bar's range is the narrowest of the last
|
||||||
|
`nr` bars, take the breakout of the prior bar's high/low on the next bars; hold `hold`."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
rng = h - l
|
||||||
|
is_nr = pd.Series(rng).rolling(nr, min_periods=nr).apply(
|
||||||
|
lambda w: 1.0 if w[-1] == np.min(w) else 0.0, raw=True).values
|
||||||
|
state = np.zeros(len(c))
|
||||||
|
cur = 0.0
|
||||||
|
hold_left = 0
|
||||||
|
armed = False
|
||||||
|
arm_hi = arm_lo = np.nan
|
||||||
|
for i in range(len(c)):
|
||||||
|
if hold_left > 0:
|
||||||
|
hold_left -= 1
|
||||||
|
else:
|
||||||
|
cur = 0.0
|
||||||
|
if armed:
|
||||||
|
if c[i] > arm_hi:
|
||||||
|
cur = 1.0
|
||||||
|
hold_left = hold
|
||||||
|
armed = False
|
||||||
|
elif c[i] < arm_lo and long_short:
|
||||||
|
cur = -1.0
|
||||||
|
hold_left = hold
|
||||||
|
armed = False
|
||||||
|
if is_nr[i] == 1.0:
|
||||||
|
armed = True
|
||||||
|
arm_hi = h[i]
|
||||||
|
arm_lo = l[i]
|
||||||
|
state[i] = cur
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def sig_decl_vol_reversal(df, bpd, mom_win=10, vwin=10, **_):
|
||||||
|
"""Declining-volume reversal (fade): after an up-move on DECLINING volume, fade (short);
|
||||||
|
after a down-move on declining volume, go long. Pure contrarian, vol-confirmed exhaustion."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
vol = df["volume"].values.astype(float)
|
||||||
|
ret = pd.Series(c).pct_change(mom_win).values
|
||||||
|
vtrend = vol - roll_mean_prior(vol, vwin)
|
||||||
|
declining = vtrend < 0
|
||||||
|
state = np.zeros(len(c))
|
||||||
|
state[(ret > 0) & declining] = -1.0
|
||||||
|
state[(ret < 0) & declining] = 1.0
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
SIGNALS = {
|
||||||
|
"VT-long": (sig_vt_long, dict(target_vol=0.20, vol_win_days=30, lev=2.0)),
|
||||||
|
"VolBreakout": (sig_vol_breakout, dict(don=20, zwin=20, zk=1.0)),
|
||||||
|
"OBV-trend": (sig_obv_trend, dict(ma=30)),
|
||||||
|
"VW-mom": (sig_vw_momentum, dict(mom_win=30, vol_win_days=30)),
|
||||||
|
"RangeExpand": (sig_range_expansion, dict(rng_win=20, k=1.5, hold=5)),
|
||||||
|
"NR7-break": (sig_nr_breakout, dict(nr=7, hold=5)),
|
||||||
|
"DeclVolRev": (sig_decl_vol_reversal, dict(mom_win=10, vwin=10)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Evaluation
|
||||||
|
# ===========================================================================
|
||||||
|
def eval_signal(fn, params, tf, asset, fee_side=FEE_SIDE):
|
||||||
|
df = resample_tf(load(asset, "1h"), tf)
|
||||||
|
bpd = TF_BPD[tf]
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
idx = pd.to_datetime(df["datetime"].values)
|
||||||
|
tgt = fn(df, bpd, **params)
|
||||||
|
net, pos, turn = net_from_target(tgt, r, fee_side)
|
||||||
|
m = metrics(net, idx, turn, bpy)
|
||||||
|
# OOS split
|
||||||
|
cut = int(len(net) * OOS_FRAC)
|
||||||
|
mi = metrics(net[:cut], idx[:cut], turn[:cut], bpy)
|
||||||
|
mo = metrics(net[cut:], idx[cut:], turn[cut:], bpy)
|
||||||
|
return dict(net=net, idx=idx, full=m, is_=mi, oos=mo, py=per_year(net, idx))
|
||||||
|
|
||||||
|
|
||||||
|
def tp01_net(asset, tf):
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
df = resample_tf(load(asset, "1h"), tf)
|
||||||
|
net, ts = tp.net_returns(df)
|
||||||
|
return pd.Series(net, index=pd.to_datetime(ts.values))
|
||||||
|
|
||||||
|
|
||||||
|
def corr_to_tp01(net, idx, tp_series):
|
||||||
|
s = pd.Series(net, index=idx)
|
||||||
|
j = pd.concat([s.rename("a"), tp_series.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||||
|
if j["a"].std() == 0 or j["b"].std() == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(j["a"].corr(j["b"]))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Reports
|
||||||
|
# ===========================================================================
|
||||||
|
def report_headline(tf, quick):
|
||||||
|
print("\n" + "=" * 120)
|
||||||
|
print(f"# HEADLINE — TF {tf} | standalone signals, full / IS / OOS, turnover, corr→TP01 (fee 0.10% RT)")
|
||||||
|
print("=" * 120)
|
||||||
|
tp = {a: tp01_net(a, tf) for a in ASSETS}
|
||||||
|
print(f" {'signal':<14s}{'asset':<6s}"
|
||||||
|
f"{'fullShrp':>9s}{'fullCAGR':>9s}{'fullDD':>7s}"
|
||||||
|
f"{'IS_Shrp':>8s}{'OOS_Shrp':>9s}{'OOS_ret':>8s}{'turn/y':>8s}{'corrTP':>8s}")
|
||||||
|
results = {}
|
||||||
|
for name, (fn, params) in SIGNALS.items():
|
||||||
|
for a in ASSETS:
|
||||||
|
res = eval_signal(fn, params, tf, a)
|
||||||
|
cr = corr_to_tp01(res["net"], res["idx"], tp[a])
|
||||||
|
results[(name, a)] = (res, cr)
|
||||||
|
print(f" {name:<14s}{a:<6s}"
|
||||||
|
f"{res['full']['sharpe']:>9.2f}{res['full']['cagr']*100:>8.1f}%"
|
||||||
|
f"{res['full']['max_dd']*100:>6.1f}%"
|
||||||
|
f"{res['is_']['sharpe']:>8.2f}{res['oos']['sharpe']:>9.2f}"
|
||||||
|
f"{res['oos']['total']*100:>7.1f}%{res['full']['ann_turnover']:>8.1f}{cr:>8.2f}")
|
||||||
|
return results, tp
|
||||||
|
|
||||||
|
|
||||||
|
def report_peryear(results):
|
||||||
|
print("\n" + "-" * 120)
|
||||||
|
print("# PER-YEAR net return (%) — only signals with OOS Sharpe>0 on BOTH assets shown")
|
||||||
|
print("-" * 120)
|
||||||
|
years = list(range(2018, 2027))
|
||||||
|
# which signals pass OOS>0 both assets
|
||||||
|
good = []
|
||||||
|
for name in SIGNALS:
|
||||||
|
if all(results[(name, a)][0]["oos"]["sharpe"] > 0 for a in ASSETS):
|
||||||
|
good.append(name)
|
||||||
|
if not good:
|
||||||
|
print(" (none — no signal has positive OOS Sharpe on BOTH assets)")
|
||||||
|
return good
|
||||||
|
print(" " + " " * 22 + "".join(f"{y:>7d}" for y in years))
|
||||||
|
for name in good:
|
||||||
|
for a in ASSETS:
|
||||||
|
py = results[(name, a)][0]["py"]
|
||||||
|
row = "".join((" . " if y not in py else f"{py[y]*100:>+7.0f}") for y in years)
|
||||||
|
print(f" {name+' '+a:<22s}{row}")
|
||||||
|
return good
|
||||||
|
|
||||||
|
|
||||||
|
def report_grid(quick):
|
||||||
|
print("\n" + "=" * 120)
|
||||||
|
print("# GRID ROBUSTNESS (TF 12h) — fraction of cells with positive full+OOS Sharpe on BOTH assets")
|
||||||
|
print("=" * 120)
|
||||||
|
tf = "12h"
|
||||||
|
grids = {
|
||||||
|
"VolBreakout": ("sig", sig_vol_breakout,
|
||||||
|
dict(don=[10, 20, 40] if not quick else [20],
|
||||||
|
zwin=[10, 20, 40], zk=[0.5, 1.0, 2.0])),
|
||||||
|
"OBV-trend": ("sig", sig_obv_trend, dict(ma=[15, 30, 60, 100])),
|
||||||
|
"VW-mom": ("sig", sig_vw_momentum,
|
||||||
|
dict(mom_win=[15, 30, 60, 90], long_only=[True])),
|
||||||
|
"RangeExpand": ("sig", sig_range_expansion,
|
||||||
|
dict(rng_win=[10, 20, 40], k=[1.3, 1.5, 2.0], hold=[3, 5, 10])),
|
||||||
|
"VT-long": ("sig", sig_vt_long, dict(target_vol=[0.15, 0.20, 0.30],
|
||||||
|
vol_win_days=[15, 30, 60])),
|
||||||
|
}
|
||||||
|
from itertools import product
|
||||||
|
for name, (_, fn, axes) in grids.items():
|
||||||
|
keys = list(axes.keys())
|
||||||
|
combos = list(product(*[axes[k] for k in keys]))
|
||||||
|
npos = 0
|
||||||
|
best = (-9, None)
|
||||||
|
for combo in combos:
|
||||||
|
params = dict(zip(keys, combo))
|
||||||
|
ok = True
|
||||||
|
sh_sum = 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
res = eval_signal(fn, params, tf, a)
|
||||||
|
if not (res["full"]["sharpe"] > 0 and res["oos"]["sharpe"] > 0):
|
||||||
|
ok = False
|
||||||
|
sh_sum += res["oos"]["sharpe"]
|
||||||
|
if ok:
|
||||||
|
npos += 1
|
||||||
|
if sh_sum > best[0]:
|
||||||
|
best = (sh_sum, params)
|
||||||
|
print(f" {name:<14s} positive both(full&OOS): {npos:>3d}/{len(combos):<3d} "
|
||||||
|
f"({npos/len(combos)*100:>4.0f}%) best OOS-sum cfg: {best[1]}")
|
||||||
|
|
||||||
|
|
||||||
|
def report_feesweep():
|
||||||
|
print("\n" + "=" * 120)
|
||||||
|
print("# FEE SWEEP (TF 12h) — OOS Sharpe (BTC/ETH) vs round-trip fee for the headline signals")
|
||||||
|
print("=" * 120)
|
||||||
|
tf = "12h"
|
||||||
|
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002] # per side; RT = 2x
|
||||||
|
print(f" {'signal':<14s}" + "".join(f" RT{f*2*100:>4.2f}%" for f in fees))
|
||||||
|
for name, (fn, params) in SIGNALS.items():
|
||||||
|
cells = []
|
||||||
|
for f in fees:
|
||||||
|
shs = []
|
||||||
|
for a in ASSETS:
|
||||||
|
res = eval_signal(fn, params, tf, a, fee_side=f)
|
||||||
|
shs.append(res["oos"]["sharpe"])
|
||||||
|
cells.append(f"{shs[0]:>4.2f}/{shs[1]:<4.2f}")
|
||||||
|
print(f" {name:<14s}" + "".join(f" {c:>9s}" for c in cells))
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# REGIME FILTER on TP01 — does a vol/volume regime mask lift Sharpe or cut DD?
|
||||||
|
# ===========================================================================
|
||||||
|
def vol_regime_mask(df, bpd, win_days=30, mode="low", q=0.5):
|
||||||
|
"""Boolean per-bar mask (decided <= close[i]) for a realized-vol regime.
|
||||||
|
mode='low': keep exposure when vol <= rolling median; 'high': when vol > median."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
vol = realized_vol(r, win_days * bpd, bpy)
|
||||||
|
# causal expanding/rolling quantile threshold (use a long rolling window, prior bars)
|
||||||
|
thr = pd.Series(vol).shift(1).rolling(180 * bpd, min_periods=30 * bpd).quantile(q).values
|
||||||
|
if mode == "low":
|
||||||
|
mask = vol <= thr
|
||||||
|
else:
|
||||||
|
mask = vol > thr
|
||||||
|
return np.nan_to_num(mask.astype(float), nan=1.0) # default keep before warmup
|
||||||
|
|
||||||
|
|
||||||
|
def vol_managed_mask(df, bpd, win_days=30, target_vol=0.20, cap=1.5):
|
||||||
|
"""Continuous vol-scaling multiplier on TP01: scale exposure by target_vol/realized_vol,
|
||||||
|
capped — an explicit volatility-managed overlay distinct from TP01's own sizing."""
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
vol = realized_vol(r, win_days * bpd, bpy)
|
||||||
|
mult = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 1.0)
|
||||||
|
return np.clip(mult, 0.0, cap)
|
||||||
|
|
||||||
|
|
||||||
|
def report_regime_filter(tf="12h"):
|
||||||
|
print("\n" + "=" * 120)
|
||||||
|
print(f"# REGIME FILTER on TP01 (TF {tf}) — apply a vol mask/overlay to TP01 target, 50/50 portfolio")
|
||||||
|
print("=" * 120)
|
||||||
|
bpd = TF_BPD[tf]
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
tp = TrendPortfolio(**CANONICAL)
|
||||||
|
|
||||||
|
def portfolio(transform):
|
||||||
|
"""transform(df,target)->target'; returns combined 50/50 net series + idx."""
|
||||||
|
series = {}
|
||||||
|
for a in ASSETS:
|
||||||
|
df = resample_tf(load(a, "1h"), tf)
|
||||||
|
r = simple_returns(df["close"].values.astype(float))
|
||||||
|
tgt = tp.target_series(df)
|
||||||
|
tgt2 = transform(df, tgt)
|
||||||
|
net, _, _ = net_from_target(tgt2, r, CANONICAL["fee_side"])
|
||||||
|
series[a] = pd.Series(net, index=pd.to_datetime(df["datetime"].values))
|
||||||
|
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||||
|
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
|
||||||
|
return combo, J.index
|
||||||
|
|
||||||
|
variants = {
|
||||||
|
"TP01 baseline": lambda df, t: t,
|
||||||
|
"× keep LOW-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.5),
|
||||||
|
"× keep HIGH-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="high", q=0.5),
|
||||||
|
"× keep LOW-vol q.7": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.7),
|
||||||
|
"× vol-managed x1.5": lambda df, t: t * vol_managed_mask(df, bpd, cap=1.5) /
|
||||||
|
np.maximum(vol_managed_mask(df, bpd, cap=1.5).mean(), 1e-9),
|
||||||
|
"× obv-up only": lambda df, t: t * (np.where(
|
||||||
|
np.cumsum(np.sign(simple_returns(df['close'].values.astype(float))) * df['volume'].values)
|
||||||
|
> pd.Series(np.cumsum(np.sign(simple_returns(df['close'].values.astype(float)))
|
||||||
|
* df['volume'].values)).ewm(span=30, adjust=False).mean().values,
|
||||||
|
1.0, 0.0)),
|
||||||
|
}
|
||||||
|
print(f" {'variant':<22s}{'fullShrp':>9s}{'IS_Shrp':>8s}{'OOS_Shrp':>9s}"
|
||||||
|
f"{'CAGR':>8s}{'maxDD':>8s}{'turn/y':>9s}")
|
||||||
|
for name, tr in variants.items():
|
||||||
|
combo, idx = portfolio(tr)
|
||||||
|
m = metrics(combo, idx, np.zeros_like(combo), bpy)
|
||||||
|
cut = int(len(combo) * OOS_FRAC)
|
||||||
|
mi = metrics(combo[:cut], idx[:cut], np.zeros_like(combo[:cut]), bpy)
|
||||||
|
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
|
||||||
|
tt = 0.0
|
||||||
|
for a in ASSETS:
|
||||||
|
df = resample_tf(load(a, "1h"), tf)
|
||||||
|
tgt2 = tr(df, tp.target_series(df))
|
||||||
|
tt += np.sum(np.abs(np.diff(np.nan_to_num(tgt2), prepend=0.0)))
|
||||||
|
ann_tt = tt / m["years"] / 2.0
|
||||||
|
print(f" {name:<22s}{m['sharpe']:>9.2f}{mi['sharpe']:>8.2f}{mo['sharpe']:>9.2f}"
|
||||||
|
f"{m['cagr']*100:>7.1f}%{m['max_dd']*100:>7.1f}%{ann_tt:>9.1f}")
|
||||||
|
|
||||||
|
# robustness of the OBV-up filter across EMA spans (is 1.49 luck or stable?)
|
||||||
|
print("\n OBV-up filter robustness across EMA span (full / OOS Sharpe, maxDD):")
|
||||||
|
for span in [15, 20, 30, 45, 60, 90]:
|
||||||
|
def tr(df, t, sp=span):
|
||||||
|
c = df['close'].values.astype(float)
|
||||||
|
v = df['volume'].values.astype(float)
|
||||||
|
obv = np.cumsum(np.sign(simple_returns(c)) * v)
|
||||||
|
ema = pd.Series(obv).ewm(span=sp, adjust=False).mean().values
|
||||||
|
return t * np.where(obv > ema, 1.0, 0.0)
|
||||||
|
combo, idx = portfolio(tr)
|
||||||
|
m = metrics(combo, idx, np.zeros_like(combo), bpy)
|
||||||
|
cut = int(len(combo) * OOS_FRAC)
|
||||||
|
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
|
||||||
|
py = per_year(combo, idx)
|
||||||
|
neg_years = sum(1 for y, v in py.items() if v < 0)
|
||||||
|
print(f" span {span:>3d}: full {m['sharpe']:>4.2f} OOS {mo['sharpe']:>4.2f} "
|
||||||
|
f"DD {m['max_dd']*100:>4.1f}% CAGR {m['cagr']*100:>5.1f}% neg-years {neg_years}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--quick", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
print("#" * 120)
|
||||||
|
print("# TRACK H — VOLUME / RANGE / VOLATILITY-REGIME on certified BTC/ETH (Deribit mainnet)")
|
||||||
|
print("# Honest engine: target decided <=close[i], held bar i+1; fee on |Δpos|; OOS 65/35; >=12h only.")
|
||||||
|
print("#" * 120)
|
||||||
|
|
||||||
|
tfs = ["12h"] if args.quick else ["12h", "1d"]
|
||||||
|
for tf in tfs:
|
||||||
|
results, tp = report_headline(tf, args.quick)
|
||||||
|
report_peryear(results)
|
||||||
|
if tf == "12h":
|
||||||
|
crosscheck_backtest_signals()
|
||||||
|
report_grid(args.quick)
|
||||||
|
report_feesweep()
|
||||||
|
report_regime_filter("12h")
|
||||||
|
|
||||||
|
print("\n" + "#" * 120)
|
||||||
|
print("# VERDICT (track H) — honest reading of the tables above")
|
||||||
|
print("#" * 120)
|
||||||
|
for line in [
|
||||||
|
"1. NO uncorrelated additive edge found. Every PROFITABLE volume/range/vol signal",
|
||||||
|
" (VolBreakout, OBV-trend, VW-mom, VT-long) is trend-in-disguise: corr-to-TP01 0.61-0.75.",
|
||||||
|
" They do not diversify TP01 -> cannot raise the 50/50 portfolio Sharpe.",
|
||||||
|
"2. The genuinely LOWER-corr signals (RangeExpand ~0.48, NR7 ~0.48) FAIL OOS on >=1 asset",
|
||||||
|
" (NR7 ETH OOS Sharpe ~0.0/-0.03; RangeExpand BTC weak, ETH negative on 1d). Not deployable.",
|
||||||
|
"3. Declining-volume / fade (mean-reversion) is firmly NEGATIVE net of fees on both assets",
|
||||||
|
" and at ZERO fee -> confirms the v2.0.0 lesson: MR edge was feed contamination, it is dead.",
|
||||||
|
"4. Vol-REGIME gating of TP01 (keep low-vol / keep high-vol) HURTS Sharpe (1.32 -> 0.94/0.98).",
|
||||||
|
" A vol-managed x1.5 overlay leaves Sharpe ~flat (1.33) but raises DD (17.9%). No win.",
|
||||||
|
"5. The ONLY non-harmful overlay is an OBV-up trend-CONFIRMATION filter (keep TP01 long only",
|
||||||
|
" while OBV>EMA): full Sharpe 1.32->1.49, maxDD 13.3%->10.1%, but CAGR 16.2%->14.4%, turnover",
|
||||||
|
" +60%, and OOS gain is marginal (0.90->1.04) and span-sensitive (fades for EMA>45). It is",
|
||||||
|
" trend double-confirmation (de-risking), NOT new alpha. Worth noting as a DEFENSIVE overlay",
|
||||||
|
" if cutting DD matters more than CAGR; it does NOT robustly raise the portfolio Sharpe.",
|
||||||
|
"BOTTOM LINE: the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only HOLDS. Volume/range/vol add",
|
||||||
|
"nothing uncorrelated. TP01 stays the deployable winner.",
|
||||||
|
]:
|
||||||
|
print(" " + line)
|
||||||
|
print("#" * 120)
|
||||||
|
|
||||||
|
|
||||||
|
def crosscheck_backtest_signals():
|
||||||
|
"""Cross-check two DISCRETE signals through the canonical harness `backtest_signals`
|
||||||
|
(decide<=close[i], fill at close[i]) to confirm the per-bar engine isn't flattering them."""
|
||||||
|
print("\n" + "-" * 120)
|
||||||
|
print("# CROSS-CHECK via harness.backtest_signals (discrete entries, fee 0.10% RT, TF 12h)")
|
||||||
|
print("-" * 120)
|
||||||
|
tf = "12h"
|
||||||
|
for a in ASSETS:
|
||||||
|
df = resample_tf(load(a, "1h"), tf)
|
||||||
|
h = df["high"].values.astype(float)
|
||||||
|
l = df["low"].values.astype(float)
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
rng = h - l
|
||||||
|
avg = roll_mean_prior(rng, 20)
|
||||||
|
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
|
||||||
|
expand = rng > 1.5 * avg
|
||||||
|
entries = [None] * len(df)
|
||||||
|
for i in range(len(df)):
|
||||||
|
if expand[i] and pos_in_bar[i] > 0.6:
|
||||||
|
entries[i] = dict(dir=1, tp=None, sl=None, max_bars=5)
|
||||||
|
m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0, asset=a, tf=tf)
|
||||||
|
m.print_summary(f"RangeExpand(L,5b) {a}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""TRACK I — ALTERNATIVE MOMENTUM FORMULATIONS + LONG-HORIZON REVERSAL (BTC & ETH, >=12h).
|
||||||
|
|
||||||
|
Goal:
|
||||||
|
(A) Find a momentum formulation that BEATS or DIVERSIFIES the canonical TP01 sign-blend
|
||||||
|
(TSMOM 1-3-6m, vol-targeted, 50/50 BTC+ETH, 12h, Sharpe ~1.32).
|
||||||
|
(B) Test the classic LONG-HORIZON REVERSAL effect (fade 12/18/24-month winners) as a
|
||||||
|
potentially UNCORRELATED positive overlay, and a momentum+reversal blend.
|
||||||
|
|
||||||
|
Honest harness (mirrors src/strategies/trend_portfolio.py exactly):
|
||||||
|
- direction decided with data <= close[i]; positions HELD next bar (pos_held[1:] = tgt[:-1]);
|
||||||
|
- vol-target by inverse PAST-ONLY realized vol (target_vol/vol), leverage-capped;
|
||||||
|
- NET fees 0.10% RT (0.05%/side) on turnover; fee sweep included;
|
||||||
|
- 12h / 1d only (sub-12h is dominated by costs/overfit and a prior 4h look-ahead bug);
|
||||||
|
- OOS 65/35 split + per-year; robustness across lookbacks AND both assets;
|
||||||
|
- correlation vs TP01 net returns reported for EVERY candidate.
|
||||||
|
|
||||||
|
A candidate is INTERESTING only if net-positive OOS on BOTH assets AND either
|
||||||
|
(higher portfolio Sharpe than TP01 ~1.32) OR (|corr to TP01| < ~0.3 and positive).
|
||||||
|
|
||||||
|
Run: uv run python scripts/research/trackI_momentum_reversal.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from src.strategies.trend_portfolio import resample_tf, simple_returns, realized_vol
|
||||||
|
|
||||||
|
ASSETS = ["BTC", "ETH"]
|
||||||
|
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||||
|
TARGET_VOL = 0.20
|
||||||
|
LEVERAGE = 2.0
|
||||||
|
VOL_WIN_DAYS = 30
|
||||||
|
OOS_FRAC = 0.65
|
||||||
|
MONTH = 30 # days per "month" (calendar-consistent across TFs)
|
||||||
|
|
||||||
|
# tf -> bars_per_day
|
||||||
|
TF_BPD = {"12h": 2, "1d": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||||
|
df = load(asset, "1h")
|
||||||
|
rule = {"12h": "12h", "1d": "1D"}[tf]
|
||||||
|
return resample_tf(df, rule)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# vol-target machinery (identical convention to TP01)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def build_target(direction, vol, long_only):
|
||||||
|
d = np.clip(direction, 0, None) if long_only else direction
|
||||||
|
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
|
||||||
|
tgt = np.clip(d * scal, -LEVERAGE, LEVERAGE)
|
||||||
|
tgt[~np.isfinite(tgt)] = 0.0
|
||||||
|
return tgt
|
||||||
|
|
||||||
|
|
||||||
|
def net_from_target(tgt, r, fee_side=FEE_SIDE):
|
||||||
|
pos_held = np.zeros(len(tgt))
|
||||||
|
pos_held[1:] = tgt[:-1]
|
||||||
|
gross = pos_held * r
|
||||||
|
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||||
|
net = gross - fee_side * turn
|
||||||
|
net[0] = 0.0
|
||||||
|
return np.clip(net, -0.99, None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DIRECTION FORMULATIONS (each returns array in roughly [-1, 1], causal, decided <= close[i])
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _log_mom(c, h):
|
||||||
|
"""log return over h bars; nan before h."""
|
||||||
|
m = np.full(len(c), np.nan)
|
||||||
|
m[h:] = np.log(c[h:] / c[:-h])
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
def dir_signblend(c, bpd, horizons_m=(1, 3, 6)):
|
||||||
|
"""TP01 baseline: mean of sign(log return) over horizons."""
|
||||||
|
n = len(c)
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for hm in horizons_m:
|
||||||
|
h = hm * MONTH * bpd
|
||||||
|
s = np.full(n, np.nan)
|
||||||
|
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||||
|
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dir_zscore(c, bpd, horizons_m=(1, 3, 6), std_win_m=12):
|
||||||
|
"""(i) Continuous momentum: z-scored cumulative log-return, tanh-bounded, multi-horizon avg."""
|
||||||
|
n = len(c); w = std_win_m * MONTH * bpd
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for hm in horizons_m:
|
||||||
|
h = hm * MONTH * bpd
|
||||||
|
m = _log_mom(c, h)
|
||||||
|
s = pd.Series(m)
|
||||||
|
sd = s.rolling(w, min_periods=w // 3).std().values
|
||||||
|
z = np.where((sd > 0) & np.isfinite(sd), m / sd, np.nan)
|
||||||
|
d = np.tanh(z)
|
||||||
|
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dir_riskadj(c, bpd, horizons_m=(1, 3, 6)):
|
||||||
|
"""(ii) Risk-adjusted momentum: h-horizon return / vol-of-that-horizon, tanh, multi-horizon."""
|
||||||
|
n = len(c); r = simple_returns(c)
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for hm in horizons_m:
|
||||||
|
h = hm * MONTH * bpd
|
||||||
|
ret = np.full(n, np.nan); ret[h:] = c[h:] / c[:-h] - 1.0
|
||||||
|
# vol of the h-bar return = per-bar std over last h bars * sqrt(h)
|
||||||
|
sd = pd.Series(r).rolling(h, min_periods=h // 2).std().values * np.sqrt(h)
|
||||||
|
ra = np.where((sd > 0) & np.isfinite(sd), ret / sd, np.nan)
|
||||||
|
d = np.tanh(ra)
|
||||||
|
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ema(c, span):
|
||||||
|
return pd.Series(c).ewm(span=span, adjust=False).mean().values
|
||||||
|
|
||||||
|
|
||||||
|
def dir_emacross(c, bpd, pairs_m=((1, 3), (2, 6), (3, 9))):
|
||||||
|
"""(iii) EMA-cross trend: mean of sign(ema_fast - ema_slow) over calendar-day pairs."""
|
||||||
|
n = len(c)
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for fm, sm in pairs_m:
|
||||||
|
ef = _ema(c, fm * MONTH * bpd)
|
||||||
|
es = _ema(c, sm * MONTH * bpd)
|
||||||
|
warm = sm * MONTH * bpd
|
||||||
|
d = np.sign(ef - es)
|
||||||
|
d[:warm] = np.nan
|
||||||
|
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dir_macd(c, bpd):
|
||||||
|
"""(iii-b) Classic MACD with calendar spans (fast~1m, slow~2m, signal~0.75m): sign(macd-signal)."""
|
||||||
|
n = len(c)
|
||||||
|
fast = int(round(1.0 * MONTH * bpd)); slow = int(round(2.0 * MONTH * bpd))
|
||||||
|
sig = int(round(0.75 * MONTH * bpd))
|
||||||
|
macd = _ema(c, fast) - _ema(c, slow)
|
||||||
|
signal = pd.Series(macd).ewm(span=sig, adjust=False).mean().values
|
||||||
|
d = np.sign(macd - signal)
|
||||||
|
d[:slow] = 0.0
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def dir_donchian(c, bpd, n_m=2):
|
||||||
|
"""(iv) Donchian breakout (>=12h): +1 if close > prior-N max, -1 if < prior-N min, else hold."""
|
||||||
|
n = len(c); N = n_m * MONTH * bpd
|
||||||
|
hi = pd.Series(c).rolling(N, min_periods=N).max().shift(1).values
|
||||||
|
lo = pd.Series(c).rolling(N, min_periods=N).min().shift(1).values
|
||||||
|
d = np.zeros(n); state = 0.0
|
||||||
|
for i in range(n):
|
||||||
|
if np.isfinite(hi[i]) and c[i] >= hi[i]:
|
||||||
|
state = 1.0
|
||||||
|
elif np.isfinite(lo[i]) and c[i] <= lo[i]:
|
||||||
|
state = -1.0
|
||||||
|
d[i] = state
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def dir_accel(c, bpd, horizons_m=(3, 6), lag_m=1):
|
||||||
|
"""(v) Acceleration: sign of CHANGE in momentum (mom[i] - mom[i-lag]) i.e. 2nd derivative."""
|
||||||
|
n = len(c); lag = lag_m * MONTH * bpd
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for hm in horizons_m:
|
||||||
|
h = hm * MONTH * bpd
|
||||||
|
m = _log_mom(c, h)
|
||||||
|
dm = np.full(n, np.nan)
|
||||||
|
dm[lag:] = m[lag:] - m[:-lag]
|
||||||
|
d = np.sign(dm)
|
||||||
|
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def dir_mom12_1(c, bpd, lookbacks_m=(6, 12), skip_m=1):
|
||||||
|
"""(vi) 12-1 momentum: return from (i-L) to (i-skip), skipping the most-recent `skip` month.
|
||||||
|
For index i (>=L): sign( c[i-skip] / c[i-L] - 1 ). Causal (uses data <= close[i-skip])."""
|
||||||
|
n = len(c); skip = skip_m * MONTH * bpd
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for Lm in lookbacks_m:
|
||||||
|
L = Lm * MONTH * bpd
|
||||||
|
s = np.full(n, np.nan)
|
||||||
|
# i runs L..n-1: c[i-skip] = c[L-skip : n-skip], c[i-L] = c[0 : n-L]
|
||||||
|
s[L:] = np.sign(c[L - skip:n - skip] / c[:n - L] - 1.0)
|
||||||
|
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_reversal(lookbacks_m):
|
||||||
|
"""(B) long-horizon reversal: -sign of long-horizon return (short past winners)."""
|
||||||
|
def fn(c, bpd):
|
||||||
|
n = len(c)
|
||||||
|
acc = np.zeros(n); cnt = np.zeros(n)
|
||||||
|
for Lm in lookbacks_m:
|
||||||
|
L = Lm * MONTH * bpd
|
||||||
|
s = np.full(n, np.nan)
|
||||||
|
s[L:] = -np.sign(c[L:] / c[:-L] - 1.0)
|
||||||
|
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||||
|
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||||
|
return out
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def make_mom_minus_rev(mom_m, rev_m, rev_w=0.5):
|
||||||
|
"""Blend: long medium-term momentum + fade very-long-term extension (weighted)."""
|
||||||
|
def fn(c, bpd):
|
||||||
|
n = len(c)
|
||||||
|
mom = dir_signblend(c, bpd, horizons_m=mom_m)
|
||||||
|
rev_fn = make_reversal(rev_m)
|
||||||
|
rev = rev_fn(c, bpd)
|
||||||
|
return np.clip(mom + rev_w * rev, -1.0, 1.0)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run a formulation -> per-asset net series, combined portfolio series, metrics
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def asset_net_series(asset, tf, dir_fn, long_only, fee_side=FEE_SIDE):
|
||||||
|
df = get_df(asset, tf); bpd = TF_BPD[tf]
|
||||||
|
c = df["close"].values.astype(float)
|
||||||
|
r = simple_returns(c)
|
||||||
|
bpy = bpd * 365.25
|
||||||
|
vol = realized_vol(r, VOL_WIN_DAYS * bpd, bpy)
|
||||||
|
direction = dir_fn(c, bpd)
|
||||||
|
tgt = build_target(direction, vol, long_only)
|
||||||
|
net = net_from_target(tgt, r, fee_side)
|
||||||
|
return pd.Series(net, index=pd.to_datetime(df["datetime"].values))
|
||||||
|
|
||||||
|
|
||||||
|
def portfolio_combo(tf, dir_fn, long_only, fee_side=FEE_SIDE):
|
||||||
|
s = {a: asset_net_series(a, tf, dir_fn, long_only, fee_side) for a in ASSETS}
|
||||||
|
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
|
||||||
|
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
|
||||||
|
return pd.Series(combo, index=J.index), s
|
||||||
|
|
||||||
|
|
||||||
|
def sharpe_of(series, bpy):
|
||||||
|
r = series.values[np.isfinite(series.values)]
|
||||||
|
return float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def metrics_of(combo: pd.Series, bpy):
|
||||||
|
idx = combo.index
|
||||||
|
equity = np.cumprod(1.0 + np.clip(combo.values, -0.99, None))
|
||||||
|
sharpe = sharpe_of(combo, bpy)
|
||||||
|
peak = np.maximum.accumulate(equity)
|
||||||
|
dd = float(np.max((peak - equity) / peak))
|
||||||
|
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
|
||||||
|
total = equity[-1] / equity[0]
|
||||||
|
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||||
|
eq = pd.Series(equity, index=idx)
|
||||||
|
yearly = {}
|
||||||
|
for y, g in eq.groupby(eq.index.year):
|
||||||
|
if len(g) > 1 and g.iloc[0] > 0:
|
||||||
|
v = g.values; pk = np.maximum.accumulate(v)
|
||||||
|
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((pk - v) / pk)))
|
||||||
|
# OOS split
|
||||||
|
k = int(len(combo) * OOS_FRAC)
|
||||||
|
is_sh = sharpe_of(combo.iloc[:k], bpy)
|
||||||
|
oos_sh = sharpe_of(combo.iloc[k:], bpy)
|
||||||
|
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
|
||||||
|
yearly=yearly, is_sharpe=is_sh, oos_sharpe=oos_sh, equity=eq)
|
||||||
|
|
||||||
|
|
||||||
|
ALL_YEARS = list(range(2018, 2027))
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_yearly(yearly):
|
||||||
|
return "".join((" . " if y not in yearly else f"{yearly[y][0]*100:>+6.0f}") for y in ALL_YEARS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PART_A = [
|
||||||
|
("baseline signblend 1-3-6m", dir_signblend),
|
||||||
|
("(i) z-score cum-ret", dir_zscore),
|
||||||
|
("(ii) risk-adj momentum", dir_riskadj),
|
||||||
|
("(iii) EMA-cross trend", dir_emacross),
|
||||||
|
("(iii-b) MACD", dir_macd),
|
||||||
|
("(iv) Donchian breakout", dir_donchian),
|
||||||
|
("(v) acceleration", dir_accel),
|
||||||
|
("(vi) 12-1 skip momentum", dir_mom12_1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def report_block(title, items, tf, long_only, tp_combo, bpy):
|
||||||
|
mode = "LONG-FLAT" if long_only else "LONG-SHORT"
|
||||||
|
print(f"\n{'='*112}\n {title} | TF={tf} mode={mode}\n{'='*112}")
|
||||||
|
print(f" {'formulation':<26s} {'Shrp':>5s} {'IS':>5s} {'OOS':>5s} {'CAGR':>6s} "
|
||||||
|
f"{'maxDD':>6s} {'corrTP':>7s} {'aBTC':>5s} {'aETH':>5s} per-year PnL%")
|
||||||
|
print(f" {'':<26s} {'':>5s} {'':>5s} {'':>5s} {'':>6s} {'':>6s} {'':>7s} {'':>5s} {'':>5s} "
|
||||||
|
+ "".join(f"{y%100:>6d}" for y in ALL_YEARS))
|
||||||
|
results = {}
|
||||||
|
for name, fn in items:
|
||||||
|
combo, sleeves = portfolio_combo(tf, fn, long_only)
|
||||||
|
m = metrics_of(combo, bpy)
|
||||||
|
# per-asset standalone Sharpe
|
||||||
|
a_sh = {a: sharpe_of(sleeves[a], bpy) for a in ASSETS}
|
||||||
|
# correlation to TP01 (aligned inner)
|
||||||
|
J = pd.concat([combo.rename("x"), tp_combo.rename("t")], axis=1, join="inner").dropna()
|
||||||
|
corr = float(np.corrcoef(J["x"], J["t"])[0, 1]) if len(J) > 2 else float("nan")
|
||||||
|
print(f" {name:<26s} {m['sharpe']:>5.2f} {m['is_sharpe']:>5.2f} {m['oos_sharpe']:>5.2f} "
|
||||||
|
f"{m['cagr']*100:>+5.0f}% {m['max_dd']*100:>5.1f}% {corr:>7.2f} "
|
||||||
|
f"{a_sh['BTC']:>5.2f} {a_sh['ETH']:>5.2f} {fmt_yearly(m['yearly'])}")
|
||||||
|
results[name] = dict(metrics=m, corr=corr, combo=combo, a_sh=a_sh)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("#" * 112)
|
||||||
|
print("# TRACK I — alternative momentum formulations + long-horizon reversal (BTCÐ, >=12h)")
|
||||||
|
print("# vol-target 20%, lev cap 2x, fee 0.10% RT, positions +1 bar, 50/50 BTC+ETH. OOS 65/35.")
|
||||||
|
print("#" * 112)
|
||||||
|
|
||||||
|
for tf in ("12h", "1d"):
|
||||||
|
bpy = TF_BPD[tf] * 365.25
|
||||||
|
# TP01 reference combo at this TF (long-flat canonical) for correlation
|
||||||
|
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
|
||||||
|
tp_m = metrics_of(tp_combo, bpy)
|
||||||
|
print(f"\n>>> TP01 reference @ {tf} (long-flat 1-3-6m): "
|
||||||
|
f"Sharpe {tp_m['sharpe']:.2f} IS {tp_m['is_sharpe']:.2f} OOS {tp_m['oos_sharpe']:.2f} "
|
||||||
|
f"CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
|
||||||
|
|
||||||
|
# PART A — long-flat (fair vs canonical) and long-short
|
||||||
|
report_block("PART A — momentum formulations", PART_A, tf, True, tp_combo, bpy)
|
||||||
|
if tf == "12h":
|
||||||
|
report_block("PART A — momentum formulations (long-short)", PART_A, tf, False, tp_combo, bpy)
|
||||||
|
|
||||||
|
# ----- PART B: reversal + blends, focus 12h -----
|
||||||
|
tf = "12h"; bpy = TF_BPD[tf] * 365.25
|
||||||
|
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
|
||||||
|
|
||||||
|
rev_items = [
|
||||||
|
("reversal 12m", make_reversal((12,))),
|
||||||
|
("reversal 18m", make_reversal((18,))),
|
||||||
|
("reversal 24m", make_reversal((24,))),
|
||||||
|
("reversal 12-18-24m", make_reversal((12, 18, 24))),
|
||||||
|
]
|
||||||
|
print("\n\n" + "#" * 112)
|
||||||
|
print("# PART B — LONG-HORIZON REVERSAL (fade past winners). Must be net-positive AND uncorrelated.")
|
||||||
|
print("#" * 112)
|
||||||
|
revB = report_block("PART B — reversal (long-short)", rev_items, tf, False, tp_combo, bpy)
|
||||||
|
# reversal long-flat (long past losers only) for completeness
|
||||||
|
report_block("PART B — reversal (long-flat)", rev_items, tf, True, tp_combo, bpy)
|
||||||
|
|
||||||
|
blend_items = [
|
||||||
|
("mom(1-6) - 0.5*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5)),
|
||||||
|
("mom(1-6) - 1.0*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 1.0)),
|
||||||
|
("mom(1-3) - 0.5*rev(18-24)", make_mom_minus_rev((1, 3), (18, 24), 0.5)),
|
||||||
|
]
|
||||||
|
report_block("PART B — momentum + reversal blend", blend_items, tf, True, tp_combo, bpy)
|
||||||
|
|
||||||
|
# ----- COMBINED PORTFOLIO: TP01 + best diversifier -----
|
||||||
|
print("\n\n" + "#" * 112)
|
||||||
|
print("# COMBINED: TP01 (long-flat) + candidate diversifier, blended on net returns")
|
||||||
|
print("#" * 112)
|
||||||
|
tp_m = metrics_of(tp_combo, bpy)
|
||||||
|
print(f" TP01 alone: Sharpe {tp_m['sharpe']:.3f} CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
|
||||||
|
|
||||||
|
# candidates to try as overlay: the best A formulations + reversal variants
|
||||||
|
overlays = {
|
||||||
|
"z-score": (dir_zscore, True),
|
||||||
|
"risk-adj": (dir_riskadj, True),
|
||||||
|
"12-1 skip": (dir_mom12_1, True),
|
||||||
|
"reversal 12-18-24 LS": (make_reversal((12, 18, 24)), False),
|
||||||
|
"reversal 24m LS": (make_reversal((24,)), False),
|
||||||
|
}
|
||||||
|
for name, (fn, lo) in overlays.items():
|
||||||
|
cand, _ = portfolio_combo(tf, fn, lo)
|
||||||
|
J = pd.concat([tp_combo.rename("t"), cand.rename("c")], axis=1, join="inner").fillna(0.0)
|
||||||
|
corr = float(np.corrcoef(J["t"], J["c"])[0, 1])
|
||||||
|
for w in (0.5, 0.3, 0.2):
|
||||||
|
mix = pd.Series((1 - w) * J["t"].values + w * J["c"].values, index=J.index)
|
||||||
|
mm = metrics_of(mix, bpy)
|
||||||
|
tag = f"TP01 + {w:.0%} {name}"
|
||||||
|
print(f" {tag:<30s} Sharpe {mm['sharpe']:.3f} CAGR {mm['cagr']*100:+5.0f}% "
|
||||||
|
f"maxDD {mm['max_dd']*100:4.1f}% OOS {mm['oos_sharpe']:.2f} (corr={corr:+.2f})")
|
||||||
|
|
||||||
|
# ----- FEE SWEEP (robustness): 0.00 .. 0.40% RT -----
|
||||||
|
print("\n\n" + "#" * 112)
|
||||||
|
print("# FEE SWEEP — portfolio Sharpe @12h across round-trip fees (0.00-0.40% RT)")
|
||||||
|
print("#" * 112)
|
||||||
|
sweep = [
|
||||||
|
("baseline 1-3-6m (LF)", dir_signblend, True),
|
||||||
|
("z-score cum-ret (LF)", dir_zscore, True),
|
||||||
|
("MACD (LF)", dir_macd, True),
|
||||||
|
("mom(1-6)-0.5rev(12-24)(LF)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5), True),
|
||||||
|
("reversal 24m (LS)", make_reversal((24,)), False),
|
||||||
|
]
|
||||||
|
rts = [0.0, 0.0005, 0.0010, 0.0020, 0.0040]
|
||||||
|
print(f" {'formulation':<28s}" + "".join(f"{rt*100:>7.2f}%" for rt in rts) + " (RT)")
|
||||||
|
for name, fn, lo in sweep:
|
||||||
|
row = [sharpe_of(portfolio_combo(tf, fn, lo, fee_side=rt / 2)[0], bpy) for rt in rts]
|
||||||
|
print(f" {name:<28s}" + "".join(f"{v:>8.2f}" for v in row))
|
||||||
|
|
||||||
|
print("\nDone. See verdict in the script docstring / diary.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user