import asyncio import logging from functools import partial from eth_account import Account from hyperliquid.exchange import Exchange from hyperliquid.info import Info logger = logging.getLogger(__name__) HL_MAINNET = "https://api.hyperliquid.xyz" # Coin name mapping: our asset -> Hyperliquid coin COIN_MAP = { "BTC": "BTC", "ETH": "ETH", } class HyperliquidTrader: def __init__(self, private_key: str): self.account = Account.from_key(private_key) self.exchange = Exchange(self.account, HL_MAINNET) self.info = Info(HL_MAINNET) self._loop = asyncio.get_event_loop() # ── helpers ────────────────────────────────────────────────────────────── async def _run_sync(self, fn, *args, **kwargs): """Run a blocking SDK call in the default thread pool.""" return await self._loop.run_in_executor(None, partial(fn, *args, **kwargs)) def _wallet(self) -> str: return self.account.address # ── public interface ───────────────────────────────────────────────────── async def get_balance(self) -> float: """Return USDC balance (withdrawable).""" try: state = await self._run_sync( self.info.user_state, self._wallet() ) return float(state.get("withdrawable", 0)) except Exception as exc: logger.error("get_balance error: %s", exc) return 0.0 async def open_position(self, asset: str, side: str, size_usd: float) -> dict: """Place a market order. Args: asset: "BTC" or "ETH" side: "long" (buy) or "short" (sell) size_usd: notional size in USD Returns: {"order_id": str, "fill_price": float} """ coin = COIN_MAP.get(asset.upper(), asset.upper()) is_buy = side.lower() == "long" try: # Get current price to compute size in coins meta = await self._run_sync(self.info.meta) universe = {u["name"]: u for u in meta.get("universe", [])} coin_meta = universe.get(coin, {}) sz_decimals = int(coin_meta.get("szDecimals", 3)) # Use mid-price approximation from user state all_mids = await self._run_sync(self.info.all_mids) mid_price = float(all_mids.get(coin, 0)) if mid_price <= 0: raise ValueError(f"Could not get mid price for {coin}") size_coins = round(size_usd / mid_price, sz_decimals) # Slippage: 1% for longs (limit above mid), 1% for shorts (limit below mid) slippage = 0.01 if is_buy: limit_px = round(mid_price * (1 + slippage), 1) else: limit_px = round(mid_price * (1 - slippage), 1) order_result = await self._run_sync( self.exchange.order, coin, is_buy, size_coins, limit_px, {"limit": {"tif": "Ioc"}}, # IOC ~ market ) status = order_result.get("response", {}).get("data", {}) filled = status.get("statuses", [{}])[0] fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price) order_id = str(filled.get("resting", {}).get("oid", "")) logger.info( "Opened %s %s position: size=%.4f coins @ %.2f", side, coin, size_coins, fill_price, ) return {"order_id": order_id, "fill_price": fill_price} except Exception as exc: logger.error("open_position error: %s", exc) raise async def close_position(self, asset: str) -> dict: """Close all open positions for the given asset. Returns: {"fill_price": float} """ coin = COIN_MAP.get(asset.upper(), asset.upper()) try: positions = await self.get_open_positions() target = next( (p for p in positions if p.get("coin") == coin), None ) if target is None: logger.warning("No open position found for %s", coin) return {"fill_price": 0.0} szi = float(target.get("szi", 0)) is_buy = szi < 0 # closing a short means buying all_mids = await self._run_sync(self.info.all_mids) mid_price = float(all_mids.get(coin, 0)) slippage = 0.01 if is_buy: limit_px = round(mid_price * (1 + slippage), 1) else: limit_px = round(mid_price * (1 - slippage), 1) close_result = await self._run_sync( self.exchange.order, coin, is_buy, abs(szi), limit_px, {"limit": {"tif": "Ioc"}}, reduce_only=True, ) status = close_result.get("response", {}).get("data", {}) filled = status.get("statuses", [{}])[0] fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price) logger.info("Closed %s position @ %.2f", coin, fill_price) return {"fill_price": fill_price} except Exception as exc: logger.error("close_position error: %s", exc) raise async def get_open_positions(self) -> list: """Return list of open positions from Hyperliquid.""" try: state = await self._run_sync(self.info.user_state, self._wallet()) positions = [] for asset_pos in state.get("assetPositions", []): pos = asset_pos.get("position", {}) szi = float(pos.get("szi", 0)) if szi != 0: positions.append({ "coin": pos.get("coin"), "szi": szi, "entry_px": float(pos.get("entryPx", 0) or 0), "unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0), }) return positions except Exception as exc: logger.error("get_open_positions error: %s", exc) return []