feat(hyperliquid): add stock symbol market data support

- Add Hyperliquid/XYZ symbol normalization tests and backend coverage

- Extend kline and market data lookup paths for US stock symbols

- Wire frontend data API types for stock-oriented market requests
This commit is contained in:
tinklefund
2026-05-25 01:24:49 +08:00
parent 908fc09aca
commit f37fc9f887
12 changed files with 551 additions and 109 deletions

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
return klines, nil return klines, nil
} }
func hyperliquidXYZDisplayBase(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
// User-facing names should be product names, not exchange shorthand tickers.
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
// orders/candles by the short coin name (for example xyz:SMSN).
fullNames := map[string]string{
"XYZ100": "XYZ100",
"TSLA": "TESLA",
"NVDA": "NVIDIA",
"GOLD": "GOLD",
"HOOD": "ROBINHOOD",
"INTC": "INTEL",
"PLTR": "PALANTIR",
"COIN": "COINBASE",
"META": "META",
"AAPL": "APPLE",
"MSFT": "MICROSOFT",
"ORCL": "ORACLE",
"GOOGL": "GOOGLE",
"AMZN": "AMAZON",
"AMD": "AMD",
"MU": "MICRON",
"SNDK": "SANDISK",
"MSTR": "MICROSTRATEGY",
"CRCL": "CIRCLE",
"NFLX": "NETFLIX",
"COST": "COSTCO",
"LLY": "ELI-LILLY",
"SKHX": "SK-HYNIX",
"TSM": "TSMC",
"JPY": "JPY",
"EUR": "EUR",
"SILVER": "SILVER",
"RIVN": "RIVIAN",
"BABA": "ALIBABA",
"CL": "CRUDE-OIL",
"COPPER": "COPPER",
"NATGAS": "NATURAL-GAS",
"URANIUM": "URANIUM",
"ALUMINIUM": "ALUMINIUM",
"SMSN": "SAMSUNG",
"PLATINUM": "PLATINUM",
"USAR": "USA-RARE-EARTH",
"CRWV": "COREWEAVE",
"URNM": "URNM",
"PALLADIUM": "PALLADIUM",
"DXY": "DOLLAR-INDEX",
"GME": "GAMESTOP",
"KR200": "KOREA-200",
"SOFTBANK": "SOFTBANK",
"JP225": "JAPAN-225",
"HYUNDAI": "HYUNDAI",
"KIOXIA": "KIOXIA",
"EWY": "SOUTH-KOREA-ETF",
"EWJ": "JAPAN-ETF",
"BRENTOIL": "BRENT-OIL",
"VIX": "VIX",
"HIMS": "HIMS-HERS",
"SP500": "S&P-500",
"DKNG": "DRAFTKINGS",
"LITE": "LITECOIN",
"CORN": "CORN",
"XLE": "ENERGY-SECTOR-ETF",
"WHEAT": "WHEAT",
"TTF": "TTF-GAS",
"BX": "BLACKSTONE",
"PURRDAT": "PURRDAT",
"MRVL": "MARVELL",
"RKLB": "ROCKET-LAB",
"BIRD": "BIRD",
"VOL": "VOLATILITY",
"DRAM": "DRAM",
"CBRS": "COINBASE-PRE-IPO",
"EWZ": "BRAZIL-ETF",
"KRW": "KRW",
"ZM": "ZOOM",
"EBAY": "EBAY",
"H100": "H100",
"NIFTY": "NIFTY-50",
"ARM": "ARM",
"EWT": "TAIWAN-ETF",
"GBP": "GBP",
"SPCX": "SPACEX-PRE-IPO",
"IBOV": "IBOVESPA",
"ASML": "ASML",
}
if fullName, ok := fullNames[baseSymbol]; ok {
return fullName
}
return baseSymbol
}
func hyperliquidXYZCategory(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
switch baseSymbol {
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
return "commodity"
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
return "index"
case "EUR", "JPY", "GBP", "KRW":
return "forex"
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
return "pre_ipo"
default:
return "stock"
}
}
func hyperliquidCategoryOrder(category string) int {
switch category {
case "stock":
return 0
case "commodity":
return 1
case "index":
return 2
case "forex":
return 3
case "pre_ipo":
return 4
case "crypto":
return 5
default:
return 99
}
}
// handleSymbols returns available symbols for a given exchange // handleSymbols returns available symbols for a given exchange
func (s *Server) handleSymbols(c *gin.Context) { func (s *Server) handleSymbols(c *gin.Context) {
exchange := c.DefaultQuery("exchange", "hyperliquid") exchange := c.DefaultQuery("exchange", "hyperliquid")
type SymbolInfo struct { type SymbolInfo struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Name string `json:"name"` Display string `json:"display"`
Category string `json:"category"` // crypto, stock, forex, commodity, index Name string `json:"name"`
MaxLeverage int `json:"maxLeverage,omitempty"` Category string `json:"category"` // crypto, stock, forex, commodity, index
Exchange string `json:"exchange"`
Volume24h float64 `json:"volume_24h"`
MarkPrice float64 `json:"mark_price"`
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
Change24hPct float64 `json:"change_24h_pct,omitempty"`
MaxLeverage int `json:"maxLeverage,omitempty"`
SzDecimals int `json:"sz_decimals,omitempty"`
} }
var symbols []SymbolInfo var symbols []SymbolInfo
switch strings.ToLower(exchange) { exchangeLower := strings.ToLower(exchange)
switch exchangeLower {
case "hyperliquid", "hyperliquid-xyz", "xyz": case "hyperliquid", "hyperliquid-xyz", "xyz":
// Fetch symbols from Hyperliquid
client := hyperliquid.NewClient()
ctx := context.Background() ctx := context.Background()
// Get crypto perps from default dex // hyperliquid-xyz returns the full USDC trading board in product order:
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" { // stocks → commodities → indices → forex → pre-IPO → crypto.
mids, err := client.GetAllMids(ctx) if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
if err == nil { xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
for symbol := range mids { if err != nil {
// Skip spot tokens (start with @) SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
if strings.HasPrefix(symbol, "@") { return
continue }
} for _, coin := range xyzCoins {
symbols = append(symbols, SymbolInfo{ baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
Symbol: symbol, displayBase := hyperliquidXYZDisplayBase(baseSymbol)
Name: symbol, displaySymbol := displayBase + "-USDC"
Category: "crypto", tradeSymbol := baseSymbol + "-USDC"
}) symbols = append(symbols, SymbolInfo{
} Symbol: tradeSymbol,
Display: displaySymbol,
Name: displayBase,
Category: hyperliquidXYZCategory(baseSymbol),
Exchange: "hyperliquid-xyz",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
})
} }
} }
// Get xyz dex symbols (stocks, forex, commodities) // Crypto perps are shown last; only include them on the combined Hyperliquid board.
xyzMids, err := client.GetAllMidsXYZ(ctx) if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
if err == nil { coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
for symbol := range xyzMids { if err != nil {
// Remove xyz: prefix for display SafeInternalError(c, "Get Hyperliquid symbols", err)
displaySymbol := strings.TrimPrefix(symbol, "xyz:") return
category := "stock" }
if displaySymbol == "GOLD" || displaySymbol == "SILVER" { for _, coin := range coins {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
}
symbols = append(symbols, SymbolInfo{ symbols = append(symbols, SymbolInfo{
Symbol: displaySymbol, Symbol: coin.Symbol,
Name: displaySymbol, Display: coin.Symbol,
Category: category, Name: coin.Symbol,
Category: "crypto",
Exchange: "hyperliquid",
Volume24h: coin.Volume24h,
MarkPrice: coin.MarkPrice,
PrevDayPrice: coin.PrevDayPrice,
Change24hPct: coin.Change24hPct,
MaxLeverage: coin.MaxLeverage,
SzDecimals: coin.SzDecimals,
}) })
} }
} }
@@ -384,6 +531,15 @@ func (s *Server) handleSymbols(c *gin.Context) {
return return
} }
sort.SliceStable(symbols, func(i, j int) bool {
ci := hyperliquidCategoryOrder(symbols[i].Category)
cj := hyperliquidCategoryOrder(symbols[j].Category)
if ci != cj {
return ci < cj
}
return symbols[i].Volume24h > symbols[j].Volume24h
})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"exchange": exchange, "exchange": exchange,
"symbols": symbols, "symbols": symbols,

