mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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
This commit is contained in:
+233
-8
@@ -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,13 +2361,44 @@ 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
|
||||
// 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)
|
||||
@@ -2372,6 +2407,7 @@ func (s *Server) handleKlines(c *gin.Context) {
|
||||
})
|
||||
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,6 +2514,7 @@ 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{
|
||||
@@ -2488,7 +2523,8 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
|
||||
High: ck.High,
|
||||
Low: ck.Low,
|
||||
Close: ck.Close,
|
||||
Volume: ck.Volume,
|
||||
Volume: ck.Volume, // BTC 数量
|
||||
QuoteVolume: ck.Quantity, // USDT 成交额
|
||||
CloseTime: ck.EndTime,
|
||||
}
|
||||
}
|
||||
@@ -2496,6 +2532,192 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+135
-6
@@ -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,17 +93,71 @@ 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)
|
||||
|
||||
// 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
|
||||
if isStaleData(klines3m, symbol) {
|
||||
@@ -110,11 +165,18 @@ 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)
|
||||
// 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
|
||||
if len(klines3m) == 0 {
|
||||
@@ -212,13 +274,29 @@ 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
|
||||
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 {
|
||||
logger.Infof("⚠️ %s %s K-line data is empty", symbol, tf)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+16
-4
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+797
-127
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||
@@ -77,6 +100,7 @@ export function AdvancedChart({
|
||||
const indicatorSeriesRef = useRef<Map<string, ISeriesApi<any>>>(new Map())
|
||||
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||
const currentMarkersDataRef = useRef<any[]>([]) // 存储当前的标记数据
|
||||
const klineDataRef = useRef<Map<number, { volume: number; quoteVolume: number }>>(new Map()) // 存储 kline 额外数据
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -86,6 +110,17 @@ export function AdvancedChart({
|
||||
const [tooltipData, setTooltipData] = useState<any>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(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<IndicatorConfig[]>([
|
||||
{ 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)
|
||||
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,118 +769,95 @@ export function AdvancedChart({
|
||||
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||
}}
|
||||
>
|
||||
{/* 标题栏 - 专业化设计 */}
|
||||
{/* Compact Professional Header */}
|
||||
<div
|
||||
className="px-4 py-2.5 space-y-2"
|
||||
style={{ borderBottom: '1px solid #2B3139', background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)' }}
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#0D1117' }}
|
||||
>
|
||||
{/* 第一行:标题和控制按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="text-base font-bold" style={{ color: '#F0B90B' }}>
|
||||
{symbol}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{interval}
|
||||
</span>
|
||||
{/* 交易所标识 */}
|
||||
{/* Left: Symbol Info + Price */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Symbol & Interval */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">{symbol}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[#1F2937] text-gray-400">{interval}</span>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded font-medium uppercase"
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium uppercase"
|
||||
style={{
|
||||
background: exchange === 'binance' ? 'rgba(243, 186, 47, 0.15)' :
|
||||
exchange === 'bybit' ? 'rgba(247, 147, 26, 0.15)' :
|
||||
exchange === 'okx' ? 'rgba(0, 180, 255, 0.15)' :
|
||||
exchange === 'bitget' ? 'rgba(0, 212, 170, 0.15)' :
|
||||
exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.15)' :
|
||||
exchange === 'aster' ? 'rgba(138, 43, 226, 0.15)' :
|
||||
'rgba(255, 255, 255, 0.1)',
|
||||
color: exchange === 'binance' ? '#F3BA2F' :
|
||||
exchange === 'bybit' ? '#F7931A' :
|
||||
exchange === 'okx' ? '#00B4FF' :
|
||||
exchange === 'bitget' ? '#00D4AA' :
|
||||
exchange === 'hyperliquid' ? '#50E3C2' :
|
||||
exchange === 'aster' ? '#8A2BE2' :
|
||||
'#848E9C',
|
||||
border: `1px solid ${
|
||||
exchange === 'binance' ? 'rgba(243, 186, 47, 0.3)' :
|
||||
exchange === 'bybit' ? 'rgba(247, 147, 26, 0.3)' :
|
||||
exchange === 'okx' ? 'rgba(0, 180, 255, 0.3)' :
|
||||
exchange === 'bitget' ? 'rgba(0, 212, 170, 0.3)' :
|
||||
exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.3)' :
|
||||
exchange === 'aster' ? 'rgba(138, 43, 226, 0.3)' :
|
||||
'rgba(255, 255, 255, 0.2)'
|
||||
}`
|
||||
background: exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.1)' : 'rgba(243, 186, 47, 0.1)',
|
||||
color: exchange === 'hyperliquid' ? '#50E3C2' : '#F3BA2F',
|
||||
}}
|
||||
title={['bitget', 'lighter'].includes(exchange?.toLowerCase() || '')
|
||||
? 'Data source: Binance (fallback)' : undefined}
|
||||
>
|
||||
{exchange}
|
||||
{['bitget', 'lighter'].includes(exchange?.toLowerCase() || '') && (
|
||||
<span className="ml-1 text-[9px] opacity-60">*</span>
|
||||
)}
|
||||
{exchange?.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && (
|
||||
<div className="text-xs px-2 py-1 rounded" style={{ background: '#2B3139', color: '#F0B90B' }}>
|
||||
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||
{/* Price Display */}
|
||||
{marketStats && (
|
||||
<div className="flex items-center gap-3 pl-3 border-l border-[#2B3139]">
|
||||
<span
|
||||
className="text-base font-bold tabular-nums"
|
||||
style={{ color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444' }}
|
||||
>
|
||||
{marketStats.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: exchange === 'forex' || exchange === 'metals' ? 4 : 2
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium px-1.5 py-0.5 rounded tabular-nums"
|
||||
style={{
|
||||
background: marketStats.priceChange >= 0 ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444',
|
||||
}}
|
||||
>
|
||||
{marketStats.priceChange >= 0 ? '+' : ''}{marketStats.priceChangePercent.toFixed(2)}%
|
||||
</span>
|
||||
|
||||
{/* Compact H/L */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500">
|
||||
<span>H <span className="text-gray-300">{marketStats.high.toFixed(2)}</span></span>
|
||||
<span>L <span className="text-gray-300">{marketStats.low.toFixed(2)}</span></span>
|
||||
{marketStats.volume > 0 && baseUnit && (
|
||||
<span>Vol <span className="text-gray-300">{formatVolume(marketStats.volume)}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{loading && (
|
||||
<span className="text-[10px] text-yellow-400 animate-pulse mr-2">
|
||||
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showIndicatorPanel ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: showIndicatorPanel ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${showIndicatorPanel ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||
background: showIndicatorPanel ? 'rgba(96, 165, 250, 0.15)' : 'transparent',
|
||||
color: showIndicatorPanel ? '#60A5FA' : '#6B7280',
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
<Settings className="w-3 h-3" />
|
||||
<span>{language === 'zh' ? '指标' : 'Indicators'}</span>
|
||||
</button>
|
||||
|
||||
{/* 订单标记开关 */}
|
||||
<button
|
||||
onClick={() => setShowOrderMarkers(!showOrderMarkers)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showOrderMarkers ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||
color: showOrderMarkers ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${showOrderMarkers ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||
background: showOrderMarkers ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
|
||||
color: showOrderMarkers ? '#10B981' : '#6B7280',
|
||||
}}
|
||||
title={language === 'zh' ? '切换订单标记显示' : 'Toggle Order Markers'}
|
||||
title={language === 'zh' ? '订单标记' : 'Order Markers'}
|
||||
>
|
||||
<span className="font-bold text-[11px]">B/S</span>
|
||||
<span>B/S</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第二行:热门币种快速选择 */}
|
||||
{onSymbolChange && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium mr-1" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '快速选择:' : 'Quick:'}
|
||||
</span>
|
||||
{POPULAR_SYMBOLS.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => onSymbolChange(sym)}
|
||||
className="px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: symbol === sym ? 'rgba(240, 185, 11, 0.2)' : 'rgba(43, 49, 57, 0.5)',
|
||||
color: symbol === sym ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${symbol === sym ? 'rgba(240, 185, 11, 0.4)' : 'transparent'}`,
|
||||
}}
|
||||
>
|
||||
{sym.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 指标面板 - 专业化设计 */}
|
||||
{showIndicatorPanel && (
|
||||
<div
|
||||
@@ -895,6 +978,24 @@ export function AdvancedChart({
|
||||
}}>
|
||||
{tooltipData.close?.toFixed(2)}
|
||||
</span>
|
||||
|
||||
{tooltipData.volume > 0 && baseUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({baseUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.volume)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tooltipData.quoteVolume > 0 && quoteUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({quoteUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.quoteVolume)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<ChartTab>('equity')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTCUSDT')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTC')
|
||||
const [interval, setInterval] = useState<Interval>('5m')
|
||||
const [symbolInput, setSymbolInput] = useState('')
|
||||
const [marketType, setMarketType] = useState<MarketType>('hyperliquid')
|
||||
const [availableSymbols, setAvailableSymbols] = useState<SymbolInfo[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const dropdownRef = useRef<HTMLDivElement>(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<string, number> = { 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 (
|
||||
<div className="binance-card">
|
||||
{/* Tab Headers - 专业化工具栏 */}
|
||||
<div className="binance-card" style={{ background: '#0D1117', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
{/* Clean Professional Toolbar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{
|
||||
borderBottom: '1px solid #2B3139',
|
||||
background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)',
|
||||
}}
|
||||
className="flex items-center justify-between px-3 py-1.5"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#161B22' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Left: Tab Switcher */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to equity')
|
||||
setActiveTab('equity')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'equity'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
onClick={() => setActiveTab('equity')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'equity'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
<BarChart3 className="w-3 h-3" />
|
||||
<span>{t('accountEquityCurve', language)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[ChartTabs] switching to kline')
|
||||
setActiveTab('kline')
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'kline'
|
||||
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
onClick={() => setActiveTab('kline')}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded text-[11px] font-medium transition-all ${
|
||||
activeTab === 'kline'
|
||||
? 'bg-blue-500/15 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
<CandlestickChart className="w-3 h-3" />
|
||||
<span>{t('marketChart', language)}</span>
|
||||
</button>
|
||||
|
||||
{/* Market Type Pills - Only when kline active */}
|
||||
{activeTab === 'kline' && (
|
||||
<>
|
||||
<div className="w-px h-3 bg-[#30363D] mx-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-all ${
|
||||
isActive
|
||||
? 'bg-[#21262D] text-white'
|
||||
: 'text-gray-500 hover:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{config.icon} {language === 'zh' ? config.label.zh : config.label.en}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */}
|
||||
{/* Right: Symbol + Interval */}
|
||||
{activeTab === 'kline' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 当前币种显示 */}
|
||||
<div className="px-2.5 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-xs font-bold text-yellow-400">
|
||||
{chartSymbol}
|
||||
{/* Symbol Dropdown */}
|
||||
{marketConfig.hasDropdown ? (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white hover:bg-[#30363D] transition-all"
|
||||
>
|
||||
<span>{chartSymbol}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 w-56 bg-[#161B22] border border-[#30363D] rounded-lg shadow-2xl z-50 max-h-72 overflow-hidden">
|
||||
<div className="p-2 border-b border-[#30363D]">
|
||||
<div className="flex items-center gap-2 px-2 py-1 bg-[#0D1117] rounded border border-[#30363D]">
|
||||
<Search className="w-3 h-3 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-52">
|
||||
{['crypto', 'stock', 'forex', 'commodity', 'index'].map(category => {
|
||||
const categorySymbols = filteredSymbols.filter(s => s.category === category)
|
||||
if (categorySymbols.length === 0) return null
|
||||
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Index' }
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="px-3 py-1 text-[9px] font-medium text-gray-500 bg-[#0D1117] uppercase tracking-wider">{labels[category]}</div>
|
||||
{categorySymbols.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
|
||||
className={`w-full px-3 py-1.5 text-left text-[11px] hover:bg-[#21262D] transition-all ${chartSymbol === s.symbol ? 'bg-blue-500/20 text-blue-400' : 'text-gray-300'}`}
|
||||
>
|
||||
{s.symbol}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-[#21262D] rounded text-[11px] font-bold text-white">{chartSymbol}</span>
|
||||
)}
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 时间周期选择器 - 更紧凑专业 */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{/* Interval Selector */}
|
||||
<div className="flex items-center bg-[#21262D] rounded overflow-hidden">
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
key={int.value}
|
||||
onClick={() => setInterval(int.value)}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${
|
||||
interval === int.value
|
||||
? 'bg-yellow-500/20 text-yellow-400 rounded'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
? 'bg-blue-500/30 text-blue-400'
|
||||
: 'text-gray-500 hover:text-gray-300 hover:bg-[#30363D]'
|
||||
}`}
|
||||
>
|
||||
{int.label}
|
||||
@@ -121,22 +261,17 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||
|
||||
{/* 币种输入框 - 更紧凑 */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center gap-1.5">
|
||||
{/* Quick Input */}
|
||||
<form onSubmit={handleSymbolSubmit} className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={symbolInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2 py-1 bg-yellow-500/15 text-yellow-400 border border-yellow-500/30 rounded text-[10px] font-medium hover:bg-yellow-500/25 transition-all"
|
||||
>
|
||||
GO
|
||||
<button type="submit" className="px-2 py-1 bg-[#21262D] border border-[#30363D] border-l-0 rounded-r text-[10px] text-gray-400 hover:text-white hover:bg-[#30363D] transition-all">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -159,7 +294,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`kline-${chartSymbol}-${interval}-${exchangeId}`}
|
||||
key={`kline-${chartSymbol}-${interval}-${currentExchange}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
@@ -171,7 +306,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
interval={interval}
|
||||
traderID={traderId}
|
||||
height={550}
|
||||
exchange={exchangeId || 'binance'}
|
||||
exchange={currentExchange}
|
||||
onSymbolChange={setChartSymbol}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
@@ -315,9 +315,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Fee */}
|
||||
{/* Fee - show more precision for small fees */}
|
||||
<td className="py-3 px-4 text-right font-mono text-xs" style={{ color: '#848E9C' }}>
|
||||
-{(position.fee || 0).toFixed(2)}
|
||||
-{((position.fee || 0) < 0.01 && (position.fee || 0) > 0)
|
||||
? (position.fee || 0).toFixed(4)
|
||||
: (position.fee || 0).toFixed(2)}
|
||||
</td>
|
||||
|
||||
{/* Duration */}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user