Riy777 commited on
Commit
fa9f6bd
·
verified ·
1 Parent(s): 916aa63

Update ml_engine/data_manager.py

Browse files
Files changed (1) hide show
  1. ml_engine/data_manager.py +134 -107
ml_engine/data_manager.py CHANGED
@@ -1,5 +1,5 @@
1
  # ml_engine/data_manager.py
2
- # (V15.1 - GEM-Architect: Tuned Logic Tree - Marksman Mode)
3
 
4
  import asyncio
5
  import httpx
@@ -17,20 +17,33 @@ logging.getLogger("ccxt").setLevel(logging.WARNING)
17
 
18
 
19
  class DataManager:
20
- def __init__(self, contracts_db, whale_tracker, exchange, news_client):
21
- self.contracts_db = contracts_db
22
- self.whale_tracker = whale_tracker
23
- self.exchange = exchange
24
- self.news_client = news_client
25
-
26
- # KuCoin spot
27
- self.exchange = ccxt.kucoin({
28
- "enableRateLimit": True,
29
- "timeout": 60000,
30
- "options": {"defaultType": "spot"},
31
- })
32
-
33
- self.http_client: httpx.AsyncClient | None = None
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  self.market_cache: Dict[str, Any] = {}
35
 
36
  # قوائم الاستبعاد
@@ -51,15 +64,17 @@ class DataManager:
51
  "3L",
52
  ]
53
 
54
- if self.contracts_db is None:
55
- self.contracts_db = {}
56
-
57
  async def initialize(self):
58
  """تهيئة مدير البيانات والاتصالات"""
59
  print(" > [DataManager] Starting initialization...")
60
  self.http_client = httpx.AsyncClient(timeout=30.0)
61
  await self._load_markets()
62
- print(f"✅ [DataManager V15.1] Ready (Logic Tree: Tuned/Flexible).")
 
 
63
 
64
  async def _load_markets(self):
65
  try:
@@ -71,23 +86,41 @@ class DataManager:
71
  traceback.print_exc()
72
 
73
  async def close(self):
74
- if self.http_client:
75
- await self.http_client.aclose()
76
- if self.exchange:
77
- await self.exchange.close()
 
 
 
 
 
 
 
78
 
79
  def get_contracts_db(self) -> Dict[str, Any]:
80
  return self.contracts_db
81
 
82
  # ==================================================================
83
- # 🛡️ Layer 1: The Tuned Decision Tree Screening
 
 
 
 
 
 
 
 
 
 
 
 
84
  # ==================================================================
85
  async def layer1_rapid_screening(self) -> List[Dict[str, Any]]:
86
- print(f"🔍 [Layer 1] Initiating Tuned Logic Tree Screening...")
87
 
88
  # 1. المرحلة 0: فلتر الكون (مخفف)
89
  initial_candidates = await self._stage0_universe_filter()
90
-
91
  if not initial_candidates:
92
  return []
93
 
@@ -96,9 +129,9 @@ class DataManager:
96
  enriched_data = await self._fetch_technical_data_batch(top_liquid_candidates)
97
 
98
  # 3. تطبيق شجرة القرار (Overbought -> Classify)
99
- breakout_list = []
100
- reversal_list = []
101
- neutral_list = []
102
 
103
  for item in enriched_data:
104
  classification = self._apply_logic_tree(item)
@@ -109,14 +142,14 @@ class DataManager:
109
  continue
110
 
111
  if ctype == "BREAKOUT":
112
- item["l1_sort_score"] = classification.get("score", 0.0)
113
  breakout_list.append(item)
114
  elif ctype == "REVERSAL":
115
- item["l1_sort_score"] = classification.get("score", 0.0)
116
  reversal_list.append(item)
117
  else:
118
  # عملات "عادية" تمر كفلتر أولي فقط – نرتبها بالسيولة
119
- item["l1_sort_score"] = float(item.get("quote_volume", 0)) or 0.0
120
  neutral_list.append(item)
121
 
