Files
tinkle-community 47bff87966 feat: add xyz dex balance calculation, market data providers, and UI improvements
- Fix xyz dex balance calculation (use marginSummary for isolated margin)
- Add Alpaca provider for US stocks market data
- Add TwelveData provider for forex & metals market data
- Add Hyperliquid kline provider
- Centralize API keys in config system
- Add builder fee for order routing
- Improve chart UI with compact design
- Fix position history fee display precision
- Add comprehensive balance calculation tests
2025-12-29 22:16:48 +08:00

415 lines
10 KiB
Go

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"
// - "xyz:TSLA" -> "TSLA"
// - "BTC" -> "BTC"
func NormalizeCoin(symbol string) string {
return NormalizeCoinBase(symbol)
}
// 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)
func IsXYZAsset(symbol string) bool {
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
}
}
return false
}
// NormalizeCoinBase removes common suffixes to get base symbol
func NormalizeCoinBase(symbol string) string {
// Remove xyz: prefix if present
if strings.HasPrefix(symbol, "xyz:") {
return strings.TrimPrefix(symbol, "xyz:")
}
// Remove -USDC suffix
if strings.HasSuffix(symbol, "-USDC") {
return 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 symbol
}
// FormatCoinForAPI formats the coin name for Hyperliquid API
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol
func FormatCoinForAPI(symbol string) string {
base := NormalizeCoinBase(symbol)
if IsXYZAsset(base) {
return "xyz:" + base
}
return base
}