|
@@ -85,6 +85,217 @@ class ExternalEventMonitor:
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"❌ Error sending alarm notification: {e}")
|
|
|
+
|
|
|
+ async def _determine_position_action_type(self, full_symbol: str, side_from_fill: str,
|
|
|
+ amount_from_fill: float, existing_lc: Optional[Dict] = None) -> str:
|
|
|
+ """
|
|
|
+ Determine the type of position action based on current state and fill details.
|
|
|
+ Returns one of: 'position_opened', 'position_closed', 'position_increased', 'position_decreased'
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # Get current position from exchange
|
|
|
+ current_positions = self.trading_engine.get_positions() or []
|
|
|
+ current_exchange_position = None
|
|
|
+ for pos in current_positions:
|
|
|
+ if pos.get('symbol') == full_symbol:
|
|
|
+ current_exchange_position = pos
|
|
|
+ break
|
|
|
+
|
|
|
+ current_size = 0.0
|
|
|
+ if current_exchange_position:
|
|
|
+ current_size = abs(float(current_exchange_position.get('contracts', 0)))
|
|
|
+
|
|
|
+ # If no existing lifecycle, this is a position opening
|
|
|
+ if not existing_lc:
|
|
|
+ if current_size > 1e-9: # Position exists on exchange
|
|
|
+ return 'position_opened'
|
|
|
+ else:
|
|
|
+ return 'external_unmatched'
|
|
|
+
|
|
|
+ # Get previous position size from lifecycle
|
|
|
+ previous_size = existing_lc.get('current_position_size', 0)
|
|
|
+ lc_position_side = existing_lc.get('position_side')
|
|
|
+
|
|
|
+ # Check if this is a closing trade (opposite side)
|
|
|
+ is_closing_trade = False
|
|
|
+ if lc_position_side == 'long' and side_from_fill.lower() == 'sell':
|
|
|
+ is_closing_trade = True
|
|
|
+ elif lc_position_side == 'short' and side_from_fill.lower() == 'buy':
|
|
|
+ is_closing_trade = True
|
|
|
+
|
|
|
+ if is_closing_trade:
|
|
|
+ if current_size < 1e-9: # Position is now closed
|
|
|
+ return 'position_closed'
|
|
|
+ elif current_size < previous_size - 1e-9: # Position reduced but not closed
|
|
|
+ return 'position_decreased'
|
|
|
+ else:
|
|
|
+ # Same side trade - position increase
|
|
|
+ if current_size > previous_size + 1e-9:
|
|
|
+ return 'position_increased'
|
|
|
+
|
|
|
+ # Default fallback
|
|
|
+ return 'external_unmatched'
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error determining position action type: {e}")
|
|
|
+ return 'external_unmatched'
|
|
|
+
|
|
|
+ async def _update_lifecycle_position_size(self, lifecycle_id: str, new_size: float) -> bool:
|
|
|
+ """Update the current position size in the lifecycle."""
|
|
|
+ try:
|
|
|
+ stats = self.trading_engine.get_stats()
|
|
|
+ if not stats:
|
|
|
+ return False
|
|
|
+
|
|
|
+ # Update the current position size
|
|
|
+ success = stats.trade_manager.update_trade_market_data(
|
|
|
+ lifecycle_id, current_position_size=new_size
|
|
|
+ )
|
|
|
+ return success
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error updating lifecycle position size: {e}")
|
|
|
+ return False
|
|
|
+
|
|
|
+ async def _send_position_change_notification(self, full_symbol: str, side_from_fill: str,
|
|
|
+ amount_from_fill: float, price_from_fill: float,
|
|
|
+ action_type: str, timestamp_dt: datetime,
|
|
|
+ existing_lc: Optional[Dict] = None,
|
|
|
+ realized_pnl: Optional[float] = None):
|
|
|
+ """Send appropriate notification based on position action type."""
|
|
|
+ try:
|
|
|
+ if not self.notification_manager:
|
|
|
+ return
|
|
|
+
|
|
|
+ token = full_symbol.split('/')[0] if '/' in full_symbol else full_symbol.split(':')[0]
|
|
|
+ formatter = get_formatter()
|
|
|
+
|
|
|
+ # Format timestamp
|
|
|
+ time_str = timestamp_dt.strftime('%H:%M:%S')
|
|
|
+
|
|
|
+ if action_type == 'position_opened':
|
|
|
+ position_side = 'LONG' if side_from_fill.lower() == 'buy' else 'SHORT'
|
|
|
+ message = f"""
|
|
|
+🚀 <b>Position Opened (External)</b>
|
|
|
+
|
|
|
+📊 <b>Trade Details:</b>
|
|
|
+• Token: {token}
|
|
|
+• Direction: {position_side}
|
|
|
+• Size: {formatter.format_amount(amount_from_fill, token)}
|
|
|
+• Entry Price: {formatter.format_price_with_symbol(price_from_fill, token)}
|
|
|
+• Position Value: {formatter.format_price_with_symbol(amount_from_fill * price_from_fill)}
|
|
|
+
|
|
|
+✅ <b>Status:</b> New {position_side} position opened externally
|
|
|
+⏰ <b>Time:</b> {time_str}
|
|
|
+
|
|
|
+📱 Use /positions to view all positions
|
|
|
+ """
|
|
|
+
|
|
|
+ elif action_type == 'position_closed' and existing_lc:
|
|
|
+ position_side = existing_lc.get('position_side', 'unknown').upper()
|
|
|
+ entry_price = existing_lc.get('entry_price', 0)
|
|
|
+ pnl_emoji = "🟢" if realized_pnl and realized_pnl >= 0 else "🔴"
|
|
|
+ pnl_text = f"{formatter.format_price_with_symbol(realized_pnl)}" if realized_pnl is not None else "N/A"
|
|
|
+
|
|
|
+ message = f"""
|
|
|
+🎯 <b>Position Closed (External)</b>
|
|
|
+
|
|
|
+📊 <b>Trade Details:</b>
|
|
|
+• Token: {token}
|
|
|
+• Direction: {position_side}
|
|
|
+• Size Closed: {formatter.format_amount(amount_from_fill, token)}
|
|
|
+• Entry Price: {formatter.format_price_with_symbol(entry_price, token)}
|
|
|
+• Exit Price: {formatter.format_price_with_symbol(price_from_fill, token)}
|
|
|
+• Exit Value: {formatter.format_price_with_symbol(amount_from_fill * price_from_fill)}
|
|
|
+
|
|
|
+{pnl_emoji} <b>P&L:</b> {pnl_text}
|
|
|
+✅ <b>Status:</b> {position_side} position closed externally
|
|
|
+⏰ <b>Time:</b> {time_str}
|
|
|
+
|
|
|
+📊 Use /stats to view updated performance
|
|
|
+ """
|
|
|
+
|
|
|
+ elif action_type == 'position_increased' and existing_lc:
|
|
|
+ position_side = existing_lc.get('position_side', 'unknown').upper()
|
|
|
+ previous_size = existing_lc.get('current_position_size', 0)
|
|
|
+ # Get current size from exchange
|
|
|
+ current_positions = self.trading_engine.get_positions() or []
|
|
|
+ current_size = 0
|
|
|
+ for pos in current_positions:
|
|
|
+ if pos.get('symbol') == full_symbol:
|
|
|
+ current_size = abs(float(pos.get('contracts', 0)))
|
|
|
+ break
|
|
|
+
|
|
|
+ message = f"""
|
|
|
+📈 <b>Position Increased (External)</b>
|
|
|
+
|
|
|
+📊 <b>Trade Details:</b>
|
|
|
+• Token: {token}
|
|
|
+• Direction: {position_side}
|
|
|
+• Size Added: {formatter.format_amount(amount_from_fill, token)}
|
|
|
+• Add Price: {formatter.format_price_with_symbol(price_from_fill, token)}
|
|
|
+• Previous Size: {formatter.format_amount(previous_size, token)}
|
|
|
+• New Size: {formatter.format_amount(current_size, token)}
|
|
|
+• Add Value: {formatter.format_price_with_symbol(amount_from_fill * price_from_fill)}
|
|
|
+
|
|
|
+📈 <b>Status:</b> {position_side} position size increased externally
|
|
|
+⏰ <b>Time:</b> {time_str}
|
|
|
+
|
|
|
+📈 Use /positions to view current position
|
|
|
+ """
|
|
|
+
|
|
|
+ elif action_type == 'position_decreased' and existing_lc:
|
|
|
+ position_side = existing_lc.get('position_side', 'unknown').upper()
|
|
|
+ previous_size = existing_lc.get('current_position_size', 0)
|
|
|
+ entry_price = existing_lc.get('entry_price', 0)
|
|
|
+
|
|
|
+ # Get current size from exchange
|
|
|
+ current_positions = self.trading_engine.get_positions() or []
|
|
|
+ current_size = 0
|
|
|
+ for pos in current_positions:
|
|
|
+ if pos.get('symbol') == full_symbol:
|
|
|
+ current_size = abs(float(pos.get('contracts', 0)))
|
|
|
+ break
|
|
|
+
|
|
|
+ # Calculate partial PnL for the reduced amount
|
|
|
+ partial_pnl = 0
|
|
|
+ if entry_price > 0:
|
|
|
+ if position_side == 'LONG':
|
|
|
+ partial_pnl = amount_from_fill * (price_from_fill - entry_price)
|
|
|
+ else: # SHORT
|
|
|
+ partial_pnl = amount_from_fill * (entry_price - price_from_fill)
|
|
|
+
|
|
|
+ pnl_emoji = "🟢" if partial_pnl >= 0 else "🔴"
|
|
|
+
|
|
|
+ message = f"""
|
|
|
+📉 <b>Position Decreased (External)</b>
|
|
|
+
|
|
|
+📊 <b>Trade Details:</b>
|
|
|
+• Token: {token}
|
|
|
+• Direction: {position_side}
|
|
|
+• Size Reduced: {formatter.format_amount(amount_from_fill, token)}
|
|
|
+• Exit Price: {formatter.format_price_with_symbol(price_from_fill, token)}
|
|
|
+• Previous Size: {formatter.format_amount(previous_size, token)}
|
|
|
+• Remaining Size: {formatter.format_amount(current_size, token)}
|
|
|
+• Exit Value: {formatter.format_price_with_symbol(amount_from_fill * price_from_fill)}
|
|
|
+
|
|
|
+{pnl_emoji} <b>Partial P&L:</b> {formatter.format_price_with_symbol(partial_pnl)}
|
|
|
+📉 <b>Status:</b> {position_side} position size decreased externally
|
|
|
+⏰ <b>Time:</b> {time_str}
|
|
|
+
|
|
|
+📊 Position remains open. Use /positions to view details
|
|
|
+ """
|
|
|
+ else:
|
|
|
+ # Fallback to generic notification
|
|
|
+ await self.notification_manager.send_external_trade_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ action_type, timestamp_dt.isoformat()
|
|
|
+ )
|
|
|
+ return
|
|
|
+
|
|
|
+ await self.notification_manager.send_generic_notification(message.strip())
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"Error sending position change notification: {e}")
|
|
|
|
|
|
async def _check_external_trades(self):
|
|
|
"""Check for trades made outside the Telegram bot and update stats."""
|
|
@@ -138,6 +349,7 @@ class ExternalEventMonitor:
|
|
|
|
|
|
exchange_order_id_from_fill = fill.get('info', {}).get('oid')
|
|
|
|
|
|
+ # First check if this is a pending entry order fill
|
|
|
if exchange_order_id_from_fill:
|
|
|
pending_lc = stats.get_lifecycle_by_entry_order_id(exchange_order_id_from_fill, status='pending')
|
|
|
if pending_lc and pending_lc.get('symbol') == full_symbol:
|
|
@@ -153,55 +365,52 @@ class ExternalEventMonitor:
|
|
|
order_in_db_for_entry = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
|
|
|
if order_in_db_for_entry:
|
|
|
stats.update_order_status(order_db_id=order_in_db_for_entry['id'], new_status='filled', amount_filled_increment=amount_from_fill)
|
|
|
+
|
|
|
+ # Send position opened notification (this is a bot-initiated position)
|
|
|
+ await self._send_position_change_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ 'position_opened', timestamp_dt
|
|
|
+ )
|
|
|
fill_processed_this_iteration = True
|
|
|
-
|
|
|
- if not fill_processed_this_iteration:
|
|
|
+
|
|
|
+ # Check if this is a known bot order (SL/TP/exit)
|
|
|
+ if not fill_processed_this_iteration and exchange_order_id_from_fill:
|
|
|
active_lc = None
|
|
|
closure_reason_action_type = None
|
|
|
bot_order_db_id_to_update = None
|
|
|
|
|
|
- if exchange_order_id_from_fill:
|
|
|
- bot_order_for_fill = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
|
|
|
- if bot_order_for_fill and bot_order_for_fill.get('symbol') == full_symbol:
|
|
|
- order_type = bot_order_for_fill.get('type')
|
|
|
- order_side = bot_order_for_fill.get('side')
|
|
|
- if order_type == 'market':
|
|
|
- potential_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
|
|
|
- if potential_lc:
|
|
|
- lc_pos_side = potential_lc.get('position_side')
|
|
|
- if (lc_pos_side == 'long' and order_side == 'sell' and side_from_fill == 'sell') or \
|
|
|
- (lc_pos_side == 'short' and order_side == 'buy' and side_from_fill == 'buy'):
|
|
|
- active_lc = potential_lc
|
|
|
- closure_reason_action_type = f"bot_exit_{lc_pos_side}_close"
|
|
|
- bot_order_db_id_to_update = bot_order_for_fill.get('id')
|
|
|
- logger.info(f"ℹ️ Lifecycle BOT EXIT: Fill {trade_id} (OID {exchange_order_id_from_fill}) for {full_symbol} matches bot exit for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
+ bot_order_for_fill = stats.get_order_by_exchange_id(exchange_order_id_from_fill)
|
|
|
+ if bot_order_for_fill and bot_order_for_fill.get('symbol') == full_symbol:
|
|
|
+ order_type = bot_order_for_fill.get('type')
|
|
|
+ order_side = bot_order_for_fill.get('side')
|
|
|
+ if order_type == 'market':
|
|
|
+ potential_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
|
|
|
+ if potential_lc:
|
|
|
+ lc_pos_side = potential_lc.get('position_side')
|
|
|
+ if (lc_pos_side == 'long' and order_side == 'sell' and side_from_fill == 'sell') or \
|
|
|
+ (lc_pos_side == 'short' and order_side == 'buy' and side_from_fill == 'buy'):
|
|
|
+ active_lc = potential_lc
|
|
|
+ closure_reason_action_type = f"bot_exit_{lc_pos_side}_close"
|
|
|
+ bot_order_db_id_to_update = bot_order_for_fill.get('id')
|
|
|
+ logger.info(f"ℹ️ Lifecycle BOT EXIT: Fill {trade_id} (OID {exchange_order_id_from_fill}) for {full_symbol} matches bot exit for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
+
|
|
|
+ if not active_lc:
|
|
|
+ lc_by_sl = stats.get_lifecycle_by_sl_order_id(exchange_order_id_from_fill, status='position_opened')
|
|
|
+ if lc_by_sl and lc_by_sl.get('symbol') == full_symbol:
|
|
|
+ active_lc = lc_by_sl
|
|
|
+ closure_reason_action_type = f"sl_{active_lc.get('position_side')}_close"
|
|
|
+ bot_order_db_id_to_update = bot_order_for_fill.get('id')
|
|
|
+ logger.info(f"ℹ️ Lifecycle SL: Fill {trade_id} for OID {exchange_order_id_from_fill} matches SL for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
|
|
|
- if not active_lc:
|
|
|
- lc_by_sl = stats.get_lifecycle_by_sl_order_id(exchange_order_id_from_fill, status='position_opened')
|
|
|
- if lc_by_sl and lc_by_sl.get('symbol') == full_symbol:
|
|
|
- active_lc = lc_by_sl
|
|
|
- closure_reason_action_type = f"sl_{active_lc.get('position_side')}_close"
|
|
|
+ if not active_lc:
|
|
|
+ lc_by_tp = stats.get_lifecycle_by_tp_order_id(exchange_order_id_from_fill, status='position_opened')
|
|
|
+ if lc_by_tp and lc_by_tp.get('symbol') == full_symbol:
|
|
|
+ active_lc = lc_by_tp
|
|
|
+ closure_reason_action_type = f"tp_{active_lc.get('position_side')}_close"
|
|
|
bot_order_db_id_to_update = bot_order_for_fill.get('id')
|
|
|
- logger.info(f"ℹ️ Lifecycle SL: Fill {trade_id} for OID {exchange_order_id_from_fill} matches SL for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
-
|
|
|
- if not active_lc:
|
|
|
- lc_by_tp = stats.get_lifecycle_by_tp_order_id(exchange_order_id_from_fill, status='position_opened')
|
|
|
- if lc_by_tp and lc_by_tp.get('symbol') == full_symbol:
|
|
|
- active_lc = lc_by_tp
|
|
|
- closure_reason_action_type = f"tp_{active_lc.get('position_side')}_close"
|
|
|
- bot_order_db_id_to_update = bot_order_for_fill.get('id')
|
|
|
- logger.info(f"ℹ️ Lifecycle TP: Fill {trade_id} for OID {exchange_order_id_from_fill} matches TP for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
-
|
|
|
- if not active_lc:
|
|
|
- potential_lc_external = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
|
|
|
- if potential_lc_external:
|
|
|
- lc_pos_side = potential_lc_external.get('position_side')
|
|
|
- if (lc_pos_side == 'long' and side_from_fill == 'sell') or \
|
|
|
- (lc_pos_side == 'short' and side_from_fill == 'buy'):
|
|
|
- active_lc = potential_lc_external
|
|
|
- closure_reason_action_type = f"external_{lc_pos_side}_close"
|
|
|
- logger.info(f"ℹ️ Lifecycle EXTERNAL CLOSE: Fill {trade_id} for {full_symbol} (no matching bot OID) for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
-
|
|
|
+ logger.info(f"ℹ️ Lifecycle TP: Fill {trade_id} for OID {exchange_order_id_from_fill} matches TP for lifecycle {active_lc['trade_lifecycle_id']}.")
|
|
|
+
|
|
|
+ # Process known bot order fills
|
|
|
if active_lc and closure_reason_action_type:
|
|
|
lc_id = active_lc['trade_lifecycle_id']
|
|
|
lc_entry_price = active_lc.get('entry_price', 0)
|
|
@@ -222,18 +431,20 @@ class ExternalEventMonitor:
|
|
|
formatter = get_formatter()
|
|
|
logger.info(f"{pnl_emoji} Lifecycle CLOSED: {lc_id} ({closure_reason_action_type}). PNL for fill: {formatter.format_price_with_symbol(realized_pnl)}")
|
|
|
symbols_with_fills.add(token)
|
|
|
- if self.notification_manager:
|
|
|
- await self.notification_manager.send_external_trade_notification(
|
|
|
- full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
- closure_reason_action_type, timestamp_dt.isoformat()
|
|
|
- )
|
|
|
+
|
|
|
+ # Send position closed notification
|
|
|
+ await self._send_position_change_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ 'position_closed', timestamp_dt, active_lc, realized_pnl
|
|
|
+ )
|
|
|
+
|
|
|
stats._migrate_trade_to_aggregated_stats(lc_id)
|
|
|
if bot_order_db_id_to_update:
|
|
|
stats.update_order_status(order_db_id=bot_order_db_id_to_update, new_status='filled', amount_filled_increment=amount_from_fill)
|
|
|
fill_processed_this_iteration = True
|
|
|
|
|
|
+ # Check for external stop losses
|
|
|
if not fill_processed_this_iteration:
|
|
|
- # Access external_stop_losses via self.shared_state
|
|
|
if (exchange_order_id_from_fill and
|
|
|
self.shared_state.get('external_stop_losses') and
|
|
|
exchange_order_id_from_fill in self.shared_state['external_stop_losses']):
|
|
@@ -264,104 +475,26 @@ class ExternalEventMonitor:
|
|
|
fill_processed_this_iteration = True
|
|
|
else:
|
|
|
logger.warning(f"⚠️ External SL (MM) {exchange_order_id_from_fill} for {full_symbol}, but no active lifecycle found.")
|
|
|
-
|
|
|
- if not fill_processed_this_iteration:
|
|
|
- existing_open_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
|
|
|
- if existing_open_lc:
|
|
|
- lc_id = existing_open_lc['trade_lifecycle_id']
|
|
|
- lc_entry_price = existing_open_lc.get('entry_price', 0)
|
|
|
- lc_position_side = existing_open_lc.get('position_side')
|
|
|
- lc_current_size_before_fill = existing_open_lc.get('current_position_size', 0)
|
|
|
-
|
|
|
- is_potentially_closing_external_fill = False
|
|
|
- if lc_position_side == 'long' and side_from_fill.lower() == 'sell':
|
|
|
- is_potentially_closing_external_fill = True
|
|
|
- elif lc_position_side == 'short' and side_from_fill.lower() == 'buy':
|
|
|
- is_potentially_closing_external_fill = True
|
|
|
-
|
|
|
- if is_potentially_closing_external_fill:
|
|
|
- logger.info(f"ℹ️ Detected potentially closing external fill {trade_id} for {full_symbol} (Lifecycle: {lc_id}). Verifying exchange position state...")
|
|
|
-
|
|
|
- fresh_positions_after_fill = self.trading_engine.get_positions() or []
|
|
|
- position_on_exchange_after_fill = None
|
|
|
- for pos in fresh_positions_after_fill:
|
|
|
- if pos.get('symbol') == full_symbol:
|
|
|
- position_on_exchange_after_fill = pos
|
|
|
- break
|
|
|
-
|
|
|
- position_is_closed_on_exchange = False
|
|
|
- if position_on_exchange_after_fill is None:
|
|
|
- position_is_closed_on_exchange = True
|
|
|
- logger.info(f"✅ Exchange Verification: Position for {full_symbol} (Lifecycle: {lc_id}) not found after fill {trade_id}. Confirming closure.")
|
|
|
- elif abs(float(position_on_exchange_after_fill.get('contracts', 0))) < 1e-9:
|
|
|
- position_is_closed_on_exchange = True
|
|
|
- logger.info(f"✅ Exchange Verification: Position for {full_symbol} (Lifecycle: {lc_id}) has zero size on exchange after fill {trade_id}. Confirming closure.")
|
|
|
-
|
|
|
- if position_is_closed_on_exchange:
|
|
|
- amount_for_pnl_calc = amount_from_fill
|
|
|
- if abs(lc_current_size_before_fill - amount_from_fill) < 0.000001 * amount_from_fill:
|
|
|
- amount_for_pnl_calc = lc_current_size_before_fill
|
|
|
-
|
|
|
- logger.info(f"ℹ️ Attempting to close lifecycle {lc_id} for {full_symbol} via confirmed external fill {trade_id}.")
|
|
|
- realized_pnl = 0
|
|
|
- if lc_position_side == 'long':
|
|
|
- realized_pnl = amount_for_pnl_calc * (price_from_fill - lc_entry_price)
|
|
|
- elif lc_position_side == 'short':
|
|
|
- realized_pnl = amount_for_pnl_calc * (lc_entry_price - price_from_fill)
|
|
|
-
|
|
|
- success = stats.update_trade_position_closed(
|
|
|
- lifecycle_id=lc_id,
|
|
|
- exit_price=price_from_fill,
|
|
|
- realized_pnl=realized_pnl,
|
|
|
- exchange_fill_id=trade_id
|
|
|
- )
|
|
|
- if success:
|
|
|
- pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
|
|
|
- formatter = get_formatter()
|
|
|
- logger.info(f"{pnl_emoji} Lifecycle CLOSED (Verified External): {lc_id}. PNL for fill: {formatter.format_price_with_symbol(realized_pnl)}")
|
|
|
- symbols_with_fills.add(token)
|
|
|
- if self.notification_manager:
|
|
|
- await self.notification_manager.send_external_trade_notification(
|
|
|
- full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
- f"verified_external_{lc_position_side}_close",
|
|
|
- timestamp_dt.isoformat()
|
|
|
- )
|
|
|
- stats._migrate_trade_to_aggregated_stats(lc_id)
|
|
|
- fill_processed_this_iteration = True
|
|
|
- else:
|
|
|
- logger.error(f"❌ Failed to close lifecycle {lc_id} via verified external fill {trade_id}.")
|
|
|
- else:
|
|
|
- current_size_on_exchange = float(position_on_exchange_after_fill.get('contracts', 0)) if position_on_exchange_after_fill else 'Unknown'
|
|
|
- logger.warning(f"⚠️ External fill {trade_id} for {full_symbol} (Lifecycle: {lc_id}, Amount: {amount_from_fill}) did NOT fully close position. Exchange size now: {current_size_on_exchange}. Lifecycle remains open. Fill will be recorded as 'external_unmatched'.")
|
|
|
|
|
|
+ # NEW: Enhanced external trade processing with position state detection
|
|
|
if not fill_processed_this_iteration:
|
|
|
- # Check if this external trade opens a new position that should be tracked
|
|
|
- current_positions_from_cache_map = {
|
|
|
- pos.get('symbol'): pos for pos in (self.market_monitor_cache.cached_positions or [])
|
|
|
- if pos.get('symbol') and abs(float(pos.get('contracts', 0))) > 1e-9
|
|
|
- }
|
|
|
+ existing_lc = stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
|
|
|
+ action_type = await self._determine_position_action_type(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, existing_lc
|
|
|
+ )
|
|
|
|
|
|
- # Check if there's a position on the exchange for this symbol
|
|
|
- exchange_position = current_positions_from_cache_map.get(full_symbol)
|
|
|
- should_create_lifecycle = False
|
|
|
+ logger.info(f"🔍 External fill analysis: {full_symbol} {side_from_fill} {amount_from_fill} -> {action_type}")
|
|
|
|
|
|
- if exchange_position:
|
|
|
- # There's a position on exchange but no lifecycle tracking it
|
|
|
- # This indicates a new external position that should be tracked
|
|
|
- should_create_lifecycle = True
|
|
|
- logger.info(f"🚀 Detected new external position for {full_symbol}: {side_from_fill} {amount_from_fill} @ {price_from_fill}")
|
|
|
-
|
|
|
- if should_create_lifecycle:
|
|
|
- # Create a new trade lifecycle for this external position
|
|
|
+ if action_type == 'position_opened':
|
|
|
+ # Create new lifecycle for external position
|
|
|
lifecycle_id = stats.create_trade_lifecycle(
|
|
|
symbol=full_symbol,
|
|
|
side=side_from_fill,
|
|
|
- entry_order_id=exchange_order_id_from_fill,
|
|
|
+ entry_order_id=exchange_order_id_from_fill or f"external_{trade_id}",
|
|
|
trade_type="external"
|
|
|
)
|
|
|
|
|
|
if lifecycle_id:
|
|
|
- # Update the lifecycle to position_opened status
|
|
|
success = stats.update_trade_position_opened(
|
|
|
lifecycle_id=lifecycle_id,
|
|
|
entry_price=price_from_fill,
|
|
@@ -373,76 +506,98 @@ class ExternalEventMonitor:
|
|
|
logger.info(f"📈 Created and opened new external lifecycle: {lifecycle_id[:8]} for {full_symbol}")
|
|
|
symbols_with_fills.add(token)
|
|
|
|
|
|
- # Send notification for new external position
|
|
|
- if self.notification_manager:
|
|
|
- await self.notification_manager.send_external_trade_notification(
|
|
|
- full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
- "position_opened", timestamp_dt.isoformat()
|
|
|
- )
|
|
|
+ # Send position opened notification
|
|
|
+ await self._send_position_change_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ action_type, timestamp_dt
|
|
|
+ )
|
|
|
fill_processed_this_iteration = True
|
|
|
|
|
|
- # If we didn't create a lifecycle, fall back to the old behavior
|
|
|
- if not fill_processed_this_iteration:
|
|
|
- all_open_positions_in_db = stats.get_open_positions()
|
|
|
- db_open_symbols = {pos_db.get('symbol') for pos_db in all_open_positions_in_db}
|
|
|
+ elif action_type == 'position_closed' and existing_lc:
|
|
|
+ # Close existing lifecycle
|
|
|
+ lc_id = existing_lc['trade_lifecycle_id']
|
|
|
+ lc_entry_price = existing_lc.get('entry_price', 0)
|
|
|
+ lc_position_side = existing_lc.get('position_side')
|
|
|
|
|
|
- if full_symbol in db_open_symbols:
|
|
|
- logger.error(f"🚨 DIAGNOSTIC: Contradiction for {full_symbol}! get_open_positions() includes it, but get_trade_by_symbol_and_status('{full_symbol}', 'position_opened') failed to find it within _check_external_trades context for fill {trade_id}. This needs investigation into TradingStats symbol querying.")
|
|
|
-
|
|
|
- potential_match_failure_logged = False
|
|
|
- if not stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened'):
|
|
|
- logger.warning(f"⚠️ DIAGNOSTIC for UNMATCHED FILL {trade_id} ({full_symbol}):")
|
|
|
- logger.warning(f" Fill details: Side={side_from_fill}, Amount={amount_from_fill}, Price={price_from_fill}")
|
|
|
- logger.warning(f" Attempted lookup with full_symbol='{full_symbol}' and status='position_opened' found NO active lifecycle.")
|
|
|
- if all_open_positions_in_db:
|
|
|
- logger.warning(f" However, DB currently has these 'position_opened' lifecycles (symbol - lifecycle_id):")
|
|
|
- for db_pos in all_open_positions_in_db:
|
|
|
- logger.warning(f" - '{db_pos.get('symbol')}' - ID: {db_pos.get('trade_lifecycle_id')}")
|
|
|
- base_token_fill = full_symbol.split('/')[0].split(':')[0]
|
|
|
- near_matches = [db_s for db_s in db_open_symbols if base_token_fill in db_s]
|
|
|
- if near_matches:
|
|
|
- logger.warning(f" Possible near matches in DB for base token '{base_token_fill}': {near_matches}")
|
|
|
- else:
|
|
|
- logger.warning(f" No near matches found in DB for base token '{base_token_fill}'.")
|
|
|
- else:
|
|
|
- logger.warning(" DB has NO 'position_opened' lifecycles at all right now.")
|
|
|
- potential_match_failure_logged = True
|
|
|
-
|
|
|
- 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"🔗 Fallback: Fill {trade_id} for OID {exchange_order_id_from_fill} (DB ID {linked_order_db_id}) not tied to active lifecycle step.")
|
|
|
- current_status = order_in_db.get('status', '')
|
|
|
- if current_status in ['open', 'partially_filled', 'pending_submission']:
|
|
|
- amt_req = float(order_in_db.get('amount_requested', 0))
|
|
|
- amt_filled_so_far = float(order_in_db.get('amount_filled',0))
|
|
|
- new_status = 'partially_filled'
|
|
|
- if (amt_filled_so_far + amount_from_fill) >= amt_req - 1e-9:
|
|
|
- new_status = 'filled'
|
|
|
- stats.update_order_status(
|
|
|
- order_db_id=linked_order_db_id, new_status=new_status,
|
|
|
- amount_filled_increment=amount_from_fill
|
|
|
- )
|
|
|
- logger.info(f"📊 Updated bot order {linked_order_db_id} (fallback): {current_status} → {new_status}")
|
|
|
+ realized_pnl = 0
|
|
|
+ if lc_position_side == 'long':
|
|
|
+ realized_pnl = amount_from_fill * (price_from_fill - lc_entry_price)
|
|
|
+ elif lc_position_side == 'short':
|
|
|
+ realized_pnl = amount_from_fill * (lc_entry_price - price_from_fill)
|
|
|
|
|
|
- if not (hasattr(stats, 'get_trade_by_exchange_fill_id') and stats.get_trade_by_exchange_fill_id(trade_id)):
|
|
|
- stats.record_trade(
|
|
|
- full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
- exchange_fill_id=trade_id, trade_type="external_unmatched",
|
|
|
- timestamp=timestamp_dt.isoformat(),
|
|
|
- linked_order_table_id_to_link=linked_order_db_id
|
|
|
+ success = stats.update_trade_position_closed(
|
|
|
+ lifecycle_id=lc_id,
|
|
|
+ exit_price=price_from_fill,
|
|
|
+ realized_pnl=realized_pnl,
|
|
|
+ exchange_fill_id=trade_id
|
|
|
+ )
|
|
|
+ if success:
|
|
|
+ pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
|
|
|
+ formatter = get_formatter()
|
|
|
+ logger.info(f"{pnl_emoji} Lifecycle CLOSED (External): {lc_id}. PNL: {formatter.format_price_with_symbol(realized_pnl)}")
|
|
|
+ symbols_with_fills.add(token)
|
|
|
+
|
|
|
+ # Send position closed notification
|
|
|
+ await self._send_position_change_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ action_type, timestamp_dt, existing_lc, realized_pnl
|
|
|
)
|
|
|
- logger.info(f"📋 Recorded trade via FALLBACK: {trade_id} (Unmatched External Fill)")
|
|
|
|
|
|
- # Send notification for unmatched external trade
|
|
|
- if self.notification_manager:
|
|
|
- await self.notification_manager.send_external_trade_notification(
|
|
|
- full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
- "external_unmatched", timestamp_dt.isoformat()
|
|
|
- )
|
|
|
+ stats._migrate_trade_to_aggregated_stats(lc_id)
|
|
|
+ fill_processed_this_iteration = True
|
|
|
+
|
|
|
+ elif action_type in ['position_increased', 'position_decreased'] and existing_lc:
|
|
|
+ # Update lifecycle position size and send notification
|
|
|
+ current_positions = self.trading_engine.get_positions() or []
|
|
|
+ new_size = 0
|
|
|
+ for pos in current_positions:
|
|
|
+ if pos.get('symbol') == full_symbol:
|
|
|
+ new_size = abs(float(pos.get('contracts', 0)))
|
|
|
+ break
|
|
|
+
|
|
|
+ # Update lifecycle with new position size
|
|
|
+ await self._update_lifecycle_position_size(existing_lc['trade_lifecycle_id'], new_size)
|
|
|
+
|
|
|
+ # Send appropriate notification
|
|
|
+ await self._send_position_change_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ action_type, timestamp_dt, existing_lc
|
|
|
+ )
|
|
|
+
|
|
|
+ symbols_with_fills.add(token)
|
|
|
fill_processed_this_iteration = True
|
|
|
+ logger.info(f"📊 Position {action_type}: {full_symbol} new size: {new_size}")
|
|
|
+
|
|
|
+ # Fallback for unmatched external trades
|
|
|
+ if not fill_processed_this_iteration:
|
|
|
+ all_open_positions_in_db = stats.get_open_positions()
|
|
|
+ db_open_symbols = {pos_db.get('symbol') for pos_db in all_open_positions_in_db}
|
|
|
+
|
|
|
+ if full_symbol in db_open_symbols:
|
|
|
+ logger.error(f"🚨 DIAGNOSTIC: Contradiction for {full_symbol}! get_open_positions() includes it, but get_trade_by_symbol_and_status('{full_symbol}', 'position_opened') failed to find it within _check_external_trades context for fill {trade_id}. This needs investigation into TradingStats symbol querying.")
|
|
|
+
|
|
|
+ # Record as unmatched external trade
|
|
|
+ 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')
|
|
|
+
|
|
|
+ stats.record_trade(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ exchange_fill_id=trade_id, trade_type="external_unmatched",
|
|
|
+ timestamp=timestamp_dt.isoformat(),
|
|
|
+ linked_order_table_id_to_link=linked_order_db_id
|
|
|
+ )
|
|
|
+ logger.info(f"📋 Recorded trade via FALLBACK: {trade_id} (Unmatched External Fill)")
|
|
|
+
|
|
|
+ # Send generic notification for unmatched trade
|
|
|
+ if self.notification_manager:
|
|
|
+ await self.notification_manager.send_external_trade_notification(
|
|
|
+ full_symbol, side_from_fill, amount_from_fill, price_from_fill,
|
|
|
+ "external_unmatched", timestamp_dt.isoformat()
|
|
|
+ )
|
|
|
+ fill_processed_this_iteration = True
|
|
|
|
|
|
if fill_processed_this_iteration:
|
|
|
external_trades_processed += 1
|