122
  print(
@@ -140,17 +173,15 @@ class DataManager:
140
 
141
  final_selection = top_breakouts + top_reversals + top_neutrals
142
 
143
- cleaned_selection = []
144
  for item in final_selection:
145
  cleaned_selection.append(
146
  {
147
  "symbol": item["symbol"],
148
- "quote_volume": item.get("quote_volume", 0),
149
- "current_price": item.get("current_price", 0),
150
- "type": item.get(
151
- "type", "UNKNOWN"
152
- ), # نمرر النوع لـ app.py إذا رغب باستخدامه
153
- "l1_score": item.get("l1_sort_score", 0),
154
  }
155
  )
156
 
@@ -160,7 +191,7 @@ class DataManager:
160
  return cleaned_selection
161
 
162
  # ------------------------------------------------------------------
163
- # Stage 0: Universe Filter (RELAXED)
164
  # ------------------------------------------------------------------
165
  async def _stage0_universe_filter(self) -> List[Dict[str, Any]]:
166
  try:
@@ -168,6 +199,7 @@ class DataManager:
168
  candidates: List[Dict[str, Any]] = []
169
 
170
  for symbol, ticker in tickers.items():
 
171
  if not symbol.endswith("/USDT"):
172
  continue
173
 
@@ -181,6 +213,7 @@ class DataManager:
181
  continue
182
 
183
  last_price = ticker.get("last")
 
184
  if last_price is None:
185
  continue
186
 
@@ -194,6 +227,7 @@ class DataManager:
194
  )
195
 
196
  candidates.sort(key=lambda x: x["quote_volume"], reverse=True)
 
197
  return candidates
198
 
199
  except Exception as e:
@@ -206,39 +240,39 @@ class DataManager:
206
  async def _fetch_technical_data_batch(
207
  self, candidates: List[Dict[str, Any]]
208
  ) -> List[Dict[str, Any]]:
209
- chunk_size = 10
210
  results: List[Dict[str, Any]] = []
211
- for i in range(0, len(candidates), chunk_size):
212
- chunk = candidates[i : i + chunk_size]
213
- chunk_tasks = [self._fetch_single_tech_data(c) for c in chunk]
214
- chunk_results = await asyncio.gather(*chunk_tasks, return_exceptions=False)
215
- for r in chunk_results:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  if r is not None:
217
  results.append(r)
218
  await asyncio.sleep(0.1)
 
219
  print(f" -> [Stage1.5] Enriched OHLCV for {len(results)} symbols.")
220
  return results
221
 
222
- async def _fetch_single_tech_data(self, candidate: Dict[str, Any]) -> Any:
223
- symbol = candidate["symbol"]
224
- try:
225
- ohlcv_1h = await self.exchange.fetch_ohlcv(symbol, "1h", limit=60)
226
- ohlcv_15m = await self.exchange.fetch_ohlcv(symbol, "15m", limit=60)
227
-
228
- if (
229
- not ohlcv_1h
230
- or len(ohlcv_1h) < 55
231
- or not ohlcv_15m
232
- or len(ohlcv_15m) < 55
233
- ):
234
- return None
235
-
236
- candidate["ohlcv_1h"] = ohlcv_1h
237
- candidate["ohlcv_15m"] = ohlcv_15m
238
- return candidate
239
- except Exception:
240
- return None
241
-
242
  # ------------------------------------------------------------------
243
  # Stage 2 + 3: Overbought Filter + Classification
244
  # ------------------------------------------------------------------
@@ -246,7 +280,8 @@ class DataManager:
246
  try:
247
  df_1h = self._calc_indicators(data["ohlcv_1h"])
248
  df_15m = self._calc_indicators(data["ohlcv_15m"])
249
- except:
 
250
  return {"type": "FILTERED"}
251
 
252
  curr_1h = df_1h.iloc[-1]
@@ -255,23 +290,22 @@ class DataManager:
255
  # --- Stage 2: Overbought / Extreme Pump Filter (RELAXED HARD FILTER) ---
256
  try:
257
  close_4h_ago = df_1h.iloc[-5]["close"]
258
- change_4h = (
259
- (curr_1h["close"] - close_4h_ago) / close_4h_ago
260
- ) * 100
261
- except:
262
  change_4h = 0.0
263
 
 
264
  if change_4h > 25.0:
265
  return {"type": "FILTERED"}
266
- if data.get("change_24h", 0) > 40.0:
267
  return {"type": "FILTERED"}
268
- if curr_1h["rsi"] > 88:
269
  return {"type": "FILTERED"}
270
 
271
  deviation = (
272
  (curr_1h["close"] - curr_1h["ema20"]) / curr_1h["atr"]
273
- if curr_1h["atr"] > 0
274
- else 0
275
  )
276
  if deviation > 3.5:
277
  return {"type": "FILTERED"}
@@ -282,14 +316,14 @@ class DataManager:
282
  is_breakout = False
283
  breakout_score = 0.0
