daily multi-asset strategy grid
Browse files- crypto_eval/grid2.py +86 -0
crypto_eval/grid2.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Daily multi-asset strategy families for the panel sweep.
|
| 2 |
+
|
| 3 |
+
All signals use data through t-1: `panel.shift(1)` everywhere.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _vol_scale(panel: pd.DataFrame, target: float, window: int) -> pd.DataFrame:
|
| 12 |
+
ret = panel.pct_change()
|
| 13 |
+
rv = ret.rolling(window).std().shift(1) * np.sqrt(365)
|
| 14 |
+
return (target / rv).clip(upper=1.0).fillna(0.0)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def tsmom_panel(panel: pd.DataFrame, lookback: int, long_only: bool = False,
|
| 18 |
+
vol_target: float | None = None,
|
| 19 |
+
vol_window: int = 60) -> pd.DataFrame:
|
| 20 |
+
c = panel.shift(1)
|
| 21 |
+
mom = c / c.shift(lookback) - 1
|
| 22 |
+
sig = pd.DataFrame(np.where(mom > 0, 1.0, -1.0),
|
| 23 |
+
index=panel.index, columns=panel.columns)
|
| 24 |
+
if long_only:
|
| 25 |
+
sig = sig.clip(lower=0.0)
|
| 26 |
+
if vol_target is not None:
|
| 27 |
+
sig = sig * _vol_scale(panel, vol_target, vol_window)
|
| 28 |
+
# pre-listing: no data -> flat
|
| 29 |
+
return sig.where(panel.notna(), 0.0).fillna(0.0)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def donchian_panel(panel: pd.DataFrame, window: int, long_only: bool = False,
|
| 33 |
+
vol_target: float | None = None) -> pd.DataFrame:
|
| 34 |
+
# channel uses highs/lows we don't have on the index panel; approximate
|
| 35 |
+
# with rolling max/min of closes through t-2 (close-based breakout)
|
| 36 |
+
c = panel.shift(1)
|
| 37 |
+
upper = panel.rolling(window).max().shift(2)
|
| 38 |
+
lower = panel.rolling(window).min().shift(2)
|
| 39 |
+
pos = pd.DataFrame(np.nan, index=panel.index, columns=panel.columns)
|
| 40 |
+
pos[c > upper] = 1.0
|
| 41 |
+
if long_only:
|
| 42 |
+
pos[c < lower] = 0.0
|
| 43 |
+
else:
|
| 44 |
+
pos[c < lower] = -1.0
|
| 45 |
+
pos = pos.ffill().fillna(0.0)
|
| 46 |
+
if vol_target is not None:
|
| 47 |
+
pos = pos * _vol_scale(panel, vol_target, 60)
|
| 48 |
+
return pos.where(panel.notna(), 0.0)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def xs_momentum(panel: pd.DataFrame, lookback: int, top_k: int = 2,
|
| 52 |
+
short_bottom: bool = False, rebalance: str = "W") -> pd.DataFrame:
|
| 53 |
+
"""Cross-sectional momentum: long top-k by trailing return, optional
|
| 54 |
+
short bottom-k, rebalanced weekly (Mondays) or monthly."""
|
| 55 |
+
c = panel.shift(1)
|
| 56 |
+
mom = c / c.shift(lookback) - 1
|
| 57 |
+
ranks = mom.rank(axis=1, ascending=False)
|
| 58 |
+
n = panel.shape[1]
|
| 59 |
+
tgt = pd.DataFrame(0.0, index=panel.index, columns=panel.columns)
|
| 60 |
+
tgt[ranks <= top_k] = 1.0 / top_k
|
| 61 |
+
if short_bottom:
|
| 62 |
+
tgt[ranks > n - top_k] = -1.0 / top_k
|
| 63 |
+
idx = panel.index
|
| 64 |
+
if rebalance == "W":
|
| 65 |
+
mask = pd.Series(idx.dayofweek == 0, index=idx)
|
| 66 |
+
else: # monthly: first listed trading day of each month
|
| 67 |
+
mask = idx.to_series().groupby(idx.to_period("M")).transform("min").eq(idx)
|
| 68 |
+
pos = tgt.where(mask, np.nan).ffill().fillna(0.0)
|
| 69 |
+
return pos.where(panel.notna(), 0.0)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def xs_momentum_tsmom_filter(panel: pd.DataFrame, lookback: int, top_k: int = 2,
|
| 73 |
+
trend_lookback: int = 100,
|
| 74 |
+
rebalance: str = "W") -> pd.DataFrame:
|
| 75 |
+
"""XS momentum gated by per-asset trend: only hold top-k names that are
|
| 76 |
+
also above their own trend_lookback MA."""
|
| 77 |
+
pos = xs_momentum(panel, lookback, top_k, False, rebalance)
|
| 78 |
+
c = panel.shift(1)
|
| 79 |
+
above = c > c.rolling(trend_lookback).mean()
|
| 80 |
+
return pos.where(above, 0.0).fillna(0.0).where(panel.notna(), 0.0)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def buy_and_hold_btc(panel: pd.DataFrame) -> pd.DataFrame:
|
| 84 |
+
pos = pd.DataFrame(0.0, index=panel.index, columns=panel.columns)
|
| 85 |
+
pos["BTC"] = 1.0
|
| 86 |
+
return pos
|