hyperliquid-bot / hbot.py
Daniel Alnawaz
claude update
5a5dd15 unverified
Raw History Blame Contribute Delete
11.4 kB
#!/usr/bin/env python3
"""Hyperliquid API client β€” candle fetching, order placement, and live execution helpers."""
import math
import os
import time
import logging
import requests
from typing import Dict, Optional, List, Any
import urllib3
import certifi
logger = logging.getLogger(__name__)
class HyperliquidAPI:
def __init__(
self,
verify_ssl=True,
min_request_interval_ms: int = 250,
max_retries: int = 5,
max_backoff_seconds: int = 30,
):
self.base_url = "https://api.hyperliquid.xyz"
self.verify_ssl = verify_ssl
self.min_request_interval_ms = max(0, int(min_request_interval_ms))
self.base_min_request_interval_ms = self.min_request_interval_ms
self.max_retries = max(1, int(max_retries))
self.max_backoff_seconds = max(1, int(max_backoff_seconds))
self._last_request_ts = 0.0
self.session = requests.Session()
self.session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'HyperliquidBot/1.0'
})
if not self.verify_ssl:
self._disable_ssl_verification()
else:
self.session.verify = self._get_ca_bundle()
def _disable_ssl_verification(self):
"""Disable SSL verification for the session permanently"""
self.verify_ssl = False
self.session.verify = False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger.debug("SSL verification disabled for this session.")
def _get_ca_bundle(self):
cert_paths = [
'/etc/ssl/certs/ca-certificates.crt',
'/etc/ssl/ca-bundle.pem',
'/usr/local/etc/openssl/cert.pem',
'/opt/homebrew/etc/openssl/cert.pem',
]
for path in cert_paths:
if os.path.exists(path):
return path
return True
def _make_request(self, endpoint: str, data: dict) -> dict:
url = f"{self.base_url}/{endpoint}"
for attempt in range(self.max_retries):
try:
# Global pacing
elapsed_ms = (time.time() - self._last_request_ts) * 1000.0
if elapsed_ms < self.min_request_interval_ms:
time.sleep((self.min_request_interval_ms - elapsed_ms) / 1000.0)
# Explicitly pass verify to ensure it's respected
response = self.session.post(url, json=data, timeout=30, verify=self.session.verify)
self._last_request_ts = time.time()
response.raise_for_status()
return response.json()
except (requests.exceptions.SSLError, requests.exceptions.ConnectionError) as e:
if self.verify_ssl:
logger.warning(f"SSL/Connection failure. Retrying with verification disabled... Error: {e}")
self._disable_ssl_verification()
continue
logger.error(f"Request failed: {e}")
raise
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
time.sleep(min(self.max_backoff_seconds, 2 ** (attempt + 1)))
continue
raise
except Exception as e:
if attempt == self.max_retries - 1:
raise
time.sleep(min(self.max_backoff_seconds, 2 ** attempt))
def get_meta(self) -> Dict[str, Any]:
return self._make_request("info", {"type": "meta"})
def get_candles(self, coin: str, interval: str, start_time: int) -> List[dict]:
try:
data = {"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": start_time}}
return self._make_request("info", data)
except Exception as e:
logger.error(f"Error fetching candles for {coin}: {e}")
return []
def get_asset_ctx(self, coin: str) -> Optional[Dict[str, Any]]:
"""Return the perp asset context for coin (funding rate, open interest, mark price, etc.)."""
try:
data = self._make_request("info", {"type": "metaAndAssetCtxs"})
meta, ctxs = data[0], data[1]
for asset, ctx in zip(meta.get("universe", []), ctxs):
if asset.get("name") == coin:
return ctx
except Exception as e:
logger.warning(f"Failed to fetch asset ctx for {coin}: {e}")
return None
def create_live_execution_clients(self, private_key: str, account_address: Optional[str] = None, timeout: float = 15.0, skip_ws: bool = True) -> Dict[str, Any]:
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
wallet = Account.from_key(private_key)
meta = self.get_meta()
info = Info(self.base_url, skip_ws=skip_ws, meta=meta)
exchange = Exchange(wallet, self.base_url, meta=meta, account_address=account_address)
# Sync SSL settings to SDK clients
for client in (info, exchange, exchange.info):
client.session.verify = self.session.verify
if not self.verify_ssl:
client.session.mount('https://', requests.adapters.HTTPAdapter(pool_connections=10, pool_maxsize=10))
return {'wallet': wallet, 'info': info, 'exchange': exchange}
def get_sz_decimals(self, coin: str) -> int:
"""Return the number of decimal places allowed for order SIZE on this coin."""
try:
meta = self.get_meta()
for asset in meta.get("universe", []):
if asset.get("name") == coin:
return int(asset.get("szDecimals", 4))
except Exception:
pass
return 4 # safe fallback
def get_px_decimals(self, coin: str) -> int:
"""
Return the number of decimal places allowed for order PRICE on this coin.
Uses allMids for the current mid price (universeContext is not present
in the perps meta response).
Rule: max_decimals = max(0, 5 - floor(log10(price)) - 1)
Examples:
BTC ~95000 β†’ 1 decimal
ETH ~3500 β†’ 2 decimals
SOL ~90 β†’ 2 decimals
TIA ~5 β†’ 4 decimals
DOGE ~0.15 β†’ 5 decimals
"""
try:
all_mids = self._make_request("info", {"type": "allMids"})
mid_px = float(all_mids.get(coin, 0))
if mid_px > 0:
mag = math.floor(math.log10(mid_px))
return max(0, 5 - mag - 1)
except Exception:
pass
return 2 # safe fallback for mid-range coins like SOL
def place_market_order(
self,
exchange: Any,
coin: str,
is_buy: bool,
sz: float,
px: float,
sl: Optional[float] = None,
tp: Optional[float] = None,
limit_sl: bool = False,
) -> Any:
"""
Taker entry (IOC) + exchange-native SL/TP.
sl_type:
limit_sl=False β†’ SL is market trigger (taker, guaranteed fill)
limit_sl=True β†’ SL is limit trigger (maker, may not fill on gap)
Fee profile (your account rates 0.0432% taker / 0.0144% maker):
Hype/Scalp: taker entry + limit TP + market SL
win: 0.0432 + 0.0144 = 0.0576% round-trip
loss: 0.0432 + 0.0432 = 0.0864% round-trip
"""
sz_dec = self.get_sz_decimals(coin)
px_dec = self.get_px_decimals(coin)
sz = round(float(sz), sz_dec)
if sz <= 0:
logger.error(f"Size rounded to zero for {coin} (sz_dec={sz_dec}). Skipping.")
return None
# ── Check notional after rounding ───────────────────────────────────
notional_after_rounding = sz * px
if notional_after_rounding < 10.0:
logger.error(
f"[{coin}] Notional ${notional_after_rounding:.2f} falls below $10 after rounding "
f"(sz={sz}, px={px:.{px_dec}f}). Skipping."
)
return None
limit_px = round(px * 1.02, px_dec)
logger.info(f"Placing {'BUY' if is_buy else 'SELL'} limit-IOC {sz} {coin} @ {limit_px} (mark={round(px, px_dec)}) [px_dec={px_dec} sz_dec={sz_dec}]")
try:
result = exchange.order(coin, is_buy, sz, limit_px, {"limit": {"tif": "Ioc"}})
logger.info(f"Order response for {coin}: {result}")
except Exception as e:
logger.error(f"Entry order failed: {e}")
return None
try:
statuses = result.get("response", {}).get("data", {}).get("statuses", [])
filled_sz = sum(float(s["filled"].get("totalSz", 0)) for s in statuses if "filled" in s)
errors = [s["error"] for s in statuses if "error" in s]
if errors:
logger.error(f"Entry rejected for {coin}: {errors}")
return result
if filled_sz <= 0:
logger.warning(f"Entry IOC for {coin} did not fill. Skipping SL/TP.")
return result
logger.info(f"Entry filled {filled_sz} {coin}. Placing SL/TP on exchange...")
sz = round(filled_sz, sz_dec)
except Exception as e:
logger.warning(f"Could not parse fill status for {coin}: {e}. Attempting SL/TP anyway.")
# ── Stop-Loss ─────────────────────────────────────────────────────────
if sl is not None:
try:
sl_px = round(float(sl), px_dec)
sl_side = not is_buy
is_mkt = not limit_sl
exchange.order(
coin, sl_side, sz, sl_px,
{"trigger": {"triggerPx": sl_px, "isMarket": is_mkt, "tpsl": "sl"}},
reduce_only=True,
)
sl_type = "market trigger" if is_mkt else "limit trigger"
logger.info(f"SL placed for {coin} @ {sl_px} ({sl_type})")
except Exception as e:
logger.error(f"SL order failed for {coin}: {e}")
# ── Take-Profit (limit trigger β€” maker exit) ──────────────────────────
if tp is not None:
try:
tp_px = round(float(tp), px_dec)
tp_side = not is_buy
exchange.order(
coin, tp_side, sz, tp_px,
{"trigger": {"triggerPx": tp_px, "isMarket": False, "tpsl": "tp"}},
reduce_only=True,
)
logger.info(f"TP placed for {coin} @ {tp_px} (limit trigger β€” maker exit)")
except Exception as e:
logger.error(f"TP order failed for {coin}: {e}")
return result