Askhat777 commited on
Commit
cc14bc8
·
verified ·
1 Parent(s): f12fb74

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +172 -2
app.py CHANGED
@@ -36,6 +36,17 @@ EXCHANGE_RETRY_ATTEMPTS = 3 # Кол-во повторных попыт
36
  EXCHANGE_RETRY_DELAY_SEC = 1.5 # Базовая задержка между повторными попытками (экспоненциально растет)
37
  RSI_BOTTOM_LOOKBACK = 5 # Окно свечей для поиска локального дна RSI
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  # ── Авто-масштабирование слотов по росту депозита ──
40
  SLOT_SCALING_TABLE = [
41
  (20, 5),
@@ -225,6 +236,10 @@ class BotState:
225
  self.grading_budget = 0.0 # Отдельный бюджет под режим градации (не пересекается с total_budget портфеля)
226
  self.grading_positions: dict[str, dict] = {} # symbol -> {"rolling_high", "filled": set(), "qty", "cost"}
227
 
 
 
 
 
228
  def budget_per_slot(self) -> float:
229
  if self.num_slots <= 0:
230
  return 0.0
@@ -263,6 +278,32 @@ class BotState:
263
  def occupied_symbols(self) -> set[str]:
264
  return {s.symbol for s in self.slots if s.symbol is not None}
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  def save_persisted_state(self):
267
  """
268
  Сохраняет слоты, статистику И параметры торговли на диск + асинхронно в публичный
@@ -293,6 +334,18 @@ class BotState:
293
  "strategy_engine": self.strategy_engine,
294
  "grading_symbols": self.grading_symbols,
295
  "grading_budget": self.grading_budget,
 
 
 
 
 
 
 
 
 
 
 
 
296
  "auto_scale_slots": self.auto_scale_slots,
297
  "proxy_url": self.proxy_url,
298
  "total_profit": self.total_profit,
@@ -460,6 +513,7 @@ def safe_call(fn, *args, state: "BotState | None" = None, label: str = "exchange
460
  last_err = e
461
  if state:
462
  state.log(f"{label}: временная ошибка сети (попытка {attempt}/{EXCHANGE_RETRY_ATTEMPTS}): {e}", "WARN")
 
463
  time.sleep(EXCHANGE_RETRY_DELAY_SEC * attempt)
464
  except ccxt.ExchangeError as e:
465
  # Ошибки биржи (неверные параметры, отклоненный ордер и т.п.) ретраить бессмысленно - пробрасываем сразу
@@ -873,6 +927,35 @@ def screen_full_market(
873
 
874
  candidates.sort(key=lambda x: x["vol"], reverse=True)
875
  batch = candidates[:CANDLE_FETCH_BATCH_SIZE]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
876
  state.log(f"Анализируем индикаторами топ-{len(batch)} по объему", "INFO")
877
 
878
  scored: list[dict] = []
@@ -1527,6 +1610,83 @@ def reconcile_persisted_slots(exchange: ccxt.binance, state: BotState):
1527
  state.save_persisted_state()
1528
 
1529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1530
  def trading_loop(state: BotState):
1531
  state.log(f"Торговый цикл запущен — портфель из {state.num_slots} слотов, бюджет ${state.total_budget}", "INFO")
1532
  if state.paper_trading:
@@ -1546,6 +1706,10 @@ def trading_loop(state: BotState):
1546
  # их с реальным балансом на бирже (актуально в первую очередь для Live)
1547
  reconcile_persisted_slots(exchange, state)
1548
 
 
 
 
 
1549
  per_slot = state.budget_per_slot()
1550
  if per_slot * 1.001 < 1.0:
1551
  state.log(
@@ -1625,10 +1789,16 @@ def trading_loop(state: BotState):
1625
 
1626
  if empty_slots:
1627
  now = time.time()
1628
- should_screen = (now - state.last_full_screen_at >= SCREENER_INTERVAL_SEC) or any(
 
1629
  s.last_refill_attempt == 0.0 for s in empty_slots
1630
  )
1631
  if should_screen:
 
 
 
 
 
1632
  state.last_full_screen_at = now
1633
  refill_empty_slots(exchange, allowed_symbols, empty_slots, state)
1634
 
@@ -1636,7 +1806,7 @@ def trading_loop(state: BotState):
1636
  if occupied_n == state.num_slots:
1637
  state.status = f"🟢 Портфель полон ({occupied_n}/{state.num_slots})"
1638
  elif occupied_n == 0:
1639
- wait_left = max(0, int(SCREENER_INTERVAL_SEC - (time.time() - state.last_full_screen_at)))
1640
  state.status = f"🔍 Поиск ({occupied_n}/{state.num_slots}) · след. скрин ~{wait_left}с"
1641
  else:
1642
  state.status = f"🟡 Частично занят ({occupied_n}/{state.num_slots})"
 
36
  EXCHANGE_RETRY_DELAY_SEC = 1.5 # Базовая задержка между повторными попытками (экспоненциально растет)
37
  RSI_BOTTOM_LOOKBACK = 5 # Окно свечей для поиска локального дна RSI
38
 
39
+ # ── Авто-бэкофф скринера при сетевых сбоях ──
40
+ # SCREENER_INTERVAL_SEC=90 + CANDLE_FETCH_BATCH_SIZE=55 дают до ~165 запросов свечей за один скрин
41
+ # (3 таймфрейма x 55 монет) плюс fetch_tickers - на коротком интервале это ощутимо чаще бьет по
42
+ # рейт-лимитам биржи, чем было при 300 сек. Вместо того чтобы просто зашить более длинный интервал
43
+ # (что убьет смысл ускорения входов), бот сам временно увеличивает интервал скрина, если видит
44
+ # всплеск сетевых ошибок/рейт-лимитов от safe_call, и возвращается к нормальному интервалу,
45
+ # когда сеть/лимиты снова спокойны.
46
+ SCREENER_ERROR_WINDOW_SEC = 300 # Окно, за которое считаем недавние сетевые ошибки
47
+ SCREENER_ERROR_BACKOFF_THRESHOLD = 5 # Сколько ошибок в окне триггерят бэкофф
48
+ SCREENER_BACKOFF_MULTIPLIER = 3 # Во сколько раз увеличиваем интервал скрина во время бэкоффа
49
+
50
  # ── Авто-масштабирование слотов по росту депозита ──
51
  SLOT_SCALING_TABLE = [
52
  (20, 5),
 
236
  self.grading_budget = 0.0 # Отдельный бюджет под режим градации (не пересекается с total_budget портфеля)
237
  self.grading_positions: dict[str, dict] = {} # symbol -> {"rolling_high", "filled": set(), "qty", "cost"}
238
 
239
+ # ── Авто-бэкофф скринера при всплеске сетевых ошибок ──
240
+ self._network_error_lock = threading.Lock()
241
+ self.recent_network_errors: list[float] = [] # метки времени недавних сетевых ошибок (для скользящего окна)
242
+
243
  def budget_per_slot(self) -> float:
244
  if self.num_slots <= 0:
245
  return 0.0
 
278
  def occupied_symbols(self) -> set[str]:
279
  return {s.symbol for s in self.slots if s.symbol is not None}
280
 
281
+ def register_network_error(self):
282
+ """Фиксирует метку времени сетевой ошибки биржи (вызывается из safe_call).
283
+ Используется для авто-бэкоффа интервала скринера при всплеске сбоев/рейт-лимитов."""
284
+ now = time.time()
285
+ with self._network_error_lock:
286
+ self.recent_network_errors.append(now)
287
+ cutoff = now - SCREENER_ERROR_WINDOW_SEC
288
+ self.recent_network_errors = [t for t in self.recent_network_errors if t >= cutoff]
289
+
290
+ def effective_screener_interval(self) -> float:
291
+ """
292
+ Возвращает интервал между полными скринами рынка: обычно SCREENER_INTERVAL_SEC,
293
+ но временно увеличенный в SCREENER_BACKOFF_MULTIPLIER раз, если за последние
294
+ SCREENER_ERROR_WINDOW_SEC секунд накопилось SCREENER_ERROR_BACKOFF_THRESHOLD+ сетевых
295
+ ошибок - защита от того, чтобы частый скрин (много запросов свечей за короткий интервал)
296
+ не долбил биржу еще сильнее, когда она уже отвечает сбоями/рейт-лимитами.
297
+ """
298
+ now = time.time()
299
+ with self._network_error_lock:
300
+ cutoff = now - SCREENER_ERROR_WINDOW_SEC
301
+ self.recent_network_errors = [t for t in self.recent_network_errors if t >= cutoff]
302
+ error_count = len(self.recent_network_errors)
303
+ if error_count >= SCREENER_ERROR_BACKOFF_THRESHOLD:
304
+ return SCREENER_INTERVAL_SEC * SCREENER_BACKOFF_MULTIPLIER
305
+ return SCREENER_INTERVAL_SEC
306
+
307
  def save_persisted_state(self):
308
  """
309
  Сохраняет слоты, статистику И параметры торговли на диск + асинхронно в публичный
 
334
  "strategy_engine": self.strategy_engine,
335
  "grading_symbols": self.grading_symbols,
336
  "grading_budget": self.grading_budget,
337
+ # "filled" внутри позиции - это set(), json не умеет его сериализовать напрямую,
338
+ # поэтому конвертируем в list только на момент сохранения (см. load_from_dict-аналог
339
+ # при восстановлении - reconcile_persisted_grading конвертирует list обратно в set).
340
+ "grading_positions": {
341
+ sym: {
342
+ "rolling_high": pos.get("rolling_high", 0.0),
343
+ "filled": sorted(pos.get("filled", set())),
344
+ "qty": pos.get("qty", 0.0),
345
+ "cost": pos.get("cost", 0.0),
346
+ }
347
+ for sym, pos in self.grading_positions.items()
348
+ },
349
  "auto_scale_slots": self.auto_scale_slots,
350
  "proxy_url": self.proxy_url,
351
  "total_profit": self.total_profit,
 
513
  last_err = e
514
  if state:
515
  state.log(f"{label}: временная ошибка сети (попытка {attempt}/{EXCHANGE_RETRY_ATTEMPTS}): {e}", "WARN")
516
+ state.register_network_error() # авто-бэкофф скринера при накоплении таких ошибок
517
  time.sleep(EXCHANGE_RETRY_DELAY_SEC * attempt)
518
  except ccxt.ExchangeError as e:
519
  # Ошибки биржи (неверные параметры, отклоненный ордер и т.п.) ретраить бессмысленно - пробрасываем сразу
 
927
 
928
  candidates.sort(key=lambda x: x["vol"], reverse=True)
929
  batch = candidates[:CANDLE_FETCH_BATCH_SIZE]
930
+
931
+ # ── Принудительное включение "самой просевшей" монеты ──
932
+ # Обычный батч режется по топ-CANDLE_FETCH_BATCH_SIZE по ОБЪЕМУ, поэтому монета с рекордной
933
+ # просадкой от суточного хая, но за пределами этого топа по объему, раньше вообще не доходила
934
+ # до анализа индикаторами и не могла попасть в scored, даже если по факту сильнее всех просела.
935
+ # Здесь ищем такую монету среди ВСЕХ кандидатов, прошедших базовый фильтр объема/спреда
936
+ # (а не только среди топ-N по объему), и добавляем её в батч на анализ, если она там еще не
937
+ # присутствует. Это НЕ обходит фильтры входа (дивергенция/RSI/Боллинджер, адаптивная просадка) -
938
+ # монета все равно должна пройти is_entry_triggered ниже, просто ей гарантированно дают шанс.
939
+ most_dropped = None
940
+ best_drawdown_24h = -1.0
941
+ for c in candidates:
942
+ high_24h = c["high_24h"]
943
+ if high_24h <= 0:
944
+ continue
945
+ drawdown_24h = (high_24h - c["ask"]) / high_24h
946
+ if drawdown_24h > best_drawdown_24h:
947
+ best_drawdown_24h = drawdown_24h
948
+ most_dropped = c
949
+
950
+ batch_symbols = {c["symbol"] for c in batch}
951
+ if most_dropped is not None and most_dropped["symbol"] not in batch_symbols:
952
+ batch = batch + [most_dropped]
953
+ state.log(
954
+ f"📉 Принудительно добавлена в анализ самая просевшая монета вне топа по объему: "
955
+ f"{most_dropped['symbol']} (просадка от суточного хая {best_drawdown_24h*100:.2f}%)",
956
+ "INFO"
957
+ )
958
+
959
  state.log(f"Анализируем индикаторами топ-{len(batch)} по объему", "INFO")
960
 
961
  scored: list[dict] = []
 
1610
  state.save_persisted_state()
1611
 
1612
 
1613
+ def reconcile_persisted_grading(exchange: ccxt.binance, state: BotState):
1614
+ """
1615
+ Восстанавливает state.grading_positions (лесенка "Градации") из сохраненного состояния -
1616
+ до этого исправления оно вообще не переживало рестарт контейнера, из-за чего после редеплоя
1617
+ бот "забывал" про уже заполненные грейды и в Live рисковал докупать те же уровни повторно.
1618
+
1619
+ В paper-режиме восстанавливаем как есть (виртуальный учет, бирже нечего сверять).
1620
+ В Live - сверяем накопленное qty по каждой монете с реальным балансом на бирже: если на
1621
+ балансе заметно меньше, чем должно быть по записи, считаем позицию по этой монете закрытой
1622
+ вручную/устаревшей и НЕ восстанавливаем ее (безопаснее начать грейдацию с нуля от нового
1623
+ скользящего хая, чем рисковать задвоением докупок поверх уже не существующей позиции).
1624
+ """
1625
+ saved = state.load_persisted_state()
1626
+ if not saved:
1627
+ return
1628
+
1629
+ if saved.get("paper_trading") != state.paper_trading:
1630
+ state.log(
1631
+ "Персистентность Градации: сохраненное состояние относится к другому режиму (paper/live) - "
1632
+ "восстановление ПРОПУЩЕНО ради безопасности", "WARN"
1633
+ )
1634
+ return
1635
+
1636
+ saved_grading = saved.get("grading_positions", {})
1637
+ if not saved_grading:
1638
+ return
1639
+
1640
+ restored_n = 0
1641
+ for sym, sdict in saved_grading.items():
1642
+ qty = float(sdict.get("qty", 0.0))
1643
+ cost = float(sdict.get("cost", 0.0))
1644
+ rolling_high = float(sdict.get("rolling_high", 0.0))
1645
+ filled = set(sdict.get("filled", []))
1646
+
1647
+ if qty <= 0:
1648
+ continue # позиции по монете не было на момент сохранения - восстанавливать нечего
1649
+
1650
+ if state.paper_trading:
1651
+ state.grading_positions[sym] = {
1652
+ "rolling_high": rolling_high, "filled": filled, "qty": qty, "cost": cost,
1653
+ }
1654
+ restored_n += 1
1655
+ continue
1656
+
1657
+ # ── Live: сверяем накопленное qty с реальным балансом на бирже ──
1658
+ try:
1659
+ base = sym.split("/")[0]
1660
+ balance = safe_call(exchange.fetch_balance, state=state, label=f"grading reconcile {base}")
1661
+ qty_total = float(balance.get("total", {}).get(base, 0) or 0)
1662
+ except Exception as e:
1663
+ state.log(
1664
+ f"Градация {sym}: сверка после рестарта - ошибка баланса {e}, позиция НЕ восстановлена (безопасность)",
1665
+ "ERR"
1666
+ )
1667
+ continue
1668
+
1669
+ if qty_total < qty * 0.9:
1670
+ state.log(
1671
+ f"Градация {sym}: сверка после рестарта - на балансе {qty_total:.6g}, а по записи "
1672
+ f"должно быть ~{qty:.6g} - позиция закрыта помимо бота. Грейды сброшены, старт с чистого листа.",
1673
+ "WARN"
1674
+ )
1675
+ continue
1676
+
1677
+ state.grading_positions[sym] = {
1678
+ "rolling_high": rolling_high, "filled": filled, "qty": qty, "cost": cost,
1679
+ }
1680
+ restored_n += 1
1681
+ state.log(
1682
+ f"♻️ Градация {sym}: восстановлено {len(filled)} грейдов после рестарта, "
1683
+ f"qty={qty:.6g} подтверждено на балансе", "OK"
1684
+ )
1685
+
1686
+ if restored_n:
1687
+ state.log(f"♻️ Персистентность Градации: восстановлено позиций по {restored_n} монете(ам)", "OK")
1688
+
1689
+
1690
  def trading_loop(state: BotState):
1691
  state.log(f"Торговый цикл запущен — портфель из {state.num_slots} слотов, бюджет ${state.total_budget}", "INFO")
1692
  if state.paper_trading:
 
1706
  # их с реальным балансом на бирже (актуально в первую очередь для Live)
1707
  reconcile_persisted_slots(exchange, state)
1708
 
1709
+ # Восстанавливаем позиции режима "Градация" (лесенка по крупным монетам) - до этого
1710
+ # исправления они вообще не переживали рестарт контейнера (см. reconcile_persisted_grading)
1711
+ reconcile_persisted_grading(exchange, state)
1712
+
1713
  per_slot = state.budget_per_slot()
1714
  if per_slot * 1.001 < 1.0:
1715
  state.log(
 
1789
 
1790
  if empty_slots:
1791
  now = time.time()
1792
+ current_interval = state.effective_screener_interval()
1793
+ should_screen = (now - state.last_full_screen_at >= current_interval) or any(
1794
  s.last_refill_attempt == 0.0 for s in empty_slots
1795
  )
1796
  if should_screen:
1797
+ if current_interval > SCREENER_INTERVAL_SEC:
1798
+ state.log(
1799
+ f"⚠️ Авто-��экофф скринера активен: интервал временно увеличен до {current_interval:.0f}с "
1800
+ f"из-за недавних сетевых ошибок/рейт-лимитов", "WARN"
1801
+ )
1802
  state.last_full_screen_at = now
1803
  refill_empty_slots(exchange, allowed_symbols, empty_slots, state)
1804
 
 
1806
  if occupied_n == state.num_slots:
1807
  state.status = f"🟢 Портфель полон ({occupied_n}/{state.num_slots})"
1808
  elif occupied_n == 0:
1809
+ wait_left = max(0, int(state.effective_screener_interval() - (time.time() - state.last_full_screen_at)))
1810
  state.status = f"🔍 Поиск ({occupied_n}/{state.num_slots}) · след. скрин ~{wait_left}с"
1811
  else:
1812
  state.status = f"🟡 Частично занят ({occupied_n}/{state.num_slots})"