Parcourir la source

Enhance position notification system.

- Introduced a new documentation file for position notifications detailing enhancements in tracking and notifications for position state changes.
- Enhanced the ExternalEventMonitor to accurately determine position action types (opened, closed, increased, decreased) and send corresponding notifications.
- Improved NotificationManager to format and send detailed notifications for position changes, including real-time P&L calculations and reduced notification noise.
Carles Sentis il y a 1 jour
Parent
commit
13203deadd

+ 9 - 1
docs/documentation-index.md

@@ -10,7 +10,8 @@
 
 ## 🏗️ **Technical Reference**
 **[📖 Project Structure](project-structure.md)** - Detailed architecture and module breakdown  
-**[📖 System Integration](system-integration.md)** - Advanced integration and customization
+**[📖 System Integration](system-integration.md)** - Advanced integration and customization  
+**[📖 Position Notifications](position-notifications.md)** - Enhanced position tracking and notifications
 
 ## 🚀 **Production Deployment**
 **[📖 Deployment Guide](deployment.md)** - Server setup, process management, and monitoring
@@ -23,6 +24,7 @@
 | **[commands.md](commands.md)** | **Daily trading commands** | 10 minutes | Traders |
 | **[project-structure.md](project-structure.md)** | **Code architecture** | 15 minutes | Developers |
 | **[system-integration.md](system-integration.md)** | **Advanced setup** | 20 minutes | Advanced users |
+| **[position-notifications.md](position-notifications.md)** | **Position tracking features** | 10 minutes | Traders & Developers |
 | **[deployment.md](deployment.md)** | **Production setup** | 30 minutes | System admins |
 
 ## 🎯 **What You'll Find in Each Guide**
@@ -51,6 +53,12 @@
 - 🛡️ Security considerations
 - 🔄 Custom workflow setup
 
+### **📖 position-notifications.md**
+- 🔔 Enhanced position tracking system
+- 📊 Distinct notification types (opened/closed/increased/decreased)
+- 💰 Real-time P&L calculations
+- 🎯 Reduced notification noise
+
 ### **📖 deployment.md**
 - 🚀 Production server setup
 - 📊 Process management and monitoring

+ 152 - 0
docs/position-notifications.md

