trading_stats.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. #!/usr/bin/env python3
  2. """
  3. Trading Statistics Tracker
  4. Tracks and calculates comprehensive trading statistics including:
  5. - Balance tracking
  6. - Trade history
  7. - P&L analysis
  8. - Win/Loss ratios
  9. - Risk metrics (Sharpe, Sortino)
  10. - Performance metrics
  11. """
  12. import json
  13. import os
  14. import logging
  15. from datetime import datetime, timedelta
  16. from typing import Dict, List, Any, Optional, Tuple
  17. import numpy as np
  18. from config import Config
  19. logger = logging.getLogger(__name__)
  20. class TradingStats:
  21. """Comprehensive trading statistics tracker."""
  22. def __init__(self, stats_file: str = "trading_stats.json"):
  23. """Initialize the stats tracker."""
  24. self.stats_file = stats_file
  25. self.data = self._load_stats()
  26. # Initialize if first run
  27. if not self.data:
  28. self._initialize_stats()
  29. def _load_stats(self) -> Dict[str, Any]:
  30. """Load stats from file."""
  31. try:
  32. if os.path.exists(self.stats_file):
  33. with open(self.stats_file, 'r') as f:
  34. return json.load(f)
  35. except Exception as e:
  36. logger.error(f"Error loading stats: {e}")
  37. return {}
  38. def _save_stats(self):
  39. """Save stats to file."""
  40. try:
  41. with open(self.stats_file, 'w') as f:
  42. json.dump(self.data, f, indent=2, default=str)
  43. except Exception as e:
  44. logger.error(f"Error saving stats: {e}")
  45. def _initialize_stats(self):
  46. """Initialize stats structure."""
  47. self.data = {
  48. 'start_time': datetime.now().isoformat(),
  49. 'initial_balance': 0.0,
  50. 'trades': [],
  51. 'daily_balances': [],
  52. 'last_update': datetime.now().isoformat(),
  53. 'manual_trades_only': True # Flag to indicate manual trading
  54. }
  55. self._save_stats()
  56. def set_initial_balance(self, balance: float):
  57. """Set the initial balance when bot starts."""
  58. if self.data.get('initial_balance', 0) == 0:
  59. self.data['initial_balance'] = balance
  60. self.data['start_time'] = datetime.now().isoformat()
  61. logger.info(f"Initial balance set to: ${balance:.2f}")
  62. self._save_stats()
  63. def record_balance(self, balance: float):
  64. """Record daily balance snapshot."""
  65. today = datetime.now().date().isoformat()
  66. # Check if we already have today's balance
  67. for entry in self.data['daily_balances']:
  68. if entry['date'] == today:
  69. entry['balance'] = balance
  70. entry['timestamp'] = datetime.now().isoformat()
  71. self._save_stats()
  72. return
  73. # Add new daily balance
  74. self.data['daily_balances'].append({
  75. 'date': today,
  76. 'balance': balance,
  77. 'timestamp': datetime.now().isoformat()
  78. })
  79. self._save_stats()
  80. def record_trade(self, symbol: str, side: str, amount: float, price: float,
  81. order_id: str = None, trade_type: str = "manual"):
  82. """Record a trade."""
  83. trade = {
  84. 'timestamp': datetime.now().isoformat(),
  85. 'symbol': symbol,
  86. 'side': side.lower(),
  87. 'amount': amount,
  88. 'price': price,
  89. 'value': amount * price,
  90. 'order_id': order_id,
  91. 'type': trade_type,
  92. 'pnl': 0.0 # Will be calculated when position is closed
  93. }
  94. self.data['trades'].append(trade)
  95. self.data['last_update'] = datetime.now().isoformat()
  96. self._save_stats()
  97. logger.info(f"Recorded trade: {side} {amount} {symbol} @ ${price:.2f}")
  98. def calculate_trade_pnl(self) -> List[Dict[str, Any]]:
  99. """Calculate P&L for completed trades using FIFO method."""
  100. trades_with_pnl = []
  101. positions = {} # Track open positions by symbol
  102. for trade in self.data['trades']:
  103. symbol = trade['symbol']
  104. side = trade['side']
  105. amount = trade['amount']
  106. price = trade['price']
  107. if symbol not in positions:
  108. positions[symbol] = {'amount': 0, 'avg_price': 0, 'total_cost': 0}
  109. pos = positions[symbol]
  110. if side == 'buy':
  111. # Add to position
  112. pos['total_cost'] += amount * price
  113. pos['amount'] += amount
  114. pos['avg_price'] = pos['total_cost'] / pos['amount'] if pos['amount'] > 0 else 0
  115. trade_copy = trade.copy()
  116. trade_copy['pnl'] = 0 # No PnL on opening
  117. trades_with_pnl.append(trade_copy)
  118. elif side == 'sell':
  119. # Reduce position and calculate PnL
  120. if pos['amount'] > 0:
  121. sold_amount = min(amount, pos['amount'])
  122. pnl = sold_amount * (price - pos['avg_price'])
  123. # Update position
  124. pos['amount'] -= sold_amount
  125. if pos['amount'] > 0:
  126. pos['total_cost'] = pos['amount'] * pos['avg_price']
  127. else:
  128. pos['total_cost'] = 0
  129. pos['avg_price'] = 0
  130. trade_copy = trade.copy()
  131. trade_copy['pnl'] = pnl
  132. trade_copy['sold_amount'] = sold_amount
  133. trades_with_pnl.append(trade_copy)
  134. else:
  135. # Short selling (negative PnL calculation)
  136. trade_copy = trade.copy()
  137. trade_copy['pnl'] = 0 # Would need more complex logic for shorts
  138. trades_with_pnl.append(trade_copy)
  139. return trades_with_pnl
  140. def get_basic_stats(self) -> Dict[str, Any]:
  141. """Get basic trading statistics."""
  142. if not self.data['trades']:
  143. return {
  144. 'total_trades': 0,
  145. 'initial_balance': self.data.get('initial_balance', 0),
  146. 'current_balance': 0,
  147. 'total_pnl': 0,
  148. 'days_active': 0
  149. }
  150. trades_with_pnl = self.calculate_trade_pnl()
  151. total_pnl = sum(trade.get('pnl', 0) for trade in trades_with_pnl)
  152. # Calculate days active
  153. start_date = datetime.fromisoformat(self.data['start_time'])
  154. days_active = (datetime.now() - start_date).days + 1
  155. return {
  156. 'total_trades': len(self.data['trades']),
  157. 'buy_trades': len([t for t in self.data['trades'] if t['side'] == 'buy']),
  158. 'sell_trades': len([t for t in self.data['trades'] if t['side'] == 'sell']),
  159. 'initial_balance': self.data.get('initial_balance', 0),
  160. 'total_pnl': total_pnl,
  161. 'days_active': days_active,
  162. 'start_date': start_date.strftime('%Y-%m-%d'),
  163. 'last_trade': self.data['trades'][-1]['timestamp'] if self.data['trades'] else None
  164. }
  165. def get_performance_stats(self) -> Dict[str, Any]:
  166. """Calculate advanced performance statistics."""
  167. trades_with_pnl = self.calculate_trade_pnl()
  168. completed_trades = [t for t in trades_with_pnl if t.get('pnl', 0) != 0]
  169. if not completed_trades:
  170. return {
  171. 'win_rate': 0,
  172. 'profit_factor': 0,
  173. 'avg_win': 0,
  174. 'avg_loss': 0,
  175. 'largest_win': 0,
  176. 'largest_loss': 0,
  177. 'consecutive_wins': 0,
  178. 'consecutive_losses': 0
  179. }
  180. # Separate wins and losses
  181. wins = [t['pnl'] for t in completed_trades if t['pnl'] > 0]
  182. losses = [abs(t['pnl']) for t in completed_trades if t['pnl'] < 0]
  183. # Basic metrics
  184. total_wins = len(wins)
  185. total_losses = len(losses)
  186. total_completed = total_wins + total_losses
  187. win_rate = (total_wins / total_completed * 100) if total_completed > 0 else 0
  188. # Profit metrics
  189. total_profit = sum(wins) if wins else 0
  190. total_loss = sum(losses) if losses else 0
  191. profit_factor = (total_profit / total_loss) if total_loss > 0 else float('inf') if total_profit > 0 else 0
  192. avg_win = np.mean(wins) if wins else 0
  193. avg_loss = np.mean(losses) if losses else 0
  194. largest_win = max(wins) if wins else 0
  195. largest_loss = max(losses) if losses else 0
  196. # Consecutive wins/losses
  197. consecutive_wins = 0
  198. consecutive_losses = 0
  199. current_wins = 0
  200. current_losses = 0
  201. for trade in completed_trades:
  202. if trade['pnl'] > 0:
  203. current_wins += 1
  204. current_losses = 0
  205. consecutive_wins = max(consecutive_wins, current_wins)
  206. else:
  207. current_losses += 1
  208. current_wins = 0
  209. consecutive_losses = max(consecutive_losses, current_losses)
  210. return {
  211. 'win_rate': win_rate,
  212. 'profit_factor': profit_factor,
  213. 'avg_win': avg_win,
  214. 'avg_loss': avg_loss,
  215. 'largest_win': largest_win,
  216. 'largest_loss': largest_loss,
  217. 'consecutive_wins': consecutive_wins,
  218. 'consecutive_losses': consecutive_losses,
  219. 'total_wins': total_wins,
  220. 'total_losses': total_losses,
  221. 'expectancy': avg_win * (win_rate/100) - avg_loss * ((100-win_rate)/100)
  222. }
  223. def get_risk_metrics(self) -> Dict[str, Any]:
  224. """Calculate risk-adjusted metrics."""
  225. if not self.data['daily_balances'] or len(self.data['daily_balances']) < 2:
  226. return {
  227. 'sharpe_ratio': 0,
  228. 'sortino_ratio': 0,
  229. 'max_drawdown': 0,
  230. 'volatility': 0,
  231. 'var_95': 0
  232. }
  233. # Calculate daily returns
  234. balances = [entry['balance'] for entry in self.data['daily_balances']]
  235. returns = []
  236. for i in range(1, len(balances)):
  237. daily_return = (balances[i] - balances[i-1]) / balances[i-1] if balances[i-1] > 0 else 0
  238. returns.append(daily_return)
  239. if not returns:
  240. return {
  241. 'sharpe_ratio': 0,
  242. 'sortino_ratio': 0,
  243. 'max_drawdown': 0,
  244. 'volatility': 0,
  245. 'var_95': 0
  246. }
  247. returns = np.array(returns)
  248. # Risk-free rate (assume 2% annually, convert to daily)
  249. risk_free_rate = 0.02 / 365
  250. # Sharpe Ratio
  251. excess_returns = returns - risk_free_rate
  252. sharpe_ratio = np.mean(excess_returns) / np.std(returns) * np.sqrt(365) if np.std(returns) > 0 else 0
  253. # Sortino Ratio (only downside volatility)
  254. downside_returns = returns[returns < 0]
  255. downside_std = np.std(downside_returns) if len(downside_returns) > 0 else 0
  256. sortino_ratio = np.mean(excess_returns) / downside_std * np.sqrt(365) if downside_std > 0 else 0
  257. # Maximum Drawdown
  258. cumulative = np.cumprod(1 + returns)
  259. running_max = np.maximum.accumulate(cumulative)
  260. drawdown = (cumulative - running_max) / running_max
  261. max_drawdown = np.min(drawdown) * 100
  262. # Volatility (annualized)
  263. volatility = np.std(returns) * np.sqrt(365) * 100
  264. # Value at Risk (95%)
  265. var_95 = np.percentile(returns, 5) * 100
  266. return {
  267. 'sharpe_ratio': sharpe_ratio,
  268. 'sortino_ratio': sortino_ratio,
  269. 'max_drawdown': abs(max_drawdown),
  270. 'volatility': volatility,
  271. 'var_95': abs(var_95)
  272. }
  273. def get_comprehensive_stats(self, current_balance: float = None) -> Dict[str, Any]:
  274. """Get all statistics combined."""
  275. if current_balance:
  276. self.record_balance(current_balance)
  277. basic = self.get_basic_stats()
  278. performance = self.get_performance_stats()
  279. risk = self.get_risk_metrics()
  280. # Calculate total return
  281. initial_balance = basic['initial_balance']
  282. total_return = 0
  283. if current_balance and initial_balance > 0:
  284. total_return = ((current_balance - initial_balance) / initial_balance) * 100
  285. return {
  286. 'basic': basic,
  287. 'performance': performance,
  288. 'risk': risk,
  289. 'current_balance': current_balance or 0,
  290. 'total_return': total_return,
  291. 'last_updated': datetime.now().isoformat()
  292. }
  293. def format_stats_message(self, current_balance: float = None) -> str:
  294. """Format stats for Telegram display."""
  295. stats = self.get_comprehensive_stats(current_balance)
  296. basic = stats['basic']
  297. perf = stats['performance']
  298. risk = stats['risk']
  299. message = f"""
  300. 📊 <b>Trading Statistics</b>
  301. 💰 <b>Balance Overview</b>
  302. • Initial: ${basic['initial_balance']:,.2f}
  303. • Current: ${stats['current_balance']:,.2f}
  304. • Total P&L: ${basic['total_pnl']:,.2f}
  305. • Total Return: {stats['total_return']:.2f}%
  306. 📈 <b>Trading Activity</b>
  307. • Total Trades: {basic['total_trades']}
  308. • Buy Orders: {basic['buy_trades']}
  309. • Sell Orders: {basic['sell_trades']}
  310. • Days Active: {basic['days_active']}
  311. 🏆 <b>Performance Metrics</b>
  312. • Win Rate: {perf['win_rate']:.1f}%
  313. • Profit Factor: {perf['profit_factor']:.2f}
  314. • Avg Win: ${perf['avg_win']:.2f}
  315. • Avg Loss: ${perf['avg_loss']:.2f}
  316. • Expectancy: ${perf['expectancy']:.2f}
  317. 📊 <b>Risk Metrics</b>
  318. • Sharpe Ratio: {risk['sharpe_ratio']:.2f}
  319. • Sortino Ratio: {risk['sortino_ratio']:.2f}
  320. • Max Drawdown: {risk['max_drawdown']:.2f}%
  321. • Volatility: {risk['volatility']:.2f}%
  322. • VaR (95%): {risk['var_95']:.2f}%
  323. 🎯 <b>Best/Worst</b>
  324. • Largest Win: ${perf['largest_win']:.2f}
  325. • Largest Loss: ${perf['largest_loss']:.2f}
  326. • Max Consecutive Wins: {perf['consecutive_wins']}
  327. • Max Consecutive Losses: {perf['consecutive_losses']}
  328. 📅 <b>Since:</b> {basic['start_date']}
  329. """
  330. return message.strip()
  331. def get_recent_trades(self, limit: int = 10) -> List[Dict[str, Any]]:
  332. """Get recent trades."""
  333. return self.data['trades'][-limit:] if self.data['trades'] else []