284
 
285
- # Trend check (EMA Cross OR Price above both EMAs)
286
  bullish_structure = (curr_1h["ema20"] > curr_1h["ema50"]) or (
287
  curr_1h["close"] > curr_1h["ema20"]
288
  )
289
 
290
  if bullish_structure:
291
- # RSI range
292
- if 40 <= curr_1h["rsi"] <= 70:
293
  # 15m bullish
294
  if curr_15m["close"] >= curr_15m["ema20"]:
295
  # Volatility check (Range)
@@ -301,17 +335,15 @@ class DataManager:
301
  )
302
  # Less strict squeeze check
303
  if (curr_15m["high"] - curr_15m["low"]) <= avg_range * 1.5:
304
- vol_ma20 = (
305
- df_15m["volume"].rolling(20).mean().iloc[-1]
306
- )
307
- # Volume Spike lowered
308
- if curr_15m["volume"] >= 1.2 * vol_ma20:
 
 
309
  is_breakout = True
310
- breakout_score = (
311
- curr_15m["volume"] / vol_ma20
312
- if vol_ma20 > 0
313
- else 1.0
314
- )
315
 
316
  if is_breakout:
317
  data["type"] = "BREAKOUT"
@@ -321,28 +353,22 @@ class DataManager:
321
  is_reversal = False
322
  reversal_score = 100.0
323
 
324
- # RSI threshold
325
- if 10 <= curr_1h["rsi"] <= 40:
326
- # Drop requirement
327
  if change_4h <= -3.0:
328
- # Rejection check (Any bullish closing in last 3 candles)
329
  last_3 = df_15m.iloc[-3:]
330
  found_rejection = False
331
  for _, row in last_3.iterrows():
332
- # Green candle OR Close in upper half
333
  rng = row["high"] - row["low"]
334
  if rng > 0:
335
  is_green = row["close"] > row["open"]
336
- upper_half = row["close"] > (
337
- row["low"] + rng * 0.5
338
- )
339
  if is_green or upper_half:
340
  found_rejection = True
341
  break
342
 
343
  if found_rejection:
344
  is_reversal = True
345
- reversal_score = curr_1h["rsi"]
346
 
347
  if is_reversal:
348
  data["type"] = "REVERSAL"
@@ -350,7 +376,10 @@ class DataManager:
350
 
351
  return {"type": "NONE"}
352
 
353
- def _calc_indicators(self, ohlcv_list):
 
 
 
354
  df = pd.DataFrame(
355
  ohlcv_list,
356
  columns=["timestamp", "open", "high", "low", "close", "volume"],
@@ -358,10 +387,10 @@ class DataManager:
358
 
359
  # RSI
360
  delta = df["close"].diff()
361
- gain = (delta.where(delta > 0, 0)).rolling(window=14).mean()
362
- loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
363
  rs = gain / loss
364
- df["rsi"] = 100 - (100 / (1 + rs))
365
 
366
  # EMAs
367
  df["ema20"] = df["close"].ewm(span=20, adjust=False).mean()
@@ -369,8 +398,8 @@ class DataManager:
369
 
370
  # ATR
371
  high_low = df["high"] - df["low"]
372
- high_close = np.abs(df["high"] - df["close"].shift())
373
- low_close = np.abs(df["low"] - df["close"].shift())
374
  ranges = pd.concat([high_low, high_close, low_close], axis=1)
375
  true_range = ranges.max(axis=1)
376
  df["atr"] = true_range.rolling(14).mean()
@@ -378,7 +407,7 @@ class DataManager:
378
  return df
379
 
380
  # ==================================================================
381
- # 🎯 Public Helpers
382
  # ==================================================================
383
  async def get_latest_price_async(self, symbol: str) -> float:
384
  try:
@@ -391,9 +420,7 @@ class DataManager:
391
  self, symbol: str, timeframe: str = "5m", limit: int = 100
392
  ) -> List[List[float]]:
393
  try:
394
- candles = await self.exchange.fetch_ohlcv(
395
- symbol, timeframe, limit=limit
396
- )
397
  return candles or []
398
  except Exception:
399
  return []
 
1
  # ml_engine/data_manager.py
2
+ # (V15.2 - GEM-Architect: Tuned Logic Tree - Marksman Mode, R2-Compatible)
3
 
4
  import asyncio
5
  import httpx
 
17
 
18
 
19
  class DataManager:
