GoshawkVortexAI commited on
Commit
a461698
·
verified ·
1 Parent(s): 1cdd6ba

Update volume_analysis.py

Browse files
Files changed (1) hide show
  1. volume_analysis.py +198 -44
volume_analysis.py CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from typing import Dict, Any
2
 
3
  import numpy as np
@@ -9,6 +22,14 @@ from config import (
9
  VOLUME_CLIMAX_MULT,
10
  VOLUME_WEAK_THRESHOLD,
11
  BREAKOUT_LOOKBACK,
 
 
 
 
 
 
 
 
12
  )
13
 
14
 
@@ -16,105 +37,238 @@ def compute_volume_ma(df: pd.DataFrame, period: int = VOLUME_MA_PERIOD) -> pd.Se
16
  return df["volume"].rolling(period).mean()
17
 
18
 
19
- def detect_spikes(df: pd.DataFrame, period: int = VOLUME_MA_PERIOD) -> pd.Series:
20
- vol_ma = compute_volume_ma(df, period)
21
  return df["volume"] > vol_ma * VOLUME_SPIKE_MULT
22
 
23
 
24
- def detect_climax(df: pd.DataFrame, period: int = VOLUME_MA_PERIOD) -> pd.Series:
25
- vol_ma = compute_volume_ma(df, period)
26
  return df["volume"] > vol_ma * VOLUME_CLIMAX_MULT
27
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  def compute_obv(df: pd.DataFrame) -> pd.Series:
30
  direction = np.sign(df["close"].diff()).fillna(0)
31
  return (df["volume"] * direction).cumsum()
32
 
33
 
34
- def compute_vwap_deviation(df: pd.DataFrame, period: int = VOLUME_MA_PERIOD) -> pd.Series:
35
- typical = (df["high"] + df["low"] + df["close"]) / 3
36
- vol = df["volume"]
37
- cum_vp = (typical * vol).rolling(period).sum()
38
- cum_vol = vol.rolling(period).sum().replace(0, np.nan)
39
- vwap = cum_vp / cum_vol
40
- return (df["close"] - vwap) / vwap
 
 
 
 
 
 
 
 
41
 
42
 
43
  def compute_delta_approx(df: pd.DataFrame) -> pd.Series:
44
  body = df["close"] - df["open"]
45
  wick = (df["high"] - df["low"]).replace(0, np.nan)
46
  buy_ratio = ((body / wick) * 0.5 + 0.5).clip(0.0, 1.0).fillna(0.5)
47
- buy_vol = df["volume"] * buy_ratio
48
- sell_vol = df["volume"] * (1 - buy_ratio)
49
- return buy_vol - sell_vol
 
 
 
 
 
 
 
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
 
52
- def compute_breakout_signal(df: pd.DataFrame, lookback: int = BREAKOUT_LOOKBACK) -> pd.Series:
53
- prior_high = df["close"].rolling(lookback).max().shift(1)
54
- prior_low = df["close"].rolling(lookback).min().shift(1)
55
- spikes = detect_spikes(df)
56
  signal = pd.Series(0, index=df.index)
57
- signal[(df["close"] > prior_high) & spikes] = 1
58
- signal[(df["close"] < prior_low) & spikes] = -1
59
  return signal
60
 
61
 
62
- def analyze_volume(df: pd.DataFrame) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  vol_ma = compute_volume_ma(df, VOLUME_MA_PERIOD)
64
- spike_series = detect_spikes(df, VOLUME_MA_PERIOD)
65
- climax_series = detect_climax(df, VOLUME_MA_PERIOD)
66
- breakout_series = compute_breakout_signal(df, BREAKOUT_LOOKBACK)
67
  obv = compute_obv(df)
 
68
  delta = compute_delta_approx(df)
69
  vwap_dev = compute_vwap_deviation(df, VOLUME_MA_PERIOD)
70
 
 
 
 
 
 
 
 
 
71
  last_vol = float(df["volume"].iloc[-1])
72
  last_vol_ma = float(vol_ma.iloc[-1]) if not np.isnan(vol_ma.iloc[-1]) else 1.0
73
  last_spike = bool(spike_series.iloc[-1])
74
  last_climax = bool(climax_series.iloc[-1])
 
