package hyperliquid import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const ( MainnetAPIURL = "https://api.hyperliquid.xyz/info" TestnetAPIURL = "https://api.hyperliquid-testnet.xyz/info" ) // Candle represents a single OHLCV candle from Hyperliquid type Candle struct { OpenTime int64 `json:"t"` // Open time in milliseconds CloseTime int64 `json:"T"` // Close time in milliseconds Symbol string `json:"s"` // Coin symbol Interval string `json:"i"` // Interval Open string `json:"o"` // Open price High string `json:"h"` // High price Low string `json:"l"` // Low price Close string `json:"c"` // Close price Volume string `json:"v"` // Volume in base unit TradeCount int `json:"n"` // Number of trades } // CandleRequest represents the request for candleSnapshot type CandleRequest struct { Type string `json:"type"` Req CandleRequestBody `json:"req"` } // CandleRequestBody represents the body of candleSnapshot request type CandleRequestBody struct { Coin string `json:"coin"` Interval string `json:"interval"` StartTime int64 `json:"startTime"` EndTime int64 `json:"endTime"` } // Client is the Hyperliquid API client type Client struct { apiURL string client *http.Client } // NewClient creates a new Hyperliquid client for mainnet func NewClient() *Client { return &Client{ apiURL: MainnetAPIURL, client: &http.Client{ Timeout: 30 * time.Second, }, } } // NewTestnetClient creates a new Hyperliquid client for testnet func NewTestnetClient() *Client { return &Client{ apiURL: TestnetAPIURL, client: &http.Client{ Timeout: 30 * time.Second, }, } } // GetCandles fetches historical candlestick data for a symbol // coin: symbol name (e.g., "BTC", "TSLA", "AAPL", "xyz:TSLA") // interval: "1m", "5m", "15m", "1h", "4h", "1d" // limit: number of candles to fetch (max 5000) func (c *Client) GetCandles(ctx context.Context, coin string, interval string, limit int) ([]Candle, error) { // Format coin name for API (stock perps need xyz: prefix) coin = FormatCoinForAPI(coin) // Calculate time range based on interval and limit now := time.Now() endTime := now.UnixMilli() // Calculate start time based on interval intervalDuration := getIntervalDuration(interval) startTime := now.Add(-intervalDuration * time.Duration(limit)).UnixMilli() // Build request reqBody := CandleRequest{ Type: "candleSnapshot", Req: CandleRequestBody{ Coin: coin, Interval: interval, StartTime: startTime, EndTime: endTime, }, } jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } // Create request req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // Execute request resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // Check status code if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) } // Parse response var candles []Candle if err := json.Unmarshal(body, &candles); err != nil { return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body)) } return candles, nil } // GetAllMids fetches current mid prices for all assets (default perp dex) func (c *Client) GetAllMids(ctx context.Context) (map[string]string, error) { return c.GetAllMidsWithDex(ctx, "") } // GetAllMidsXYZ fetches current mid prices for xyz dex (stocks, forex, commodities) func (c *Client) GetAllMidsXYZ(ctx context.Context) (map[string]string, error) { return c.GetAllMidsWithDex(ctx, XYZDex) } // GetAllMidsWithDex fetches current mid prices for a specific dex func (c *Client) GetAllMidsWithDex(ctx context.Context, dex string) (map[string]string, error) { reqBody := map[string]string{"type": "allMids"} if dex != "" { reqBody["dex"] = dex } jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) } var mids map[string]string if err := json.Unmarshal(body, &mids); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return mids, nil } // GetMeta fetches metadata for all perpetual assets func (c *Client) GetMeta(ctx context.Context) (*Meta, error) { reqBody := map[string]string{"type": "meta"} jsonBody, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) } var meta Meta if err := json.Unmarshal(body, &meta); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } return &meta, nil } // Meta represents the metadata response type Meta struct { Universe []AssetInfo `json:"universe"` } // AssetInfo represents information about a single asset type AssetInfo struct { Name string `json:"name"` SzDecimals int `json:"szDecimals"` MaxLeverage int `json:"maxLeverage"` } // NormalizeCoin normalizes coin name for Hyperliquid API // Examples: // - "BTCUSDT" -> "BTC" // - "TSLA-USDC" -> "TSLA" // - "TESLA-USDC" -> "TSLA" // - "SAMSUNG-USDC" -> "SMSN" // - "xyz:TSLA" -> "TSLA" // - "BTC" -> "BTC" func NormalizeCoin(symbol string) string { return NormalizeCoinBase(symbol) } // XYZDisplayNameToCoin maps user-facing product labels back to Hyperliquid xyz coin names. // Hyperliquid routes candles/orders by short names (for example xyz:SMSN), while NOFX // shows full names (for example SAMSUNG-USDC) in the UI. var XYZDisplayNameToCoin = map[string]string{ "TESLA": "TSLA", "NVIDIA": "NVDA", "ROBINHOOD": "HOOD", "INTEL": "INTC", "PALANTIR": "PLTR", "COINBASE": "COIN", "APPLE": "AAPL", "MICROSOFT": "MSFT", "ORACLE": "ORCL", "GOOGLE": "GOOGL", "ALPHABET": "GOOGL", "AMAZON": "AMZN", "MICRON": "MU", "SANDISK": "SNDK", "MICROSTRATEGY": "MSTR", "CIRCLE": "CRCL", "NETFLIX": "NFLX", "COSTCO": "COST", "ELI-LILLY": "LLY", "SK-HYNIX": "SKHX", "SKHYNIX": "SKHX", "TSMC": "TSM", "RIVIAN": "RIVN", "ALIBABA": "BABA", "CRUDE-OIL": "CL", "CRUDEOIL": "CL", "NATURAL-GAS": "NATGAS", "NATURALGAS": "NATGAS", "SAMSUNG": "SMSN", "USA-RARE-EARTH": "USAR", "USARAREEARTH": "USAR", "COREWEAVE": "CRWV", "DOLLAR-INDEX": "DXY", "DOLLARINDEX": "DXY", "GAMESTOP": "GME", "KOREA-200": "KR200", "KOREA200": "KR200", "JAPAN-225": "JP225", "JAPAN225": "JP225", "SOUTH-KOREA-ETF": "EWY", "SOUTHKOREAETF": "EWY", "JAPAN-ETF": "EWJ", "JAPANETF": "EWJ", "BRENT-OIL": "BRENTOIL", "BRENTOIL": "BRENTOIL", "HIMS-HERS": "HIMS", "HIMSHERS": "HIMS", "S&P-500": "SP500", "SP-500": "SP500", "SP500": "SP500", "DRAFTKINGS": "DKNG", "LITECOIN": "LITE", "ENERGY-SECTOR-ETF": "XLE", "ENERGYSECTORETF": "XLE", "TTF-GAS": "TTF", "TTFGAS": "TTF", "BLACKSTONE": "BX", "MARVELL": "MRVL", "ROCKET-LAB": "RKLB", "ROCKETLAB": "RKLB", "VOLATILITY": "VOL", "COINBASE-PRE-IPO": "CBRS", "COINBASEPREIPO": "CBRS", "BRAZIL-ETF": "EWZ", "BRAZILETF": "EWZ", "ZOOM": "ZM", "NIFTY-50": "NIFTY", "NIFTY50": "NIFTY", "TAIWAN-ETF": "EWT", "TAIWANETF": "EWT", "SPACEX-PRE-IPO": "SPCX", "SPACEXPREIPO": "SPCX", "IBOVESPA": "IBOV", } func NormalizeXYZAlias(base string) string { base = strings.ToUpper(strings.TrimSpace(base)) base = strings.TrimPrefix(base, "XYZ:") base = strings.TrimSuffix(base, "-USDC") base = strings.TrimSuffix(base, "-USD") if mapped, ok := XYZDisplayNameToCoin[base]; ok { return mapped } compact := strings.NewReplacer(" ", "", "_", "", ".", "", "/", "", "&", "AND").Replace(base) if mapped, ok := XYZDisplayNameToCoin[compact]; ok { return mapped } return base } // MapTimeframe maps common timeframe strings to Hyperliquid format func MapTimeframe(interval string) string { switch interval { case "1m": return "1m" case "3m": return "5m" // Hyperliquid doesn't have 3m, use 5m case "5m": return "5m" case "15m": return "15m" case "30m": return "30m" case "1h": return "1h" case "2h": return "1h" // Hyperliquid doesn't have 2h, use 1h case "4h": return "4h" case "6h": return "4h" // Hyperliquid doesn't have 6h, use 4h case "8h": return "8h" case "12h": return "12h" case "1d": return "1d" case "3d": return "1d" // Hyperliquid doesn't have 3d, use 1d case "1w": return "1w" case "1M": return "1M" default: return "5m" // Default to 5 minutes } } // getIntervalDuration returns the duration for a given interval func getIntervalDuration(interval string) time.Duration { switch interval { case "1m": return time.Minute case "5m": return 5 * time.Minute case "15m": return 15 * time.Minute case "30m": return 30 * time.Minute case "1h": return time.Hour case "4h": return 4 * time.Hour case "8h": return 8 * time.Hour case "12h": return 12 * time.Hour case "1d": return 24 * time.Hour case "1w": return 7 * 24 * time.Hour case "1M": return 30 * 24 * time.Hour default: return 5 * time.Minute } } // XYZ Dex name for stock perps, forex, and commodities const XYZDex = "xyz" // Stock perps symbols available on Hyperliquid xyz dex // Use xyz:SYMBOL format when calling the API var StockPerpsSymbols = []string{ "TSLA", // Tesla "AAPL", // Apple "NVDA", // Nvidia "MSFT", // Microsoft "META", // Meta "AMZN", // Amazon "GOOGL", // Alphabet "AMD", // AMD "COIN", // Coinbase "NFLX", // Netflix "PLTR", // Palantir "HOOD", // Robinhood "INTC", // Intel "MSTR", // MicroStrategy "TSM", // TSMC "ORCL", // Oracle "MU", // Micron "RIVN", // Rivian "COST", // Costco "LLY", // Eli Lilly "CRCL", // Circle (new) "SKHX", // Skyward (new) "SNDK", // Sandisk (new) } // Forex and commodities on xyz dex var XYZOtherSymbols = []string{ "GOLD", // Gold "SILVER", // Silver "EUR", // EUR/USD "JPY", // USD/JPY "XYZ100", // Index } // IsStockPerp checks if a symbol is a stock perpetual func IsStockPerp(symbol string) bool { coin := NormalizeCoinBase(symbol) for _, s := range StockPerpsSymbols { if s == coin { return true } } return false } // IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities). // // Detection is suffix-driven first, hardcoded-list second: // 1. `xyz:` prefix or `-USDC` suffix are unambiguous Hyperliquid signals — // the only place those tokens originate is the Hyperliquid USDC board. // This unblocks newly-listed stock perpetuals (QNT, ARM, ...) without // requiring a code change every time Hyperliquid adds a ticker. // 2. Bare bases (e.g. "QNT" with no qualifying suffix) still fall back to // the hardcoded StockPerpsSymbols / XYZOtherSymbols / display alias lists // so callers passing pre-normalized base symbols continue to work. func IsXYZAsset(symbol string) bool { trimmed := strings.ToUpper(strings.TrimSpace(symbol)) if strings.HasPrefix(strings.ToLower(trimmed), "xyz:") || strings.HasSuffix(trimmed, "-USDC") { return true } coin := NormalizeCoinBase(symbol) // Check stock perps for _, s := range StockPerpsSymbols { if s == coin { return true } } // Check other xyz assets for _, s := range XYZOtherSymbols { if s == coin { return true } } // Check newer xyz assets that are represented by full display-name aliases in NOFX. for _, s := range XYZDisplayNameToCoin { if s == coin { return true } } return false } // NormalizeCoinBase removes common suffixes to get base symbol func NormalizeCoinBase(symbol string) string { symbol = strings.ToUpper(strings.TrimSpace(symbol)) hasXYZPrefix := strings.HasPrefix(symbol, "XYZ:") // Remove xyz: prefix if present if hasXYZPrefix { return NormalizeXYZAlias(strings.TrimPrefix(symbol, "XYZ:")) } // Remove -USDC suffix if strings.HasSuffix(symbol, "-USDC") { return NormalizeXYZAlias(strings.TrimSuffix(symbol, "-USDC")) } // Remove USDT suffix if strings.HasSuffix(symbol, "USDT") { return strings.TrimSuffix(symbol, "USDT") } // Remove USD suffix if strings.HasSuffix(symbol, "USD") { return strings.TrimSuffix(symbol, "USD") } return NormalizeXYZAlias(symbol) } // FormatCoinForAPI formats the coin name for Hyperliquid API. // Stock perps need xyz:SYMBOL format, crypto uses plain symbol. // // Decision order: // 1. `xyz:` prefix OR `-USDC` suffix on the original input ⇒ xyz asset // (these tokens are Hyperliquid-specific, so the answer is unambiguous // regardless of whether the base symbol appears in our hardcoded lists). // 2. After stripping suffixes, if the bare base matches a known xyz asset // (stock perps, forex, commodities, display aliases) ⇒ also xyz. // 3. Otherwise crypto. func FormatCoinForAPI(symbol string) string { trimmed := strings.TrimSpace(symbol) upper := strings.ToUpper(trimmed) hasExplicitXYZ := strings.HasPrefix(strings.ToLower(trimmed), "xyz:") hasUSDCSuffix := strings.HasSuffix(upper, "-USDC") base := NormalizeCoinBase(symbol) if hasExplicitXYZ || hasUSDCSuffix || IsXYZAsset(base) { return "xyz:" + base } return base }