trading_stats.py 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  1. #!/usr/bin/env python3
  2. """
  3. Trading Statistics Tracker (Refactored Version)
  4. Main class that coordinates between specialized manager components.
  5. """
  6. import logging
  7. from datetime import datetime, timezone
  8. from typing import Dict, List, Any, Optional, Tuple
  9. import math
  10. import numpy as np
  11. import uuid
  12. from .database_manager import DatabaseManager
  13. from .order_manager import OrderManager
  14. from .trade_lifecycle_manager import TradeLifecycleManager
  15. from .aggregation_manager import AggregationManager
  16. from .performance_calculator import PerformanceCalculator
  17. from src.utils.token_display_formatter import get_formatter
  18. logger = logging.getLogger(__name__)
  19. def _normalize_token_case(token: str) -> str:
  20. """Normalize token case for consistency."""
  21. if any(c.isupper() for c in token):
  22. return token # Keep original case for mixed-case tokens
  23. else:
  24. return token.upper() # Convert to uppercase for all-lowercase
  25. class TradingStats:
  26. """Refactored trading statistics tracker using modular components."""
  27. def __init__(self, db_path: str = "data/trading_stats.sqlite"):
  28. """Initialize with all manager components."""
  29. # Initialize core database manager
  30. self.db_manager = DatabaseManager(db_path)
  31. # Initialize specialized managers
  32. self.order_manager = OrderManager(self.db_manager)
  33. self.trade_manager = TradeLifecycleManager(self.db_manager)
  34. self.aggregation_manager = AggregationManager(self.db_manager)
  35. self.performance_calculator = PerformanceCalculator(self.db_manager)
  36. logger.info("🚀 TradingStats initialized with modular components")
  37. def close(self):
  38. """Close database connection."""
  39. self.db_manager.close()
  40. # =============================================================================
  41. # COMPATIBILITY METHODS - Direct exposure of internal methods
  42. # =============================================================================
  43. def _get_metadata(self, key: str) -> Optional[str]:
  44. """Get metadata from database."""
  45. return self.db_manager._get_metadata(key)
  46. def _set_metadata(self, key: str, value: str):
  47. """Set metadata in database."""
  48. return self.db_manager._set_metadata(key, value)
  49. # =============================================================================
  50. # DATABASE MANAGEMENT DELEGATION
  51. # =============================================================================
  52. def set_initial_balance(self, balance: float):
  53. """Set initial balance."""
  54. return self.db_manager.set_initial_balance(balance)
  55. def get_initial_balance(self) -> float:
  56. """Get initial balance."""
  57. return self.db_manager.get_initial_balance()
  58. def record_balance_snapshot(self, balance: float, unrealized_pnl: float = 0.0,
  59. timestamp: Optional[str] = None, notes: Optional[str] = None):
  60. """Record balance snapshot."""
  61. return self.db_manager.record_balance_snapshot(balance, unrealized_pnl, timestamp, notes)
  62. def purge_old_balance_history(self, days_to_keep: int = 30) -> int:
  63. """Purge old balance history."""
  64. return self.db_manager.purge_old_balance_history(days_to_keep)
  65. def get_balance_history_record_count(self) -> int:
  66. """Get balance history record count."""
  67. return self.db_manager.get_balance_history_record_count()
  68. def purge_old_daily_aggregated_stats(self, days_to_keep: int = 365) -> int:
  69. """Purge old daily aggregated stats."""
  70. return self.db_manager.purge_old_daily_aggregated_stats(days_to_keep)
  71. # =============================================================================
  72. # ORDER MANAGEMENT DELEGATION
  73. # =============================================================================
  74. def record_order_placed(self, symbol: str, side: str, order_type: str,
  75. amount_requested: float, price: Optional[float] = None,
  76. bot_order_ref_id: Optional[str] = None,
  77. exchange_order_id: Optional[str] = None,
  78. timestamp: Optional[str] = None) -> bool:
  79. """Record order placement."""
  80. result = self.order_manager.record_order_placed(
  81. symbol, side, order_type, amount_requested, price,
  82. bot_order_ref_id, exchange_order_id
  83. )
  84. return result is not None
  85. def update_order_exchange_id(self, bot_order_ref_id: str, exchange_order_id: str) -> bool:
  86. """Update order with exchange ID."""
  87. return self.order_manager.update_order_exchange_id(bot_order_ref_id, exchange_order_id)
  88. def record_order_filled(self, exchange_order_id: str, actual_amount: float,
  89. actual_price: float, fees: float = 0.0,
  90. timestamp: Optional[str] = None,
  91. exchange_fill_id: Optional[str] = None) -> bool:
  92. """Record order fill."""
  93. return self.order_manager.record_order_filled(
  94. exchange_order_id, actual_amount, actual_price, fees, timestamp, exchange_fill_id
  95. )
  96. def record_order_cancelled(self, exchange_order_id: str, reason: str = "user_cancelled",
  97. timestamp: Optional[str] = None) -> bool:
  98. """Record order cancellation."""
  99. return self.order_manager.record_order_cancelled(exchange_order_id, reason, timestamp)
  100. def update_order_status(self, exchange_order_id: str, new_status: str,
  101. notes: Optional[str] = None, timestamp: Optional[str] = None) -> bool:
  102. """Update order status."""
  103. return self.order_manager.update_order_status(exchange_order_id, new_status, notes, timestamp)
  104. def get_order_by_exchange_id(self, exchange_order_id: str) -> Optional[Dict[str, Any]]:
  105. """Get order by exchange ID."""
  106. return self.order_manager.get_order_by_exchange_id(exchange_order_id)
  107. def get_order_by_bot_ref_id(self, bot_order_ref_id: str) -> Optional[Dict[str, Any]]:
  108. """Get order by bot reference ID."""
  109. return self.order_manager.get_order_by_bot_ref_id(bot_order_ref_id)
  110. def get_orders_by_symbol(self, symbol: str, limit: int = 50) -> List[Dict[str, Any]]:
  111. """Get orders by symbol."""
  112. return self.order_manager.get_orders_by_symbol(symbol, limit)
  113. def get_orders_by_status(self, status: str, limit: Optional[int] = 50,
  114. order_type_filter: Optional[str] = None,
  115. parent_bot_order_ref_id: Optional[str] = None) -> List[Dict[str, Any]]:
  116. """Get orders by status with optional filters."""
  117. # OrderManager expects (status, order_type_filter, parent_bot_order_ref_id) without limit
  118. return self.order_manager.get_orders_by_status(status, order_type_filter, parent_bot_order_ref_id)
  119. def get_recent_orders(self, limit: int = 20) -> List[Dict[str, Any]]:
  120. """Get recent orders."""
  121. return self.order_manager.get_recent_orders(limit)
  122. def cleanup_old_cancelled_orders(self, days_old: int = 7) -> int:
  123. """Clean up old cancelled orders."""
  124. return self.order_manager.cleanup_old_cancelled_orders(days_old)
  125. # =============================================================================
  126. # TRADE LIFECYCLE DELEGATION
  127. # =============================================================================
  128. def create_trade_lifecycle(self, symbol: str, side: str, entry_order_id: Optional[str] = None,
  129. entry_bot_order_ref_id: Optional[str] = None,
  130. stop_loss_price: Optional[float] = None,
  131. take_profit_price: Optional[float] = None,
  132. trade_type: str = 'manual') -> Optional[str]:
  133. """Create trade lifecycle."""
  134. return self.trade_manager.create_trade_lifecycle(
  135. symbol, side, entry_order_id, entry_bot_order_ref_id,
  136. stop_loss_price, take_profit_price, trade_type
  137. )
  138. def update_trade_position_opened(self, lifecycle_id: str, entry_price: float,
  139. entry_amount: float, exchange_fill_id: str) -> bool:
  140. """Update trade position opened."""
  141. return self.trade_manager.update_trade_position_opened(
  142. lifecycle_id, entry_price, entry_amount, exchange_fill_id
  143. )
  144. def update_trade_position_closed(self, lifecycle_id: str, exit_price: float,
  145. realized_pnl: float, exchange_fill_id: str) -> bool:
  146. """Update trade position closed."""
  147. return self.trade_manager.update_trade_position_closed(
  148. lifecycle_id, exit_price, realized_pnl, exchange_fill_id
  149. )
  150. def update_trade_cancelled(self, lifecycle_id: str, reason: str = "order_cancelled") -> bool:
  151. """Update trade cancelled."""
  152. return self.trade_manager.update_trade_cancelled(lifecycle_id, reason)
  153. def link_stop_loss_to_trade(self, lifecycle_id: str, stop_loss_order_id: str,
  154. stop_loss_price: float) -> bool:
  155. """Link stop loss to trade."""
  156. return self.trade_manager.link_stop_loss_to_trade(
  157. lifecycle_id, stop_loss_order_id, stop_loss_price
  158. )
  159. def link_take_profit_to_trade(self, lifecycle_id: str, take_profit_order_id: str,
  160. take_profit_price: float) -> bool:
  161. """Link take profit to trade."""
  162. return self.trade_manager.link_take_profit_to_trade(
  163. lifecycle_id, take_profit_order_id, take_profit_price
  164. )
  165. def get_trade_by_lifecycle_id(self, lifecycle_id: str) -> Optional[Dict[str, Any]]:
  166. """Get trade by lifecycle ID."""
  167. return self.trade_manager.get_trade_by_lifecycle_id(lifecycle_id)
  168. def get_trade_by_symbol_and_status(self, symbol: str, status: str = 'position_opened') -> Optional[Dict[str, Any]]:
  169. """Get trade by symbol and status."""
  170. return self.trade_manager.get_trade_by_symbol_and_status(symbol, status)
  171. def get_open_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
  172. """Get open positions."""
  173. return self.trade_manager.get_open_positions(symbol)
  174. def get_trades_by_status(self, status: str, limit: int = 50) -> List[Dict[str, Any]]:
  175. """Get trades by status."""
  176. return self.trade_manager.get_trades_by_status(status, limit)
  177. def get_lifecycle_by_entry_order_id(self, entry_exchange_order_id: str, status: Optional[str] = None) -> Optional[Dict[str, Any]]:
  178. """Get lifecycle by entry order ID."""
  179. return self.trade_manager.get_lifecycle_by_entry_order_id(entry_exchange_order_id, status)
  180. def get_lifecycle_by_sl_order_id(self, sl_exchange_order_id: str, status: str = 'position_opened') -> Optional[Dict[str, Any]]:
  181. """Get lifecycle by stop loss order ID."""
  182. return self.trade_manager.get_lifecycle_by_sl_order_id(sl_exchange_order_id, status)
  183. def get_lifecycle_by_tp_order_id(self, tp_exchange_order_id: str, status: str = 'position_opened') -> Optional[Dict[str, Any]]:
  184. """Get lifecycle by take profit order ID."""
  185. return self.trade_manager.get_lifecycle_by_tp_order_id(tp_exchange_order_id, status)
  186. def get_pending_stop_loss_activations(self) -> List[Dict[str, Any]]:
  187. """Get pending stop loss activations."""
  188. return self.trade_manager.get_pending_stop_loss_activations()
  189. def cleanup_old_cancelled_trades(self, days_old: int = 7) -> int:
  190. """Clean up old cancelled trades."""
  191. return self.trade_manager.cleanup_old_cancelled_trades(days_old)
  192. def confirm_position_with_exchange(self, symbol: str, exchange_position_size: float,
  193. exchange_open_orders: List[Dict]) -> bool:
  194. """Confirm position with exchange."""
  195. return self.trade_manager.confirm_position_with_exchange(
  196. symbol, exchange_position_size, exchange_open_orders
  197. )
  198. def update_trade_market_data(self, trade_lifecycle_id: str, **kwargs) -> bool:
  199. """Update trade market data."""
  200. return self.trade_manager.update_trade_market_data(trade_lifecycle_id, **kwargs)
  201. def get_recent_trades(self, limit: int = 10) -> List[Dict[str, Any]]:
  202. """Get recent trades."""
  203. return self.trade_manager.get_recent_trades(limit)
  204. def get_all_trades(self) -> List[Dict[str, Any]]:
  205. """Get all trades."""
  206. return self.trade_manager.get_all_trades()
  207. # =============================================================================
  208. # AGGREGATION MANAGEMENT DELEGATION
  209. # =============================================================================
  210. def migrate_trade_to_aggregated_stats(self, trade_lifecycle_id: str):
  211. """Migrate trade to aggregated stats."""
  212. return self.aggregation_manager.migrate_trade_to_aggregated_stats(trade_lifecycle_id)
  213. def record_deposit(self, amount: float, timestamp: Optional[str] = None,
  214. deposit_id: Optional[str] = None, description: Optional[str] = None):
  215. """Record deposit."""
  216. return self.aggregation_manager.record_deposit(amount, timestamp, deposit_id, description)
  217. def record_withdrawal(self, amount: float, timestamp: Optional[str] = None,
  218. withdrawal_id: Optional[str] = None, description: Optional[str] = None):
  219. """Record withdrawal."""
  220. return self.aggregation_manager.record_withdrawal(amount, timestamp, withdrawal_id, description)
  221. def get_balance_adjustments_summary(self) -> Dict[str, Any]:
  222. """Get balance adjustments summary."""
  223. return self.aggregation_manager.get_balance_adjustments_summary()
  224. def get_daily_stats(self, limit: int = 10) -> List[Dict[str, Any]]:
  225. """Get daily stats."""
  226. return self.aggregation_manager.get_daily_stats(limit)
  227. def get_weekly_stats(self, limit: int = 10) -> List[Dict[str, Any]]:
  228. """Get weekly stats."""
  229. return self.aggregation_manager.get_weekly_stats(limit)
  230. def get_monthly_stats(self, limit: int = 10) -> List[Dict[str, Any]]:
  231. """Get monthly stats."""
  232. return self.aggregation_manager.get_monthly_stats(limit)
  233. # =============================================================================
  234. # PERFORMANCE CALCULATION DELEGATION
  235. # =============================================================================
  236. def get_performance_stats(self) -> Dict[str, Any]:
  237. """Get performance stats."""
  238. return self.performance_calculator.get_performance_stats()
  239. def get_token_performance(self, limit: int = 20) -> List[Dict[str, Any]]:
  240. """Get token performance."""
  241. return self.performance_calculator.get_token_performance(limit)
  242. def get_balance_history(self, days: int = 30) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
  243. """Get balance history."""
  244. return self.performance_calculator.get_balance_history(days)
  245. def get_live_max_drawdown(self) -> Tuple[float, float]:
  246. """Get live max drawdown."""
  247. return self.performance_calculator.get_live_max_drawdown()
  248. def update_live_max_drawdown(self, current_balance: float) -> bool:
  249. """Update live max drawdown."""
  250. return self.performance_calculator.update_live_max_drawdown(current_balance)
  251. def get_drawdown_monitor_data(self) -> Dict[str, float]:
  252. """Get drawdown data from DrawdownMonitor for external monitoring systems."""
  253. try:
  254. peak_balance = float(self._get_metadata('drawdown_peak_balance') or '0.0')
  255. max_drawdown_pct = float(self._get_metadata('drawdown_max_drawdown_pct') or '0.0')
  256. return {
  257. 'peak_balance': peak_balance,
  258. 'max_drawdown_percentage': max_drawdown_pct
  259. }
  260. except (ValueError, TypeError):
  261. return {'peak_balance': 0.0, 'max_drawdown_percentage': 0.0}
  262. def calculate_sharpe_ratio(self, days: int = 30) -> Optional[float]:
  263. """Calculate Sharpe ratio."""
  264. return self.performance_calculator.calculate_sharpe_ratio(days)
  265. def calculate_max_consecutive_losses(self) -> int:
  266. """Calculate max consecutive losses."""
  267. return self.performance_calculator.calculate_max_consecutive_losses()
  268. def get_risk_metrics(self) -> Dict[str, Any]:
  269. """Get risk metrics."""
  270. return self.performance_calculator.get_risk_metrics()
  271. def get_period_performance(self, start_date: str, end_date: str) -> Dict[str, Any]:
  272. """Get period performance."""
  273. return self.performance_calculator.get_period_performance(start_date, end_date)
  274. def get_recent_performance_trend(self, days: int = 7) -> Dict[str, Any]:
  275. """Get recent performance trend."""
  276. return self.performance_calculator.get_recent_performance_trend(days)
  277. # =============================================================================
  278. # COMPATIBILITY METHODS - Legacy API Support
  279. # =============================================================================
  280. def get_basic_stats(self, current_balance: Optional[float] = None) -> Dict[str, Any]:
  281. """Get basic trading statistics from DB, primarily using aggregated tables."""
  282. # Get counts of open positions (trades that are not yet migrated)
  283. open_positions_count = self._get_open_positions_count_from_db()
  284. # Get overall aggregated stats from token_stats table
  285. query_token_stats_summary = """
  286. SELECT
  287. SUM(total_realized_pnl) as total_pnl_from_cycles,
  288. SUM(total_completed_cycles) as total_completed_cycles_sum,
  289. MIN(first_cycle_closed_at) as overall_first_cycle_closed,
  290. MAX(last_cycle_closed_at) as overall_last_cycle_closed
  291. FROM token_stats
  292. """
  293. token_stats_summary = self.db_manager._fetchone_query(query_token_stats_summary)
  294. total_pnl_from_cycles = token_stats_summary['total_pnl_from_cycles'] if token_stats_summary and token_stats_summary['total_pnl_from_cycles'] is not None else 0.0
  295. total_completed_cycles_sum = token_stats_summary['total_completed_cycles_sum'] if token_stats_summary and token_stats_summary['total_completed_cycles_sum'] is not None else 0
  296. # Total trades considered as sum of completed cycles and currently open positions
  297. total_trades_redefined = total_completed_cycles_sum + open_positions_count
  298. initial_balance_str = self._get_metadata('initial_balance')
  299. initial_balance = float(initial_balance_str) if initial_balance_str else 0.0
  300. start_date_iso = self._get_metadata('start_date')
  301. start_date_obj = datetime.fromisoformat(start_date_iso) if start_date_iso else datetime.now(timezone.utc)
  302. days_active = (datetime.now(timezone.utc) - start_date_obj).days + 1
  303. # 'last_trade' timestamp could be the last update to token_stats or an open trade
  304. last_activity_ts = token_stats_summary['overall_last_cycle_closed'] if token_stats_summary else None
  305. last_open_trade_ts_row = self.db_manager._fetchone_query("SELECT MAX(updated_at) as last_update FROM trades WHERE status = 'position_opened'")
  306. if last_open_trade_ts_row and last_open_trade_ts_row['last_update']:
  307. if not last_activity_ts or datetime.fromisoformat(last_open_trade_ts_row['last_update']) > datetime.fromisoformat(last_activity_ts):
  308. last_activity_ts = last_open_trade_ts_row['last_update']
  309. return {
  310. 'total_trades': total_trades_redefined,
  311. 'completed_trades': total_completed_cycles_sum,
  312. 'initial_balance': initial_balance,
  313. 'total_pnl': total_pnl_from_cycles,
  314. 'days_active': days_active,
  315. 'start_date': start_date_obj.strftime('%Y-%m-%d'),
  316. 'last_trade': last_activity_ts,
  317. 'open_positions_count': open_positions_count
  318. }
  319. def _get_open_positions_count_from_db(self) -> int:
  320. """Get count of open positions from trades table."""
  321. row = self.db_manager._fetchone_query("SELECT COUNT(DISTINCT symbol) as count FROM trades WHERE status = 'position_opened'")
  322. return row['count'] if row else 0
  323. def get_token_detailed_stats(self, token: str) -> Dict[str, Any]:
  324. """Get detailed statistics for a specific token."""
  325. try:
  326. # Normalize token case
  327. upper_token = _normalize_token_case(token)
  328. # Get aggregated stats from token_stats table
  329. token_agg_stats = self.db_manager._fetchone_query(
  330. "SELECT * FROM token_stats WHERE token = ?", (upper_token,)
  331. )
  332. # Get open trades for this token
  333. open_trades_for_token = self.db_manager._fetch_query(
  334. "SELECT * FROM trades WHERE status = 'position_opened' AND symbol LIKE ? ORDER BY position_opened_at DESC",
  335. (f"{upper_token}/%",)
  336. )
  337. # Initialize performance stats
  338. perf_stats = {
  339. 'completed_trades': 0,
  340. 'total_pnl': 0.0,
  341. 'pnl_percentage': 0.0,
  342. 'win_rate': 0.0,
  343. 'profit_factor': 0.0,
  344. 'avg_win': 0.0,
  345. 'avg_loss': 0.0,
  346. 'largest_win': 0.0,
  347. 'largest_loss': 0.0,
  348. 'expectancy': 0.0,
  349. 'total_wins': 0,
  350. 'total_losses': 0,
  351. 'completed_entry_volume': 0.0,
  352. 'completed_exit_volume': 0.0,
  353. 'total_cancelled': 0,
  354. 'total_duration_seconds': 0,
  355. 'avg_trade_duration': "N/A"
  356. }
  357. if token_agg_stats:
  358. total_cycles = token_agg_stats.get('total_completed_cycles', 0)
  359. winning_cycles = token_agg_stats.get('winning_cycles', 0)
  360. losing_cycles = token_agg_stats.get('losing_cycles', 0)
  361. sum_winning_pnl = token_agg_stats.get('sum_of_winning_pnl', 0.0)
  362. sum_losing_pnl = token_agg_stats.get('sum_of_losing_pnl', 0.0)
  363. # Calculate percentages for largest trades
  364. largest_win_pnl = token_agg_stats.get('largest_winning_cycle_pnl', 0.0)
  365. largest_loss_pnl = token_agg_stats.get('largest_losing_cycle_pnl', 0.0)
  366. largest_win_entry_volume = token_agg_stats.get('largest_winning_cycle_entry_volume', 0.0)
  367. largest_loss_entry_volume = token_agg_stats.get('largest_losing_cycle_entry_volume', 0.0)
  368. largest_win_percentage = (largest_win_pnl / largest_win_entry_volume * 100) if largest_win_entry_volume > 0 else 0.0
  369. largest_loss_percentage = (largest_loss_pnl / largest_loss_entry_volume * 100) if largest_loss_entry_volume > 0 else 0.0
  370. perf_stats.update({
  371. 'completed_trades': total_cycles,
  372. 'total_pnl': token_agg_stats.get('total_realized_pnl', 0.0),
  373. 'win_rate': (winning_cycles / total_cycles * 100) if total_cycles > 0 else 0.0,
  374. 'profit_factor': (sum_winning_pnl / sum_losing_pnl) if sum_losing_pnl > 0 else float('inf') if sum_winning_pnl > 0 else 0.0,
  375. 'avg_win': (sum_winning_pnl / winning_cycles) if winning_cycles > 0 else 0.0,
  376. 'avg_loss': (sum_losing_pnl / losing_cycles) if losing_cycles > 0 else 0.0,
  377. 'largest_win': largest_win_pnl,
  378. 'largest_loss': largest_loss_pnl,
  379. 'largest_win_percentage': largest_win_percentage,
  380. 'largest_loss_percentage': largest_loss_percentage,
  381. 'total_wins': winning_cycles,
  382. 'total_losses': losing_cycles,
  383. 'completed_entry_volume': token_agg_stats.get('total_entry_volume', 0.0),
  384. 'completed_exit_volume': token_agg_stats.get('total_exit_volume', 0.0),
  385. 'total_cancelled': token_agg_stats.get('total_cancelled_cycles', 0),
  386. 'total_duration_seconds': token_agg_stats.get('total_duration_seconds', 0)
  387. })
  388. # Calculate expectancy
  389. win_rate_decimal = perf_stats['win_rate'] / 100
  390. perf_stats['expectancy'] = (perf_stats['avg_win'] * win_rate_decimal) - (perf_stats['avg_loss'] * (1 - win_rate_decimal))
  391. # Format average trade duration
  392. if total_cycles > 0:
  393. avg_duration_seconds = token_agg_stats.get('total_duration_seconds', 0) / total_cycles
  394. perf_stats['avg_trade_duration'] = self._format_duration(avg_duration_seconds)
  395. # Calculate open positions summary
  396. open_positions_summary = []
  397. total_open_value = 0.0
  398. total_open_unrealized_pnl = 0.0
  399. for op_trade in open_trades_for_token:
  400. open_positions_summary.append({
  401. 'lifecycle_id': op_trade.get('trade_lifecycle_id'),
  402. 'side': op_trade.get('position_side'),
  403. 'amount': op_trade.get('current_position_size'),
  404. 'entry_price': op_trade.get('entry_price'),
  405. 'mark_price': op_trade.get('mark_price'),
  406. 'unrealized_pnl': op_trade.get('unrealized_pnl'),
  407. 'opened_at': op_trade.get('position_opened_at')
  408. })
  409. total_open_value += op_trade.get('value', 0.0)
  410. total_open_unrealized_pnl += op_trade.get('unrealized_pnl', 0.0)
  411. # Get open orders count for this token
  412. open_orders_count_row = self.db_manager._fetchone_query(
  413. "SELECT COUNT(*) as count FROM orders WHERE symbol LIKE ? AND status IN ('open', 'submitted', 'pending_trigger')",
  414. (f"{upper_token}/%",)
  415. )
  416. current_open_orders_for_token = open_orders_count_row['count'] if open_orders_count_row else 0
  417. effective_total_trades = perf_stats['completed_trades'] + len(open_trades_for_token)
  418. return {
  419. 'token': upper_token,
  420. 'message': f"Statistics for {upper_token}",
  421. 'performance_summary': perf_stats, # Expected key by formatting method
  422. 'performance': perf_stats, # Legacy compatibility
  423. 'open_positions': open_positions_summary, # Direct list as expected
  424. 'summary_total_trades': effective_total_trades, # Expected by formatting method
  425. 'summary_total_unrealized_pnl': total_open_unrealized_pnl, # Expected by formatting method
  426. 'current_open_orders_count': current_open_orders_for_token, # Expected by formatting method
  427. 'summary': {
  428. 'total_trades': effective_total_trades,
  429. 'open_orders': current_open_orders_for_token,
  430. }
  431. }
  432. except Exception as e:
  433. logger.error(f"❌ Error getting detailed stats for {token}: {e}")
  434. return {}
  435. def _format_duration(self, seconds: float) -> str:
  436. """Format duration in seconds to a human-readable string."""
  437. if seconds <= 0:
  438. return "0s"
  439. days = int(seconds // 86400)
  440. hours = int((seconds % 86400) // 3600)
  441. minutes = int((seconds % 3600) // 60)
  442. secs = int(seconds % 60)
  443. parts = []
  444. if days > 0:
  445. parts.append(f"{days}d")
  446. if hours > 0:
  447. parts.append(f"{hours}h")
  448. if minutes > 0:
  449. parts.append(f"{minutes}m")
  450. if secs > 0 or not parts:
  451. parts.append(f"{secs}s")
  452. return " ".join(parts)
  453. def format_stats_message(self, current_balance: Optional[float] = None) -> str:
  454. """Format stats for Telegram display using data from DB."""
  455. try:
  456. basic = self.get_basic_stats(current_balance)
  457. perf = self.get_performance_stats()
  458. risk = self.get_risk_metrics()
  459. formatter = get_formatter()
  460. effective_current_balance = current_balance if current_balance is not None else (basic['initial_balance'] + basic['total_pnl'])
  461. initial_bal = basic['initial_balance']
  462. total_pnl_val = effective_current_balance - initial_bal if initial_bal > 0 and current_balance is not None else basic['total_pnl']
  463. total_return_pct = (total_pnl_val / initial_bal * 100) if initial_bal > 0 else 0.0
  464. pnl_emoji = "🟢" if total_pnl_val >= 0 else "🔴"
  465. open_positions_count = basic['open_positions_count']
  466. stats_text_parts = []
  467. stats_text_parts.append(f"📊 <b>Trading Statistics</b>\n")
  468. # Account Overview
  469. stats_text_parts.append(f"\n💰 <b>Account Overview:</b>")
  470. stats_text_parts.append(f"• Current Balance: {formatter.format_price_with_symbol(effective_current_balance)}")
  471. stats_text_parts.append(f"• Initial Balance: {formatter.format_price_with_symbol(initial_bal)}")
  472. stats_text_parts.append(f"• Open Positions: {open_positions_count}")
  473. stats_text_parts.append(f"• {pnl_emoji} Total P&L: {formatter.format_price_with_symbol(total_pnl_val)} ({total_return_pct:+.2f}%)")
  474. stats_text_parts.append(f"• Days Active: {basic['days_active']}\n")
  475. # Performance Metrics
  476. stats_text_parts.append(f"\n🏆 <b>Performance Metrics:</b>")
  477. stats_text_parts.append(f"• Total Completed Trades: {basic['completed_trades']}")
  478. stats_text_parts.append(f"• Win Rate: {perf['win_rate']:.1f}% ({perf['total_wins']}/{basic['completed_trades']})")
  479. stats_text_parts.append(f"• Trading Volume (Entry Vol.): {formatter.format_price_with_symbol(perf.get('total_trading_volume', 0.0))}")
  480. stats_text_parts.append(f"• Profit Factor: {perf['profit_factor']:.2f}")
  481. stats_text_parts.append(f"• Expectancy: {formatter.format_price_with_symbol(perf['expectancy'])}")
  482. largest_win_pct_str = f" ({perf.get('largest_winning_percentage', 0):+.2f}%)" if perf.get('largest_winning_percentage', 0) != 0 else ""
  483. largest_loss_pct_str = f" ({perf.get('largest_losing_percentage', 0):+.2f}%)" if perf.get('largest_losing_percentage', 0) != 0 else ""
  484. stats_text_parts.append(f"• Largest Winning Trade: {formatter.format_price_with_symbol(perf['largest_win'])}{largest_win_pct_str}")
  485. stats_text_parts.append(f"• Largest Losing Trade: {formatter.format_price_with_symbol(-perf['largest_loss'])}{largest_loss_pct_str}")
  486. best_token_stats = perf.get('best_performing_token', {'name': 'N/A', 'pnl_percentage': 0.0, 'volume': 0.0, 'pnl_value': 0.0})
  487. worst_token_stats = perf.get('worst_performing_token', {'name': 'N/A', 'pnl_percentage': 0.0, 'volume': 0.0, 'pnl_value': 0.0})
  488. stats_text_parts.append(f"• Best Token: {best_token_stats['name']} {formatter.format_price_with_symbol(best_token_stats['pnl_value'])} ({best_token_stats['pnl_percentage']:+.2f}%)")
  489. stats_text_parts.append(f"• Worst Token: {worst_token_stats['name']} {formatter.format_price_with_symbol(worst_token_stats['pnl_value'])} ({worst_token_stats['pnl_percentage']:+.2f}%)")
  490. stats_text_parts.append(f"• Average Trade Duration: {perf.get('avg_trade_duration', 'N/A')}")
  491. stats_text_parts.append(f"• Portfolio Max Drawdown: {risk.get('max_drawdown_live_percentage', 0.0):.2f}% <i>(Live)</i>")
  492. # Session Info
  493. stats_text_parts.append(f"\n\n⏰ <b>Session Info:</b>")
  494. stats_text_parts.append(f"• Bot Started: {basic['start_date']}")
  495. stats_text_parts.append(f"• Stats Last Updated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}")
  496. return "\n".join(stats_text_parts).strip()
  497. except Exception as e:
  498. logger.error(f"❌ Error formatting stats message: {e}", exc_info=True)
  499. return f"""📊 <b>Trading Statistics</b>\n\n❌ <b>Error loading statistics</b>\n\n🔧 <b>Debug info:</b> {str(e)[:100]}"""
  500. def format_token_stats_message(self, token: str) -> str:
  501. """Format detailed statistics for a specific token."""
  502. try:
  503. from src.utils.token_display_formatter import get_formatter
  504. formatter = get_formatter()
  505. token_stats_data = self.get_token_detailed_stats(token)
  506. token_name = token_stats_data.get('token', token.upper())
  507. if not token_stats_data or token_stats_data.get('summary_total_trades', 0) == 0:
  508. return (
  509. f"📊 <b>{token_name} Statistics</b>\n\n"
  510. f"📭 No trading data found for {token_name}.\n\n"
  511. f"💡 To trade this token, try commands like:\n"
  512. f" <code>/long {token_name} 100</code>\n"
  513. f" <code>/short {token_name} 100</code>"
  514. )
  515. perf_summary = token_stats_data.get('performance_summary', {})
  516. open_positions = token_stats_data.get('open_positions', [])
  517. parts = [f"📊 <b>{token_name.upper()} Detailed Statistics</b>\n"]
  518. # Completed Trades Summary
  519. parts.append("📈 <b>Completed Trades Summary:</b>")
  520. if perf_summary.get('completed_trades', 0) > 0:
  521. pnl_emoji = "🟢" if perf_summary.get('total_pnl', 0) >= 0 else "🔴"
  522. entry_vol = perf_summary.get('completed_entry_volume', 0.0)
  523. pnl_pct = (perf_summary.get('total_pnl', 0.0) / entry_vol * 100) if entry_vol > 0 else 0.0
  524. parts.append(f"• Total Completed: {perf_summary.get('completed_trades', 0)}")
  525. parts.append(f"• {pnl_emoji} Realized P&L: {formatter.format_price_with_symbol(perf_summary.get('total_pnl', 0.0))} ({pnl_pct:+.2f}%)")
  526. 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)")
  527. parts.append(f"• Profit Factor: {perf_summary.get('profit_factor', 0.0):.2f}")
  528. parts.append(f"• Expectancy: {formatter.format_price_with_symbol(perf_summary.get('expectancy', 0.0))}")
  529. 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))}")
  530. # Format largest trades with percentages
  531. largest_win_pct_str = f" ({perf_summary.get('largest_win_percentage', 0):+.2f}%)" if perf_summary.get('largest_win_percentage', 0) != 0 else ""
  532. largest_loss_pct_str = f" ({perf_summary.get('largest_loss_percentage', 0):+.2f}%)" if perf_summary.get('largest_loss_percentage', 0) != 0 else ""
  533. parts.append(f"• Largest Win: {formatter.format_price_with_symbol(perf_summary.get('largest_win', 0.0))}{largest_win_pct_str} | Largest Loss: {formatter.format_price_with_symbol(perf_summary.get('largest_loss', 0.0))}{largest_loss_pct_str}")
  534. parts.append(f"• Entry Volume: {formatter.format_price_with_symbol(perf_summary.get('completed_entry_volume', 0.0))}")
  535. parts.append(f"• Exit Volume: {formatter.format_price_with_symbol(perf_summary.get('completed_exit_volume', 0.0))}")
  536. parts.append(f"• Average Trade Duration: {perf_summary.get('avg_trade_duration', 'N/A')}")
  537. parts.append(f"• Cancelled Cycles: {perf_summary.get('total_cancelled', 0)}")
  538. else:
  539. parts.append("• No completed trades for this token yet.")
  540. parts.append("")
  541. # Open Positions
  542. parts.append("📉 <b>Current Open Positions:</b>")
  543. if open_positions:
  544. total_open_unrealized_pnl = token_stats_data.get('summary_total_unrealized_pnl', 0.0)
  545. open_pnl_emoji = "🟢" if total_open_unrealized_pnl >= 0 else "🔴"
  546. for pos in open_positions:
  547. pos_side_emoji = "🟢" if pos.get('side') == 'long' else "🔴"
  548. pos_pnl_emoji = "🟢" if pos.get('unrealized_pnl', 0) >= 0 else "🔴"
  549. opened_at_str = "N/A"
  550. if pos.get('opened_at'):
  551. try:
  552. from datetime import datetime
  553. opened_at_dt = datetime.fromisoformat(pos['opened_at'])
  554. opened_at_str = opened_at_dt.strftime('%Y-%m-%d %H:%M')
  555. except:
  556. pass
  557. parts.append(f"• {pos_side_emoji} {pos.get('side', '').upper()} {formatter.format_amount(abs(pos.get('amount',0)), token_name)} {token_name}")
  558. 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)}")
  559. parts.append(f" {pos_pnl_emoji} Unrealized P&L: {formatter.format_price_with_symbol(pos.get('unrealized_pnl',0))}")
  560. parts.append(f" Opened: {opened_at_str} | ID: ...{pos.get('lifecycle_id', '')[-6:]}")
  561. parts.append(f" {open_pnl_emoji} <b>Total Open P&L: {formatter.format_price_with_symbol(total_open_unrealized_pnl)}</b>")
  562. else:
  563. parts.append("• No open positions for this token.")
  564. parts.append("")
  565. parts.append(f"📋 Open Orders (Exchange): {token_stats_data.get('current_open_orders_count', 0)}")
  566. parts.append(f"💡 Use <code>/performance {token_name}</code> for another view including recent trades.")
  567. return "\n".join(parts)
  568. except Exception as e:
  569. logger.error(f"❌ Error formatting token stats message for {token}: {e}", exc_info=True)
  570. return f"❌ Error generating statistics for {token}: {str(e)[:100]}"
  571. # =============================================================================
  572. # CONVENIENCE METHODS & HIGH-LEVEL OPERATIONS
  573. # =============================================================================
  574. def process_trade_complete_cycle(self, symbol: str, side: str, entry_price: float,
  575. exit_price: float, amount: float,
  576. timestamp: Optional[str] = None) -> str:
  577. """Process a complete trade cycle in one operation."""
  578. # Create lifecycle
  579. lifecycle_id = self.create_trade_lifecycle(symbol, side, trade_type='complete_cycle')
  580. if not lifecycle_id:
  581. raise Exception("Failed to create trade lifecycle")
  582. # Update to position opened
  583. success = self.update_trade_position_opened(lifecycle_id, entry_price, amount, "manual_entry")
  584. if not success:
  585. raise Exception("Failed to update position opened")
  586. # Calculate PnL
  587. if side.lower() == 'buy':
  588. realized_pnl = (exit_price - entry_price) * amount
  589. else: # sell
  590. realized_pnl = (entry_price - exit_price) * amount
  591. # Update to position closed
  592. success = self.update_trade_position_closed(lifecycle_id, exit_price, realized_pnl, "manual_exit")
  593. if not success:
  594. raise Exception("Failed to update position closed")
  595. # Migrate to aggregated stats
  596. self.migrate_trade_to_aggregated_stats(lifecycle_id)
  597. logger.info(f"✅ Processed complete trade cycle: {symbol} {side.upper()} P&L: ${realized_pnl:.2f}")
  598. return lifecycle_id
  599. def get_summary_report(self) -> Dict[str, Any]:
  600. """Get comprehensive summary report."""
  601. try:
  602. perf_stats = self.get_performance_stats()
  603. token_performance = self.get_token_performance(limit=10)
  604. daily_stats = self.get_daily_stats(limit=7)
  605. risk_metrics = self.get_risk_metrics()
  606. balance_adjustments = self.get_balance_adjustments_summary()
  607. # Get current positions
  608. open_positions = self.get_open_positions()
  609. return {
  610. 'performance_stats': perf_stats,
  611. 'top_tokens': token_performance,
  612. 'recent_daily_stats': daily_stats,
  613. 'risk_metrics': risk_metrics,
  614. 'balance_adjustments': balance_adjustments,
  615. 'open_positions_count': len(open_positions),
  616. 'open_positions': open_positions,
  617. 'generated_at': datetime.now(timezone.utc).isoformat()
  618. }
  619. except Exception as e:
  620. logger.error(f"❌ Error generating summary report: {e}")
  621. return {'error': str(e)}
  622. def record_trade(self, symbol: str, side: str, amount: float, price: float,
  623. exchange_fill_id: Optional[str] = None, trade_type: str = "manual",
  624. pnl: Optional[float] = None, timestamp: Optional[str] = None,
  625. linked_order_table_id_to_link: Optional[int] = None):
  626. """Record a trade directly in the database (used for unmatched external fills)."""
  627. if timestamp is None:
  628. timestamp = datetime.now(timezone.utc).isoformat()
  629. value = amount * price
  630. try:
  631. self.db_manager._execute_query(
  632. "INSERT OR IGNORE INTO trades (symbol, side, amount, price, value, trade_type, timestamp, exchange_fill_id, pnl, linked_order_table_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
  633. (symbol, side, amount, price, value, trade_type, timestamp, exchange_fill_id, pnl or 0.0, linked_order_table_id_to_link)
  634. )
  635. formatter = get_formatter()
  636. base_asset_for_amount = symbol.split('/')[0] if '/' in symbol else symbol
  637. logger.info(f"📈 Trade recorded: {side.upper()} {formatter.format_amount(amount, base_asset_for_amount)} {symbol} @ {formatter.format_price(price, symbol)} ({formatter.format_price(value, symbol)}) [{trade_type}]")
  638. except Exception as e:
  639. logger.error(f"Failed to record trade: {e}")
  640. def health_check(self) -> Dict[str, Any]:
  641. """Perform health check on all components."""
  642. try:
  643. health = {
  644. 'database': 'ok',
  645. 'order_manager': 'ok',
  646. 'trade_manager': 'ok',
  647. 'aggregation_manager': 'ok',
  648. 'performance_calculator': 'ok',
  649. 'overall': 'ok'
  650. }
  651. # Test database connection
  652. self.db_manager._fetch_query("SELECT 1")
  653. # Test each component with basic operations
  654. self.get_recent_orders(limit=1)
  655. self.get_recent_trades(limit=1)
  656. self.get_daily_stats(limit=1)
  657. self.get_performance_stats()
  658. return health
  659. except Exception as e:
  660. logger.error(f"❌ Health check failed: {e}")
  661. return {'overall': 'error', 'error': str(e)}