瀏覽代碼

Enhance InfoCommands and TradingStats for detailed token statistics reporting

- Introduced a new method in InfoCommands to format and display detailed statistics for specific tokens, including completed trades and open positions.
- Updated the stats_command to handle both overall and token-specific statistics, improving user experience and clarity.
- Enhanced TradingStats to calculate and report total trading volume, best and worst performing tokens, and integrated these metrics into the overall statistics display.
- Improved error handling and logging for better feedback during statistics retrieval.
Carles Sentis 2 天之前
父節點
當前提交
40b39cecdb
共有 2 個文件被更改,包括 167 次插入160 次删除
  1. 97 100
      src/commands/info_commands.py
  2. 70 60
      src/trading/trading_stats.py

+ 97 - 100
src/commands/info_commands.py

@@ -390,8 +390,75 @@ class InfoCommands:
             logger.error(f"Error in orders command: {e}")
             await reply_method(text="❌ Error retrieving open orders.", parse_mode='HTML')
     
+    async def _format_token_specific_stats_message(self, token_stats_data: Dict[str, Any], token_name: str) -> str:
+        """Format detailed statistics for a specific token."""
+        formatter = get_formatter()
+        
+        if not token_stats_data or token_stats_data.get('summary_total_trades', 0) == 0: # Check summary_total_trades
+            return (
+                f"📊 <b>{token_name} Statistics</b>\\n\\n"
+                f"📭 No trading data found for {token_name}.\\n\\n"
+                f"💡 To trade this token, try commands like:\\n"
+                f"   <code>/long {token_name} 100</code>\\n"
+                f"   <code>/short {token_name} 100</code>"
+            )
+
+        perf_summary = token_stats_data.get('performance_summary', {})
+        open_positions = token_stats_data.get('open_positions', [])
+        
+        parts = [f"📊 <b>{token_name.upper()} Detailed Statistics</b>\\n"]
+
+        # Completed Trades Summary (from token_stats table)
+        parts.append("📈 <b>Completed Trades Summary:</b>")
+        if perf_summary.get('completed_trades', 0) > 0:
+            pnl_emoji = "🟢" if perf_summary.get('total_pnl', 0) >= 0 else "🔴"
+            parts.append(f"• Total Completed: {perf_summary.get('completed_trades', 0)}")
+            parts.append(f"• {pnl_emoji} Realized P&L: {formatter.format_price_with_symbol(perf_summary.get('total_pnl', 0.0))} ({perf_summary.get('pnl_percentage', 0.0):+.2f}%)")
+            parts.append(f"• Win Rate: {perf_summary.get('win_rate', 0.0):.1f}% ({perf_summary.get('total_wins', 0)}W / {perf_summary.get('total_losses', 0)}L)")
+            parts.append(f"• Profit Factor: {perf_summary.get('profit_factor', 0.0):.2f}")
+            parts.append(f"• Expectancy: {formatter.format_price_with_symbol(perf_summary.get('expectancy', 0.0))} (Value per trade)")
+            parts.append(f"• Avg Win: {formatter.format_price_with_symbol(perf_summary.get('avg_win', 0.0))} | Avg Loss: {formatter.format_price_with_symbol(perf_summary.get('avg_loss', 0.0))}")
+            parts.append(f"• Largest Win: {formatter.format_price_with_symbol(perf_summary.get('largest_win', 0.0))} | Largest Loss: {formatter.format_price_with_symbol(perf_summary.get('largest_loss', 0.0))}")
+            parts.append(f"• Entry Volume: {formatter.format_price_with_symbol(perf_summary.get('completed_entry_volume', 0.0))}")
+            parts.append(f"• Exit Volume: {formatter.format_price_with_symbol(perf_summary.get('completed_exit_volume', 0.0))}")
+            parts.append(f"• Cancelled Cycles: {perf_summary.get('total_cancelled', 0)}")
+        else:
+            parts.append("• No completed trades for this token yet.")
+        parts.append("") # Newline
+
+        # Open Positions for this token
+        parts.append("📉 <b>Current Open Positions:</b>")
+        if open_positions:
+            total_open_unrealized_pnl = token_stats_data.get('summary_total_unrealized_pnl', 0.0)
+            open_pnl_emoji = "🟢" if total_open_unrealized_pnl >= 0 else "🔴"
+            
+            for pos in open_positions:
+                pos_side_emoji = "🟢" if pos.get('side') == 'long' else "🔴"
+                pos_pnl_emoji = "🟢" if pos.get('unrealized_pnl', 0) >= 0 else "🔴"
+                opened_at_str = "N/A"
+                if pos.get('opened_at'):
+                    try:
+                        opened_at_dt = datetime.fromisoformat(pos['opened_at'])
+                        opened_at_str = opened_at_dt.strftime('%Y-%m-%d %H:%M')
+                    except:
+                        pass # Keep N/A
+                
+                parts.append(f"• {pos_side_emoji} {pos.get('side', '').upper()} {formatter.format_amount(abs(pos.get('amount',0)), token_name)} {token_name}")
+                parts.append(f"    Entry: {formatter.format_price_with_symbol(pos.get('entry_price',0), token_name)} | Mark: {formatter.format_price_with_symbol(pos.get('mark_price',0), token_name)}")
+                parts.append(f"    {pos_pnl_emoji} Unrealized P&L: {formatter.format_price_with_symbol(pos.get('unrealized_pnl',0))}")
+                parts.append(f"    Opened: {opened_at_str} | ID: ...{pos.get('lifecycle_id', '')[-6:]}")
+            parts.append(f"  {open_pnl_emoji} <b>Total Open P&L: {formatter.format_price_with_symbol(total_open_unrealized_pnl)}</b>")
+        else:
+            parts.append("• No open positions for this token.")
+        parts.append("") # Newline
+
+        parts.append(f"📋 Open Orders (Exchange): {token_stats_data.get('current_open_orders_count', 0)}")
+        parts.append(f"💡 Use <code>/performance {token_name}</code> for another view including recent trades.")
+        
+        return "\\n".join(parts)
+
     async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /stats command."""
+        """Handle the /stats command. Shows overall stats or stats for a specific token."""
         chat_id = update.effective_chat.id
         if not self._is_authorized(update):
             return
@@ -403,24 +470,41 @@ class InfoCommands:
             reply_method = update.message.reply_text
         else:
             logger.error("stats_command: Cannot find a method to reply.")
-            await context.bot.send_message(chat_id=Config.TELEGRAM_CHAT_ID, text="Error: Could not determine how to reply.")
+            # Attempt to send to context.bot.send_message if no direct reply method.
+            # It's possible this could be from a button click where original message is gone.
+            target_chat_id = chat_id if chat_id else Config.TELEGRAM_CHAT_ID
+            await context.bot.send_message(chat_id=target_chat_id, text="Error: Could not determine how to reply for /stats command.")
             return
 
         try:
-            # Get current balance for stats
-            balance = self.trading_engine.get_balance()
-            current_balance = 0
-            if balance and balance.get('total'):
-                current_balance = float(balance['total'].get('USDC', 0))
-            
-            stats = self.trading_engine.get_stats()
-            if stats:
-                stats_message = stats.format_stats_message(current_balance)
+            stats_manager = self.trading_engine.get_stats()
+            if not stats_manager:
+                await reply_method(text="❌ Could not load trading statistics manager.", parse_mode='HTML')
+                return
+
+            if context.args and len(context.args) > 0:
+                # Token-specific stats
+                token_name_arg = _normalize_token_case(context.args[0])
+                token_stats_data = stats_manager.get_token_detailed_stats(token_name_arg)
+                
+                if not token_stats_data: # Should not happen if get_token_detailed_stats returns a dict
+                     await reply_method(text=f"❌ Could not fetch detailed stats for {token_name_arg}.", parse_mode='HTML')
+                     return
+
+                stats_message = self._format_token_specific_stats_message(token_stats_data, token_name_arg)
                 await reply_method(text=stats_message, parse_mode='HTML')
             else:
-                await reply_method(text="❌ Could not load trading statistics", parse_mode='HTML')
+                # Overall stats
+                balance = self.trading_engine.get_balance()
+                current_balance = 0
+                if balance and balance.get('total') and 'USDC' in balance['total']:
+                    current_balance = float(balance['total']['USDC'])
+                
+                stats_message = stats_manager.format_stats_message(current_balance)
+                await reply_method(text=stats_message, parse_mode='HTML')
+                
         except Exception as e:
-            logger.error(f"Error in stats command: {e}", exc_info=True) # Added exc_info for more details
+            logger.error(f"Error in stats command: {e}", exc_info=True)
             await reply_method(text="❌ Error retrieving statistics.", parse_mode='HTML')
     
     async def trades_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -498,93 +582,6 @@ class InfoCommands:
             logger.error(f"Error in trades command: {e}")
             await reply_method("❌ Error retrieving trade history.", parse_mode='HTML')
     
-    async def active_trades_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /active command to show active trades (using open positions)."""
-        chat_id = update.effective_chat.id
-        if not self._is_authorized(update):
-            return
-            
-        try:
-            stats = self.trading_engine.get_stats()
-            if not stats:
-                await context.bot.send_message(chat_id=chat_id, text="❌ Could not load trading statistics")
-                return
-            
-            # Get open positions from unified trades table (current active trades)
-            open_positions = stats.get_open_positions()
-            
-            if not open_positions:
-                await context.bot.send_message(
-                    chat_id=chat_id, 
-                    text="📊 <b>Active Positions</b>\n\n📭 No active positions found.\n\n💡 Use /long or /short to open positions.",
-                    parse_mode='HTML'
-                )
-                return
-            
-            message_text = "📊 <b>Active Positions</b>\n\n"
-            
-            # Show each position
-            for position in open_positions:
-                symbol = position['symbol']
-                token = symbol.split('/')[0] if '/' in symbol else symbol
-                position_side = position['position_side']  # 'long' or 'short'
-                entry_price = position['entry_price']
-                current_amount = position['current_position_size']
-                trade_type = position.get('trade_type', 'manual')
-                
-                # Position emoji and formatting
-                if position_side == 'long':
-                    pos_emoji = "🟢"
-                    direction = "LONG"
-                else:  # Short position
-                    pos_emoji = "🔴"
-                    direction = "SHORT"
-                
-                # Trade type indicator
-                type_indicator = ""
-                if trade_type == 'external':
-                    type_indicator = " 🔄"  # External/synced position
-                elif trade_type == 'bot':
-                    type_indicator = " 🤖"  # Bot-created position
-                
-                message_text += f"{pos_emoji} <b>{token} ({direction}){type_indicator}</b>\n"
-                message_text += f"   📏 Size: {abs(current_amount):.6f} {token}\n"
-                message_text += f"   💰 Entry: ${entry_price:.4f}\n"
-                
-                # Show stop loss if linked
-                if position.get('stop_loss_price'):
-                    sl_price = position['stop_loss_price']
-                    sl_status = "Pending" if not position.get('stop_loss_order_id') else "Active"
-                    message_text += f"   🛑 Stop Loss: ${sl_price:.4f} ({sl_status})\n"
-                
-                # Show take profit if linked
-                if position.get('take_profit_price'):
-                    tp_price = position['take_profit_price']
-                    tp_status = "Pending" if not position.get('take_profit_order_id') else "Active"
-                    message_text += f"   🎯 Take Profit: ${tp_price:.4f} ({tp_status})\n"
-                
-                message_text += f"   🆔 Lifecycle ID: {position['trade_lifecycle_id'][:8]}\n\n"
-            
-            # Add summary
-            total_positions = len(open_positions)
-            bot_positions = len([p for p in open_positions if p.get('trade_type') == 'bot'])
-            external_positions = len([p for p in open_positions if p.get('trade_type') == 'external'])
-            
-            message_text += f"📈 <b>Summary:</b>\n"
-            message_text += f"  Total: {total_positions} | "
-            message_text += f"Bot: {bot_positions} | "
-            message_text += f"External: {external_positions}\n\n"
-            
-            message_text += f"🤖 <b>Legend:</b> 🤖 Bot-created • 🔄 External/synced\n"
-            message_text += f"💡 Use /positions for detailed P&L information"
-            
-            await context.bot.send_message(chat_id=chat_id, text=message_text.strip(), parse_mode='HTML')
-            
-        except Exception as e:
-            error_message = f"❌ Error processing active positions command: {str(e)}"
-            await context.bot.send_message(chat_id=chat_id, text=error_message)
-            logger.error(f"Error in active positions command: {e}")
-    
     async def market_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
         """Handle the /market command."""
         chat_id = update.effective_chat.id