75
  last_breakout = int(breakout_series.iloc[-1])
 
 
76
  last_vwap_dev = float(vwap_dev.iloc[-1]) if not np.isnan(vwap_dev.iloc[-1]) else 0.0
77
 
78
  vol_ratio = last_vol / last_vol_ma if last_vol_ma > 0 else 1.0
 
79
 
80
- obv_recent = obv.iloc[-10:]
81
- obv_slope = float(np.polyfit(range(len(obv_recent)), obv_recent.values, 1)[0])
82
- obv_normalized = obv_slope / (abs(obv_recent.mean()) + 1e-10)
83
 
84
- delta_sum_5 = float(delta.iloc[-5:].sum())
85
- delta_sign = 1 if delta_sum_5 > 0 else -1
86
 
87
- weak_vol = vol_ratio < VOLUME_WEAK_THRESHOLD
88
-
89
- if last_climax:
90
- base_score = 0.3
91
- elif last_spike and last_breakout != 0:
 
 
92
  base_score = 1.0
93
- elif last_spike and last_breakout == 0:
94
- base_score = 0.65
 
 
95
  elif vol_ratio >= 1.2:
96
- base_score = 0.5
97
  elif vol_ratio >= 0.8:
98
- base_score = 0.35
99
  else:
100
- base_score = 0.1
 
 
 
 
 
 
 
 
 
 
101
 
102
- obv_bonus = float(np.clip(obv_normalized * 0.1, -0.1, 0.1))
103
- vwap_bonus = 0.05 if last_vwap_dev > 0 and last_breakout == 1 else 0.0
104
- volume_score = float(np.clip(base_score + obv_bonus + vwap_bonus, 0.0, 1.0))
105
 
106
  return {
107
  "vol_ratio": round(vol_ratio, 3),
108
  "spike": last_spike,
109
  "climax": last_climax,
 
110
  "weak": weak_vol,
111
  "breakout": last_breakout,
112
- "obv_slope_norm": round(obv_normalized, 4),
113
- "delta_sum_5": round(delta_sum_5, 2),
 
 
114
  "delta_sign": delta_sign,
115
  "vwap_deviation": round(last_vwap_dev, 4),
116
  "volume_score": round(volume_score, 4),
117
  "spike_series": spike_series,
118
  "climax_series": climax_series,
 
119
  "breakout_series": breakout_series,
 
120
  }
 
1
+ """
2
+ volume_analysis.py — Volume & order flow with absorption detection,
3
+ multi-bar breakout confirmation, and fake breakout identification.
4
+
5
+ Key fixes vs prior version:
6
+ - Absorption detection: high-volume small-body bars at resistance = institutional selling
7
+ - Multi-bar breakout confirmation (BREAKOUT_CONFIRMATION_BARS) before firing signal
8
+ - ATR buffer on breakout level (price must exceed level by N*ATR, not just 1 tick)
9
+ - OBV slope computed over configurable window, normalized vs rolling stddev
10
+ - Climax threshold lowered (3.0x) and now triggers a hard absorption check
11
+ - Failed retest detection: breakout that closes back below the level = fake
12
+ """
13
+
14
  from typing import Dict, Any
15
 
16
  import numpy as np
 
22
  VOLUME_CLIMAX_MULT,
23
  VOLUME_WEAK_THRESHOLD,
24
  BREAKOUT_LOOKBACK,
25
+ BREAKOUT_ATR_BUFFER,
26
+ BREAKOUT_CONFIRMATION_BARS,
27
+ BREAKOUT_RETEST_BARS,
28
+ ABSORPTION_WICK_RATIO,
29
+ ABSORPTION_VOL_MULT,
30
+ ABSORPTION_BODY_RATIO,
31
+ OBV_SLOPE_BARS,
32
+ ATR_PERIOD,
33
  )
34
 
35
 
 
37
  return df["volume"].rolling(period).mean()
38
 
39
 
40
+ def detect_spikes(df: pd.DataFrame, vol_ma: pd.Series) -> pd.Series:
 
41
  return df["volume"] > vol_ma * VOLUME_SPIKE_MULT
42
 
43
 
