package api import ( "context" "fmt" "net/http" "strconv" "strings" "time" "nofx/logger" "nofx/market" "nofx/provider/alpaca" "nofx/provider/coinank/coinank_api" "nofx/provider/coinank/coinank_enum" "nofx/provider/hyperliquid" "nofx/provider/twelvedata" "github.com/gin-gonic/gin" ) // handleKlines K-line data (supports multiple exchanges via coinank) func (s *Server) handleKlines(c *gin.Context) { // Get query parameters symbol := c.Query("symbol") if symbol == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"}) return } interval := c.DefaultQuery("interval", "5m") exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility limitStr := c.DefaultQuery("limit", "1000") limit, err := strconv.Atoi(limitStr) if err != nil || limit <= 0 { limit = 1000 } // Coinank API has a maximum limit of 1500 klines per request if limit > 1500 { limit = 1500 } var klines []market.Kline exchangeLower := strings.ToLower(exchange) // 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 { SafeInternalError(c, "Get klines from Alpaca", err) return } case "forex", "metals": // Forex and Metals via Twelve Data klines, err = s.getKlinesFromTwelveData(symbol, interval, limit) if err != nil { SafeInternalError(c, "Get klines from TwelveData", 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 { SafeInternalError(c, "Get klines from Hyperliquid", err) return } default: // Crypto exchanges via CoinAnk symbol = market.Normalize(symbol) klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit) if err != nil { SafeInternalError(c, "Get klines from CoinAnk", err) return } } c.JSON(http.StatusOK, klines) } // getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) { // Map exchange string to coinank enum var coinankExchange coinank_enum.Exchange switch strings.ToLower(exchange) { case "binance": coinankExchange = coinank_enum.Binance case "bybit": coinankExchange = coinank_enum.Bybit case "okx": coinankExchange = coinank_enum.Okex case "bitget": coinankExchange = coinank_enum.Bitget case "gate": coinankExchange = coinank_enum.Gate case "aster": coinankExchange = coinank_enum.Aster case "lighter": // Lighter doesn't have direct CoinAnk support, use Binance data as fallback coinankExchange = coinank_enum.Binance case "kucoin": // KuCoin doesn't have direct CoinAnk support, use Binance data as fallback coinankExchange = coinank_enum.Binance default: // For any unknown exchange, default to Binance logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange) coinankExchange = coinank_enum.Binance } // Map interval string to coinank enum var coinankInterval coinank_enum.Interval switch interval { case "1s": coinankInterval = coinank_enum.Second1 case "5s": coinankInterval = coinank_enum.Second5 case "10s": coinankInterval = coinank_enum.Second10 case "30s": coinankInterval = coinank_enum.Second30 case "1m": coinankInterval = coinank_enum.Minute1 case "3m": coinankInterval = coinank_enum.Minute3 case "5m": coinankInterval = coinank_enum.Minute5 case "10m": coinankInterval = coinank_enum.Minute10 case "15m": coinankInterval = coinank_enum.Minute15 case "30m": coinankInterval = coinank_enum.Minute30 case "1h": coinankInterval = coinank_enum.Hour1 case "2h": coinankInterval = coinank_enum.Hour2 case "4h": coinankInterval = coinank_enum.Hour4 case "6h": coinankInterval = coinank_enum.Hour6 case "8h": coinankInterval = coinank_enum.Hour8 case "12h": coinankInterval = coinank_enum.Hour12 case "1d": coinankInterval = coinank_enum.Day1 case "3d": coinankInterval = coinank_enum.Day3 case "1w": coinankInterval = coinank_enum.Week1 case "1M": coinankInterval = coinank_enum.Month1 default: return nil, fmt.Errorf("unsupported interval for coinank: %s", interval) } // Convert symbol format for different exchanges // OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT" apiSymbol := symbol if coinankExchange == coinank_enum.Okex { // Convert BTCUSDT -> BTC-USDT-SWAP if strings.HasSuffix(symbol, "USDT") { base := strings.TrimSuffix(symbol, "USDT") apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base) } } // Call coinank free/open API (no authentication required) ctx := context.Background() ts := time.Now().UnixMilli() // Use "To" side to search backward from current time (get historical klines) coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval) if err != nil { // Free API doesn't support all exchanges (e.g., OKX, Bitget) // Fallback to Binance data as reference if coinankExchange != coinank_enum.Binance { logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange) coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval) if err != nil { return nil, fmt.Errorf("coinank API error (fallback): %w", err) } } else { return nil, fmt.Errorf("coinank API error: %w", err) } } // Convert coinank kline format to market.Kline format // Coinank: Volume = BTC quantity, Quantity = USDT turnover 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, // BTC quantity QuoteVolume: ck.Quantity, // USDT turnover 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), // share count QuoteVolume: float64(bar.Volume) * bar.Close, // turnover = shares * close price (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, // contract quantity QuoteVolume: volume * close, // turnover (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), }) }