copy_trading.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. """
  2. Copy Trading API Router
  3. Provides endpoints for:
  4. - Copy trading status and control
  5. - Account analysis functionality
  6. - Target trader evaluation
  7. """
  8. from fastapi import APIRouter, HTTPException, Query, BackgroundTasks
  9. from pydantic import BaseModel, Field
  10. from typing import List, Optional, Dict, Any
  11. from datetime import datetime
  12. import asyncio
  13. import logging
  14. # Import our existing classes
  15. from ...monitoring.copy_trading_monitor import CopyTradingMonitor
  16. from ...monitoring.copy_trading_state import CopyTradingStateManager
  17. from ...clients.hyperliquid_client import HyperliquidClient
  18. from ...notifications.notification_manager import NotificationManager
  19. from ...config.config import Config
  20. # Import the account analyzer utility
  21. import sys
  22. import os
  23. sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'utils'))
  24. from hyperliquid_account_analyzer import HyperliquidAccountAnalyzer, AccountStats
  25. router = APIRouter(prefix="/copy-trading", tags=["copy-trading"])
  26. logger = logging.getLogger(__name__)
  27. # Pydantic Models
  28. class CopyTradingStatus(BaseModel):
  29. enabled: bool
  30. target_address: Optional[str]
  31. portfolio_percentage: float
  32. copy_mode: str
  33. max_leverage: float
  34. target_positions: int
  35. our_positions: int
  36. tracked_positions: int
  37. copied_trades: int
  38. session_start_time: Optional[datetime]
  39. session_duration_hours: Optional[float]
  40. last_check: Optional[datetime]
  41. class CopyTradingConfig(BaseModel):
  42. target_address: str = Field(..., description="Target trader address to copy")
  43. portfolio_percentage: float = Field(default=0.1, ge=0.01, le=0.5, description="Portfolio percentage (1-50%)")
  44. copy_mode: str = Field(default="FIXED", description="Copy mode: FIXED or PROPORTIONAL")
  45. max_leverage: float = Field(default=10.0, ge=1.0, le=20.0, description="Maximum leverage (1-20x)")
  46. min_position_size: float = Field(default=25.0, ge=10.0, description="Minimum position size in USD")
  47. execution_delay: float = Field(default=2.0, ge=0.0, le=10.0, description="Execution delay in seconds")
  48. notifications_enabled: bool = Field(default=True, description="Enable notifications")
  49. class AccountAnalysisRequest(BaseModel):
  50. addresses: List[str] = Field(..., description="List of addresses to analyze")
  51. limit: Optional[int] = Field(default=10, ge=1, le=50, description="Limit results")
  52. class AccountStatsResponse(BaseModel):
  53. address: str
  54. total_pnl: float
  55. win_rate: float
  56. total_trades: int
  57. avg_trade_duration_hours: float
  58. max_drawdown: float
  59. avg_position_size: float
  60. max_leverage_used: float
  61. avg_leverage_used: float
  62. trading_frequency_per_day: float
  63. risk_reward_ratio: float
  64. profit_factor: float
  65. active_positions: int
  66. current_drawdown: float
  67. last_trade_timestamp: int
  68. analysis_period_days: int
  69. is_copyable: bool
  70. copyability_reason: str
  71. unique_tokens_traded: int
  72. trading_type: str
  73. top_tokens: List[str]
  74. short_percentage: float
  75. trading_style: str
  76. buy_sell_ratio: float
  77. relative_score: Optional[float] = None
  78. class LeaderboardRequest(BaseModel):
  79. window: str = Field(default="7d", description="Time window: 1d, 7d, 30d, allTime")
  80. limit: int = Field(default=10, ge=1, le=50, description="Number of top accounts")
  81. class BalanceTestResponse(BaseModel):
  82. our_balance: float
  83. target_balance: float
  84. portfolio_percentage: float
  85. test_price: float
  86. test_leverage: float
  87. margin_to_use: float
  88. position_value: float
  89. token_amount: float
  90. min_position_size: float
  91. would_execute: bool
  92. config_enabled: bool
  93. state_enabled: bool
  94. error: Optional[str] = None
  95. class SingleAccountAnalysisRequest(BaseModel):
  96. address: str = Field(..., description="Address to analyze in detail")
  97. # Global instances
  98. copy_trading_monitor: Optional[CopyTradingMonitor] = None
  99. state_manager = CopyTradingStateManager()
  100. def get_copy_trading_monitor():
  101. """Get or create copy trading monitor instance"""
  102. global copy_trading_monitor
  103. if copy_trading_monitor is None:
  104. try:
  105. config = Config()
  106. client = HyperliquidClient()
  107. notification_manager = NotificationManager()
  108. copy_trading_monitor = CopyTradingMonitor(client, notification_manager)
  109. except Exception as e:
  110. logger.error(f"Failed to create copy trading monitor: {e}")
  111. raise HTTPException(status_code=500, detail="Failed to initialize copy trading system")
  112. return copy_trading_monitor
  113. @router.get("/status", response_model=CopyTradingStatus)
  114. async def get_copy_trading_status():
  115. """Get current copy trading status"""
  116. try:
  117. monitor = get_copy_trading_monitor()
  118. status = monitor.get_status()
  119. return CopyTradingStatus(
  120. enabled=status['enabled'],
  121. target_address=status['target_address'],
  122. portfolio_percentage=status['portfolio_percentage'],
  123. copy_mode=status['copy_mode'],
  124. max_leverage=status['max_leverage'],
  125. target_positions=status['target_positions'],
  126. our_positions=status['our_positions'],
  127. tracked_positions=status['tracked_positions'],
  128. copied_trades=status['copied_trades'],
  129. session_start_time=status['session_start_time'],
  130. session_duration_hours=status['session_duration_hours'],
  131. last_check=status['last_check']
  132. )
  133. except Exception as e:
  134. logger.error(f"Error getting copy trading status: {e}")
  135. raise HTTPException(status_code=500, detail=str(e))
  136. @router.post("/start")
  137. async def start_copy_trading(config: CopyTradingConfig, background_tasks: BackgroundTasks):
  138. """Start copy trading with the specified configuration"""
  139. try:
  140. monitor = get_copy_trading_monitor()
  141. # Update configuration
  142. monitor.target_address = config.target_address
  143. monitor.portfolio_percentage = config.portfolio_percentage
  144. monitor.copy_mode = config.copy_mode
  145. monitor.max_leverage = config.max_leverage
  146. monitor.min_position_size = config.min_position_size
  147. monitor.execution_delay = config.execution_delay
  148. monitor.notifications_enabled = config.notifications_enabled
  149. monitor.enabled = True
  150. # Start monitoring in background
  151. background_tasks.add_task(monitor.start_monitoring)
  152. return {"message": "Copy trading started successfully", "target_address": config.target_address}
  153. except Exception as e:
  154. logger.error(f"Error starting copy trading: {e}")
  155. raise HTTPException(status_code=500, detail=str(e))
  156. @router.post("/stop")
  157. async def stop_copy_trading():
  158. """Stop copy trading"""
  159. try:
  160. monitor = get_copy_trading_monitor()
  161. await monitor.stop_monitoring()
  162. return {"message": "Copy trading stopped successfully"}
  163. except Exception as e:
  164. logger.error(f"Error stopping copy trading: {e}")
  165. raise HTTPException(status_code=500, detail=str(e))
  166. @router.get("/config")
  167. async def get_copy_trading_config():
  168. """Get current copy trading configuration"""
  169. try:
  170. config = Config()
  171. return {
  172. "target_address": config.COPY_TRADING_TARGET_ADDRESS,
  173. "portfolio_percentage": config.COPY_TRADING_PORTFOLIO_PERCENTAGE,
  174. "copy_mode": config.COPY_TRADING_MODE,
  175. "max_leverage": config.COPY_TRADING_MAX_LEVERAGE,
  176. "min_position_size": config.COPY_TRADING_MIN_POSITION_SIZE,
  177. "execution_delay": config.COPY_TRADING_EXECUTION_DELAY,
  178. "notifications_enabled": config.COPY_TRADING_NOTIFICATIONS
  179. }
  180. except Exception as e:
  181. logger.error(f"Error getting copy trading config: {e}")
  182. raise HTTPException(status_code=500, detail=str(e))
  183. @router.get("/test-balance", response_model=BalanceTestResponse)
  184. async def test_balance_fetching():
  185. """Test balance fetching and position sizing for debugging"""
  186. try:
  187. monitor = get_copy_trading_monitor()
  188. result = await monitor.test_balance_fetching()
  189. if 'error' in result:
  190. return BalanceTestResponse(
  191. our_balance=0,
  192. target_balance=0,
  193. portfolio_percentage=0,
  194. test_price=0,
  195. test_leverage=0,
  196. margin_to_use=0,
  197. position_value=0,
  198. token_amount=0,
  199. min_position_size=0,
  200. would_execute=False,
  201. config_enabled=False,
  202. state_enabled=False,
  203. error=result['error']
  204. )
  205. return BalanceTestResponse(**result)
  206. except Exception as e:
  207. logger.error(f"Error testing balance: {e}")
  208. raise HTTPException(status_code=500, detail=str(e))
  209. @router.post("/analyze-accounts", response_model=List[AccountStatsResponse])
  210. async def analyze_accounts(request: AccountAnalysisRequest):
  211. """Analyze multiple Hyperliquid accounts for copy trading suitability"""
  212. try:
  213. async with HyperliquidAccountAnalyzer() as analyzer:
  214. results = await analyzer.analyze_multiple_accounts(request.addresses)
  215. if not results:
  216. return []
  217. # Calculate relative scores
  218. def calculate_relative_score(stats: AccountStats, all_stats: List[AccountStats]) -> float:
  219. """Calculate relative score for ranking accounts"""
  220. score = 0.0
  221. # Copyability (35% weight)
  222. if stats.trading_frequency_per_day > 50:
  223. score += 0 # HFT bots get 0
  224. elif stats.trading_frequency_per_day < 1:
  225. score += 5 # Inactive accounts get 5
  226. elif 1 <= stats.trading_frequency_per_day <= 20:
  227. ideal_freq = 15
  228. freq_distance = abs(stats.trading_frequency_per_day - ideal_freq)
  229. score += max(0, 35 - (freq_distance * 1.5))
  230. else:
  231. score += 15 # Questionable frequency
  232. # Profitability (30% weight)
  233. if stats.total_pnl < 0:
  234. pnl_range = max([s.total_pnl for s in all_stats]) - min([s.total_pnl for s in all_stats])
  235. if pnl_range > 0:
  236. loss_severity = abs(stats.total_pnl) / pnl_range
  237. score += -15 * loss_severity
  238. else:
  239. score += -15
  240. elif stats.total_pnl == 0:
  241. score += 0
  242. else:
  243. max_pnl = max([s.total_pnl for s in all_stats if s.total_pnl > 0], default=1)
  244. score += (stats.total_pnl / max_pnl) * 30
  245. # Risk management (20% weight)
  246. if stats.max_drawdown > 0.5:
  247. score += -10
  248. elif stats.max_drawdown > 0.25:
  249. score += -5
  250. elif stats.max_drawdown > 0.15:
  251. score += 5
  252. elif stats.max_drawdown > 0.05:
  253. score += 15
  254. else:
  255. score += 20
  256. # Account maturity (10% weight)
  257. if stats.analysis_period_days < 7:
  258. score += 0
  259. elif stats.analysis_period_days < 14:
  260. score += 2
  261. elif stats.analysis_period_days < 30:
  262. score += 5
  263. else:
  264. max_age = max([s.analysis_period_days for s in all_stats])
  265. age_ratio = min(1.0, (stats.analysis_period_days - 30) / max(1, max_age - 30))
  266. score += 7 + (age_ratio * 3)
  267. # Win rate (5% weight)
  268. win_rates = [s.win_rate for s in all_stats]
  269. if max(win_rates) > min(win_rates):
  270. winrate_normalized = (stats.win_rate - min(win_rates)) / (max(win_rates) - min(win_rates))
  271. score += winrate_normalized * 5
  272. else:
  273. score += 2.5
  274. return score
  275. # Convert to response format with relative scores
  276. response_list = []
  277. for stats in results:
  278. relative_score = calculate_relative_score(stats, results)
  279. response_list.append(AccountStatsResponse(
  280. address=stats.address,
  281. total_pnl=stats.total_pnl,
  282. win_rate=stats.win_rate,
  283. total_trades=stats.total_trades,
  284. avg_trade_duration_hours=stats.avg_trade_duration_hours,
  285. max_drawdown=stats.max_drawdown,
  286. avg_position_size=stats.avg_position_size,
  287. max_leverage_used=stats.max_leverage_used,
  288. avg_leverage_used=stats.avg_leverage_used,
  289. trading_frequency_per_day=stats.trading_frequency_per_day,
  290. risk_reward_ratio=stats.risk_reward_ratio,
  291. profit_factor=stats.profit_factor,
  292. active_positions=stats.active_positions,
  293. current_drawdown=stats.current_drawdown,
  294. last_trade_timestamp=stats.last_trade_timestamp,
  295. analysis_period_days=stats.analysis_period_days,
  296. is_copyable=stats.is_copyable,
  297. copyability_reason=stats.copyability_reason,
  298. unique_tokens_traded=stats.unique_tokens_traded,
  299. trading_type=stats.trading_type,
  300. top_tokens=stats.top_tokens,
  301. short_percentage=stats.short_percentage,
  302. trading_style=stats.trading_style,
  303. buy_sell_ratio=stats.buy_sell_ratio,
  304. relative_score=relative_score
  305. ))
  306. # Sort by relative score
  307. response_list.sort(key=lambda x: x.relative_score or 0, reverse=True)
  308. return response_list[:request.limit]
  309. except Exception as e:
  310. logger.error(f"Error analyzing accounts: {e}")
  311. raise HTTPException(status_code=500, detail=str(e))
  312. @router.post("/get-leaderboard", response_model=List[str])
  313. async def get_leaderboard_accounts(request: LeaderboardRequest):
  314. """Get top accounts from curated leaderboard"""
  315. try:
  316. async with HyperliquidAccountAnalyzer() as analyzer:
  317. addresses = await analyzer.get_top_accounts_from_leaderboard(request.window, request.limit)
  318. return addresses or []
  319. except Exception as e:
  320. logger.error(f"Error getting leaderboard: {e}")
  321. raise HTTPException(status_code=500, detail=str(e))
  322. @router.get("/session-info")
  323. async def get_session_info():
  324. """Get detailed session information"""
  325. try:
  326. session_info = state_manager.get_session_info()
  327. return session_info
  328. except Exception as e:
  329. logger.error(f"Error getting session info: {e}")
  330. raise HTTPException(status_code=500, detail=str(e))
  331. @router.post("/reset-state")
  332. async def reset_copy_trading_state():
  333. """Reset copy trading state (use with caution)"""
  334. try:
  335. state_manager.reset_state()
  336. return {"message": "Copy trading state reset successfully"}
  337. except Exception as e:
  338. logger.error(f"Error resetting state: {e}")
  339. raise HTTPException(status_code=500, detail=str(e))
  340. @router.post("/analyze-single-account", response_model=Dict[str, Any])
  341. async def analyze_single_account(request: SingleAccountAnalysisRequest):
  342. """Get detailed analysis for a single account including current positions and recent trades"""
  343. try:
  344. async with HyperliquidAccountAnalyzer() as analyzer:
  345. # Get the account stats
  346. stats = await analyzer.analyze_account(request.address)
  347. if not stats:
  348. raise HTTPException(status_code=404, detail="Account not found or no trading data")
  349. # Get current positions and recent trades
  350. account_state = await analyzer.get_account_state(request.address)
  351. recent_fills = await analyzer.get_user_fills(request.address, limit=20)
  352. # Parse current positions
  353. current_positions = []
  354. if account_state:
  355. positions = analyzer.parse_positions(account_state)
  356. for pos in positions:
  357. side_emoji = "📈" if pos.side == "long" else "📉"
  358. pnl_emoji = "✅" if pos.unrealized_pnl >= 0 else "❌"
  359. current_positions.append({
  360. "coin": pos.coin,
  361. "side": pos.side,
  362. "side_emoji": side_emoji,
  363. "size": pos.size,
  364. "entry_price": pos.entry_price,
  365. "mark_price": pos.mark_price,
  366. "unrealized_pnl": pos.unrealized_pnl,
  367. "pnl_emoji": pnl_emoji,
  368. "leverage": pos.leverage,
  369. "margin_used": pos.margin_used,
  370. "position_value": abs(pos.size * pos.mark_price)
  371. })
  372. # Parse recent trades
  373. recent_trades = []
  374. if recent_fills:
  375. trades = analyzer.parse_trades(recent_fills)
  376. for trade in trades[-10:]: # Last 10 trades
  377. side_emoji = "🟢" if trade.side == "buy" else "🔴"
  378. trade_time = datetime.fromtimestamp(trade.timestamp / 1000)
  379. recent_trades.append({
  380. "coin": trade.coin,
  381. "side": trade.side,
  382. "side_emoji": side_emoji,
  383. "size": trade.size,
  384. "price": trade.price,
  385. "value": trade.size * trade.price,
  386. "fee": trade.fee,
  387. "timestamp": trade_time.strftime('%Y-%m-%d %H:%M:%S'),
  388. "is_maker": trade.is_maker
  389. })
  390. # Calculate total position value and unrealized PnL
  391. total_position_value = sum(pos["position_value"] for pos in current_positions)
  392. total_unrealized_pnl = sum(pos["unrealized_pnl"] for pos in current_positions)
  393. # Convert stats to dict and add extra details
  394. stats_dict = {
  395. "address": stats.address,
  396. "total_pnl": stats.total_pnl,
  397. "win_rate": stats.win_rate,
  398. "total_trades": stats.total_trades,
  399. "avg_trade_duration_hours": stats.avg_trade_duration_hours,
  400. "max_drawdown": stats.max_drawdown,
  401. "avg_position_size": stats.avg_position_size,
  402. "max_leverage_used": stats.max_leverage_used,
  403. "avg_leverage_used": stats.avg_leverage_used,
  404. "trading_frequency_per_day": stats.trading_frequency_per_day,
  405. "risk_reward_ratio": stats.risk_reward_ratio,
  406. "profit_factor": stats.profit_factor,
  407. "active_positions": stats.active_positions,
  408. "current_drawdown": stats.current_drawdown,
  409. "last_trade_timestamp": stats.last_trade_timestamp,
  410. "analysis_period_days": stats.analysis_period_days,
  411. "is_copyable": stats.is_copyable,
  412. "copyability_reason": stats.copyability_reason,
  413. "unique_tokens_traded": stats.unique_tokens_traded,
  414. "trading_type": stats.trading_type,
  415. "top_tokens": stats.top_tokens,
  416. "short_percentage": stats.short_percentage,
  417. "trading_style": stats.trading_style,
  418. "buy_sell_ratio": stats.buy_sell_ratio
  419. }
  420. # Calculate relative score
  421. def calculate_single_account_score(stats):
  422. score = 0.0
  423. # Copyability (35% weight)
  424. if stats.trading_frequency_per_day > 50:
  425. score += 0
  426. elif stats.trading_frequency_per_day < 1:
  427. score += 5
  428. elif 1 <= stats.trading_frequency_per_day <= 20:
  429. ideal_freq = 15
  430. freq_distance = abs(stats.trading_frequency_per_day - ideal_freq)
  431. score += max(0, 35 - (freq_distance * 1.5))
  432. else:
  433. score += 15
  434. # Profitability (30% weight)
  435. if stats.total_pnl < 0:
  436. score += -15
  437. elif stats.total_pnl == 0:
  438. score += 0
  439. else:
  440. # Score based on PnL magnitude (assuming $10k is excellent)
  441. score += min(30, (stats.total_pnl / 10000) * 30)
  442. # Risk management (20% weight)
  443. if stats.max_drawdown > 0.5:
  444. score += -10
  445. elif stats.max_drawdown > 0.25:
  446. score += -5
  447. elif stats.max_drawdown > 0.15:
  448. score += 5
  449. elif stats.max_drawdown > 0.05:
  450. score += 15
  451. else:
  452. score += 20
  453. # Account maturity (10% weight)
  454. if stats.analysis_period_days < 7:
  455. score += 0
  456. elif stats.analysis_period_days < 14:
  457. score += 2
  458. elif stats.analysis_period_days < 30:
  459. score += 5
  460. else:
  461. score += 7 + min(3, (stats.analysis_period_days - 30) / 30)
  462. # Win rate (5% weight)
  463. score += stats.win_rate * 5
  464. return score
  465. relative_score = calculate_single_account_score(stats)
  466. stats_dict["relative_score"] = relative_score
  467. # Determine recommendation
  468. if relative_score >= 60:
  469. recommendation = "🟢 HIGHLY RECOMMENDED"
  470. portfolio_allocation = "10-25% (confident allocation)"
  471. max_leverage_limit = "5-10x"
  472. elif relative_score >= 40:
  473. recommendation = "🟡 MODERATELY RECOMMENDED"
  474. portfolio_allocation = "5-15% (moderate allocation)"
  475. max_leverage_limit = "3-5x"
  476. elif relative_score >= 20:
  477. recommendation = "🟠 PROCEED WITH CAUTION"
  478. portfolio_allocation = "2-5% (very small allocation)"
  479. max_leverage_limit = "2-3x"
  480. elif relative_score >= 0:
  481. recommendation = "🔴 NOT RECOMMENDED"
  482. portfolio_allocation = "DO NOT COPY (Risky)"
  483. max_leverage_limit = "N/A"
  484. else:
  485. recommendation = "⛔ DANGEROUS"
  486. portfolio_allocation = "DO NOT COPY (Negative Score)"
  487. max_leverage_limit = "N/A"
  488. # Evaluation points
  489. evaluation = []
  490. is_hft_pattern = stats.trading_frequency_per_day > 50
  491. is_copyable = 1 <= stats.trading_frequency_per_day <= 20
  492. if is_hft_pattern:
  493. evaluation.append("❌ HFT/Bot pattern detected")
  494. elif stats.trading_frequency_per_day < 1:
  495. evaluation.append("❌ Too inactive for copy trading")
  496. elif is_copyable:
  497. evaluation.append("✅ Human-like trading pattern")
  498. if stats.total_pnl > 0:
  499. evaluation.append("✅ Profitable track record")
  500. else:
  501. evaluation.append("❌ Not profitable")
  502. if stats.max_drawdown < 0.15:
  503. evaluation.append("✅ Good risk management")
  504. elif stats.max_drawdown < 0.25:
  505. evaluation.append("⚠️ Moderate risk")
  506. else:
  507. evaluation.append("❌ High risk (excessive drawdown)")
  508. if 2 <= stats.avg_trade_duration_hours <= 48:
  509. evaluation.append("✅ Suitable trade duration")
  510. elif stats.avg_trade_duration_hours < 2:
  511. evaluation.append("⚠️ Very short trades (scalping)")
  512. else:
  513. evaluation.append("⚠️ Long hold times")
  514. return {
  515. "stats": stats_dict,
  516. "current_positions": current_positions,
  517. "recent_trades": recent_trades,
  518. "position_summary": {
  519. "total_position_value": total_position_value,
  520. "total_unrealized_pnl": total_unrealized_pnl,
  521. "position_count": len(current_positions)
  522. },
  523. "recommendation": {
  524. "overall": recommendation,
  525. "portfolio_allocation": portfolio_allocation,
  526. "max_leverage_limit": max_leverage_limit,
  527. "evaluation_points": evaluation
  528. },
  529. "trading_type_display": {
  530. "perps": "🔄 Perpetuals",
  531. "spot": "💱 Spot Trading",
  532. "mixed": "🔀 Mixed (Spot + Perps)",
  533. "unknown": "❓ Unknown"
  534. }.get(stats.trading_type, f"❓ {stats.trading_type}"),
  535. "buy_sell_ratio_display": "∞ (only buys)" if stats.buy_sell_ratio == float('inf') else "0 (only sells)" if stats.buy_sell_ratio == 0 else f"{stats.buy_sell_ratio:.2f}"
  536. }
  537. except Exception as e:
  538. logger.error(f"Error analyzing single account {request.address}: {e}")
  539. raise HTTPException(status_code=500, detail=str(e))