+ 70 - 60
src/trading/trading_stats.py

@@ -415,13 +415,43 @@ class TradingStats:
         """
         summary = self._fetchone_query(query)
 
+        # Add total volume
+        volume_summary = self._fetchone_query("SELECT SUM(total_exit_volume) as total_volume FROM token_stats")
+        total_trading_volume = volume_summary['total_volume'] if volume_summary and volume_summary['total_volume'] is not None else 0.0
+
+        # Get individual token performances for best/worst
+        all_token_perf_stats = self.get_token_performance() 
+        best_token_pnl_pct = -float('inf')
+        best_token_name = "N/A"
+        worst_token_pnl_pct = float('inf')
+        worst_token_name = "N/A"
+
+        if all_token_perf_stats:
+            for token_name_iter, stats_data in all_token_perf_stats.items():
+                pnl_pct = stats_data.get('pnl_percentage', 0.0)
+                # Ensure token has completed trades and pnl_pct is a valid number
+                if stats_data.get('completed_trades', 0) > 0 and isinstance(pnl_pct, (int, float)) and not math.isinf(pnl_pct) and not math.isnan(pnl_pct):
+                    if pnl_pct > best_token_pnl_pct:
+                        best_token_pnl_pct = pnl_pct
+                        best_token_name = token_name_iter
+                    if pnl_pct < worst_token_pnl_pct:
+                        worst_token_pnl_pct = pnl_pct
+                        worst_token_name = token_name_iter
+        
+        # Handle cases where no valid tokens were found for best/worst
+        if best_token_name == "N/A":
+            best_token_pnl_pct = 0.0
+        if worst_token_name == "N/A":
+            worst_token_pnl_pct = 0.0
+
         if not summary or summary['total_cycles'] is None or summary['total_cycles'] == 0:
             return {
                 'win_rate': 0.0, 'profit_factor': 0.0, 'avg_win': 0.0, 'avg_loss': 0.0,
                 'largest_win': 0.0, 'largest_loss': 0.0, 
-                # 'consecutive_wins': 0, # Removed
-                # 'consecutive_losses': 0, # Removed
-                'total_wins': 0, 'total_losses': 0, 'expectancy': 0.0
+                'total_wins': 0, 'total_losses': 0, 'expectancy': 0.0,
+                'total_trading_volume': total_trading_volume,
+                'best_performing_token': {'name': best_token_name, 'pnl_percentage': best_token_pnl_pct},
+                'worst_performing_token': {'name': worst_token_name, 'pnl_percentage': worst_token_pnl_pct},
             }
 
         total_completed_count = summary['total_cycles']
@@ -448,9 +478,10 @@ class TradingStats:
         return {
             'win_rate': win_rate, 'profit_factor': profit_factor, 'avg_win': avg_win, 'avg_loss': avg_loss,
             'largest_win': largest_win, 'largest_loss': largest_loss, 
-            # 'consecutive_wins': consecutive_wins, # Removed
-            # 'consecutive_losses': consecutive_losses, # Removed
-            'total_wins': total_wins_count, 'total_losses': total_losses_count, 'expectancy': expectancy
+            'total_wins': total_wins_count, 'total_losses': total_losses_count, 'expectancy': expectancy,
+            'total_trading_volume': total_trading_volume,
+            'best_performing_token': {'name': best_token_name, 'pnl_percentage': best_token_pnl_pct},
+            'worst_performing_token': {'name': worst_token_name, 'pnl_percentage': worst_token_pnl_pct},
         }
 
     def get_risk_metrics(self) -> Dict[str, Any]:
@@ -528,77 +559,56 @@ class TradingStats:
         """Format stats for Telegram display using data from DB."""
         try:
             stats = self.get_comprehensive_stats(current_balance)
-            formatter = get_formatter() # Get formatter
+            formatter = get_formatter()
             
             basic = stats['basic']
             perf = stats['performance']
+            risk = stats['risk'] # For portfolio drawdown
             
             effective_current_balance = stats['current_balance']
             initial_bal = basic['initial_balance']
 
-            total_pnl_val = effective_current_balance - initial_bal if initial_bal > 0 else basic['total_pnl']
+            total_pnl_val = effective_current_balance - initial_bal if initial_bal > 0 and current_balance is not None else basic['total_pnl']
             total_return_pct = (total_pnl_val / initial_bal * 100) if initial_bal > 0 else 0.0
-            
             pnl_emoji = "🟢" if total_pnl_val >= 0 else "🔴"
-            
-            open_positions_count = self._get_open_positions_count_from_db()
+            open_positions_count = basic['open_positions_count']
 
-            sell_trades_data = self._fetch_query("SELECT value FROM trades WHERE side = 'sell'")
-            total_sell_volume = sum(t['value'] for t in sell_trades_data)
-            avg_trade_size_sell = (total_sell_volume / len(sell_trades_data)) if sell_trades_data else 0.0
-            
-            adjustments_summary = self.get_balance_adjustments_summary()
-
-            # Main stats text block
             stats_text_parts = []
             stats_text_parts.append(f"📊 <b>Trading Statistics</b>\n")
+            
+            # Account Overview
             stats_text_parts.append(f"\n💰 <b>Account Overview:</b>")
             stats_text_parts.append(f"• Current Balance: {formatter.format_price_with_symbol(effective_current_balance)}")
             stats_text_parts.append(f"• Initial Balance: {formatter.format_price_with_symbol(initial_bal)}")
-            stats_text_parts.append(f"• {pnl_emoji} Total P&L: {formatter.format_price_with_symbol(total_pnl_val)} ({total_return_pct:+.2f}%)\n")
-            stats_text_parts.append(f"\n📈 <b>Trading Activity:</b>")
-            stats_text_parts.append(f"• Total Orders (Cycles + Open): {basic['total_trades']}") # Clarified meaning
-            stats_text_parts.append(f"• Completed Trades (Cycles): {basic['completed_trades']}")
-            stats_text_parts.append(f"• Open Positions: {open_positions_count}") # Using dedicated count
+            stats_text_parts.append(f"• Open Positions: {open_positions_count}")
+            stats_text_parts.append(f"• {pnl_emoji} Total P&L: {formatter.format_price_with_symbol(total_pnl_val)} ({total_return_pct:+.2f}%)")
             stats_text_parts.append(f"• Days Active: {basic['days_active']}\n")
-            stats_text_parts.append(f"\n🏆 <b>Performance Metrics (Completed Cycles):</b>") # Clarified scope
-            stats_text_parts.append(f"• Win Rate: {perf['win_rate']:.1f}% ({perf['total_wins']}W/{perf['total_losses']}L)")
+            
+            # Performance Metrics
+            stats_text_parts.append(f"\n🏆 <b>Performance Metrics:</b>")
+            stats_text_parts.append(f"• Total Completed Trades: {basic['completed_trades']}")
+            stats_text_parts.append(f"• Trading Volume (Exit Vol.): {formatter.format_price_with_symbol(perf.get('total_trading_volume', 0.0))}")
             stats_text_parts.append(f"• Profit Factor: {perf['profit_factor']:.2f}")
-            stats_text_parts.append(f"• Avg Win: {formatter.format_price_with_symbol(perf['avg_win'])} | Avg Loss: {formatter.format_price_with_symbol(perf['avg_loss'])}")
-            stats_text_parts.append(f"• Largest Win: {formatter.format_price_with_symbol(perf['largest_win'])} | Largest Loss: {formatter.format_price_with_symbol(perf['largest_loss'])}")
-
-            if adjustments_summary['adjustment_count'] > 0:
-                adj_emoji = "💰" if adjustments_summary['net_adjustment'] >= 0 else "💸"
-                stats_text_parts.append(f"\n\n💰 <b>Balance Adjustments:</b>")
-                stats_text_parts.append(f"• Deposits: {formatter.format_price_with_symbol(adjustments_summary['total_deposits'])}")
-                stats_text_parts.append(f"• Withdrawals: {formatter.format_price_with_symbol(adjustments_summary['total_withdrawals'])}")
-                stats_text_parts.append(f"• {adj_emoji} Net: {formatter.format_price_with_symbol(adjustments_summary['net_adjustment'])} ({adjustments_summary['adjustment_count']} transactions)")
-            
-            stats_text_parts.append(f"\n\n🎯 <b>Trade Distribution (Completed Cycles):</b>") # Renamed & Clarified
-            # The old buy_trades/sell_trades counts are no longer directly available from basic_stats
-            # We can show total completed cycle volume from token_stats if desired.
-            # For now, removing the detailed buy/sell order counts and specific sell volume.
-            # Consider adding total volume from token_stats later if needed.
-            # stats_text_parts.append(f"• Buy Orders: {basic['buy_trades']} | Sell Orders: {basic['sell_trades']}")
-            # stats_text_parts.append(f"• Volume Traded (Sells): {formatter.format_price_with_symbol(total_sell_volume)}")
-            # stats_text_parts.append(f"• Avg Sell Trade Size: {formatter.format_price_with_symbol(avg_trade_size_sell)}\n")
-            
-            # Let's add total completed volume as a more relevant stat now
-            overall_token_stats_summary = self._fetchone_query(
-                "SELECT SUM(total_entry_volume) as total_entry, SUM(total_exit_volume) as total_exit FROM token_stats"
-            )
-            total_entry_vol_all_cycles = 0.0
-            total_exit_vol_all_cycles = 0.0
-            if overall_token_stats_summary:
-                total_entry_vol_all_cycles = overall_token_stats_summary.get('total_entry', 0.0) or 0.0
-                total_exit_vol_all_cycles = overall_token_stats_summary.get('total_exit', 0.0) or 0.0
-
-            stats_text_parts.append(f"• Total Entry Volume (Cycles): {formatter.format_price_with_symbol(total_entry_vol_all_cycles)}")
-            stats_text_parts.append(f"• Total Exit Volume (Cycles): {formatter.format_price_with_symbol(total_exit_vol_all_cycles)}\n")
-
-            stats_text_parts.append(f"\n⏰ <b>Session Info:</b>")
-            stats_text_parts.append(f"• Started: {basic['start_date']}")
-            stats_text_parts.append(f"• Last Update: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
+            stats_text_parts.append(f"• Expectancy: {formatter.format_price_with_symbol(perf['expectancy'])} (Value per trade)")
+            # Note for Expectancy Percentage: \"[Info: Percentage representation requires further definition]\" might be too verbose for typical display.
+            
+            stats_text_parts.append(f"• Largest Winning Trade: {formatter.format_price_with_symbol(perf['largest_win'])} (Value)")
+            stats_text_parts.append(f"• Largest Losing Trade: {formatter.format_price_with_symbol(perf['largest_loss'])} (Value)")
+            # Note for Largest Trade P&L %: Similar to expectancy, noting \"[Info: P&L % for specific trades requires data enhancement]\" in the bot message might be too much.
+
+            best_token_stats = perf.get('best_performing_token', {'name': 'N/A', 'pnl_percentage': 0.0})
+            worst_token_stats = perf.get('worst_performing_token', {'name': 'N/A', 'pnl_percentage': 0.0})
+            stats_text_parts.append(f"• Best Performing Token: {best_token_stats['name']} ({best_token_stats['pnl_percentage']:+.2f}%)")
+            stats_text_parts.append(f"• Worst Performing Token: {worst_token_stats['name']} ({worst_token_stats['pnl_percentage']:+.2f}%)")
+            
+            stats_text_parts.append(f"• Average Trade Duration: N/A <font color=\"grey\"><i>(Data collection required)</i></font>")
+            stats_text_parts.append(f"• Portfolio Max Drawdown: {risk['max_drawdown']:.2f}% <font color=\"grey\"><i>(Daily Balance based)</i></font>")
+            # Future note: \"[Info: Trading P&L specific drawdown analysis planned]\"
+            
+            # Session Info
+            stats_text_parts.append(f"\n\n⏰ <b>Session Info:</b>")
+            stats_text_parts.append(f"• Bot Started: {basic['start_date']}")
+            stats_text_parts.append(f"• Stats Last Updated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
             
             return "\n".join(stats_text_parts).strip()