Riy777 commited on
Commit
f60c8b6
·
verified ·
1 Parent(s): 642bf96

Update ml_engine/data_manager.py

Browse files
Files changed (1) hide show
  1. ml_engine/data_manager.py +200 -60
ml_engine/data_manager.py CHANGED
@@ -1,6 +1,6 @@
1
  # ============================================================
2
  # 📂 ml_engine/data_manager.py
3
- # (V41.0 - GEM-Architect: Debugger & Logic Fix)
4
  # ============================================================
5
 
6
  import asyncio
@@ -14,113 +14,192 @@ from typing import List, Dict, Any
14
 
15
  import ccxt.async_support as ccxt
16
 
 
17
  try:
18
  from ml_engine.processor import SystemLimits
19
  except ImportError:
 
20
  class SystemLimits:
21
  L1_MIN_AFFINITY_SCORE = 15.0
22
  CURRENT_REGIME = "RANGE"
23
  SCANNER_WEIGHTS = {"RSI_MOMENTUM": 0.3, "BB_BREAKOUT": 0.3, "MACD_CROSS": 0.2, "VOLUME_FLOW": 0.2}
24
 
 
25
  logging.getLogger("httpx").setLevel(logging.WARNING)
 
26
  logging.getLogger("ccxt").setLevel(logging.WARNING)
27
 
28
  class DataManager:
 
 
 
 
 
 
 
29
  def __init__(self, contracts_db, whale_monitor, r2_service=None):
 
 
 
30
  self.contracts_db = contracts_db or {}
31
  self.whale_monitor = whale_monitor
32
  self.r2_service = r2_service
 
 
33
  self.exchange = ccxt.kucoin({
34
  'enableRateLimit': True,
35
  'timeout': 30000,
36
  'options': {'defaultType': 'spot'}
37
  })
 
38
  self.http_client = None
39
- self.BLACKLIST_TOKENS = ['USDT', 'USDC', 'DAI', 'TUSD', 'BUSD', 'UP', 'DOWN', 'BEAR', 'BULL', '3S', '3L']
40
- print(f"📦 [DataManager V41.0] Scanner Matrix (Debug Mode) Online.")
 
 
 
 
 
 
 
41
 
42
  async def initialize(self):
43
- self.http_client = httpx.AsyncClient(timeout=60.0)
44
- await self._load_markets()
 
 
 
 
 
 
 
45
 
46
  async def _load_markets(self):
47
- if self.exchange and not self.exchange.markets:
48
- await self.exchange.load_markets()
 
 
 
 
 
 
 
 
49
 
50
  async def close(self):
51
- if self.http_client: await self.http_client.aclose()
52
- if self.exchange: await self.exchange.close()
 
 
 
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  async def layer1_rapid_screening(self) -> List[Dict[str, Any]]:
 
 
 
 
55
  current_regime = getattr(SystemLimits, "CURRENT_REGIME", "RANGE")
56
  scanner_weights = getattr(SystemLimits, "SCANNER_WEIGHTS", {"RSI_MOMENTUM": 1.0})
57
  min_score = getattr(SystemLimits, "L1_MIN_AFFINITY_SCORE", 15.0)
58
-
59
  print(f"🔍 [L1 Matrix] Regime: {current_regime} | Weights: {scanner_weights}")
60
-
61
- tickers = await self._fetch_universe_tickers()
62
- if not tickers:
63
- print("⚠️ [L1] Universe fetch returned empty.")
64
- return []
65
 
66
- top_candidates = tickers[:80]
 
 
 
 
 
 
 
 
67
  enriched_data = await self._batch_fetch_ta_data(top_candidates, timeframe='15m', limit=100)
68
 
69
  scored_candidates = []
70
- debug_log_sample = [] # لعرض عينة من الدرجات
71
 
 
72
  for item in enriched_data:
73
  df = item.get('df')
74
  if df is None or len(df) < 50: continue
75
 
76
- # تطبيق الكاشفات
77
  scores = self._apply_scanner_strategies(df)
78
 
79
  final_score = 0.0
80
  tags = []
81
 
 
82
  for strategy, val in scores.items():
83
  w = scanner_weights.get(strategy, 0.0)
84
  final_score += (val['score'] * w)
85
  if val['active']: tags.append(strategy)
86
 
87
- # توافقية النظام القديم (Boost)
88
  if item['change_24h'] > 3.0 and current_regime == "BULL": final_score += 10
89
 
