BrainDock/src/main_ml.py

1052 lines
48 KiB
Python

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.open_positions = {} # 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.open_positions:
logger.debug(f'⏭️ {pair} not in open_positions (keys: {list(self.open_positions.keys())})')
continue
pos = self.open_positions[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.open_positions[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.open_positions[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.open_positions[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.open_positions[pair] = {
'qty': qty,
'buy_price': price,
'buy_time': datetime.now().isoformat(),
'peak_profit': 0.0,
'trailing_stop': None,
'order_id': result.get('orderId', 'unknown')
}
logger.info(f'🔍 DEBUG: After storage, open_positions keys = {list(self.open_positions.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.open_positions)}
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.open_positions)}')
# 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.open_positions, # 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