Avi-L commited on
Commit
eb9cbc3
·
verified ·
1 Parent(s): 5cd51b9

parameterized strategy grid (donchian breakout fix)

Browse files
Files changed (1) hide show
  1. crypto_eval/grid.py +130 -0
crypto_eval/grid.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parameterized strategy families for the sweep.
2
+
3
+ Every function returns positions (units of base asset) indexed by bar
4
+ start time, and uses ONLY information available at decision time t:
5
+ closes/highs/lows shifted by 1 (the closed bar is t-1), funding ticks
6
+ shifted by 1. The harness audit + engine catch peeking downstream.
7
+
8
+ Families (grounded in the literature survey, 2026-10):
9
+ tsmom time-series momentum, lookbacks 1d-200d, long/short or
10
+ long-only, optional vol targeting, optional rebalance band
11
+ donchian breakout channels 20d/55d, same overlays
12
+ carry funding-level harvest on the perp (expected to fail post-2024)
13
+ funding_extreme asymmetric long event on extreme negative funding
14
+ meanrev 1h mean reversion (documented cost-loser; sweep as control)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ BARS_PER_YEAR = 24 * 365
23
+
24
+
25
+ def _ann_vol(close: pd.Series, window: int) -> pd.Series:
26
+ return close.pct_change().rolling(window).std() * np.sqrt(BARS_PER_YEAR)
27
+
28
+
29
+ def _vol_scale(close: pd.Series, target: float, window: int) -> pd.Series:
30
+ rv = _ann_vol(close, window).shift(1) # vol realized through t-1
31
+ return (target / rv).clip(upper=1.0).fillna(0.0)
32
+
33
+
34
+ def _apply_band(target: pd.Series, band: float) -> pd.Series:
35
+ """Hold the previous position until the target moves away by > band."""
36
+ arr = target.to_numpy()
37
+ pos = np.empty_like(arr)
38
+ cur = 0.0
39
+ for i in range(len(arr)):
40
+ if abs(arr[i] - cur) > band:
41
+ cur = arr[i]
42
+ pos[i] = cur
43
+ return pd.Series(pos, index=target.index)
44
+
45
+
46
+ def tsmom(bars: pd.DataFrame, funding: pd.Series, lookback: int,
47
+ long_only: bool = False, vol_target: float | None = None,
48
+ vol_window: int = 720, band: float | None = None) -> pd.Series:
49
+ c = bars["close"].shift(1)
50
+ mom = c / c.shift(lookback) - 1
51
+ target = pd.Series(np.where(mom > 0, 1.0, -1.0), index=bars.index)
52
+ if long_only:
53
+ target = target.clip(lower=0.0)
54
+ if vol_target is not None:
55
+ target = target * _vol_scale(bars["close"], vol_target, vol_window)
56
+ if band is not None:
57
+ target = _apply_band(target, band)
58
+ return target.fillna(0.0)
59
+
60
+
61
+ def donchian(bars: pd.DataFrame, funding: pd.Series, window: int,
62
+ long_only: bool = False, vol_target: float | None = None,
63
+ vol_window: int = 720) -> pd.Series:
64
+ c = bars["close"].shift(1)
65
+ # channel through bar t-2 (rolling then shift): the close of bar t-1
66
+ # can actually exceed it; including t-1's own high/low makes breakout
67
+ # impossible (close <= high of the same bar, always).
68
+ upper = bars["high"].rolling(window).max().shift(2)
69
+ lower = bars["low"].rolling(window).min().shift(2)
70
+ pos = pd.Series(np.nan, index=bars.index)
71
+ pos[c > upper] = 1.0
72
+ if long_only:
73
+ pos[c < lower] = 0.0
74
+ else:
75
+ pos[c < lower] = -1.0
76
+ pos = pos.ffill().fillna(0.0)
77
+ if vol_target is not None:
78
+ pos = pos * _vol_scale(bars["close"], vol_target, vol_window)
79
+ return pos
80
+
81
+
82
+ def carry(bars: pd.DataFrame, funding: pd.Series, threshold_mult: float = 1.0,
83
+ base: float = 1e-4, vol_target: float | None = None) -> pd.Series:
84
+ """Short the perp when trailing funding > threshold*base, long when < -."""
85
+ if isinstance(funding, pd.DataFrame):
86
+ funding = funding["funding_rate"]
87
+ f = funding.shift(1) # last announced tick at decision time
88
+ signal = f.rolling(3, min_periods=1).mean()
89
+ tick = signal.reindex(bars.index, method="ffill")
90
+ pos = pd.Series(0.0, index=bars.index)
91
+ pos[tick > threshold_mult * base] = -1.0
92
+ pos[tick < -threshold_mult * base] = 1.0
93
+ if vol_target is not None:
94
+ pos = pos * _vol_scale(bars["close"], vol_target, vol_window)
95
+ return pos
96
+
97
+
98
+ def funding_extreme(bars: pd.DataFrame, funding: pd.Series, k: float = 3.0,
99
+ hold_bars: int = 24) -> pd.Series:
100
+ """Long event trade: extreme NEGATIVE funding (documented asymmetric)."""
101
+ if isinstance(funding, pd.DataFrame):
102
+ funding = funding["funding_rate"]
103
+ f = funding.shift(1)
104
+ sigma = f.rolling(90, min_periods=30).std() # ~30d of 8h ticks
105
+ trigger = (f < -k * sigma).reindex(bars.index, method="ffill").fillna(False)
106
+ pos = pd.Series(0.0, index=bars.index)
107
+ # hold for hold_bars after each trigger
108
+ trig = trigger.to_numpy()
109
+ out = np.zeros(len(trig))
110
+ remaining = 0
111
+ for i in range(len(trig)):
112
+ if trig[i]:
113
+ remaining = hold_bars
114
+ if remaining > 0:
115
+ out[i] = 1.0
116
+ remaining -= 1
117
+ return pd.Series(out, index=bars.index)
118
+
119
+
120
+ def meanrev(bars: pd.DataFrame, funding: pd.Series, window: int,
121
+ k: float = 2.5) -> pd.Series:
122
+ """1h mean reversion control: fade >k-sigma moves (expected to lose)."""
123
+ c = bars["close"].shift(1)
124
+ ma = c.rolling(window).mean()
125
+ sd = c.rolling(window).std()
126
+ z = (c - ma) / sd
127
+ pos = pd.Series(0.0, index=bars.index)
128
+ pos[z < -k] = 1.0
129
+ pos[z > k] = -1.0
130
+ return pos