Files
gridbot/main.py.bak.grid_placed.20260607_034603

2365 lines
99 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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_step, True
eff_center, eff_step, pause_now = apply_kronos_advice(base_center, base_step, live)
if pause_now:
return 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"center ${base_center:,.2f}→${eff_center:,.2f} "
f"(conf={adv.get('confidence', 0):.2f}, source={adv.get('source', 'n/a')})"
)
kronos_last_log_ts = now
return eff_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_step, False
# В LIVE без явного флага — только логируем, не применяем
if live and not KRONOS_LIVE_ENABLED:
return 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_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
center_offset_pct = float(advice.get("center_offset_pct", 0.0))
eff_offset = max(-0.0015, min(0.0015, center_offset_pct))
eff_center = base_center * (1.0 + eff_offset)
prev = kronos_last_applied
kronos_last_applied = {
"ts": time.time(),
"advice": advice,
"base_center": base_center,
"eff_center": eff_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_center": eff_center,
"bias": advice.get("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_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_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_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_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)
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):
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
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
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/<path:filename>")
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()