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"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
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
func (s *Server) handleSymbols(c *gin.Context) {
exchange := c.DefaultQuery("exchange", "hyperliquid")
type SymbolInfo struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index
MaxLeverage int `json:"maxLeverage,omitempty"`
Symbol string `json:"symbol"`
Display string `json:"display"`
Name string `json:"name"`
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
switch strings.ToLower(exchange) {
exchangeLower := strings.ToLower(exchange)
switch exchangeLower {
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Fetch symbols from Hyperliquid
client := hyperliquid.NewClient()
ctx := context.Background()
// Get crypto perps from default dex
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
mids, err := client.GetAllMids(ctx)
if err == nil {
for symbol := range mids {
// Skip spot tokens (start with @)
if strings.HasPrefix(symbol, "@") {
continue
}
symbols = append(symbols, SymbolInfo{
Symbol: symbol,
Name: symbol,
Category: "crypto",
})
}
// hyperliquid-xyz returns the full USDC trading board in product order:
// stocks → commodities → indices → forex → pre-IPO → crypto.
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
if err != nil {
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
return
}
for _, coin := range xyzCoins {
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
displaySymbol := displayBase + "-USDC"
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)
xyzMids, err := client.GetAllMidsXYZ(ctx)
if err == nil {
for symbol := range xyzMids {
// Remove xyz: prefix for display
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
category := "stock"
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
}
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
if err != nil {
SafeInternalError(c, "Get Hyperliquid symbols", err)
return
}
for _, coin := range coins {
symbols = append(symbols, SymbolInfo{
Symbol: displaySymbol,
Name: displaySymbol,
Category: category,
Symbol: coin.Symbol,
Display: coin.Symbol,
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
}
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{
"exchange": exchange,
"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"
"math"
"nofx/logger"
"nofx/provider/hyperliquid"
"strconv"
"strings"
"sync"
@@ -229,7 +230,7 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
currentRSI7 := calculateRSI(primaryKlines, 7)
// Calculate price changes
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4 hours
// Get OI data
@@ -540,6 +541,9 @@ var xyzDexAssets = map[string]bool{
// IsXyzDexAsset checks if a symbol is an xyz dex asset
func IsXyzDexAsset(symbol string) bool {
base := strings.ToUpper(symbol)
if strings.HasSuffix(base, "-USDC") || strings.HasPrefix(strings.ToLower(base), "xyz:") {
return hyperliquid.IsXYZAsset(base)
}
// Remove any prefix/suffix
base = strings.TrimPrefix(base, "XYZ:")
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
@@ -548,7 +552,7 @@ func IsXyzDexAsset(symbol string) bool {
break
}
}
return xyzDexAssets[base]
return xyzDexAssets[base] || hyperliquid.IsXYZAsset(base)
}
// 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
func Normalize(symbol string) string {
symbol = strings.ToUpper(symbol)
if strings.HasSuffix(symbol, "-USDC") {
return hyperliquid.FormatCoinForAPI(symbol)
}
// Check if this is an xyz dex asset
if IsXyzDexAsset(symbol) {
// Remove any xyz: prefix (case-insensitive) and USDT suffix, then add xyz: prefix
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
return hyperliquid.FormatCoinForAPI(symbol)
}
// 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"
"nofx/logger"
"sort"
"strconv"
"strings"
"sync"
"time"
)
@@ -17,19 +19,43 @@ const (
cacheDuration = 24 * time.Hour // Cache for 24 hours
)
// CoinInfo represents basic coin information
// CoinInfo represents basic Hyperliquid market information.
type CoinInfo struct {
Symbol string `json:"symbol"`
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
Symbol string `json:"symbol"`
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
type CoinProvider struct {
mu sync.RWMutex
allCoins []CoinInfo
mainCoins []CoinInfo
lastUpdated time.Time
httpClient *http.Client
mu sync.RWMutex
allCoins []CoinInfo
mainCoins []CoinInfo
lastUpdated time.Time
httpClient *http.Client
}
var (
@@ -50,76 +76,105 @@ func GetProvider() *CoinProvider {
// metaResponse represents the response from Hyperliquid meta endpoint
type metaResponse struct {
Universe []struct {
Name string `json:"name"`
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
} `json:"universe"`
}
// assetCtx represents asset context with volume data
// assetCtx represents asset context with market data.
type assetCtx struct {
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 (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,
func fetchPerpDexCoins(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
reqPayload := map[string]string{"type": "metaAndAssetCtxs"}
if dex != "" {
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,
bytes.NewReader(reqBody))
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")
resp, err := p.httpClient.Do(req)
resp, err := client.Do(req)
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()
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...]]
var rawResp []json.RawMessage
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 {
return fmt.Errorf("unexpected response format")
return nil, 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)
return nil, 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)
return nil, fmt.Errorf("failed to parse asset contexts: %w", err)
}
// Build coin list with volume
var coins []CoinInfo
coins := make([]CoinInfo, 0, len(meta.Universe))
for i, u := range meta.Universe {
var vol float64
var vol, mark, prevDay, change24hPct float64
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{
Symbol: u.Name,
Volume24h: vol,
Symbol: u.Name,
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 {
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()
defer p.mu.Unlock()
@@ -134,7 +189,7 @@ func (p *CoinProvider) fetchCoins(ctx context.Context) error {
p.lastUpdated = time.Now()
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
return nil
}
@@ -195,7 +250,7 @@ func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol
@@ -209,7 +264,7 @@ func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
if err != nil {
return nil, err
}
symbols := make([]string, len(coins))
for i, c := range coins {
symbols[i] = c.Symbol

View File

@@ -18,16 +18,16 @@ const (
// Candle represents a single OHLCV candle from Hyperliquid
type Candle struct {
OpenTime int64 `json:"t"` // Open time in milliseconds
CloseTime int64 `json:"T"` // Close time in milliseconds
Symbol string `json:"s"` // Coin symbol
Interval string `json:"i"` // Interval
Open string `json:"o"` // Open price
High string `json:"h"` // High price
Low string `json:"l"` // Low price
Close string `json:"c"` // Close price
Volume string `json:"v"` // Volume in base unit
TradeCount int `json:"n"` // Number of trades
OpenTime int64 `json:"t"` // Open time in milliseconds
CloseTime int64 `json:"T"` // Close time in milliseconds
Symbol string `json:"s"` // Coin symbol
Interval string `json:"i"` // Interval
Open string `json:"o"` // Open price
High string `json:"h"` // High price
Low string `json:"l"` // Low price
Close string `json:"c"` // Close price
Volume string `json:"v"` // Volume in base unit
TradeCount int `json:"n"` // Number of trades
}
// CandleRequest represents the request for candleSnapshot
@@ -230,21 +230,117 @@ type Meta struct {
// AssetInfo represents information about a single asset
type AssetInfo struct {
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
Name string `json:"name"`
SzDecimals int `json:"szDecimals"`
MaxLeverage int `json:"maxLeverage"`
}
// NormalizeCoin normalizes coin name for Hyperliquid API
// Examples:
// - "BTCUSDT" -> "BTC"
// - "TSLA-USDC" -> "TSLA"
// - "TESLA-USDC" -> "TSLA"
// - "SAMSUNG-USDC" -> "SMSN"
// - "xyz:TSLA" -> "TSLA"
// - "BTC" -> "BTC"
func NormalizeCoin(symbol string) string {
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
func MapTimeframe(interval string) string {
switch interval {
@@ -379,18 +475,26 @@ func IsXYZAsset(symbol string) bool {
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
}
// NormalizeCoinBase removes common suffixes to get base symbol
func NormalizeCoinBase(symbol string) string {
symbol = strings.ToUpper(strings.TrimSpace(symbol))
hasXYZPrefix := strings.HasPrefix(symbol, "XYZ:")
// Remove xyz: prefix if present
if strings.HasPrefix(symbol, "xyz:") {
return strings.TrimPrefix(symbol, "xyz:")
if hasXYZPrefix {
return NormalizeXYZAlias(strings.TrimPrefix(symbol, "XYZ:"))
}
// Remove -USDC suffix
if strings.HasSuffix(symbol, "-USDC") {
return strings.TrimSuffix(symbol, "-USDC")
return NormalizeXYZAlias(strings.TrimSuffix(symbol, "-USDC"))
}
// Remove USDT suffix
if strings.HasSuffix(symbol, "USDT") {
@@ -400,14 +504,15 @@ func NormalizeCoinBase(symbol string) string {
if strings.HasSuffix(symbol, "USD") {
return strings.TrimSuffix(symbol, "USD")
}
return symbol
return NormalizeXYZAlias(symbol)
}
// FormatCoinForAPI formats the coin name for Hyperliquid API
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol
func FormatCoinForAPI(symbol string) string {
hasExplicitXYZ := strings.HasPrefix(strings.ToLower(strings.TrimSpace(symbol)), "xyz:")
base := NormalizeCoinBase(symbol)
if IsXYZAsset(base) {
if hasExplicitXYZ || IsXYZAsset(base) {
return "xyz:" + base
}
return base

View File

@@ -159,6 +159,10 @@ func TestNormalizeCoin(t *testing.T) {
{"BTCUSDT", "BTC"},
{"BTCUSD", "BTC"},
{"TSLA-USDC", "TSLA"},
{"TESLA-USDC", "TSLA"},
{"SMSN-USDC", "SMSN"},
{"SAMSUNG-USDC", "SMSN"},
{"xyz:SMSN", "SMSN"},
{"AAPL-USDC", "AAPL"},
{"ETH", "ETH"},
{"ETHUSDT", "ETH"},
@@ -204,6 +208,10 @@ func TestFormatCoinForAPI(t *testing.T) {
{"ETH", "ETH"},
{"TSLA", "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"},
{"NVDA", "xyz:NVDA"},
{"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'
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 = {
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> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`

View File

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

View File

@@ -105,7 +105,7 @@ export interface GridStrategyConfig {
}
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[];
excluded_coins?: string[]; // 排除的币种列表
use_ai500: boolean;
@@ -114,6 +114,12 @@ export interface CoinSourceConfig {
oi_top_limit?: number;
use_oi_low: boolean;
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
}