|
@@ -9,8 +9,10 @@ with comprehensive statistics tracking and phone-friendly controls.
|
|
|
import logging
|
|
|
import asyncio
|
|
|
import re
|
|
|
+import json
|
|
|
+import os
|
|
|
from datetime import datetime, timedelta
|
|
|
-from typing import Optional, Dict, Any
|
|
|
+from typing import Optional, Dict, Any, List, Tuple
|
|
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
|
|
|
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes, MessageHandler, filters
|
|
|
from hyperliquid_client import HyperliquidClient
|
|
@@ -39,6 +41,9 @@ class TelegramTradingBot:
|
|
|
self.stats = None
|
|
|
self.version = "Unknown" # Will be set by launcher
|
|
|
|
|
|
+ # Bot state persistence file
|
|
|
+ self.bot_state_file = "bot_state.json"
|
|
|
+
|
|
|
# Order monitoring attributes
|
|
|
self.monitoring_active = False
|
|
|
self.last_known_orders = set() # Track order IDs we've seen
|
|
@@ -57,9 +62,77 @@ class TelegramTradingBot:
|
|
|
# Pending stop loss storage
|
|
|
self.pending_stop_losses = {} # Format: {order_id: {'token': str, 'stop_price': float, 'side': str, 'amount': float, 'order_type': str}}
|
|
|
|
|
|
- # Initialize stats
|
|
|
+ # Load bot state first, then initialize stats
|
|
|
+ self._load_bot_state()
|
|
|
self._initialize_stats()
|
|
|
|
|
|
+ def _load_bot_state(self):
|
|
|
+ """Load bot state from disk."""
|
|
|
+ try:
|
|
|
+ if os.path.exists(self.bot_state_file):
|
|
|
+ with open(self.bot_state_file, 'r') as f:
|
|
|
+ state_data = json.load(f)
|
|
|
+
|
|
|
+ # Restore critical state
|
|
|
+ self.pending_stop_losses = state_data.get('pending_stop_losses', {})
|
|
|
+ self.last_known_orders = set(state_data.get('last_known_orders', []))
|
|
|
+ self.last_known_positions = state_data.get('last_known_positions', {})
|
|
|
+
|
|
|
+ # Restore timestamps (convert from ISO string if present)
|
|
|
+ last_trade_time = state_data.get('last_processed_trade_time')
|
|
|
+ if last_trade_time:
|
|
|
+ try:
|
|
|
+ self.last_processed_trade_time = datetime.fromisoformat(last_trade_time)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ self.last_processed_trade_time = None
|
|
|
+
|
|
|
+ last_deposit_check = state_data.get('last_deposit_withdrawal_check')
|
|
|
+ if last_deposit_check:
|
|
|
+ try:
|
|
|
+ self.last_deposit_withdrawal_check = datetime.fromisoformat(last_deposit_check)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ self.last_deposit_withdrawal_check = None
|
|
|
+
|
|
|
+ logger.info(f"🔄 Restored bot state: {len(self.pending_stop_losses)} pending stop losses, {len(self.last_known_orders)} tracked orders")
|
|
|
+
|
|
|
+ # Log details about restored pending stop losses
|
|
|
+ if self.pending_stop_losses:
|
|
|
+ for order_id, stop_loss_info in self.pending_stop_losses.items():
|
|
|
+ token = stop_loss_info.get('token', 'Unknown')
|
|
|
+ stop_price = stop_loss_info.get('stop_price', 0)
|
|
|
+ order_type = stop_loss_info.get('order_type', 'Unknown')
|
|
|
+ logger.info(f"📋 Restored pending stop loss: {order_id} -> {token} {order_type} @ ${stop_price}")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"❌ Error loading bot state: {e}")
|
|
|
+ # Initialize with defaults
|
|
|
+ self.pending_stop_losses = {}
|
|
|
+ self.last_known_orders = set()
|
|
|
+ self.last_known_positions = {}
|
|
|
+ self.last_processed_trade_time = None
|
|
|
+ self.last_deposit_withdrawal_check = None
|
|
|
+
|
|
|
+ def _save_bot_state(self):
|
|
|
+ """Save bot state to disk."""
|
|
|
+ try:
|
|
|
+ state_data = {
|
|
|
+ 'pending_stop_losses': self.pending_stop_losses,
|
|
|
+ 'last_known_orders': list(self.last_known_orders), # Convert set to list for JSON
|
|
|
+ 'last_known_positions': self.last_known_positions,
|
|
|
+ 'last_processed_trade_time': self.last_processed_trade_time.isoformat() if self.last_processed_trade_time else None,
|
|
|
+ 'last_deposit_withdrawal_check': self.last_deposit_withdrawal_check.isoformat() if self.last_deposit_withdrawal_check else None,
|
|
|
+ 'last_updated': datetime.now().isoformat(),
|
|
|
+ 'version': self.version
|
|
|
+ }
|
|
|
+
|
|
|
+ with open(self.bot_state_file, 'w') as f:
|
|
|
+ json.dump(state_data, f, indent=2, default=str)
|
|
|
+
|
|
|
+ logger.debug(f"💾 Saved bot state: {len(self.pending_stop_losses)} pending stop losses")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"❌ Error saving bot state: {e}")
|
|
|
+
|
|
|
def _initialize_stats(self):
|
|
|
"""Initialize stats with current balance."""
|
|
|
try:
|
|
@@ -105,7 +178,7 @@ class TelegramTradingBot:
|
|
|
for row in rows:
|
|
|
commands = [cmd.strip() for cmd in row.split(',') if cmd.strip()]
|
|
|
if commands:
|
|
|
- keyboard.append([KeyboardButton(cmd) for cmd in commands])
|
|
|
+ keyboard.append([KeyboardButton(cmd.lstrip('/').capitalize()) for cmd in commands])
|
|
|
|
|
|
if keyboard:
|
|
|
return ReplyKeyboardMarkup(
|
|
@@ -960,6 +1033,7 @@ Tap any button below for instant access to bot functions:
|
|
|
'original_order_id': order_id,
|
|
|
'is_limit': is_limit
|
|
|
}
|
|
|
+ self._save_bot_state() # Save state after adding pending stop loss
|
|
|
logger.info(f"💾 Saved pending stop loss for order {order_id}: sell {token_amount:.6f} {token} @ ${stop_loss_price}")
|
|
|
|
|
|
success_message = f"""
|
|
@@ -1046,6 +1120,7 @@ Tap any button below for instant access to bot functions:
|
|
|
'original_order_id': order_id,
|
|
|
'is_limit': is_limit
|
|
|
}
|
|
|
+ self._save_bot_state() # Save state after adding pending stop loss
|
|
|
logger.info(f"💾 Saved pending stop loss for order {order_id}: buy {token_amount:.6f} {token} @ ${stop_loss_price}")
|
|
|
|
|
|
success_message = f"""
|
|
@@ -1313,6 +1388,31 @@ Tap any button below for instant access to bot functions:
|
|
|
await query.edit_message_text(error_message)
|
|
|
logger.error(f"Error setting take profit: {e}")
|
|
|
|
|
|
+ async def handle_keyboard_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
|
+ """Handle messages from custom keyboard buttons (without /)."""
|
|
|
+ if not self.is_authorized(update.effective_chat.id):
|
|
|
+ return
|
|
|
+
|
|
|
+ message_text = update.message.text.lower()
|
|
|
+
|
|
|
+ # Map clean button text to command handlers
|
|
|
+ command_handlers = {
|
|
|
+ 'daily': self.daily_command,
|
|
|
+ 'performance': self.performance_command,
|
|
|
+ 'balance': self.balance_command,
|
|
|
+ 'stats': self.stats_command,
|
|
|
+ 'positions': self.positions_command,
|
|
|
+ 'orders': self.orders_command,
|
|
|
+ 'price': self.price_command,
|
|
|
+ 'market': self.market_command,
|
|
|
+ 'help': self.help_command,
|
|
|
+ 'commands': self.commands_command
|
|
|
+ }
|
|
|
+
|
|
|
+ # Execute the corresponding command handler
|
|
|
+ if message_text in command_handlers:
|
|
|
+ await command_handlers[message_text](update, context)
|
|
|
+
|
|
|
async def unknown_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
|
"""Handle unknown commands."""
|
|
|
if not self.is_authorized(update.effective_chat.id):
|
|
@@ -1356,10 +1456,14 @@ Tap any button below for instant access to bot functions:
|
|
|
self.application.add_handler(CommandHandler("risk", self.risk_command))
|
|
|
self.application.add_handler(CommandHandler("version", self.version_command))
|
|
|
self.application.add_handler(CommandHandler("balance_adjustments", self.balance_adjustments_command))
|
|
|
+ self.application.add_handler(CommandHandler("keyboard", self.keyboard_command))
|
|
|
|
|
|
# Callback query handler for inline keyboards
|
|
|
self.application.add_handler(CallbackQueryHandler(self.button_callback))
|
|
|
|
|
|
+ # Handle clean keyboard button messages (without /)
|
|
|
+ self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_keyboard_message))
|
|
|
+
|
|
|
# Handle unknown commands
|
|
|
self.application.add_handler(MessageHandler(filters.COMMAND, self.unknown_command))
|
|
|
|
|
@@ -2240,6 +2344,9 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
self.last_known_orders = current_order_ids
|
|
|
await self._update_position_tracking(current_positions)
|
|
|
|
|
|
+ # Save state after updating tracking data
|
|
|
+ self._save_bot_state()
|
|
|
+
|
|
|
# Check price alarms
|
|
|
await self._check_price_alarms()
|
|
|
|
|
@@ -2262,6 +2369,7 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
async def _process_pending_stop_losses(self, filled_order_ids: set):
|
|
|
"""Process pending stop losses for filled orders."""
|
|
|
try:
|
|
|
+ processed_any = False
|
|
|
for order_id in filled_order_ids:
|
|
|
if order_id in self.pending_stop_losses:
|
|
|
stop_loss_info = self.pending_stop_losses[order_id]
|
|
@@ -2271,7 +2379,12 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
|
|
|
# Remove from pending after processing
|
|
|
del self.pending_stop_losses[order_id]
|
|
|
+ processed_any = True
|
|
|
logger.info(f"🗑️ Removed processed stop loss for order {order_id}")
|
|
|
+
|
|
|
+ # Save state if any stop losses were processed
|
|
|
+ if processed_any:
|
|
|
+ self._save_bot_state()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ Error processing pending stop losses: {e}")
|
|
@@ -2338,6 +2451,10 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
# Remove from pending
|
|
|
del self.pending_stop_losses[order_id]
|
|
|
logger.info(f"🗑️ Removed pending stop loss for cancelled order {order_id}")
|
|
|
+
|
|
|
+ # Save state if any stop losses were removed
|
|
|
+ if orders_to_remove:
|
|
|
+ self._save_bot_state()
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ Error cleaning up cancelled stop losses: {e}")
|
|
@@ -2527,6 +2644,9 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
# Update last processed time
|
|
|
self.last_processed_trade_time = latest_trade_time
|
|
|
|
|
|
+ # Save state after updating last processed time
|
|
|
+ self._save_bot_state()
|
|
|
+
|
|
|
if new_trades:
|
|
|
logger.info(f"📊 Processed {len(new_trades)} external trades")
|
|
|
|
|
@@ -2615,6 +2735,9 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
# Update last check time
|
|
|
self.last_deposit_withdrawal_check = current_time
|
|
|
|
|
|
+ # Save state after updating last check time
|
|
|
+ self._save_bot_state()
|
|
|
+
|
|
|
if new_deposits > 0 or new_withdrawals > 0:
|
|
|
logger.info(f"💰 Processed {new_deposits} deposits and {new_withdrawals} withdrawals")
|
|
|
|
|
@@ -2989,6 +3112,14 @@ This will place a limit {exit_side} order at ${profit_price:,.2f} to capture pro
|
|
|
• 🛑 Automatic Stop Loss Triggers
|
|
|
• 🛑 Order-based Stop Loss Placement
|
|
|
|
|
|
+💾 <b>Bot State Persistence:</b>
|
|
|
+• Pending Stop Losses: Saved to disk
|
|
|
+• Order Tracking: Saved to disk
|
|
|
+• External Trade Times: Saved to disk
|
|
|
+• Deposit Check Times: Saved to disk
|
|
|
+• State File: bot_state.json
|
|
|
+• Survives Bot Restarts: ✅ Yes
|
|
|
+
|
|
|
⏰ <b>Last Check:</b> {datetime.now().strftime('%H:%M:%S')}
|
|
|
|
|
|
💡 <b>Monitoring Features:</b>
|
|
@@ -4136,6 +4267,72 @@ Will trigger when {token} price moves {alarm['direction']} ${target_price:,.2f}
|
|
|
|
|
|
self.last_known_positions = new_position_map
|
|
|
|
|
|
+ async def keyboard_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
|
+ """Handle the /keyboard command to enable/show custom keyboard."""
|
|
|
+ if not self.is_authorized(update.effective_chat.id):
|
|
|
+ await update.message.reply_text("❌ Unauthorized access.")
|
|
|
+ return
|
|
|
+
|
|
|
+ custom_keyboard = self._create_custom_keyboard()
|
|
|
+
|
|
|
+ if custom_keyboard:
|
|
|
+ await update.message.reply_text(
|
|
|
+ "⌨️ <b>Custom Keyboard Activated!</b>\n\n"
|
|
|
+ "🎯 <b>Your quick buttons are now ready:</b>\n"
|
|
|
+ "• Daily - Daily performance\n"
|
|
|
+ "• Performance - Performance stats\n"
|
|
|
+ "• Balance - Account balance\n"
|
|
|
+ "• Stats - Trading statistics\n"
|
|
|
+ "• Positions - Open positions\n"
|
|
|
+ "• Orders - Active orders\n"
|
|
|
+ "• Price - Quick price check\n"
|
|
|
+ "• Market - Market overview\n"
|
|
|
+ "• Help - Help guide\n"
|
|
|
+ "• Commands - Command menu\n\n"
|
|
|
+ "💡 <b>How to use:</b>\n"
|
|
|
+ "Tap any button below instead of typing the command manually!\n\n"
|
|
|
+ "🔧 These buttons will stay at the bottom of your chat.",
|
|
|
+ parse_mode='HTML',
|
|
|
+ reply_markup=custom_keyboard
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ await update.message.reply_text(
|
|
|
+ "❌ <b>Custom Keyboard Disabled</b>\n\n"
|
|
|
+ "🔧 <b>To enable:</b>\n"
|
|
|
+ "• Set TELEGRAM_CUSTOM_KEYBOARD_ENABLED=true in your .env file\n"
|
|
|
+ "• Restart the bot\n"
|
|
|
+ "• Run /keyboard again\n\n"
|
|
|
+ f"📋 <b>Current config:</b>\n"
|
|
|
+ f"• Enabled: {Config.TELEGRAM_CUSTOM_KEYBOARD_ENABLED}\n"
|
|
|
+ f"• Layout: {Config.TELEGRAM_CUSTOM_KEYBOARD_LAYOUT}",
|
|
|
+ parse_mode='HTML'
|
|
|
+ )
|
|
|
+
|
|
|
+ async def handle_keyboard_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|
|
+ """Handle messages from custom keyboard buttons (without /)."""
|
|
|
+ if not self.is_authorized(update.effective_chat.id):
|
|
|
+ return
|
|
|
+
|
|
|
+ message_text = update.message.text.lower()
|
|
|
+
|
|
|
+ # Map clean button text to command handlers
|
|
|
+ command_handlers = {
|
|
|
+ 'daily': self.daily_command,
|
|
|
+ 'performance': self.performance_command,
|
|
|
+ 'balance': self.balance_command,
|
|
|
+ 'stats': self.stats_command,
|
|
|
+ 'positions': self.positions_command,
|
|
|
+ 'orders': self.orders_command,
|
|
|
+ 'price': self.price_command,
|
|
|
+ 'market': self.market_command,
|
|
|
+ 'help': self.help_command,
|
|
|
+ 'commands': self.commands_command
|
|
|
+ }
|
|
|
+
|
|
|
+ # Execute the corresponding command handler
|
|
|
+ if message_text in command_handlers:
|
|
|
+ await command_handlers[message_text](update, context)
|
|
|
+
|
|
|
|
|
|
async def main_async():
|
|
|
"""Async main entry point for the Telegram bot."""
|