90
  item['l1_score'] = final_score
91
  item['tags'] = tags
92
 
93
- # تسجيل عينة للمراقبة (أول 5 عملات فقط لتجنب إغراق السجل)
94
- if len(debug_log_sample) < 5:
95
- debug_details = f"{item['symbol']}: {final_score:.1f} (RSI:{scores['RSI_MOMENTUM']['val']:.1f}|Score:{scores['RSI_MOMENTUM']['score']})"
 
96
  debug_log_sample.append(debug_details)
97
 
98
  if final_score >= min_score:
99
  scored_candidates.append(item)
100
 
 
101
  scored_candidates.sort(key=lambda x: x['l1_score'], reverse=True)
102
 
103
- # طباعة تقرير التصحيح
104
- print(f" -> [DEBUG L1] Sample Scores: { ' | '.join(debug_log_sample) }")
 
 
105
  print(f" -> Matrix selected {len(scored_candidates)} candidates (Threshold: {min_score}).")
106
 
 
107
  return [
108
  {
109
  'symbol': c['symbol'],
110
- 'quote_volume': c['quote_volume'],
111
- 'current_price': c['current_price'],
112
- 'type': ','.join(c['tags']),
113
- 'l1_score': c['l1_score']
114
  }
115
  for c in scored_candidates[:40]
116
  ]
117
 
 
 
 
118
  def _apply_scanner_strategies(self, df: pd.DataFrame) -> Dict[str, Any]:
 
119
  results = {}
120
  try:
121
  close = df['close']
122
 
123
- # 1. RSI (تم إصلاح المنطقة العمياء)
124
  rsi = ta.rsi(close, length=14)
125
  curr_rsi = rsi.iloc[-1] if rsi is not None else 50
126
 
@@ -128,17 +207,17 @@ class DataManager:
128
  active_rsi = False
129
 
130
  if 50 < curr_rsi < 75:
131
- score_rsi = 100
132
  active_rsi = True
133
- elif curr_rsi <= 30: # Oversold
134
- score_rsi = 80
135
  active_rsi = True
136
- elif 30 < curr_rsi <= 50: # ✅ المنطقة المحايدة (تمت إضافتها)
137
- score_rsi = 40 # نعطيها بعض النقاط بدلاً من الصفر
138
 
139
  results["RSI_MOMENTUM"] = {'score': score_rsi, 'active': active_rsi, 'val': curr_rsi}
140
 
141
- # 2. BB Breakout
142
  bb = ta.bbands(close, length=20, std=2)
143
  if bb is not None:
144
  upper = bb[f'BBU_20_2.0'].iloc[-1]
@@ -153,7 +232,7 @@ class DataManager:
153
  score_bb = 0; active_bb = False
154
  results["BB_BREAKOUT"] = {'score': score_bb, 'active': active_bb}
155
 
156
- # 3. MACD
157
  macd = ta.macd(close)
158
  if macd is not None:
159
  hist = macd[f'MACDh_12_26_9'].iloc[-1]
@@ -172,40 +251,87 @@ class DataManager:
172
  curr_vol = vol.iloc[-1]
173
  score_vol = 0
174
  active_vol = False
175
- if curr_vol > (vol_ma * 1.2): # خففنا الشرط من 1.5 إلى 1.2
176
  score_vol = 100
177
  active_vol = True
178
  results["VOLUME_FLOW"] = {'score': score_vol, 'active': active_vol}
179
 
180
- except Exception as e:
181
- # في حال حدوث خطأ في الحساب، نعيد قيم صفرية لعدم إيقاف النظام
182
- # print(f"Indicator Error: {e}")
183
  return {k: {'score': 0, 'active': False, 'val': 0} for k in ["RSI_MOMENTUM", "BB_BREAKOUT", "MACD_CROSS", "VOLUME_FLOW"]}
184
 
185
  return results
186
 
187
- # --- Helpers ---
188
- async def _fetch_universe_tickers(self):
 
 
 
 
 
 
189
  try:
190
  tickers = await self.exchange.fetch_tickers()
 
 
191
  candidates = []
 
 
 
 
 
 
 
192
  for symbol, ticker in tickers.items():
193
- if not symbol.endswith('/USDT'): continue
194
- if any(bad in symbol for bad in self.BLACKLIST_TOKENS): continue
195
- if not ticker.get('quoteVolume') or ticker['quoteVolume'] < 300_000: continue # تخفيف شرط السيولة قليلاً
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  candidates.append({
197
  'symbol': symbol,
198
- 'quote_volume': ticker['quoteVolume'],
199
- 'current_price': ticker['last'],
200
- 'change_24h': float(ticker.get('percentage', 0.0))
201
  })
 
 
 
 
