mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user