|
@@ -18,11 +18,16 @@ logger = logging.getLogger(__name__)
|
|
|
class MarketMonitor:
|
|
|
"""Handles external trade monitoring and market events."""
|
|
|
|
|
|
- def __init__(self, trading_engine):
|
|
|
+ def __init__(self, trading_engine, notification_manager=None):
|
|
|
"""Initialize the market monitor."""
|
|
|
self.trading_engine = trading_engine
|
|
|
- self.is_running = False
|
|
|
- self._monitor_task = None
|
|
|
+ self.notification_manager = notification_manager
|
|
|
+ self.client = trading_engine.client
|
|
|
+ self._last_processed_trade_time = datetime.now(timezone.utc) - timedelta(seconds=120)
|
|
|
+ self._monitoring_active = False
|
|
|
+
|
|
|
+ # 🆕 External stop loss tracking
|
|
|
+ self._external_stop_loss_orders = {} # Format: {exchange_order_id: {'token': str, 'trigger_price': float, 'side': str, 'detected_at': datetime}}
|
|
|
|
|
|
# External trade monitoring
|
|
|
# self.state_file = "data/market_monitor_state.json" # Removed, state now in DB
|
|
@@ -35,21 +40,14 @@ class MarketMonitor:
|
|
|
self.last_known_orders = set()
|
|
|
self.last_known_positions = {}
|
|
|
|
|
|
- # Notification manager (will be set by the core bot)
|
|
|
- self.notification_manager = None
|
|
|
-
|
|
|
self._load_state()
|
|
|
|
|
|
- def set_notification_manager(self, notification_manager):
|
|
|
- """Set the notification manager for sending alerts."""
|
|
|
- self.notification_manager = notification_manager
|
|
|
-
|
|
|
async def start(self):
|
|
|
"""Start the market monitor."""
|
|
|
- if self.is_running:
|
|
|
+ if self._monitoring_active:
|
|
|
return
|
|
|
|
|
|
- self.is_running = True
|
|
|
+ self._monitoring_active = True
|
|
|
logger.info("🔄 Market monitor started")
|
|
|
|
|
|
# Initialize tracking
|
|
@@ -60,10 +58,10 @@ class MarketMonitor:
|
|
|
|
|
|
async def stop(self):
|
|
|
"""Stop the market monitor."""
|
|
|
- if not self.is_running:
|
|
|
+ if not self._monitoring_active:
|
|
|
return
|
|
|
|
|
|
- self.is_running = False
|
|
|
+ self._monitoring_active = False
|
|
|
|
|
|
if self._monitor_task:
|
|
|
self._monitor_task.cancel()
|
|
@@ -157,17 +155,19 @@ class MarketMonitor:
|
|
|
"""Main monitoring loop that runs every BOT_HEARTBEAT_SECONDS."""
|
|
|
try:
|
|
|
loop_count = 0
|
|
|
- while self.is_running:
|
|
|
+ while self._monitoring_active:
|
|
|
await self._check_order_fills()
|
|
|
await self._check_price_alarms()
|
|
|
await self._check_external_trades()
|
|
|
await self._check_pending_triggers()
|
|
|
await self._check_automatic_risk_management()
|
|
|
+ await self._check_external_stop_loss_orders()
|
|
|
|
|
|
# Run orphaned stop loss cleanup every 10 heartbeats (less frequent but regular)
|
|
|
loop_count += 1
|
|
|
if loop_count % 10 == 0:
|
|
|
await self._cleanup_orphaned_stop_losses()
|
|
|
+ await self._cleanup_external_stop_loss_tracking()
|
|
|
loop_count = 0 # Reset counter to prevent overflow
|
|
|
|
|
|
await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)
|
|
@@ -177,7 +177,7 @@ class MarketMonitor:
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error in market monitor loop: {e}")
|
|
|
# Restart after error
|
|
|
- if self.is_running:
|
|
|
+ if self._monitoring_active:
|
|
|
await asyncio.sleep(5)
|
|
|
await self._monitor_loop()
|
|
|
|
|
@@ -244,7 +244,7 @@ class MarketMonitor:
|
|
|
|
|
|
if not old_data:
|
|
|
# New position opened
|
|
|
- logger.info(f"📈 New position detected (observed by MarketMonitor): {symbol} {new_data['contracts']} @ ${new_data['entry_price']}. TradingStats is the definitive source.")
|
|
|
+ logger.info(f"📈 New position detected (observed by MarketMonitor): {symbol} {new_data['contracts']} @ ${new_data['entry_price']:.4f}. TradingStats is the definitive source.")
|
|
|
elif abs(new_data['contracts'] - old_data['contracts']) > 0.000001:
|
|
|
# Position size changed
|
|
|
change = new_data['contracts'] - old_data['contracts']
|
|
@@ -507,59 +507,142 @@ class MarketMonitor:
|
|
|
# Look for Hyperliquid order ID in the raw response
|
|
|
exchange_order_id_from_fill = fill['info'].get('oid')
|
|
|
|
|
|
- logger.info(f"🔍 Processing external trade: {trade_id} - {side} {amount} {full_symbol} @ ${price:.2f}")
|
|
|
+ # 🆕 Check if this fill corresponds to an external stop loss order
|
|
|
+ is_external_stop_loss = False
|
|
|
+ stop_loss_info = None
|
|
|
+ if exchange_order_id_from_fill and exchange_order_id_from_fill in self._external_stop_loss_orders:
|
|
|
+ is_external_stop_loss = True
|
|
|
+ stop_loss_info = self._external_stop_loss_orders[exchange_order_id_from_fill]
|
|
|
+ logger.info(f"🛑 EXTERNAL STOP LOSS EXECUTION: {token} - Order {exchange_order_id_from_fill} filled @ ${price:.2f}")
|
|
|
+
|
|
|
+ logger.info(f"🔍 Processing {'external stop loss' if is_external_stop_loss else 'external trade'}: {trade_id} - {side} {amount} {full_symbol} @ ${price:.2f}")
|
|
|
|
|
|
stats = self.trading_engine.stats
|
|
|
- if stats:
|
|
|
- linked_order_db_id = None
|
|
|
- if exchange_order_id_from_fill:
|
|
|
- order_in_db = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
|
|
|
- if order_in_db:
|
|
|
- linked_order_db_id = order_in_db['id']
|
|
|
- logger.info(f"🔗 Linking fill {trade_id} to order DB ID {linked_order_db_id} (Exchange Order ID: {exchange_order_id_from_fill})")
|
|
|
- # Update the order status and amount filled
|
|
|
- new_status_after_fill = order_in_db['status'] # Default to current
|
|
|
- current_filled = order_in_db.get('amount_filled', 0.0)
|
|
|
- requested_amount = order_in_db.get('amount_requested', 0.0)
|
|
|
-
|
|
|
- if abs((current_filled + amount) - requested_amount) < 1e-9: # Comparing floats
|
|
|
- new_status_after_fill = 'filled'
|
|
|
- elif (current_filled + amount) < requested_amount:
|
|
|
- new_status_after_fill = 'partially_filled'
|
|
|
- else: # Overfilled? Or issue with amounts. Log a warning.
|
|
|
- logger.warning(f"Order {linked_order_db_id} might be overfilled. Current: {current_filled}, Fill: {amount}, Requested: {requested_amount}")
|
|
|
- new_status_after_fill = 'filled' # Assume filled for now if it exceeds
|
|
|
-
|
|
|
- stats.update_order_status(order_db_id=linked_order_db_id,
|
|
|
- new_status=new_status_after_fill,
|
|
|
- amount_filled_increment=amount)
|
|
|
-
|
|
|
- # Check if this order is now fully filled and has pending stop losses to activate
|
|
|
- if new_status_after_fill == 'filled':
|
|
|
- await self._activate_pending_stop_losses(order_in_db, stats)
|
|
|
-
|
|
|
- # Record the trade in stats with enhanced tracking
|
|
|
+ if not stats:
|
|
|
+ logger.warning("⚠️ TradingStats not available in _check_external_trades.")
|
|
|
+ continue
|
|
|
+
|
|
|
+ # If this is an external stop loss execution, handle it specially
|
|
|
+ if is_external_stop_loss and stop_loss_info:
|
|
|
+ # Record the trade with enhanced tracking but mark it as stop loss execution
|
|
|
action_type = stats.record_trade_with_enhanced_tracking(
|
|
|
full_symbol, side, amount, price,
|
|
|
- exchange_fill_id=trade_id, trade_type="external",
|
|
|
+ exchange_fill_id=trade_id, trade_type="external_stop_loss",
|
|
|
timestamp=timestamp_dt.isoformat(),
|
|
|
- linked_order_table_id_to_link=linked_order_db_id
|
|
|
+ linked_order_table_id_to_link=None # External stop losses don't link to bot orders
|
|
|
)
|
|
|
|
|
|
- # Track symbol for potential stop loss activation
|
|
|
- symbols_with_fills.add(token)
|
|
|
+ # 🆕 Handle trade cycle closure for external stop loss
|
|
|
+ # Find open trade cycle for this symbol and close it
|
|
|
+ open_trade_cycles = stats.get_open_trade_cycles(full_symbol)
|
|
|
+ for trade_cycle in open_trade_cycles:
|
|
|
+ if trade_cycle['status'] == 'open':
|
|
|
+ stats.update_trade_cycle_closed(
|
|
|
+ trade_cycle['id'], trade_id, price, amount,
|
|
|
+ timestamp_dt.isoformat(), 'stop_loss', None
|
|
|
+ )
|
|
|
+ logger.info(f"📊 Trade cycle {trade_cycle['id']} closed via external stop loss {trade_id}")
|
|
|
+ break # Only close one trade cycle per stop loss execution
|
|
|
|
|
|
- # Send notification for external trade
|
|
|
+ # Send specialized stop loss execution notification
|
|
|
if self.notification_manager:
|
|
|
- await self.notification_manager.send_external_trade_notification(
|
|
|
- full_symbol, side, amount, price, action_type, timestamp_dt.isoformat()
|
|
|
+ await self.notification_manager.send_stop_loss_execution_notification(
|
|
|
+ stop_loss_info, full_symbol, side, amount, price, action_type, timestamp_dt.isoformat()
|
|
|
)
|
|
|
|
|
|
- logger.info(f"📋 Processed external trade: {side} {amount} {full_symbol} @ ${price:.2f} ({action_type}) using timestamp {timestamp_dt.isoformat()}")
|
|
|
- external_trades_processed += 1
|
|
|
+ # Remove from tracking since it's now executed
|
|
|
+ del self._external_stop_loss_orders[exchange_order_id_from_fill]
|
|
|
|
|
|
- # Update last processed time
|
|
|
- self._last_processed_trade_time = timestamp_dt
|
|
|
+ logger.info(f"🛑 Processed external stop loss execution: {side} {amount} {full_symbol} @ ${price:.2f} ({action_type})")
|
|
|
+
|
|
|
+ else:
|
|
|
+ # Handle as regular external trade
|
|
|
+ # Check if this corresponds to a bot order by exchange_order_id
|
|
|
+ linked_order_db_id = None
|
|
|
+ if exchange_order_id_from_fill:
|
|
|
+ order_in_db = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
|
|
|
+ if order_in_db:
|
|
|
+ linked_order_db_id = order_in_db.get('id')
|
|
|
+ logger.info(f"🔗 Linked external fill {trade_id} to bot order DB ID {linked_order_db_id} (Exchange OID: {exchange_order_id_from_fill})")
|
|
|
+
|
|
|
+ # Update order status to filled if it was open
|
|
|
+ current_status = order_in_db.get('status', '')
|
|
|
+ if current_status in ['open', 'partially_filled', 'pending_submission']:
|
|
|
+ # Determine if this is a partial or full fill
|
|
|
+ order_amount_requested = float(order_in_db.get('amount_requested', 0))
|
|
|
+ if abs(amount - order_amount_requested) < 0.000001: # Allow small floating point differences
|
|
|
+ new_status_after_fill = 'filled'
|
|
|
+ else:
|
|
|
+ new_status_after_fill = 'partially_filled'
|
|
|
+
|
|
|
+ stats.update_order_status(
|
|
|
+ order_db_id=linked_order_db_id,
|
|
|
+ new_status=new_status_after_fill
|
|
|
+ )
|
|
|
+ logger.info(f"📊 Updated bot order {linked_order_db_id} status: {current_status} → {new_status_after_fill}")
|
|
|
+
|
|
|
+ # Check if this order is now fully filled and has pending stop losses to activate
|
|
|
+ if new_status_after_fill == 'filled':
|
|
|
+ await self._activate_pending_stop_losses(order_in_db, stats)
|
|
|
+
|
|
|
+ # Record the trade in stats with enhanced tracking
|
|
|
+ action_type = stats.record_trade_with_enhanced_tracking(
|
|
|
+ full_symbol, side, amount, price,
|
|
|
+ exchange_fill_id=trade_id, trade_type="external",
|
|
|
+ timestamp=timestamp_dt.isoformat(),
|
|
|
+ linked_order_table_id_to_link=linked_order_db_id
|
|
|
+ )
|
|
|
+
|
|
|
+ # 🆕 Update trade cycle based on the action
|
|
|
+ if linked_order_db_id:
|
|
|
+ # This fill is linked to a bot order - check if we need to update trade cycle
|
|
|
+ trade_cycle = stats.get_trade_cycle_by_entry_order(linked_order_db_id)
|
|
|
+ if trade_cycle:
|
|
|
+ if trade_cycle['status'] == 'pending_open' and action_type in ['long_opened', 'short_opened', 'position_opened']:
|
|
|
+ # Entry order filled - update trade cycle to opened
|
|
|
+ stats.update_trade_cycle_opened(
|
|
|
+ trade_cycle['id'], trade_id, price, amount, timestamp_dt.isoformat()
|
|
|
+ )
|
|
|
+ logger.info(f"📊 Trade cycle {trade_cycle['id']} opened via fill {trade_id}")
|
|
|
+
|
|
|
+ elif trade_cycle['status'] == 'open' and action_type in ['long_closed', 'short_closed', 'position_closed']:
|
|
|
+ # Exit order filled - update trade cycle to closed
|
|
|
+ exit_type = 'manual' # Default for manual exits
|
|
|
+ stats.update_trade_cycle_closed(
|
|
|
+ trade_cycle['id'], trade_id, price, amount,
|
|
|
+ timestamp_dt.isoformat(), exit_type, linked_order_db_id
|
|
|
+ )
|
|
|
+ logger.info(f"📊 Trade cycle {trade_cycle['id']} closed via fill {trade_id}")
|
|
|
+ elif action_type in ['long_opened', 'short_opened', 'position_opened']:
|
|
|
+ # External trade that opened a position - create external trade cycle
|
|
|
+ side_for_cycle = 'buy' if side.lower() == 'buy' else 'sell'
|
|
|
+ trade_cycle_id = stats.create_trade_cycle(
|
|
|
+ symbol=full_symbol,
|
|
|
+ side=side_for_cycle,
|
|
|
+ entry_order_id=None, # External order
|
|
|
+ trade_type='external'
|
|
|
+ )
|
|
|
+ if trade_cycle_id:
|
|
|
+ stats.update_trade_cycle_opened(
|
|
|
+ trade_cycle_id, trade_id, price, amount, timestamp_dt.isoformat()
|
|
|
+ )
|
|
|
+ logger.info(f"📊 Created external trade cycle {trade_cycle_id} for {side.upper()} {full_symbol}")
|
|
|
+
|
|
|
+ # Track symbol for potential stop loss activation
|
|
|
+ symbols_with_fills.add(token)
|
|
|
+
|
|
|
+ # Send notification for external trade
|
|
|
+ if self.notification_manager:
|
|
|
+ await self.notification_manager.send_external_trade_notification(
|
|
|
+ full_symbol, side, amount, price, action_type, timestamp_dt.isoformat()
|
|
|
+ )
|
|
|
+
|
|
|
+ logger.info(f"📋 Processed external trade: {side} {amount} {full_symbol} @ ${price:.2f} ({action_type}) using timestamp {timestamp_dt.isoformat()}")
|
|
|
+
|
|
|
+ external_trades_processed += 1
|
|
|
+
|
|
|
+ # Update last processed time
|
|
|
+ self._last_processed_trade_time = timestamp_dt
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"Error processing fill {fill}: {e}")
|
|
@@ -1121,4 +1204,132 @@ class MarketMonitor:
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ Error checking for recent fills for order: {e}", exc_info=True)
|
|
|
- return False
|
|
|
+ return False
|
|
|
+
|
|
|
+ async def _check_external_stop_loss_orders(self):
|
|
|
+ """Check for externally placed stop loss orders and track them."""
|
|
|
+ try:
|
|
|
+ # Get current open orders
|
|
|
+ open_orders = self.trading_engine.get_orders()
|
|
|
+ if not open_orders:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Get current positions to understand what could be stop losses
|
|
|
+ positions = self.trading_engine.get_positions()
|
|
|
+ if not positions:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Create a map of current positions
|
|
|
+ position_map = {}
|
|
|
+ for position in positions:
|
|
|
+ symbol = position.get('symbol')
|
|
|
+ contracts = float(position.get('contracts', 0))
|
|
|
+ if symbol and contracts != 0:
|
|
|
+ token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
|
|
|
+ position_map[token] = {
|
|
|
+ 'symbol': symbol,
|
|
|
+ 'contracts': contracts,
|
|
|
+ 'side': 'long' if contracts > 0 else 'short',
|
|
|
+ 'entry_price': float(position.get('entryPx', 0))
|
|
|
+ }
|
|
|
+
|
|
|
+ # Check each order to see if it could be a stop loss
|
|
|
+ newly_detected = 0
|
|
|
+ for order in open_orders:
|
|
|
+ try:
|
|
|
+ exchange_order_id = order.get('id')
|
|
|
+ symbol = order.get('symbol')
|
|
|
+ side = order.get('side') # 'buy' or 'sell'
|
|
|
+ amount = float(order.get('amount', 0))
|
|
|
+ price = float(order.get('price', 0))
|
|
|
+ order_type = order.get('type', '').lower()
|
|
|
+
|
|
|
+ if not all([exchange_order_id, symbol, side, amount, price]):
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Skip if we're already tracking this order
|
|
|
+ if exchange_order_id in self._external_stop_loss_orders:
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Check if this order could be a stop loss
|
|
|
+ token = symbol.split('/')[0] if '/' in symbol else symbol.split(':')[0]
|
|
|
+
|
|
|
+ # Must have a position in this token to have a stop loss
|
|
|
+ if token not in position_map:
|
|
|
+ continue
|
|
|
+
|
|
|
+ position = position_map[token]
|
|
|
+
|
|
|
+ # Check if this order matches stop loss pattern
|
|
|
+ is_stop_loss = False
|
|
|
+
|
|
|
+ if position['side'] == 'long' and side == 'sell':
|
|
|
+ # Long position with sell order - could be stop loss if price is below entry
|
|
|
+ if price < position['entry_price'] * 0.98: # Allow 2% buffer for approximation
|
|
|
+ is_stop_loss = True
|
|
|
+
|
|
|
+ elif position['side'] == 'short' and side == 'buy':
|
|
|
+ # Short position with buy order - could be stop loss if price is above entry
|
|
|
+ if price > position['entry_price'] * 1.02: # Allow 2% buffer for approximation
|
|
|
+ is_stop_loss = True
|
|
|
+
|
|
|
+ if is_stop_loss:
|
|
|
+ # Track this as an external stop loss order
|
|
|
+ self._external_stop_loss_orders[exchange_order_id] = {
|
|
|
+ 'token': token,
|
|
|
+ 'symbol': symbol,
|
|
|
+ 'trigger_price': price,
|
|
|
+ 'side': side,
|
|
|
+ 'amount': amount,
|
|
|
+ 'position_side': position['side'],
|
|
|
+ 'detected_at': datetime.now(timezone.utc),
|
|
|
+ 'entry_price': position['entry_price']
|
|
|
+ }
|
|
|
+ newly_detected += 1
|
|
|
+ logger.info(f"🛑 Detected external stop loss order: {token} {side.upper()} {amount} @ ${price:.2f} (protecting {position['side'].upper()} position)")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error analyzing order for stop loss detection: {e}")
|
|
|
+ continue
|
|
|
+
|
|
|
+ if newly_detected > 0:
|
|
|
+ logger.info(f"🔍 Detected {newly_detected} new external stop loss orders")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"❌ Error checking external stop loss orders: {e}")
|
|
|
+
|
|
|
+ async def _cleanup_external_stop_loss_tracking(self):
|
|
|
+ """Clean up external stop loss orders that are no longer active."""
|
|
|
+ try:
|
|
|
+ if not self._external_stop_loss_orders:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Get current open orders
|
|
|
+ open_orders = self.trading_engine.get_orders()
|
|
|
+ if not open_orders:
|
|
|
+ # No open orders, clear all tracking
|
|
|
+ removed_count = len(self._external_stop_loss_orders)
|
|
|
+ self._external_stop_loss_orders.clear()
|
|
|
+ if removed_count > 0:
|
|
|
+ logger.info(f"🧹 Cleared {removed_count} external stop loss orders (no open orders)")
|
|
|
+ return
|
|
|
+
|
|
|
+ # Get set of current order IDs
|
|
|
+ current_order_ids = {order.get('id') for order in open_orders if order.get('id')}
|
|
|
+
|
|
|
+ # Remove any tracked stop loss orders that are no longer open
|
|
|
+ to_remove = []
|
|
|
+ for order_id, stop_loss_info in self._external_stop_loss_orders.items():
|
|
|
+ if order_id not in current_order_ids:
|
|
|
+ to_remove.append(order_id)
|
|
|
+
|
|
|
+ for order_id in to_remove:
|
|
|
+ stop_loss_info = self._external_stop_loss_orders[order_id]
|
|
|
+ del self._external_stop_loss_orders[order_id]
|
|
|
+ logger.info(f"🗑️ Removed external stop loss tracking for {stop_loss_info['token']} order {order_id} (no longer open)")
|
|
|
+
|
|
|
+ if to_remove:
|
|
|
+ logger.info(f"🧹 Cleaned up {len(to_remove)} external stop loss orders")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"❌ Error cleaning up external stop loss tracking: {e}")
|