stats.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import logging
  2. from typing import Dict, Any, List
  3. from datetime import datetime
  4. from telegram import Update
  5. from telegram.ext import ContextTypes
  6. from .base import InfoCommandsBase
  7. from src.utils.token_display_formatter import get_formatter, normalize_token_case
  8. logger = logging.getLogger(__name__)
  9. class StatsCommands(InfoCommandsBase):
  10. """Handles all statistics-related commands."""
  11. def __init__(self, trading_engine, notification_manager):
  12. super().__init__(trading_engine, notification_manager)
  13. self.formatter = get_formatter()
  14. async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
  15. """Handle the /stats command. Shows overall stats or stats for a specific token."""
  16. try:
  17. if not self._is_authorized(update):
  18. await self._reply(update, "❌ Unauthorized access.")
  19. return
  20. # If a token is provided, show detailed stats for that token
  21. if context.args and len(context.args) > 0:
  22. token_name = normalize_token_case(context.args[0])
  23. stats_message = await self.trading_engine.stats.format_token_stats_message(token_name)
  24. await self._reply(update, stats_message)
  25. return
  26. # For the general /stats command, create a detailed report
  27. balance_info = self.trading_engine.get_balance()
  28. current_balance = float(balance_info.get('total', {}).get('USDC', 0.0))
  29. report = self.trading_engine.stats.get_summary_report()
  30. if not report or not report.get('performance_stats'):
  31. await self._reply(update, "❌ Trading stats not available yet.")
  32. return
  33. stats_message = await self._format_comprehensive_stats(report, current_balance)
  34. await self._reply(update, stats_message, disable_web_page_preview=True)
  35. except Exception as e:
  36. logger.error(f"Error in stats command: {e}", exc_info=True)
  37. await self._reply(update, f"❌ Error getting statistics: {str(e)}")
  38. def _format_duration(self, seconds: float) -> str:
  39. """Helper to format seconds into a human-readable string."""
  40. if seconds < 60:
  41. return f"{seconds:.1f}s"
  42. minutes = seconds / 60
  43. if minutes < 60:
  44. return f"{minutes:.1f}m"
  45. hours = minutes / 60
  46. if hours < 24:
  47. return f"{hours:.1f}h"
  48. days = hours / 24
  49. return f"{days:.1f}d"
  50. async def _format_comprehensive_stats(self, report: Dict[str, Any], current_balance: float) -> str:
  51. """Formats the main, detailed statistics message."""
  52. perf = report.get('performance_stats', {})
  53. risk = report.get('risk_metrics', {})
  54. top_tokens = report.get('top_tokens', [])
  55. # --- Header ---
  56. initial_balance = perf.get('initial_balance', 0.0)
  57. total_pnl = perf.get('total_pnl', 0.0)
  58. total_return_pct = (total_pnl / initial_balance * 100) if initial_balance > 0 else 0.0
  59. pnl_emoji = "🟢" if total_pnl >= 0 else "🔴"
  60. header = [
  61. "📊 <b>Overall Trading Performance</b>\n",
  62. f"• <b>Current Balance:</b> {await self.formatter.format_price_with_symbol(current_balance)}",
  63. f"• <b>Initial Balance:</b> {await self.formatter.format_price_with_symbol(initial_balance)}",
  64. f"• {pnl_emoji} <b>Total Net P&L:</b> {await self.formatter.format_price_with_symbol(total_pnl)} ({total_return_pct:+.2f}%)"
  65. ]
  66. # --- Core Metrics ---
  67. max_drawdown_pct = risk.get('max_drawdown_percentage', 0.0)
  68. drawdown_start_date_iso = risk.get('drawdown_start_date')
  69. drawdown_date_str = ""
  70. if drawdown_start_date_iso:
  71. try:
  72. drawdown_date = datetime.fromisoformat(drawdown_start_date_iso).strftime('%Y-%m-%d')
  73. drawdown_date_str = f" (since {drawdown_date})"
  74. except (ValueError, TypeError):
  75. logger.warning(f"Could not parse drawdown start date: {drawdown_start_date_iso}")
  76. sharpe_ratio = risk.get('sharpe_ratio')
  77. sharpe_str = f"{sharpe_ratio:.2f}" if sharpe_ratio is not None else "N/A"
  78. core_metrics = [
  79. "\n<b>Core Metrics:</b>",
  80. f"• <b>Win Rate:</b> {perf.get('win_rate', 0.0):.2f}% ({perf.get('total_wins', 0)}W / {perf.get('total_losses', 0)}L)",
  81. f"• <b>Profit Factor:</b> {perf.get('profit_factor', 'N/A')}",
  82. f"• <b>Max Drawdown:</b> {max_drawdown_pct:.2f}%{drawdown_date_str}",
  83. f"• <b>Sharpe Ratio:</b> {sharpe_str}",
  84. f"• <b>Total Trades:</b> {perf.get('total_trades', 0)}",
  85. f"• <b>Total Volume:</b> {await self.formatter.format_price_with_symbol(perf.get('total_entry_volume', 0.0))}"
  86. ]
  87. # --- P&L Analysis ---
  88. pnl_analysis = [
  89. "\n<b>P&L Analysis:</b>",
  90. f"• <b>Avg Profit per Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_trade_pnl', 0.0))}",
  91. f"• <b>Avg Winning Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_win_pnl', 0.0))}",
  92. f"• <b>Avg Losing Trade:</b> {await self.formatter.format_price_with_symbol(perf.get('avg_loss_pnl', 0.0))}",
  93. f"• <b>Largest Win:</b> {await self.formatter.format_price_with_symbol(perf.get('largest_win_pnl', 0.0))}",
  94. f"• <b>Largest Loss:</b> {await self.formatter.format_price_with_symbol(perf.get('largest_loss_pnl', 0.0))}"
  95. ]
  96. # --- ROE Analysis (with fixes) ---
  97. best_roe = perf.get('best_roe_trade', {})
  98. worst_roe = perf.get('worst_roe_trade', {})
  99. roe_analysis = ["\n<b>Return on Equity (ROE) Analysis:</b>"]
  100. if best_roe:
  101. roe_analysis.append(f"• <b>Best ROE Trade:</b> {best_roe.get('percentage', 0.0):+.2f}% ({best_roe.get('token')})")
  102. if worst_roe:
  103. roe_analysis.append(f"• <b>Worst ROE Trade:</b> {worst_roe.get('percentage', 0.0):+.2f}% ({worst_roe.get('token')})")
  104. # --- Trade Duration ---
  105. duration_analysis = [
  106. "\n<b>Trade Duration:</b>",
  107. f"• <b>Avg Duration:</b> {self._format_duration(perf.get('avg_trade_duration_seconds', 0.0))}",
  108. f"• <b>Avg Win Duration:</b> {self._format_duration(perf.get('avg_win_duration_seconds', 0.0))}",
  109. f"• <b>Avg Loss Duration:</b> {self._format_duration(perf.get('avg_loss_duration_seconds', 0.0))}"
  110. ]
  111. # --- Top Movers ---
  112. top_movers = ["\n<b>Top Performing Tokens (by P&L):</b>"]
  113. if top_tokens:
  114. # Sort by total_pnl descending
  115. sorted_tokens = sorted(top_tokens, key=lambda x: x.get('total_pnl', 0.0), reverse=True)
  116. for token_stat in sorted_tokens[:5]:
  117. token_pnl = token_stat.get('total_pnl', 0.0)
  118. token_pnl_emoji = "🟢" if token_pnl >= 0 else "🔴"
  119. top_movers.append(
  120. f"• {token_stat.get('token')}: {token_pnl_emoji} "
  121. f"{await self.formatter.format_price_with_symbol(token_pnl)} "
  122. f"({token_stat.get('win_rate', 0):.1f}% WR)"
  123. )
  124. else:
  125. top_movers.append("• No token-specific data yet.")
  126. # --- Footer ---
  127. footer = [
  128. "\n<i>Statistics are based on completed trades. Use /positions for live data.</i>"
  129. ]
  130. return "\n".join(header + core_metrics + pnl_analysis + roe_analysis + duration_analysis + top_movers + footer)