202
  candidates.sort(key=lambda x: x['quote_volume'], reverse=True)
203
  return candidates
204
- except Exception: return []
205
 
 
 
 
 
 
 
 
 
206
  async def _batch_fetch_ta_data(self, candidates, timeframe='15m', limit=100):
207
  results = []
208
- chunk_size = 15
209
  for i in range(0, len(candidates), chunk_size):
210
  chunk = candidates[i:i+chunk_size]
211
  tasks = [self._fetch_ohlcv_safe(c, timeframe, limit) for c in chunk]
@@ -224,13 +350,27 @@ class DataManager:
224
  candidate['df'] = df
225
  return candidate
226
  except: return None
227
-
228
- async def get_latest_price_async(self, symbol):
229
- t = await self.exchange.fetch_ticker(symbol)
230
- return float(t['last'])
231
- async def get_latest_ohlcv(self, symbol, tf, limit=100):
232
- return await self.exchange.fetch_ohlcv(symbol, tf, limit=limit)
233
- async def get_order_book_snapshot(self, symbol, limit=20):
234
- return await self.exchange.fetch_order_book(symbol, limit)
235
- async def load_contracts_from_r2(self): pass
236
- def get_contracts_db(self): return self.contracts_db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # ============================================================
2
  # 📂 ml_engine/data_manager.py
3
+ # (V41.1 - GEM-Architect: Matrix Scanner + Deep Debugger)
4
  # ============================================================
5
 
6
  import asyncio
 
14
 
15
  import ccxt.async_support as ccxt
16
 
17
+ # ✅ استيراد الدستور الديناميكي (لقراءة الحالة الحالية)
18
  try:
19
  from ml_engine.processor import SystemLimits
20
  except ImportError:
21
+ # Fallback إذا لم يتم التحميل بعد
22
  class SystemLimits:
23
  L1_MIN_AFFINITY_SCORE = 15.0
24
  CURRENT_REGIME = "RANGE"
25
  SCANNER_WEIGHTS = {"RSI_MOMENTUM": 0.3, "BB_BREAKOUT": 0.3, "MACD_CROSS": 0.2, "VOLUME_FLOW": 0.2}
26
 
27
+ # إعدادات التسجيل لإسكات الإزعاج
28
  logging.getLogger("httpx").setLevel(logging.WARNING)
29
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
30
  logging.getLogger("ccxt").setLevel(logging.WARNING)
31
 
32
  class DataManager:
33
+ """
34
+ DataManager V41.1 (The Scanner Matrix - Debug Edition)
35
+ - تتغير خوارزمية الفلترة (L1) تماماً بناءً على حالة السوق (Regime).
36
+ - تعتمد على SystemLimits.SCANNER_WEIGHTS لتحديد التكتيك.
37
+ - يتضمن سجلات تشخيصية (Debug Logs) لحل مشاكل البيانات.
38
+ """
39
+
40
  def __init__(self, contracts_db, whale_monitor, r2_service=None):
41
+ # ==================================================================
42
+ # ⚙️ إعدادات التحكم والتهيئة
43
+ # ==================================================================
44
  self.contracts_db = contracts_db or {}
45
  self.whale_monitor = whale_monitor
46
  self.r2_service = r2_service
47
+
48
+ # إعداد عميل المنصة (KuCoin) مع تفعيل حدود السرعة
49
  self.exchange = ccxt.kucoin({
50
  'enableRateLimit': True,
51
  'timeout': 30000,
52
  'options': {'defaultType': 'spot'}
53
  })
54
+
55
  self.http_client = None
56
+ self.market_cache = {}
57
+
58
+ # 🚫 قوائم الاستبعاد (العملات المستقرة، الرافعة، والعملات المحظورة)
59
+ self.BLACKLIST_TOKENS = [
60
+ 'USDT', 'USDC', 'DAI', 'TUSD', 'BUSD', 'FDUSD', 'EUR', 'PAX',
61
+ 'UP', 'DOWN', 'BEAR', 'BULL', '3S', '3L', 'USDD', 'USDP'
62
+ ]
63
+
64
+ print(f"📦 [DataManager V41.1] Scanner Matrix (Debug Mode) Online.")
65
 
66
  async def initialize(self):
