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"`
Display string `json:"display"`
Name string `json:"name"` Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index 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"` 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 {
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
displaySymbol := displayBase + "-USDC"
tradeSymbol := baseSymbol + "-USDC"
symbols = append(symbols, SymbolInfo{ symbols = append(symbols, SymbolInfo{
Symbol: symbol, Symbol: tradeSymbol,
Name: symbol, Display: displaySymbol,
Category: "crypto", 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" {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
} }
for _, coin := range coins {
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"
@@ -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,10 +19,34 @@ 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
@@ -51,75 +77,104 @@ func GetProvider() *CoinProvider {
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
}
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, 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()

View File

@@ -239,12 +239,108 @@ type AssetInfo struct {
// 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
} }