177 lines
6.3 KiB
Python
177 lines
6.3 KiB
Python
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 []
|