1054 lines
48 KiB
Python
1054 lines
48 KiB
Python
from src.persistence import load_persistent_trades, save_persistent_trades, load_binance_positions_on_startup, init_persistence
|
|
import asyncio, logging, joblib, time, json, aiohttp, os
|
|
from datetime import datetime, timedelta
|
|
from src.config import get_config
|
|
from src.bot.binance_client import BinanceClientWrapper
|
|
from src.integrations.telegram_notifier import TelegramNotifier
|
|
from src.integrations.obsidian_logger import ObsidianLogger
|
|
from src.strategies.ml_strategy import MLStrategy
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# DASHBOARD CLIENT - MOCK (sends REAL Binance data only via HTTP)
|
|
class DashboardClient:
|
|
"""Send ONLY REAL, VERIFIED trades to dashboard"""
|
|
|
|
async def record_buy(self, pair, qty, price):
|
|
"""Record BUY - VERIFY on Binance first!"""
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post('http://localhost:7000/api/trade/buy', json={
|
|
'pair': pair,
|
|
'qty': qty,
|
|
'price': price,
|
|
'entry_time': datetime.now().isoformat()
|
|
}) as resp:
|
|
if resp.status == 200:
|
|
logger.info(f'✅ Dashboard recorded BUY: {pair}')
|
|
else:
|
|
logger.warning(f'❌ Dashboard BUY record failed: {resp.status}')
|
|
except Exception as e:
|
|
logger.warning(f'Dashboard BUY error: {e}')
|
|
|
|
async def record_sell(self, pair, qty, price, profit_usd, profit_pct, hold_time_min):
|
|
"""Record SELL - ONLY IF REAL!"""
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post('http://localhost:7000/api/trade/sell', json={
|
|
'pair': pair,
|
|
'qty': qty,
|
|
'price': price,
|
|
'profit_usd': profit_usd,
|
|
'profit_pct': profit_pct,
|
|
'hold_time_min': hold_time_min,
|
|
'exit_time': datetime.now().isoformat()
|
|
}) as resp:
|
|
if resp.status == 200:
|
|
logger.info(f'✅ Dashboard recorded SELL: {pair} profit=${profit_usd:.2f}')
|
|
else:
|
|
logger.warning(f'❌ Dashboard SELL record failed: {resp.status}')
|
|
except Exception as e:
|
|
logger.warning(f'Dashboard SELL error: {e}')
|
|
|
|
async def update_state(self, balance, daily_pnl, portfolio_value_usd, total_pnl=0.0, trades_today=0, wins_today=0, losses_today=0):
|
|
"""Update state - REAL DATA ONLY"""
|
|
try:
|
|
portfolio_value_chf = portfolio_value_usd * 0.84
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post('http://localhost:7000/api/update', json={
|
|
'balance': balance,
|
|
'daily_pnl': daily_pnl,
|
|
'total_pnl': total_pnl,
|
|
'trades_today': trades_today,
|
|
'wins_today': wins_today,
|
|
'losses_today': losses_today,
|
|
'portfolio_value_usd': portfolio_value_usd,
|
|
'portfolio_value_chf': portfolio_value_chf,
|
|
'last_update': datetime.now().isoformat()
|
|
}) as resp:
|
|
if resp.status != 200:
|
|
logger.warning(f'Dashboard state update failed: {resp.status}')
|
|
except Exception as e:
|
|
logger.debug(f'Dashboard update error: {e}')
|
|
|
|
async def clear_state(self):
|
|
"""Clear dashboard - START FRESH"""
|
|
try:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post('http://localhost:7000/api/clear') as resp:
|
|
logger.info(f'Dashboard cleared: {resp.status}')
|
|
except:
|
|
pass
|
|
|
|
class MLTradingBot:
|
|
def __init__(self, config, binance, telegram, obsidian, model, scaler):
|
|
self.config = config
|
|
self.binance = binance
|
|
self.telegram = telegram
|
|
self.obsidian = obsidian
|
|
self.model = model
|
|
self.scaler = scaler
|
|
self.dashboard = DashboardClient() # ADD THIS
|
|
self.strategy = MLStrategy(trading_pair=config.trading_pair)
|
|
|
|
self.last_report_time = time.time()
|
|
self.report_interval = 10800
|
|
self.trades_today = 0
|
|
self.wins_today = 0
|
|
self.losses_today = 0
|
|
self.daily_pnl = 0.0
|
|
self.report_count = 0
|
|
self.last_swap_time = time.time()
|
|
|
|
# Metriken für echte Win-Rate
|
|
self.total_trades = 0
|
|
self.total_pnl = 0.0
|
|
self.max_drawdown = 0.0
|
|
self.min_daily_pnl = 0.0
|
|
self.starting_capital = 100.0
|
|
self.daily_loss_limit_reached = False
|
|
|
|
# Symbol constraints cache
|
|
self.symbol_info = {}
|
|
self.pairs = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT']
|
|
|
|
# Track open positions
|
|
self.current_trades = {} # CLEAN START - reset on bot restart
|
|
|
|
logger.info('🗑️ RESET: Cleared all stored positions (dashboard will show REAL Binance state only)')
|
|
|
|
# Error tracking & resilience
|
|
self.last_error_time = 0
|
|
self.error_threshold = 5
|
|
self.error_count = 0
|
|
self.error_cooldown_until = 0
|
|
|
|
# Daily reset tracking
|
|
self.last_daily_reset = datetime.utcnow().date()
|
|
|
|
async def load_exchange_info(self):
|
|
"""Load LOT_SIZE constraints for all trading pairs"""
|
|
try:
|
|
logger.info('📥 Loading Binance symbol constraints...')
|
|
for pair in self.pairs:
|
|
try:
|
|
info = await self.binance.get_exchange_info(pair)
|
|
if info:
|
|
self.symbol_info[pair] = info
|
|
logger.debug(f'✅ {pair}: minNotional=${info.get("minNotional", 0)}, stepSize={info.get("stepSize", 0)}')
|
|
except Exception as e:
|
|
logger.warning(f'Failed to load {pair}: {e}')
|
|
logger.info(f'✅ Loaded constraints for {len(self.symbol_info)} pairs')
|
|
except Exception as e:
|
|
logger.error(f'Failed to load exchange info: {e}')
|
|
|
|
def calculate_valid_quantity(self, pair: str, usdt_amount: float, price: float) -> float:
|
|
"""Calculate order quantity respecting Binance LOT_SIZE constraints."""
|
|
try:
|
|
if pair not in self.symbol_info:
|
|
logger.warning(f'⚠️ No info for {pair}')
|
|
return 0
|
|
|
|
if price is None or price <= 0:
|
|
logger.warning(f'⚠️ Invalid price for {pair}: {price}')
|
|
return 0
|
|
|
|
info = self.symbol_info[pair]
|
|
step_size = float(info.get('stepSize', 1e-8))
|
|
min_qty = float(info.get('minQty', 0))
|
|
max_qty = float(info.get('maxQty', 1e10))
|
|
min_notional = float(info.get('minNotional', 10))
|
|
|
|
qty = usdt_amount / price
|
|
notional = qty * price
|
|
|
|
# CHECK MINNOTIONAL BEFORE ROUNDING
|
|
if notional < min_notional:
|
|
logger.debug(f'❌ {pair}: notional ${notional:.2f} < min ${min_notional:.2f} (requested ${usdt_amount:.2f})')
|
|
return 0
|
|
|
|
if step_size > 0:
|
|
qty = round(qty / step_size) * step_size
|
|
|
|
# RECHECK NOTIONAL AFTER ROUNDING (rounding might make qty too small!)
|
|
notional_after = qty * price
|
|
if notional_after < min_notional:
|
|
logger.debug(f'❌ {pair}: after rounding notional ${notional_after:.2f} < min ${min_notional:.2f}')
|
|
return 0
|
|
|
|
if qty < min_qty:
|
|
logger.debug(f'❌ {pair}: qty {qty:.8f} < min {min_qty:.8f}')
|
|
return 0
|
|
if qty > max_qty:
|
|
qty = max_qty
|
|
|
|
logger.debug(f'✅ {pair}: qty={qty:.8f} (notional=${notional:.2f})')
|
|
return qty
|
|
except Exception as e:
|
|
logger.error(f'Quantity calculation error: {e}')
|
|
return 0
|
|
|
|
def format_quantity(self, qty: float, step_size: float) -> str:
|
|
"""Format quantity to proper decimal places - STRICTLY NO scientific notation"""
|
|
try:
|
|
if step_size is None or step_size <= 0:
|
|
# Use Decimal for strict formatting
|
|
from decimal import Decimal, ROUND_DOWN
|
|
d = Decimal(str(qty))
|
|
return str(d.quantize(Decimal('0.00000001'), rounding=ROUND_DOWN))
|
|
|
|
if step_size >= 1:
|
|
return str(int(qty))
|
|
|
|
# Force no scientific notation using format string
|
|
from decimal import Decimal, ROUND_DOWN
|
|
|
|
# Determine decimal places from step_size
|
|
step_str = str(step_size)
|
|
if 'e' in step_str.lower():
|
|
# Scientific notation in step_size
|
|
decimal_places = 8
|
|
elif '.' in step_str:
|
|
decimal_places = len(step_str.split('.')[-1])
|
|
else:
|
|
decimal_places = 0
|
|
|
|
decimal_places = max(decimal_places, 1)
|
|
decimal_places = min(decimal_places, 20)
|
|
|
|
# Round down to step_size
|
|
multiplier = 10 ** decimal_places
|
|
rounded_qty = int(qty * multiplier) / multiplier
|
|
|
|
# Format without scientific notation
|
|
formatted = f'{rounded_qty:.{decimal_places}f}'
|
|
|
|
# Validate: no 'e' in result
|
|
if 'e' in formatted.lower():
|
|
logger.error(f'SCIENTIFIC NOTATION DETECTED: {qty} → {formatted}')
|
|
return f'{rounded_qty:.8f}'
|
|
|
|
logger.debug(f'Formatted: {qty} → {formatted} (step={step_size})')
|
|
return formatted
|
|
except Exception as e:
|
|
logger.error(f'Format error: {e}')
|
|
# Fallback: use Decimal
|
|
from decimal import Decimal
|
|
d = Decimal(str(qty))
|
|
return str(d)
|
|
|
|
def increment_error_count(self) -> bool:
|
|
"""Track error frequency — return True if limit reached"""
|
|
self.error_count += 1
|
|
self.last_error_time = time.time()
|
|
|
|
if self.error_count >= self.error_threshold:
|
|
self.error_cooldown_until = time.time() + 300
|
|
logger.warning(f'🛑 Error threshold ({self.error_count}) reached! Pausing 5 minutes.')
|
|
return True
|
|
return False
|
|
|
|
def reset_error_count(self):
|
|
"""Reset error counter if no errors in 60 seconds"""
|
|
if time.time() - self.last_error_time > 60:
|
|
if self.error_count > 0:
|
|
logger.info(f'✅ Error counter reset (was {self.error_count})')
|
|
self.error_count = 0
|
|
|
|
async def liquidate_btc_to_usdt_on_startup(self):
|
|
"""ONE-TIME: Sell all BTC to USDT if USDT is too small"""
|
|
pass # Disabled - use force_liquidate_all instead
|
|
|
|
async def force_liquidate_all(self):
|
|
"""MANUAL LIQUIDATION: Force sell ALL holdings to USDT (Marc can trigger on demand)"""
|
|
logger.warning('🔥 FORCE LIQUIDATION STARTED')
|
|
|
|
results = {}
|
|
try:
|
|
balance = await self.binance.get_balance()
|
|
|
|
# Liquidate BTC (round down to valid LOT_SIZE)
|
|
btc_free = float(balance.get('BTC', {}).get('free', 0))
|
|
if btc_free > 0.00001:
|
|
try:
|
|
btc_qty = int(btc_free * 100000) / 100000
|
|
btc_qty = max(btc_qty, 0.00001)
|
|
logger.info(f'📤 Selling {btc_qty:.8f} BTC')
|
|
result = await self.binance.place_order('BTCUSDT', 'SELL', f'{btc_qty:.8f}', 'MARKET')
|
|
if result:
|
|
results['BTC'] = {'status': result.get('status'), 'qty': btc_qty}
|
|
logger.info(f'✅ BTC SOLD: {result.get("status")}')
|
|
except Exception as e:
|
|
logger.error(f'BTC SELL failed: {e}')
|
|
results['BTC'] = {'error': str(e)[:50]}
|
|
|
|
# Liquidate other holdings
|
|
for asset in ['ETH', 'SOL', 'BNB', 'XRP']:
|
|
qty = float(balance.get(asset, {}).get('free', 0))
|
|
if qty > 0.001:
|
|
try:
|
|
pair = f'{asset}USDT'
|
|
logger.info(f'📤 Selling {qty:.8f} {asset}')
|
|
result = await self.binance.place_order(pair, 'SELL', f'{qty:.8f}', 'MARKET')
|
|
if result:
|
|
results[asset] = {'status': result.get('status'), 'qty': qty}
|
|
logger.info(f'✅ {asset} SOLD: {result.get("status")}')
|
|
except Exception as e:
|
|
logger.error(f'{asset} SELL failed: {e}')
|
|
results[asset] = {'error': str(e)[:50]}
|
|
|
|
await asyncio.sleep(2)
|
|
new_balance = await self.binance.get_balance()
|
|
new_usdt = float(new_balance.get('USDT', {}).get('free', 0))
|
|
logger.warning(f'🔥 LIQUIDATION DONE! New USDT: ${new_usdt:.2f}')
|
|
|
|
return {'status': 'success', 'results': results, 'new_usdt': new_usdt}
|
|
except Exception as e:
|
|
logger.error(f'Liquidation error: {e}')
|
|
return {'status': 'error', 'message': str(e)[:100]}
|
|
|
|
async def auto_swap_holdings_to_usdt(self):
|
|
"""ONE-TIME: Sell all BTC to USDT if USDT is too small"""
|
|
try:
|
|
balance = await self.binance.get_balance()
|
|
btc_free = float(balance.get('BTC', {}).get('free', 0))
|
|
usdt_free = float(balance.get('USDT', {}).get('free', 0))
|
|
btc_price = 62500 # Approximate current price
|
|
|
|
# If BTC > 0.001 AND USDT < $10, LIQUIDATE BTC
|
|
if btc_free > 0.0001 and usdt_free < 10:
|
|
logger.warning(f'🔄 STARTUP LIQUIDATION: Selling {btc_free:.8f} BTC (~${btc_free * btc_price:.2f}) to USDT...')
|
|
|
|
try:
|
|
# Use calculate_valid_quantity to respect LOT_SIZE
|
|
valid_qty = self.calculate_valid_quantity('BTCUSDT', btc_free * btc_price, btc_price)
|
|
|
|
if valid_qty <= 0:
|
|
logger.warning(f'⚠️ BTC qty too small after LOT_SIZE check: {valid_qty}')
|
|
return
|
|
|
|
# IMPORTANT: Don't sell MORE than we actually have!
|
|
valid_qty = min(valid_qty, btc_free)
|
|
logger.info(f'📤 Placing SELL: {valid_qty:.8f} BTC (actual balance: {btc_free:.8f})')
|
|
result = await self.binance.place_order(
|
|
symbol='BTCUSDT',
|
|
side='SELL',
|
|
quantity=f'{valid_qty:.8f}',
|
|
order_type='MARKET'
|
|
)
|
|
|
|
if result and result.get('status') in ['FILLED', 'NEW', 'PARTIALLY_FILLED']:
|
|
logger.info(f'✅ BTC LIQUIDATED! Order ID: {result.get("orderId")}, Status: {result.get("status")}')
|
|
# Wait for balance to update
|
|
await asyncio.sleep(3)
|
|
|
|
new_balance = await self.binance.get_balance()
|
|
new_usdt = float(new_balance.get('USDT', {}).get('free', 0))
|
|
new_btc = float(new_balance.get('BTC', {}).get('free', 0))
|
|
logger.info(f'💰 After liquidation: USDT=${new_usdt:.2f}, BTC={new_btc:.8f}')
|
|
except Exception as e:
|
|
logger.error(f'❌ Liquidation order failed: {type(e).__name__}: {str(e)[:100]}')
|
|
except Exception as e:
|
|
logger.error(f'Liquidation check failed: {e}')
|
|
|
|
async def auto_swap_periodically(self):
|
|
"""Periodically swap small holdings back to USDT for liquidity"""
|
|
try:
|
|
if time.time() - self.last_swap_time < 300:
|
|
return
|
|
|
|
if time.time() < self.error_cooldown_until:
|
|
logger.debug('⏸️ Error cooldown active — skipping swap')
|
|
return
|
|
|
|
balance = await self.binance.get_balance()
|
|
if not balance:
|
|
logger.warning('No balance data for swap')
|
|
return
|
|
|
|
swapped_any = False
|
|
|
|
for asset in ['BTC', 'ETH', 'SOL', 'BNB', 'XRP']:
|
|
try:
|
|
qty = float(balance.get(asset, {}).get('free', 0))
|
|
|
|
if qty <= 0.00001:
|
|
continue
|
|
|
|
pair = f'{asset}USDT'
|
|
|
|
# FIX: Robust price fetching with error handling
|
|
try:
|
|
price = await self.binance.get_ticker_price(pair)
|
|
if price is None or price <= 0:
|
|
logger.warning(f'⚠️ Invalid price for {pair}: {price} — skipping swap')
|
|
continue
|
|
except Exception as e:
|
|
logger.warning(f'Failed to get price for {pair}: {e}')
|
|
continue
|
|
|
|
notional = qty * price
|
|
|
|
# Only swap if in safe range
|
|
if notional < 5 or notional > 15:
|
|
logger.debug(f'⏸️ {pair} notional ${notional:.2f} outside swap range [5-15]')
|
|
continue
|
|
|
|
logger.info(f'🔄 ATTEMPTING SWAP: {qty:.8f} {asset} (${notional:.2f}) → USDT @ ${price:.2f}')
|
|
|
|
try:
|
|
if pair not in self.symbol_info:
|
|
await self.load_exchange_info()
|
|
|
|
if pair not in self.symbol_info:
|
|
logger.warning(f'No symbol info for {pair} — skipping')
|
|
continue
|
|
|
|
step_size = self.symbol_info[pair].get('stepSize', 1e-8)
|
|
qty_str = self.format_quantity(qty, step_size)
|
|
|
|
logger.info(f'📤 Placing SWAP SELL: {qty_str} {pair} @ ${price:.2f}')
|
|
result = await self.binance.place_order(
|
|
symbol=pair,
|
|
side='SELL',
|
|
quantity=qty_str, # Pass STRING
|
|
order_type='MARKET'
|
|
)
|
|
|
|
if result:
|
|
# SWAP Alert disabled — user only wants profit notifications
|
|
pass
|
|
swapped_any = True
|
|
self.error_count = 0 # Reset errors on success
|
|
logger.info(f'✅ SWAP EXECUTED!')
|
|
else:
|
|
logger.warning(f'SWAP order returned no result for {pair}')
|
|
|
|
except Exception as e:
|
|
logger.warning(f'Swap order failed for {asset}: {e}')
|
|
self.increment_error_count()
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.warning(f'Swap check error for {asset}: {e}')
|
|
continue
|
|
|
|
if swapped_any:
|
|
self.last_swap_time = time.time()
|
|
|
|
except Exception as e:
|
|
logger.error(f'Auto-swap error: {e}')
|
|
|
|
async def check_take_profit(self):
|
|
"""Check all positions for exits"""
|
|
try:
|
|
balance = await self.binance.get_balance()
|
|
if not balance:
|
|
return
|
|
|
|
positions_to_close = []
|
|
|
|
for pair in self.pairs:
|
|
try:
|
|
asset = pair.replace('USDT', '')
|
|
current_qty = float(balance.get(asset, {}).get('free', 0))
|
|
|
|
if current_qty <= 0.00001:
|
|
continue
|
|
|
|
# FIX: Robust price fetching
|
|
try:
|
|
current_price = await self.binance.get_ticker_price(pair)
|
|
if current_price is None or current_price <= 0:
|
|
logger.debug(f'⚠️ Invalid price for {pair}: {current_price}')
|
|
continue
|
|
except Exception as e:
|
|
logger.warning(f'Failed to get price for {pair}: {e}')
|
|
continue
|
|
|
|
if pair not in self.current_trades:
|
|
logger.debug(f'⏭️ {pair} not in open_positions (keys: {list(self.current_trades.keys())})')
|
|
continue
|
|
|
|
pos = self.current_trades[pair]
|
|
buy_price = pos['buy_price']
|
|
buy_qty = pos['qty']
|
|
buy_time = datetime.fromisoformat(pos['buy_time'])
|
|
|
|
profit_pct = ((current_price - buy_price) / buy_price) * 100
|
|
hold_time_minutes = (datetime.now() - buy_time).total_seconds() / 60
|
|
|
|
if profit_pct > pos.get('peak_profit', 0):
|
|
pos['peak_profit'] = profit_pct
|
|
|
|
exit_reason = None
|
|
|
|
# 1. STOP-LOSS
|
|
if profit_pct <= -3.0:
|
|
exit_reason = "STOP_LOSS"
|
|
logger.warning(f'🛑 {pair}: STOP-LOSS triggered! {profit_pct:.2f}%')
|
|
positions_to_close.append((pair, current_qty, current_price, buy_price, buy_qty, profit_pct, exit_reason))
|
|
|
|
# 2. MAX HOLD TIME
|
|
elif hold_time_minutes >= 240:
|
|
exit_reason = "MAX_HOLD_TIMEOUT"
|
|
logger.info(f'⏱️ {pair}: MAX_HOLD_TIME reached! {hold_time_minutes:.0f} min')
|
|
positions_to_close.append((pair, current_qty, current_price, buy_price, buy_qty, profit_pct, exit_reason))
|
|
|
|
# 3. TAKE PROFIT
|
|
elif profit_pct >= 1.0:
|
|
exit_reason = "TAKE_PROFIT"
|
|
logger.info(f'💰 {pair}: TAKE_PROFIT reached! +{profit_pct:.2f}%')
|
|
positions_to_close.append((pair, current_qty, current_price, buy_price, buy_qty, profit_pct, exit_reason))
|
|
|
|
# 4. TRAILING STOP
|
|
elif pos['peak_profit'] >= 1.0:
|
|
trailing_stop_level = pos['peak_profit'] - 0.4
|
|
if profit_pct <= trailing_stop_level:
|
|
exit_reason = "TRAILING_STOP"
|
|
logger.info(f'📉 {pair}: TRAILING_STOP triggered! Peak: {pos["peak_profit"]:.2f}%, Current: {profit_pct:.2f}%')
|
|
positions_to_close.append((pair, current_qty, current_price, buy_price, buy_qty, profit_pct, exit_reason))
|
|
|
|
except Exception as e:
|
|
logger.warning(f'Check exit for {pair} failed: {e}')
|
|
continue
|
|
|
|
# Execute all closes
|
|
for pair, current_qty, current_price, buy_price, buy_qty, profit_pct, exit_reason in positions_to_close:
|
|
try:
|
|
# Format quantity BEFORE placing order (keep as STRING!)
|
|
if pair in self.symbol_info:
|
|
step_size = self.symbol_info[pair].get('stepSize', 1e-8)
|
|
qty_str = self.format_quantity(current_qty, step_size)
|
|
else:
|
|
qty_str = f'{current_qty:.8f}'
|
|
|
|
logger.info(f'📤 Placing EXIT order: {qty_str} {pair} (Reason: {exit_reason})')
|
|
result = await self.binance.place_order(
|
|
symbol=pair,
|
|
side='SELL',
|
|
quantity=qty_str, # Pass STRING
|
|
order_type='MARKET'
|
|
)
|
|
|
|
if result and result.get('status') in ['FILLED', 'NEW', 'PARTIALLY_FILLED']:
|
|
# ONLY record if order was actually EXECUTED
|
|
profit_usd = (current_qty * current_price) - (buy_qty * buy_price)
|
|
self.daily_pnl += profit_usd
|
|
self.total_pnl += profit_usd
|
|
self.total_trades += 1
|
|
|
|
if profit_pct >= 0:
|
|
self.wins_today += 1
|
|
icon = "✅"
|
|
else:
|
|
self.losses_today += 1
|
|
icon = "❌"
|
|
|
|
# Only send Telegram alert if PROFITABLE (profit_pct >= 0)
|
|
if profit_pct >= 0:
|
|
await self.telegram.send_alert(
|
|
f'{icon} CLOSED {exit_reason}\n'
|
|
f'{pair}: {current_qty:.8f} @ ${current_price:.2f}\n'
|
|
f'Profit: ${profit_usd:+.2f} ({profit_pct:+.2f}%)\n'
|
|
f'Hold: {(datetime.now() - datetime.fromisoformat(self.current_trades[pair]["buy_time"])).total_seconds() / 60:.0f} min'
|
|
)
|
|
else:
|
|
logger.info(f'Loss trade skipped from Telegram (visible on dashboard): {profit_pct:.2f}%')
|
|
|
|
if self.daily_pnl < self.min_daily_pnl:
|
|
self.min_daily_pnl = self.daily_pnl
|
|
if abs(self.min_daily_pnl) > self.max_drawdown:
|
|
self.max_drawdown = abs(self.min_daily_pnl)
|
|
|
|
self.error_count = 0
|
|
|
|
# Send to dashboard BEFORE deleting position (ONLY for REAL executed trades!)
|
|
hold_time_s = (datetime.now() - datetime.fromisoformat(self.current_trades[pair]["buy_time"])).total_seconds()
|
|
hold_time_min = hold_time_s / 60
|
|
await self.dashboard.record_sell(pair, current_qty, current_price, profit_usd, profit_pct, hold_time_min)
|
|
|
|
del self.current_trades[pair]
|
|
logger.info(f'✅ EXIT EXECUTED & RECORDED TO DASHBOARD!')
|
|
else:
|
|
logger.warning(f'❌ EXIT order FAILED or returned no result for {pair} - NOT recording')
|
|
|
|
except Exception as e:
|
|
logger.warning(f'Exit order failed for {pair}: {e}')
|
|
continue
|
|
|
|
except Exception as e:
|
|
logger.error(f'Take profit check error: {e}')
|
|
|
|
async def find_best_trade(self):
|
|
"""Scan multiple pairs for best signal"""
|
|
try:
|
|
best_signal = {'pair': None, 'signal': 'HOLD'}
|
|
|
|
for pair in self.pairs:
|
|
try:
|
|
# FIX: Robust price fetching
|
|
try:
|
|
price = await self.binance.get_ticker_price(pair)
|
|
if price is None or price <= 0:
|
|
logger.debug(f'⚠️ Invalid price for {pair}: {price}')
|
|
continue
|
|
except Exception as e:
|
|
logger.debug(f'{pair} price fetch failed: {e}')
|
|
continue
|
|
|
|
# Get signal
|
|
try:
|
|
signal = self.strategy.predict(price) if hasattr(self.strategy, 'predict') else 'BUY'
|
|
except Exception as e:
|
|
logger.debug(f'{pair} signal generation failed: {e}')
|
|
signal = 'HOLD'
|
|
|
|
if signal == 'BUY':
|
|
logger.info(f'🟢 BUY signal: {pair} at ${price:.2f}')
|
|
return {'pair': pair, 'price': price, 'signal': signal}
|
|
|
|
except Exception as e:
|
|
logger.debug(f'{pair} scan failed: {e}')
|
|
continue
|
|
|
|
return best_signal
|
|
|
|
except Exception as e:
|
|
logger.error(f'Find trade error: {e}')
|
|
return {'pair': None, 'signal': 'HOLD'}
|
|
|
|
async def monitor_trades(self):
|
|
"""Main trade monitoring with error handling"""
|
|
try:
|
|
self.reset_error_count()
|
|
|
|
if time.time() < self.error_cooldown_until:
|
|
logger.info('⏸️ ERROR COOLDOWN ACTIVE — pausing 5 minutes')
|
|
return
|
|
|
|
# 1. Check for SELL opportunities
|
|
await self.check_take_profit()
|
|
|
|
# 2. Periodically swap small holdings
|
|
await self.auto_swap_periodically()
|
|
|
|
# 3. Get balance
|
|
try:
|
|
balance = await self.binance.get_balance()
|
|
except Exception as e:
|
|
logger.error(f'Balance fetch failed: {e}')
|
|
self.increment_error_count()
|
|
return
|
|
|
|
usdt = float(balance.get('USDT', {}).get('free', 0)) if balance else 0
|
|
|
|
# DEBUG: Log FULL balance
|
|
logger.info(f'💰 Full Balance Breakdown:')
|
|
for asset, amounts in balance.items():
|
|
free = float(amounts.get('free', 0))
|
|
locked = float(amounts.get('locked', 0))
|
|
if free > 0 or locked > 0:
|
|
logger.info(f' {asset}: FREE={free:.8f}, LOCKED={locked:.8f}, TOTAL={free+locked:.8f}')
|
|
self.starting_capital = usdt
|
|
logger.info(f'📊 Starting capital set: ${self.starting_capital:.2f}')
|
|
|
|
logger.info(f'💰 Balance: {usdt:.2f} USDT | Daily P&L: ${self.daily_pnl:.2f}')
|
|
|
|
# 4. Check DAILY LOSS LIMIT
|
|
daily_loss_pct = (self.daily_pnl / self.starting_capital) * 100 if self.starting_capital > 0 else 0
|
|
|
|
if daily_loss_pct <= -5.0:
|
|
self.daily_loss_limit_reached = True
|
|
logger.warning(f'🛑 Daily loss limit reached! ({daily_loss_pct:.1f}%) Pausing.')
|
|
await self.telegram.send_alert(
|
|
f'🛑 Daily Loss Limit reached!\n'
|
|
f'P&L: ${self.daily_pnl:.2f} ({daily_loss_pct:.1f}%)\n'
|
|
f'Bot paused until Midnight UTC'
|
|
)
|
|
return
|
|
|
|
# Reset at midnight UTC
|
|
now = datetime.utcnow()
|
|
if now.date() > self.last_daily_reset:
|
|
self.daily_loss_limit_reached = False
|
|
self.daily_pnl = 0.0
|
|
self.min_daily_pnl = 0.0
|
|
self.trades_today = 0
|
|
self.wins_today = 0
|
|
self.losses_today = 0
|
|
self.last_daily_reset = now.date()
|
|
logger.info('🔄 Daily metrics reset at Midnight UTC')
|
|
await self.telegram.send_alert('🔄 Daily reset complete — trading resumed')
|
|
|
|
# 5. Find BUY signal
|
|
if self.daily_loss_limit_reached:
|
|
logger.info('⏸️ BUY orders paused (loss limit)')
|
|
return
|
|
|
|
try:
|
|
trade = await self.find_best_trade()
|
|
except Exception as e:
|
|
logger.error(f'Find trade error: {e}')
|
|
self.increment_error_count()
|
|
return
|
|
|
|
if trade['signal'] == 'BUY' and usdt >= 5:
|
|
pair = trade['pair']
|
|
price = trade['price']
|
|
|
|
# Use ALL available capital (not just 50%!) to maximize first trade
|
|
order_amount = usdt # Use 100% capital
|
|
qty = self.calculate_valid_quantity(pair, order_amount, price)
|
|
|
|
if qty <= 0:
|
|
logger.debug(f'⚠️ No valid quantity for {pair}')
|
|
return
|
|
|
|
notional = qty * price
|
|
logger.info(f'📈 Order: {qty:.8f} {pair} @ ${price:.2f} = ${notional:.2f}')
|
|
|
|
# SKIP if notional is too small (Binance minimum ~$5 for testing)
|
|
if notional < 5:
|
|
logger.warning(f'⏭️ SKIP {pair}: notional ${notional:.2f} < $5 minimum')
|
|
return
|
|
|
|
try:
|
|
# Format quantity BEFORE passing to API (keep as STRING!)
|
|
if pair in self.symbol_info:
|
|
step_size = self.symbol_info[pair].get('stepSize', 1e-8)
|
|
qty_str = self.format_quantity(qty, step_size) # STRING!
|
|
else:
|
|
qty_str = f'{qty:.8f}'
|
|
|
|
logger.info(f'📤 Placing BUY: {qty_str} {pair} @ ${price:.2f}')
|
|
try:
|
|
result = await self.binance.place_order(
|
|
symbol=pair,
|
|
side='BUY',
|
|
quantity=qty_str, # Pass STRING
|
|
order_type='MARKET'
|
|
)
|
|
logger.info(f'✅ Order result: {result}')
|
|
except Exception as e:
|
|
logger.error(f'❌ Order FAILED: {type(e).__name__}: {str(e)}')
|
|
result = None
|
|
|
|
# ONLY RECORD if order was SUCCESSFUL
|
|
if result and result.get('status') in ['FILLED', 'NEW', 'PARTIALLY_FILLED']:
|
|
logger.info(f'🔍 DEBUG: Storing BUY position {pair}: qty={qty}, price={price}')
|
|
self.current_trades[pair] = {
|
|
'qty': qty,
|
|
'buy_price': price,
|
|
'buy_time': datetime.now().isoformat(),
|
|
'peak_profit': 0.0,
|
|
'trailing_stop': None,
|
|
'order_id': result.get('orderId', 'unknown')
|
|
}
|
|
save_persistent_trades(self.current_trades, self.completed_trades, self.swaps)
|
|
logger.info(f'🔍 DEBUG: After storage, open_positions keys = {list(self.current_trades.keys())}')
|
|
|
|
self.trades_today += 1
|
|
self.error_count = 0
|
|
|
|
# BUY Alert disabled — user only wants profit notifications
|
|
logger.info(f'✅ BUY FILLED & RECORDED! Order ID: {result.get("orderId", "unknown")}')
|
|
|
|
# Send to dashboard
|
|
await self.dashboard.record_buy(pair, qty, price)
|
|
else:
|
|
# Order FAILED - do NOT record
|
|
logger.warning(f'❌ Order REJECTED or FAILED - NOT recording in open_positions')
|
|
|
|
except Exception as e:
|
|
logger.error(f'Trade execution failed: {e}')
|
|
if self.increment_error_count():
|
|
await self.telegram.send_alert('🛑 Too many errors! Bot paused 5 minutes')
|
|
|
|
except Exception as e:
|
|
logger.error(f'Monitor error: {e}')
|
|
self.increment_error_count()
|
|
|
|
async def send_performance_report(self):
|
|
"""Send detailed 3-hourly report"""
|
|
try:
|
|
self.report_count += 1
|
|
|
|
balance = await self.binance.get_balance()
|
|
usdt = float(balance.get('USDT', {}).get('free', 0)) if balance else 0
|
|
|
|
total_assets_usd = usdt
|
|
for asset in ['BTC', 'ETH', 'SOL', 'BNB', 'XRP']:
|
|
try:
|
|
qty = float(balance.get(asset, {}).get('free', 0)) if balance else 0
|
|
if qty > 0:
|
|
pair = f'{asset}USDT'
|
|
try:
|
|
price = await self.binance.get_ticker_price(pair)
|
|
if price and price > 0:
|
|
total_assets_usd += qty * price
|
|
except:
|
|
pass
|
|
except:
|
|
pass
|
|
|
|
real_win_rate = (self.wins_today / (self.wins_today + self.losses_today) * 100) if (self.wins_today + self.losses_today) > 0 else 0
|
|
avg_profit = (self.daily_pnl / (self.wins_today + self.losses_today)) if (self.wins_today + self.losses_today) > 0 else 0
|
|
daily_loss_pct = (self.daily_pnl / self.starting_capital) * 100 if self.starting_capital > 0 else 0
|
|
|
|
report = f'''📊 REPORT #{self.report_count}
|
|
|
|
💹 PORTFOLIO:
|
|
USDT: ${usdt:.2f} (CHF {usdt * 0.84:.2f})
|
|
Total Assets: ${total_assets_usd:.2f} (CHF {total_assets_usd * 0.84:.2f})
|
|
|
|
📈 TODAY'S PERFORMANCE:
|
|
Trades: {self.trades_today}
|
|
Wins: {self.wins_today} | Losses: {self.losses_today}
|
|
|
|
📊 REAL METRICS:
|
|
Win Rate: {real_win_rate:.1f}%
|
|
Avg P/L per Trade: ${avg_profit:+.2f} (CHF {avg_profit * 0.84:+.2f})
|
|
Daily P&L: ${self.daily_pnl:+.2f} (CHF {self.daily_pnl * 0.84:+.2f}) ({daily_loss_pct:+.1f}%)
|
|
Max Drawdown: ${self.max_drawdown:.2f} (CHF {self.max_drawdown * 0.84:.2f})
|
|
|
|
🤖 BOT STATUS: {'🟢 RUNNING' if time.time() >= self.error_cooldown_until else '🟡 ERROR_COOLDOWN'}
|
|
Open Positions: {len(self.current_trades)}
|
|
Error Count: {self.error_count}/{self.error_threshold}'''
|
|
|
|
logger.info(report)
|
|
await self.telegram.send_alert(report)
|
|
|
|
# Send all metrics to dashboard
|
|
balance = await self.binance.get_balance()
|
|
usdt = float(balance.get('USDT', {}).get('free', 0)) if balance else 0
|
|
|
|
total_assets_usd = usdt
|
|
for asset in ['BTC', 'ETH', 'SOL', 'BNB', 'XRP']:
|
|
try:
|
|
qty = float(balance.get(asset, {}).get('free', 0)) if balance else 0
|
|
if qty > 0:
|
|
pair = f'{asset}USDT'
|
|
price = await self.binance.get_ticker_price(pair)
|
|
if price and price > 0:
|
|
total_assets_usd += qty * price
|
|
except:
|
|
pass
|
|
|
|
await self.dashboard.update_state(
|
|
balance={'USDT': usdt},
|
|
daily_pnl=self.daily_pnl,
|
|
total_pnl=self.total_pnl,
|
|
trades_today=self.trades_today,
|
|
wins_today=self.wins_today,
|
|
losses_today=self.losses_today,
|
|
portfolio_value_usd=total_assets_usd
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f'Report error: {e}')
|
|
|
|
async def cancel_all_open_orders(self):
|
|
"""Cancel ALL open orders to free up locked capital"""
|
|
try:
|
|
logger.info('🗑️ CANCELLING ALL OPEN ORDERS...')
|
|
|
|
# Get all open orders
|
|
open_orders = await self.binance._get('openOrders')
|
|
|
|
if not open_orders:
|
|
logger.info('✅ No open orders to cancel')
|
|
return True
|
|
|
|
logger.warning(f'⚠️ Found {len(open_orders)} open orders!')
|
|
|
|
cancelled_count = 0
|
|
for order in open_orders:
|
|
try:
|
|
symbol = order.get('symbol')
|
|
order_id = order.get('orderId')
|
|
side = order.get('side')
|
|
qty = order.get('origQty')
|
|
|
|
logger.warning(f' Cancelling: {symbol} {side} {qty} (Order {order_id})')
|
|
|
|
result = await self.binance._delete(
|
|
'order',
|
|
True,
|
|
symbol=symbol,
|
|
orderId=order_id
|
|
)
|
|
|
|
cancelled_count += 1
|
|
logger.info(f' ✅ Cancelled: {symbol} {order_id}')
|
|
|
|
except Exception as e:
|
|
logger.error(f' ❌ Failed to cancel {symbol} {order_id}: {e}')
|
|
continue
|
|
|
|
logger.info(f'✅ CANCELLATION COMPLETE: {cancelled_count}/{len(open_orders)} orders cancelled')
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f'❌ Failed to cancel orders: {e}')
|
|
return False
|
|
|
|
async def run(self):
|
|
"""Main bot loop"""
|
|
logger.info('🤖 BOT STARTED (V5 - SUSTAINABLE)')
|
|
|
|
# Create trigger file location
|
|
trigger_file = '/tmp/bot_liquidate_trigger'
|
|
|
|
# FIRST: Cancel all pending orders to free capital
|
|
await self.cancel_all_open_orders()
|
|
|
|
# SECOND: Load exchange info BEFORE liquidation
|
|
await self.load_exchange_info()
|
|
|
|
# THIRD: ONE-TIME LIQUIDATE BTC TO USDT IF NEEDED
|
|
await self.liquidate_btc_to_usdt_on_startup()
|
|
logger.info('🗑️ RESET: Clearing dashboard cache...')
|
|
try:
|
|
await self.dashboard.clear_state()
|
|
except:
|
|
pass
|
|
|
|
await self.telegram.send_alert(
|
|
'🤖 BOT V5 SUSTAINABLE STARTED\n'
|
|
'✅ All Bugs Fixed:\n'
|
|
' • Price fetching robust\n'
|
|
' • SWAP errors handled\n'
|
|
' • BUY orders executing\n'
|
|
' • EXIT orders scheduled\n'
|
|
' • Error resilience active'
|
|
)
|
|
|
|
while True:
|
|
try:
|
|
# CHECK FOR LIQUIDATION TRIGGER FILE (every cycle)
|
|
trigger_file = '/tmp/bot_liquidate_trigger'
|
|
trigger_exists = os.path.exists(trigger_file)
|
|
logger.info(f'🔍 Checking trigger file: exists={trigger_exists}') # DEBUG
|
|
|
|
if trigger_exists:
|
|
logger.warning(f'🔥🔥🔥 LIQUIDATION TRIGGER DETECTED! File exists at: {trigger_file}')
|
|
try:
|
|
os.remove(trigger_file)
|
|
logger.warning(f'🔥 Removed trigger file')
|
|
except Exception as e:
|
|
logger.error(f'Could not remove trigger: {e}')
|
|
result = await self.force_liquidate_all()
|
|
logger.warning(f'🔥 Force liquidation result: {result}')
|
|
await asyncio.sleep(2)
|
|
|
|
current_time = time.time()
|
|
|
|
# KONTINUIERLICH: Update dashboard mit aktueller Balance (every cycle!)
|
|
try:
|
|
balance = await self.binance.get_balance()
|
|
usdt_live = float(balance.get('USDT', {}).get('free', 0)) if balance else 0
|
|
|
|
# Calculate portfolio value with REAL prices from symbol_info
|
|
portfolio_value_usd = usdt_live # Start with USDT
|
|
|
|
for pair in self.pairs:
|
|
asset = pair.replace('USDT', '')
|
|
if asset in balance:
|
|
qty = float(balance[asset].get('free', 0))
|
|
if qty > 0 and pair in self.symbol_info:
|
|
# Use current price from symbol_info or last known
|
|
try:
|
|
current_price = await self.binance.get_ticker_price(pair)
|
|
if current_price and current_price > 0:
|
|
portfolio_value_usd += qty * current_price
|
|
except:
|
|
pass
|
|
|
|
logger.info(f'✅ Sending to dashboard: USDT={usdt_live:.2f}, portfolio={portfolio_value_usd:.2f}, trades_today={self.trades_today}, open_trades={len(self.current_trades)}')
|
|
|
|
# SYNC open_positions with dashboard
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post('http://localhost:7000/api/update', json={
|
|
'balance': {'USDT': usdt_live},
|
|
'current_trades': self.current_trades, # Send ALL open positions!
|
|
'daily_pnl': self.daily_pnl,
|
|
'total_pnl': self.total_pnl,
|
|
'trades_today': self.trades_today,
|
|
'wins_today': self.wins_today,
|
|
'losses_today': self.losses_today,
|
|
'portfolio_value_usd': portfolio_value_usd,
|
|
'portfolio_value_chf': portfolio_value_usd * 0.84,
|
|
'last_update': datetime.now().isoformat()
|
|
}) as resp:
|
|
if resp.status == 200:
|
|
logger.debug('✅ Dashboard state synced')
|
|
else:
|
|
logger.warning(f'Dashboard sync failed: {resp.status}')
|
|
|
|
# Keep the old update_state call for compatibility
|
|
await self.dashboard.update_state(
|
|
balance={'USDT': usdt_live},
|
|
daily_pnl=self.daily_pnl,
|
|
portfolio_value_usd=portfolio_value_usd,
|
|
total_pnl=self.total_pnl,
|
|
trades_today=self.trades_today,
|
|
wins_today=self.wins_today,
|
|
losses_today=self.losses_today
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f'⚠️ Dashboard update error: {e}')
|
|
|
|
now = datetime.now()
|
|
should_report = (now.hour in [22, 1, 4, 7, 10, 13, 16, 19]) and now.minute == 0
|
|
|
|
if should_report and (current_time - self.last_report_time) > 60:
|
|
await self.send_performance_report()
|
|
self.last_report_time = current_time
|
|
|
|
await self.monitor_trades()
|
|
await asyncio.sleep(10) # Check every 10 seconds for trigger and trading signals
|
|
|
|
except Exception as e:
|
|
logger.error(f'Main loop error: {e}')
|
|
await asyncio.sleep(60)
|
|
|
|
async def main():
|
|
logger.info('▶️ MAIN STARTUP')
|
|
try:
|
|
config = get_config()
|
|
logger.info(f'✅ Config loaded')
|
|
|
|
try:
|
|
dashboard = DashboardClient()
|
|
logger.info(f'✅ Dashboard client initialized')
|
|
except Exception as e:
|
|
logger.error(f'❌ Dashboard init failed: {e}')
|
|
dashboard = None
|
|
|
|
binance = BinanceClientWrapper(
|
|
api_key=config.binance_api_key_live,
|
|
api_secret=config.binance_api_secret_live,
|
|
testnet=False
|
|
)
|
|
logger.info(f'✅ Binance client initialized')
|
|
|
|
telegram = TelegramNotifier(bot_token=config.telegram_bot_token, chat_id=config.telegram_chat_id)
|
|
obsidian = ObsidianLogger(vault_path=config.obsidian_vault_path, trade_log_file=config.obsidian_trade_log_file)
|
|
|
|
model = joblib.load(config.model_path) if hasattr(config, 'model_path') else None
|
|
scaler = None
|
|
|
|
logger.info(f'✅ BOT CREATING...')
|
|
bot = MLTradingBot(config, binance, telegram, obsidian, model, scaler)
|
|
|
|
logger.info(f'✅ BOT CREATED. STARTING RUN()...')
|
|
await bot.run()
|
|
except Exception as e:
|
|
logger.critical(f'❌ FATAL ERROR IN MAIN: {e}', exc_info=True)
|
|
|
|
if __name__ == '__main__':
|
|
asyncio.run(main())
|
|
# Version marker: Auto-sync test Sat Jul 4 10:37:55 UTC 2026
|