20
+ def __init__(self, contracts_db, whale_monitor, r2_service=None):
21
+ """
22
+ DataManager
23
+ ----------
24
+ contracts_db : dict
25
+ قاعدة بيانات العقود (تُحمّل من R2 عند توفره).
26
+ whale_monitor : أي كائن حيتان خارجي (EnhancedWhaleMonitor) أو None
27
+ يتم تمريره من app.py وربطه لاحقاً.
28
+ r2_service : كائن خدمة R2 أو None
29
+ يُستخدم لتحميل/حفظ قاعدة العقود.
30
+ """
31
+ # إعدادات التحكم
32
+ self.contracts_db: Dict[str, Any] = contracts_db or {}
33
+ self.whale_monitor = whale_monitor
34
+ self.r2_service = r2_service
35
+
36
+ # إعداد المنصة (KuCoin Spot)
37
+ self.exchange = ccxt.kucoin(
38
+ {
39
+ "enableRateLimit": True,
40
+ "timeout": 60000,
41
+ "options": {"defaultType": "spot"},
42
+ }
43
+ )
44
+
45
+ # HTTP client + كاش الأسواق
46
+ self.http_client = None
47
  self.market_cache: Dict[str, Any] = {}
48
 
49
  # قوائم الاستبعاد
 
64
  "3L",
65
  ]
66
 
67
+ # ==================================================================
68
+ # 🚀 Lifecycle
69
+ # ==================================================================
70
  async def initialize(self):
71
  """تهيئة مدير البيانات والاتصالات"""
72
  print(" > [DataManager] Starting initialization...")
73
  self.http_client = httpx.AsyncClient(timeout=30.0)
74
  await self._load_markets()
75
+ # تحميل العقود من R2 إن وجد
76
+ await self.load_contracts_from_r2()
77
+ print("✅ [DataManager] Ready (Logic Tree: Tuned/Flexible).")
78
 
79
  async def _load_markets(self):
80
  try:
 
86
  traceback.print_exc()
87
 
88
  async def close(self):
89
+ try:
90
+ if self.http_client:
91
+ await self.http_client.aclose()
92
+ finally:
93
+ self.http_client = None
94
+
95
+ try:
96
+ if self.exchange:
97
+ await self.exchange.close()
98
+ except Exception:
99
+ pass
100
 
101
  def get_contracts_db(self) -> Dict[str, Any]:
102
  return self.contracts_db
103
 
104
  # ==================================================================
105
+ # 🚀 R2 Compatibility
106
+ # ==================================================================
107
+ async def load_contracts_from_r2(self):
108
+ """تحميل contracts_db من R2 إن كانت الخدمة متاحة"""
109
+ if not self.r2_service:
110
+ return
111
+ try:
112
+ self.contracts_db = await self.r2_service.load_contracts_db_async()
113
+ except Exception as e:
114
+ print(f"❌ [DataManager] load_contracts_from_r2 failed: {e}")
115
+
116
+ # ==================================================================
117
+ # 🛡️ Layer 1: Tuned Decision Tree Screening
118
  # ==================================================================
119
  async def layer1_rapid_screening(self) -> List[Dict[str, Any]]:
120
+ print("🔍 [Layer 1] Initiating Tuned Logic Tree Screening...")
121
 
122
  # 1. المرحلة 0: فلتر الكون (مخفف)
123
  initial_candidates = await self._stage0_universe_filter()
 
124
  if not initial_candidates:
125
  return []
126
 
 
129
  enriched_data = await self._fetch_technical_data_batch(top_liquid_candidates)
130
 
131
  # 3. تطبيق شجرة القرار (Overbought -> Classify)
132
+ breakout_list: List[Dict[str, Any]] = []
133
+ reversal_list: List[Dict[str, Any]] = []
134
+ neutral_list: List[Dict[str, Any]] = []
135
 
136
  for item in enriched_data:
137
  classification = self._apply_logic_tree(item)
 
142
  continue
143
 
144
  if ctype == "BREAKOUT":
145
+ item["l1_sort_score"] = float(classification.get("score", 0.0))
146
  breakout_list.append(item)
147
  elif ctype == "REVERSAL":
148
+ item["l1_sort_score"] = float(classification.get("score", 0.0))
149
  reversal_list.append(item)
150
  else:
151
  # عملات "عادية" تمر كفلتر أولي فقط – نرتبها بالسيولة
152
+ item["l1_sort_score"] = float(item.get("quote_volume", 0.0) or 0.0)
153
  neutral_list.append(item)
154
 