67
+ """تهيئة مدير البيانات والاتصالات"""
68
+ print(" > [DataManager] Starting initialization...")
69
+ try:
70
+ self.http_client = httpx.AsyncClient(timeout=60.0)
71
+ await self._load_markets()
72
+ print(f"✅ [DataManager] Ready (Mode: {getattr(SystemLimits, 'CURRENT_REGIME', 'UNKNOWN')}).")
73
+ except Exception as e:
74
+ print(f"❌ [DataManager] Init Error: {e}")
75
+ traceback.print_exc()
76
 
77
  async def _load_markets(self):
78
+ """تحميل أزواج التداول وحفظها في الذاكرة"""
79
+ try:
80
+ if self.exchange:
81
+ # التأكد من عدم تحميل الأسواق مرتين إذا كانت محملة
82
+ if not self.exchange.markets:
83
+ await self.exchange.load_markets()
84
+ self.market_cache = self.exchange.markets
85
+ except Exception as e:
86
+ print(f"❌ [DataManager] Market load failed: {e}")
87
+ traceback.print_exc()
88
 
89
  async def close(self):
90
+ """إغلاق الاتصالات بأمان"""
91
+ if self.http_client:
92
+ await self.http_client.aclose()
93
+ if self.exchange:
94
+ await self.exchange.close()
95
+ print("🛑 [DataManager] Connections Closed.")
96
 
97
+ # ==================================================================
98
+ # 🚀 R2 Compatibility
99
+ # ==================================================================
100
+ async def load_contracts_from_r2(self):
101
+ if not self.r2_service: return
102
+ try:
103
+ self.contracts_db = await self.r2_service.load_contracts_db_async()
104
+ print(f" -> [DataManager] Contracts DB updated from R2: {len(self.contracts_db)} records.")
105
+ except Exception as e:
106
+ print(f"⚠️ [DataManager] R2 Load Warning: {e}")
107
+ self.contracts_db = {}
108
+
109
+ def get_contracts_db(self) -> Dict[str, Any]:
110
+ return self.contracts_db
111
+
112
+ # ==================================================================
113
+ # 🛡️ Layer 1: Matrix Screening (The Core)
114
+ # ==================================================================
115
  async def layer1_rapid_screening(self) -> List[Dict[str, Any]]:
116
+ """
117
+ تقوم باختيار استراتيجية المسح بناءً على مصفوفة الكاشفات (Scanner Matrix).
118
+ """
119
+ # 1. قراءة الإعدادات الحالية
120
  current_regime = getattr(SystemLimits, "CURRENT_REGIME", "RANGE")
121
  scanner_weights = getattr(SystemLimits, "SCANNER_WEIGHTS", {"RSI_MOMENTUM": 1.0})
122
  min_score = getattr(SystemLimits, "L1_MIN_AFFINITY_SCORE", 15.0)
123
+
124
  print(f"🔍 [L1 Matrix] Regime: {current_regime} | Weights: {scanner_weights}")
 
 
 
 
 
125
 
126
+ # 2. جلب الكون الأولي (Universe) - باستخدام الدالة التشخيصية الجديدة
127
+ all_tickers = await self._fetch_universe_tickers()
128
+ if not all_tickers:
129
+ print("⚠️ [Layer 1] Universe fetch returned empty.")
130
+ return []
131
+
132
+ # 3. الجلب العميق للبيانات (Batch Fetch 15m)
133
+ # نأخذ أفضل 80 عملة فقط لتجنب استهلاك الـ Rate Limit
134
+ top_candidates = all_tickers[:80]
135
  enriched_data = await self._batch_fetch_ta_data(top_candidates, timeframe='15m', limit=100)
136
 
137
  scored_candidates = []
138
+ debug_log_sample = [] # لعرض عينة من الدرجات للمراقبة
139
 
140
+ # 4. تطبيق الكاشفات وحساب النقاط
141
  for item in enriched_data:
142
  df = item.get('df')
143
  if df is None or len(df) < 50: continue
144
 
145
+ # تطبيق استراتيجيات الكشف (RSI, BB, MACD, etc.)
146
  scores = self._apply_scanner_strategies(df)
147
 
148
  final_score = 0.0
149
  tags = []
150
 
151
+ # حساب المجموع الموزون
152
  for strategy, val in scores.items():
153
  w = scanner_weights.get(strategy, 0.0)
154
  final_score += (val['score'] * w)
155
  if val['active']: tags.append(strategy)
