← Back
"""
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}")

📜 Git History

c6f6bd5chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...