Files
workspace/projects/crypto-signals/scripts/coinex_live_trader.py

1123 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
CoinEx Live Futures Trader - ENHANCED SAFETY VERSION
Real money trading based on the paper trading strategy.
Extremely careful implementation with kill switches and safety measures.
FUTURES ONLY — NEVER TOUCH SPOT
"""
import json
import os
import sys
import time
import hashlib
import hmac
import requests
import argparse
from datetime import datetime, timezone
from pathlib import Path
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.short_scanner import scan_coin, COINS as SHORT_COINS
# Load CoinEx credentials
CREDENTIALS_FILE = Path.home() / ".openclaw/workspace/.credentials/coinex.env"
def load_coinex_credentials():
"""Load CoinEx API credentials from environment file."""
if not CREDENTIALS_FILE.exists():
raise RuntimeError(f"Credentials file not found: {CREDENTIALS_FILE}")
credentials = {}
with open(CREDENTIALS_FILE) as f:
for line in f:
line = line.strip()
if line and '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
credentials[key] = value
if 'COINEX_ACCESS_ID' not in credentials or 'COINEX_SECRET_KEY' not in credentials:
raise RuntimeError("Missing COINEX_ACCESS_ID or COINEX_SECRET_KEY in credentials file")
return credentials['COINEX_ACCESS_ID'], credentials['COINEX_SECRET_KEY']
# Configuration
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", os.environ.get("BOT_TOKEN", ""))
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", os.environ.get("CHAT_ID", "6443752046"))
# CoinEx API settings
COINEX_BASE_URL = "https://api.coinex.com"
# Data directory
DATA_DIR = Path(__file__).parent.parent / "data" / "coinex-live"
STATE_FILE = DATA_DIR / "trader_state.json"
TRADES_LOG = DATA_DIR / "trades.log"
ERROR_LOG = DATA_DIR / "errors.log"
LOCKFILE = DATA_DIR / "live-trader-lock.json"
CONFIG_FILE = DATA_DIR / "trader_config.json"
# KILL SWITCH SETTINGS
STARTING_BALANCE_KEY = "starting_balance"
RETRY_ATTEMPTS = 3
RETRY_DELAY = 2.0
# Global state for circuit breaker
consecutive_failures = 0
def setup_logging():
"""Ensure log files exist and directories are created."""
DATA_DIR.mkdir(parents=True, exist_ok=True)
for log_file in [TRADES_LOG, ERROR_LOG]:
if not log_file.exists():
log_file.write_text("")
def log_trade(message, is_dry_run=False):
"""Log trade information to trades.log with timestamp."""
prefix = "[DRY-RUN]" if is_dry_run else "[LIVE]"
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
log_entry = f"[{timestamp}] {prefix} {message}\n"
print(f"[TRADE] {prefix} {message}")
with open(TRADES_LOG, 'a') as f:
f.write(log_entry)
def log_error(message, is_dry_run=False):
"""Log error information to errors.log with timestamp."""
prefix = "[DRY-RUN]" if is_dry_run else "[LIVE]"
timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
log_entry = f"[{timestamp}] {prefix} ERROR: {message}\n"
print(f"[ERROR] {prefix} {message}")
with open(ERROR_LOG, 'a') as f:
f.write(log_entry)
def load_config():
"""Load trader configuration from file with fallback to defaults."""
default_config = {
"mode": "paused",
"position_size_pct": 5.0,
"max_positions": 3,
"max_leverage": 10,
"kill_switch_drawdown_pct": 50.0,
"tp_pct": 5.0,
"sl_pct": -3.0,
"trailing_stop_pct": 2.0,
"circuit_breaker_threshold": 3,
"scan_interval_minutes": 5,
"long_threshold": 45,
"short_threshold": 50,
"coin_blacklist": [],
"coin_whitelist": []
}
if CONFIG_FILE.exists():
try:
config = json.loads(CONFIG_FILE.read_text())
# Merge with defaults to handle missing keys
for key, default_value in default_config.items():
if key not in config:
config[key] = default_value
return config
except Exception as e:
log_error(f"Failed to load config, using defaults: {e}")
return default_config
else:
# Create default config file
try:
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONFIG_FILE.write_text(json.dumps(default_config, indent=2))
log_trade("Created default trader_config.json")
except Exception as e:
log_error(f"Failed to create default config: {e}")
return default_config
def load_state():
"""Load trader state from file."""
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except Exception as e:
log_error(f"Failed to load state: {e}")
return {"peak_pnl": {}, "last_alert": None}
return {"peak_pnl": {}, "last_alert": None}
def save_state(state):
"""Save trader state to file."""
try:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2))
except Exception as e:
log_error(f"Failed to save state: {e}")
def create_lockfile(reason, last_equity=None, positions_at_halt=None):
"""Create lockfile to prevent restart after kill switch or circuit breaker."""
lockfile_data = {
"reason": reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
"last_equity": last_equity,
"positions_at_halt": positions_at_halt or []
}
try:
LOCKFILE.parent.mkdir(parents=True, exist_ok=True)
LOCKFILE.write_text(json.dumps(lockfile_data, indent=2))
log_error(f"LOCKFILE CREATED: {reason}")
except Exception as e:
log_error(f"Failed to create lockfile: {e}")
def check_lockfile():
"""Check if lockfile exists and refuse to run if it does."""
if LOCKFILE.exists():
try:
lockfile_data = json.loads(LOCKFILE.read_text())
reason = lockfile_data.get("reason", "Unknown")
timestamp = lockfile_data.get("timestamp", "Unknown")
print(f"🚨 LOCKFILE EXISTS - Bot was halted due to: {reason}")
print(f"Halt time: {timestamp}")
print(f"To restart, manually delete: {LOCKFILE}")
print("⚠️ Only do this if you've verified the issue is resolved!")
return True
except Exception as e:
print(f"Error reading lockfile: {e}")
return True
return False
def send_telegram(message):
"""Send Telegram alert."""
if not TELEGRAM_BOT_TOKEN:
print(f"[TG] {message}")
return
try:
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
response = requests.post(url, json={
"chat_id": TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "HTML"
}, timeout=10)
response.raise_for_status()
except Exception as e:
log_error(f"Telegram failed: {e}")
def increment_failure_count(max_failures=3):
"""Increment consecutive API failure count and check circuit breaker."""
global consecutive_failures
consecutive_failures += 1
if consecutive_failures >= max_failures:
reason = f"Circuit breaker triggered - {consecutive_failures} consecutive API failures"
create_lockfile(reason)
send_telegram(f"🚨 <b>CIRCUIT BREAKER TRIGGERED</b>\n{reason}\nBot halted - manual intervention required")
log_error(reason)
sys.exit(1)
def reset_failure_count():
"""Reset consecutive failure count after successful API call."""
global consecutive_failures
consecutive_failures = 0
class CoinExAPI:
"""CoinEx API client with authentication and safety measures."""
def __init__(self, access_id, secret_key):
self.access_id = access_id
self.secret_key = secret_key
self.base_url = COINEX_BASE_URL
def _sign_request(self, method, path, params=None, json_data=None):
"""Generate CoinEx API signature."""
timestamp = str(int(time.time() * 1000))
if method == "GET" and params:
query_string = "&".join([f"{k}={v}" for k, v in sorted(params.items())])
prepared = f"{method}{path}?{query_string}{timestamp}"
elif method == "POST" and json_data:
import json as json_mod
body = json_mod.dumps(json_data, separators=(',', ':'))
prepared = f"{method}{path}{body}{timestamp}"
else:
prepared = f"{method}{path}{timestamp}"
sign = hmac.new(
self.secret_key.encode('latin-1'),
prepared.encode('latin-1'),
hashlib.sha256
).hexdigest().lower()
return {
"X-COINEX-KEY": self.access_id,
"X-COINEX-SIGN": sign,
"X-COINEX-TIMESTAMP": timestamp,
"Content-Type": "application/json"
}
def _request(self, method, path, params=None, json_data=None):
"""Make authenticated request to CoinEx API."""
try:
headers = self._sign_request(method, path, params, json_data)
url = f"{self.base_url}{path}"
if method == "GET":
response = requests.get(url, headers=headers, params=params, timeout=30)
elif method == "POST":
import json as json_mod
response = requests.post(url, headers=headers, data=json_mod.dumps(json_data, separators=(',', ':')), timeout=30)
else:
raise ValueError(f"Unsupported method: {method}")
response.raise_for_status()
result = response.json()
if result.get("code") != 0:
raise RuntimeError(f"CoinEx API error: {result.get('message', 'Unknown error')}")
# Success - reset failure count
reset_failure_count()
return result.get("data", {})
except Exception as e:
log_error(f"CoinEx API request failed: {method} {path} - {e}")
increment_failure_count()
raise
def get_futures_balance(self):
"""Get futures account balance."""
return self._request("GET", "/v2/assets/futures/balance")
def get_market_price(self, market):
"""Get current market price for a symbol."""
return self._request("GET", "/v2/futures/market", {"market": market})
def get_positions(self):
"""Get all open futures positions. Returns None on failure (never empty list)."""
try:
result = self._request("GET", "/v2/futures/pending-position", params={"market_type": "futures"})
return result if result is not None else []
except Exception as e:
log_error(f"CRITICAL: Positions endpoint failed: {e}")
# SAFETY FIX #2: Return None on failure, never assume empty positions
return None
def place_order(self, market, side, type_order, amount, price=None, leverage=None):
"""Place a futures order with verification."""
data = {
"market": market,
"market_type": "FUTURES",
"side": side, # "buy" or "sell"
"type": type_order, # "market" or "limit"
"amount": str(amount)
}
if price:
data["price"] = str(price)
result = self._request("POST", "/v2/futures/order", json_data=data)
# SAFETY FIX #6: Verify order was accepted
if not result or "order_id" not in result:
raise RuntimeError(f"Order placement failed - no order ID returned: {result}")
order_id = result["order_id"]
log_trade(f"Order placed successfully - ID: {order_id}")
return result
def set_leverage(self, market, leverage):
"""Set leverage for a market and verify it was set correctly."""
data = {
"market": market,
"market_type": "FUTURES",
"margin_mode": "cross",
"leverage": leverage
}
result = self._request("POST", "/v2/futures/adjust-position-leverage", json_data=data)
# SAFETY FIX #7: Verify leverage was set correctly
if result:
actual_leverage = result.get("leverage", 0)
if actual_leverage and abs(int(actual_leverage) - leverage) > 0:
raise RuntimeError(f"Leverage verification failed - requested {leverage}x, got {actual_leverage}x")
log_trade(f"Leverage verified: {market} set to {leverage}x")
return result
def cancel_order(self, market, order_id):
"""Cancel a futures order."""
data = {
"market": market,
"market_type": "FUTURES",
"order_id": order_id
}
return self._request("POST", "/v2/futures/cancel-order", json_data=data)
def close_position_with_retry(api, market, side, amount, is_dry_run=False):
"""Close position with retry logic for emergency situations."""
if is_dry_run:
log_trade(f"Would close position {market} {side} amount {amount}", is_dry_run=True)
return True
# SAFETY FIX #3: Retry logic for position closes
for attempt in range(RETRY_ATTEMPTS):
try:
result = api.place_order(market, side, "market", amount)
log_trade(f"Position closed successfully: {market} (attempt {attempt + 1})")
return True
except Exception as e:
log_error(f"Failed to close position {market} attempt {attempt + 1}: {e}")
if attempt < RETRY_ATTEMPTS - 1:
time.sleep(RETRY_DELAY)
else:
# All retries exhausted - send emergency alert
emergency_msg = f"EMERGENCY: Failed to close position {market} after {RETRY_ATTEMPTS} retries — MANUAL INTERVENTION REQUIRED"
send_telegram(f"🚨 <b>{emergency_msg}</b>")
log_error(emergency_msg)
return False
return False
def check_kill_switch(api, state, config, is_dry_run=False):
"""Check if kill switch should be triggered due to drawdown."""
try:
balance_response = api.get_futures_balance()
# SAFETY FIX #1: Kill switch bypass protection
if not balance_response:
reason = "Balance API returned empty response - STOPPING for safety"
log_error(reason)
create_lockfile(reason)
send_telegram(f"🚨 <b>KILL SWITCH - API FAILURE</b>\n{reason}")
return True
# Handle different response formats
current_equity = 0
if isinstance(balance_response, list):
if balance_response:
balance_data = balance_response[0]
current_equity = float(balance_data.get("available", 0)) + float(balance_data.get("frozen", 0))
else:
reason = "Balance API returned empty list - STOPPING for safety"
log_error(reason)
create_lockfile(reason)
send_telegram(f"🚨 <b>KILL SWITCH - EMPTY BALANCE</b>\n{reason}")
return True
elif isinstance(balance_response, dict):
current_equity = float(balance_response.get("available", 0)) + float(balance_response.get("frozen", 0))
else:
reason = f"Balance API returned unexpected format: {type(balance_response)} - STOPPING for safety"
log_error(reason)
create_lockfile(reason)
send_telegram(f"🚨 <b>KILL SWITCH - INVALID BALANCE FORMAT</b>\n{reason}")
return True
# SAFETY FIX #5: Zero equity is a kill-switch event
if current_equity <= 0:
reason = f"Equity is zero or negative (${current_equity}) - KILL SWITCH TRIGGERED"
log_error(reason)
create_lockfile(reason, current_equity)
send_telegram(f"🚨 <b>KILL SWITCH - ZERO EQUITY</b>\n{reason}")
return True
# Initialize starting balance if not set
if STARTING_BALANCE_KEY not in state:
state[STARTING_BALANCE_KEY] = current_equity
save_state(state)
log_trade(f"Starting balance recorded: ${current_equity:.2f}", is_dry_run)
return False
starting_balance = state[STARTING_BALANCE_KEY]
drawdown = (starting_balance - current_equity) / starting_balance
log_trade(f"Balance check - Current: ${current_equity:.2f}, Starting: ${starting_balance:.2f}, Drawdown: {drawdown*100:.2f}%", is_dry_run)
kill_switch_threshold = config.get("kill_switch_drawdown_pct", 50.0) / 100.0
if drawdown >= kill_switch_threshold:
# KILL SWITCH TRIGGERED
msg = (f"🚨 <b>KILL SWITCH TRIGGERED</b> 🚨\n"
f"Account hit {drawdown*100:.1f}% drawdown\n"
f"Starting: ${starting_balance:.2f} → Current: ${current_equity:.2f}\n"
f"Bot stopped, attempting to close all positions")
send_telegram(msg)
log_error(f"KILL SWITCH: {drawdown*100:.1f}% drawdown")
# Try to close all positions with retry logic
positions_at_halt = []
try:
positions = api.get_positions()
if positions: # Only process if we got valid data
for pos in positions:
market = pos.get("market")
if market:
# Record position for lockfile
positions_at_halt.append({
"market": market,
"side": pos.get("side"),
"amount": pos.get("amount"),
"pnl": pos.get("unrealized_pnl", 0)
})
# Close with retry logic
side = "buy" if pos.get("side") == "sell" else "sell"
amount = abs(float(pos.get("close_avbl", pos.get("open_interest", pos.get("amount", 0)))))
if amount > 0:
success = close_position_with_retry(api, market, side, amount, is_dry_run)
if success:
log_trade(f"Kill switch: closed position {market}", is_dry_run)
except Exception as e:
log_error(f"Failed to close positions during kill switch: {e}")
# Create lockfile
create_lockfile(f"Kill switch - {drawdown*100:.1f}% drawdown", current_equity, positions_at_halt)
return True
return False
except Exception as e:
# SAFETY FIX #1: API failure = STOP, don't continue
reason = f"Kill switch check failed: {e} - STOPPING for safety"
log_error(reason)
create_lockfile(reason)
send_telegram(f"🚨 <b>KILL SWITCH - API ERROR</b>\n{reason}")
return True
def get_leverage_for_score(config, score):
"""Determine leverage based on signal score using configurable tiers.
Tiers are sorted by min_score descending, so highest matching tier wins.
Example tiers: [{"min_score": 45, "leverage": 5}, {"min_score": 60, "leverage": 7}]
Score 55 → 5x, Score 65 → 7x
"""
max_leverage = config.get("max_leverage", 10)
tiers = config.get("leverage_tiers", [
{"min_score": 45, "leverage": 5},
{"min_score": 60, "leverage": 7}
])
# Sort descending by min_score so we match the highest qualifying tier
tiers_sorted = sorted(tiers, key=lambda t: t["min_score"], reverse=True)
for tier in tiers_sorted:
if score >= tier["min_score"]:
return min(max_leverage, tier["leverage"])
# Fallback: lowest tier leverage or 5x
return min(max_leverage, tiers_sorted[-1]["leverage"] if tiers_sorted else 5)
def get_position_size(api, config, is_dry_run=False):
"""Calculate position size based on current equity."""
try:
balance_response = api.get_futures_balance()
# Handle different response formats
if isinstance(balance_response, list):
if balance_response:
balance_data = balance_response[0]
equity = float(balance_data.get("available", 0)) + float(balance_data.get("frozen", 0))
else:
log_error("Empty balance list returned from API")
return None # Return None instead of 0
elif isinstance(balance_response, dict):
equity = float(balance_response.get("available", 0)) + float(balance_response.get("frozen", 0))
else:
log_error(f"Unexpected balance response format: {type(balance_response)}")
return None # Return None instead of 0
# SAFETY FIX #5: Zero equity check
if equity <= 0:
log_error(f"Equity is zero or negative: ${equity}")
return None # This should trigger kill switch upstream
position_size_pct = config.get("position_size_pct", 5.0)
position_size = equity * (position_size_pct / 100)
# SAFETY FIX #5: Validate calculated position size
if position_size <= 0:
log_error(f"Calculated position size is invalid: ${position_size}")
return None
log_trade(f"Position size: ${position_size:.2f} ({position_size_pct}% of ${equity:.2f})", is_dry_run)
return position_size
except Exception as e:
log_error(f"Failed to calculate position size: {e}")
return None # Return None instead of 0
def get_token_amount(api, market, usd_margin, leverage):
"""Convert USD margin to token amount for CoinEx order.
CoinEx expects amount in base currency (tokens), not USD.
notional = margin * leverage, then amount = notional / price.
Also checks against market minimum amount."""
try:
import requests as req
# Get current price from ticker (public endpoint, no auth needed)
ticker_resp = req.get("https://api.coinex.com/v2/futures/ticker",
params={"market": market}, timeout=10).json()
if ticker_resp.get("code") != 0 or not ticker_resp.get("data"):
log_error(f"Cannot get ticker for {market}")
return None
ticker = ticker_resp["data"][0] if isinstance(ticker_resp["data"], list) else ticker_resp["data"]
price = float(ticker.get("last", 0))
if price <= 0:
log_error(f"Invalid price for {market}: {price}")
return None
# Get min_amount from market info (public endpoint)
market_resp = req.get("https://api.coinex.com/v2/futures/market",
params={"market": market}, timeout=10).json()
min_amount = 1
if market_resp.get("code") == 0 and market_resp.get("data"):
mkt = market_resp["data"][0] if isinstance(market_resp["data"], list) else market_resp["data"]
min_amount = float(mkt.get("min_amount", 1))
# Calculate: notional = margin * leverage, amount = notional / price
notional = usd_margin * leverage
amount = int(notional / price)
if amount < min_amount:
log_error(f"{market}: calculated amount {amount} below minimum {int(min_amount)} (need ${min_amount * price / leverage:.2f} margin at {leverage}x)")
return None
log_trade(f"{market}: ${usd_margin:.2f} margin × {leverage}x = ${notional:.2f} notional = {amount} tokens @ ${price:.4f}")
return amount
except Exception as e:
log_error(f"Failed to calculate token amount for {market}: {e}")
return None
def format_symbol_for_coinex(symbol):
"""Convert scanner symbol to CoinEx futures format."""
# Scanner uses symbols like "BTC", CoinEx uses "BTCUSDT"
if symbol.endswith("USDT"):
return symbol
return f"{symbol}USDT"
def run_short_scan(is_dry_run=False):
"""Run short scanner on all coins - same logic as paper trader."""
results = []
for symbol in SHORT_COINS:
try:
r = scan_coin(symbol)
if r:
r["direction"] = "short"
r["coinex_symbol"] = format_symbol_for_coinex(symbol)
results.append(r)
time.sleep(0.15)
except Exception as e:
log_error(f"Short scan failed for {symbol}: {e}", is_dry_run)
return sorted(results, key=lambda x: x['score'], reverse=True)
def run_long_scan(is_dry_run=False):
"""Run long scanner - same logic as paper trader."""
results = []
for symbol in SHORT_COINS:
try:
r = scan_coin(symbol)
if r:
# Invert short criteria for long opportunities
long_score = 0
reasons = []
if r['rsi'] <= 25:
long_score += 30
reasons.append(f"RSI extremely oversold ({r['rsi']})")
elif r['rsi'] <= 30:
long_score += 25
reasons.append(f"RSI oversold ({r['rsi']})")
elif r['rsi'] <= 35:
long_score += 15
reasons.append(f"RSI low ({r['rsi']})")
elif r['rsi'] <= 40:
long_score += 5
reasons.append(f"RSI mildly low ({r['rsi']})")
if r['vwap_pct'] < -5:
long_score += 20
reasons.append(f"Well below VWAP ({r['vwap_pct']:+.1f}%)")
elif r['vwap_pct'] < -3:
long_score += 15
reasons.append(f"Below VWAP ({r['vwap_pct']:+.1f}%)")
elif r['vwap_pct'] < -1:
long_score += 8
reasons.append(f"Slightly below VWAP ({r['vwap_pct']:+.1f}%)")
if r['change_24h'] < -15:
long_score += 15
reasons.append(f"Dumped {r['change_24h']:.1f}% 24h")
elif r['change_24h'] < -8:
long_score += 10
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
elif r['change_24h'] < -4:
long_score += 5
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
if r['bb_position'] < 0:
long_score += 15
reasons.append(f"Below lower Bollinger ({r['bb_position']:.2f})")
elif r['bb_position'] < 0.15:
long_score += 10
reasons.append(f"Near lower Bollinger ({r['bb_position']:.2f})")
results.append({
"symbol": r["symbol"],
"coinex_symbol": format_symbol_for_coinex(r["symbol"]),
"price": r["price"],
"rsi": r["rsi"],
"vwap_pct": r["vwap_pct"],
"change_24h": r["change_24h"],
"bb_position": r["bb_position"],
"score": long_score,
"reasons": reasons,
"direction": "long",
})
time.sleep(0.15)
except Exception as e:
log_error(f"Long scan failed for {symbol}: {e}", is_dry_run)
return sorted(results, key=lambda x: x['score'], reverse=True)
def manage_exits(api, state, config, dry_run=False):
"""Manage position exits for TP/SL/trailing stops."""
exits = []
try:
positions = api.get_positions()
# SAFETY FIX #2: Handle position query failure
if positions is None:
log_error("Position query failed - halting trading cycle for safety")
return None # Return None to signal halt
if not positions or not isinstance(positions, list):
log_trade("No positions to manage (empty or invalid response)", dry_run)
return exits
for pos in positions:
market = pos.get("market", "")
position_id = f"{market}_{pos.get('side', '')}"
unrealized_pnl = float(pos.get("unrealized_pnl", 0))
margin_used = float(pos.get("margin", 1))
if margin_used == 0:
continue
pnl_pct = (unrealized_pnl / margin_used) * 100
# Track peak PnL for trailing stop
if position_id not in state.get("peak_pnl", {}):
state["peak_pnl"][position_id] = pnl_pct
if pnl_pct > state["peak_pnl"][position_id]:
state["peak_pnl"][position_id] = pnl_pct
peak = state["peak_pnl"][position_id]
reason = None
# Check exit conditions using config
tp_pct = config.get("tp_pct", 5.0)
sl_pct = config.get("sl_pct", -3.0)
trailing_stop_pct = config.get("trailing_stop_pct", 2.0)
if tp_pct and pnl_pct >= tp_pct:
reason = f"TP hit ({pnl_pct:+.1f}%)"
elif sl_pct and pnl_pct <= sl_pct:
reason = f"SL hit ({pnl_pct:+.1f}%)"
elif peak >= 2.0 and (peak - pnl_pct) >= trailing_stop_pct:
reason = f"Trailing stop (peak {peak:+.1f}%, now {pnl_pct:+.1f}%)"
if reason:
try:
# Calculate exit order
side = "buy" if pos.get("side") == "sell" else "sell"
amount = abs(float(pos.get("close_avbl", pos.get("open_interest", pos.get("amount", 0)))))
if not dry_run and amount > 0:
# Use retry logic for position closes
success = close_position_with_retry(api, market, side, amount, dry_run)
if success:
log_trade(f"CLOSED {market} {pos.get('side')} - {reason} - PnL: ${unrealized_pnl:+.2f}", dry_run)
exits.append({
"market": market,
"side": pos.get("side"),
"reason": reason,
"pnl": unrealized_pnl,
"pnl_pct": pnl_pct
})
# Clean up peak tracking
state["peak_pnl"].pop(position_id, None)
else:
log_trade(f"Would close {market} {pos.get('side')} - {reason}", dry_run)
except Exception as e:
log_error(f"Failed to close position {market}: {e}", dry_run)
except Exception as e:
log_error(f"Failed to manage exits: {e}", dry_run)
return None # Signal failure
return exits
def open_new_positions(api, state, config, dry_run=False):
"""Scan for opportunities and open new positions."""
opened = []
try:
# Get current positions to avoid doubling up
positions = api.get_positions()
# SAFETY FIX #2: Handle position query failure
if positions is None:
log_error("Position query failed - cannot safely open new positions")
return None
if not positions or not isinstance(positions, list):
positions = []
existing_markets = {pos.get("market", "") for pos in positions if isinstance(pos, dict)}
num_open = len([pos for pos in positions if isinstance(pos, dict) and pos.get("market")])
max_positions = config.get("max_positions", 3)
slots_available = max_positions - num_open
log_trade(f"Current positions: {num_open}, Available slots: {slots_available}", dry_run)
if slots_available <= 0:
return opened
# Get position size
position_size = get_position_size(api, config, dry_run)
# SAFETY FIX #5: Position size validation
if position_size is None or position_size <= 0:
log_error("Position size is invalid - stopping new position opens for safety")
if position_size is None:
# This could indicate a kill-switch scenario
return None
return opened
# Run scanners
shorts = run_short_scan(dry_run)
longs = run_long_scan(dry_run)
# Get thresholds from config
short_threshold = config.get("short_threshold", 50)
long_threshold = config.get("long_threshold", 45)
max_leverage = config.get("max_leverage", 10)
# Process short opportunities
for opportunity in shorts:
if slots_available <= 0:
break
if opportunity["score"] < short_threshold:
break
if opportunity["coinex_symbol"] in existing_markets:
continue
# Determine leverage (capped by config)
leverage = get_leverage_for_score(config, opportunity["score"])
market = opportunity["coinex_symbol"]
try:
if not dry_run:
# Convert USD margin to token amount
token_amount = get_token_amount(api, market, position_size, leverage)
if token_amount is None:
log_trade(f"SKIP SHORT {market} - amount below minimum", dry_run)
continue
# SAFETY FIX #7: Set and verify leverage
api.set_leverage(market, leverage)
# Place short order (sell) with verification
result = api.place_order(market, "sell", "market", token_amount)
log_trade(f"OPENED SHORT {market} {leverage}x - Score: {opportunity['score']} - {token_amount} tokens (${position_size:.2f} margin)", dry_run)
opened.append({
"market": market,
"direction": "short",
"leverage": leverage,
"score": opportunity["score"],
"size": position_size,
"order_id": result.get("order_id")
})
existing_markets.add(market)
slots_available -= 1
else:
log_trade(f"Would open SHORT {market} {leverage}x - Score: {opportunity['score']}", dry_run)
time.sleep(0.5) # Rate limiting
except Exception as e:
log_error(f"Failed to open short position {market}: {e}", dry_run)
# Process long opportunities
for opportunity in longs:
if slots_available <= 0:
break
if opportunity["score"] < long_threshold:
break
if opportunity["coinex_symbol"] in existing_markets:
continue
# Determine leverage (capped by config)
leverage = get_leverage_for_score(config, opportunity["score"])
market = opportunity["coinex_symbol"]
try:
if not dry_run:
# Convert USD margin to token amount
token_amount = get_token_amount(api, market, position_size, leverage)
if token_amount is None:
log_trade(f"SKIP LONG {market} - amount below minimum", dry_run)
continue
# SAFETY FIX #7: Set and verify leverage
api.set_leverage(market, leverage)
# Place long order (buy) with verification
result = api.place_order(market, "buy", "market", token_amount)
log_trade(f"OPENED LONG {market} {leverage}x - Score: {opportunity['score']} - {token_amount} tokens (${position_size:.2f} margin)", dry_run)
opened.append({
"market": market,
"direction": "long",
"leverage": leverage,
"score": opportunity["score"],
"size": position_size,
"order_id": result.get("order_id")
})
existing_markets.add(market)
slots_available -= 1
else:
log_trade(f"Would open LONG {market} {leverage}x - Score: {opportunity['score']}", dry_run)
time.sleep(0.5) # Rate limiting
except Exception as e:
log_error(f"Failed to open long position {market}: {e}", dry_run)
except Exception as e:
log_error(f"Failed to open new positions: {e}", dry_run)
return None # Signal failure
return opened
def startup_safety_checks(api, is_dry_run=False):
"""Perform comprehensive safety checks before starting trading."""
log_trade("=== STARTUP SAFETY CHECKS ===", is_dry_run)
# SAFETY FIX #10: Check lockfile first
if check_lockfile():
sys.exit(1)
log_trade("✓ Lockfile check passed", is_dry_run)
# Check balance accessibility
try:
balance_response = api.get_futures_balance()
if not balance_response:
raise RuntimeError("Balance API returned empty response")
# Calculate current equity
current_equity = 0
if isinstance(balance_response, list):
if balance_response:
balance_data = balance_response[0]
current_equity = float(balance_data.get("available", 0)) + float(balance_data.get("frozen", 0))
else:
raise RuntimeError("Balance API returned empty list")
elif isinstance(balance_response, dict):
current_equity = float(balance_response.get("available", 0)) + float(balance_response.get("frozen", 0))
else:
raise RuntimeError(f"Balance API returned unexpected format: {type(balance_response)}")
log_trade(f"✓ Balance check passed - Current equity: ${current_equity:.2f}", is_dry_run)
if current_equity <= 0:
raise RuntimeError(f"Current equity is zero or negative: ${current_equity}")
except Exception as e:
log_error(f"Balance check failed: {e}")
reason = f"Startup balance check failed: {e}"
create_lockfile(reason)
send_telegram(f"🚨 <b>STARTUP FAILURE</b>\n{reason}")
sys.exit(1)
# Check positions accessibility
try:
positions = api.get_positions()
if positions is None:
raise RuntimeError("Positions API failed to return data")
log_trade(f"✓ Positions check passed - Found {len(positions) if positions else 0} positions", is_dry_run)
# Log existing positions for awareness
if positions:
for pos in positions:
market = pos.get("market", "Unknown")
side = pos.get("side", "Unknown")
pnl = float(pos.get("unrealized_pnl", 0))
log_trade(f" Existing position: {market} {side} PnL: ${pnl:+.2f}", is_dry_run)
except Exception as e:
log_error(f"Position check failed: {e}")
reason = f"Startup position check failed: {e}"
create_lockfile(reason)
send_telegram(f"🚨 <b>STARTUP FAILURE</b>\n{reason}")
sys.exit(1)
log_trade("=== ALL STARTUP CHECKS PASSED ===", is_dry_run)
def main():
parser = argparse.ArgumentParser(description="CoinEx Live Futures Trader - Enhanced Safety Version")
parser.add_argument("--dry-run", action="store_true", help="Dry run mode - no actual trades")
args = parser.parse_args()
setup_logging()
print(f"=== CoinEx Live Futures Trader - ENHANCED SAFETY ===")
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE TRADING'}")
# SAFETY FIX #9: Clear mode prefixes in logs
if args.dry_run:
log_trade("Running in DRY RUN mode - no actual trades will be placed", True)
else:
log_trade("LIVE TRADING MODE - real money at risk", False)
try:
# Load configuration first
config = load_config()
log_trade(f"Loaded config - Mode: {config.get('mode', 'unknown')}")
# Check if trading is paused
if config.get("mode") == "paused":
log_trade("Trading is PAUSED via config - exiting")
print("Trading is PAUSED - check trader_config.json")
return
# Load credentials and initialize API
access_id, secret_key = load_coinex_credentials()
api = CoinExAPI(access_id, secret_key)
# Update increment_failure_count to use config
def increment_failure_count_configured():
increment_failure_count(config.get("circuit_breaker_threshold", 3))
# SAFETY FIX #10: Startup safety checks
startup_safety_checks(api, args.dry_run)
# Load state
state = load_state()
# CRITICAL: Check kill switch FIRST
if check_kill_switch(api, state, config, args.dry_run):
log_error("Kill switch triggered - stopping bot")
send_telegram("🛑 Bot stopped due to kill switch")
return
# Manage exits first
exits = manage_exits(api, state, config, args.dry_run)
if exits is None:
log_error("Exit management failed - stopping trading cycle")
return
if exits:
exit_messages = []
for ex in exits:
emoji = "" if ex["pnl"] > 0 else ""
msg = f"{emoji} Closed {ex['market']} {ex.get('side', ex.get('direction',''))} - {ex['reason']} - PnL: ${ex['pnl']:+.2f}"
exit_messages.append(msg)
if exit_messages and not args.dry_run:
send_telegram(f"🔄 <b>Position Updates</b>\n" + "\n".join(exit_messages))
# Open new positions
opened = open_new_positions(api, state, config, args.dry_run)
if opened is None:
log_error("Position opening failed - stopping trading cycle")
return
if opened:
open_messages = []
for op in opened:
emoji = "🔴" if op["direction"] == "short" else "🟢"
msg = f"{emoji} {op['market']} {op['direction']} {op['leverage']}x - Score: {op['score']}"
open_messages.append(msg)
if open_messages and not args.dry_run:
send_telegram(f"📊 <b>New Positions</b>\n" + "\n".join(open_messages))
# Save state
save_state(state)
# Cycle summary alert
if not args.dry_run:
try:
bal = api.get_futures_balance()
avail = float(bal[0].get("available", 0)) if bal else 0
positions = api.get_positions() or []
lines = [f"⚡ <b>Cycle Summary</b> | {len(positions)} positions | ${avail:.2f} free"]
for pos in positions:
pnl = float(pos.get("unrealized_pnl", 0))
margin_est = float(pos.get("cml_position_value", 0)) / float(pos.get("leverage", 1))
pnl_pct = (pnl / margin_est * 100) if margin_est > 0 else 0
emoji = "🟢" if pnl >= 0 else "🔴"
lines.append(f"{emoji} {pos['market']} {pos.get('side','')} {pnl_pct:+.0f}% (${pnl:+.2f})")
if not positions:
lines.append("No open positions")
action = "no action"
if exits:
action = f"closed {len(exits)}"
if opened:
action = f"opened {len(opened)}" if not exits else f"{action}, opened {len(opened)}"
lines.append(f"Action: {action}")
send_telegram("\n".join(lines))
except Exception as e:
log_error(f"Cycle summary alert failed: {e}")
log_trade("Trade cycle completed successfully", args.dry_run)
print("Done.")
except Exception as e:
log_error(f"Fatal error in main: {e}", args.dry_run)
send_telegram(f"🚨 CoinEx Live Trader Error: {e}")
# Create lockfile for unexpected errors
create_lockfile(f"Fatal error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()