Askhat777 commited on
Commit
ce9c433
·
verified ·
1 Parent(s): cd68bfd

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +272 -13
app.py CHANGED
@@ -50,9 +50,67 @@ SLOT_SCALING_TABLE = [
50
  AUTOSCALE_CHECK_INTERVAL_SEC = 3600 # Проверять реальный баланс раз в час (не смысла чаще - депозит не растет каждую минуту)
51
  SYMBOLS_REFRESH_INTERVAL_SEC = 3600 # Раз в час пересчитывать список разрешенных пар под текущий бюджет/слот
52
 
53
- # ── Персистентность состояния слотов на диск (переживает рестарт процесса/контейнера,
54
- # НЕ переживает полный редеплой с чистого образа - там нужна внешняя БД/volume) ──
 
 
 
55
  STATE_FILE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gvp_scalper_state.json")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  # ── Стратегия "Градация" - крупные высоколиквидные монеты, лесенка докупок на просадке ──
58
  GRADING_DEFAULT_SYMBOLS = ["BTC/USDT", "ETH/USDT", "BNB/USDT", "SOL/USDT"]
@@ -77,6 +135,7 @@ class Slot:
77
  target_profit_set: float = 0.05 # Целевая прибыль, рассчитанная для конкретной сделки
78
  status: str = "🔍 Поиск" # Отображаемый статус
79
  last_refill_attempt: float = 0.0 # Метка времени для анти-спама
 
80
 
81
  def is_empty(self) -> bool:
82
  return self.symbol is None
@@ -91,6 +150,7 @@ class Slot:
91
  self.usdt_spent = 0.0
92
  self.target_profit_set = 0.05
93
  self.status = "🔍 Поиск"
 
94
 
95
  def to_dict(self) -> dict:
96
  """Сериализация слота для сохранения на диск (datetime -> ISO-строка)."""
@@ -112,6 +172,7 @@ class Slot:
112
  self.target_profit_set = float(d.get("target_profit_set", 0.05))
113
  self.status = d.get("status", "🔍 Поиск")
114
  self.last_refill_attempt = float(d.get("last_refill_attempt", 0.0))
 
115
 
116
 
117
  # ─────────────────────────────────────────────
@@ -125,6 +186,8 @@ class BotState:
125
  self.status = "⏹ Остановлен"
126
  self.api_key = ""
127
  self.api_secret = ""
 
 
128
  self.target_profit = 0.05 # Целевой профит на сделку (USDT)
129
  self.total_budget = 25.0 # Общий выделенный бюджет
130
  self.num_slots = DEFAULT_SLOTS
@@ -201,34 +264,70 @@ class BotState:
201
 
202
  def save_persisted_state(self):
203
  """
204
- Сохраняет слоты и ключевую статистику на диск, чтобы пережить рестарт процесса
205
- он/пробуждение HF Spaces, краш контейнера, ручной рестарт) без потери информации
206
- о том, что куплено и по какой цене. Вызывается после каждой значимой мутации слота.
207
- Ошибки записи на диск НЕ должны ронять торговый цикл - только логируются.
 
 
208
  """
209
  try:
210
  payload = {
211
  "saved_at": datetime.now(timezone.utc).isoformat(),
 
212
  "paper_trading": self.paper_trading,
213
  "num_slots": self.num_slots,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  "total_profit": self.total_profit,
215
  "trades_count": self.trades_count,
216
  "slots": [s.to_dict() for s in self.slots],
217
  }
 
 
 
 
 
 
 
218
  tmp_path = STATE_FILE_PATH + ".tmp"
219
  with open(tmp_path, "w", encoding="utf-8") as f:
220
  json.dump(payload, f, ensure_ascii=False)
221
  os.replace(tmp_path, STATE_FILE_PATH) # атомарная замена - не оставит битый файл при обрыве записи
 
 
222
  except Exception as e:
223
  logging.warning(f"Не удалось сохранить состояние на диск: {e}")
224
 
225
  @staticmethod
226
  def load_persisted_state() -> dict | None:
227
- """Читает сохраненное состояние с диска, если файл существует. None, если файла нет или он битый."""
228
- if not os.path.exists(STATE_FILE_PATH):
 
 
 
 
 
 
 
229
  return None
230
  try:
231
- with open(STATE_FILE_PATH, "r", encoding="utf-8") as f:
232
  return json.load(f)
233
  except Exception as e:
234
  logging.warning(f"Не удалось прочитать сохраненное состояние: {e}")
@@ -1278,6 +1377,9 @@ def process_occupied_slot(exchange: ccxt.binance, slot: Slot, state: BotState):
1278
  })
