""" Grid Bot — Trading Loop + HTTP REST API. Run: python main.py [--dry-run] [--once] REST endpoints: GET /api/status — status + quotes GET /api/balance — account/demo balance GET /api/grid — current grid state GET GET /api/orderbook — order book (bids/asks) GET /api/logs — trade log GET /api/settings — current settings POST /api/settings — update settings (grid_levels, step_percent, take_profit_percent, demo_mode) POST /api/bot/start — start trading loop POST /api/bot/stop — stop trading loop POST /api/reset — reset demo balance """ import argparse import asyncio import base64 import json import logging import os import signal import socket import subprocess import sys import time import urllib.parse import urllib.request from datetime import datetime, timezone from collections import deque from pathlib import Path from threading import Thread, Lock from typing import Optional from flask import Flask, jsonify, request, render_template, redirect, send_from_directory # ─── Local modules ───────────────────────────────────────────────────────────── from config import ( TRADERNET_PUBLIC_KEY, TRADERNET_PRIVATE_KEY, TRADERNET_LOGIN, TRADERNET_PASSWORD, TRADERNET_BASE_URL, SYMBOL, QUOTE, BASE, GRID_LEVELS, GRID_STEP_PERCENT, GRID_TAKE_PROFIT_PERCENT, DEMO_MODE, DEMO_START_BALANCE, HOST, PORT, DEBUG, LOG_FILE, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, GRID_STALE_DROP_PERCENT, STATE_DIR, STATE_FILE, SERVER_URL, LIVE_TRADING_DRY_RUN, KRONOS_ENABLED, KRONOS_LIVE_ENABLED, KRONOS_MODEL, KRONOS_TF_MIN, KRONOS_LOOKBACK, KRONOS_PRED_LEN, KRONOS_MIN_CONFIDENCE, KRONOS_ADVICE_FILE, WEB_USERNAME, WEB_PASSWORD, ) from api import TradernetAPI from grid import GridEngine, DemoSimulator, GridState # Добавляем kronos/ в sys.path, чтобы model/ (исходники Kronos) находился import sys from pathlib import Path as _Path _HERE = _Path(__file__).parent.resolve() if str(_HERE / "kronos") not in sys.path: sys.path.insert(0, str(_HERE / "kronos")) # ─── Globals ───────────────────────────────────────────────────────────────── app = Flask("margin-bot", template_folder="templates") api_client: Optional[TradernetAPI] = None grid_engine = GridEngine( levels=GRID_LEVELS, step_percent=GRID_STEP_PERCENT, take_profit_percent=GRID_TAKE_PROFIT_PERCENT, symbol=SYMBOL, mode="live" if not DEMO_MODE else "demo", ) demo = DemoSimulator(start_balance=DEMO_START_BALANCE) demo_orders_placed = False # Live-mode state: mirrors demo orders/position/trades for real exchange trading # Each entry: {"order_id": int, "side": "BUY"|"SELL", "price": float, "qty": float, # "level_id": int, "status": "pending"|"filled"|"cancelled"|"rejected", # "created_at": float, "raw": dict} live_pending_orders: list[dict] = [] # Live position: {"side": "BUY", "entry_price": float, "qty": float, "order_id": int, # "raw": dict} live_position: Optional[dict] = None # Live trade log: closed round-trip trades # Each: {"entry_order_id": int, "exit_order_id": int, "entry_price": float, # "exit_price": float, "qty": float, "pnl_usdc": float, "pnl_pct": float, # "ts": float} live_trade_log: list[dict] = [] live_orders_placed = False # True after we've placed initial grid BUY limits current_price = 0.0 start_price = 0.0 # price when bot was started bot_running = False bot_thread: Optional[Thread] = None last_error = "" last_quote: dict = {} # последний котировочный снимок (bid/ask/chg/vol) _price_source: str = "none" # tradernet | binance | synth | none # Stale-grid info (for dashboard): did we rebuild because price dropped without fills? grid_stale = False grid_stale_drop_pct = 0.0 grid_stale_rebuilds = 0 # ─── Kronos advisor integration ───────────────────────────────────────────────── # kronos_pause_until: epoch time. If > now, advisor asked to pause the grid. # kronos_last_applied: dict последнего применённого совета (для дашборда / лога). # kronos_last_log_ts: чтобы не флудить в лог каждый цикл. kronos_pause_until = 0.0 kronos_last_applied: dict = {} kronos_last_log_ts = 0.0 kronos_advice_history: list[dict] = [] # последние 50 советов для дашборда def effective_grid_params(base_center: float, base_step: float, live: bool) -> tuple[float, float, bool]: """Обёртка над apply_kronos_advice — точка интеграции в trading_loop. Возвращает (eff_center, eff_step, paused_by_kronos). - В DEMO: применяется всегда (если KRONOS_ENABLED). - В LIVE: применяется, только если KRONOS_LIVE_ENABLED=True. - Если Kronos-пауза активна и не истекла — возвращаем (base_center, base_step, True). """ global kronos_last_log_ts # Активная пауза от Kronos? if kronos_pause_until and time.time() < kronos_pause_until: return base_center, base_center, base_step, True eff_buy_center, eff_sell_center, eff_step, pause_now = apply_kronos_advice(base_center, base_step, live) if pause_now: return base_center, base_center, base_step, True # Лог с троттлингом (раз в 60с) — только если что-то реально поменялось now = time.time() if now - kronos_last_log_ts > 60 and abs(eff_step - base_step) > 1e-6: adv = read_kronos_advice() or {} logger.info( f"[KRONOS] applied: step {base_step*100:.3f}%→{eff_step*100:.3f}%, " f"buy_anchor ${base_center:,.2f}→${eff_buy_center:,.2f}, " f"sell_anchor ${base_center:,.2f}→${eff_sell_center:,.2f} " f"(conf={adv.get('confidence', 0):.2f}, source={adv.get('source', 'n/a')})" ) kronos_last_log_ts = now return eff_buy_center, eff_sell_center, eff_step, False def read_kronos_advice() -> Optional[dict]: """Прочитать kronos_advice.json, если файл свежий и валидный. Возвращает dict или None. Файл считается «протухшим» через 2×KRONOS_PRED_LEN (т.е. дольше горизонта прогноза — данные устарели). """ if not KRONOS_ENABLED: return None path = Path(KRONOS_ADVICE_FILE) if not path.exists(): return None try: data = json.loads(path.read_text()) except Exception as e: logger.debug(f"[KRONOS] failed to read advice: {e}") return None # Проверка свежести try: gen = datetime.fromisoformat(data.get("generated_at", "").replace("Z", "+00:00")) age_sec = (datetime.now(timezone.utc) - gen).total_seconds() horizon_sec = KRONOS_PRED_LEN * KRONOS_TF_MIN * 60 if age_sec > 2 * horizon_sec: logger.debug(f"[KRONOS] advice stale ({age_sec:.0f}s > 2*horizon)") return None except Exception: pass # Проверка confidence if data.get("confidence", 0) < KRONOS_MIN_CONFIDENCE: return None return data def apply_kronos_advice(base_center: float, base_step: float, live: bool) -> tuple[float, float, bool]: """Применить Kronos-совет к (центр сетки, шаг). Возвращает (effective_center, effective_step, pause_now). Логика: - step_percent: советник может снизить шаг (при низкой волатильности) или поднять. Минимум: 0.7× base_step (защита от слишком мелких сделок). Максимум: 1.5× base_step. - center: сдвигаем в сторону bias, максимум на 0.2% (если base_step = 0.5%). - pause: если Kronos сказал пауза и уверен — возращаем pause_now=True. - В DEMO всегда применяем. В LIVE — только если KRONOS_LIVE_ENABLED=True. """ global kronos_pause_until, kronos_last_applied, kronos_advice_history advice = read_kronos_advice() if advice is None: return base_center, base_center, base_step, False # В LIVE без явного флага — только логируем, не применяем if live and not KRONOS_LIVE_ENABLED: return base_center, base_center, base_step, False # pause (capped at 15 minutes by user request 2026-06-06) if advice.get("pause_grid") and advice.get("confidence", 0) >= 0.6: horizon_sec = KRONOS_PRED_LEN * KRONOS_TF_MIN * 60 # Cap the pause at 15 minutes so KRONOS can't lock the bot for a full day max_pause_sec = 15 * 60 if horizon_sec > max_pause_sec: horizon_sec = max_pause_sec was_paused = bool(kronos_pause_until and time.time() < kronos_pause_until) kronos_pause_until = time.time() + horizon_sec # TG только при новой паузе if not was_paused: tg_notify( f"⏸️ KRONOS: пауза сетки на {horizon_sec/60:.0f} мин\n" f"Bias: {advice.get('bias', '?')}\n" f"Ожид. диапазон: {advice.get('expected_range_pct', 0)*100:.2f}%\n" f"Conf: {advice.get('confidence', 0):.2f}" ) # Записываем в last_applied, чтобы в дашборде было видно kronos_last_applied = { "ts": time.time(), "advice": advice, "base_center": base_center, "eff_center": base_center, # на паузе центр не двигаем "base_step": base_step, "eff_step": base_step, # на паузе шаг не двигаем "pause_until": kronos_pause_until, } return base_center, base_center, base_step, True # step: защитные пределы ("adjusted" режим по результатам backtest 3-config, # 14-мес BTCUSDT 1h: +1450% PnL, MaxDD -0.69% (лучший из 4-х конфигов), # см. deploy/BACKTEST-RESULTS.md) kronos_step = float(advice.get("step_percent", base_step)) eff_step = max(base_step * 0.85, min(base_step * 1.3, kronos_step)) # center offset (split for BUY and SELL anchors) # bias=up -> BUY anchor = current price (aggressive), SELL anchor = forecast price (above) # bias=down -> BUY anchor = forecast price (below), SELL anchor = current price # bias=neutral -> both = current price (legacy symmetric) center_offset_pct = float(advice.get("center_offset_pct", 0.0)) eff_offset = max(-0.0015, min(0.0015, center_offset_pct)) forecast_center = base_center * (1.0 + eff_offset) bias = advice.get("bias") if bias == "up": eff_buy_center = base_center eff_sell_center = forecast_center elif bias == "down": eff_buy_center = forecast_center eff_sell_center = base_center else: eff_buy_center = base_center eff_sell_center = base_center prev = kronos_last_applied kronos_last_applied = { "ts": time.time(), "advice": advice, "base_center": base_center, "eff_buy_center": eff_buy_center, "eff_sell_center": eff_sell_center, "eff_center": forecast_center, "base_step": base_step, "eff_step": eff_step, "pause_until": kronos_pause_until, } kronos_advice_history.append({ "ts": time.time(), "eff_step": eff_step, "eff_buy_center": eff_buy_center, "eff_sell_center": eff_sell_center, "eff_center": forecast_center, "bias": bias, "conf": advice.get("confidence", 0), "source": advice.get("source"), "mode": "live" if live else "demo", }) if len(kronos_advice_history) > 50: kronos_advice_history = kronos_advice_history[-50:] # TG-уведомление: только при ЗНАЧИМОМ изменении (или смене bias) if prev: step_change = abs(eff_step - prev.get("eff_step", eff_step)) / max(eff_step, 1e-9) bias_changed = advice.get("bias") != (prev.get("advice") or {}).get("bias") if step_change > 0.10 or bias_changed: tg_notify( f"🧠 KRONOS: обновлён совет\n" f"Шаг: {prev.get('eff_step', base_step)*100:.3f}% → {eff_step*100:.3f}%\n" f"Центр: ${prev.get('eff_center', base_center):,.0f} → ${eff_center:,.0f}\n" f"Bias: {advice.get('bias', '?')} (было {(prev.get('advice') or {}).get('bias', '?')})\n" f"Conf: {advice.get('confidence', 0):.2f}\n" f"Источник: {advice.get('source', '?')}" ) return eff_buy_center, eff_sell_center, eff_step, False # Wallet (live-mode) balance cache. # accountGetSummary is called at most once per WALLET_CACHE_TTL seconds. # On error / 403 we keep the last known good response and surface a "stale" flag. WALLET_CACHE_TTL = 30 # seconds _wallet_lock = Lock() _wallet_cache: dict = {"balances": [], "primary_currency": "USDT", "primary_balance": 0.0, "ts": 0.0, "error": None, "stale": True} # ─── Account info cache (from getOPQ) ───────────────────────────────────────── _opq_lock = Lock() _opq_cache: dict = { "rev": None, "brief_nm": None, "main_curr": "USDT", "active": None, "init_margin": None, "reception": None, "f_kval": None, "ts": 0.0, "stale": True, "error": None, } OPQ_CACHE_TTL = 300.0 # 5 minutes # ─── Quote info cache (from getSecurityInfo) ───────────────────────────────── _quote_info_lock = Lock() _quote_info_cache: dict = { "ticker": SYMBOL, "short_name": "", "currency": "USDT", "lot": None, "min_step": None, "mkt_name": "", "mkt_tz": "", "ts": 0.0, "stale": True, "error": None, } QUOTE_INFO_CACHE_TTL = 3600.0 # 1 hour # ─── Candles cache (from getHloc) ───────────────────────────────────────────── _candles_lock = Lock() _candles_cache: dict = { "timeframe": 60, "candles": [], # [{"t": unix_ts, "o": open, "h": high, "l": low, "c": close, "v": vol}, ...] "ts": 0.0, "stale": True, "error": None, } CANDLES_CACHE_TTL = 120.0 # 2 minutes # Price history for the dashboard chart. # Sampled every PRICE_SAMPLE_INTERVAL seconds (not every loop iteration) to keep size sane. # Each entry: {t: epoch_seconds, p: float, type: "tick"|"fill"|"rebuild", side?: "BUY"|"SELL"} PRICE_SAMPLE_INTERVAL = 30 # seconds between tick samples PRICE_HISTORY_MAX = 5000 # ~41 hours at 30s; covers 24h range comfortably price_history: deque = deque(maxlen=PRICE_HISTORY_MAX) price_history_lock = Lock() _last_price_sample_ts = 0.0 logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(), ], ) logger = logging.getLogger(__name__) # ─── Helpers ────────────────────────────────────────────────────────────────── def _tg_resolve_v4(host: str) -> str: """Resolve host to an IPv4 literal. Used as a fallback when IPv6 route flakes.""" try: infos = socket.getaddrinfo(host, 443, family=socket.AF_INET, type=socket.SOCK_STREAM) return infos[0][4][0] except Exception: return host def tg_notify(text: str): """Send Telegram notification. Appends SERVER_URL footer so the source is always visible. Retries up to 3 times with backoff, and falls back to IPv4 if the IPv6 path times out. """ if not TELEGRAM_BOT_TOKEN or TELEGRAM_BOT_TOKEN == "": return try: if SERVER_URL: text = f"{text}\n\n🔗 {SERVER_URL}" host = "api.telegram.org" url = f"https://{host}/bot{TELEGRAM_BOT_TOKEN}/sendMessage" data = urllib.parse.urlencode({"chat_id": TELEGRAM_CHAT_ID, "text": text}).encode() last_err = None for attempt in range(3): try: req = urllib.request.Request(url, data=data, headers={"Host": host}) with urllib.request.urlopen(req, timeout=10) as r: if r.status == 200: return except Exception as e: last_err = e if attempt < 2: # backoff: 1s, 3s time.sleep(1 + 2 * attempt) # if it looks like a network-level failure, try IPv4 once if "timed out" in str(e) or "Network is unreachable" in str(e): ip = _tg_resolve_v4(host) if ip and ip != host: url = f"https://{ip}/bot{TELEGRAM_BOT_TOKEN}/sendMessage" logger.error(f"Telegram notify failed after retries: {last_err}") except Exception as e: logger.error(f"Telegram notify failed: {e}") def _state_path() -> Path: """Primary state file (grid-state.json). Fall back to legacy margin-state.json for one-time migration.""" STATE_DIR.mkdir(parents=True, exist_ok=True) new = STATE_DIR / "grid-state.json" legacy = STATE_DIR / "margin-state.json" # one-time migration: if only legacy exists, copy it across if not new.exists() and legacy.exists(): try: new.write_text(legacy.read_text()) logger.info(f"Migrated state from {legacy} to {new}") except Exception as e: logger.warning(f"State migration failed: {e}") return new def load_state() -> dict: path = _state_path() if path.exists(): try: return json.loads(path.read_text()) except Exception: pass return { "grid_levels": GRID_LEVELS, "step_percent": GRID_STEP_PERCENT, "take_profit_percent": GRID_TAKE_PROFIT_PERCENT, "demo_mode": DEMO_MODE, "active_symbol": SYMBOL, } def save_state(state: dict): path = _state_path() path.write_text(json.dumps(state, indent=2, ensure_ascii=False)) def read_current_settings() -> dict: s = load_state() return { "grid_levels": s.get("grid_levels", GRID_LEVELS), "step_percent": s.get("step_percent", GRID_STEP_PERCENT), "take_profit_percent": s.get("take_profit_percent", GRID_TAKE_PROFIT_PERCENT), "demo_mode": s.get("demo_mode", DEMO_MODE), "active_symbol": SYMBOL, } def record_price_point(price: float, ptype: str = "tick", side: Optional[str] = None, bid: Optional[float] = None, ask: Optional[float] = None) -> None: """Append a point to price_history. Thread-safe (called from trading_loop thread + flask).""" if price <= 0: return pt = {"t": time.time(), "p": round(price, 4), "type": ptype} if side: pt["side"] = side if bid is not None and bid > 0: pt["bid"] = round(bid, 4) if ask is not None and ask > 0: pt["ask"] = round(ask, 4) with price_history_lock: price_history.append(pt) def _parse_account_summary(data: dict) -> list: """Normalize Tradernet account/position response into a list of balances. Accepts several response shapes: - getPositionJson: {"result": {"ps": {"acc": [{"curr": "USD", "currval": ..., "s": ...}, ...]}}} - {"money": [{"curr": "USDT", "free": ..., "locked": ...}, ...]} - {"result": {"money": [...]}} - [{"curr": "USDT", ...}, ...] - {"USDT": {"free": ...}, "BTC": {...}, ...} (currency-keyed dict) Each output item: {currency, free, locked, total} """ if not isinstance(data, dict): # raw list if isinstance(data, list): data = {"money": data} else: return [] # getPositionJson shape: {"result": {"ps": {"acc": [...]}}} if "result" in data and isinstance(data["result"], dict): ps = data["result"].get("ps") if isinstance(ps, dict) and "acc" in ps and isinstance(ps["acc"], list): data = {"money": ps["acc"]} money = data.get("money") if money is None and "result" in data and isinstance(data["result"], dict): money = data["result"].get("money") if money is None: # maybe currency-keyed dict at top level sample = next(iter(data.values()), None) if isinstance(sample, dict) and any(k in sample for k in ("free", "locked", "total", "balance", "currval")): money = [{"curr": k, **(v if isinstance(v, dict) else {})} for k, v in data.items()] out: list = [] if isinstance(money, list): for item in money: if not isinstance(item, dict): continue cur = (item.get("curr") or item.get("currency") or item.get("code") or item.get("symbol") or item.get("asset")) if not cur: continue # Tradernet getPositionJson uses: currval (free) and s (blocked/settled) free = float(item.get("currval") or item.get("free") or item.get("available") or item.get("avail") or item.get("free_balance") or 0) locked = float(item.get("s") or item.get("locked") or item.get("reserved") or item.get("blocked") or item.get("locked_balance") or 0) total = float(item.get("total") or item.get("balance") or (free + abs(locked))) out.append({"currency": str(cur), "free": free, "locked": locked, "total": total}) return out def fetch_wallet_balance(force: bool = False) -> dict: """Get wallet balances from Tradernet via getPositionJson with 30s caching. Thread-safe. Returns the cache dict: {balances: [{currency, free, locked, total}, ...], primary_currency, primary_balance, ts, age_seconds, stale, error} """ global _wallet_cache now = time.time() with _wallet_lock: last_ts = _wallet_cache.get("ts", 0) is_fresh = (now - last_ts) < WALLET_CACHE_TTL if is_fresh and not force and not _wallet_cache.get("stale", True): return dict(_wallet_cache, stale=False, age_seconds=int(now - last_ts)) if not api_client: with _wallet_lock: _wallet_cache.update({"error": "API client not initialised", "stale": True, "ts": now}) return dict(_wallet_cache) # Use sync REST call — safe in Flask handlers, no aiohttp event-loop issues. try: raw = api_client.get_position_json_sync() except Exception as e: logger.warning(f"getPositionJson sync error: {e}") with _wallet_lock: existing = _wallet_cache.get("balances") or [] _wallet_cache.update({"error": str(e), "stale": True, "ts": now, "balances": existing, "primary_balance": _wallet_cache.get("primary_balance", 0.0)}) return dict(_wallet_cache) if not raw: # 403, timeout, etc. — _post returns {} on 403 err = "API ключ невалиден или запрос отклонён (403)" with _wallet_lock: existing = _wallet_cache.get("balances") or [] _wallet_cache.update({"error": err, "stale": True, "ts": now, "balances": existing, "primary_balance": _wallet_cache.get("primary_balance", 0.0)}) logger.warning(f"getPositionJson returned empty: {err}") return dict(_wallet_cache) balances = _parse_account_summary(raw) if not balances: logger.warning(f"getPositionJson unparseable: {str(raw)[:300]}") with _wallet_lock: existing = _wallet_cache.get("balances") or [] _wallet_cache.update({ "error": f"Неизвестный формат ответа: {str(raw)[:120]}", "stale": True, "ts": now, "balances": existing, "primary_balance": _wallet_cache.get("primary_balance", 0.0), }) return dict(_wallet_cache) # Pick primary currency: USDT if present, else USD (Tradernet main_curr is often USDT), # else QUOTE config, else first non-zero balance primary = None cur_set = {b["currency"].upper(): b for b in balances} # Try main_curr from getOPQ as a hint, but we don't fetch it separately for candidate in ["USDT", "USD", QUOTE.upper()]: if candidate in cur_set: primary = candidate break if not primary: # pick first with non-zero balance nonzero = [b for b in balances if b["free"] != 0 or b["locked"] != 0] primary = (nonzero or balances)[0]["currency"].upper() primary_balance = cur_set[primary]["total"] if primary in cur_set else 0.0 with _wallet_lock: _wallet_cache.update({ "balances": balances, "primary_currency": primary, "primary_balance": primary_balance, "ts": now, "error": None, "stale": False, }) logger.info(f"Wallet refreshed: {len(balances)} currencies, {primary}={primary_balance}") return dict(_wallet_cache, stale=False, age_seconds=0) def fetch_account_info(force: bool = False) -> dict: """Get account info (getOPQ) with 5-minute cache. Returns: {rev, brief_nm, main_curr, active, init_margin, reception, f_kval, ts, age_seconds, stale, error} """ global _opq_cache now = time.time() with _opq_lock: last_ts = _opq_cache.get("ts", 0) is_fresh = (now - last_ts) < OPQ_CACHE_TTL if is_fresh and not force and not _opq_cache.get("stale", True): return dict(_opq_cache, stale=False, age_seconds=int(now - last_ts)) if not api_client: with _opq_lock: _opq_cache.update({"error": "API client not initialised", "stale": True, "ts": now}) return dict(_opq_cache) try: raw = api_client.get_opq_sync() except Exception as e: logger.warning(f"getOPQ sync error: {e}") with _opq_lock: existing = {k: _opq_cache.get(k) for k in ("rev", "brief_nm", "main_curr", "active", "init_margin", "reception", "f_kval")} existing.update({"error": str(e), "stale": True, "ts": now}) _opq_cache.update(existing) return dict(_opq_cache) if not raw: with _opq_lock: existing = {k: _opq_cache.get(k) for k in ("rev", "brief_nm", "main_curr", "active", "init_margin", "reception", "f_kval")} existing.update({"error": "API ключ невалиден или запрос отклонён (403)", "stale": True, "ts": now}) _opq_cache.update(existing) return dict(_opq_cache) opq = raw.get("OPQ", {}) with _opq_lock: _opq_cache.update({ "rev": opq.get("rev"), "brief_nm": opq.get("brief_nm"), "main_curr": opq.get("main_curr", "USDT"), "active": opq.get("active"), "init_margin": opq.get("init_margin"), "reception": opq.get("reception"), "f_kval": opq.get("f_kval"), "ts": now, "error": None, "stale": False, }) logger.info(f"Account info refreshed: brief_nm={opq.get('brief_nm')}, main_curr={opq.get('main_curr')}") return dict(_opq_cache, stale=False, age_seconds=0) def fetch_quote_info(force: bool = False) -> dict: """Get security info (lot, min_step, currency, market) for SYMBOL. Returns: {ticker, short_name, currency, lot, min_step, mkt_name, mkt_tz, ts, age_seconds, stale, error} """ global _quote_info_cache now = time.time() with _quote_info_lock: last_ts = _quote_info_cache.get("ts", 0) is_fresh = (now - last_ts) < QUOTE_INFO_CACHE_TTL if is_fresh and not force and not _quote_info_cache.get("stale", True): return dict(_quote_info_cache, stale=False, age_seconds=int(now - last_ts)) if not api_client: with _quote_info_lock: _quote_info_cache.update({"error": "API client not initialised", "stale": True, "ts": now}) return dict(_quote_info_cache) try: info = api_client.get_security_info_sync(SYMBOL) except Exception as e: logger.warning(f"getSecurityInfo sync error: {e}") with _quote_info_lock: existing = {k: _quote_info_cache.get(k) for k in ("ticker", "short_name", "currency", "lot", "min_step", "mkt_name", "mkt_tz")} existing.update({"error": str(e), "stale": True, "ts": now}) _quote_info_cache.update(existing) return dict(_quote_info_cache) if not info: with _quote_info_lock: existing = {k: _quote_info_cache.get(k) for k in ("ticker", "short_name", "currency", "lot", "min_step", "mkt_name", "mkt_tz")} existing.update({"error": "API ключ невалиден или запрос отклонён (403)", "stale": True, "ts": now}) _quote_info_cache.update(existing) return dict(_quote_info_cache) mrkt = info.get("mrkt", {}) or {} with _quote_info_lock: _quote_info_cache.update({ "ticker": info.get("id", SYMBOL), "short_name": info.get("short_name", ""), "currency": info.get("currency", "USDT"), "lot": info.get("lot"), "min_step": info.get("min_step"), "mkt_name": info.get("mkt_name", ""), "mkt_tz": mrkt.get("tz", ""), "ts": now, "error": None, "stale": False, }) logger.info(f"Quote info refreshed: lot={info.get('lot')}, min_step={info.get('min_step')}") return dict(_quote_info_cache, stale=False, age_seconds=0) def fetch_candles(timeframe_min: int = 60, force: bool = False) -> dict: """Get OHLCV candlesticks for SYMBOL via getHloc. Args: timeframe_min: 1, 5, 15, 60, 1440 (daily) force: bypass cache Returns: {timeframe, candles: [{t, o, h, l, c, v}, ...], ts, age_seconds, stale, error} Candles cover last 7 days for intraday, last 30 days for daily. """ global _candles_cache now = time.time() with _candles_lock: last_tf = _candles_cache.get("timeframe", 0) last_ts = _candles_cache.get("ts", 0) is_fresh = (now - last_ts) < CANDLES_CACHE_TTL and last_tf == timeframe_min if is_fresh and not force and not _candles_cache.get("stale", True): return dict(_candles_cache, stale=False, age_seconds=int(now - last_ts)) if not api_client: with _candles_lock: _candles_cache.update({ "timeframe": timeframe_min, "candles": _candles_cache.get("candles", []), "error": "API client not initialised", "stale": True, "ts": now, }) return dict(_candles_cache) # Date range: last 7d for intraday (1/5/15/60 min), 60d for daily now_struct = time.gmtime(now) if timeframe_min >= 1440: days_back = 60 else: days_back = 7 from datetime import datetime, timedelta, timezone dt_to = datetime.fromtimestamp(now, tz=timezone.utc) dt_from = dt_to - timedelta(days=days_back) date_from = dt_from.strftime("%d.%m.%Y %H:%M") date_to = dt_to.strftime("%d.%m.%Y %H:%M") try: raw = api_client.get_hloc_sync(SYMBOL, timeframe_min, date_from, date_to, 0) except Exception as e: logger.warning(f"getHloc sync error: {e}") with _candles_lock: _candles_cache.update({ "timeframe": timeframe_min, "candles": _candles_cache.get("candles", []), "error": str(e), "stale": True, "ts": now, }) return dict(_candles_cache) if not raw: with _candles_lock: _candles_cache.update({ "timeframe": timeframe_min, "candles": _candles_cache.get("candles", []), "error": "API ключ невалиден или запрос отклонён (403)", "stale": True, "ts": now, }) return dict(_candles_cache) # Normalize: Tradernet returns {"hloc": {"TICKER": [[o,h,l,c], ...]}, "xSeries": {...}, "vl": {...}} hloc_list = raw.get("hloc", {}).get(SYMBOL, []) ts_list = raw.get("xSeries", {}).get(SYMBOL, []) vol_list = raw.get("vl", {}).get(SYMBOL, []) candles = [] for i, ohlc in enumerate(hloc_list): if not isinstance(ohlc, (list, tuple)) or len(ohlc) < 4: continue t = ts_list[i] if i < len(ts_list) else 0 v = vol_list[i] if i < len(vol_list) else 0 candles.append({ "t": int(t), "o": float(ohlc[0]), "h": float(ohlc[1]), "l": float(ohlc[2]), "c": float(ohlc[3]), "v": float(v) if v is not None else 0.0, }) with _candles_lock: _candles_cache.update({ "timeframe": timeframe_min, "candles": candles, "ts": now, "error": None, "stale": False, }) logger.info(f"Candles refreshed: tf={timeframe_min}min, {len(candles)} candles") return dict(_candles_cache, stale=False, age_seconds=0) async def refresh_price() -> float: global api_client, current_price, last_quote, _price_source # 1) Tradernet — основной источник в обоих режимах (LIVE и DEMO) if api_client: try: data = await api_client.get_quotes([SYMBOL]) items = data.get("result", {}).get("q", []) or data.get("q", []) if items: q = items[0] ltp = q.get("ltp") or q.get("last") or 0 if ltp and ltp > 0: last_quote = { "ltp": float(q.get("ltp") or 0), "bap": float(q.get("bap") or 0), "bbp": float(q.get("bbp") or 0), "ltt": q.get("ltt", ""), "chg": float(q.get("chg") or 0), "chg110": float(q.get("chg110") or q.get("chg_pct") or 0), "vol": float(q.get("vol") or 0), "op": float(q.get("op") or 0), "pp": float(q.get("pp") or 0), } _price_source = "tradernet" return float(ltp) except Exception as e: logger.debug(f"Tradernet price error: {e}") # 2) DEMO + нет Tradernet — публичный Binance (без ключей) как запасной if DEMO_MODE: try: binance_sym = f"{BASE}{QUOTE}".upper() # BTC-USDT.IMEX -> BTCUSDT url = f"https://api.binance.com/api/v3/ticker/price?symbol={binance_sym}" req = urllib.request.Request(url, headers={"User-Agent": "grid-bot/demo"}) with urllib.request.urlopen(req, timeout=4) as r: payload = json.loads(r.read().decode()) px = float(payload.get("price") or 0) if px > 0: _price_source = "binance" return px except Exception as e: logger.debug(f"Binance price fallback error: {e}") return current_price # ─── Trading Loop ───────────────────────────────────────────────────────────── # ─── Live-trading helpers ────────────────────────────────────────────────── # All these are SYNC (urllib-based via api_client.*_sync) because the aiohttp # session in api_client is bound to its own event loop, and our trading loop # runs in a different loop. urllib is safe in any context. def _live_cancel_all_pending(): """Cancel every order we believe is still active on the exchange.""" global live_pending_orders if not api_client: return cancelled = 0 failed = 0 for o in list(live_pending_orders): oid = o.get("order_id") if not oid: continue if LIVE_TRADING_DRY_RUN: logger.info(f"[DRY-RUN] would cancel order {oid} ({o['side']} @ {o['price']:.2f})") cancelled += 1 continue try: resp = api_client.cancel_order_sync(oid) if resp and "error" in resp: logger.warning(f"[LIVE] cancel order {oid} rejected: {resp['error']}") # Probably already filled/cancelled — treat as terminal failed += 1 else: cancelled += 1 except Exception as e: logger.warning(f"[LIVE] cancel order {oid} error: {e}") failed += 1 if cancelled or failed: logger.info(f"[LIVE] cancel_all: cancelled={cancelled}, errors={failed}") live_pending_orders = [] def _live_sync_position(): """Pull current position from getPositionJson and reconcile with local state. Side effects: - If a position appears and we don't track one, mark the oldest pending BUY order as filled and create live_position. - If position disappears and we had one, mark the SELL order as filled and append a trade to live_trade_log. """ global live_position, live_pending_orders, live_trade_log if not api_client: return None try: data = api_client.get_position_json_sync(timeout=10) except Exception as e: logger.debug(f"[LIVE] sync_position error: {e}") return None if not data: return None ps = data.get("result", {}).get("ps", {}) pos_list = ps.get("pos", []) or [] btc_pos = None for p in pos_list: if isinstance(p, dict) and p.get("symbol") == SYMBOL: btc_pos = p break if btc_pos: vol = float(btc_pos.get("vol", 0) or 0) avg = float(btc_pos.get("avg_price", 0) or btc_pos.get("open_price", 0) or 0) if vol > 0: if live_position is None: filled_buy = None for o in live_pending_orders: if o["side"] == "BUY" and o["status"] == "pending": filled_buy = o break if filled_buy: filled_buy["status"] = "filled" filled_at = filled_buy["price"] live_position = { "side": "BUY", "entry_price": filled_at, "qty": filled_buy["qty"], "order_id": filled_buy["order_id"], "avg_price_exchange": avg, "raw": btc_pos, } live_pending_orders = [o for o in live_pending_orders if o["order_id"] != filled_buy["order_id"]] logger.info(f"[LIVE] BUY filled @ {filled_at:.2f} qty={filled_buy['qty']} " f"(exchange avg={avg:.2f})") record_price_point(filled_at, "fill", "BUY") tg_notify( f"🟢 LIVE BUY заполнен\n" f"Цена: ${filled_at:,.2f}\n" f"Количество: {filled_buy['qty']} BTC\n" f"Средняя биржи: ${avg:,.2f}\n" f"Текущая: ${current_price:,.2f}" ) else: live_position = { "side": "BUY", "entry_price": avg, "qty": vol, "order_id": None, "avg_price_exchange": avg, "raw": btc_pos, } logger.info(f"[LIVE] detected external position: qty={vol} avg={avg}") else: live_position["avg_price_exchange"] = avg live_position["raw"] = btc_pos else: if live_position is not None: entry = live_position.get("entry_price", 0) qty = live_position.get("qty", 0) entry_oid = live_position.get("order_id") exit_oid = None exit_price = 0 for o in list(live_pending_orders): if o["side"] == "SELL" and o["status"] == "pending": exit_oid = o["order_id"] exit_price = o["price"] o["status"] = "filled" if exit_price == 0 and live_pending_orders: exit_price = current_price pnl = (exit_price - entry) * qty if entry and exit_price else 0 pnl_pct = (pnl / (entry * qty) * 100) if entry and qty else 0 trade = { "entry_order_id": entry_oid, "exit_order_id": exit_oid, "entry_price": entry, "exit_price": exit_price, "qty": qty, "pnl_usdc": round(pnl, 8), "pnl_pct": round(pnl_pct, 4), "ts": time.time(), } live_trade_log.append(trade) logger.info(f"[LIVE] SELL filled @ {exit_price:.2f} entry={entry:.2f} " f"qty={qty} pnl={pnl:+.6f} USDT ({pnl_pct:+.2f}%)") record_price_point(exit_price, "fill", "SELL") tg_notify( f"🔴 LIVE SELL заполнен\n" f"Entry: ${entry:,.2f}\n" f"Exit: ${exit_price:,.2f}\n" f"Количество: {qty} BTC\n" f"PNL: {pnl:+.6f} USDT ({pnl_pct:+.2f}%)" ) live_pending_orders = [o for o in live_pending_orders if o.get("status") != "filled"] live_position = None return btc_pos def _live_stale_check(): """If price dropped more than GRID_STALE_DROP_PERCENT from start_price with no fills yet — cancel all and rebuild from current price. """ global start_price, live_orders_placed, grid_stale, grid_stale_drop_pct, grid_stale_rebuilds if GRID_STALE_DROP_PERCENT <= 0 or start_price <= 0 or current_price <= 0: return if live_position is not None: return if not live_pending_orders: return drop_pct = (start_price - current_price) / start_price * 100 if drop_pct >= GRID_STALE_DROP_PERCENT: cancelled = len(live_pending_orders) _live_cancel_all_pending() start_price = current_price live_orders_placed = False grid_stale = True grid_stale_drop_pct = drop_pct grid_stale_rebuilds += 1 logger.warning( f"[LIVE STALE-GRID] Drop {drop_pct:.2f}% >= {GRID_STALE_DROP_PERCENT}% " f"with no fills. Cancelled {cancelled} orders, rebuilding at ${current_price:,.2f}" ) tg_notify( f"♻️ LIVE Stale-grid: цена упала на {drop_pct:.2f}% без сделок\n" f"Отменено ордеров: {cancelled}\n" f"Новая сетка от ${current_price:,.2f}" ) record_price_point(current_price, "rebuild") def _live_place_grid_buys(grid_center: float): """Place LIMIT BUY orders for all grid levels below current price. Live qty is 0.00001 (1 minimum lot for BTC-USDT.IMEX) per level regardless of grid_engine base_qty, because the live account has only 138 USDT available and 0.001 BTC per level = 70 USDT × 5 = 350 USDT (insufficient). """ global live_pending_orders, live_orders_placed if not api_client: return levels = grid_engine.get_grid_levels(grid_center) placed = 0 failed = 0 LIVE_QTY = 0.00001 # 1 minimum lot for lvl in levels: if lvl.side != "BUY": continue if any(o["side"] == "BUY" and o["level_id"] == lvl.level_id and o["status"] == "pending" for o in live_pending_orders): continue if LIVE_TRADING_DRY_RUN: fake_id = int(time.time() * 1000) + lvl.level_id live_pending_orders.append({ "order_id": fake_id, "side": "BUY", "price": lvl.price, "qty": LIVE_QTY, "level_id": lvl.level_id, "status": "pending", "created_at": time.time(), "raw": {"dry_run": True, "client_id": f"gridbot-buy-{lvl.level_id}"}, }) logger.info(f"[DRY-RUN] would place BUY {LIVE_QTY} BTC @ ${lvl.price:.2f} " f"(fake_id={fake_id})") placed += 1 continue try: resp = api_client.put_order_sync( ticker=SYMBOL, price=lvl.price, qty=LIVE_QTY, action="BUY", order_type="LIMIT", client_id=f"gridbot-buy-{lvl.level_id}" ) if not resp: failed += 1 continue if "error" in resp: logger.warning(f"[LIVE] BUY @ {lvl.price:.2f} rejected: {resp['error']}") failed += 1 continue oid = resp.get("order_id") if not oid: logger.warning(f"[LIVE] BUY @ {lvl.price:.2f} no order_id: {resp}") failed += 1 continue live_pending_orders.append({ "order_id": oid, "side": "BUY", "price": lvl.price, "qty": LIVE_QTY, "level_id": lvl.level_id, "status": "pending", "created_at": time.time(), "raw": resp.get("order", resp), }) placed += 1 except Exception as e: logger.warning(f"[LIVE] BUY @ {lvl.price:.2f} error: {e}") failed += 1 if placed or failed: logger.info(f"[LIVE] Placed {placed} BUY orders, {failed} failed (price ~{grid_center:.2f})") if placed > 0: live_orders_placed = True def _live_place_grid_sells(grid_center: float): """Place LIMIT SELL orders above current price when we have a position.""" global live_pending_orders if not api_client or live_position is None: return if live_position.get("side") != "BUY": return levels = grid_engine.get_grid_levels(grid_center) placed = 0 failed = 0 LIVE_QTY = 0.00001 # match the BUY qty for lvl in levels: if lvl.side != "SELL": continue if any(o["side"] == "SELL" and o["level_id"] == lvl.level_id and o["status"] == "pending" for o in live_pending_orders): continue if LIVE_TRADING_DRY_RUN: fake_id = int(time.time() * 1000) + lvl.level_id + 10000 live_pending_orders.append({ "order_id": fake_id, "side": "SELL", "price": lvl.price, "qty": LIVE_QTY, "level_id": lvl.level_id, "status": "pending", "created_at": time.time(), "raw": {"dry_run": True, "client_id": f"gridbot-sell-{lvl.level_id}"}, }) logger.info(f"[DRY-RUN] would place SELL {LIVE_QTY} BTC @ ${lvl.price:.2f} " f"(fake_id={fake_id})") placed += 1 continue try: resp = api_client.put_order_sync( ticker=SYMBOL, price=lvl.price, qty=LIVE_QTY, action="SELL", order_type="LIMIT", client_id=f"gridbot-sell-{lvl.level_id}" ) if not resp: failed += 1 continue if "error" in resp: logger.warning(f"[LIVE] SELL @ {lvl.price:.2f} rejected: {resp['error']}") failed += 1 continue oid = resp.get("order_id") if not oid: failed += 1 continue live_pending_orders.append({ "order_id": oid, "side": "SELL", "price": lvl.price, "qty": LIVE_QTY, "level_id": lvl.level_id, "status": "pending", "created_at": time.time(), "raw": resp.get("order", resp), }) placed += 1 except Exception as e: logger.warning(f"[LIVE] SELL @ {lvl.price:.2f} error: {e}") failed += 1 if placed or failed: logger.info(f"[LIVE] Placed {placed} SELL orders, {failed} failed (price ~{grid_center:.2f})") def _live_take_profit(): """If position PnL >= take_profit_percent, close it with a market order.""" if not api_client or live_position is None: return if live_position.get("side") != "BUY": return entry = live_position.get("entry_price", 0) qty = live_position.get("qty", 0) if entry <= 0 or qty <= 0 or current_price <= 0: return # Override to LIVE_QTY if needed (qty from pending BUY may be wrong if BUY was external) pnl_pct = (current_price - entry) / entry * 100 if pnl_pct >= grid_engine.take_profit_percent * 100: for o in list(live_pending_orders): if o["side"] == "SELL" and o["status"] == "pending": if LIVE_TRADING_DRY_RUN: logger.info(f"[DRY-RUN] would cancel SELL order {o['order_id']} @ {o['price']:.2f}") else: try: api_client.cancel_order_sync(o["order_id"]) except Exception: pass o["status"] = "cancelled" live_pending_orders = [o for o in live_pending_orders if o.get("status") != "cancelled"] if LIVE_TRADING_DRY_RUN: logger.info(f"[DRY-RUN] would place MARKET SELL {qty} BTC (TP, pnl={pnl_pct:+.2f}%)") tg_notify( f"🎯 LIVE TAKE PROFIT (DRY-RUN)\n" f"Entry: ${entry:,.2f}\n" f"Текущая: ${current_price:,.2f}\n" f"PNL: {pnl_pct:+.2f}%\n" f"Бот бы закрыл позицию market-ордером" ) return try: resp = api_client.put_order_sync( ticker=SYMBOL, price=0, qty=qty, action="SELL", order_type="MARKET", client_id=f"gridbot-tp" ) if resp and "error" not in resp and resp.get("order_id"): logger.info(f"[LIVE TP] Market SELL placed: order_id={resp['order_id']}") tg_notify( f"🎯 LIVE TAKE PROFIT\n" f"Entry: ${entry:,.2f}\n" f"Текущая: ${current_price:,.2f}\n" f"PNL: {pnl_pct:+.2f}%\n" f"Закрытие market-ордером" ) else: logger.warning(f"[LIVE TP] market SELL failed: {resp}") except Exception as e: logger.error(f"[LIVE TP] exception: {e}") async def trading_loop(): global current_price, start_price, last_error, bot_running global grid_engine, demo, demo_orders_placed global grid_stale, grid_stale_drop_pct global live_pending_orders, live_position, live_trade_log, live_orders_placed global _diag_demo_logged, _price_source settings = read_current_settings() grid_engine = GridEngine( levels=settings["grid_levels"], step_percent=settings["step_percent"], take_profit_percent=settings["take_profit_percent"], symbol=SYMBOL, mode="demo" if settings["demo_mode"] else "live", ) demo = DemoSimulator(start_balance=DEMO_START_BALANCE) demo_orders_placed = False # Reset live state on every (re)start live_pending_orders = [] live_position = None live_trade_log = [] live_orders_placed = False grid_stale = False _diag_demo_logged = False grid_stale_drop_pct = 0.0 with price_history_lock: price_history.clear() _last_price_sample_ts = 0.0 price = await refresh_price() if price > 0: current_price = price start_price = price bid = last_quote.get("bbp") or None ask = last_quote.get("bap") or None record_price_point(price, "tick", bid=bid, ask=ask) _last_price_sample_ts = time.time() elif DEMO_MODE and current_price <= 0: # Синхронный фолбэк на Tradernet (прямой HMAC-вызов, чтобы не зависеть от async) try: import hmac as _hmac, hashlib as _hashlib ts = str(int(time.time() * 1000)) params = {"tickers": [SYMBOL]} body = json.dumps(params).encode() body_b64 = base64.b64encode(body).decode() msg = (TRADERNET_PRIVATE_KEY + TRADERNET_PUBLIC_KEY + ts + body_b64).encode() sig = _hmac.new(TRADERNET_PRIVATE_KEY.encode(), msg, _hashlib.sha256).hexdigest() url = f"{TRADERNET_BASE_URL}/api/v1/cmd" req = urllib.request.Request(url, data=body, method="POST") req.add_header("Content-Type", "application/json") req.add_header("X-NtApi-PublicKey", TRADERNET_PUBLIC_KEY) req.add_header("X-NtApi-Sig", sig) req.add_header("X-NtApi-Timestamp", ts) req.add_header("X-NtApi-Cmd", "getQuotes") with urllib.request.urlopen(req, timeout=5) as r: payload = json.loads(r.read().decode()) items = payload.get("result", {}).get("q", []) or payload.get("q", []) if items: q = items[0] ltp = float(q.get("ltp") or q.get("last") or 0) if ltp > 0: current_price = ltp start_price = ltp _price_source = "tradernet" logger.info(f"DEMO startup: Tradernet price ${ltp:,.2f}") record_price_point(ltp, "tradernet-startup") _last_price_sample_ts = time.time() except Exception as e: logger.warning(f"DEMO startup Tradernet fetch failed: {e}") if current_price <= 0: # Запасной вариант: Binance try: binance_sym = f"{BASE}{QUOTE}".upper() url = f"https://api.binance.com/api/v3/ticker/price?symbol={binance_sym}" req = urllib.request.Request(url, headers={"User-Agent": "grid-bot/demo-startup"}) with urllib.request.urlopen(req, timeout=5) as r: payload = json.loads(r.read().decode()) px = float(payload.get("price") or 0) if px > 0: current_price = px start_price = px _price_source = "binance" logger.info(f"DEMO startup: Binance fallback price ${px:,.2f}") record_price_point(px, "binance-startup") _last_price_sample_ts = time.time() except Exception as e: logger.warning(f"DEMO startup Binance fetch failed: {e}") if current_price <= 0: # Последний фолбэк — синтетика (только если ничего не доступно) current_price = 100000.0 start_price = 100000.0 _price_source = "synth" logger.warning(f"DEMO mode: no live price anywhere, using synth ${current_price:,.2f}") record_price_point(current_price, "synth-fallback") mode_str = "демо" if settings["demo_mode"] else "реальный" balance_str = "" if not settings["demo_mode"]: # Refresh balance once on startup so TG notification shows real USDT try: b = fetch_wallet_balance(force=True) balance_str = f"\nБаланс: {b.get('balance', 0):.2f} {b.get('currency', '')}" except Exception as e: logger.warning(f"startup balance fetch failed: {e}") tg_notify(f"🚀 Бот запущен\nРежим: {mode_str}\nШаг: {settings['step_percent']*100:.2f}%\nTP: {settings['take_profit_percent']*100:.2f}%{balance_str}" + ("\n⚠️ DRY-RUN: ордера не отправляются на биржу" if LIVE_TRADING_DRY_RUN else "\n✅ РЕАЛЬНАЯ ТОРГОВЛЯ")) while bot_running: try: # DEBUG: лог для отслеживания активности цикла (троттлинг 5 сек) if not hasattr(trading_loop, '_last_loop_log') or time.time() - trading_loop._last_loop_log > 5: trading_loop._last_loop_log = time.time() logger.debug(f"[loop] iter: current_price={current_price}, bot_running={bot_running}") price = await refresh_price() if price > 0: current_price = price # Throttled price tick for the chart now_ts = time.time() if now_ts - _last_price_sample_ts >= PRICE_SAMPLE_INTERVAL: bid = last_quote.get("bbp") or None ask = last_quote.get("bap") or None record_price_point(price, "tick", bid=bid, ask=ask) _last_price_sample_ts = now_ts elif DEMO_MODE and _price_source == "binance" and current_price > 0: # DEMO Binance уже загрузился, но один тик пришёл пустым — # держим последнюю цену (не дрейфуем от $100k). pass elif DEMO_MODE and current_price > 0 and _price_source == "synth": # Демо без живого источника: лёгкий random-walk (только если не было реальных данных) import random _drift = (random.random() - 0.5) * 0.006 # ±0.3% current_price = current_price * (1.0 + _drift) now_ts = time.time() if now_ts - _last_price_sample_ts >= PRICE_SAMPLE_INTERVAL: record_price_point(current_price, "synth") _last_price_sample_ts = now_ts # Микро-шум на живой DEMO-цене: имитация межинтревального тика if DEMO_MODE and price > 0 and _price_source == "binance": import random _noise = (random.random() - 0.5) * 0.0002 # ±0.01% (микро-шум) current_price = current_price * (1.0 + _noise) # ─── DEMO-ветка / LIVE-ветка (общая для обоих путей) ── state = load_state() demo_mode = state.get("demo_mode", DEMO_MODE) # First-iter debug: log the resolved value to confirm # which branch is taken. if not _diag_demo_logged: _diag_demo_logged = True logger.info(f"[DEBUG] state.demo_mode={state.get('demo_mode')!r} " f"resolved demo_mode={demo_mode!r} " f"branch={'DEMO' if demo_mode else 'LIVE'}") if demo_mode: grid_center = current_price # ─── Kronos advisor (DEMO) ───────────────────── eff_buy_center, eff_sell_center, eff_step, kronos_paused = effective_grid_params( grid_center, settings["step_percent"], live=False ) if kronos_paused: # Kronos-пауза: пропускаем перестановку ордеров, логируем logger.debug("[KRONOS] DEMO paused, skipping grid rebuild") else: grid_center = eff_center # Пересобираем grid_engine только если шаг реально изменился if abs(eff_step - settings["step_percent"]) > 1e-6: grid_engine = GridEngine( levels=settings["grid_levels"], step_percent=eff_step, take_profit_percent=settings["take_profit_percent"], symbol=SYMBOL, mode="demo", ) # ─── LIVE mode (real exchange orders) ────────────────────── # Runs in parallel with demo-mode logic above. The two never # touch the same state, so demo and live can both be tested # without interference (only one is active at a time per state). if not demo_mode: # Stale-grid: rebuild if price dropped too far without fills _live_stale_check() # Sync position with exchange (detects fills of pending orders) _live_sync_position() grid_center = current_price # ─── Kronos advisor (LIVE) ────────────────────── eff_buy_center_l, eff_sell_center_l, eff_step_l, kronos_paused_l = effective_grid_params( grid_center, settings["step_percent"], live=True ) if kronos_paused_l: logger.info("[KRONOS] LIVE paused: not placing new grid orders this cycle") # Не ставим новые ордера, но и не отменяем существующие # (на случай, если это короткая пауза) else: grid_center = eff_buy_center_l if abs(eff_step_l - settings["step_percent"]) > 1e-6: grid_engine = GridEngine( levels=settings["grid_levels"], step_percent=eff_step_l, take_profit_percent=settings["take_profit_percent"], symbol=SYMBOL, mode="live", ) # Place initial BUY grid (only once per session / per rebuild) if not kronos_paused_l and not live_orders_placed and live_position is None: _live_place_grid_buys(grid_center) # Once we have a position, place SELL limits if not kronos_paused_l and live_position is not None: _live_place_grid_sells(grid_center) # Take-profit check (независимо от паузы — TP всегда важен) _live_take_profit() # ─── DEMO mode (offline simulator, runs only in demo) ─────── # The simulator just runs the same grid logic on a fake balance # so you can see how the strategy behaves without touching the # real account. The two branches (LIVE / DEMO) are mutually # exclusive by `state.demo_mode` flag. if demo_mode: # Honor Kronos pause: SKIP new placements and after-fill SELL placement while paused # (TP and fill checks still run below, but no new orders) if kronos_paused: logger.debug("[KRONOS] DEMO paused, skipping new placements this cycle") # Skip the rest of the demo placement block # (we still want to fall through to TP/fill checks if position exists) # ─── Stale-grid recovery ──────────────────────────────── # If no trades yet, position is empty, orders are still pending # and price dropped more than GRID_STALE_DROP_PERCENT from start_price # → cancel all orders and rebuild grid from current price. if ( GRID_STALE_DROP_PERCENT > 0 and start_price > 0 and current_price > 0 and len(demo.trade_log) == 0 and demo.position is None and len(demo.orders) > 0 ): drop_pct = (start_price - current_price) / start_price * 100 if drop_pct >= GRID_STALE_DROP_PERCENT: cancelled = len(demo.orders) demo.orders = [] demo_orders_placed = False start_price = current_price grid_stale = True grid_stale_drop_pct = drop_pct grid_stale_rebuilds += 1 logger.warning( f"[STALE-GRID] Drop {drop_pct:.2f}% >= {GRID_STALE_DROP_PERCENT}% " f"with no fills. Cancelled {cancelled} orders, rebuilding at ${current_price:,.2f}" ) tg_notify( f"♻️ Stale-grid: цена упала на {drop_pct:.2f}% без сделок\n" f"Отменено ордеров: {cancelled}\n" f"Новая сетка от ${current_price:,.2f}" ) record_price_point(current_price, "rebuild") # rebuild on next iteration (orders will be re-placed below) # Place grid orders once (with dedup by level_id) — SKIP while Kronos paused if not kronos_paused and (not demo_orders_placed or demo.position is None): levels = grid_engine.get_grid_levels( grid_center, buy_center=eff_buy_center, sell_center=eff_sell_center, ) placed_buys = 0 placed_sells = 0 for lvl in levels: if lvl.side == "BUY": # Skip if open order already exists for this level if any(o["side"] == "BUY" and o["level_id"] == lvl.level_id and not o["filled"] for o in demo.orders): continue if demo.place_buy_order(lvl.price, lvl.qty, lvl.level_id): placed_buys += 1 elif lvl.side == "SELL" and demo.position: # Skip if open order already exists for this level if any(o["side"] == "SELL" and o["level_id"] == lvl.level_id and not o["filled"] for o in demo.orders): continue if demo.place_sell_order(lvl.price, lvl.qty, lvl.level_id): placed_sells += 1 if placed_buys > 0 or placed_sells > 0: demo_orders_placed = True logger.info(f"[DEMO] Placed {placed_buys} BUY / {placed_sells} SELL at price {current_price}") # Check fills filled_buys = demo.check_fill_buy(current_price) filled_sells = demo.check_fill_sell(current_price) for order in filled_buys: record_price_point(order['price'], "fill", "BUY") tg_notify( f"🟢 BUY заполнен\n" f"Цена: ${order['price']:,.2f}\n" f"Количество: {order['qty']} BTC\n" f"BTC: ${current_price:,.2f}" ) logger.info(f"[DEMO] BUY filled @ ${order['price']:,.2f} qty={order['qty']} BTC | spot=${current_price:,.2f}") for order in filled_sells: record_price_point(order['price'], "fill", "SELL") pnl = 0 for t in demo.trade_log: if t.get("exit") == order["price"]: pnl = t["pnl_usdc"] break tg_notify( f"🔴 SELL заполнен\n" f"Цена: ${order['price']:,.2f}\n" f"Количество: {order['qty']} BTC\n" f"PNL: {pnl:+.6f} USDT" ) logger.info(f"[DEMO] SELL filled @ ${order['price']:,.2f} qty={order['qty']} BTC | pnl={pnl:+.6f} USDT | spot=${current_price:,.2f}") # After BUY fill, place SELL orders (with dedup by level_id) — SKIP while Kronos paused if not kronos_paused and demo.position and demo.position.side == "BUY": placed_sells = 0 for lvl in grid_engine.get_grid_levels( grid_center, buy_center=eff_buy_center, sell_center=eff_sell_center, ): if lvl.side != "SELL": continue if any(o["side"] == "SELL" and o["level_id"] == lvl.level_id and not o["filled"] for o in demo.orders): continue if demo.place_sell_order(lvl.price, lvl.qty, lvl.level_id): placed_sells += 1 if placed_sells > 0: logger.info(f"[DEMO] Placed {placed_sells} SELL (after BUY fill) at price {current_price}") tp_triggered = demo.check_take_profit(current_price, grid_engine.take_profit_percent) if tp_triggered: last_trade = demo.trade_log[-1] if demo.trade_log else {} tg_notify( f"🎯 TAKE PROFIT!\n" f"Entry: ${last_trade.get('entry', 0):,.2f}\n" f"Exit: ${last_trade.get('exit', 0):,.2f}\n" f"PNL: {last_trade.get('pnl_usdc', 0):+.6f} USDT" ) # end if demo_mode (demo simulator branch) # Проверяем, не завершился ли фоновый kronos_advisor процесс _kronos_refresh_check() await asyncio.sleep(2) except asyncio.CancelledError: logger.info("Trading loop cancelled") break except Exception as e: logger.error(f"Trading loop error: {e}") last_error = str(e) await asyncio.sleep(2) def start_bot_async(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(trading_loop()) finally: loop.close() def _run_async(coro): """Run an async coroutine from a sync Flask handler using a fresh event loop. Each call gets its own loop so we never step on the trading loop's loop and never leak half-closed sessions. """ loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() # ─── Auth ───────────────────────────────────────────────────────────────────── # Web-auth теперь из .env (WEB_USERNAME/WEB_PASSWORD). Если дефолты из config — # на старте выдаём предупреждение, но не блокируем запуск. if WEB_USERNAME == "admin" and WEB_PASSWORD == "changeme": logger.warning( "[AUTH] WEB_USERNAME/WEB_PASSWORD в .env пустые — используются deprecation-дефолты. " "Срочно смените в .env: WEB_USERNAME=... WEB_PASSWORD=..." ) USERNAME = WEB_USERNAME PASSWORD = WEB_PASSWORD _session_cache = {} # simple server-side session: token -> True def check_auth(): auth = request.authorization if not auth: return False return auth.username == USERNAME and auth.password == PASSWORD def require_auth(f): def wrapper(*args, **kwargs): if not check_auth(): resp = jsonify({"error": "Authentication required"}) resp.headers["WWW-Authenticate"] = 'Basic realm="Grid Bot"' return resp, 401 return f(*args, **kwargs) wrapper.__name__ = f.__name__ return wrapper @app.before_request def check_all_requests(): # Allow static assets and login page if request.path in ("/", "/login", "/favicon.ico"): return None if request.path.startswith("/static/"): return None # Require auth for all API calls if request.path.startswith("/api/"): if not check_auth(): resp = jsonify({"error": "Authentication required"}) resp.headers["WWW-Authenticate"] = 'Basic realm="Grid Bot"' return resp, 401 # ─── REST API ───────────────────────────────────────────────────────────────── @app.route("/login") def login_page(): auth = request.authorization if auth and auth.username == USERNAME and auth.password == PASSWORD: return redirect("/") resp = jsonify({"error": "Authentication required"}) resp.headers["WWW-Authenticate"] = 'Basic realm="Grid Bot"' return resp, 401 @app.route("/") def index(): auth = request.authorization if not auth or auth.username != USERNAME or auth.password != PASSWORD: resp = jsonify({"error": "Authentication required"}) resp.headers["WWW-Authenticate"] = 'Basic realm="Grid Bot"' return resp, 401 return render_template("dashboard.html") @app.route("/api/status") def api_status(): global current_price, start_price, bot_running state = load_state() demo_mode = state.get("demo_mode", DEMO_MODE) if demo_mode: status = demo.get_status(current_price) if current_price else {} price_change_pct = 0.0 price_change_abs = 0.0 if start_price > 0 and current_price > 0: price_change_abs = current_price - start_price price_change_pct = (price_change_abs / start_price) * 100 return jsonify({ "mode": "demo", "symbol": SYMBOL, "current_price": current_price, "start_price": start_price, "price_source": _price_source, "price_change_pct": round(price_change_pct, 4), "price_change_abs": round(price_change_abs, 4), "bot_running": bot_running, "balance": status.get("balance", DEMO_START_BALANCE), "position": status.get("position"), "open_orders": status.get("open_orders", 0), "quote": dict(last_quote) if last_quote else {}, "total_trades": status.get("total_trades", 0), "total_pnl": status.get("total_pnl", 0), "last_trade": status.get("last_trade"), "error": last_error, "grid_stale": grid_stale, "grid_stale_drop_pct": round(grid_stale_drop_pct, 4), "grid_stale_rebuilds": grid_stale_rebuilds, "stale_threshold_pct": GRID_STALE_DROP_PERCENT, }) else: # Live mode: fetch (cached) wallet balance from Tradernet try: wallet = fetch_wallet_balance(force=False) except Exception as e: wallet = {"balances": [], "primary_currency": "USDT", "primary_balance": 0.0, "stale": True, "error": str(e), "age_seconds": 0} # Account info (getOPQ) — non-blocking, uses cache try: account = fetch_account_info(force=False) except Exception: account = {} # Quote info (lot, min_step, currency) — cached try: quote_info = fetch_quote_info(force=False) except Exception: quote_info = {} # Last quote (bid/ask, day change) try: quote = dict(last_quote) if last_quote else {} except Exception: quote = {} return jsonify({ "mode": "live", "symbol": SYMBOL, "current_price": current_price, "start_price": start_price, "price_source": _price_source, "price_change_pct": round(((current_price - start_price) / start_price * 100), 4) if start_price > 0 and current_price > 0 else 0.0, "price_change_abs": round((current_price - start_price), 4) if start_price > 0 else 0.0, "bot_running": bot_running, "error": last_error, "balance": wallet.get("primary_balance", 0.0), "currency": wallet.get("primary_currency", "USDT"), "balances": wallet.get("balances", []), "wallet_stale": wallet.get("stale", True), "wallet_error": wallet.get("error"), "wallet_age_seconds": int(wallet.get("age_seconds", 0)), "account": { "brief_nm": account.get("brief_nm"), "main_curr": account.get("main_curr"), "active": account.get("active"), "rev": account.get("rev"), }, "quote_info": { "ticker": quote_info.get("ticker", SYMBOL), "short_name": quote_info.get("short_name"), "currency": quote_info.get("currency"), "lot": quote_info.get("lot"), "min_step": quote_info.get("min_step"), "mkt_name": quote_info.get("mkt_name"), }, "quote": quote, "live_position": { "side": live_position.get("side"), "entry_price": live_position.get("entry_price"), "qty": live_position.get("qty"), "order_id": live_position.get("order_id"), "avg_price_exchange": live_position.get("avg_price_exchange"), "current_pnl_pct": round(((current_price - live_position.get("entry_price", 0)) / live_position.get("entry_price", 1) * 100), 4) if live_position and live_position.get("entry_price", 0) > 0 else 0.0, } if live_position else None, "live_pending_orders": [ {"order_id": o["order_id"], "side": o["side"], "price": o["price"], "qty": o["qty"], "level_id": o["level_id"], "status": o["status"]} for o in live_pending_orders ], "live_trade_count": len(live_trade_log), "live_total_pnl": round(sum(t["pnl_usdc"] for t in live_trade_log), 8), "live_last_trade": live_trade_log[-1] if live_trade_log else None, "grid_stale": grid_stale, "grid_stale_drop_pct": round(grid_stale_drop_pct, 4), "grid_stale_rebuilds": grid_stale_rebuilds, "stale_threshold_pct": GRID_STALE_DROP_PERCENT, }) @app.route("/api/balance") def api_balance(): state = load_state() demo_mode = state.get("demo_mode", DEMO_MODE) if demo_mode: return jsonify(demo.get_status(current_price)) # Live: ?refresh=1 forces an immediate API call (bypasses cache) force = request.args.get("refresh") == "1" try: wallet = fetch_wallet_balance(force=force) except Exception as e: wallet = {"balances": [], "primary_currency": "USDT", "primary_balance": 0.0, "stale": True, "error": str(e), "age_seconds": 0} return jsonify({ "mode": "live", "balance": wallet.get("primary_balance", 0.0), "currency": wallet.get("primary_currency", "USDT"), "balances": wallet.get("balances", []), "stale": wallet.get("stale", True), "error": wallet.get("error"), "age_seconds": int(wallet.get("age_seconds", 0)), }) @app.route("/api/grid") def api_grid(): global current_price, grid_engine if current_price <= 0: return jsonify({"error": "Price not available"}), 503 state = load_state() grid_center = current_price levels = grid_engine.get_grid_levels(grid_center) # In live mode, mark each level with whether we have a real exchange order live_orders_by_level = {} for o in live_pending_orders: if o.get("status") == "pending": live_orders_by_level[(o["side"], o["level_id"])] = o # Build set of (side, level_id) that are ACTUALLY placed on the simulator/exchange. # Demo: orders in demo.orders (not filled yet). Live: pending in live_pending_orders. placed_keys: set = set() if grid_engine and grid_engine.mode == "demo": for o in (demo.orders if demo else []): if not o.get("filled"): placed_keys.add((o.get("side"), o.get("level_id"))) else: for o in live_pending_orders: if o.get("status") == "pending": placed_keys.add((o.get("side"), o.get("level_id"))) levels_data = [] for l in levels: d = l.to_dict() match = live_orders_by_level.get((l.side, l.level_id)) if match: d["exchange_order_id"] = match["order_id"] d["exchange_status"] = match["status"] d["exchange_placed"] = True else: d["exchange_placed"] = False d["placed"] = (l.side, l.level_id) in placed_keys levels_data.append(d) return jsonify({ "center_price": grid_center, "levels": levels_data, "total_orders": len(levels), "step_percent": grid_engine.step_percent, "take_profit_percent": grid_engine.take_profit_percent, "mode": grid_engine.mode, "live_pending_count": sum(1 for o in live_pending_orders if o.get("status") == "pending"), "live_position": live_position, }) @app.route("/api/account") def api_account(): """Account info from getOPQ (brief_nm, main_curr, active, etc).""" force = request.args.get("refresh") == "1" try: info = fetch_account_info(force=force) except Exception as e: info = {"error": str(e), "stale": True, "ts": 0, "age_seconds": 0} return jsonify({ "brief_nm": info.get("brief_nm"), "main_curr": info.get("main_curr"), "active": info.get("active"), "rev": info.get("rev"), "init_margin": info.get("init_margin"), "reception": info.get("reception"), "f_kval": info.get("f_kval"), "stale": info.get("stale", True), "error": info.get("error"), "age_seconds": int(info.get("age_seconds", 0)), }) @app.route("/api/quote-info") def api_quote_info(): """Security info for SYMBOL (lot, min_step, currency, market).""" force = request.args.get("refresh") == "1" try: info = fetch_quote_info(force=force) except Exception as e: info = {"error": str(e), "stale": True, "ts": 0, "age_seconds": 0} return jsonify({ "ticker": info.get("ticker", SYMBOL), "short_name": info.get("short_name"), "currency": info.get("currency"), "lot": info.get("lot"), "min_step": info.get("min_step"), "mkt_name": info.get("mkt_name"), "mkt_tz": info.get("mkt_tz"), "stale": info.get("stale", True), "error": info.get("error"), "age_seconds": int(info.get("age_seconds", 0)), }) @app.route("/api/quote") def api_quote(): """Last full quote snapshot (ltp, bid, ask, day change, volume).""" global last_quote, current_price if not last_quote: return jsonify({ "ltp": current_price, "bap": 0, "bbp": 0, "ltt": "", "chg": 0, "chg110": 0, "vol": 0, "op": 0, "pp": 0, "stale": True, }) return jsonify({**last_quote, "stale": False}) @app.route("/api/candles") def api_candles(): """OHLCV candles for the active symbol. Query: ?tf=1|5|15|60|1440 (default 60) &refresh=1 (bypass cache) """ try: tf = int(request.args.get("tf", "60")) except ValueError: tf = 60 if tf not in (1, 5, 15, 60, 1440): tf = 60 force = request.args.get("refresh") == "1" try: data = fetch_candles(timeframe_min=tf, force=force) except Exception as e: data = {"timeframe": tf, "candles": [], "error": str(e), "stale": True, "ts": 0, "age_seconds": 0} return jsonify({ "timeframe": data.get("timeframe", tf), "candles": data.get("candles", []), "stale": data.get("stale", True), "error": data.get("error"), "age_seconds": int(data.get("age_seconds", 0)), }) @app.route("/api/orderbook") @require_auth async def api_orderbook(): global api_client if not api_client: return jsonify({"error": "API client not initialized"}), 503 try: data = await api_client.get_orderbook(SYMBOL, depth=20) # Normalize response - Tradernet returns {result: {bid: [...], ask: [...]}} result = data.get("result", data) return jsonify({ "symbol": SYMBOL, "bids": result.get("bid", result.get("bids", [])), "asks": result.get("ask", result.get("asks", [])), }) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/logs") def api_logs(): state = load_state() demo_mode = state.get("demo_mode", DEMO_MODE) if demo_mode: return jsonify({ "trades": demo.trade_log[-50:], "total_pnl": sum(t["pnl_usdc"] for t in demo.trade_log), "total_trades": len(demo.trade_log), }) # Live mode: return real trade log from live broker return jsonify({ "trades": live_trade_log[-50:], "total_pnl": round(sum(t["pnl_usdc"] for t in live_trade_log), 8), "total_trades": len(live_trade_log), "open_position": live_position, "pending_orders": [ {"order_id": o["order_id"], "side": o["side"], "price": o["price"], "qty": o["qty"], "level_id": o["level_id"], "status": o["status"]} for o in live_pending_orders ], }) # ─── Price history (for chart) ────────────────────────────────────────── RANGE_SECONDS = { "1h": 3600, "6h": 6 * 3600, "24h": 24 * 3600, "all": None, # all retained points } @app.route("/api/price-history") def api_price_history(): """Return price samples + fills + rebuild markers within the requested time range. Query: ?range=1h|6h|24h|all (default 1h) Response: { range: "1h", range_seconds: 3600, sample_interval: 30, points: [ {t, p, type, side?}, ... ] # ticks (sampled) fills: [ {t, p, side, level_id?}, ... ] # BUY/SELL fills as overlay points rebuilds: [ {t, p}, ... ] # stale-grid rebuild markers levels: [ {price, side, level_id}, ... ] # current grid levels for horizontal lines current_price: float, start_price: float } """ rng = (request.args.get("range") or "1h").lower() if rng not in RANGE_SECONDS: rng = "1h" rng_secs = RANGE_SECONDS[rng] now = time.time() cutoff = now - rng_secs if rng_secs is not None else 0.0 with price_history_lock: # Filter once: bucketed into ticks / fills / rebuilds ticks: list = [] fills: list = [] rebuilds: list = [] for pt in price_history: if pt["t"] < cutoff: continue ptype = pt.get("type", "tick") entry = {"t": pt["t"], "p": pt["p"]} if ptype == "fill": entry["side"] = pt.get("side", "BUY") fills.append(entry) elif ptype == "rebuild": rebuilds.append(entry) else: ticks.append(entry) # Down-sample ticks to keep chart responsive on long ranges MAX_TICKS = 800 if len(ticks) > MAX_TICKS: step = max(1, len(ticks) // MAX_TICKS) ticks = ticks[::step] # Current grid levels for horizontal reference lines levels_data: list = [] if grid_engine and current_price > 0: try: for lv in grid_engine.get_grid_levels(current_price): levels_data.append({ "price": lv.price, "side": lv.side, "level_id": lv.level_id, }) except Exception: pass return jsonify({ "range": rng, "range_seconds": rng_secs, "sample_interval": PRICE_SAMPLE_INTERVAL, "points": ticks, "fills": fills, "rebuilds": rebuilds, "levels": levels_data, "current_price": current_price, "start_price": start_price, "bot_running": bot_running, }) @app.route("/api/kronos") def api_kronos(): """Kronos advisor status: current advice, applied params, history. Auth: требуется (глобальный before_request для /api/*). Returns: { "enabled": bool, # KRONOS_ENABLED из .env "live_enabled": bool, # KRONOS_LIVE_ENABLED из .env "model": str, "tf_min": int, ... "current_advice": dict | None, # что лежит в kronos_advice.json "last_applied": dict | None, # что trading_loop реально применил "pause_until": float, # epoch seconds, 0 если не активна "paused_now": bool, "history": [dict, ...], # последние 20 применённых советов "advice_file": str, "advice_file_exists": bool, } """ advice_path = Path(KRONOS_ADVICE_FILE) current_advice = None if advice_path.exists(): try: current_advice = json.loads(advice_path.read_text()) except Exception: pass paused_now = bool(kronos_pause_until and time.time() < kronos_pause_until) return jsonify({ "enabled": KRONOS_ENABLED, "live_enabled": KRONOS_LIVE_ENABLED, "model": KRONOS_MODEL, "tf_min": KRONOS_TF_MIN, "lookback": KRONOS_LOOKBACK, "pred_len": KRONOS_PRED_LEN, "min_confidence": KRONOS_MIN_CONFIDENCE, "advice_file": KRONOS_ADVICE_FILE, "advice_file_exists": advice_path.exists(), "current_advice": current_advice, "last_applied": kronos_last_applied or None, "pause_until": kronos_pause_until, "paused_now": paused_now, "history": kronos_advice_history[-20:], }) # Глобальный лок на запуск refresh (защита от двойного клика) _kronos_refresh_lock = Lock() _kronos_refresh_pid: Optional[int] = None _kronos_refresh_started: float = 0.0 @app.route("/api/kronos/refresh", methods=["POST"]) def api_kronos_refresh(): """Запустить kronos_advisor_main.py и обновить kronos_advice.json. Неблокирующий: запускает subprocess в фоне, сразу возвращает ответ. Если уже идёт refresh — вернёт 409 Conflict. """ global _kronos_refresh_pid, _kronos_refresh_started with _kronos_refresh_lock: # Если уже идёт процесс, но он завис дольше 5 мин — считаем его мёртвым if _kronos_refresh_pid and _kronos_refresh_started: if time.time() - _kronos_refresh_started > 300: logger.warning("[KRONOS REFRESH] previous run > 5min, считаем мёртвым") _kronos_refresh_pid = None if _kronos_refresh_pid: return jsonify({"ok": False, "error": "уже выполняется", "pid": _kronos_refresh_pid}), 409 # Запускаем kronos_advisor_main.py в фоне venv_python = Path(__file__).parent / "kronos-venv" / "bin" / "python" if not venv_python.exists(): return jsonify({"ok": False, "error": f"venv не найден: {venv_python}"}), 500 cmd = [str(venv_python), str(Path(__file__).parent / "kronos_advisor_main.py")] # В проде — --live, в дев-окружении — по умолчанию binance if KRONOS_LIVE_ENABLED: cmd.append("--live") log_path = Path(__file__).parent / "kronos-advisor.log" log_fh = open(log_path, "a") try: proc = subprocess.Popen( cmd, stdout=log_fh, stderr=subprocess.STDOUT, cwd=str(Path(__file__).parent), start_new_session=True, ) _kronos_refresh_pid = proc.pid _kronos_refresh_started = time.time() except Exception as e: log_fh.close() return jsonify({"ok": False, "error": f"Popen failed: {e}"}), 500 return jsonify({ "ok": True, "message": f"запущен kronos advisor (pid={_kronos_refresh_pid}), подождите 5-30 сек", "pid": _kronos_refresh_pid, }) def _kronos_refresh_check(): """Фоновая проверка: завершился ли subprocess refresh-а. Вызывается в trading_loop.""" global _kronos_refresh_pid, _kronos_refresh_started if not _kronos_refresh_pid: return try: os.kill(_kronos_refresh_pid, 0) # не убиваем, просто проверяем except ProcessLookupError: # Процесс завершился logger.info(f"[KRONOS REFRESH] pid={_kronos_refresh_pid} завершился") _kronos_refresh_pid = None _kronos_refresh_started = 0.0 @app.route("/api/kronos/refresh/status") def api_kronos_refresh_status(): """Статус фонового refresh-процесса (для UI).""" global _kronos_refresh_pid, _kronos_refresh_started running = False if _kronos_refresh_pid: try: os.kill(_kronos_refresh_pid, 0) running = True except ProcessLookupError: # Процесс завершился — чистим глобал _kronos_refresh_pid = None _kronos_refresh_started = 0.0 return jsonify({ "running": running, "pid": _kronos_refresh_pid, "started_at": _kronos_refresh_started, }) # Static assets (chart.js, plugins) — served from /root/grid-bot/static/ @app.route("/static/") def static_files(filename): return send_from_directory(Path(__file__).parent / "static", filename) @app.route("/api/settings", methods=["GET"]) def api_settings_get(): return jsonify(read_current_settings()) @app.route("/api/settings", methods=["POST"]) def api_settings_set(): global grid_engine, demo, demo_orders_placed try: state = load_state() data = request.get_json() or {} if "grid_levels" in data: state["grid_levels"] = max(5, min(20, int(data["grid_levels"]))) if "step_percent" in data: val = float(data["step_percent"]) if val > 1.0: val = val / 100.0 state["step_percent"] = max(0.0001, min(0.1, val)) if "take_profit_percent" in data: val = float(data["take_profit_percent"]) if val > 1.0: val = val / 100.0 state["take_profit_percent"] = max(0.01, min(20.0, val)) if "demo_mode" in data: state["demo_mode"] = bool(data["demo_mode"]) save_state(state) grid_engine = GridEngine( levels=state["grid_levels"], step_percent=state["step_percent"], take_profit_percent=state["take_profit_percent"], symbol=SYMBOL, mode="demo" if state.get("demo_mode", DEMO_MODE) else "live", ) # Reset demo state when settings change demo.orders = [] demo.position = None demo_orders_placed = False except Exception as e: return jsonify({"error": f"Save/parse error: {e}"}), 500 return jsonify({"ok": True, "settings": read_current_settings()}) @app.route("/api/bot/start", methods=["POST"]) def api_bot_start(): global bot_running, bot_thread, api_client if bot_running: return jsonify({"ok": False, "error": "Already running"}) # После предыдущего Stop aiohttp-session привязан к закрытому loop — # пересоздаём клиента, чтобы trading_loop получил свежую session. if api_client is not None: try: # Лучше создать новый sync-loop для закрытия старой session import threading as _t def _close(): loop = asyncio.new_event_loop() try: loop.run_until_complete(api_client.close()) finally: loop.close() _t.Thread(target=_close, daemon=True).start() except Exception as e: logger.warning(f"close old api_client failed: {e}") api_client = TradernetAPI( TRADERNET_PUBLIC_KEY, TRADERNET_PRIVATE_KEY, TRADERNET_LOGIN, TRADERNET_PASSWORD, TRADERNET_BASE_URL, ) bot_running = True bot_thread = Thread(target=start_bot_async, daemon=True) bot_thread.start() return jsonify({"ok": True}) @app.route("/api/bot/stop", methods=["POST"]) def api_bot_stop(): global bot_running, bot_thread bot_running = False # Не блокируем воркер — пусть thread-daemon сам завершится на следующей итерации. # Условие while bot_running в начале trading_loop гарантирует выход через ~0.5 сек. bot_thread = None tg_notify("🛑 Бот остановлен") return jsonify({"ok": True}) @app.route("/api/reset", methods=["POST"]) def api_reset(): global demo, demo_orders_placed, start_price, current_price demo.reset() demo_orders_placed = False start_price = current_price return jsonify({"ok": True, "balance": demo.balance}) @app.route("/api/_debug/demo_orders") def _debug_demo_orders(): """TEMPORARY debug endpoint — list contents of demo.orders.""" out = [] for o in demo.orders: out.append({"side": o.get("side"), "price": o.get("price"), "qty": o.get("qty"), "level_id": o.get("level_id"), "filled": o.get("filled")}) return jsonify({ "count": len(demo.orders), "unique_level_ids": len(set(o["level_id"] for o in demo.orders if not o["filled"])), "orders": out, "position": {"side": demo.position.side, "entry": demo.position.entry_price, "qty": demo.position.qty} if demo.position else None, "demo_orders_placed_flag": demo_orders_placed, }) # ─── Main ───────────────────────────────────────────────────────────────────── def main(): global api_client parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true") parser.add_argument("--once", action="store_true") args = parser.parse_args() api_client = TradernetAPI( TRADERNET_PUBLIC_KEY, TRADERNET_PRIVATE_KEY, TRADERNET_LOGIN, TRADERNET_PASSWORD, TRADERNET_BASE_URL, ) logger.info(f"Grid Bot starting — mode={'DEMO' if DEMO_MODE else 'LIVE'}") logger.info(f"Tradernet API: {TRADERNET_BASE_URL}") logger.info(f"Trading pair: {SYMBOL}") if args.once: logger.info("Running once (--once mode)") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(trading_loop()) loop.run_until_complete(api_client.close()) return logger.info(f"Starting web dashboard on {HOST}:{PORT}") # Автостарт trading_loop, если это не LIVE-прод-режим # (в LIVE оператор запускает через /api/bot/start вручную после проверки настроек) if DEMO_MODE: logger.info("DEMO mode → auto-starting trading loop") global bot_running bot_running = True from threading import Thread def _safe_start(): try: logger.info("[bot-thread] starting trading_loop...") start_bot_async() except Exception as e: logger.error(f"[bot-thread] crashed: {e}", exc_info=True) _t = Thread(target=_safe_start, daemon=True, name="trading-loop") _t.start() logger.info(f"[main] trading_loop thread started: {_t.ident}, alive={_t.is_alive()}") app.run(host=HOST, port=PORT, debug=DEBUG, threaded=True) if __name__ == "__main__": main()