237 lines
14 KiB
Python
237 lines
14 KiB
Python
"""
|
||
Grid Trading Bot — config.
|
||
All secrets MUST come from environment (.env), never from defaults in code.
|
||
"""
|
||
|
||
import os
|
||
from pathlib import Path
|
||
|
||
from dotenv import load_dotenv
|
||
|
||
# Load .env from project root (cwd or script dir) so all os.getenv() below
|
||
# see real values, not code defaults. Safe to call multiple times.
|
||
_ENV_PATH = Path(__file__).resolve().parent / ".env"
|
||
if _ENV_PATH.exists():
|
||
load_dotenv(_ENV_PATH, override=False)
|
||
else:
|
||
# fallback: cwd/.env (for 'python main.py' from project dir)
|
||
load_dotenv(override=False)
|
||
|
||
|
||
def _require(name: str) -> str:
|
||
"""Read required env var. Empty/None → raise (only when LIVE mode is on)."""
|
||
v = os.getenv(name, "").strip()
|
||
if not v:
|
||
raise RuntimeError(
|
||
f"Environment variable {name!r} is empty. "
|
||
f"Set it in .env (see .env.example). Aborting."
|
||
)
|
||
return v
|
||
|
||
|
||
def _opt(name: str, default: str = "") -> str:
|
||
return os.getenv(name, default).strip()
|
||
|
||
|
||
# === Tradernet API credentials ===
|
||
# LIVE: ключи обязательны → main.py вызовет _require().
|
||
# DEMO: ключи могут быть пустыми (например, dry-run без сети).
|
||
TRADERNET_PUBLIC_KEY = os.getenv("TRADERNET_PUBLIC_KEY", "")
|
||
TRADERNET_PRIVATE_KEY = os.getenv("TRADERNET_PRIVATE_KEY", "")
|
||
TRADERNET_LOGIN = os.getenv("TRADERNET_LOGIN", "")
|
||
TRADERNET_PASSWORD = os.getenv("TRADERNET_PASSWORD", "")
|
||
TRADERNET_BASE_URL = os.getenv("TRADERNET_BASE_URL", "https://tradernet.by")
|
||
|
||
|
||
def require_tradernet_keys() -> None:
|
||
"""Вызывайте из main.py в начале LIVE-режима.
|
||
Падает, если хотя бы один ключ пустой."""
|
||
_require("TRADERNET_PUBLIC_KEY")
|
||
_require("TRADERNET_PRIVATE_KEY")
|
||
|
||
|
||
# === Trading settings ===
|
||
SYMBOL = os.getenv("SYMBOL", "BTC-USDT.IMEX")
|
||
QUOTE = os.getenv("QUOTE", "USDT")
|
||
BASE = os.getenv("BASE", "BTC")
|
||
|
||
# === Grid settings (default 10 levels = 20 orders total) ===
|
||
GRID_LEVELS = int(os.getenv("GRID_LEVELS", "10"))
|
||
GRID_STEP_PERCENT = float(os.getenv("GRID_STEP_PERCENT", "0.005"))
|
||
GRID_TAKE_PROFIT_PERCENT = float(os.getenv("GRID_TAKE_PROFIT_PERCENT", "0.5"))
|
||
|
||
# === Fee-coverage validator (QuantDinger validator pattern) ===
|
||
# Оценочная комиссия биржи Cifra Markets за сделку. Не подтверждена
|
||
# официально (см. MEMORY.md). 0.2% — безопасная оценка для BTC.
|
||
FEE_RATE_PERCENT = float(os.getenv("FEE_RATE_PERCENT", "0.2")) # 0.2% = 0.002
|
||
# Множитель запаса: round-trip fee * safety = минимально допустимый
|
||
# шаг сетки. QuantDinger default = 1.25.
|
||
FEE_SAFETY_MULTIPLIER = float(os.getenv("FEE_SAFETY_MULTIPLIER", "1.25"))
|
||
GRID_RENEW_THRESHOLD = int(os.getenv("GRID_RENEW_THRESHOLD", "3"))
|
||
GRID_PRICE_OFFSET = float(os.getenv("GRID_PRICE_OFFSET", "0"))
|
||
# Режим построения сетки: 'arithmetic' (равные $шаги, по умолчанию)
|
||
# или 'geometric' (равные %шаги, не зависит от цены — точнее для
|
||
# широких диапазонов BTC).
|
||
GRID_MODE = os.getenv("GRID_MODE", "arithmetic").lower().strip()
|
||
|
||
# === State.json versioning (2026-06-30) ===
|
||
# При изменении grid-параметров в .env (step/tp/levels) - bump на новую
|
||
# версию. load_state() в main.py делает auto-merge при несовпадении версии:
|
||
# подтягивает свежие grid-параметры из .env, сохраняет runtime (position,
|
||
# balance, trade_log, demo_mode, symbol).
|
||
# История версий:
|
||
# "1.0" - оригинал, без version (читается как "0.0")
|
||
# "1.1" - fee-aware step: GRID_STEP_PERCENT по умолчанию 0.020 (2.0%)
|
||
GRID_STATE_VERSION = os.getenv("GRID_STATE_VERSION", "1.1")
|
||
# Какие ключи подтягиваются из .env при merge (runtime-state не трогаем)
|
||
GRID_CONFIG_KEYS = ("step_percent", "take_profit_percent", "grid_levels")
|
||
|
||
# === Stale grid recovery ===
|
||
GRID_STALE_DROP_PERCENT = float(os.getenv("GRID_STALE_DROP_PERCENT", "5.0"))
|
||
|
||
# === Inactivity-driven soft rebuild ===
|
||
# Если за INACTIVITY_RESTART_HOURS часов не было ни одной сделки (BUY/SELL fill)
|
||
# И BTC отошёл от центра сетки (start_price) более чем на
|
||
# INACTIVITY_PRICE_DRIFT_PCT процентов — отменяем висящие BUY-ордера и
|
||
# пересобираем сетку вокруг текущей цены (мягкий рестарт: процесс не
|
||
# перезапускается). Применяется ТОЛЬКО в DEMO-режиме.
|
||
INACTIVITY_RESTART_HOURS = float(os.getenv("INACTIVITY_RESTART_HOURS", "1"))
|
||
INACTIVITY_PRICE_DRIFT_PCT = float(os.getenv("INACTIVITY_PRICE_DRIFT_PCT", "0.3"))
|
||
|
||
# === SMA-ATR strategy (2026-06-21) ===
|
||
# Replaces "grid_center = current_price" with SMA-anchored grid + lock FSM.
|
||
# Lock triggers when |price - sma| > LOCK_PCT%, unlock when < UNLOCK_PCT%.
|
||
# Bias source for asymmetry (3:2 split): Kronos primary, TREND fallback.
|
||
STRATEGY_CENTER_MODE = os.getenv("STRATEGY_CENTER_MODE", "current_price") # "sma" | "current_price"
|
||
STRATEGY_SMA_TF_MIN = int(os.getenv("STRATEGY_SMA_TF_MIN", "60")) # 60 = 1h
|
||
STRATEGY_SMA_PERIOD = int(os.getenv("STRATEGY_SMA_PERIOD", "24"))
|
||
STRATEGY_LOCK_PCT = float(os.getenv("STRATEGY_LOCK_PCT", "2.0")) # |dev| > 2% → lock
|
||
STRATEGY_UNLOCK_PCT = float(os.getenv("STRATEGY_UNLOCK_PCT", "1.5")) # |dev| < 1.5% → unlock
|
||
STRATEGY_BIAS_SOURCE = os.getenv("STRATEGY_BIAS_SOURCE", "kronos") # "kronos" | "trend" | "none"
|
||
STRATEGY_BIAS_FALLBACK = os.getenv("STRATEGY_BIAS_FALLBACK", "trend") # used when primary is unavailable
|
||
STRATEGY_SMA_CACHE_SEC = int(os.getenv("STRATEGY_SMA_CACHE_SEC", "300")) # recompute SMA every 5 min
|
||
STRATEGY_LOCK_COOLDOWN_SEC = int(os.getenv("STRATEGY_LOCK_COOLDOWN_SEC", "900")) # 15 min between lock state changes
|
||
STRATEGY_ASYMMETRY_UP = os.getenv("STRATEGY_ASYMMETRY_UP", "3:2") # bias=up → 3 BUY / 2 SELL
|
||
STRATEGY_ASYMMETRY_DOWN = os.getenv("STRATEGY_ASYMMETRY_DOWN", "2:3") # bias=down → 2 BUY / 3 SELL
|
||
STRATEGY_ASYMMETRY_RANGE = os.getenv("STRATEGY_ASYMMETRY_RANGE", "3:2") # bias=range → 3 BUY / 2 SELL
|
||
|
||
# === State ===
|
||
STATE_DIR = Path(os.getenv("STATE_DIR", str(Path.home() / ".openclaw")))
|
||
STATE_FILE = str(STATE_DIR / "grid-state.json")
|
||
|
||
# === Kill switch (2026-06-22) ===
|
||
# Защита для live-режима: автоматически останавливает бота при:
|
||
# 1. Drawdown > KILL_DD_PCT% от пикового баланса (balance + unrealized position)
|
||
# 2. > KILL_API_ERRORS ошибок API за KILL_API_WINDOW_SEC секунд
|
||
# 3. Telegram /stop команда от TELEGRAM_CHAT_ID (long-poll каждые KILL_TG_POLL_SEC)
|
||
# При срабатывании: cancel all open orders (для live - через _live_cancel_all_pending),
|
||
# bot_running=False, требует ручного /api/bot/start для resume.
|
||
# KILL_SWITCH_ENABLED=false - весь модуль no-op (для отладки).
|
||
KILL_SWITCH_ENABLED = os.getenv("KILL_SWITCH_ENABLED", "true").lower() in ("true", "1", "yes")
|
||
KILL_DD_PCT = float(os.getenv("KILL_DD_PCT", "10.0"))
|
||
KILL_API_ERRORS = int(os.getenv("KILL_API_ERRORS", "5"))
|
||
KILL_API_WINDOW_SEC = int(os.getenv("KILL_API_WINDOW_SEC", "600"))
|
||
KILL_TG_POLL_SEC = int(os.getenv("KILL_TG_POLL_SEC", "30"))
|
||
KILL_TG_LONG_POLL = int(os.getenv("KILL_TG_LONG_POLL", "25"))
|
||
|
||
# === Live-mode kill switch (2026-06-22) ===
|
||
# В live режиме kill switch дополнительно может:
|
||
# - LIVE_KILL_CLOSE_POSITION=true: закрыть открытую позицию market-sell при trigger
|
||
# (опасно в falling knife - оставь false для консервативного старта)
|
||
# - LIVE_KILL_DD_PCT: если задан (>0), переопределяет KILL_DD_PCT для live
|
||
# (например, можно осторожнее: 7% для live вместо 10% для demo)
|
||
LIVE_KILL_CLOSE_POSITION = os.getenv("LIVE_KILL_CLOSE_POSITION", "false").lower() in ("true", "1", "yes")
|
||
_live_dd_env = os.getenv("LIVE_KILL_DD_PCT", "")
|
||
LIVE_KILL_DD_PCT = float(_live_dd_env) if _live_dd_env else 0.0 # 0 = use KILL_DD_PCT
|
||
|
||
# === Reconcile (QuantDinger ledger_reconcile pattern) ===
|
||
# Периодическая сверка demo.position с trade_log: ловит
|
||
# "зависшие" позиции, открытые ДО последнего TP/SELL-fill.
|
||
# 0 = выключено. 300 = каждые 5 мин (рекомендация QuantDinger).
|
||
RECONCILE_INTERVAL_SEC = int(os.getenv("RECONCILE_INTERVAL_SEC", "300"))
|
||
# Если найдена shadow-позиция: 'log' (только предупреждение, безопасно)
|
||
# или 'clear' (auto-clear). По умолчанию log.
|
||
RECONCILE_AUTO_CLEAR = os.getenv("RECONCILE_AUTO_CLEAR", "log").lower() in ("true", "1", "yes")
|
||
|
||
# === Public server URL ===
|
||
SERVER_URL = os.getenv("SERVER_URL", "https://gridbot.kolp.pro")
|
||
|
||
# === Demo / Live ===
|
||
DEMO_MODE = os.getenv("DEMO_MODE", "true").lower() in ("true", "1", "yes")
|
||
DEMO_START_BALANCE = float(os.getenv("DEMO_START_BALANCE", "100"))
|
||
LIVE_TRADING_DRY_RUN = os.getenv("LIVE_TRADING_DRY_RUN", "true").lower() in ("true", "1", "yes")
|
||
|
||
# === Telegram ===
|
||
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||
TELEGRAM_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||
|
||
# === Flask ===
|
||
HOST = os.getenv("HOST", "0.0.0.0")
|
||
PORT = int(os.getenv("PORT", "5555"))
|
||
DEBUG = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes")
|
||
|
||
# === Web auth (Basic Auth для дашборда /api/*) ===
|
||
# ВАЖНО: обязательно сменить дефолты в .env. Сейчас — deprecation-дефолты,
|
||
# которые выдаются на 1 запуск, если .env не заполнен.
|
||
WEB_USERNAME = os.getenv("WEB_USERNAME", "admin")
|
||
WEB_PASSWORD = os.getenv("WEB_PASSWORD", "changeme")
|
||
|
||
# === Kronos advisor ===
|
||
# KRONOS_ENABLED: подмешивать kronos_advice.json в параметры сетки
|
||
# KRONOS_LIVE_ENABLED: только в LIVE — реально применять советы к ордерам
|
||
# KRONOS_MODEL: "kronos-mini" | "kronos-small" (mini быстрее, small точнее)
|
||
# KRONOS_TF_MIN: таймфрейм свечей (1, 5, 15, 60, 1440)
|
||
# KRONOS_LOOKBACK: сколько свечей подавать в модель (≤ 512 для small/mini)
|
||
# KRONOS_PRED_LEN: сколько свечей прогнозировать
|
||
# KRONOS_MIN_CONFIDENCE: ниже этого — fallback на константы из .env
|
||
KRONOS_ENABLED = os.getenv("KRONOS_ENABLED", "false").lower() in ("true", "1", "yes")
|
||
KRONOS_LIVE_ENABLED = os.getenv("KRONOS_LIVE_ENABLED", "false").lower() in ("true", "1", "yes")
|
||
KRONOS_MODEL = os.getenv("KRONOS_MODEL", "kronos-mini")
|
||
KRONOS_TF_MIN = int(os.getenv("KRONOS_TF_MIN", "60"))
|
||
KRONOS_LOOKBACK = int(os.getenv("KRONOS_LOOKBACK", "400"))
|
||
KRONOS_PRED_LEN = int(os.getenv("KRONOS_PRED_LEN", "24"))
|
||
KRONOS_MIN_CONFIDENCE = float(os.getenv("KRONOS_MIN_CONFIDENCE", "0.4"))
|
||
KRONOS_ADVICE_FILE = os.getenv("KRONOS_ADVICE_FILE", str(STATE_DIR / "kronos_advice.json"))
|
||
|
||
# === Local trend detector (slope-based, on price_history) ===
|
||
# Independent of Kronos. Linear-regression slope over the last TREND_LOOKBACK
|
||
# points in price_history. When slope > TREND_THRESHOLD_PCT% AND R² >
|
||
# TREND_MIN_CONFIDENCE — bias="up" (or "down" for negative slope).
|
||
# On uptrend: shift buy_anchor up by TREND_UP_ANCHOR_SHIFT to make BUY
|
||
# levels sit closer to current price (catch pullbacks in rising trends).
|
||
# On downtrend: shift buy_anchor down (skip falling knives).
|
||
TREND_ENABLED = os.getenv("TREND_ENABLED", "false").lower() in ("true", "1", "yes")
|
||
TREND_LOOKBACK = int(os.getenv("TREND_LOOKBACK", "60"))
|
||
TREND_THRESHOLD_PCT = float(os.getenv("TREND_THRESHOLD_PCT", "0.3"))
|
||
TREND_MIN_CONFIDENCE = float(os.getenv("TREND_MIN_CONFIDENCE", "0.5"))
|
||
TREND_UP_ANCHOR_SHIFT = float(os.getenv("TREND_UP_ANCHOR_SHIFT", "0.0015"))
|
||
TREND_DOWN_ANCHOR_SHIFT = float(os.getenv("TREND_DOWN_ANCHOR_SHIFT", "0.0015"))
|
||
|
||
# === Local trend detector (slope-based, on price_history) ===
|
||
# Independent of Kronos. Linear-regression slope over the last TREND_LOOKBACK
|
||
# points in price_history. When slope > TREND_THRESHOLD_PCT% AND R² >
|
||
# TREND_MIN_CONFIDENCE — bias="up" (or "down" for negative slope).
|
||
# On uptrend: shift buy_anchor up by TREND_UP_ANCHOR_SHIFT to make BUY
|
||
# levels sit closer to current price (catch pullbacks in rising trends).
|
||
# On downtrend: shift buy_anchor down (skip falling knives).
|
||
TREND_ENABLED = os.getenv("TREND_ENABLED", "false").lower() in ("true", "1", "yes")
|
||
TREND_LOOKBACK = int(os.getenv("TREND_LOOKBACK", "60"))
|
||
TREND_THRESHOLD_PCT = float(os.getenv("TREND_THRESHOLD_PCT", "0.3"))
|
||
TREND_MIN_CONFIDENCE = float(os.getenv("TREND_MIN_CONFIDENCE", "0.5"))
|
||
TREND_UP_ANCHOR_SHIFT = float(os.getenv("TREND_UP_ANCHOR_SHIFT", "0.0015"))
|
||
TREND_DOWN_ANCHOR_SHIFT = float(os.getenv("TREND_DOWN_ANCHOR_SHIFT", "0.0015"))
|
||
|
||
# === Local trend detector (slope-based, on price_history) ===
|
||
# Independent of Kronos. Linear-regression slope over the last TREND_LOOKBACK
|
||
# points in price_history. When slope > TREND_THRESHOLD_PCT% AND R² >
|
||
# TREND_MIN_CONFIDENCE — bias="up" (or "down" for negative slope).
|
||
# On uptrend: shift buy_anchor up by TREND_UP_ANCHOR_SHIFT to make BUY
|
||
# levels sit closer to current price (catch pullbacks in rising trends).
|
||
# On downtrend: shift buy_anchor down (skip falling knives).
|
||
TREND_ENABLED = os.getenv("TREND_ENABLED", "false").lower() in ("true", "1", "yes")
|
||
TREND_LOOKBACK = int(os.getenv("TREND_LOOKBACK", "60"))
|
||
TREND_THRESHOLD_PCT = float(os.getenv("TREND_THRESHOLD_PCT", "0.3"))
|
||
TREND_MIN_CONFIDENCE = float(os.getenv("TREND_MIN_CONFIDENCE", "0.5"))
|
||
TREND_UP_ANCHOR_SHIFT = float(os.getenv("TREND_UP_ANCHOR_SHIFT", "0.0015"))
|
||
TREND_DOWN_ANCHOR_SHIFT = float(os.getenv("TREND_DOWN_ANCHOR_SHIFT", "0.0015"))
|