diff --git a/kernel/engine.go b/kernel/engine.go index d4010070..6a309e55 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -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 "" diff --git a/provider/hyperliquid/coins.go b/provider/hyperliquid/coins.go new file mode 100644 index 00000000..7e5b0d45 --- /dev/null +++ b/provider/hyperliquid/coins.go @@ -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) +} diff --git a/store/strategy.go b/store/strategy.go index d27a9948..a406399e 100644 --- a/store/strategy.go +++ b/store/strategy.go @@ -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 }