Browse Source

Enhance risk and stats commands for comprehensive reporting

- Updated the `risk_command` in `RiskCommands` to provide a detailed portfolio risk report, including open positions, portfolio summary, trading stats, and risk metrics.
- Improved message formatting for clarity and user engagement.
- Refactored the `stats_command` in `StatsCommands` to generate a comprehensive statistics report, incorporating performance metrics, risk analysis, and top-performing tokens.
- Introduced helper methods for better organization and readability of the code.
Carles Sentis 2 days ago
parent
commit
a763f218da
3 changed files with 199 additions and 126 deletions
  1. 76 46
      src/commands/info/risk.py
  2. 122 79
      src/commands/info/stats.py
  3. 1 1
      trading_bot.py

+ 76 - 46
src/commands/info/risk.py

@@ -17,62 +17,92 @@ class RiskCommands(InfoCommandsBase):
         self.formatter = get_formatter()
 
     async def risk_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
-        """Handle the /risk command to show key portfolio risk metrics."""
+        """Handle the /risk command to show a comprehensive portfolio risk report."""
         try:
-            # Check for unused arguments and notify user
             if context.args:
-                await self._reply(update, "Note: The `/risk` command shows portfolio-level data. Token-specific arguments are ignored.")
+                await self._reply(update, "Note: The `/risk` command shows portfolio-level data and does not accept arguments.")
 
-            # Fetch necessary data
-            risk_metrics = self.trading_engine.stats.get_risk_metrics()
+            message = ["<b>🛡️ Portfolio Risk Analysis</b>\n"]
+
+            # 1. Open Positions
             positions = self.trading_engine.get_positions()
-            balance_data = self.trading_engine.get_balance()
+            message.append(self._format_open_positions(positions))
 
-            if not risk_metrics:
-                await self._reply(update, "Risk metrics are not available yet.")
-                return
+            # 2. Portfolio Summary
+            balance_data = self.trading_engine.get_balance()
+            message.append(await self._format_portfolio_summary(balance_data, positions))
 
-            # Calculate total unrealized P&L
-            total_unrealized_pnl = sum(float(p.get('unrealizedPnl', 0.0)) for p in positions)
-            portfolio_value = float(balance_data.get('total', {}).get('USDC', 0.0))
-            
-            # Calculate portfolio risk percentage
-            portfolio_risk_pct = (total_unrealized_pnl / portfolio_value * 100) if portfolio_value > 0 else 0.0
-
-            # Format the message
-            message = [
-                "<b>🛡️ Portfolio Risk Analysis</b>\n",
-                f"Here are the key risk metrics for your portfolio:\n"
-            ]
-
-            # Max Drawdown
-            max_drawdown_pct = risk_metrics.get('max_drawdown_percentage', 0.0)
-            drawdown_start_date_iso = risk_metrics.get('drawdown_start_date')
-            drawdown_date_str = ""
-            if drawdown_start_date_iso:
-                try:
-                    drawdown_date = datetime.fromisoformat(drawdown_start_date_iso).strftime('%Y-%m-%d')
-                    drawdown_date_str = f" (since {drawdown_date})"
-                except (ValueError, TypeError):
-                    logger.warning(f"Could not parse drawdown_start_date: {drawdown_start_date_iso}")
-            
-            message.append(f"• <b>Max Drawdown:</b> {max_drawdown_pct:.2f}%{drawdown_date_str}")
-            
-            # Sharpe Ratio
-            sharpe_ratio = risk_metrics.get('sharpe_ratio')
-            if sharpe_ratio is not None:
-                message.append(f"• <b>Sharpe Ratio:</b> {sharpe_ratio:.2f}")
-            else:
-                message.append("• <b>Sharpe Ratio:</b> N/A (Insufficient data)")
+            # 3. Trading Stats
+            stats = self.trading_engine.stats.get_trading_stats()
+            message.append(self._format_trading_stats(stats))
 