1279
  slot.clear()
1280
  state.save_persisted_state()
 
 
 
1281
  if slot.order_placed_at:
1282
  elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60)
1283
 
@@ -1315,7 +1417,14 @@ def process_occupied_slot(exchange: ccxt.binance, slot: Slot, state: BotState):
1315
  elapsed_min = 0
1316
  if slot.order_placed_at:
1317
  elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60)
1318
-
 
 
 
 
 
 
 
1319
  timeout_parts = []
1320
  if state.use_timeout:
1321
  timeout_parts.append(f"timeout {state.order_timeout_min}м")
@@ -1540,6 +1649,93 @@ def trading_loop(state: BotState):
1540
  state.status = "⏹ Остановлен"
1541
 
1542
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1543
  # ─────────────────────────────────────────────
1544
  # STREAMLIT ИНТЕРФЕЙС
1545
  # ─────────────────────────────────────────────
@@ -1555,7 +1751,7 @@ def render_ui():
1555
  .main { background-color: #0d1117; }
1556
  .metric-card {
1557
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
1558
- padding: 16px; text-align: center;
1559
  }
1560
  .status-bar {
1561
  background: #161b22; border-left: 3px solid #f0b429; padding: 10px 16px;
@@ -1567,13 +1763,14 @@ def render_ui():
1567
  }
1568
  .slot-card {
1569
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
1570
- padding: 14px; margin-bottom: 10px;
1571
  }
 
1572
  .slot-card.occupied { border-left: 3px solid #1f9d55; }
1573
  .slot-card.empty { border-left: 3px solid #555; opacity: 0.7; }
1574
  .panic-section {
1575
  background: #2d1b1b; border: 1px solid #7c2d2d; border-radius: 8px;
1576
- padding: 10px 14px; margin-top: 8px;
1577
  }
1578
  .screen-row {
1579
  display: grid; grid-template-columns: 110px 90px 70px 70px 1fr;
@@ -1583,6 +1780,7 @@ def render_ui():
1583
  """, unsafe_allow_html=True)
1584
 
1585
  state = get_bot_state()
 
1586
 
1587
  st.markdown("## ⚡ GVP Micro Scalper — Ротационный портфель")
1588
  st.caption("Полный скрин рынка | N параллельных позиций | Ротация при фиксации прибыли | HF Spaces")
@@ -1639,6 +1837,7 @@ def render_ui():
1639
  api_key = ""
1640
  api_secret = ""
1641
  proxy_url = state.proxy_url
 
1642
  else:
1643
  api_key = st.text_input("API Key", value=state.api_key, type="password", key="inp_key")
1644
  api_secret = st.text_input("API Secret", value=state.api_secret, type="password", key="inp_secret")
@@ -1658,6 +1857,19 @@ def render_ui():
1658
  if proxy_url:
1659
  st.caption("🌐 Прокси будет применен ко всем запросам к бирже в Live режиме.")
1660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1661
  st.markdown("**Параметры торговли**")
1662
  total_budget = st.number_input(
1663
  "Общий бюджет портфеля (USDT)",
@@ -1823,6 +2035,7 @@ def render_ui():
1823
  state.grading_symbols = grading_symbols
1824
  state.grading_budget = grading_budget
1825
  state.auto_scale_slots = auto_scale_slots
 
1826
  state.resize_slots(num_slots)
1827
  state.is_running = True
1828
  state.logs = []
@@ -1831,11 +2044,13 @@ def render_ui():
1831
  state.status = "🔍 Инициализация..."
1832
  t = threading.Thread(target=trading_loop, args=(state,), daemon=True)
1833
  t.start()
 
1834
  st.success(f"Портфель из {num_slots} слотов запущен!")
1835
  st.rerun()
1836
  else:
1837
  if st.button("⏹ Остановить портфель", use_container_width=True, type="secondary"):
1838
  state.is_running = False
 
1839
  st.warning("Сигнал остановки отправлен. Открытые позиции НЕ закрываются автоматически — используй Panic для каждого слота при необходимости.")
1840
  st.rerun()
1841
 
@@ -1887,12 +2102,26 @@ def render_ui():
1887
  if state.use_profit_exit:
1888
  timeout_parts.append(f"плюс-ротация > {state.profit_exit_timeout_min}м")
1889
  timeout_str = " (" + " | ".join(timeout_parts) + ")" if timeout_parts else " (безлимит)"
 
 
 
 
 
 
 
 
 
 
 
 
 
1890
 
1891
  st.markdown(f"""
1892
  <div class="{css_class}">
1893
  <b>Слот {slot.index}: {slot.symbol}</b><br>
1894
  Куплено: {slot.bought_qty:.4f} по {slot.buy_price_avg:.6g} ({slot.usdt_spent:.4f} USDT)<br>
1895
  Цель продажи: <b>{slot.sell_price_set:.6g}</b> (цель profit: {slot.target_profit_set:.4f} USDT)<br>
 
1896
  Статус: {slot.status} | Удержание: {elapsed_min} мин{timeout_str}
1897
  </div>
1898
  """, unsafe_allow_html=True)
@@ -1919,6 +2148,36 @@ def render_ui():
1919
  except Exception as e:
1920
  st.error(f"Ошибка Panic Sell: {e}")
1921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1922
  # ── Таблица виртуальных сделок ──
1923
  st.divider()
1924
  st.subheader("📝 Журнал сделок (Paper Trading)")
 
50
  AUTOSCALE_CHECK_INTERVAL_SEC = 3600 # Проверять реальный баланс раз в час (не смысла чаще - депозит не растет каждую минуту)
51
  SYMBOLS_REFRESH_INTERVAL_SEC = 3600 # Раз в час пересчитывать список разрешенных пар под текущий бюджет/слот
52
 
53
+ # ── Персистентность состояния слотов на диск + приватный HF Dataset ──
54
+ # Локальный диск переживает обычный рестарт процесса, но НЕ переживает полный редеплой
55
+ # HF Spaces "с нуля" (там пересоздается вся файловая система). Поэтому дополнительно
56
+ # состояние зеркалируется в приватный HF Dataset - это переживает ЛЮБОЙ рестарт контейнера,
57
+ # включая пересоздание образа, т.к. датасет живет отдельно от самого Space.
58
  STATE_FILE_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "gvp_scalper_state.json")
59
+ STATE_FILENAME_IN_REPO = "gvp_scalper_state.json"
60
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
61
+ HF_DATASET_ID = os.environ.get("HF_DATASET_ID", "")
62
+
63
+
64
+ def _hf_dataset_configured() -> bool:
65
+ return bool(HF_TOKEN and HF_DATASET_ID)
66
+
67
+
68
+ def _push_state_to_hf_dataset(local_path: str):
69
+ """
70
+ Асинхронно (в отдельном daemon-потоке) заливает файл состояния в приватный HF Dataset.
71
+ Намеренно НЕ блокирует торговый поток - сетевой запрос к серверам HF может занять
72
+ секунды, а сохранение состояния вызывается часто (после каждой сделки).
73
+ Если HF_TOKEN/HF_DATASET_ID не заданы в Секретах Space - тихо ничего не делает.
74
+ """
75
+ if not _hf_dataset_configured():
76
+ return
77
+
78
+ def _upload():
79
+ try:
80
+ from huggingface_hub import HfApi
81
+ api = HfApi(token=HF_TOKEN)
82
+ # exist_ok=True - безопасно дергать при КАЖДОЙ заливке, датасет создается
83
+ # автоматически только один раз, при первом реальном запуске
84
+ api.create_repo(repo_id=HF_DATASET_ID, repo_type="dataset", private=True, exist_ok=True)
85
+ api.upload_file(
86
+ path_or_fileobj=local_path,
87
+ path_in_repo=STATE_FILENAME_IN_REPO,
88
+ repo_id=HF_DATASET_ID,
89
+ repo_type="dataset",
90
+ )
91
+ except Exception as e:
92
+ logging.warning(f"HF Dataset: не удалось залить состояние - {e}")
93
+
94
+ threading.Thread(target=_upload, daemon=True).start()
95
+
96
+
97
+ def _pull_state_from_hf_dataset() -> str | None:
98
+ """
99
+ Скачивает файл состояния из приватного HF Dataset. Возвращает ЛОКАЛЬНЫЙ путь к скачанному
100
+ файлу (huggingface_hub кэширует его на диск) или None, если датасет не настроен, либо
101
+ файла там еще нет - это нормальная ситуация при самом первом запуске, падать нельзя.
102
+ """
103
+ if not _hf_dataset_configured():
104
+ return None
105
+ try:
106
+ from huggingface_hub import hf_hub_download
107
+ return hf_hub_download(
108
+ repo_id=HF_DATASET_ID, repo_type="dataset",
109
+ filename=STATE_FILENAME_IN_REPO, token=HF_TOKEN,
110
+ )
111
+ except Exception as e:
112
+ logging.info(f"HF Dataset: файл состояния не найден/недоступен ({e}) - считаем первым запуском")
113
+ return None
114
 
115
  # ── Стратегия "Градация" - крупные высоколиквидные монеты, лесенка докупок на просадке ──
116
  GRADING_DEFAULT_SYMBOLS = ["BTC/USDT", "ETH/USDT", "BNB/USDT", "SOL/USDT"]
 
135
  target_profit_set: float = 0.05 # Целевая прибыль, рассчитанная для конкретной сделки
136
  status: str = "🔍 Поиск" # Отображаемый статус
137
  last_refill_attempt: float = 0.0 # Метка времени для анти-спама
138
+ last_known_price: float = 0.0 # Последняя увиденная рыночная цена (для о��ображения незафиксированного P&L)
139
 
140
  def is_empty(self) -> bool:
141
  return self.symbol is None
 
150
  self.usdt_spent = 0.0
151
  self.target_profit_set = 0.05
152
  self.status = "🔍 Поиск"
153
+ self.last_known_price = 0.0
154
 
155
  def to_dict(self) -> dict:
156
  """Сериализация слота для сохранения на диск (datetime -> ISO-строка)."""
 
172
  self.target_profit_set = float(d.get("target_profit_set", 0.05))
173
  self.status = d.get("status", "🔍 Поиск")
174
  self.last_refill_attempt = float(d.get("last_refill_attempt", 0.0))
175
+ self.last_known_price = float(d.get("last_known_price", 0.0))
176
 
177
 
178
  # ─────────────────────────────────────────────
 
186
  self.status = "⏹ Остановлен"
187
  self.api_key = ""
188
  self.api_secret = ""
189
+ self.persist_api_keys_in_state = False # Явное согласие пользователя хранить ключи в файле состояния
190
+ # (нужно для авто-возобновления Live без Секретов HF - см. предупреждение в UI)
191
  self.target_profit = 0.05 # Целевой профит на сделку (USDT)
192
  self.total_budget = 25.0 # Общий выделенный бюджет
193
  self.num_slots = DEFAULT_SLOTS
 
264
 
265
  def save_persisted_state(self):
266
  """
267
+ Сохраняет слоты, статистику И параметры торговли на диск + асинхронно в приватный
268
+ HF Dataset (если настроен), чтобы пережить ЛЮБОЙ рестарт контейнера, включая полный
269
+ редеплой окальный диск это не переживает, датасет - переживает).
270
+ Флаг "is_running" в payload - основа для холодного автозапуска (maybe_cold_start_resume):
271
+ если он True при следующем старте процесса, бот сам поднимет trading_loop.
272
+ Ошибки записи НЕ должны ронять торговый цикл - только логируются.
273
  """
274
  try:
275
  payload = {
276
  "saved_at": datetime.now(timezone.utc).isoformat(),
277
+ "is_running": self.is_running,
278
  "paper_trading": self.paper_trading,
279
  "num_slots": self.num_slots,
280
+ "total_budget": self.total_budget,
281
+ "target_profit": self.target_profit,
282
+ "use_bnb_discount": self.use_bnb_discount,
283
+ "use_timeout": self.use_timeout,
284
+ "order_timeout_min": self.order_timeout_min,
285
+ "use_profit_exit": self.use_profit_exit,
286
+ "profit_exit_timeout_min": self.profit_exit_timeout_min,
287
+ "strategy_mode": self.strategy_mode,
288
+ "use_dynamic_tp": self.use_dynamic_tp,
289
+ "max_correlation": self.max_correlation,
290
+ "use_depth_check": self.use_depth_check,
291
+ "max_slippage_pct": self.max_slippage_pct,
292
+ "strategy_engine": self.strategy_engine,
293
+ "grading_symbols": self.grading_symbols,
294
+ "grading_budget": self.grading_budget,
295
+ "auto_scale_slots": self.auto_scale_slots,
296
+ "proxy_url": self.proxy_url,
297
  "total_profit": self.total_profit,
298
  "trades_count": self.trades_count,
299
  "slots": [s.to_dict() for s in self.slots],
300
  }
301
+ # API-ключи в файле - ТОЛЬКО если пользователь явно на это согласился в UI
302
+ # (по умолчанию выключено; безопаснее прописать BINANCE_API_KEY/SECRET в Секретах HF Spaces -
303
+ # они не попадают в этот файл и, соответственно, никогда не улетают в датасет)
304
+ if self.persist_api_keys_in_state and not self.paper_trading:
305
+ payload["api_key"] = self.api_key
306
+ payload["api_secret"] = self.api_secret
307
+
308
  tmp_path = STATE_FILE_PATH + ".tmp"
309
  with open(tmp_path, "w", encoding="utf-8") as f:
310
  json.dump(payload, f, ensure_ascii=False)
311
  os.replace(tmp_path, STATE_FILE_PATH) # атомарная замена - не оставит битый файл при обрыве записи
312
+
313
+ _push_state_to_hf_dataset(STATE_FILE_PATH)
314
  except Exception as e:
315
  logging.warning(f"Не удалось сохранить состояние на диск: {e}")
316
 
317
  @staticmethod
318
  def load_persisted_state() -> dict | None:
319
+ """
320
+ Читает сохраненное состояние. Приоритет: сначала пробуем скачать актуальную версию
321
+ из приватного HF Dataset (переживает полный редеплой), при неудаче/отсутствии -
322
+ читаем локальный файл на диске, если он еще жив. None - если нигде ничего нет
323
+ (это нормально для самого первого запуска, программа не должна падать).
324
+ """
325
+ hf_path = _pull_state_from_hf_dataset()
326
+ path_to_read = hf_path if hf_path else (STATE_FILE_PATH if os.path.exists(STATE_FILE_PATH) else None)
327
+ if not path_to_read:
328
  return None
329
  try:
330
+ with open(path_to_read, "r", encoding="utf-8") as f:
331
  return json.load(f)
332
  except Exception as e:
333
  logging.warning(f"Не удалось прочитать сохраненное состояние: {e}")
 
1377
  })
1378
  slot.clear()
1379
  state.save_persisted_state()
1380
+ else:
1381
+ slot.last_known_price = current_bid
1382
+ elapsed_min = 0
1383
  if slot.order_placed_at:
1384
  elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60)
1385
 
 
1417
  elapsed_min = 0
1418
  if slot.order_placed_at:
1419
  elapsed_min = int((datetime.now(timezone.utc) - slot.order_placed_at).total_seconds() / 60)
1420
+
1421
+ # Легкий доп. запрос цены для отображения незафиксированного P&L в UI (не критично при сбое)
1422
+ try:
1423
+ t = safe_call(exchange.fetch_ticker, slot.symbol, state=state, label=f"unrealized price {slot.symbol}")
1424
+ slot.last_known_price = t.get("bid") or t.get("last") or slot.last_known_price
1425
+ except Exception:
1426
+ pass
1427
+
1428
  timeout_parts = []
1429
  if state.use_timeout:
1430
  timeout_parts.append(f"timeout {state.order_timeout_min}м")
 
1649
  state.status = "⏹ Остановлен"
1650
 
1651
 
1652
+ # Модульный флаг (НЕ st.session_state - тот привязан к браузерной сессии, а нам нужен
1653
+ # ровно один автозапуск на весь ЖИЗНЕННЫЙ ЦИКЛ ПРОЦЕССА, независимо от того, сколько
1654
+ # раз Streamlit перерисовывает скрипт или сколько вкладок браузера открыто).
1655
+ _cold_start_lock = threading.Lock()
1656
+ _cold_start_attempted = False
1657
+
1658
+
1659
+ def maybe_cold_start_resume(state: BotState):
1660
+ """
1661
+ Холодный старт: если сервер (контейнер HF Spaces) только что перезапустился/проснулся,
1662
+ а в сохраненном состоянии стоит is_running=True - автоматически поднимает фоновый
1663
+ trading_loop БЕЗ клика пользователя по кнопке "Запустить портфель".
1664
+ Срабатывает ровно один раз за жизнь процесса благодаря module-level флагу.
1665
+ """
1666
+ global _cold_start_attempted
1667
+ with _cold_start_lock:
1668
+ if _cold_start_attempted:
1669
+ return
1670
+ _cold_start_attempted = True
1671
+
1672
+ if state.is_running:
1673
+ return # поток уже поднят в этом процессе - холодный старт не нужен
1674
+
1675
+ saved = BotState.load_persisted_state()
1676
+ if not saved or not saved.get("is_running"):
1677
+ return
1678
+
1679
+ state.log("♻️ Холодный старт: обнаружен флаг is_running=True в сохраненном состоянии - автозапуск торговли...", "INFO")
1680
+
1681
+ # ── Восстанавливаем параметры торговли из сохраненного состояния ──
1682
+ state.paper_trading = saved.get("paper_trading", state.paper_trading)
1683
+ state.total_budget = saved.get("total_budget", state.total_budget)
1684
+ state.target_profit = saved.get("target_profit", state.target_profit)
1685
+ state.use_bnb_discount = saved.get("use_bnb_discount", state.use_bnb_discount)
1686
+ state.use_timeout = saved.get("use_timeout", state.use_timeout)
1687
+ state.order_timeout_min = saved.get("order_timeout_min", state.order_timeout_min)
1688
+ state.use_profit_exit = saved.get("use_profit_exit", state.use_profit_exit)
1689
+ state.profit_exit_timeout_min = saved.get("profit_exit_timeout_min", state.profit_exit_timeout_min)
1690
+ state.strategy_mode = saved.get("strategy_mode", state.strategy_mode)
1691
+ state.use_dynamic_tp = saved.get("use_dynamic_tp", state.use_dynamic_tp)
1692
+ state.max_correlation = saved.get("max_correlation", state.max_correlation)
1693
+ state.use_depth_check = saved.get("use_depth_check", state.use_depth_check)
1694
+ state.max_slippage_pct = saved.get("max_slippage_pct", state.max_slippage_pct)
1695
+ state.strategy_engine = saved.get("strategy_engine", state.strategy_engine)
1696
+ state.grading_symbols = saved.get("grading_symbols", state.grading_symbols)
1697
+ state.grading_budget = saved.get("grading_budget", state.grading_budget)
1698
+ state.auto_scale_slots = saved.get("auto_scale_slots", state.auto_scale_slots)
1699
+ state.proxy_url = saved.get("proxy_url", state.proxy_url)
1700
+ state.resize_slots(saved.get("num_slots", state.num_slots))
1701
+
1702
+ # ── API-ключи: ПРИОРИТЕТ у Секретов HF Spaces (переменные окружения) - они безопаснее,
1703
+ # чем ключи, лежащие в файле состояния/датасете. Файл - только запасной вариант,
1704
+ # и то ТОЛЬКО если пользователь сам явно включил "persist_api_keys_in_state" в UI. ──
1705
+ env_key = os.environ.get("BINANCE_API_KEY", "")
1706
+ env_secret = os.environ.get("BINANCE_API_SECRET", "")
1707
+ if env_key and env_secret:
1708
+ state.api_key = env_key
1709
+ state.api_secret = env_secret
1710
+ state.log("🔑 Холодный старт: API-ключи взяты из Секретов HF Spaces (приоритетный источник)", "OK")
1711
+ elif not state.paper_trading:
1712
+ saved_key = saved.get("api_key", "")
1713
+ saved_secret = saved.get("api_secret", "")
1714
+ if saved_key and saved_secret:
1715
+ state.api_key = saved_key
1716
+ state.api_secret = saved_secret
1717
+ state.log(
1718
+ "🔑 Холодный старт: Секреты HF Spaces не найдены, взял API-ключи из файла состояния "
1719
+ "(менее безопасно - рекомендуется прописать BINANCE_API_KEY/BINANCE_API_SECRET в Секретах Space)",
1720
+ "WARN",
1721
+ )
1722
+ else:
1723
+ state.log(
1724
+ "🔴 Холодный старт: нет API-ключей ни в Секретах HF, ни в сохраненном состоянии - "
1725
+ "автозапуск Live невозможен, требуется ручной ввод ключей в UI", "ERR"
1726
+ )
1727
+ return
1728
+
1729
+ if not state.paper_trading and (not state.api_key or not state.api_secret):
1730
+ return
1731
+
1732
+ state.is_running = True
1733
+ state.status = "🔍 Автозапуск после рестарта..."
1734
+ t = threading.Thread(target=trading_loop, args=(state,), daemon=True)
1735
+ t.start()
1736
+ state.log(f"♻️ Холодный старт: фоновый поток trading_loop запущен автоматически ({state.num_slots} слотов)", "OK")
1737
+
1738
+
1739
  # ─────────────────────────────────────────────
1740
  # STREAMLIT ИНТЕРФЕЙС
1741
  # ─────────────────────────────────────────────
 
1751
  .main { background-color: #0d1117; }
1752
  .metric-card {
1753
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
1754
+ padding: 16px; text-align: center; color: #e6edf3;
1755
  }
1756
  .status-bar {
1757
  background: #161b22; border-left: 3px solid #f0b429; padding: 10px 16px;
 
1763
  }
1764
  .slot-card {
1765
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
1766
+ padding: 14px; margin-bottom: 10px; color: #e6edf3;
1767
  }
1768
+ .slot-card b { color: #ffffff; }
1769
  .slot-card.occupied { border-left: 3px solid #1f9d55; }
1770
  .slot-card.empty { border-left: 3px solid #555; opacity: 0.7; }
1771
  .panic-section {
1772
  background: #2d1b1b; border: 1px solid #7c2d2d; border-radius: 8px;
1773
+ padding: 10px 14px; margin-top: 8px; color: #e6edf3;
1774
  }
1775
  .screen-row {
1776
  display: grid; grid-template-columns: 110px 90px 70px 70px 1fr;
 
1780
  """, unsafe_allow_html=True)
1781
 
1782
  state = get_bot_state()
1783
+ maybe_cold_start_resume(state) # автозапуск trading_loop после рестарта контейнера, если is_running был True
1784
 
1785
  st.markdown("## ⚡ GVP Micro Scalper — Ротационный портфель")
1786
  st.caption("Полный скрин рынка | N параллельных позиций | Ротация при фиксации прибыли | HF Spaces")
 
1837
  api_key = ""
1838
  api_secret = ""
1839
  proxy_url = state.proxy_url
1840
+ persist_api_keys_in_state = False
1841
  else:
1842
  api_key = st.text_input("API Key", value=state.api_key, type="password", key="inp_key")
1843
  api_secret = st.text_input("API Secret", value=state.api_secret, type="password", key="inp_secret")
 
1857
  if proxy_url:
1858
  st.caption("🌐 Прокси будет применен ко всем запросам к бирже в Live режиме.")
1859
 
1860
+ st.divider()
1861
+ persist_api_keys_in_state = st.checkbox(
1862
+ "Сохранять API-ключи в файле состояния/HF Dataset для автозапуска после рестарта",
1863
+ value=state.persist_api_keys_in_state,
1864
+ help=(
1865
+ "Нужно ТОЛЬКО если ты не прописал BINANCE_API_KEY/BINANCE_API_SECRET в Секретах "
1866
+ "HF Space (это приоритетный и более безопасный способ - ключи тогда вообще не "
1867
+ "попадают в этот файл). Если включишь здесь - ключи в открытом виде уйдут в JSON "
1868
+ "на диске и, если настроен HF Dataset, в твой приватный датасет. Используй, только "
1869
+ "если понимаешь риск: любой, у кого есть доступ к датасету/токену, увидит ключи."
1870
+ ),
1871
+ )
1872
+
1873
  st.markdown("**Параметры торговли**")
1874
  total_budget = st.number_input(
1875
  "Общий бюджет портфеля (USDT)",
 
2035
  state.grading_symbols = grading_symbols
2036
  state.grading_budget = grading_budget
2037
  state.auto_scale_slots = auto_scale_slots
2038
+ state.persist_api_keys_in_state = persist_api_keys_in_state
2039
  state.resize_slots(num_slots)
2040
  state.is_running = True
2041
  state.logs = []
 
2044
  state.status = "🔍 Инициализация..."
2045
  t = threading.Thread(target=trading_loop, args=(state,), daemon=True)
2046
  t.start()
2047
+ state.save_persisted_state() # сразу фиксируем is_running=True - основа холодного автозапуска
2048
  st.success(f"Портфель из {num_slots} слотов запущен!")
2049
  st.rerun()
2050
  else:
2051
  if st.button("⏹ Остановить портфель", use_container_width=True, type="secondary"):
2052
  state.is_running = False
2053
+ state.save_persisted_state() # фиксируем is_running=False - холодный автозапуск не сработает зря
2054
  st.warning("Сигнал остановки отправлен. Открытые позиции НЕ закрываются автоматически — используй Panic для каждого слота при необходимости.")
2055
  st.rerun()
2056
 
 
2102
  if state.use_profit_exit:
2103
  timeout_parts.append(f"плюс-ротация > {state.profit_exit_timeout_min}м")
2104
  timeout_str = " (" + " | ".join(timeout_parts) + ")" if timeout_parts else " (безлимит)"
2105
+
2106
+ # Незафиксированный P&L по последней увиденной цене - для ручного р��шения "закрыть/держать"
2107
+ if slot.last_known_price > 0:
2108
+ unrealized_usd = slot.last_known_price * slot.bought_qty - slot.usdt_spent
2109
+ unrealized_cents = unrealized_usd * 100
2110
+ pnl_color = "#3fb950" if unrealized_usd >= 0 else "#f85149"
2111
+ pnl_sign = "+" if unrealized_usd >= 0 else ""
2112
+ pnl_html = (
2113
+ f'<span style="color:{pnl_color}; font-weight:600;">'
2114
+ f'{pnl_sign}{unrealized_usd:.4f} USDT ({pnl_sign}{unrealized_cents:.1f}¢)</span>'
2115
+ )
2116
+ else:
2117
+ pnl_html = '<span style="color:#8b949e;">— (цена еще не проверялась)</span>'
2118
 
2119
  st.markdown(f"""
2120
  <div class="{css_class}">
2121
  <b>Слот {slot.index}: {slot.symbol}</b><br>
2122
  Куплено: {slot.bought_qty:.4f} по {slot.buy_price_avg:.6g} ({slot.usdt_spent:.4f} USDT)<br>
2123
  Цель продажи: <b>{slot.sell_price_set:.6g}</b> (цель profit: {slot.target_profit_set:.4f} USDT)<br>
2124
+ Незафиксированный P&L: {pnl_html}<br>
2125
  Статус: {slot.status} | Удержание: {elapsed_min} мин{timeout_str}
2126
  </div>
2127
  """, unsafe_allow_html=True)
 
2148
  except Exception as e:
2149
  st.error(f"Ошибка Panic Sell: {e}")
2150
 
2151
+ # ── Сводная таблица открытых позиций: кто в плюсе, кто в просадке, сколько по времени ──
2152
+ occupied_slots = [s for s in state.slots if not s.is_empty()]
2153
+ if occupied_slots:
2154
+ st.divider()
2155
+ st.subheader("📊 Открытые позиции — незафиксированный P&L")
2156
+ rows = []
2157
+ for s in occupied_slots:
2158
+ elapsed_min = 0
2159
+ if s.order_placed_at:
2160
+ elapsed_min = int((datetime.now(timezone.utc) - s.order_placed_at).total_seconds() / 60)
2161
+ if s.last_known_price > 0:
2162
+ u_usd = s.last_known_price * s.bought_qty - s.usdt_spent
2163
+ u_cents = u_usd * 100
2164
+ pnl_str = f"{'+' if u_usd >= 0 else ''}{u_usd:.4f} USDT"
2165
+ cents_str = f"{'+' if u_cents >= 0 else ''}{u_cents:.1f}¢"
2166
+ emoji = "🟢" if u_usd >= 0 else "🔴"
2167
+ else:
2168
+ pnl_str, cents_str, emoji = "—", "—", "⏳"
2169
+ rows.append({
2170
+ "": emoji,
2171
+ "Слот": s.index,
2172
+ "Монета": s.symbol,
2173
+ "Цена покупки": round(s.buy_price_avg, 6),
2174
+ "Тек. цена": round(s.last_known_price, 6) if s.last_known_price > 0 else "—",
2175
+ "P&L (USDT)": pnl_str,
2176
+ "P&L (центы)": cents_str,
2177
+ "В сделке (мин)": elapsed_min,
2178
+ })
2179
+ st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
2180
+
2181
  # ── Таблица виртуальных сделок ──
2182
  st.divider()
2183
  st.subheader("📝 Журнал сделок (Paper Trading)")