hyperliquid_account_analyzer.py 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512
  1. #!/usr/bin/env python3
  2. """
  3. Hyperliquid Account Analyzer
  4. Analyzes Hyperliquid trading accounts to evaluate:
  5. - Profitability and performance metrics
  6. - Average trade duration and trading patterns
  7. - Risk management quality
  8. - Win rates and consistency
  9. - Position sizing and leverage usage
  10. Usage:
  11. # Analyze specific addresses
  12. python utils/hyperliquid_account_analyzer.py [address1] [address2] ...
  13. # Use curated high-performance accounts (default)
  14. python utils/hyperliquid_account_analyzer.py
  15. python utils/hyperliquid_account_analyzer.py --limit 15
  16. # Use hardcoded top 10 addresses
  17. python utils/hyperliquid_account_analyzer.py --top10
  18. Options:
  19. --leaderboard Use curated high-performance accounts (recommended)
  20. --window Time window preference: 1d, 7d, 30d, allTime (default: 7d)
  21. --limit Number of accounts to analyze (default: 10)
  22. --top10 Use original hardcoded list of top 10 accounts
  23. Note: Hyperliquid's leaderboard API is not publicly accessible, so the script uses
  24. a manually curated list of high-performing accounts identified through analysis.
  25. """
  26. import asyncio
  27. import aiohttp
  28. import json
  29. import sys
  30. import os
  31. from datetime import datetime, timedelta
  32. from typing import Dict, List, Optional, Any, Tuple
  33. from dataclasses import dataclass
  34. import statistics
  35. from collections import defaultdict
  36. import argparse
  37. # Add src to path to import our modules
  38. sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'src'))
  39. @dataclass
  40. class Trade:
  41. """Represents a single trade"""
  42. timestamp: int
  43. coin: str
  44. side: str # 'buy' or 'sell'
  45. size: float
  46. price: float
  47. fee: float
  48. is_maker: bool
  49. @dataclass
  50. class Position:
  51. """Represents a position"""
  52. coin: str
  53. size: float
  54. side: str # 'long' or 'short'
  55. entry_price: float
  56. mark_price: float
  57. unrealized_pnl: float
  58. leverage: float
  59. margin_used: float
  60. @dataclass
  61. class AccountStats:
  62. """Comprehensive account statistics"""
  63. address: str
  64. total_pnl: float
  65. win_rate: float
  66. total_trades: int
  67. avg_trade_duration_hours: float
  68. max_drawdown: float
  69. sharpe_ratio: float
  70. avg_position_size: float
  71. max_leverage_used: float
  72. avg_leverage_used: float
  73. trading_frequency_per_day: float
  74. risk_reward_ratio: float
  75. consecutive_losses_max: int
  76. profit_factor: float
  77. largest_win: float
  78. largest_loss: float
  79. active_positions: int
  80. current_drawdown: float
  81. last_trade_timestamp: int
  82. analysis_period_days: int
  83. is_copyable: bool # Whether this account is suitable for copy trading
  84. copyability_reason: str # Why it is/isn't copyable
  85. unique_tokens_traded: int # Number of unique tokens/coins traded
  86. trading_type: str # "spot", "perps", or "mixed"
  87. top_tokens: List[str] # Top 5 most traded tokens by volume
  88. short_percentage: float # Percentage of trades that are likely shorts
  89. trading_style: str # Directional trading style description
  90. buy_sell_ratio: float # Ratio of buys to sells
  91. account_balance: float # Current account balance (accountValue)
  92. pnl_percentage: float # Total PnL as percentage of account balance
  93. class HyperliquidAccountAnalyzer:
  94. """Analyzes Hyperliquid trading accounts"""
  95. def __init__(self):
  96. self.info_url = "https://api.hyperliquid.xyz/info"
  97. self.session = None
  98. async def __aenter__(self):
  99. self.session = aiohttp.ClientSession()
  100. return self
  101. async def __aexit__(self, exc_type, exc_val, exc_tb):
  102. if self.session:
  103. await self.session.close()
  104. async def get_account_state(self, address: str) -> Optional[Dict]:
  105. """Get current account state including positions and balance"""
  106. try:
  107. payload = {
  108. "type": "clearinghouseState",
  109. "user": address
  110. }
  111. async with self.session.post(self.info_url, json=payload) as response:
  112. if response.status == 200:
  113. return await response.json()
  114. else:
  115. print(f"❌ Error fetching account state for {address}: HTTP {response.status}")
  116. return None
  117. except Exception as e:
  118. print(f"❌ Exception fetching account state for {address}: {e}")
  119. return None
  120. async def get_user_fills(self, address: str, limit: int = 1000) -> Optional[List[Dict]]:
  121. """Get recent fills/trades for a user"""
  122. try:
  123. payload = {
  124. "type": "userFills",
  125. "user": address
  126. }
  127. async with self.session.post(self.info_url, json=payload) as response:
  128. if response.status == 200:
  129. data = await response.json()
  130. # Return only the most recent fills up to limit
  131. fills = data if isinstance(data, list) else []
  132. return fills[:limit]
  133. else:
  134. print(f"❌ Error fetching fills for {address}: HTTP {response.status}")
  135. return None
  136. except Exception as e:
  137. print(f"❌ Exception fetching fills for {address}: {e}")
  138. return None
  139. async def get_funding_history(self, address: str) -> Optional[List[Dict]]:
  140. """Get funding payments history"""
  141. try:
  142. payload = {
  143. "type": "userFunding",
  144. "user": address
  145. }
  146. async with self.session.post(self.info_url, json=payload) as response:
  147. if response.status == 200:
  148. return await response.json()
  149. else:
  150. return []
  151. except Exception as e:
  152. print(f"⚠️ Could not fetch funding history for {address}: {e}")
  153. return []
  154. def parse_trades(self, fills: List[Dict]) -> List[Trade]:
  155. """Parse fills into Trade objects"""
  156. trades = []
  157. for fill in fills:
  158. try:
  159. # Parse timestamp
  160. timestamp = int(fill.get('time', 0))
  161. if timestamp == 0:
  162. continue
  163. # Parse trade data
  164. coin = fill.get('coin', 'UNKNOWN')
  165. side = fill.get('side', 'buy').lower()
  166. size = float(fill.get('sz', '0'))
  167. price = float(fill.get('px', '0'))
  168. fee = float(fill.get('fee', '0'))
  169. is_maker = fill.get('liquidation', False) == False # Simplified maker detection
  170. if size > 0 and price > 0:
  171. trades.append(Trade(
  172. timestamp=timestamp,
  173. coin=coin,
  174. side=side,
  175. size=size,
  176. price=price,
  177. fee=fee,
  178. is_maker=is_maker
  179. ))
  180. except (ValueError, KeyError) as e:
  181. print(f"⚠️ Warning: Could not parse fill: {fill} - {e}")
  182. continue
  183. return trades
  184. def get_account_balance(self, account_state: Dict) -> float:
  185. """Extract account balance from account state"""
  186. if not account_state:
  187. return 0.0
  188. try:
  189. # Try to get account value from marginSummary
  190. margin_summary = account_state.get('marginSummary', {})
  191. if margin_summary:
  192. account_value = float(margin_summary.get('accountValue', '0'))
  193. if account_value > 0:
  194. return account_value
  195. # Fallback: try crossMarginSummary
  196. cross_margin = account_state.get('crossMarginSummary', {})
  197. if cross_margin:
  198. account_value = float(cross_margin.get('accountValue', '0'))
  199. if account_value > 0:
  200. return account_value
  201. # Last resort: estimate from withdrawable balance
  202. withdrawable = float(account_state.get('withdrawable', '0'))
  203. return withdrawable
  204. except (ValueError, KeyError) as e:
  205. print(f"⚠️ Warning: Could not extract account balance: {e}")
  206. return 0.0
  207. def parse_positions(self, account_state: Dict) -> List[Position]:
  208. """Parse account state into Position objects"""
  209. positions = []
  210. if not account_state or 'assetPositions' not in account_state:
  211. return positions
  212. for asset_pos in account_state['assetPositions']:
  213. try:
  214. position_data = asset_pos.get('position', {})
  215. coin = position_data.get('coin', 'UNKNOWN')
  216. size_str = position_data.get('szi', '0')
  217. size = float(size_str)
  218. if abs(size) < 1e-6: # Skip dust positions
  219. continue
  220. side = 'long' if size > 0 else 'short'
  221. entry_price = float(position_data.get('entryPx', '0'))
  222. mark_price = float(position_data.get('positionValue', '0')) / abs(size) if size != 0 else 0
  223. unrealized_pnl = float(position_data.get('unrealizedPnl', '0'))
  224. leverage = float(position_data.get('leverage', {}).get('value', '1'))
  225. margin_used = float(position_data.get('marginUsed', '0'))
  226. positions.append(Position(
  227. coin=coin,
  228. size=abs(size),
  229. side=side,
  230. entry_price=entry_price,
  231. mark_price=mark_price,
  232. unrealized_pnl=unrealized_pnl,
  233. leverage=leverage,
  234. margin_used=margin_used
  235. ))
  236. except (ValueError, KeyError) as e:
  237. print(f"⚠️ Warning: Could not parse position: {asset_pos} - {e}")
  238. continue
  239. return positions
  240. def calculate_trade_performance(self, trades: List[Trade]) -> Tuple[float, float, int, int]:
  241. """Calculate more accurate trade performance metrics"""
  242. if len(trades) < 2:
  243. return 0.0, 0.0, 0, 0
  244. # Group trades by coin and track P&L per completed round trip
  245. trades_by_coin = defaultdict(list)
  246. for trade in sorted(trades, key=lambda x: x.timestamp):
  247. trades_by_coin[trade.coin].append(trade)
  248. total_realized_pnl = 0.0
  249. winning_trades = 0
  250. losing_trades = 0
  251. total_fees = 0.0
  252. for coin, coin_trades in trades_by_coin.items():
  253. position = 0.0
  254. entry_price = 0.0
  255. entry_cost = 0.0
  256. for trade in coin_trades:
  257. total_fees += trade.fee
  258. if trade.side.lower() in ['buy', 'b']:
  259. if position <= 0: # Opening long or closing short
  260. if position < 0: # Closing short position
  261. pnl = (entry_price - trade.price) * abs(position) - trade.fee
  262. total_realized_pnl += pnl
  263. if pnl > 0:
  264. winning_trades += 1
  265. else:
  266. losing_trades += 1
  267. # Start new long position
  268. new_size = trade.size - max(0, -position)
  269. if new_size > 0:
  270. entry_price = trade.price
  271. entry_cost = new_size * trade.price
  272. position = new_size
  273. else: # Adding to long position
  274. entry_cost += trade.size * trade.price
  275. position += trade.size
  276. entry_price = entry_cost / position
  277. elif trade.side.lower() in ['sell', 's', 'a', 'ask']:
  278. if position >= 0: # Closing long or opening short
  279. if position > 0: # Closing long position
  280. pnl = (trade.price - entry_price) * min(position, trade.size) - trade.fee
  281. total_realized_pnl += pnl
  282. if pnl > 0:
  283. winning_trades += 1
  284. else:
  285. losing_trades += 1
  286. # Start new short position
  287. new_size = trade.size - max(0, position)
  288. if new_size > 0:
  289. entry_price = trade.price
  290. position = -new_size
  291. else: # Adding to short position
  292. position -= trade.size
  293. entry_price = trade.price # Simplified for shorts
  294. win_rate = winning_trades / (winning_trades + losing_trades) if (winning_trades + losing_trades) > 0 else 0
  295. return total_realized_pnl, win_rate, winning_trades, losing_trades
  296. def analyze_hft_patterns(self, trades: List[Trade]) -> Dict[str, Any]:
  297. """
  298. Analyze high-frequency trading patterns that don't follow traditional open/close cycles
  299. """
  300. if not trades:
  301. return {
  302. 'avg_time_between_trades_minutes': 0,
  303. 'max_time_between_trades_hours': 0,
  304. 'min_time_between_trades_seconds': 0,
  305. 'trading_clusters': 0,
  306. 'trades_per_cluster': 0,
  307. 'is_hft_pattern': False
  308. }
  309. trades_sorted = sorted(trades, key=lambda x: x.timestamp)
  310. time_gaps = []
  311. # Calculate time gaps between consecutive trades
  312. for i in range(1, len(trades_sorted)):
  313. gap_ms = trades_sorted[i].timestamp - trades_sorted[i-1].timestamp
  314. gap_minutes = gap_ms / (1000 * 60)
  315. time_gaps.append(gap_minutes)
  316. if not time_gaps:
  317. return {
  318. 'avg_time_between_trades_minutes': 0,
  319. 'max_time_between_trades_hours': 0,
  320. 'min_time_between_trades_seconds': 0,
  321. 'trading_clusters': 0,
  322. 'trades_per_cluster': 0,
  323. 'is_hft_pattern': False
  324. }
  325. avg_gap_minutes = statistics.mean(time_gaps)
  326. max_gap_hours = max(time_gaps) / 60
  327. min_gap_seconds = min(time_gaps) * 60
  328. # Identify trading clusters (periods of intense activity)
  329. clusters = []
  330. current_cluster = [trades_sorted[0]]
  331. for i in range(1, len(trades_sorted)):
  332. gap_minutes = time_gaps[i-1]
  333. if gap_minutes <= 5: # Trades within 5 minutes = same cluster
  334. current_cluster.append(trades_sorted[i])
  335. else:
  336. if len(current_cluster) >= 3: # Minimum 3 trades to be a cluster
  337. clusters.append(current_cluster)
  338. current_cluster = [trades_sorted[i]]
  339. # Don't forget the last cluster
  340. if len(current_cluster) >= 3:
  341. clusters.append(current_cluster)
  342. avg_trades_per_cluster = statistics.mean([len(cluster) for cluster in clusters]) if clusters else 0
  343. # Determine if this is HFT pattern
  344. is_hft = (
  345. avg_gap_minutes < 30 and # Average < 30 minutes between trades
  346. len([gap for gap in time_gaps if gap < 1]) > len(time_gaps) * 0.3 # 30%+ trades within 1 minute
  347. )
  348. return {
  349. 'avg_time_between_trades_minutes': avg_gap_minutes,
  350. 'max_time_between_trades_hours': max_gap_hours,
  351. 'min_time_between_trades_seconds': min_gap_seconds,
  352. 'trading_clusters': len(clusters),
  353. 'trades_per_cluster': avg_trades_per_cluster,
  354. 'is_hft_pattern': is_hft
  355. }
  356. def calculate_rolling_pnl(self, trades: List[Trade]) -> Tuple[float, List[float], int, int]:
  357. """
  358. Calculate P&L using rolling window approach for HFT patterns
  359. """
  360. if not trades:
  361. return 0.0, [], 0, 0
  362. trades_sorted = sorted(trades, key=lambda x: x.timestamp)
  363. # Track net position and P&L over time
  364. cumulative_pnl = 0.0
  365. pnl_series = []
  366. winning_periods = 0
  367. losing_periods = 0
  368. # Use 1-hour windows for P&L calculation
  369. window_size_ms = 60 * 60 * 1000 # 1 hour
  370. if not trades_sorted:
  371. return 0.0, [], 0, 0
  372. start_time = trades_sorted[0].timestamp
  373. end_time = trades_sorted[-1].timestamp
  374. current_time = start_time
  375. window_trades = []
  376. while current_time <= end_time:
  377. window_end = current_time + window_size_ms
  378. # Get trades in this window
  379. window_trades = [
  380. t for t in trades_sorted
  381. if current_time <= t.timestamp < window_end
  382. ]
  383. if window_trades:
  384. # Calculate net flow and fees for this window
  385. net_usd_flow = 0.0
  386. window_fees = 0.0
  387. for trade in window_trades:
  388. trade_value = trade.size * trade.price
  389. if trade.side.lower() in ['buy', 'b']:
  390. net_usd_flow -= trade_value # Cash out
  391. elif trade.side.lower() in ['sell', 's', 'a', 'ask']: # sell
  392. net_usd_flow += trade_value # Cash in
  393. window_fees += trade.fee
  394. # Window P&L = net cash flow - fees
  395. window_pnl = net_usd_flow - window_fees
  396. cumulative_pnl += window_pnl
  397. pnl_series.append(cumulative_pnl)
  398. if window_pnl > 0:
  399. winning_periods += 1
  400. elif window_pnl < 0:
  401. losing_periods += 1
  402. current_time = window_end
  403. win_rate = winning_periods / (winning_periods + losing_periods) if (winning_periods + losing_periods) > 0 else 0
  404. return cumulative_pnl, pnl_series, winning_periods, losing_periods
  405. def analyze_token_diversity_and_type(self, trades: List[Trade], positions: List[Position]) -> Tuple[int, str, List[str]]:
  406. """
  407. Analyze token diversity and determine trading type (spot vs perps)
  408. Returns:
  409. tuple: (unique_tokens_count, trading_type, top_tokens_list)
  410. """
  411. if not trades:
  412. return 0, "unknown", []
  413. # Count token frequency by volume
  414. token_volumes = defaultdict(float)
  415. for trade in trades:
  416. volume = trade.size * trade.price
  417. token_volumes[trade.coin] += volume
  418. # Get unique token count
  419. unique_tokens = len(token_volumes)
  420. # Get top 5 tokens by volume
  421. sorted_tokens = sorted(token_volumes.items(), key=lambda x: x[1], reverse=True)
  422. top_tokens = [token for token, _ in sorted_tokens[:5]]
  423. # Determine trading type based on positions and leverage
  424. trading_type = self._determine_trading_type(positions, trades)
  425. return unique_tokens, trading_type, top_tokens
  426. def _determine_trading_type(self, positions: List[Position], trades: List[Trade]) -> str:
  427. """
  428. Determine if account trades spot, perps, or mixed
  429. Logic:
  430. - If positions have leverage > 1.1, it's perps
  431. - If no positions with leverage, check for margin/leverage indicators
  432. - Hyperliquid primarily offers perps, so default to perps if uncertain
  433. """
  434. if not positions and not trades:
  435. return "unknown"
  436. # Check current positions for leverage
  437. leveraged_positions = 0
  438. total_positions = len(positions)
  439. for position in positions:
  440. if position.leverage > 1.1: # Consider leverage > 1.1 as perps
  441. leveraged_positions += 1
  442. # If we have positions, determine based on leverage
  443. if total_positions > 0:
  444. leverage_ratio = leveraged_positions / total_positions
  445. if leverage_ratio >= 0.8: # 80%+ leveraged positions = perps
  446. return "perps"
  447. elif leverage_ratio <= 0.2: # 20%- leveraged positions = spot
  448. return "spot"
  449. else: # Mixed
  450. return "mixed"
  451. # If no current positions, check historical leverage patterns
  452. # For Hyperliquid, most trading is perps, so default to perps
  453. # We could also check if trades show signs of leverage (frequent short selling, etc.)
  454. # Check for short selling patterns (indicator of perps)
  455. total_trades = len(trades)
  456. if total_trades > 0:
  457. sell_trades = sum(1 for trade in trades if trade.side.lower() in ['sell', 's', 'a', 'ask'])
  458. buy_trades = total_trades - sell_trades
  459. # If significantly more sells than buys, likely includes short selling (perps)
  460. if sell_trades > buy_trades * 1.2:
  461. return "perps"
  462. # If roughly balanced, could be perps with both long/short
  463. elif abs(sell_trades - buy_trades) / total_trades < 0.3:
  464. return "perps"
  465. # Default to perps for Hyperliquid (they primarily offer perps)
  466. return "perps"
  467. def analyze_short_long_patterns(self, trades: List[Trade]) -> Dict[str, Any]:
  468. """
  469. Analyze short/long trading patterns for perpetual traders
  470. Returns:
  471. dict: Analysis of directional trading patterns
  472. """
  473. if not trades:
  474. return {
  475. 'total_buys': 0,
  476. 'total_sells': 0,
  477. 'buy_sell_ratio': 0,
  478. 'likely_short_trades': 0,
  479. 'short_percentage': 0,
  480. 'directional_balance': 'unknown',
  481. 'trading_style': 'unknown'
  482. }
  483. # Handle Hyperliquid API format: 'b' = buy/bid, 'a' = sell/ask
  484. total_buys = sum(1 for trade in trades if trade.side.lower() in ['buy', 'b'])
  485. total_sells = sum(1 for trade in trades if trade.side.lower() in ['sell', 's', 'a', 'ask'])
  486. total_trades = len(trades)
  487. # Calculate buy/sell ratio (handle edge cases)
  488. if total_sells == 0:
  489. buy_sell_ratio = float('inf') if total_buys > 0 else 0
  490. else:
  491. buy_sell_ratio = total_buys / total_sells
  492. # Analyze trading patterns
  493. if total_sells == 0 and total_buys > 0:
  494. directional_balance = "buy_only"
  495. trading_style = "Long-Only (buy and hold strategy)"
  496. elif total_buys == 0 and total_sells > 0:
  497. directional_balance = "sell_only"
  498. trading_style = "Short-Only (bearish strategy)"
  499. elif abs(total_buys - total_sells) / total_trades < 0.1: # Within 10%
  500. directional_balance = "balanced"
  501. trading_style = "Long/Short Balanced (can profit both ways)"
  502. elif total_sells > total_buys * 1.3: # 30% more sells
  503. directional_balance = "sell_heavy"
  504. trading_style = "Short-Heavy (profits from price drops)"
  505. elif total_buys > total_sells * 1.3: # 30% more buys
  506. directional_balance = "buy_heavy"
  507. trading_style = "Long-Heavy (profits from price rises)"
  508. else:
  509. directional_balance = "moderately_balanced"
  510. trading_style = "Moderately Balanced (flexible direction)"
  511. # Estimate likely short positions (sells without preceding buys)
  512. likely_shorts = 0
  513. position_tracker = defaultdict(lambda: {'net_position': 0})
  514. for trade in sorted(trades, key=lambda x: x.timestamp):
  515. coin_pos = position_tracker[trade.coin]
  516. # Handle both 'sell'/'s' and 'buy'/'b' formats
  517. if trade.side.lower() in ['sell', 's', 'a', 'ask']:
  518. if coin_pos['net_position'] <= 0: # Selling without long position = likely short
  519. likely_shorts += 1
  520. coin_pos['net_position'] -= trade.size
  521. elif trade.side.lower() in ['buy', 'b']:
  522. coin_pos['net_position'] += trade.size
  523. short_percentage = (likely_shorts / total_trades * 100) if total_trades > 0 else 0
  524. return {
  525. 'total_buys': total_buys,
  526. 'total_sells': total_sells,
  527. 'buy_sell_ratio': buy_sell_ratio,
  528. 'likely_short_trades': likely_shorts,
  529. 'short_percentage': short_percentage,
  530. 'directional_balance': directional_balance,
  531. 'trading_style': trading_style
  532. }
  533. async def analyze_account(self, address: str) -> Optional[AccountStats]:
  534. """Analyze a single account and return comprehensive statistics"""
  535. print(f"\n🔍 Analyzing account: {address}")
  536. # Get account data
  537. account_state = await self.get_account_state(address)
  538. fills = await self.get_user_fills(address, limit=500) # Reduced limit for better analysis
  539. if not fills:
  540. print(f"❌ No trading data found for {address}")
  541. return None
  542. # Parse data
  543. trades = self.parse_trades(fills)
  544. positions = self.parse_positions(account_state) if account_state else []
  545. if not trades:
  546. print(f"❌ No valid trades found for {address}")
  547. return None
  548. print(f"📊 Found {len(trades)} trades, {len(positions)} active positions")
  549. # Calculate time period
  550. trades_sorted = sorted(trades, key=lambda x: x.timestamp)
  551. oldest_trade = trades_sorted[0].timestamp
  552. newest_trade = trades_sorted[-1].timestamp
  553. analysis_period_ms = newest_trade - oldest_trade
  554. analysis_period_days = max(1, analysis_period_ms / (1000 * 60 * 60 * 24))
  555. # Calculate improved metrics
  556. total_trades = len(trades)
  557. total_fees = sum(trade.fee for trade in trades)
  558. # Analyze HFT patterns first
  559. hft_patterns = self.analyze_hft_patterns(trades)
  560. # Check if this is a manageable trading frequency for copy trading
  561. trading_freq = total_trades / analysis_period_days if analysis_period_days > 0 else 0
  562. is_copyable_frequency = 1 <= trading_freq <= 20 # 1-20 trades per day is manageable
  563. if hft_patterns['is_hft_pattern'] or trading_freq > 50:
  564. print(f"🤖 ❌ UNSUITABLE: High-frequency algorithmic trading detected")
  565. print(f"⚡ Trading frequency: {trading_freq:.1f} trades/day (TOO HIGH for copy trading)")
  566. print(f"🕒 Avg time between trades: {hft_patterns['avg_time_between_trades_minutes']:.1f} minutes")
  567. print(f"❌ This account cannot be safely copied - would result in overtrading and high fees")
  568. # Still calculate metrics for completeness but mark as unsuitable
  569. rolling_pnl, pnl_series, winning_periods, losing_periods = self.calculate_rolling_pnl(trades)
  570. realized_pnl = rolling_pnl
  571. win_rate = winning_periods / (winning_periods + losing_periods) if (winning_periods + losing_periods) > 0 else 0
  572. avg_duration = hft_patterns['avg_time_between_trades_minutes'] / 60 # Convert to hours
  573. print(f"💰 Rolling P&L: ${realized_pnl:.2f}, Periods: {winning_periods}W/{losing_periods}L")
  574. elif is_copyable_frequency:
  575. print(f"✅ SUITABLE: Human-manageable trading pattern detected")
  576. print(f"📊 Trading frequency: {trading_freq:.1f} trades/day (GOOD for copy trading)")
  577. # Use traditional P&L calculation for human traders
  578. realized_pnl, win_rate, winning_trades, losing_trades = self.calculate_trade_performance(trades)
  579. print(f"💰 Realized PnL: ${realized_pnl:.2f}, Wins: {winning_trades}, Losses: {losing_trades}")
  580. print(f"📈 Trade Win Rate: {win_rate:.1%}")
  581. # Calculate traditional trade durations
  582. durations = []
  583. position_tracker = defaultdict(lambda: {'size': 0, 'start_time': 0})
  584. for trade in trades_sorted:
  585. coin = trade.coin
  586. pos = position_tracker[coin]
  587. if trade.side.lower() in ['buy', 'b']:
  588. if pos['size'] <= 0 and trade.size > abs(pos['size']): # Opening new long
  589. pos['start_time'] = trade.timestamp
  590. pos['size'] += trade.size
  591. else: # sell
  592. if pos['size'] > 0: # Closing long position
  593. if trade.size >= pos['size'] and pos['start_time'] > 0: # Fully closing
  594. duration_hours = (trade.timestamp - pos['start_time']) / (1000 * 3600)
  595. if duration_hours > 0:
  596. durations.append(duration_hours)
  597. pos['start_time'] = 0
  598. pos['size'] -= trade.size
  599. elif pos['size'] <= 0: # Opening short
  600. pos['start_time'] = trade.timestamp
  601. pos['size'] -= trade.size
  602. avg_duration = statistics.mean(durations) if durations else 0
  603. print(f"🕒 Found {len(durations)} completed trades, avg duration: {avg_duration:.1f} hours")
  604. else:
  605. print(f"⚠️ QUESTIONABLE: Low trading frequency detected")
  606. print(f"📊 Trading frequency: {trading_freq:.1f} trades/day (might be inactive)")
  607. # Use traditional analysis for low-frequency traders
  608. realized_pnl, win_rate, winning_trades, losing_trades = self.calculate_trade_performance(trades)
  609. print(f"💰 Realized PnL: ${realized_pnl:.2f}, Wins: {winning_trades}, Losses: {losing_trades}")
  610. print(f"📈 Trade Win Rate: {win_rate:.1%}")
  611. avg_duration = 24.0 # Assume longer holds for infrequent traders
  612. print(f"🕒 Infrequent trading pattern - assuming longer hold times")
  613. # Common calculations
  614. unrealized_pnl = sum(pos.unrealized_pnl for pos in positions)
  615. total_pnl = realized_pnl + unrealized_pnl
  616. # Extract account balance for percentage calculations
  617. account_balance = self.get_account_balance(account_state)
  618. # Calculate percentage return
  619. if account_balance > 0:
  620. # PnL percentage = total_pnl / (account_balance - total_pnl) * 100
  621. # This gives us the return on the original balance before PnL
  622. original_balance = account_balance - total_pnl
  623. pnl_percentage = (total_pnl / original_balance * 100) if original_balance > 0 else 0.0
  624. else:
  625. pnl_percentage = 0.0
  626. print(f"💰 Total PnL: ${total_pnl:.2f} (Realized: ${realized_pnl:.2f} + Unrealized: ${unrealized_pnl:.2f})")
  627. print(f"💸 Total Fees: ${total_fees:.2f}")
  628. print(f"🏦 Account Balance: ${account_balance:.2f}")
  629. print(f"📊 PnL Percentage: {pnl_percentage:.2f}%")
  630. # Calculate position size statistics
  631. position_sizes = [trade.size * trade.price for trade in trades]
  632. avg_position_size = statistics.mean(position_sizes) if position_sizes else 0
  633. # Calculate leverage statistics from current positions
  634. leverages = [pos.leverage for pos in positions if pos.leverage > 0]
  635. max_leverage = max(leverages) if leverages else 0
  636. avg_leverage = statistics.mean(leverages) if leverages else 1
  637. # Calculate trading frequency
  638. trading_freq = total_trades / analysis_period_days if analysis_period_days > 0 else 0
  639. # Simplified drawdown calculation
  640. max_drawdown = 0.0
  641. current_drawdown = 0.0
  642. if total_pnl < 0:
  643. max_drawdown = abs(total_pnl) / (avg_position_size * 10) if avg_position_size > 0 else 0
  644. current_drawdown = max_drawdown
  645. # Risk metrics
  646. profit_factor = abs(realized_pnl) / total_fees if total_fees > 0 else 0
  647. # Analyze HFT patterns
  648. hft_patterns = self.analyze_hft_patterns(trades)
  649. # Determine copyability
  650. is_hft = trading_freq > 50
  651. is_inactive = trading_freq < 1
  652. is_copyable_freq = 1 <= trading_freq <= 20
  653. if is_hft:
  654. is_copyable = False
  655. copyability_reason = f"HFT Bot ({trading_freq:.1f} trades/day - too fast to copy)"
  656. elif is_inactive:
  657. is_copyable = False
  658. copyability_reason = f"Inactive ({trading_freq:.1f} trades/day - insufficient activity)"
  659. elif is_copyable_freq:
  660. is_copyable = True
  661. copyability_reason = f"Human trader ({trading_freq:.1f} trades/day - manageable frequency)"
  662. else:
  663. is_copyable = False
  664. copyability_reason = f"Questionable frequency ({trading_freq:.1f} trades/day)"
  665. # Calculate risk reward ratio safely
  666. if hft_patterns['is_hft_pattern']:
  667. # For HFT, use win rate as proxy for risk/reward
  668. risk_reward_ratio = win_rate / (1 - win_rate) if win_rate < 1 else 1.0
  669. else:
  670. # For traditional trading, try to use winning/losing trade counts
  671. try:
  672. # These variables should exist from traditional analysis
  673. risk_reward_ratio = winning_trades / max(1, losing_trades)
  674. except NameError:
  675. # Fallback if variables don't exist
  676. risk_reward_ratio = win_rate / (1 - win_rate) if win_rate < 1 else 1.0
  677. # Analyze token diversity and trading type
  678. unique_tokens, trading_type, top_tokens = self.analyze_token_diversity_and_type(trades, positions)
  679. # Analyze short/long patterns
  680. short_long_analysis = self.analyze_short_long_patterns(trades)
  681. return AccountStats(
  682. address=address,
  683. total_pnl=total_pnl,
  684. win_rate=win_rate,
  685. total_trades=total_trades,
  686. avg_trade_duration_hours=avg_duration,
  687. max_drawdown=max_drawdown,
  688. sharpe_ratio=0, # Would need returns data
  689. avg_position_size=avg_position_size,
  690. max_leverage_used=max_leverage,
  691. avg_leverage_used=avg_leverage,
  692. trading_frequency_per_day=trading_freq,
  693. risk_reward_ratio=risk_reward_ratio,
  694. consecutive_losses_max=0, # Would need sequence analysis
  695. profit_factor=profit_factor,
  696. largest_win=0, # Would need individual trade P&L
  697. largest_loss=0, # Would need individual trade P&L
  698. active_positions=len(positions),
  699. current_drawdown=current_drawdown,
  700. last_trade_timestamp=newest_trade,
  701. analysis_period_days=int(analysis_period_days),
  702. is_copyable=is_copyable,
  703. copyability_reason=copyability_reason,
  704. unique_tokens_traded=unique_tokens,
  705. trading_type=trading_type,
  706. top_tokens=top_tokens,
  707. short_percentage=short_long_analysis['short_percentage'],
  708. trading_style=short_long_analysis['trading_style'],
  709. buy_sell_ratio=short_long_analysis['buy_sell_ratio'],
  710. account_balance=account_balance,
  711. pnl_percentage=pnl_percentage
  712. )
  713. async def analyze_multiple_accounts(self, addresses: List[str]) -> List[AccountStats]:
  714. """Analyze multiple accounts concurrently"""
  715. print(f"🚀 Starting analysis of {len(addresses)} accounts...\n")
  716. tasks = [self.analyze_account(addr) for addr in addresses]
  717. results = await asyncio.gather(*tasks, return_exceptions=True)
  718. # Filter out None results and exceptions
  719. valid_results = []
  720. for i, result in enumerate(results):
  721. if isinstance(result, Exception):
  722. print(f"❌ Error analyzing {addresses[i]}: {result}")
  723. elif result is not None:
  724. valid_results.append(result)
  725. return valid_results
  726. def print_analysis_results(self, stats_list: List[AccountStats]):
  727. """Print comprehensive analysis results with relative scoring"""
  728. if not stats_list:
  729. print("❌ No valid analysis results to display")
  730. return
  731. print("\n" + "="*100)
  732. print("📊 HYPERLIQUID ACCOUNT ANALYSIS RESULTS")
  733. print("="*100)
  734. # Calculate data ranges for relative scoring
  735. def get_data_ranges(stats_list):
  736. """Calculate min/max values for relative scoring"""
  737. if not stats_list:
  738. return {}
  739. # Separate copyable from non-copyable for different scoring
  740. copyable_accounts = [s for s in stats_list if s.is_copyable]
  741. all_accounts = stats_list
  742. ranges = {}
  743. # Profitability range (use percentage returns for fair comparison)
  744. pnl_percentages = [s.pnl_percentage for s in all_accounts]
  745. ranges['pnl_pct_min'] = min(pnl_percentages)
  746. ranges['pnl_pct_max'] = max(pnl_percentages)
  747. ranges['pnl_pct_range'] = ranges['pnl_pct_max'] - ranges['pnl_pct_min']
  748. # Separate positive and negative percentage returns for different scoring
  749. positive_pnl_pcts = [p for p in pnl_percentages if p > 0]
  750. negative_pnl_pcts = [p for p in pnl_percentages if p < 0]
  751. ranges['has_profitable'] = len(positive_pnl_pcts) > 0
  752. ranges['has_unprofitable'] = len(negative_pnl_pcts) > 0
  753. ranges['most_profitable_pct'] = max(positive_pnl_pcts) if positive_pnl_pcts else 0
  754. ranges['most_unprofitable_pct'] = min(negative_pnl_pcts) if negative_pnl_pcts else 0
  755. # Keep absolute PnL for display purposes
  756. pnls = [s.total_pnl for s in all_accounts]
  757. ranges['pnl_min'] = min(pnls)
  758. ranges['pnl_max'] = max(pnls)
  759. ranges['most_profitable'] = max([p for p in pnls if p > 0]) if any(p > 0 for p in pnls) else 0
  760. ranges['most_unprofitable'] = min([p for p in pnls if p < 0]) if any(p < 0 for p in pnls) else 0
  761. # Win rate range (use all accounts)
  762. win_rates = [s.win_rate for s in all_accounts]
  763. ranges['winrate_min'] = min(win_rates)
  764. ranges['winrate_max'] = max(win_rates)
  765. ranges['winrate_range'] = ranges['winrate_max'] - ranges['winrate_min']
  766. # Trading frequency range (use all accounts)
  767. frequencies = [s.trading_frequency_per_day for s in all_accounts]
  768. ranges['freq_min'] = min(frequencies)
  769. ranges['freq_max'] = max(frequencies)
  770. ranges['freq_range'] = ranges['freq_max'] - ranges['freq_min']
  771. # Trade duration range (use all accounts)
  772. durations = [s.avg_trade_duration_hours for s in all_accounts if s.avg_trade_duration_hours > 0]
  773. if durations:
  774. ranges['duration_min'] = min(durations)
  775. ranges['duration_max'] = max(durations)
  776. ranges['duration_range'] = ranges['duration_max'] - ranges['duration_min']
  777. else:
  778. ranges['duration_min'] = 0
  779. ranges['duration_max'] = 24
  780. ranges['duration_range'] = 24
  781. # Drawdown range (use all accounts) - ENHANCED
  782. drawdowns = [s.max_drawdown for s in all_accounts]
  783. ranges['drawdown_min'] = min(drawdowns)
  784. ranges['drawdown_max'] = max(drawdowns)
  785. ranges['drawdown_range'] = ranges['drawdown_max'] - ranges['drawdown_min']
  786. # Account age range (NEW)
  787. ages = [s.analysis_period_days for s in all_accounts]
  788. ranges['age_min'] = min(ages)
  789. ranges['age_max'] = max(ages)
  790. ranges['age_range'] = ranges['age_max'] - ranges['age_min']
  791. return ranges
  792. ranges = get_data_ranges(stats_list)
  793. # Relative scoring function
  794. def calculate_relative_score(stats: AccountStats, ranges: dict) -> float:
  795. score = 0.0
  796. score_breakdown = {}
  797. # 1. COPYABILITY FILTER (35% weight - most important)
  798. is_hft = stats.trading_frequency_per_day > 50
  799. is_too_slow = stats.trading_frequency_per_day < 1
  800. is_copyable = 1 <= stats.trading_frequency_per_day <= 20
  801. if is_hft:
  802. copyability_score = 0 # HFT bots get 0
  803. score_breakdown['copyability'] = f"❌ HFT Bot (0 points)"
  804. elif is_too_slow:
  805. copyability_score = 5 # Inactive accounts get very low points
  806. score_breakdown['copyability'] = f"⚠️ Inactive (5 points)"
  807. elif is_copyable:
  808. # For copyable accounts, score based on how close to ideal frequency (15 trades/day)
  809. ideal_freq = 15
  810. freq_distance = abs(stats.trading_frequency_per_day - ideal_freq)
  811. # Max score when exactly at ideal, decreases as distance increases
  812. copyability_score = max(0, 35 - (freq_distance * 1.5)) # Lose 1.5 points per trade away from ideal
  813. score_breakdown['copyability'] = f"✅ Copyable ({copyability_score:.1f} points - {stats.trading_frequency_per_day:.1f} trades/day)"
  814. else:
  815. copyability_score = 15 # Questionable frequency
  816. score_breakdown['copyability'] = f"⚠️ Questionable ({copyability_score} points)"
  817. score += copyability_score
  818. # 2. PROFITABILITY (30% weight) - FAIR COMPARISON using percentage returns
  819. if stats.pnl_percentage < 0:
  820. # Severe punishment for unprofitable accounts (based on percentage loss)
  821. if ranges['has_unprofitable'] and ranges['most_unprofitable_pct'] < stats.pnl_percentage:
  822. # Scale from -15 (worst) to 0 (break-even)
  823. loss_severity = abs(stats.pnl_percentage) / abs(ranges['most_unprofitable_pct'])
  824. profitability_score = -15 * loss_severity # Negative score for losses!
  825. else:
  826. profitability_score = -15 # Maximum penalty
  827. score_breakdown['profitability'] = f"❌ LOSING ({profitability_score:.1f} points - {stats.pnl_percentage:.1f}% LOSS, ${stats.total_pnl:.0f})"
  828. elif stats.pnl_percentage == 0:
  829. profitability_score = 0 # Breakeven gets no points
  830. score_breakdown['profitability'] = f"⚖️ Breakeven (0 points - {stats.pnl_percentage:.1f}%, ${stats.total_pnl:.0f})"
  831. else:
  832. # Positive percentage returns get full scoring - FAIR comparison regardless of account size
  833. if ranges['has_profitable'] and ranges['most_profitable_pct'] > 0:
  834. profit_ratio = stats.pnl_percentage / ranges['most_profitable_pct']
  835. profitability_score = profit_ratio * 30
  836. else:
  837. profitability_score = 15 # Average score if only one profitable account
  838. score_breakdown['profitability'] = f"✅ Profitable ({profitability_score:.1f} points - {stats.pnl_percentage:.1f}% return, ${stats.total_pnl:.0f})"
  839. score += profitability_score
  840. # 3. RISK MANAGEMENT (20% weight) - HARSH PUNISHMENT for high drawdown
  841. if stats.max_drawdown > 0.5: # 50%+ drawdown is disqualifying
  842. risk_score = -10 # Negative score for extreme risk!
  843. score_breakdown['risk'] = f"❌ EXTREME RISK ({risk_score} points - {stats.max_drawdown:.1%} drawdown)"
  844. elif stats.max_drawdown > 0.25: # 25%+ drawdown is very bad
  845. risk_score = -5 # Negative score for high risk
  846. score_breakdown['risk'] = f"❌ HIGH RISK ({risk_score} points - {stats.max_drawdown:.1%} drawdown)"
  847. elif stats.max_drawdown > 0.15: # 15%+ drawdown is concerning
  848. risk_score = 5 # Low positive score
  849. score_breakdown['risk'] = f"⚠️ Moderate Risk ({risk_score} points - {stats.max_drawdown:.1%} drawdown)"
  850. elif stats.max_drawdown > 0.05: # 5-15% drawdown is acceptable
  851. risk_score = 15 # Good score
  852. score_breakdown['risk'] = f"✅ Good Risk Mgmt ({risk_score} points - {stats.max_drawdown:.1%} drawdown)"
  853. else: # <5% drawdown is excellent
  854. risk_score = 20 # Full points
  855. score_breakdown['risk'] = f"✅ Excellent Risk ({risk_score} points - {stats.max_drawdown:.1%} drawdown)"
  856. score += risk_score
  857. # 4. ACCOUNT MATURITY (10% weight) - NEW FACTOR
  858. min_good_age = 30 # At least 30 days of history is preferred
  859. if stats.analysis_period_days < 7:
  860. age_score = 0 # Too new, no confidence
  861. score_breakdown['maturity'] = f"❌ Too New ({age_score} points - {stats.analysis_period_days} days)"
  862. elif stats.analysis_period_days < 14:
  863. age_score = 2 # Very new
  864. score_breakdown['maturity'] = f"⚠️ Very New ({age_score} points - {stats.analysis_period_days} days)"
  865. elif stats.analysis_period_days < min_good_age:
  866. age_score = 5 # Somewhat new
  867. score_breakdown['maturity'] = f"⚠️ Somewhat New ({age_score} points - {stats.analysis_period_days} days)"
  868. else:
  869. # Scale from 30 days (7 points) to max age (10 points)
  870. if ranges['age_range'] > 0:
  871. age_ratio = min(1.0, (stats.analysis_period_days - min_good_age) / max(1, ranges['age_max'] - min_good_age))
  872. age_score = 7 + (age_ratio * 3) # 7-10 points
  873. else:
  874. age_score = 8 # Average if all same age
  875. score_breakdown['maturity'] = f"✅ Mature ({age_score:.1f} points - {stats.analysis_period_days} days)"
  876. score += age_score
  877. # 5. WIN RATE (5% weight) - Reduced importance
  878. if ranges['winrate_range'] > 0:
  879. winrate_normalized = (stats.win_rate - ranges['winrate_min']) / ranges['winrate_range']
  880. winrate_score = winrate_normalized * 5
  881. else:
  882. winrate_score = 2.5 # If all same win rate, give average score
  883. score += winrate_score
  884. score_breakdown['winrate'] = f"📈 Win Rate ({winrate_score:.1f} points - {stats.win_rate:.1%})"
  885. return score, score_breakdown
  886. # Calculate scores for all accounts
  887. scored_accounts = []
  888. for stats in stats_list:
  889. score, breakdown = calculate_relative_score(stats, ranges)
  890. scored_accounts.append((stats, score, breakdown))
  891. # Sort by score
  892. scored_accounts.sort(key=lambda x: x[1], reverse=True)
  893. # Print data ranges for context
  894. print(f"\n📊 COHORT ANALYSIS (for relative scoring):")
  895. print(f" 📊 Return Range: {ranges['pnl_pct_min']:.1f}% to {ranges['pnl_pct_max']:.1f}% (FAIR COMPARISON)")
  896. if ranges['has_unprofitable']:
  897. print(f" ❌ Worst Loss: {ranges['most_unprofitable_pct']:.1f}% (${ranges['most_unprofitable']:.0f})")
  898. if ranges['has_profitable']:
  899. print(f" ✅ Best Return: {ranges['most_profitable_pct']:.1f}% (${ranges['most_profitable']:.0f})")
  900. print(f" 💰 Absolute PnL Range: ${ranges['pnl_min']:.0f} to ${ranges['pnl_max']:.0f}")
  901. print(f" 📈 Win Rate Range: {ranges['winrate_min']:.1%} to {ranges['winrate_max']:.1%}")
  902. print(f" 🔄 Frequency Range: {ranges['freq_min']:.1f} to {ranges['freq_max']:.1f} trades/day")
  903. print(f" 📉 Drawdown Range: {ranges['drawdown_min']:.1%} to {ranges['drawdown_max']:.1%}")
  904. print(f" 📅 Account Age Range: {ranges['age_min']} to {ranges['age_max']} days")
  905. print(f"\n⚠️ NOTE: Scoring now uses PERCENTAGE RETURNS for fair comparison across account sizes!")
  906. print(f"⚠️ WARNING: Accounts with losses or high drawdown receive NEGATIVE scores!")
  907. # Print results (top 10 only)
  908. total_accounts = len(scored_accounts)
  909. showing_count = min(10, total_accounts)
  910. print(f"\n📋 DETAILED ANALYSIS - Showing top {showing_count} of {total_accounts} accounts:")
  911. for i, (stats, score, breakdown) in enumerate(scored_accounts[:10], 1):
  912. print(f"\n{i}. 📋 ACCOUNT: {stats.address}")
  913. print(f" 🏆 RELATIVE SCORE: {score:.1f}/100")
  914. print(f" 📊 Score Breakdown:")
  915. for metric, description in breakdown.items():
  916. print(f" {description}")
  917. print(f" 📊 Return: {stats.pnl_percentage:.2f}% (${stats.total_pnl:.2f})")
  918. print(f" 🏦 Account Balance: ${stats.account_balance:.2f}")
  919. print(f" 📈 Win Rate: {stats.win_rate:.1%}")
  920. print(f" 🕒 Avg Trade Duration: {stats.avg_trade_duration_hours:.1f} hours")
  921. print(f" 📉 Max Drawdown: {stats.max_drawdown:.1%}")
  922. print(f" 🔄 Trading Frequency: {stats.trading_frequency_per_day:.1f} trades/day")
  923. print(f" 💵 Avg Position Size: ${stats.avg_position_size:.2f}")
  924. print(f" ⚡ Max Leverage: {stats.max_leverage_used:.1f}x")
  925. print(f" 📊 Total Trades: {stats.total_trades}")
  926. print(f" 📍 Active Positions: {stats.active_positions}")
  927. print(f" 📅 Analysis Period: {stats.analysis_period_days} days")
  928. # New token and trading type information
  929. print(f" 🪙 Unique Tokens: {stats.unique_tokens_traded}")
  930. # Trading type with emoji
  931. trading_type_display = {
  932. "perps": "🔄 Perpetuals",
  933. "spot": "💱 Spot Trading",
  934. "mixed": "🔀 Mixed (Spot + Perps)",
  935. "unknown": "❓ Unknown"
  936. }.get(stats.trading_type, f"❓ {stats.trading_type}")
  937. print(f" 📈 Trading Type: {trading_type_display}")
  938. # Short/Long patterns - KEY ADVANTAGE
  939. print(f" 📊 Trading Style: {stats.trading_style}")
  940. print(f" 📉 Short Trades: {stats.short_percentage:.1f}% (can profit from price drops)")
  941. # Format buy/sell ratio properly
  942. if stats.buy_sell_ratio == float('inf'):
  943. ratio_display = "∞ (only buys)"
  944. elif stats.buy_sell_ratio == 0:
  945. ratio_display = "0 (only sells)"
  946. else:
  947. ratio_display = f"{stats.buy_sell_ratio:.2f}"
  948. print(f" ⚖️ Buy/Sell Ratio: {ratio_display}")
  949. # Top tokens
  950. if stats.top_tokens:
  951. top_tokens_str = ", ".join(stats.top_tokens[:3]) # Show top 3
  952. if len(stats.top_tokens) > 3:
  953. top_tokens_str += f" +{len(stats.top_tokens)-3} more"
  954. print(f" 🏆 Top Tokens: {top_tokens_str}")
  955. # Copy Trading Suitability Evaluation
  956. evaluation = []
  957. is_hft_pattern = stats.trading_frequency_per_day > 50
  958. is_copyable = 1 <= stats.trading_frequency_per_day <= 20
  959. # First determine if account is copyable
  960. if is_hft_pattern:
  961. evaluation.append("❌ NOT COPYABLE - HFT/Bot")
  962. elif stats.trading_frequency_per_day < 1:
  963. evaluation.append("❌ NOT COPYABLE - Inactive")
  964. elif is_copyable:
  965. evaluation.append("✅ COPYABLE - Human trader")
  966. else:
  967. evaluation.append("⚠️ QUESTIONABLE - Check frequency")
  968. # Profitability check (using percentage returns for fairness)
  969. if stats.pnl_percentage > 0:
  970. evaluation.append(f"✅ Profitable ({stats.pnl_percentage:.1f}% return)")
  971. else:
  972. evaluation.append(f"❌ Not profitable ({stats.pnl_percentage:.1f}% loss)")
  973. # Trade duration evaluation for copyable accounts
  974. if is_copyable:
  975. if 2 <= stats.avg_trade_duration_hours <= 48:
  976. evaluation.append("✅ Good trade duration")
  977. elif stats.avg_trade_duration_hours < 2:
  978. evaluation.append("⚠️ Very short trades")
  979. else:
  980. evaluation.append("⚠️ Long hold times")
  981. # Win rate for human traders
  982. if stats.win_rate > 0.6:
  983. evaluation.append("✅ Excellent win rate")
  984. elif stats.win_rate > 0.4:
  985. evaluation.append("✅ Good win rate")
  986. else:
  987. evaluation.append("⚠️ Low win rate")
  988. else:
  989. # For non-copyable accounts, just note the pattern
  990. if is_hft_pattern:
  991. evaluation.append("🤖 Algorithmic trading")
  992. else:
  993. evaluation.append("💤 Low activity")
  994. # Risk management (universal)
  995. if stats.max_drawdown < 0.15:
  996. evaluation.append("✅ Good risk management")
  997. elif stats.max_drawdown < 0.25:
  998. evaluation.append("⚠️ Moderate risk")
  999. else:
  1000. evaluation.append("❌ High drawdown risk")
  1001. print(f" 🎯 Evaluation: {' | '.join(evaluation)}")
  1002. # Recommendation section (rest remains the same)
  1003. print("\n" + "="*100)
  1004. print("🎯 COPY TRADING RECOMMENDATIONS")
  1005. print("="*100)
  1006. # Separate copyable from non-copyable accounts
  1007. copyable_accounts = [(stats, score, breakdown) for stats, score, breakdown in scored_accounts if stats.is_copyable]
  1008. non_copyable_accounts = [(stats, score, breakdown) for stats, score, breakdown in scored_accounts if not stats.is_copyable]
  1009. if copyable_accounts:
  1010. print(f"\n✅ FOUND {len(copyable_accounts)} COPYABLE ACCOUNTS:")
  1011. best_stats, best_score, best_breakdown = copyable_accounts[0]
  1012. print(f"\n🏆 TOP COPYABLE RECOMMENDATION: {best_stats.address}")
  1013. print(f" 📊 Relative Score: {best_score:.1f}/100")
  1014. print(f" 🎯 Status: {best_stats.copyability_reason}")
  1015. if best_score >= 60:
  1016. recommendation = "🟢 HIGHLY RECOMMENDED"
  1017. elif best_score >= 40:
  1018. recommendation = "🟡 MODERATELY RECOMMENDED"
  1019. elif best_score >= 20:
  1020. recommendation = "🟠 PROCEED WITH EXTREME CAUTION"
  1021. elif best_score >= 0:
  1022. recommendation = "🔴 NOT RECOMMENDED (Risky)"
  1023. else:
  1024. recommendation = "⛔ DANGEROUS (Negative Score)"
  1025. print(f" {recommendation}")
  1026. print(f"\n📋 Why this account scored highest:")
  1027. for metric, description in best_breakdown.items():
  1028. print(f" {description}")
  1029. print(f"\n⚙️ Suggested copy trading settings:")
  1030. if best_score >= 60:
  1031. print(f" 📊 Portfolio allocation: 10-25% (confident allocation)")
  1032. print(f" ⚡ Max leverage limit: 5-10x")
  1033. elif best_score >= 40:
  1034. print(f" 📊 Portfolio allocation: 5-15% (moderate allocation)")
  1035. print(f" ⚡ Max leverage limit: 3-5x")
  1036. elif best_score >= 20:
  1037. print(f" 📊 Portfolio allocation: 2-5% (very small allocation)")
  1038. print(f" ⚡ Max leverage limit: 2-3x")
  1039. else:
  1040. print(f" 📊 Portfolio allocation: DO NOT COPY")
  1041. print(f" ⚡ ACCOUNT IS TOO RISKY FOR COPY TRADING")
  1042. print(f" 💰 Min position size: $25-50")
  1043. print(f" 🔄 Expected trades: {best_stats.trading_frequency_per_day:.1f} per day")
  1044. print(f" 📅 Account age: {best_stats.analysis_period_days} days")
  1045. else:
  1046. print(f"\n❌ NO COPYABLE ACCOUNTS FOUND")
  1047. print(f" All analyzed accounts are unsuitable for copy trading")
  1048. if non_copyable_accounts:
  1049. print(f"\n❌ {len(non_copyable_accounts)} UNSUITABLE ACCOUNTS (DO NOT COPY):")
  1050. for i, (account, score, breakdown) in enumerate(non_copyable_accounts[:3], 1): # Show top 3 unsuitable
  1051. score_indicator = "⛔ DANGEROUS" if score < 0 else "🔴 Risky" if score < 20 else "⚠️ Poor"
  1052. print(f" {i}. {account.address[:10]}... - {account.copyability_reason} ({score_indicator}: {score:.1f})")
  1053. if len(non_copyable_accounts) > 3:
  1054. print(f" ... and {len(non_copyable_accounts) - 3} more unsuitable accounts")
  1055. print(f"\n⚠️ ENHANCED COPY TRADING GUIDELINES:")
  1056. print(f" • ✅ ONLY copy accounts with 30+ days of history")
  1057. print(f" • ✅ ONLY copy PROFITABLE accounts (positive PnL)")
  1058. print(f" • ✅ AVOID accounts with >15% max drawdown")
  1059. print(f" • ✅ Ideal frequency: 5-15 trades per day")
  1060. print(f" • ❌ NEVER copy accounts with negative scores")
  1061. print(f" • ❌ NEVER copy accounts losing money")
  1062. print(f" • ⚠️ Start with 2-5% allocation even for good accounts")
  1063. print(f" • 📊 Higher scores = more reliable performance")
  1064. print(f" • 🔄 ADVANTAGE: Perpetual traders can profit in BOTH bull & bear markets!")
  1065. print(f" • 📈📉 They go long (profit when price rises) AND short (profit when price falls)")
  1066. print(f" • 💡 This means potential profits in any market condition")
  1067. # Show directional trading summary
  1068. if copyable_accounts:
  1069. print(f"\n🎯 DIRECTIONAL TRADING ANALYSIS OF COPYABLE ACCOUNTS:")
  1070. for i, (stats, score, breakdown) in enumerate(copyable_accounts, 1):
  1071. short_capability = "✅ Excellent" if stats.short_percentage > 30 else "⚠️ Limited" if stats.short_percentage > 10 else "❌ Minimal"
  1072. risk_indicator = "⛔ DANGEROUS" if score < 0 else "🔴 Risky" if score < 20 else "⚠️ Caution" if score < 40 else "✅ Good"
  1073. print(f" {i}. {stats.address[:10]}... - {stats.short_percentage:.1f}% shorts ({short_capability} short capability)")
  1074. print(f" Score: {score:.1f}/100 ({risk_indicator}) | Style: {stats.trading_style}")
  1075. print(f" Age: {stats.analysis_period_days} days | Return: {stats.pnl_percentage:.1f}% (${stats.total_pnl:.0f}) | Drawdown: {stats.max_drawdown:.1%}")
  1076. print(f" Advantage: Can profit when {', '.join(stats.top_tokens[:2])} prices move in EITHER direction")
  1077. async def get_leaderboard(self, window: str = "7d", limit: int = 20) -> Optional[List[str]]:
  1078. """
  1079. Get top accounts from Hyperliquid leaderboard
  1080. Note: Hyperliquid's public API doesn't expose leaderboard data directly.
  1081. This function serves as a template for when/if the API becomes available.
  1082. Args:
  1083. window: Time window for leaderboard ("1d", "7d", "30d", "allTime")
  1084. limit: Number of top accounts to return
  1085. Returns:
  1086. List of account addresses from leaderboard (currently returns None)
  1087. """
  1088. print(f"⚠️ Hyperliquid leaderboard API not publicly accessible")
  1089. print(f"💡 To analyze current top performers:")
  1090. print(f" 1. Visit: https://app.hyperliquid.xyz/leaderboard")
  1091. print(f" 2. Copy top performer addresses manually")
  1092. print(f" 3. Run: python utils/hyperliquid_account_analyzer.py [address1] [address2] ...")
  1093. print(f" 4. Or use --top10 for a curated list of known good traders")
  1094. # Note: If Hyperliquid ever makes their leaderboard API public,
  1095. # we can implement the actual fetching logic here
  1096. return None
  1097. async def _try_alternative_leaderboard(self, window: str, limit: int) -> Optional[List[str]]:
  1098. """Try alternative methods to get leaderboard data"""
  1099. try:
  1100. # Try different payload formats
  1101. alternative_payloads = [
  1102. {
  1103. "type": "leaderBoard",
  1104. "timeWindow": window
  1105. },
  1106. {
  1107. "type": "userLeaderboard",
  1108. "window": window
  1109. },
  1110. {
  1111. "type": "spotLeaderboard",
  1112. "req": {"timeWindow": window}
  1113. }
  1114. ]
  1115. for payload in alternative_payloads:
  1116. try:
  1117. async with self.session.post(self.info_url, json=payload) as response:
  1118. if response.status == 200:
  1119. data = await response.json()
  1120. # Try to extract addresses from any structure
  1121. addresses = self._extract_addresses_from_data(data, limit)
  1122. if addresses:
  1123. print(f"📊 Successfully fetched {len(addresses)} addresses using alternative method")
  1124. return addresses
  1125. except Exception as e:
  1126. continue
  1127. print("⚠️ Could not fetch leaderboard data, using fallback top accounts")
  1128. return None
  1129. except Exception as e:
  1130. print(f"⚠️ Alternative leaderboard fetch failed: {e}")
  1131. return None
  1132. def _extract_addresses_from_data(self, data: Any, limit: int) -> List[str]:
  1133. """Extract addresses from any nested data structure"""
  1134. addresses = []
  1135. def recursive_search(obj, depth=0):
  1136. if depth > 5: # Prevent infinite recursion
  1137. return
  1138. if isinstance(obj, list):
  1139. for item in obj:
  1140. recursive_search(item, depth + 1)
  1141. elif isinstance(obj, dict):
  1142. # Check if this dict has an address field
  1143. for addr_field in ['user', 'address', 'account', 'trader', 'wallet']:
  1144. if addr_field in obj:
  1145. addr = obj[addr_field]
  1146. if isinstance(addr, str) and addr.startswith('0x') and len(addr) == 42:
  1147. if addr not in addresses: # Avoid duplicates
  1148. addresses.append(addr)
  1149. # Recurse into nested objects
  1150. for value in obj.values():
  1151. recursive_search(value, depth + 1)
  1152. recursive_search(data)
  1153. return addresses[:limit]
  1154. async def get_top_accounts_from_leaderboard(self, window: str = "7d", limit: int = 10) -> List[str]:
  1155. """
  1156. Get top performing accounts from Hyperliquid leaderboard
  1157. Currently uses a curated list of high-performing accounts since
  1158. the Hyperliquid leaderboard API is not publicly accessible.
  1159. Args:
  1160. window: Time window ("1d", "7d", "30d", "allTime")
  1161. limit: Number of accounts to return
  1162. Returns:
  1163. List of top account addresses
  1164. """
  1165. print(f"🔍 Attempting to fetch top {limit} accounts from {window} leaderboard...")
  1166. addresses = await self.get_leaderboard(window, limit)
  1167. if not addresses:
  1168. print("\n📋 Using curated list of high-performing accounts")
  1169. print("💡 These accounts have been manually verified for good performance")
  1170. # Curated list of known high-performing accounts
  1171. # Updated based on our previous analysis
  1172. curated_addresses = [
  1173. "0x59a15c79a007cd6e9965b949fcf04125c2212524", # Best performer from previous analysis
  1174. "0xa10ec245b3483f83e350a9165a52ae23dbab01bc",
  1175. "0x0487b5e806ac781508cb3272ebd83ad603ddcc0f",
  1176. "0x72fad4e75748b65566a3ebb555b6f6ee18ce08d1",
  1177. "0xa70434af5778038245d53da1b4d360a30307a827",
  1178. "0xeaa400abec7c62d315fd760cbba817fa35e4e0e8",
  1179. "0x3104b7668f9e46fb13ec0b141d2902e144d67efe",
  1180. "0x74dcdc6df25bd7ba70336632ecd76a053d0f8dd4",
  1181. "0xc62df97dcf96324adf4edd30a4a7bffd5402f4da",
  1182. "0xd11f5de0189d52b3abe6b0960b8377c20988e17e"
  1183. ]
  1184. selected_addresses = curated_addresses[:limit]
  1185. print(f"📊 Selected {len(selected_addresses)} accounts for analysis:")
  1186. for i, addr in enumerate(selected_addresses, 1):
  1187. print(f" {i}. {addr}")
  1188. return selected_addresses
  1189. print(f"✅ Successfully fetched {len(addresses)} top accounts from leaderboard")
  1190. for i, addr in enumerate(addresses, 1):
  1191. print(f" {i}. {addr}")
  1192. return addresses
  1193. async def main():
  1194. """Main function"""
  1195. parser = argparse.ArgumentParser(description='Analyze Hyperliquid trading accounts')
  1196. parser.add_argument('addresses', nargs='*', help='Account addresses to analyze')
  1197. parser.add_argument('--top10', action='store_true', help='Analyze the provided top 10 accounts (hardcoded list)')
  1198. parser.add_argument('--leaderboard', action='store_true', help='Fetch and analyze top accounts from Hyperliquid leaderboard')
  1199. parser.add_argument('--window', default='7d', choices=['1d', '7d', '30d', 'allTime'],
  1200. help='Time window for leaderboard (default: 7d)')
  1201. parser.add_argument('--limit', type=int, default=10, help='Number of top accounts to analyze (default: 10)')
  1202. args = parser.parse_args()
  1203. # Top 10 accounts from the user (fallback)
  1204. top10_addresses = [
  1205. "0xa10ec245b3483f83e350a9165a52ae23dbab01bc",
  1206. "0x2aab3badd6a5daa388da47de4c72a6fa618a6265",
  1207. "0xd11f5de0189d52b3abe6b0960b8377c20988e17e",
  1208. "0xc62df97dcf96324adf4edd30a4a7bffd5402f4da",
  1209. "0xa70434af5778038245d53da1b4d360a30307a827",
  1210. "0x72fad4e75748b65566a3ebb555b6f6ee18ce08d1",
  1211. "0x0487b5e806ac781508cb3272ebd83ad603ddcc0f",
  1212. "0x59a15c79a007cd6e9965b949fcf04125c2212524",
  1213. "0xeaa400abec7c62d315fd760cbba817fa35e4e0e8",
  1214. "0x3104b7668f9e46fb13ec0b141d2902e144d67efe",
  1215. "0x74dcdc6df25bd7ba70336632ecd76a053d0f8dd4",
  1216. "0x101a2d2afc2f9b0b217637f53e3a3e859104a33d",
  1217. "0x836f01e63bd0fcbe673dcd905f882a5a808dd36e",
  1218. "0xae42743b5d6a3594b7f95b5cebce64cfedc69318",
  1219. "0x944fdea9d4956ce673c7545862cefccad6ee1b04",
  1220. "0x2a93e999816c9826ade0b51aaa2d83240d8f4596",
  1221. "0x7d3ca5fa94383b22ee49fc14e89aa417f65b4d92",
  1222. "0xfacb7404c1fad06444bda161d1304e4b7aa14e77",
  1223. "0x654d8c01f308d670d6bed13d892ee7ee285028a6",
  1224. "0xbbf3fc6f14e70eb451d1ecd2c20227702fc435c6",
  1225. "0x41dd4becd2930c37e8c05bac4e82459489d47e32",
  1226. "0xe97b3608b2c527b92400099b144b8868e8e02b14",
  1227. "0x9d8769bf821cec63f5e5436ef194002377d917f1",
  1228. "0x258855d09cf445835769f21370230652c4294a92",
  1229. "0x69e07d092e3b4bd5bbc02aed7491916269426ad1",
  1230. "0x456385399308ec63b264435457e9c877e423d40e",
  1231. "0x6acaa29b5241bd03dca19fd1d7e37bb354843951",
  1232. "0x0595cc0e36af4d2e11b23cb446ed02eaea7f87fd",
  1233. "0xf19dbdb7a58e51705cd792a469346f7bc19d16ee",
  1234. "0xadb1c408648a798d04bb5f32d7fccaa067ff58d2",
  1235. "0x17716dcb45ea700143361bf6d3b1d12065806c88",
  1236. "0xa3f27ae63b409f1e06be5665eba1f4002a71f54e",
  1237. "0xc9daf6f40aff9698784b77aa186cb0095cec8e65",
  1238. "0xb90e0421cb5d2ce8f015b57cd37b6cf6eaba8359",
  1239. "0x1cb007b5e23a10e4658a8e8affe7a060c3a697f6"
  1240. ]
  1241. async with HyperliquidAccountAnalyzer() as analyzer:
  1242. if args.leaderboard:
  1243. # Fetch top accounts from leaderboard
  1244. addresses = await analyzer.get_top_accounts_from_leaderboard(args.window, args.limit)
  1245. elif args.top10:
  1246. # Use hardcoded top 10 list
  1247. addresses = top10_addresses
  1248. print("ℹ️ Using hardcoded top 10 accounts")
  1249. elif args.addresses:
  1250. # Use provided addresses
  1251. addresses = args.addresses
  1252. print(f"ℹ️ Analyzing {len(addresses)} provided addresses")
  1253. else:
  1254. # Default: use curated list (since leaderboard API isn't available)
  1255. print("ℹ️ No addresses specified, using curated high-performance accounts...")
  1256. addresses = await analyzer.get_top_accounts_from_leaderboard(args.window, args.limit)
  1257. if not addresses:
  1258. print("❌ No addresses to analyze")
  1259. return
  1260. results = await analyzer.analyze_multiple_accounts(addresses)
  1261. analyzer.print_analysis_results(results)
  1262. if __name__ == "__main__":
  1263. asyncio.run(main())