done
This commit is contained in:
+201
-133
@@ -1,6 +1,19 @@
|
||||
"""
|
||||
Hyperliquid BTC perpetual trading client.
|
||||
|
||||
KEY CONCEPT — two wallets:
|
||||
• account_address : Your MetaMask address. Holds USDC and open positions.
|
||||
Used for all info/query calls.
|
||||
• api_private_key : API wallet private key generated at app.hyperliquid.xyz/API.
|
||||
Used only for signing orders. Cannot withdraw.
|
||||
|
||||
Both are read from config (hl_account_address, hl_api_private_key).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Optional
|
||||
|
||||
from eth_account import Account
|
||||
from hyperliquid.exchange import Exchange
|
||||
@@ -8,157 +21,81 @@ from hyperliquid.info import Info
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HL_MAINNET = "https://api.hyperliquid.xyz"
|
||||
HL_MAINNET_URL = "https://api.hyperliquid.xyz"
|
||||
HL_TESTNET_URL = "https://api.hyperliquid-testnet.xyz"
|
||||
|
||||
# Coin name mapping: our asset -> Hyperliquid coin
|
||||
COIN_MAP = {
|
||||
"BTC": "BTC",
|
||||
"ETH": "ETH",
|
||||
}
|
||||
# Precision map — Hyperliquid's szDecimals for common coins
|
||||
# Fetched dynamically via meta() but cached here as fallback
|
||||
SZ_DECIMALS_FALLBACK = {"BTC": 5, "ETH": 4}
|
||||
|
||||
|
||||
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()
|
||||
def __init__(
|
||||
self,
|
||||
api_private_key: str,
|
||||
account_address: str,
|
||||
leverage: int = 3,
|
||||
mainnet: bool = True,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
api_private_key: Private key of the API wallet (from Hyperliquid dashboard).
|
||||
account_address: Main MetaMask wallet address (holds USDC + positions).
|
||||
leverage: Isolated leverage to set before each trade.
|
||||
mainnet: True = mainnet, False = testnet.
|
||||
"""
|
||||
self._api_wallet = Account.from_key(api_private_key)
|
||||
self._account_address = account_address.lower()
|
||||
self._leverage = leverage
|
||||
self._base_url = HL_MAINNET_URL if mainnet else HL_TESTNET_URL
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
# Exchange signs with api_wallet but acts on behalf of account_address
|
||||
self._exchange = Exchange(
|
||||
self._api_wallet,
|
||||
self._base_url,
|
||||
account_address=self._account_address,
|
||||
)
|
||||
self._info = Info(self._base_url, skip_ws=True)
|
||||
|
||||
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))
|
||||
# ── internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _wallet(self) -> str:
|
||||
return self.account.address
|
||||
async def _run(self, fn, *args, **kwargs):
|
||||
"""Run blocking SDK calls in a thread pool without blocking the event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, partial(fn, *args, **kwargs))
|
||||
|
||||
async def _get_sz_decimals(self, coin: str) -> int:
|
||||
try:
|
||||
meta = await self._run(self._info.meta)
|
||||
for u in meta.get("universe", []):
|
||||
if u.get("name") == coin:
|
||||
return int(u.get("szDecimals", SZ_DECIMALS_FALLBACK.get(coin, 4)))
|
||||
except Exception:
|
||||
pass
|
||||
return SZ_DECIMALS_FALLBACK.get(coin, 4)
|
||||
|
||||
async def _mid_price(self, coin: str) -> float:
|
||||
mids = await self._run(self._info.all_mids)
|
||||
price = float(mids.get(coin, 0))
|
||||
if price <= 0:
|
||||
raise ValueError(f"Cannot get mid price for {coin}")
|
||||
return price
|
||||
|
||||
# ── public interface ─────────────────────────────────────────────────────
|
||||
|
||||
async def get_balance(self) -> float:
|
||||
"""Return USDC balance (withdrawable)."""
|
||||
"""Return withdrawable USDC balance of the main (MetaMask) account."""
|
||||
try:
|
||||
state = await self._run_sync(
|
||||
self.info.user_state, self._wallet()
|
||||
)
|
||||
state = await self._run(self._info.user_state, self._account_address)
|
||||
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."""
|
||||
"""Return all open positions of the main account (non-zero size only)."""
|
||||
try:
|
||||
state = await self._run_sync(self.info.user_state, self._wallet())
|
||||
state = await self._run(self._info.user_state, self._account_address)
|
||||
positions = []
|
||||
for asset_pos in state.get("assetPositions", []):
|
||||
pos = asset_pos.get("position", {})
|
||||
@@ -166,11 +103,142 @@ class HyperliquidTrader:
|
||||
if szi != 0:
|
||||
positions.append({
|
||||
"coin": pos.get("coin"),
|
||||
"szi": szi,
|
||||
"szi": szi, # positive = long, negative = short
|
||||
"entry_px": float(pos.get("entryPx", 0) or 0),
|
||||
"unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0),
|
||||
"leverage": pos.get("leverage", {}),
|
||||
})
|
||||
return positions
|
||||
except Exception as exc:
|
||||
logger.error("get_open_positions error: %s", exc)
|
||||
return []
|
||||
|
||||
async def set_leverage(self, coin: str) -> None:
|
||||
"""Set isolated leverage for the given coin."""
|
||||
try:
|
||||
await self._run(
|
||||
self._exchange.update_leverage,
|
||||
self._leverage,
|
||||
coin,
|
||||
False, # is_cross=False → isolated margin
|
||||
)
|
||||
logger.info("Set leverage %dx for %s (isolated)", self._leverage, coin)
|
||||
except Exception as exc:
|
||||
logger.warning("set_leverage error (non-fatal): %s", exc)
|
||||
|
||||
async def open_position(
|
||||
self,
|
||||
asset: str,
|
||||
side: str, # "long" or "short"
|
||||
size_usd: float,
|
||||
) -> dict:
|
||||
"""
|
||||
Open a market position.
|
||||
|
||||
Returns:
|
||||
{"order_id": str, "fill_price": float, "size_coins": float}
|
||||
"""
|
||||
coin = asset.upper()
|
||||
is_buy = side.lower() == "long"
|
||||
|
||||
# 1. Set leverage before opening
|
||||
await self.set_leverage(coin)
|
||||
|
||||
# 2. Compute coin size from USD notional
|
||||
mid = await self._mid_price(coin)
|
||||
sz_dec = await self._get_sz_decimals(coin)
|
||||
size_coins = round(size_usd / mid, sz_dec)
|
||||
if size_coins <= 0:
|
||||
raise ValueError(f"Computed size too small: {size_coins} {coin} from ${size_usd}")
|
||||
|
||||
# 3. Limit price with 1% slippage (IOC acts as market)
|
||||
slippage = 0.01
|
||||
# Hyperliquid px must be ≤5 sig-figs AND divisible by tick size.
|
||||
# For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding.
|
||||
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
|
||||
if raw_px >= 10000:
|
||||
limit_px = float(round(raw_px)) # integer dollars for BTC
|
||||
elif raw_px >= 1000:
|
||||
limit_px = round(raw_px, 1)
|
||||
elif raw_px >= 100:
|
||||
limit_px = round(raw_px, 2)
|
||||
else:
|
||||
limit_px = round(raw_px, 3)
|
||||
|
||||
logger.info(
|
||||
"Opening %s %s: %.5f coins @ limit %.2f (mid=%.2f, usd=%.2f)",
|
||||
side, coin, size_coins, limit_px, mid, size_usd,
|
||||
)
|
||||
|
||||
result = await self._run(
|
||||
self._exchange.order,
|
||||
coin,
|
||||
is_buy,
|
||||
size_coins,
|
||||
limit_px,
|
||||
{"limit": {"tif": "Ioc"}}, # Immediate-or-Cancel ≈ market
|
||||
)
|
||||
|
||||
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
|
||||
filled = statuses[0] if statuses else {}
|
||||
fill_price = float(
|
||||
(filled.get("filled") or {}).get("avgPx", mid) or mid
|
||||
)
|
||||
order_id = str((filled.get("resting") or {}).get("oid", ""))
|
||||
|
||||
logger.info("Opened %s %s @ %.2f (order_id=%s)", side, coin, fill_price, order_id)
|
||||
return {"order_id": order_id, "fill_price": fill_price, "size_coins": size_coins}
|
||||
|
||||
async def close_position(self, asset: str) -> dict:
|
||||
"""
|
||||
Close all open positions for the given asset using a reduce-only IOC order.
|
||||
|
||||
Returns:
|
||||
{"fill_price": float}
|
||||
"""
|
||||
coin = asset.upper()
|
||||
|
||||
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 %s position to close", coin)
|
||||
return {"fill_price": 0.0}
|
||||
|
||||
szi = float(target["szi"])
|
||||
is_buy = szi < 0 # closing a short → buy back
|
||||
size = abs(szi)
|
||||
|
||||
mid = await self._mid_price(coin)
|
||||
slippage = 0.01
|
||||
# Hyperliquid px must be ≤5 sig-figs AND divisible by tick size.
|
||||
# For BTC ~$76k tick=$1 (integer); for ETH ~$3k tick=$0.1. Use 5 sig-figs + coin-based rounding.
|
||||
raw_px = mid * (1 + slippage) if is_buy else mid * (1 - slippage)
|
||||
if raw_px >= 10000:
|
||||
limit_px = float(round(raw_px)) # integer dollars for BTC
|
||||
elif raw_px >= 1000:
|
||||
limit_px = round(raw_px, 1)
|
||||
elif raw_px >= 100:
|
||||
limit_px = round(raw_px, 2)
|
||||
else:
|
||||
limit_px = round(raw_px, 3)
|
||||
|
||||
logger.info("Closing %s %s: size=%.5f @ limit %.2f", coin, "short" if is_buy else "long", size, limit_px)
|
||||
|
||||
result = await self._run(
|
||||
self._exchange.order,
|
||||
coin,
|
||||
is_buy,
|
||||
size,
|
||||
limit_px,
|
||||
{"limit": {"tif": "Ioc"}},
|
||||
reduce_only=True,
|
||||
)
|
||||
|
||||
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
|
||||
filled = statuses[0] if statuses else {}
|
||||
fill_price = float(
|
||||
(filled.get("filled") or {}).get("avgPx", mid) or mid
|
||||
)
|
||||
|
||||
logger.info("Closed %s position @ %.2f", coin, fill_price)
|
||||
return {"fill_price": fill_price}
|
||||
|
||||
Reference in New Issue
Block a user