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