-            # Overall Portfolio Risk
-            pnl_emoji = "🟢" if total_unrealized_pnl >= 0 else "🔴"
-            message.append(f"• <b>Open P&L Risk:</b> {pnl_emoji} {portfolio_risk_pct:.2f}% ({await self.formatter.format_price_with_symbol(total_unrealized_pnl)})")
+            # 4. Risk Metrics
+            risk_metrics = self.trading_engine.stats.get_risk_metrics()
+            message.append(self._format_risk_metrics(risk_metrics))
 
-            message.append("\n<i>This provides a high-level overview of portfolio risk. For position-specific details, use /positions.</i>")
+            # 5. Footer
+            message.append("<i>This report provides a snapshot of your portfolio's risk. Manage positions carefully.</i>")
 
             await self._reply(update, "\n".join(message))
             
         except Exception as e:
             logger.error(f"Error in risk command: {e}", exc_info=True)
-            await self._reply(update, "❌ Error getting risk information. Please try again later.")
+            await self._reply(update, "❌ Error generating risk report. Please try again later.")
+
+    def _format_open_positions(self, positions):
+        """Formats the open positions section."""
+        if not positions:
+            return "<b>Open Positions:</b> None\n"
+
+        lines = ["<b>Open Positions:</b>"]
+        for p in positions:
+            pnl_emoji = "🟢" if float(p.get('unrealizedPnl', 0.0)) >= 0 else "🔴"
+            lines.append(f"• {p['position']['coin']}: {p['position']['szi']} {p['position']['side']} @ ${p['position']['entryPx']} {pnl_emoji}")
+        return "\n".join(lines) + "\n"
+
+    async def _format_portfolio_summary(self, balance_data, positions):
+        """Formats the portfolio summary section."""
+        total_unrealized_pnl = sum(float(p.get('unrealizedPnl', 0.0)) for p in positions)
+        portfolio_value = float(balance_data.get('total', {}).get('USDC', 0.0))
+        pnl_emoji = "🟢" if total_unrealized_pnl >= 0 else "🔴"
+        
+        return (
+            "<b>Portfolio Summary:</b>\n"
+            f"• <b>Total Value:</b> {await self.formatter.format_price_with_symbol(portfolio_value)}\n"
+            f"• <b>Unrealized P&L:</b> {pnl_emoji} {await self.formatter.format_price_with_symbol(total_unrealized_pnl)}\n"
+        )
+
+    def _format_trading_stats(self, stats):
+        """Formats the trading statistics section."""
+        if not stats:
+            return "<b>Trading Stats:</b> Not available\n"
+        
+        return (
+            "<b>Trading Stats:</b>\n"
+            f"• <b>Win Rate:</b> {stats.get('win_rate', 0.0):.2f}%\n"
+            f"• <b>Profit Factor:</b> {stats.get('profit_factor', 0.0):.2f}\n"
+        )
+
+    def _format_risk_metrics(self, risk_metrics):
+        """Formats the risk metrics section."""
+        if not risk_metrics:
+            return "<b>Risk Metrics:</b> Not available\n"
+
+        max_drawdown_pct = risk_metrics.get('max_drawdown_percentage', 0.0)
+        drawdown_start_date_iso = risk_metrics.get('drawdown_start_date')
+        drawdown_date_str = ""
+        if drawdown_start_date_iso:
+            try:
+                drawdown_date = datetime.fromisoformat(drawdown_start_date_iso).strftime('%Y-%m-%d')
+                drawdown_date_str = f" (since {drawdown_date})"
+            except (ValueError, TypeError):
+                logger.warning(f"Could not parse drawdown_start_date: {drawdown_start_date_iso}")
+
+        sharpe_ratio = risk_metrics.get('sharpe_ratio')
+        sharpe_str = f"{sharpe_ratio:.2f}" if sharpe_ratio is not None else "N/A"
+
+        return (
+            "<b>Risk Metrics:</b>\n"
+            f"• <b>Max Drawdown:</b> {max_drawdown_pct:.2f}%{drawdown_date_str}\n"
+            f"• <b>Sharpe Ratio:</b> {sharpe_str}\n"
+        )

+ 122 - 79
src/commands/info/stats.py

@@ -1,80 +1,19 @@
 import logging
