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:
tinkle-community
2025-12-29 22:16:48 +08:00
parent 4776fc37ce
commit 47bff87966
21 changed files with 3863 additions and 393 deletions

View File

@@ -13,8 +13,11 @@ import (
"nofx/logger"
"nofx/manager"
"nofx/market"
"nofx/provider/alpaca"
"nofx/provider/coinank/coinank_api"
"nofx/provider/coinank/coinank_enum"
"nofx/provider/hyperliquid"
"nofx/provider/twelvedata"
"nofx/store"
"nofx/trader"
"strconv"
@@ -122,6 +125,7 @@ func (s *Server) setupRoutes() {
// Market data (no authentication required)
api.GET("/klines", s.handleKlines)
api.GET("/symbols", s.handleSymbols)
// Authentication related routes (no authentication required)
api.POST("/register", s.handleRegister)
@@ -2357,20 +2361,52 @@ func (s *Server) handleKlines(c *gin.Context) {
limit = 1500
}
// Normalize symbol (add USDT suffix if not present)
symbol = market.Normalize(symbol)
// Use CoinAnk API for all exchanges (no more Binance API or WebSocket cache)
var klines []market.Kline
exchangeLower := strings.ToLower(exchange)
// All data now comes from CoinAnk
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
if err != nil {
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
})
return
// Route to appropriate data source based on exchange type
switch exchangeLower {
case "alpaca":
// US Stocks via Alpaca
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
if err != nil {
logger.Errorf("❌ Alpaca API failed for %s: %v", symbol, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from Alpaca: %v", err),
})
return
}
case "forex", "metals":
// Forex and Metals via Twelve Data
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
if err != nil {
logger.Errorf("❌ TwelveData API failed for %s: %v", symbol, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from TwelveData: %v", err),
})
return
}
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
if err != nil {
logger.Errorf("❌ Hyperliquid API failed for %s: %v", symbol, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from Hyperliquid: %v", err),
})
return
}
default:
// Crypto exchanges via CoinAnk
symbol = market.Normalize(symbol)
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
if err != nil {
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
})
return
}
}
c.JSON(http.StatusOK, klines)
@@ -2389,8 +2425,6 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
coinankExchange = coinank_enum.Okex
case "bitget":
coinankExchange = coinank_enum.Bitget
case "hyperliquid":
coinankExchange = coinank_enum.Hyperliquid
case "aster":
coinankExchange = coinank_enum.Aster
case "lighter":
@@ -2480,22 +2514,210 @@ func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit i
}
// Convert coinank kline format to market.Kline format
// Coinank: Volume = BTC 数量, Quantity = USDT 成交额
klines := make([]market.Kline, len(coinankKlines))
for i, ck := range coinankKlines {
klines[i] = market.Kline{
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume,
CloseTime: ck.EndTime,
OpenTime: ck.StartTime,
Open: ck.Open,
High: ck.High,
Low: ck.Low,
Close: ck.Close,
Volume: ck.Volume, // BTC 数量
QuoteVolume: ck.Quantity, // USDT 成交额
CloseTime: ck.EndTime,
}
}
return klines, nil
}
// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks
func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Alpaca client
client := alpaca.NewClient()
// Map interval to Alpaca timeframe format
timeframe := alpaca.MapTimeframe(interval)
// Fetch bars from Alpaca
ctx := context.Background()
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("alpaca API error: %w", err)
}
// Convert Alpaca bars to market.Kline format
klines := make([]market.Kline, len(bars))
for i, bar := range bars {
klines[i] = market.Kline{
OpenTime: bar.Timestamp.UnixMilli(),
Open: bar.Open,
High: bar.High,
Low: bar.Low,
Close: bar.Close,
Volume: float64(bar.Volume), // 股数
QuoteVolume: float64(bar.Volume) * bar.Close, // 成交额 = 股数 * 收盘价 (USD)
CloseTime: bar.Timestamp.UnixMilli(),
}
}
return klines, nil
}
// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals
func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Twelve Data client
client := twelvedata.NewClient()
// Map interval to Twelve Data timeframe format
timeframe := twelvedata.MapTimeframe(interval)
// Fetch time series from Twelve Data
ctx := context.Background()
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("twelvedata API error: %w", err)
}
// Convert Twelve Data bars to market.Kline format
// Note: Twelve Data returns bars in reverse order (newest first)
klines := make([]market.Kline, len(result.Values))
for i, bar := range result.Values {
open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar)
if err != nil {
logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err)
continue
}
// Reverse order: put oldest first
idx := len(result.Values) - 1 - i
klines[idx] = market.Kline{
OpenTime: timestamp,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume,
CloseTime: timestamp,
}
}
return klines, nil
}
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) {
// Create Hyperliquid client
client := hyperliquid.NewClient()
// Map interval to Hyperliquid format
timeframe := hyperliquid.MapTimeframe(interval)
// Fetch candles from Hyperliquid
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
ctx := context.Background()
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
if err != nil {
return nil, fmt.Errorf("hyperliquid API error: %w", err)
}
// Convert Hyperliquid candles to market.Kline format
klines := make([]market.Kline, len(candles))
for i, candle := range candles {
open, _ := strconv.ParseFloat(candle.Open, 64)
high, _ := strconv.ParseFloat(candle.High, 64)
low, _ := strconv.ParseFloat(candle.Low, 64)
close, _ := strconv.ParseFloat(candle.Close, 64)
volume, _ := strconv.ParseFloat(candle.Volume, 64)
klines[i] = market.Kline{
OpenTime: candle.OpenTime,
Open: open,
High: high,
Low: low,
Close: close,
Volume: volume, // 合约数量
QuoteVolume: volume * close, // 成交额 (USD)
CloseTime: candle.CloseTime,
}
}
return klines, nil
}
// handleSymbols returns available symbols for a given exchange
func (s *Server) handleSymbols(c *gin.Context) {
exchange := c.DefaultQuery("exchange", "hyperliquid")
type SymbolInfo struct {
Symbol string `json:"symbol"`
Name string `json:"name"`
Category string `json:"category"` // crypto, stock, forex, commodity, index
MaxLeverage int `json:"maxLeverage,omitempty"`
}
var symbols []SymbolInfo
switch strings.ToLower(exchange) {
case "hyperliquid", "hyperliquid-xyz", "xyz":
// Fetch symbols from Hyperliquid
client := hyperliquid.NewClient()
ctx := context.Background()
// Get crypto perps from default dex
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
mids, err := client.GetAllMids(ctx)
if err == nil {
for symbol := range mids {
// Skip spot tokens (start with @)
if strings.HasPrefix(symbol, "@") {
continue
}
symbols = append(symbols, SymbolInfo{
Symbol: symbol,
Name: symbol,
Category: "crypto",
})
}
}
}
// Get xyz dex symbols (stocks, forex, commodities)
xyzMids, err := client.GetAllMidsXYZ(ctx)
if err == nil {
for symbol := range xyzMids {
// Remove xyz: prefix for display
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
category := "stock"
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
category = "commodity"
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
category = "forex"
} else if displaySymbol == "XYZ100" {
category = "index"
}
symbols = append(symbols, SymbolInfo{
Symbol: displaySymbol,
Name: displaySymbol,
Category: category,
})
}
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
return
}
c.JSON(http.StatusOK, gin.H{
"exchange": exchange,
"symbols": symbols,
"count": len(symbols),
})
}
// handleDecisions Decision log list
func (s *Server) handleDecisions(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
@@ -3039,6 +3261,9 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
}
c.JSON(http.StatusOK, supportedExchanges)