2464 lines
104 KiB
Plaintext
2464 lines
104 KiB
Plaintext
"""
|
||
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
|
||
|
||
# Если Kronos снял паузу (pause_grid=false или conf<0.6) —
|
||
# сбрасываем залипший kronos_pause_until, иначе бот сидит «на паузе»
|
||
# ещё до 15 мин с момента последней паузы (см. issue 2026-06-07).
|
||
global kronos_pause_until
|
||
if not advice.get("pause_grid") or advice.get("confidence", 0) < 0.9:
|
||
kronos_pause_until = 0.0
|
||
|
||
# pause (capped at 15 minutes by user request 2026-06-06)
|
||
if advice.get("pause_grid") and advice.get("confidence", 0) >= 0.9:
|
||
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"Buy-anchor: ${prev.get('eff_buy_center', base_center):,.0f} → ${eff_buy_center:,.0f}\n"
|
||
f"Sell-anchor: ${prev.get('eff_sell_center', base_center):,.0f} → ${eff_sell_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=2) as r:
|
||
if r.status == 200:
|
||
return
|
||
except Exception as e:
|
||
last_err = e
|
||
if attempt < 2:
|
||
# backoff: 1s, 3s
|
||
time.sleep(0.1 + 0.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)
|
||
|
||
|
||
def refresh_price() -> float:
|
||
"""Fetch latest price for SYMBOL synchronously.
|
||
|
||
Uses urllib.request with a fresh HMAC signature per call (no aiohttp
|
||
session reuse) to avoid aiohttp session hangs in the trading loop's
|
||
event loop. The hot path adds ~1 RTT (3-5ms in same DC) per call,
|
||
so the 2-second loop cadence is preserved.
|
||
|
||
Returns the last seen price (current_price) on any failure — never
|
||
raises. The caller treats a stale-but-nonzero price as a valid tick.
|
||
"""
|
||
global current_price, last_quote, _price_source
|
||
# 1) Tradernet — основной источник (sync HMAC POST)
|
||
try:
|
||
import hmac as _hmac, hashlib as _hashlib
|
||
ts = str(int(time.time()))
|
||
params = {"tickers": SYMBOL}
|
||
payload = json.dumps(params, separators=(",", ":"))
|
||
msg = (payload + ts).encode()
|
||
sig = _hmac.new(
|
||
TRADERNET_PRIVATE_KEY.encode(), msg, _hashlib.sha256
|
||
).hexdigest()
|
||
url = f"{TRADERNET_BASE_URL}/api/getStockQuotesJson"
|
||
req = urllib.request.Request(url, data=payload.encode(), method="POST")
|
||
req.add_header("Content-Type", "application/json")
|
||
req.add_header("X-NtApi-PublicKey", TRADERNET_PUBLIC_KEY)
|
||
req.add_header("X-NtApi-Timestamp", ts)
|
||
req.add_header("X-NtApi-Sig", sig)
|
||
with urllib.request.urlopen(req, timeout=2) as r:
|
||
data = json.loads(r.read().decode())
|
||
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 = 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=2) 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=2) 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✅ РЕАЛЬНАЯ ТОРГОВЛЯ"))
|
||
|
||
import socket
|
||
socket.setdefaulttimeout(2) # global socket timeout — prevent hangs in sync urllib calls
|
||
while bot_running:
|
||
try:
|
||
import os as _os_b
|
||
with open('/tmp/loop_beat', 'a') as _f:
|
||
_f.write(f"{datetime.now().isoformat()} iter\n")
|
||
# 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 asyncio.to_thread(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_buy_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
|
||
# [PLACE-DBG] removed 2026-06-08 12:49 GMT+7 (debug spam)
|
||
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():
|
||
"""Запуск trading_loop в отдельном event loop. Ловим все исключения (включая BaseException)
|
||
и пишем полный traceback в /tmp/bot_crash.log, чтобы не терять информацию о падениях.
|
||
Без этой обёртки исключение из coroutine проглатывалось и thread умирал молча.
|
||
"""
|
||
import traceback as _tb_b
|
||
loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(loop)
|
||
try:
|
||
try:
|
||
loop.run_until_complete(trading_loop())
|
||
except BaseException as _bexc:
|
||
try:
|
||
with open('/tmp/bot_crash.log', 'a', encoding='utf-8') as _cf:
|
||
_cf.write(f"\\n=== {time.strftime('%Y-%m-%d %H:%M:%S')} ===\\n")
|
||
_cf.write(''.join(_tb_b.format_exception(type(_bexc), _bexc, _bexc.__traceback__)))
|
||
except Exception as _werr:
|
||
pass
|
||
logger.error(f"[bot-thread] crashed: {_bexc!r}")
|
||
finally:
|
||
try:
|
||
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
||
for t in pending:
|
||
t.cancel()
|
||
if pending:
|
||
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
||
loop.run_until_complete(loop.shutdown_asyncgens())
|
||
except Exception:
|
||
pass
|
||
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/<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() |