From 47bff879667f0aee127fd3547a2011cb623fa738 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Mon, 29 Dec 2025 22:16:48 +0800 Subject: [PATCH] feat: add xyz dex balance calculation, market data providers, and UI improvements - Fix xyz dex balance calculation (use marginSummary for isolated margin) - Add Alpaca provider for US stocks market data - Add TwelveData provider for forex & metals market data - Add Hyperliquid kline provider - Centralize API keys in config system - Add builder fee for order routing - Improve chart UI with compact design - Fix position history fee display precision - Add comprehensive balance calculation tests --- api/server.go | 267 ++++- config/config.go | 10 + decision/engine.go | 5 +- go.mod | 15 +- go.sum | 15 + market/data.go | 159 ++- provider/alpaca/kline.go | 171 +++ provider/alpaca/kline_test.go | 35 + provider/coinank/coinank_api/kline_test.go | 32 + provider/coinank/kline_test.go | 34 + provider/hyperliquid/kline.go | 414 +++++++ provider/hyperliquid/kline_test.go | 219 ++++ provider/twelvedata/kline.go | 271 +++++ trader/auto_trader.go | 20 +- trader/balance_test.go | 295 +++++ trader/hyperliquid_trader.go | 1000 ++++++++++++++--- trader/xyz_dex_test.go | 669 +++++++++++ web/src/components/AdvancedChart.tsx | 343 ++++-- web/src/components/ChartTabs.tsx | 245 +++- web/src/components/PositionHistory.tsx | 6 +- .../components/strategy/CoinSourceEditor.tsx | 31 +- 21 files changed, 3863 insertions(+), 393 deletions(-) create mode 100644 provider/alpaca/kline.go create mode 100644 provider/alpaca/kline_test.go create mode 100644 provider/hyperliquid/kline.go create mode 100644 provider/hyperliquid/kline_test.go create mode 100644 provider/twelvedata/kline.go create mode 100644 trader/balance_test.go create mode 100644 trader/xyz_dex_test.go diff --git a/api/server.go b/api/server.go index 4790d2a8..6b56a848 100644 --- a/api/server.go +++ b/api/server.go @@ -13,8 +13,11 @@ import ( "nofx/logger" "nofx/manager" "nofx/market" + "nofx/provider/alpaca" "nofx/provider/coinank/coinank_api" "nofx/provider/coinank/coinank_enum" + "nofx/provider/hyperliquid" + "nofx/provider/twelvedata" "nofx/store" "nofx/trader" "strconv" @@ -122,6 +125,7 @@ func (s *Server) setupRoutes() { // Market data (no authentication required) api.GET("/klines", s.handleKlines) + api.GET("/symbols", s.handleSymbols) // Authentication related routes (no authentication required) api.POST("/register", s.handleRegister) @@ -2357,20 +2361,52 @@ func (s *Server) handleKlines(c *gin.Context) { limit = 1500 } - // Normalize symbol (add USDT suffix if not present) - symbol = market.Normalize(symbol) - - // Use CoinAnk API for all exchanges (no more Binance API or WebSocket cache) var klines []market.Kline + exchangeLower := strings.ToLower(exchange) - // All data now comes from CoinAnk - klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) - if err != nil { - logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err), - }) - return + // Route to appropriate data source based on exchange type + switch exchangeLower { + case "alpaca": + // US Stocks via Alpaca + klines, err = s.getKlinesFromAlpaca(symbol, interval, limit) + if err != nil { + logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err), + }) + return + } + case "forex", "metals": + // Forex and Metals via Twelve Data + klines, err = s.getKlinesFromTwelveData(symbol, interval, limit) + if err != nil { + logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err), + }) + return + } + case "hyperliquid", "hyperliquid-xyz", "xyz": + // Hyperliquid native API - supports both crypto perps and stock perps (xyz dex) + klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit) + if err != nil { + logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err), + }) + return + } + default: + // Crypto exchanges via CoinAnk + symbol = market.Normalize(symbol) + klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) + if err != nil { + logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err), + }) + return + } } c.JSON(http.StatusOK, klines) @@ -2389,8 +2425,6 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i coinankExchange = coinank_enum.Okex case "bitget": coinankExchange = coinank_enum.Bitget - case "hyperliquid": - coinankExchange = coinank_enum.Hyperliquid case "aster": coinankExchange = coinank_enum.Aster case "lighter": @@ -2480,22 +2514,210 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i } // Convert coinank kline format to market.Kline format + // Coinank: Volume = BTC 数量, Quantity = USDT 成交额 klines := make([]market.Kline, len(coinankKlines)) for i, ck := range coinankKlines { klines[i] = market.Kline{ - OpenTime: ck.StartTime, - Open: ck.Open, - High: ck.High, - Low: ck.Low, - Close: ck.Close, - Volume: ck.Volume, - CloseTime: ck.EndTime, + OpenTime: ck.StartTime, + Open: ck.Open, + High: ck.High, + Low: ck.Low, + Close: ck.Close, + Volume: ck.Volume, // BTC 数量 + QuoteVolume: ck.Quantity, // USDT 成交额 + CloseTime: ck.EndTime, } } return klines, nil } +// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks +func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) { + // Create Alpaca client + client := alpaca.NewClient() + + // Map interval to Alpaca timeframe format + timeframe := alpaca.MapTimeframe(interval) + + // Fetch bars from Alpaca + ctx := context.Background() + bars, err := client.GetBars(ctx, symbol, timeframe, limit) + if err != nil { + return nil, fmt.Errorf("alpaca API error: %w", err) + } + + // Convert Alpaca bars to market.Kline format + klines := make([]market.Kline, len(bars)) + for i, bar := range bars { + klines[i] = market.Kline{ + OpenTime: bar.Timestamp.UnixMilli(), + Open: bar.Open, + High: bar.High, + Low: bar.Low, + Close: bar.Close, + Volume: float64(bar.Volume), // 股数 + QuoteVolume: float64(bar.Volume) * bar.Close, // 成交额 = 股数 * 收盘价 (USD) + CloseTime: bar.Timestamp.UnixMilli(), + } + } + + return klines, nil +} + +// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals +func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) { + // Create Twelve Data client + client := twelvedata.NewClient() + + // Map interval to Twelve Data timeframe format + timeframe := twelvedata.MapTimeframe(interval) + + // Fetch time series from Twelve Data + ctx := context.Background() + result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit) + if err != nil { + return nil, fmt.Errorf("twelvedata API error: %w", err) + } + + // Convert Twelve Data bars to market.Kline format + // Note: Twelve Data returns bars in reverse order (newest first) + klines := make([]market.Kline, len(result.Values)) + for i, bar := range result.Values { + open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar) + if err != nil { + logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err) + continue + } + + // Reverse order: put oldest first + idx := len(result.Values) - 1 - i + klines[idx] = market.Kline{ + OpenTime: timestamp, + Open: open, + High: high, + Low: low, + Close: close, + Volume: volume, + CloseTime: timestamp, + } + } + + return klines, nil +} + +// getKlinesFromHyperliquid fetches kline data from Hyperliquid API +// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex) +func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) { + // Create Hyperliquid client + client := hyperliquid.NewClient() + + // Map interval to Hyperliquid format + timeframe := hyperliquid.MapTimeframe(interval) + + // Fetch candles from Hyperliquid + // FormatCoinForAPI will automatically add xyz: prefix for stock perps + ctx := context.Background() + candles, err := client.GetCandles(ctx, symbol, timeframe, limit) + if err != nil { + return nil, fmt.Errorf("hyperliquid API error: %w", err) + } + + // Convert Hyperliquid candles to market.Kline format + klines := make([]market.Kline, len(candles)) + for i, candle := range candles { + open, _ := strconv.ParseFloat(candle.Open, 64) + high, _ := strconv.ParseFloat(candle.High, 64) + low, _ := strconv.ParseFloat(candle.Low, 64) + close, _ := strconv.ParseFloat(candle.Close, 64) + volume, _ := strconv.ParseFloat(candle.Volume, 64) + + klines[i] = market.Kline{ + OpenTime: candle.OpenTime, + Open: open, + High: high, + Low: low, + Close: close, + Volume: volume, // 合约数量 + QuoteVolume: volume * close, // 成交额 (USD) + CloseTime: candle.CloseTime, + } + } + + return klines, nil +} + +// 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"` + } + + var symbols []SymbolInfo + + switch strings.ToLower(exchange) { + 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", + }) + } + } + } + + // 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" + } + symbols = append(symbols, SymbolInfo{ + Symbol: displaySymbol, + Name: displaySymbol, + Category: category, + }) + } + } + + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "exchange": exchange, + "symbols": symbols, + "count": len(symbols), + }) +} + // handleDecisions Decision log list func (s *Server) handleDecisions(c *gin.Context) { _, traderID, err := s.getTraderFromQuery(c) @@ -3039,6 +3261,9 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) { {ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"}, {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"}, {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"}, + {ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"}, + {ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"}, + {ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"}, } c.JSON(http.StatusOK, supportedExchanges) diff --git a/config/config.go b/config/config.go index e22c9250..33c58f19 100644 --- a/config/config.go +++ b/config/config.go @@ -29,6 +29,11 @@ type Config struct { // Helps us understand product usage and improve the experience // Set EXPERIENCE_IMPROVEMENT=false to disable ExperienceImprovement bool + + // Market data provider API keys + AlpacaAPIKey string // Alpaca API key for US stocks + AlpacaSecretKey string // Alpaca secret key + TwelveDataKey string // TwelveData API key for forex & metals } // Init initializes global configuration (from .env) @@ -76,6 +81,11 @@ func Init() { cfg.ExperienceImprovement = strings.ToLower(v) != "false" } + // Market data provider API keys + cfg.AlpacaAPIKey = os.Getenv("ALPACA_API_KEY") + cfg.AlpacaSecretKey = os.Getenv("ALPACA_SECRET_KEY") + cfg.TwelveDataKey = os.Getenv("TWELVEDATA_API_KEY") + global = cfg // Initialize experience improvement (installation ID will be set after database init) diff --git a/decision/engine.go b/decision/engine.go index eeb16551..bafa320b 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -354,9 +354,10 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { continue } - // Liquidity filter + // Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance) isExistingPosition := positionSymbols[coin.Symbol] - if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { + isXyzAsset := market.IsXyzDexAsset(coin.Symbol) + if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 { oiValue := data.OpenInterest.Latest * data.CurrentPrice oiValueInMillions := oiValue / 1_000_000 if oiValueInMillions < minOIThresholdMillions { diff --git a/go.mod b/go.mod index 5ead4589..820e55d4 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,11 @@ module nofx -go 1.25.0 +go 1.25.3 require ( github.com/adshao/go-binance/v2 v2.8.9 github.com/agiledragon/gomonkey/v2 v2.13.0 - github.com/ethereum/go-ethereum v1.16.5 + github.com/ethereum/go-ethereum v1.16.7 github.com/gin-gonic/gin v1.11.0 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-jwt/jwt/v5 v5.2.0 @@ -15,13 +15,14 @@ require ( github.com/pquerna/otp v1.4.0 github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 - github.com/sonirico/go-hyperliquid v0.17.0 + github.com/sonirico/go-hyperliquid v0.26.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.42.0 modernc.org/sqlite v1.40.0 ) require ( + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bitly/go-simplejson v0.5.1 // indirect github.com/bits-and-blooms/bitset v1.24.0 // indirect @@ -33,7 +34,7 @@ require ( github.com/consensys/gnark-crypto v0.19.0 // indirect github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect @@ -64,18 +65,18 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/procfs v0.17.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sonirico/vago v0.9.0 // indirect + github.com/sonirico/vago v0.10.0 // indirect github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect github.com/supranational/blst v0.3.16 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/valyala/fastjson v1.6.4 // indirect + github.com/valyala/fastjson v1.6.7 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect diff --git a/go.sum b/go.sum index 15ee1f95..9c9a8987 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU= @@ -36,6 +38,8 @@ github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwz github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= @@ -56,6 +60,8 @@ github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3 github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs= github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0= github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok= +github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ= +github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk= github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= @@ -160,6 +166,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= @@ -187,8 +195,12 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4= github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w= +github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0= +github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I= github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo= github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0= +github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc= +github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc= github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY= github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -215,6 +227,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= +github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -261,6 +275,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA= gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ= +gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/market/data.go b/market/data.go index a92cecee..a9b8edd0 100644 --- a/market/data.go +++ b/market/data.go @@ -5,10 +5,11 @@ import ( "encoding/json" "fmt" "io" + "math" "nofx/logger" "nofx/provider/coinank/coinank_api" "nofx/provider/coinank/coinank_enum" - "math" + "nofx/provider/hyperliquid" "strconv" "strings" "sync" @@ -92,16 +93,70 @@ func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) { return klines, nil } +// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets +func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) { + // Remove xyz: prefix if present for the API call + baseCoin := strings.TrimPrefix(symbol, "xyz:") + + // Map interval to Hyperliquid format + hlInterval := hyperliquid.MapTimeframe(interval) + + // Create Hyperliquid client + client := hyperliquid.NewClient() + + // Fetch candles + ctx := context.Background() + candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit) + if err != nil { + return nil, fmt.Errorf("Hyperliquid API error: %w", err) + } + + // Convert to market.Kline format + klines := make([]Kline, len(candles)) + for i, c := range candles { + open, _ := strconv.ParseFloat(c.Open, 64) + high, _ := strconv.ParseFloat(c.High, 64) + low, _ := strconv.ParseFloat(c.Low, 64) + closePrice, _ := strconv.ParseFloat(c.Close, 64) + volume, _ := strconv.ParseFloat(c.Volume, 64) + + klines[i] = Kline{ + OpenTime: c.OpenTime, + Open: open, + High: high, + Low: low, + Close: closePrice, + Volume: volume, + CloseTime: c.CloseTime, + } + } + + return klines, nil +} + // Get retrieves market data for the specified token func Get(symbol string) (*Data, error) { var klines3m, klines4h []Kline var err error // Normalize symbol symbol = Normalize(symbol) - // Get 3-minute K-line data from CoinAnk (get 100 for calculation) - klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100) - if err != nil { - return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err) + + // Check if this is an xyz dex asset (use Hyperliquid API) + isXyzAsset := IsXyzDexAsset(symbol) + + // Get 3-minute K-line data (or 5-minute for xyz assets as 3m may not be available) + if isXyzAsset { + // Use Hyperliquid API for xyz dex assets (use 5m since 3m may not be available) + klines3m, err = getKlinesFromHyperliquid(symbol, "5m", 100) + if err != nil { + return nil, fmt.Errorf("Failed to get 5-minute K-line from Hyperliquid: %v", err) + } + } else { + // Use CoinAnk for regular crypto assets + klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100) + if err != nil { + return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err) + } } // Data staleness detection: Prevent DOGEUSDT-style price freeze issues @@ -110,10 +165,17 @@ func Get(symbol string) (*Data, error) { return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol) } - // Get 4-hour K-line data from CoinAnk (get 100 for indicator calculation) - klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100) - if err != nil { - return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err) + // Get 4-hour K-line data + if isXyzAsset { + klines4h, err = getKlinesFromHyperliquid(symbol, "4h", 100) + if err != nil { + return nil, fmt.Errorf("Failed to get 4-hour K-line from Hyperliquid: %v", err) + } + } else { + klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100) + if err != nil { + return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err) + } } // Check if data is empty @@ -212,12 +274,28 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri timeframeData := make(map[string]*TimeframeSeriesData) var primaryKlines []Kline - // Get K-line data for each timeframe from CoinAnk + // Check if this is an xyz dex asset (use Hyperliquid API) + isXyzAsset := IsXyzDexAsset(symbol) + + // Get K-line data for each timeframe for _, tf := range timeframes { - klines, err := getKlinesFromCoinAnk(symbol, tf, 200) // Get enough data for indicators - if err != nil { - logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err) - continue + var klines []Kline + var err error + + if isXyzAsset { + // Use Hyperliquid API for xyz dex assets + klines, err = getKlinesFromHyperliquid(symbol, tf, 200) + if err != nil { + logger.Infof("⚠️ Failed to get %s %s K-line from Hyperliquid: %v", symbol, tf, err) + continue + } + } else { + // Use CoinAnk for regular crypto assets + klines, err = getKlinesFromCoinAnk(symbol, tf, 200) + if err != nil { + logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err) + continue + } } if len(klines) == 0 { @@ -937,9 +1015,60 @@ func formatFloatSlice(values []float64) string { return "[" + strings.Join(strValues, ", ") + "]" } -// Normalize normalizes symbol, ensures it's a USDT trading pair +// xyz dex assets that should NOT get USDT suffix +var xyzDexAssets = map[string]bool{ + // Stocks + "TSLA": true, "NVDA": true, "AAPL": true, "MSFT": true, "META": true, + "AMZN": true, "GOOGL": true, "AMD": true, "COIN": true, "NFLX": true, + "PLTR": true, "HOOD": true, "INTC": true, "MSTR": true, "TSM": true, + "ORCL": true, "MU": true, "RIVN": true, "COST": true, "LLY": true, + "CRCL": true, "SKHX": true, "SNDK": true, + // Forex + "EUR": true, "JPY": true, + // Commodities + "GOLD": true, "SILVER": true, + // Index + "XYZ100": true, +} + +// IsXyzDexAsset checks if a symbol is an xyz dex asset +func IsXyzDexAsset(symbol string) bool { + base := strings.ToUpper(symbol) + // Remove any prefix/suffix + base = strings.TrimPrefix(base, "XYZ:") + for _, suffix := range []string{"USDT", "USD", "-USDC"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } + } + return xyzDexAssets[base] +} + +// Normalize normalizes symbol +// For crypto: ensures it's a USDT trading pair +// For xyz dex assets (stocks, forex, commodities): uses xyz: prefix without USDT suffix func Normalize(symbol string) string { symbol = strings.ToUpper(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 + } + + // For regular crypto assets if strings.HasSuffix(symbol, "USDT") { return symbol } diff --git a/provider/alpaca/kline.go b/provider/alpaca/kline.go new file mode 100644 index 00000000..71d07f4c --- /dev/null +++ b/provider/alpaca/kline.go @@ -0,0 +1,171 @@ +package alpaca + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "nofx/config" + "time" +) + +const ( + DataAPIURL = "https://data.alpaca.markets/v2" +) + +// Bar represents a single OHLCV bar from Alpaca +type Bar struct { + Timestamp time.Time `json:"t"` + Open float64 `json:"o"` + High float64 `json:"h"` + Low float64 `json:"l"` + Close float64 `json:"c"` + Volume uint64 `json:"v"` + TradeCount uint64 `json:"n"` + VWAP float64 `json:"vw"` +} + +// BarsResponse represents the response from Alpaca bars API +type BarsResponse struct { + Bars []Bar `json:"bars"` + Symbol string `json:"symbol"` + NextPageToken string `json:"next_page_token"` +} + +// Client is the Alpaca API client +type Client struct { + apiKey string + secretKey string + client *http.Client +} + +// NewClient creates a new Alpaca client from config +func NewClient() *Client { + cfg := config.Get() + return &Client{ + apiKey: cfg.AlpacaAPIKey, + secretKey: cfg.AlpacaSecretKey, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// NewClientWithKeys creates a new Alpaca client with provided keys +func NewClientWithKeys(apiKey, secretKey string) *Client { + return &Client{ + apiKey: apiKey, + secretKey: secretKey, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// GetBars fetches historical bars for a symbol +// timeframe: 1Min, 5Min, 15Min, 30Min, 1Hour, 4Hour, 1Day, 1Week, 1Month +func (c *Client) GetBars(ctx context.Context, symbol string, timeframe string, limit int) ([]Bar, error) { + if c.apiKey == "" || c.secretKey == "" { + return nil, fmt.Errorf("alpaca API keys not configured") + } + + // Build URL + endpoint := fmt.Sprintf("%s/stocks/%s/bars", DataAPIURL, symbol) + params := url.Values{} + params.Set("timeframe", timeframe) + params.Set("limit", fmt.Sprintf("%d", limit)) + params.Set("adjustment", "raw") + params.Set("feed", "iex") // Use IEX feed (free tier) + + // Set time range: last 30 days for intraday, last 2 years for daily + now := time.Now() + var start time.Time + switch timeframe { + case "1Day", "1Week", "1Month": + start = now.AddDate(-2, 0, 0) // 2 years back + default: + start = now.AddDate(0, 0, -30) // 30 days back for intraday + } + params.Set("start", start.Format(time.RFC3339)) + params.Set("end", now.Format(time.RFC3339)) + + fullURL := endpoint + "?" + params.Encode() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set auth headers + req.Header.Set("APCA-API-KEY-ID", c.apiKey) + req.Header.Set("APCA-API-SECRET-KEY", c.secretKey) + + // Execute request + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("alpaca API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse response + var result BarsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return result.Bars, nil +} + +// MapTimeframe maps common timeframe strings to Alpaca format +func MapTimeframe(interval string) string { + switch interval { + case "1m": + return "1Min" + case "3m": + return "1Min" // Alpaca doesn't have 3m, use 1m + case "5m": + return "5Min" + case "10m": + return "15Min" // Alpaca doesn't have 10m, use 15m + case "15m": + return "15Min" + case "30m": + return "30Min" + case "1h": + return "1Hour" + case "2h": + return "1Hour" // Alpaca doesn't have 2h, use 1h + case "4h": + return "4Hour" + case "6h": + return "4Hour" // Alpaca doesn't have 6h, use 4h + case "8h": + return "4Hour" // Alpaca doesn't have 8h, use 4h + case "12h": + return "4Hour" // Alpaca doesn't have 12h, use 4h + case "1d": + return "1Day" + case "3d": + return "1Day" // Alpaca doesn't have 3d, use 1d + case "1w": + return "1Week" + case "1M": + return "1Month" + default: + return "5Min" // Default to 5 minutes + } +} diff --git a/provider/alpaca/kline_test.go b/provider/alpaca/kline_test.go new file mode 100644 index 00000000..6425f332 --- /dev/null +++ b/provider/alpaca/kline_test.go @@ -0,0 +1,35 @@ +package alpaca + +import ( + "context" + "fmt" + "testing" +) + +func TestGetBars(t *testing.T) { + client := NewClient() + + resp, err := client.GetBars(context.TODO(), "AAPL", "1Day", 5) + if err != nil { + t.Fatal(err) + } + + t.Log("=== AAPL 日线数据 (Alpaca IEX feed) ===") + for i, bar := range resp { + t.Logf("\n[%d] 时间: %s", i, bar.Timestamp.Format("2006-01-02 15:04:05")) + t.Logf(" Open: %.2f", bar.Open) + t.Logf(" High: %.2f", bar.High) + t.Logf(" Low: %.2f", bar.Low) + t.Logf(" Close: %.2f", bar.Close) + t.Logf(" Volume: %d (股数)", bar.Volume) + t.Logf(" TradeCount: %d (成交笔数)", bar.TradeCount) + t.Logf(" VWAP: %.2f (成交量加权平均价)", bar.VWAP) + + // 计算成交额 + quoteVolume := float64(bar.Volume) * bar.Close + t.Logf(" 成交额: %.2f USD (Volume × Close)", quoteVolume) + } + + fmt.Printf("\n⚠️ 注意:IEX feed 只包含 IEX 交易所的数据,不是完整市场数据\n") + fmt.Printf("完整市场数据需要使用 SIP feed(付费)\n") +} diff --git a/provider/coinank/coinank_api/kline_test.go b/provider/coinank/coinank_api/kline_test.go index 07c66e40..fe0f7ec9 100644 --- a/provider/coinank/coinank_api/kline_test.go +++ b/provider/coinank/coinank_api/kline_test.go @@ -3,6 +3,7 @@ package coinank_api import ( "context" "encoding/json" + "fmt" "nofx/provider/coinank/coinank_enum" "testing" "time" @@ -19,3 +20,34 @@ func TestKline(t *testing.T) { } t.Logf("%s", res) } + +func TestKlineDaily(t *testing.T) { + resp, err := Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, time.Now().UnixMilli(), coinank_enum.To, 5, coinank_enum.Day1) + if err != nil { + t.Fatal(err) + } + + t.Log("=== BTCUSDT 日线 K线数据 (coinank_api 免费接口) ===") + for i, k := range resp { + startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05") + t.Logf("\n[%d] 时间: %s", i, startTime) + t.Logf(" Open: %.2f", k.Open) + t.Logf(" High: %.2f", k.High) + t.Logf(" Low: %.2f", k.Low) + t.Logf(" Close: %.2f", k.Close) + t.Logf(" Volume: %.4f (k[6])", k.Volume) + t.Logf(" Quantity: %.4f (k[7])", k.Quantity) + t.Logf(" Count: %.0f (k[8])", k.Count) + + // 计算验证 + if k.Close > 0 && k.Volume > 0 { + t.Logf(" --- 验证 ---") + t.Logf(" Volume × Close = %.2f", k.Volume*k.Close) + t.Logf(" Quantity / Close = %.4f", k.Quantity/k.Close) + } + } + + // 打印原始 JSON + res, _ := json.MarshalIndent(resp, "", " ") + fmt.Printf("\n原始 JSON:\n%s\n", res) +} diff --git a/provider/coinank/kline_test.go b/provider/coinank/kline_test.go index dff84aa2..d7e690df 100644 --- a/provider/coinank/kline_test.go +++ b/provider/coinank/kline_test.go @@ -3,6 +3,7 @@ package coinank import ( "context" "encoding/json" + "fmt" "nofx/provider/coinank/coinank_enum" "testing" "time" @@ -20,3 +21,36 @@ func TestKline(t *testing.T) { } t.Logf("%s", res) } + +func TestKlineDaily(t *testing.T) { + client := NewCoinankClient(coinank_enum.MainUrl, TestApikey) + resp, err := client.Kline(context.TODO(), "BTCUSDT", coinank_enum.Binance, 0, time.Now().UnixMilli(), 5, coinank_enum.Day1) + if err != nil { + t.Fatal(err) + } + + t.Log("=== BTCUSDT 日线 K线数据 ===") + for i, k := range resp { + startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05") + t.Logf("\n[%d] 时间: %s", i, startTime) + t.Logf(" Open: %.2f", k.Open) + t.Logf(" High: %.2f", k.High) + t.Logf(" Low: %.2f", k.Low) + t.Logf(" Close: %.2f", k.Close) + t.Logf(" Volume: %.2f (k[6])", k.Volume) + t.Logf(" Quantity: %.2f (k[7])", k.Quantity) + t.Logf(" Count: %.0f (k[8])", k.Count) + + // 计算验证 + if k.Close > 0 { + calcQuote := k.Volume * k.Close + t.Logf(" --- 验证 ---") + t.Logf(" Volume × Close = %.2f", calcQuote) + t.Logf(" Quantity / Close = %.2f", k.Quantity/k.Close) + } + } + + // 打印原始 JSON + res, _ := json.MarshalIndent(resp, "", " ") + fmt.Printf("\n原始 JSON:\n%s\n", res) +} diff --git a/provider/hyperliquid/kline.go b/provider/hyperliquid/kline.go new file mode 100644 index 00000000..de682845 --- /dev/null +++ b/provider/hyperliquid/kline.go @@ -0,0 +1,414 @@ +package hyperliquid + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + MainnetAPIURL = "https://api.hyperliquid.xyz/info" + TestnetAPIURL = "https://api.hyperliquid-testnet.xyz/info" +) + +// 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 +} + +// CandleRequest represents the request for candleSnapshot +type CandleRequest struct { + Type string `json:"type"` + Req CandleRequestBody `json:"req"` +} + +// CandleRequestBody represents the body of candleSnapshot request +type CandleRequestBody struct { + Coin string `json:"coin"` + Interval string `json:"interval"` + StartTime int64 `json:"startTime"` + EndTime int64 `json:"endTime"` +} + +// Client is the Hyperliquid API client +type Client struct { + apiURL string + client *http.Client +} + +// NewClient creates a new Hyperliquid client for mainnet +func NewClient() *Client { + return &Client{ + apiURL: MainnetAPIURL, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// NewTestnetClient creates a new Hyperliquid client for testnet +func NewTestnetClient() *Client { + return &Client{ + apiURL: TestnetAPIURL, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// GetCandles fetches historical candlestick data for a symbol +// coin: symbol name (e.g., "BTC", "TSLA", "AAPL", "xyz:TSLA") +// interval: "1m", "5m", "15m", "1h", "4h", "1d" +// limit: number of candles to fetch (max 5000) +func (c *Client) GetCandles(ctx context.Context, coin string, interval string, limit int) ([]Candle, error) { + // Format coin name for API (stock perps need xyz: prefix) + coin = FormatCoinForAPI(coin) + + // Calculate time range based on interval and limit + now := time.Now() + endTime := now.UnixMilli() + + // Calculate start time based on interval + intervalDuration := getIntervalDuration(interval) + startTime := now.Add(-intervalDuration * time.Duration(limit)).UnixMilli() + + // Build request + reqBody := CandleRequest{ + Type: "candleSnapshot", + Req: CandleRequestBody{ + Coin: coin, + Interval: interval, + StartTime: startTime, + EndTime: endTime, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create request + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Execute request + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse response + var candles []Candle + if err := json.Unmarshal(body, &candles); err != nil { + return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(body)) + } + + return candles, nil +} + +// GetAllMids fetches current mid prices for all assets (default perp dex) +func (c *Client) GetAllMids(ctx context.Context) (map[string]string, error) { + return c.GetAllMidsWithDex(ctx, "") +} + +// GetAllMidsXYZ fetches current mid prices for xyz dex (stocks, forex, commodities) +func (c *Client) GetAllMidsXYZ(ctx context.Context) (map[string]string, error) { + return c.GetAllMidsWithDex(ctx, XYZDex) +} + +// GetAllMidsWithDex fetches current mid prices for a specific dex +func (c *Client) GetAllMidsWithDex(ctx context.Context, dex string) (map[string]string, error) { + reqBody := map[string]string{"type": "allMids"} + if dex != "" { + reqBody["dex"] = dex + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) + } + + var mids map[string]string + if err := json.Unmarshal(body, &mids); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return mids, nil +} + +// GetMeta fetches metadata for all perpetual assets +func (c *Client) GetMeta(ctx context.Context) (*Meta, error) { + reqBody := map[string]string{"type": "meta"} + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hyperliquid API error (status %d): %s", resp.StatusCode, string(body)) + } + + var meta Meta + if err := json.Unmarshal(body, &meta); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &meta, nil +} + +// Meta represents the metadata response +type Meta struct { + Universe []AssetInfo `json:"universe"` +} + +// AssetInfo represents information about a single asset +type AssetInfo struct { + 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" +// - "xyz:TSLA" -> "TSLA" +// - "BTC" -> "BTC" +func NormalizeCoin(symbol string) string { + return NormalizeCoinBase(symbol) +} + +// MapTimeframe maps common timeframe strings to Hyperliquid format +func MapTimeframe(interval string) string { + switch interval { + case "1m": + return "1m" + case "3m": + return "5m" // Hyperliquid doesn't have 3m, use 5m + case "5m": + return "5m" + case "15m": + return "15m" + case "30m": + return "30m" + case "1h": + return "1h" + case "2h": + return "1h" // Hyperliquid doesn't have 2h, use 1h + case "4h": + return "4h" + case "6h": + return "4h" // Hyperliquid doesn't have 6h, use 4h + case "8h": + return "8h" + case "12h": + return "12h" + case "1d": + return "1d" + case "3d": + return "1d" // Hyperliquid doesn't have 3d, use 1d + case "1w": + return "1w" + case "1M": + return "1M" + default: + return "5m" // Default to 5 minutes + } +} + +// getIntervalDuration returns the duration for a given interval +func getIntervalDuration(interval string) time.Duration { + switch interval { + case "1m": + return time.Minute + case "5m": + return 5 * time.Minute + case "15m": + return 15 * time.Minute + case "30m": + return 30 * time.Minute + case "1h": + return time.Hour + case "4h": + return 4 * time.Hour + case "8h": + return 8 * time.Hour + case "12h": + return 12 * time.Hour + case "1d": + return 24 * time.Hour + case "1w": + return 7 * 24 * time.Hour + case "1M": + return 30 * 24 * time.Hour + default: + return 5 * time.Minute + } +} + +// XYZ Dex name for stock perps, forex, and commodities +const XYZDex = "xyz" + +// Stock perps symbols available on Hyperliquid xyz dex +// Use xyz:SYMBOL format when calling the API +var StockPerpsSymbols = []string{ + "TSLA", // Tesla + "AAPL", // Apple + "NVDA", // Nvidia + "MSFT", // Microsoft + "META", // Meta + "AMZN", // Amazon + "GOOGL", // Alphabet + "AMD", // AMD + "COIN", // Coinbase + "NFLX", // Netflix + "PLTR", // Palantir + "HOOD", // Robinhood + "INTC", // Intel + "MSTR", // MicroStrategy + "TSM", // TSMC + "ORCL", // Oracle + "MU", // Micron + "RIVN", // Rivian + "COST", // Costco + "LLY", // Eli Lilly + "CRCL", // Circle (new) + "SKHX", // Skyward (new) + "SNDK", // Sandisk (new) +} + +// Forex and commodities on xyz dex +var XYZOtherSymbols = []string{ + "GOLD", // Gold + "SILVER", // Silver + "EUR", // EUR/USD + "JPY", // USD/JPY + "XYZ100", // Index +} + +// IsStockPerp checks if a symbol is a stock perpetual +func IsStockPerp(symbol string) bool { + coin := NormalizeCoinBase(symbol) + for _, s := range StockPerpsSymbols { + if s == coin { + return true + } + } + return false +} + +// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities) +func IsXYZAsset(symbol string) bool { + coin := NormalizeCoinBase(symbol) + // Check stock perps + for _, s := range StockPerpsSymbols { + if s == coin { + return true + } + } + // Check other xyz assets + for _, s := range XYZOtherSymbols { + if s == coin { + return true + } + } + return false +} + +// NormalizeCoinBase removes common suffixes to get base symbol +func NormalizeCoinBase(symbol string) string { + // Remove xyz: prefix if present + if strings.HasPrefix(symbol, "xyz:") { + return strings.TrimPrefix(symbol, "xyz:") + } + // Remove -USDC suffix + if strings.HasSuffix(symbol, "-USDC") { + return strings.TrimSuffix(symbol, "-USDC") + } + // Remove USDT suffix + if strings.HasSuffix(symbol, "USDT") { + return strings.TrimSuffix(symbol, "USDT") + } + // Remove USD suffix + if strings.HasSuffix(symbol, "USD") { + return strings.TrimSuffix(symbol, "USD") + } + return symbol +} + +// FormatCoinForAPI formats the coin name for Hyperliquid API +// Stock perps need xyz:SYMBOL format, crypto uses plain symbol +func FormatCoinForAPI(symbol string) string { + base := NormalizeCoinBase(symbol) + if IsXYZAsset(base) { + return "xyz:" + base + } + return base +} diff --git a/provider/hyperliquid/kline_test.go b/provider/hyperliquid/kline_test.go new file mode 100644 index 00000000..5c4f1743 --- /dev/null +++ b/provider/hyperliquid/kline_test.go @@ -0,0 +1,219 @@ +package hyperliquid + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +func TestGetCandles_BTC(t *testing.T) { + client := NewClient() + + candles, err := client.GetCandles(context.TODO(), "BTC", "1d", 5) + if err != nil { + t.Fatal(err) + } + + t.Log("=== BTC 日线数据 (Hyperliquid) ===") + for i, c := range candles { + openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05") + t.Logf("\n[%d] 时间: %s", i, openTime) + t.Logf(" Symbol: %s", c.Symbol) + t.Logf(" Interval: %s", c.Interval) + t.Logf(" Open: %s", c.Open) + t.Logf(" High: %s", c.High) + t.Logf(" Low: %s", c.Low) + t.Logf(" Close: %s", c.Close) + t.Logf(" Volume: %s", c.Volume) + t.Logf(" TradeCount: %d", c.TradeCount) + } + + // 打印原始 JSON + res, _ := json.MarshalIndent(candles, "", " ") + fmt.Printf("\n原始 JSON:\n%s\n", res) +} + +func TestGetCandles_TSLA(t *testing.T) { + client := NewClient() + + // 测试股票永续合约 - 使用 xyz dex + candles, err := client.GetCandles(context.TODO(), "TSLA", "1d", 5) + if err != nil { + t.Fatal(err) + } + + t.Log("=== TSLA 日线数据 (Hyperliquid xyz dex) ===") + for i, c := range candles { + openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05") + t.Logf("\n[%d] 时间: %s", i, openTime) + t.Logf(" Symbol: %s", c.Symbol) + t.Logf(" Interval: %s", c.Interval) + t.Logf(" Open: %s", c.Open) + t.Logf(" High: %s", c.High) + t.Logf(" Low: %s", c.Low) + t.Logf(" Close: %s", c.Close) + t.Logf(" Volume: %s", c.Volume) + t.Logf(" TradeCount: %d", c.TradeCount) + } + + // 打印原始 JSON + res, _ := json.MarshalIndent(candles, "", " ") + fmt.Printf("\n原始 JSON:\n%s\n", res) +} + +func TestGetCandles_StockPerps(t *testing.T) { + client := NewClient() + + // 测试多个股票永续合约 (xyz dex) + symbols := []string{"TSLA", "NVDA", "AAPL", "MSFT"} + + for _, symbol := range symbols { + t.Logf("\n=== %s 日线数据 ===", symbol) + candles, err := client.GetCandles(context.TODO(), symbol, "1d", 3) + if err != nil { + t.Errorf("%s 获取失败: %v", symbol, err) + continue + } + + if len(candles) == 0 { + t.Logf("%s: 无数据", symbol) + continue + } + + latest := candles[len(candles)-1] + openTime := time.UnixMilli(latest.OpenTime).Format("2006-01-02") + t.Logf("%s 最新: %s Open=%s High=%s Low=%s Close=%s Vol=%s", + symbol, openTime, latest.Open, latest.High, latest.Low, latest.Close, latest.Volume) + } +} + +func TestGetAllMids(t *testing.T) { + client := NewClient() + + mids, err := client.GetAllMids(context.TODO()) + if err != nil { + t.Fatal(err) + } + + t.Log("=== 加密货币资产中间价 (默认 dex) ===") + + // 显示一些主要加密货币资产 + cryptoAssets := []string{"BTC", "ETH", "SOL", "DOGE", "XRP"} + for _, asset := range cryptoAssets { + if mid, ok := mids[asset]; ok { + t.Logf("%s: %s", asset, mid) + } else { + t.Logf("%s: 不存在", asset) + } + } + + t.Logf("\n总共 %d 个加密货币交易对", len(mids)) +} + +func TestGetAllMidsXYZ(t *testing.T) { + client := NewClient() + + mids, err := client.GetAllMidsXYZ(context.TODO()) + if err != nil { + t.Fatal(err) + } + + t.Log("=== xyz dex 资产中间价 (股票、外汇、大宗商品) ===") + + // 显示所有 xyz dex 资产 + for symbol, mid := range mids { + t.Logf("%s: %s", symbol, mid) + } + + t.Logf("\n总共 %d 个 xyz dex 交易对", len(mids)) +} + +func TestGetMeta(t *testing.T) { + client := NewClient() + + meta, err := client.GetMeta(context.TODO()) + if err != nil { + t.Fatal(err) + } + + t.Log("=== 资产元数据 ===") + t.Logf("总共 %d 个资产", len(meta.Universe)) + + // 显示股票永续合约 + t.Log("\n股票永续合约:") + for _, asset := range meta.Universe { + if IsStockPerp(asset.Name) { + t.Logf(" %s: szDecimals=%d, maxLeverage=%d", asset.Name, asset.SzDecimals, asset.MaxLeverage) + } + } +} + +func TestNormalizeCoin(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"BTC", "BTC"}, + {"BTCUSDT", "BTC"}, + {"BTCUSD", "BTC"}, + {"TSLA-USDC", "TSLA"}, + {"AAPL-USDC", "AAPL"}, + {"ETH", "ETH"}, + {"ETHUSDT", "ETH"}, + } + + for _, tt := range tests { + result := NormalizeCoin(tt.input) + if result != tt.expected { + t.Errorf("NormalizeCoin(%s) = %s, expected %s", tt.input, result, tt.expected) + } + } +} + +func TestIsStockPerp(t *testing.T) { + tests := []struct { + symbol string + expected bool + }{ + {"TSLA", true}, + {"TSLA-USDC", true}, + {"xyz:TSLA", true}, + {"AAPL", true}, + {"BTC", false}, + {"BTCUSDT", false}, + {"ETH", false}, + } + + for _, tt := range tests { + result := IsStockPerp(tt.symbol) + if result != tt.expected { + t.Errorf("IsStockPerp(%s) = %v, expected %v", tt.symbol, result, tt.expected) + } + } +} + +func TestFormatCoinForAPI(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"BTC", "BTC"}, + {"BTCUSDT", "BTC"}, + {"ETH", "ETH"}, + {"TSLA", "xyz:TSLA"}, + {"TSLA-USDC", "xyz:TSLA"}, + {"xyz:TSLA", "xyz:TSLA"}, + {"NVDA", "xyz:NVDA"}, + {"GOLD", "xyz:GOLD"}, + {"EUR", "xyz:EUR"}, + } + + for _, tt := range tests { + result := FormatCoinForAPI(tt.input) + if result != tt.expected { + t.Errorf("FormatCoinForAPI(%s) = %s, expected %s", tt.input, result, tt.expected) + } + } +} diff --git a/provider/twelvedata/kline.go b/provider/twelvedata/kline.go new file mode 100644 index 00000000..caa9161a --- /dev/null +++ b/provider/twelvedata/kline.go @@ -0,0 +1,271 @@ +package twelvedata + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "nofx/config" + "strconv" + "time" +) + +const ( + BaseURL = "https://api.twelvedata.com" +) + +// Bar represents a single OHLCV bar from Twelve Data +type Bar struct { + Datetime string `json:"datetime"` + Open string `json:"open"` + High string `json:"high"` + Low string `json:"low"` + Close string `json:"close"` + Volume string `json:"volume,omitempty"` +} + +// TimeSeriesResponse represents the response from Twelve Data time_series API +type TimeSeriesResponse struct { + Meta Meta `json:"meta"` + Values []Bar `json:"values"` + Status string `json:"status"` + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// Meta contains metadata about the time series +type Meta struct { + Symbol string `json:"symbol"` + Interval string `json:"interval"` + CurrencyBase string `json:"currency_base,omitempty"` + CurrencyQuote string `json:"currency_quote,omitempty"` + Type string `json:"type,omitempty"` + Exchange string `json:"exchange,omitempty"` + ExchangeTimezone string `json:"exchange_timezone,omitempty"` +} + +// QuoteResponse represents the response from Twelve Data quote API +type QuoteResponse struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + Exchange string `json:"exchange"` + Open string `json:"open"` + High string `json:"high"` + Low string `json:"low"` + Close string `json:"close"` + PreviousClose string `json:"previous_close"` + Volume string `json:"volume,omitempty"` + Change string `json:"change"` + PercentChange string `json:"percent_change"` + AverageVolume string `json:"average_volume,omitempty"` + FiftyTwoWeekHigh string `json:"fifty_two_week_high,omitempty"` + FiftyTwoWeekLow string `json:"fifty_two_week_low,omitempty"` + Datetime string `json:"datetime"` + Status string `json:"status,omitempty"` + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// Client is the Twelve Data API client +type Client struct { + apiKey string + client *http.Client +} + +// NewClient creates a new Twelve Data client from config +func NewClient() *Client { + return &Client{ + apiKey: config.Get().TwelveDataKey, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// NewClientWithKey creates a new Twelve Data client with provided key +func NewClientWithKey(apiKey string) *Client { + return &Client{ + apiKey: apiKey, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// GetTimeSeries fetches historical bars for a symbol +// interval: 1min, 5min, 15min, 30min, 45min, 1h, 2h, 4h, 1day, 1week, 1month +func (c *Client) GetTimeSeries(ctx context.Context, symbol string, interval string, limit int) (*TimeSeriesResponse, error) { + if c.apiKey == "" { + return nil, fmt.Errorf("twelve data API key not configured") + } + + // Build URL + endpoint := fmt.Sprintf("%s/time_series", BaseURL) + params := url.Values{} + params.Set("symbol", symbol) + params.Set("interval", interval) + params.Set("outputsize", fmt.Sprintf("%d", limit)) + params.Set("apikey", c.apiKey) + + fullURL := endpoint + "?" + params.Encode() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Execute request + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response + var result TimeSeriesResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Check for API errors + if result.Status == "error" { + return nil, fmt.Errorf("twelve data API error: %s", result.Message) + } + + return &result, nil +} + +// GetQuote fetches real-time quote for a symbol +func (c *Client) GetQuote(ctx context.Context, symbol string) (*QuoteResponse, error) { + if c.apiKey == "" { + return nil, fmt.Errorf("twelve data API key not configured") + } + + // Build URL + endpoint := fmt.Sprintf("%s/quote", BaseURL) + params := url.Values{} + params.Set("symbol", symbol) + params.Set("apikey", c.apiKey) + + fullURL := endpoint + "?" + params.Encode() + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Execute request + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + // Read response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response + var result QuoteResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Check for API errors + if result.Status == "error" { + return nil, fmt.Errorf("twelve data API error: %s", result.Message) + } + + return &result, nil +} + +// MapTimeframe maps common timeframe strings to Twelve Data format +func MapTimeframe(interval string) string { + switch interval { + case "1m": + return "1min" + case "3m": + return "5min" // Twelve Data doesn't have 3m, use 5m + case "5m": + return "5min" + case "10m": + return "15min" // Twelve Data doesn't have 10m, use 15m + case "15m": + return "15min" + case "30m": + return "30min" + case "1h": + return "1h" + case "2h": + return "2h" + case "4h": + return "4h" + case "6h": + return "4h" // Twelve Data doesn't have 6h, use 4h + case "8h": + return "4h" // Twelve Data doesn't have 8h, use 4h + case "12h": + return "4h" // Twelve Data doesn't have 12h, use 4h + case "1d": + return "1day" + case "3d": + return "1day" // Twelve Data doesn't have 3d, use 1d + case "1w": + return "1week" + case "1M": + return "1month" + default: + return "5min" // Default to 5 minutes + } +} + +// ParseBar converts a Twelve Data bar to numeric values +func ParseBar(bar Bar) (open, high, low, close, volume float64, timestamp int64, err error) { + open, err = strconv.ParseFloat(bar.Open, 64) + if err != nil { + return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse open: %w", err) + } + high, err = strconv.ParseFloat(bar.High, 64) + if err != nil { + return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse high: %w", err) + } + low, err = strconv.ParseFloat(bar.Low, 64) + if err != nil { + return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse low: %w", err) + } + close, err = strconv.ParseFloat(bar.Close, 64) + if err != nil { + return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse close: %w", err) + } + + // Volume might be empty for forex + if bar.Volume != "" { + volume, _ = strconv.ParseFloat(bar.Volume, 64) + } + + // Parse datetime - format is "2024-01-15 09:30:00" or "2024-01-15" + var t time.Time + if len(bar.Datetime) > 10 { + t, err = time.Parse("2006-01-02 15:04:05", bar.Datetime) + } else { + t, err = time.Parse("2006-01-02", bar.Datetime) + } + if err != nil { + return 0, 0, 0, 0, 0, 0, fmt.Errorf("failed to parse datetime: %w", err) + } + timestamp = t.UnixMilli() + + return open, high, low, close, volume, timestamp, nil +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index e489fc3e..fcb025d7 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -675,6 +675,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { totalWalletBalance := 0.0 totalUnrealizedProfit := 0.0 availableBalance := 0.0 + totalEquity := 0.0 if wallet, ok := balance["totalWalletBalance"].(float64); ok { totalWalletBalance = wallet @@ -686,8 +687,13 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { availableBalance = avail } - // Total Equity = Wallet balance + Unrealized profit - totalEquity := totalWalletBalance + totalUnrealizedProfit + // Use totalEquity directly if provided by trader (more accurate) + if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { + totalEquity = eq + } else { + // Fallback: Total Equity = Wallet balance + Unrealized profit + totalEquity = totalWalletBalance + totalUnrealizedProfit + } // 2. Get position information positions, err := at.trader.GetPositions() @@ -1473,6 +1479,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { totalWalletBalance := 0.0 totalUnrealizedProfit := 0.0 availableBalance := 0.0 + totalEquity := 0.0 if wallet, ok := balance["totalWalletBalance"].(float64); ok { totalWalletBalance = wallet @@ -1484,8 +1491,13 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { availableBalance = avail } - // Total Equity = Wallet balance + Unrealized profit - totalEquity := totalWalletBalance + totalUnrealizedProfit + // Use totalEquity directly if provided by trader (more accurate) + if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { + totalEquity = eq + } else { + // Fallback: Total Equity = Wallet balance + Unrealized profit + totalEquity = totalWalletBalance + totalUnrealizedProfit + } // Get positions to calculate total margin positions, err := at.trader.GetPositions() diff --git a/trader/balance_test.go b/trader/balance_test.go new file mode 100644 index 00000000..45056e72 --- /dev/null +++ b/trader/balance_test.go @@ -0,0 +1,295 @@ +package trader + +import ( + "os" + "testing" + "time" +) + +// TestHyperliquidBalanceCalculation tests the balance calculation for Hyperliquid +// including perp, spot, and xyz dex (stocks, forex, metals) accounts +// Run with: TEST_PRIVATE_KEY=xxx TEST_WALLET_ADDR=xxx go test -v -run TestHyperliquidBalanceCalculation ./trader/ +func TestHyperliquidBalanceCalculation(t *testing.T) { + // Get credentials from environment + privateKeyHex := os.Getenv("TEST_PRIVATE_KEY") + walletAddr := os.Getenv("TEST_WALLET_ADDR") + + if privateKeyHex == "" || walletAddr == "" { + t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required") + } + + t.Logf("=== Testing Hyperliquid Balance Calculation ===") + t.Logf("Wallet: %s", walletAddr) + + // Create trader instance + trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false) + if err != nil { + t.Fatalf("Failed to create trader: %v", err) + } + + // Test GetBalance + t.Log("\n--- Testing GetBalance ---") + balance, err := trader.GetBalance() + if err != nil { + t.Fatalf("GetBalance failed: %v", err) + } + + // Extract values + totalWalletBalance, _ := balance["totalWalletBalance"].(float64) + totalEquity, _ := balance["totalEquity"].(float64) + totalUnrealizedProfit, _ := balance["totalUnrealizedProfit"].(float64) + availableBalance, _ := balance["availableBalance"].(float64) + spotBalance, _ := balance["spotBalance"].(float64) + xyzDexBalance, _ := balance["xyzDexBalance"].(float64) + xyzDexUnrealizedPnl, _ := balance["xyzDexUnrealizedPnl"].(float64) + perpAccountValue, _ := balance["perpAccountValue"].(float64) + + t.Logf("\n📊 Balance Results:") + t.Logf(" Perp Account Value: %.4f USDC", perpAccountValue) + t.Logf(" Spot Balance: %.4f USDC", spotBalance) + t.Logf(" xyz Dex Balance: %.4f USDC", xyzDexBalance) + t.Logf(" xyz Dex Unrealized PnL: %.4f USDC", xyzDexUnrealizedPnl) + t.Logf(" ---") + t.Logf(" Total Wallet Balance: %.4f USDC", totalWalletBalance) + t.Logf(" Total Unrealized PnL: %.4f USDC", totalUnrealizedProfit) + t.Logf(" Total Equity: %.4f USDC", totalEquity) + t.Logf(" Available Balance: %.4f USDC", availableBalance) + + // Verify calculation: totalEquity should equal perpAccountValue + spotBalance + xyzDexBalance + expectedEquity := perpAccountValue + spotBalance + xyzDexBalance + t.Logf("\n🔍 Verification:") + t.Logf(" Expected Equity (Perp + Spot + xyz): %.4f", expectedEquity) + t.Logf(" Actual Total Equity: %.4f", totalEquity) + + if abs(totalEquity-expectedEquity) > 0.01 { + t.Errorf("❌ Equity mismatch! Expected %.4f, got %.4f", expectedEquity, totalEquity) + } else { + t.Logf("✅ Equity calculation correct!") + } + + // Verify: totalWalletBalance + totalUnrealizedProfit should equal totalEquity + calculatedEquity := totalWalletBalance + totalUnrealizedProfit + t.Logf("\n🔍 Secondary Verification:") + t.Logf(" Wallet + Unrealized = %.4f + %.4f = %.4f", totalWalletBalance, totalUnrealizedProfit, calculatedEquity) + t.Logf(" Total Equity: %.4f", totalEquity) + + if abs(calculatedEquity-totalEquity) > 0.01 { + t.Errorf("❌ Secondary check failed! Wallet+Unrealized=%.4f != Equity=%.4f", calculatedEquity, totalEquity) + } else { + t.Logf("✅ Secondary verification passed!") + } + + // Test GetPositions + t.Log("\n--- Testing GetPositions ---") + positions, err := trader.GetPositions() + if err != nil { + t.Fatalf("GetPositions failed: %v", err) + } + + t.Logf("Found %d positions:", len(positions)) + totalPositionValue := 0.0 + totalPositionPnL := 0.0 + + for i, pos := range positions { + symbol, _ := pos["symbol"].(string) + side, _ := pos["side"].(string) + positionAmt, _ := pos["positionAmt"].(float64) + entryPrice, _ := pos["entryPrice"].(float64) + markPrice, _ := pos["markPrice"].(float64) + unrealizedPnL, _ := pos["unRealizedProfit"].(float64) + leverage, _ := pos["leverage"].(float64) + isXyzDex, _ := pos["isXyzDex"].(bool) + + posValue := positionAmt * markPrice + totalPositionValue += posValue + totalPositionPnL += unrealizedPnL + + assetType := "Crypto" + if isXyzDex { + assetType = "xyz Dex" + } + + t.Logf(" [%d] %s (%s)", i+1, symbol, assetType) + t.Logf(" Side: %s, Qty: %.4f, Leverage: %.0fx", side, positionAmt, leverage) + t.Logf(" Entry: %.4f, Mark: %.4f", entryPrice, markPrice) + t.Logf(" Value: %.4f, PnL: %.4f", posValue, unrealizedPnL) + + // Verify xyz dex position has valid entry/mark prices + if isXyzDex { + if entryPrice == 0 { + t.Errorf("❌ xyz dex position %s has zero entry price!", symbol) + } + if markPrice == 0 { + t.Errorf("❌ xyz dex position %s has zero mark price!", symbol) + } + } + } + + t.Logf("\n📊 Position Summary:") + t.Logf(" Total Position Value: %.4f USDC", totalPositionValue) + t.Logf(" Total Position PnL: %.4f USDC", totalPositionPnL) + + // Compare position PnL with balance unrealized PnL + t.Logf("\n🔍 PnL Comparison:") + t.Logf(" Balance Unrealized PnL: %.4f", totalUnrealizedProfit) + t.Logf(" Position Sum PnL: %.4f", totalPositionPnL) + + if abs(totalUnrealizedProfit-totalPositionPnL) > 0.1 { + t.Logf("⚠️ PnL mismatch (may be due to funding fees or timing)") + } else { + t.Logf("✅ PnL values match!") + } +} + +// TestXyzDexBalanceDirectQuery directly queries xyz dex balance for debugging +func TestXyzDexBalanceDirectQuery(t *testing.T) { + privateKeyHex := os.Getenv("TEST_PRIVATE_KEY") + walletAddr := os.Getenv("TEST_WALLET_ADDR") + + if privateKeyHex == "" || walletAddr == "" { + t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required") + } + + trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false) + if err != nil { + t.Fatalf("Failed to create trader: %v", err) + } + + t.Log("=== Direct xyz Dex Balance Query ===") + + accountValue, unrealizedPnl, positions, err := trader.getXYZDexBalance() + if err != nil { + t.Fatalf("getXYZDexBalance failed: %v", err) + } + + t.Logf("xyz Dex Account Value: %.4f", accountValue) + t.Logf("xyz Dex Unrealized PnL: %.4f", unrealizedPnl) + t.Logf("xyz Dex Wallet Balance: %.4f", accountValue-unrealizedPnl) + t.Logf("xyz Dex Positions: %d", len(positions)) + + for i, pos := range positions { + entryPx := "nil" + if pos.Position.EntryPx != nil { + entryPx = *pos.Position.EntryPx + } + liqPx := "nil" + if pos.Position.LiquidationPx != nil { + liqPx = *pos.Position.LiquidationPx + } + + t.Logf(" [%d] %s:", i+1, pos.Position.Coin) + t.Logf(" Size: %s", pos.Position.Szi) + t.Logf(" Entry Price: %s", entryPx) + t.Logf(" Position Value: %s", pos.Position.PositionValue) + t.Logf(" Unrealized PnL: %s", pos.Position.UnrealizedPnl) + t.Logf(" Liquidation Price: %s", liqPx) + t.Logf(" Leverage: %d (%s)", pos.Position.Leverage.Value, pos.Position.Leverage.Type) + } +} + +// TestEquityAfterOpeningPosition simulates opening a position and verifies equity +func TestEquityAfterOpeningPosition(t *testing.T) { + privateKeyHex := os.Getenv("TEST_PRIVATE_KEY") + walletAddr := os.Getenv("TEST_WALLET_ADDR") + + if privateKeyHex == "" || walletAddr == "" { + t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required") + } + + if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" { + t.Skip("Set XYZ_DEX_LIVE_TEST=1 to run live position test") + } + + trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false) + if err != nil { + t.Fatalf("Failed to create trader: %v", err) + } + + // Step 1: Record initial balance + t.Log("=== Step 1: Record Initial Balance ===") + initialBalance, _ := trader.GetBalance() + initialEquity, _ := initialBalance["totalEquity"].(float64) + t.Logf("Initial Equity: %.4f", initialEquity) + + // Step 2: Fetch xyz meta + if err := trader.fetchXyzMeta(); err != nil { + t.Fatalf("Failed to fetch xyz meta: %v", err) + } + + // Step 3: Get current price and place a small order + price, err := trader.getXyzMarketPrice("xyz:SILVER") + if err != nil { + t.Fatalf("Failed to get price: %v", err) + } + t.Logf("Current xyz:SILVER price: %.4f", price) + + // Place a small buy order (minimum ~$10) + testSize := 0.14 + testPrice := price * 1.05 // 5% above for IOC + + t.Log("\n=== Step 2: Place Test Order ===") + t.Logf("Opening position: xyz:SILVER BUY %.4f @ %.4f", testSize, testPrice) + + err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false) + if err != nil { + t.Logf("Order result: %v", err) + // Even if IOC doesn't fill, continue to check balance + } + + // Wait a moment for the order to process + time.Sleep(2 * time.Second) + + // Step 3: Check balance after order + t.Log("\n=== Step 3: Check Balance After Order ===") + afterBalance, _ := trader.GetBalance() + afterEquity, _ := afterBalance["totalEquity"].(float64) + afterPerpAV, _ := afterBalance["perpAccountValue"].(float64) + afterXyzAV, _ := afterBalance["xyzDexBalance"].(float64) + + t.Logf("After Order:") + t.Logf(" Perp Account Value: %.4f", afterPerpAV) + t.Logf(" xyz Dex Balance: %.4f", afterXyzAV) + t.Logf(" Total Equity: %.4f", afterEquity) + + equityChange := afterEquity - initialEquity + t.Logf("\nEquity Change: %.4f (%.2f%%)", equityChange, (equityChange/initialEquity)*100) + + // Equity should not change significantly (only by trading fees/slippage) + if abs(equityChange) > initialEquity*0.05 { // More than 5% change is suspicious + t.Errorf("❌ Equity changed too much! Initial=%.4f, After=%.4f, Change=%.4f", + initialEquity, afterEquity, equityChange) + } else { + t.Logf("✅ Equity change is within acceptable range") + } + + // Step 4: Close position if opened + t.Log("\n=== Step 4: Close Position ===") + positions, _ := trader.GetPositions() + for _, pos := range positions { + symbol, _ := pos["symbol"].(string) + if symbol == "xyz:SILVER" { + posAmt, _ := pos["positionAmt"].(float64) + if posAmt > 0 { + closePrice := price * 0.95 // 5% below for IOC sell + t.Logf("Closing position: SELL %.4f @ %.4f", posAmt, closePrice) + trader.placeXyzOrder("xyz:SILVER", false, posAmt, closePrice, true) + } + } + } + + time.Sleep(2 * time.Second) + + // Final balance check + t.Log("\n=== Step 5: Final Balance ===") + finalBalance, _ := trader.GetBalance() + finalEquity, _ := finalBalance["totalEquity"].(float64) + t.Logf("Final Equity: %.4f", finalEquity) + t.Logf("Net Change: %.4f", finalEquity-initialEquity) +} + +func abs(x float64) float64 { + if x < 0 { + return -x + } + return x +} diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index 4ce86dec..58313b7b 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -1,10 +1,13 @@ package trader import ( + "bytes" "context" "crypto/ecdsa" "encoding/json" "fmt" + "io" + "net/http" "nofx/logger" "strconv" "strings" @@ -23,6 +26,56 @@ type HyperliquidTrader struct { meta *hyperliquid.Meta // Cache meta information (including precision) metaMutex sync.RWMutex // Protect concurrent access to meta field isCrossMargin bool // Whether to use cross margin mode + // xyz dex support (stocks, forex, commodities) + xyzMeta *xyzDexMeta + xyzMetaMutex sync.RWMutex + privateKey *ecdsa.PrivateKey // For xyz dex signing + isTestnet bool +} + +// xyzDexMeta represents metadata for xyz dex assets +type xyzDexMeta struct { + Universe []xyzAssetInfo `json:"universe"` +} + +// xyzAssetInfo represents info for a single xyz dex asset +type xyzAssetInfo struct { + Name string `json:"name"` + SzDecimals int `json:"szDecimals"` + MaxLeverage int `json:"maxLeverage"` +} + +// xyz dex assets (stocks, forex, commodities, index) +// Updated based on actual available assets from xyz dex API +var xyzDexAssets = map[string]bool{ + // Stocks (US equities perpetuals) + "TSLA": true, "NVDA": true, "AAPL": true, "MSFT": true, "META": true, + "AMZN": true, "GOOGL": true, "AMD": true, "COIN": true, "NFLX": true, + "PLTR": true, "HOOD": true, "INTC": true, "MSTR": true, "TSM": true, + "ORCL": true, "MU": true, "RIVN": true, "COST": true, "LLY": true, + "CRCL": true, "SKHX": true, "SNDK": true, + // Forex (currency pairs) + "EUR": true, "JPY": true, + // Commodities (precious metals) + "GOLD": true, "SILVER": true, + // Index + "XYZ100": true, +} + +// isXyzDexAsset checks if a symbol is an xyz dex asset +func isXyzDexAsset(symbol string) bool { + // Remove common suffixes to get base symbol + base := strings.ToUpper(symbol) // Convert to uppercase for case-insensitive matching + for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } + } + // Remove xyz: prefix if present (case-insensitive) + base = strings.TrimPrefix(base, "XYZ:") + base = strings.TrimPrefix(base, "xyz:") + return xyzDexAssets[base] } // NewHyperliquidTrader creates a Hyperliquid trader @@ -127,6 +180,8 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) walletAddr: walletAddr, meta: meta, isCrossMargin: true, // Use cross margin mode by default + privateKey: privateKey, + isTestnet: testnet, }, nil } @@ -218,32 +273,237 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { } } - // ✅ Step 5: Correctly handle Spot + Perpetuals balance - // Important: Spot is only added to total assets, not to available balance - // Reason: Spot and Perpetuals are independent accounts, manual ClassTransfer required for transfers - totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance + // ✅ Step 5: Query xyz dex balance (stock perps, forex, commodities) + var xyzAccountValue, xyzUnrealizedPnl float64 + var xyzPositions []xyzAssetPosition + xyzAccountValue, xyzUnrealizedPnl, xyzPositions, err = t.getXYZDexBalance() + if err != nil { + // xyz dex query failed - log warning but don't fail the entire balance query + logger.Infof("⚠️ Failed to query xyz dex balance: %v", err) + } + // Always log xyz dex state for debugging + logger.Infof("🔍 xyz dex state: accountValue=%.4f, unrealizedPnl=%.4f, positions=%d", + xyzAccountValue, xyzUnrealizedPnl, len(xyzPositions)) + for _, pos := range xyzPositions { + entryPx := "nil" + if pos.Position.EntryPx != nil { + entryPx = *pos.Position.EntryPx + } + logger.Infof(" └─ %s: size=%s, entryPx=%s, posValue=%s, pnl=%s", + pos.Position.Coin, pos.Position.Szi, entryPx, pos.Position.PositionValue, pos.Position.UnrealizedPnl) + } + xyzWalletBalance := xyzAccountValue - xyzUnrealizedPnl - result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot) - result["availableBalance"] = availableBalance // Available balance (Perpetuals only, excluding Spot) - result["totalUnrealizedProfit"] = totalUnrealizedPnl // Unrealized PnL (from Perpetuals only) - result["spotBalance"] = spotUSDCBalance // Spot balance (returned separately) + // ✅ Step 6: Correctly handle Spot + Perpetuals + xyz dex balance + // Important: Each account is independent, manual transfers required + totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance + xyzWalletBalance + totalUnrealizedPnlAll := totalUnrealizedPnl + xyzUnrealizedPnl + + // Calculate total equity properly: perpAccountValue + spotUSDCBalance + xyzAccountValue + // Note: totalWalletBalance + totalUnrealizedPnlAll should equal this + totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue + + result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized + result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV + result["availableBalance"] = availableBalance // Available balance (Perpetuals only) + result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz) + result["spotBalance"] = spotUSDCBalance // Spot balance + result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities) + result["xyzDexUnrealizedPnl"] = xyzUnrealizedPnl // xyz dex unrealized PnL + result["perpAccountValue"] = accountValue // Perp account value for debugging logger.Infof("✓ Hyperliquid complete account:") - logger.Infof(" • Spot balance: %.2f USDC (manual transfer to Perpetuals required for opening positions)", spotUSDCBalance) + logger.Infof(" • Spot balance: %.2f USDC", spotUSDCBalance) logger.Infof(" • Perpetuals equity: %.2f USDC (wallet %.2f + unrealized %.2f)", accountValue, walletBalanceWithoutUnrealized, totalUnrealizedPnl) - logger.Infof(" • Perpetuals available balance: %.2f USDC (directly usable for opening positions)", availableBalance) + logger.Infof(" • Perpetuals available balance: %.2f USDC", availableBalance) logger.Infof(" • Margin used: %.2f USDC", totalMarginUsed) - logger.Infof(" • Total assets (Perp+Spot): %.2f USDC", totalWalletBalance) - logger.Infof(" ⭐ Total assets: %.2f USDC | Perp available: %.2f USDC | Spot balance: %.2f USDC", - totalWalletBalance, availableBalance, spotUSDCBalance) + logger.Infof(" • xyz dex equity: %.2f USDC (wallet %.2f + unrealized %.2f)", + xyzAccountValue, + xyzWalletBalance, + xyzUnrealizedPnl) + logger.Infof(" • Total assets (Perp+Spot+xyz): %.2f USDC", totalWalletBalance) + logger.Infof(" ⭐ Total: %.2f USDC | Perp: %.2f | Spot: %.2f | xyz: %.2f", + totalWalletBalance, availableBalance, spotUSDCBalance, xyzAccountValue) return result, nil } -// GetPositions gets all positions +// xyzDexState represents the clearinghouse state for xyz dex +type xyzDexState struct { + MarginSummary *xyzMarginSummary `json:"marginSummary,omitempty"` + CrossMarginSummary *xyzMarginSummary `json:"crossMarginSummary,omitempty"` + Withdrawable string `json:"withdrawable,omitempty"` + AssetPositions []xyzAssetPosition `json:"assetPositions,omitempty"` +} + +type xyzMarginSummary struct { + AccountValue string `json:"accountValue"` + TotalMarginUsed string `json:"totalMarginUsed"` +} + +type xyzAssetPosition struct { + Position struct { + Coin string `json:"coin"` + Szi string `json:"szi"` + EntryPx *string `json:"entryPx"` + PositionValue string `json:"positionValue"` + UnrealizedPnl string `json:"unrealizedPnl"` + LiquidationPx *string `json:"liquidationPx"` + Leverage struct { + Type string `json:"type"` + Value int `json:"value"` + } `json:"leverage"` + } `json:"position"` +} + +// getXYZDexBalance queries the xyz dex balance (stock perps, forex, commodities) +func (t *HyperliquidTrader) getXYZDexBalance() (accountValue float64, unrealizedPnl float64, positions []xyzAssetPosition, err error) { + // Build request for xyz dex clearinghouse state + reqBody := map[string]interface{}{ + "type": "clearinghouseState", + "user": t.walletAddr, + "dex": "xyz", + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return 0, 0, nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Determine API URL + apiURL := "https://api.hyperliquid.xyz/info" + // Note: xyz dex may not be available on testnet + + req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return 0, 0, nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, 0, nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, 0, nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return 0, 0, nil, fmt.Errorf("xyz dex API error (status %d): %s", resp.StatusCode, string(body)) + } + + var state xyzDexState + if err := json.Unmarshal(body, &state); err != nil { + return 0, 0, nil, fmt.Errorf("failed to parse response: %w", err) + } + + // Parse account value - xyz dex uses MarginSummary for isolated margin mode + // CrossMarginSummary may exist but with 0 values, so check MarginSummary first + if state.MarginSummary != nil && state.MarginSummary.AccountValue != "" { + av, _ := strconv.ParseFloat(state.MarginSummary.AccountValue, 64) + if av > 0 { + accountValue = av + } + } + // Fallback to CrossMarginSummary if MarginSummary is 0 + if accountValue == 0 && state.CrossMarginSummary != nil && state.CrossMarginSummary.AccountValue != "" { + accountValue, _ = strconv.ParseFloat(state.CrossMarginSummary.AccountValue, 64) + } + + // Calculate total unrealized PnL from positions + for _, pos := range state.AssetPositions { + pnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64) + unrealizedPnl += pnl + } + + return accountValue, unrealizedPnl, state.AssetPositions, nil +} + +// fetchXyzMeta fetches metadata for xyz dex assets (stocks, forex, commodities) +func (t *HyperliquidTrader) fetchXyzMeta() error { + // Build request for xyz dex meta + reqBody := map[string]string{ + "type": "meta", + "dex": "xyz", + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := "https://api.hyperliquid.xyz/info" + + req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("xyz dex meta API error (status %d): %s", resp.StatusCode, string(body)) + } + + var meta xyzDexMeta + if err := json.Unmarshal(body, &meta); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + t.xyzMetaMutex.Lock() + t.xyzMeta = &meta + t.xyzMetaMutex.Unlock() + + logger.Infof("✅ xyz dex meta fetched, contains %d assets", len(meta.Universe)) + return nil +} + +// getXyzSzDecimals gets quantity precision for xyz dex asset +func (t *HyperliquidTrader) getXyzSzDecimals(coin string) int { + t.xyzMetaMutex.RLock() + defer t.xyzMetaMutex.RUnlock() + + if t.xyzMeta == nil { + logger.Infof("⚠️ xyz meta information is empty, using default precision 2") + return 2 // Default precision for stocks/forex + } + + // The meta API returns names with xyz: prefix, so ensure we match correctly + lookupName := coin + if !strings.HasPrefix(lookupName, "xyz:") { + lookupName = "xyz:" + lookupName + } + + // Find corresponding asset in xyzMeta.Universe + for _, asset := range t.xyzMeta.Universe { + if asset.Name == lookupName { + return asset.SzDecimals + } + } + + logger.Infof("⚠️ Precision information not found for %s, using default precision 2", lookupName) + return 2 // Default precision for stocks/forex +} + +// GetPositions gets all positions (including xyz dex positions) func (t *HyperliquidTrader) GetPositions() ([]map[string]interface{}, error) { // Get account status accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr) @@ -253,7 +513,7 @@ func (t *HyperliquidTrader) GetPositions() ([]map[string]interface{}, error) { var result []map[string]interface{} - // Iterate through all positions + // Iterate through all perp positions for _, assetPos := range accountState.AssetPositions { position := assetPos.Position @@ -306,6 +566,71 @@ func (t *HyperliquidTrader) GetPositions() ([]map[string]interface{}, error) { result = append(result, posMap) } + // Also get xyz dex positions (stocks, forex, commodities) + _, _, xyzPositions, err := t.getXYZDexBalance() + if err != nil { + // xyz dex query failed - log warning but don't fail + logger.Infof("⚠️ Failed to get xyz dex positions: %v", err) + } else { + for _, pos := range xyzPositions { + posAmt, _ := strconv.ParseFloat(pos.Position.Szi, 64) + if posAmt == 0 { + continue + } + + posMap := make(map[string]interface{}) + + // xyz dex positions - the API returns coin names with xyz: prefix (e.g., "xyz:SILVER") + // Only add prefix if not already present + symbol := pos.Position.Coin + if !strings.HasPrefix(symbol, "xyz:") { + symbol = "xyz:" + symbol + } + posMap["symbol"] = symbol + + if posAmt > 0 { + posMap["side"] = "long" + posMap["positionAmt"] = posAmt + } else { + posMap["side"] = "short" + posMap["positionAmt"] = -posAmt + } + + // Parse price information + var entryPrice, liquidationPx float64 + if pos.Position.EntryPx != nil { + entryPrice, _ = strconv.ParseFloat(*pos.Position.EntryPx, 64) + } + if pos.Position.LiquidationPx != nil { + liquidationPx, _ = strconv.ParseFloat(*pos.Position.LiquidationPx, 64) + } + + positionValue, _ := strconv.ParseFloat(pos.Position.PositionValue, 64) + unrealizedPnl, _ := strconv.ParseFloat(pos.Position.UnrealizedPnl, 64) + + // Calculate mark price from position value + var markPrice float64 + if posAmt != 0 { + markPrice = positionValue / absFloat(posAmt) + } + + // Get leverage (default to 1 if not available) + leverage := float64(pos.Position.Leverage.Value) + if leverage == 0 { + leverage = 1.0 + } + + posMap["entryPrice"] = entryPrice + posMap["markPrice"] = markPrice + posMap["unRealizedProfit"] = unrealizedPnl + posMap["leverage"] = leverage + posMap["liquidationPrice"] = liquidationPx + posMap["isXyzDex"] = true // Mark as xyz dex position + + result = append(result, posMap) + } + } + return result, nil } @@ -372,113 +697,69 @@ func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { return nil } -// OpenLong opens a long position +// OpenLong opens a long position (supports both crypto and xyz dex) func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // First cancel all pending orders for this coin if err := t.CancelAllOrders(symbol); err != nil { logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) } - // Set leverage - if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, err - } - // Hyperliquid symbol format coin := convertSymbolToHyperliquid(symbol) + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + // Set leverage (skip for xyz dex as it may not support leverage adjustment) + if !isXyz { + if err := t.SetLeverage(symbol, leverage); err != nil { + return nil, err + } + } else { + logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) + } + // Get current price (for market order) price, err := t.GetMarketPrice(symbol) if err != nil { return nil, err } - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - // ⚠️ Critical: Price also needs to be processed to 5 significant figures + // ⚠️ Critical: Price needs to be processed to 5 significant figures aggressivePrice := t.roundPriceToSigfigs(price * 1.01) logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) - // Create market buy order (using IOC limit order with aggressive price) - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: true, - Size: roundedQuantity, // Use rounded quantity - Price: aggressivePrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, // Immediate or Cancel (similar to market order) + // Handle xyz dex assets differently + if isXyz { + // xyz dex order + if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, false); err != nil { + return nil, fmt.Errorf("failed to open long position on xyz dex: %w", err) + } + } else { + // Standard crypto order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: true, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, }, - }, - ReduceOnly: false, + ReduceOnly: false, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to open long position: %w", err) + } } - _, err = t.exchange.Order(t.ctx, order, nil) - if err != nil { - return nil, fmt.Errorf("failed to open long position: %w", err) - } - - logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, roundedQuantity) - - result := make(map[string]interface{}) - result["orderId"] = 0 // Hyperliquid does not return order ID - result["symbol"] = symbol - result["status"] = "FILLED" - - return result, nil -} - -// OpenShort opens a short position -func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { - // First cancel all pending orders for this coin - if err := t.CancelAllOrders(symbol); err != nil { - logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) - } - - // Set leverage - if err := t.SetLeverage(symbol, leverage); err != nil { - return nil, err - } - - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - - // Get current price - price, err := t.GetMarketPrice(symbol) - if err != nil { - return nil, err - } - - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - // ⚠️ Critical: Price also needs to be processed to 5 significant figures - aggressivePrice := t.roundPriceToSigfigs(price * 0.99) - logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) - - // Create market sell order - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: false, - Size: roundedQuantity, // Use rounded quantity - Price: aggressivePrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, - }, - }, - ReduceOnly: false, - } - - _, err = t.exchange.Order(t.ctx, order, nil) - if err != nil { - return nil, fmt.Errorf("failed to open short position: %w", err) - } - - logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, roundedQuantity) + logger.Infof("✓ Long position opened successfully: %s quantity: %.4f", symbol, quantity) result := make(map[string]interface{}) result["orderId"] = 0 @@ -488,8 +769,84 @@ func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage return result, nil } -// CloseLong closes a long position +// OpenShort opens a short position (supports both crypto and xyz dex) +func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + // First cancel all pending orders for this coin + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof(" ⚠ Failed to cancel old pending orders: %v", err) + } + + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + + // Check if this is an xyz dex asset + isXyz := strings.HasPrefix(coin, "xyz:") + + // Set leverage (skip for xyz dex) + if !isXyz { + if err := t.SetLeverage(symbol, leverage); err != nil { + return nil, err + } + } else { + logger.Infof(" ℹ xyz dex asset %s - using default leverage", coin) + } + + // Get current price + price, err := t.GetMarketPrice(symbol) + if err != nil { + return nil, err + } + + // ⚠️ Critical: Price needs to be processed to 5 significant figures + aggressivePrice := t.roundPriceToSigfigs(price * 0.99) + logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) + + // Handle xyz dex assets differently + if isXyz { + // xyz dex order + if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, false); err != nil { + return nil, fmt.Errorf("failed to open short position on xyz dex: %w", err) + } + } else { + // Standard crypto order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: false, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, + }, + ReduceOnly: false, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to open short position: %w", err) + } + } + + logger.Infof("✓ Short position opened successfully: %s quantity: %.4f", symbol, quantity) + + result := make(map[string]interface{}) + result["orderId"] = 0 + result["symbol"] = symbol + result["status"] = "FILLED" + + return result, nil +} + +// CloseLong closes a long position (supports both crypto and xyz dex) func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + isXyz := strings.HasPrefix(coin, "xyz:") + // If quantity is 0, get current position quantity if quantity == 0 { positions, err := t.GetPositions() @@ -497,8 +854,15 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri return nil, err } + // For xyz dex, also check xyz: prefixed symbols + searchSymbol := symbol + if isXyz { + searchSymbol = coin // Use xyz:SYMBOL format for comparison + } + for _, pos := range positions { - if pos["symbol"] == symbol && pos["side"] == "long" { + posSymbol := pos["symbol"].(string) + if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "long" { quantity = pos["positionAmt"].(float64) break } @@ -509,43 +873,47 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri } } - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - // Get current price price, err := t.GetMarketPrice(symbol) if err != nil { return nil, err } - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - // ⚠️ Critical: Price also needs to be processed to 5 significant figures + // ⚠️ Critical: Price needs to be processed to 5 significant figures aggressivePrice := t.roundPriceToSigfigs(price * 0.99) logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*0.99, aggressivePrice) - // Create close position order (sell + ReduceOnly) - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: false, - Size: roundedQuantity, // Use rounded quantity - Price: aggressivePrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, + // Handle xyz dex assets differently + if isXyz { + // xyz dex close order + if err := t.placeXyzOrder(coin, false, quantity, aggressivePrice, true); err != nil { + return nil, fmt.Errorf("failed to close long position on xyz dex: %w", err) + } + } else { + // Standard crypto close order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: false, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, }, - }, - ReduceOnly: true, // Only close position, don't open new position + ReduceOnly: true, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to close long position: %w", err) + } } - _, err = t.exchange.Order(t.ctx, order, nil) - if err != nil { - return nil, fmt.Errorf("failed to close long position: %w", err) - } - - logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, roundedQuantity) + logger.Infof("✓ Long position closed successfully: %s quantity: %.4f", symbol, quantity) // Cancel all pending orders for this coin after closing position if err := t.CancelAllOrders(symbol); err != nil { @@ -560,8 +928,12 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri return result, nil } -// CloseShort closes a short position +// CloseShort closes a short position (supports both crypto and xyz dex) func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { + // Hyperliquid symbol format + coin := convertSymbolToHyperliquid(symbol) + isXyz := strings.HasPrefix(coin, "xyz:") + // If quantity is 0, get current position quantity if quantity == 0 { positions, err := t.GetPositions() @@ -569,8 +941,15 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str return nil, err } + // For xyz dex, also check xyz: prefixed symbols + searchSymbol := symbol + if isXyz { + searchSymbol = coin + } + for _, pos := range positions { - if pos["symbol"] == symbol && pos["side"] == "short" { + posSymbol := pos["symbol"].(string) + if (posSymbol == symbol || posSymbol == searchSymbol) && pos["side"] == "short" { quantity = pos["positionAmt"].(float64) break } @@ -581,43 +960,47 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str } } - // Hyperliquid symbol format - coin := convertSymbolToHyperliquid(symbol) - // Get current price price, err := t.GetMarketPrice(symbol) if err != nil { return nil, err } - // ⚠️ Critical: Round quantity according to coin precision requirements - roundedQuantity := t.roundToSzDecimals(coin, quantity) - logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) - - // ⚠️ Critical: Price also needs to be processed to 5 significant figures + // ⚠️ Critical: Price needs to be processed to 5 significant figures aggressivePrice := t.roundPriceToSigfigs(price * 1.01) logger.Infof(" 💰 Price precision handling: %.8f -> %.8f (5 significant figures)", price*1.01, aggressivePrice) - // Create close position order (buy + ReduceOnly) - order := hyperliquid.CreateOrderRequest{ - Coin: coin, - IsBuy: true, - Size: roundedQuantity, // Use rounded quantity - Price: aggressivePrice, // Use processed price - OrderType: hyperliquid.OrderType{ - Limit: &hyperliquid.LimitOrderType{ - Tif: hyperliquid.TifIoc, + // Handle xyz dex assets differently + if isXyz { + // xyz dex close order + if err := t.placeXyzOrder(coin, true, quantity, aggressivePrice, true); err != nil { + return nil, fmt.Errorf("failed to close short position on xyz dex: %w", err) + } + } else { + // Standard crypto close order + roundedQuantity := t.roundToSzDecimals(coin, quantity) + logger.Infof(" 📏 Quantity precision handling: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + + order := hyperliquid.CreateOrderRequest{ + Coin: coin, + IsBuy: true, + Size: roundedQuantity, + Price: aggressivePrice, + OrderType: hyperliquid.OrderType{ + Limit: &hyperliquid.LimitOrderType{ + Tif: hyperliquid.TifIoc, + }, }, - }, - ReduceOnly: true, + ReduceOnly: true, + } + + _, err = t.exchange.Order(t.ctx, order, defaultBuilder) + if err != nil { + return nil, fmt.Errorf("failed to close short position: %w", err) + } } - _, err = t.exchange.Order(t.ctx, order, nil) - if err != nil { - return nil, fmt.Errorf("failed to close short position: %w", err) - } - - logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, roundedQuantity) + logger.Infof("✓ Short position closed successfully: %s quantity: %.4f", symbol, quantity) // Cancel all pending orders for this coin after closing position if err := t.CancelAllOrders(symbol); err != nil { @@ -706,11 +1089,16 @@ func (t *HyperliquidTrader) CancelStopOrders(symbol string) error { return nil } -// GetMarketPrice gets market price +// GetMarketPrice gets market price (supports both crypto and xyz dex assets) func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) { coin := convertSymbolToHyperliquid(symbol) - // Get all market prices + // Check if this is an xyz dex asset + if strings.HasPrefix(coin, "xyz:") { + return t.getXyzMarketPrice(coin) + } + + // Get all market prices for crypto allMids, err := t.exchange.Info().AllMids(t.ctx) if err != nil { return 0, fmt.Errorf("failed to get price: %w", err) @@ -728,6 +1116,267 @@ func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) { return 0, fmt.Errorf("price not found for %s", symbol) } +// getXyzMarketPrice gets market price for xyz dex assets +func (t *HyperliquidTrader) getXyzMarketPrice(coin string) (float64, error) { + // Build request for xyz dex allMids + reqBody := map[string]string{ + "type": "allMids", + "dex": "xyz", + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return 0, fmt.Errorf("failed to marshal request: %w", err) + } + + apiURL := "https://api.hyperliquid.xyz/info" + + req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody)) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("xyz dex allMids API error (status %d): %s", resp.StatusCode, string(body)) + } + + var mids map[string]string + if err := json.Unmarshal(body, &mids); err != nil { + return 0, fmt.Errorf("failed to parse response: %w", err) + } + + // The API returns keys with xyz: prefix, so ensure the coin has it + lookupKey := coin + if !strings.HasPrefix(lookupKey, "xyz:") { + lookupKey = "xyz:" + lookupKey + } + + if priceStr, ok := mids[lookupKey]; ok { + priceFloat, err := strconv.ParseFloat(priceStr, 64) + if err == nil { + return priceFloat, nil + } + return 0, fmt.Errorf("price format error: %v", err) + } + + return 0, fmt.Errorf("xyz dex price not found for %s (lookup key: %s)", coin, lookupKey) +} + +// floatToWireStr converts a float to wire format string (8 decimal places, trimmed zeros) +// This matches the SDK's floatToWire function +func floatToWireStr(x float64) string { + // Format to 8 decimal places + result := fmt.Sprintf("%.8f", x) + // Remove trailing zeros + result = strings.TrimRight(result, "0") + // Remove trailing decimal point if no decimals left + result = strings.TrimRight(result, ".") + return result +} + +// placeXyzOrder places an order on the xyz dex (stocks, forex, commodities) +// Note: xyz dex orders use builder-deployed perpetuals and require different handling +// xyz dex asset indices start from 10000 (10000 + meta_index) +// This implementation bypasses the SDK's NameToAsset lookup and directly constructs the order +func (t *HyperliquidTrader) placeXyzOrder(coin string, isBuy bool, size float64, price float64, reduceOnly bool) error { + // Fetch xyz meta if not cached + t.xyzMetaMutex.RLock() + hasMeta := t.xyzMeta != nil + t.xyzMetaMutex.RUnlock() + + if !hasMeta { + if err := t.fetchXyzMeta(); err != nil { + return fmt.Errorf("failed to fetch xyz meta: %w", err) + } + } + + // Get asset index from xyz meta (returns 0-based index) + metaIndex := t.getXyzAssetIndex(coin) + if metaIndex < 0 { + return fmt.Errorf("xyz asset %s not found in meta", coin) + } + + // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta + // xyz dex is at perp_dex_index = 1 (verified from perpDexs API: [null, {name:"xyz",...}]) + // So xyz asset index = 100000 + 1 * 10000 + metaIndex = 110000 + metaIndex + const xyzPerpDexIndex = 1 + assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex + + // Round size to correct precision + szDecimals := t.getXyzSzDecimals(coin) + multiplier := 1.0 + for i := 0; i < szDecimals; i++ { + multiplier *= 10.0 + } + roundedSize := float64(int(size*multiplier+0.5)) / multiplier + + // Round price to 5 significant figures + roundedPrice := t.roundPriceToSigfigs(price) + + logger.Infof("📝 Placing xyz dex order (direct): %s %s size=%.4f price=%.4f metaIndex=%d assetIndex=%d (formula: 100000 + 1*10000 + %d) reduceOnly=%v", + map[bool]string{true: "BUY", false: "SELL"}[isBuy], + coin, roundedSize, roundedPrice, metaIndex, assetIndex, metaIndex, reduceOnly) + + // Construct OrderWire directly with correct asset index (bypassing SDK's NameToAsset) + orderWire := hyperliquid.OrderWire{ + Asset: assetIndex, + IsBuy: isBuy, + LimitPx: floatToWireStr(roundedPrice), + Size: floatToWireStr(roundedSize), + ReduceOnly: reduceOnly, + OrderType: hyperliquid.OrderWireType{ + Limit: &hyperliquid.OrderWireTypeLimit{ + Tif: hyperliquid.TifIoc, + }, + }, + } + + // Create OrderAction with builder (xyz dex requires builder info for order routing) + action := hyperliquid.OrderAction{ + Type: "order", + Orders: []hyperliquid.OrderWire{orderWire}, + Grouping: "na", + Builder: &hyperliquid.BuilderInfo{ + Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", + Fee: 10, + }, + } + + // Sign the action + nonce := time.Now().UnixMilli() + isMainnet := !t.isTestnet + vaultAddress := "" // No vault for personal account + + sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet) + if err != nil { + return fmt.Errorf("failed to sign xyz dex order: %w", err) + } + + // Construct payload for /exchange endpoint + payload := map[string]any{ + "action": action, + "nonce": nonce, + "signature": sig, + } + + // Determine API URL + apiURL := hyperliquid.MainnetAPIURL + if t.isTestnet { + apiURL = hyperliquid.TestnetAPIURL + } + + // POST to /exchange + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + logger.Infof("📤 Sending xyz dex order to %s/exchange", apiURL) + + req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + // Parse response + var result struct { + Status string `json:"status"` + Response struct { + Type string `json:"type"` + Data struct { + Statuses []struct { + Resting *struct { + Oid int64 `json:"oid"` + } `json:"resting,omitempty"` + Filled *struct { + TotalSz string `json:"totalSz"` + AvgPx string `json:"avgPx"` + Oid int `json:"oid"` + } `json:"filled,omitempty"` + Error *string `json:"error,omitempty"` + } `json:"statuses"` + } `json:"data"` + } `json:"response"` + } + + if err := json.Unmarshal(body, &result); err != nil { + // Try to parse as error response + logger.Infof("⚠️ Failed to parse response as success, raw body: %s", string(body)) + return fmt.Errorf("xyz dex order failed, status=%d, body=%s", resp.StatusCode, string(body)) + } + + // Check for errors in response + if result.Status != "ok" { + return fmt.Errorf("xyz dex order failed: status=%s, body=%s", result.Status, string(body)) + } + + // Check order statuses + if len(result.Response.Data.Statuses) > 0 { + status := result.Response.Data.Statuses[0] + if status.Error != nil { + return fmt.Errorf("xyz dex order error (coin=%s, assetIndex=%d, size=%.4f, price=%.4f): %s", coin, assetIndex, roundedSize, roundedPrice, *status.Error) + } + if status.Filled != nil { + logger.Infof("✅ xyz dex order filled: totalSz=%s avgPx=%s oid=%d", + status.Filled.TotalSz, status.Filled.AvgPx, status.Filled.Oid) + } else if status.Resting != nil { + logger.Infof("✅ xyz dex order resting: oid=%d", status.Resting.Oid) + } + } + + logger.Infof("✅ xyz dex order placed successfully: %s (response: %s)", coin, string(body)) + return nil +} + +// getXyzAssetIndex gets the asset index for an xyz dex asset +func (t *HyperliquidTrader) getXyzAssetIndex(baseCoin string) int { + t.xyzMetaMutex.RLock() + defer t.xyzMetaMutex.RUnlock() + + if t.xyzMeta == nil { + return -1 + } + + // The meta API returns names with xyz: prefix, so ensure we match correctly + lookupName := baseCoin + if !strings.HasPrefix(lookupName, "xyz:") { + lookupName = "xyz:" + lookupName + } + + for i, asset := range t.xyzMeta.Universe { + if asset.Name == lookupName { + return i + } + } + return -1 +} + // SetStopLoss sets stop loss order func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { coin := convertSymbolToHyperliquid(symbol) @@ -756,7 +1405,7 @@ func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quan ReduceOnly: true, } - _, err := t.exchange.Order(t.ctx, order, nil) + _, err := t.exchange.Order(t.ctx, order, defaultBuilder) if err != nil { return fmt.Errorf("failed to set stop loss: %w", err) } @@ -793,7 +1442,7 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu ReduceOnly: true, } - _, err := t.exchange.Order(t.ctx, order, nil) + _, err := t.exchange.Order(t.ctx, order, defaultBuilder) if err != nil { return fmt.Errorf("failed to set take profit: %w", err) } @@ -887,13 +1536,28 @@ func (t *HyperliquidTrader) roundPriceToSigfigs(price float64) float64 { } // convertSymbolToHyperliquid converts standard symbol to Hyperliquid format -// Example: "BTCUSDT" -> "BTC" +// Example: "BTCUSDT" -> "BTC", "TSLA" -> "xyz:TSLA", "silver" -> "xyz:SILVER" func convertSymbolToHyperliquid(symbol string) string { - // Remove USDT suffix - if len(symbol) > 4 && symbol[len(symbol)-4:] == "USDT" { - return symbol[:len(symbol)-4] + // Convert to uppercase for consistent handling + base := strings.ToUpper(symbol) + + // Remove common suffixes to get base symbol + for _, suffix := range []string{"USDT", "USD", "-USDC", "-USD"} { + if strings.HasSuffix(base, suffix) { + base = strings.TrimSuffix(base, suffix) + break + } } - return symbol + // Remove xyz: prefix if present (case-insensitive, will be re-added if needed) + if strings.HasPrefix(strings.ToLower(base), "xyz:") { + base = base[4:] // Remove first 4 characters + } + + // Check if this is an xyz dex asset (stocks, forex, commodities) + if isXyzDexAsset(base) { + return "xyz:" + base + } + return base } // GetOrderStatus gets order status @@ -1008,7 +1672,7 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]Clos func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { // Use UserFillsByTime API startTimeMs := startTime.UnixMilli() - fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil) + fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil) if err != nil { return nil, fmt.Errorf("failed to get user fills: %w", err) } @@ -1075,3 +1739,9 @@ func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRe return trades, nil } + +// defaultBuilder is the builder info for order routing +var defaultBuilder = &hyperliquid.BuilderInfo{ + Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", + Fee: 10, +} diff --git a/trader/xyz_dex_test.go b/trader/xyz_dex_test.go new file mode 100644 index 00000000..8b46432d --- /dev/null +++ b/trader/xyz_dex_test.go @@ -0,0 +1,669 @@ +package trader + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +// testXyzDexAsset is a local copy of testXyzDexAsset for testing +type testXyzDexAsset struct { + Name string `json:"name"` + SzDecimals int `json:"szDecimals"` + MaxLeverage int `json:"maxLeverage"` +} + +// testXyzDexMeta is a local copy of xyzDexMeta for testing +type testXyzDexMeta struct { + Universe []testXyzDexAsset `json:"universe"` +} + +// TestXyzDexMetaFetch tests fetching xyz dex meta from Hyperliquid API +func TestXyzDexMetaFetch(t *testing.T) { + reqBody := map[string]string{ + "type": "meta", + "dex": "xyz", + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to execute request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response: %v", err) + } + + var meta testXyzDexMeta + if err := json.Unmarshal(body, &meta); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if len(meta.Universe) == 0 { + t.Fatal("xyz meta universe is empty") + } + + t.Logf("✅ xyz dex meta contains %d assets", len(meta.Universe)) + + // Check that SILVER exists + // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta + // xyz dex is at perp_dex_index = 1 + found := false + for i, asset := range meta.Universe { + if asset.Name == "xyz:SILVER" { + found = true + assetIndex := 100000 + 1*10000 + i // xyz dex index = 1 + t.Logf("✅ Found xyz:SILVER at index %d (asset ID: %d)", i, assetIndex) + t.Logf(" SzDecimals: %d, MaxLeverage: %d", asset.SzDecimals, asset.MaxLeverage) + break + } + } + if !found { + t.Fatal("xyz:SILVER not found in meta") + } +} + +// TestXyzDexPriceFetch tests fetching xyz dex prices from Hyperliquid API +func TestXyzDexPriceFetch(t *testing.T) { + reqBody := map[string]string{ + "type": "allMids", + "dex": "xyz", + } + jsonBody, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to execute request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response: %v", err) + } + + var mids map[string]string + if err := json.Unmarshal(body, &mids); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Check that prices have xyz: prefix + silverPrice, ok := mids["xyz:SILVER"] + if !ok { + t.Fatal("xyz:SILVER price not found (key should include xyz: prefix)") + } + t.Logf("✅ xyz:SILVER price: %s", silverPrice) + + // Verify a few more assets + testAssets := []string{"xyz:GOLD", "xyz:TSLA", "xyz:NVDA"} + for _, asset := range testAssets { + if price, ok := mids[asset]; ok { + t.Logf("✅ %s price: %s", asset, price) + } else { + t.Logf("⚠️ %s not found in prices", asset) + } + } +} + +// TestXyzAssetIndexLookup tests the asset index lookup for xyz dex assets +func TestXyzAssetIndexLookup(t *testing.T) { + // Fetch xyz meta + reqBody := map[string]string{ + "type": "meta", + "dex": "xyz", + } + jsonBody, _ := json.Marshal(reqBody) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to fetch meta: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var meta testXyzDexMeta + json.Unmarshal(body, &meta) + + // Test lookup with different formats + testCases := []struct { + input string + expected string // expected match in meta + }{ + {"SILVER", "xyz:SILVER"}, + {"xyz:SILVER", "xyz:SILVER"}, + {"GOLD", "xyz:GOLD"}, + {"xyz:TSLA", "xyz:TSLA"}, + } + + for _, tc := range testCases { + lookupName := tc.input + if !strings.HasPrefix(lookupName, "xyz:") { + lookupName = "xyz:" + lookupName + } + + found := false + for i, asset := range meta.Universe { + if asset.Name == lookupName { + found = true + assetIndex := 100000 + 1*10000 + i // HIP-3 formula: 100000 + xyz_dex_index(1) * 10000 + meta_index + t.Logf("✅ Lookup '%s' -> found at index %d (asset ID: %d)", tc.input, i, assetIndex) + break + } + } + if !found { + t.Errorf("❌ Lookup '%s' -> NOT FOUND (expected to match %s)", tc.input, tc.expected) + } + } +} + +// TestXyzSzDecimalsLookup tests the szDecimals lookup for different xyz assets +func TestXyzSzDecimalsLookup(t *testing.T) { + reqBody := map[string]string{ + "type": "meta", + "dex": "xyz", + } + jsonBody, _ := json.Marshal(reqBody) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to fetch meta: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var meta testXyzDexMeta + json.Unmarshal(body, &meta) + + // Check szDecimals for various assets + expectedDecimals := map[string]int{ + "xyz:SILVER": 2, + "xyz:GOLD": 4, + "xyz:TSLA": 3, + } + + for name, expected := range expectedDecimals { + for _, asset := range meta.Universe { + if asset.Name == name { + if asset.SzDecimals == expected { + t.Logf("✅ %s szDecimals: %d (expected %d)", name, asset.SzDecimals, expected) + } else { + t.Logf("⚠️ %s szDecimals: %d (expected %d, may have changed)", name, asset.SzDecimals, expected) + } + break + } + } + } +} + +// TestXyzOrderParameters tests order parameter calculation +func TestXyzOrderParameters(t *testing.T) { + // Simulate order parameter calculation + testCases := []struct { + price float64 + size float64 + szDecimals int + expectedSz float64 + }{ + {75.33, 1.0, 2, 1.00}, + {75.33, 1.234, 2, 1.23}, + {75.33, 5.567, 2, 5.57}, + {188.15, 0.5, 3, 0.500}, + {188.15, 0.1234, 3, 0.123}, + } + + for _, tc := range testCases { + multiplier := 1.0 + for i := 0; i < tc.szDecimals; i++ { + multiplier *= 10.0 + } + roundedSize := float64(int(tc.size*multiplier+0.5)) / multiplier + + if roundedSize != tc.expectedSz { + t.Errorf("Size rounding failed: input=%v, decimals=%d, got=%v, expected=%v", + tc.size, tc.szDecimals, roundedSize, tc.expectedSz) + } else { + t.Logf("✅ Size rounding: %v (decimals=%d) -> %v", tc.size, tc.szDecimals, roundedSize) + } + } +} + +// TestXyzAssetIndexCalculation tests the HIP-3 asset index calculation +// Formula: 100000 + perp_dex_index * 10000 + meta_index +// For xyz dex: perp_dex_index = 1, so asset_index = 110000 + meta_index +func TestXyzAssetIndexCalculation(t *testing.T) { + reqBody := map[string]string{ + "type": "meta", + "dex": "xyz", + } + jsonBody, _ := json.Marshal(reqBody) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to fetch meta: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var meta testXyzDexMeta + json.Unmarshal(body, &meta) + + // Test asset index calculation for SILVER + // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta + // xyz dex is at perp_dex_index = 1 + const xyzPerpDexIndex = 1 + for i, asset := range meta.Universe { + if asset.Name == "xyz:SILVER" { + assetIndex := 100000 + xyzPerpDexIndex*10000 + i + t.Logf("✅ xyz:SILVER: meta_index=%d, asset_index=%d", i, assetIndex) + + if assetIndex < 110000 { + t.Errorf("Asset index should be >= 110000, got %d", assetIndex) + } + break + } + } + + // Log first few assets for reference + t.Log("\nFirst 5 xyz assets:") + for i := 0; i < 5 && i < len(meta.Universe); i++ { + asset := meta.Universe[i] + assetIndex := 100000 + xyzPerpDexIndex*10000 + i + t.Logf(" [%d] %s -> asset_index=%d, szDecimals=%d", i, asset.Name, assetIndex, asset.SzDecimals) + } +} + +// TestIsXyzDexAsset tests the isXyzDexAsset function +func TestIsXyzDexAsset(t *testing.T) { + testCases := []struct { + symbol string + expected bool + }{ + {"xyz:SILVER", true}, + {"SILVER", true}, + {"silver", true}, + {"xyz:GOLD", true}, + {"GOLD", true}, + {"xyz:TSLA", true}, + {"TSLA", true}, + {"BTCUSDT", false}, + {"BTC", false}, + {"ETHUSDT", false}, + {"SOLUSDT", false}, + {"xyz:BTC", false}, // BTC is not an xyz asset + } + + for _, tc := range testCases { + result := isXyzDexAsset(tc.symbol) + if result != tc.expected { + t.Errorf("isXyzDexAsset(%q) = %v, expected %v", tc.symbol, result, tc.expected) + } else { + t.Logf("✅ isXyzDexAsset(%q) = %v", tc.symbol, result) + } + } +} + +// TestConvertSymbolToHyperliquidXyz tests symbol conversion for xyz assets +func TestConvertSymbolToHyperliquidXyz(t *testing.T) { + testCases := []struct { + input string + expected string + }{ + {"SILVER", "xyz:SILVER"}, + {"silver", "xyz:SILVER"}, + {"xyz:SILVER", "xyz:SILVER"}, + {"GOLD", "xyz:GOLD"}, + {"TSLA", "xyz:TSLA"}, + {"BTC", "BTC"}, + {"BTCUSDT", "BTC"}, + {"ETH", "ETH"}, + {"ETHUSDT", "ETH"}, + } + + for _, tc := range testCases { + result := convertSymbolToHyperliquid(tc.input) + if result != tc.expected { + t.Errorf("convertSymbolToHyperliquid(%q) = %q, expected %q", tc.input, result, tc.expected) + } else { + t.Logf("✅ convertSymbolToHyperliquid(%q) = %q", tc.input, result) + } + } +} + +// TestXyzDexOrderFlow tests the complete order flow (without actually placing an order) +func TestXyzDexOrderFlow(t *testing.T) { + t.Log("=== Testing xyz Dex Order Flow ===") + + // Step 1: Fetch meta + t.Log("\nStep 1: Fetching xyz meta...") + reqBody := map[string]string{"type": "meta", "dex": "xyz"} + jsonBody, _ := json.Marshal(reqBody) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("Failed to fetch meta: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var meta testXyzDexMeta + json.Unmarshal(body, &meta) + t.Logf("✅ Fetched %d xyz assets", len(meta.Universe)) + + // Step 2: Find SILVER + t.Log("\nStep 2: Looking up xyz:SILVER...") + var silverIndex int = -1 + var silverAsset *testXyzDexAsset + for i, asset := range meta.Universe { + if asset.Name == "xyz:SILVER" { + silverIndex = i + silverAsset = &meta.Universe[i] + break + } + } + if silverIndex < 0 { + t.Fatal("SILVER not found in xyz meta") + } + t.Logf("✅ Found at index %d", silverIndex) + + // Step 3: Fetch price + t.Log("\nStep 3: Fetching price...") + priceReq := map[string]string{"type": "allMids", "dex": "xyz"} + priceBody, _ := json.Marshal(priceReq) + req2, _ := http.NewRequestWithContext(ctx, "POST", "https://api.hyperliquid.xyz/info", bytes.NewBuffer(priceBody)) + req2.Header.Set("Content-Type", "application/json") + resp2, _ := client.Do(req2) + body2, _ := io.ReadAll(resp2.Body) + resp2.Body.Close() + + var mids map[string]string + json.Unmarshal(body2, &mids) + priceStr := mids["xyz:SILVER"] + var price float64 + fmt.Sscanf(priceStr, "%f", &price) + t.Logf("✅ Price: %s", priceStr) + + // Step 4: Calculate order parameters + t.Log("\nStep 4: Calculating order parameters...") + orderSize := 1.0 + multiplier := 1.0 + for i := 0; i < silverAsset.SzDecimals; i++ { + multiplier *= 10.0 + } + roundedSize := float64(int(orderSize*multiplier+0.5)) / multiplier + roundedPrice := price * 1.001 // 0.1% slippage + // HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta + // xyz dex is at perp_dex_index = 1 + assetIndex := 100000 + 1*10000 + silverIndex + + t.Logf(" Asset Index: %d (110000 + %d)", assetIndex, silverIndex) + t.Logf(" Size: %.4f (szDecimals=%d)", roundedSize, silverAsset.SzDecimals) + t.Logf(" Price: %.4f (with slippage)", roundedPrice) + + // Step 5: Summary + t.Log("\n=== Order Flow Test Summary ===") + t.Log("✅ Meta fetch: OK") + t.Log("✅ Asset lookup: OK") + t.Log("✅ Price fetch: OK") + t.Log("✅ Parameter calculation: OK") + t.Logf("\n📋 Order would be placed with:") + t.Logf(" coin: xyz:SILVER") + t.Logf(" assetIndex: %d", assetIndex) + t.Logf(" isBuy: true") + t.Logf(" size: %.4f", roundedSize) + t.Logf(" price: %.4f", roundedPrice) +} + +// TestXyzDexLiveOrder tests placing a real order on xyz dex +// This test requires: +// - XYZ_DEX_LIVE_TEST=1 to enable +// - TEST_PRIVATE_KEY - the private key for signing +// - TEST_WALLET_ADDR - the wallet address with funds +func TestXyzDexLiveOrder(t *testing.T) { + // Skip unless explicitly enabled + if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" { + t.Skip("Skipping live order test. Set XYZ_DEX_LIVE_TEST=1 to run") + } + + // Get credentials from environment variables + privateKeyHex := os.Getenv("TEST_PRIVATE_KEY") + walletAddr := os.Getenv("TEST_WALLET_ADDR") + + if privateKeyHex == "" || walletAddr == "" { + t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required") + } + + t.Logf("=== Live xyz Dex Order Test ===") + t.Logf("Wallet: %s", walletAddr) + + // Create trader instance + trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false) + if err != nil { + t.Fatalf("Failed to create trader: %v", err) + } + + // Check xyz dex balance first + xyzState, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz") + if xyzState != nil && xyzState.CrossMarginSummary.AccountValue == "0.0" { + t.Logf("⚠️ xyz dex account has no funds (balance: %s)", xyzState.CrossMarginSummary.AccountValue) + t.Logf(" To trade xyz dex, you need to transfer funds using perpDexClassTransfer") + t.Logf(" The test will still verify order signing and submission...") + } + + // Fetch xyz meta first + if err := trader.fetchXyzMeta(); err != nil { + t.Fatalf("Failed to fetch xyz meta: %v", err) + } + + // Get current price for xyz:SILVER + price, err := trader.getXyzMarketPrice("xyz:SILVER") + if err != nil { + t.Fatalf("Failed to get price: %v", err) + } + t.Logf("Current xyz:SILVER price: %.4f", price) + + // Place a test order (minimum $10 value = 0.14 SILVER at ~$75) + // With 5% slippage for IOC (market order) + testSize := 0.14 // ~$10.5 at current price + testPrice := price * 1.05 // 5% above market for IOC buy (market order) + + t.Logf("Attempting to place order:") + t.Logf(" Symbol: xyz:SILVER") + t.Logf(" Side: BUY") + t.Logf(" Size: %.4f", testSize) + t.Logf(" Price: %.4f", testPrice) + + // Place the order using the new direct method + err = trader.placeXyzOrder("xyz:SILVER", true, testSize, testPrice, false) + if err != nil { + t.Logf("⚠️ Order result: %v", err) + // Check if this is an expected error (e.g., insufficient margin, no matching orders for IOC) + if strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "margin") || strings.Contains(err.Error(), "minimum value") { + t.Logf("This may be expected if the test wallet has no margin in xyz dex") + t.Logf("✅ Order was properly signed and submitted (API validated format/signature)") + } else if strings.Contains(err.Error(), "could not immediately match") { + // IOC order didn't fill - this is actually SUCCESS! + // It means the order was properly signed, submitted, and processed + t.Logf("✅ Order was properly submitted but didn't fill (IOC with no matching orders)") + t.Logf(" This confirms the asset index (%d) and signing are correct!", 110026) + } else if strings.Contains(err.Error(), "Order has invalid price") || strings.Contains(err.Error(), "95% away") { + t.Errorf("FAILED: Order has invalid price - asset index issue") + } else { + t.Errorf("FAILED: Unexpected error: %v", err) + } + } else { + t.Logf("✅ Order placed and filled successfully!") + } +} + +// TestXyzDexClosePosition tests closing a position on xyz dex +// This test requires the XYZ_DEX_LIVE_TEST environment variable to be set +func TestXyzDexClosePosition(t *testing.T) { + // Skip unless explicitly enabled + if os.Getenv("XYZ_DEX_LIVE_TEST") != "1" { + t.Skip("Skipping live close position test. Set XYZ_DEX_LIVE_TEST=1 to run") + } + + // Get credentials from environment variables + privateKeyHex := os.Getenv("TEST_PRIVATE_KEY") + walletAddr := os.Getenv("TEST_WALLET_ADDR") + + if privateKeyHex == "" || walletAddr == "" { + t.Skip("TEST_PRIVATE_KEY and TEST_WALLET_ADDR env vars required") + } + + t.Logf("=== Live xyz Dex Close Position Test ===") + t.Logf("Wallet: %s", walletAddr) + + // Create trader instance + trader, err := NewHyperliquidTrader(privateKeyHex, walletAddr, false) + if err != nil { + t.Fatalf("Failed to create trader: %v", err) + } + + // Check current xyz dex position + xyzState, err := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz") + if err != nil { + t.Fatalf("Failed to get xyz state: %v", err) + } + + if len(xyzState.AssetPositions) == 0 { + t.Logf("No xyz dex positions to close") + return + } + + // Get the position details + pos := xyzState.AssetPositions[0].Position + entryPx := "" + if pos.EntryPx != nil { + entryPx = *pos.EntryPx + } + t.Logf("Current position: %s size=%s entryPx=%s", pos.Coin, pos.Szi, entryPx) + + // Fetch xyz meta + if err := trader.fetchXyzMeta(); err != nil { + t.Fatalf("Failed to fetch xyz meta: %v", err) + } + + // Get current price + price, err := trader.getXyzMarketPrice(pos.Coin) + if err != nil { + t.Fatalf("Failed to get price: %v", err) + } + t.Logf("Current %s price: %.4f", pos.Coin, price) + + // Parse position size + var posSize float64 + fmt.Sscanf(pos.Szi, "%f", &posSize) + + // Close position: if long (szi > 0), sell; if short (szi < 0), buy + isBuy := posSize < 0 + closeSize := posSize + if closeSize < 0 { + closeSize = -closeSize + } + + // Use aggressive slippage for close + closePrice := price * 0.95 // 5% below for sell + if isBuy { + closePrice = price * 1.05 // 5% above for buy + } + + t.Logf("Closing position:") + t.Logf(" Side: %s", map[bool]string{true: "BUY", false: "SELL"}[isBuy]) + t.Logf(" Size: %.4f", closeSize) + t.Logf(" Price: %.4f", closePrice) + + // Place close order with reduceOnly=true + err = trader.placeXyzOrder(pos.Coin, isBuy, closeSize, closePrice, true) + if err != nil { + t.Logf("⚠️ Close order result: %v", err) + if strings.Contains(err.Error(), "could not immediately match") { + t.Logf("✅ Close order submitted but didn't fill (IOC)") + } else { + t.Errorf("FAILED: %v", err) + } + } else { + t.Logf("✅ Position closed successfully!") + } + + // Verify position is closed + xyzState2, _ := trader.exchange.Info().UserState(trader.ctx, walletAddr, "xyz") + if len(xyzState2.AssetPositions) == 0 { + t.Logf("✅ Position confirmed closed (no positions remaining)") + } else { + newPos := xyzState2.AssetPositions[0].Position + t.Logf("Position after close: %s size=%s", newPos.Coin, newPos.Szi) + } +} diff --git a/web/src/components/AdvancedChart.tsx b/web/src/components/AdvancedChart.tsx index 73d0fb0a..01b5cdff 100644 --- a/web/src/components/AdvancedChart.tsx +++ b/web/src/components/AdvancedChart.tsx @@ -18,7 +18,7 @@ import { calculateBollingerBands, type Kline, } from '../utils/indicators' -import { Settings, TrendingUp, BarChart2 } from 'lucide-react' +import { Settings, BarChart2 } from 'lucide-react' // 订单接口定义 interface OrderMarker { @@ -49,17 +49,37 @@ interface IndicatorConfig { params?: any } -// 热门币种 -const POPULAR_SYMBOLS = [ - 'BTCUSDT', - 'ETHUSDT', - 'SOLUSDT', - 'BNBUSDT', - 'XRPUSDT', - 'DOGEUSDT', - 'ADAUSDT', - 'AVAXUSDT', -] +// 获取成交额货币单位 +const getQuoteUnit = (exchange: string): string => { + if (['alpaca'].includes(exchange)) { + return 'USD' + } + if (['forex', 'metals'].includes(exchange)) { + return '' // 外汇/贵金属没有真实成交量 + } + return 'USDT' // 加密货币默认 USDT +} + +// 获取成交量数量单位 +const getBaseUnit = (exchange: string, symbol: string): string => { + if (['alpaca'].includes(exchange)) { + return '股' + } + if (['forex', 'metals'].includes(exchange)) { + return '' + } + // 加密货币:从 symbol 提取基础资产 + const base = symbol.replace(/USDT$|USD$|BUSD$/, '') + return base || '个' +} + +// 格式化大数字 +const formatVolume = (value: number): string => { + if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B' + if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M' + if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K' + return value.toFixed(2) +} export function AdvancedChart({ symbol = 'BTCUSDT', @@ -67,9 +87,12 @@ export function AdvancedChart({ traderID, height = 550, exchange = 'binance', // 默认使用 binance - onSymbolChange, + onSymbolChange: _onSymbolChange, // Available for future use }: AdvancedChartProps) { + void _onSymbolChange // Prevent unused warning const { language } = useLanguage() + const quoteUnit = getQuoteUnit(exchange) + const baseUnit = getBaseUnit(exchange, symbol) const chartContainerRef = useRef(null) const chartRef = useRef(null) const candlestickSeriesRef = useRef | null>(null) @@ -77,6 +100,7 @@ export function AdvancedChart({ const indicatorSeriesRef = useRef>>(new Map()) const seriesMarkersRef = useRef(null) // Markers primitive for v5 const currentMarkersDataRef = useRef([]) // 存储当前的标记数据 + const klineDataRef = useRef>(new Map()) // 存储 kline 额外数据 const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -86,6 +110,17 @@ export function AdvancedChart({ const [tooltipData, setTooltipData] = useState(null) const tooltipRef = useRef(null) + // 行情统计数据(当前K线) + const [marketStats, setMarketStats] = useState<{ + price: number + priceChange: number + priceChangePercent: number + high: number + low: number + volume: number // 数量(BTC/股数) + quoteVolume: number // 成交额(USDT/USD) + } | null>(null) + // 指标配置 const [indicators, setIndicators] = useState([ { id: 'volume', name: 'Volume', enabled: true, color: '#3B82F6' }, @@ -109,14 +144,28 @@ export function AdvancedChart({ throw new Error('Failed to fetch kline data') } - return result.data.map((candle: any) => ({ + // 转换数据格式 + const rawData = result.data.map((candle: any) => ({ time: Math.floor(candle.openTime / 1000) as UTCTimestamp, open: candle.open, high: candle.high, low: candle.low, close: candle.close, - volume: candle.volume, + volume: candle.volume, // 数量(BTC/股数) + quoteVolume: candle.quoteVolume, // 成交额(USDT/USD) })) + + // 按时间排序并去重(lightweight-charts 要求数据按时间升序且无重复) + const sortedData = rawData.sort((a: any, b: any) => a.time - b.time) + const dedupedData = sortedData.filter((item: any, index: number, arr: any[]) => + index === 0 || item.time !== arr[index - 1].time + ) + + if (rawData.length !== dedupedData.length) { + console.warn('[AdvancedChart] Removed', rawData.length - dedupedData.length, 'duplicate klines') + } + + return dedupedData } catch (err) { console.error('[AdvancedChart] Error fetching kline:', err) throw err @@ -383,12 +432,18 @@ export function AdvancedChart({ } const candleData = data as any + + // 从存储的数据中获取 volume 和 quoteVolume + const klineExtra = klineDataRef.current.get(param.time as number) || { volume: 0, quoteVolume: 0 } + setTooltipData({ time: param.time, open: candleData.open, high: candleData.high, low: candleData.low, close: candleData.close, + volume: klineExtra.volume, + quoteVolume: klineExtra.quoteVolume, x: param.point.x, y: param.point.y, }) @@ -405,11 +460,25 @@ export function AdvancedChart({ // 当 symbol 或 interval 改变时,重置初始加载标志(以便自动适配新数据) isInitialLoadRef.current = true - const loadData = async () => { + // 清除旧的标记数据,避免旧数据影响新图表 + currentMarkersDataRef.current = [] + if (seriesMarkersRef.current) { + try { + seriesMarkersRef.current.setMarkers([]) + } catch (e) { + // 忽略错误,稍后会重新创建 + } + seriesMarkersRef.current = null + } + + const loadData = async (isRefresh = false) => { if (!candlestickSeriesRef.current) return - console.log('[AdvancedChart] Loading data for', symbol, interval) - setLoading(true) + console.log('[AdvancedChart] Loading data for', symbol, interval, isRefresh ? '(refresh)' : '') + // 只在首次加载时显示 loading,刷新时不显示避免闪烁 + if (!isRefresh) { + setLoading(true) + } setError(null) try { @@ -418,6 +487,43 @@ export function AdvancedChart({ console.log('[AdvancedChart] Loaded', klineData.length, 'klines') candlestickSeriesRef.current.setData(klineData) + // 存储 volume/quoteVolume 数据供 tooltip 使用 + klineDataRef.current.clear() + klineData.forEach((k: any) => { + klineDataRef.current.set(k.time, { volume: k.volume || 0, quoteVolume: k.quoteVolume || 0 }) + }) + + // 1.5 计算行情统计数据 + if (klineData.length > 1) { + const latestKline = klineData[klineData.length - 1] + const prevKline = klineData[klineData.length - 2] + + // 涨跌幅:当前K线收盘价 vs 前一根K线收盘价 + const priceChange = latestKline.close - prevKline.close + const priceChangePercent = (priceChange / prevKline.close) * 100 + + setMarketStats({ + price: latestKline.close, + priceChange, + priceChangePercent, + high: latestKline.high, + low: latestKline.low, + volume: latestKline.volume || 0, + quoteVolume: latestKline.quoteVolume || 0, + }) + } else if (klineData.length === 1) { + const latestKline = klineData[0] + setMarketStats({ + price: latestKline.close, + priceChange: 0, + priceChangePercent: 0, + high: latestKline.high, + low: latestKline.low, + volume: latestKline.volume || 0, + quoteVolume: latestKline.quoteVolume || 0, + }) + } + // 2. 显示成交量 if (volumeSeriesRef.current) { const volumeEnabled = indicators.find(i => i.id === 'volume')?.enabled @@ -561,12 +667,12 @@ export function AdvancedChart({ } } - loadData() + loadData(false) // 首次加载 // 实时自动刷新 (5秒更新一次) - const refreshInterval = setInterval(loadData, 5000) + const refreshInterval = setInterval(() => loadData(true), 5000) return () => clearInterval(refreshInterval) - }, [symbol, interval, traderID, indicators]) + }, [symbol, interval, traderID, exchange]) // 单独处理订单标记的显示/隐藏,避免重新加载数据 useEffect(() => { @@ -663,116 +769,93 @@ export function AdvancedChart({ border: '1px solid rgba(43, 49, 57, 0.5)', }} > - {/* 标题栏 - 专业化设计 */} + {/* Compact Professional Header */}
- {/* 第一行:标题和控制按钮 */} -
-
- -

- {symbol} -

- - {interval} - - {/* 交易所标识 */} - - {exchange} - {['bitget', 'lighter'].includes(exchange?.toLowerCase() || '') && ( - * - )} - -
- + {/* Left: Symbol Info + Price */} +
+ {/* Symbol & Interval */}
- {loading && ( -
- {language === 'zh' ? '更新中...' : 'Updating...'} -
- )} - - - {/* 订单标记开关 */} - -
-
- - {/* 第二行:热门币种快速选择 */} - {onSymbolChange && ( -
- - {language === 'zh' ? '快速选择:' : 'Quick:'} + {exchange?.toUpperCase()} - {POPULAR_SYMBOLS.map((sym) => ( -
+ + {/* Price Display */} + {marketStats && ( +
+ = 0 ? '#10B981' : '#EF4444' }} + > + {marketStats.price.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: exchange === 'forex' || exchange === 'metals' ? 4 : 2 + })} + + = 0 ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)', + color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444', }} > - {sym.replace('USDT', '')} - - ))} -
- )} + {marketStats.priceChange >= 0 ? '+' : ''}{marketStats.priceChangePercent.toFixed(2)}% + + + {/* Compact H/L */} +
+ H {marketStats.high.toFixed(2)} + L {marketStats.low.toFixed(2)} + {marketStats.volume > 0 && baseUnit && ( + Vol {formatVolume(marketStats.volume)} + )} +
+
+ )} +
+ + {/* Right: Controls */} +
+ {loading && ( + + {language === 'zh' ? '更新中...' : 'Updating...'} + + )} + + + +
{/* 指标面板 - 专业化设计 */} @@ -895,6 +978,24 @@ export function AdvancedChart({ }}> {tooltipData.close?.toFixed(2)} + + {tooltipData.volume > 0 && baseUnit && ( + <> + V({baseUnit}): + + {formatVolume(tooltipData.volume)} + + + )} + + {tooltipData.quoteVolume > 0 && quoteUnit && ( + <> + V({quoteUnit}): + + {formatVolume(tooltipData.quoteVolume)} + + + )} )} diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx index 2d9256cd..e02fa625 100644 --- a/web/src/components/ChartTabs.tsx +++ b/web/src/components/ChartTabs.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { EquityChart } from './EquityChart' import { AdvancedChart } from './AdvancedChart' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' -import { BarChart3, CandlestickChart } from 'lucide-react' +import { BarChart3, CandlestickChart, ChevronDown, Search } from 'lucide-react' import { motion, AnimatePresence } from 'framer-motion' interface ChartTabsProps { @@ -15,6 +15,22 @@ interface ChartTabsProps { type ChartTab = 'equity' | 'kline' type Interval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d' +type MarketType = 'hyperliquid' | 'crypto' | 'stocks' | 'forex' | 'metals' + +interface SymbolInfo { + symbol: string + name: string + category: string +} + +// 市场类型配置 +const MARKET_CONFIG = { + hyperliquid: { exchange: 'hyperliquid', defaultSymbol: 'BTC', icon: '🔷', label: { zh: 'HL', en: 'HL' }, color: 'cyan', hasDropdown: true }, + crypto: { exchange: 'binance', defaultSymbol: 'BTCUSDT', icon: '₿', label: { zh: '加密', en: 'Crypto' }, color: 'yellow', hasDropdown: false }, + stocks: { exchange: 'alpaca', defaultSymbol: 'AAPL', icon: '📈', label: { zh: '美股', en: 'Stocks' }, color: 'green', hasDropdown: false }, + forex: { exchange: 'forex', defaultSymbol: 'EUR/USD', icon: '💱', label: { zh: '外汇', en: 'Forex' }, color: 'blue', hasDropdown: false }, + metals: { exchange: 'metals', defaultSymbol: 'XAU/USD', icon: '🥇', label: { zh: '金属', en: 'Metals' }, color: 'amber', hasDropdown: false }, +} const INTERVALS: { value: Interval; label: string }[] = [ { value: '1m', label: '1m' }, @@ -29,9 +45,63 @@ const INTERVALS: { value: Interval; label: string }[] = [ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) { const { language } = useLanguage() const [activeTab, setActiveTab] = useState('equity') - const [chartSymbol, setChartSymbol] = useState('BTCUSDT') + const [chartSymbol, setChartSymbol] = useState('BTC') const [interval, setInterval] = useState('5m') const [symbolInput, setSymbolInput] = useState('') + const [marketType, setMarketType] = useState('hyperliquid') + const [availableSymbols, setAvailableSymbols] = useState([]) + const [showDropdown, setShowDropdown] = useState(false) + const [searchFilter, setSearchFilter] = useState('') + const dropdownRef = useRef(null) + + // 根据市场类型确定交易所 + const marketConfig = MARKET_CONFIG[marketType] + const currentExchange = marketType === 'crypto' ? (exchangeId || marketConfig.exchange) : marketConfig.exchange + + // 获取可用币种列表 + useEffect(() => { + if (marketConfig.hasDropdown) { + fetch(`/api/symbols?exchange=${marketConfig.exchange}`) + .then(res => res.json()) + .then(data => { + if (data.symbols) { + // 按类别排序: crypto > stock > forex > commodity > index + const categoryOrder: Record = { crypto: 0, stock: 1, forex: 2, commodity: 3, index: 4 } + const sorted = [...data.symbols].sort((a: SymbolInfo, b: SymbolInfo) => { + const orderA = categoryOrder[a.category] ?? 5 + const orderB = categoryOrder[b.category] ?? 5 + if (orderA !== orderB) return orderA - orderB + return a.symbol.localeCompare(b.symbol) + }) + setAvailableSymbols(sorted) + } + }) + .catch(err => console.error('Failed to fetch symbols:', err)) + } + }, [marketType, marketConfig.exchange, marketConfig.hasDropdown]) + + // 点击外部关闭下拉 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // 切换市场类型时更新默认符号 + const handleMarketTypeChange = (type: MarketType) => { + setMarketType(type) + setChartSymbol(MARKET_CONFIG[type].defaultSymbol) + setShowDropdown(false) + } + + // 过滤后的币种列表 + const filteredSymbols = availableSymbols.filter(s => + s.symbol.toLowerCase().includes(searchFilter.toLowerCase()) + ) // 当从外部选择币种时,自动切换到K线图 useEffect(() => { @@ -42,11 +112,15 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C } }, [selectedSymbol, updateKey]) - // 处理手动输入币种 + // 处理手动输入符号 const handleSymbolSubmit = (e: React.FormEvent) => { e.preventDefault() if (symbolInput.trim()) { - const symbol = symbolInput.trim().toUpperCase() + let symbol = symbolInput.trim().toUpperCase() + // 加密货币自动加 USDT 后缀 + if (marketType === 'crypto' && !symbol.endsWith('USDT')) { + symbol = symbol + 'USDT' + } setChartSymbol(symbol) setSymbolInput('') } @@ -55,65 +129,131 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C console.log('[ChartTabs] rendering, activeTab:', activeTab) return ( -
- {/* Tab Headers - 专业化工具栏 */} +
+ {/* Clean Professional Toolbar */}
-
+ {/* Left: Tab Switcher */} +
+ + {/* Market Type Pills - Only when kline active */} + {activeTab === 'kline' && ( + <> +
+
+ {(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => { + const config = MARKET_CONFIG[type] + const isActive = marketType === type + return ( + + ) + })} +
+ + )}
- {/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */} + {/* Right: Symbol + Interval */} {activeTab === 'kline' && (
- {/* 当前币种显示 */} -
- {chartSymbol} -
+ {/* Symbol Dropdown */} + {marketConfig.hasDropdown ? ( +
+ + {showDropdown && ( +
+
+
+ + setSearchFilter(e.target.value)} + placeholder="Search..." + className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none" + autoFocus + /> +
+
+
+ {['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => { + const categorySymbols = filteredSymbols.filter(s => s.category === category) + if (categorySymbols.length === 0) return null + const labels: Record = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' } + return ( +
+
{labels[category]}
+ {categorySymbols.map(s => ( + + ))} +
+ ) + })} +
+
+ )} +
+ ) : ( + {chartSymbol} + )} -
- - {/* 时间周期选择器 - 更紧凑专业 */} -
+ {/* Interval Selector */} +
{INTERVALS.map((int) => (
-
- - {/* 币种输入框 - 更紧凑 */} -
+ {/* Quick Input */} + setSymbolInput(e.target.value)} - placeholder="输入币种..." - className="px-2 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-[11px] text-white placeholder-gray-600 focus:outline-none focus:border-yellow-500/50 w-24" + placeholder="Symbol..." + className="w-20 px-2 py-1 bg-[#0D1117] border border-[#30363D] rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-blue-500/50" /> -
@@ -159,7 +294,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C ) : ( diff --git a/web/src/components/PositionHistory.tsx b/web/src/components/PositionHistory.tsx index 14bb88b0..3f0191ab 100644 --- a/web/src/components/PositionHistory.tsx +++ b/web/src/components/PositionHistory.tsx @@ -315,9 +315,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
- {/* Fee */} + {/* Fee - show more precision for small fees */} - -{(position.fee || 0).toFixed(2)} + -{((position.fee || 0) < 0.01 && (position.fee || 0) > 0) + ? (position.fee || 0).toFixed(4) + : (position.fee || 0).toFixed(2)} {/* Duration */} diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx index 538284d0..7ba59bac 100644 --- a/web/src/components/strategy/CoinSourceEditor.tsx +++ b/web/src/components/strategy/CoinSourceEditor.tsx @@ -65,10 +65,39 @@ export function CoinSourceEditor({ { value: 'mixed', icon: Database, color: '#60a5fa' }, ] as const + // xyz dex assets (stocks, forex, commodities) - should NOT get USDT suffix + const xyzDexAssets = new Set([ + // Stocks + 'TSLA', 'NVDA', 'AAPL', 'MSFT', 'META', 'AMZN', 'GOOGL', 'AMD', 'COIN', 'NFLX', + 'PLTR', 'HOOD', 'INTC', 'MSTR', 'TSM', 'ORCL', 'MU', 'RIVN', 'COST', 'LLY', + 'CRCL', 'SKHX', 'SNDK', + // Forex + 'EUR', 'JPY', + // Commodities + 'GOLD', 'SILVER', + // Index + 'XYZ100', + ]) + + const isXyzDexAsset = (symbol: string): boolean => { + const base = symbol.toUpperCase().replace(/^XYZ:/, '').replace(/USDT$|USD$|-USDC$/, '') + return xyzDexAssets.has(base) + } + const handleAddCoin = () => { if (!newCoin.trim()) return const symbol = newCoin.toUpperCase().trim() - const formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` + + // For xyz dex assets (stocks, forex, commodities), use xyz: prefix without USDT + let formattedSymbol: string + if (isXyzDexAsset(symbol)) { + // Remove xyz: prefix (case-insensitive) and any USD suffixes + const base = symbol.replace(/^xyz:/i, '').replace(/USDT$|USD$|-USDC$/i, '') + formattedSymbol = `xyz:${base}` + } else { + formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT` + } + const currentCoins = config.static_coins || [] if (!currentCoins.includes(formattedSymbol)) { onChange({