-from typing import Dict, Any, List, Optional
-from datetime import datetime, timedelta, timezone
+from typing import Dict, Any, List
+from datetime import datetime
 from telegram import Update
 from telegram.ext import ContextTypes
 from .base import InfoCommandsBase
-from src.utils.token_display_formatter import get_formatter
-from src.config.config import Config
+from src.utils.token_display_formatter import get_formatter, normalize_token_case
 
 logger = logging.getLogger(__name__)
 
 class StatsCommands(InfoCommandsBase):
     """Handles all statistics-related commands."""
 
-    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, matching the main /stats style."""
-        formatter = get_formatter()
-        if not token_stats_data or token_stats_data.get('summary_total_trades', 0) == 0:
-            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', [])
-        session = token_stats_data.get('session_info', {})
-
-        # --- Account Overview ---
-        account_lines = [
-            f"💰 <b>{token_name.upper()} Account Overview:</b>",
-            f"• Current Balance: {await formatter.format_price_with_symbol(perf_summary.get('current_balance', 0.0))}",
-            f"• Initial Balance: {await formatter.format_price_with_symbol(perf_summary.get('initial_balance', 0.0))}",
-            f"• Open Positions: {len(open_positions)}",
-        ]
-        total_pnl = perf_summary.get('total_pnl', 0.0)
-        entry_vol = perf_summary.get('completed_entry_volume', 0.0)
-        total_pnl_pct = (total_pnl / entry_vol * 100) if entry_vol > 0 else 0.0
-        pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
-        account_lines.append(f"• {pnl_emoji} Total P&L: {await formatter.format_price_with_symbol(total_pnl)} ({total_pnl_pct:+.2f}%)")
-        account_lines.append(f"• Days Active: {perf_summary.get('days_active', 0)}")
-
-        # --- Performance Metrics ---
-        perf_lines = [
-            "🏆 <b>Performance Metrics:</b>",
-            f"• Total Completed Trades: {perf_summary.get('completed_trades', 0)}",
-            f"• Win Rate: {perf_summary.get('win_rate', 0.0):.1f}% ({perf_summary.get('total_wins', 0)}/{perf_summary.get('completed_trades', 0)})",
-            f"• Trading Volume (Entry Vol.): {await formatter.format_price_with_symbol(perf_summary.get('completed_entry_volume', 0.0))}",
-            f"• Profit Factor: {perf_summary.get('profit_factor', 0.0):.2f}",
-            f"• Expectancy: {await formatter.format_price_with_symbol(perf_summary.get('expectancy', 0.0))}",
-            f"• Largest Winning Trade: {await formatter.format_price_with_symbol(perf_summary.get('largest_win', 0.0))} ({perf_summary.get('largest_win_pct', 0.0):+.2f}%)",
-            f"• Largest Losing Trade: {await formatter.format_price_with_symbol(perf_summary.get('largest_loss', 0.0))}",
-            f"• Best ROE Trade: {await formatter.format_price_with_symbol(perf_summary.get('best_roe_trade', 0.0))} ({perf_summary.get('best_roe_trade_pct', 0.0):+.2f}%)",
-            f"• Worst ROE Trade: {await formatter.format_price_with_symbol(perf_summary.get('worst_roe_trade', 0.0))} ({perf_summary.get('worst_roe_trade_pct', 0.0):+.2f}%)",
-            f"• Average Trade Duration: {perf_summary.get('avg_trade_duration', 'N/A')}",
-            f"• Max Drawdown: {perf_summary.get('max_drawdown', 0.0):.2f}% <i>(Live)</i>",
-        ]
-
-        # --- Session Info ---
-        session_lines = [
-            "⏰ <b>Session Info:</b>",
-            f"• Bot Started: {session.get('bot_started', 'N/A')}",
-            f"• Stats Last Updated: {session.get('last_updated', 'N/A')}",
-        ]
-
-        # Combine all sections
-        stats_text = (
-            f"📊 <b>{token_name.upper()} Trading Statistics</b>\n\n" +
-            "\n".join(account_lines) +
-            "\n\n" +
-            "\n".join(perf_lines) +
-            "\n\n" +
-            "\n".join(session_lines)
-        )
-        return stats_text.strip()
+    def __init__(self, trading_engine, notification_manager):
+        super().__init__(trading_engine, notification_manager)
+        self.formatter = get_formatter()
 
     async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
         """Handle the /stats command. Shows overall stats or stats for a specific token."""
@@ -83,27 +22,131 @@ class StatsCommands(InfoCommandsBase):
                 await self._reply(update, "❌ Unauthorized access.")
                 return
 
-            stats = self.trading_engine.get_stats()
-            if not stats:
-                await self._reply(update, "❌ Trading stats not available.")
-                return
-
             # If a token is provided, show detailed stats for that token
             if context.args and len(context.args) > 0:
-                token_name = context.args[0].upper()
-                # Assuming get_token_detailed_stats is the correct method in TradingStats
-                # to get the data needed by format_token_stats_message.
-                stats_message = await stats.format_token_stats_message(token_name)
+                token_name = normalize_token_case(context.args[0])
+                stats_message = await self.trading_engine.stats.format_token_stats_message(token_name)
                 await self._reply(update, stats_message)
                 return
 
-            # For the general /stats command, use the centralized formatter
+            # For the general /stats command, create a detailed report
             balance_info = self.trading_engine.get_balance()
-            current_balance = balance_info.get('total', {}).get('USDC', 0.0)
+            current_balance = float(balance_info.get('total', {}).get('USDC', 0.0))
             
-            stats_message = await stats.format_stats_message(current_balance)
+            report = self.trading_engine.stats.get_summary_report()
+            if not report or not report.get('performance_stats'):
+                await self._reply(update, "❌ Trading stats not available yet.")
+                return
+
+            stats_message = await self._format_comprehensive_stats(report, current_balance)
             await self._reply(update, stats_message, disable_web_page_preview=True)
 
         except Exception as e:
             logger.error(f"Error in stats command: {e}", exc_info=True)
             await self._reply(update, f"❌ Error getting statistics: {str(e)}")
+
+    def _format_duration(self, seconds: float) -> str:
+        """Helper to format seconds into a human-readable string."""
+        if seconds < 60:
+            return f"{seconds:.1f}s"
+        minutes = seconds / 60
+        if minutes < 60:
+            return f"{minutes:.1f}m"
+        hours = minutes / 60
+        if hours < 24:
+            return f"{hours:.1f}h"
+        days = hours / 24
+        return f"{days:.1f}d"
+
+    async def _format_comprehensive_stats(self, report: Dict[str, Any], current_balance: float) -> str:
+        """Formats the main, detailed statistics message."""
+        perf = report.get('performance_stats', {})
+        risk = report.get('risk_metrics', {})
+        top_tokens = report.get('top_tokens', [])
+
+        # --- Header ---
+        initial_balance = perf.get('initial_balance', 0.0)
+        total_pnl = perf.get('total_pnl', 0.0)
+        total_return_pct = (total_pnl / initial_balance * 100) if initial_balance > 0 else 0.0
+        pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
+        
+        header = [
+            "📊 <b>Overall Trading Performance</b>\n",
+            f"• <b>Current Balance:</b> {await self.formatter.format_price_with_symbol(current_balance)}",
+            f"• <b>Initial Balance:</b> {await self.formatter.format_price_with_symbol(initial_balance)}",
+            f"• {pnl_emoji} <b>Total Net P&L:</b> {await self.formatter.format_price_with_symbol(total_pnl)} ({total_return_pct:+.2f}%)"
+        ]
+
+        # --- Core Metrics ---
+        max_drawdown_pct = risk.get('max_drawdown_percentage', 0.0)
+        drawdown_start_date_iso = risk.get('drawdown_start_date')
+        drawdown_date_str = ""
+        if drawdown_start_date_iso:
+            try:
+                drawdown_date = datetime.fromisoformat(drawdown_start_date_iso).strftime('%Y-%m-%d')
+                drawdown_date_str = f" (since {drawdown_date})"
+            except (ValueError, TypeError):
+                logger.warning(f"Could not parse drawdown start date: {drawdown_start_date_iso}")
+
+        sharpe_ratio = risk.get('sharpe_ratio')
+        sharpe_str = f"{sharpe_ratio:.2f}" if sharpe_ratio is not None else "N/A"
+
+        core_metrics = [
+            "\n<b>Core Metrics:</b>",
+            f"• <b>Win Rate:</b> {perf.get('win_rate', 0.0):.2f}% ({perf.get('total_wins', 0)}W / {perf.get('total_losses', 0)}L)",
+            f"• <b>Profit Factor:</b> {perf.get('profit_factor', 'N/A')}",
+            f"• <b>Max Drawdown:</b> {max_drawdown_pct:.2f}%{drawdown_date_str}",
+            f"• <b>Sharpe Ratio:</b> {sharpe_str}",
+            f"• <b>Total Trades:</b> {perf.get('total_trades', 0)}",
+            f"• <b>Total Volume:</b> {await self.formatter.format_price_with_symbol(perf.get('total_entry_volume', 0.0))}"
+        ]
+        
+        # --- P&L Analysis ---
+        pnl_analysis = [
+            "\n<b>P&L Analysis:</b>",
+            f"• <b>Avg Profit per Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_trade_pnl', 0.0))}",
+            f"• <b>Avg Winning Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_win_pnl', 0.0))}",
+            f"• <b>Avg Losing Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_loss_pnl', 0.0))}",
+            f"• <b>Largest Win:</b> {await self.formatter.format_price_with_symbol(perf.get('largest_win_pnl', 0.0))}",
+            f"• <b>Largest Loss:</b> {await self.formatter.format_price_with_symbol(perf.get('largest_loss_pnl', 0.0))}"
+        ]
+        
+        # --- ROE Analysis (with fixes) ---
+        best_roe = perf.get('best_roe_trade', {})
+        worst_roe = perf.get('worst_roe_trade', {})
+        roe_analysis = ["\n<b>Return on Equity (ROE) Analysis:</b>"]
+        if best_roe:
+            roe_analysis.append(f"• <b>Best ROE Trade:</b> {best_roe.get('percentage', 0.0):+.2f}% ({best_roe.get('token')})")
+        if worst_roe:
+            roe_analysis.append(f"• <b>Worst ROE Trade:</b> {worst_roe.get('percentage', 0.0):+.2f}% ({worst_roe.get('token')})")
+        
+        # --- Trade Duration ---
+        duration_analysis = [
+            "\n<b>Trade Duration:</b>",
+            f"• <b>Avg Duration:</b> {self._format_duration(perf.get('avg_trade_duration_seconds', 0.0))}",
+            f"• <b>Avg Win Duration:</b> {self._format_duration(perf.get('avg_win_duration_seconds', 0.0))}",
+            f"• <b>Avg Loss Duration:</b> {self._format_duration(perf.get('avg_loss_duration_seconds', 0.0))}"
+        ]
+
+        # --- Top Movers ---
+        top_movers = ["\n<b>Top Performing Tokens (by P&L):</b>"]
+        if top_tokens:
+            # Sort by total_pnl descending
+            sorted_tokens = sorted(top_tokens, key=lambda x: x.get('total_pnl', 0.0), reverse=True)
+            for token_stat in sorted_tokens[:5]:
+                token_pnl = token_stat.get('total_pnl', 0.0)
+                token_pnl_emoji = "🟢" if token_pnl >= 0 else "🔴"
+                top_movers.append(
+                    f"• {token_stat.get('token')}: {token_pnl_emoji} "
+                    f"{await self.formatter.format_price_with_symbol(token_pnl)} "
+                    f"({token_stat.get('win_rate', 0):.1f}% WR)"
+                )
+        else:
+            top_movers.append("• No token-specific data yet.")
+
+        # --- Footer ---
+        footer = [
+            "\n<i>Statistics are based on completed trades. Use /positions for live data.</i>"
+        ]
+
+        return "\n".join(header + core_metrics + pnl_analysis + roe_analysis + duration_analysis + top_movers + footer)

+ 1 - 1
trading_bot.py

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