156
 
157
+ # Boost: إضافة نقاط بسيطة إذا كان السعر يرتفع بقوة (للتوافق مع النظام القديم)
158
  if item['change_24h'] > 3.0 and current_regime == "BULL": final_score += 10
159
 
160
  item['l1_score'] = final_score
161
  item['tags'] = tags
162
 
163
+ # تسجيل عينة للمراقبة (للتشخيص)
164
+ if len(debug_log_sample) < 3:
165
+ rsi_val = scores.get('RSI_MOMENTUM', {}).get('val', 0)
166
+ debug_details = f"{item['symbol']}: {final_score:.1f} (RSI:{rsi_val:.1f})"
167
  debug_log_sample.append(debug_details)
168
 
169
  if final_score >= min_score:
170
  scored_candidates.append(item)
171
 
172
+ # ترتيب النتائج
173
  scored_candidates.sort(key=lambda x: x['l1_score'], reverse=True)
174
 
175
+ # طباعة تقرير
176
+ if debug_log_sample:
177
+ print(f" -> [DEBUG L1] Sample Scores: { ' | '.join(debug_log_sample) }")
178
+
179
  print(f" -> Matrix selected {len(scored_candidates)} candidates (Threshold: {min_score}).")
180
 
181
+ # نمرر أفضل 40 للمعالج
182
  return [
183
  {
184
  'symbol': c['symbol'],
185
+ 'quote_volume': c.get('quote_volume', 0),
186
+ 'current_price': c.get('current_price', 0),
187
+ 'type': ','.join(c.get('tags', [])),
188
+ 'l1_score': c.get('l1_score', 0)
189
  }
190
  for c in scored_candidates[:40]
191
  ]
192
 
193
+ # ==================================================================
194
+ # 🧩 Scanner Strategies Logic (The Engines)
195
+ # ==================================================================
196
  def _apply_scanner_strategies(self, df: pd.DataFrame) -> Dict[str, Any]:
197
+ """تطبيق مؤشرات فنية متعددة على البيانات"""
198
  results = {}
199
  try:
200
  close = df['close']
201
 
202
+ # 1. RSI (تم إصلاح المنطقة العمياء 30-50)
203
  rsi = ta.rsi(close, length=14)
204
  curr_rsi = rsi.iloc[-1] if rsi is not None else 50
205
 
 
207
  active_rsi = False
208
 
209
  if 50 < curr_rsi < 75:
210
+ score_rsi = 100 # زخم صاعد قوي
211
  active_rsi = True
212
+ elif curr_rsi <= 30:
213
+ score_rsi = 80 # تشبع بيعي (ارتداد محتمل)
214
  active_rsi = True
215
+ elif 30 < curr_rsi <= 50:
216
+ score_rsi = 40 # المنطقة المحايدة (لا تعاقب بشدة)
217
 
218
  results["RSI_MOMENTUM"] = {'score': score_rsi, 'active': active_rsi, 'val': curr_rsi}
219
 
220
+ # 2. Bollinger Band Breakout
221
  bb = ta.bbands(close, length=20, std=2)
222
  if bb is not None:
223
  upper = bb[f'BBU_20_2.0'].iloc[-1]
 
232
  score_bb = 0; active_bb = False
233
  results["BB_BREAKOUT"] = {'score': score_bb, 'active': active_bb}
234
 
235
+ # 3. MACD Cross
236
  macd = ta.macd(close)
237
  if macd is not None:
238
  hist = macd[f'MACDh_12_26_9'].iloc[-1]
 
251
  curr_vol = vol.iloc[-1]
252
  score_vol = 0
253
  active_vol = False
254
+ if curr_vol > (vol_ma * 1.2): # خففنا الشرط قليلاً
255
  score_vol = 100
256
  active_vol = True
257
  results["VOLUME_FLOW"] = {'score': score_vol, 'active': active_vol}
258
 
259
+ except Exception:
260
+ # في حال الخطأ نعيد أصفار لعدم إيقاف النظام
 
261
  return {k: {'score': 0, 'active': False, 'val': 0} for k in ["RSI_MOMENTUM", "BB_BREAKOUT", "MACD_CROSS", "VOLUME_FLOW"]}
262
 
263
  return results
264
 
