浏览代码

Refactor ROE percentage handling across multiple components to improve data consistency and clarity. Updated positions command to retrieve 'roe_percentage' from the database, enhanced SimplePositionTracker and MarketMonitor to extract and utilize ROE from exchange responses, and modified TradeLifecycleManager to accommodate the new data structure. This ensures a unified approach to financial data representation throughout the application.

Carles Sentis 6 天之前
父节点
当前提交
82fd062c25

+ 6 - 0
print_ccxt_positions.py

@@ -0,0 +1,6 @@
+from src.clients.hyperliquid_client import HyperliquidClient
+import pprint
+
+client = HyperliquidClient()
+positions = client.get_positions()
+pprint.pprint(positions) 

+ 1 - 1
src/commands/info/positions.py

@@ -82,7 +82,7 @@ class PositionsCommands(InfoCommandsBase):
                     unrealized_pnl = unrealized_pnl or 0.0
 
                     # ROE Percentage from database
-                    roe_percentage = position_trade.get('unrealized_pnl_percentage', 0.0)
+                    roe_percentage = position_trade.get('roe_percentage', 0.0)
 
                     # Add to totals
                     individual_position_value = position_trade.get('position_value')

+ 2 - 1
src/migrations/migrate_db.py

@@ -45,7 +45,8 @@ TRADES_TABLE_SCHEMA = {
     "position_opened_at": "TEXT",
     "position_closed_at": "TEXT",
     "updated_at": "TEXT DEFAULT CURRENT_TIMESTAMP", # Ensure this default is correctly applied by SQLite
-    "notes": "TEXT"
+    "notes": "TEXT",
+    "roe_percentage": "REAL DEFAULT NULL"
 }
 
 TOKEN_STATS_TABLE_SCHEMA = {

+ 14 - 13
src/monitoring/market_monitor.py

@@ -333,22 +333,23 @@ class MarketMonitor:
                             if position_value is None and mark_price is not None and current_position_size is not None:
                                 position_value = abs(current_position_size) * mark_price
 
-                            roe_from_ex = ex_pos.get('returnOnEquity')
-                            # The exchange provides ROE as a decimal (e.g., -0.326 for -32.6%)
-                            # We need to multiply by 100 and keep the sign
-                            unrealized_pnl_percentage_val = float(roe_from_ex) * 100 if roe_from_ex is not None else None
-
-                            stats.update_trade_market_data(
-                                trade_lifecycle_id=lifecycle_id, 
-                                unrealized_pnl=unrealized_pnl, 
-                                mark_price=mark_price,
+                            # Extract ROE from info.position.returnOnEquity
+                            roe_raw = None
+                            if 'info' in ex_pos and 'position' in ex_pos['info']:
+                                roe_raw = ex_pos['info']['position'].get('returnOnEquity')
+                            roe_percentage = float(roe_raw) * 100 if roe_raw is not None else None
+
+                            # Update position size using existing manager
+                            success = stats.trade_manager.update_trade_market_data(
+                                lifecycle_id, 
                                 current_position_size=current_position_size,
-                                entry_price=entry_price, 
-                                liquidation_price=liquidation_price,
+                                unrealized_pnl=unrealized_pnl,
+                                roe_percentage=roe_percentage,
+                                mark_price=mark_price,
+                                position_value=position_value,
                                 margin_used=margin_used,
                                 leverage=leverage,
-                                position_value=position_value,
-                                unrealized_pnl_percentage=unrealized_pnl_percentage_val
+                                liquidation_price=liquidation_price
                             )
                         except (ValueError, TypeError) as e:
                             logger.warning(f"Could not parse full market data for {symbol} (Lifecycle: {lifecycle_id}) from {ex_pos}: {e}")

+ 17 - 9
src/monitoring/simple_position_tracker.py

@@ -111,7 +111,13 @@ class SimplePositionTracker:
             if not entry_price:
                 logger.error(f"❌ Cannot determine entry price for {symbol}")
                 return
-            
+
+            # Extract ROE from info.position.returnOnEquity
+            roe_raw = None
+            if 'info' in exchange_pos and 'position' in exchange_pos['info']:
+                roe_raw = exchange_pos['info']['position'].get('returnOnEquity')
+            roe_percentage = float(roe_raw) * 100 if roe_raw is not None else 0.0
+
             # Create trade lifecycle using existing manager
             lifecycle_id = stats.create_trade_lifecycle(
                 symbol=symbol,
@@ -128,7 +134,7 @@ class SimplePositionTracker:
                     entry_amount=size,
                     exchange_fill_id=f"position_detected_{timestamp.isoformat()}",
                     unrealized_pnl=exchange_pos.get('unrealizedPnl'),
-                    unrealized_pnl_percentage=exchange_pos.get('returnOnEquity', 0.0) * 100  # Convert to percentage
+                    roe_percentage=roe_percentage
                 )
                 
                 if success:
@@ -141,7 +147,6 @@ class SimplePositionTracker:
                         'price': entry_price,
                         'timestamp': timestamp
                     })
