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