44
+ def detect_climax(df: pd.DataFrame, vol_ma: pd.Series) -> pd.Series:
 
45
  return df["volume"] > vol_ma * VOLUME_CLIMAX_MULT
46
 
47
 
48
+ def detect_absorption(df: pd.DataFrame, vol_ma: pd.Series) -> pd.Series:
49
+ """
50
+ Absorption = high-volume bar with small body and large upper wick,
51
+ occurring near recent highs (institutional supply absorbing retail demand).
52
+
53
+ Conditions (all must be true):
54
+ - Volume > ABSORPTION_VOL_MULT * MA
55
+ - Body / range < ABSORPTION_BODY_RATIO (small real body)
56
+ - Upper wick / range > ABSORPTION_WICK_RATIO (large upper wick)
57
+ - Close is in lower half of the bar's range (sellers won the bar)
58
+ """
59
+ bar_range = (df["high"] - df["low"]).replace(0, np.nan)
60
+ body = (df["close"] - df["open"]).abs()
61
+ upper_wick = df["high"] - df[["close", "open"]].max(axis=1)
62
+
63
+ body_ratio = body / bar_range
64
+ wick_ratio = upper_wick / bar_range
65
+ close_in_lower_half = df["close"] < (df["low"] + bar_range * 0.5)
66
+
67
+ high_volume = df["volume"] > vol_ma * ABSORPTION_VOL_MULT
68
+ small_body = body_ratio < ABSORPTION_BODY_RATIO
69
+ large_wick = wick_ratio > ABSORPTION_WICK_RATIO
70
+
71
+ return high_volume & small_body & large_wick & close_in_lower_half
72
+
73
+
74
  def compute_obv(df: pd.DataFrame) -> pd.Series:
75
  direction = np.sign(df["close"].diff()).fillna(0)
76
  return (df["volume"] * direction).cumsum()
77
 
78
 
79
+ def compute_obv_slope(obv: pd.Series, bars: int = OBV_SLOPE_BARS) -> pd.Series:
80
+ """
81
+ OBV slope normalized by rolling stddev of OBV to make it comparable
82
+ across different price scales. Values > 1 = strong upward flow.
83
+ """
84
+ x = np.arange(bars)
85
+
86
+ def slope_normalized(window):
87
+ if len(window) < bars:
88
+ return np.nan
89
+ s = np.polyfit(x, window, 1)[0]
90
+ std = np.std(window)
91
+ return s / std if std > 0 else 0.0
92
+
93
+ return obv.rolling(bars).apply(slope_normalized, raw=True)
94
 
95
 
96
  def compute_delta_approx(df: pd.DataFrame) -> pd.Series:
97
  body = df["close"] - df["open"]
98
  wick = (df["high"] - df["low"]).replace(0, np.nan)
99
  buy_ratio = ((body / wick) * 0.5 + 0.5).clip(0.0, 1.0).fillna(0.5)
100
+ return df["volume"] * buy_ratio - df["volume"] * (1 - buy_ratio)
101
+
102
+
103
+ def compute_vwap_deviation(df: pd.DataFrame, period: int = VOLUME_MA_PERIOD) -> pd.Series:
104
+ typical = (df["high"] + df["low"] + df["close"]) / 3
105
+ cum_vp = (typical * df["volume"]).rolling(period).sum()
106
+ cum_vol = df["volume"].rolling(period).sum().replace(0, np.nan)
107
+ vwap = cum_vp / cum_vol
108
+ atr_approx = (df["high"] - df["low"]).rolling(ATR_PERIOD).mean().replace(0, np.nan)
109
+ return (df["close"] - vwap) / atr_approx
110
+
111
 