@@ -0,0 +1,152 @@
+# Position Notification Improvements
+
+## Overview
+
+Enhanced the external event monitoring system to provide clear, distinct notifications for all position state changes. The system now properly distinguishes between position opening, closing, size increases, and size decreases.
+
+## Key Changes
+
+### 1. Enhanced External Event Monitor (`src/monitoring/external_event_monitor.py`)
+
+**Added Methods:**
+- `_determine_position_action_type()`: Analyzes fills and current exchange state to determine the type of position action
+- `_update_lifecycle_position_size()`: Updates position size in the lifecycle when positions change
+- `_send_position_change_notification()`: Sends appropriate notifications based on position action type
+
+**Improved Logic:**
+- Better distinction between position opening, closing, increasing, and decreasing
+- Real-time position size tracking and updates
+- Enhanced PnL calculations for partial position changes
+
+### 2. Enhanced Notification Manager (`src/notifications/notification_manager.py`)
+
+**Added Support:**
+- `position_decreased` action type in `send_external_trade_notification()`
+- Detailed notifications showing before/after position sizes
+- Partial PnL calculations for position decreases
+
+## Notification Types
+
+### 1. Position Opened 🚀
+**When:** New position created from zero
+**Includes:**
+- Token and direction (LONG/SHORT)
+- Entry size and price
+- Total position value
+- Timestamp
+
+### 2. Position Closed 🎯
+**When:** Position fully closed to zero
+**Includes:**
+- Token and direction
+- Size closed and exit price
+- Entry vs exit price comparison
+- Realized P&L with color coding
+- Performance impact note
+
+### 3. Position Increased 📈
+**When:** Existing position size increased
+**Includes:**
+- Token and direction
+- Size added and add price
+- Previous size vs new size
+- Value of addition
+- Current position status
+
+### 4. Position Decreased 📉
+**When:** Existing position partially closed
+**Includes:**
+- Token and direction
+- Size reduced and exit price
+- Previous size vs remaining size
+- Partial P&L for reduced amount
+- Remaining position note
+
+## Benefits
+
+### 1. Clear Position State Tracking
+- No more confusion about whether a notification is for opening vs increasing position
+- Distinct notifications for each type of position change
+- Real-time position size updates in the database
+
+### 2. Better PnL Visibility
+- Immediate P&L calculations for position closures
+- Partial P&L for position decreases
+- Color-coded profit/loss indicators
+
+### 3. Reduced Notification Noise
+- Single notification per position change (no duplicate order fill notifications)
+- Consolidated information in each notification
+- Clear distinction between bot-initiated and external trades
+
+### 4. Enhanced Decision Making
+- Clear visibility into position size changes
+- Better understanding of trading activity
+- Improved risk management through real-time updates
+
+## Technical Implementation
+
+### Position State Detection Algorithm
+
+```
+1. Check if existing lifecycle exists for symbol
+2. Get current position size from exchange
+3. Compare with previous position size from lifecycle
+4. Determine action based on:
+   - No lifecycle + exchange position = position_opened
+   - Lifecycle exists + no exchange position = position_closed
+   - Same side trade + larger size = position_increased
+   - Opposite side trade + smaller size = position_decreased
+```
+
+### Lifecycle Updates
+
+The system now properly updates the `current_position_size` field in trade lifecycles, ensuring accurate tracking of position changes over time.
+
+### Notification Flow
+
+```
+Fill Detected → Action Type Determined → Lifecycle Updated → Notification Sent
+```
+
+## Future Enhancements
+
+### Potential Additions
+1. Position flip detection (LONG to SHORT or vice versa)
+2. Average entry price tracking for multiple entries
+3. Position size alerts (e.g., when position exceeds certain thresholds)
+4. Integration with risk management alerts
+
+### Configuration Options
+1. Notification filtering by position size
+2. Custom P&L thresholds for notifications
+3. Different notification styles for different action types
+
+## Migration Notes
+
+### Backwards Compatibility
+- Existing notification methods remain functional
+- No breaking changes to current bot functionality
+- Enhanced notifications work alongside existing position synchronizer
+
+### Database Updates
+- Uses existing `current_position_size` field in trade lifecycles
+- No schema changes required
+- Leverages existing `update_trade_market_data()` method
+
+## Testing Recommendations
+
+### Test Scenarios
+1. External position opening from zero
+2. External position size increase
+3. External position size decrease (partial close)
+4. External position full close
+5. Multiple consecutive size changes
+6. Position changes while bot orders are active
+
+### Verification Points
+- Notification accuracy and timing
+- Position size tracking in database
+- P&L calculations correctness
+- No duplicate notifications
+- Proper lifecycle state management 

+ 353 - 198
src/monitoring/external_event_monitor.py

@@ -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

+ 16 - 0
src/notifications/notification_manager.py

@@ -338,6 +338,22 @@ class NotificationManager:
 
 📈 Use /positions to view current position
             """
+        elif action_type == "position_decreased":
+            message = f"""
+📉 <b>Position Decreased (External)</b>
+
+📊 <b>Trade Details:</b>
+• Token: {token}
+• Direction: {side.upper()}
+• Reduced Size: {amount} {token}
+• Price: ${price:,.2f}
+• Value: ${amount * price:,.2f}
+
+✅ <b>Status:</b> Position size decreased externally
+⏰ <b>Time:</b> {time_str}
+
+📊 Position remains open. Use /positions to view details
+            """
         else:
             # Generic external trade notification
             side_emoji = "🟢" if side.lower() == 'buy' else "🔴"

+ 1 - 1
trading_bot.py

@@ -14,7 +14,7 @@ from datetime import datetime
 from pathlib import Path
 
 # Bot version
-BOT_VERSION = "2.3.158"
+BOT_VERSION = "2.3.159"
 
 # Add src directory to Python path
 sys.path.insert(0, str(Path(__file__).parent / "src"))