155
  print(
 
173
 
174
  final_selection = top_breakouts + top_reversals + top_neutrals
175
 
176
+ cleaned_selection: List[Dict[str, Any]] = []
177
  for item in final_selection:
178
  cleaned_selection.append(
179
  {
180
  "symbol": item["symbol"],
181
+ "quote_volume": item.get("quote_volume", 0.0),
182
+ "current_price": item.get("current_price", 0.0),
183
+ "type": item.get("type", "UNKNOWN"),
184
+ "l1_score": item.get("l1_sort_score", 0.0),
 
 
185
  }
186
  )
187
 
 
191
  return cleaned_selection
192
 
193
  # ------------------------------------------------------------------
194
+ # Stage 0: Universe Filter (RELAXED) + إزالة شرط الحد الأدنى للسعر
195
  # ------------------------------------------------------------------
196
  async def _stage0_universe_filter(self) -> List[Dict[str, Any]]:
197
  try:
 
199
  candidates: List[Dict[str, Any]] = []
200
 
201
  for symbol, ticker in tickers.items():
202
+ # نعمل فقط على أزواج USDT
203
  if not symbol.endswith("/USDT"):
204
  continue
205
 
 
213
  continue
214
 
215
  last_price = ticker.get("last")
216
+ # تم إلغاء شرط 0.0005 بالكامل – نسمح بأي سعر > 0 ما دام موجوداً
217
  if last_price is None:
218
  continue
219
 
 
227
  )
228
 
229
  candidates.sort(key=lambda x: x["quote_volume"], reverse=True)
230
+ print(f" -> [Stage0] Universe Filter found {len(candidates)} USDT pairs.")
231
  return candidates
232
 
233
  except Exception as e:
 
240
  async def _fetch_technical_data_batch(
241
  self, candidates: List[Dict[str, Any]]
242
  ) -> List[Dict[str, Any]]:
 
243
  results: List[Dict[str, Any]] = []
244
+
245
+ async def process_symbol(item: Dict[str, Any]):
246
+ symbol = item["symbol"]
247
+ try:
248
+ ohlcv_1h = await self.exchange.fetch_ohlcv(symbol, "1h", limit=60)
249
+ ohlcv_15m = await self.exchange.fetch_ohlcv(symbol, "15m", limit=60)
250
+
251
+ if not ohlcv_1h or len(ohlcv_1h) < 55:
252
+ return None
253
+ if not ohlcv_15m or len(ohlcv_15m) < 55:
254
+ return None
255
+
256
+ item["ohlcv_1h"] = ohlcv_1h
257
+ item["ohlcv_15m"] = ohlcv_15m
258
+ return item
259
+ except Exception as e:
260
+ print(f" -> [Stage1.5] Error fetching OHLCV for {symbol}: {e}")
261
+ return None
262
+
263
+ batch_size = 25
264
+ for i in range(0, len(candidates), batch_size):
265
+ batch = candidates[i : i + batch_size]
266
+ tasks = [process_symbol(c) for c in batch]
267
+ batch_results = await asyncio.gather(*tasks, return_exceptions=False)
268
+ for r in batch_results:
269
  if r is not None:
270
  results.append(r)
271
  await asyncio.sleep(0.1)
272
+
273
  print(f" -> [Stage1.5] Enriched OHLCV for {len(results)} symbols.")
274
  return results
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  # ------------------------------------------------------------------
277
  # Stage 2 + 3: Overbought Filter + Classification
278
  # ------------------------------------------------------------------
 
280
  try:
281
  df_1h = self._calc_indicators(data["ohlcv_1h"])
282
  df_15m = self._calc_indicators(data["ohlcv_15m"])
283
+ except Exception as e:
284
+ print(f"❌ [LogicTree] Indicator calc failed: {e}")
285
  return {"type": "FILTERED"}
286
 
287
  curr_1h = df_1h.iloc[-1]
 
290
  # --- Stage 2: Overbought / Extreme Pump Filter (RELAXED HARD FILTER) ---
291
  try:
292
  close_4h_ago = df_1h.iloc[-5]["close"]
293
+ change_4h = ((curr_1h["close"] - close_4h_ago) / close_4h_ago) * 100.0
294
+ except Exception:
 
 
295
  change_4h = 0.0
296
 
297
+ # فلتر "الجنون" فقط
298
  if change_4h > 25.0:
299
  return {"type": "FILTERED"}
300
+ if data.get("change_24h", 0.0) > 40.0:
301
  return {"type": "FILTERED"}
