← Назад
""" WT Bot v3 — Market Screener ============================== Сканирует ВСЕ USDT фьючерсы на Binance, фильтрует по: 1. Volume 24h > $50M 2. Trades 24h > 1M 3. ATR(14) в зоне 5-12% 4. Не в чёрном списке Из прошедших: считает WT + EMA200, кто в зоне → в вайтлист. """ import time import json import logging from datetime import datetime, timezone import numpy as np import pandas as pd from src.config import ( BLACKLIST, MIN_VOLUME_24H, MIN_TRADES_24H, ATR_MIN_PCT, ATR_MAX_PCT, ATR_PERIOD, NATR_5M_MIN, TIMEFRAME, WHITELIST_FILE, COOLDOWNS_FILE, COOLDOWN_MIN, ) from src.indicators import calc_wavetrend, calc_ema, calc_atr_pct, is_in_wt_zone logger = logging.getLogger("screener") class Screener: def __init__(self, exchange, notifier=None): self.exchange = exchange self.notifier = notifier # async notify function self.whitelist = [] self.get_open_positions = None # callback: returns dict of open positions self._load_whitelist() # ============================================================ # PHASE 1: Quick filter (1 API call for ALL pairs) # ============================================================ def get_candidates(self): """ Фильтр первого уровня — один запрос ticker/24hr. Возвращает список символов прошедших vol + trades + blacklist. """ tickers = self.exchange.get_all_tickers_24h() candidates = [] for t in tickers: symbol = t["symbol"] # Только USDT пары if not symbol.endswith("USDT"): continue # Чёрный список if symbol in BLACKLIST: continue volume_24h = float(t.get("quoteVolume", 0)) trades_24h = int(t.get("count", 0)) # Фильтры if volume_24h < MIN_VOLUME_24H: continue if trades_24h < MIN_TRADES_24H: continue candidates.append({ "symbol": symbol, "volume_24h": volume_24h, "trades_24h": trades_24h, "price": float(t.get("lastPrice", 0)), "change_pct": float(t.get("priceChangePercent", 0)), }) logger.info(f"Phase 1: {len(candidates)} candidates (vol>${MIN_VOLUME_24H/1e6:.0f}M, trades>{MIN_TRADES_24H/1e3:.0f}K)") return candidates # ============================================================ # PHASE 2: ATR filter (klines for each candidate) # ============================================================ def filter_by_atr(self, candidates): """ Фильтр второго уровня — ATR(14)/1h в зоне + NATR(14)/5m >= 1.2%. NATR/5m отсекает "спящие" монеты с низкой внутрисвечной волатильностью. """ passed = [] for c in candidates: symbol = c["symbol"] try: # ATR on 1h klines = self.exchange.get_klines(symbol, "1h", limit=ATR_PERIOD + 5) if len(klines) < ATR_PERIOD + 1: continue atr_pct = calc_atr_pct(klines, ATR_PERIOD) if atr_pct is None: continue if not (ATR_MIN_PCT <= atr_pct <= ATR_MAX_PCT): continue # NATR on 5m — skip sleepy coins klines_5m = self.exchange.get_klines(symbol, "5m", limit=ATR_PERIOD + 5) if len(klines_5m) >= ATR_PERIOD + 1: natr_5m = calc_atr_pct(klines_5m, ATR_PERIOD) if natr_5m is not None and natr_5m < NATR_5M_MIN: logger.debug(f"NATR/5m skip {symbol}: {natr_5m:.2f}% < {NATR_5M_MIN}%") continue c["natr_5m"] = round(natr_5m, 2) if natr_5m else None c["atr_pct"] = round(atr_pct, 2) passed.append(c) time.sleep(0.05) # Rate limit except Exception as e: logger.debug(f"ATR error {symbol}: {e}") continue logger.info(f"Phase 2: {len(passed)} passed ATR filter ({ATR_MIN_PCT}-{ATR_MAX_PCT}%) + NATR/5m>={NATR_5M_MIN}%") return passed # ============================================================ # PHASE 3: WT zone check → whitelist # ============================================================ def check_wt_zones(self, candidates): """ Фильтр третьего уровня — WT в зоне перекупленности/перепроданности + EMA200 тренд фильтр. Добавляет в вайтлист. """ new_whitelist_entries = [] cooldowns = self._load_cooldowns() for c in candidates: symbol = c["symbol"] # Проверка cooldown if symbol in cooldowns: cooldown_until = cooldowns[symbol] if time.time() < cooldown_until: continue try: # Получаем 250 свечей 5m для WT + EMA200 klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=250) if len(klines) < 210: continue # Считаем индикаторы wt1, wt2 = calc_wavetrend(klines) ema200 = calc_ema(klines, 200) if wt1 is None or ema200 is None: continue last_close = float(klines[-2][4]) # Закрытая свеча, не текущая! zone = is_in_wt_zone(wt1, wt2) if zone == 0: continue # Не в зоне # EMA200 фильтр if zone == 1 and last_close <= ema200: continue # Oversold но цена ниже EMA — нет лонга if zone == -1 and last_close >= ema200: continue # Overbought но цена выше EMA — нет шорта entry = { "symbol": symbol, "zone": zone, # 1 = oversold (long), -1 = overbought (short) "wt1": round(wt1, 2), "wt2": round(wt2, 2), "ema200": round(ema200, 4), "price": last_close, "atr_pct": c.get("atr_pct", 0), "volume_24h": c["volume_24h"], "added_at": datetime.now(timezone.utc).isoformat(), } new_whitelist_entries.append(entry) logger.info( f"→ WHITELIST: {symbol} zone={'OVERSOLD' if zone == 1 else 'OVERBOUGHT'} " f"WT1={wt1:.1f} price={last_close} EMA200={ema200:.4f}" ) time.sleep(0.05) except Exception as e: logger.debug(f"WT check error {symbol}: {e}") continue # Обновляем вайтлист — добавляем ТОЛЬКО новые, убираем дубли и открытые позиции existing_symbols = {e["symbol"] for e in self.whitelist} open_positions = set() if self.get_open_positions: try: open_positions = set(self.get_open_positions().keys()) except Exception: pass actually_added = [] for entry in new_whitelist_entries: if entry["symbol"] in open_positions: logger.debug(f"Skip WL {entry['symbol']}: already have open position") continue if entry["symbol"] not in existing_symbols: self.whitelist.append(entry) actually_added.append(entry) self._save_whitelist() logger.info(f"Phase 3: {len(actually_added)} new, {len(self.whitelist)} total in whitelist") # Notify ONLY for actually new entries (not already in whitelist) for entry in actually_added: zone_str = "🟢 OVERSOLD → Long" if entry["zone"] == 1 else "🔴 OVERBOUGHT → Short" msg = ( f"📋 **WHITELIST: {entry['symbol']}**\n" f"{zone_str}\n" f"WT1={entry['wt1']:.1f} | WT2={entry['wt2']:.1f}\n" f"ATR={entry.get('atr_pct', '?')}% | Price={entry['price']}\n" f"⏳ Ждём WT cross..." ) self._notify(msg) return actually_added # ============================================================ # FULL SCAN # ============================================================ def _cleanup_stale_whitelist(self): """Убрать записи: 1) с открытой позицией, 2) старше 4 часов, 3) вышедшие из WT зоны.""" if not self.whitelist: return # Get open positions to exclude open_positions = set() if self.get_open_positions: try: open_positions = set(self.get_open_positions().keys()) except Exception: pass now = datetime.now(timezone.utc) before = len(self.whitelist) fresh = [] for entry in self.whitelist: # Skip symbols with open positions if entry["symbol"] in open_positions: logger.info(f"Whitelist removed: {entry['symbol']} has open position") continue symbol = entry["symbol"] # TTL check added = entry.get("added_at", "") if added: try: added_dt = datetime.fromisoformat(added) age_min = (now - added_dt).total_seconds() / 60 if age_min > 240: # 4 часа logger.info(f"Whitelist expired: {symbol} (age={age_min:.0f}min)") continue except Exception: pass # Zone check — если WT вышел из зоны, монета неактуальна # НО: grace period 30 мин — кросс часто происходит при выходе из зоны try: age_min_zone = 0 if added: try: age_min_zone = (now - datetime.fromisoformat(added)).total_seconds() / 60 except Exception: pass if age_min_zone >= 30: # grace period: не удалять первые 30 мин klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=50) if len(klines) >= 35: wt1, wt2 = calc_wavetrend(klines) if wt1 is not None: zone_now = is_in_wt_zone(wt1, wt2) if zone_now == 0: logger.info(f"Whitelist removed: {symbol} left WT zone (WT1={wt1:.1f})") continue time.sleep(0.05) except Exception: pass fresh.append(entry) self.whitelist = fresh if len(fresh) < before: self._save_whitelist() def run_scan(self): """Полный цикл сканирования.""" logger.info("=" * 40) logger.info("SCAN START") start = time.time() self._cleanup_stale_whitelist() candidates = self.get_candidates() if not candidates: logger.warning("No candidates found") return [] atr_passed = self.filter_by_atr(candidates) if not atr_passed: logger.info("No candidates passed ATR filter") return [] new_entries = self.check_wt_zones(atr_passed) elapsed = round(time.time() - start, 1) logger.info(f"SCAN DONE in {elapsed}s | candidates={len(candidates)} → ATR={len(atr_passed)} → new WL={len(new_entries)}") return new_entries # ============================================================ # WHITELIST MANAGEMENT # ============================================================ def remove_from_whitelist(self, symbol): """Убрать символ из вайтлиста (после входа или выхода из зоны).""" self.whitelist = [e for e in self.whitelist if e["symbol"] != symbol] self._save_whitelist() def get_whitelist(self): """Текущий вайтлист.""" return self.whitelist.copy() def add_cooldown(self, symbol): """Добавить cooldown на символ (после стопа).""" cooldowns = self._load_cooldowns() cooldowns[symbol] = time.time() + (COOLDOWN_MIN * 60) self._save_cooldowns(cooldowns) logger.info(f"Cooldown {COOLDOWN_MIN}min set for {symbol}") # ============================================================ # PERSISTENCE # ============================================================ def _load_whitelist(self): try: with open(WHITELIST_FILE, "r") as f: data = json.load(f) self.whitelist = data.get("whitelist", []) if isinstance(data, dict) else data except Exception: self.whitelist = [] def _save_whitelist(self): try: with open(WHITELIST_FILE, "w") as f: json.dump({ "whitelist": self.whitelist, "updated_at": datetime.now(timezone.utc).isoformat(), }, f, indent=2) except Exception as e: logger.error(f"Error saving whitelist: {e}") def _load_cooldowns(self): try: with open(COOLDOWNS_FILE, "r") as f: cooldowns = json.load(f) # Cleanup expired now = time.time() return {k: v for k, v in cooldowns.items() if v > now} except Exception: return {} def _save_cooldowns(self, cooldowns): try: with open(COOLDOWNS_FILE, "w") as f: json.dump(cooldowns, f, indent=2) except Exception as e: logger.error(f"Error saving cooldowns: {e}") def _notify(self, msg): if self.notifier: try: self.notifier(msg) except Exception as e: logger.debug(f"Notify error: {e}")