copy_trading_monitor.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. """
  2. Copy Trading Monitor - Tracks and copies trades from a target trader on Hyperliquid
  3. """
  4. import logging
  5. import time
  6. import asyncio
  7. from datetime import datetime, timedelta
  8. from typing import Dict, List, Optional, Any
  9. from dataclasses import dataclass
  10. import aiohttp
  11. import json
  12. from decimal import Decimal, ROUND_DOWN
  13. from ..config.config import Config
  14. from ..clients.hyperliquid_client import HyperliquidClient
  15. from ..notifications.notification_manager import NotificationManager
  16. from .copy_trading_state import CopyTradingStateManager
  17. @dataclass
  18. class TraderPosition:
  19. """Represents a position held by the target trader"""
  20. coin: str
  21. size: float
  22. side: str # 'long' or 'short'
  23. entry_price: float
  24. leverage: float
  25. position_value: float
  26. unrealized_pnl: float
  27. margin_used: float
  28. timestamp: int
  29. @dataclass
  30. class CopyTrade:
  31. """Represents a trade to be copied"""
  32. coin: str
  33. action: str # 'open_long', 'open_short', 'close_long', 'close_short'
  34. size: float
  35. leverage: float
  36. original_trade_hash: str
  37. target_trader_address: str
  38. timestamp: int
  39. class CopyTradingMonitor:
  40. """Monitor and copy trades from a target trader"""
  41. def __init__(self, client: HyperliquidClient, notification_manager: NotificationManager):
  42. self.client = client
  43. self.notification_manager = notification_manager
  44. self.config = Config()
  45. self.logger = logging.getLogger(__name__)
  46. # Configuration
  47. self.enabled = self.config.COPY_TRADING_ENABLED
  48. self.target_address = self.config.COPY_TRADING_TARGET_ADDRESS
  49. self.portfolio_percentage = self.config.COPY_TRADING_PORTFOLIO_PERCENTAGE
  50. self.copy_mode = self.config.COPY_TRADING_MODE
  51. self.max_leverage = self.config.COPY_TRADING_MAX_LEVERAGE
  52. self.min_position_size = self.config.COPY_TRADING_MIN_POSITION_SIZE
  53. self.execution_delay = self.config.COPY_TRADING_EXECUTION_DELAY
  54. self.notifications_enabled = self.config.COPY_TRADING_NOTIFICATIONS
  55. # State management for persistence and tracking
  56. self.state_manager = CopyTradingStateManager()
  57. # Override enabled status from state if different from config
  58. if self.state_manager.is_enabled() and self.target_address:
  59. self.enabled = True
  60. # State tracking (legacy, kept for compatibility)
  61. self.target_positions: Dict[str, TraderPosition] = {}
  62. self.our_positions: Dict[str, Any] = {}
  63. self.last_check_time = 0
  64. self.pending_trades: List[CopyTrade] = []
  65. # API endpoints
  66. self.info_url = "https://api.hyperliquid.xyz/info"
  67. self.logger.info(f"Copy Trading Monitor initialized - Target: {self.target_address}")
  68. self.logger.info(f"📊 Configuration:")
  69. self.logger.info(f" - Enabled: {self.enabled}")
  70. self.logger.info(f" - Target Address: {self.target_address}")
  71. self.logger.info(f" - Portfolio %: {self.portfolio_percentage:.1%}")
  72. self.logger.info(f" - Copy Mode: {self.copy_mode}")
  73. self.logger.info(f" - Max Leverage: {self.max_leverage}x")
  74. self.logger.info(f" - Min Position Size: ${self.min_position_size:.2f}")
  75. self.logger.info(f" - Execution Delay: {self.execution_delay}s")
  76. self.logger.info(f" - Notifications: {self.notifications_enabled}")
  77. # Load previous session info if available
  78. session_info = self.state_manager.get_session_info()
  79. if session_info['start_time']:
  80. self.logger.info(f"📅 Previous session started: {session_info['start_time']}")
  81. self.logger.info(f"📊 Tracked positions: {session_info['tracked_positions_count']}")
  82. self.logger.info(f"🔄 Copied trades: {session_info['copied_trades_count']}")
  83. async def start_monitoring(self):
  84. """Start the copy trading monitoring loop"""
  85. if not self.enabled:
  86. self.logger.info("Copy trading is disabled")
  87. return
  88. if not self.target_address:
  89. self.logger.error("No target trader address configured")
  90. return
  91. self.logger.info(f"Starting copy trading monitor for {self.target_address}")
  92. try:
  93. # Start state tracking (using async version to prevent blocking)
  94. await self.state_manager.start_copy_trading_async(self.target_address)
  95. # Get current target positions for initialization (with timeout)
  96. try:
  97. current_positions = await asyncio.wait_for(
  98. self.get_target_positions(),
  99. timeout=15.0 # 15 second timeout for initialization
  100. )
  101. except asyncio.TimeoutError:
  102. self.logger.warning("Timeout during initialization - will retry in monitoring loop")
  103. current_positions = None
  104. except Exception as e:
  105. self.logger.error(f"Error during initialization: {e}")
  106. current_positions = None
  107. if current_positions:
  108. # Check if this is a fresh start or resuming
  109. if not self.state_manager.get_tracked_positions():
  110. # Fresh start - initialize tracking but don't copy existing positions
  111. self.logger.info("🆕 Fresh start - initializing with existing positions (won't copy)")
  112. await self.state_manager.initialize_tracked_positions_async(current_positions)
  113. startup_message = (
  114. f"🔄 Copy Trading Started (Fresh)\n"
  115. f"Target: {self.target_address[:10]}...\n"
  116. f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
  117. f"Mode: {self.copy_mode}\n"
  118. f"Max Leverage: {self.max_leverage}x\n\n"
  119. f"📊 Found {len(current_positions)} existing positions\n"
  120. f"⚠️ Will only copy NEW trades from now on"
  121. )
  122. else:
  123. # Resuming - continue from where we left off
  124. tracked_count = len(self.state_manager.get_tracked_positions())
  125. self.logger.info(f"▶️ Resuming session - {tracked_count} positions tracked")
  126. startup_message = (
  127. f"▶️ Copy Trading Resumed\n"
  128. f"Target: {self.target_address[:10]}...\n"
  129. f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
  130. f"Mode: {self.copy_mode}\n"
  131. f"Max Leverage: {self.max_leverage}x\n\n"
  132. f"📊 Resuming with {tracked_count} tracked positions"
  133. )
  134. else:
  135. startup_message = (
  136. f"🔄 Copy Trading Started\n"
  137. f"Target: {self.target_address[:10]}...\n"
  138. f"Portfolio Allocation: {self.portfolio_percentage:.1%}\n"
  139. f"Mode: {self.copy_mode}\n"
  140. f"Max Leverage: {self.max_leverage}x\n\n"
  141. f"⚠️ Could not access target trader positions during startup"
  142. )
  143. # Send startup notification
  144. if self.notifications_enabled:
  145. try:
  146. await asyncio.wait_for(
  147. self.notification_manager.send_generic_notification(startup_message),
  148. timeout=5.0
  149. )
  150. except Exception as e:
  151. self.logger.error(f"Error sending startup notification: {e}")
  152. # Initial sync (non-blocking)
  153. try:
  154. await asyncio.wait_for(self.sync_positions(), timeout=10.0)
  155. except Exception as e:
  156. self.logger.error(f"Error during initial sync: {e}")
  157. # Start monitoring loop
  158. while self.enabled and self.state_manager.is_enabled():
  159. try:
  160. await self.monitor_cycle()
  161. await asyncio.sleep(30) # Check every 30 seconds
  162. except Exception as e:
  163. self.logger.error(f"Error in copy trading monitor cycle: {e}")
  164. await asyncio.sleep(60) # Wait longer on error
  165. except Exception as e:
  166. self.logger.error(f"Fatal error in copy trading monitor: {e}")
  167. self.enabled = False
  168. async def monitor_cycle(self):
  169. """Single monitoring cycle"""
  170. try:
  171. # Get target trader's current positions with timeout
  172. try:
  173. new_positions = await asyncio.wait_for(
  174. self.get_target_positions(),
  175. timeout=15.0 # 15 second timeout
  176. )
  177. except asyncio.TimeoutError:
  178. self.logger.warning("Timeout getting target positions - skipping this cycle")
  179. return
  180. except Exception as e:
  181. self.logger.error(f"Error getting target positions: {e}")
  182. return
  183. if new_positions is None:
  184. return
  185. # Compare with previous positions to detect changes
  186. try:
  187. position_changes = await self.detect_position_changes(new_positions)
  188. except Exception as e:
  189. self.logger.error(f"Error detecting position changes: {e}")
  190. return
  191. # Execute any detected trades
  192. for trade in position_changes:
  193. try:
  194. await asyncio.wait_for(
  195. self.execute_copy_trade(trade),
  196. timeout=30.0 # 30 second timeout per trade
  197. )
  198. except asyncio.TimeoutError:
  199. self.logger.error(f"Timeout executing copy trade for {trade.coin}")
  200. except Exception as e:
  201. self.logger.error(f"Error executing copy trade for {trade.coin}: {e}")
  202. # Update our tracking
  203. self.target_positions = new_positions
  204. # Update last check timestamp (async version)
  205. await self.state_manager.update_last_check_async()
  206. except Exception as e:
  207. self.logger.error(f"Error in monitor cycle: {e}")
  208. async def get_target_positions(self) -> Optional[Dict[str, TraderPosition]]:
  209. """Get current positions of target trader"""
  210. try:
  211. payload = {
  212. "type": "clearinghouseState",
  213. "user": self.target_address
  214. }
  215. # Use timeout to prevent blocking
  216. timeout = aiohttp.ClientTimeout(total=10.0) # 10 second timeout
  217. async with aiohttp.ClientSession(timeout=timeout) as session:
  218. async with session.post(self.info_url, json=payload) as response:
  219. if response.status != 200:
  220. self.logger.error(f"Failed to get target positions: {response.status}")
  221. return None
  222. data = await response.json()
  223. positions = {}
  224. # Parse asset positions
  225. for asset_pos in data.get('assetPositions', []):
  226. if asset_pos.get('type') == 'oneWay':
  227. pos = asset_pos['position']
  228. coin = pos['coin']
  229. size = float(pos['szi'])
  230. if abs(size) < 0.001: # Skip dust positions
  231. continue
  232. side = 'long' if size > 0 else 'short'
  233. positions[coin] = TraderPosition(
  234. coin=coin,
  235. size=abs(size),
  236. side=side,
  237. entry_price=float(pos['entryPx']),
  238. leverage=float(pos['leverage']['value']),
  239. position_value=float(pos['positionValue']),
  240. unrealized_pnl=float(pos['unrealizedPnl']),
  241. margin_used=float(pos['marginUsed']),
  242. timestamp=int(time.time() * 1000)
  243. )
  244. return positions
  245. except asyncio.TimeoutError:
  246. self.logger.warning("Timeout getting target positions - will retry next cycle")
  247. return None
  248. except Exception as e:
  249. self.logger.error(f"Error getting target positions: {e}")
  250. return None
  251. async def detect_position_changes(self, new_positions: Dict[str, TraderPosition]) -> List[CopyTrade]:
  252. """Detect changes in target trader's positions using state manager"""
  253. trades = []
  254. # Check for new positions and position increases
  255. for coin, new_pos in new_positions.items():
  256. position_data = {
  257. 'size': new_pos.size,
  258. 'side': new_pos.side,
  259. 'entry_price': new_pos.entry_price,
  260. 'leverage': new_pos.leverage
  261. }
  262. # Check if this is a new position we should copy
  263. if self.state_manager.should_copy_position(coin, position_data):
  264. tracked_pos = self.state_manager.get_tracked_positions().get(coin)
  265. if tracked_pos is None:
  266. # Completely new position
  267. action = f"open_{new_pos.side}"
  268. copy_size = new_pos.size
  269. self.logger.info(f"🆕 Detected NEW position: {action} {copy_size} {coin} at {new_pos.leverage}x")
  270. else:
  271. # Position increase
  272. size_increase = new_pos.size - tracked_pos['size']
  273. action = f"add_{new_pos.side}"
  274. copy_size = size_increase
  275. self.logger.info(f"📈 Detected position increase: {action} {size_increase} {coin}")
  276. # Create trade to copy
  277. trade_id = f"{coin}_{action}_{new_pos.timestamp}"
  278. if not self.state_manager.has_copied_trade(trade_id):
  279. trades.append(CopyTrade(
  280. coin=coin,
  281. action=action,
  282. size=copy_size,
  283. leverage=new_pos.leverage,
  284. original_trade_hash=trade_id,
  285. target_trader_address=self.target_address,
  286. timestamp=new_pos.timestamp
  287. ))
  288. # Check for position reductions
  289. elif self.state_manager.is_position_reduction(coin, position_data):
  290. tracked_pos = self.state_manager.get_tracked_positions()[coin]
  291. size_decrease = tracked_pos['size'] - new_pos.size
  292. action = f"reduce_{new_pos.side}"
  293. trade_id = f"{coin}_{action}_{new_pos.timestamp}"
  294. if not self.state_manager.has_copied_trade(trade_id):
  295. trades.append(CopyTrade(
  296. coin=coin,
  297. action=action,
  298. size=size_decrease,
  299. leverage=new_pos.leverage,
  300. original_trade_hash=trade_id,
  301. target_trader_address=self.target_address,
  302. timestamp=new_pos.timestamp
  303. ))
  304. self.logger.info(f"📉 Detected position decrease: {action} {size_decrease} {coin}")
  305. # Update tracking regardless
  306. await self.state_manager.update_tracked_position_async(coin, position_data)
  307. # Check for closed positions (exits)
  308. tracked_positions = self.state_manager.get_tracked_positions()
  309. for coin in list(tracked_positions.keys()):
  310. if coin not in new_positions:
  311. # Position fully closed
  312. tracked_pos = tracked_positions[coin]
  313. action = f"close_{tracked_pos['side']}"
  314. trade_id = f"{coin}_{action}_{int(time.time() * 1000)}"
  315. if not self.state_manager.has_copied_trade(trade_id):
  316. trades.append(CopyTrade(
  317. coin=coin,
  318. action=action,
  319. size=tracked_pos['size'],
  320. leverage=tracked_pos['leverage'],
  321. original_trade_hash=trade_id,
  322. target_trader_address=self.target_address,
  323. timestamp=int(time.time() * 1000)
  324. ))
  325. self.logger.info(f"❌ Detected position closure: {action} {tracked_pos['size']} {coin}")
  326. # Remove from tracking
  327. await self.state_manager.remove_tracked_position_async(coin)
  328. # Update last check time (already updated in monitor_cycle, so skip here)
  329. return trades
  330. async def execute_copy_trade(self, trade: CopyTrade):
  331. """Execute a copy trade"""
  332. try:
  333. # Check if we've already copied this trade
  334. if self.state_manager.has_copied_trade(trade.original_trade_hash):
  335. self.logger.debug(f"Skipping already copied trade: {trade.original_trade_hash}")
  336. return
  337. # Get current price for the asset
  338. symbol = f"{trade.coin}/USDC:USDC"
  339. try:
  340. market_data = await asyncio.to_thread(self.client.get_market_data, symbol)
  341. if not market_data or not market_data.get('ticker'):
  342. self.logger.error(f"❌ Could not get market data for {trade.coin}")
  343. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  344. return
  345. current_price = float(market_data['ticker'].get('last', 0))
  346. if current_price <= 0:
  347. self.logger.error(f"❌ Invalid price for {trade.coin}: {current_price}")
  348. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  349. return
  350. except Exception as e:
  351. self.logger.error(f"❌ Error getting price for {trade.coin}: {e}")
  352. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  353. return
  354. # Apply leverage limit
  355. leverage = min(trade.leverage, self.max_leverage)
  356. # Calculate our position size with proper leverage handling
  357. position_calc = await self.calculate_position_size(trade, current_price, leverage)
  358. if position_calc['margin_to_use'] < self.min_position_size:
  359. self.logger.info(f"Skipping {trade.coin} trade - margin too small: ${position_calc['margin_to_use']:.2f} (min: ${self.min_position_size:.2f})")
  360. # Still mark as copied to avoid retrying
  361. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  362. return
  363. # Add execution delay
  364. await asyncio.sleep(self.execution_delay)
  365. # Execute the trade
  366. success = await self._execute_hyperliquid_trade(trade, position_calc, leverage)
  367. # Mark trade as copied (whether successful or not to avoid retrying)
  368. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  369. # Send notification
  370. if self.notifications_enabled:
  371. status = "✅ SUCCESS" if success else "❌ FAILED"
  372. await self.notification_manager.send_generic_notification(
  373. f"🔄 Copy Trade {status}\n"
  374. f"Action: {trade.action}\n"
  375. f"Asset: {trade.coin}\n"
  376. f"💳 Margin Used: ${position_calc['margin_to_use']:.2f}\n"
  377. f"🏦 Position Value: ${position_calc['position_value']:.2f}\n"
  378. f"🪙 Token Amount: {position_calc['token_amount']:.6f}\n"
  379. f"⚖️ Leverage: {leverage}x\n"
  380. f"Target: {trade.target_trader_address[:10]}...\n"
  381. f"Trade ID: {trade.original_trade_hash[:16]}..."
  382. )
  383. except Exception as e:
  384. self.logger.error(f"Error executing copy trade for {trade.coin}: {e}")
  385. # Mark as copied even on error to avoid infinite retries
  386. await self.state_manager.add_copied_trade_async(trade.original_trade_hash)
  387. if self.notifications_enabled:
  388. await self.notification_manager.send_generic_notification(
  389. f"❌ Copy Trade Error\n"
  390. f"Asset: {trade.coin}\n"
  391. f"Action: {trade.action}\n"
  392. f"Error: {str(e)[:100]}\n"
  393. f"Trade ID: {trade.original_trade_hash[:16]}..."
  394. )
  395. async def calculate_position_size(self, trade: CopyTrade, current_price: float, leverage: float) -> Dict[str, float]:
  396. """
  397. Calculate our position size based on the copy trading mode.
  398. Returns margin allocation and converts to actual token amount.
  399. Args:
  400. trade: The copy trade to execute
  401. current_price: Current price of the asset
  402. leverage: Leverage to use for the trade
  403. Returns:
  404. Dict with 'margin_to_use', 'position_value', 'token_amount'
  405. """
  406. try:
  407. # Get our current account balance
  408. our_balance = await self.get_our_account_balance()
  409. self.logger.info(f"📊 Our account balance: ${our_balance:.2f}")
  410. self.logger.info(f"🎯 Portfolio percentage: {self.portfolio_percentage:.1%}")
  411. self.logger.info(f"📈 Copy mode: {self.copy_mode}")
  412. self.logger.info(f"💰 Current {trade.coin} price: ${current_price:.4f}")
  413. self.logger.info(f"⚖️ Leverage to use: {leverage:.1f}x")
  414. # Calculate the MARGIN we want to allocate (risk-based)
  415. margin_to_use = 0.0
  416. if self.copy_mode == 'FIXED':
  417. # Fixed percentage of our account as margin
  418. margin_to_use = our_balance * self.portfolio_percentage
  419. self.logger.info(f"🔢 FIXED mode - margin to allocate: ${margin_to_use:.2f}")
  420. elif self.copy_mode == 'PROPORTIONAL':
  421. # Get target trader's account balance
  422. target_balance = await self.get_target_account_balance()
  423. self.logger.info(f"🎯 Target balance: ${target_balance:.2f}")
  424. if target_balance <= 0:
  425. margin_to_use = our_balance * self.portfolio_percentage
  426. self.logger.info(f"🔢 PROPORTIONAL mode (fallback) - margin: ${margin_to_use:.2f}")
  427. else:
  428. # Calculate target trader's margin percentage
  429. target_pos = self.target_positions.get(trade.coin)
  430. if not target_pos:
  431. margin_to_use = our_balance * self.portfolio_percentage
  432. self.logger.info(f"🔢 PROPORTIONAL mode (no target pos) - margin: ${margin_to_use:.2f}")
  433. else:
  434. target_margin_percentage = target_pos.margin_used / target_balance
  435. self.logger.info(f"📊 Target margin percentage: {target_margin_percentage:.1%}")
  436. # Apply same margin percentage to our account
  437. our_proportional_margin = our_balance * target_margin_percentage
  438. # Cap at our portfolio percentage limit
  439. max_margin = our_balance * self.portfolio_percentage
  440. margin_to_use = min(our_proportional_margin, max_margin)
  441. self.logger.info(f"🔢 PROPORTIONAL mode - uncapped: ${our_proportional_margin:.2f}, max: ${max_margin:.2f}, final: ${margin_to_use:.2f}")
  442. else:
  443. margin_to_use = our_balance * self.portfolio_percentage
  444. self.logger.info(f"🔢 Unknown mode (fallback) - margin: ${margin_to_use:.2f}")
  445. # Calculate position value with leverage
  446. position_value = margin_to_use * leverage
  447. # Calculate token amount based on current price
  448. token_amount = position_value / current_price
  449. result = {
  450. 'margin_to_use': margin_to_use,
  451. 'position_value': position_value,
  452. 'token_amount': token_amount
  453. }
  454. self.logger.info(f"📊 Position calculation:")
  455. self.logger.info(f" 💳 Margin to use: ${margin_to_use:.2f}")
  456. self.logger.info(f" 🏦 Position value (with {leverage:.1f}x): ${position_value:.2f}")
  457. self.logger.info(f" 🪙 Token amount: {token_amount:.6f} {trade.coin}")
  458. return result
  459. except Exception as e:
  460. self.logger.error(f"Error calculating position size: {e}")
  461. # Fallback to fixed percentage
  462. our_balance = await self.get_our_account_balance()
  463. fallback_margin = our_balance * self.portfolio_percentage
  464. fallback_value = fallback_margin * leverage
  465. fallback_tokens = fallback_value / current_price
  466. result = {
  467. 'margin_to_use': fallback_margin,
  468. 'position_value': fallback_value,
  469. 'token_amount': fallback_tokens
  470. }
  471. self.logger.info(f"🔢 Error fallback - margin: ${fallback_margin:.2f}, tokens: {fallback_tokens:.6f}")
  472. return result
  473. async def get_our_account_balance(self) -> float:
  474. """Get our account balance"""
  475. try:
  476. balance_info = await asyncio.to_thread(self.client.get_balance)
  477. self.logger.info(f"🔍 Raw balance info: {balance_info}")
  478. if balance_info:
  479. # Use the same approach as the /balance command
  480. usdc_total = 0.0
  481. usdc_free = 0.0
  482. usdc_used = 0.0
  483. if 'USDC' in balance_info.get('total', {}):
  484. usdc_total = float(balance_info['total']['USDC'])
  485. usdc_free = float(balance_info.get('free', {}).get('USDC', 0))
  486. usdc_used = float(balance_info.get('used', {}).get('USDC', 0))
  487. self.logger.info(f"💰 USDC Balance - Total: ${usdc_total:.2f}, Free: ${usdc_free:.2f}, Used: ${usdc_used:.2f}")
  488. if usdc_total > 0:
  489. self.logger.info(f"📊 Using total USDC balance: ${usdc_total:.2f}")
  490. return usdc_total
  491. else:
  492. self.logger.warning(f"⚠️ No USDC balance found - raw response: {balance_info}")
  493. return 0.0
  494. else:
  495. self.logger.warning("⚠️ No balance info returned")
  496. return 0.0
  497. except Exception as e:
  498. self.logger.error(f"Error getting our account balance: {e}")
  499. return 0.0
  500. async def get_target_account_balance(self) -> float:
  501. """Get target trader's account balance"""
  502. try:
  503. payload = {
  504. "type": "clearinghouseState",
  505. "user": self.target_address
  506. }
  507. # Use timeout to prevent blocking
  508. timeout = aiohttp.ClientTimeout(total=10.0) # 10 second timeout
  509. async with aiohttp.ClientSession(timeout=timeout) as session:
  510. async with session.post(self.info_url, json=payload) as response:
  511. if response.status == 200:
  512. data = await response.json()
  513. return float(data.get('marginSummary', {}).get('accountValue', 0))
  514. else:
  515. return 0.0
  516. except asyncio.TimeoutError:
  517. self.logger.warning("Timeout getting target account balance")
  518. return 0.0
  519. except Exception as e:
  520. self.logger.error(f"Error getting target account balance: {e}")
  521. return 0.0
  522. async def _execute_hyperliquid_trade(self, trade: CopyTrade, position_calc: Dict[str, float], leverage: float) -> bool:
  523. """Execute trade on Hyperliquid"""
  524. try:
  525. # Determine if this is a buy or sell order
  526. is_buy = 'long' in trade.action or ('close' in trade.action and 'short' in trade.action)
  527. side = 'buy' if is_buy else 'sell'
  528. # Extract values from position calculation
  529. token_amount = position_calc['token_amount']
  530. margin_used = position_calc['margin_to_use']
  531. position_value = position_calc['position_value']
  532. self.logger.info(f"🔄 Executing {trade.action} for {trade.coin}:")
  533. self.logger.info(f" 📊 Side: {side}")
  534. self.logger.info(f" 🪙 Token Amount: {token_amount:.6f} {trade.coin}")
  535. self.logger.info(f" 💳 Margin: ${margin_used:.2f}")
  536. self.logger.info(f" 🏦 Position Value: ${position_value:.2f}")
  537. self.logger.info(f" ⚖️ Leverage: {leverage}x")
  538. # For position opening/closing/modifying - all use market orders
  539. if 'open' in trade.action or 'add' in trade.action:
  540. # Open new position or add to existing position
  541. symbol = f"{trade.coin}/USDC:USDC"
  542. result, error = await asyncio.to_thread(
  543. self.client.place_market_order,
  544. symbol=symbol,
  545. side=side,
  546. amount=token_amount
  547. )
  548. if error:
  549. self.logger.error(f"❌ Market order failed: {error}")
  550. return False
  551. elif 'close' in trade.action:
  552. # Close existing position - we need to place an opposite market order
  553. # Get current position to determine the exact size to close
  554. symbol = f"{trade.coin}/USDC:USDC"
  555. positions = await asyncio.to_thread(self.client.get_positions, symbol=symbol)
  556. if not positions:
  557. self.logger.warning(f"⚠️ No position found for {trade.coin} to close")
  558. return False
  559. # Find the position to close
  560. position_to_close = None
  561. for pos in positions:
  562. if pos.get('symbol') == symbol and float(pos.get('contracts', 0)) != 0:
  563. position_to_close = pos
  564. break
  565. if not position_to_close:
  566. self.logger.warning(f"⚠️ No open position found for {trade.coin}")
  567. return False
  568. # Determine the opposite side to close the position
  569. current_side = 'long' if float(position_to_close.get('contracts', 0)) > 0 else 'short'
  570. close_side = 'sell' if current_side == 'long' else 'buy'
  571. close_size = abs(float(position_to_close.get('contracts', 0)))
  572. self.logger.info(f"📉 Closing {current_side} position: {close_side} {close_size} {trade.coin}")
  573. result, error = await asyncio.to_thread(
  574. self.client.place_market_order,
  575. symbol=symbol,
  576. side=close_side,
  577. amount=close_size
  578. )
  579. if error:
  580. self.logger.error(f"❌ Close order failed: {error}")
  581. return False
  582. elif 'reduce' in trade.action:
  583. # Reduce existing position
  584. reduce_side = 'sell' if 'long' in trade.action else 'buy'
  585. symbol = f"{trade.coin}/USDC:USDC"
  586. result, error = await asyncio.to_thread(
  587. self.client.place_market_order,
  588. symbol=symbol,
  589. side=reduce_side,
  590. amount=token_amount
  591. )
  592. if error:
  593. self.logger.error(f"❌ Reduce order failed: {error}")
  594. return False
  595. else:
  596. self.logger.error(f"❌ Unknown trade action: {trade.action}")
  597. return False
  598. # Check if result indicates success
  599. if result:
  600. self.logger.info(f"✅ Successfully executed copy trade: {trade.action}")
  601. self.logger.info(f" 🪙 {token_amount:.6f} {trade.coin} (${position_value:.2f} value, ${margin_used:.2f} margin)")
  602. return True
  603. else:
  604. self.logger.error(f"❌ Failed to execute copy trade - no result returned")
  605. return False
  606. except Exception as e:
  607. self.logger.error(f"❌ Error executing Hyperliquid trade: {e}")
  608. return False
  609. async def sync_positions(self):
  610. """Sync our current positions with tracking"""
  611. try:
  612. # Get our current positions
  613. positions = self.client.get_positions()
  614. if positions:
  615. self.our_positions = {pos['symbol']: pos for pos in positions}
  616. else:
  617. self.our_positions = {}
  618. # Get target positions for initial sync
  619. self.target_positions = await self.get_target_positions() or {}
  620. self.logger.info(f"Synced positions - Target: {len(self.target_positions)}, Ours: {len(self.our_positions)}")
  621. except Exception as e:
  622. self.logger.error(f"Error syncing positions: {e}")
  623. async def stop_monitoring(self):
  624. """Stop copy trading monitoring"""
  625. self.enabled = False
  626. await self.state_manager.stop_copy_trading_async()
  627. self.logger.info("Copy trading monitor stopped")
  628. if self.notifications_enabled:
  629. session_info = self.state_manager.get_session_info()
  630. duration_str = ""
  631. if session_info['session_duration_seconds']:
  632. duration_hours = session_info['session_duration_seconds'] / 3600
  633. duration_str = f"\nSession duration: {duration_hours:.1f} hours"
  634. await self.notification_manager.send_generic_notification(
  635. f"🛑 Copy Trading Stopped\n"
  636. f"📊 Tracked positions: {session_info['tracked_positions_count']}\n"
  637. f"🔄 Copied trades: {session_info['copied_trades_count']}"
  638. + duration_str +
  639. f"\n\n💾 State saved - can resume later"
  640. )
  641. async def test_balance_fetching(self) -> Dict[str, Any]:
  642. """Test balance fetching and position sizing for debugging purposes"""
  643. try:
  644. our_balance = await self.get_our_account_balance()
  645. target_balance = await self.get_target_account_balance()
  646. # Test with a mock trade for SOL
  647. test_price = 150.0 # Mock SOL price
  648. test_leverage = min(10.0, self.max_leverage) # Test leverage
  649. mock_trade = CopyTrade(
  650. coin="SOL",
  651. action="open_long",
  652. size=100,
  653. leverage=test_leverage,
  654. original_trade_hash="test_hash",
  655. target_trader_address=self.target_address or "test_address",
  656. timestamp=int(time.time() * 1000)
  657. )
  658. # Calculate position with leverage
  659. position_calc = await self.calculate_position_size(mock_trade, test_price, test_leverage)
  660. result = {
  661. 'our_balance': our_balance,
  662. 'target_balance': target_balance,
  663. 'portfolio_percentage': self.portfolio_percentage,
  664. 'test_price': test_price,
  665. 'test_leverage': test_leverage,
  666. 'margin_to_use': position_calc['margin_to_use'],
  667. 'position_value': position_calc['position_value'],
  668. 'token_amount': position_calc['token_amount'],
  669. 'min_position_size': self.min_position_size,
  670. 'would_execute': position_calc['margin_to_use'] >= self.min_position_size,
  671. 'config_enabled': self.enabled,
  672. 'state_enabled': self.state_manager.is_enabled()
  673. }
  674. self.logger.info(f"🧪 Balance & Leverage test results:")
  675. self.logger.info(f" 💰 Our balance: ${our_balance:.2f}")
  676. self.logger.info(f" 🎯 Target balance: ${target_balance:.2f}")
  677. self.logger.info(f" 📊 Portfolio allocation: {self.portfolio_percentage:.1%}")
  678. self.logger.info(f" ⚖️ Test leverage: {test_leverage:.1f}x")
  679. self.logger.info(f" 💳 Margin to use: ${position_calc['margin_to_use']:.2f}")
  680. self.logger.info(f" 🏦 Position value: ${position_calc['position_value']:.2f}")
  681. self.logger.info(f" 🪙 Token amount: {position_calc['token_amount']:.6f} SOL")
  682. self.logger.info(f" ✅ Would execute: {position_calc['margin_to_use'] >= self.min_position_size}")
  683. return result
  684. except Exception as e:
  685. self.logger.error(f"❌ Error in balance test: {e}")
  686. return {'error': str(e)}
  687. def get_status(self) -> Dict[str, Any]:
  688. """Get current copy trading status"""
  689. session_info = self.state_manager.get_session_info()
  690. return {
  691. 'enabled': self.enabled and self.state_manager.is_enabled(),
  692. 'target_address': self.target_address,
  693. 'portfolio_percentage': self.portfolio_percentage,
  694. 'copy_mode': self.copy_mode,
  695. 'max_leverage': self.max_leverage,
  696. 'target_positions': len(self.target_positions),
  697. 'our_positions': len(self.our_positions),
  698. 'tracked_positions': session_info['tracked_positions_count'],
  699. 'copied_trades': session_info['copied_trades_count'],
  700. 'session_start_time': session_info['start_time'],
  701. 'session_duration_hours': session_info['session_duration_seconds'] / 3600 if session_info['session_duration_seconds'] else None,
  702. 'last_check': session_info['last_check_time']
  703. }