feat(strategy): Add Hyperliquid coin sources (hyper_all, hyper_main) (#1388)

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.
This commit is contained in:
Hao Fu
2026-02-22 04:03:05 -05:00
committed by GitHub
parent bdb2744845
commit 64935b9d47
3 changed files with 353 additions and 0 deletions
+124
View File
@@ -1,6 +1,7 @@
package kernel
import (
"context"
"encoding/json"
"fmt"
"io"
@@ -8,6 +9,7 @@ import (
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/provider/hyperliquid"
"nofx/provider/nofxos"
"nofx/security"
"nofx/store"
@@ -490,6 +492,44 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
// 空列表是正常情况,直接返回
return e.filterExcludedCoins(coins), nil
case "hyper_all":
// All Hyperliquid perp coins
if !coinSource.UseHyperAll {
logger.Infof("⚠️ source_type is 'hyper_all' but use_hyper_all is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getHyperAllCoins()
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "hyper_main":
// Top N Hyperliquid coins by 24h volume
if !coinSource.UseHyperMain {
logger.Infof("⚠️ source_type is 'hyper_main' but use_hyper_main is false, falling back to static coins")
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
candidates = append(candidates, CandidateCoin{
Symbol: symbol,
Sources: []string{"static"},
})
}
return e.filterExcludedCoins(candidates), nil
}
coins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
return nil, err
}
return e.filterExcludedCoins(coins), nil
case "mixed":
if coinSource.UseAI500 {
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
@@ -524,6 +564,28 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
}
}
if coinSource.UseHyperAll {
hyperCoins, err := e.getHyperAllCoins()
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid All coins: %v", err)
} else {
for _, coin := range hyperCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_all")
}
}
}
if coinSource.UseHyperMain {
hyperMainCoins, err := e.getHyperMainCoins(coinSource.HyperMainLimit)
if err != nil {
logger.Infof("⚠️ Failed to get Hyperliquid Main coins: %v", err)
} else {
for _, coin := range hyperMainCoins {
symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "hyper_main")
}
}
}
for _, symbol := range coinSource.StaticCoins {
symbol = market.Normalize(symbol)
if _, exists := symbolSources[symbol]; !exists {
@@ -640,6 +702,52 @@ func (e *StrategyEngine) getOILowCoins(limit int) ([]CandidateCoin, error) {
return candidates, nil
}
// getHyperAllCoins returns all available Hyperliquid perpetual coins
func (e *StrategyEngine) getHyperAllCoins() ([]CandidateCoin, error) {
ctx := context.Background()
symbols, err := hyperliquid.GetAllCoinSymbols(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_all"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid coins (hyper_all)", len(candidates))
return candidates, nil
}
// getHyperMainCoins returns top N Hyperliquid coins by 24h volume
func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
if limit <= 0 {
limit = 20
}
ctx := context.Background()
symbols, err := hyperliquid.GetMainCoinSymbols(ctx, limit)
if err != nil {
return nil, fmt.Errorf("failed to get Hyperliquid main coins: %w", err)
}
var candidates []CandidateCoin
for _, symbol := range symbols {
// Add USDT suffix for compatibility
normalizedSymbol := market.Normalize(symbol + "USDT")
candidates = append(candidates, CandidateCoin{
Symbol: normalizedSymbol,
Sources: []string{"hyper_main"},
})
}
logger.Infof("✅ Loaded %d Hyperliquid main coins (hyper_main) by 24h volume", len(candidates))
return candidates, nil
}
// ============================================================================
// External & Quant Data
// ============================================================================
@@ -1350,6 +1458,8 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasAI500 := false
hasOITop := false
hasOILow := false
hasHyperAll := false
hasHyperMain := false
for _, s := range sources {
switch s {
case "ai500":
@@ -1358,6 +1468,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
hasOITop = true
case "oi_low":
hasOILow = true
case "hyper_all":
hasHyperAll = true
case "hyper_main":
hasHyperMain = true
}
}
if hasAI500 && hasOITop {
@@ -1369,6 +1483,12 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
if hasOITop && hasOILow {
return " (OI_Top+OI_Low)"
}
if hasHyperMain && hasAI500 {
return " (HyperMain+AI500)"
}
if hasHyperAll || hasHyperMain {
return " (Hyperliquid)"
}
return " (Multiple sources)"
} else if len(sources) == 1 {
switch sources[0] {
@@ -1380,6 +1500,10 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
return " (OI_Low 持仓减少)"
case "static":
return " (Manual selection)"
case "hyper_all":
return " (Hyperliquid All)"
case "hyper_main":
return " (Hyperliquid Top20)"
}
}
return ""
+223
View File
@@ -0,0 +1,223 @@
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)
}
+6
View File
@@ -119,6 +119,12 @@ type CoinSourceConfig struct {
UseOILow bool `json:"use_oi_low"`
// OI Low maximum count
OILowLimit int `json:"oi_low_limit,omitempty"`
// whether to use Hyperliquid All coins (all available perp pairs)
UseHyperAll bool `json:"use_hyper_all"`
// whether to use Hyperliquid Main coins (top N by 24h volume)
UseHyperMain bool `json:"use_hyper_main"`
// Hyperliquid Main maximum count (default 20)
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
}