diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -14,29 +14,46 @@ import plotly.graph_objects as go # ───────────────────────────────────────────── # КОНСТАНТЫ СТРАТЕГИИ # ───────────────────────────────────────────── -SCREENER_INTERVAL_SEC = 90 -SLOT_REFILL_DEBOUNCE_SEC = 8 -MIN_NOTIONAL_FILTER = 1.0 -MIN_QUOTE_VOLUME = 25_000 -MAX_SPREAD_RATIO = 0.003 -CANDLE_LIMIT = 30 -MIN_VOLATILITY = 0.008 -MIN_DRAWDOWN = 0.01 -ORDER_TIMEOUT_MIN = 30 -FEE_RATE_STANDARD = 0.001 -FEE_RATE_BNB_DISCOUNT = 0.00075 -DEFAULT_SLOTS = 5 -MAX_SLOTS = 12 # Максимальное расширение слотов при авто-масштабировании -CANDLE_FETCH_BATCH_SIZE = 25 -MAX_CORRELATION_DEFAULT = 0.75 -CORRELATION_LOOKBACK = 30 -EXCHANGE_RETRY_ATTEMPTS = 3 -EXCHANGE_RETRY_DELAY_SEC = 1.5 - -# --- НОВЫЕ КОНСТАНТЫ ИЗ АУДИТА --- -GRADATION_MIN_VOLUME = 50_000_000 # Мин. суточный объем для "крупных монет" (50 млн $) -GRADATION_DEEP_DIP = 0.04 # Просадка 4%+ считается "на дне" для мажоров -GRADATION_MULTIPLIER = 2.0 # Во сколько раз увеличить сумму покупки для мажора +SCREENER_INTERVAL_SEC = 90 # Интервал полного сканирования рынка (90 секунд для более частых входов) +SLOT_REFILL_DEBOUNCE_SEC = 8 # Пауза перед заполнением освободившегося слота +MIN_NOTIONAL_FILTER = 1.0 # Фильтр минимального объема ордера (USDT) +MIN_QUOTE_VOLUME = 25_000 # Минимальный суточный объем торгов (USDT) (ослабили фильтр для расширения списка монет) +MAX_SPREAD_RATIO = 0.003 # Максимальный спред для входа (0.3%) +CANDLE_LIMIT = 30 # 30 свечей по 1м для расчета RSI-1m и поиска дивергенций +MIN_VOLATILITY = 0.008 # Минимальная волатильность за час (0.8%) +MIN_DRAWDOWN = 0.01 # Минимальная просадка от часового пика (1%) +ORDER_TIMEOUT_MIN = 30 # Время удержания позиции до авто-выхода (минут) +FEE_RATE_STANDARD = 0.001 # Стандартная комиссия (0.1%) +FEE_RATE_BNB_DISCOUNT = 0.00075 # Сниженная комиссия со скидкой BNB (0.075%) +DEFAULT_SLOTS = 5 # Количество параллельных слотов по умолчанию +MAX_SLOTS = 12 # Защита от избыточного дробления бюджета (увеличено для поддержки 5+ слотов) +CANDLE_FETCH_BATCH_SIZE = 25 # Увеличили батч скрининга до 25 для анализа большего количества монет +MAX_CORRELATION_DEFAULT = 0.75 # Порог макс. корреляции доходностей между отбираемыми монетами (диверсификация слотов) +CORRELATION_LOOKBACK = 30 # Кол-во 1м свечей для расчета корреляции доходностей +EXCHANGE_RETRY_ATTEMPTS = 3 # Кол-во повторных попыток при сетевых сбоях биржи +EXCHANGE_RETRY_DELAY_SEC = 1.5 # Базовая задержка между повторными попытками (экспоненциально растет) + +# ── Авто-масштабирование слотов по росту депозита ── +SLOT_SCALING_TABLE = [ + (20, 5), + (50, 6), + (100, 7), + (200, 8), + (400, 9), + (700, 10), + (1000, 11), + (1500, 12), +] +AUTOSCALE_CHECK_INTERVAL_SEC = 3600 # Проверять реальный баланс раз в час (не смысла чаще - депозит не растет каждую минуту) + +# ── Авто-оплата VPS с биржевого баланса (ОПАСНАЯ функция - см. предупреждения в UI) ── +VPS_PAYMENT_INTERVAL_SEC = 30 * 24 * 3600 # Раз в 30 дней + +# ── Стратегия "Градация" - крупные высоколиквидные монеты, лесенка докупок на просадке ── +GRADING_DEFAULT_SYMBOLS = ["BTC/USDT", "ETH/USDT", "BNB/USDT", "SOL/USDT"] +GRADING_DROP_LEVELS_PCT = [2.0, 4.0, 6.5, 9.0, 12.0] # % просадки от скользящего хая для каждого "грейда" лесенки +GRADING_WEIGHTS = [1.0, 1.0, 1.5, 2.0, 3.0] # относительный вес объема покупки на грейд (глубже просадка -> крупнее докупка, как в твоих grid-EA) +GRADING_RECOVERY_EXIT_PCT = 1.0 # закрываем накопленную позицию, когда цена восстановилась в пределах X% от скользящего хая # ───────────────────────────────────────────── @@ -44,17 +61,17 @@ GRADATION_MULTIPLIER = 2.0 # Во сколько раз увеличит # ───────────────────────────────────────────── @dataclass class Slot: - index: int - symbol: str | None = None - order_id: str | None = None + index: int # Индекс слота в портфеле (0, 1, 2...) + symbol: str | None = None # Пара (например, "DOGE/USDT") или None + order_id: str | None = None # ID активного лимитного ордера продажи order_placed_at: datetime | None = None bought_qty: float = 0.0 buy_price_avg: float = 0.0 sell_price_set: float = 0.0 usdt_spent: float = 0.0 - target_profit_set: float = 0.05 - status: str = "🔍 Поиск" - last_refill_attempt: float = 0.0 + target_profit_set: float = 0.05 # Целевая прибыль, рассчитанная для конкретной сделки + status: str = "🔍 Поиск" # Отображаемый статус + last_refill_attempt: float = 0.0 # Метка времени для анти-спама def is_empty(self) -> bool: return self.symbol is None @@ -77,47 +94,52 @@ class Slot: class BotState: def __init__(self): self._lock = threading.Lock() - self.slot_lock = threading.RLock() + self.slot_lock = threading.RLock() # Защищает мутации Slot от гонки: UI-поток (Panic Sell) vs фоновый trading_loop self.is_running = False self.status = "⏹ Остановлен" self.api_key = "" self.api_secret = "" - self.target_profit = 0.05 - self.total_budget = 20.0 + self.target_profit = 0.05 # Целевой профит на сделку (USDT) + self.total_budget = 20.0 # Общий выделенный бюджет self.num_slots = DEFAULT_SLOTS - self.use_bnb_discount = True - self.paper_trading = True - self.paper_trades: list[dict] = [] + self.use_bnb_discount = True # Флаг скидки комиссии BNB + self.paper_trading = True # Включен ли режим симулятора + self.paper_trades: list[dict] = [] # Полный журнал виртуальных сделок self.slots: list[Slot] = [Slot(index=i) for i in range(DEFAULT_SLOTS)] self.logs = [] - self.total_profit = 0.0 - self.trades_count = 0 - self.last_full_screen_at = 0.0 - self.market_snapshot = [] - self.allowed_symbols_count = 0 - self.order_timeout_min = 360 - self.use_timeout = False - self.use_profit_exit = True - self.profit_exit_timeout_min = 120 - self.strategy_mode = "⚖️ Умеренный (Сбалансированный)" - self.use_dynamic_tp = True - self.max_correlation = MAX_CORRELATION_DEFAULT - - # --- НОВЫЕ ПОЛЯ --- - self.proxy_url = "" - self.check_orderbook = True - self.max_slippage_pct = 0.5 - - self.auto_scale_slots = False - self.base_slot_budget = 4.0 - - self.vps_auto_pay = False - self.vps_wallet = "" - self.vps_cost = 5.0 - self.vps_pay_day = 1 - self.last_vps_pay_month = 0 - - self.use_gradation = False + self.total_profit = 0.0 # Накопленная прибыль сессии + self.trades_count = 0 # Счетчик закрытых сделок (ротаций) + self.last_full_screen_at = 0.0 # Время последнего скрининга + self.market_snapshot = [] # Последние результаты скринера для UI + self.allowed_symbols_count = 0 # Кол-во доступных USDT пар + self.order_timeout_min = 360 # Вре��я удержания до авто-выхода в минутах (6 часов по умолчанию) + self.use_timeout = False # Принудительный выход по тайм-ауту по умолчанию выключен по вашей просьбе + self.use_profit_exit = True # Включить ротацию по плюсу после определенного времени удержания + self.profit_exit_timeout_min = 120 # Тайм-аут удержания до проверки профита в минутах (2 часа) + self.strategy_mode = "⚖️ Умеренный (Сбалансированный)" # Режим скальпинга + self.use_dynamic_tp = True # Использовать адаптивный Тейк-Профит на основе волатильности + self.max_correlation = MAX_CORRELATION_DEFAULT # Порог диверсификации: макс. допустимая корреляция доходностей с уже открытыми/отбираемыми монетами + self.proxy_url = "" # http(s)://user:pass@host:port - обход гео-блокировки Binance для Live (нужен только в Live, paper использует data-api.binance.vision) + self.use_depth_check = True # Проверять глубину стакана перед покупкой, чтобы не покупать в тонкий стакан с большим проскальзыванием + self.max_slippage_pct = 0.5 # Макс. допустимое проскальзывание (%) между лучшим ask и средневзвешенной ценой исполнения нашего объема + + # ── Авто-масштабирование слотов по росту депозита ── + self.auto_scale_slots = False # Только Live: раз в час проверяет реальный баланс и добавляет слоты по SLOT_SCALING_TABLE + self.last_autoscale_check_at = 0.0 + self.initial_deposit = 0.0 # Зафиксированный стартовый депозит (для безопасных расчетов авто-оплаты VPS) + + # ── Авто-оплата VPS с биржевого баланса (см. предупреждения в UI - реальный вывод денег!) ── + self.auto_pay_vps_enabled = False + self.vps_monthly_cost = 20.0 + self.vps_payout_address = "" + self.vps_payout_network = "TRC20" + self.last_vps_payment_at = 0.0 + + # ── Стратегия "Градация" (альтернатива микро-скальпинг портфелю) ── + self.strategy_engine = "portfolio" # "portfolio" (текущий 5-слотовый микро-скальпинг) | "grading" (лесенка по крупным монетам) + self.grading_symbols = list(GRADING_DEFAULT_SYMBOLS) + self.grading_budget = 0.0 # Отдельный бюджет под режим градации (не пересекается с total_budget портфеля) + self.grading_positions: dict[str, dict] = {} # symbol -> {"rolling_high", "filled": set(), "qty", "cost"} def budget_per_slot(self) -> float: if self.num_slots <= 0: @@ -163,10 +185,15 @@ def get_bot_state() -> BotState: return BotState() +# ───────────────────────────────────────────── +# РАБОТА С БИРЖЕЙ +# ───────────────────────────────────────────── + # ───────────────────────────────────────────── # ИНДИКАТОРЫ # ───────────────────────────────────────────── def calculate_rsi(prices: list[float], period: int = 14) -> float: + """Чистый Python-расчет RSI-14 для избежания внешних C-зависимостей типа TA-Lib""" if len(prices) < period + 1: return 50.0 deltas = [] @@ -179,104 +206,473 @@ def calculate_rsi(prices: list[float], period: int = 14) -> float: avg_gain = sum(gains[:period]) / period avg_loss = sum(losses[:period]) / period - if avg_loss == 0: return 100.0 + if avg_loss == 0: + return 100.0 for i in range(period, len(deltas)): avg_gain = (avg_gain * (period - 1) + gains[i]) / period avg_loss = (avg_loss * (period - 1) + losses[i]) / period - if avg_loss == 0: return 100.0 + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss return 100.0 - (100.0 / (1.0 + rs)) + def detect_bullish_divergence(closes: list[float], rsi_vals: list[float], window: int = 15) -> tuple[bool, str]: - if len(closes) < 15 or len(rsi_vals) < 15: return False, "none" + """ + Ищет бычью дивергенцию (классическую или скрытую) на последних свечах. + Бычья дивергенция (классическая): цена делает более низкое дно (или равное), а RSI - более высокое дно. + Скрытая бычья дивергенция: цена делает более высокое дно, а RSI - более низкое дно (сброс RSI перед ростом). + """ + if len(closes) < 15 or len(rsi_vals) < 15: + return False, "none" + + # Ищем впадины (локальные минимумы) за последние 'window' свечей troughs = [] for i in range(len(closes) - window, len(closes) - 1): - if i <= 0 or i >= len(closes) - 1: continue + if i <= 0 or i >= len(closes) - 1: + continue if closes[i] < closes[i-1] and closes[i] < closes[i+1]: troughs.append(i) - if len(troughs) < 2: return False, "none" - i1, i2 = troughs[-2], troughs[-1] - if i2 - i1 > 12: return False, "none" - p1, p2, r1, r2 = closes[i1], closes[i2], rsi_vals[i1], rsi_vals[i2] - if p2 <= p1 and r2 > r1 and r2 < 45: return True, "classic" - if p2 > p1 and r2 < r1 and r2 < 45: return True, "hidden" + + if len(troughs) < 2: + return False, "none" + + # Сравниваем две последние впадины + i1 = troughs[-2] + i2 = troughs[-1] + + # Если впадины расположены слишком далеко друг от друга (больше 12 свечей), пропускаем + if i2 - i1 > 12: + return False, "none" + + p1, p2 = closes[i1], closes[i2] + r1, r2 = rsi_vals[i1], rsi_vals[i2] + + # 1. КЛАССИЧЕСКАЯ БЫЧЬЯ ДИВЕРГЕНЦИЯ: + # Цена падает (вторая впадина ниже первой), а RSI растет (вторая впадина на RSI выше первой) + if p2 <= p1 and r2 > r1: + if r2 < 45: # RSI во второй впадине должен быть перепродан + return True, "classic" + + # 2. СКРЫТАЯ БЫЧЬЯ ДИВЕРГЕНЦИЯ (сигнал продолжения восходящего тренда после отката): + # Цена растет (вторая впадина выше первой), а RSI падает (вторая впадина на RSI ниже первой) + if p2 > p1 and r2 < r1: + if r2 < 45: + return True, "hidden" + return False, "none" + def calculate_bollinger_bands(prices: list[float], period: int = 20, num_stdDev: float = 2.0) -> tuple[float, float, float]: - if len(prices) < period: return 0.0, 0.0, 0.0 + """Чистый Python-расчет Bollinger Bands (Верхняя, Средняя, Нижняя лента) для 1м-свечей""" + if len(prices) < period: + return 0.0, 0.0, 0.0 slice_prices = prices[-period:] sma = sum(slice_prices) / period variance = sum((x - sma) ** 2 for x in slice_prices) / period std_dev = math.sqrt(variance) - return sma + (num_stdDev * std_dev), sma, sma - (num_stdDev * std_dev) + upper_band = sma + (num_stdDev * std_dev) + lower_band = sma - (num_stdDev * std_dev) + return upper_band, sma, lower_band + def safe_call(fn, *args, state: "BotState | None" = None, label: str = "exchange", **kwargs): + """ + Обертка с повторными попытками (retry + экспоненциальный backoff) для сетевых вызовов биржи. + Защищает от падения потока при временных сбоях сети / rate-limit / таймаутах CCXT, + вместо того чтобы просто ловить исключение один раз и терять цикл. + """ last_err = None for attempt in range(1, EXCHANGE_RETRY_ATTEMPTS + 1): try: return fn(*args, **kwargs) except (ccxt.NetworkError, ccxt.RequestTimeout, ccxt.DDoSProtection, ccxt.ExchangeNotAvailable) as e: last_err = e - if state: state.log(f"{label}: временная ошибка сети (попытка {attempt}): {e}", "WARN") + if state: + state.log(f"{label}: временная ошибка сети (попытка {attempt}/{EXCHANGE_RETRY_ATTEMPTS}): {e}", "WARN") time.sleep(EXCHANGE_RETRY_DELAY_SEC * attempt) except ccxt.ExchangeError as e: + # Ошибки биржи (неверные параметры, отклоненный ордер и т.п.) ретраить бессмысленно - пробрасываем сразу raise raise last_err + def get_returns(closes: list[float]) -> list[float]: - if len(closes) < 2: return [] + """Процентные изменения цены свеча-к-свече (доходности) для расчета корреляции.""" + if len(closes) < 2: + return [] return [(closes[i] - closes[i - 1]) / closes[i - 1] for i in range(1, len(closes)) if closes[i - 1] != 0] + def calculate_correlation(series_a: list[float], series_b: list[float]) -> float | None: + """Чистый Python-расчет коэффициента корреляции Пирсона (без numpy), по последним общим N точкам.""" n = min(len(series_a), len(series_b)) - if n < 5: return None - a, b = series_a[-n:], series_b[-n:] - mean_a, mean_b = sum(a) / n, sum(b) / n + if n < 5: + return None + a = series_a[-n:] + b = series_b[-n:] + mean_a = sum(a) / n + mean_b = sum(b) / n cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n)) var_a = sum((x - mean_a) ** 2 for x in a) var_b = sum((x - mean_b) ** 2 for x in b) denom = math.sqrt(var_a * var_b) - if denom == 0: return None + if denom == 0: + return None return cov / denom -# ───────────────────────────────────────────── -# РАБОТА С БИРЖЕЙ -# ───────────────────────────────────────────── +def select_diversified_candidates( + scored: list[dict], + need_n: int, + baseline_closes: list[list[float]], + max_correlation: float, + state: "BotState", +) -> list[dict]: + """ + Жадный отбор кандидатов с фильтром по низкой корреляции доходностей: + - сначала проверяем корреляцию с уже ОТКРЫТЫМИ позициями в других слотах (baseline_closes), + - затем корреляцию между собой (чтобы не набрать 5 слотов из одной "мемной" группы монет). + Если после фильтра не набрали need_n кандидатов - дозаполняем оставшимися лучшими по score, + чтобы не оставлять слоты пустыми (диверсификация - это приоритет, а не жесткий стоп). + """ + selected: list[dict] = [] + selected_returns = [get_returns(c) for c in baseline_closes if c] + + for cand in scored: + if len(selected) >= need_n: + break + cand_returns = get_returns(cand.get("closes", [])) + too_correlated = False + for other_returns in selected_returns: + corr = calculate_correlation(cand_returns, other_returns) + if corr is not None and corr > max_correlation: + too_correlated = True + break + if not too_correlated: + selected.append(cand) + selected_returns.append(cand_returns) + + if len(selected) < need_n: + selected_symbols = {c["symbol"] for c in selected} + for cand in scored: + if len(selected) >= need_n: + break + if cand["symbol"] not in selected_symbols: + state.log(f"Диверсификация: не хватило слабокоррелируемых кандидатов, добираем {cand['symbol']} по score", "INFO") + selected.append(cand) + selected_symbols.add(cand["symbol"]) + + return selected + + +def estimate_buy_slippage(exchange: ccxt.binance, symbol: str, budget_usdt: float, state: BotState) -> tuple[float, float] | None: + """ + Проверка ГЛУБИНЫ стакана перед покупкой: идем по уровням ask, пока не наберем нужный объем в USDT, + и считаем средневзвешенную цену исполнения. Возвращает (avg_price, slippage_pct) или None при ошибке. + slippage_pct = насколько средняя цена исполнения хуже лучшего ask (в процентах). + Это защищает от покупки в тонкий стакан у низколиквидных монет (score может быть высоким, + а объем в стакане - маленьким, особенно у 1000SATS/DOGS-подобных микро-кап монет). + """ + try: + order_book = safe_call(exchange.fetch_order_book, symbol, 20, state=state, label=f"orderbook {symbol}") + except Exception as e: + state.log(f"Глубина стакана {symbol}: не удалось получить стакан: {e}", "WARN") + return None + + asks = order_book.get("asks", []) + if not asks: + return None + + best_ask = asks[0][0] + remaining_usdt = budget_usdt + cost_total = 0.0 + qty_total = 0.0 + + for price, qty in asks: + level_usdt = price * qty + take_usdt = min(level_usdt, remaining_usdt) + take_qty = take_usdt / price + cost_total += take_usdt + qty_total += take_qty + remaining_usdt -= take_usdt + if remaining_usdt <= 0: + break + + if qty_total == 0 or remaining_usdt > 0: + # Стакан слишком тонкий - даже 20 уровней не покрывают нужный объем + return None + + avg_price = cost_total / qty_total + slippage_pct = ((avg_price - best_ask) / best_ask) * 100.0 + return avg_price, slippage_pct + + +def compute_target_slots(current_balance: float) -> int: + """Определяет целевое кол-во слотов по таблице SLOT_SCALING_TABLE - чем больше реальный депозит, тем больше независимых слотов.""" + target = DEFAULT_SLOTS + for threshold, slots_n in SLOT_SCALING_TABLE: + if current_balance >= threshold: + target = slots_n + return min(target, MAX_SLOTS) + + +def maybe_autoscale_slots(exchange: ccxt.binance, state: BotState): + """ + Раз в AUTOSCALE_CHECK_INTERVAL_SEC проверяет РЕАЛЬНЫЙ баланс USDT на бирже (только Live) и, + если депозит вырос до следующего порога в SLOT_SCALING_TABLE - добавляет слоты. + ВАЖНО: только увеличивает количество слотов, никогда не уменьшает автоматически - + уменьшение отрезало бы уже открытые позиции (resize_slots() обрезает список слотов). + """ + if state.paper_trading or not state.auto_scale_slots: + return + now = time.time() + if now - state.last_autoscale_check_at < AUTOSCALE_CHECK_INTERVAL_SEC: + return + state.last_autoscale_check_at = now + + try: + balance = safe_call(exchange.fetch_balance, state=state, label="autoscale fetch_balance") + usdt_total = float(balance.get("total", {}).get("USDT", 0) or 0) + except Exception as e: + state.log(f"Авто-масштабирование: ошибка получения баланса: {e}", "WARN") + return + + if state.initial_deposit == 0.0: + state.initial_deposit = usdt_total + + target = compute_target_slots(usdt_total) + if target > state.num_slots: + old_n = state.num_slots + state.resize_slots(target) + state.total_budget = usdt_total # Пересчитываем общий бюджет под реальный депозит + state.log(f"📈 Авто-масштабирование: депозит ${usdt_total:.2f} -> слотов {old_n} -> {target}", "OK") + + +def maybe_autopay_vps(exchange: ccxt.binance, state: BotState): + """ + ⚠️ ОПАСНАЯ операция: реально выводит USDT с биржи на адрес хостинга для оплаты VPS. + Срабатывает ТОЛЬКО если ВСЕ условия выполнены: + 1) state.auto_pay_vps_enabled == True (явно включено пользователем + подтверждено в UI) + 2) НЕ paper trading (в paper режиме функция не имеет смысла и не выполняется) + 3) указан state.vps_payout_address (адрес ДОЛЖЕН быть заранее добавлен в whitelist адресов Binance - + это встроенная защита самой биржи от вывода на непроверенный адрес) + 4) баланс позволяет оплатить VPS, не залезая в исходный депозит + (usdt_free - vps_monthly_cost >= initial_deposit) - то есть платим только из ПРИБЫЛИ + 5) прошло >= VPS_PAYMENT_INTERVAL_SEC (30 дней) с последней оплаты + """ + if state.paper_trading or not state.auto_pay_vps_enabled: + return + if not state.vps_payout_address: + return + + now = time.time() + if now - state.last_vps_payment_at < VPS_PAYMENT_INTERVAL_SEC: + return + + try: + balance = safe_call(exchange.fetch_balance, state=state, label="autopay fetch_balance") + usdt_free = float(balance.get("free", {}).get("USDT", 0) or 0) + except Exception as e: + state.log(f"Авто-оплата VPS: ошибка получения баланса: {e}", "WARN") + return + + if usdt_free - state.vps_monthly_cost < state.initial_deposit: + state.log( + f"Авто-оплата VPS: пропуск - свободный баланс ${usdt_free:.2f} после оплаты " + f"${state.vps_monthly_cost:.2f} ушел бы ниже исходного депозита ${state.initial_deposit:.2f}. " + f"Платим только из прибыли, не из тела депозита.", "WARN" + ) + return + + try: + safe_call( + exchange.withdraw, "USDT", state.vps_monthly_cost, state.vps_payout_address, + None, {"network": state.vps_payout_network}, + state=state, label="VPS withdraw", + ) + state.last_vps_payment_at = now + masked_addr = state.vps_payout_address[:6] + "..." + state.vps_payout_address[-4:] + state.log( + f"💸 Авто-оплата VPS: отправлено {state.vps_monthly_cost} USDT на {masked_addr} " + f"(сеть {state.vps_payout_network})", "OK" + ) + except Exception as e: + state.log(f"🔴 Авто-оплата VPS: ОШИБКА вывода - {e}. Оплати VPS вручную в этом месяце!", "ERR") + + def build_exchange(api_key: str, api_secret: str, paper_trading: bool = False, proxy_url: str = "") -> ccxt.binance: config = { "apiKey": api_key, "secret": api_secret, "options": { "defaultType": "spot", - "fetchMarkets": ["spot"], + "fetchMarkets": ["spot"], # Ограничиваем загрузку только спотовым рынком, чтобы CCXT не опрашивал фьючерсы (fapi/dapi/eapi) }, "enableRateLimit": True, } if paper_trading: + # Для бесплатного симулятора на серверах в США (Hugging Face) + # перенаправляем запросы на официальный незаблокированный шлюз рыночных данных Binance config["urls"] = { "api": { "public": "https://data-api.binance.vision/api/v3", "private": "https://data-api.binance.vision/api/v3" } } - else: - # Применяем прокси только для боевого режима - if proxy_url: - config["proxies"] = { - "http": proxy_url, - "https": proxy_url - } exchange = ccxt.binance(config) + + # Обход гео-блокировки Binance для Live-режима (data-api.binance.vision не подходит для приватных + # эндпоинтов - ордера, баланс - там нужен полноценный api.binance.com, который блокирует US-датацентры). + # proxy_url должен указывать на прокси/VPS/туннель, геолоцированный в РАЗРЕШЕННОЙ Binance стране + # (в идеале - в стране, совпадающей с твоим KYC, например Казахстан), иначе есть риск compliance-флага. + if proxy_url: + exchange.proxies = {"http": proxy_url, "https": proxy_url} + return exchange + +def execute_grading_buy(exchange: ccxt.binance, symbol: str, budget_usdt: float, grade_idx: int, pos: dict, state: BotState) -> bool: + """Докупка одного 'грейда' лесенки на просадке крупной монеты. Аналог execute_buy, но без Slot - + накопленная позиция живет в state.grading_positions[symbol], не в портфельных слотах.""" + try: + ticker = safe_call(exchange.fetch_ticker, symbol, state=state, label=f"grading buy ticker {symbol}") + ask = ticker.get("ask") or ticker.get("last") + if not ask: + return False + + if state.use_depth_check: + depth_result = estimate_buy_slippage(exchange, symbol, budget_usdt, state) + if depth_result is not None: + _, slippage_pct = depth_result + if slippage_pct > state.max_slippage_pct: + state.log(f"Градация {symbol} грейд {grade_idx}: отмена - проскальзывание {slippage_pct:.2f}%", "WARN") + return False + + qty = budget_usdt / ask + + if state.paper_trading: + state.record_paper_trade({ + "symbol": symbol, "action": f"GRADE_BUY_{grade_idx}", "slot": "grading", + "price": ask, "qty": qty, "usdt_value": budget_usdt, "profit": None, + }) + else: + order = safe_call(exchange.create_market_buy_order, symbol, qty, state=state, label=f"grading buy {symbol}") + filled_qty = order.get("filled") or qty + avg_price = order.get("average") or ask + state.record_paper_trade({ + "symbol": symbol, "action": f"GRADE_BUY_{grade_idx}", "slot": "grading", + "price": avg_price, "qty": filled_qty, "usdt_value": budget_usdt, "profit": None, + }) + + pos["qty"] += qty + pos["cost"] += budget_usdt + state.log(f"📐 Градация {symbol}: докуплен грейд {grade_idx} (просадка от хая) на ${budget_usdt:.2f} по {ask:.6g}", "OK") + return True + except Exception as e: + state.log(f"Градация {symbol} грейд {grade_idx}: ошибка покупки - {e}", "ERR") + return False + + +def check_grading_opportunities(exchange: ccxt.binance, state: BotState): + """ + Для каждой монеты из state.grading_symbols проверяет откат от скользящего суточного хая. + Как только просадка достигает следующего незаполненного уровня GRADING_DROP_LEVELS_PCT - + докупает этот грейд (глубже просадка -> крупнее объем, вес из GRADING_WEIGHTS - как лесенка в grid-EA). + За один цикл заполняется максимум ОДИН новый грейд на монету, чтобы не докупить все уровни разом на резком проливе. + """ + if not state.grading_symbols or state.grading_budget <= 0: + return + + budget_per_symbol = state.grading_budget / len(state.grading_symbols) + weight_sum = sum(GRADING_WEIGHTS) + + for sym in state.grading_symbols: + try: + ticker = safe_call(exchange.fetch_ticker, sym, state=state, label=f"grading scan {sym}") + except Exception as e: + state.log(f"Градация {sym}: ошибка тикера при сканировании - {e}", "WARN") + continue + + high24h = ticker.get("high") + last = ticker.get("last") or ticker.get("bid") + if not high24h or not last: + continue + + pos = state.grading_positions.setdefault(sym, {"rolling_high": high24h, "filled": set(), "qty": 0.0, "cost": 0.0}) + + # Скользящий хай обновляем, только если по монете сейчас НЕТ позиции - + # иначе после входа новый локальный хай исказил бы % просадки от точки, где мы уже купили + if not pos["filled"]: + pos["rolling_high"] = max(pos["rolling_high"], high24h) + + drop_pct = (pos["rolling_high"] - last) / pos["rolling_high"] * 100.0 + + for i, level in enumerate(GRADING_DROP_LEVELS_PCT): + if i in pos["filled"]: + continue + if drop_pct >= level: + grade_budget = budget_per_symbol * (GRADING_WEIGHTS[i] / weight_sum) + if execute_grading_buy(exchange, sym, grade_budget, i, pos, state): + pos["filled"].add(i) + break + + +def process_grading_exits(exchange: ccxt.binance, state: BotState): + """Закрывает всю накопленную лесенку по монете разом, когда цена восстановилась + в пределах GRADING_RECOVERY_EXIT_PCT % от скользящего хая (почти отросла обратно к максимуму).""" + for sym, pos in list(state.grading_positions.items()): + if pos["qty"] <= 0: + continue + try: + ticker = safe_call(exchange.fetch_ticker, sym, state=state, label=f"grading exit check {sym}") + except Exception as e: + state.log(f"Градация {sym}: ошибка проверки выхода - {e}", "WARN") + continue + + last = ticker.get("last") or ticker.get("bid") + if not last: + continue + + recovery_price = pos["rolling_high"] * (1 - GRADING_RECOVERY_EXIT_PCT / 100.0) + if last >= recovery_price: + try: + if state.paper_trading: + received = pos["qty"] * last + else: + order = safe_call(exchange.create_market_sell_order, sym, pos["qty"], state=state, label=f"grading sell {sym}") + received = order.get("cost") or (pos["qty"] * last) + + profit = received - pos["cost"] + state.record_paper_trade({ + "symbol": sym, "action": "GRADE_SELL", "slot": "grading", + "price": last, "qty": pos["qty"], "usdt_value": received, "profit": profit, + }) + state.total_profit += profit + state.trades_count += 1 + state.log( + f"📐✅ Градация {sym}: лесенка закрыта разом ({len(pos['filled'])} грейдов), " + f"восстановление к хаю, P&L={profit:+.4f} USDT", "OK" + ) + except Exception as e: + state.log(f"Градация {sym}: ошибка продажи при выходе - {e}", "ERR") + continue + + # Сброс позиции по монете - готова к новому циклу лесенки от нового скользящего хая + state.grading_positions[sym] = {"rolling_high": last, "filled": set(), "qty": 0.0, "cost": 0.0} + + def get_allowed_symbols(exchange: ccxt.binance) -> list[str]: markets = exchange.load_markets() allowed = [] for sym, info in markets.items(): - if not sym.endswith("/USDT") or not info.get("active", False): continue + if not sym.endswith("/USDT"): + continue + if not info.get("active", False): + continue filters = info.get("info", {}).get("filters", []) min_notional = None for f in filters: @@ -289,67 +685,57 @@ def get_allowed_symbols(exchange: ccxt.binance) -> list[str]: allowed.append(sym) return allowed -def estimate_buy_slippage(exchange: ccxt.binance, symbol: str, budget_usdt: float, state: BotState) -> tuple[bool, float, float]: - """Проверка глубины стакана (fetch_order_book) для защиты от проскальзывания при Market Buy""" - try: - order_book = safe_call(exchange.fetch_order_book, symbol, 20, state=state, label=f"orderbook {symbol}") - asks = order_book.get("asks", []) - if not asks: - return False, 0.0, 100.0 - - best_ask = asks[0][0] - remaining = budget_usdt - total_qty = 0.0 - total_cost = 0.0 - - for price, qty in asks: - level_cost = price * qty - if remaining <= level_cost: - total_qty += remaining / price - total_cost += remaining - remaining = 0 - break - else: - total_qty += qty - total_cost += level_cost - remaining -= level_cost - - if remaining > 0 or total_qty == 0: - return False, 0.0, 100.0 - - avg_px = total_cost / total_qty - slippage = ((avg_px - best_ask) / best_ask) * 100 - return True, avg_px, slippage - - except Exception as e: - state.log(f"Ошибка проверки стакана {symbol}: {e}", "WARN") - return False, 0.0, 100.0 -def screen_full_market(exchange: ccxt.binance, allowed_symbols: list[str], occupied: set[str], state: BotState, need_n: int) -> list[dict]: +def screen_full_market( + exchange: ccxt.binance, + allowed_symbols: list[str], + occupied: set[str], + state: BotState, + need_n: int, +) -> list[dict]: state.log(f"Полный скрин рынка: {len(allowed_symbols)} разрешенных пар...", "INFO") + try: + # Получаем тикеры для всех пар, так как передача конкретного списка symbols в CCXT + # может вызывать ошибку форматирования (например, пробелы после запятых в JSON) на стороне Binance all_tickers = safe_call(exchange.fetch_tickers, state=state, label="Скринер fetch_tickers") tickers = {sym: t for sym, t in all_tickers.items() if sym in allowed_symbols} except Exception as e: state.log(f"Скринер: ошибка fetch_tickers: {e}", "ERR") return [] + # Настройка параметров адаптивных фильтров под выбранный режим if state.strategy_mode.startswith("🛡️"): - rsi_threshold, drawdown_mult, max_range_pos = 32.0, 0.20, 0.45 + rsi_threshold = 32.0 # Только сильная перепроданность + drawdown_mult = 0.20 # Глубокий откат (20% от суточной волатильности) + max_range_pos = 0.45 # Строго в нижней трети диапазона elif state.strategy_mode.startswith("🔥"): - rsi_threshold, drawdown_mult, max_range_pos = 48.0, 0.08, 0.70 - else: - rsi_threshold, drawdown_mult, max_range_pos = 40.0, 0.14, 0.60 + rsi_threshold = 48.0 # Мягкие условия входа для частых покупок + drawdown_mult = 0.08 # Минимальный откат (8% от волатильности) + max_range_pos = 0.70 # Позволяет брать чуть выше + else: # Сбалансированный + rsi_threshold = 40.0 + drawdown_mult = 0.14 + max_range_pos = 0.60 candidates = [] for sym, t in tickers.items(): - if sym in occupied: continue - bid, ask, vol = t.get("bid") or 0, t.get("ask") or 0, t.get("quoteVolume") or 0 - if bid <= 0 or ask <= 0 or vol < MIN_QUOTE_VOLUME: continue + if sym in occupied: + continue + bid = t.get("bid") or 0 + ask = t.get("ask") or 0 + vol = t.get("quoteVolume") or 0 + if bid <= 0 or ask <= 0: + continue spread = (ask - bid) / bid - if spread > MAX_SPREAD_RATIO: continue - - high_24h, low_24h = t.get("high") or ask, t.get("low") or ask + if vol < MIN_QUOTE_VOLUME: + continue + if spread > MAX_SPREAD_RATIO: + continue + + # Получаем суточные параметры монеты для адаптации + high_24h = t.get("high") or ask + low_24h = t.get("low") or ask volatility_24h = (high_24h - low_24h) / low_24h if low_24h else 0 candidates.append({ @@ -357,146 +743,184 @@ def screen_full_market(exchange: ccxt.binance, allowed_symbols: list[str], occup "volatility_24h": volatility_24h, "high_24h": high_24h, "low_24h": low_24h }) - if not candidates: return [] + state.log(f"Скрин: {len(candidates)} пар прошли фильтр объема/спреда", "INFO") + + if not candidates: + state.log("Скрин: ни одна пара не прошла базовый фильтр", "WARN") + return [] + candidates.sort(key=lambda x: x["vol"], reverse=True) batch = candidates[:CANDLE_FETCH_BATCH_SIZE] + state.log(f"Анализируем индикаторами топ-{len(batch)} по объему", "INFO") + scored: list[dict] = [] for c in batch: sym = c["symbol"] + + # ─── МУЛЬТИТАЙМФРЕЙМОВЫЙ АНАЛИЗ ─── + + # Шаг А: Анализ на ЧАСОВОМ таймфрейме ("1h") - оценка макро-тренда try: ohlcv_1h = safe_call(exchange.fetch_ohlcv, sym, "1h", limit=15, state=state, label=f"1h {sym}") - except Exception: continue - if len(ohlcv_1h) < 15: continue + except Exception as e: + state.log(f"Скрин: ошибка 1h свечей {sym}: {e}", "WARN") + continue + + if len(ohlcv_1h) < 15: + continue - rsi_1h = calculate_rsi([k[4] for k in ohlcv_1h], period=14) - if rsi_1h > 75.0 or rsi_1h < 25.0: continue + closes_1h = [k[4] for k in ohlcv_1h] + rsi_1h = calculate_rsi(closes_1h, period=14) + # Вместо жесткой часовой отсечки, которая полностью блокирует сделки на растущем рынке, + # мы фильтруем только экстремальную перегрутость (RSI > 75) или умирающие монеты (RSI < 25) + if rsi_1h > 75.0 or rsi_1h < 25.0: + continue + + # Шаг Б: Подтверждение на МИНУТНОМ таймфрейме ("1m") - Боллинджер, дивергенции и адаптивный RSI try: ohlcv_1m = safe_call(exchange.fetch_ohlcv, sym, "1m", limit=CANDLE_LIMIT, state=state, label=f"1m {sym}") - except Exception: continue - if len(ohlcv_1m) < 15: continue + except Exception as e: + state.log(f"Скрин: ошибка 1m свечей {sym}: {e}", "WARN") + continue - closes_1m, highs_1m, lows_1m, opens_1m = [k[4] for k in ohlcv_1m], [k[2] for k in ohlcv_1m], [k[3] for k in ohlcv_1m], [k[1] for k in ohlcv_1m] - high_max_1m, low_min_1m = max(highs_1m), min(lows_1m) + if len(ohlcv_1m) < 15: + continue - rsi_array_1m = [calculate_rsi(closes_1m[:i+1], period=14) for i in range(len(closes_1m))] + closes_1m = [k[4] for k in ohlcv_1m] + highs_1m = [k[2] for k in ohlcv_1m] + lows_1m = [k[3] for k in ohlcv_1m] + opens_1m = [k[1] for k in ohlcv_1m] + + high_max_1m = max(highs_1m) + low_min_1m = min(lows_1m) + + # Вычисляем массив исторических значений RSI-14 для 1м свечей + rsi_array_1m = [] + for i in range(len(closes_1m)): + rsi_array_1m.append(calculate_rsi(closes_1m[:i+1], period=14)) + rsi_val_1m = rsi_array_1m[-1] has_divergence, div_type = detect_bullish_divergence(closes_1m, rsi_array_1m, window=15) + + # Рассчитываем ленты Боллинджера на 1м (период 20, StdDev 2.0) _, _, lower_band = calculate_bollinger_bands(closes_1m, period=20, num_stdDev=2.0) - dynamic_rsi_threshold = max(28.0, min(52.0, rsi_threshold + (rsi_1h - 50.0) * 0.25)) + # АДАПТИВНЫЙ ПОРОГ RSI-1m: если часовой тренд сильный, мы повышаем порог RSI-1m для легкого входа на откатах. + # Если часовой тренд слабый/падающий, мы ужесточаем порог RSI-1m для защиты от "падающих ножей". + dynamic_rsi_threshold = rsi_threshold + (rsi_1h - 50.0) * 0.25 + dynamic_rsi_threshold = max(28.0, min(52.0, dynamic_rsi_threshold)) + is_entry_triggered = False if state.strategy_mode.startswith("🛡️"): + # 🛡️ Консервативный: дивергенция при перепроданности + касание нижней ленты Боллинджера if has_divergence and rsi_val_1m <= dynamic_rsi_threshold and c["ask"] <= lower_band: - rp = (c["ask"] - low_min_1m) / (high_max_1m - low_min_1m) if (high_max_1m - low_min_1m) > 0 else 0 - if rp <= max_range_pos: is_entry_triggered = True + range_position = (c["ask"] - low_min_1m) / (high_max_1m - low_min_1m) if (high_max_1m - low_min_1m) > 0 else 0 + if range_position <= max_range_pos: + is_entry_triggered = True elif state.strategy_mode.startswith("🔥"): + # 🔥 Агрессивный: покупаем сразу, как только RSI ниже адаптивного порога, ИЛИ коснулись ленты Боллинджера, ИЛИ есть дивергенция if rsi_val_1m <= dynamic_rsi_threshold or has_divergence or c["ask"] <= lower_band: is_entry_triggered = True else: - rp = (c["ask"] - low_min_1m) / (high_max_1m - low_min_1m) if (high_max_1m - low_min_1m) > 0 else 0 - if rp <= max_range_pos: - if has_divergence: is_entry_triggered = True - elif rsi_val_1m <= dynamic_rsi_threshold and closes_1m[-1] > opens_1m[-1]: is_entry_triggered = True - elif c["ask"] <= lower_band: is_entry_triggered = True - - if not is_entry_triggered: continue - + # ⚖️ Сбалансированный: дивергенция, либо (RSI ниже динамического порога + зеленая свеча), либо пробой нижней ленты Боллинджера + range_position = (c["ask"] - low_min_1m) / (high_max_1m - low_min_1m) if (high_max_1m - low_min_1m) > 0 else 0 + if range_position <= max_range_pos: + if has_divergence: + is_entry_triggered = True + elif rsi_val_1m <= dynamic_rsi_threshold and closes_1m[-1] > opens_1m[-1]: + is_entry_triggered = True + elif c["ask"] <= lower_band: + is_entry_triggered = True + + if not is_entry_triggered: + continue + + # Вычисляем адаптивную просадку на основе суточной волатильности adaptive_min_drawdown = max(0.006, c["volatility_24h"] * drawdown_mult) drawdown = (high_max_1m - c["ask"]) / high_max_1m - if drawdown < adaptive_min_drawdown: continue + if drawdown < adaptive_min_drawdown: + continue + # Считаем итоговый скоринг (чем ниже rsi_1h и rsi_val_1m, тем выше балл) score = (drawdown * math.log10(c["vol"])) / (c["spread"] * (rsi_val_1m / 50.0)) scored.append({ "symbol": sym, "score": score, "ask": c["ask"], "volume": c["vol"], "spread": c["spread"], "drawdown": drawdown, "rsi": rsi_val_1m, "volatility_24h": c["volatility_24h"], - "closes": closes_1m, + "closes": closes_1m, # доходности для последующего расчета корреляции (диверсификация слотов) }) scored.sort(key=lambda x: x["score"], reverse=True) state.market_snapshot = scored[:10] - return scored -def select_diversified_candidates(scored: list[dict], need_n: int, baseline_closes: list[list[float]], max_correlation: float, state: BotState) -> list[dict]: - selected: list[dict] = [] - selected_returns = [get_returns(c) for c in baseline_closes if c] - - for cand in scored: - if len(selected) >= need_n: break - cand_returns = get_returns(cand.get("closes", [])) - too_correlated = False - for other_returns in selected_returns: - corr = calculate_correlation(cand_returns, other_returns) - if corr is not None and corr > max_correlation: - too_correlated = True - break - if not too_correlated: - selected.append(cand) - selected_returns.append(cand_returns) - - if len(selected) < need_n: - selected_symbols = {c["symbol"] for c in selected} - for cand in scored: - if len(selected) >= need_n: break - if cand["symbol"] not in selected_symbols: - selected.append(cand) - selected_symbols.add(cand["symbol"]) - return selected - -def fetch_baseline_closes(exchange: ccxt.binance, occupied_symbols: set[str], state: BotState) -> list[list[float]]: - baseline = [] - for sym in occupied_symbols: - try: - ohlcv = safe_call(exchange.fetch_ohlcv, sym, "1m", limit=CORRELATION_LOOKBACK, state=state, label=f"baseline {sym}") - closes = [k[4] for k in ohlcv] - if closes: baseline.append(closes) - except Exception: pass - return baseline + if not scored: + state.log("Скрин: ни одна монета не прошла адаптивные индикаторы", "WARN") + # Возвращаем ПОЛНЫЙ ранжированный список (не обрезаем до need_n) - + # финальный отбор с фильтром по корреляции делает вызывающая сторона (refill_empty_slots) + return scored def execute_buy(exchange: ccxt.binance, symbol: str, budget_usdt: float, slot: Slot, state: BotState) -> bool: try: ticker = exchange.fetch_ticker(symbol) ask = ticker["ask"] - if not ask or ask <= 0: return False - - # --- ПРОВЕРКА ГЛУБИНЫ СТАКАНА ПЕРЕД ПОКУПКОЙ --- - if state.check_orderbook and not state.paper_trading: - ok, est_px, slip = estimate_buy_slippage(exchange, symbol, budget_usdt, state) - if not ok or slip > state.max_slippage_pct: - state.log(f"[Слот {slot.index}] Отмена {symbol}: проскальзывание {slip:.2f}% > {state.max_slippage_pct}% порога", "WARN") - return False - ask = est_px # Заменяем тикер на реальную средневзвешенную цену исполнения + if not ask or ask <= 0: + state.log(f"[Слот {slot.index}] Buy: некорректный Ask для {symbol}", "ERR") + return False raw_qty = budget_usdt / ask qty = float(exchange.amount_to_precision(symbol, raw_qty)) + notional = qty * ask - if notional < 1.0: return False + if notional < 1.0: + state.log(f"[Слот {slot.index}] Buy: notional={notional:.4f} < 1.0 USDT - пропуск", "ERR") + return False + + # ── Проверка глубины стакана (защита от проскальзывания на тонких стаканах) ── + if state.use_depth_check: + depth_result = estimate_buy_slippage(exchange, symbol, budget_usdt, state) + if depth_result is None: + state.log(f"[Слот {slot.index}] Buy {symbol}: не удалось оценить глубину стакана - пропуск (осторожность)", "WARN") + return False + avg_depth_price, slippage_pct = depth_result + if slippage_pct > state.max_slippage_pct: + state.log( + f"[Слот {slot.index}] Buy {symbol}: ОТМЕНА - проскальзывание {slippage_pct:.2f}% " + f"> лимита {state.max_slippage_pct:.2f}% (стакан слишком тонкий для ${budget_usdt:.2f})", "WARN" + ) + return False if state.paper_trading: filled = qty avg_cost = qty * ask avg_px = ask - state.log(f"📝 [PAPER][Слот {slot.index}] Buy {symbol}: qty={qty}, ask={ask:.6f}", "INFO") + state.log( + f"📝 [PAPER][Слот {slot.index}] Buy {symbol}: qty={qty}, ask={ask} (РЕАЛЬНАЯ цена), " + f"notional approx {notional:.4f} - виртуально, без реального ордера", "INFO" + ) state.record_paper_trade({ "symbol": symbol, "action": "buy", "slot": slot.index, "price": avg_px, "qty": filled, "usdt_value": avg_cost, "profit": None }) else: - state.log(f"[Слот {slot.index}] Buy {symbol}: qty={qty}, ask={ask:.6f}", "INFO") + state.log(f"[Слот {slot.index}] Buy {symbol}: qty={qty}, ask={ask}, notional approx {notional:.4f}", "INFO") order = exchange.create_market_buy_order(symbol, qty) filled = float(order.get("filled") or qty) avg_cost = float(order.get("cost") or (qty * ask)) avg_px = avg_cost / filled if filled > 0 else ask + # Рассчитываем целевую прибыль под волатильность конкретной монеты (Адаптивный Тейк-Профит) if state.use_dynamic_tp: - high24h, low24h = ticker.get("high") or ask, ticker.get("low") or ask - vol_24h = (high24h - low24h) / low24h if low24h else 0 - tp_percent = max(0.005, min(0.025, vol_24h * 0.12)) + high24h = ticker.get("high") or ask + low24h = ticker.get("low") or ask + volatility_24h = (high24h - low24h) / low24h if low24h else 0 + # Тейк-Профит составит от 0.5% до 2.5% от суммы сделки в зависимости от суточных колебаний + tp_percent = max(0.005, min(0.025, volatility_24h * 0.12)) deal_profit = avg_cost * tp_percent + state.log(f"[Слот {slot.index}] Адаптивный Тейк-Профит для {symbol}: {tp_percent*100:.2f}% (цель profit: {deal_profit:.4f} USDT)", "INFO") else: deal_profit = state.target_profit @@ -506,7 +930,10 @@ def execute_buy(exchange: ccxt.binance, symbol: str, budget_usdt: float, slot: S slot.usdt_spent = avg_cost slot.target_profit_set = deal_profit - state.log(f"[Слот {slot.index}] Исполнено {symbol} по approx {avg_px:.6f}, потрачено {avg_cost:.4f}", "OK") + state.log( + f"[Слот {slot.index}] Buy исполнен: {filled} {symbol.replace('/USDT','')} " + f"по approx {avg_px:.6f}, потрачено {avg_cost:.4f} USDT", "OK" + ) return True except Exception as e: @@ -518,19 +945,31 @@ def place_limit_sell(exchange: ccxt.binance, slot: Slot, state: BotState) -> boo symbol = slot.symbol fee = state.fee_rate() if state.use_bnb_discount: + # С учетом скидки BNB, монеты при покупке НЕ урезались (qty полный) sell_raw = (slot.usdt_spent + slot.target_profit_set) / slot.bought_qty else: + # Без скидки (стандартный расчет) usdt_out = slot.usdt_spent + slot.target_profit_set sell_raw = usdt_out / (slot.bought_qty * (1 - fee)) - sell_px = float(exchange.price_to_precision(symbol, sell_raw)) qty = float(exchange.amount_to_precision(symbol, slot.bought_qty)) - if qty * sell_px < 1.0: return False + notional = qty * sell_px + if notional < 1.0: + state.log(f"[Слот {slot.index}] LimitSell: notional={notional:.4f} < 1.0 - пропуск", "ERR") + return False if state.paper_trading: order_id = f"PAPER-{uuid.uuid4().hex[:10]}" + state.log( + f"📝 [PAPER][Слот {slot.index}] LimitSell {symbol}: qty={qty}, price={sell_px:.6f} " + f"(виртуальный ордер {order_id}) - ожидание достижения цены", "INFO" + ) else: + state.log( + f"[Слот {slot.index}] LimitSell {symbol}: qty={qty}, price={sell_px:.6f}, " + f"notional={notional:.4f}, target_profit={slot.target_profit_set:.4f}", "INFO" + ) order = exchange.create_limit_sell_order(symbol, qty, sell_px) order_id = order["id"] @@ -539,7 +978,7 @@ def place_limit_sell(exchange: ccxt.binance, slot: Slot, state: BotState) -> boo slot.sell_price_set = sell_px slot.status = "⏳ Ждем продажи" - state.log(f"[Слот {slot.index}] LimitSell {symbol} по {sell_px:.6f}", "OK") + state.log(f"[Слот {slot.index}] LimitSell выставлен: ID={order_id}, цена={sell_px:.6f}", "OK") return True except Exception as e: @@ -548,52 +987,104 @@ def place_limit_sell(exchange: ccxt.binance, slot: Slot, state: BotState) -> boo def panic_sell_slot(exchange: ccxt.binance, slot: Slot, state: BotState, prefix: str = "🚨 PANIC"): sym = slot.symbol + state.log(f"{prefix} [Слот {slot.index}]: принудительный/таймаут выход из {sym}", "WARN") + if state.paper_trading: try: ticker = exchange.fetch_ticker(sym) current_bid = ticker.get("bid") or ticker.get("last") or slot.buy_price_avg received = slot.bought_qty * current_bid loss = received - slot.usdt_spent + + # Обновляем общую статистику сессии (прибыль/убыток) при любом выходе state.total_profit += loss state.trades_count += 1 - action_type = "timeout" if "TIMEOUT" in prefix else ("rotation" if "ROTATE" in prefix else "panic") + + state.log( + f"📝 {prefix} [Слот {slot.index}]: виртуальная продажа {slot.bought_qty:.6g} " + f"{sym.split('/')[0]} по РЕАЛЬНОЙ цене {current_bid:.6g}, получено approx {received:.4f} USDT. " + f"изменение approx {loss:.4f} USDT", "WARN" + ) + + action_type = "panic" + if "TIMEOUT" in prefix: + action_type = "timeout" + elif "ROTATE" in prefix: + action_type = "rotation" + state.record_paper_trade({ "symbol": sym, "action": action_type, "slot": slot.index, - "price": current_bid, "qty": slot.bought_qty, "usdt_value": received, "profit": loss, + "price": current_bid, "qty": slot.bought_qty, + "usdt_value": received, "profit": loss, }) - state.log(f"📝 {prefix} [Слот {slot.index}]: вирт. выход по {current_bid:.6g}, профит {loss:.4f}", "WARN") - except Exception: pass + except Exception as e: + state.log(f"📝 {prefix} [Слот {slot.index}]: ошибка получения цены для виртуального выхода: {e}", "ERR") slot.clear() return if slot.order_id: - try: exchange.cancel_order(slot.order_id, sym) - except Exception: pass + try: + exchange.cancel_order(slot.order_id, sym) + state.log(f"{prefix} [Слот {slot.index}]: лимит ID={slot.order_id} отменен", "OK") + except Exception as e: + state.log(f"{prefix} [Слот {slot.index}]: ошибка отмены лимитного ордера: {e}", "WARN") + time.sleep(1) try: base = sym.split("/")[0] balance = exchange.fetch_balance() qty_free = float(balance["free"].get(base, 0)) + if qty_free <= 0: + state.log(f"{prefix} [Слот {slot.index}]: баланс {base}={qty_free} - нечего продавать", "WARN") slot.clear() return + qty_sell = float(exchange.amount_to_precision(sym, qty_free)) order = exchange.create_market_sell_order(sym, qty_sell) received = float(order.get("cost") or 0) loss = received - slot.usdt_spent + + # Обновляем общую статистику сессии state.total_profit += loss state.trades_count += 1 - state.log(f"{prefix} [Слот {slot.index}]: выход по рынку, профит {loss:.4f}", "WARN") + + state.log( + f"{prefix} [Слот {slot.index}]: продано {qty_sell} {base}, получено approx {received:.4f} USDT. " + f"изменение approx {loss:.4f} USDT", "WARN" + ) + except Exception as e: - state.log(f"{prefix} ОШИБКА рыночной продажи: {e}", "ERR") + state.log(f"{prefix} [Слот {slot.index}]: ОШИБКА рыночной продажи: {e}", "ERR") + slot.clear() +def fetch_baseline_closes(exchange: ccxt.binance, occupied_symbols: set[str], state: BotState) -> list[list[float]]: + """Подтягивает свежие 1m closes по уже ОТКРЫТЫМ позициям - нужно как база для расчета корреляции + новых кандидатов с тем, что уже куплено в других слотах (чтобы не набрать портфель из коррелирующих монет).""" + baseline = [] + for sym in occupied_symbols: + try: + ohlcv = safe_call(exchange.fetch_ohlcv, sym, "1m", limit=CORRELATION_LOOKBACK, state=state, label=f"baseline {sym}") + closes = [k[4] for k in ohlcv] + if closes: + baseline.append(closes) + except Exception as e: + state.log(f"Диверсификация: не удалось получить свечи {sym} для расчета корреляции: {e}", "WARN") + return baseline + + def refill_empty_slots(exchange: ccxt.binance, allowed_symbols: list[str], empty_slots: list[Slot], state: BotState): + """Выполняет один общий пакетный скрин рынка для всех пустых слотов сразу, + с отбором кандидатов по принципу слабой корреляции (диверсификация портфеля слотов).""" now = time.time() + + # Фильтруем слоты, готовые к наполнению slots_to_refill = [s for s in empty_slots if now - s.last_refill_attempt >= SLOT_REFILL_DEBOUNCE_SEC] - if not slots_to_refill: return + if not slots_to_refill: + return for slot in slots_to_refill: slot.last_refill_attempt = now @@ -601,176 +1092,250 @@ def refill_empty_slots(exchange: ccxt.binance, allowed_symbols: list[str], empty occupied = state.occupied_symbols() need_n = len(slots_to_refill) - scored = screen_full_market(exchange, allowed_symbols, occupied, state, need_n) + + # Полный ранжированный список кандидатов (score desc), без обрезки + scored = screen_full_market(exchange, allowed_symbols, occupied, state, need_n=need_n) if not scored: - for slot in slots_to_refill: slot.status = "🔍 Поиск" + for slot in slots_to_refill: + slot.status = "🔍 Поиск" + state.log(f"Нет подходящих кандидатов в этом цикле для {need_n} пустых слотов", "INFO") return + # Слабокоррелируемый отбор: сравниваем доходности кандидатов с уже открытыми позициями + друг с другом baseline_closes = fetch_baseline_closes(exchange, occupied, state) if occupied else [] - candidates = select_diversified_candidates(scored, need_n, baseline_closes, state.max_correlation, state) + candidates = select_diversified_candidates( + scored, need_n, baseline_closes, state.max_correlation, state + ) + + names = ", ".join(f"{x['symbol']}(score={x['score']:.1f}, rsi={x['rsi']:.1f})" for x in candidates) + state.log(f"Скрин: выбрано {len(candidates)} кандидатов (диверсификация ≤{state.max_correlation:.2f}) -> {names}", "OK") + # Заполняем слоты найденными кандидатами (мутации слота - под локом, слот живет независимо от других) for slot, best in zip(slots_to_refill, candidates): with state.slot_lock: - # --- ГРАДАЦИЯ --- - budget = state.budget_per_slot() - if state.use_gradation: - is_major_dip = best.get("volume", 0) > GRADATION_MIN_VOLUME and best.get("drawdown", 0) > GRADATION_DEEP_DIP - if is_major_dip: - budget *= GRADATION_MULTIPLIER - state.log(f"🐋 Градация: {best['symbol']} на дне! Выделяем двойной бюджет (${budget:.2f})", "INFO") - slot.status = f"🟡 Покупка {best['symbol']}" - ok_buy = execute_buy(exchange, best["symbol"], budget, slot, state) + ok_buy = execute_buy(exchange, best["symbol"], state.budget_per_slot(), slot, state) if ok_buy: ok_sell = place_limit_sell(exchange, slot, state) - if not ok_sell: panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") + if not ok_sell: + state.log(f"[Слот {slot.index}] LimitSell не удался -> экстренная продажа слота", "ERR") + panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") else: slot.clear() + +# ───────────────────────────────────────────── +# ГЛАВНЫЙ ЦИКЛ ПОЛЛИНГА +# ───────────────────────────────────────────── def process_occupied_slot(exchange: ccxt.binance, slot: Slot, state: BotState): - if slot.is_empty(): return + """Обрабатывает один занятый слот: проверка тайм-аута/ротации/статуса ордера. + Вызывается под state.slot_lock, чтобы не пересекаться с UI-потоком (Panic Sell). + Каждый слот обрабатывается и живет полностью независимо от остальных.""" + if slot.is_empty(): + return if slot.order_placed_at: elapsed = (datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() + + # 1. Жесткий тайм-аут выхода по рынку (если включен) if state.use_timeout and elapsed > state.order_timeout_min * 60: + state.log(f"[Слот {slot.index}] Жесткий тайм-аут {state.order_timeout_min} мин — авто-выход", "WARN") panic_sell_slot(exchange, slot, state, prefix="⏰ TIMEOUT") return + + # 2. Ротация по плюсовой прибыли с учетом комиссий по истечении заданного времени (например, 2 часа) if state.use_profit_exit and elapsed > state.profit_exit_timeout_min * 60: try: ticker = exchange.fetch_ticker(slot.symbol) current_bid = ticker.get("bid") or ticker.get("last") or 0 + if current_bid > 0: fee = state.fee_rate() + # Расчет чистой прибыли при рыночной продаже по цене current_bid if state.use_bnb_discount: + # Оплата в BNB (0.075% на входе и выходе) net_profit = (slot.bought_qty * current_bid) - slot.usdt_spent - (slot.usdt_spent * fee) - (slot.bought_qty * current_bid * fee) else: + # Стандартная комиссия урезает количество на входе, а на выходе берется из USDT net_profit = (slot.bought_qty * current_bid * (1 - fee)) - slot.usdt_spent + if net_profit > 0.0: + state.log( + f"[Слот {slot.index}] Ротация: удержание {slot.symbol} превысило {state.profit_exit_timeout_min} мин. " + f"Рыночная цена {current_bid:.6g} дает чистый профит +{net_profit:.4f} USDT с учетом комиссий. " + f"Закрываем позицию для высвобождения слота!", "OK" + ) panic_sell_slot(exchange, slot, state, prefix="⏰ ROTATE") return - except Exception: pass + except Exception as e: + state.log(f"[Слот {slot.index}] Ошибка при расчете плюсовой ротации: {e}", "WARN") try: if state.paper_trading: ticker = exchange.fetch_ticker(slot.symbol) current_bid = ticker.get("bid") or ticker.get("last") or 0 + if current_bid >= slot.sell_price_set: received = slot.bought_qty * slot.sell_price_set profit = received - slot.usdt_spent state.total_profit += profit state.trades_count += 1 - state.log(f"📝 [PAPER] СДЕЛКА ЗАКРЫТА: {slot.symbol}, профит {profit:.4f}", "DEAL") + state.log( + f"📝 [PAPER][Слот {slot.index}] ВИРТУАЛЬНАЯ СДЕЛКА ЗАКРЫТА: {slot.symbol}, " + f"реальная цена рынка {current_bid:.6g} достигла цели {slot.sell_price_set:.6g}, " + f"прибыль approx {profit:.4f} USDT 🎉", "DEAL" + ) state.record_paper_trade({ "symbol": slot.symbol, "action": "sell", "slot": slot.index, - "price": slot.sell_price_set, "qty": slot.bought_qty, "usdt_value": received, "profit": profit, + "price": slot.sell_price_set, "qty": slot.bought_qty, + "usdt_value": received, "profit": profit, }) slot.clear() else: - elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60) if slot.order_placed_at else 0 - slot.status = f"⏳📝 -> {slot.sell_price_set:.6g} ({elapsed_min}м)" + elapsed_min = 0 + if slot.order_placed_at: + elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60) + + timeout_parts = [] + if state.use_timeout: + timeout_parts.append(f"timeout {state.order_timeout_min}м") + if state.use_profit_exit: + timeout_parts.append(f"плюс-ротация > {state.profit_exit_timeout_min}м") + timeout_str = " (" + " | ".join(timeout_parts) + ")" if timeout_parts else " (безлимит)" + + slot.status = f"⏳📝 {current_bid:.6g} -> {slot.sell_price_set:.6g} ({elapsed_min}м{timeout_str})" return order = exchange.fetch_order(slot.order_id, slot.symbol) status = order.get("status", "") + if status == "closed": received = float(order.get("cost") or 0) profit = received - slot.usdt_spent state.total_profit += profit state.trades_count += 1 - state.log(f"[Слот {slot.index}] СДЕЛКА ЗАКРЫТА: {slot.symbol}, профит {profit:.4f} 🎉", "DEAL") + state.log( + f"[Слот {slot.index}] СДЕЛКА ЗАКРЫТА: {slot.symbol}, " + f"куплено={slot.usdt_spent:.4f}, продано={received:.4f}, " + f"прибыль approx {profit:.4f} USDT 🎉", "DEAL" + ) slot.clear() + elif status in ("canceled", "expired"): + state.log(f"[Слот {slot.index}] Ордер отменен/просрочен извне — выход по рынку", "WARN") panic_sell_slot(exchange, slot, state) + else: - elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60) if slot.order_placed_at else 0 - slot.status = f"⏳ -> {slot.sell_price_set:.6g} ({elapsed_min}м)" - except Exception: pass + elapsed_min = 0 + if slot.order_placed_at: + elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60) + + timeout_parts = [] + if state.use_timeout: + timeout_parts.append(f"timeout {state.order_timeout_min}м") + if state.use_profit_exit: + timeout_parts.append(f"плюс-ротация > {state.profit_exit_timeout_min}м") + timeout_str = " (" + " | ".join(timeout_parts) + ")" if timeout_parts else " (безлимит)" + + slot.status = f"⏳ -> {slot.sell_price_set:.6g} ({elapsed_min}м{timeout_str})" + + except Exception as e: + state.log(f"[Слот {slot.index}] Ошибка проверки ордера: {e}", "WARN") def trading_loop(state: BotState): + state.log(f"Торговый цикл запущен — портфель из {state.num_slots} слотов, бюджет ${state.total_budget}", "INFO") if state.paper_trading: - exchange = build_exchange("", "", paper_trading=True) - else: - exchange = build_exchange(state.api_key, state.api_secret, paper_trading=False, proxy_url=state.proxy_url) - + state.log( + "📝 PAPER TRADING: РЕАЛЬНЫЕ цены с ��оевого Binance, ВИРТУАЛЬНЫЕ деньги. Реальные ордера создаваться не будут.", "WARN" + ) + try: + if state.paper_trading: + exchange = build_exchange("", "", paper_trading=True) + else: + exchange = build_exchange(state.api_key, state.api_secret, paper_trading=False, proxy_url=state.proxy_url) exchange.load_markets() + state.log("Биржа подключена", "OK") + allowed_symbols = get_allowed_symbols(exchange) state.allowed_symbols_count = len(allowed_symbols) - last_allowed_symbols_update = time.time() - + state.log(f"Разрещенных ликвидных пар (minNotional <= {MIN_NOTIONAL_FILTER}): {len(allowed_symbols)}", "INFO") + if not allowed_symbols: + state.log("КРИТИЧНО: нет пар с minNotional <= 1.0 USDT — проверьте API права", "ERR") state.is_running = False + state.status = "🔴 Ошибка: нет пар" return - + per_slot = state.budget_per_slot() if per_slot * 1.001 < 1.0: + state.log( + f"КРИТИЧНО: бюджет на слот ${per_slot:.2f} слишком мал (нужно от $1.05).", "ERR" + ) state.is_running = False + state.status = "🔴 Ошибка: мало бюджета на слот" return + except Exception as e: + state.log(f"Ошибка подключения к бирже: {e}", "ERR") state.is_running = False + state.status = "🔴 Ошибка подключения" return state.last_full_screen_at = 0.0 while state.is_running: try: - # Обновление allowed_symbols раз в 2 часа - if time.time() - last_allowed_symbols_update > 7200: - try: - state.log("🔄 Периодическое обновление списка allowed_symbols...", "INFO") - allowed_symbols = get_allowed_symbols(exchange) - state.allowed_symbols_count = len(allowed_symbols) - last_allowed_symbols_update = time.time() - except Exception as e: - state.log(f"Ошибка обновления allowed_symbols: {e}", "WARN") - + # Авто-масштабирование слотов и авто-оплата VPS - работают только в Live, + # сами себя троттлят по времени внутри (раз в час / раз в 30 дней) + maybe_autoscale_slots(exchange, state) + maybe_autopay_vps(exchange, state) + + if state.strategy_engine == "grading": + # ── Режим "Градация": несколько крупных высоколиквидных монет, лесенка докупок на просадке ── + check_grading_opportunities(exchange, state) + process_grading_exits(exchange, state) + filled_total = sum(len(p["filled"]) for p in state.grading_positions.values()) + state.status = f"📐 Градация: {len(state.grading_symbols)} монет, {filled_total} грейдов открыто" + time.sleep(5) + continue + + # ── Режим "Портфель микро-слотов" (по умолчанию) ── for slot in state.slots: with state.slot_lock: process_occupied_slot(exchange, slot, state) + # Пакетное наполнение пустых слотов за один общий проход скринера empty_slots = [s for s in state.slots if s.is_empty()] + if empty_slots: now = time.time() - should_screen = (now - state.last_full_screen_at >= SCREENER_INTERVAL_SEC) or any(s.last_refill_attempt == 0.0 for s in empty_slots) + should_screen = (now - state.last_full_screen_at >= SCREENER_INTERVAL_SEC) or any( + s.last_refill_attempt == 0.0 for s in empty_slots + ) if should_screen: state.last_full_screen_at = now refill_empty_slots(exchange, allowed_symbols, empty_slots, state) - # --- АВТО-МАСШТАБИРОВАНИЕ СЛОТОВ --- - if state.auto_scale_slots and state.base_slot_budget > 0: - current_capital = state.total_budget + state.total_profit - target_slots = int(current_capital // state.base_slot_budget) - target_slots = max(1, min(MAX_SLOTS, target_slots)) - if target_slots > state.num_slots: - state.log(f"📈 Авто-масштабирование: капитал вырос до ${current_capital:.2f}. Расширяем портфель: {state.num_slots} -> {target_slots} слотов!", "OK") - state.resize_slots(target_slots) - - # --- АВТО-ОПЛАТА VPS С ПРИБЫЛИ --- - if state.vps_auto_pay and not state.paper_trading and state.vps_wallet: - now_utc = datetime.now(timezone.utc) - if now_utc.day == state.vps_pay_day and state.last_vps_pay_month != now_utc.month: - if state.total_profit >= state.vps_cost: - try: - state.log(f"💸 Авто-оплата VPS: Вывод {state.vps_cost} USDT на {state.vps_wallet}...", "INFO") - exchange.withdraw('USDT', state.vps_cost, state.vps_wallet, params={'network': 'TRX'}) - state.total_profit -= state.vps_cost - state.last_vps_pay_month = now_utc.month - state.log("✅ Оплата VPS успешно отправлена!", "OK") - except Exception as e: - state.log(f"❌ Ошибка вывода на VPS (проверьте права API): {e}", "ERR") - state.last_vps_pay_month = now_utc.month - occupied_n = sum(1 for s in state.slots if not s.is_empty()) - if occupied_n == state.num_slots: state.status = f"🟢 Портфель полон ({occupied_n}/{state.num_slots})" - elif occupied_n == 0: state.status = f"🔍 Поиск ({occupied_n}/{state.num_slots})" - else: state.status = f"🟡 Частично занят ({occupied_n}/{state.num_slots})" + if occupied_n == state.num_slots: + state.status = f"🟢 Портфель полон ({occupied_n}/{state.num_slots})" + elif occupied_n == 0: + wait_left = max(0, int(SCREENER_INTERVAL_SEC - (time.time() - state.last_full_screen_at))) + state.status = f"🔍 Поиск ({occupied_n}/{state.num_slots}) · след. скрин ~{wait_left}с" + else: + state.status = f"🟡 Частично занят ({occupied_n}/{state.num_slots})" except Exception as e: state.log(f"Ошибка в главном цикле: {e}", "ERR") + time.sleep(5) + state.log("Торговый цикл остановлен", "INFO") + state.status = "⏹ Остановлен" + # ───────────────────────────────────────────── # STREAMLIT ИНТЕРФЕЙС @@ -850,57 +1415,217 @@ def render_ui(): options=["📝 Paper trading (симулятор)", "🔴 Live (реальные деньги)"], index=0 if state.paper_trading else 1, disabled=state.is_running, - help="Paper trading: реальные цены с боевого Binance, виртуальные деньги. Live: настоящие сделки." + help="Paper trading: реальные цены с боевого Binance, виртуальные деньги, ключи НЕ нужны вообще. Live: настоящие сделки, настоящие деньги." ) paper_trading = trading_mode.startswith("📝") if paper_trading: - st.success("📝 Paper trading активен. Бот будет смотреть РЕАЛЬНЫЕ цены живого рынка и вести виртуальный учёт сделок. Ключи НЕ нужны.", icon="📝") + st.success( + "📝 Paper trading активен. Бот будет смотреть РЕАЛЬНЫЕ цены живого рынка и вести виртуальный учёт сделок — ни один реальный ордер создан не будет. API-ключи не требуются для этого режима.", + icon="📝", + ) else: - st.error("🔴 LIVE режим. Бот будет создавать РЕАЛЬНЫЕ ордера на твои деньги.", icon="🔴") + st.error( + "🔴 LIVE режим. Бот будет создавать РЕАЛЬНЫЕ ордера на твои РЕАЛЬНЫЕ деньги. Используй только после полноценной проверки в paper trading.", + icon="🔴", + ) - with st.expander("🔑 API Ключи & Прокси", expanded=not state.is_running and not paper_trading): + with st.expander("🔑 API Ключи", expanded=not state.is_running and not paper_trading): if paper_trading: - st.caption("Paper trading не требует ключей.") - api_key, api_secret, proxy_url = "", "", "" + st.caption("Paper trading не требует ключей — бот использует только публичные данные рынка (data-api.binance.vision, не блокируется гео-фильтром).") + api_key = "" + api_secret = "" + proxy_url = state.proxy_url else: - api_key = st.text_input("API Key", value=state.api_key, type="password") - api_secret = st.text_input("API Secret", value=state.api_secret, type="password") - proxy_url = st.text_input("Прокси (для Live HF Spaces)", value=state.proxy_url, placeholder="http://user:pass@host:port") - - st.markdown("**Новые фичи из аудита (Масштабирование и Защита)**") - col_f1, col_f2 = st.columns(2) - with col_f1: - check_orderbook = st.checkbox("Проверять стакан", value=state.check_orderbook, help="Защита от проскальзывания при Market Buy") - max_slippage_pct = st.slider("Макс. проскальзывание (%)", 0.1, 2.0, value=state.max_slippage_pct, step=0.1, disabled=not check_orderbook) - use_gradation = st.checkbox("Градация (мажоры на дне = х2 бюджет)", value=state.use_gradation) - with col_f2: - auto_scale_slots = st.checkbox("Авто-масштабирование слотов", value=state.auto_scale_slots, help="Добавлять слоты при росте депозита") - vps_auto_pay = st.checkbox("Авто-оплата VPS (USDT)", value=state.vps_auto_pay, help="Раз в месяц выводит профит на кошелек") - vps_wallet = st.text_input("TRC20 Адрес", value=state.vps_wallet, disabled=not vps_auto_pay) + api_key = st.text_input("API Key", value=state.api_key, type="password", key="inp_key") + api_secret = st.text_input("API Secret", value=state.api_secret, type="password", key="inp_secret") + st.divider() + proxy_url = st.text_input( + "🌐 Прокси для обхода гео-блокировки (опционально)", + value=state.proxy_url, type="password", key="inp_proxy", + placeholder="http://user:pass@host:port", + help=( + "Приватные эндпоинты Binance (ордера, баланс) блокируют запросы с IP из США - " + "именно там обычно физически находятся бесплатные HF Spaces. Укажи здесь прокси/VPS/туннель, " + "геолоцированный в разрешенной Binance стране (в идеале - совпадающей с твоим KYC, " + "например Казахстан), чтобы избежать и блокировки, и compliance-флагов на аккаунте. " + "Оставь пустым, если бот уже работает из разрешенного региона." + ), + ) + if proxy_url: + st.caption("🌐 Прокси будет применен ко всем запросам к бирже в Live режиме.") st.markdown("**Параметры торговли**") - total_budget = st.number_input("Общий бюджет портфеля (USDT)", min_value=3.0, max_value=1000.0, value=state.total_budget, step=1.0) - num_slots = st.slider("Количество слотов", min_value=1, max_value=MAX_SLOTS, value=state.num_slots, disabled=state.is_running) - strategy_mode = st.selectbox("Режим скальпинга", ["🛡️ Консервативный (Особо точный)", "⚖️ Умеренный (Сбалансированный)", "🔥 Агрессивный (Частые сделки)"], index=1 if "Умеренный" in state.strategy_mode else (0 if "Консервативный" in state.strategy_mode else 2)) - use_dynamic_tp = st.checkbox("Использовать динамический Тейк-Профит", value=state.use_dynamic_tp) - max_correlation = st.slider("Порог диверсификации: макс. корреляция", 0.10, 1.00, value=state.max_correlation, step=0.05) - target_profit = st.number_input("Базовая прибыль на ПОЗИЦИЮ (USDT)", min_value=0.01, max_value=2.0, value=state.target_profit, step=0.01, disabled=use_dynamic_tp) - use_bnb = st.checkbox("У меня включена скидка 'Use BNB' на Binance", value=state.use_bnb_discount) - use_timeout = st.checkbox("Включить принудительный выход по тайм-ауту", value=state.use_timeout) - order_timeout_min = st.number_input("Тайм-аут удержания (минут)", min_value=10, max_value=14400, value=state.order_timeout_min, step=10, disabled=not use_timeout) - use_profit_exit = st.checkbox("Включить ротацию по профиту через время", value=state.use_profit_exit) - profit_exit_timeout_min = st.number_input("Тайм-аут плюсовой ротации (минут)", min_value=10, max_value=14400, value=state.profit_exit_timeout_min, step=10, disabled=not use_profit_exit) + total_budget = st.number_input( + "Общий бюджет портфеля (USDT)", + min_value=3.0, max_value=1000.0, value=state.total_budget, step=1.0, + help="Делится поровну между всеми слотами." + ) + num_slots = st.slider( + "Количество параллельных слотов", + min_value=1, max_value=MAX_SLOTS, value=state.num_slots, + help=f"Сколько монет держим одновременно. Каждой нужно >= $1.05 при выбранном бюджете.", + disabled=state.is_running, + ) + strategy_mode = st.selectbox( + "Режим скальпинга (Адаптивные фильтры)", + options=["🛡️ Консервативный (Особо точный)", "⚖️ Умеренный (Сбалансированный)", "🔥 Агрессивный (Частые сделки)"], + index=1 if "Умеренный" in state.strategy_mode else (0 if "Консервативный" in state.strategy_mode else 2), + help="🛡️ Консервативный: более жесткие фильтры входа, покупает только на глубоких просадках при сильной перепроданности. 🔥 Агрессивный: более мягкие фильтры, совершает входы гораздо чаще." + ) + use_dynamic_tp = st.checkbox( + "Использовать динамический Тейк-Профит", + value=state.use_dynamic_tp, + help="Если включено, бот будет автоматически рассчитывать цель прибыли под каждую монету на основе её суточной волатильности (для более высокой прибыли на волатильных парах)." + ) + max_correlation = st.slider( + "Порог диверсификации: макс. корреляция доходностей между слотами", + min_value=0.10, max_value=1.00, value=state.max_correlation, step=0.05, + help=( + "Бот сравнивает недавние доходности (1м) кандидата с уже открытыми позициями и с другими " + "отбираемыми кандидатами. Если корреляция выше этого порога - монета пропускается в пользу " + "менее коррелируемой (если таких не осталось - слот все равно заполняется лучшим по score, " + "чтобы не простаивать). Меньше значение = строже диверсификация, слоты более независимы друг от друга." + ), + ) + use_depth_check = st.checkbox( + "Проверять глубину стакана перед покупкой (защита от проскальзывания)", + value=state.use_depth_check, + help=( + "Перед market buy бот считает средневзвешенную цену исполнения по реальному стакану (asks) " + "и сравнивает с лучшим ask. Если расхождение больше порога ниже - покупка отменяется " + "(значит стакан слишком тонкий для нашего объема на этой монете)." + ), + ) + max_slippage_pct = st.slider( + "Макс. допустимое проскальзывание при покупке (%)", + min_value=0.05, max_value=3.0, value=state.max_slippage_pct, step=0.05, + disabled=not use_depth_check, + help="Если средняя цена исполнения нашего объема хуже лучшего ask больше чем на этот %, покупка отменяется.", + ) + + st.divider() + st.markdown("**⚙️ Движок стратегии**") + strategy_engine = st.radio( + "Выбери режим торговли (работает только один из двух одновременно)", + options=["portfolio", "grading"], + index=0 if state.strategy_engine == "portfolio" else 1, + format_func=lambda x: "🔹 Портфель микро-слотов (��екущий)" if x == "portfolio" else "📐 Градация крупных монет (лесенка на просадке)", + help=( + "Портфель микро-слотов - то, что уже работает: N независимых слотов по $4-20, ротация, диверсификация.\n\n" + "Градация - альтернативный режим: несколько КРУПНЫХ высоколиквидных монет (BTC/ETH/BNB/...), " + "докупка лесенкой по мере просадки от суточного хая (глубже просадка -> крупнее объем докупки), " + "выход всей накопленной позицией разом при восстановлении цены к хаю." + ), + ) + + grading_symbols = state.grading_symbols + grading_budget = state.grading_budget + if strategy_engine == "grading": + grading_symbols = st.multiselect( + "Монеты для градации (высоколиквидные)", + options=["BTC/USDT", "ETH/USDT", "BNB/USDT", "SOL/USDT", "XRP/USDT", "DOGE/USDT", "ADA/USDT", "TON/USDT"], + default=state.grading_symbols, + ) + grading_budget = st.number_input( + "Бюджет на режим градации (USDT, отдельно от портфеля слотов)", + min_value=0.0, value=max(state.grading_budget, 20.0), step=10.0, + ) + st.caption( + f"Уровни просадки для докупки: {', '.join(f'{l}%' for l in GRADING_DROP_LEVELS_PCT)} от суточного хая. " + f"Выход - при восстановлении в пределах {GRADING_RECOVERY_EXIT_PCT}% от хая." + ) + + st.divider() + st.markdown("**📈 Авто-масштабирование слотов по депозиту** (только Live)") + auto_scale_slots = st.checkbox( + "Включить авто-масштабирование", + value=state.auto_scale_slots, + disabled=paper_trading, + help=( + "Раз в час проверяет реальный баланс USDT на бирже и, если депозит дорос до следующего порога, " + "автоматически добавляет слоты (никогда не уменьшает - открытые позиции не трогает). " + "Пороги: " + ", ".join(f"${t}→{n} слотов" for t, n in SLOT_SCALING_TABLE) + ), + ) + + target_profit = st.number_input( + "Базовая чистая прибыль на ПОЗИЦИЮ (USDT)", + min_value=0.01, max_value=2.0, value=state.target_profit, step=0.01, + disabled=use_dynamic_tp, + help="Используется только если выключен динамический Тейк-Профит." + ) + use_bnb = st.checkbox( + "У меня включена скидка 'Use BNB for fees' на Binance", + value=state.use_bnb_discount, + help="Снижает комиссию с 0.1% до 0.075%. ВАЖНО: должно совпадать с реальной настройкой в Binance, иначе цена продажи будет рассчитана неточно и часть прибыли уходит в погрешность." + ) + use_timeout = st.checkbox( + "Включить принудительный выход по жесткому тайм-ауту", + value=state.use_timeout, + help="Если выключено, бот будет держать купленную монету, пока она не продастся по лимиту или плюсовой ротации." + ) + order_timeout_min = st.number_input( + "Тайм-аут жесткого удержания позиции (минут)", + min_value=10, max_value=14400, value=state.order_timeout_min, step=10, + disabled=not use_timeout, + help="Применяется только при включенном жестком фланге выше." + ) + use_profit_exit = st.checkbox( + "Включить ротацию по профиту через время", + value=state.use_profit_exit, + help="Если включено, по истечении тайм-аута удержания бот закроет позицию по рынку, только если текущая чистая прибыль с учетом всех комиссий положительна (> 0 USDT)." + ) + profit_exit_timeout_min = st.number_input( + "Тайм-аут до проверки плюсовой ротации (минут)", + min_value=10, max_value=14400, value=state.profit_exit_timeout_min, step=10, + disabled=not use_profit_exit, + help="Минимальное время удержания позиции (например, 120 минут = 2 часа) перед началом регулярных проверок на выход в плюс." + ) active_fee = FEE_RATE_BNB_DISCOUNT if use_bnb else FEE_RATE_STANDARD + per_slot_preview = total_budget / num_slots if num_slots else 0 if per_slot_preview < 1.05: - st.error(f"⚠️ Бюджет на слот ${per_slot_preview:.2f} < $1.05 минимума! Увеличь бюджет.") + st.error(f"⚠️ Бюджет на слот ${per_slot_preview:.2f} < $1.05 минимума! Увеличь бюджет или уменьши слоты.") else: growth = (target_profit + per_slot_preview * active_fee * 2) / per_slot_preview * 100 - st.info(f"📐 На слот: **${per_slot_preview:.2f}** | Комиссия: **{'BNB' if use_bnb else 'Стандарт'}**") + fee_label = "0.075% (BNB)" if use_bnb else "0.1% (стандарт)" + st.info(f"📐 На слот: **${per_slot_preview:.2f}** | Требуемый рост: **{growth:.2f}%** | Комиссия: **{fee_label}**") + + st.divider() + st.markdown("**💸 Авто-оплата VPS с биржевого баланса**") + st.error( + "🔴 ОПАСНО: это РЕАЛЬНЫЙ автоматический вывод настоящих денег с биржи на внешний адрес, " + "без дополнительного подтверждения в момент каждого перевода. Включай, только если полностью " + "понимаешь риски. Платит СТРОГО из прибыли (никогда не залезает в исходный депозит) и не чаще " + "раза в 30 дней. Адрес обязательно должен быть заранее добавлен в whitelist адресов на Binance." + ) + vps_payout_address = st.text_input( + "Адрес для оплаты VPS (USDT), должен быть в whitelist Binance", + value=state.vps_payout_address, type="password", key="inp_vps_addr", + placeholder="Txxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ) + col_vps1, col_vps2 = st.columns(2) + with col_vps1: + vps_payout_network = st.selectbox( + "Сеть перевода", ["TRC20", "BEP20", "ERC20"], + index=["TRC20", "BEP20", "ERC20"].index(state.vps_payout_network) if state.vps_payout_network in ["TRC20","BEP20","ERC20"] else 0, + help="TRC20 (TRON) обычно самый дешевый по комиссии для USDT.", + ) + with col_vps2: + vps_monthly_cost = st.number_input( + "Стоимость VPS в месяц (USDT)", min_value=1.0, value=state.vps_monthly_cost, step=1.0, + ) + vps_confirm_text = st.text_input( + "Введи ПОДТВЕРЖДАЮ АВТО-ВЫВОД, чтобы разрешить включение (доп. защита от случайного клика)", + value="", key="inp_vps_confirm", + ) + auto_pay_vps_checkbox = st.checkbox("Включить авто-оплату VPS", value=False, key="inp_vps_enable") + auto_pay_vps_enabled = auto_pay_vps_checkbox and (vps_confirm_text.strip() == "ПОДТВЕРЖДАЮ АВТО-ВЫВОД") and bool(vps_payout_address) + if auto_pay_vps_checkbox and not auto_pay_vps_enabled: + st.warning("Не активировано: нужно и ввести подтверждающую фразу точь-в-точь, и указать адрес.") - st.markdown("**Управление ботом**") if not state.is_running: if st.button("▶️ Запустить портфель", use_container_width=True, type="primary"): @@ -912,7 +1637,6 @@ def render_ui(): state.paper_trading = paper_trading state.api_key = api_key state.api_secret = api_secret - state.proxy_url = proxy_url state.total_budget = total_budget state.target_profit = target_profit state.use_bnb_discount = use_bnb @@ -923,15 +1647,17 @@ def render_ui(): state.strategy_mode = strategy_mode state.use_dynamic_tp = use_dynamic_tp state.max_correlation = max_correlation - - state.check_orderbook = check_orderbook + state.proxy_url = proxy_url + state.use_depth_check = use_depth_check state.max_slippage_pct = max_slippage_pct + state.strategy_engine = strategy_engine + state.grading_symbols = grading_symbols + state.grading_budget = grading_budget state.auto_scale_slots = auto_scale_slots - state.vps_auto_pay = vps_auto_pay - state.vps_wallet = vps_wallet - state.use_gradation = use_gradation - - state.base_slot_budget = per_slot_preview + state.auto_pay_vps_enabled = auto_pay_vps_enabled + state.vps_payout_address = vps_payout_address + state.vps_payout_network = vps_payout_network + state.vps_monthly_cost = vps_monthly_cost state.resize_slots(num_slots) state.is_running = True state.logs = [] @@ -945,9 +1671,20 @@ def render_ui(): else: if st.button("⏹ Остановить портфель", use_container_width=True, type="secondary"): state.is_running = False - st.warning("Остановка. Открытые позиции НЕ закрываются — используй Panic при необходимости.") + st.warning("Сигнал остановки отправлен. Открытые позиции НЕ закрываются автоматически — используй Panic для каждого слота при необходимости.") st.rerun() + st.divider() + st.markdown("**📐 Формула цены продажи (на слот)**") + st.code( + "sell_price = (usdt_spent + target_profit) / bought_qty [При оплате в BNB]\n" + "или\n" + "sell_price = (usdt_spent + target_profit) / (bought_qty * (1 - fee_rate)) [Стандарт]\n\n" + f"budget_per_slot = total_budget / num_slots\n" + f"fee_rate = {active_fee} ({fee_label})", + language="python" + ) + st.divider() st.markdown("**📡 Снимок последнего скрина рынка**") if state.market_snapshot: @@ -983,7 +1720,7 @@ def render_ui(): if state.use_timeout: timeout_parts.append(f"timeout {state.order_timeout_min}м") if state.use_profit_exit: - timeout_parts.append(f"ротация > {state.profit_exit_timeout_min}м") + timeout_parts.append(f"плюс-ротация > {state.profit_exit_timeout_min}м") timeout_str = " (" + " | ".join(timeout_parts) + ")" if timeout_parts else " (безлимит)" st.markdown(f""" @@ -998,15 +1735,20 @@ def render_ui(): if state.is_running: if st.button(f"🚨 Panic Sell слот {slot.index}", key=f"panic_{slot.index}"): try: - exc = build_exchange(state.api_key, state.api_secret, paper_trading=state.paper_trading, proxy_url=state.proxy_url) + if state.paper_trading: + exchange = build_exchange("", "", paper_trading=True) + else: + exchange = build_exchange(state.api_key, state.api_secret, paper_trading=False, proxy_url=state.proxy_url) + # Лочим тот же slot_lock, что и фоновый trading_loop - + # исключает гонку потоков, если бот в этот момент как раз обрабатывает этот слот with state.slot_lock: - panic_sell_slot(exc, slot, state, prefix="🚨 PANIC") + panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") st.warning(f"Слот {slot.index} closed принудительно!") st.rerun() except Exception as e: st.error(f"Ошибка Panic Sell: {e}") - # ── ТАБЛИЦА СДЕЛОК (ВОССТАНОВЛЕНО) ── + # ── Таблица виртуальных сделок ── st.divider() st.subheader("📝 Журнал сделок (Paper Trading)") if state.paper_trades: @@ -1025,7 +1767,7 @@ def render_ui(): else: st.caption("Сделок еще не зафиксировано.") - # ── ГРАФИК СДЕЛОК С ОТМЕТКАМИ (ВОССТАНОВЛЕНО) ── + # ── График сделок (Candlestick + отметки входов/выходов) ── st.divider() st.subheader("📈 График сделок") @@ -1042,7 +1784,11 @@ def render_ui(): if build_chart: try: - chart_exchange = build_exchange(state.api_key, state.api_secret, paper_trading=state.paper_trading, proxy_url=state.proxy_url) + if state.paper_trading: + chart_exchange = build_exchange("", "", paper_trading=True) + else: + chart_exchange = build_exchange(state.api_key, state.api_secret, paper_trading=False, proxy_url=state.proxy_url) + ohlcv = chart_exchange.fetch_ohlcv(chart_symbol, timeframe="1m", limit=150) if not ohlcv: @@ -1062,6 +1808,7 @@ def render_ui(): decreasing_line_color="#ef5350", )]) + # Фильтруем сделки бота ровно по выбранному символу symbol_trades = [t for t in state.paper_trades if t["symbol"] == chart_symbol] buy_x, buy_y, buy_txt = [], [], [] @@ -1070,12 +1817,19 @@ def render_ui(): for t in symbol_trades: raw_time = t.get("time", "") try: - t_dt = pd.to_datetime(raw_time.replace(" UTC", ""), format="%Y-%m-%d %H:%M:%S", utc=True) - except Exception: continue + # Формат "YYYY-MM-DD HH:MM:SS UTC" -> парсим, убрав суффикс UTC + t_dt = pd.to_datetime( + raw_time.replace(" UTC", ""), + format="%Y-%m-%d %H:%M:%S", + utc=True, + ) + except Exception: + continue action = (t.get("action") or "").upper() price = t.get("price") - if price is None: continue + if price is None: + continue if action == "BUY": buy_x.append(t_dt) @@ -1091,22 +1845,31 @@ def render_ui(): if buy_x: fig.add_trace(go.Scatter( x=buy_x, y=buy_y, mode="markers", name="BUY", - marker=dict(symbol="triangle-up", size=14, color="#00e676", line=dict(width=1, color="#00381f")), + marker=dict(symbol="triangle-up", size=14, color="#00e676", + line=dict(width=1, color="#00381f")), text=buy_txt, hoverinfo="text+x", )) if sell_x: fig.add_trace(go.Scatter( x=sell_x, y=sell_y, mode="markers", name="SELL / ROTATE / PANIC", - marker=dict(symbol="triangle-down", size=14, color="#ff1744", line=dict(width=1, color="#4a0000")), + marker=dict(symbol="triangle-down", size=14, color="#ff1744", + line=dict(width=1, color="#4a0000")), text=sell_txt, hoverinfo="text+x", )) fig.update_layout( - template="plotly_dark", title=f"{chart_symbol} — 1m свечи с отметками сделок бота", - xaxis_title="Время (UTC)", yaxis_title="Цена", xaxis_rangeslider_visible=False, height=550, - margin=dict(l=10, r=10, t=50, b=10), paper_bgcolor="#0d1117", plot_bgcolor="#0d1117", + template="plotly_dark", + title=f"{chart_symbol} — 1m свечи с отметками сделок бота", + xaxis_title="Время (UTC)", + yaxis_title="Цена", + xaxis_rangeslider_visible=False, + height=550, + margin=dict(l=10, r=10, t=50, b=10), + paper_bgcolor="#0d1117", + plot_bgcolor="#0d1117", legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), ) + st.plotly_chart(fig, use_container_width=True) except Exception as e: @@ -1119,7 +1882,7 @@ def render_ui(): log_lines = list(state.logs) if log_lines: - log_text = "
".join(reversed(log_lines)) + log_text = "\n".join(reversed(log_lines)) st.markdown(f'
{log_text}
', unsafe_allow_html=True) else: st.info("Лог пуст. Запусти бота для начала работы.") @@ -1137,5 +1900,6 @@ def render_ui(): """, unsafe_allow_html=True) + if __name__ == "__main__": render_ui() \ No newline at end of file