-                    
         except Exception as e:
             logger.error(f"❌ Error handling position opened for {symbol}: {e}")
     
@@ -242,15 +247,18 @@ class SimplePositionTracker:
             lifecycle_id = db_pos['trade_lifecycle_id']
             entry_price = db_pos.get('entry_price', 0)
             
-            # Get ROE from exchange position
-            roe = exchange_pos.get('returnOnEquity', 0.0) * 100  # Convert to percentage
-            
+            # Extract ROE from info.position.returnOnEquity
+            roe_raw = None
+            if 'info' in exchange_pos and 'position' in exchange_pos['info']:
+                roe_raw = exchange_pos['info']['position'].get('returnOnEquity')
+            roe_percentage = float(roe_raw) * 100 if roe_raw is not None else 0.0
+
             # Update position size using existing manager
             success = stats.trade_manager.update_trade_market_data(
                 lifecycle_id, 
                 current_position_size=exchange_size,
                 unrealized_pnl=exchange_pos.get('unrealizedPnl'),
-                unrealized_pnl_percentage=roe,  # Store ROE as percentage
+                roe_percentage=roe_percentage,
                 mark_price=exchange_pos.get('markPrice'),
                 position_value=exchange_pos.get('positionValue'),
                 margin_used=exchange_pos.get('marginUsed'),
@@ -261,7 +269,7 @@ class SimplePositionTracker:
             if success:
                 change_type = 'increased' if exchange_size > db_size else 'decreased'
                 size_diff = abs(exchange_size - db_size)
-                logger.info(f"📊 Position size {change_type}: {symbol} by {size_diff} (ROE: {roe:+.2f}%)")
+                logger.info(f"📊 Position size {change_type}: {symbol} by {size_diff} (ROE: {roe_percentage:+.2f}%)")
                 
                 # Send notification
                 await self._send_position_notification('size_changed', symbol, {
@@ -270,7 +278,7 @@ class SimplePositionTracker:
                     'new_size': exchange_size,
                     'change_type': change_type,
                     'size_diff': size_diff,
-                    'roe': roe,
+                    'roe': roe_percentage,
                     'timestamp': timestamp
                 })
                 

+ 25 - 39
src/stats/trade_lifecycle_manager.py

@@ -321,42 +321,36 @@ class TradeLifecycleManager:
             logger.error(f"❌ Error confirming position with exchange: {e}")
             return False
 
-    def update_trade_market_data(self, 
-                                 trade_lifecycle_id: str, 
-                                 unrealized_pnl: Optional[float] = None, 
-                                 mark_price: Optional[float] = None,
-                                 current_position_size: Optional[float] = None,
-                                 entry_price: Optional[float] = None,
-                                 liquidation_price: Optional[float] = None,
-                                 margin_used: Optional[float] = None,
-                                 leverage: Optional[float] = None,
-                                 position_value: Optional[float] = None,
-                                 unrealized_pnl_percentage: Optional[float] = None) -> bool:
+    def update_trade_market_data(self, lifecycle_id: str, current_position_size: Optional[float] = None,
+                                 unrealized_pnl: Optional[float] = None, roe_percentage: Optional[float] = None,
+                                 mark_price: Optional[float] = None, position_value: Optional[float] = None,
+                                 margin_used: Optional[float] = None, leverage: Optional[float] = None,
+                                 liquidation_price: Optional[float] = None) -> bool:
         """Update market data for a trade lifecycle."""
         try:
             # Build dynamic update query based on provided fields
             update_fields = []
             params = []
             
+            if current_position_size is not None:
+                update_fields.append("current_position_size = ?")
+                params.append(current_position_size)
+            
             if unrealized_pnl is not None:
                 update_fields.append("unrealized_pnl = ?")
                 params.append(unrealized_pnl)
             
+            if roe_percentage is not None:
+                update_fields.append("roe_percentage = ?")
+                params.append(roe_percentage)
+            
             if mark_price is not None:
                 update_fields.append("mark_price = ?")
                 params.append(mark_price)
             
-            if current_position_size is not None:
-                update_fields.append("current_position_size = ?")
-                params.append(current_position_size)
-            
-            if entry_price is not None:
-                update_fields.append("entry_price = ?")
-                params.append(entry_price)
-            
-            if liquidation_price is not None:
-                update_fields.append("liquidation_price = ?")
-                params.append(liquidation_price)
+            if position_value is not None:
+                update_fields.append("position_value = ?")
+                params.append(position_value)
             
             if margin_used is not None:
                 update_fields.append("margin_used = ?")
@@ -366,35 +360,27 @@ class TradeLifecycleManager:
                 update_fields.append("leverage = ?")
                 params.append(leverage)
             
-            if position_value is not None:
-                update_fields.append("position_value = ?")
-                params.append(position_value)
-            
-            if unrealized_pnl_percentage is not None:
-                update_fields.append("unrealized_pnl_percentage = ?")
-                params.append(unrealized_pnl_percentage)
+            if liquidation_price is not None:
+                update_fields.append("liquidation_price = ?")
+                params.append(liquidation_price)
             
             if not update_fields:
-                logger.warning(f"No fields to update for trade {trade_lifecycle_id}")
                 return False
             
             # Add timestamp and lifecycle_id
-            timestamp = datetime.now(timezone.utc).isoformat()
-            update_fields.append("updated_at = ?")
-            params.append(timestamp)
-            params.append(trade_lifecycle_id)
+            update_fields.append("updated_at = CURRENT_TIMESTAMP")
+            params.append(lifecycle_id)
             
             query = f"""
-                UPDATE trades 
+                UPDATE trades
                 SET {', '.join(update_fields)}
-                WHERE trade_lifecycle_id = ? AND status = 'position_opened'
+                WHERE trade_lifecycle_id = ?
             """
             
-            self.db._execute_query(query, params)
+            self.db.execute_query(query, params)
             return True
-            
         except Exception as e:
-            logger.error(f"Error updating trade market data: {e}")
+            logger.error(f"Error updating trade market data: {e}")
             return False
 
     def get_recent_trades(self, limit: int = 10) -> List[Dict[str, Any]]:

+ 114 - 0
tests/test_position_roe.py

@@ -0,0 +1,114 @@
+import unittest
+import asyncio
+from unittest.mock import Mock, patch, AsyncMock
+from datetime import datetime, timezone
+from src.monitoring.simple_position_tracker import SimplePositionTracker
+from src.commands.info.positions import PositionsCommands
+
+class TestPositionROE(unittest.TestCase):
+    def setUp(self):
+        self.trading_engine = Mock()
+        self.notification_manager = Mock()
+        self.position_tracker = SimplePositionTracker(self.trading_engine, self.notification_manager)
+        self.stats = Mock()
+        self.timestamp = datetime.now(timezone.utc)
+
+    async def async_test_position_size_change_roe_calculation(self):
+        """Test ROE calculation during position size changes."""
+        # Mock exchange position data
+        exchange_pos = {
+            'contracts': 0.01,
+            'side': 'long',
+            'entryPrice': 50000.0,
+            'unrealizedPnl': -16.21,
+            'info': {
+                'position': {
+                    'returnOnEquity': '-0.324'  # -32.4%
+                }
+            }
+        }
+        
+        # Mock database position data
+        db_pos = {
+            'trade_lifecycle_id': 'test_lifecycle',
+            'current_position_size': 0.005,
+            'position_side': 'long',
+            'entry_price': 50000.0
+        }
+        
+        # Mock stats manager
+        self.stats.trade_manager.update_trade_market_data = AsyncMock(return_value=True)
+        
+        # Call the method
+        await self.position_tracker._handle_position_size_change('BTC/USD', exchange_pos, db_pos, self.stats, self.timestamp)
+        
+        # Verify the call
+        call_args = self.stats.trade_manager.update_trade_market_data.call_args[1]
+        self.assertEqual(call_args['roe_percentage'], -32.4)  # Should match exchange ROE
+
+    def test_position_size_change_roe_calculation(self):
+        """Test ROE calculation during position size changes."""
+        asyncio.run(self.async_test_position_size_change_roe_calculation())
+
+    async def async_test_position_opened_roe_calculation(self):
+        """Test ROE calculation when position is opened."""
+        # Mock exchange position data
+        exchange_pos = {
+            'contracts': 0.01,
+            'side': 'long',
+            'entryPrice': 50000.0,
+            'unrealizedPnl': -16.21,
+            'info': {
+                'position': {
+                    'returnOnEquity': '-0.324'  # -32.4%
+                }
+            }
+        }
+        
+        # Mock stats manager
+        self.stats.create_trade_lifecycle = AsyncMock(return_value='test_lifecycle')
+        self.stats.update_trade_position_opened = AsyncMock(return_value=True)
+        
+        # Call the method
+        await self.position_tracker._handle_position_opened('BTC/USD', exchange_pos, self.stats, self.timestamp)
+        
+        # Verify the call
+        call_args = self.stats.update_trade_position_opened.call_args[1]
+        self.assertEqual(call_args['roe_percentage'], -32.4)  # Should match exchange ROE
+
+    def test_position_opened_roe_calculation(self):
+        """Test ROE calculation when position is opened."""
+        asyncio.run(self.async_test_position_opened_roe_calculation())
+
+    async def async_test_positions_command_roe_display(self):
+        """Test ROE display in positions command."""
+        # Mock open positions data
+        open_positions = [{
+            'symbol': 'BTC/USD',
+            'position_side': 'long',
+            'current_position_size': 0.01,
+            'entry_price': 50000.0,
+            'duration': '1h',
+            'unrealized_pnl': -16.21,
+            'roe_percentage': -32.4,  # ROE from database
+            'position_value': 500.0,
+            'margin_used': 50.0,
+            'leverage': 10.0,
+            'liquidation_price': 45000.0
+        }]
+        
+        # Mock stats manager
+        self.stats.get_open_positions = AsyncMock(return_value=open_positions)
+        
+        # Call the positions command
+        result = await positions_command(None, self.stats)
+        
+        # Verify the output
+        self.assertIn('-32.4%', result)  # Should display ROE correctly
+
+    def test_positions_command_roe_display(self):
+        """Test ROE display in positions command."""
+        asyncio.run(self.async_test_positions_command_roe_display())
+
+if __name__ == '__main__':
+    unittest.main() 

+ 1 - 1
trading_bot.py

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