position_tracker.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. import asyncio
  2. import logging
  3. import uuid
  4. from typing import Dict, List, Optional, Any
  5. from datetime import datetime, timezone
  6. from ..clients.hyperliquid_client import HyperliquidClient
  7. from ..notifications.notification_manager import NotificationManager
  8. from ..config.config import Config
  9. logger = logging.getLogger(__name__)
  10. class PositionTracker:
  11. """
  12. Simplified position tracker that mirrors exchange state.
  13. Monitors for position changes and saves stats when positions close.
  14. """
  15. def __init__(self, hl_client: HyperliquidClient, notification_manager: NotificationManager):
  16. self.hl_client = hl_client
  17. self.notification_manager = notification_manager
  18. self.trading_stats = None # Will be lazy loaded
  19. # Track current positions
  20. self.current_positions: Dict[str, Dict] = {}
  21. self.is_running = False
  22. async def start(self):
  23. """Start position tracking"""
  24. if self.is_running:
  25. return
  26. self.is_running = True
  27. logger.info("🔄 Starting position tracker")
  28. try:
  29. # Initialize current positions
  30. logger.info("📊 Initializing current positions...")
  31. await self._update_current_positions()
  32. logger.info(f"✅ Position tracker initialized with {len(self.current_positions)} open positions")
  33. # Start monitoring loop
  34. logger.info("🔄 Starting position monitoring loop...")
  35. asyncio.create_task(self._monitoring_loop())
  36. logger.info("✅ Position tracker started successfully")
  37. except Exception as e:
  38. logger.error(f"❌ Error starting position tracker: {e}", exc_info=True)
  39. self.is_running = False
  40. raise
  41. async def stop(self):
  42. """Stop position tracking"""
  43. self.is_running = False
  44. logger.info("Stopping position tracker")
  45. async def _monitoring_loop(self):
  46. """Main monitoring loop"""
  47. logger.info(f"🔄 Position tracker monitoring loop started (heartbeat: {Config.BOT_HEARTBEAT_SECONDS}s)")
  48. loop_count = 0
  49. while self.is_running:
  50. try:
  51. loop_count += 1
  52. logger.debug(f"📊 Position tracker loop #{loop_count} - checking for position changes...")
  53. await self._check_position_changes()
  54. # Log periodically to show it's alive
  55. if loop_count % 12 == 0: # Every 12 loops (60 seconds with 5s heartbeat)
  56. logger.info(f"📊 Position tracker alive - loop #{loop_count}, {len(self.current_positions)} positions tracked")
  57. await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS) # Use config heartbeat
  58. except Exception as e:
  59. logger.error(f"❌ Error in position tracking loop #{loop_count}: {e}", exc_info=True)
  60. await asyncio.sleep(Config.BOT_HEARTBEAT_SECONDS)
  61. logger.info("🛑 Position tracker monitoring loop stopped")
  62. async def _check_position_changes(self):
  63. """Check for any position changes"""
  64. try:
  65. previous_positions = self.current_positions.copy()
  66. await self._update_current_positions()
  67. # Compare with previous positions (simple exchange state tracking)
  68. await self._process_position_changes(previous_positions, self.current_positions)
  69. # Simple database sync check (once per cycle)
  70. await self._sync_database_once()
  71. # Update database with current market data for open positions
  72. await self._update_database_market_data()
  73. except Exception as e:
  74. logger.error(f"Error checking position changes: {e}")
  75. async def _sync_database_once(self):
  76. """Simple bidirectional sync: close database positions that don't exist on exchange,
  77. and create database records for exchange positions that don't exist in database"""
  78. try:
  79. if self.trading_stats is None:
  80. from ..stats.trading_stats import TradingStats
  81. self.trading_stats = TradingStats()
  82. open_trades = self.trading_stats.get_open_positions()
  83. # PART 1: Close database positions that don't exist on exchange
  84. for trade in open_trades:
  85. symbol = trade.get('symbol', '')
  86. if not symbol:
  87. continue
  88. token = symbol.split('/')[0] if '/' in symbol else symbol
  89. # If database position doesn't exist on exchange, close it
  90. if token not in self.current_positions:
  91. # Create simulated position object from database data
  92. entry_price = float(trade.get('entry_price', 0))
  93. amount = float(trade.get('amount', 0))
  94. side = trade.get('side', '').lower()
  95. simulated_position = {
  96. 'size': -amount if side == 'sell' else amount, # sell=short(negative), buy=long(positive)
  97. 'entry_px': entry_price,
  98. 'unrealized_pnl': 0, # Will be calculated
  99. 'margin_used': 0,
  100. 'max_leverage': 1,
  101. 'current_leverage': 1,
  102. 'return_on_equity': 0
  103. }
  104. # Reuse existing position closed handler - consistent behavior!
  105. await self._handle_position_closed(token, simulated_position)
  106. # PART 2: Create database records for exchange positions that don't exist in database
  107. # Get current open trades after potential closures above
  108. current_open_trades = self.trading_stats.get_open_positions()
  109. database_tokens = {trade.get('symbol', '').split('/')[0] for trade in current_open_trades if trade.get('symbol')}
  110. for token, position_data in self.current_positions.items():
  111. if token not in database_tokens:
  112. logger.info(f"🔄 Found exchange position for {token} with no database record - creating trade record")
  113. # Create new trade record using existing process_trade_complete_cycle method
  114. # but we'll need to handle this differently since we don't have entry/exit
  115. # Instead, we'll create a manual position record
  116. full_symbol = f"{token}/USDC:USDC"
  117. size = abs(position_data['size'])
  118. side = 'sell' if position_data['size'] < 0 else 'buy' # sell=short, buy=long
  119. entry_price = position_data['entry_px']
  120. # Create a trade lifecycle record for this existing position
  121. lifecycle_id = str(uuid.uuid4())
  122. timestamp = datetime.now(timezone.utc).isoformat()
  123. # Insert into trades table
  124. query = """
  125. INSERT INTO trades (
  126. trade_lifecycle_id, symbol, side, amount, price, value,
  127. entry_price, current_position_size, position_side, status,
  128. position_opened_at, timestamp, updated_at, trade_type
  129. ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  130. """
  131. position_side = 'short' if side == 'sell' else 'long'
  132. value = size * entry_price
  133. params = (
  134. lifecycle_id, full_symbol, side, size, entry_price, value,
  135. entry_price, size, position_side, 'position_opened',
  136. timestamp, timestamp, timestamp, 'sync_detected'
  137. )
  138. self.trading_stats.db_manager._execute_query(query, params)
  139. logger.info(f"✅ Created database record for {token} position: {side} {size} @ ${entry_price}")
  140. except Exception as e:
  141. logger.error(f"Error syncing database: {e}")
  142. import traceback
  143. traceback.print_exc()
  144. async def _update_database_market_data(self):
  145. """Update database with current market data for open positions"""
  146. try:
  147. # Lazy load TradingStats if needed
  148. if self.trading_stats is None:
  149. from ..stats.trading_stats import TradingStats
  150. self.trading_stats = TradingStats()
  151. # Get open trades from database
  152. open_trades = self.trading_stats.get_open_positions()
  153. for trade in open_trades:
  154. try:
  155. symbol = trade.get('symbol', '')
  156. if not symbol:
  157. continue
  158. # Extract token from symbol (e.g., "BTC/USDC:USDC" -> "BTC")
  159. token = symbol.split('/')[0] if '/' in symbol else symbol
  160. # Find corresponding exchange position
  161. if token in self.current_positions:
  162. pos_data = self.current_positions[token]
  163. # Convert exchange ROE from decimal to percentage
  164. roe_percentage = pos_data['return_on_equity'] * 100
  165. # Get current leverage from database to compare
  166. old_leverage = trade.get('leverage', 0)
  167. new_leverage = pos_data['current_leverage']
  168. # Get current market price for mark price and position value calculation
  169. current_mark_price = 0.0
  170. try:
  171. market_data = self.hl_client.get_market_data(symbol)
  172. if market_data and market_data.get('ticker'):
  173. current_mark_price = float(market_data['ticker'].get('last', 0))
  174. except Exception as e:
  175. logger.debug(f"Could not fetch current market price for {symbol}: {e}")
  176. # Fallback to entry price if we can't get current market price
  177. if current_mark_price <= 0:
  178. current_mark_price = pos_data['entry_px']
  179. # Calculate position value (size * current price)
  180. position_size = abs(pos_data['size'])
  181. position_value = position_size * current_mark_price
  182. # Update database with live market data including position value
  183. self.trading_stats.update_trade_market_data(
  184. trade_lifecycle_id=trade['trade_lifecycle_id'],
  185. current_position_size=position_size,
  186. unrealized_pnl=pos_data['unrealized_pnl'],
  187. roe_percentage=roe_percentage,
  188. mark_price=current_mark_price,
  189. position_value=position_value,
  190. margin_used=pos_data['margin_used'],
  191. leverage=new_leverage # Use current leverage, not max leverage
  192. )
  193. # Log leverage changes
  194. if old_leverage and abs(old_leverage - new_leverage) > 0.1:
  195. logger.info(f"📊 Database updated - Leverage changed for {symbol}: {old_leverage:.1f}x → {new_leverage:.1f}x, "
  196. f"Position Value: ${position_value:,.2f}")
  197. else:
  198. logger.debug(f"Updated market data for {symbol}: leverage={new_leverage:.1f}x, ROE={roe_percentage:.2f}%, "
  199. f"mark_price=${current_mark_price:.4f}, value=${position_value:,.2f}")
  200. except Exception as e:
  201. logger.warning(f"Error updating market data for trade {trade.get('trade_lifecycle_id', 'unknown')}: {e}")
  202. continue
  203. except Exception as e:
  204. logger.error(f"Error updating database market data: {e}")
  205. async def _update_current_positions(self):
  206. """Update current positions from exchange"""
  207. try:
  208. logger.debug("🔍 Fetching positions from Hyperliquid client...")
  209. positions = self.hl_client.get_positions()
  210. # Distinguish between API failure (None) and legitimate empty positions ([])
  211. if positions is None:
  212. logger.warning("📊 API failure - could not fetch positions from exchange!")
  213. # Don't clear positions during API failures - keep last known state to avoid false "position opened" notifications
  214. if not self.current_positions:
  215. # Only clear if we truly have no tracked positions (e.g., first startup)
  216. self.current_positions = {}
  217. else:
  218. logger.info(f"📊 Keeping last known positions during API failure: {list(self.current_positions.keys())}")
  219. return
  220. elif not positions: # Empty list [] - legitimately no positions
  221. logger.info("📊 No open positions on exchange - clearing position tracker state")
  222. self.current_positions = {}
  223. return
  224. logger.info(f"📊 Raw positions data from exchange: {len(positions)} positions")
  225. # Log first position structure for debugging
  226. #if positions:
  227. # logger.info(f"📊 Sample position structure: {positions[0]}")
  228. logger.debug(f"📊 Processing {len(positions)} positions from exchange...")
  229. new_positions = {}
  230. for i, position in enumerate(positions):
  231. logger.debug(f"📊 Processing position {i+1}: {position}")
  232. # Access nested position data from info.position
  233. position_data = position.get('info', {}).get('position', {})
  234. if not position_data:
  235. logger.warning(f"📊 Position {i+1} has no info.position data: {position}")
  236. continue
  237. size = float(position_data.get('szi', '0'))
  238. logger.debug(f"📊 Position {i+1} size: {size}")
  239. if size != 0: # Only include open positions
  240. symbol = position_data.get('coin', '')
  241. if symbol:
  242. # Get actual current leverage from leverage object
  243. leverage_info = position_data.get('leverage', {})
  244. if isinstance(leverage_info, dict) and 'value' in leverage_info:
  245. current_leverage = float(leverage_info['value'])
  246. logger.debug(f"Using current leverage {current_leverage}x for {symbol} (max: {position_data.get('maxLeverage', 'N/A')}x)")
  247. else:
  248. current_leverage = float(position_data.get('maxLeverage', '1'))
  249. logger.debug(f"Fallback to max leverage {current_leverage}x for {symbol} (no current leverage data)")
  250. new_positions[symbol] = {
  251. 'size': size,
  252. 'entry_px': float(position_data.get('entryPx', '0')),
  253. 'unrealized_pnl': float(position_data.get('unrealizedPnl', '0')),
  254. 'margin_used': float(position_data.get('marginUsed', '0')),
  255. 'max_leverage': float(position_data.get('maxLeverage', '1')),
  256. 'current_leverage': current_leverage, # Add current leverage
  257. 'return_on_equity': float(position_data.get('returnOnEquity', '0'))
  258. }
  259. # Log position state changes
  260. had_positions_before = len(self.current_positions) > 0
  261. getting_positions_now = len(new_positions) > 0
  262. if had_positions_before and not getting_positions_now:
  263. logger.info("📊 All positions have been closed on exchange")
  264. elif not had_positions_before and getting_positions_now:
  265. logger.info(f"📊 New positions detected: {list(new_positions.keys())}")
  266. elif had_positions_before and getting_positions_now:
  267. logger.debug(f"✅ Updated current positions: {len(new_positions)} open positions ({list(new_positions.keys())})")
  268. else:
  269. logger.debug(f"✅ Confirmed no open positions on exchange")
  270. self.current_positions = new_positions
  271. except Exception as e:
  272. logger.error(f"❌ Error updating current positions: {e}", exc_info=True)
  273. # Don't clear positions on exception - keep last known state to avoid false notifications
  274. logger.info(f"📊 Keeping last known positions during exception: {list(self.current_positions.keys()) if self.current_positions else 'none'}")
  275. async def _process_position_changes(self, previous: Dict, current: Dict):
  276. """Process changes between previous and current positions"""
  277. # Find new positions
  278. for symbol in current:
  279. if symbol not in previous:
  280. await self._handle_position_opened(symbol, current[symbol])
  281. # Find closed positions
  282. for symbol in previous:
  283. if symbol not in current:
  284. await self._handle_position_closed(symbol, previous[symbol])
  285. # Find changed positions
  286. for symbol in current:
  287. if symbol in previous:
  288. await self._handle_position_changed(symbol, previous[symbol], current[symbol])
  289. async def _handle_position_opened(self, symbol: str, position: Dict):
  290. """Handle new position opened"""
  291. try:
  292. size = position['size']
  293. side = "Long" if size > 0 else "Short"
  294. message = (
  295. f"🟢 Position Opened\n"
  296. f"Token: {symbol}\n"
  297. f"Side: {side}\n"
  298. f"Size: {abs(size):.4f}\n"
  299. f"Entry: ${position['entry_px']:.4f}\n"
  300. f"Leverage: {position.get('current_leverage', position['max_leverage']):.1f}x\n\n"
  301. f"💡 Use /positions to see current positions"
  302. )
  303. await self.notification_manager.send_generic_notification(message)
  304. logger.info(f"Position opened: {symbol} {side} {abs(size)}")
  305. except Exception as e:
  306. logger.error(f"Error handling position opened for {symbol}: {e}")
  307. async def _handle_position_closed(self, symbol: str, position: Dict):
  308. """Handle position closed - find and close the corresponding database trade"""
  309. try:
  310. # Lazy load TradingStats if needed
  311. if self.trading_stats is None:
  312. from ..stats.trading_stats import TradingStats
  313. self.trading_stats = TradingStats()
  314. # Construct full symbol format (symbol here is just token name like "BTC")
  315. full_symbol = f"{symbol}/USDC:USDC"
  316. # Find the open trade in database for this symbol
  317. open_trade = self.trading_stats.get_trade_by_symbol_and_status(full_symbol, 'position_opened')
  318. if not open_trade:
  319. logger.warning(f"No open trade found in database for {full_symbol} - position was closed on exchange but no database record")
  320. return
  321. lifecycle_id = open_trade['trade_lifecycle_id']
  322. entry_price = position['entry_px']
  323. size = abs(position['size'])
  324. side = "Long" if position['size'] > 0 else "Short"
  325. # Get current market price for exit calculation
  326. market_data = self.hl_client.get_market_data(full_symbol)
  327. if not market_data:
  328. logger.error(f"Could not get market data for {full_symbol}")
  329. return
  330. current_price = float(market_data.get('ticker', {}).get('last', 0))
  331. # Calculate realized PnL
  332. if side == "Long":
  333. realized_pnl = (current_price - entry_price) * size
  334. else:
  335. realized_pnl = (entry_price - current_price) * size
  336. # Close the trade in database
  337. success = await self.trading_stats.update_trade_position_closed(
  338. lifecycle_id=lifecycle_id,
  339. exit_price=current_price,
  340. realized_pnl=realized_pnl,
  341. exchange_fill_id="position_tracker_detected_closure"
  342. )
  343. if success:
  344. # Migrate to aggregated stats (token_stats table, etc.)
  345. self.trading_stats.migrate_trade_to_aggregated_stats(lifecycle_id)
  346. # Send clean notification
  347. pnl_emoji = "🟢" if realized_pnl >= 0 else "🔴"
  348. message = (
  349. f"{pnl_emoji} Position Closed\n"
  350. f"Token: {symbol}\n"
  351. f"Side: {side}\n"
  352. f"Size: {size:.4f}\n"
  353. f"Entry: ${entry_price:.4f}\n"
  354. f"Exit: ${current_price:.4f}\n"
  355. f"PnL: ${realized_pnl:.3f}\n\n"
  356. f"💡 Use /positions to see current positions"
  357. )
  358. await self.notification_manager.send_generic_notification(message)
  359. logger.info(f"Position closed: {symbol} {side} PnL: ${realized_pnl:.3f}")
  360. else:
  361. logger.error(f"Failed to close trade {lifecycle_id} for {symbol}")
  362. except Exception as e:
  363. logger.error(f"Error handling position closed for {symbol}: {e}")
  364. async def _handle_position_changed(self, symbol: str, previous: Dict, current: Dict):
  365. """Handle position size, direction, or leverage changes"""
  366. try:
  367. prev_size = previous['size']
  368. curr_size = current['size']
  369. prev_leverage = previous.get('current_leverage', 0)
  370. curr_leverage = current.get('current_leverage', 0)
  371. # Check if position reversed (long to short or vice versa)
  372. if (prev_size > 0 and curr_size < 0) or (prev_size < 0 and curr_size > 0):
  373. # Position reversed - close old, open new
  374. await self._handle_position_closed(symbol, previous)
  375. await self._handle_position_opened(symbol, current)
  376. return
  377. # Check if leverage changed
  378. if abs(prev_leverage - curr_leverage) > 0.1: # Threshold to avoid noise
  379. logger.info(f"📊 Leverage changed for {symbol}: {prev_leverage:.1f}x → {curr_leverage:.1f}x")
  380. # Optional: Send notification for significant leverage changes
  381. if abs(prev_leverage - curr_leverage) >= 1.0: # Only notify for changes >= 1x
  382. side = "Long" if curr_size > 0 else "Short"
  383. change_direction = "Increased" if curr_leverage > prev_leverage else "Decreased"
  384. message = (
  385. f"⚖️ Leverage {change_direction}\n"
  386. f"Token: {symbol}\n"
  387. f"Side: {side}\n"
  388. f"Leverage: {prev_leverage:.1f}x → {curr_leverage:.1f}x\n\n"
  389. f"💡 Use /positions to see current positions"
  390. )
  391. await self.notification_manager.send_generic_notification(message)
  392. # Check if position size changed significantly
  393. size_change = abs(curr_size) - abs(prev_size)
  394. # Get current market price for more accurate value calculation
  395. try:
  396. full_symbol = f"{symbol}/USDC:USDC"
  397. market_data = self.hl_client.get_market_data(full_symbol)
  398. current_market_price = float(market_data.get('ticker', {}).get('last', current['entry_px'])) if market_data else current['entry_px']
  399. except Exception:
  400. current_market_price = current['entry_px'] # Fallback to entry price
  401. # Calculate change value using current market price
  402. change_value = abs(size_change) * current_market_price
  403. # Get formatter to determine token category and appropriate thresholds
  404. try:
  405. from src.utils.token_display_formatter import get_formatter
  406. formatter = get_formatter()
  407. # Use the existing token classification system to determine threshold
  408. price_decimals = await formatter.get_token_price_decimal_places(symbol)
  409. amount_decimals = await formatter.get_token_amount_decimal_places(symbol)
  410. # Determine quantity threshold based on token characteristics
  411. # Higher precision tokens (like BTC, ETH) need smaller quantity thresholds
  412. if price_decimals <= 2: # Major tokens like BTC, ETH (high value)
  413. quantity_threshold = 0.0001
  414. elif price_decimals <= 4: # Mid-tier tokens
  415. quantity_threshold = 0.001
  416. else: # Lower-value tokens (meme coins, etc.)
  417. quantity_threshold = 0.01
  418. # Also set minimum value threshold based on token category
  419. min_value_threshold = 1.0 # Minimum $1 change for any token
  420. except Exception as e:
  421. logger.debug(f"Could not get token formatting info for {symbol}, using defaults: {e}")
  422. quantity_threshold = 0.001
  423. min_value_threshold = 1.0
  424. price_decimals = 4 # Default for fallback logging
  425. # Trigger notification if either:
  426. # 1. Quantity change exceeds token-specific threshold, OR
  427. # 2. Value change exceeds minimum value threshold
  428. should_notify = (abs(size_change) > quantity_threshold or
  429. change_value > min_value_threshold)
  430. if should_notify:
  431. change_type = "Increased" if size_change > 0 else "Decreased"
  432. side = "Long" if curr_size > 0 else "Short"
  433. # Use formatter for consistent display
  434. try:
  435. formatted_new_size = await formatter.format_amount(abs(curr_size), symbol)
  436. formatted_change = await formatter.format_amount(abs(size_change), symbol)
  437. formatted_value_change = await formatter.format_price_with_symbol(change_value)
  438. formatted_current_price = await formatter.format_price_with_symbol(current_market_price, symbol)
  439. except Exception:
  440. # Fallback formatting
  441. formatted_new_size = f"{abs(curr_size):.4f}"
  442. formatted_change = f"{abs(size_change):.4f}"
  443. formatted_value_change = f"${change_value:.2f}"
  444. formatted_current_price = f"${current_market_price:.4f}"
  445. message = (
  446. f"🔄 Position {change_type}\n"
  447. f"Token: {symbol}\n"
  448. f"Side: {side}\n"
  449. f"New Size: {formatted_new_size}\n"
  450. f"Change: {'+' if size_change > 0 else ''}{formatted_change}\n"
  451. f"Value Change: {formatted_value_change}\n"
  452. f"Current Price: {formatted_current_price}\n\n"
  453. f"💡 Use /positions to see current positions"
  454. )
  455. await self.notification_manager.send_generic_notification(message)
  456. logger.info(f"Position changed: {symbol} {change_type} by {size_change:.6f} (${change_value:.2f}) "
  457. f"threshold: {quantity_threshold} qty or ${min_value_threshold} value")
  458. else:
  459. # Log when changes don't meet threshold (debug level to avoid spam)
  460. logger.debug(f"Position size changed for {symbol} but below notification threshold: "
  461. f"{size_change:.6f} quantity (${change_value:.2f} value), "
  462. f"thresholds: {quantity_threshold} qty or ${min_value_threshold} value "
  463. f"(price_decimals: {price_decimals if 'price_decimals' in locals() else 'unknown'})")
  464. except Exception as e:
  465. logger.error(f"Error handling position change for {symbol}: {e}")
  466. async def _save_position_stats(self, symbol: str, side: str, size: float,
  467. entry_price: float, exit_price: float, pnl: float):
  468. """Save position statistics to database using existing TradingStats interface"""
  469. try:
  470. # Lazy load TradingStats to avoid circular imports
  471. if self.trading_stats is None:
  472. from ..stats.trading_stats import TradingStats
  473. self.trading_stats = TradingStats()
  474. # Use the existing process_trade_complete_cycle method
  475. lifecycle_id = self.trading_stats.process_trade_complete_cycle(
  476. symbol=symbol,
  477. side=side.lower(),
  478. entry_price=entry_price,
  479. exit_price=exit_price,
  480. amount=size,
  481. timestamp=datetime.now(timezone.utc).isoformat()
  482. )
  483. logger.info(f"Saved stats for {symbol}: PnL ${pnl:.3f}, lifecycle_id: {lifecycle_id}")
  484. except Exception as e:
  485. logger.error(f"Error saving position stats for {symbol}: {e}")