Spaces:
Running
Running
| import time | |
| import math | |
| import threading | |
| import logging | |
| import uuid | |
| import json | |
| import os | |
| from datetime import datetime, timezone | |
| from dataclasses import dataclass, asdict | |
| import ccxt | |
| import streamlit as st | |
| import pandas as pd | |
| import plotly.graph_objects as go | |
| # ───────────────────────────────────────────── | |
| # КОНСТАНТЫ СТРАТЕГИИ | |
| # ───────────────────────────────────────────── | |
| SCREENER_INTERVAL_SEC = 90 # Интервал полного сканирования рынка (90 секунд для более частых входов) | |
| SLOT_REFILL_DEBOUNCE_SEC = 8 # Пауза перед заполнением освободившегося слота | |
| MIN_NOTIONAL_FILTER = 1.0 # Фильтр минимального объема ордера (USDT) | |
| MIN_QUOTE_VOLUME = 200_000 # Минимальный суточный объем торгов (USDT). Было 25_000 (ослабляли специально | |
| # для расширения списка монет). Подняли 2026-07-25 - слишком широкий хвост | |
| # микрокапов оказался частью синхронной просадки сразу нескольких слотов | |
| # при общем откате рынка. 200k - компромисс: отсекает токсичный хвост, но | |
| # не откатывает расширение пула целиком, в отличие от жесткого топ-30/50 | |
| # (который резко просадил бы частоту сделок и противоречил бы поведенческой | |
| # мем-тир логике ниже - см. HIGH_VOLATILITY_MEME_THRESHOLD). | |
| 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 = 90 # Подняли с 55 до 90 (запрос от 2026-07-15): при среднем ~384 | |
| # кандидатах, проходящих объем/спред, батч=55 давал индикаторный | |
| # анализ лишь ~14% из них - это была основная причина редких сделок, | |
| # а НЕ MIN_QUOTE_VOLUME/MAX_SPREAD_RATIO (те отсеивают лишь ~15% от | |
| # всех разрешенных пар). Подняли умеренно, а не сразу до "всех": это | |
| # почти удвоит число запросов свечей за скрин, а авто-бэкофф ниже | |
| # существует именно потому, что даже 55 уже ощутимо давит на рейт- | |
| # лимиты Binance (см. комментарий у SCREENER_ERROR_WINDOW_SEC). | |
| MAX_CORRELATION_DEFAULT = 0.75 # Порог макс. корреляции доходностей между отбираемыми монетами (диверсификация слотов) | |
| CORRELATION_LOOKBACK = 30 # Кол-во 1м свечей для расчета корреляции доходностей | |
| EXCHANGE_RETRY_ATTEMPTS = 3 # Кол-во повторных попыток при сетевых сбоях биржи | |
| EXCHANGE_RETRY_DELAY_SEC = 1.5 # Базовая задержка между повторными попытками (экспоненциально растет) | |
| RSI_BOTTOM_LOOKBACK = 5 # Окно свечей для поиска локального дна RSI | |
| RSI_BOTTOM_CONFIRM_BARS = 2 # Кол-во ПОДРЯД неубывающих шагов RSI для подтверждения разворота. | |
| # Было 1 (current >= prev) - этого достаточно, чтобы RSI дернулся | |
| # вверх на одну шумную 1m свечу и тут же снова пошел вниз (ложный | |
| # разворот). 2 подряд неубывающих шага - более надежное подтверждение. | |
| # ── Фильтр старшего тренда (защита от "падающих ножей") ── | |
| # Разворотные сигналы в screen_full_market (дивергенция/RSI-дно/Боллинджер) считаются на | |
| # 1h/15m/1m - это "видит" только краткосрочный микро-отскок и ничего не знает про то, | |
| # находится ли монета уже несколько дней в устойчивом падении. Здесь мы дополнительно | |
| # смотрим на 4h SMA: если она снижается И цена уже далеко ниже нее - это не "дип для покупки", | |
| # а падающий нож в структурном даунтренде, и вход пропускается, даже если 1m/15m сигнал сработал. | |
| HTF_TREND_TIMEFRAME = "4h" # Старший таймфрейм для фильтра общего тренда | |
| HTF_TREND_CANDLE_LIMIT = 50 # Свечей 4h для анализа (50 x 4ч ≈ 8.3 дня истории) | |
| HTF_TREND_SMA_PERIOD = 20 # Период SMA на старшем ТФ (20 x 4ч ≈ 3.3 дня) | |
| HTF_TREND_SMA_SLOPE_LOOKBACK = 5 # На сколько свечей назад сравниваем SMA для определения наклона (растет/падает) | |
| HTF_TREND_MAX_BELOW_SMA_PCT = 5.5 # Макс. допустимое отклонение цены НИЖЕ SMA (%), если SMA при этом падает. | |
| # Было 8.0, снижено 2026-07-25 после случая одновременной просадки | |
| # нескольких слотов на общем откате рынка. Не опускать ниже ~5.0-5.5: | |
| # MTF ниже специально мягче HTF (порог 4.0) - если HTF станет строже | |
| # MTF, слоистая логика "HTF=общая картина, MTF=быстрая проверка" ломается. | |
| HTF_TREND_CACHE_TTL_SEC = 1800 # Кэш результата на 30 минут - 4h свеча меняется медленно, незачем | |
| # дергать 4h-эндпоинт на КАЖДОМ скрине (раз в ~90-270с) для той же монеты | |
| # ── Среднесрочный (1h) трендовый фильтр - ловит "свежие" многочасовые даунтренды ── | |
| # HTF-фильтр выше смотрит на 4h SMA(20) - это ~3.3 дня истории, и физически инертен: даунтренд, | |
| # начавшийся всего несколько часов назад, еще не успевает заметно сдвинуть такую медленную SMA, | |
| # поэтому HTF пропускает такие входы. Здесь используем ТОТ ЖЕ принцип (наклон SMA + отклонение | |
| # цены от нее), но на более быстром 1h таймфрейме с более коротким периодом - это ДОПОЛНИТЕЛЬНЫЙ | |
| # слой защиты поверх HTF, а не замена ему (многодневные структурные даунтренды по-прежнему ловит HTF). | |
| MTF_TREND_FILTER_ENABLED = True | |
| MTF_TREND_TIMEFRAME = "1h" # Среднесрочный таймфрейм для более быстрого фильтра тренда | |
| MTF_TREND_CANDLE_LIMIT = 30 # Свечей 1h для анализа | |
| MTF_TREND_SMA_PERIOD = 12 # Период SMA на среднем ТФ (12 x 1ч = 12 часов) | |
| MTF_TREND_SMA_SLOPE_LOOKBACK = 3 # На сколько свечей назад сравниваем SMA для определения наклона | |
| MTF_TREND_MAX_BELOW_SMA_PCT = 4.0 # Порог мягче, чем у HTF (8.0%) - здесь ловим более свежий/короткий тренд | |
| MTF_TREND_CACHE_TTL_SEC = 600 # Короче, чем у HTF (30 мин) - 1h свеча "устаревает" для нас быстрее | |
| # ── BTC Market Guard - защита от покупок алтов во время рыночного (BTC-led) провала ── | |
| # Фильтр диверсификации (calculate_correlation/max_correlation) сравнивает кандидатов только ДРУГ | |
| # С ДРУГОМ и с уже открытыми позициями по 30-минутному окну доходностей - BTC как отдельный | |
| # системный фактор риска там не участвует. Если на момент входа рынок был спокоен, разные по | |
| # сектору монеты вполне могли пройти фильтр (низкая взаимная корреляция), а затем синхронно | |
| # просесть вместе с BTC при резком движении рынка (см. случай 2026-07-25: одновременная просадка | |
| # 5 слотов из разных секторов - ZEC/ONDO/SUI/ALLO/JTO). Этот гейт - прямая дешевая проверка тренда | |
| # самого BTC перед ЛЮБОЙ покупкой в движке Портфеля. НЕ распространяется на Градацию - там смысл | |
| # стратегии как раз в докупке BTC/ETH на просадке, гейт "BTC падает -> не покупать" убил бы саму | |
| # стратегию. | |
| BTC_GUARD_ENABLED = True | |
| BTC_GUARD_SYMBOL = "BTC/USDT" | |
| BTC_GUARD_TIMEFRAME = "15m" # Быстрый таймфрейм - нужно поймать начавшийся провал, а не структурный тренд | |
| BTC_GUARD_LOOKBACK = 2 # Сравниваем текущую цену с ценой N свечей назад (2 x 15m = 30 минут) | |
| BTC_GUARD_MAX_DROP_PCT = 0.5 # Если BTC упал более чем на X% за это окно - покупки в Портфеле приостанавливаются | |
| BTC_GUARD_CACHE_TTL_SEC = 60 # Кэш результата - проверяется один раз за скрин, а не на каждого кандидата | |
| # ── Подтверждение объемом на входе ── | |
| # Разворотные индикаторы (RSI-дно/дивергенция/Боллинджер) сами по себе не отличают "реальный | |
| # отскок со спросом" от монеты, которая просто затихла перед следующим проливом. Требуем всплеск | |
| # объема на подтверждающей 1m свече относительно недавнего среднего. | |
| VOLUME_CONFIRMATION_ENABLED = True | |
| VOLUME_CONFIRMATION_LOOKBACK = 20 # Свечей для расчета среднего объема | |
| VOLUME_CONFIRMATION_MULTIPLIER = 1.3 # Объем последней свечи должен быть минимум в X раз больше среднего | |
| # ── Подтверждение дивергенции RSI на 15m ── | |
| # detect_bullish_divergence сама по себе - чисто геометрический паттерн (цена ниже/равна, RSI выше на | |
| # второй впадине), и уже требует r2 < 45 внутри себя, но этого может быть недостаточно строгим сигналом | |
| # самим по себе. Добавляем дополнительное требование СВЕРХУ: на момент сигнала RSI(15m) должен быть | |
| # отчетливо в зоне перепроданности. Если не проходит - дивергенция как самостоятельный триггер входа | |
| # не засчитывается (остальные сигналы - RSI-дно на 1m, Боллинджер - продолжают работать независимо). | |
| RSI_15M_DIVERGENCE_CONFIRM_ENABLED = True | |
| RSI_15M_DIVERGENCE_CONFIRM_MAX = 35.0 | |
| # ── Дисбаланс ликвидности в стакане на входе ── | |
| # Технические индикаторы не видят реальный спрос прямо сейчас - только историю свечей. Стакан | |
| # показывает это напрямую: если под ценой (bids) значительно больше ликвидности, чем над ней | |
| # (asks), это похоже на реальную поддержку покупателей, а не на "техническое дно". | |
| BOOK_IMBALANCE_ENABLED = True | |
| BOOK_IMBALANCE_DEPTH_PCT = 1.0 # Анализируем стакан в пределах ±1% от текущей цены | |
| BOOK_IMBALANCE_MIN_RATIO = 1.2 # bid-ликвидность должна быть минимум в 1.2 раза больше ask-ликвидности | |
| # ── Фильтр скорости падения (защита от входа в еще активно падающую монету) ── | |
| # Разворотные сигналы (RSI-дно/дивергенция/Боллинджер) могут сработать на ОДНОЙ 1m свече, даже | |
| # если монета в моменте продолжает практически свободное падение - например, кратковременный | |
| # технический отскок на фоне сильного нисходящего импульса. Дополнительно проверяем % изменения | |
| # цены за последние FREEFALL_LOOKBACK_CANDLES 1m свечей (данные уже загружены для RSI/Боллинджера, | |
| # доп. запрос к API не нужен): если падение за это окно все еще превышает FREEFALL_MAX_DROP_PCT - | |
| # вход пропускается независимо от остальных сигналов, т.к. это похоже на активно продолжающийся | |
| # обвал, а не на остановившуюся просадку. | |
| FREEFALL_GUARD_ENABLED = True | |
| FREEFALL_LOOKBACK_CANDLES = 5 # Смотрим на изменение цены за последние 5 1m свечей (~5 минут) | |
| FREEFALL_MAX_DROP_PCT = 2.5 # Если упало более чем на 2.5% за это окно - считаем "еще падает", пропуск | |
| # ── Верхняя граница просадки на входе (защита от "ловли обвала") ── | |
| # adaptive_min_drawdown ниже задает только НИЖНЮЮ границу (просадка должна быть достаточной, | |
| # чтобы вообще иметь смысл для скальпа) - верхней границы раньше не было вообще. Провал на | |
| # 15-20%+ за 30-минутное окно (CANDLE_LIMIT=30 1m свечей) - это, скорее, начало настоящего | |
| # обвала, а не "дип для скальпа": именно такие кандидаты статистически дольше всего потом | |
| # сидят в минусе. Работает НЕЗАВИСИМО от freefall-фильтра выше: freefall смотрит на СКОРОСТЬ | |
| # падения ПРЯМО СЕЙЧАС (последние 5 минут), а этот порог - на ОБЩУЮ глубину провала за все | |
| # 30-минутное окно, даже если в последние минуты падение уже замедлилось. | |
| MAX_DRAWDOWN_FROM_1M_HIGH_PCT = 6.5 # Просадка от 30-минутного хая свыше этого % - уже не "дип", пропуск. | |
| # Было 10.0, снижено 2026-07-25 (тот же случай, что и у HTF выше). | |
| # Не опускать ниже ~6: в Умеренном режиме (drawdown_mult=0.14) нижняя | |
| # граница входа для монет с 24h-волатильностью ~25%+ и так уже | |
| # приближается к этому значению (adaptive_min_drawdown = volatility_24h * | |
| # drawdown_mult) - порог ниже 6% начинает схлопывать окно входа для | |
| # части мем-тира до нуля. | |
| # ── "Мем-тир" - ужесточение для аномально волатильных монет ── | |
| # Кейс PUMP: RSI-дно + всплеск объема сработали на резкий, но ЛОЖНЫЙ импульс посреди бокового | |
| # шума - типично для низкокапных/мемных монет, где объемные всплески чаще манипулятивные, чем | |
| # признак реального разворота. Вместо хрупкого списка тикеров определяем "мем-тир" ПО ПОВЕДЕНИЮ | |
| # (аномальная суточная волатильность) и для таких монет требуем заметно более сильное | |
| # подтверждение объемом и стаканом, чем для обычных кандидатов. | |
| HIGH_VOLATILITY_MEME_THRESHOLD = 0.15 # 24h волатильность (high-low)/low, выше которой считаем монету мем-тиром | |
| MEME_TIER_VOLUME_MULTIPLIER = 2.0 # Для мем-тира: объем свечи должен быть минимум в 2x выше среднего (вместо 1.3x) | |
| MEME_TIER_BOOK_MIN_RATIO = 1.8 # Для мем-тира: bid-ликвидность должна перевешивать ask сильнее (вместо 1.2x) | |
| # ── Убывающий тейк-профит для зависших позиций ── | |
| # use_timeout=False и профит-ротация (срабатывает только при net_profit > 0) означают, что | |
| # позиция, ушедшая в минус и не отросшая обратно к изначальной цели, может висеть в слоте | |
| # бесконечно. Это НЕ стоп-лосс (мы не продаем принудительно в минус) - просто со временем | |
| # СНИЖАЕМ саму цель прибыли (target_profit_set), чтобы лимитка стала более реалистичной и | |
| # позиции было легче закрыться хотя бы в небольшой плюс, а не ждать полного отката к | |
| # изначальной, возможно слишком оптимистичной, цене. Никогда не опускаем цель ниже | |
| # STALE_POSITION_MIN_PROFIT_FLOOR_PCT от суммы сделки. | |
| STALE_POSITION_DECAY_START_MIN = 720 # Начинаем снижать цель, если слот висит без исполнения дольше 12 часов | |
| STALE_POSITION_DECAY_INTERVAL_MIN = 240 # Очередной шаг снижения - не чаще, чем раз в 4 часа | |
| STALE_POSITION_DECAY_STEP_PCT = 0.15 # На сколько % от ТЕКУЩЕЙ цели снижаем ее на каждом шаге | |
| STALE_POSITION_MIN_PROFIT_FLOOR_PCT = 0.05 # Минимальный пол цели (% от usdt_spent) - ниже не опускаем | |
| # ── Авто-бэкофф скринера при сетевых сбоях ── | |
| # SCREENER_INTERVAL_SEC=90 + CANDLE_FETCH_BATCH_SIZE=55 дают до ~165 запросов свечей за один скрин | |
| # (3 таймфрейма x 55 монет) плюс fetch_tickers - на коротком интервале это ощутимо чаще бьет по | |
| # рейт-лимитам биржи, чем было при 300 сек. Вместо того чтобы просто зашить более длинный интервал | |
| # (что убьет смысл ускорения входов), бот сам временно увеличивает интервал скрина, если видит | |
| # всплеск сетевых ошибок/рейт-лимитов от safe_call, и возвращается к нормальному интервалу, | |
| # когда сеть/лимиты снова спокойны. | |
| SCREENER_ERROR_WINDOW_SEC = 300 # Окно, за которое считаем недавние сетевые ошибки | |
| SCREENER_ERROR_BACKOFF_THRESHOLD = 5 # Сколько ошибок в окне триггерят бэкофф | |
| SCREENER_BACKOFF_MULTIPLIER = 3 # Во сколько раз увеличиваем интервал скрина во время бэкоффа | |
| # ── Авто-масштабирование слотов по росту депозита ── | |
| SLOT_SCALING_TABLE = [ | |
| (20, 5), | |
| (50, 6), | |
| (100, 7), | |
| (200, 8), | |
| (400, 9), | |
| (700, 10), | |
| (1000, 11), | |
| (1500, 12), | |
| ] | |
| AUTOSCALE_CHECK_INTERVAL_SEC = 3600 # Проверять реальный баланс раз в час (не смысла чаще - депозит не растет каждую минуту) | |
| SYMBOLS_REFRESH_INTERVAL_SEC = 3600 # Раз в час пересчитывать список разрешенных пар под текущий бюджет/слот | |
| # ── Персистентность состояния слотов на диск + публичный HF Dataset ── | |
| # Локальный диск переживает обычный рестарт процесса, но НЕ переживает полный редеплой | |
| # HF Spaces "с нуля" (там пересоздается вся файловая система). Поэтому дополнительно | |
| # состояние зеркалируется в публичный HF Dataset - это переживает ЛЮБОЙ рестарт контейнера, | |
| # включая пересоздание образа, т.к. датасет живет отдельно от самого Space. | |
| STATE_FILE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".gvp_scalper_state.json") | |
| STATE_FILENAME_IN_REPO = "gvp_scalper_state.json" | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| HF_DATASET_ID = os.environ.get("HF_DATASET_ID", "") | |
| def _hf_dataset_configured() -> bool: | |
| return bool(HF_TOKEN and HF_DATASET_ID) | |
| def _push_state_to_hf_dataset(local_path: str, state: "BotState"): | |
| """ | |
| Асинхронно заливает файл состояния в публичный HF Dataset через ОДИН сериализованный | |
| воркер на BotState (не отдельный поток на каждый вызов!). save_persisted_state() | |
| вызывается очень часто (после каждой сделки), и раньше каждый вызов спавнил свой | |
| независимый поток аплоада - без гарантии порядка завершения по сети. При активной | |
| торговле это давало РЕАЛЬНЫЙ баг: аплоад со СТАРым payload (например, is_running=True | |
| из сделки за секунду до нажатия "Стоп") мог по сети завершиться ПОЗЖЕ, чем аплоад с | |
| новым payload (is_running=False) - и облако откатывалось на устаревшее состояние, | |
| из-за чего после ребилда бот "воскресал" даже после явной остановки. | |
| Теперь: если воркер уже заливает - новый вызов просто помечает "есть более свежая | |
| версия", и ТОТ ЖЕ воркер после завершения текущей попытки перечитывает local_path | |
| (он к этому моменту уже содержит самые актуальные данные, т.к. локальная запись | |
| синхронна) и заливает снова - пока не останется несливающихся изменений. | |
| """ | |
| if not _hf_dataset_configured(): | |
| return | |
| with state._hf_upload_lock: | |
| state._hf_upload_pending = True | |
| if state._hf_upload_in_progress: | |
| return # воркер уже работает - он сам подхватит актуальную версию файла | |
| state._hf_upload_in_progress = True | |
| def _upload_loop(): | |
| try: | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=HF_TOKEN) | |
| # exist_ok=True - безопасно дергать при КАЖДОЙ заливке, датасет создается | |
| # автоматически только один раз, при первом реальном запуске. | |
| # По просьбе пользователя private=False, чтобы не требовать приватного спейса/лимитов. | |
| api.create_repo(repo_id=HF_DATASET_ID, repo_type="dataset", private=False, exist_ok=True) | |
| while True: | |
| with state._hf_upload_lock: | |
| state._hf_upload_pending = False | |
| try: | |
| # path_or_fileobj=local_path - huggingface_hub читает файл С ДИСКА в | |
| # момент вызова, поэтому если локальный файл уже успели перезаписать | |
| # заново (более новым save_persisted_state), уйдет уже АКТУАЛЬНАЯ версия. | |
| api.upload_file( | |
| path_or_fileobj=local_path, | |
| path_in_repo=STATE_FILENAME_IN_REPO, | |
| repo_id=HF_DATASET_ID, | |
| repo_type="dataset", | |
| ) | |
| except Exception as e: | |
| logging.warning(f"HF Dataset: не удалось залить состояние - {e}") | |
| with state._hf_upload_lock: | |
| if not state._hf_upload_pending: | |
| state._hf_upload_in_progress = False | |
| return | |
| # пока шла заливка, пришла более новая версия - повторяем цикл заново | |
| except Exception as e: | |
| logging.warning(f"HF Dataset: не удалось залить состояние - {e}") | |
| with state._hf_upload_lock: | |
| state._hf_upload_in_progress = False | |
| threading.Thread(target=_upload_loop, daemon=True).start() | |
| def _pull_state_from_hf_dataset() -> str | None: | |
| """ | |
| Скачивает файл состояния из публичного HF Dataset. Возвращает ЛОКАЛЬНЫЙ путь к скачанному | |
| файлу (huggingface_hub кэширует его на диск) или None, если датасет не настроен, либо | |
| файла там еще нет - это нормальная ситуация при самом первом запуске, падать нельзя. | |
| """ | |
| if not _hf_dataset_configured(): | |
| return None | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| return hf_hub_download( | |
| repo_id=HF_DATASET_ID, repo_type="dataset", | |
| filename=STATE_FILENAME_IN_REPO, token=HF_TOKEN, | |
| ) | |
| except Exception as e: | |
| logging.info(f"HF Dataset: файл состояния не найден/недоступен ({e}) - считаем первым запуском") | |
| return None | |
| # ── Стратегия "Градация" - крупные высоколиквидные монеты, лесенка докупок на просадке ── | |
| 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% от скользящего хая | |
| # ───────────────────────────────────────────── | |
| # МОДЕЛЬ СЛОТА ПОРТФЕЛЯ | |
| # ───────────────────────────────────────────── | |
| class Slot: | |
| 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 # Метка времени для анти-спама | |
| last_known_price: float = 0.0 # Последняя увиденная рыночная цена (для отображения незафиксированного P&L) | |
| last_decay_at: float = 0.0 # Метка времени последнего снижения цели прибыли (убывающий ТП для зависших позиций) | |
| def is_empty(self) -> bool: | |
| return self.symbol is None | |
| def clear(self): | |
| self.symbol = None | |
| self.order_id = None | |
| self.order_placed_at = None | |
| self.bought_qty = 0.0 | |
| self.buy_price_avg = 0.0 | |
| self.sell_price_set = 0.0 | |
| self.usdt_spent = 0.0 | |
| self.target_profit_set = 0.05 | |
| self.status = "🔍 Поиск" | |
| self.last_known_price = 0.0 | |
| self.last_decay_at = 0.0 | |
| def to_dict(self) -> dict: | |
| """Сериализация слота для сохранения на диск (datetime -> ISO-строка).""" | |
| d = asdict(self) | |
| if self.order_placed_at is not None: | |
| d["order_placed_at"] = self.order_placed_at.isoformat() | |
| return d | |
| def load_from_dict(self, d: dict): | |
| """Восстановление слота из сохраненного словаря. index НЕ трогаем - он привязан к позиции в списке.""" | |
| self.symbol = d.get("symbol") | |
| self.order_id = d.get("order_id") | |
| raw_dt = d.get("order_placed_at") | |
| self.order_placed_at = datetime.fromisoformat(raw_dt) if raw_dt else None | |
| self.bought_qty = float(d.get("bought_qty", 0.0)) | |
| self.buy_price_avg = float(d.get("buy_price_avg", 0.0)) | |
| self.sell_price_set = float(d.get("sell_price_set", 0.0)) | |
| self.usdt_spent = float(d.get("usdt_spent", 0.0)) | |
| self.target_profit_set = float(d.get("target_profit_set", 0.05)) | |
| self.status = d.get("status", "🔍 Поиск") | |
| self.last_refill_attempt = float(d.get("last_refill_attempt", 0.0)) | |
| self.last_known_price = float(d.get("last_known_price", 0.0)) | |
| self.last_decay_at = float(d.get("last_decay_at", 0.0)) | |
| # ───────────────────────────────────────────── | |
| # ПОТОКОБЕЗОПАСНОЕ СОСТОЯНИЕ БОТА (СИНГЛТОН) | |
| # ───────────────────────────────────────────── | |
| class BotState: | |
| def __init__(self): | |
| self._lock = threading.Lock() | |
| 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.persist_api_keys_in_state = False # Явное согласие пользователя хранить ключи в файле состояния | |
| # (нужно для авто-возобновления Live без Секретов HF - см. предупреждение в UI) | |
| self.target_profit = 0.05 # Целевой профит на сделку (USDT) | |
| self.total_budget = 25.0 # Общий выделенный бюджет | |
| self.num_slots = DEFAULT_SLOTS | |
| 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 = [] # Последние результаты скринера для 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.last_symbols_refresh_at = 0.0 # Когда последний раз пересчитывался список разрешенных пар под бюджет | |
| self.initial_deposit = 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"} | |
| # ── Авто-бэкофф скринера при всплеске сетевых ошибок ── | |
| self._network_error_lock = threading.Lock() | |
| self.recent_network_errors: list[float] = [] # метки времени недавних сетевых ошибок (для скользящего окна) | |
| # ── Кэш фильтра старшего тренда (4h) - снижает нагрузку на API ── | |
| self._htf_cache_lock = threading.Lock() | |
| self.htf_trend_cache: dict[str, tuple[float, bool]] = {} # symbol -> (когда проверяли, прошел ли фильтр) | |
| # ── Кэш среднесрочного (1h) трендового фильтра - отдельный от HTF, короче TTL ── | |
| self._mtf_cache_lock = threading.Lock() | |
| self.mtf_trend_cache: dict[str, tuple[float, bool]] = {} # symbol -> (когда проверяли, прошел ли фильтр) | |
| # ── Кэш BTC Market Guard - один результат на весь скрин, а не на каждого кандидата ── | |
| self._btc_guard_lock = threading.Lock() | |
| self.btc_guard_cache: tuple[float, bool, float] | None = None # (когда проверяли, ok?, % изменения BTC) | |
| # ── Персистентность: защита от гонки потоков (UI-клик "Стоп" vs фоновый trading_loop) ── | |
| self._persist_write_lock = threading.Lock() # сериализует запись локального tmp-файла | |
| self._hf_upload_lock = threading.Lock() # см. _push_state_to_hf_dataset | |
| self._hf_upload_in_progress = False | |
| self._hf_upload_pending = False | |
| # ── Защита от повторного холодного старта ── | |
| # ВАЖНО: это НЕ модульная переменная и НЕ st.session_state. Streamlit перевыполняет | |
| # ВЕСЬ файл app.py целиком сверху вниз на КАЖДЫЙ клик/rerun - модульная переменная | |
| # вида "_cold_start_attempted = False" на верхнем уровне файла обнулялась бы на | |
| # КАЖДОМ ререндере (это и было реальным багом: клик "Стоп" тут же откатывался | |
| # обратно в "Автозапуск после рестарта", т.к. защита никогда реально не держалась). | |
| # BotState - синглтон через st.cache_resource и переживает reruns корректно | |
| # (__init__ вызывается ровно один раз за жизнь процесса), поэтому флаг живет здесь. | |
| self._cold_start_lock = threading.Lock() | |
| self._cold_start_attempted = False | |
| def budget_per_slot(self) -> float: | |
| if self.num_slots <= 0: | |
| return 0.0 | |
| return self.total_budget / self.num_slots | |
| def fee_rate(self) -> float: | |
| return FEE_RATE_BNB_DISCOUNT if self.use_bnb_discount else FEE_RATE_STANDARD | |
| def log(self, msg: str, level: str = "INFO"): | |
| ts = datetime.now(timezone.utc).strftime("%H:%M:%S") | |
| emoji = {"INFO": "ℹ️", "OK": "✅", "WARN": "⚠️", "ERR": "🔴", "DEAL": "💰"}.get(level, "▪️") | |
| entry = f"[{ts}] {emoji} {msg}" | |
| with self._lock: | |
| self.logs.append(entry) | |
| if len(self.logs) > 300: | |
| self.logs = self.logs[-300:] | |
| logging.info(entry) | |
| def record_paper_trade(self, record: dict): | |
| record["time"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") | |
| with self._lock: | |
| self.paper_trades.append(record) | |
| if len(self.paper_trades) > 500: | |
| self.paper_trades = self.paper_trades[-500:] | |
| def resize_slots(self, new_n: int): | |
| with self._lock: | |
| new_n = max(1, min(MAX_SLOTS, new_n)) | |
| current = len(self.slots) | |
| if new_n > current: | |
| self.slots.extend(Slot(index=i) for i in range(current, new_n)) | |
| elif new_n < current: | |
| self.slots = self.slots[:new_n] | |
| self.num_slots = new_n | |
| def occupied_symbols(self) -> set[str]: | |
| return {s.symbol for s in self.slots if s.symbol is not None} | |
| def register_network_error(self): | |
| """Фиксирует метку времени сетевой ошибки биржи (вызывается из safe_call). | |
| Используется для авто-бэкоффа интервала скринера при всплеске сбоев/рейт-лимитов.""" | |
| now = time.time() | |
| with self._network_error_lock: | |
| self.recent_network_errors.append(now) | |
| cutoff = now - SCREENER_ERROR_WINDOW_SEC | |
| self.recent_network_errors = [t for t in self.recent_network_errors if t >= cutoff] | |
| def effective_screener_interval(self) -> float: | |
| """ | |
| Возвращает интервал между полными скринами рынка: обычно SCREENER_INTERVAL_SEC, | |
| но временно увеличенный в SCREENER_BACKOFF_MULTIPLIER раз, если за последние | |
| SCREENER_ERROR_WINDOW_SEC секунд накопилось SCREENER_ERROR_BACKOFF_THRESHOLD+ сетевых | |
| ошибок - защита от того, чтобы частый скрин (много запросов свечей за короткий интервал) | |
| не долбил биржу еще сильнее, когда она уже отвечает сбоями/рейт-лимитами. | |
| """ | |
| now = time.time() | |
| with self._network_error_lock: | |
| cutoff = now - SCREENER_ERROR_WINDOW_SEC | |
| self.recent_network_errors = [t for t in self.recent_network_errors if t >= cutoff] | |
| error_count = len(self.recent_network_errors) | |
| if error_count >= SCREENER_ERROR_BACKOFF_THRESHOLD: | |
| return SCREENER_INTERVAL_SEC * SCREENER_BACKOFF_MULTIPLIER | |
| return SCREENER_INTERVAL_SEC | |
| def save_persisted_state(self): | |
| """ | |
| Сохраняет слоты, статистику И параметры торговли на диск + асинхронно в публичный | |
| HF Dataset (если настроен), чтобы пережить ЛЮБОЙ рестарт контейнера, включая полный | |
| редеплой (локальный диск это не переживает, датасет - переживает). | |
| Флаг "is_running" в payload - основа для холодного автозапуска (maybe_cold_start_resume): | |
| если он True при следующем старте процесса, бот сам поднимет trading_loop. | |
| Ошибки записи НЕ должны ронять торговый цикл - только логируются. | |
| """ | |
| try: | |
| payload = { | |
| "saved_at": datetime.now(timezone.utc).isoformat(), | |
| "is_running": self.is_running, | |
| "paper_trading": self.paper_trading, | |
| "num_slots": self.num_slots, | |
| "total_budget": self.total_budget, | |
| "target_profit": self.target_profit, | |
| "use_bnb_discount": self.use_bnb_discount, | |
| "use_timeout": self.use_timeout, | |
| "order_timeout_min": self.order_timeout_min, | |
| "use_profit_exit": self.use_profit_exit, | |
| "profit_exit_timeout_min": self.profit_exit_timeout_min, | |
| "strategy_mode": self.strategy_mode, | |
| "use_dynamic_tp": self.use_dynamic_tp, | |
| "max_correlation": self.max_correlation, | |
| "use_depth_check": self.use_depth_check, | |
| "max_slippage_pct": self.max_slippage_pct, | |
| "strategy_engine": self.strategy_engine, | |
| "grading_symbols": self.grading_symbols, | |
| "grading_budget": self.grading_budget, | |
| # "filled" внутри позиции - это set(), json не умеет его сериализовать напрямую, | |
| # поэтому конвертируем в list только на момент сохранения (см. load_from_dict-аналог | |
| # при восстановлении - reconcile_persisted_grading конвертирует list обратно в set). | |
| "grading_positions": { | |
| sym: { | |
| "rolling_high": pos.get("rolling_high", 0.0), | |
| "filled": sorted(pos.get("filled", set())), | |
| "qty": pos.get("qty", 0.0), | |
| "cost": pos.get("cost", 0.0), | |
| } | |
| for sym, pos in self.grading_positions.items() | |
| }, | |
| "auto_scale_slots": self.auto_scale_slots, | |
| "proxy_url": self.proxy_url, | |
| "total_profit": self.total_profit, | |
| "trades_count": self.trades_count, | |
| "slots": [s.to_dict() for s in self.slots], | |
| } | |
| # API-ключи в файле - ТОЛЬКО если пользователь явно на это согласился в UI | |
| # (по умолчанию выключено; безопаснее прописать BINANCE_API_KEY/SECRET в Секретах HF Spaces - | |
| # они не попадают в этот файл и, соответственно, никогда не улетают в датасет) | |
| if self.persist_api_keys_in_state and not self.paper_trading: | |
| payload["api_key"] = self.api_key | |
| payload["api_secret"] = self.api_secret | |
| tmp_path = STATE_FILE_PATH + ".tmp" | |
| with self._persist_write_lock: | |
| with open(tmp_path, "w", encoding="utf-8") as f: | |
| json.dump(payload, f, ensure_ascii=False) | |
| os.replace(tmp_path, STATE_FILE_PATH) # атомарная замена - не оставит битый файл при обрыве записи | |
| _push_state_to_hf_dataset(STATE_FILE_PATH, self) | |
| except Exception as e: | |
| logging.warning(f"Не удалось сохранить состояние на диск: {e}") | |
| def load_persisted_state() -> dict | None: | |
| """ | |
| Читает сохраненное состояние. Приоритет: сначала пробуем скачать актуальную версию | |
| из публичного HF Dataset (переживает полный редеплой), при неудаче/отсутствии - | |
| читаем локальный файл на диске, если он еще жив. None - если нигде ничего нет | |
| (это нормально для самого первого запуска, программа не должна падать). | |
| """ | |
| hf_path = _pull_state_from_hf_dataset() | |
| path_to_read = hf_path if hf_path else (STATE_FILE_PATH if os.path.exists(STATE_FILE_PATH) else None) | |
| if not path_to_read: | |
| return None | |
| try: | |
| with open(path_to_read, "r", encoding="utf-8") as f: | |
| return json.load(f) | |
| except Exception as e: | |
| logging.warning(f"Не удалось прочитать сохраненное состояние: {e}") | |
| return None | |
| 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 = [] | |
| for i in range(1, len(prices)): | |
| deltas.append(prices[i] - prices[i - 1]) | |
| gains = [d if d > 0 else 0.0 for d in deltas] | |
| losses = [-d if d < 0 else 0.0 for d in deltas] | |
| avg_gain = sum(gains[:period]) / period | |
| avg_loss = sum(losses[:period]) / period | |
| 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 | |
| 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]: | |
| """ | |
| Ищет бычью дивергенцию (классическую или скрытую) на последних свечах. | |
| Бычья дивергенция (классическая): цена делает более низкое дно (или равное), а 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 closes[i] < closes[i-1] and closes[i] < closes[i+1]: | |
| troughs.append(i) | |
| 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]: | |
| """Чистый 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) | |
| upper_band = sma + (num_stdDev * std_dev) | |
| lower_band = sma - (num_stdDev * std_dev) | |
| return upper_band, sma, lower_band | |
| def is_rsi_local_bottom(rsi_array: list[float], lookback: int = 5, confirm_bars: int = RSI_BOTTOM_CONFIRM_BARS) -> bool: | |
| """ | |
| Определяет, развернулся ли RSI вверх после локального минимума. | |
| Требует confirm_bars (по умолчанию 2) ПОДРЯД неубывающих шагов RSI, а не один - единичный | |
| тик вверх на шумной 1m свече слишком легко давал ложный разворот, который тут же гас. | |
| Два (и более) подряд неубывающих шага - более надежное подтверждение начавшегося отскока. | |
| """ | |
| n = len(rsi_array) | |
| if n < lookback + confirm_bars + 1: | |
| return False | |
| # Последние confirm_bars переходов должны быть неубывающими (ни одного отката вниз) | |
| for i in range(n - confirm_bars, n): | |
| if rsi_array[i] < rsi_array[i - 1]: | |
| return False | |
| # Значение, с которого начался этот подъем, должно быть минимумом за окно lookback ДО него | |
| bottom_idx = n - confirm_bars - 1 | |
| window = rsi_array[max(0, bottom_idx - lookback):bottom_idx] | |
| if not window: | |
| return False | |
| return rsi_array[bottom_idx] <= min(window) | |
| 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}/{EXCHANGE_RETRY_ATTEMPTS}): {e}", "WARN") | |
| state.register_network_error() # авто-бэкофф скринера при накоплении таких ошибок | |
| 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 [] | |
| 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 = 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 | |
| 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 check_book_liquidity_imbalance(exchange: ccxt.binance, symbol: str, current_price: float, state: BotState, min_ratio: float = BOOK_IMBALANCE_MIN_RATIO) -> bool: | |
| """ | |
| Проверяет дисбаланс ликвидности в стакане в пределах BOOK_IMBALANCE_DEPTH_PCT% от текущей | |
| цены: суммарная ликвидность на покупку (bids) должна ощутимо превышать ликвидность на | |
| продажу (asks) - это признак реального спроса ("поддержки") прямо сейчас, а не просто | |
| технического сигнала по свечам. Используется как ДОПОЛНИТЕЛЬНОЕ подтверждение точки входа | |
| поверх RSI/дивергенции/Боллинджера, а не вместо них. min_ratio настраиваемый - для | |
| аномально волатильных ("мем-тир") монет вызывающая сторона передает более строгий порог. | |
| При ошибке сети/пустом стакане - считаем подтверждение не пройденным (безопаснее пропустить | |
| сомнительный вход). | |
| """ | |
| try: | |
| order_book = safe_call(exchange.fetch_order_book, symbol, 50, state=state, label=f"book-imbalance {symbol}") | |
| except Exception as e: | |
| state.log(f"Дисбаланс стакана {symbol}: ошибка получения стакана: {e}", "WARN") | |
| return False | |
| bids = order_book.get("bids", []) | |
| asks = order_book.get("asks", []) | |
| if not bids or not asks or current_price <= 0: | |
| return False | |
| lower_bound = current_price * (1 - BOOK_IMBALANCE_DEPTH_PCT / 100.0) | |
| upper_bound = current_price * (1 + BOOK_IMBALANCE_DEPTH_PCT / 100.0) | |
| bid_liquidity = sum(price * qty for price, qty in bids if price >= lower_bound) | |
| ask_liquidity = sum(price * qty for price, qty in asks if price <= upper_bound) | |
| if bid_liquidity <= 0: | |
| return False | |
| if ask_liquidity <= 0: | |
| return True | |
| ratio = bid_liquidity / ask_liquidity | |
| return ratio >= min_ratio | |
| 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 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"], # Ограничиваем загрузку только спотовым рынком, чтобы 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" | |
| } | |
| } | |
| 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, max_notional: float = MIN_NOTIONAL_FILTER) -> list[str]: | |
| """ | |
| Возвращает пары, где минимальный ордер Binance (minNotional) не превышает max_notional. | |
| ВАЖНО: max_notional теперь передается динамически (обычно = бюджет на слот), а не жестко зашит в 1.0 - | |
| именно жесткая "1.0" была причиной, почему было видно только ~29 монет: у большинства нормальных | |
| монет (не микро-капов) minNotional на Binance составляет 5 или 10 USDT, и они отсекались напрямую. | |
| Чем больше депозит/слот - тем больше пар автоматически становится доступно, без правки кода. | |
| """ | |
| markets = exchange.load_markets() | |
| allowed = [] | |
| for sym, info in markets.items(): | |
| 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: | |
| if f.get("filterType") in ("MIN_NOTIONAL", "NOTIONAL"): | |
| val = f.get("minNotional") or f.get("notional") or f.get("minQty") | |
| if val is not None: | |
| min_notional = float(val) | |
| break | |
| if min_notional is not None and min_notional <= max_notional: | |
| allowed.append(sym) | |
| return allowed | |
| def _compute_htf_trend_ok(exchange: ccxt.binance, symbol: str, state: BotState) -> bool: | |
| """ | |
| Фильтр старшего тренда (4h) перед входом - защита от "падающих ножей". | |
| Разворотные сигналы на 1m/15m ловят только краткосрочный микро-отскок и ничего не знают | |
| про многодневный тренд: монета может показывать "дно RSI" на 1m, но при этом уже неделю | |
| падать на 4h. Отклоняем вход, только если старший тренд ЯВНО нисходящий (SMA20 на 4h | |
| снижается) И цена уже далеко ниже этой SMA - это отличает "дип в боковике/аптренде" | |
| (нормальный вход) от "лови нож в устойчивом даунтренде" (пропускаем). | |
| Если данных недостаточно/ошибка сети - пропускаем кандидата целиком (безопаснее, | |
| чем входить вслепую без понимания старшего тренда). | |
| """ | |
| try: | |
| ohlcv_4h = safe_call( | |
| exchange.fetch_ohlcv, symbol, HTF_TREND_TIMEFRAME, limit=HTF_TREND_CANDLE_LIMIT, | |
| state=state, label=f"htf-trend {symbol}" | |
| ) | |
| except Exception as e: | |
| state.log(f"HTF-тренд {symbol}: ошибка получения {HTF_TREND_TIMEFRAME} свечей: {e}", "WARN") | |
| return False | |
| min_needed = HTF_TREND_SMA_PERIOD + HTF_TREND_SMA_SLOPE_LOOKBACK | |
| if len(ohlcv_4h) < min_needed: | |
| return False | |
| closes_4h = [k[4] for k in ohlcv_4h] | |
| sma_now = sum(closes_4h[-HTF_TREND_SMA_PERIOD:]) / HTF_TREND_SMA_PERIOD | |
| sma_prev_window = closes_4h[-(HTF_TREND_SMA_PERIOD + HTF_TREND_SMA_SLOPE_LOOKBACK):-HTF_TREND_SMA_SLOPE_LOOKBACK] | |
| sma_prev = sum(sma_prev_window) / HTF_TREND_SMA_PERIOD | |
| last_price = closes_4h[-1] | |
| if sma_now <= 0: | |
| return False | |
| below_sma_pct = (sma_now - last_price) / sma_now * 100.0 | |
| sma_declining = sma_now < sma_prev | |
| # Отклоняем ТОЛЬКО если тренд старшего ТФ явно падающий И цена уже далеко под ним - | |
| # если SMA растет/плоская, дип ниже нее - это нормальный вход, не блокируем. | |
| if sma_declining and below_sma_pct > HTF_TREND_MAX_BELOW_SMA_PCT: | |
| return False | |
| return True | |
| def check_htf_trend_ok(exchange: ccxt.binance, symbol: str, state: BotState) -> bool: | |
| """ | |
| Кэширующая обертка над _compute_htf_trend_ok. 4h свеча физически не может измениться за | |
| 90-270 секунд между скринами, поэтому дергать 4h-эндпоинт на КАЖДОМ цикле для одной и той же | |
| монеты - чистый перерасход лимитов API (именно это внесло вклад в 429 Too Many Requests). | |
| Результат кэшируется на HTF_TREND_CACHE_TTL_SEC (30 минут) на символ. | |
| """ | |
| now = time.time() | |
| with state._htf_cache_lock: | |
| cached = state.htf_trend_cache.get(symbol) | |
| if cached is not None and (now - cached[0]) < HTF_TREND_CACHE_TTL_SEC: | |
| return cached[1] | |
| result = _compute_htf_trend_ok(exchange, symbol, state) | |
| with state._htf_cache_lock: | |
| state.htf_trend_cache[symbol] = (now, result) | |
| return result | |
| def _compute_mtf_trend_ok(exchange: ccxt.binance, symbol: str, state: BotState) -> bool: | |
| """ | |
| Среднесрочный (1h) трендовый фильтр - тот же принцип, что и _compute_htf_trend_ok (наклон SMA | |
| + отклонение цены под ней), но на более быстром таймфрейме с более коротким периодом. 4h SMA | |
| инертна и не успевает заметно сдвинуться за несколько часов даунтренда - этот фильтр ловит | |
| именно такие "свежие" провалы, которые HTF-фильтр физически не может увидеть вовремя. | |
| """ | |
| try: | |
| ohlcv_mtf = safe_call( | |
| exchange.fetch_ohlcv, symbol, MTF_TREND_TIMEFRAME, limit=MTF_TREND_CANDLE_LIMIT, | |
| state=state, label=f"mtf-trend {symbol}" | |
| ) | |
| except Exception as e: | |
| state.log(f"MTF-тренд {symbol}: ошибка получения {MTF_TREND_TIMEFRAME} свечей: {e}", "WARN") | |
| return False | |
| min_needed = MTF_TREND_SMA_PERIOD + MTF_TREND_SMA_SLOPE_LOOKBACK | |
| if len(ohlcv_mtf) < min_needed: | |
| return False | |
| closes_mtf = [k[4] for k in ohlcv_mtf] | |
| sma_now = sum(closes_mtf[-MTF_TREND_SMA_PERIOD:]) / MTF_TREND_SMA_PERIOD | |
| sma_prev_window = closes_mtf[-(MTF_TREND_SMA_PERIOD + MTF_TREND_SMA_SLOPE_LOOKBACK):-MTF_TREND_SMA_SLOPE_LOOKBACK] | |
| sma_prev = sum(sma_prev_window) / MTF_TREND_SMA_PERIOD | |
| last_price = closes_mtf[-1] | |
| if sma_now <= 0: | |
| return False | |
| below_sma_pct = (sma_now - last_price) / sma_now * 100.0 | |
| sma_declining = sma_now < sma_prev | |
| # Тот же принцип, что и у HTF: отклоняем ТОЛЬКО если тренд явно падающий И цена уже далеко | |
| # под ним - если SMA растет/плоская, дип ниже нее не блокируем. | |
| if sma_declining and below_sma_pct > MTF_TREND_MAX_BELOW_SMA_PCT: | |
| return False | |
| return True | |
| def check_mtf_trend_ok(exchange: ccxt.binance, symbol: str, state: BotState) -> bool: | |
| """ | |
| Кэширующая обертка над _compute_mtf_trend_ok, по аналогии с check_htf_trend_ok, но с более | |
| коротким TTL (MTF_TREND_CACHE_TTL_SEC) - 1h свеча "устаревает" для наших целей быстрее, чем 4h. | |
| """ | |
| now = time.time() | |
| with state._mtf_cache_lock: | |
| cached = state.mtf_trend_cache.get(symbol) | |
| if cached is not None and (now - cached[0]) < MTF_TREND_CACHE_TTL_SEC: | |
| return cached[1] | |
| result = _compute_mtf_trend_ok(exchange, symbol, state) | |
| with state._mtf_cache_lock: | |
| state.mtf_trend_cache[symbol] = (now, result) | |
| return result | |
| def check_btc_market_guard(exchange: ccxt.binance, state: BotState) -> tuple[bool, float]: | |
| """ | |
| Проверяет, не проваливается ли сам BTC прямо сейчас - системный риск, который per-symbol | |
| HTF/MTF-фильтры (смотрят на тренд КАЖДОЙ монеты отдельно) и диверсификация по корреляции | |
| кандидатов (короткое 30-минутное окно, без BTC как референса) не ловят напрямую. Вызывается | |
| ОДИН раз за весь скрин (не на каждого кандидата) и кэшируется на BTC_GUARD_CACHE_TTL_SEC. | |
| Возвращает (ok, change_pct): ok=False означает, что BTC упал более чем на BTC_GUARD_MAX_DROP_PCT | |
| за последние BTC_GUARD_LOOKBACK свечей BTC_GUARD_TIMEFRAME - новые покупки в Портфеле в этом | |
| цикле приостанавливаются. При ошибке сети/нехватке данных - НЕ блокируем (ok=True), чтобы сбой | |
| получения одной доп. свечи не останавливал торговлю полностью. | |
| """ | |
| now = time.time() | |
| with state._btc_guard_lock: | |
| cached = state.btc_guard_cache | |
| if cached is not None and (now - cached[0]) < BTC_GUARD_CACHE_TTL_SEC: | |
| return cached[1], cached[2] | |
| try: | |
| ohlcv = safe_call( | |
| exchange.fetch_ohlcv, BTC_GUARD_SYMBOL, BTC_GUARD_TIMEFRAME, | |
| limit=BTC_GUARD_LOOKBACK + 1, state=state, label="btc-guard" | |
| ) | |
| except Exception as e: | |
| state.log(f"BTC Guard: ошибка получения свечей BTC: {e} - пропускаем проверку (не блокируем вход)", "WARN") | |
| result = (True, 0.0) | |
| with state._btc_guard_lock: | |
| state.btc_guard_cache = (now, result[0], result[1]) | |
| return result | |
| if len(ohlcv) < BTC_GUARD_LOOKBACK + 1: | |
| result = (True, 0.0) | |
| with state._btc_guard_lock: | |
| state.btc_guard_cache = (now, result[0], result[1]) | |
| return result | |
| closes = [k[4] for k in ohlcv] | |
| ref_price = closes[-1 - BTC_GUARD_LOOKBACK] | |
| last_price = closes[-1] | |
| change_pct = ((last_price - ref_price) / ref_price * 100.0) if ref_price > 0 else 0.0 | |
| ok = change_pct > -BTC_GUARD_MAX_DROP_PCT | |
| with state._btc_guard_lock: | |
| state.btc_guard_cache = (now, ok, change_pct) | |
| return ok, change_pct | |
| def screen_full_market( | |
| exchange: ccxt.binance, | |
| allowed_symbols: list[str], | |
| occupied: set[str], | |
| state: BotState, | |
| need_n: int, | |
| ) -> list[dict]: | |
| if BTC_GUARD_ENABLED: | |
| btc_ok, btc_change_pct = check_btc_market_guard(exchange, state) | |
| if not btc_ok: | |
| state.log( | |
| f"🛡️ BTC Guard: BTC {btc_change_pct:+.2f}% за последние {BTC_GUARD_LOOKBACK} x {BTC_GUARD_TIMEFRAME} " | |
| f"(лимит -{BTC_GUARD_MAX_DROP_PCT:.2f}%) - рынок падает, новые покупки в Портфеле " | |
| f"приостановлены на этот цикл", "WARN" | |
| ) | |
| return [] | |
| 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 = 32.0 # Только сильная перепроданность | |
| drawdown_mult = 0.20 # Глубокий откат (20% от суточной волатильности) | |
| max_range_pos = 0.45 # Строго в нижней трети диапазона | |
| elif state.strategy_mode.startswith("🔥"): | |
| 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 = 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 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({ | |
| "symbol": sym, "vol": vol, "spread": spread, "ask": ask, | |
| "volatility_24h": volatility_24h, "high_24h": high_24h, "low_24h": low_24h | |
| }) | |
| 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] | |
| # ── Принудительное включение "самой просевшей" монеты ── | |
| # Обычный батч режется по топ-CANDLE_FETCH_BATCH_SIZE по ОБЪЕМУ, поэтому монета с рекордной | |
| # просадкой от суточного хая, но за пределами этого топа по объему, раньше вообще не доходила | |
| # до анализа индикаторами и не могла попасть в scored, даже если по факту сильнее всех просела. | |
| # Здесь ищем такую монету среди ВСЕХ кандидатов, прошедших базовый фильтр объема/спреда | |
| # (а не только среди топ-N по объему), и добавляем её в батч на анализ, если она там еще не | |
| # присутствует. Это НЕ обходит фильтры входа (дивергенция/RSI/Боллинджер, адаптивная просадка) - | |
| # монета все равно должна пройти is_entry_triggered ниже, просто ей гарантированно дают шанс. | |
| most_dropped = None | |
| best_drawdown_24h = -1.0 | |
| for c in candidates: | |
| high_24h = c["high_24h"] | |
| if high_24h <= 0: | |
| continue | |
| drawdown_24h = (high_24h - c["ask"]) / high_24h | |
| if drawdown_24h > best_drawdown_24h: | |
| best_drawdown_24h = drawdown_24h | |
| most_dropped = c | |
| batch_symbols = {c["symbol"] for c in batch} | |
| if most_dropped is not None and most_dropped["symbol"] not in batch_symbols: | |
| batch = batch + [most_dropped] | |
| state.log( | |
| f"📉 Принудительно добавлена в анализ самая просевшая монета вне топа по объему: " | |
| f"{most_dropped['symbol']} (просадка от суточного хая {best_drawdown_24h*100:.2f}%)", | |
| "INFO" | |
| ) | |
| state.log(f"Анализируем индикаторами топ-{len(batch)} по объему", "INFO") | |
| scored: list[dict] = [] | |
| for c in batch: | |
| sym = c["symbol"] | |
| # ─── МУЛЬТИТАЙМФРЕЙМОВЫЙ АНАЛИЗ (1h → 15m → 1m) ─── | |
| # Шаг А: Анализ на ЧАСОВОМ таймфрейме ("1h") - оценка макро-тренда | |
| try: | |
| ohlcv_1h = safe_call(exchange.fetch_ohlcv, sym, "1h", limit=15, state=state, label=f"1h {sym}") | |
| except Exception as e: | |
| state.log(f"Скрин: ошибка 1h свечей {sym}: {e}", "WARN") | |
| continue | |
| if len(ohlcv_1h) < 15: | |
| 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 | |
| # ── Фильтр старшего тренда (4h) - защита от "падающих ножей" ── | |
| # Проверяем ДО дорогих 15m/1m запросов - если монета в структурном даунтренде, | |
| # нет смысла тратить лимиты API на детальный анализ входа. | |
| if not check_htf_trend_ok(exchange, sym, state): | |
| continue | |
| # ── Среднесрочный фильтр тренда (1h) - ловит свежие провалы, которые 4h SMA еще не видит ── | |
| if MTF_TREND_FILTER_ENABLED and not check_mtf_trend_ok(exchange, sym, state): | |
| continue | |
| # Шаг Б: Дивергенция на 15-МИНУТНОМ таймфрейме - более надежный сигнал разворота, | |
| # чем на 1m (там слишком много рыночного шума для впадин цены/RSI) | |
| try: | |
| ohlcv_15m = safe_call(exchange.fetch_ohlcv, sym, "15m", limit=40, state=state, label=f"15m {sym}") | |
| except Exception as e: | |
| state.log(f"Скрин: ошибка 15m свечей {sym}: {e}", "WARN") | |
| continue | |
| if len(ohlcv_15m) < 20: | |
| continue | |
| closes_15m = [k[4] for k in ohlcv_15m] | |
| rsi_array_15m = [] | |
| for i in range(len(closes_15m)): | |
| rsi_array_15m.append(calculate_rsi(closes_15m[:i+1], period=14)) | |
| has_divergence, div_type = detect_bullish_divergence(closes_15m, rsi_array_15m, window=15) | |
| # Доп. подтверждение: дивергенция засчитывается как сигнал, только если RSI(15m) на момент | |
| # сигнала все еще отчетливо в зоне перепроданности - см. RSI_15M_DIVERGENCE_CONFIRM_ENABLED | |
| if RSI_15M_DIVERGENCE_CONFIRM_ENABLED and has_divergence and rsi_array_15m[-1] >= RSI_15M_DIVERGENCE_CONFIRM_MAX: | |
| has_divergence = False | |
| # Шаг В: Точный тайминг входа на МИНУТНОМ таймфрейме ("1m") - Боллинджер + "дно" RSI | |
| try: | |
| ohlcv_1m = safe_call(exchange.fetch_ohlcv, sym, "1m", limit=CANDLE_LIMIT, state=state, label=f"1m {sym}") | |
| except Exception as e: | |
| state.log(f"Скрин: ошибка 1m свечей {sym}: {e}", "WARN") | |
| continue | |
| if len(ohlcv_1m) < 15: | |
| continue | |
| 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] | |
| # "На самом дне" = RSI(1m) только что обновил локальный минимум за последние RSI_BOTTOM_LOOKBACK баров | |
| # И УЖЕ начал разворачиваться вверх (текущее значение >= предыдущего) - это отличает точку входа | |
| # от "ловли падающего ножа": мы ждем не саму просадку, а момент, когда просадка останавливается. | |
| is_rsi_bottom = is_rsi_local_bottom(rsi_array_1m, lookback=RSI_BOTTOM_LOOKBACK, confirm_bars=RSI_BOTTOM_CONFIRM_BARS) | |
| # Рассчитываем ленты Боллинджера на 1м (период 20, StdDev 2.0) | |
| _, _, lower_band = calculate_bollinger_bands(closes_1m, period=20, num_stdDev=2.0) | |
| # АДАПТИВНЫЙ ПОРОГ 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("🛡️"): | |
| # 🛡️ Консервативный: дивергенция на 15m при перепроданности + дно RSI на 1m + касание нижней ленты Боллинджера | |
| if has_divergence and is_rsi_bottom and rsi_val_1m <= dynamic_rsi_threshold and c["ask"] <= lower_band: | |
| 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 на 1m (в разумной зоне), ИЛИ дивергенция на 15m, ИЛИ пробой ленты Боллинджера | |
| if (is_rsi_bottom and rsi_val_1m <= dynamic_rsi_threshold) or has_divergence or c["ask"] <= lower_band: | |
| is_entry_triggered = True | |
| else: | |
| # ⚖️ Сбалансированный: дивергенция на 15m, либо (дно RSI на 1m + зеленая свеча), либо пробой нижней ленты Боллинджера | |
| 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 | |
| # ── Фильтр скорости падения (защита от входа в еще активно падающую монету) ── | |
| # Разворотные сигналы выше могли сработать на одной свече, даже если цена в моменте | |
| # продолжает активно падать. closes_1m уже загружены для RSI/Боллинджера - доп. запрос | |
| # к API не нужен. Если падение за последние FREEFALL_LOOKBACK_CANDLES 1m свечей все еще | |
| # превышает FREEFALL_MAX_DROP_PCT - входа не будет, независимо от остальных сигналов. | |
| if FREEFALL_GUARD_ENABLED and len(closes_1m) > FREEFALL_LOOKBACK_CANDLES: | |
| ref_price = closes_1m[-1 - FREEFALL_LOOKBACK_CANDLES] | |
| if ref_price > 0: | |
| recent_change_pct = (closes_1m[-1] - ref_price) / ref_price * 100.0 | |
| if recent_change_pct <= -FREEFALL_MAX_DROP_PCT: | |
| continue | |
| # ── "Мем-тир": аномально волатильные монеты требуют более строгого подтверждения ── | |
| # Определяем ПО ПОВЕДЕНИЮ (суточная волатильность), а не по хрупкому списку тикеров - | |
| # кейс PUMP показал, что на таких монетах RSI-дно + обычный всплеск объема могут | |
| # сработать на ложный, манипулятивный импульс, а не на реальный разворот. | |
| is_meme_tier = c["volatility_24h"] >= HIGH_VOLATILITY_MEME_THRESHOLD | |
| vol_multiplier = MEME_TIER_VOLUME_MULTIPLIER if is_meme_tier else VOLUME_CONFIRMATION_MULTIPLIER | |
| book_min_ratio = MEME_TIER_BOOK_MIN_RATIO if is_meme_tier else BOOK_IMBALANCE_MIN_RATIO | |
| # ── Подтверждение объемом ── | |
| # Разворотные индикаторы (RSI-дно, дивергенция, Боллинджер) сами по себе НЕ отличают | |
| # "настоящий отскок с реальным спросом" от "монета просто затихла и продолжит валиться | |
| # дальше на тонком объеме". Требуем, чтобы объем на последней 1m свече был заметно выше | |
| # среднего за последние VOLUME_CONFIRMATION_LOOKBACK свечей - это признак того, что в | |
| # разворот заходит реальный объем покупателей, а не штиль перед новым проливом. | |
| # Для мем-тира порог заметно строже (vol_multiplier). | |
| if VOLUME_CONFIRMATION_ENABLED: | |
| volumes_1m = [k[5] for k in ohlcv_1m] | |
| vol_window = volumes_1m[-(VOLUME_CONFIRMATION_LOOKBACK + 1):-1] | |
| avg_vol = sum(vol_window) / len(vol_window) if vol_window else 0 | |
| last_vol = volumes_1m[-1] | |
| if avg_vol <= 0 or last_vol < avg_vol * vol_multiplier: | |
| 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 | |
| # Верхняя граница - слишком глубокая просадка за 30 минут похожа на обвал, а не на дип | |
| if drawdown > MAX_DRAWDOWN_FROM_1M_HIGH_PCT / 100.0: | |
| continue | |
| # ── Дисбаланс ликвидности в стакане ── | |
| # Технические индикаторы работают только со свечами - они не видят реальный СПРОС на | |
| # покупку прямо сейчас. Проверяем стакан: если ниже текущей цены (bids) заметно больше | |
| # ликвидности, чем выше нее (asks) в пределах BOOK_IMBALANCE_DEPTH_PCT% - это похоже на | |
| # реальную поддержку от покупателей. Если наоборот (по факту сверху густой навес на | |
| # продажу) - монете, скорее всего, еще есть куда падать даже после "дна" по индикаторам. | |
| # Проверяем это ПОСЛЕДНИМ (после всех остальных, более дешевых фильтров), т.к. это | |
| # отдельный сетевой запрос стакана на каждого кандидата, а до этой строки доходят уже | |
| # немногие. Для мем-тира требуем более выраженный перевес (book_min_ratio). | |
| if BOOK_IMBALANCE_ENABLED: | |
| if not check_book_liquidity_imbalance(exchange, sym, c["ask"], state, min_ratio=book_min_ratio): | |
| continue | |
| # Считаем итоговый скоринг (чем ниже rsi_1h и rsi_val_1m, тем выше балл) | |
| # ВАЖНО: math.sqrt(drawdown), а не drawdown линейно. Линейный drawdown в числителе | |
| # систематически отдавал предпочтение монетам с САМЫМ экстремальным падением среди всех | |
| # кандидатов, прошедших фильтры - а именно такие монеты (в т.ч. force-include "самой | |
| # просевшей" ниже) статистически чаще оказываются настоящим обвалом (долго/не | |
| # восстанавливаются), чем "здоровым" дипом с тем же качеством остальных сигналов | |
| # (объем/спред/RSI). sqrt() сглаживает этот перекос, не убирая фактор просадки из скоринга. | |
| score = (math.sqrt(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, # доходности для последующего расчета корреляции (диверсификация слотов) | |
| }) | |
| scored.sort(key=lambda x: x["score"], reverse=True) | |
| state.market_snapshot = scored[:10] | |
| 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: | |
| # fetch_ticker - безопасно ретраить (чтение, без побочных эффектов на бирже) | |
| ticker = safe_call(exchange.fetch_ticker, symbol, state=state, label=f"buy ticker {symbol}") | |
| ask = ticker["ask"] | |
| 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: | |
| 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} (РЕАЛЬНАЯ цена), " | |
| 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}, notional approx {notional:.4f}", "INFO") | |
| # ВАЖНО: create_market_buy_order намеренно НЕ через safe_call/ретрай. | |
| # Если сеть оборвется уже ПОСЛЕ того, как биржа приняла ордер, слепой повтор | |
| # рискует купить в 2 раза больше запланированного (двойной вход на слот). | |
| # Один неудавшийся вход - не страшно (просто пропустим цикл), а вот | |
| # задвоенная покупка - реальная потеря контроля над бюджетом на слот. | |
| 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 = 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 | |
| slot.symbol = symbol | |
| slot.bought_qty = filled | |
| slot.buy_price_avg = avg_px | |
| slot.usdt_spent = avg_cost | |
| slot.target_profit_set = deal_profit | |
| state.log( | |
| f"[Слот {slot.index}] Buy исполнен: {filled} {symbol.replace('/USDT','')} " | |
| f"по approx {avg_px:.6f}, потрачено {avg_cost:.4f} USDT", "OK" | |
| ) | |
| state.save_persisted_state() | |
| return True | |
| except Exception as e: | |
| state.log(f"[Слот {slot.index}] Buy ОШИБКА: {e}", "ERR") | |
| return False | |
| def place_limit_sell(exchange: ccxt.binance, slot: Slot, state: BotState) -> bool: | |
| try: | |
| 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)) | |
| 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" | |
| ) | |
| # Продажа уже КУПЛЕННОЙ монеты - повторная попытка при сетевом сбое безопаснее, | |
| # чем при покупке: худший случай - вторая попытка получит "insufficient balance", | |
| # т.к. монета уже зарезервирована первым (успешным) ордером. | |
| order = safe_call(exchange.create_limit_sell_order, symbol, qty, sell_px, state=state, label=f"limit sell {symbol}") | |
| order_id = order["id"] | |
| slot.order_id = order_id | |
| slot.order_placed_at = datetime.now(timezone.utc) | |
| slot.sell_price_set = sell_px | |
| slot.status = "⏳ Ждем продажи" | |
| state.log(f"[Слот {slot.index}] LimitSell выставлен: ID={order_id}, цена={sell_px:.6f}", "OK") | |
| state.save_persisted_state() | |
| return True | |
| except Exception as e: | |
| state.log(f"[Слот {slot.index}] LimitSell ОШИБКА: {e}", "ERR") | |
| return False | |
| def maybe_decay_stale_target(exchange: ccxt.binance, slot: Slot, state: BotState): | |
| """ | |
| Для позиций, которые висят намного дольше обычного и до сих пор не достигли цели продажи, | |
| постепенно СНИЖАЕТ требуемую прибыль (target_profit_set), а затем перевыставляет лимит-ордер | |
| по новой, более скромной цели. Это НЕ стоп-лосс - мы никогда не продаем принудительно в | |
| минус, просто со временем делаем изначально заданную цель прибыли менее оптимистичной, чтобы | |
| позиции было легче закрыться (например, в 0.02 USDT профита вместо изначальных 0.09), а не | |
| ждать полного отката цены к исходному таргету неделями. Никогда не опускает цель ниже | |
| STALE_POSITION_MIN_PROFIT_FLOOR_PCT % от суммы сделки. Применяется не чаще, чем раз в | |
| STALE_POSITION_DECAY_INTERVAL_MIN минут на слот (не спамит переустановку ордера). | |
| """ | |
| if not slot.order_placed_at: | |
| return | |
| elapsed_min = (datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60.0 | |
| if elapsed_min < STALE_POSITION_DECAY_START_MIN: | |
| return | |
| now = time.time() | |
| if now - slot.last_decay_at < STALE_POSITION_DECAY_INTERVAL_MIN * 60: | |
| return | |
| floor_profit = slot.usdt_spent * (STALE_POSITION_MIN_PROFIT_FLOOR_PCT / 100.0) | |
| if slot.target_profit_set <= floor_profit * 1.001: | |
| slot.last_decay_at = now # уже на полу - не пересчитываем зря каждый цикл, но и не спамим лог | |
| return | |
| old_target_profit = slot.target_profit_set | |
| new_target_profit = max(floor_profit, slot.target_profit_set * (1 - STALE_POSITION_DECAY_STEP_PCT)) | |
| slot.target_profit_set = new_target_profit | |
| slot.last_decay_at = now | |
| state.log( | |
| f"[Слот {slot.index}] 📉 Убывающий ТП: {slot.symbol} висит {elapsed_min:.0f}м без исполнения, " | |
| f"снижаю цель прибыли {old_target_profit:.4f} -> {new_target_profit:.4f} USDT " | |
| f"(минимальный пол: {floor_profit:.4f})", "WARN" | |
| ) | |
| if not state.paper_trading and slot.order_id: | |
| try: | |
| safe_call(exchange.cancel_order, slot.order_id, slot.symbol, state=state, label=f"decay cancel {slot.symbol}") | |
| except Exception as e: | |
| state.log(f"[Слот {slot.index}] Убывающий ТП: не удалось отменить старый ордер: {e} - пробую перевыставить все равно", "WARN") | |
| if not place_limit_sell(exchange, slot, state): | |
| state.log(f"[Слот {slot.index}] Убывающий ТП: не удалось выставить новый лимит-ордер -> экстренная продажа", "ERR") | |
| panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") | |
| def panic_sell_slot(exchange: ccxt.binance, slot: Slot, state: BotState, prefix: str = "🚨 PANIC") -> bool: | |
| """Обертка над _panic_sell_slot_impl: гарантирует сохранение состояния на диск | |
| при любом исходе (успех/ошибка), не дублируя save_persisted_state() на каждый return.""" | |
| try: | |
| return _panic_sell_slot_impl(exchange, slot, state, prefix) | |
| finally: | |
| state.save_persisted_state() | |
| def _panic_sell_slot_impl(exchange: ccxt.binance, slot: Slot, state: BotState, prefix: str = "🚨 PANIC") -> bool: | |
| """ | |
| Продает позицию слота (по рынку) и очищает слот - но ТОЛЬКО если продажа реально прошла | |
| или на балансе уже нечего продавать. Если рыночная продажа падает с ошибкой, слот НЕ | |
| очищается: symbol/bought_qty/buy_price_avg/usdt_spent остаются как есть, чтобы бот не | |
| "забывал" про реально купленную монету, пока она физически висит на балансе биржи. | |
| Возвращает True, если позиция закрыта (слот очищен), False - если нужна повторная попытка. | |
| """ | |
| sym = slot.symbol | |
| state.log(f"{prefix} [Слот {slot.index}]: принудительный/таймаут выход из {sym}", "WARN") | |
| if state.paper_trading: | |
| try: | |
| ticker = safe_call(exchange.fetch_ticker, sym, state=state, label=f"panic 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 | |
| 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, | |
| }) | |
| except Exception as e: | |
| state.log( | |
| f"📝 {prefix} [Слот {slot.index}]: ошибка получения цены для виртуального выхода: {e} - " | |
| f"слот НЕ очищен, повтори выход вручную", "ERR" | |
| ) | |
| slot.status = f"🔴 Ошибка выхода ({prefix}) - повтори Panic Sell" | |
| return False | |
| slot.clear() | |
| return True | |
| if slot.order_id: | |
| try: | |
| safe_call(exchange.cancel_order, slot.order_id, sym, state=state, label=f"panic cancel {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 = safe_call(exchange.fetch_balance, state=state, label=f"panic balance {base}") | |
| 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 True | |
| qty_sell = float(exchange.amount_to_precision(sym, qty_free)) | |
| order = safe_call(exchange.create_market_sell_order, sym, qty_sell, state=state, label=f"panic sell {sym}") | |
| 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}]: продано {qty_sell} {base}, получено approx {received:.4f} USDT. " | |
| f"изменение approx {loss:.4f} USDT", "WARN" | |
| ) | |
| slot.clear() | |
| return True | |
| except Exception as e: | |
| state.log( | |
| f"{prefix} [Слот {slot.index}]: ОШИБКА рыночной продажи - {e}. Слот НЕ очищен: " | |
| f"{sym} и его цена покупки остаются под учетом бота, чтобы позиция не потерялась. " | |
| f"Проверь баланс на бирже вручную и повтори Panic Sell.", "ERR" | |
| ) | |
| slot.status = f"🔴 Ошибка выхода ({prefix}) - повтори Panic Sell вручную!" | |
| return False | |
| 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 | |
| for slot in slots_to_refill: | |
| slot.last_refill_attempt = now | |
| slot.status = "🔍 Поиск замены" | |
| occupied = state.occupied_symbols() | |
| need_n = len(slots_to_refill) | |
| # Полный ранжированный список кандидатов (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 = "🔍 Поиск" | |
| 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 | |
| ) | |
| 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: | |
| slot.status = f"🟡 Покупка {best['symbol']}" | |
| 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: | |
| state.log(f"[Слот {slot.index}] LimitSell не удался -> экстренная продажа слота", "ERR") | |
| panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") | |
| else: | |
| slot.clear() | |
| state.save_persisted_state() | |
| # ───────────────────────────────────────────── | |
| # ГЛАВНЫЙ ЦИКЛ ПОЛЛИНГА | |
| # ───────────────────────────────────────────── | |
| def process_occupied_slot(exchange: ccxt.binance, slot: Slot, state: BotState): | |
| """Обрабатывает один занятый слот: проверка тайм-аута/ротации/статуса ордера. | |
| Вызывается под 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 as e: | |
| state.log(f"[Слот {slot.index}] Ошибка при расчете плюсовой ротации: {e}", "WARN") | |
| # 3. Убывающий тейк-профит для зависших позиций (НЕ выход по рынку - просто смягчение | |
| # изначально заданной цели прибыли, чтобы облегчить закрытие вместо бесконечного ожидания) | |
| maybe_decay_stale_target(exchange, slot, state) | |
| 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.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, | |
| }) | |
| slot.clear() | |
| state.save_persisted_state() | |
| else: | |
| slot.last_known_price = current_bid | |
| 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}, " | |
| f"куплено={slot.usdt_spent:.4f}, продано={received:.4f}, " | |
| f"прибыль approx {profit:.4f} USDT 🎉", "DEAL" | |
| ) | |
| slot.clear() | |
| state.save_persisted_state() | |
| elif status in ("canceled", "expired"): | |
| state.log(f"[Слот {slot.index}] Ордер отменен/просрочен извне — выход по рынку", "WARN") | |
| panic_sell_slot(exchange, slot, state) | |
| else: | |
| elapsed_min = 0 | |
| if slot.order_placed_at: | |
| elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60) | |
| # Легкий доп. запрос цены для отображения незафиксированного P&L в UI (не критично при сбое) | |
| try: | |
| t = safe_call(exchange.fetch_ticker, slot.symbol, state=state, label=f"unrealized price {slot.symbol}") | |
| slot.last_known_price = t.get("bid") or t.get("last") or slot.last_known_price | |
| except Exception: | |
| pass | |
| 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 reconcile_persisted_slots(exchange: ccxt.binance, state: BotState): | |
| """ | |
| Восстанавливает слоты из файла на диске (переживших рестарт процесса/контейнера) и, | |
| для Live-режима, сверяет их с РЕАЛЬНЫМ балансом на бирже, прежде чем бот продолжит | |
| торговать - файлу вслепую не доверяем, т.к. позиция могла быть закрыта вручную, | |
| пока бот не работал. | |
| """ | |
| saved = state.load_persisted_state() | |
| if not saved: | |
| state.log("Персистентность: сохраненного состояния слотов не найдено - стартуем с чистого листа", "INFO") | |
| return | |
| if saved.get("paper_trading") != state.paper_trading: | |
| state.log( | |
| "Персистентность: сохраненное состояние относится к другому режиму (paper/live) - " | |
| "восстановление ПРОПУЩЕНО ради безопасности", "WARN" | |
| ) | |
| return | |
| saved_slots = saved.get("slots", []) | |
| restored_n = 0 | |
| for i, slot in enumerate(state.slots): | |
| if i >= len(saved_slots): | |
| continue | |
| sdict = saved_slots[i] | |
| if not sdict.get("symbol"): | |
| continue # слот был пуст на момент сохранения - восстанавливать нечего | |
| slot.load_from_dict(sdict) | |
| restored_n += 1 | |
| if state.paper_trading: | |
| # Виртуальная позиция - восстанавливаем как есть, цена дальше берется из реального рынка | |
| slot.status = f"♻️ Восстановлено после рестарта: {slot.symbol}" | |
| continue | |
| # ── Live: сверяем с реальным балансом на бирже ── | |
| try: | |
| base = slot.symbol.split("/")[0] | |
| balance = safe_call(exchange.fetch_balance, state=state, label=f"reconcile balance {base}") | |
| qty_total = float(balance.get("total", {}).get(base, 0) or 0) | |
| except Exception as e: | |
| state.log(f"[Слот {slot.index}] Сверка после рестарта: ошибка баланса {e} - слот НЕ восстановлен (безопасность)", "ERR") | |
| slot.clear() | |
| continue | |
| if qty_total < slot.bought_qty * 0.9: | |
| state.log( | |
| f"[Слот {slot.index}] Сверка после рестарта: на балансе {base}={qty_total:.6g}, " | |
| f"а по записи должно быть ~{slot.bought_qty:.6g} - позиция уже закрыта помимо бота " | |
| f"(продана вручную или файл устарел). Слот очищен.", "WARN" | |
| ) | |
| slot.clear() | |
| continue | |
| # Монета на месте - проверяем, жив ли лимитный ордер на продажу | |
| order_alive = False | |
| if slot.order_id: | |
| try: | |
| order = safe_call(exchange.fetch_order, slot.order_id, slot.symbol, state=state, label=f"reconcile order {slot.symbol}") | |
| order_alive = order.get("status") == "open" | |
| except Exception: | |
| order_alive = False | |
| if order_alive: | |
| slot.status = f"♻️ Восстановлено после рестарта: {slot.symbol} (лимит-ордер на месте)" | |
| state.log( | |
| f"[Слот {slot.index}] Сверка после рестарта: {slot.symbol} на балансе, " | |
| f"лимит-ордер ID={slot.order_id} все еще открыт - продолжаем как обычно", "OK" | |
| ) | |
| else: | |
| state.log( | |
| f"[Слот {slot.index}] Сверка после рестарта: {slot.symbol} на балансе, но лимит-ордер " | |
| f"не найден/не открыт - выставляю новый лимит-ордер на сохраненную цель {slot.sell_price_set:.6g}", "WARN" | |
| ) | |
| if not place_limit_sell(exchange, slot, state): | |
| state.log(f"[Слот {slot.index}] Не удалось переставить лимит-ордер после рестарта -> экстренная продажа", "ERR") | |
| panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") | |
| if restored_n: | |
| state.log( | |
| f"♻️ Персистентность: восстановлено {restored_n} слот(ов) из сохраненного состояния " | |
| f"(сохранено {saved.get('saved_at', '?')})", "OK" | |
| ) | |
| state.save_persisted_state() | |
| def reconcile_persisted_grading(exchange: ccxt.binance, state: BotState): | |
| """ | |
| Восстанавливает state.grading_positions (лесенка "Градации") из сохраненного состояния - | |
| до этого исправления оно вообще не переживало рестарт контейнера, из-за чего после редеплоя | |
| бот "забывал" про уже заполненные грейды и в Live рисковал докупать те же уровни повторно. | |
| В paper-режиме восстанавливаем как есть (виртуальный учет, бирже нечего сверять). | |
| В Live - сверяем накопленное qty по каждой монете с реальным балансом на бирже: если на | |
| балансе заметно меньше, чем должно быть по записи, считаем позицию по этой монете закрытой | |
| вручную/устаревшей и НЕ восстанавливаем ее (безопаснее начать грейдацию с нуля от нового | |
| скользящего хая, чем рисковать задвоением докупок поверх уже не существующей позиции). | |
| """ | |
| saved = state.load_persisted_state() | |
| if not saved: | |
| return | |
| if saved.get("paper_trading") != state.paper_trading: | |
| state.log( | |
| "Персистентность Градации: сохраненное состояние относится к другому режиму (paper/live) - " | |
| "восстановление ПРОПУЩЕНО ради безопасности", "WARN" | |
| ) | |
| return | |
| saved_grading = saved.get("grading_positions", {}) | |
| if not saved_grading: | |
| return | |
| restored_n = 0 | |
| for sym, sdict in saved_grading.items(): | |
| qty = float(sdict.get("qty", 0.0)) | |
| cost = float(sdict.get("cost", 0.0)) | |
| rolling_high = float(sdict.get("rolling_high", 0.0)) | |
| filled = set(sdict.get("filled", [])) | |
| if qty <= 0: | |
| continue # позиции по монете не было на момент сохранения - восстанавливать нечего | |
| if state.paper_trading: | |
| state.grading_positions[sym] = { | |
| "rolling_high": rolling_high, "filled": filled, "qty": qty, "cost": cost, | |
| } | |
| restored_n += 1 | |
| continue | |
| # ── Live: сверяем накопленное qty с реальным балансом на бирже ── | |
| try: | |
| base = sym.split("/")[0] | |
| balance = safe_call(exchange.fetch_balance, state=state, label=f"grading reconcile {base}") | |
| qty_total = float(balance.get("total", {}).get(base, 0) or 0) | |
| except Exception as e: | |
| state.log( | |
| f"Градация {sym}: сверка после рестарта - ошибка баланса {e}, позиция НЕ восстановлена (безопасность)", | |
| "ERR" | |
| ) | |
| continue | |
| if qty_total < qty * 0.9: | |
| state.log( | |
| f"Градация {sym}: сверка после рестарта - на балансе {qty_total:.6g}, а по записи " | |
| f"должно быть ~{qty:.6g} - позиция закрыта помимо бота. Грейды сброшены, старт с чистого листа.", | |
| "WARN" | |
| ) | |
| continue | |
| state.grading_positions[sym] = { | |
| "rolling_high": rolling_high, "filled": filled, "qty": qty, "cost": cost, | |
| } | |
| restored_n += 1 | |
| state.log( | |
| f"♻️ Градация {sym}: восстановлено {len(filled)} грейдов после рестарта, " | |
| f"qty={qty:.6g} подтверждено на балансе", "OK" | |
| ) | |
| if restored_n: | |
| state.log(f"♻️ Персистентность Градации: восстановлено позиций по {restored_n} монете(ам)", "OK") | |
| def trading_loop(state: BotState): | |
| state.log(f"Торговый цикл запущен — портфель из {state.num_slots} слотов, бюджет ${state.total_budget}", "INFO") | |
| if state.paper_trading: | |
| 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") | |
| # Восстанавливаем слоты, сохраненные до возможного рестарта процесса, и сверяем | |
| # их с реальным балансом на бирже (актуально в первую очередь для Live) | |
| reconcile_persisted_slots(exchange, state) | |
| # Восстанавливаем позиции режима "Градация" (лесенка по крупным монетам) - до этого | |
| # исправления они вообще не переживали рестарт контейнера (см. reconcile_persisted_grading) | |
| reconcile_persisted_grading(exchange, state) | |
| 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 | |
| # Динамический лимит: разрешаем пары, где Binance допускает ордер <= бюджета на слот | |
| # (с запасом 10%, чтобы не упираться в minNotional впритык из-за округления/комиссии) | |
| dynamic_notional_cap = per_slot | |
| allowed_symbols = get_allowed_symbols(exchange, max_notional=dynamic_notional_cap) | |
| state.allowed_symbols_count = len(allowed_symbols) | |
| state.last_symbols_refresh_at = time.time() | |
| state.log( | |
| f"Разрешенных ликвидных пар (minNotional <= ${dynamic_notional_cap:.2f}, бюджет/слот ${per_slot:.2f}): {len(allowed_symbols)}", | |
| "INFO" | |
| ) | |
| if not allowed_symbols: | |
| state.log("КРИТИЧНО: нет пар с подходящим minNotional при текущем бюджете на слот", "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: | |
| # Авто-масштабирование слотов - работает только в Live, | |
| # само себя троттлит по времени внутри (раз в час) | |
| maybe_autoscale_slots(exchange, state) | |
| # Раз в час пересчитываем список разрешенных пар под АКТУАЛЬНЫЙ бюджет на слот - | |
| # если депозит вырос (в т.ч. через авто-масштабирование выше), автоматически откроется больше монет | |
| if time.time() - state.last_symbols_refresh_at >= SYMBOLS_REFRESH_INTERVAL_SEC: | |
| try: | |
| new_per_slot = state.budget_per_slot() | |
| new_cap = new_per_slot | |
| new_allowed = get_allowed_symbols(exchange, max_notional=new_cap) | |
| if new_allowed: | |
| old_count = len(allowed_symbols) | |
| allowed_symbols = new_allowed | |
| state.allowed_symbols_count = len(allowed_symbols) | |
| if len(allowed_symbols) != old_count: | |
| state.log( | |
| f"🔄 Список пар обновлен: {old_count} -> {len(allowed_symbols)} " | |
| f"(бюджет/слот ${new_per_slot:.2f})", "INFO" | |
| ) | |
| except Exception as e: | |
| state.log(f"Ошибка обновления списка разрешенных пар: {e}", "WARN") | |
| state.last_symbols_refresh_at = time.time() | |
| 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() | |
| current_interval = state.effective_screener_interval() | |
| should_screen = (now - state.last_full_screen_at >= current_interval) or any( | |
| s.last_refill_attempt == 0.0 for s in empty_slots | |
| ) | |
| if should_screen: | |
| if current_interval > SCREENER_INTERVAL_SEC: | |
| state.log( | |
| f"⚠️ Авто-бэкофф скринера активен: интервал временно увеличен до {current_interval:.0f}с " | |
| f"из-за недавних сетевых ошибок/рейт-лимитов", "WARN" | |
| ) | |
| state.last_full_screen_at = now | |
| refill_empty_slots(exchange, allowed_symbols, empty_slots, state) | |
| 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: | |
| wait_left = max(0, int(state.effective_screener_interval() - (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 = "⏹ Остановлен" | |
| def maybe_cold_start_resume(state: BotState): | |
| """ | |
| Холодный старт: если сервер (контейнер HF Spaces) только что перезапустился/проснулся, | |
| а в сохраненном состоянии стоит is_running=True - автоматически поднимает фоновый | |
| trading_loop БЕЗ клика пользователя по кнопке "Запустить портфель". | |
| Срабатывает ровно один раз за жизнь процесса благодаря флагу на самом BotState | |
| (не модульной переменной - см. пояснение в BotState.__init__). | |
| """ | |
| with state._cold_start_lock: | |
| if state._cold_start_attempted: | |
| return | |
| state._cold_start_attempted = True | |
| if state.is_running: | |
| return # поток уже поднят в этом процессе - холодный старт не нужен | |
| saved = BotState.load_persisted_state() | |
| if not saved or not saved.get("is_running"): | |
| return | |
| state.log("♻️ Холодный старт: обнаружен флаг is_running=True в сохраненном состоянии - автозапуск торговли...", "INFO") | |
| # ── Восстанавливаем параметры торговли из сохраненного состояния ── | |
| state.paper_trading = saved.get("paper_trading", state.paper_trading) | |
| state.total_budget = saved.get("total_budget", state.total_budget) | |
| state.target_profit = saved.get("target_profit", state.target_profit) | |
| state.use_bnb_discount = saved.get("use_bnb_discount", state.use_bnb_discount) | |
| state.use_timeout = saved.get("use_timeout", state.use_timeout) | |
| state.order_timeout_min = saved.get("order_timeout_min", state.order_timeout_min) | |
| state.use_profit_exit = saved.get("use_profit_exit", state.use_profit_exit) | |
| state.profit_exit_timeout_min = saved.get("profit_exit_timeout_min", state.profit_exit_timeout_min) | |
| state.strategy_mode = saved.get("strategy_mode", state.strategy_mode) | |
| state.use_dynamic_tp = saved.get("use_dynamic_tp", state.use_dynamic_tp) | |
| state.max_correlation = saved.get("max_correlation", state.max_correlation) | |
| state.use_depth_check = saved.get("use_depth_check", state.use_depth_check) | |
| state.max_slippage_pct = saved.get("max_slippage_pct", state.max_slippage_pct) | |
| state.strategy_engine = saved.get("strategy_engine", state.strategy_engine) | |
| state.grading_symbols = saved.get("grading_symbols", state.grading_symbols) | |
| state.grading_budget = saved.get("grading_budget", state.grading_budget) | |
| state.auto_scale_slots = saved.get("auto_scale_slots", state.auto_scale_slots) | |
| state.proxy_url = saved.get("proxy_url", state.proxy_url) | |
| state.resize_slots(saved.get("num_slots", state.num_slots)) | |
| # ── API-ключи: ПРИОРИТЕТ у Секретов HF Spaces (переменные окружения) - они безопаснее, | |
| # чем ключи, лежащие в файле состояния/датасете. Файл - только запасной вариант, | |
| # и то ТОЛЬКО если пользователь сам явно включил "persist_api_keys_in_state" в UI. ── | |
| env_key = os.environ.get("BINANCE_API_KEY", "") | |
| env_secret = os.environ.get("BINANCE_API_SECRET", "") | |
| if env_key and env_secret: | |
| state.api_key = env_key | |
| state.api_secret = env_secret | |
| state.log("🔑 Холодный старт: API-ключи взяты из Секретов HF Spaces (приоритетный источник)", "OK") | |
| elif not state.paper_trading: | |
| saved_key = saved.get("api_key", "") | |
| saved_secret = saved.get("api_secret", "") | |
| if saved_key and saved_secret: | |
| state.api_key = saved_key | |
| state.api_secret = saved_secret | |
| state.log( | |
| "🔑 Холодный старт: Секреты HF Spaces не найдены, взял API-ключи из файла состояния " | |
| "(менее безопасно - рекомендуется прописать BINANCE_API_KEY/BINANCE_API_SECRET в Секретах Space)", | |
| "WARN", | |
| ) | |
| else: | |
| state.log( | |
| "🔴 Холодный старт: нет API-ключей ни в Секретах HF, ни в сохраненном состоянии - " | |
| "автозапуск Live невозможен, требуется ручной ввод ключей в UI", "ERR" | |
| ) | |
| return | |
| if not state.paper_trading and (not state.api_key or not state.api_secret): | |
| return | |
| state.is_running = True | |
| state.status = "🔍 Автозапуск после рестарта..." | |
| t = threading.Thread(target=trading_loop, args=(state,), daemon=True) | |
| t.start() | |
| state.log(f"♻️ Холодный старт: фоновый поток trading_loop запущен автоматически ({state.num_slots} слотов)", "OK") | |
| # ───────────────────────────────────────────── | |
| # STREAMLIT ИНТЕРФЕЙС | |
| # ───────────────────────────────────────────── | |
| def render_ui(): | |
| st.set_page_config( | |
| page_title="GVP Micro Scalper — Portfolio", | |
| page_icon="⚡", | |
| layout="wide", | |
| ) | |
| st.markdown(""" | |
| <style> | |
| .main { background-color: #0d1117; } | |
| .metric-card { | |
| background: #161b22; border: 1px solid #30363d; border-radius: 8px; | |
| padding: 16px; text-align: center; color: #e6edf3; | |
| } | |
| .status-bar { | |
| background: #161b22; border-left: 3px solid #f0b429; padding: 10px 16px; | |
| border-radius: 4px; color: #e6edf3; font-family: monospace; font-size: 14px; margin-bottom: 16px; | |
| } | |
| .log-box { | |
| background: #0d1117; border: 1px solid #21262d; border-radius: 6px; padding: 12px; | |
| font-family: monospace; font-size: 12px; color: #8b949e; max-height: 400px; overflow-y: auto; | |
| } | |
| .slot-card { | |
| background: #161b22; border: 1px solid #30363d; border-radius: 8px; | |
| padding: 14px; margin-bottom: 10px; color: #e6edf3; | |
| } | |
| .slot-card b { color: #ffffff; } | |
| .slot-card.occupied { border-left: 3px solid #1f9d55; } | |
| .slot-card.empty { border-left: 3px solid #555; opacity: 0.7; } | |
| .panic-section { | |
| background: #2d1b1b; border: 1px solid #7c2d2d; border-radius: 8px; | |
| padding: 10px 14px; margin-top: 8px; color: #e6edf3; | |
| } | |
| .screen-row { | |
| display: grid; grid-template-columns: 110px 90px 70px 70px 1fr; | |
| padding: 6px 4px; border-bottom: 1px solid #21262d; font-size: 12px; color: #c9d1d9; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| state = get_bot_state() | |
| maybe_cold_start_resume(state) # автозапуск trading_loop после рестарта контейнера, если is_running был True | |
| st.markdown("## ⚡ GVP Micro Scalper — Ротационный портфель") | |
| st.caption("Полный скрин рынка | N параллельных позиций | Ротация при фиксации прибыли | HF Spaces") | |
| env_badge = "📝 PAPER TRADING (симулятор на живом рынке)" if state.paper_trading else "🔴 LIVE (реальные деньги)" | |
| st.markdown( | |
| f'<div class="status-bar">{env_badge} | Status: <b>{state.status}</b></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| col1, col2, col3, col4, col5 = st.columns(5) | |
| with col1: | |
| st.metric("💰 Прибыль сессии", f"${state.total_profit:.4f}") | |
| with col2: | |
| st.metric("🔄 Ротаций", state.trades_count) | |
| with col3: | |
| occupied_n = sum(1 for s in state.slots if not s.is_empty()) | |
| st.metric("📦 Слотов занято", f"{occupied_n}/{state.num_slots}") | |
| with col4: | |
| st.metric("💵 Бюджет/слот", f"${state.budget_per_slot():.2f}") | |
| with col5: | |
| st.metric("🌐 Ликвидных пар", state.allowed_symbols_count or "—") | |
| st.divider() | |
| col_left, col_right = st.columns([1, 2]) | |
| with col_left: | |
| st.subheader("⚙️ Настройки портфеля") | |
| trading_mode = st.radio( | |
| "Режим торговли", | |
| options=["📝 Paper trading (симулятор)", "🔴 Live (реальные деньги)"], | |
| index=0 if state.paper_trading else 1, | |
| disabled=state.is_running, | |
| help="Paper trading: реальные цены с боевого Binance, виртуальные деньги, ключи НЕ нужны вообще. Live: настоящие сделки, настоящие деньги." | |
| ) | |
| paper_trading = trading_mode.startswith("📝") | |
| if paper_trading: | |
| st.success( | |
| "📝 Paper trading активен. Бот будет смотреть РЕАЛЬНЫЕ цены живого рынка и вести виртуальный учёт сделок — ни один реальный ордер создан не будет. API-ключи не требуются для этого режима.", | |
| icon="📝", | |
| ) | |
| else: | |
| st.error( | |
| "🔴 LIVE режим. Бот будет создавать РЕАЛЬНЫЕ ордера на твои РЕАЛЬНЫЕ деньги. Используй только после полноценной проверки в paper trading.", | |
| icon="🔴", | |
| ) | |
| with st.expander("🔑 API Ключи", expanded=not state.is_running and not paper_trading): | |
| if paper_trading: | |
| st.caption("Paper trading не требует ключей — бот использует только публичные данные рынка (data-api.binance.vision, не блокируется гео-фильтром).") | |
| api_key = "" | |
| api_secret = "" | |
| proxy_url = state.proxy_url | |
| persist_api_keys_in_state = False | |
| else: | |
| 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.divider() | |
| persist_api_keys_in_state = st.checkbox( | |
| "Сохранять API-ключи в файле состояния/HF Dataset для автозапуска после рестарта", | |
| value=state.persist_api_keys_in_state, | |
| help=( | |
| "Нужно ТОЛЬКО если ты не прописал BINANCE_API_KEY/BINANCE_API_SECRET в Секретах " | |
| "HF Space (это приоритетный и более безопасный способ - ключи тогда вообще не " | |
| "попадают в этот файл). Если включишь здесь - ключи в открытом виде уйдут в JSON " | |
| "на диске и, если настроен HF Dataset, в твой датасет (ВНИМАНИЕ: датасет теперь публичный!). Используй, только " | |
| "если понимаешь риск." | |
| ), | |
| ) | |
| st.markdown("**Параметры торговли**") | |
| 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 минимума! Увеличь бюджет или уменьши слоты.") | |
| else: | |
| growth = (target_profit + per_slot_preview * active_fee * 2) / per_slot_preview * 100 | |
| fee_label = "0.075% (BNB)" if use_bnb else "0.1% (стандарт)" | |
| st.info(f"📐 На слот: **${per_slot_preview:.2f}** | Требуемый рост: **{growth:.2f}%** | Комиссия: **{fee_label}**") | |
| if not state.is_running: | |
| if st.button("▶️ Запустить портфель", use_container_width=True, type="primary"): | |
| if not paper_trading and (not api_key or not api_secret): | |
| st.error("Введи API Key и Secret!") | |
| elif per_slot_preview < 1.05: | |
| st.error("Бюджет на слот недостаточен") | |
| else: | |
| state.paper_trading = paper_trading | |
| state.api_key = api_key | |
| state.api_secret = api_secret | |
| state.total_budget = total_budget | |
| state.target_profit = target_profit | |
| state.use_bnb_discount = use_bnb | |
| state.use_timeout = use_timeout | |
| state.order_timeout_min = order_timeout_min | |
| state.use_profit_exit = use_profit_exit | |
| state.profit_exit_timeout_min = profit_exit_timeout_min | |
| state.strategy_mode = strategy_mode | |
| state.use_dynamic_tp = use_dynamic_tp | |
| state.max_correlation = max_correlation | |
| 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.persist_api_keys_in_state = persist_api_keys_in_state | |
| state.resize_slots(num_slots) | |
| state.is_running = True | |
| state.logs = [] | |
| state.total_profit = 0.0 | |
| state.trades_count = 0 | |
| state.status = "🔍 Инициализация..." | |
| t = threading.Thread(target=trading_loop, args=(state,), daemon=True) | |
| t.start() | |
| state.save_persisted_state() # сразу фиксируем is_running=True - основа холодного автозапуска | |
| st.success(f"Портфель из {num_slots} слотов запущен!") | |
| st.rerun() | |
| else: | |
| if st.button("⏹ Остановить портфель", use_container_width=True, type="secondary"): | |
| state.is_running = False | |
| state.save_persisted_state() # фиксируем is_running=False - холодный автозапуск не сработает зря | |
| st.warning("Сигнал остановки отправлен. Открытые позиции НЕ закрываются автоматически — используй Panic для каждого слота при необходимости.") | |
| st.rerun() | |
| st.divider() | |
| st.markdown("**🔄 Сброс для чистого теста стратегии**") | |
| st.caption( | |
| "Полностью обнуляет прибыль сессии, ротации, журнал сделок, лог и все слоты (включая " | |
| "сохраненное состояние на диске/HF Dataset) — чтобы гонять новую версию стратегии с " | |
| "нуля, не смешивая её результаты со старыми цифрами. Доступно только когда бот остановлен." | |
| ) | |
| confirm_reset = st.checkbox( | |
| "Понимаю: это необратимо сотрет статистику/слоты. Если есть открытые LIVE-позиции, " | |
| "сначала закрою их Panic Sell — сброс их НЕ продаст на бирже, только забудет в боте.", | |
| key="confirm_full_reset", # Убрали конфликтующий value=False | |
| ) | |
| # Кнопка теперь всегда нажимается, защита находится внутри | |
| if st.button("🔄 Сбросить всё и начать заново", use_container_width=True): | |
| if state.is_running: | |
| st.warning("⚠️ Бот должен быть остановлен для сброса (нажмите 'Остановить портфель').") | |
| elif not confirm_reset: | |
| st.warning("⚠️ Сначала подтвердите сброс, поставив галочку выше.") | |
| else: | |
| has_open = any(not s.is_empty() for s in state.slots) | |
| if has_open and not state.paper_trading: | |
| st.error( | |
| "Есть открытые LIVE-позиции в слотах. Сначала закрой их через Panic Sell — " | |
| "иначе бот забудет про реальные монеты у тебя на балансе биржи." | |
| ) | |
| else: | |
| state.slots = [Slot(index=i) for i in range(state.num_slots)] | |
| state.paper_trades = [] | |
| state.logs = [] | |
| state.total_profit = 0.0 | |
| state.trades_count = 0 | |
| state.market_snapshot = [] | |
| state.grading_positions = {} | |
| state.last_full_screen_at = 0.0 | |
| state.last_autoscale_check_at = 0.0 | |
| state.recent_network_errors = [] | |
| state.status = "⏹ Остановлен (после сброса)" | |
| state.save_persisted_state() | |
| # Снимаем галочку автоматически после успешного сброса | |
| st.session_state["confirm_full_reset"] = False | |
| st.success("Готово — статистика и слоты обнулены (в т.ч. в сохраненном состоянии). Можно запускать новый тест.") | |
| 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: | |
| df = pd.DataFrame(state.market_snapshot) | |
| df = df[["symbol", "score", "drawdown", "rsi", "volatility_24h", "volume", "spread"]].copy() | |
| df["score"] = df["score"].round(1) | |
| df["rsi"] = df["rsi"].round(1) | |
| df["drawdown"] = (df["drawdown"] * 100).round(2).astype(str) + "%" | |
| df["volatility_24h"] = (df["volatility_24h"] * 100).round(2).astype(str) + "%" | |
| df["spread"] = (df["spread"] * 100).round(3).astype(str) + "%" | |
| df["volume"] = df["volume"].round(0).astype(int) | |
| st.dataframe(df, use_container_width=True, hide_index=True) | |
| else: | |
| st.caption("Скрин еще не выполнялся.") | |
| with col_right: | |
| st.subheader(f"📦 Слоты портфеля ({state.num_slots})") | |
| for slot in state.slots: | |
| css_class = "slot-card occupied" if not slot.is_empty() else "slot-card empty" | |
| if slot.is_empty(): | |
| st.markdown(f""" | |
| <div class="{css_class}"> | |
| <b>Слот {slot.index}</b> — 🔍 Пусто, ищем монету... | |
| </div> | |
| """, unsafe_allow_html=True) | |
| else: | |
| 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 " (безлимит)" | |
| # Незафиксированный P&L по последней увиденной цене - для ручного решения "закрыть/держать" | |
| if slot.last_known_price > 0: | |
| unrealized_usd = slot.last_known_price * slot.bought_qty - slot.usdt_spent | |
| unrealized_cents = unrealized_usd * 100 | |
| pnl_color = "#3fb950" if unrealized_usd >= 0 else "#f85149" | |
| pnl_sign = "+" if unrealized_usd >= 0 else "" | |
| pnl_html = ( | |
| f'<span style="color:{pnl_color}; font-weight:600;">' | |
| f'{pnl_sign}{unrealized_usd:.4f} USDT ({pnl_sign}{unrealized_cents:.1f}¢)</span>' | |
| ) | |
| else: | |
| pnl_html = '<span style="color:#8b949e;">— (цена еще не проверялась)</span>' | |
| st.markdown(f""" | |
| <div class="{css_class}"> | |
| <b>Слот {slot.index}: {slot.symbol}</b><br> | |
| Куплено: {slot.bought_qty:.4f} по {slot.buy_price_avg:.6g} ({slot.usdt_spent:.4f} USDT)<br> | |
| Цель продажи: <b>{slot.sell_price_set:.6g}</b> (цель profit: {slot.target_profit_set:.4f} USDT)<br> | |
| Незафиксированный P&L: {pnl_html}<br> | |
| Статус: {slot.status} | Удержание: {elapsed_min} мин{timeout_str} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if state.is_running: | |
| if st.button(f"🚨 Panic Sell слот {slot.index}", key=f"panic_{slot.index}"): | |
| 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) | |
| # Лочим тот же slot_lock, что и фоновый trading_loop - | |
| # исключает гонку потоков, если бот в этот момент как раз обрабатывает этот слот | |
| with state.slot_lock: | |
| closed_ok = panic_sell_slot(exchange, slot, state, prefix="🚨 PANIC") | |
| if closed_ok: | |
| st.warning(f"Слот {slot.index} закрыт принудительно!") | |
| else: | |
| st.error( | |
| f"Слот {slot.index}: продажа не удалась - позиция НЕ очищена из учета бота " | |
| f"(проверь баланс на бирже и попробуй Panic Sell еще раз, см. лог)." | |
| ) | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Ошибка Panic Sell: {e}") | |
| # ── Сводная таблица открытых позиций: кто в плюсе, кто в просадке, сколько по времени ── | |
| occupied_slots = [s for s in state.slots if not s.is_empty()] | |
| if occupied_slots: | |
| st.divider() | |
| st.subheader("📊 Открытые позиции — незафиксированный P&L") | |
| rows = [] | |
| for s in occupied_slots: | |
| elapsed_min = 0 | |
| if s.order_placed_at: | |
| elapsed_min = int((datetime.now(timezone.utc) - s.order_placed_at).total_seconds() / 60) | |
| if s.last_known_price > 0: | |
| u_usd = s.last_known_price * s.bought_qty - s.usdt_spent | |
| u_cents = u_usd * 100 | |
| pnl_str = f"{'+' if u_usd >= 0 else ''}{u_usd:.4f} USDT" | |
| cents_str = f"{'+' if u_cents >= 0 else ''}{u_cents:.1f}¢" | |
| emoji = "🟢" if u_usd >= 0 else "🔴" | |
| else: | |
| pnl_str, cents_str, emoji = "—", "—", "⏳" | |
| rows.append({ | |
| "": emoji, | |
| "Слот": s.index, | |
| "Монета": s.symbol, | |
| "Цена покупки": round(s.buy_price_avg, 6), | |
| "Тек. цена": round(s.last_known_price, 6) if s.last_known_price > 0 else "—", | |
| "P&L (USDT)": pnl_str, | |
| "P&L (центы)": cents_str, | |
| "В сделке (мин)": elapsed_min, | |
| }) | |
| st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) | |
| # ── Таблица виртуальных сделок ── | |
| st.divider() | |
| st.subheader("📝 Журнал сделок (Paper Trading)") | |
| if state.paper_trades: | |
| trades_df = pd.DataFrame(state.paper_trades) | |
| cols = ["time", "symbol", "action", "slot", "price", "qty", "usdt_value", "profit"] | |
| for c in cols: | |
| if c not in trades_df.columns: | |
| trades_df[c] = None | |
| trades_df = trades_df[cols].copy() | |
| trades_df["action"] = trades_df["action"].str.upper() | |
| trades_df["price"] = trades_df["price"].round(6) | |
| trades_df["qty"] = trades_df["qty"].round(6) | |
| trades_df["usdt_value"] = trades_df["usdt_value"].round(4) | |
| trades_df["profit"] = trades_df["profit"].apply(lambda x: f"${x:.4f}" if (pd.notnull(x) and x is not None) else "—") | |
| st.dataframe(trades_df, use_container_width=True, hide_index=True) | |
| else: | |
| st.caption("Сделок еще не зафиксировано.") | |
| # ── График сделок (Candlestick + отметки входов/выходов) ── | |
| st.divider() | |
| st.subheader("📈 График сделок") | |
| traded_symbols = sorted({t["symbol"] for t in state.paper_trades}) if state.paper_trades else [] | |
| if traded_symbols: | |
| col_sel, col_btn = st.columns([3, 1]) | |
| with col_sel: | |
| chart_symbol = st.selectbox("Тикер для графика", options=traded_symbols, key="chart_symbol_select") | |
| with col_btn: | |
| st.write("") | |
| st.write("") | |
| build_chart = st.button("📊 Построить график", use_container_width=True) | |
| if build_chart: | |
| try: | |
| 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: | |
| st.warning(f"Не удалось получить свечи для {chart_symbol}") | |
| else: | |
| df_chart = pd.DataFrame(ohlcv, columns=["ts", "open", "high", "low", "close", "volume"]) | |
| df_chart["dt"] = pd.to_datetime(df_chart["ts"], unit="ms", utc=True) | |
| fig = go.Figure(data=[go.Candlestick( | |
| x=df_chart["dt"], | |
| open=df_chart["open"], | |
| high=df_chart["high"], | |
| low=df_chart["low"], | |
| close=df_chart["close"], | |
| name=chart_symbol, | |
| increasing_line_color="#26a69a", | |
| decreasing_line_color="#ef5350", | |
| )]) | |
| # Фильтруем сделки бота ровно по выбранному символу | |
| symbol_trades = [t for t in state.paper_trades if t["symbol"] == chart_symbol] | |
| buy_x, buy_y, buy_txt = [], [], [] | |
| sell_x, sell_y, sell_txt = [], [], [] | |
| for t in symbol_trades: | |
| raw_time = t.get("time", "") | |
| try: | |
| # Формат "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 action == "BUY": | |
| buy_x.append(t_dt) | |
| buy_y.append(price) | |
| buy_txt.append(f"BUY slot={t.get('slot')}<br>{price:.6g}") | |
| elif any(k in action for k in ("SELL", "ROTATION", "PANIC", "TIMEOUT")): | |
| sell_x.append(t_dt) | |
| sell_y.append(price) | |
| profit = t.get("profit") | |
| p_str = f"{profit:.4f}" if profit is not None else "—" | |
| sell_txt.append(f"{action} slot={t.get('slot')}<br>{price:.6g} | profit={p_str}") | |
| 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")), | |
| 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")), | |
| 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", | |
| 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: | |
| st.error(f"Ошибка построения графика: {e}") | |
| else: | |
| st.caption("Пока нет сделок для отображения графика — дождись первой покупки бота.") | |
| st.divider() | |
| st.subheader("📋 Лог торговли") | |
| log_lines = list(state.logs) | |
| if log_lines: | |
| log_text = "\n".join(reversed(log_lines)) | |
| st.markdown(f'<div class="log-box">{log_text}</div>', unsafe_allow_html=True) | |
| else: | |
| st.info("Лог пуст. Запусти бота для начала работы.") | |
| if state.is_running: | |
| if st.button("🔄 Обновить"): | |
| st.rerun() | |
| st.divider() | |
| st.markdown(""" | |
| <div style="color:#8b949e; font-size:11px; text-align:center; padding:8px;"> | |
| ⚠️ Торговля криптовалютой сопряжена с высоким риском потери капитала. | |
| Этот бот предназначен для образовательных целей. Используй только те средства, | |
| которые готов потерять полностью. Прошлые результаты не гарантируют будущих. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if __name__ == "__main__": | |
| render_ui() |