112
+ def compute_confirmed_breakout(
113
+ df: pd.DataFrame,
114
+ atr_series: pd.Series,
115
+ vol_ma: pd.Series,
116
+ lookback: int = BREAKOUT_LOOKBACK,
117
+ confirm_bars: int = BREAKOUT_CONFIRMATION_BARS,
118
+ atr_buffer: float = BREAKOUT_ATR_BUFFER,
119
+ ) -> pd.Series:
120
+ """
121
+ Genuine breakout requires ALL of:
122
+ 1. Close exceeds prior N-bar high/low by at least atr_buffer * ATR
123
+ 2. Close holds above/below that level for confirm_bars consecutive bars
124
+ 3. Volume spike on at least one of the confirmation bars
125
+ 4. No absorption signal on the breakout bar or confirmation bars
126
+
127
+ Returns: +1 confirmed bull breakout, -1 confirmed bear, 0 none
128
+ """
129
+ prior_high = df["high"].rolling(lookback).max().shift(lookback)
130
+ prior_low = df["low"].rolling(lookback).min().shift(lookback)
131
+ spike = detect_spikes(df, vol_ma)
132
+ absorption = detect_absorption(df, vol_ma)
133
+
134
+ # Level cleared with buffer
135
+ cleared_up = df["close"] > prior_high + atr_series * atr_buffer
136
+ cleared_dn = df["close"] < prior_low - atr_series * atr_buffer
137
+
138
+ # Rolling confirmation: all bars in last confirm_bars cleared the level
139
+ held_up = cleared_up.rolling(confirm_bars).min().fillna(0).astype(bool)
140
+ held_dn = cleared_dn.rolling(confirm_bars).min().fillna(0).astype(bool)
141
+
142
+ # Volume spike in confirmation window
143
+ vol_ok = spike.rolling(confirm_bars).max().fillna(0).astype(bool)
144
+
145
+ # No absorption in confirmation window
146
+ no_absorption = (~absorption).rolling(confirm_bars).min().fillna(1).astype(bool)
147
 
 
 
 
 
148
  signal = pd.Series(0, index=df.index)
149
+ signal[held_up & vol_ok & no_absorption] = 1
150
+ signal[held_dn & vol_ok & no_absorption] = -1
151
  return signal
152
 
153
 
154
+ def detect_failed_breakout(
155
+ df: pd.DataFrame,
156
+ breakout_series: pd.Series,
157
+ atr_series: pd.Series,
158
+ retest_bars: int = BREAKOUT_RETEST_BARS,
159
+ ) -> pd.Series:
160
+ """
161
+ A breakout that closes back below/above the breakout level within
162
+ retest_bars is flagged as a failed (fake) breakout.
163
+ Returns: True where a prior confirmed breakout has since failed.
164
+ """
165
+ prior_high = df["high"].rolling(BREAKOUT_LOOKBACK).max().shift(BREAKOUT_LOOKBACK)
166
+ prior_low = df["low"].rolling(BREAKOUT_LOOKBACK).min().shift(BREAKOUT_LOOKBACK)
167
+
168
+ had_bull_bo = breakout_series.shift(1).rolling(retest_bars).max().fillna(0) > 0
169
+ had_bear_bo = breakout_series.shift(1).rolling(retest_bars).min().fillna(0) < 0
170
+
171
+ # Failed: price returned below the breakout level
172
+ bull_failed = had_bull_bo & (df["close"] < prior_high.shift(retest_bars))
173
+ bear_failed = had_bear_bo & (df["close"] > prior_low.shift(retest_bars))
174
+
175
+ return bull_failed | bear_failed
176
+
177
+
178
+ def analyze_volume(df: pd.DataFrame, atr_series: pd.Series = None) -> Dict[str, Any]:
179
+ if atr_series is None:
180
+ # Fallback: compute simple ATR if not provided
181
+ high, low, prev_close = df["high"], df["low"], df["close"].shift(1)
182
+ tr = pd.concat(
183
+ [high - low, (high - prev_close).abs(), (low - prev_close).abs()],
184
+ axis=1,
185
+ ).max(axis=1)
186
+ atr_series = tr.ewm(alpha=1.0 / ATR_PERIOD, adjust=False).mean()
187
+
188
  vol_ma = compute_volume_ma(df, VOLUME_MA_PERIOD)
189
+ spike_series = detect_spikes(df, vol_ma)
190
+ climax_series = detect_climax(df, vol_ma)
191
+ absorption_series = detect_absorption(df, vol_ma)
192
  obv = compute_obv(df)
193
+ obv_slope_series = compute_obv_slope(obv, OBV_SLOPE_BARS)
194
  delta = compute_delta_approx(df)
195
  vwap_dev = compute_vwap_deviation(df, VOLUME_MA_PERIOD)