302
+ if curr_1h["rsi"] > 88.0:
303
  return {"type": "FILTERED"}
304
 
305
  deviation = (
306
  (curr_1h["close"] - curr_1h["ema20"]) / curr_1h["atr"]
307
+ if curr_1h["atr"] and curr_1h["atr"] > 0
308
+ else 0.0
309
  )
310
  if deviation > 3.5:
311
  return {"type": "FILTERED"}
 
316
  is_breakout = False
317
  breakout_score = 0.0
318
 
319
+ # Trend check (EMA Cross OR Price above EMA20)
320
  bullish_structure = (curr_1h["ema20"] > curr_1h["ema50"]) or (
321
  curr_1h["close"] > curr_1h["ema20"]
322
  )
323
 
324
  if bullish_structure:
325
+ # RSI صحي للزخم
326
+ if 40.0 <= curr_1h["rsi"] <= 70.0:
327
  # 15m bullish
328
  if curr_15m["close"] >= curr_15m["ema20"]:
329
  # Volatility check (Range)
 
335
  )
336
  # Less strict squeeze check
337
  if (curr_15m["high"] - curr_15m["low"]) <= avg_range * 1.5:
338
+ vol_ma20 = df_15m["volume"].rolling(20).mean().iloc[-1]
339
+ if vol_ma20 and vol_ma20 > 0:
340
+ if curr_15m["volume"] >= 1.2 * vol_ma20:
341
+ is_breakout = True
342
+ breakout_score = curr_15m["volume"] / vol_ma20
343
+ else:
344
+ # حجم تداول بدون MA موثوق
345
  is_breakout = True
346
+ breakout_score = 1.0
 
 
 
 
347
 
348
  if is_breakout:
349
  data["type"] = "BREAKOUT"
 
353
  is_reversal = False
354
  reversal_score = 100.0
355
 
356
+ if 10.0 <= curr_1h["rsi"] <= 40.0:
 
 
357
  if change_4h <= -3.0:
 
358
  last_3 = df_15m.iloc[-3:]
359
  found_rejection = False
360
  for _, row in last_3.iterrows():
 
361
  rng = row["high"] - row["low"]
362
  if rng > 0:
363
  is_green = row["close"] > row["open"]
364
+ upper_half = row["close"] > (row["low"] + rng * 0.5)
 
 
365
  if is_green or upper_half:
366
  found_rejection = True
367
  break
368
 
369
  if found_rejection:
370
  is_reversal = True
371
+ reversal_score = float(curr_1h["rsi"])
372
 
373
  if is_reversal:
374
  data["type"] = "REVERSAL"
 
376
 
377
  return {"type": "NONE"}
378
 
379
+ # ------------------------------------------------------------------
380
+ # Indicator Helper
381
+ # ------------------------------------------------------------------
382
+ def _calc_indicators(self, ohlcv_list: List[List[float]]) -> pd.DataFrame:
383
  df = pd.DataFrame(
384
  ohlcv_list,
385
  columns=["timestamp", "open", "high", "low", "close", "volume"],
 
387
 
388
  # RSI
389
  delta = df["close"].diff()
390
+ gain = delta.where(delta > 0, 0.0).rolling(window=14).mean()
391
+ loss = (-delta.where(delta < 0, 0.0)).rolling(window=14).mean()
392
  rs = gain / loss
393
+ df["rsi"] = 100.0 - (100.0 / (1.0 + rs))
394
 
395
  # EMAs
396
  df["ema20"] = df["close"].ewm(span=20, adjust=False).mean()
 
398
 
399
  # ATR
400
  high_low = df["high"] - df["low"]
401
+ high_close = (df["high"] - df["close"].shift()).abs()
402
+ low_close = (df["low"] - df["close"].shift()).abs()
403
  ranges = pd.concat([high_low, high_close, low_close], axis=1)
404
  true_range = ranges.max(axis=1)
405
  df["atr"] = true_range.rolling(14).mean()
 
407
  return df
408
 
409
  # ==================================================================
410
+ # 🎯 Public Helpers (used by TradeManager / Processor / app.py)
411
  # ==================================================================
412
  async def get_latest_price_async(self, symbol: str) -> float:
413
  try:
 
420
  self, symbol: str, timeframe: str = "5m", limit: int = 100
421
  ) -> List[List[float]]:
422
  try:
423
+ candles = await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
 
 
424
  return candles or []
425
  except Exception:
426
  return []