Riy777 commited on
Commit
fdf1b31
·
verified ·
1 Parent(s): a61f677

Upload governance_engine_fixed.py

Browse files
Files changed (1) hide show
  1. governance_engine_fixed.py +1058 -0
governance_engine_fixed.py ADDED
@@ -0,0 +1,1058 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # 🏛️ governance_engine.py (V1.3 - Stability Fix)
3
+ # ============================================================
4
+ # Description:
5
+ # Evaluates trade quality using 156 INDICATORS.
6
+ # Fixes: Solved "The truth value of a Series is ambiguous" error.
7
+ # Update: Enhanced error logging to show real causes.
8
+ # ============================================================
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ try:
13
+ import pandas_ta as ta
14
+ except Exception as _e:
15
+ ta = None
16
+ from typing import Dict, Any, List
17
+
18
+ class GovernanceEngine:
19
+ def __init__(self):
20
+ # ⚖️ Strategic Weights
21
+ self.WEIGHTS = {
22
+ "order_book": 0.25, # 25%
23
+ "market_structure": 0.20, # 20%
24
+ "trend": 0.15, # 15%
25
+ "momentum": 0.15, # 15%
26
+ "volume": 0.10, # 10%
27
+ "volatility": 0.05, # 5%
28
+ "cycle_math": 0.10 # 10%
29
+ }
30
+ print("🏛️ [Governance Engine V1.3] Stability Patch Applied. Ready.")
31
+
32
+
33
+ async def evaluate_trade(
34
+ self,
35
+ symbol: str,
36
+ ohlcv_data: Dict[str, Any],
37
+ order_book: Dict[str, Any],
38
+ verbose: bool = True,
39
+ include_details: bool = False,
40
+ use_multi_timeframes: bool = False
41
+ ) -> Dict[str, Any]:
42
+ """
43
+ Main Execution Entry.
44
+
45
+ Backwards compatible:
46
+ - Requires '15m' data (same as before)
47
+ - Output schema unchanged unless include_details=True
48
+ - Multi-timeframe aggregation is opt-in (use_multi_timeframes=True)
49
+ """
50
+ try:
51
+ if ta is None:
52
+ return self._create_rejection('Missing dependency: pandas_ta')
53
+
54
+ # 1) Data Prep
55
+ if not isinstance(ohlcv_data, dict) or '15m' not in ohlcv_data:
56
+ return self._create_rejection("No 15m Data")
57
+
58
+ def _get_df(tf: str) -> Any:
59
+ if tf not in ohlcv_data:
60
+ return None
61
+ df_tf = self._prepare_dataframe(ohlcv_data[tf])
62
+ if len(df_tf) < 60:
63
+ return None
64
+ return df_tf
65
+
66
+ df15 = _get_df('15m')
67
+ if df15 is None:
68
+ return self._create_rejection("Insufficient Data Length (<60)")
69
+
70
+ # optional timeframes (only used when enabled)
71
+ df_map: Dict[str, pd.DataFrame] = {'15m': df15}
72
+ if use_multi_timeframes:
73
+ for tf in ('1h', '4h', '1d'):
74
+ d = _get_df(tf)
75
+ if d is not None:
76
+ df_map[tf] = d
77
+
78
+ if verbose:
79
+ print(f"\n📝 [Gov Audit] Opening Session for {symbol}...")
80
+ print("-" * 80)
81
+
82
+ # 2) Calculate Domains (single TF by default for compatibility)
83
+ details_pack = {} # only filled when include_details=True
84
+
85
+ if not use_multi_timeframes:
86
+ s_trend = self._calc_trend_domain(df15, verbose, include_details, details_pack)
87
+ s_mom = self._calc_momentum_domain(df15, verbose, include_details, details_pack)
88
+ s_vol = self._calc_volatility_domain(df15, verbose, include_details, details_pack)
89
+ s_volu = self._calc_volume_domain(df15, verbose, include_details, details_pack)
90
+ s_cycle = self._calc_cycle_math_domain(df15, verbose, include_details, details_pack)
91
+ s_struct = self._calc_structure_domain(df15, verbose, include_details, details_pack)
92
+ else:
93
+ # Weighted by timeframe importance; only timeframes available are used
94
+ tfw = {'15m': 0.50, '1h': 0.30, '4h': 0.20, '1d': 0.10}
95
+
96
+ def _agg(fn, name: str) -> float:
97
+ total_w = 0.0
98
+ acc = 0.0
99
+ per_tf = {}
100
+ for tf, df_tf in df_map.items():
101
+ w = tfw.get(tf, 0.1)
102
+ s = fn(df_tf, False, include_details, details_pack) # per-tf verbose off to avoid noise
103
+ per_tf[tf] = float(s)
104
+ acc += w * float(s)
105
+ total_w += w
106
+ if include_details:
107
+ details_pack[f"{name}_per_tf"] = per_tf
108
+ return (acc / total_w) if total_w > 0 else 0.0
109
+
110
+ s_trend = _agg(self._calc_trend_domain, "trend")
111
+ s_mom = _agg(self._calc_momentum_domain, "momentum")
112
+ s_vol = _agg(self._calc_volatility_domain, "volatility")
113
+ s_volu = _agg(self._calc_volume_domain, "volume")
114
+ s_cycle = _agg(self._calc_cycle_math_domain, "cycle_math")
115
+ s_struct = _agg(self._calc_structure_domain, "structure")
116
+
117
+ if verbose:
118
+ print(f" 🧩 Multi-TF used: {', '.join(df_map.keys())}")
119
+
120
+ s_ob = self._calc_orderbook_domain(order_book, verbose, include_details, details_pack)
121
+
122
+ if verbose:
123
+ print("-" * 80)
124
+
125
+ # 3) Weighted Aggregation (domain scores are in [-1, +1])
126
+ raw_weighted_score = (
127
+ (s_trend * self.WEIGHTS['trend']) +
128
+ (s_mom * self.WEIGHTS['momentum']) +
129
+ (s_vol * self.WEIGHTS['volatility']) +
130
+ (s_volu * self.WEIGHTS['volume']) +
131
+ (s_cycle * self.WEIGHTS['cycle_math']) +
132
+ (s_struct * self.WEIGHTS['market_structure']) +
133
+ (s_ob * self.WEIGHTS['order_book'])
134
+ )
135
+
136
+ # 4) Final Scoring & Grading
137
+ final_score = max(0.0, min(100.0, ((raw_weighted_score + 1) / 2) * 100))
138
+ grade = self._get_grade(final_score)
139
+
140
+ result = {
141
+ "governance_score": round(final_score, 2),
142
+ "grade": grade,
143
+ "components": {
144
+ "trend": round(float(s_trend), 3),
145
+ "momentum": round(float(s_mom), 3),
146
+ "volatility": round(float(s_vol), 3),
147
+ "volume": round(float(s_volu), 3),
148
+ "cycle_math": round(float(s_cycle), 3),
149
+ "structure": round(float(s_struct), 3),
150
+ "order_book": round(float(s_ob), 3),
151
+ },
152
+ "status": "APPROVED" if grade != "REJECT" else "REJECTED",
153
+ }
154
+
155
+ if include_details:
156
+ result["details"] = details_pack
157
+ result["timeframes_used"] = list(df_map.keys()) if use_multi_timeframes else ["15m"]
158
+
159
+ return result
160
+
161
+ except Exception as e:
162
+ if verbose:
163
+ print(f"❌ [Governance Critical Error] {e}")
164
+ return self._create_rejection(f"Exception: {str(e)}")
165
+
166
+
167
+ # ==============================================================================
168
+ # 📈 DOMAIN 1: TREND (Fixed)
169
+ # ==============================================================================
170
+ def _calc_trend_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
171
+ points = 0.0
172
+ details = []
173
+ try:
174
+ c = df['close']
175
+
176
+ # 1. EMA 9 > 21
177
+ ema9 = ta.ema(c, 9); ema21 = ta.ema(c, 21)
178
+ if self._valid(ema9) and self._valid(ema21) and ema9.iloc[-1] > ema21.iloc[-1]:
179
+ points += 1; details.append("EMA9>21")
180
+
181
+ # 2. EMA 21 > 50
182
+ ema50 = ta.ema(c, 50)
183
+ if self._valid(ema21) and self._valid(ema50) and ema21.iloc[-1] > ema50.iloc[-1]:
184
+ points += 1; details.append("EMA21>50")
185
+
186
+ # 3. Price > EMA 200
187
+ ema200 = ta.ema(c, 200)
188
+ if self._valid(ema200):
189
+ if c.iloc[-1] > ema200.iloc[-1]: points += 2; details.append("Price>EMA200")
190
+ else: points -= 2; details.append("Price<EMA200")
191
+
192
+ # 4. Supertrend
193
+ st = ta.supertrend(df['high'], df['low'], c, length=10, multiplier=3)
194
+ if self._valid(st):
195
+ # Supertrend returns [trend, direction, long, short], usually col 0 is trend line
196
+ st_line = st.iloc[:, 0]
197
+ if c.iloc[-1] > st_line.iloc[-1]: points += 1; details.append("ST:Bull")
198
+ else: points -= 1
199
+
200
+ # 5. Parabolic SAR
201
+ psar = ta.psar(df['high'], df['low'], c)
202
+ if self._valid(psar):
203
+ # Handle both single series or dataframe return
204
+ val = psar.iloc[-1]
205
+ if isinstance(val, pd.Series): val = val.dropna().iloc[0] if not val.dropna().empty else 0
206
+
207
+ if val != 0:
208
+ if val < c.iloc[-1]: points += 1; details.append("PSAR:Bull")
209
+ else: points -= 1
210
+
211
+ # 6. ADX
212
+ adx = ta.adx(df['high'], df['low'], c, length=14)
213
+ if self._valid(adx):
214
+ val = adx[adx.columns[0]].iloc[-1]
215
+ dmp = adx[adx.columns[1]].iloc[-1]
216
+ dmn = adx[adx.columns[2]].iloc[-1]
217
+ if val > 25:
218
+ if dmp > dmn: points += 1.5; details.append("ADX:StrongBull")
219
+ else: points -= 1.5; details.append("ADX:StrongBear")
220
+ else: details.append("ADX:Weak")
221
+
222
+ # 7. Ichimoku
223
+ ichi = ta.ichimoku(df['high'], df['low'], c)
224
+ # Ichimoku returns a tuple of (DataFrame, DataFrame)
225
+ if ichi is not None and isinstance(ichi, tuple) and self._valid(ichi[0]):
226
+ span_a = ichi[0][ichi[0].columns[0]].iloc[-1]
227
+ span_b = ichi[0][ichi[0].columns[1]].iloc[-1]
228
+ if c.iloc[-1] > span_a and c.iloc[-1] > span_b: points += 1; details.append("Ichi:AboveCloud")
229
+
230
+ # 8. Vortex
231
+ vortex = ta.vortex(df['high'], df['low'], c)
232
+ if self._valid(vortex):
233
+ if vortex[vortex.columns[0]].iloc[-1] > vortex[vortex.columns[1]].iloc[-1]:
234
+ points += 1; details.append("Vortex:Bull")
235
+
236
+ # 9. Aroon
237
+ aroon = ta.aroon(df['high'], df['low'])
238
+ if self._valid(aroon):
239
+ if aroon[aroon.columns[0]].iloc[-1] > 70: points += 1; details.append("Aroon:Up")
240
+ elif aroon[aroon.columns[1]].iloc[-1] > 70: points -= 1; details.append("Aroon:Down")
241
+
242
+ # 10. Slope
243
+ slope = ta.slope(c, length=14)
244
+ if self._valid(slope) and slope.iloc[-1] > 0: points += 1; details.append("Slope:Pos")
245
+
246
+ # 11. KAMA
247
+ kama = ta.kama(c, length=10)
248
+ if self._valid(kama) and c.iloc[-1] > kama.iloc[-1]: points += 1; details.append("KAMA:Bull")
249
+
250
+ # 12. TRIX
251
+ trix = ta.trix(c, length=30)
252
+ if self._valid(trix) and trix.iloc[-1] > 0: points += 1; details.append("TRIX:Bull")
253
+
254
+ # 13. DPO
255
+ dpo = ta.dpo(c, length=20)
256
+ if self._valid(dpo) and dpo.iloc[-1] > 0: points += 1; details.append("DPO:Bull")
257
+
258
+ # 14. SMA Cluster
259
+ sma20 = ta.sma(c, 20); sma50 = ta.sma(c, 50)
260
+ if self._valid(sma20) and self._valid(sma50) and sma20.iloc[-1] > sma50.iloc[-1]:
261
+ points += 1; details.append("SMA20>50")
262
+
263
+ # 15. ZigZag
264
+ if df['high'].iloc[-1] > df['high'].iloc[-5]: points += 1; details.append("ZigZag:Up")
265
+
266
+ # 16. MACD Slope
267
+ macd = ta.macd(c)
268
+ if self._valid(macd):
269
+ ml = macd[macd.columns[0]]
270
+ if ml.iloc[-1] > ml.iloc[-2]: points += 1; details.append("MACD_Slope:Up")
271
+
272
+ # 17. Coppock
273
+ coppock = ta.coppock(c)
274
+ if self._valid(coppock) and coppock.iloc[-1] > 0: points += 0.5; details.append("Coppock:Bull")
275
+
276
+ # 18. HMA
277
+ hma = ta.hma(c, length=9)
278
+ if self._valid(hma) and c.iloc[-1] > hma.iloc[-1]: points += 1; details.append("HMA:Bull")
279
+
280
+ # 19. Donchian
281
+ dc = ta.donchian(df['high'], df['low'])
282
+ if self._valid(dc) and c.iloc[-1] > dc[dc.columns[1]].iloc[-1]:
283
+ points += 1; details.append("Donchian:Upper")
284
+
285
+ # 20. Keltner
286
+ kc = ta.kc(df['high'], df['low'], c)
287
+ if self._valid(kc) and c.iloc[-1] > kc[kc.columns[0]].iloc[-1]:
288
+ points += 0.5; details.append("Keltner:Safe")
289
+
290
+ except Exception as e: details.append(f"TrendErr:{str(e)[:15]}")
291
+
292
+ norm_score = self._normalize(points, max_possible=22.0)
293
+ if include_details and details_pack is not None:
294
+ details_pack['trend'] = details
295
+ if include_details and details_pack is not None:
296
+ details_pack['momentum'] = details
297
+ if include_details and details_pack is not None:
298
+ details_pack['volatility'] = details
299
+ if include_details and details_pack is not None:
300
+ details_pack['volume'] = details
301
+ if include_details and details_pack is not None:
302
+ details_pack['cycle_math'] = details
303
+ if include_details and details_pack is not None:
304
+ details_pack['structure'] = details
305
+ if include_details and details_pack is not None:
306
+ details_pack['order_book'] = details
307
+ if verbose: print(f" 📈 [TREND] Score: {norm_score:.2f} | {', '.join(details)}")
308
+ return norm_score
309
+
310
+ # ==============================================================================
311
+ # 🚀 DOMAIN 2: MOMENTUM (Fixed)
312
+ # ==============================================================================
313
+ def _calc_momentum_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
314
+ points = 0.0
315
+ details = []
316
+ try:
317
+ c = df['close']
318
+
319
+ # 1. RSI
320
+ rsi = ta.rsi(c, length=14)
321
+ if self._valid(rsi):
322
+ val = rsi.iloc[-1]
323
+ if 50 < val < 70: points += 2; details.append(f"RSI:{val:.0f}")
324
+ elif val > 70: points -= 1; details.append("RSI:OB")
325
+ elif val < 30: points += 1; details.append("RSI:OS")
326
+
327
+ # 2. MACD
328
+ macd = ta.macd(c)
329
+ if self._valid(macd):
330
+ if macd[macd.columns[0]].iloc[-1] > macd[macd.columns[2]].iloc[-1]:
331
+ points += 1.5; details.append("MACD:X_Bull")
332
+ if macd[macd.columns[1]].iloc[-1] > 0:
333
+ points += 1; details.append("MACD_Hist:Pos")
334
+
335
+ # 4. Stochastic
336
+ stoch = ta.stoch(df['high'], df['low'], c)
337
+ if self._valid(stoch):
338
+ k = stoch[stoch.columns[0]].iloc[-1]
339
+ d = stoch[stoch.columns[1]].iloc[-1]
340
+ if 20 < k < 80 and k > d: points += 1; details.append("Stoch:Bull")
341
+
342
+ # 5. AO
343
+ ao = ta.ao(df['high'], df['low'])
344
+ if self._valid(ao) and ao.iloc[-1] > 0 and ao.iloc[-1] > ao.iloc[-2]:
345
+ points += 1; details.append("AO:Rising")
346
+
347
+ # 6. CCI
348
+ cci = ta.cci(df['high'], df['low'], c)
349
+ if self._valid(cci):
350
+ val = cci.iloc[-1]
351
+ if val > 100: points += 1; details.append("CCI:>100")
352
+ elif val < -100: points -= 1
353
+
354
+ # 7. Williams %R
355
+ willr = ta.willr(df['high'], df['low'], c)
356
+ if self._valid(willr) and willr.iloc[-1] < -80:
357
+ points += 1; details.append("WillR:OS")
358
+
359
+ # 8. ROC
360
+ roc = ta.roc(c, length=10)
361
+ if self._valid(roc) and roc.iloc[-1] > 0:
362
+ points += 1; details.append(f"ROC:{roc.iloc[-1]:.2f}")
363
+
364
+ # 9. MOM
365
+ mom = ta.mom(c, length=10)
366
+ if self._valid(mom) and mom.iloc[-1] > 0:
367
+ points += 1; details.append("MOM:Pos")
368
+
369
+ # 10. PPO
370
+ ppo = ta.ppo(c)
371
+ if self._valid(ppo) and ppo[ppo.columns[0]].iloc[-1] > 0:
372
+ points += 1; details.append("PPO:Pos")
373
+
374
+ # 11. TSI
375
+ tsi = ta.tsi(c)
376
+ if self._valid(tsi) and tsi[tsi.columns[0]].iloc[-1] > tsi[tsi.columns[1]].iloc[-1]:
377
+ points += 1; details.append("TSI:Bull")
378
+
379
+ # 12. Fisher
380
+ fish = ta.fisher(df['high'], df['low'])
381
+ if self._valid(fish) and fish[fish.columns[0]].iloc[-1] > fish[fish.columns[1]].iloc[-1]:
382
+ points += 1; details.append("Fisher:Bull")
383
+
384
+ # 13. CMO
385
+ cmo = ta.cmo(c, length=14)
386
+ if self._valid(cmo) and cmo.iloc[-1] > 0:
387
+ points += 1; details.append("CMO:Pos")
388
+
389
+ # 14. Squeeze
390
+ bb = ta.bbands(c, length=20)
391
+ kc = ta.kc(df['high'], df['low'], c)
392
+ if self._valid(bb) and self._valid(kc):
393
+ if bb[bb.columns[0]].iloc[-1] < kc[kc.columns[0]].iloc[-1]:
394
+ points += 1; details.append("SQZ:Active")
395
+
396
+ # 15. UO
397
+ uo = ta.uo(df['high'], df['low'], c)
398
+ if self._valid(uo) and uo.iloc[-1] > 50:
399
+ points += 0.5; details.append("UO:>50")
400
+
401
+ # 16. KDJ (kdj returns df)
402
+ kdj = ta.kdj(df['high'], df['low'], c)
403
+ if self._valid(kdj) and kdj[kdj.columns[0]].iloc[-1] > kdj[kdj.columns[1]].iloc[-1]:
404
+ points += 0.5; details.append("KDJ:Bull")
405
+
406
+ # 17. StochRSI
407
+ stochrsi = ta.stochrsi(c)
408
+ if self._valid(stochrsi) and stochrsi[stochrsi.columns[0]].iloc[-1] < 20:
409
+ points += 1; details.append("StochRSI:OS")
410
+
411
+ # 18. Elder Ray
412
+ ema13 = ta.ema(c, 13)
413
+ if self._valid(ema13):
414
+ bull_power = df['high'] - ema13
415
+ if bull_power.iloc[-1] > 0 and bull_power.iloc[-1] > bull_power.iloc[-2]:
416
+ points += 1; details.append("BullPower:Rising")
417
+
418
+ # 19. Streak
419
+ if c.iloc[-1] > c.iloc[-2] and c.iloc[-2] > c.iloc[-3]:
420
+ points += 0.5; details.append("Streak:Up")
421
+
422
+ # 20. Bias
423
+ ema20 = ta.ema(c, 20)
424
+ if self._valid(ema20):
425
+ bias = (c.iloc[-1] - ema20.iloc[-1]) / ema20.iloc[-1]
426
+ if 0 < bias < 0.05: points += 1; details.append("Bias:Healthy")
427
+
428
+ except Exception as e: details.append(f"MomErr:{str(e)[:10]}")
429
+
430
+ norm_score = self._normalize(points, max_possible=20.0)
431
+ if include_details and details_pack is not None:
432
+ details_pack['trend'] = details
433
+ if include_details and details_pack is not None:
434
+ details_pack['momentum'] = details
435
+ if include_details and details_pack is not None:
436
+ details_pack['volatility'] = details
437
+ if include_details and details_pack is not None:
438
+ details_pack['volume'] = details
439
+ if include_details and details_pack is not None:
440
+ details_pack['cycle_math'] = details
441
+ if include_details and details_pack is not None:
442
+ details_pack['structure'] = details
443
+ if include_details and details_pack is not None:
444
+ details_pack['order_book'] = details
445
+ if verbose: print(f" 🚀 [MOMENTUM] Score: {norm_score:.2f} | {', '.join(details)}")
446
+ return norm_score
447
+
448
+ # ==============================================================================
449
+ # 🌊 DOMAIN 3: VOLATILITY (Fixed)
450
+ # ==============================================================================
451
+ def _calc_volatility_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
452
+ points = 0.0
453
+ details = []
454
+ try:
455
+ # 1. Bollinger Bands (Bandwidth + %B)
456
+ bb = ta.bbands(df['close'], length=20)
457
+ if self._valid(bb):
458
+ # pandas_ta names usually: BBL_, BBM_, BBU_, BBB_ (bandwidth), BBP_ (%B)
459
+ bw_col = self._find_col(bb, ["bbb_", "bandwidth", "bbw"])
460
+ pb_col = self._find_col(bb, ["bbp_", "%b", "percentb", "pb"])
461
+ width = self._safe_last(bb, col=bw_col) if bw_col else np.nan
462
+ pct_b = self._safe_last(bb, col=pb_col) if pb_col else np.nan
463
+
464
+ # Bandwidth: smaller -> squeeze, larger -> expansion
465
+ # Typical BBB values ~ 0.02 - 0.25 in many markets (depends on volatility)
466
+ if np.isfinite(width):
467
+ if width < 0.05:
468
+ points -= 1; details.append("BBW:Squeeze")
469
+ elif width > 0.18:
470
+ points += 1; details.append("BBW:Expand")
471
+
472
+ # %B: location within bands (0..1 typically)
473
+ if np.isfinite(pct_b):
474
+ if pct_b > 0.90:
475
+ points += 0.5; details.append("BB%B:High")
476
+ elif pct_b < 0.10:
477
+ points -= 0.5; details.append("BB%B:Low")
478
+
479
+ # 3. ATR
480
+ atr = ta.atr(df['high'], df['low'], df['close'], length=14)
481
+ if self._valid(atr) and atr.iloc[-1] > atr.iloc[-5]:
482
+ points += 1; details.append("ATR:Rising")
483
+
484
+ # 4. KC Break
485
+ kc = ta.kc(df['high'], df['low'], df['close'])
486
+ if self._valid(kc):
487
+ kcu_col = self._find_col(kc, ['kcu_', 'upper']) or kc.columns[-1]
488
+ if df['close'].iloc[-1] > kc[kcu_col].iloc[-1]:
489
+ points += 2; details.append("KC:Breakout")
490
+
491
+ # 5. Donchian
492
+ dc = ta.donchian(df['high'], df['low'])
493
+ if self._valid(dc):
494
+ dcu_col = self._find_col(dc, ['dcu_', 'upper']) or dc.columns[-1]
495
+ if df['high'].iloc[-1] >= dc[dcu_col].iloc[-2]:
496
+ points += 1; details.append("DC:High")
497
+
498
+ # 6. Mass Index
499
+ mass = ta.massi(df['high'], df['low'])
500
+ if self._valid(mass) and mass.iloc[-1] > 25:
501
+ points -= 1; details.append("Mass:Risk")
502
+
503
+ # 7. Chaikin Vol
504
+ c_vol = ta.stdev(df['close'], 20)
505
+ if self._valid(c_vol) and c_vol.iloc[-1] > c_vol.iloc[-10]:
506
+ points += 1; details.append("Vol:Exp")
507
+
508
+ # 8. Ulcer
509
+ ui = ta.ui(df['close'])
510
+ if self._valid(ui):
511
+ val = ui.iloc[-1]
512
+ if val < 2: points += 1; details.append("UI:Safe")
513
+ else: points -= 1
514
+
515
+ # 9. NATR
516
+ natr = ta.natr(df['high'], df['low'], df['close'])
517
+ if self._valid(natr) and natr.iloc[-1] > 1.0:
518
+ points += 1; details.append(f"NATR:{natr.iloc[-1]:.1f}")
519
+
520
+ # 10. Gap
521
+ if self._valid(atr):
522
+ gap = abs(df['open'].iloc[-1] - df['close'].iloc[-2])
523
+ if gap > atr.iloc[-1] * 0.5: points += 1; details.append("Gap")
524
+
525
+ # 11. Vol Ratio
526
+ if self._valid(atr):
527
+ vr = atr.iloc[-1] / atr.iloc[-20]
528
+ if vr > 1.2: points += 1; details.append("VolRatio:High")
529
+
530
+ # 12. RVI (Proxy)
531
+ if self._valid(c_vol):
532
+ std_rsi = ta.rsi(c_vol, length=14)
533
+ if self._valid(std_rsi) and std_rsi.iloc[-1] > 50: points += 0.5
534
+
535
+ # 13. StdDev Channel
536
+ mean = df['close'].rolling(20).mean()
537
+ std = df['close'].rolling(20).std()
538
+ z = (df['close'].iloc[-1] - mean.iloc[-1]) / std.iloc[-1]
539
+ if abs(z) < 2: points += 0.5
540
+
541
+ # 14. ATS
542
+ if self._valid(atr):
543
+ ats = df['close'].iloc[-1] - (atr.iloc[-1] * 2)
544
+ if df['close'].iloc[-1] > ats: points += 1
545
+
546
+ # 15. Chop
547
+ chop = ta.chop(df['high'], df['low'], df['close'])
548
+ if self._valid(chop):
549
+ val = chop.iloc[-1]
550
+ if val < 38.2: points += 1; details.append("Chop:Trend")
551
+ elif val > 61.8: points -= 1; details.append("Chop:Range")
552
+
553
+ # 16. KC Width
554
+ if self._valid(kc):
555
+ kw = kc[kc.columns[0]].iloc[-1] - kc[kc.columns[2]].iloc[-1]
556
+ if kw > kw * 1.1: points += 0.5
557
+
558
+ # 17. Accel
559
+ if df['close'].diff().iloc[-1] > df['close'].diff().iloc[-2]: points += 0.5
560
+
561
+ # 18. Efficiency
562
+ denom = (df['high'].rolling(10).max() - df['low'].rolling(10).min()).iloc[-1]
563
+ if denom > 0:
564
+ eff = abs(df['close'].iloc[-1] - df['close'].iloc[-10]) / denom
565
+ if eff > 0.5: points += 1; details.append("Eff:High")
566
+
567
+ # 19. Gator
568
+ if ta.ema(df['close'], 5).iloc[-1] > ta.ema(df['close'], 13).iloc[-1]: points += 0.5
569
+
570
+ # 20. Range
571
+ if self._valid(atr):
572
+ rng = df['high'].iloc[-1] - df['low'].iloc[-1]
573
+ if rng > atr.iloc[-1]: points += 1
574
+
575
+ except Exception as e: details.append(f"VolErr:{str(e)[:10]}")
576
+ norm_score = self._normalize(points, max_possible=18.0)
577
+ if include_details and details_pack is not None:
578
+ details_pack['trend'] = details
579
+ if include_details and details_pack is not None:
580
+ details_pack['momentum'] = details
581
+ if include_details and details_pack is not None:
582
+ details_pack['volatility'] = details
583
+ if include_details and details_pack is not None:
584
+ details_pack['volume'] = details
585
+ if include_details and details_pack is not None:
586
+ details_pack['cycle_math'] = details
587
+ if include_details and details_pack is not None:
588
+ details_pack['structure'] = details
589
+ if include_details and details_pack is not None:
590
+ details_pack['order_book'] = details
591
+ if verbose: print(f" 🌊 [VOLATILITY] Score: {norm_score:.2f} | {', '.join(details)}")
592
+ return norm_score
593
+
594
+ # ==============================================================================
595
+ # ⛽ DOMAIN 4: VOLUME (Fixed)
596
+ # ==============================================================================
597
+ def _calc_volume_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
598
+ points = 0.0
599
+ details = []
600
+ try:
601
+ c = df['close']; v = df['volume']
602
+ # 1. OBV
603
+ obv = ta.obv(c, v)
604
+ if self._valid(obv) and obv.iloc[-1] > obv.iloc[-5]:
605
+ points += 1.5; details.append("OBV:Up")
606
+
607
+ # 2. CMF
608
+ cmf = ta.cmf(df['high'], df['low'], c, v, length=20)
609
+ if self._valid(cmf):
610
+ val = cmf.iloc[-1]
611
+ if val > 0.05: points += 2; details.append(f"CMF:{val:.2f}")
612
+ elif val < -0.05: points -= 2
613
+
614
+ # 3. MFI
615
+ mfi = ta.mfi(df['high'], df['low'], c, v, length=14)
616
+ if self._valid(mfi):
617
+ val = mfi.iloc[-1]
618
+ if 50 < val < 80: points += 1; details.append(f"MFI:{val:.0f}")
619
+
620
+ # 4. Vol > Avg
621
+ vol_ma = v.rolling(20).mean().iloc[-1]
622
+ if v.iloc[-1] > vol_ma: points += 1
623
+
624
+ # 5. Vol Spike
625
+ if v.iloc[-1] > vol_ma * 1.5: points += 1; details.append("Vol:Spike")
626
+
627
+ # 6. EOM
628
+ eom = ta.eom(df['high'], df['low'], c, v)
629
+ if self._valid(eom) and eom.iloc[-1] > 0: points += 1; details.append("EOM:Pos")
630
+
631
+ # 7. VWAP
632
+ vwap = ta.vwap(df['high'], df['low'], c, v)
633
+ if self._valid(vwap) and c.iloc[-1] > vwap.iloc[-1]: points += 1; details.append("Price>VWAP")
634
+
635
+ # 8. NVI
636
+ nvi = ta.nvi(c, v)
637
+ if self._valid(nvi) and nvi.iloc[-1] > nvi.iloc[-5]: points += 1; details.append("NVI:Smart")
638
+
639
+ # 9. PVI
640
+ pvi = ta.pvi(c, v)
641
+ if self._valid(pvi) and pvi.iloc[-1] > pvi.iloc[-5]: points += 0.5
642
+
643
+ # 10. ADL
644
+ adl = ta.ad(df['high'], df['low'], c, v)
645
+ if self._valid(adl) and adl.iloc[-1] > adl.iloc[-2]: points += 1; details.append("ADL:Up")
646
+
647
+ # 11. PVT
648
+ pvt = ta.pvt(c, v)
649
+ if self._valid(pvt) and pvt.iloc[-1] > pvt.iloc[-2]: points += 1
650
+
651
+ # 12. Vol Osc
652
+ if v.rolling(5).mean().iloc[-1] > v.rolling(10).mean().iloc[-1]: points += 1
653
+
654
+ # 13. KVO
655
+ kvo = ta.kvo(df['high'], df['low'], c, v)
656
+ if self._valid(kvo) and kvo[kvo.columns[0]].iloc[-1] > 0: points += 1; details.append("KVO:Bull")
657
+
658
+ # 14. Force
659
+ fi = (c.diff() * v).rolling(13).mean()
660
+ if fi.iloc[-1] > 0: points += 1
661
+
662
+ # 15. MFI (Bill Williams)
663
+ if v.iloc[-1] > 0:
664
+ my_mfi = (df['high'] - df['low']) / v
665
+ if my_mfi.iloc[-1] > my_mfi.iloc[-2] and v.iloc[-1] > v.iloc[-2]: points += 1
666
+
667
+ # 16. Buying Climax
668
+ if v.iloc[-1] > vol_ma * 3 and c.iloc[-1] > df['high'].iloc[-2]: points -= 1
669
+
670
+ # 17. RVOL
671
+ if vol_ma > 0:
672
+ rvol = v.iloc[-1] / vol_ma
673
+ if rvol > 1.2: points += 1; details.append(f"RVOL:{rvol:.1f}")
674
+
675
+ # 18. Delta
676
+ delta = (c.iloc[-1] - df['open'].iloc[-1]) * v.iloc[-1]
677
+ if delta > 0: points += 1
678
+
679
+ # 20. Low Vol Gap
680
+ if self._valid(ta.atr(df['high'], df['low'], c)):
681
+ if v.iloc[-1] < vol_ma * 0.5 and abs(c.diff().iloc[-1]) > ta.atr(df['high'], df['low'], c).iloc[-1]:
682
+ points -= 1
683
+
684
+ except Exception as e: details.append(f"VolErr:{str(e)[:10]}")
685
+ norm_score = self._normalize(points, max_possible=18.0)
686
+ if include_details and details_pack is not None:
687
+ details_pack['trend'] = details
688
+ if include_details and details_pack is not None:
689
+ details_pack['momentum'] = details
690
+ if include_details and details_pack is not None:
691
+ details_pack['volatility'] = details
692
+ if include_details and details_pack is not None:
693
+ details_pack['volume'] = details
694
+ if include_details and details_pack is not None:
695
+ details_pack['cycle_math'] = details
696
+ if include_details and details_pack is not None:
697
+ details_pack['structure'] = details
698
+ if include_details and details_pack is not None:
699
+ details_pack['order_book'] = details
700
+ if verbose: print(f" ⛽ [VOLUME] Score: {norm_score:.2f} | {', '.join(details)}")
701
+ return norm_score
702
+
703
+ # ==============================================================================
704
+ # 🔢 DOMAIN 5: CYCLE & MATH (Fixed)
705
+ # ==============================================================================
706
+ def _calc_cycle_math_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
707
+ points = 0.0
708
+ details = []
709
+ try:
710
+ c = df['close']; h = df['high']; l = df['low']
711
+
712
+ # 1. Pivot
713
+ pp = (h.iloc[-2] + l.iloc[-2] + c.iloc[-2]) / 3
714
+ if c.iloc[-1] > pp: points += 1; details.append("AbovePP")
715
+
716
+ # 2. R1
717
+ r1 = (2 * pp) - l.iloc[-2]
718
+ if c.iloc[-1] > r1: points += 1; details.append("AboveR1")
719
+
720
+ # 3. Fib 618
721
+ range_h = h.rolling(100).max().iloc[-1]
722
+ range_l = l.rolling(100).min().iloc[-1]
723
+ fib_618 = range_l + (range_h - range_l) * 0.618
724
+ if c.iloc[-1] > fib_618: points += 1; details.append("AboveFib")
725
+
726
+ # 4. Z-Score
727
+ zscore = ta.zscore(c, length=30)
728
+ if self._valid(zscore):
729
+ z = zscore.iloc[-1]
730
+ if z < -2: points += 2; details.append("Z:OS")
731
+ elif -1 < z < 1: points += 0.5; details.append("Z:Norm")
732
+
733
+ # 5. Entropy
734
+ entropy = ta.entropy(c, length=10)
735
+ if self._valid(entropy) and entropy.iloc[-1] < 0.5:
736
+ points += 1; details.append(f"Ent:{entropy.iloc[-1]:.2f}")
737
+
738
+ # 6. Kurtosis
739
+ kurt = c.rolling(30).kurt().iloc[-1]
740
+ if kurt > 3: points -= 0.5
741
+
742
+ # 7. Skew
743
+ skew = c.rolling(30).skew().iloc[-1]
744
+ if skew > 0: points += 0.5; details.append("PosSkew")
745
+
746
+ # 8. Variance
747
+ var = ta.variance(c, length=20)
748
+ if self._valid(var): points += 0
749
+
750
+ # 9. StdDev
751
+ std = c.rolling(20).std().iloc[-1]
752
+ if c.iloc[-1] > (c.rolling(20).mean().iloc[-1] + std): points += 0.5
753
+
754
+ # 10. LinReg
755
+ linreg = ta.linreg(c, length=20)
756
+ if self._valid(linreg) and c.iloc[-1] > linreg.iloc[-1]:
757
+ points += 1; details.append("AboveLinReg")
758
+
759
+ # 13. CG
760
+ cg = ta.cg(c, length=10)
761
+ if self._valid(cg) and c.diff().iloc[-1] > 0: points += 0.5
762
+
763
+ # 20. Mean Rev
764
+ dist_mean = abs(c.iloc[-1] - c.rolling(50).mean().iloc[-1])
765
+ if dist_mean > std * 2: points -= 1
766
+ else: points += 0.5
767
+
768
+ except Exception as e: details.append(f"MathErr:{str(e)[:10]}")
769
+ norm_score = self._normalize(points, max_possible=12.0)
770
+ if include_details and details_pack is not None:
771
+ details_pack['trend'] = details
772
+ if include_details and details_pack is not None:
773
+ details_pack['momentum'] = details
774
+ if include_details and details_pack is not None:
775
+ details_pack['volatility'] = details
776
+ if include_details and details_pack is not None:
777
+ details_pack['volume'] = details
778
+ if include_details and details_pack is not None:
779
+ details_pack['cycle_math'] = details
780
+ if include_details and details_pack is not None:
781
+ details_pack['structure'] = details
782
+ if include_details and details_pack is not None:
783
+ details_pack['order_book'] = details
784
+ if verbose: print(f" 🔢 [MATH] Score: {norm_score:.2f} | {', '.join(details)}")
785
+ return norm_score
786
+
787
+ # ==============================================================================
788
+ # 🧱 DOMAIN 6: STRUCTURE (Fixed)
789
+ # ==============================================================================
790
+ def _calc_structure_domain(self, df: pd.DataFrame, verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
791
+ points = 0.0
792
+ details = []
793
+ try:
794
+ closes = df['close'].values; opens = df['open'].values
795
+ highs = df['high'].values; lows = df['low'].values
796
+
797
+ # 1. HH
798
+ if highs[-1] > highs[-2] and highs[-2] > highs[-3]:
799
+ points += 2; details.append("HH")
800
+
801
+ # 2. HL
802
+ if lows[-1] > lows[-2] and lows[-2] > lows[-3]:
803
+ points += 2; details.append("HL")
804
+
805
+ # 3. Engulfing
806
+ if closes[-1] > opens[-1]:
807
+ if closes[-1] > highs[-2] and opens[-1] < lows[-2]:
808
+ points += 2; details.append("Engulfing")
809
+
810
+ # 4. Hammer
811
+ body = abs(closes[-1] - opens[-1])
812
+ lower_wick = min(closes[-1], opens[-1]) - lows[-1]
813
+ if lower_wick > body * 2:
814
+ points += 2; details.append("Hammer")
815
+
816
+ # 5. BOS
817
+ recent_high = np.max(highs[-11:-1])
818
+ if closes[-1] > recent_high: points += 2; details.append("BOS")
819
+
820
+ # 6. FVG
821
+ if len(closes) > 3 and lows[-1] > highs[-3] * 1.001:
822
+ points += 1; details.append("FVG")
823
+
824
+ # 7. Order Block
825
+ if closes[-2] < opens[-2] and closes[-1] > opens[-1]:
826
+ if (closes[-1] - opens[-1]) > (opens[-2] - closes[-2]) * 2:
827
+ points += 1.5; details.append("OB")
828
+
829
+ # 8. SFP
830
+ if lows[-1] < lows[-2] and closes[-1] > lows[-2]:
831
+ points += 2.5; details.append("SFP")
832
+
833
+ # 9. Inside Bar
834
+ if highs[-1] < highs[-2] and lows[-1] > lows[-2]:
835
+ points -= 0.5; details.append("IB")
836
+
837
+ # 10. Morning Star
838
+ if closes[-3] < opens[-3] and abs(closes[-2]-opens[-2]) < body*0.5 and closes[-1] > opens[-1]:
839
+ points += 2; details.append("MorningStar")
840
+
841
+ # 14. Golden Cross Struct
842
+ m50 = np.mean(closes[-50:]); m200 = np.mean(closes[-200:]) if len(closes)>200 else m50
843
+ if m50 > m200: points += 1
844
+
845
+ # 16. Impulse
846
+ avg_body = np.mean([abs(c-o) for c,o in zip(closes[-10:], opens[-10:])])
847
+ if body > avg_body * 2: points += 1; details.append("Impulse")
848
+
849
+ except Exception as e: details.append(f"PAErr:{str(e)[:10]}")
850
+ norm_score = self._normalize(points, max_possible=18.0)
851
+ if include_details and details_pack is not None:
852
+ details_pack['trend'] = details
853
+ if include_details and details_pack is not None:
854
+ details_pack['momentum'] = details
855
+ if include_details and details_pack is not None:
856
+ details_pack['volatility'] = details
857
+ if include_details and details_pack is not None:
858
+ details_pack['volume'] = details
859
+ if include_details and details_pack is not None:
860
+ details_pack['cycle_math'] = details
861
+ if include_details and details_pack is not None:
862
+ details_pack['structure'] = details
863
+ if include_details and details_pack is not None:
864
+ details_pack['order_book'] = details
865
+ if verbose: print(f" 🧱 [STRUCTURE] Score: {norm_score:.2f} | {', '.join(details)}")
866
+ return norm_score
867
+
868
+ # ==============================================================================
869
+ # 📖 DOMAIN 7: ORDER BOOK (Already Safe, but kept consistent)
870
+ # ==============================================================================
871
+ def _calc_orderbook_domain(self, ob: Dict[str, Any], verbose: bool, include_details: bool = False, details_pack: Any = None) -> float:
872
+ points = 0.0
873
+ details = []
874
+ if not ob or 'bids' not in ob or 'asks' not in ob: return 0.0
875
+
876
+ try:
877
+ bids = np.array(ob['bids'], dtype=float)
878
+ asks = np.array(ob['asks'], dtype=float)
879
+ if len(bids) < 20 or len(asks) < 20: return 0.0
880
+
881
+ bid_vol = np.sum(bids[:20, 1])
882
+ ask_vol = np.sum(asks[:20, 1])
883
+ imbal = (bid_vol - ask_vol) / (bid_vol + ask_vol)
884
+ points += imbal * 5; details.append(f"Imbal:{imbal:.2f}")
885
+
886
+ avg_size = np.mean(bids[:50, 1])
887
+ if np.max(bids[:20, 1]) > avg_size * 5: points += 3; details.append("BidWall")
888
+ if np.max(asks[:20, 1]) > avg_size * 5: points -= 3; details.append("AskWall")
889
+
890
+ spread = (asks[0,0] - bids[0,0]) / bids[0,0] * 100
891
+ if spread < 0.05: points += 1; details.append("TightSpread")
892
+ elif spread > 0.2: points -= 1; details.append("WideSpread")
893
+
894
+ if bid_vol > ask_vol * 1.5: points += 2; details.append("Depth:Bull")
895
+ if bids[0,1] > bids[1,1] and bids[1,1] > bids[2,1]: points += 1; details.append("Slope:Up")
896
+ # Slippage / depth-to-move (normalized; avoids hard-coded thresholds)
897
+ mid = (asks[0, 0] + bids[0, 0]) / 2.0
898
+ target_p = mid * 1.005 # ~0.5% up move
899
+ vol_needed = 0.0
900
+ for p, s in asks:
901
+ if p > target_p:
902
+ break
903
+ vol_needed += float(s)
904
+
905
+ # Normalize by visible depth (top 20)
906
+ visible_ask = float(np.sum(asks[:20, 1])) if len(asks) >= 20 else float(np.sum(asks[:, 1]))
907
+ ratio = (vol_needed / visible_ask) if visible_ask > 0 else 0.0
908
+
909
+ # Higher ratio => more depth needed to move price => thicker book (safer entry)
910
+ if ratio > 0.65:
911
+ points += 1; details.append(f"ThickBook:{ratio:.2f}")
912
+ elif ratio < 0.30:
913
+ points -= 1; details.append(f"ThinBook:{ratio:.2f}")
914
+ else:
915
+ details.append(f"BookOK:{ratio:.2f}")
916
+
917
+ # Best-level dominance (simple slope proxy)
918
+ if bids[0, 1] > asks[0, 1] * 2:
919
+ points += 1; details.append("TopBid>TopAsk*2")
920
+
921
+ top_bid_notional = float(bids[0, 0] * bids[0, 1])
922
+ # Dynamic whale detection vs median level notional (top 20)
923
+ level_notionals = (bids[:20, 0] * bids[:20, 1]).astype(float)
924
+ med_notional = float(np.median(level_notionals)) if len(level_notionals) else 0.0
925
+ if med_notional > 0 and (top_bid_notional / med_notional) >= 8.0:
926
+ points += 1; details.append(f"WhaleBid:{top_bid_notional/med_notional:.1f}x")
927
+
928
+ except Exception as e: details.append("OBErr")
929
+
930
+ norm_score = self._normalize(points, max_possible=15.0)
931
+ if include_details and details_pack is not None:
932
+ details_pack['trend'] = details
933
+ if include_details and details_pack is not None:
934
+ details_pack['momentum'] = details
935
+ if include_details and details_pack is not None:
936
+ details_pack['volatility'] = details
937
+ if include_details and details_pack is not None:
938
+ details_pack['volume'] = details
939
+ if include_details and details_pack is not None:
940
+ details_pack['cycle_math'] = details
941
+ if include_details and details_pack is not None:
942
+ details_pack['structure'] = details
943
+ if include_details and details_pack is not None:
944
+ details_pack['order_book'] = details
945
+ if verbose: print(f" 📖 [ORDERBOOK] Score: {norm_score:.2f} | {', '.join(details)}")
946
+ return norm_score
947
+
948
+ # ==============================================================================
949
+ # 🔧 Utilities
950
+ # ==============================================================================
951
+ def _valid(self, item, col: Any = None) -> bool:
952
+ """Return True if item has a finite last value (Series) or at least one finite last-row value (DataFrame).
953
+ If col is provided and item is a DataFrame, checks that column's last value.
954
+ """
955
+ if item is None:
956
+ return False
957
+
958
+ # pandas_ta sometimes returns tuples (e.g., ichimoku)
959
+ if isinstance(item, tuple):
960
+ # consider valid if any element is valid
961
+ return any(self._valid(x, col=col) for x in item)
962
+
963
+ try:
964
+ if isinstance(item, pd.Series):
965
+ if item.empty:
966
+ return False
967
+ v = item.iloc[-1]
968
+ return pd.notna(v) and np.isfinite(v)
969
+
970
+ if isinstance(item, pd.DataFrame):
971
+ if item.empty:
972
+ return False
973
+ if col is not None:
974
+ c = self._find_col(item, [col]) or (col if col in item.columns else None)
975
+ if c is None:
976
+ return False
977
+ v = item[c].iloc[-1]
978
+ return pd.notna(v) and np.isfinite(v)
979
+ # any finite in last row
980
+ last = item.iloc[-1]
981
+ if isinstance(last, pd.Series):
982
+ vals = last.values.astype(float, copy=False)
983
+ return np.isfinite(vals).any()
984
+ return False
985
+
986
+ # scalars
987
+ if isinstance(item, (int, float, np.number)):
988
+ return np.isfinite(item)
989
+ return True
990
+
991
+ except Exception:
992
+ return False
993
+
994
+ def _find_col(self, df: pd.DataFrame, contains_any: List[str]) -> Any:
995
+ """Find first column whose name contains any of the provided substrings (case-insensitive)."""
996
+ if df is None or getattr(df, "empty", True):
997
+ return None
998
+ cols = list(df.columns)
999
+ lowered = [str(c).lower() for c in cols]
1000
+ needles = [s.lower() for s in contains_any]
1001
+ for n in needles:
1002
+ for c, lc in zip(cols, lowered):
1003
+ if n in lc:
1004
+ return c
1005
+ return None
1006
+
1007
+ def _safe_last(self, item, default=np.nan, col: Any = None) -> float:
1008
+ """Safely get last finite value from Series/DataFrame (optionally from matched column)."""
1009
+ if not self._valid(item, col=col):
1010
+ return float(default)
1011
+ try:
1012
+ if isinstance(item, pd.Series):
1013
+ return float(item.iloc[-1])
1014
+ if isinstance(item, pd.DataFrame):
1015
+ if col is None:
1016
+ # pick first finite value in last row
1017
+ last = item.iloc[-1]
1018
+ for v in last.values:
1019
+ if pd.notna(v) and np.isfinite(v):
1020
+ return float(v)
1021
+ return float(default)
1022
+ c = self._find_col(item, [col]) or (col if col in item.columns else None)
1023
+ if c is None:
1024
+ return float(default)
1025
+ return float(item[c].iloc[-1])
1026
+ if isinstance(item, (int, float, np.number)):
1027
+ return float(item)
1028
+ return float(default)
1029
+ except Exception:
1030
+ return float(default)
1031
+
1032
+ def _normalize(self, value: float, max_possible: float) -> float:
1033
+ if max_possible == 0: return 0.0
1034
+ return max(-1.0, min(1.0, value / max_possible))
1035
+
1036
+ def _prepare_dataframe(self, ohlcv: List) -> pd.DataFrame:
1037
+ df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
1038
+ df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
1039
+ df.set_index('timestamp', inplace=True)
1040
+ cols = ['open', 'high', 'low', 'close', 'volume']
1041
+ df[cols] = df[cols].astype(float)
1042
+ return df
1043
+
1044
+ def _get_grade(self, score: float) -> str:
1045
+ if score >= 85: return "ULTRA"
1046
+ if score >= 70: return "STRONG"
1047
+ if score >= 50: return "NORMAL"
1048
+ if score >= 35: return "WEAK"
1049
+ return "REJECT"
1050
+
1051
+ def _create_rejection(self, reason: str):
1052
+ return {
1053
+ "governance_score": 0.0,
1054
+ "grade": "REJECT",
1055
+ "status": "REJECTED",
1056
+ "reason": reason,
1057
+ "components": {}
1058
+ }