196
 
197
+ breakout_series = compute_confirmed_breakout(
198
+ df, atr_series, vol_ma,
199
+ lookback=BREAKOUT_LOOKBACK,
200
+ confirm_bars=BREAKOUT_CONFIRMATION_BARS,
201
+ atr_buffer=BREAKOUT_ATR_BUFFER,
202
+ )
203
+ failed_breakout_series = detect_failed_breakout(df, breakout_series, atr_series)
204
+
205
  last_vol = float(df["volume"].iloc[-1])
206
  last_vol_ma = float(vol_ma.iloc[-1]) if not np.isnan(vol_ma.iloc[-1]) else 1.0
207
  last_spike = bool(spike_series.iloc[-1])
208
  last_climax = bool(climax_series.iloc[-1])
209
+ last_absorption = bool(absorption_series.iloc[-1])
210
  last_breakout = int(breakout_series.iloc[-1])
211
+ last_failed_bo = bool(failed_breakout_series.iloc[-1])
212
+ last_obv_slope = float(obv_slope_series.iloc[-1]) if not np.isnan(obv_slope_series.iloc[-1]) else 0.0
213
  last_vwap_dev = float(vwap_dev.iloc[-1]) if not np.isnan(vwap_dev.iloc[-1]) else 0.0
214
 
215
  vol_ratio = last_vol / last_vol_ma if last_vol_ma > 0 else 1.0
216
+ weak_vol = vol_ratio < VOLUME_WEAK_THRESHOLD
217
 
218
+ delta_5 = float(delta.iloc[-5:].sum())
219
+ delta_sign = 1 if delta_5 > 0 else -1
 
220
 
221
+ # Recent failed breakout count (rolling 10 bars) — context for trust level
222
+ recent_failed = int(failed_breakout_series.iloc[-10:].sum())
223
 
224
+ # Score construction
225
+ if last_absorption:
226
+ # Absorption at high: bearish signal masquerading as bullish
227
+ base_score = 0.15
228
+ elif last_climax:
229
+ base_score = 0.25
230
+ elif last_breakout != 0 and not last_failed_bo:
231
  base_score = 1.0
232
+ elif last_breakout != 0 and last_failed_bo:
233
+ base_score = 0.20
234
+ elif last_spike and not last_absorption:
235
+ base_score = 0.60
236
  elif vol_ratio >= 1.2:
237
+ base_score = 0.45
238
  elif vol_ratio >= 0.8:
239
+ base_score = 0.30
240
  else:
241
+ base_score = 0.10
242
+
243
+ # OBV slope bonus/penalty (normalized)
244
+ obv_bonus = float(np.clip(last_obv_slope * 0.08, -0.12, 0.12))
245
+
246
+ # VWAP deviation bonus for on-side entries
247
+ vwap_bonus = 0.05 if (last_vwap_dev > 0 and last_breakout == 1) else 0.0
248
+ vwap_bonus += 0.05 if (last_vwap_dev < 0 and last_breakout == -1) else 0.0
249
+
250
+ # Penalty for recent failed breakouts (trust decay)
251
+ fake_penalty = min(0.20, recent_failed * 0.05)
252
 
253
+ volume_score = float(np.clip(base_score + obv_bonus + vwap_bonus - fake_penalty, 0.0, 1.0))
 
 
254
 
255
  return {
256
  "vol_ratio": round(vol_ratio, 3),
257
  "spike": last_spike,
258
  "climax": last_climax,
259
+ "absorption": last_absorption,
260
  "weak": weak_vol,
261
  "breakout": last_breakout,
262
+ "failed_breakout": last_failed_bo,
263
+ "recent_failed_count": recent_failed,
264
+ "obv_slope_norm": round(last_obv_slope, 4),
265
+ "delta_sum_5": round(delta_5, 2),
266
  "delta_sign": delta_sign,
267
  "vwap_deviation": round(last_vwap_dev, 4),
268
  "volume_score": round(volume_score, 4),
269
  "spike_series": spike_series,
270
  "climax_series": climax_series,
271
+ "absorption_series": absorption_series,
272
  "breakout_series": breakout_series,
273
+ "failed_breakout_series": failed_breakout_series,
274
  }