View File

@@ -0,0 +1,28 @@
package api
import "testing"
func TestIsSupportedTraderSymbol(t *testing.T) {
tests := []struct {
name string
symbol string
want bool
}{
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
{name: "empty slot ignored", symbol: " ", want: true},
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,15 @@
package kernel
import "testing"
func TestClampHyperRankLimit(t *testing.T) {
if got := clampHyperRankLimit(0); got != 5 {
t.Fatalf("clamp 0 = %d, want 5", got)
}
if got := clampHyperRankLimit(99); got != 10 {
t.Fatalf("clamp 99 = %d, want 10", got)
}
if got := clampHyperRankLimit(3); got != 3 {
t.Fatalf("clamp 3 = %d, want 3", got)
}
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"math" "math"
"nofx/logger" "nofx/logger"
"nofx/provider/hyperliquid"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -229,7 +230,7 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
currentRSI7 := calculateRSI(primaryKlines, 7) currentRSI7 := calculateRSI(primaryKlines, 7)
// Calculate price changes // Calculate price changes
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4 hours priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4 hours
// Get OI data // Get OI data
@@ -540,6 +541,9 @@ var xyzDexAssets = map[string]bool{
// IsXyzDexAsset checks if a symbol is an xyz dex asset // IsXyzDexAsset checks if a symbol is an xyz dex asset
func IsXyzDexAsset(symbol string) bool { func IsXyzDexAsset(symbol string) bool {
base := strings.ToUpper(symbol) base := strings.ToUpper(symbol)
if strings.HasSuffix(base, "-USDC") || strings.HasPrefix(strings.ToLower(base), "xyz:") {
return hyperliquid.IsXYZAsset(base)
}
// Remove any prefix/suffix // Remove any prefix/suffix
base = strings.TrimPrefix(base, "XYZ:") base = strings.TrimPrefix(base, "XYZ:")
for _, suffix := range []string{"USDT", "USD", "-USDC"} { for _, suffix := range []string{"USDT", "USD", "-USDC"} {
@@ -548,7 +552,7 @@ func IsXyzDexAsset(symbol string) bool {
break break
} }
} }
return xyzDexAssets[base] return xyzDexAssets[base] || hyperliquid.IsXYZAsset(base)
} }
// Normalize normalizes symbol // Normalize normalizes symbol
@@ -556,22 +560,13 @@ func IsXyzDexAsset(symbol string) bool {
// For xyz dex assets (stocks, forex, commodities): uses xyz: prefix without USDT suffix // For xyz dex assets (stocks, forex, commodities): uses xyz: prefix without USDT suffix
func Normalize(symbol string) string { func Normalize(symbol string) string {
symbol = strings.ToUpper(symbol) symbol = strings.ToUpper(symbol)
if strings.HasSuffix(symbol, "-USDC") {
return hyperliquid.FormatCoinForAPI(symbol)
}
// Check if this is an xyz dex asset // Check if this is an xyz dex asset
if IsXyzDexAsset(symbol) { if IsXyzDexAsset(symbol) {
// Remove any xyz: prefix (case-insensitive) and USDT suffix, then add xyz: prefix return hyperliquid.FormatCoinForAPI(symbol)
base := symbol
// Handle both lowercase and uppercase xyz: prefix
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
base = base[4:] // Remove first 4 characters ("xyz:")
}
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
return "xyz:" + base
} }
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP) // Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)

View File

@@ -0,0 +1,26 @@
package market
import "testing"
func TestHyperliquidXYZAliasesNormalizeForAIDecisionData(t *testing.T) {
tests := []struct {
input string
normalized string
isXyzAsset bool
}{
{input: "SMSN-USDC", normalized: "xyz:SMSN", isXyzAsset: true},
{input: "SAMSUNG-USDC", normalized: "xyz:SMSN", isXyzAsset: true},
{input: "xyz:SMSN", normalized: "xyz:SMSN", isXyzAsset: true},
{input: "TESLA-USDC", normalized: "xyz:TSLA", isXyzAsset: true},
{input: "TSLA-USDC", normalized: "xyz:TSLA", isXyzAsset: true},
}
for _, tt := range tests {
if got := Normalize(tt.input); got != tt.normalized {
t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.normalized)
}
if got := IsXyzDexAsset(tt.normalized); got != tt.isXyzAsset {
t.Fatalf("IsXyzDexAsset(%q) = %v, want %v", tt.normalized, got, tt.isXyzAsset)
}
}
}

View File

@@ -8,6 +8,8 @@ import (
"net/http" "net/http"
"nofx/logger" "nofx/logger"
"sort" "sort"
"strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -17,19 +19,43 @@ const (
cacheDuration = 24 * time.Hour // Cache for 24 hours cacheDuration = 24 * time.Hour // Cache for 24 hours
) )
// CoinInfo represents basic coin information // CoinInfo represents basic Hyperliquid market information.
type CoinInfo struct { type CoinInfo struct {
Symbol string `json:"symbol"` Symbol string `json:"symbol"`
Volume24h float64 `json:"volume_24h"` // 24h volume in USD Volume24h float64 `json:"volume_24h"` // 24h notional volume in USD
MarkPrice float64 `json:"mark_price"`
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
Change24hPct float64 `json:"change_24h_pct,omitempty"`
MaxLeverage int `json:"max_leverage,omitempty"`
SzDecimals int `json:"sz_decimals,omitempty"`
}
// XYZCategory returns the NOFX product category for a Hyperliquid XYZ base symbol.
func XYZCategory(baseSymbol string) string {
baseSymbol = strings.ToUpper(strings.TrimSpace(strings.TrimPrefix(baseSymbol, "xyz:")))
switch baseSymbol {
case "TSLA", "NVDA", "AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "META", "NFLX", "AMD", "INTC", "COIN", "MSTR", "PLTR", "HOOD", "CRCL", "SNDK", "MU", "SMSN", "DRAM", "SKHX", "BABA", "ASML", "AVGO", "IONQ", "RGTI", "RKLB", "SMCI", "MARA", "RIOT", "MRVL", "SNOW", "CRM", "ORCL", "ADBE", "PYPL", "SHOP", "UBER", "SPOT", "ABNB", "RDDT", "ARM", "SOFI", "XYZ", "LVMH", "PDD", "NVO", "SONY", "DIS", "WMT", "NKE", "JPM", "BAC", "V", "MA", "JNJ", "PG", "UNH", "HD", "XOM", "CVX", "TM", "RACE", "VOW3", "BMW", "MBG":
return "stock"
case "GOLD", "SILVER", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CL", "CORN", "WHEAT", "TTF":
return "commodity"
case "SPX", "NDX", "DJI", "VIX", "DAX", "FTSE", "NIKKEI", "HSI", "CSI300", "XYZ100", "XYZ25", "XYZ50":
return "index"
case "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "MXN", "BRL", "TRY", "ZAR", "CNH", "KRW":
return "forex"
case "OPENAI", "ANTHROPIC", "SPACEX", "STRIPE", "FIGMA", "DATBRICKS", "PERPLEXITY", "XAI", "BYTEDANCE", "REVOLUT":
return "pre_ipo"
default:
return "stock"
}
} }
// CoinProvider provides Hyperliquid coin lists // CoinProvider provides Hyperliquid coin lists
type CoinProvider struct { type CoinProvider struct {
mu sync.RWMutex mu sync.RWMutex
allCoins []CoinInfo allCoins []CoinInfo
mainCoins []CoinInfo mainCoins []CoinInfo
lastUpdated time.Time lastUpdated time.Time
httpClient *http.Client httpClient *http.Client
} }
var ( var (
@@ -50,76 +76,105 @@ func GetProvider() *CoinProvider {
// metaResponse represents the response from Hyperliquid meta endpoint // metaResponse represents the response from Hyperliquid meta endpoint
type metaResponse struct { type metaResponse struct {
Universe []struct { Universe []struct {
Name string `json:"name"` Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
} `json:"universe"` } `json:"universe"`
} }
// assetCtx represents asset context with volume data // assetCtx represents asset context with market data.
type assetCtx struct { type assetCtx struct {
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
MarkPx string `json:"markPx"`
PrevDayPx string `json:"prevDayPx"`
} }
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume func fetchPerpDexCoins(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
func (p *CoinProvider) fetchCoins(ctx context.Context) error { reqPayload := map[string]string{"type": "metaAndAssetCtxs"}
// Request metaAndAssetCtxs to get both coin names and volume data if dex != "" {
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`) reqPayload["dex"] = dex
}
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL, reqBody, err := json.Marshal(reqPayload)
if err != nil {
return nil, fmt.Errorf("failed to encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
bytes.NewReader(reqBody)) bytes.NewReader(reqBody))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return nil, fmt.Errorf("failed to create request: %w", err)
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch coin data: %w", err) return nil, fmt.Errorf("failed to fetch coin data: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode) return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
} }
// Response is an array: [meta, [assetCtxs...]] // Response is an array: [meta, [assetCtxs...]]
var rawResp []json.RawMessage var rawResp []json.RawMessage
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
return fmt.Errorf("failed to decode response: %w", err) return nil, fmt.Errorf("failed to decode response: %w", err)
} }
if len(rawResp) < 2 { if len(rawResp) < 2 {
return fmt.Errorf("unexpected response format") return nil, fmt.Errorf("unexpected response format")
} }
// Parse meta
var meta metaResponse var meta metaResponse
if err := json.Unmarshal(rawResp[0], &meta); err != nil { if err := json.Unmarshal(rawResp[0], &meta); err != nil {
return fmt.Errorf("failed to parse meta: %w", err) return nil, fmt.Errorf("failed to parse meta: %w", err)
} }
// Parse asset contexts
var ctxs []assetCtx var ctxs []assetCtx
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil { if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
return fmt.Errorf("failed to parse asset contexts: %w", err) return nil, fmt.Errorf("failed to parse asset contexts: %w", err)
} }
// Build coin list with volume coins := make([]CoinInfo, 0, len(meta.Universe))
var coins []CoinInfo
for i, u := range meta.Universe { for i, u := range meta.Universe {
var vol float64 var vol, mark, prevDay, change24hPct float64
if i < len(ctxs) { if i < len(ctxs) {
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol) vol, _ = strconv.ParseFloat(ctxs[i].DayNtlVlm, 64)
mark, _ = strconv.ParseFloat(ctxs[i].MarkPx, 64)
prevDay, _ = strconv.ParseFloat(ctxs[i].PrevDayPx, 64)
if prevDay > 0 && mark > 0 {
change24hPct = ((mark - prevDay) / prevDay) * 100
}
} }
coins = append(coins, CoinInfo{ coins = append(coins, CoinInfo{
Symbol: u.Name, Symbol: u.Name,
Volume24h: vol, Volume24h: vol,
MarkPrice: mark,
PrevDayPrice: prevDay,
Change24hPct: change24hPct,
MaxLeverage: u.MaxLeverage,
SzDecimals: u.SzDecimals,
}) })
} }
// Sort by volume descending
sort.Slice(coins, func(i, j int) bool { sort.Slice(coins, func(i, j int) bool {
return coins[i].Volume24h > coins[j].Volume24h return coins[i].Volume24h > coins[j].Volume24h
}) })
return coins, nil
}
// GetPerpDexCoins fetches current tradable USDC perp assets for a given Hyperliquid dex.
func GetPerpDexCoins(ctx context.Context, dex string) ([]CoinInfo, error) {
return fetchPerpDexCoins(ctx, &http.Client{Timeout: 30 * time.Second}, dex)
}
// fetchCoins fetches all default Hyperliquid crypto coins and sorts by volume
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
coins, err := fetchPerpDexCoins(ctx, p.httpClient, "")
if err != nil {
return err
}
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -134,7 +189,7 @@ func (p *CoinProvider) fetchCoins(ctx context.Context) error {
p.lastUpdated = time.Now() p.lastUpdated = time.Now()
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins)) logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
return nil return nil
} }
@@ -195,7 +250,7 @@ func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
symbols := make([]string, len(coins)) symbols := make([]string, len(coins))
for i, c := range coins { for i, c := range coins {
symbols[i] = c.Symbol symbols[i] = c.Symbol
@@ -209,7 +264,7 @@ func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
symbols := make([]string, len(coins)) symbols := make([]string, len(coins))
for i, c := range coins { for i, c := range coins {
symbols[i] = c.Symbol symbols[i] = c.Symbol

View File

@@ -18,16 +18,16 @@ const (
// Candle represents a single OHLCV candle from Hyperliquid // Candle represents a single OHLCV candle from Hyperliquid
type Candle struct { type Candle struct {
OpenTime int64 `json:"t"` // Open time in milliseconds OpenTime int64 `json:"t"` // Open time in milliseconds
CloseTime int64 `json:"T"` // Close time in milliseconds CloseTime int64 `json:"T"` // Close time in milliseconds
Symbol string `json:"s"` // Coin symbol Symbol string `json:"s"` // Coin symbol
Interval string `json:"i"` // Interval Interval string `json:"i"` // Interval
Open string `json:"o"` // Open price Open string `json:"o"` // Open price
High string `json:"h"` // High price High string `json:"h"` // High price
Low string `json:"l"` // Low price Low string `json:"l"` // Low price
Close string `json:"c"` // Close price Close string `json:"c"` // Close price
Volume string `json:"v"` // Volume in base unit Volume string `json:"v"` // Volume in base unit
TradeCount int `json:"n"` // Number of trades TradeCount int `json:"n"` // Number of trades
} }
// CandleRequest represents the request for candleSnapshot // CandleRequest represents the request for candleSnapshot
@@ -230,21 +230,117 @@ type Meta struct {
// AssetInfo represents information about a single asset // AssetInfo represents information about a single asset
type AssetInfo struct { type AssetInfo struct {
Name string `json:"name"` Name string `json:"name"`
SzDecimals int `json:"szDecimals"` SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"` MaxLeverage int `json:"maxLeverage"`
} }
// NormalizeCoin normalizes coin name for Hyperliquid API // NormalizeCoin normalizes coin name for Hyperliquid API
// Examples: // Examples:
// - "BTCUSDT" -> "BTC" // - "BTCUSDT" -> "BTC"
// - "TSLA-USDC" -> "TSLA" // - "TSLA-USDC" -> "TSLA"
// - "TESLA-USDC" -> "TSLA"
// - "SAMSUNG-USDC" -> "SMSN"
// - "xyz:TSLA" -> "TSLA" // - "xyz:TSLA" -> "TSLA"
// - "BTC" -> "BTC" // - "BTC" -> "BTC"
func NormalizeCoin(symbol string) string { func NormalizeCoin(symbol string) string {
return NormalizeCoinBase(symbol) return NormalizeCoinBase(symbol)
} }
// XYZDisplayNameToCoin maps user-facing product labels back to Hyperliquid xyz coin names.
// Hyperliquid routes candles/orders by short names (for example xyz:SMSN), while NOFX
// shows full names (for example SAMSUNG-USDC) in the UI.
var XYZDisplayNameToCoin = map[string]string{
"TESLA": "TSLA",
"NVIDIA": "NVDA",
"ROBINHOOD": "HOOD",
"INTEL": "INTC",
"PALANTIR": "PLTR",
"COINBASE": "COIN",
"APPLE": "AAPL",
"MICROSOFT": "MSFT",
"ORACLE": "ORCL",
"GOOGLE": "GOOGL",
"ALPHABET": "GOOGL",
"AMAZON": "AMZN",
"MICRON": "MU",
"SANDISK": "SNDK",
"MICROSTRATEGY": "MSTR",
"CIRCLE": "CRCL",
"NETFLIX": "NFLX",
"COSTCO": "COST",
"ELI-LILLY": "LLY",
"SK-HYNIX": "SKHX",
"SKHYNIX": "SKHX",
"TSMC": "TSM",
"RIVIAN": "RIVN",
"ALIBABA": "BABA",
"CRUDE-OIL": "CL",
"CRUDEOIL": "CL",
"NATURAL-GAS": "NATGAS",
"NATURALGAS": "NATGAS",
"SAMSUNG": "SMSN",
"USA-RARE-EARTH": "USAR",
"USARAREEARTH": "USAR",
"COREWEAVE": "CRWV",
"DOLLAR-INDEX": "DXY",
"DOLLARINDEX": "DXY",
"GAMESTOP": "GME",
"KOREA-200": "KR200",
"KOREA200": "KR200",
"JAPAN-225": "JP225",
"JAPAN225": "JP225",
"SOUTH-KOREA-ETF": "EWY",
"SOUTHKOREAETF": "EWY",
"JAPAN-ETF": "EWJ",
"JAPANETF": "EWJ",
"BRENT-OIL": "BRENTOIL",
"BRENTOIL": "BRENTOIL",
"HIMS-HERS": "HIMS",
"HIMSHERS": "HIMS",
"S&P-500": "SP500",
"SP-500": "SP500",
"SP500": "SP500",
"DRAFTKINGS": "DKNG",
"LITECOIN": "LITE",
"ENERGY-SECTOR-ETF": "XLE",
"ENERGYSECTORETF": "XLE",
"TTF-GAS": "TTF",
"TTFGAS": "TTF",
"BLACKSTONE": "BX",
"MARVELL": "MRVL",
"ROCKET-LAB": "RKLB",
"ROCKETLAB": "RKLB",
"VOLATILITY": "VOL",
"COINBASE-PRE-IPO": "CBRS",
"COINBASEPREIPO": "CBRS",
"BRAZIL-ETF": "EWZ",
"BRAZILETF": "EWZ",
"ZOOM": "ZM",
"NIFTY-50": "NIFTY",
"NIFTY50": "NIFTY",
"TAIWAN-ETF": "EWT",
"TAIWANETF": "EWT",
"SPACEX-PRE-IPO": "SPCX",
"SPACEXPREIPO": "SPCX",
"IBOVESPA": "IBOV",
}
func NormalizeXYZAlias(base string) string {
base = strings.ToUpper(strings.TrimSpace(base))
base = strings.TrimPrefix(base, "XYZ:")
base = strings.TrimSuffix(base, "-USDC")
base = strings.TrimSuffix(base, "-USD")
if mapped, ok := XYZDisplayNameToCoin[base]; ok {
return mapped
}
compact := strings.NewReplacer(" ", "", "_", "", ".", "", "/", "", "&", "AND").Replace(base)
if mapped, ok := XYZDisplayNameToCoin[compact]; ok {
return mapped
}
return base
}
// MapTimeframe maps common timeframe strings to Hyperliquid format // MapTimeframe maps common timeframe strings to Hyperliquid format
func MapTimeframe(interval string) string { func MapTimeframe(interval string) string {
switch interval { switch interval {
@@ -379,18 +475,26 @@ func IsXYZAsset(symbol string) bool {
return true return true
} }
} }
// Check newer xyz assets that are represented by full display-name aliases in NOFX.
for _, s := range XYZDisplayNameToCoin {
if s == coin {
return true
}
}
return false return false
} }
// NormalizeCoinBase removes common suffixes to get base symbol // NormalizeCoinBase removes common suffixes to get base symbol
func NormalizeCoinBase(symbol string) string { func NormalizeCoinBase(symbol string) string {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
hasXYZPrefix := strings.HasPrefix(symbol, "XYZ:")
// Remove xyz: prefix if present // Remove xyz: prefix if present
if strings.HasPrefix(symbol, "xyz:") { if hasXYZPrefix {
return strings.TrimPrefix(symbol, "xyz:") return NormalizeXYZAlias(strings.TrimPrefix(symbol, "XYZ:"))
} }
// Remove -USDC suffix // Remove -USDC suffix
if strings.HasSuffix(symbol, "-USDC") { if strings.HasSuffix(symbol, "-USDC") {
return strings.TrimSuffix(symbol, "-USDC") return NormalizeXYZAlias(strings.TrimSuffix(symbol, "-USDC"))
} }
// Remove USDT suffix // Remove USDT suffix
if strings.HasSuffix(symbol, "USDT") { if strings.HasSuffix(symbol, "USDT") {
@@ -400,14 +504,15 @@ func NormalizeCoinBase(symbol string) string {
if strings.HasSuffix(symbol, "USD") { if strings.HasSuffix(symbol, "USD") {
return strings.TrimSuffix(symbol, "USD") return strings.TrimSuffix(symbol, "USD")
} }
return symbol return NormalizeXYZAlias(symbol)
} }
// FormatCoinForAPI formats the coin name for Hyperliquid API // FormatCoinForAPI formats the coin name for Hyperliquid API
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol // Stock perps need xyz:SYMBOL format, crypto uses plain symbol
func FormatCoinForAPI(symbol string) string { func FormatCoinForAPI(symbol string) string {
hasExplicitXYZ := strings.HasPrefix(strings.ToLower(strings.TrimSpace(symbol)), "xyz:")
base := NormalizeCoinBase(symbol) base := NormalizeCoinBase(symbol)
if IsXYZAsset(base) { if hasExplicitXYZ || IsXYZAsset(base) {
return "xyz:" + base return "xyz:" + base
} }
return base return base

View File

@@ -159,6 +159,10 @@ func TestNormalizeCoin(t *testing.T) {
{"BTCUSDT", "BTC"}, {"BTCUSDT", "BTC"},
{"BTCUSD", "BTC"}, {"BTCUSD", "BTC"},
{"TSLA-USDC", "TSLA"}, {"TSLA-USDC", "TSLA"},
{"TESLA-USDC", "TSLA"},
{"SMSN-USDC", "SMSN"},
{"SAMSUNG-USDC", "SMSN"},
{"xyz:SMSN", "SMSN"},
{"AAPL-USDC", "AAPL"}, {"AAPL-USDC", "AAPL"},
{"ETH", "ETH"}, {"ETH", "ETH"},
{"ETHUSDT", "ETH"}, {"ETHUSDT", "ETH"},
@@ -204,6 +208,10 @@ func TestFormatCoinForAPI(t *testing.T) {
{"ETH", "ETH"}, {"ETH", "ETH"},
{"TSLA", "xyz:TSLA"}, {"TSLA", "xyz:TSLA"},
{"TSLA-USDC", "xyz:TSLA"}, {"TSLA-USDC", "xyz:TSLA"},
{"TESLA-USDC", "xyz:TSLA"},
{"SMSN-USDC", "xyz:SMSN"},
{"SAMSUNG-USDC", "xyz:SMSN"},
{"xyz:SMSN", "xyz:SMSN"},
{"xyz:TSLA", "xyz:TSLA"}, {"xyz:TSLA", "xyz:TSLA"},
{"NVDA", "xyz:NVDA"}, {"NVDA", "xyz:NVDA"},
{"GOLD", "xyz:GOLD"}, {"GOLD", "xyz:GOLD"},

View File

@@ -0,0 +1,18 @@
package hyperliquid
import "testing"
func TestConvertSymbolToHyperliquidXYZAliases(t *testing.T) {
cases := map[string]string{
"SAMSUNG-USDC": "xyz:SMSN",
"SK-HYNIX-USDC": "xyz:SKHX",
"TSLAUSDT": "xyz:TSLA",
"xyz:SMSN": "xyz:SMSN",
"HYPEUSDT": "HYPE",
}
for input, want := range cases {
if got := convertSymbolToHyperliquid(input); got != want {
t.Fatalf("convertSymbolToHyperliquid(%q) = %q, want %q", input, got, want)
}
}
}

View File

@@ -9,7 +9,35 @@ import type {
} from '../../types' } from '../../types'
import { API_BASE, httpClient } from './helpers' import { API_BASE, httpClient } from './helpers'
export interface MarketSymbol {
symbol: string
display?: string
name: string
category: 'crypto' | 'stock' | 'forex' | 'commodity' | 'index' | string
exchange?: string
volume_24h?: number
mark_price?: number
change_24h_pct?: number
prev_day_price?: number
maxLeverage?: number
sz_decimals?: number
}
export interface SymbolListResponse {
exchange: string
symbols: MarketSymbol[]
count: number
}
export const dataApi = { export const dataApi = {
async getSymbols(exchange = 'hyperliquid-xyz'): Promise<SymbolListResponse> {
const result = await httpClient.get<SymbolListResponse>(
`${API_BASE}/symbols?exchange=${encodeURIComponent(exchange)}`
)
if (!result.success) throw new Error('Failed to fetch symbol list')
return result.data || { exchange, symbols: [], count: 0 }
},
async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> { async getStatus(traderId?: string, silent?: boolean): Promise<SystemStatus> {
const url = traderId const url = traderId
? `${API_BASE}/status?trader_id=${traderId}` ? `${API_BASE}/status?trader_id=${traderId}`

View File

@@ -3,6 +3,7 @@ import { strategyApi } from './strategies'
import { configApi } from './config' import { configApi } from './config'
import { dataApi } from './data' import { dataApi } from './data'
import { telegramApi } from './telegram' import { telegramApi } from './telegram'
import { walletApi } from './wallet'
export const api = { export const api = {
...traderApi, ...traderApi,
@@ -10,4 +11,5 @@ export const api = {
...configApi, ...configApi,
...dataApi, ...dataApi,
...telegramApi, ...telegramApi,
...walletApi,
} }

View File

@@ -105,7 +105,7 @@ export interface GridStrategyConfig {
} }
export interface CoinSourceConfig { export interface CoinSourceConfig {
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low'; source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'hyper_all' | 'hyper_main' | 'hyper_rank';
static_coins?: string[]; static_coins?: string[];
excluded_coins?: string[]; // 排除的币种列表 excluded_coins?: string[]; // 排除的币种列表
use_ai500: boolean; use_ai500: boolean;
@@ -114,6 +114,12 @@ export interface CoinSourceConfig {
oi_top_limit?: number; oi_top_limit?: number;
use_oi_low: boolean; use_oi_low: boolean;
oi_low_limit?: number; oi_low_limit?: number;
use_hyper_all?: boolean;
use_hyper_main?: boolean;
hyper_main_limit?: number;
hyper_rank_category?: 'stock' | 'commodity' | 'index' | 'forex' | 'pre_ipo' | 'crypto' | 'all';
hyper_rank_direction?: 'gainers' | 'losers' | 'volume';
hyper_rank_limit?: number;
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig // Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
} }