Spaces:
Running
Running
Download hbot.py from chigginrice/hyperliquid-bot: direct link, hf CLI and curl.
- Browser
- Download file 11.4 kB
-
https://huggingface.co/spaces/chigginrice/hyperliquid-bot/resolve/main/hbot.py
- Command line
-
hf download hf://spaces/chigginrice/hyperliquid-bot/hbot.py
-
curl -L -o hbot.py https://huggingface.co/spaces/chigginrice/hyperliquid-bot/resolve/main/hbot.py
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 | |