Spaces:
Running
Running
Update app.py
Browse files
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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
(с
|
| 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 |
-
"""
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
return None
|
| 230 |
try:
|
| 231 |
-
with open(
|
| 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)")
|