mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
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:
@@ -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,
|
||||
|
||||
28
api/handler_trader_symbol_test.go
Normal file
28
api/handler_trader_symbol_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
kernel/hyperliquid_rank_test.go
Normal file
15
kernel/hyperliquid_rank_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
26
market/data_hyperliquid_xyz_test.go
Normal file
26
market/data_hyperliquid_xyz_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
18
trader/hyperliquid/symbol_conversion_test.go
Normal file
18
trader/hyperliquid/symbol_conversion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user