Spaces:
Running
Running
Update app.py
Browse files
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 |
-
|
|
|
|
| 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(
|
| 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})"
|