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