265
+ # ==================================================================
266
+ # 🌍 Stage 0 & Pre-Filters (With Deep Debugging)
267
+ # ==================================================================
268
+ async def _fetch_universe_tickers(self) -> List[Dict[str, Any]]:
269
+ """
270
+ جلب وتصفية العملات الأولية مع تسجيلات تشخيصية دقيقة.
271
+ """
272
+ print(" -> 📡 [Debug] Contacting Exchange for Tickers...")
273
  try:
274
  tickers = await self.exchange.fetch_tickers()
275
+ print(f" -> 📡 [Debug] Raw Tickers Received: {len(tickers)}")
276
+
277
  candidates = []
278
+ skipped_reason = {"pair": 0, "blacklist": 0, "volume": 0}
279
+
280
+ # فحص عينة من البيانات لمعرفة هيكلها (لأول عملة)
281
+ if tickers:
282
+ sample_key = list(tickers.keys())[0]
283
+ # print(f" -> 📡 [Debug] Sample Data ({sample_key}): {tickers[sample_key]}")
284
+
285
  for symbol, ticker in tickers.items():
286
+ # 1. فلتر الزوج (USDT Only)
287
+ if not symbol.endswith('/USDT'):
288
+ skipped_reason["pair"] += 1
289
+ continue
290
+
291
+ # 2. القائمة السوداء
292
+ if any(bad in symbol for bad in self.BLACKLIST_TOKENS):
293
+ skipped_reason["blacklist"] += 1
294
+ continue
295
+
296
+ # 3. فلتر السيولة (Quote Volume)
297
+ # KuCoin قد تستخدم مسميات مختلفة أحياناً
298
+ vol = ticker.get('quoteVolume')
299
+ if vol is None:
300
+ # محاولة بديلة لـ KuCoin API V1/V2 differences
301
+ vol = ticker.get('info', {}).get('volValue')
302
+
303
+ if vol is None: vol = 0.0
304
+ else: vol = float(vol)
305
+
306
+ # تخفيف الشرط مؤقتاً للتشخيص (100k بدلاً من 300k)
307
+ if vol < 100_000:
308
+ skipped_reason["volume"] += 1
309
+ continue
310
+
311
  candidates.append({
312
  'symbol': symbol,
313
+ 'quote_volume': vol,
314
+ 'current_price': float(ticker['last']) if ticker.get('last') else 0.0,
315
+ 'change_24h': float(ticker.get('percentage', 0.0))
316
  })
317
+
318
+ print(f" -> 📊 [Debug] Filter Stats: BadPair={skipped_reason['pair']}, Blacklist={skipped_reason['blacklist']}, LowVol={skipped_reason['volume']}")
319
+ print(f" -> ✅ [Debug] Candidates Passed: {len(candidates)}")
320
+
321
  candidates.sort(key=lambda x: x['quote_volume'], reverse=True)
322
  return candidates
 
323
 
324
+ except Exception as e:
325
+ print(f"❌ [L1 Error] Fetch Tickers Failed: {e}")
326
+ traceback.print_exc()
327
+ return []
328
+
329
+ # ==================================================================
330
+ # ⚡ Batch Fetching Utilities
331
+ # ==================================================================
332
  async def _batch_fetch_ta_data(self, candidates, timeframe='15m', limit=100):
333
  results = []
334
+ chunk_size = 15 # لعدم تجاوز الحدود
335
  for i in range(0, len(candidates), chunk_size):
336
  chunk = candidates[i:i+chunk_size]
337
  tasks = [self._fetch_ohlcv_safe(c, timeframe, limit) for c in chunk]
 
350
  candidate['df'] = df
351
  return candidate
352
  except: return None
353
+
354
+ # ==================================================================
355
+ # 🎯 Public Helpers
356
+ # ==================================================================
357
+ async def get_latest_price_async(self, symbol: str) -> float:
358
+ try:
359
+ ticker = await self.exchange.fetch_ticker(symbol)
360
+ return float(ticker['last'])
361
+ except Exception: return 0.0
362
+
363
+ async def get_latest_ohlcv(self, symbol: str, timeframe: str = '5m', limit: int = 100) -> List[List[float]]:
364
+ try:
365
+ candles = await self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
366
+ return candles or []
367
+ except Exception: return []
368
+
369
+ async def get_order_book_snapshot(self, symbol: str, limit: int = 20) -> Dict[str, Any]:
370
+ try:
371
+ ob = await self.exchange.fetch_order_book(symbol, limit)
372
+ return ob
373
+ except Exception: return {}
374
+
375
+ def get_supported_timeframes(self):
376
+ return list(self.exchange.timeframes.keys()) if self.exchange else []