mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-07 03:07:56 +08:00
64935b9d47
Add two new coin source options for Hyperliquid trading: - hyper_all: All available Hyperliquid perpetual coins (229 coins) - hyper_main: Top N coins by 24h volume (default 20) Changes: - Add CoinSourceConfig fields: UseHyperAll, UseHyperMain, HyperMainLimit - Add provider/hyperliquid/coins.go with caching (24h) and volume-based sorting - Add source types 'hyper_all' and 'hyper_main' to GetCandidateCoins() - Support mixing with other sources in 'mixed' mode - Add source tag formatting for UI display This ensures traders using Hyperliquid can select coins that are actually available on the exchange, avoiding 'symbol not found' errors.
224 lines
5.2 KiB
Go
224 lines
5.2 KiB
Go
package hyperliquid
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"nofx/logger"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
|
cacheDuration = 24 * time.Hour // Cache for 24 hours
|
|
)
|
|
|
|
// CoinInfo represents basic coin information
|
|
type CoinInfo struct {
|
|
Symbol string `json:"symbol"`
|
|
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
|
|
}
|
|
|
|
// CoinProvider provides Hyperliquid coin lists
|
|
type CoinProvider struct {
|
|
mu sync.RWMutex
|
|
allCoins []CoinInfo
|
|
mainCoins []CoinInfo
|
|
lastUpdated time.Time
|
|
httpClient *http.Client
|
|
}
|
|
|
|
var (
|
|
defaultProvider *CoinProvider
|
|
providerOnce sync.Once
|
|
)
|
|
|
|
// GetProvider returns the singleton CoinProvider instance
|
|
func GetProvider() *CoinProvider {
|
|
providerOnce.Do(func() {
|
|
defaultProvider = &CoinProvider{
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
})
|
|
return defaultProvider
|
|
}
|
|
|
|
// metaResponse represents the response from Hyperliquid meta endpoint
|
|
type metaResponse struct {
|
|
Universe []struct {
|
|
Name string `json:"name"`
|
|
} `json:"universe"`
|
|
}
|
|
|
|
// assetCtx represents asset context with volume data
|
|
type assetCtx struct {
|
|
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
|
|
}
|
|
|
|
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
|
|
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
|
// Request metaAndAssetCtxs to get both coin names and volume data
|
|
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
|
|
bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch coin data: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Response is an array: [meta, [assetCtxs...]]
|
|
var rawResp []json.RawMessage
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
|
|
return fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
if len(rawResp) < 2 {
|
|
return fmt.Errorf("unexpected response format")
|
|
}
|
|
|
|
// Parse meta
|
|
var meta metaResponse
|
|
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
|
|
return fmt.Errorf("failed to parse meta: %w", err)
|
|
}
|
|
|
|
// Parse asset contexts
|
|
var ctxs []assetCtx
|
|
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
|
|
return fmt.Errorf("failed to parse asset contexts: %w", err)
|
|
}
|
|
|
|
// Build coin list with volume
|
|
var coins []CoinInfo
|
|
for i, u := range meta.Universe {
|
|
var vol float64
|
|
if i < len(ctxs) {
|
|
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
|
|
}
|
|
coins = append(coins, CoinInfo{
|
|
Symbol: u.Name,
|
|
Volume24h: vol,
|
|
})
|
|
}
|
|
|
|
// Sort by volume descending
|
|
sort.Slice(coins, func(i, j int) bool {
|
|
return coins[i].Volume24h > coins[j].Volume24h
|
|
})
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.allCoins = coins
|
|
// Main coins are top 20 by volume
|
|
if len(coins) > 20 {
|
|
p.mainCoins = coins[:20]
|
|
} else {
|
|
p.mainCoins = coins
|
|
}
|
|
p.lastUpdated = time.Now()
|
|
|
|
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureUpdated checks if cache is stale and refreshes if needed
|
|
func (p *CoinProvider) ensureUpdated(ctx context.Context) error {
|
|
p.mu.RLock()
|
|
needsUpdate := time.Since(p.lastUpdated) > cacheDuration || len(p.allCoins) == 0
|
|
p.mu.RUnlock()
|
|
|
|
if needsUpdate {
|
|
return p.fetchCoins(ctx)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAllCoins returns all available Hyperliquid perp coins
|
|
func (p *CoinProvider) GetAllCoins(ctx context.Context) ([]CoinInfo, error) {
|
|
if err := p.ensureUpdated(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
|
|
// Return a copy to avoid mutation
|
|
result := make([]CoinInfo, len(p.allCoins))
|
|
copy(result, p.allCoins)
|
|
return result, nil
|
|
}
|
|
|
|
// GetMainCoins returns top N coins by 24h volume
|
|
func (p *CoinProvider) GetMainCoins(ctx context.Context, limit int) ([]CoinInfo, error) {
|
|
if err := p.ensureUpdated(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
// Return top N coins
|
|
count := limit
|
|
if count > len(p.allCoins) {
|
|
count = len(p.allCoins)
|
|
}
|
|
|
|
result := make([]CoinInfo, count)
|
|
copy(result, p.allCoins[:count])
|
|
return result, nil
|
|
}
|
|
|
|
// GetCoinSymbols returns just the symbol names (for compatibility)
|
|
func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
|
|
coins, err := GetProvider().GetAllCoins(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
symbols := make([]string, len(coins))
|
|
for i, c := range coins {
|
|
symbols[i] = c.Symbol
|
|
}
|
|
return symbols, nil
|
|
}
|
|
|
|
// GetMainCoinSymbols returns top N coin symbols by volume
|
|
func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
|
|
coins, err := GetProvider().GetMainCoins(ctx, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
symbols := make([]string, len(coins))
|
|
for i, c := range coins {
|
|
symbols[i] = c.Symbol
|
|
}
|
|
return symbols, nil
|
|
}
|
|
|
|
// ForceRefresh forces a refresh of the coin cache
|
|
func (p *CoinProvider) ForceRefresh(ctx context.Context) error {
|
|
return p.fetchCoins(ctx)
|
|
}
|